fix: preserve unsent desktop prompts across tab switches

Desktop non-session tabs unmount the active chat composer, so component-local draft refs disappeared before users returned to the session. Move the draft into per-session chat state and save it at session-switch and unmount boundaries, then clear it after submit.

Constraint: Settings and terminal tabs replace ActiveSession in ContentRouter.
Rejected: Persist drafts in localStorage | would add unnecessary persistence migration surface for a runtime-only draft.
Confidence: high
Scope-risk: narrow
Directive: Keep composer draft state scoped per session; do not move it back into component-only refs without covering unmount and remount.
Tested: bun run test src/components/chat/ChatInput.test.tsx --run
Tested: bun run check:desktop
Tested: bun run verify
This commit is contained in:
程序员阿江(Relakkes) 2026-05-18 14:40:39 +08:00
parent f5ce69ba71
commit 6ff76171af
3 changed files with 75 additions and 12 deletions

View File

@ -299,6 +299,23 @@ describe('ChatInput file mentions', () => {
})
})
it('restores an unsent composer draft after the composer unmounts', async () => {
const { unmount } = render(<ChatInput compact />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: { value: 'keep this prompt while I inspect another tab', selectionStart: 43 },
})
expect(input.value).toBe('keep this prompt while I inspect another tab')
unmount()
render(<ChatInput compact />)
await waitFor(() => {
expect(screen.getByRole('textbox')).toHaveValue('keep this prompt while I inspect another tab')
})
})
it('shows branch and worktree launch controls for an empty active Git session', async () => {
useSessionStore.setState({
sessions: [{

View File

@ -44,11 +44,6 @@ type GitInfo = SessionGitInfo
type Attachment = ComposerAttachment
type ComposerDraft = {
input: string
attachments: Attachment[]
}
type ChatInputProps = {
variant?: 'default' | 'hero'
compact?: boolean
@ -96,7 +91,6 @@ 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)
@ -136,6 +130,18 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const addWorkspaceReference = useWorkspaceChatContextStore((s) => s.addReference)
const removeWorkspaceReference = useWorkspaceChatContextStore((s) => s.removeReference)
const clearWorkspaceReferences = useWorkspaceChatContextStore((s) => s.clearReferences)
const saveComposerDraft = useCallback((sessionId: string) => {
const draft = {
input: inputRef.current,
attachments: attachmentsRef.current,
}
const chatStore = useChatStore.getState()
if (draft.input.length === 0 && draft.attachments.length === 0) {
chatStore.clearComposerDraft(sessionId)
return
}
chatStore.setComposerDraft(sessionId, draft)
}, [])
const isMemberSession = !!memberInfo
const isActive = chatState !== 'idle'
@ -178,13 +184,10 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
if (previousActiveTabId === activeTabId) return
if (previousActiveTabId) {
composerDraftsRef.current[previousActiveTabId] = {
input: inputRef.current,
attachments: attachmentsRef.current,
}
saveComposerDraft(previousActiveTabId)
}
const nextDraft = activeTabId ? composerDraftsRef.current[activeTabId] : undefined
const nextDraft = activeTabId ? useChatStore.getState().sessions[activeTabId]?.composerDraft : undefined
setComposerInput(nextDraft?.input ?? '')
setComposerAttachments(nextDraft?.attachments ?? [])
setPlusMenuOpen(false)
@ -195,7 +198,14 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
setAtFilter('')
setAtCursorPos(-1)
previousActiveTabIdRef.current = activeTabId
}, [activeTabId, setComposerAttachments, setComposerInput])
}, [activeTabId, saveComposerDraft, setComposerAttachments, setComposerInput])
useEffect(() => {
return () => {
const currentActiveTabId = previousActiveTabIdRef.current
if (currentActiveTabId) saveComposerDraft(currentActiveTabId)
}
}, [saveComposerDraft])
useEffect(() => {
textareaRef.current?.focus()
@ -582,6 +592,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
})
setComposerInput('')
setComposerAttachments([])
useChatStore.getState().clearComposerDraft(activeTabId!)
if (targetSessionId !== activeTabId) useChatStore.getState().clearComposerDraft(targetSessionId)
if (!isMemberSession) {
clearWorkspaceReferences(activeTabId!)
if (targetSessionId !== activeTabId) clearWorkspaceReferences(targetSessionId)

View File

@ -10,6 +10,7 @@ import { randomSpinnerVerb } from '../config/spinnerVerbs'
import { notifyDesktop } from '../lib/desktopNotifications'
import { deriveSessionTitle, isPlaceholderSessionTitle } from '../lib/sessionTitle'
import { AGENT_LIFECYCLE_TYPES } from '../types/team'
import type { ComposerAttachment } from '../lib/composerAttachments'
import type { MessageEntry } from '../types/session'
import type { PermissionMode } from '../types/settings'
import type { RuntimeSelection } from '../types/runtime'
@ -32,6 +33,11 @@ import type {
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
export type ComposerDraftState = {
input: string
attachments: ComposerAttachment[]
}
export type PerSessionState = {
messages: UIMessage[]
chatState: ChatState
@ -65,6 +71,7 @@ export type PerSessionState = {
attachments?: UIAttachment[]
nonce: number
} | null
composerDraft?: ComposerDraftState | null
}
const DEFAULT_SESSION_STATE: PerSessionState = {
@ -87,6 +94,7 @@ const DEFAULT_SESSION_STATE: PerSessionState = {
activeGoal: null,
elapsedTimer: null,
composerPrefill: null,
composerDraft: null,
}
function createDefaultSessionState(): PerSessionState {
@ -128,6 +136,8 @@ type ChatStore = {
sessionId: string,
prefill: { text: string; attachments?: UIAttachment[] },
) => void
setComposerDraft: (sessionId: string, draft: ComposerDraftState) => void
clearComposerDraft: (sessionId: string) => void
clearMessages: (sessionId: string) => void
handleServerMessage: (sessionId: string, msg: ServerMessage) => void
}
@ -403,6 +413,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
connectionState: 'connecting',
messages: existing?.messages ?? [],
activeGoal: existing?.activeGoal ?? null,
composerDraft: existing?.composerDraft ?? null,
},
},
}))
@ -738,6 +749,29 @@ export const useChatStore = create<ChatStore>((set, get) => ({
}))
},
setComposerDraft: (sessionId, draft) => {
set((state) => {
const session = state.sessions[sessionId] ?? createDefaultSessionState()
return {
sessions: {
...state.sessions,
[sessionId]: {
...session,
composerDraft: draft,
},
},
}
})
},
clearComposerDraft: (sessionId) => {
set((state) => ({
sessions: updateSessionIn(state.sessions, sessionId, () => ({
composerDraft: null,
})),
}))
},
clearMessages: (sessionId) => {
clearPendingTaskToolUseIds(sessionId)
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ messages: [], activeGoal: null, streamingText: '', chatState: 'idle' })) }))