diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx
index 6ffa12cd..0603d952 100644
--- a/desktop/src/components/chat/ChatInput.test.tsx
+++ b/desktop/src/components/chat/ChatInput.test.tsx
@@ -1,6 +1,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
+import { act } from 'react'
const viewportMocks = vi.hoisted(() => ({
isMobile: false,
@@ -168,6 +169,118 @@ describe('ChatInput file mentions', () => {
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()
+
+ 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 () => {
useSessionStore.setState({
sessions: [{
diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx
index 7a203caa..d583f294 100644
--- a/desktop/src/components/chat/ChatInput.tsx
+++ b/desktop/src/components/chat/ChatInput.tsx
@@ -48,6 +48,11 @@ type Attachment = {
quote?: string
}
+type ComposerDraft = {
+ input: string
+ attachments: Attachment[]
+}
+
type ChatInputProps = {
variant?: 'default' | 'hero'
compact?: boolean
@@ -93,6 +98,21 @@ 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)
+ 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 activeTabId = useTabStore((s) => s.activeTabId)
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
+ 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(() => {
textareaRef.current?.focus()
}, [isActive])
@@ -152,8 +205,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
useEffect(() => {
if (!composerPrefill) return
- setInput(composerPrefill.text)
- setAttachments(
+ setComposerInput(composerPrefill.text)
+ setComposerAttachments(
(composerPrefill.attachments ?? [])
.filter((attachment) => attachment.type === 'image' || attachment.data)
.map((attachment, index) => ({
@@ -178,7 +231,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const cursor = composerPrefill.text.length
el?.setSelectionRange(cursor, cursor)
})
- }, [composerPrefill])
+ }, [composerPrefill, setComposerAttachments, setComposerInput])
const refreshGitInfo = useCallback(() => {
if (!activeTabId) {
@@ -204,7 +257,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
useEffect(() => {
if (!isMemberSession) return
- setAttachments([])
+ setComposerAttachments([])
setPlusMenuOpen(false)
setSlashMenuOpen(false)
setFileSearchOpen(false)
@@ -370,11 +423,11 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const handleInputChange = (event: React.ChangeEvent) => {
const value = event.target.value
if (isMemberSession) {
- setInput(value)
+ setComposerInput(value)
return
}
const cursorPos = event.target.selectionStart ?? value.length
- setInput(value)
+ setComposerInput(value)
detectSlashTrigger(value, cursorPos)
detectAtTrigger(value, cursorPos)
}
@@ -384,7 +437,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
if (!el) return
const cursorPos = el.selectionStart ?? input.length
const replacement = replaceSlashToken(input, cursorPos, command)
- setInput(replacement.value)
+ setComposerInput(replacement.value)
setSlashMenuOpen(false)
requestAnimationFrame(() => {
el.focus()
@@ -439,7 +492,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
if (pendingSlashUiAction?.type === 'panel') {
setLocalSlashPanel(pendingSlashUiAction.command as LocalSlashCommandName)
- setInput('')
+ setComposerInput('')
setSlashMenuOpen(false)
setFileSearchOpen(false)
setPlusMenuOpen(false)
@@ -449,7 +502,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
if (pendingSlashUiAction?.type === 'settings') {
useUIStore.getState().setPendingSettingsTab(pendingSlashUiAction.tab)
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
- setInput('')
+ setComposerInput('')
setSlashMenuOpen(false)
setFileSearchOpen(false)
setPlusMenuOpen(false)
@@ -530,8 +583,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
displayContent,
displayAttachments: visibleAttachmentPayload,
})
- setInput('')
- setAttachments([])
+ setComposerInput('')
+ setComposerAttachments([])
if (!isMemberSession) {
clearWorkspaceReferences(activeTabId!)
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 reader = new FileReader()
reader.onload = () => {
- setAttachments((prev) => [
+ setComposerAttachments((prev) => [
...prev,
{
id,
@@ -659,7 +712,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const isImage = file.type.startsWith('image/')
const reader = new FileReader()
reader.onload = () => {
- setAttachments((prev) => [
+ setComposerAttachments((prev) => [
...prev,
{
id,
@@ -688,7 +741,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
}
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)
}
@@ -697,7 +750,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const el = textareaRef.current
const cursorPos = el?.selectionStart ?? input.length
const replacement = replaceSlashToken(input, cursorPos, '', { trailingSpace: false })
- setInput(replacement.value)
+ setComposerInput(replacement.value)
setPlusMenuOpen(false)
setSlashFilter('')
setSlashMenuOpen(true)
@@ -761,7 +814,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const tokenEnd = atCursorPos + 1 + atFilter.length
const newValue = `${input.slice(0, atCursorPos)}${replacement}${input.slice(tokenEnd)}`
const newCursorPos = atCursorPos + replacement.length
- setInput(newValue)
+ setComposerInput(newValue)
setAtFilter(relativePath)
requestAnimationFrame(() => {
textareaRef.current?.focus()
@@ -785,7 +838,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
name: referenceName,
})
}
- setInput(newValue)
+ setComposerInput(newValue)
setFileSearchOpen(false)
setAtFilter('')
setAtCursorPos(-1)