diff --git a/apps/web/src/domains/chat/chat-page.tsx b/apps/web/src/domains/chat/chat-page.tsx index 8fb3bf3afd1..0fd7fc4d96d 100644 --- a/apps/web/src/domains/chat/chat-page.tsx +++ b/apps/web/src/domains/chat/chat-page.tsx @@ -70,6 +70,7 @@ import { useSendMessage } from "@/domains/chat/hooks/use-send-message.js"; import { useInteractionActions } from "@/domains/chat/hooks/use-interaction-actions.js"; import { useEventStream } from "@/domains/chat/hooks/use-event-stream.js"; import { useActiveAppPinSync } from "@/domains/chat/hooks/use-active-app-pin-sync.js"; +import { useDraftInput } from "@/domains/chat/components/chat-composer/use-draft-input.js"; import { createWebSyncRouter } from "@/lib/sync/web-sync-router.js"; import { fetchAssistantIdentity } from "@/assistant/identity.js"; @@ -127,7 +128,6 @@ export function ChatPage() { // Local state // ------------------------------------------------------------------------- const [messages, setMessages] = useState([]); - const [input, setInput] = useState(""); const [error, setError] = useState(null); const [isLoadingHistory, setIsLoadingHistory] = useState(false); const [compactionCircuitOpenUntil, setCompactionCircuitOpenUntil] = useState(null); @@ -250,7 +250,6 @@ export function ChatPage() { const loadEpochRef = useRef(0); const pendingInitialMessageRef = useRef<{ conversationKey: string; content: string } | null>(null); const expandedToolCallIdsRef = useRef>(new Set()); - const draftsRef = useRef>(new Map()); const conversationCacheRef = useRef>(new Map()); const contextWindowUsageByConversationRef = useRef>(new Map()); const refreshSettleRef = useRef(null); @@ -289,6 +288,18 @@ export function ChatPage() { status: diskPressure.status, }); + // ------------------------------------------------------------------------- + // Draft input — owns composer `input` state and per-conversation draft + // persistence to localStorage. Replaces the old manual `draftsRef` that was + // threaded through useConversationLoader → useConversationHistory. + // ------------------------------------------------------------------------- + const { input, setInput, saveDraft, clearDraft } = useDraftInput({ + assistantId, + activeConversationKey, + draftKeyResolutionRef, + onDraftRestored: setRestoredDraftConversationKey, + }); + // ------------------------------------------------------------------------- // Attachments // ------------------------------------------------------------------------- @@ -375,8 +386,6 @@ export function ChatPage() { previousConversationKeyRef, onboardingDraftConversationKeyRef, activeConversationKeyRef, - inputRef, - draftsRef, messagesRef, contextWindowUsageByConversationRef, dismissedSurfaceIdsRef, @@ -404,10 +413,8 @@ export function ChatPage() { setContextWindowUsage, setSuggestion, setCompactionCircuitOpenUntil, - setInput, resetChatAttachments, syncNeedsNewBubbleFromMessages, - onDraftRestored: setRestoredDraftConversationKey, shouldSuppressGenericChatErrorNotice, }); @@ -1067,6 +1074,8 @@ export function ChatPage() { editingConversationKey, restoredDraftConversationKey, setRestoredDraftConversationKey, + saveDraft, + clearDraft, avatar: { avatarComponents: avatar.components, avatarTraits: avatar.traits, @@ -1216,7 +1225,6 @@ export function ChatPage() { assistantIdRef, streamContextRef, expandedToolCallIdsRef, - draftsRef, conversationCacheRef, dismissedSurfaceIdsRef, isLoadingOlderRef, diff --git a/apps/web/src/domains/chat/components/chat-composer/use-draft-input-test-helpers.ts b/apps/web/src/domains/chat/components/chat-composer/use-draft-input-test-helpers.ts new file mode 100644 index 00000000000..ec9956e6d86 --- /dev/null +++ b/apps/web/src/domains/chat/components/chat-composer/use-draft-input-test-helpers.ts @@ -0,0 +1,46 @@ +/** + * Exposes the internal localStorage helpers from useDraftInput for unit + * testing. These are not part of the public API. + */ + +const STORAGE_KEY_PREFIX = "vellum:chatDrafts:"; + +function storageKey(assistantId: string): string { + return `${STORAGE_KEY_PREFIX}${assistantId}`; +} + +export function loadDraftsForTest(assistantId: string): Map { + try { + const raw = window.localStorage.getItem(storageKey(assistantId)); + if (!raw) return new Map(); + const parsed: unknown = JSON.parse(raw); + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) + ) { + return new Map(); + } + return new Map( + Object.entries(parsed as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + } catch { + return new Map(); + } +} + +export function persistDraftsForTest( + assistantId: string, + drafts: Map, +): void { + try { + window.localStorage.setItem( + storageKey(assistantId), + JSON.stringify(Object.fromEntries(drafts)), + ); + } catch { + // noop + } +} diff --git a/apps/web/src/domains/chat/components/chat-composer/use-draft-input.test.ts b/apps/web/src/domains/chat/components/chat-composer/use-draft-input.test.ts new file mode 100644 index 00000000000..b5e33c69e1d --- /dev/null +++ b/apps/web/src/domains/chat/components/chat-composer/use-draft-input.test.ts @@ -0,0 +1,169 @@ +/** + * Tests for useDraftInput localStorage helpers and switch-detection logic. + * + * The workspace lacks @testing-library/react (no jsdom), so we test the + * exported pure helpers and the localStorage serialization contract directly + * via a minimal localStorage shim. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +// --------------------------------------------------------------------------- +// localStorage shim (Bun test env has no window.localStorage) +// --------------------------------------------------------------------------- + +const store = new Map(); + +const localStorageShim = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + removeItem: (key: string) => { + store.delete(key); + }, + clear: () => { + store.clear(); + }, + get length() { + return store.size; + }, + key: (_index: number): string | null => null, +}; + +// Install the shim globally before importing the module under test so it +// can see `window.localStorage`. +if (typeof globalThis.window === "undefined") { + (globalThis as Record).window = { localStorage: localStorageShim }; +} else { + Object.defineProperty(globalThis.window, "localStorage", { + value: localStorageShim, + writable: true, + configurable: true, + }); +} + +// Dynamic import AFTER the shim is installed. +const { loadDraftsForTest, persistDraftsForTest } = await import( + "./use-draft-input-test-helpers.js" +); + +// --------------------------------------------------------------------------- +// Setup / teardown +// --------------------------------------------------------------------------- + +beforeEach(() => { + store.clear(); +}); + +afterEach(() => { + store.clear(); +}); + +// --------------------------------------------------------------------------- +// loadDrafts +// --------------------------------------------------------------------------- + +describe("loadDrafts", () => { + test("returns empty map when nothing stored", () => { + const result = loadDraftsForTest("ast-1"); + expect(result.size).toBe(0); + }); + + test("round-trips through persist → load", () => { + const drafts = new Map([ + ["conv-a", "hello"], + ["conv-b", "world"], + ]); + persistDraftsForTest("ast-1", drafts); + const loaded = loadDraftsForTest("ast-1"); + expect(loaded.size).toBe(2); + expect(loaded.get("conv-a")).toBe("hello"); + expect(loaded.get("conv-b")).toBe("world"); + }); + + test("ignores non-string values in stored JSON", () => { + store.set("vellum:chatDrafts:ast-1", JSON.stringify({ + "conv-a": "valid", + "conv-b": 42, + "conv-c": null, + "conv-d": true, + })); + const loaded = loadDraftsForTest("ast-1"); + expect(loaded.size).toBe(1); + expect(loaded.get("conv-a")).toBe("valid"); + }); + + test("returns empty map for malformed JSON", () => { + store.set("vellum:chatDrafts:ast-1", "not json"); + const loaded = loadDraftsForTest("ast-1"); + expect(loaded.size).toBe(0); + }); + + test("returns empty map for non-object JSON (array)", () => { + store.set("vellum:chatDrafts:ast-1", '["a","b"]'); + const loaded = loadDraftsForTest("ast-1"); + expect(loaded.size).toBe(0); + }); + + test("scopes by assistantId", () => { + persistDraftsForTest("ast-1", new Map([["conv-a", "draft 1"]])); + persistDraftsForTest("ast-2", new Map([["conv-a", "draft 2"]])); + expect(loadDraftsForTest("ast-1").get("conv-a")).toBe("draft 1"); + expect(loadDraftsForTest("ast-2").get("conv-a")).toBe("draft 2"); + }); +}); + +// --------------------------------------------------------------------------- +// persistDrafts +// --------------------------------------------------------------------------- + +describe("persistDrafts", () => { + test("writes JSON object to localStorage", () => { + persistDraftsForTest("ast-1", new Map([["conv-a", "hello"]])); + const raw = store.get("vellum:chatDrafts:ast-1"); + expect(raw).toBeDefined(); + expect(JSON.parse(raw!)).toEqual({ "conv-a": "hello" }); + }); + + test("overwrites previous drafts", () => { + persistDraftsForTest("ast-1", new Map([["conv-a", "first"]])); + persistDraftsForTest("ast-1", new Map([["conv-b", "second"]])); + const loaded = loadDraftsForTest("ast-1"); + expect(loaded.size).toBe(1); + expect(loaded.has("conv-a")).toBe(false); + expect(loaded.get("conv-b")).toBe("second"); + }); + + test("empty map writes empty object", () => { + persistDraftsForTest("ast-1", new Map()); + const raw = store.get("vellum:chatDrafts:ast-1"); + expect(JSON.parse(raw!)).toEqual({}); + }); +}); + +// --------------------------------------------------------------------------- +// Switch detection (pure logic) +// --------------------------------------------------------------------------- + +describe("conversation switch detection", () => { + function isConversationSwitch(prevKey: string | null, nextKey: string | null): boolean { + return prevKey !== null && prevKey !== nextKey; + } + + test("null → key is not a switch (initial mount)", () => { + expect(isConversationSwitch(null, "conv-a")).toBe(false); + }); + + test("same key is not a switch", () => { + expect(isConversationSwitch("conv-a", "conv-a")).toBe(false); + }); + + test("different keys IS a switch", () => { + expect(isConversationSwitch("conv-a", "conv-b")).toBe(true); + }); + + test("key → null IS a switch (save outgoing, no restore)", () => { + expect(isConversationSwitch("conv-a", null)).toBe(true); + }); +}); diff --git a/apps/web/src/domains/chat/components/chat-composer/use-draft-input.ts b/apps/web/src/domains/chat/components/chat-composer/use-draft-input.ts new file mode 100644 index 00000000000..1d911f62c42 --- /dev/null +++ b/apps/web/src/domains/chat/components/chat-composer/use-draft-input.ts @@ -0,0 +1,248 @@ +/** + * useDraftInput — per-conversation draft persistence for the chat composer. + * + * Owns `input` state, saves/restores drafts on conversation switches, and + * persists the drafts map to localStorage keyed by `{assistantId}`. + * + * Replaces the manual `draftsRef` that was previously threaded through + * ChatPage → useConversationLoader → useConversationHistory. + * + * @see LUM-1737 + */ + +import { + type Dispatch, + type MutableRefObject, + type SetStateAction, + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const STORAGE_KEY_PREFIX = "vellum:chatDrafts:"; + +function storageKey(assistantId: string): string { + return `${STORAGE_KEY_PREFIX}${assistantId}`; +} + +// --------------------------------------------------------------------------- +// localStorage helpers +// --------------------------------------------------------------------------- + +function loadDrafts(assistantId: string): Map { + if (typeof window === "undefined") return new Map(); + try { + const raw = window.localStorage.getItem(storageKey(assistantId)); + if (!raw) return new Map(); + const parsed: unknown = JSON.parse(raw); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return new Map(); + } + return new Map( + Object.entries(parsed as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + } catch { + return new Map(); + } +} + +function persistDrafts( + assistantId: string, + drafts: Map, +): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + storageKey(assistantId), + JSON.stringify(Object.fromEntries(drafts)), + ); + } catch { + // Storage can fail in private browsing / quota-exceeded. + } +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface UseDraftInputParams { + assistantId: string | null; + activeConversationKey: string | null; + /** + * When true, the next key change is a draft-to-server key resolution (not a + * real switch). The hook skips save/restore and only updates its internal + * `previousKeyRef`. Owned by ChatPage, written by `useSendMessage` when a + * draft conversation receives its server-assigned ID. + */ + draftKeyResolutionRef: MutableRefObject; + /** + * Fires after a non-empty saved draft is restored into the composer on a + * genuine conversation switch. Used to render a transient "Draft restored" + * notice (LUM-1516). + */ + onDraftRestored?: (conversationKey: string) => void; +} + +export interface UseDraftInputReturn { + input: string; + setInput: Dispatch>; + /** + * Save the current input as a draft for the given key. Call before + * operations that wipe state but should preserve the user's text + * (e.g. pull-to-refresh). + */ + saveDraft: (key: string, text: string) => void; + /** + * Clear the draft for the given key (e.g. after a successful send). + */ + clearDraft: (key: string) => void; +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +export function useDraftInput({ + assistantId, + activeConversationKey, + draftKeyResolutionRef, + onDraftRestored, +}: UseDraftInputParams): UseDraftInputReturn { + const [input, setInputState] = useState(""); + + // Keep an in-memory ref to the latest input value so we can read it + // synchronously inside the switch effect without a stale closure. + const inputValueRef = useRef(""); + + const draftsRef = useRef>(new Map()); + const previousKeyRef = useRef(null); + const assistantIdRef = useRef(null); + + // Wrap setInput to keep the ref in sync. + const setInput: Dispatch> = useCallback( + (action: SetStateAction) => { + setInputState((prev) => { + const next = typeof action === "function" ? action(prev) : action; + inputValueRef.current = next; + return next; + }); + }, + [], + ); + + // ----------------------------------------------------------------------- + // Load drafts from localStorage when assistantId changes. + // Flush the outgoing assistant's drafts first so they aren't lost when + // both assistantId and activeConversationKey change in the same render + // (effects run in declaration order — this must run before the switch + // effect below). + // ----------------------------------------------------------------------- + useEffect(() => { + const prevId = assistantIdRef.current; + if (prevId && prevId !== assistantId) { + // Save any in-progress text before swapping assistants. + const prevConvKey = previousKeyRef.current; + if (prevConvKey) { + const currentInput = inputValueRef.current; + if (currentInput.trim()) { + draftsRef.current.set(prevConvKey, currentInput); + } else { + draftsRef.current.delete(prevConvKey); + } + } + persistDrafts(prevId, draftsRef.current); + } + + if (!assistantId) { + draftsRef.current = new Map(); + assistantIdRef.current = null; + return; + } + draftsRef.current = loadDrafts(assistantId); + assistantIdRef.current = assistantId; + }, [assistantId]); + + // ----------------------------------------------------------------------- + // Conversation switch: save outgoing draft, restore incoming draft + // ----------------------------------------------------------------------- + useEffect(() => { + const prevKey = previousKeyRef.current; + const isSwitch = prevKey !== null && prevKey !== activeConversationKey; + + // Draft-key resolution (draft-xxx → conv-yyy) is not a real conversation + // switch — the user stays on the same conversation. Skip save/restore to + // avoid clearing the composer. + if (draftKeyResolutionRef.current) { + previousKeyRef.current = activeConversationKey; + return; + } + + if (isSwitch && prevKey) { + // Save outgoing conversation's draft. + const currentInput = inputValueRef.current; + if (currentInput.trim()) { + draftsRef.current.set(prevKey, currentInput); + } else { + draftsRef.current.delete(prevKey); + } + + // Restore incoming conversation's draft (or clear). + const savedDraft = + (activeConversationKey && + draftsRef.current.get(activeConversationKey)) ?? + ""; + setInput(savedDraft); + + // Notify the caller only for non-empty restorations on a genuine + // switch. This is the fix for bug #5 (misfiring notice on same-key + // effect re-runs): same-key re-runs are excluded by the + // `isSwitch` gate above. + if (savedDraft.length > 0 && activeConversationKey) { + onDraftRestored?.(activeConversationKey); + } + + // Persist after the save/restore cycle. + if (assistantIdRef.current) { + persistDrafts(assistantIdRef.current, draftsRef.current); + } + } + + previousKeyRef.current = activeConversationKey; + }, [activeConversationKey, setInput, onDraftRestored]); + + // ----------------------------------------------------------------------- + // Public helpers for callers that manage drafts outside the switch cycle + // ----------------------------------------------------------------------- + const saveDraft = useCallback( + (key: string, text: string) => { + if (text.trim()) { + draftsRef.current.set(key, text); + } else { + draftsRef.current.delete(key); + } + if (assistantIdRef.current) { + persistDrafts(assistantIdRef.current, draftsRef.current); + } + }, + [], + ); + + const clearDraft = useCallback( + (key: string) => { + draftsRef.current.delete(key); + if (assistantIdRef.current) { + persistDrafts(assistantIdRef.current, draftsRef.current); + } + }, + [], + ); + + return { input, setInput, saveDraft, clearDraft }; +} diff --git a/apps/web/src/domains/chat/components/chat-route-content.tsx b/apps/web/src/domains/chat/components/chat-route-content.tsx index 8485bcdb906..e9cd90135ad 100644 --- a/apps/web/src/domains/chat/components/chat-route-content.tsx +++ b/apps/web/src/domains/chat/components/chat-route-content.tsx @@ -213,7 +213,6 @@ export interface ChatRouteRefs { assistantIdRef: MutableRefObject; streamContextRef: MutableRefObject; expandedToolCallIdsRef: MutableRefObject>; - draftsRef: MutableRefObject>; conversationCacheRef: MutableRefObject>; dismissedSurfaceIdsRef: MutableRefObject>; isLoadingOlderRef: MutableRefObject; @@ -282,6 +281,8 @@ export interface ChatRouteContentProps { // Draft restoredDraftConversationKey: string | null; setRestoredDraftConversationKey: Dispatch>; + saveDraft: (key: string, text: string) => void; + clearDraft: (key: string) => void; // Avatar avatar: AvatarData; @@ -400,6 +401,8 @@ export function ChatRouteContent({ editingConversationKey, restoredDraftConversationKey, setRestoredDraftConversationKey, + saveDraft, + clearDraft, avatar, conversationStarters, contextWindowUsage, @@ -494,7 +497,6 @@ export function ChatRouteContent({ assistantIdRef: _assistantIdRef, streamContextRef, expandedToolCallIdsRef, - draftsRef, conversationCacheRef, dismissedSurfaceIdsRef: _dismissedSurfaceIdsRef, isLoadingOlderRef, @@ -695,15 +697,11 @@ export function ChatRouteContent({ const handleRefreshConversation = useCallback(() => { if (activeConversationKey) { const currentInput = inputRef.current?.value ?? ""; - if (currentInput.trim()) { - draftsRef.current.set(activeConversationKey, currentInput); - } else { - draftsRef.current.delete(activeConversationKey); - } + saveDraft(activeConversationKey, currentInput); conversationCacheRef.current.delete(activeConversationKey); } setRefreshEpoch((prev) => prev + 1); - }, [activeConversationKey, inputRef, draftsRef, conversationCacheRef, setRefreshEpoch]); + }, [activeConversationKey, inputRef, saveDraft, conversationCacheRef, setRefreshEpoch]); // ------------------------------------------------------------------------- // Pull-to-refresh @@ -942,7 +940,7 @@ export function ChatRouteContent({ setInput(""); setSuggestion(null); if (activeConversationKey) { - draftsRef.current.delete(activeConversationKey); + clearDraft(activeConversationKey); } if (inputRef.current) { inputRef.current.style.height = "auto"; @@ -953,7 +951,7 @@ export function ChatRouteContent({ } haptic.medium(); await sendMessage(trimmed, attachmentsToSend); - }, [input, sendDisabled, attachmentUploadedIds.length, attachmentsUploadingCount, activeConversationKey, chatAttachments, resetChatAttachments, sendMessage, setInput, setSuggestion, draftsRef, inputRef]); + }, [input, sendDisabled, attachmentUploadedIds.length, attachmentsUploadingCount, activeConversationKey, chatAttachments, resetChatAttachments, sendMessage, setInput, setSuggestion, clearDraft, inputRef]); const handleSelectStarter = (starter: { prompt: string }) => { setInput(starter.prompt); diff --git a/apps/web/src/domains/chat/hooks/use-conversation-history.ts b/apps/web/src/domains/chat/hooks/use-conversation-history.ts index 255f21f8cd1..d58fe2d5f52 100644 --- a/apps/web/src/domains/chat/hooks/use-conversation-history.ts +++ b/apps/web/src/domains/chat/hooks/use-conversation-history.ts @@ -96,8 +96,6 @@ interface UseConversationHistoryParams { >; draftKeyResolutionRef: MutableRefObject; previousConversationKeyRef: MutableRefObject; - inputRef: MutableRefObject; - draftsRef: MutableRefObject>; messagesRef: MutableRefObject; contextWindowUsageByConversationRef: MutableRefObject>; @@ -125,22 +123,11 @@ interface UseConversationHistoryParams { setContextWindowUsage: Dispatch>; setSuggestion: Dispatch>; setCompactionCircuitOpenUntil: Dispatch>; - setInput: Dispatch>; // Callbacks resetChatAttachments: () => void; syncNeedsNewBubbleFromMessages: (nextMessages: DisplayMessage[]) => void; - /** - * Fires after a non-empty saved draft is restored into the composer on a - * genuine conversation switch. Receives the conversation key the draft - * belongs to. Used by the page to render a transient "Draft restored" - * notice so the user does not mistake the restored text for a stale - * unsent message (see LUM-1516). Optional -- omit to suppress the notice - * (e.g. in tests). - */ - onDraftRestored?: (conversationKey: string) => void; - // Error classification shouldSuppressGenericChatErrorNotice: (prev: ChatError | null) => boolean; } @@ -154,7 +141,7 @@ interface UseConversationHistoryParams { * conversation changes. * * Handles: - * - Saving outgoing conversation drafts and messages to the LRU cache + * - Caching outgoing conversation messages to the LRU cache on switch * - Resetting per-conversation state on switch * - Restoring cached messages or fetching fresh history from the server * - Reconciling cache with latest server data @@ -171,8 +158,6 @@ export function useConversationHistory({ conversationCacheRef, draftKeyResolutionRef, previousConversationKeyRef, - inputRef, - draftsRef, messagesRef, contextWindowUsageByConversationRef, dismissedSurfaceIdsRef, @@ -197,10 +182,8 @@ export function useConversationHistory({ setContextWindowUsage, setSuggestion, setCompactionCircuitOpenUntil, - setInput, resetChatAttachments, syncNeedsNewBubbleFromMessages, - onDraftRestored, shouldSuppressGenericChatErrorNotice, }: UseConversationHistoryParams) { const transcriptPaginationRef = useRef(transcriptPagination); @@ -222,25 +205,14 @@ export function useConversationHistory({ return; } - // Save the outgoing conversation's draft and messages so they can be - // restored on switch-back without a server round-trip. + // Save the outgoing conversation's messages so they can be restored + // on switch-back without a server round-trip. Draft save/restore is + // handled by useDraftInput (LUM-1737). const outgoingKey = previousConversationKeyRef.current; - // Distinguish a real conversation switch (drives draft restoration UX) - // from an effect re-run on the same conversation (e.g. pull-to-refresh - // incrementing `refreshEpoch`, feature-flag deps changing). On a - // same-key re-run the user's current input was just round-tripped - // through `draftsRef` by the refresh handler and must not be surfaced - // as a "restored draft" -- see comment on `onDraftRestored?.()` below. const isConversationSwitch = Boolean( outgoingKey && outgoingKey !== activeConversationKey, ); if (isConversationSwitch && outgoingKey) { - const currentInput = inputRef.current?.value ?? ""; - if (currentInput.trim()) { - draftsRef.current.set(outgoingKey, currentInput); - } else { - draftsRef.current.delete(outgoingKey); - } // If the outgoing conversation has a pending interaction, mark it as // needing attention so the sidebar shows an alert icon. const interactionSnapshot = useInteractionStore.getState(); @@ -275,29 +247,6 @@ export function useConversationHistory({ } previousConversationKeyRef.current = activeConversationKey; - // Restore the incoming conversation's draft (or clear the input). - // Gate on a genuine conversation switch: same-key effect re-runs (e.g. - // pull-to-refresh incrementing `refreshEpoch`, `conversationGroupsUI` - // toggling) must not overwrite the user's in-progress composer input - // with whatever stale value lives at this conversation's draft slot. - // The user's current text IS the live state at that point and is - // already preserved by `inputRef.current`. - let savedDraft = ""; - if (isConversationSwitch) { - savedDraft = draftsRef.current.get(activeConversationKey) ?? ""; - setInput(savedDraft); - if (inputRef.current) { - inputRef.current.style.height = "auto"; - } - } - // Surface the restore to the page so it can render a transient notice. - // Without this, the user can mistake the restored text for stale - // content from a prior send (LUM-1516, part 2). Empty restores are - // the expected default and not worth a notice. - if (isConversationSwitch && savedDraft.length > 0) { - onDraftRestored?.(activeConversationKey); - } - // Reset all per-conversation state so nothing leaks between threads. isLoadingOlderRef.current = false; initialPageOldestTsRef.current = null; @@ -631,8 +580,6 @@ export function useConversationHistory({ conversationCacheRef, draftKeyResolutionRef, previousConversationKeyRef, - inputRef, - draftsRef, messagesRef, contextWindowUsageByConversationRef, dismissedSurfaceIdsRef, @@ -658,8 +605,6 @@ export function useConversationHistory({ setContextWindowUsage, setSuggestion, setCompactionCircuitOpenUntil, - setInput, - onDraftRestored, shouldSuppressGenericChatErrorNotice, ]); } diff --git a/apps/web/src/domains/conversations/use-conversation-loader.ts b/apps/web/src/domains/conversations/use-conversation-loader.ts index 498ef5d2c4d..68259de9256 100644 --- a/apps/web/src/domains/conversations/use-conversation-loader.ts +++ b/apps/web/src/domains/conversations/use-conversation-loader.ts @@ -97,8 +97,6 @@ interface UseConversationLoaderParams { previousConversationKeyRef: MutableRefObject; onboardingDraftConversationKeyRef: MutableRefObject; activeConversationKeyRef: MutableRefObject; - inputRef: MutableRefObject; - draftsRef: MutableRefObject>; messagesRef: MutableRefObject; contextWindowUsageByConversationRef: MutableRefObject>; dismissedSurfaceIdsRef: MutableRefObject>; @@ -128,22 +126,11 @@ interface UseConversationLoaderParams { setContextWindowUsage: Dispatch>; setSuggestion: Dispatch>; setCompactionCircuitOpenUntil: Dispatch>; - setInput: Dispatch>; - // Callbacks resetChatAttachments: () => void; syncNeedsNewBubbleFromMessages: (nextMessages: DisplayMessage[]) => void; - /** - * Fires after a non-empty saved draft is restored into the composer on a - * conversation switch. Receives the conversation key the draft belongs to. - * Used by the page to render a transient "Draft restored" notice so the - * user does not mistake the restored text for a stale unsent message (see - * LUM-1516). Optional — omit to suppress the notice (e.g. in tests). - */ - onDraftRestored?: (conversationKey: string) => void; - // Error classification shouldSuppressGenericChatErrorNotice: (prev: ChatError | null) => boolean; } @@ -187,8 +174,6 @@ export function useConversationLoader({ previousConversationKeyRef, onboardingDraftConversationKeyRef, activeConversationKeyRef, - inputRef, - draftsRef, messagesRef, contextWindowUsageByConversationRef, dismissedSurfaceIdsRef, @@ -216,10 +201,8 @@ export function useConversationLoader({ setContextWindowUsage, setSuggestion, setCompactionCircuitOpenUntil, - setInput, resetChatAttachments, syncNeedsNewBubbleFromMessages, - onDraftRestored, shouldSuppressGenericChatErrorNotice, }: UseConversationLoaderParams) { // ------------------------------------------------------------------------- @@ -452,8 +435,6 @@ export function useConversationLoader({ conversationCacheRef, draftKeyResolutionRef, previousConversationKeyRef, - inputRef, - draftsRef, messagesRef, contextWindowUsageByConversationRef, dismissedSurfaceIdsRef, @@ -478,8 +459,6 @@ export function useConversationLoader({ setContextWindowUsage, setSuggestion, setCompactionCircuitOpenUntil, - setInput, - onDraftRestored, resetChatAttachments, syncNeedsNewBubbleFromMessages, shouldSuppressGenericChatErrorNotice,