mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
d41d5a87f0
commit
9b540554cc
@ -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: [{
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user