import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ApiError } from '../api/client' import { skillsApi } from '../api/skills' import { useTranslation } from '../i18n' import { useSessionStore } from '../stores/sessionStore' import { useChatStore } from '../stores/chatStore' import { usePluginStore } from '../stores/pluginStore' import { useSessionRuntimeStore, DRAFT_RUNTIME_SELECTION_KEY } from '../stores/sessionRuntimeStore' import { useSettingsStore } from '../stores/settingsStore' import { useUIStore } from '../stores/uiStore' import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore' import { RepositoryLaunchControls } from '../components/shared/RepositoryLaunchControls' import { PermissionModeSelector } from '../components/controls/PermissionModeSelector' import { ModelSelector } from '../components/controls/ModelSelector' import { AttachmentGallery } from '../components/chat/AttachmentGallery' import { ContextUsageIndicator } from '../components/chat/ContextUsageIndicator' import { FileSearchMenu, type FileSearchMenuHandle } from '../components/chat/FileSearchMenu' import { LocalSlashCommandPanel, type LocalSlashCommandName } from '../components/chat/LocalSlashCommandPanel' import { useMobileViewport } from '../hooks/useMobileViewport' import { isTauriRuntime } from '../lib/desktopRuntime' import { filesToComposerAttachments, selectNativeFileAttachments, type ComposerAttachment, } from '../lib/composerAttachments' import { FALLBACK_SLASH_COMMANDS, filterSlashCommands, findSlashToken, insertSlashTrigger, mergeSlashCommands, replaceSlashCommand, resolveSlashUiAction, } from '../components/chat/composerUtils' import type { AttachmentRef } from '../types/chat' import type { SlashCommandOption } from '../components/chat/composerUtils' type Attachment = ComposerAttachment type Translate = ReturnType function getApiErrorCode(error: unknown): string | null { if (!(error instanceof ApiError)) return null const body = error.body if (!body || typeof body !== 'object' || !('error' in body)) return null return typeof body.error === 'string' ? body.error : null } function resolveCreateSessionErrorMessage(error: unknown, t: Translate): string { const code = getApiErrorCode(error) switch (code) { case 'WORKDIR_MISSING': case 'WORKDIR_NOT_DIRECTORY': return t('empty.createError.workdirMissing') case 'REPOSITORY_NOT_GIT': return t('empty.createError.notGit') case 'REPOSITORY_BRANCH_NOT_FOUND': return t('empty.createError.branchNotFound') case 'REPOSITORY_DIRTY_WORKTREE': return t('empty.createError.dirtyWorktree') case 'REPOSITORY_BRANCH_CHECKED_OUT': return t('empty.createError.branchCheckedOut') case 'REPOSITORY_WORKTREE_CREATE_FAILED': return t('empty.createError.worktreeCreateFailed', { detail: error instanceof Error ? error.message : t('empty.failedToCreate'), }) case 'REPOSITORY_SWITCH_FAILED': return t('empty.createError.switchFailed', { detail: error instanceof Error ? error.message : t('empty.failedToCreate'), }) case 'REPOSITORY_CONTEXT_ERROR': return t('empty.createError.contextFailed') default: return error instanceof Error ? error.message : t('empty.failedToCreate') } } export function EmptySession() { const t = useTranslation() const [input, setInput] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const [workDir, setWorkDir] = useState('') const [selectedBranch, setSelectedBranch] = useState(null) const [useWorktree, setUseWorktree] = useState(false) const [repositoryLaunchReady, setRepositoryLaunchReady] = useState(true) const [attachments, setAttachments] = useState([]) const [plusMenuOpen, setPlusMenuOpen] = useState(false) const [slashMenuOpen, setSlashMenuOpen] = useState(false) const [fileSearchOpen, setFileSearchOpen] = useState(false) const [localSlashPanel, setLocalSlashPanel] = useState(null) const [atFilter, setAtFilter] = useState('') const [atCursorPos, setAtCursorPos] = useState(-1) const [slashFilter, setSlashFilter] = useState('') const [slashSelectedIndex, setSlashSelectedIndex] = useState(0) const [slashCommands, setSlashCommands] = useState([]) const textareaRef = useRef(null) const fileInputRef = useRef(null) const plusMenuRef = useRef(null) const slashMenuRef = useRef(null) const fileSearchRef = useRef(null) const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([]) const createSession = useSessionStore((state) => state.createSession) const sendMessage = useChatStore((state) => state.sendMessage) const connectToSession = useChatStore((state) => state.connectToSession) const setActiveView = useUIStore((state) => state.setActiveView) const addToast = useUIStore((state) => state.addToast) const currentModel = useSettingsStore((state) => state.currentModel) const lastPluginReloadSummary = usePluginStore((state) => state.lastReloadSummary) const draftRuntimeSelection = useSessionRuntimeStore((state) => state.selections[DRAFT_RUNTIME_SELECTION_KEY]) const draftRuntimeSelectionKey = draftRuntimeSelection ? `${draftRuntimeSelection.providerId ?? 'official'}:${draftRuntimeSelection.modelId}` : undefined const draftModelLabel = draftRuntimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id const isMobileComposer = useMobileViewport() && !isTauriRuntime() useEffect(() => { textareaRef.current?.focus() }, []) useEffect(() => { if (!plusMenuOpen) return const handleClick = (event: MouseEvent) => { if (plusMenuRef.current && !plusMenuRef.current.contains(event.target as Node)) { setPlusMenuOpen(false) } } document.addEventListener('mousedown', handleClick) return () => document.removeEventListener('mousedown', handleClick) }, [plusMenuOpen]) useEffect(() => { if (!slashMenuOpen) return const handleClick = (event: MouseEvent) => { if ( slashMenuRef.current && !slashMenuRef.current.contains(event.target as Node) && textareaRef.current && !textareaRef.current.contains(event.target as Node) ) { setSlashMenuOpen(false) } } document.addEventListener('mousedown', handleClick) return () => document.removeEventListener('mousedown', handleClick) }, [slashMenuOpen]) useEffect(() => { if (!localSlashPanel) return const handleClick = (event: MouseEvent) => { if ( slashMenuRef.current && !slashMenuRef.current.contains(event.target as Node) && textareaRef.current && !textareaRef.current.contains(event.target as Node) ) { setLocalSlashPanel(null) } } document.addEventListener('mousedown', handleClick) return () => document.removeEventListener('mousedown', handleClick) }, [localSlashPanel]) useEffect(() => { if (!fileSearchOpen) return const handleClick = (event: MouseEvent) => { const menu = document.getElementById('file-search-menu') if ( menu && !menu.contains(event.target as Node) && textareaRef.current && !textareaRef.current.contains(event.target as Node) ) { setFileSearchOpen(false) } } document.addEventListener('mousedown', handleClick) return () => document.removeEventListener('mousedown', handleClick) }, [fileSearchOpen]) useEffect(() => { let cancelled = false skillsApi.list(workDir || undefined) .then(({ skills }) => { if (cancelled) return setSlashCommands( skills .filter((skill) => skill.userInvocable) .map((skill) => ({ name: skill.name, description: skill.description, })), ) }) .catch(() => { if (!cancelled) { setSlashCommands([]) } }) return () => { cancelled = true } }, [workDir, lastPluginReloadSummary]) const allSlashCommands = useMemo( () => mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS), [slashCommands], ) const handleWorkDirChange = (newWorkDir: string) => { setWorkDir(newWorkDir) setSelectedBranch(null) setUseWorktree(false) setRepositoryLaunchReady(!newWorkDir) } const filteredCommands = useMemo(() => { return filterSlashCommands(allSlashCommands, slashFilter) }, [allSlashCommands, slashFilter]) const exactSlashCommand = useMemo(() => { const normalized = slashFilter.trim().toLowerCase() if (!normalized) return null return filteredCommands.find((command) => command.name.toLowerCase() === normalized) ?? null }, [filteredCommands, slashFilter]) const canSubmit = ( input.trim().length > 0 || attachments.length > 0 || !!workDir ) && !isSubmitting && repositoryLaunchReady useEffect(() => { setSlashSelectedIndex(0) }, [slashFilter]) useEffect(() => { const activeItem = slashMenuOpen ? slashItemRefs.current[slashSelectedIndex] : null if (activeItem && typeof activeItem.scrollIntoView === 'function') { activeItem.scrollIntoView({ block: 'nearest' }) } }, [slashMenuOpen, slashSelectedIndex]) const handleSubmit = async () => { const text = input.trim() if (!canSubmit) return const slashUiAction = text.startsWith('/') ? resolveSlashUiAction(text.slice(1)) : null if (slashUiAction?.type === 'panel') { setLocalSlashPanel(slashUiAction.command as LocalSlashCommandName) setInput('') setSlashMenuOpen(false) setFileSearchOpen(false) setPlusMenuOpen(false) return } if (slashUiAction?.type === 'settings') { useUIStore.getState().setPendingSettingsTab(slashUiAction.tab) useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings') setInput('') setSlashMenuOpen(false) setFileSearchOpen(false) setPlusMenuOpen(false) return } setIsSubmitting(true) try { const explicitDraftSelection = useSessionRuntimeStore.getState().selections[DRAFT_RUNTIME_SELECTION_KEY] const sessionId = await createSession( workDir || undefined, selectedBranch ? { repository: { branch: selectedBranch, worktree: useWorktree } } : undefined, ) if (explicitDraftSelection) { useSessionRuntimeStore.getState().setSelection(sessionId, explicitDraftSelection) useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY) } setActiveView('code') useTabStore.getState().openTab(sessionId, 'New Session') connectToSession(sessionId) const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({ type: attachment.type, name: attachment.name, path: attachment.path, data: attachment.data, mimeType: attachment.mimeType, })) if (text || attachmentPayload.length > 0) { sendMessage(sessionId, text, attachmentPayload) } setInput('') setAttachments([]) } catch (error) { addToast({ type: 'error', message: resolveCreateSessionErrorMessage(error, t), }) } finally { setIsSubmitting(false) } } const handleInputChange = (value: string, cursorPos: number) => { setInput(value) const token = findSlashToken(value, cursorPos) if (!token) { setSlashMenuOpen(false) } else { setSlashFilter(token.filter) setSlashMenuOpen(true) } // Detect @ trigger for file search const textBeforeCursor = value.slice(0, cursorPos) let pos = -1 for (let i = textBeforeCursor.length - 1; i >= 0; i--) { const ch = textBeforeCursor[i]! if (ch === '@') { if (i === 0 || /\s/.test(textBeforeCursor[i - 1]!)) { pos = i break } break } if (/\s/.test(ch)) { break } } if (pos < 0) { setFileSearchOpen(false) setAtFilter('') setAtCursorPos(-1) } else { setAtFilter(textBeforeCursor.slice(pos + 1)) setAtCursorPos(pos) setSlashMenuOpen(false) setFileSearchOpen(true) } } const handleKeyDown = (event: React.KeyboardEvent) => { // Ignore key events during IME composition (e.g. Chinese input method) if (event.nativeEvent.isComposing) return // Route file search navigation keys to FileSearchMenu if (fileSearchOpen) { const key = event.key if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === 'Tab' || key === 'Escape') { event.preventDefault() if (key === 'Escape') { setFileSearchOpen(false) setAtFilter('') setAtCursorPos(-1) return } fileSearchRef.current?.handleKeyDown(event.nativeEvent) return } return } if (slashMenuOpen && filteredCommands.length > 0) { if (event.key === 'ArrowDown') { event.preventDefault() setSlashSelectedIndex((prev) => (prev + 1) % filteredCommands.length) return } if (event.key === 'ArrowUp') { event.preventDefault() setSlashSelectedIndex((prev) => (prev - 1 + filteredCommands.length) % filteredCommands.length) return } if (event.key === 'Enter' || event.key === 'Tab') { if ( event.key === 'Enter' && exactSlashCommand && slashFilter.trim().toLowerCase() === exactSlashCommand.name.toLowerCase() ) { event.preventDefault() void handleSubmit() return } event.preventDefault() const selected = filteredCommands[slashSelectedIndex] if (selected) selectSlashCommand(selected.name) return } if (event.key === 'Escape') { event.preventDefault() setSlashMenuOpen(false) return } } if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() handleSubmit() } } const handlePaste = (event: React.ClipboardEvent) => { const items = event.clipboardData?.items if (!items) return let hasImage = false for (let i = 0; i < items.length; i += 1) { const item = items[i] if (!item || !item.type.startsWith('image/')) continue hasImage = true event.preventDefault() const file = item.getAsFile() if (!file) continue const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}` const reader = new FileReader() reader.onload = () => { setAttachments((prev) => [ ...prev, { id, name: `pasted-image-${Date.now()}.png`, type: 'image', mimeType: file.type || undefined, previewUrl: reader.result as string, data: reader.result as string, }, ]) } reader.readAsDataURL(file) } if (!hasImage) return } const appendFiles = useCallback((files: FileList | File[]) => { void filesToComposerAttachments(files) .then((nextAttachments) => { if (nextAttachments.length === 0) return setAttachments((prev) => [...prev, ...nextAttachments]) }) .catch((error) => { console.warn('[attachments] Failed to read selected files', error) }) }, []) const openAttachmentPicker = useCallback(() => { if (!isTauriRuntime()) { fileInputRef.current?.click() setPlusMenuOpen(false) return } void selectNativeFileAttachments() .then((nativeAttachments) => { if (nativeAttachments) { if (nativeAttachments.length > 0) { setAttachments((prev) => [...prev, ...nativeAttachments]) } return } fileInputRef.current?.click() }) .finally(() => setPlusMenuOpen(false)) }, []) const handleFileSelect = (event: React.ChangeEvent) => { const files = event.target.files if (!files) return appendFiles(files) event.target.value = '' } const handleDrop = (event: React.DragEvent) => { event.preventDefault() const files = event.dataTransfer.files if (files.length > 0) { appendFiles(files) } } const removeAttachment = (id: string) => { setAttachments((prev) => prev.filter((attachment) => attachment.id !== id)) } const selectSlashCommand = (command: string) => { const el = textareaRef.current if (!el) return const cursorPos = el.selectionStart ?? input.length const replacement = replaceSlashCommand(input, cursorPos, command) if (!replacement) return setInput(replacement.value) setSlashMenuOpen(false) requestAnimationFrame(() => { el.focus() el.setSelectionRange(replacement.cursorPos, replacement.cursorPos) }) } const insertSlashCommand = () => { const el = textareaRef.current const cursorPos = el?.selectionStart ?? input.length const replacement = insertSlashTrigger(input, cursorPos) setInput(replacement.value) setPlusMenuOpen(false) setSlashFilter('') setSlashMenuOpen(true) requestAnimationFrame(() => { textareaRef.current?.focus() textareaRef.current?.setSelectionRange(replacement.cursorPos, replacement.cursorPos) }) } return (
Claude Code Haha

{t('empty.title')}

{t('empty.subtitle')}

event.preventDefault()} onDrop={handleDrop} >
{fileSearchOpen && ( { if (atCursorPos < 0) return const replacement = `@${relativePath}` const tokenEnd = atCursorPos + 1 + atFilter.length const newValue = `${input.slice(0, atCursorPos)}${replacement}${input.slice(tokenEnd)}` const newCursorPos = atCursorPos + replacement.length setInput(newValue) setAtFilter(relativePath) requestAnimationFrame(() => { textareaRef.current?.focus() textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos) }) }} onSelect={(path, name) => { if (atCursorPos >= 0) { const attachmentName = name.split('/').filter(Boolean).pop() ?? name const tokenEnd = atCursorPos + 1 + atFilter.length const beforeToken = input.slice(0, atCursorPos) const afterToken = beforeToken ? input.slice(tokenEnd) : input.slice(tokenEnd).replace(/^\s+/, '') const spacer = beforeToken && afterToken && !/\s$/.test(beforeToken) && !/^\s/.test(afterToken) ? ' ' : '' const newValue = `${beforeToken}${spacer}${afterToken}` const newCursorPos = atCursorPos + spacer.length setAttachments((prev) => [ ...prev, { id: `att-${Date.now()}-${Math.random().toString(36).slice(2)}`, name: attachmentName, type: 'file', path, }, ]) setInput(newValue) setFileSearchOpen(false) setAtFilter('') setAtCursorPos(-1) void textareaRef.current?.focus() requestAnimationFrame(() => { textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos) }) } }} /> )} {localSlashPanel && (
setLocalSlashPanel(null)} />
)} {slashMenuOpen && filteredCommands.length > 0 && (
{filteredCommands.map((command, index) => ( ))}
)} {attachments.length > 0 && ( )}