cc-haha/desktop/src/pages/EmptySession.tsx
程序员阿江(Relakkes) 78d85f0c64 fix(desktop): apply active provider before first prompt (#844)
Materialize the active provider runtime for empty-session first prompts before the WebSocket prewarm and user message are sent. This keeps a clean install that adds a MiniMax provider from starting the first turn without the selected provider runtime.

Tested: bun test src/server/__tests__/conversations.test.ts -t "MiniMax provider turn"
Tested: bun test src/server/__tests__/websocket-handler.test.ts src/server/__tests__/conversations.test.ts -t "prewarm|active provider id|MiniMax provider turn|first-turn runtime synchronization"
Tested: cd desktop && bun run test -- --run src/pages/EmptySession.test.tsx -t "draft runtime|active provider runtime|creates a new session"
Tested: cd desktop && bun run test -- --run src/components/controls/ModelSelector.test.tsx -t "defaults blank provider-scoped runtime selections|selects provider-scoped runtime models"
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Not-tested: bun run check:desktop, unrelated full-suite failures in MessageList timestamp formatting and TabBar AbortSignal cleanup blocked the gate.
Confidence: medium
Scope-risk: narrow
2026-07-02 21:02:58 +08:00

865 lines
33 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ApiError } from '../api/client'
import { agentsApi } from '../api/agents'
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 { useProviderStore } from '../stores/providerStore'
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, type ModelSelectorHandle } from '../components/controls/ModelSelector'
import { AttachmentGallery } from '../components/chat/AttachmentGallery'
import { ComposerDropOverlay } from '../components/chat/ComposerDropOverlay'
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 { isDesktopRuntime } from '../lib/desktopRuntime'
import { publicAssetPath } from '../lib/publicAsset'
import { resolveActiveProviderRuntimeSelection } from '../lib/runtimeSelection'
import {
filesToComposerAttachments,
selectNativeFileAttachments,
type ComposerAttachment,
} from '../lib/composerAttachments'
import { useComposerFileDrop } from '../components/chat/useComposerFileDrop'
import { shouldSubmitOnEnter } from '../components/chat/sendShortcut'
import {
appendAgentSlashCommands,
buildAgentSlashCommands,
getLocalizedFallbackCommands,
filterSlashCommands,
findSlashToken,
insertSlashTrigger,
mergeSlashCommands,
replaceSlashCommand,
resolveSlashUiAction,
} from '../components/chat/composerUtils'
import type { AttachmentRef } from '../types/chat'
import type { PermissionMode } from '../types/settings'
import type { SlashCommandOption } from '../components/chat/composerUtils'
type Attachment = ComposerAttachment
type Translate = ReturnType<typeof useTranslation>
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<string | null>(null)
const [useWorktree, setUseWorktree] = useState(false)
const [repositoryLaunchReady, setRepositoryLaunchReady] = useState(true)
const [attachments, setAttachments] = useState<Attachment[]>([])
const [plusMenuOpen, setPlusMenuOpen] = useState(false)
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
const [fileSearchOpen, setFileSearchOpen] = useState(false)
const [localSlashPanel, setLocalSlashPanel] = useState<LocalSlashCommandName | null>(null)
const [atFilter, setAtFilter] = useState('')
const [atCursorPos, setAtCursorPos] = useState(-1)
const [slashFilter, setSlashFilter] = useState('')
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
const [slashCommands, setSlashCommands] = useState<SlashCommandOption[]>([])
const [agentSlashCommands, setAgentSlashCommands] = useState<SlashCommandOption[]>([])
const textareaRef = useRef<HTMLTextAreaElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const modelSelectorRef = useRef<ModelSelectorHandle>(null)
const plusMenuRef = useRef<HTMLDivElement>(null)
const slashMenuRef = useRef<HTMLDivElement>(null)
const fileSearchRef = useRef<FileSearchMenuHandle>(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 activeProviderName = useSettingsStore((state) => state.activeProviderName)
const chatSendBehavior = useSettingsStore((state) => state.chatSendBehavior)
const defaultPermissionMode = useSettingsStore((state) => state.permissionMode)
const providers = useProviderStore((state) => state.providers)
const activeProviderId = useProviderStore((state) => state.activeId)
const [draftPermissionMode, setDraftPermissionMode] = useState<PermissionMode>(defaultPermissionMode)
const lastPluginReloadSummary = usePluginStore((state) => state.lastReloadSummary)
const draftRuntimeSelection = useSessionRuntimeStore((state) => state.selections[DRAFT_RUNTIME_SELECTION_KEY])
const draftRuntimeSelectionKey = draftRuntimeSelection
? `${draftRuntimeSelection.providerId ?? 'official'}:${draftRuntimeSelection.modelId}:${draftRuntimeSelection.effortLevel ?? 'auto'}`
: undefined
const draftModelLabel = draftRuntimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id
const isMobileComposer = useMobileViewport() && !isDesktopRuntime()
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
const cwd = workDir || undefined
skillsApi.list(cwd)
.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])
useEffect(() => {
let cancelled = false
const cwd = workDir || undefined
agentsApi.list(cwd)
.then(({ activeAgents }) => {
if (cancelled) return
setAgentSlashCommands(buildAgentSlashCommands(activeAgents))
})
.catch(() => {
if (!cancelled) {
setAgentSlashCommands([])
}
})
return () => {
cancelled = true
}
}, [workDir, lastPluginReloadSummary])
const allSlashCommands = useMemo(
() => appendAgentSlashCommands(
mergeSlashCommands(slashCommands, getLocalizedFallbackCommands(t)),
agentSlashCommands,
),
[agentSlashCommands, slashCommands, t],
)
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
}
if (slashUiAction?.type === 'model') {
modelSelectorRef.current?.open()
setInput('')
setSlashMenuOpen(false)
setFileSearchOpen(false)
setPlusMenuOpen(false)
return
}
setIsSubmitting(true)
try {
const runtimeStore = useSessionRuntimeStore.getState()
const explicitDraftSelection = runtimeStore.selections[DRAFT_RUNTIME_SELECTION_KEY]
const defaultActiveProviderSelection = explicitDraftSelection
? null
: resolveActiveProviderRuntimeSelection(
activeProviderId,
activeProviderName,
providers,
currentModel?.id,
)
const runtimeSelection = explicitDraftSelection ?? defaultActiveProviderSelection ?? undefined
const sessionId = await createSession(
workDir || undefined,
{
...(selectedBranch
? { repository: { branch: selectedBranch, worktree: useWorktree } }
: {}),
permissionMode: draftPermissionMode,
},
)
if (runtimeSelection) {
runtimeStore.setSelection(sessionId, runtimeSelection)
if (explicitDraftSelection) {
runtimeStore.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') {
const selected = filteredCommands[slashSelectedIndex]
if (
event.key === 'Enter' &&
exactSlashCommand &&
selected?.name.toLowerCase() === exactSlashCommand.name.toLowerCase() &&
slashFilter.trim().toLowerCase() === exactSlashCommand.name.toLowerCase() &&
shouldSubmitOnEnter(event, chatSendBehavior)
) {
event.preventDefault()
void handleSubmit()
return
}
event.preventDefault()
if (selected) selectSlashCommand(selected.name)
return
}
if (event.key === 'Escape') {
event.preventDefault()
setSlashMenuOpen(false)
return
}
}
if (shouldSubmitOnEnter(event, chatSendBehavior)) {
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 appendAttachments = useCallback((nextAttachments: Attachment[]) => {
if (nextAttachments.length === 0) return
setAttachments((prev) => [...prev, ...nextAttachments])
}, [])
const { isDragActive, dragHandlers } = useComposerFileDrop({
panelRef,
onAttachments: appendAttachments,
onError: (error) => {
console.warn('[attachments] Failed to read dropped files', error)
},
})
const openAttachmentPicker = useCallback(() => {
setPlusMenuOpen(false)
if (!isDesktopRuntime()) {
fileInputRef.current?.click()
return
}
void selectNativeFileAttachments()
.then((nativeAttachments) => {
if (nativeAttachments) {
if (nativeAttachments.length > 0) {
setAttachments((prev) => [...prev, ...nativeAttachments])
}
return
}
fileInputRef.current?.click()
})
}, [])
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files
if (!files) return
appendFiles(files)
event.target.value = ''
}
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 (
<div className="relative flex flex-1 flex-col overflow-hidden bg-[var(--color-surface)]">
<div className={`flex flex-1 flex-col items-center justify-center ${
isMobileComposer ? 'px-6 pb-[230px] pt-10' : 'p-8 pb-32'
}`}>
<div className={`flex flex-col items-center text-center ${
isMobileComposer ? 'max-w-[300px]' : 'max-w-md'
}`}>
<img
src={publicAssetPath('app-icon.png')}
alt="Claude Code Haha"
className={isMobileComposer ? 'mb-4 h-16 w-16' : 'mb-6 h-24 w-24'}
/>
<h1
className={`mb-2 font-extrabold tracking-tight text-[var(--color-text-primary)] ${
isMobileComposer ? 'text-2xl' : 'text-3xl'
}`}
style={{ fontFamily: 'var(--font-headline)' }}
>
{t('empty.title')}
</h1>
<p
className={`mx-auto text-[var(--color-text-secondary)] ${
isMobileComposer ? 'max-w-[280px] text-sm leading-6' : 'max-w-xs'
}`}
style={{ fontFamily: 'var(--font-body)' }}
>
{t('empty.subtitle')}
</p>
</div>
</div>
<div
data-testid="empty-session-composer-shell"
className={`absolute left-0 right-0 z-30 flex justify-center ${
isMobileComposer
? 'bottom-0 px-3 pb-[calc(env(safe-area-inset-bottom)+10px)]'
: 'bottom-4 px-8'
}`}
>
<div className={`flex w-full flex-col ${isMobileComposer ? 'max-w-none' : 'max-w-3xl'}`}>
<div
ref={panelRef}
data-testid="empty-session-composer-panel"
className={`glass-panel relative flex flex-col gap-3 overflow-visible ${
isMobileComposer ? 'rounded-2xl p-3 shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl p-0'
} ${isDragActive ? 'composer-drop-target-active' : ''}`}
{...dragHandlers}
>
{isDragActive && (
<ComposerDropOverlay
testId="empty-session-drop-overlay"
title={t('chat.dropFilesTitle')}
description={t('chat.dropFilesHint')}
/>
)}
<div className={isMobileComposer ? 'contents' : 'flex flex-col gap-3 p-4'}>
{fileSearchOpen && (
<FileSearchMenu
ref={fileSearchRef}
cwd={workDir || ''}
filter={atFilter}
onNavigate={(relativePath) => {
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 && (
<div ref={slashMenuRef}>
<LocalSlashCommandPanel
command={localSlashPanel}
cwd={workDir || undefined}
commands={allSlashCommands}
onClose={() => setLocalSlashPanel(null)}
/>
</div>
)}
{slashMenuOpen && filteredCommands.length > 0 && (
<div
ref={slashMenuRef}
className="absolute bottom-full left-0 right-0 z-50 mb-2 overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]"
>
<div className="max-h-[260px] overflow-y-auto py-1">
{filteredCommands.map((command, index) => (
<button
key={command.name}
ref={(el) => { slashItemRefs.current[index] = el }}
onClick={() => selectSlashCommand(command.name)}
onMouseEnter={() => setSlashSelectedIndex(index)}
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors ${
index === slashSelectedIndex ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className="flex min-w-0 max-w-[52%] shrink-0 items-baseline gap-1.5">
<span className="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]">/{command.name}</span>
{command.argumentHint ? (
<span className="min-w-0 truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">
{command.argumentHint}
</span>
) : null}
</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">{command.description}</span>
</button>
))}
</div>
</div>
)}
{attachments.length > 0 && (
<AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
)}
<div className="flex items-start gap-3">
<textarea
ref={textareaRef}
value={input}
onChange={(event) => handleInputChange(event.target.value, event.target.selectionStart ?? event.target.value.length)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
className={`flex-1 resize-none border-none bg-transparent leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] ${
isMobileComposer ? 'max-h-[132px] min-h-[72px] py-1.5 text-base' : 'py-2'
}`}
style={{ fontFamily: 'var(--font-body)' }}
placeholder={t('empty.placeholder')}
rows={2}
/>
</div>
<div className={`border-t border-[var(--color-border-separator)] pt-3 ${
isMobileComposer ? 'flex flex-wrap items-center gap-2' : 'flex items-center justify-between'
}`}>
<div className="flex shrink-0 items-center gap-2">
<div ref={plusMenuRef} className="relative">
<button
onClick={() => setPlusMenuOpen((prev) => !prev)}
aria-label="Open composer tools"
className={`text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] ${
isMobileComposer ? 'inline-flex h-11 w-11 items-center justify-center rounded-xl' : 'rounded-lg p-1.5'
}`}
>
<span className="material-symbols-outlined text-[18px]">add</span>
</button>
{plusMenuOpen && (
<div className={`absolute bottom-full left-0 mb-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)] ${
isMobileComposer ? 'w-[min(240px,calc(100vw-32px))]' : 'w-[240px]'
}`}>
<button
onClick={openAttachmentPicker}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">attach_file</span>
{t('empty.addFiles')}
</button>
<button
onClick={insertSlashCommand}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="w-5 text-center text-[18px] font-bold text-[var(--color-text-secondary)]">/</span>
{t('empty.slashCommands')}
</button>
</div>
)}
</div>
<PermissionModeSelector
workDir={workDir}
compact={isMobileComposer}
value={draftPermissionMode}
onChange={setDraftPermissionMode}
/>
</div>
<div className={`${isMobileComposer ? 'flex min-w-0 flex-1 items-center justify-end gap-2' : 'flex items-center gap-3'}`}>
<ContextUsageIndicator
chatState="idle"
messageCount={0}
runtimeSelectionKey={draftRuntimeSelectionKey}
fallbackModelLabel={draftModelLabel}
draft
compact={isMobileComposer}
/>
<ModelSelector ref={modelSelectorRef} runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting} compact={isMobileComposer} />
<button
onClick={handleSubmit}
disabled={!canSubmit}
aria-label={t('common.run')}
title={isMobileComposer ? t('common.run') : undefined}
className={`flex shrink-0 items-center justify-center gap-1 rounded-lg bg-[image:var(--gradient-btn-primary)] text-xs font-semibold text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] transition-all hover:brightness-105 disabled:opacity-30 ${
isMobileComposer ? 'h-11 w-11 rounded-xl px-0 py-0' : 'w-[112px] px-3 py-1.5'
}`}
>
{!isMobileComposer && t('common.run')}
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
</button>
</div>
</div>
</div>
{!isMobileComposer && (
<RepositoryLaunchControls
workDir={workDir}
onWorkDirChange={handleWorkDirChange}
branch={selectedBranch}
onBranchChange={setSelectedBranch}
useWorktree={useWorktree}
onUseWorktreeChange={setUseWorktree}
onLaunchReadyChange={setRepositoryLaunchReady}
disabled={isSubmitting}
placement="composer"
/>
)}
</div>
{isMobileComposer && (
<RepositoryLaunchControls
workDir={workDir}
onWorkDirChange={handleWorkDirChange}
branch={selectedBranch}
onBranchChange={setSelectedBranch}
useWorktree={useWorktree}
onUseWorktreeChange={setUseWorktree}
onLaunchReadyChange={setRepositoryLaunchReady}
disabled={isSubmitting}
/>
)}
</div>
</div>
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
</div>
)
}