From 3e459bd2e58143a0c74d8157c2b0e2ff9d4df379 Mon Sep 17 00:00:00 2001 From: Justin Choo Date: Sun, 1 Mar 2026 17:31:58 -0800 Subject: [PATCH] fix(agent-chat): preserve draft text when pane splits Pane splits restructure the React tree, causing ChatComposer to unmount/remount and lose its useState text. Add a module-level Map draft store that survives React lifecycle. ChatComposer reads from the store on mount and syncs on every keystroke. Drafts are cleaned up when panes or tabs are closed. Co-Authored-By: Claude Opus 4.6 --- src/components/agent-chat/AgentChatView.tsx | 1 + src/components/agent-chat/ChatComposer.tsx | 51 ++++++++++++--- src/components/panes/PaneContainer.tsx | 4 +- src/lib/draft-store.ts | 25 ++++++++ src/store/tabsSlice.ts | 4 +- .../agent-chat/ChatComposer.test.tsx | 63 +++++++++++++++++++ test/unit/client/lib/draft-store.test.ts | 38 +++++++++++ 7 files changed, 175 insertions(+), 11 deletions(-) create mode 100644 src/lib/draft-store.ts create mode 100644 test/unit/client/lib/draft-store.test.ts diff --git a/src/components/agent-chat/AgentChatView.tsx b/src/components/agent-chat/AgentChatView.tsx index 926ee031..c1b36c3c 100644 --- a/src/components/agent-chat/AgentChatView.tsx +++ b/src/components/agent-chat/AgentChatView.tsx @@ -614,6 +614,7 @@ export default function AgentChatView({ tabId, paneId, paneContent, hidden }: Ag {/* Composer */} void } interface ChatComposerProps { + paneId?: string onSend: (text: string) => void onInterrupt: () => void disabled?: boolean @@ -15,10 +17,28 @@ interface ChatComposerProps { autoFocus?: boolean } -const ChatComposer = forwardRef(function ChatComposer({ onSend, onInterrupt, disabled, isRunning, placeholder, autoFocus }, ref) { - const [text, setText] = useState('') +const ChatComposer = forwardRef(function ChatComposer({ paneId, onSend, onInterrupt, disabled, isRunning, placeholder, autoFocus }, ref) { + const [text, setText] = useState(() => (paneId ? getDraft(paneId) : '')) const textareaRef = useRef(null) + const resizeTextarea = useCallback((el: HTMLTextAreaElement) => { + el.style.height = 'auto' + el.style.height = `${Math.min(el.scrollHeight, 200)}px` + }, []) + + // Resync text state and textarea height if paneId changes (component reused for a different pane) + const prevPaneIdRef = useRef(paneId) + useEffect(() => { + if (paneId !== prevPaneIdRef.current) { + prevPaneIdRef.current = paneId + setText(paneId ? getDraft(paneId) : '') + // Schedule resize after React paints the new text + requestAnimationFrame(() => { + if (textareaRef.current) resizeTextarea(textareaRef.current) + }) + } + }, [paneId, resizeTextarea]) + useImperativeHandle(ref, () => ({ focus: () => textareaRef.current?.focus(), }), []) @@ -40,16 +60,23 @@ const ChatComposer = forwardRef(function } }, []) + // Sync draft store on every text change + const handleTextChange = useCallback((value: string) => { + setText(value) + if (paneId) setDraft(paneId, value) + }, [paneId]) + const handleSend = useCallback(() => { const trimmed = text.trim() if (!trimmed) return onSend(trimmed) setText('') + if (paneId) clearDraft(paneId) // Reset height if (textareaRef.current) { textareaRef.current.style.height = 'auto' } - }, [text, onSend]) + }, [text, onSend, paneId]) const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -63,10 +90,16 @@ const ChatComposer = forwardRef(function }, [handleSend, isRunning, onInterrupt]) const handleInput = useCallback(() => { - const el = textareaRef.current - if (!el) return - el.style.height = 'auto' - el.style.height = `${Math.min(el.scrollHeight, 200)}px` + if (textareaRef.current) resizeTextarea(textareaRef.current) + }, [resizeTextarea]) + + // Restore textarea height when mounting with a saved draft + useEffect(() => { + if (text && textareaRef.current) { + resizeTextarea(textareaRef.current) + } + // Only run on mount — text is intentionally excluded + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) return ( @@ -75,7 +108,7 @@ const ChatComposer = forwardRef(function