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' })) }))