From 08564f5db7cd408b13bf4c149809aa0994233a2e Mon Sep 17 00:00:00 2001 From: dolphin Date: Fri, 21 Aug 2026 17:30:24 +0800 Subject: [PATCH] fix(chat): give each conversation its own composer draft Half-typed text followed the user from one conversation to the next. ChatView held the draft in a local useState, and ChatView is not remounted when the route's conversationId changes -- it is also KeepAlive-cached -- so the same state served every conversation. Move the draft into a module-level Map keyed by conversation id, read through a small useConversationDraft hook. Memory only, no localStorage/sessionStorage: a draft is a transient in-tab convenience, a refresh is the user's own "start over", and restoring text they thought they had discarded is worse than losing it. Plain React rather than a store atom: Recoil is frozen to new atoms (ledger #5), and this is neither server state nor app-wide state -- one component reads and writes it. Empty drafts are dropped from the Map so it does not accumulate an entry per conversation ever opened. The send handler's useCallback gains setInputText as a dependency: the setter is memoized per conversation id, so a stale closure would clear the PREVIOUS conversation's draft after a switch. --- .../client/src/components/Chat/ChatView.tsx | 8 ++- .../client/src/store/chatDraft.test.ts | 69 +++++++++++++++++++ src/frontend/client/src/store/chatDraft.ts | 56 +++++++++++++++ 3 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 src/frontend/client/src/store/chatDraft.test.ts create mode 100644 src/frontend/client/src/store/chatDraft.ts 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]; +}