import { useEffect, useMemo, useRef, useState } from 'react' import { skillsApi } from '../api/skills' import { useTranslation } from '../i18n' import { useSessionStore } from '../stores/sessionStore' import { useChatStore } from '../stores/chatStore' import { useUIStore } from '../stores/uiStore' import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore' import { DirectoryPicker } from '../components/shared/DirectoryPicker' import { PermissionModeSelector } from '../components/controls/PermissionModeSelector' import { ModelSelector } from '../components/controls/ModelSelector' import { AttachmentGallery } from '../components/chat/AttachmentGallery' import { FileSearchMenu, type FileSearchMenuHandle } from '../components/chat/FileSearchMenu' import { LocalSlashCommandPanel, type LocalSlashCommandName } from '../components/chat/LocalSlashCommandPanel' import { FALLBACK_SLASH_COMMANDS, findSlashToken, insertSlashTrigger, mergeSlashCommands, replaceSlashCommand, resolveSlashUiAction, } from '../components/chat/composerUtils' import type { AttachmentRef } from '../types/chat' import type { SlashCommandOption } from '../components/chat/composerUtils' type Attachment = { id: string name: string type: 'image' | 'file' mimeType?: string previewUrl?: string data?: string } export function EmptySession() { const t = useTranslation() const [input, setInput] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const [workDir, setWorkDir] = useState('') 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) 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]) const filteredCommands = useMemo(() => { const source = mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS) if (!slashFilter) return source const lower = slashFilter.toLowerCase() return source.filter((command) => ( command.name.toLowerCase().includes(lower) || command.description.toLowerCase().includes(lower) )) }, [slashCommands, slashFilter]) const exactSlashCommand = useMemo(() => { const normalized = slashFilter.trim().toLowerCase() if (!normalized) return null return filteredCommands.find((command) => command.name.toLowerCase() === normalized) ?? null }, [filteredCommands, slashFilter]) 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 ((!text && attachments.length === 0) || isSubmitting) 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 sessionId = await createSession(workDir || undefined) setActiveView('code') useTabStore.getState().openTab(sessionId, 'New Session') connectToSession(sessionId) const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({ type: attachment.type, name: attachment.name, data: attachment.data, mimeType: attachment.mimeType, })) sendMessage(sessionId, text, attachmentPayload) setInput('') setAttachments([]) } catch (error) { addToast({ type: 'error', message: error instanceof Error ? error.message : t('empty.failedToCreate'), }) } 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(cursorPos) 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 handleFileSelect = (event: React.ChangeEvent) => { const files = event.target.files if (!files) return Array.from(files).forEach((file) => { const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}` const isImage = file.type.startsWith('image/') const reader = new FileReader() reader.onload = () => { setAttachments((prev) => [ ...prev, { id, name: file.name, type: isImage ? 'image' : 'file', mimeType: file.type || undefined, previewUrl: isImage ? (reader.result as string) : undefined, data: reader.result as string, }, ]) } reader.readAsDataURL(file) }) event.target.value = '' } const handleDrop = (event: React.DragEvent) => { event.preventDefault() const files = event.dataTransfer.files if (files.length > 0) { const fakeEvent = { target: { files } } as React.ChangeEvent handleFileSelect(fakeEvent) } } 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) { const newValue = `${input.slice(0, atCursorPos)}${name}${input.slice(atCursorPos)}` const newCursorPos = atCursorPos + name.length 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 && ( )}