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