From 9b540554cce0742da0067295c28138f051185c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 12 May 2026 19:40:21 +0800 Subject: [PATCH] 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 --- .../src/components/chat/ChatInput.test.tsx | 113 ++++++++++++++++++ desktop/src/components/chat/ChatInput.tsx | 87 +++++++++++--- 2 files changed, 183 insertions(+), 17 deletions(-) diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index 6ffa12cd..0603d952 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -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() + + 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: [{ diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 7a203caa..d583f294 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -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(null) const fileSearchRef = useRef(null) const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([]) + const composerDraftsRef = useRef>({}) + const previousActiveTabIdRef = useRef(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) => { 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)