diff --git a/src/frontend/client/src/components/Chat/ChatView.tsx b/src/frontend/client/src/components/Chat/ChatView.tsx index d3ad6b6b3..369bf03cf 100644 --- a/src/frontend/client/src/components/Chat/ChatView.tsx +++ b/src/frontend/client/src/components/Chat/ChatView.tsx @@ -16,6 +16,7 @@ import { type ArtifactFile, toUploadedArtifacts } from '~/components/Linsight/Ar import { useLinsightManager } from '~/hooks/useLinsightManager'; import { userStopLinsightEvent } from '~/api/linsight'; import { SopStatus, taskModeState } from '~/store/linsight'; +import { useConversationDraft } from '~/store/chatDraft'; import { findPendingUserInput, splitSessionPseudoTask } from '~/components/Linsight/Execution/stepUtils'; import type { ExecStepEventData } from '~/components/Linsight/Execution/stepUtils'; import { useCitationReferencePanel } from '~/components/Chat/Messages/Content/useCitationReferencePanel'; @@ -68,7 +69,10 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index? const location = useLocation(); const conversationId = (cid ?? id) || 'new'; - const [inputText, setInputText] = useState(''); + // Draft text belongs to the conversation, not to this component: ChatView is + // not remounted when `conversationId` changes (and is KeepAlive-cached), so a + // local useState leaked half-typed text into the next conversation opened. + const [inputText, setInputText] = useConversationDraft(conversationId); // F035: task mode is a toggle on the daily welcome page — no route jump. // The route stays `/c`; only submitting in task mode navigates to /linsight. @@ -391,7 +395,7 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index? sendMessage(text, files); setInputText(''); - }, [taskMode, canUseTaskMode, sendMessage]); + }, [taskMode, canUseTaskMode, sendMessage, setInputText]); const isNew = conversationId === 'new'; const hasMessages = messages.length > 0; diff --git a/src/frontend/client/src/store/chatDraft.test.ts b/src/frontend/client/src/store/chatDraft.test.ts new file mode 100644 index 000000000..74f00d667 --- /dev/null +++ b/src/frontend/client/src/store/chatDraft.test.ts @@ -0,0 +1,69 @@ +import { act, renderHook } from '@testing-library/react'; +import { useConversationDraft } from './chatDraft'; + +// The composer's draft must belong to the conversation, not to the component: +// ChatView is NOT remounted when the route's conversationId changes, so the +// hook is what keeps one conversation's half-typed text out of another. +// Each test uses its own ids — the backing store is module-level by design. +describe('store/chatDraft — useConversationDraft', () => { + it('holds the text typed for the current conversation', () => { + const { result } = renderHook(() => useConversationDraft('hold-a')); + + expect(result.current[0]).toBe(''); + act(() => result.current[1]('half a sentence')); + expect(result.current[0]).toBe('half a sentence'); + }); + + it('does not carry a draft into the next conversation opened', () => { + const { result, rerender } = renderHook(({ id }) => useConversationDraft(id), { + initialProps: { id: 'carry-a' }, + }); + + act(() => result.current[1]('meant for A')); + rerender({ id: 'carry-b' }); + + expect(result.current[0]).toBe(''); + }); + + it('restores the draft belonging to each conversation when the user switches back', () => { + const { result, rerender } = renderHook(({ id }) => useConversationDraft(id), { + initialProps: { id: 'back-a' }, + }); + + act(() => result.current[1]('meant for A')); + rerender({ id: 'back-b' }); + act(() => result.current[1]('meant for B')); + + rerender({ id: 'back-a' }); + expect(result.current[0]).toBe('meant for A'); + + rerender({ id: 'back-b' }); + expect(result.current[0]).toBe('meant for B'); + }); + + it('forgets a draft once it is cleared (what sending does)', () => { + const { result, rerender } = renderHook(({ id }) => useConversationDraft(id), { + initialProps: { id: 'clear-a' }, + }); + + act(() => result.current[1]('about to send')); + act(() => result.current[1]('')); // send handler clears the composer + rerender({ id: 'clear-b' }); + rerender({ id: 'clear-a' }); + + expect(result.current[0]).toBe(''); + }); + + it('gives the fresh-chat draft its own slot, separate from real conversations', () => { + const { result, rerender } = renderHook(({ id }) => useConversationDraft(id), { + initialProps: { id: 'new' }, + }); + + act(() => result.current[1]('unsent first message')); + rerender({ id: 'promoted-id' }); + expect(result.current[0]).toBe(''); + + rerender({ id: 'new' }); + expect(result.current[0]).toBe('unsent first message'); + }); +}); diff --git a/src/frontend/client/src/store/chatDraft.ts b/src/frontend/client/src/store/chatDraft.ts new file mode 100644 index 000000000..cdba4dea5 --- /dev/null +++ b/src/frontend/client/src/store/chatDraft.ts @@ -0,0 +1,56 @@ +/** + * Per-conversation draft text for the daily-chat composer. + * + * The composer used to hold its draft in ChatView's local `useState`. ChatView + * is NOT remounted when the route's `conversationId` changes (and it is also + * KeepAlive-cached), so a half-typed message leaked into whichever conversation + * the user switched to next. Keying the draft by conversation id makes each + * conversation own its own unsent text. + * + * Backed by a plain module-level Map rather than a global store: Recoil is + * frozen (no new atoms, ledger #5) and this is neither server state nor + * app-wide state — it is one component's scratch text, read and written by a + * single consumer. Deliberately memory-only, no localStorage / sessionStorage: + * a draft is a transient in-tab convenience, a page refresh is the user's own + * "start over", and restoring text they thought they had discarded is worse + * than losing it. Purely client UI state — never sent to the backend, issues no + * HTTP (constitution C7). + */ +import { useCallback, useRef, useState } from 'react'; + +/** Unsent composer text, keyed by conversation id ('new' for the landing page). */ +const drafts = new Map(); + +/** + * Draft text for one conversation. + * + * @param conversationId Route conversation id; 'new' while composing the first + * message (the fresh conversation gets its own empty + * draft once the backend promotes it to a real id). + * @returns `[draft, setDraft]` with the same signature as `useState`. + */ +export function useConversationDraft(conversationId: string): [string, (val: string) => void] { + const [draft, setDraft] = useState(() => drafts.get(conversationId) ?? ''); + const shownIdRef = useRef(conversationId); + + // Switching conversation swaps the draft in place. Setting state during + // render (React's documented "adjust state when a prop changes" pattern) so + // the textarea never paints one frame of the previous conversation's text. + if (shownIdRef.current !== conversationId) { + shownIdRef.current = conversationId; + setDraft(drafts.get(conversationId) ?? ''); + } + + const setConversationDraft = useCallback( + (val: string) => { + // Drop empty drafts so the map does not accumulate one entry per + // conversation ever opened in this tab. + if (val) drafts.set(conversationId, val); + else drafts.delete(conversationId); + setDraft(val); + }, + [conversationId], + ); + + return [draft, setConversationDraft]; +}