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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-12 19:40:21 +08:00
parent d41d5a87f0
commit 9b540554cc
2 changed files with 183 additions and 17 deletions

View File

@ -1,6 +1,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
import { act } from 'react'
const viewportMocks = vi.hoisted(() => ({
isMobile: false,
@ -168,6 +169,118 @@ describe('ChatInput file mentions', () => {
mocks.getSlashCommands.mockResolvedValue({ commands: [] })
})
it('keeps unsent composer drafts isolated when switching between session tabs', async () => {
const historySessionId = 'history-session'
useTabStore.setState({
activeTabId: sessionId,
tabs: [
{ sessionId, title: 'New session', type: 'session', status: 'idle' },
{ sessionId: historySessionId, title: 'History session', type: 'session', status: 'idle' },
],
})
useSessionStore.setState({
sessions: [
{
id: sessionId,
title: 'New session',
createdAt: '2026-05-01T00:00:00.000Z',
modifiedAt: '2026-05-01T00:00:00.000Z',
messageCount: 0,
projectPath: '/repo',
workDir: '/repo',
workDirExists: true,
},
{
id: historySessionId,
title: 'History session',
createdAt: '2026-05-01T00:00:00.000Z',
modifiedAt: '2026-05-01T00:00:00.000Z',
messageCount: 1,
projectPath: '/repo',
workDir: '/repo',
workDirExists: true,
},
],
activeSessionId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
[historySessionId]: {
messages: [{ id: 'history-message', type: 'assistant_text', content: 'ready', timestamp: 1 }],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
render(<ChatInput variant="hero" />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: { value: 'new tab draft', selectionStart: 13 },
})
expect(input.value).toBe('new tab draft')
act(() => {
useTabStore.setState({ activeTabId: historySessionId })
})
await waitFor(() => {
expect(input.value).toBe('')
})
fireEvent.change(input, {
target: { value: 'history tab draft', selectionStart: 17 },
})
act(() => {
useTabStore.setState({ activeTabId: sessionId })
})
await waitFor(() => {
expect(input.value).toBe('new tab draft')
})
act(() => {
useTabStore.setState({ activeTabId: historySessionId })
})
await waitFor(() => {
expect(input.value).toBe('history tab draft')
})
})
it('shows branch and worktree launch controls for an empty active Git session', async () => {
useSessionStore.setState({
sessions: [{

View File

@ -48,6 +48,11 @@ type Attachment = {
quote?: string
}
type ComposerDraft = {
input: string
attachments: Attachment[]
}
type ChatInputProps = {
variant?: 'default' | 'hero'
compact?: boolean
@ -93,6 +98,21 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
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)
@ -145,6 +165,39 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
)
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])
@ -152,8 +205,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
useEffect(() => {
if (!composerPrefill) return
setInput(composerPrefill.text)
setAttachments(
setComposerInput(composerPrefill.text)
setComposerAttachments(
(composerPrefill.attachments ?? [])
.filter((attachment) => attachment.type === 'image' || attachment.data)
.map((attachment, index) => ({
@ -178,7 +231,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const cursor = composerPrefill.text.length
el?.setSelectionRange(cursor, cursor)
})
}, [composerPrefill])
}, [composerPrefill, setComposerAttachments, setComposerInput])
const refreshGitInfo = useCallback(() => {
if (!activeTabId) {
@ -204,7 +257,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
useEffect(() => {
if (!isMemberSession) return
setAttachments([])
setComposerAttachments([])
setPlusMenuOpen(false)
setSlashMenuOpen(false)
setFileSearchOpen(false)
@ -370,11 +423,11 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value
if (isMemberSession) {
setInput(value)
setComposerInput(value)
return
}
const cursorPos = event.target.selectionStart ?? value.length
setInput(value)
setComposerInput(value)
detectSlashTrigger(value, cursorPos)
detectAtTrigger(value, cursorPos)
}
@ -384,7 +437,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
if (!el) return
const cursorPos = el.selectionStart ?? input.length
const replacement = replaceSlashToken(input, cursorPos, command)
setInput(replacement.value)
setComposerInput(replacement.value)
setSlashMenuOpen(false)
requestAnimationFrame(() => {
el.focus()
@ -439,7 +492,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
if (pendingSlashUiAction?.type === 'panel') {
setLocalSlashPanel(pendingSlashUiAction.command as LocalSlashCommandName)
setInput('')
setComposerInput('')
setSlashMenuOpen(false)
setFileSearchOpen(false)
setPlusMenuOpen(false)
@ -449,7 +502,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
if (pendingSlashUiAction?.type === 'settings') {
useUIStore.getState().setPendingSettingsTab(pendingSlashUiAction.tab)
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
setInput('')
setComposerInput('')
setSlashMenuOpen(false)
setFileSearchOpen(false)
setPlusMenuOpen(false)
@ -530,8 +583,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
displayContent,
displayAttachments: visibleAttachmentPayload,
})
setInput('')
setAttachments([])
setComposerInput('')
setComposerAttachments([])
if (!isMemberSession) {
clearWorkspaceReferences(activeTabId!)
if (targetSessionId !== activeTabId) clearWorkspaceReferences(targetSessionId)
@ -631,7 +684,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
const reader = new FileReader()
reader.onload = () => {
setAttachments((prev) => [
setComposerAttachments((prev) => [
...prev,
{
id,
@ -659,7 +712,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const isImage = file.type.startsWith('image/')
const reader = new FileReader()
reader.onload = () => {
setAttachments((prev) => [
setComposerAttachments((prev) => [
...prev,
{
id,
@ -688,7 +741,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
}
const removeAttachment = (id: string) => {
setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
setComposerAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
if (activeTabId) removeWorkspaceReference(activeTabId, id)
}
@ -697,7 +750,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const el = textareaRef.current
const cursorPos = el?.selectionStart ?? input.length
const replacement = replaceSlashToken(input, cursorPos, '', { trailingSpace: false })
setInput(replacement.value)
setComposerInput(replacement.value)
setPlusMenuOpen(false)
setSlashFilter('')
setSlashMenuOpen(true)
@ -761,7 +814,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const tokenEnd = atCursorPos + 1 + atFilter.length
const newValue = `${input.slice(0, atCursorPos)}${replacement}${input.slice(tokenEnd)}`
const newCursorPos = atCursorPos + replacement.length
setInput(newValue)
setComposerInput(newValue)
setAtFilter(relativePath)
requestAnimationFrame(() => {
textareaRef.current?.focus()
@ -785,7 +838,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
name: referenceName,
})
}
setInput(newValue)
setComposerInput(newValue)
setFileSearchOpen(false)
setAtFilter('')
setAtCursorPos(-1)