diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index 6e65185a..05c42d26 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -299,6 +299,23 @@ describe('ChatInput file mentions', () => { }) }) + it('restores an unsent composer draft after the composer unmounts', async () => { + const { unmount } = render() + + 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() + + 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: [{ diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index e449c83f..0b1d9f2c 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -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(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) @@ -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) diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 51f87c90..a5a30765 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -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((set, get) => ({ connectionState: 'connecting', messages: existing?.messages ?? [], activeGoal: existing?.activeGoal ?? null, + composerDraft: existing?.composerDraft ?? null, }, }, })) @@ -738,6 +749,29 @@ export const useChatStore = create((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' })) }))