程序员阿江(Relakkes) 9b540554cc Prevent desktop draft text leaking between tabs
The desktop chat composer is a single mounted component under the active session view, so switching tabs reused the same local draft state. The fix keeps unsent composer input and attachments keyed by session tab and restores the matching draft when the active tab changes.

Constraint: Must preserve unsent drafts while isolating them per session tab
Rejected: Force-remount ChatInput with a React key | wider UI lifecycle change and more risk to composer substate
Confidence: high
Scope-risk: narrow
Directive: Do not make composer draft state global without preserving per-session isolation
Tested: cd desktop && bun run test -- ChatInput.test.tsx
Tested: cd desktop && bun run lint
Tested: bun run check:desktop
Tested: agent-browser reproduced the leak on an unmodified baseline and verified the fixed UI keeps history tab textarea empty while restoring the new tab draft
Not-tested: Windows 11 packaged release manual smoke
2026-05-13 10:38:13 +08:00

1072 lines
41 KiB
TypeScript

import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { useTranslation } from '../../i18n'
import { useChatStore } from '../../stores/chatStore'
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
import { useUIStore } from '../../stores/uiStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore'
import { useTeamStore } from '../../stores/teamStore'
import { useSettingsStore } from '../../stores/settingsStore'
import {
formatWorkspaceReferencePrompt,
useWorkspaceChatContextStore,
type WorkspaceChatReference,
} from '../../stores/workspaceChatContextStore'
import { sessionsApi, type SessionGitInfo } from '../../api/sessions'
import { PermissionModeSelector } from '../controls/PermissionModeSelector'
import { ModelSelector } from '../controls/ModelSelector'
import type { AttachmentRef } from '../../types/chat'
import { AttachmentGallery } from './AttachmentGallery'
import { ProjectContextChip } from '../shared/ProjectContextChip'
import { RepositoryLaunchControls } from '../shared/RepositoryLaunchControls'
import { FileSearchMenu, type FileSearchMenuHandle } from './FileSearchMenu'
import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlashCommandPanel'
import { ContextUsageIndicator } from './ContextUsageIndicator'
import {
FALLBACK_SLASH_COMMANDS,
findSlashTrigger,
mergeSlashCommands,
replaceSlashToken,
resolveSlashUiAction,
} from './composerUtils'
import { useMobileViewport } from '../../hooks/useMobileViewport'
import { isTauriRuntime } from '../../lib/desktopRuntime'
type GitInfo = SessionGitInfo
type Attachment = {
id: string
name: string
type: 'image' | 'file'
path?: string
mimeType?: string
previewUrl?: string
data?: string
lineStart?: number
lineEnd?: number
note?: string
quote?: string
}
type ComposerDraft = {
input: string
attachments: Attachment[]
}
type ChatInputProps = {
variant?: 'default' | 'hero'
compact?: boolean
}
const EMPTY_WORKSPACE_REFERENCES: WorkspaceChatReference[] = []
function workspaceReferenceToAttachment(reference: WorkspaceChatReference): Attachment {
return {
id: reference.id,
name: reference.name,
type: 'file',
path: reference.path,
lineStart: reference.lineStart,
lineEnd: reference.lineEnd,
note: reference.note,
quote: reference.quote,
}
}
export function ChatInput({ variant = 'default', compact = false }: ChatInputProps) {
const t = useTranslation()
const isMobileComposer = useMobileViewport() && !isTauriRuntime()
const [input, setInput] = useState('')
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 [launchWorkDir, setLaunchWorkDir] = useState('')
const [launchBranch, setLaunchBranch] = useState<string | null>(null)
const [launchUseWorktree, setLaunchUseWorktree] = useState(false)
const [launchReady, setLaunchReady] = useState(true)
const [launchTransitioning, setLaunchTransitioning] = useState(false)
const composingRef = useRef(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const plusMenuRef = useRef<HTMLDivElement>(null)
const slashMenuRef = useRef<HTMLDivElement>(null)
const fileSearchRef = useRef<FileSearchMenuHandle>(null)
const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([])
const composerDraftsRef = useRef<Record<string, ComposerDraft>>({})
const previousActiveTabIdRef = useRef<string | null>(null)
const inputRef = useRef(input)
const attachmentsRef = useRef(attachments)
const setComposerInput = useCallback((value: string) => {
inputRef.current = value
setInput(value)
}, [])
const setComposerAttachments = useCallback((value: Attachment[] | ((previous: Attachment[]) => Attachment[])) => {
setAttachments((previous) => {
const next = typeof value === 'function' ? value(previous) : value
attachmentsRef.current = next
return next
})
}, [])
const { sendMessage, stopGeneration } = useChatStore()
const activeTabId = useTabStore((s) => s.activeTabId)
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
const chatState = sessionState?.chatState ?? 'idle'
const slashCommands = sessionState?.slashCommands ?? []
const composerPrefill = sessionState?.composerPrefill ?? null
const runtimeSelection = useSessionRuntimeStore((state) =>
activeTabId ? state.selections[activeTabId] : undefined,
)
const currentModel = useSettingsStore((state) => state.currentModel)
const runtimeSelectionKey = runtimeSelection
? `${runtimeSelection.providerId ?? 'official'}:${runtimeSelection.modelId}`
: undefined
const runtimeModelLabel = runtimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id
const activeSession = useSessionStore((state) => activeTabId ? state.sessions.find((session) => session.id === activeTabId) ?? null : null)
const loadedMessageCount = sessionState?.messages?.length ?? 0
const messageCount = Math.max(loadedMessageCount, activeSession?.messageCount ?? 0)
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null)
const workspaceReferences = useWorkspaceChatContextStore(
(s) => activeTabId ? s.referencesBySession[activeTabId] ?? EMPTY_WORKSPACE_REFERENCES : EMPTY_WORKSPACE_REFERENCES,
)
const addWorkspaceReference = useWorkspaceChatContextStore((s) => s.addReference)
const removeWorkspaceReference = useWorkspaceChatContextStore((s) => s.removeReference)
const clearWorkspaceReferences = useWorkspaceChatContextStore((s) => s.clearReferences)
const isMemberSession = !!memberInfo
const isActive = chatState !== 'idle'
const isWorkspaceMissing = activeSession?.workDirExists === false
const hasWorkspaceReferences = !isMemberSession && workspaceReferences.length > 0
const isHeroComposer = variant === 'hero' && !isMemberSession && !compact
const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined
const showLaunchControls = !isMemberSession && messageCount === 0
const useCompactControls = compact || isMobileComposer
const iconOnlyAction = compact || isMobileComposer
const activeLaunchWorkDir = showLaunchControls ? (launchWorkDir || resolvedWorkDir || '') : (resolvedWorkDir || '')
const pendingSlashUiAction = !isMemberSession && input.trim().startsWith('/')
? resolveSlashUiAction(input.trim().slice(1))
: null
const canSubmit = !isWorkspaceMissing &&
!launchTransitioning &&
(!showLaunchControls || launchReady || !!pendingSlashUiAction) &&
(input.trim().length > 0 || (!isMemberSession && (attachments.length > 0 || hasWorkspaceReferences)))
const composerAttachments = useMemo(
() => [
...attachments,
...workspaceReferences.map(workspaceReferenceToAttachment),
],
[attachments, workspaceReferences],
)
const slashCommandCount = slashCommands.length
useEffect(() => {
inputRef.current = input
}, [input])
useEffect(() => {
attachmentsRef.current = attachments
}, [attachments])
useEffect(() => {
const previousActiveTabId = previousActiveTabIdRef.current
if (previousActiveTabId === activeTabId) return
if (previousActiveTabId) {
composerDraftsRef.current[previousActiveTabId] = {
input: inputRef.current,
attachments: attachmentsRef.current,
}
}
const nextDraft = activeTabId ? composerDraftsRef.current[activeTabId] : undefined
setComposerInput(nextDraft?.input ?? '')
setComposerAttachments(nextDraft?.attachments ?? [])
setPlusMenuOpen(false)
setSlashMenuOpen(false)
setFileSearchOpen(false)
setLocalSlashPanel(null)
setSlashFilter('')
setAtFilter('')
setAtCursorPos(-1)
previousActiveTabIdRef.current = activeTabId
}, [activeTabId, setComposerAttachments, setComposerInput])
useEffect(() => {
textareaRef.current?.focus()
}, [isActive])
useEffect(() => {
if (!composerPrefill) return
setComposerInput(composerPrefill.text)
setComposerAttachments(
(composerPrefill.attachments ?? [])
.filter((attachment) => attachment.type === 'image' || attachment.data)
.map((attachment, index) => ({
id: `rewind-prefill-${composerPrefill.nonce}-${index}`,
name: attachment.name,
type: attachment.type,
mimeType: attachment.mimeType,
previewUrl: attachment.type === 'image' ? attachment.data : undefined,
data: attachment.data,
})),
)
setPlusMenuOpen(false)
setSlashMenuOpen(false)
setFileSearchOpen(false)
setSlashFilter('')
setAtFilter('')
setAtCursorPos(-1)
requestAnimationFrame(() => {
const el = textareaRef.current
el?.focus()
const cursor = composerPrefill.text.length
el?.setSelectionRange(cursor, cursor)
})
}, [composerPrefill, setComposerAttachments, setComposerInput])
const refreshGitInfo = useCallback(() => {
if (!activeTabId) {
setGitInfo(null)
return
}
if (isMemberSession) {
setGitInfo(null)
return
}
sessionsApi.getGitInfo(activeTabId).then(setGitInfo).catch(() => setGitInfo(null))
}, [activeTabId, isMemberSession])
useEffect(() => {
refreshGitInfo()
}, [refreshGitInfo])
useEffect(() => {
if (!activeTabId || isMemberSession || messageCount === 0) return
const timeout = setTimeout(refreshGitInfo, chatState === 'idle' ? 0 : 500)
return () => clearTimeout(timeout)
}, [activeTabId, chatState, isMemberSession, messageCount, refreshGitInfo, slashCommandCount])
useEffect(() => {
if (!isMemberSession) return
setComposerAttachments([])
setPlusMenuOpen(false)
setSlashMenuOpen(false)
setFileSearchOpen(false)
}, [isMemberSession, activeTabId])
useEffect(() => {
if (!showLaunchControls) return
const nextWorkDir = activeSession?.workDir || gitInfo?.workDir || ''
setLaunchWorkDir((current) => {
if (current === nextWorkDir) return current
setLaunchBranch(null)
setLaunchUseWorktree(false)
setLaunchReady(!nextWorkDir)
return nextWorkDir
})
}, [activeSession?.workDir, activeTabId, gitInfo?.workDir, showLaunchControls])
useEffect(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 200)}px`
}, [input])
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])
const allSlashCommands = useMemo(
() => mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS),
[slashCommands],
)
const filteredCommands = useMemo(() => {
const source = allSlashCommands
if (!slashFilter) return source
const lower = slashFilter.toLowerCase()
return source.filter((command) => (
command.name.toLowerCase().includes(lower) ||
command.description.toLowerCase().includes(lower)
))
}, [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])
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 detectSlashTrigger = useCallback((value: string, cursorPos: number) => {
const token = findSlashTrigger(value, cursorPos)
if (!token) {
setSlashMenuOpen(false)
return
}
setFileSearchOpen(false)
setSlashFilter(token.filter)
setSlashMenuOpen(true)
}, [])
// Detect @ trigger (file search)
const detectAtTrigger = useCallback((value: string, cursorPos: number) => {
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)
return
}
// Extract filter text after @
const filter = textBeforeCursor.slice(pos + 1)
setAtFilter(filter)
setAtCursorPos(pos)
setSlashMenuOpen(false)
setFileSearchOpen(true)
}, [])
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value
if (isMemberSession) {
setComposerInput(value)
return
}
const cursorPos = event.target.selectionStart ?? value.length
setComposerInput(value)
detectSlashTrigger(value, cursorPos)
detectAtTrigger(value, cursorPos)
}
const selectSlashCommand = useCallback((command: string) => {
const el = textareaRef.current
if (!el) return
const cursorPos = el.selectionStart ?? input.length
const replacement = replaceSlashToken(input, cursorPos, command)
setComposerInput(replacement.value)
setSlashMenuOpen(false)
requestAnimationFrame(() => {
el.focus()
el.setSelectionRange(replacement.cursorPos, replacement.cursorPos)
})
}, [input])
const replaceEmptySession = useCallback(async (
workDir: string,
repository?: { branch?: string | null; worktree?: boolean },
) => {
if (!activeTabId) return null
const oldId = activeTabId
const { createSession, deleteSession } = useSessionStore.getState()
const { replaceTabSession } = useTabStore.getState()
const { disconnectSession, connectToSession } = useChatStore.getState()
const newId = await createSession(
workDir || undefined,
repository ? { repository } : undefined,
)
useSessionRuntimeStore.getState().moveSelection(oldId, newId)
disconnectSession(oldId)
replaceTabSession(oldId, newId)
connectToSession(newId)
deleteSession(oldId).catch(() => {})
return newId
}, [activeTabId])
const handleLaunchWorkDirChange = useCallback(async (newWorkDir: string) => {
setLaunchWorkDir(newWorkDir)
setLaunchBranch(null)
setLaunchUseWorktree(false)
setLaunchReady(!newWorkDir)
if (!activeTabId) return
setLaunchTransitioning(true)
try {
await replaceEmptySession(newWorkDir)
} catch (error) {
useUIStore.getState().addToast({
type: 'error',
message: error instanceof Error ? error.message : t('empty.failedToCreate'),
})
} finally {
setLaunchTransitioning(false)
}
}, [activeTabId, replaceEmptySession, t])
const handleSubmit = async () => {
const text = input.trim()
if ((!text && ((!attachments.length && !hasWorkspaceReferences) || isMemberSession)) || isWorkspaceMissing) return
if (pendingSlashUiAction?.type === 'panel') {
setLocalSlashPanel(pendingSlashUiAction.command as LocalSlashCommandName)
setComposerInput('')
setSlashMenuOpen(false)
setFileSearchOpen(false)
setPlusMenuOpen(false)
return
}
if (pendingSlashUiAction?.type === 'settings') {
useUIStore.getState().setPendingSettingsTab(pendingSlashUiAction.tab)
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
setComposerInput('')
setSlashMenuOpen(false)
setFileSearchOpen(false)
setPlusMenuOpen(false)
return
}
if (showLaunchControls && (!launchReady || launchTransitioning)) return
const workspaceReferencePrompt = !isMemberSession
? formatWorkspaceReferencePrompt(workspaceReferences)
: ''
const contentForModel = [workspaceReferencePrompt, text].filter(Boolean).join('\n\n')
const displayContent = text || (
workspaceReferences.length > 0
? t('chat.workspaceReferencesOnly', { count: workspaceReferences.length })
: ''
)
const uploadAttachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({
type: attachment.type,
name: attachment.name,
path: attachment.path,
data: attachment.data,
mimeType: attachment.mimeType,
lineStart: attachment.lineStart,
lineEnd: attachment.lineEnd,
note: attachment.note,
quote: attachment.quote,
}))
const workspaceAttachmentPayload: AttachmentRef[] = workspaceReferences.map((reference) => ({
type: 'file' as const,
name: reference.name,
path: reference.absolutePath ?? reference.path,
lineStart: reference.lineStart,
lineEnd: reference.lineEnd,
note: reference.note,
quote: reference.quote,
}))
const visibleAttachmentPayload: AttachmentRef[] = [
...uploadAttachmentPayload,
...workspaceReferences.map((reference) => ({
type: 'file' as const,
name: reference.name,
path: reference.path,
lineStart: reference.lineStart,
lineEnd: reference.lineEnd,
note: reference.note,
quote: reference.quote,
})),
]
let targetSessionId = activeTabId!
if (showLaunchControls && activeLaunchWorkDir && launchBranch) {
const shouldReplaceForRepositoryLaunch =
launchUseWorktree ||
(gitInfo?.branch ? launchBranch !== gitInfo.branch : true)
if (shouldReplaceForRepositoryLaunch) {
setLaunchTransitioning(true)
try {
const newSessionId = await replaceEmptySession(activeLaunchWorkDir, {
branch: launchBranch,
worktree: launchUseWorktree,
})
if (!newSessionId) return
targetSessionId = newSessionId
} catch (error) {
useUIStore.getState().addToast({
type: 'error',
message: error instanceof Error ? error.message : t('empty.failedToCreate'),
})
return
} finally {
setLaunchTransitioning(false)
}
}
}
sendMessage(targetSessionId, contentForModel, [...uploadAttachmentPayload, ...workspaceAttachmentPayload], {
displayContent,
displayAttachments: visibleAttachmentPayload,
})
setComposerInput('')
setComposerAttachments([])
if (!isMemberSession) {
clearWorkspaceReferences(activeTabId!)
if (targetSessionId !== activeTabId) clearWorkspaceReferences(targetSessionId)
}
setPlusMenuOpen(false)
setSlashMenuOpen(false)
setFileSearchOpen(false)
setLocalSlashPanel(null)
}
const handleKeyDown = (event: React.KeyboardEvent) => {
// Ignore key events during IME composition (e.g. Chinese input method)
if (composingRef.current || event.nativeEvent.isComposing || event.keyCode === 229) 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
}
// Other keys (typing) should go to the textarea - let it propagate
return
}
if (localSlashPanel) {
if (event.key === 'Escape') {
event.preventDefault()
setLocalSlashPanel(null)
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') {
if (exactSlashCommand && slashFilter.trim().toLowerCase() === exactSlashCommand.name.toLowerCase()) {
event.preventDefault()
handleSubmit()
return
}
event.preventDefault()
const selected = filteredCommands[slashSelectedIndex]
if (selected) selectSlashCommand(selected.name)
return
}
if (event.key === 'Tab') {
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) => {
if (isMemberSession) return
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 = () => {
setComposerAttachments((prev) => [
...prev,
{
id,
name: `pasted-image-${Date.now()}.png`,
type: 'image',
mimeType: file.type || 'image/png',
previewUrl: reader.result as string,
data: reader.result as string,
},
])
}
reader.readAsDataURL(file)
}
if (!hasImage) return
}
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
if (isMemberSession) return
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 = () => {
setComposerAttachments((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()
if (isMemberSession) return
const files = event.dataTransfer.files
if (files.length > 0) {
const fakeEvent = { target: { files } } as React.ChangeEvent<HTMLInputElement>
handleFileSelect(fakeEvent)
}
}
const removeAttachment = (id: string) => {
setComposerAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
if (activeTabId) removeWorkspaceReference(activeTabId, id)
}
const insertSlashCommand = () => {
if (isMemberSession) return
const el = textareaRef.current
const cursorPos = el?.selectionStart ?? input.length
const replacement = replaceSlashToken(input, cursorPos, '', { trailingSpace: false })
setComposerInput(replacement.value)
setPlusMenuOpen(false)
setSlashFilter('')
setSlashMenuOpen(true)
requestAnimationFrame(() => {
textareaRef.current?.focus()
textareaRef.current?.setSelectionRange(replacement.cursorPos, replacement.cursorPos)
})
}
const composerPlaceholder =
isHeroComposer
? t('empty.placeholder')
: isWorkspaceMissing
? t('chat.placeholderMissing')
: isMemberSession
? t('teams.memberPlaceholder')
: t('chat.placeholder')
const addFilesLabel = isHeroComposer ? t('empty.addFiles') : t('chat.addFiles')
const slashCommandsLabel = isHeroComposer ? t('empty.slashCommands') : t('chat.slashCommands')
return (
<div
data-testid="chat-input-shell"
className={
isHeroComposer
? `bg-[var(--color-surface)] ${isMobileComposer ? 'px-4 pb-3' : 'px-8 pb-4'}`
: compact
? `border-t border-[var(--color-border)]/70 bg-[var(--color-surface)] ${isMobileComposer ? 'px-3 pb-[calc(env(safe-area-inset-bottom)+10px)] pt-2' : 'px-3 py-3'}`
: `bg-[var(--color-surface)] ${isMobileComposer ? 'px-3 pb-[calc(env(safe-area-inset-bottom)+10px)] pt-2' : 'px-4 py-4'}`
}
>
<div
className={
isHeroComposer
? 'mx-auto flex w-full max-w-3xl flex-col'
: compact
? 'mx-auto max-w-full'
: `${isMobileComposer ? 'mx-0 max-w-none' : 'mx-auto max-w-[860px]'}`
}
>
<div
data-testid="chat-input-panel"
className={isHeroComposer
? 'glass-panel relative flex flex-col gap-3 rounded-t-xl rounded-b-none p-4 transition-colors'
: compact
? `glass-panel relative p-3 transition-colors ${isMobileComposer ? 'rounded-2xl shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl'}`
: `glass-panel relative transition-colors ${isMobileComposer ? 'rounded-2xl p-3 shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl p-4'}`}
onDragOver={(event) => event.preventDefault()}
onDrop={handleDrop}
>
{!isMemberSession && fileSearchOpen && (
<FileSearchMenu
ref={fileSearchRef}
cwd={activeLaunchWorkDir || resolvedWorkDir || ''}
filter={atFilter}
compact={isMobileComposer}
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
setComposerInput(newValue)
setAtFilter(relativePath)
requestAnimationFrame(() => {
textareaRef.current?.focus()
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
})
}}
onSelect={(path, name) => {
if (atCursorPos >= 0) {
const referenceName = 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
if (activeTabId) {
addWorkspaceReference(activeTabId, {
kind: 'file',
path,
absolutePath: path,
name: referenceName,
})
}
setComposerInput(newValue)
setFileSearchOpen(false)
setAtFilter('')
setAtCursorPos(-1)
void textareaRef.current?.focus()
requestAnimationFrame(() => {
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
})
}
}}
/>
)}
{!isMemberSession && localSlashPanel && (
<div ref={slashMenuRef}>
<LocalSlashCommandPanel
command={localSlashPanel}
sessionId={activeTabId ?? undefined}
cwd={activeLaunchWorkDir || resolvedWorkDir}
commands={allSlashCommands}
onClose={() => setLocalSlashPanel(null)}
/>
</div>
)}
{!isMemberSession && 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-[300px] 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="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]">
/{command.name}
</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">
{command.description}
</span>
</button>
))}
</div>
{!isMobileComposer ? (
<div className="flex items-center gap-1.5 border-t border-[var(--color-border)] px-4 py-2 text-xs text-[var(--color-text-tertiary)]">
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">Up/Down</kbd>
<span>{t('chat.navigate')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">Enter</kbd>
<span>{t('chat.select')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">Esc</kbd>
<span>{t('chat.dismiss')}</span>
</div>
) : null}
</div>
)}
{composerAttachments.length > 0 && (
isHeroComposer ? (
<AttachmentGallery attachments={composerAttachments} variant="composer" onRemove={removeAttachment} />
) : (
<div className="px-3 pt-3">
<AttachmentGallery attachments={composerAttachments} variant="composer" onRemove={removeAttachment} />
</div>
)
)}
{isHeroComposer ? (
<div className="flex items-start gap-3">
<textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onPaste={handlePaste}
placeholder={composerPlaceholder}
disabled={isWorkspaceMissing}
rows={2}
className="flex-1 resize-none border-none bg-transparent py-2 leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50"
/>
</div>
) : (
<textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onPaste={handlePaste}
placeholder={composerPlaceholder}
disabled={isWorkspaceMissing}
rows={1}
className={`w-full resize-none bg-transparent text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50 ${
useCompactControls ? 'py-1.5 pb-14' : 'py-2 pb-12'
}`}
/>
)}
<div className={isHeroComposer
? 'flex items-center justify-between border-t border-[var(--color-border-separator)] pt-3'
: `absolute bottom-0 left-0 right-0 flex items-center justify-between border-t border-[var(--color-border-separator)] ${
useCompactControls ? 'gap-2 px-2.5 py-2' : 'px-3 py-3'
}`}>
<div className="flex min-w-0 items-center gap-2">
{!isMemberSession && (
<>
<div ref={plusMenuRef} className="relative">
<button
onClick={() => setPlusMenuOpen((value) => !value)}
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-[var(--radius-md)] p-1.5'}`}
>
<span className="material-symbols-outlined text-[18px]">add</span>
</button>
{plusMenuOpen && (
<div className={`absolute bottom-full left-0 z-50 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={() => {
fileInputRef.current?.click()
setPlusMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">attach_file</span>
<span className="text-sm text-[var(--color-text-primary)]">{addFilesLabel}</span>
</button>
<button
onClick={insertSlashCommand}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="w-[24px] text-center text-[18px] font-bold text-[var(--color-text-secondary)]">/</span>
<span className="text-sm text-[var(--color-text-primary)]">{slashCommandsLabel}</span>
</button>
</div>
)}
</div>
<PermissionModeSelector compact={useCompactControls} />
</>
)}
</div>
<div className="flex min-w-0 items-center gap-2">
{!isMemberSession && activeTabId && (
<ContextUsageIndicator
sessionId={activeTabId}
chatState={chatState}
messageCount={messageCount}
runtimeSelectionKey={runtimeSelectionKey}
fallbackModelLabel={runtimeModelLabel}
compact={useCompactControls}
/>
)}
{!isMemberSession && activeTabId && (
<ModelSelector runtimeKey={activeTabId} disabled={isActive} compact={useCompactControls} />
)}
<button
onClick={!isMemberSession && isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
disabled={!isMemberSession && isActive ? false : !canSubmit}
aria-label={!isMemberSession && isActive ? t('common.stop') : isMemberSession ? t('common.send') : t('common.run')}
title={
!isMemberSession && isActive
? t('chat.stopTitle')
: iconOnlyAction
? isMemberSession
? t('common.send')
: t('common.run')
: undefined
}
className={`flex shrink-0 items-center justify-center gap-1 rounded-lg text-xs font-semibold transition-all hover:brightness-105 disabled:opacity-30 ${
iconOnlyAction ? `${isMobileComposer ? 'h-11 w-11 rounded-xl px-0 py-0' : 'h-8 w-8 px-0 py-0'}` : 'w-[112px] px-3 py-1.5'
} ${
!isMemberSession && isActive
? 'bg-[var(--color-error-container)] text-[var(--color-on-error-container)]'
: 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)]'
}`}
>
<span className="material-symbols-outlined text-[14px]">
{!isMemberSession && isActive ? 'stop' : 'arrow_forward'}
</span>
{!iconOnlyAction && (!isMemberSession && isActive ? t('common.stop') : isMemberSession ? t('common.send') : t('common.run'))}
</button>
</div>
</div>
</div>
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
{!isMemberSession && (
<div className={useCompactControls ? 'mt-2 flex min-w-0 px-1' : 'mt-3 px-1'}>
{messageCount > 0 ? (
<ProjectContextChip
workDir={resolvedWorkDir}
repoName={gitInfo?.repoName || null}
branch={gitInfo?.branch || null}
sourceWorkDir={gitInfo?.worktree?.sourceWorkDir || null}
isWorktree={!!gitInfo?.worktree?.enabled}
worktreeSlug={gitInfo?.worktree?.slug || null}
worktreePath={gitInfo?.worktree?.path || gitInfo?.worktree?.plannedPath || null}
compact={useCompactControls}
/>
) : (
<RepositoryLaunchControls
workDir={activeLaunchWorkDir}
onWorkDirChange={handleLaunchWorkDirChange}
branch={launchBranch}
onBranchChange={setLaunchBranch}
useWorktree={launchUseWorktree}
onUseWorktreeChange={setLaunchUseWorktree}
onLaunchReadyChange={setLaunchReady}
disabled={isActive || launchTransitioning}
/>
)}
</div>
)}
</div>
</div>
)
}