mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-25 14:53:32 +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 { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import '@testing-library/jest-dom'
|
import '@testing-library/jest-dom'
|
||||||
|
import { act } from 'react'
|
||||||
|
|
||||||
const viewportMocks = vi.hoisted(() => ({
|
const viewportMocks = vi.hoisted(() => ({
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
@ -168,6 +169,118 @@ describe('ChatInput file mentions', () => {
|
|||||||
mocks.getSlashCommands.mockResolvedValue({ commands: [] })
|
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 () => {
|
it('shows branch and worktree launch controls for an empty active Git session', async () => {
|
||||||
useSessionStore.setState({
|
useSessionStore.setState({
|
||||||
sessions: [{
|
sessions: [{
|
||||||
|
|||||||
@ -48,6 +48,11 @@ type Attachment = {
|
|||||||
quote?: string
|
quote?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ComposerDraft = {
|
||||||
|
input: string
|
||||||
|
attachments: Attachment[]
|
||||||
|
}
|
||||||
|
|
||||||
type ChatInputProps = {
|
type ChatInputProps = {
|
||||||
variant?: 'default' | 'hero'
|
variant?: 'default' | 'hero'
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
@ -93,6 +98,21 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
const slashMenuRef = useRef<HTMLDivElement>(null)
|
const slashMenuRef = useRef<HTMLDivElement>(null)
|
||||||
const fileSearchRef = useRef<FileSearchMenuHandle>(null)
|
const fileSearchRef = useRef<FileSearchMenuHandle>(null)
|
||||||
const slashItemRefs = useRef<(HTMLButtonElement | 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 { sendMessage, stopGeneration } = useChatStore()
|
||||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||||
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
|
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
|
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(() => {
|
useEffect(() => {
|
||||||
textareaRef.current?.focus()
|
textareaRef.current?.focus()
|
||||||
}, [isActive])
|
}, [isActive])
|
||||||
@ -152,8 +205,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!composerPrefill) return
|
if (!composerPrefill) return
|
||||||
|
|
||||||
setInput(composerPrefill.text)
|
setComposerInput(composerPrefill.text)
|
||||||
setAttachments(
|
setComposerAttachments(
|
||||||
(composerPrefill.attachments ?? [])
|
(composerPrefill.attachments ?? [])
|
||||||
.filter((attachment) => attachment.type === 'image' || attachment.data)
|
.filter((attachment) => attachment.type === 'image' || attachment.data)
|
||||||
.map((attachment, index) => ({
|
.map((attachment, index) => ({
|
||||||
@ -178,7 +231,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
const cursor = composerPrefill.text.length
|
const cursor = composerPrefill.text.length
|
||||||
el?.setSelectionRange(cursor, cursor)
|
el?.setSelectionRange(cursor, cursor)
|
||||||
})
|
})
|
||||||
}, [composerPrefill])
|
}, [composerPrefill, setComposerAttachments, setComposerInput])
|
||||||
|
|
||||||
const refreshGitInfo = useCallback(() => {
|
const refreshGitInfo = useCallback(() => {
|
||||||
if (!activeTabId) {
|
if (!activeTabId) {
|
||||||
@ -204,7 +257,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMemberSession) return
|
if (!isMemberSession) return
|
||||||
setAttachments([])
|
setComposerAttachments([])
|
||||||
setPlusMenuOpen(false)
|
setPlusMenuOpen(false)
|
||||||
setSlashMenuOpen(false)
|
setSlashMenuOpen(false)
|
||||||
setFileSearchOpen(false)
|
setFileSearchOpen(false)
|
||||||
@ -370,11 +423,11 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const value = event.target.value
|
const value = event.target.value
|
||||||
if (isMemberSession) {
|
if (isMemberSession) {
|
||||||
setInput(value)
|
setComposerInput(value)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const cursorPos = event.target.selectionStart ?? value.length
|
const cursorPos = event.target.selectionStart ?? value.length
|
||||||
setInput(value)
|
setComposerInput(value)
|
||||||
detectSlashTrigger(value, cursorPos)
|
detectSlashTrigger(value, cursorPos)
|
||||||
detectAtTrigger(value, cursorPos)
|
detectAtTrigger(value, cursorPos)
|
||||||
}
|
}
|
||||||
@ -384,7 +437,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
if (!el) return
|
if (!el) return
|
||||||
const cursorPos = el.selectionStart ?? input.length
|
const cursorPos = el.selectionStart ?? input.length
|
||||||
const replacement = replaceSlashToken(input, cursorPos, command)
|
const replacement = replaceSlashToken(input, cursorPos, command)
|
||||||
setInput(replacement.value)
|
setComposerInput(replacement.value)
|
||||||
setSlashMenuOpen(false)
|
setSlashMenuOpen(false)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
el.focus()
|
el.focus()
|
||||||
@ -439,7 +492,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
|
|
||||||
if (pendingSlashUiAction?.type === 'panel') {
|
if (pendingSlashUiAction?.type === 'panel') {
|
||||||
setLocalSlashPanel(pendingSlashUiAction.command as LocalSlashCommandName)
|
setLocalSlashPanel(pendingSlashUiAction.command as LocalSlashCommandName)
|
||||||
setInput('')
|
setComposerInput('')
|
||||||
setSlashMenuOpen(false)
|
setSlashMenuOpen(false)
|
||||||
setFileSearchOpen(false)
|
setFileSearchOpen(false)
|
||||||
setPlusMenuOpen(false)
|
setPlusMenuOpen(false)
|
||||||
@ -449,7 +502,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
if (pendingSlashUiAction?.type === 'settings') {
|
if (pendingSlashUiAction?.type === 'settings') {
|
||||||
useUIStore.getState().setPendingSettingsTab(pendingSlashUiAction.tab)
|
useUIStore.getState().setPendingSettingsTab(pendingSlashUiAction.tab)
|
||||||
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
|
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
|
||||||
setInput('')
|
setComposerInput('')
|
||||||
setSlashMenuOpen(false)
|
setSlashMenuOpen(false)
|
||||||
setFileSearchOpen(false)
|
setFileSearchOpen(false)
|
||||||
setPlusMenuOpen(false)
|
setPlusMenuOpen(false)
|
||||||
@ -530,8 +583,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
displayContent,
|
displayContent,
|
||||||
displayAttachments: visibleAttachmentPayload,
|
displayAttachments: visibleAttachmentPayload,
|
||||||
})
|
})
|
||||||
setInput('')
|
setComposerInput('')
|
||||||
setAttachments([])
|
setComposerAttachments([])
|
||||||
if (!isMemberSession) {
|
if (!isMemberSession) {
|
||||||
clearWorkspaceReferences(activeTabId!)
|
clearWorkspaceReferences(activeTabId!)
|
||||||
if (targetSessionId !== activeTabId) clearWorkspaceReferences(targetSessionId)
|
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 id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
setAttachments((prev) => [
|
setComposerAttachments((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@ -659,7 +712,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
const isImage = file.type.startsWith('image/')
|
const isImage = file.type.startsWith('image/')
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
setAttachments((prev) => [
|
setComposerAttachments((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@ -688,7 +741,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removeAttachment = (id: string) => {
|
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)
|
if (activeTabId) removeWorkspaceReference(activeTabId, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -697,7 +750,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
const el = textareaRef.current
|
const el = textareaRef.current
|
||||||
const cursorPos = el?.selectionStart ?? input.length
|
const cursorPos = el?.selectionStart ?? input.length
|
||||||
const replacement = replaceSlashToken(input, cursorPos, '', { trailingSpace: false })
|
const replacement = replaceSlashToken(input, cursorPos, '', { trailingSpace: false })
|
||||||
setInput(replacement.value)
|
setComposerInput(replacement.value)
|
||||||
setPlusMenuOpen(false)
|
setPlusMenuOpen(false)
|
||||||
setSlashFilter('')
|
setSlashFilter('')
|
||||||
setSlashMenuOpen(true)
|
setSlashMenuOpen(true)
|
||||||
@ -761,7 +814,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
const tokenEnd = atCursorPos + 1 + atFilter.length
|
const tokenEnd = atCursorPos + 1 + atFilter.length
|
||||||
const newValue = `${input.slice(0, atCursorPos)}${replacement}${input.slice(tokenEnd)}`
|
const newValue = `${input.slice(0, atCursorPos)}${replacement}${input.slice(tokenEnd)}`
|
||||||
const newCursorPos = atCursorPos + replacement.length
|
const newCursorPos = atCursorPos + replacement.length
|
||||||
setInput(newValue)
|
setComposerInput(newValue)
|
||||||
setAtFilter(relativePath)
|
setAtFilter(relativePath)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
textareaRef.current?.focus()
|
textareaRef.current?.focus()
|
||||||
@ -785,7 +838,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
name: referenceName,
|
name: referenceName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
setInput(newValue)
|
setComposerInput(newValue)
|
||||||
setFileSearchOpen(false)
|
setFileSearchOpen(false)
|
||||||
setAtFilter('')
|
setAtFilter('')
|
||||||
setAtCursorPos(-1)
|
setAtCursorPos(-1)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user