diff --git a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx index dbd56d68c8d..f2f8b342ea0 100644 --- a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx @@ -133,6 +133,7 @@ import { composerHistoryEntryToDocument, type ComposerHistoryEntry, } from '@/lib/composerQuoteDocument'; +import { deriveStableComposerHistory } from './composerHistoryProjection'; import type { PastedTextRange, SlashCommandRange } from '@/lib/imageRef'; import { pastedSessionChipAttrs, @@ -175,6 +176,13 @@ import { getAppShortcutCombos } from '@/lib/appShortcutStore'; import { getNextPermissionMode } from '@/lib/permissionModeCycle'; import { matchesKeyboardEvent } from '../../../shared/appShortcuts'; import { createLogger } from '@/lib/logger'; +import { createComposerDraftSaveScheduler } from '@/lib/composerDraftSaveScheduler'; +import { + composerRenderSnapshot, + shouldRefreshComposerRender, + type ComposerRenderSnapshot, +} from './composerRenderGate'; +import { createComposerFrameScheduler } from './composerFrameScheduler'; import { serializeEditorContent, serializeEditorSlice } from './composerContentSerialization'; import { composerDocumentContainsList, @@ -988,19 +996,8 @@ export function ChatInput({ const effectiveCompactToolbar = compactToolbar || autoCompactToolbar; // ── User message history for ↑/↓ navigation ────────────────────── - const userHistory = useMemo( - () => - (messages ?? []) - .filter((m) => m.role === 'user' && m.content.trim()) - .map((m): ComposerHistoryEntry => ({ - content: m.content, - ...(m.quotesEncoded === true ? { quotesEncoded: true } : {}), - })) - .reverse(), // newest first - [messages], - ); - const userHistoryRef = useRef(userHistory); - userHistoryRef.current = userHistory; + const userHistoryRef = useRef([]); + userHistoryRef.current = deriveStableComposerHistory(messages, userHistoryRef.current); const historyIndexRef = useRef(-1); // -1 = current draft (not browsing) const draftRef = useRef(null); // saves draft doc JSON when user starts browsing (preserves marks) const hydratedHistoryDocumentRef = useRef(null); @@ -1370,7 +1367,36 @@ export function ChatInput({ // atomic chips, and only the list nodes needed to preserve Markdown list // structure while editing. It does not use StarterKit, whose headings and // marks are not part of the chat input contract. + const [, setTick] = useState(0); + const refreshComposerRef = useRef<(() => void) | null>(null); + refreshComposerRef.current = () => setTick((t) => t + 1); + const renderSnapshotRef = useRef(null); + const draftSaveSchedulerRef = useRef | null>(null); + draftSaveSchedulerRef.current ??= createComposerDraftSaveScheduler(); + const caretScrollEditorRef = useRef(null); + const caretScrollSchedulerRef = useRef | null>(null); + caretScrollSchedulerRef.current ??= createComposerFrameScheduler(() => { + const current = caretScrollEditorRef.current; + if (current) scrollCaretIntoView(current); + }); + const scheduleCaretScroll = (ed: Editor): void => { + caretScrollEditorRef.current = ed; + caretScrollSchedulerRef.current?.schedule(); + }; + useEffect( + () => () => { + caretScrollSchedulerRef.current?.cancel(); + }, + [], + ); const editor = useEditor({ + // React receives only the narrow snapshots above; Tiptap keeps ordinary + // transactions inside its editor view instead of rerendering ChatInput. + shouldRerenderOnTransaction: false, // Match the legacy textarea's `autoFocus` prop — on mount, focus the // editor at the end so the user can continue typing after restored text. // Tiptap treats boolean `true` as `focus('start')`; its deferred mount @@ -1919,7 +1945,14 @@ export function ChatInput({ } }); } - setTick((t) => t + 1); + const nextRenderSnapshot = composerRenderSnapshot( + detectTrigger(ed), + !composerDocIsEmpty(ed.state.doc), + ); + if (shouldRefreshComposerRender(renderSnapshotRef.current, nextRenderSnapshot)) { + renderSnapshotRef.current = nextRenderSnapshot; + refreshComposerRef.current?.(); + } if (!composerMentionDragActiveRef.current) { lastComposerSelectionFromRef.current = ed.state.selection.from; } @@ -1929,45 +1962,68 @@ export function ChatInput({ // recurse on rapid switches). if (isRestoringRef.current) { // 即便是 restore,也要补一次滚动——切换 session 后光标常落在末尾 - requestAnimationFrame(() => scrollCaretIntoView(ed)); + scheduleCaretScroll(ed); return; } // composer-draft-mount-race 修复 (issue #40):hydration 还没跑过 → 这次 // onUpdate 是 Tiptap mount 期间的初始触发(空 editor),不能写 store。 if (!hasHydratedRef.current) { - requestAnimationFrame(() => scrollCaretIntoView(ed)); + scheduleCaretScroll(ed); return; } const sk = storageKeyForDraftRef.current; if (!sk) { - requestAnimationFrame(() => scrollCaretIntoView(ed)); + scheduleCaretScroll(ed); return; } - const existing = getComposerDraft(sk); // silent: 自己写自己——不通知 subscribeComposerDraft 监听器,避免回灌 - // setContent 把光标位置/IME 组合状态打乱。 - saveComposerDraft( - sk, - { - text: ed.getJSON(), - attachments: existing?.attachments ?? [], - quotes: existing?.quotes ?? [], - browserComments: existing?.browserComments ?? [], - }, - { silent: true }, - ); + // setContent 把光标位置/IME 组合状态打乱。把 JSON 序列化和写入都放进短 + // debounce,生命周期边界由 flush 强制落最后一版。 + // + // voice-input session-switch 草稿串味修复:`ed.getJSON()` 故意延后到 + // debounce 触发那一刻才读(保住上面这条 perf 优化——不在每次按键都同步 + // 序列化整份文档)。但 storageKeyForDraftRef 在语音输入 stop/refine/send + // 的 async 等待期间会「故意滞后」于 storageKey prop(见该 ref 声明处注释), + // 期间若这条 debounce 定时器还没触发,restoreNextDraft 就可能先跑完 + // setContent 把编辑器换成下一个 session 的文档、再把 ref 切到新 key — + // 定时器这时才触发的话,`ed.getJSON()` 读到的已经是下一个 session 的内容, + // 却仍会存进这里捕获的旧 `sk` 下,串味覆盖旧会话草稿。任务真正执行时重新核对 + // ref 是否还等于调度时捕获的 `sk`,不等就说明编辑器内容已经不再属于它,直接 + // 跳过这次写入(旧会话的最终内容已由 saveCurrentEditorDraft 在切换前存妥)。 + draftSaveSchedulerRef.current?.schedule(() => { + if (storageKeyForDraftRef.current !== sk) return; + const existing = getComposerDraft(sk); + saveComposerDraft( + sk, + { + text: ed.getJSON(), + attachments: existing?.attachments ?? [], + quotes: existing?.quotes ?? [], + browserComments: existing?.browserComments ?? [], + }, + { silent: true }, + ); + }); // chat-input-autoscroll fix: 输入超过 max-h 后,让光标随内容追底 - requestAnimationFrame(() => scrollCaretIntoView(ed)); + scheduleCaretScroll(ed); }, onSelectionUpdate: ({ editor: ed }) => { - setTick((t) => t + 1); + const nextRenderSnapshot = composerRenderSnapshot( + detectTrigger(ed), + !composerDocIsEmpty(ed.state.doc), + ); + if (shouldRefreshComposerRender(renderSnapshotRef.current, nextRenderSnapshot)) { + renderSnapshotRef.current = nextRenderSnapshot; + refreshComposerRef.current?.(); + } if (!composerMentionDragActiveRef.current) { lastComposerSelectionFromRef.current = ed.state.selection.from; } // 方向键移动光标也要跟随(例如 ↓ 把光标从可见区移到 doc 末尾) - requestAnimationFrame(() => scrollCaretIntoView(ed)); + scheduleCaretScroll(ed); }, onBlur: () => { + draftSaveSchedulerRef.current?.flush(); // Focus left the editor. Spec F1/F2 require the palette to close on // blur. We defer by a microtask so mouse-click selections on the // palette (which also blur the editor momentarily) still register. @@ -2573,6 +2629,7 @@ export function ChatInput({ useEffect(() => { if (!editor) return; return () => { + draftSaveSchedulerRef.current?.flush(); const editorStorageKey = storageKeyForDraftRef.current; if (!editorStorageKey) return; const existing = getComposerDraft(editorStorageKey); @@ -2657,6 +2714,7 @@ export function ChatInput({ const transitionSeq = storageKeyTransitionSeqRef.current + 1; storageKeyTransitionSeqRef.current = transitionSeq; + draftSaveSchedulerRef.current?.flush(); const saveCurrentEditorDraft = () => { if (!prevEditorKey) return; if (!hasHydratedRef.current) return; @@ -2946,9 +3004,6 @@ export function ChatInput({ [syncPaletteHover], ); - // Bump to force trigger recompute (editor state is mutable, not React state) - const [, setTick] = useState(0); - // ── Slash / At panel state ───────────────────────────────────────── const trigger: TriggerState = editor ? detectTrigger(editor) : { kind: 'none' }; @@ -3340,6 +3395,7 @@ export function ChatInput({ if (!editor) return; if (disabled) return; if (dispatchSendInFlightRef.current) return; + draftSaveSchedulerRef.current?.flush(); dispatchSendInFlightRef.current = true; setSendDispatchInFlight(true); try { @@ -4738,6 +4794,7 @@ export function ChatInput({ ); const hasMessage = !isEditorEmpty(editor); + renderSnapshotRef.current = composerRenderSnapshot(trigger, hasMessage); const canSend = hasMessage || hasAttachments || browserComments.length > 0; const hasVoiceDraftText = voiceInput.draftText.trim().length > 0; const [voiceReleaseToSendActive, setVoiceReleaseToSendActive] = useState(false); diff --git a/apps/desktop/src/renderer/components/new-chat/__tests__/composerFrameScheduler.test.ts b/apps/desktop/src/renderer/components/new-chat/__tests__/composerFrameScheduler.test.ts new file mode 100644 index 00000000000..355dc920372 --- /dev/null +++ b/apps/desktop/src/renderer/components/new-chat/__tests__/composerFrameScheduler.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { createComposerFrameScheduler } from '../composerFrameScheduler'; + +describe('createComposerFrameScheduler', () => { + it('keeps only the latest pending frame', () => { + let nextHandle = 1; + const callbacks = new Map(); + const cancelled: number[] = []; + let runs = 0; + const scheduler = createComposerFrameScheduler( + () => { + runs += 1; + }, + { + requestFrame: (callback) => { + const handle = nextHandle++; + callbacks.set(handle, callback); + return handle; + }, + cancelFrame: (handle) => { + cancelled.push(handle); + callbacks.delete(handle); + }, + }, + ); + + scheduler.schedule(); + scheduler.schedule(); + expect(cancelled).toEqual([1]); + expect(callbacks.size).toBe(1); + callbacks.get(2)?.(16); + expect(runs).toBe(1); + }); + + it('cancels pending work on teardown', () => { + let callback: FrameRequestCallback | null = null; + let cancelled = false; + const scheduler = createComposerFrameScheduler(() => undefined, { + requestFrame: (next) => { + callback = next; + return 1; + }, + cancelFrame: () => { + cancelled = true; + callback = null; + }, + }); + + scheduler.schedule(); + scheduler.cancel(); + expect(cancelled).toBe(true); + expect(callback).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/components/new-chat/__tests__/composerHistoryProjection.test.ts b/apps/desktop/src/renderer/components/new-chat/__tests__/composerHistoryProjection.test.ts new file mode 100644 index 00000000000..6d48c565b3e --- /dev/null +++ b/apps/desktop/src/renderer/components/new-chat/__tests__/composerHistoryProjection.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { deriveStableComposerHistory } from '../composerHistoryProjection'; + +describe('deriveStableComposerHistory', () => { + it('keeps the same array while only assistant streaming content changes', () => { + const previous = deriveStableComposerHistory( + [ + { role: 'user', content: 'first' }, + { role: 'assistant', content: 'a' }, + ], + [], + ); + const next = deriveStableComposerHistory( + [ + { role: 'user', content: 'first' }, + { role: 'assistant', content: 'a growing response' }, + ], + previous, + ); + + expect(next).toBe(previous); + }); + + it('returns a new newest-first projection when user rows change', () => { + const previous = deriveStableComposerHistory([{ role: 'user', content: 'first' }], []); + const next = deriveStableComposerHistory( + [ + { role: 'user', content: 'first' }, + { role: 'user', content: 'second', quotesEncoded: true }, + ], + previous, + ); + + expect(next).not.toBe(previous); + expect(next).toEqual([{ content: 'second', quotesEncoded: true }, { content: 'first' }]); + }); +}); diff --git a/apps/desktop/src/renderer/components/new-chat/__tests__/composerRenderGate.test.ts b/apps/desktop/src/renderer/components/new-chat/__tests__/composerRenderGate.test.ts new file mode 100644 index 00000000000..1d2cc296745 --- /dev/null +++ b/apps/desktop/src/renderer/components/new-chat/__tests__/composerRenderGate.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { + __composerRenderGateDefaultsForTest, + composerRenderSnapshot, + shouldRefreshComposerRender, +} from '../composerRenderGate'; + +describe('composer render gate', () => { + it('skips ordinary text updates when trigger and send state are stable', () => { + const none = composerRenderSnapshot({ kind: 'none' }, true); + expect(shouldRefreshComposerRender(none, composerRenderSnapshot({ kind: 'none' }, true))).toBe( + false, + ); + }); + + it('refreshes when text becomes sendable or empty', () => { + expect( + shouldRefreshComposerRender( + composerRenderSnapshot({ kind: 'none' }, false), + composerRenderSnapshot({ kind: 'none' }, true), + ), + ).toBe(true); + expect( + shouldRefreshComposerRender( + composerRenderSnapshot({ kind: 'none' }, true), + composerRenderSnapshot({ kind: 'none' }, false), + ), + ).toBe(true); + }); + + it('refreshes when a palette trigger changes', () => { + const before = composerRenderSnapshot({ kind: 'slash', sigil: '/', query: '', from: 1 }, true); + expect( + shouldRefreshComposerRender( + before, + composerRenderSnapshot({ kind: 'slash', sigil: '/', query: 'he', from: 1 }, true), + ), + ).toBe(true); + expect( + shouldRefreshComposerRender( + before, + composerRenderSnapshot({ kind: 'slash', sigil: '$', query: '', from: 1 }, true), + ), + ).toBe(true); + }); + + it('refreshes when the trigger kind itself changes', () => { + // 'none' -> a palette trigger opening. + expect( + shouldRefreshComposerRender( + composerRenderSnapshot({ kind: 'none' }, true), + composerRenderSnapshot({ kind: 'slash', sigil: '/', query: '', from: 1 }, true), + ), + ).toBe(true); + // A palette trigger closing back to 'none'. + expect( + shouldRefreshComposerRender( + composerRenderSnapshot({ kind: 'slash', sigil: '/', query: '', from: 1 }, true), + composerRenderSnapshot({ kind: 'none' }, true), + ), + ).toBe(true); + // One palette trigger kind swapping directly for the other. + expect( + shouldRefreshComposerRender( + composerRenderSnapshot({ kind: 'slash', sigil: '/', query: '', from: 1 }, true), + composerRenderSnapshot({ kind: 'at', query: '', from: 1 }, true), + ), + ).toBe(true); + }); + + it('keeps the ordinary update default explicit', () => { + expect(__composerRenderGateDefaultsForTest.ordinaryTextUpdatesRefresh).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/components/new-chat/composerFrameScheduler.ts b/apps/desktop/src/renderer/components/new-chat/composerFrameScheduler.ts new file mode 100644 index 00000000000..166833062fe --- /dev/null +++ b/apps/desktop/src/renderer/components/new-chat/composerFrameScheduler.ts @@ -0,0 +1,30 @@ +/** + * Creates a coalesced requestAnimationFrame scheduler. Scheduling again before + * the frame runs cancels the stale callback; teardown cancels pending work. + */ +export function createComposerFrameScheduler( + run: () => void, + options: { + requestFrame?: (callback: FrameRequestCallback) => number; + cancelFrame?: (handle: number) => void; + } = {}, +): { schedule(): void; cancel(): void } { + const requestFrame = options.requestFrame ?? ((callback) => requestAnimationFrame(callback)); + const cancelFrame = options.cancelFrame ?? ((handle) => cancelAnimationFrame(handle)); + let pending: number | null = null; + + return { + schedule(): void { + if (pending !== null) cancelFrame(pending); + pending = requestFrame(() => { + pending = null; + run(); + }); + }, + cancel(): void { + if (pending === null) return; + cancelFrame(pending); + pending = null; + }, + }; +} diff --git a/apps/desktop/src/renderer/components/new-chat/composerHistoryProjection.ts b/apps/desktop/src/renderer/components/new-chat/composerHistoryProjection.ts new file mode 100644 index 00000000000..470640f39d9 --- /dev/null +++ b/apps/desktop/src/renderer/components/new-chat/composerHistoryProjection.ts @@ -0,0 +1,41 @@ +import type { ComposerHistoryEntry } from '@/lib/composerQuoteDocument'; + +export type ComposerHistoryMessage = { + role: string; + content: string; + quotesEncoded?: boolean; +}; + +/** + * Builds a stable user-message projection. If assistant streaming replaces the + * parent messages array without changing user rows, the previous array is + * returned so ChatInput's history consumers do not receive new identities. + * + * `previous` is typed as the same mutable array its one caller + * (`userHistoryRef.current` in ChatInput) actually holds, so the identity + * short-circuit below can return it as-is — no readonly-stripping cast needed. + */ +export function deriveStableComposerHistory( + messages: ComposerHistoryMessage[] | undefined, + previous: ComposerHistoryEntry[], +): ComposerHistoryEntry[] { + const next = (messages ?? []) + .filter((message) => message.role === 'user' && message.content.trim()) + .map((message): ComposerHistoryEntry => ({ + content: message.content, + ...(message.quotesEncoded === true ? { quotesEncoded: true } : {}), + })) + .reverse(); + + if ( + next.length === previous.length && + next.every( + (entry, index) => + entry.content === previous[index]?.content && + entry.quotesEncoded === previous[index]?.quotesEncoded, + ) + ) { + return previous; + } + return next; +} diff --git a/apps/desktop/src/renderer/components/new-chat/composerRenderGate.ts b/apps/desktop/src/renderer/components/new-chat/composerRenderGate.ts new file mode 100644 index 00000000000..3b976943d4e --- /dev/null +++ b/apps/desktop/src/renderer/components/new-chat/composerRenderGate.ts @@ -0,0 +1,50 @@ +export type ComposerTriggerSnapshot = + | { kind: 'none' } + | { kind: 'slash'; sigil: '/' | '$'; query: string; from: number } + | { kind: 'at'; query: string; from: number }; + +export interface ComposerRenderSnapshot { + trigger: ComposerTriggerSnapshot; + hasMessage: boolean; +} + +/** + * Returns whether a React render is needed after an editor transaction. + * Ordinary text updates with no active trigger keep the editor mutable outside + * React; only palette state or the empty/non-empty send state can change. + */ +export function shouldRefreshComposerRender( + previous: ComposerRenderSnapshot | null, + next: ComposerRenderSnapshot, +): boolean { + if (!previous) return true; + if (previous.hasMessage !== next.hasMessage) return true; + // Check `next` first so TS narrows `next.trigger` off `'none'` before the + // single kind comparison below runs — that comparison then narrows + // `previous.trigger` too, since it's checked against an already-narrowed + // type instead of the other way around (a second, separate kind check on + // the un-narrowed `next.trigger` would only be redundant at runtime while + // failing to narrow `previous.trigger` for the property reads that follow). + if (next.trigger.kind === 'none') return previous.trigger.kind !== 'none'; + if (previous.trigger.kind !== next.trigger.kind) return true; + if (previous.trigger.from !== next.trigger.from) return true; + if ( + next.trigger.kind === 'slash' && + previous.trigger.kind === 'slash' && + previous.trigger.sigil !== next.trigger.sigil + ) { + return true; + } + return previous.trigger.query !== next.trigger.query; +} + +export function composerRenderSnapshot( + trigger: ComposerTriggerSnapshot, + hasMessage: boolean, +): ComposerRenderSnapshot { + return { trigger, hasMessage }; +} + +export const __composerRenderGateDefaultsForTest = { + ordinaryTextUpdatesRefresh: false, +}; diff --git a/apps/desktop/src/renderer/lib/__tests__/composerDraftSaveScheduler.test.ts b/apps/desktop/src/renderer/lib/__tests__/composerDraftSaveScheduler.test.ts new file mode 100644 index 00000000000..88dfac88b12 --- /dev/null +++ b/apps/desktop/src/renderer/lib/__tests__/composerDraftSaveScheduler.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import { + __composerDraftSaveSchedulerDefaultsForTest, + createComposerDraftSaveScheduler, +} from '@/lib/composerDraftSaveScheduler'; + +describe('createComposerDraftSaveScheduler', () => { + it('keeps only the latest task and flushes it immediately', () => { + let nextHandle = 1; + const timers = new Map void>(); + const cleared: number[] = []; + const ran: string[] = []; + const scheduler = createComposerDraftSaveScheduler({ + setTimer: (callback) => { + const handle = nextHandle++; + timers.set(handle, callback); + return handle; + }, + clearTimer: (handle) => { + cleared.push(handle); + timers.delete(handle); + }, + }); + + scheduler.schedule(() => ran.push('old')); + scheduler.schedule(() => ran.push('latest')); + expect(timers.size).toBe(1); + + scheduler.flush(); + expect(ran).toEqual(['latest']); + expect(cleared).toEqual([1]); + }); + + it('runs the pending task when the debounce timer fires', () => { + const callbacks: Array<() => void> = []; + const ran: string[] = []; + const scheduler = createComposerDraftSaveScheduler({ + setTimer: (callback) => { + callbacks.push(callback); + return 1; + }, + clearTimer: () => undefined, + }); + + scheduler.schedule(() => ran.push('saved')); + expect(ran).toEqual([]); + callbacks[0]?.(); + expect(ran).toEqual(['saved']); + }); + + it('cancels pending work on teardown', () => { + const callbacks: Array<() => void> = []; + const ran: string[] = []; + const scheduler = createComposerDraftSaveScheduler({ + setTimer: (callback) => { + callbacks.push(callback); + return 1; + }, + clearTimer: () => undefined, + }); + + scheduler.schedule(() => ran.push('saved')); + scheduler.cancel(); + callbacks[0]?.(); + expect(ran).toEqual([]); + }); + + it('keeps the intended debounce default', () => { + expect(__composerDraftSaveSchedulerDefaultsForTest.delayMs).toBe(120); + }); +}); diff --git a/apps/desktop/src/renderer/lib/__tests__/composerDraftSessionSwitchGuard.test.ts b/apps/desktop/src/renderer/lib/__tests__/composerDraftSessionSwitchGuard.test.ts new file mode 100644 index 00000000000..aaca4b8dd90 --- /dev/null +++ b/apps/desktop/src/renderer/lib/__tests__/composerDraftSessionSwitchGuard.test.ts @@ -0,0 +1,186 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import type { JSONContent } from '@tiptap/core'; + +import { createComposerDraftSaveScheduler } from '@/lib/composerDraftSaveScheduler'; +import { getDraft, saveDraft } from '@/lib/composerDraftStore'; + +/** + * Regression test for the voice-input/session-switch draft corruption fix in + * ChatInput.tsx's Tiptap `onUpdate` handler. + * + * Background: to avoid serializing the editor's JSON on every keystroke, + * `onUpdate` captures the storage key (`sk`) eagerly but defers the + * `editor.getJSON()` read into a short debounce, via the real + * `createComposerDraftSaveScheduler` (perf win this suite must keep). During a + * voice-input stop/refine/send transaction, ChatInput intentionally lets + * `storageKeyForDraftRef` lag behind the `storageKey` prop until that async + * work settles. ChatInput reuses a single Tiptap editor instance across + * session switches, so if the debounce timer for an old-session update is + * still pending when the async transition finishes and swaps that same + * editor over to the next session's document, a naive scheduled task would + * read the NEXT session's content via the deferred `ed.getJSON()` call but + * persist it under the OLD session's storage key — corrupting the old + * session's draft with the new session's text. + * + * The fix re-checks, at the moment the debounce timer actually fires, + * whether `storageKeyForDraftRef.current` still equals the `sk` the task was + * scheduled for; a mismatch means the editor's content no longer belongs to + * `sk` and the write is skipped. + */ + +const chatInputSource = readFileSync( + resolve(__dirname, '..', '..', 'components', 'new-chat', 'ChatInput.tsx'), + 'utf8', +).replace(/\r\n?/g, '\n'); + +describe('ChatInput scheduled draft-save session-switch guard', () => { + it('pins the production guard in onUpdate so this regression test cannot silently drift from ChatInput.tsx', () => { + const scheduledSaveBlock = extractBetween( + chatInputSource, + 'const sk = storageKeyForDraftRef.current;', + '// chat-input-autoscroll fix:', + ); + + expect(scheduledSaveBlock).toContain('draftSaveSchedulerRef.current?.schedule(() => {'); + expect(scheduledSaveBlock).toContain('if (storageKeyForDraftRef.current !== sk) return;'); + expect(scheduledSaveBlock).toContain('text: ed.getJSON(),'); + // The re-check must guard the deferred read, not run after it. Search + // from the start of the `.schedule(...)` callback body (not the + // explanatory comment above it, which also mentions `ed.getJSON()`) so + // this only inspects the actual guarded code shape. + const scheduleCallbackStart = scheduledSaveBlock.indexOf( + 'draftSaveSchedulerRef.current?.schedule(() => {', + ); + const callbackBody = scheduledSaveBlock.slice(scheduleCallbackStart); + expect(callbackBody.indexOf('storageKeyForDraftRef.current !== sk')).toBeLessThan( + callbackBody.indexOf('ed.getJSON()'), + ); + }); + + it('does not corrupt the old session draft when the debounce timer fires after a session switch completed mid-flight (voice-input stop/refine/send race)', () => { + const timers: Array<() => void> = []; + const scheduler = createComposerDraftSaveScheduler({ + setTimer: (callback) => { + timers.push(callback); + return timers.length; + }, + clearTimer: () => undefined, + }); + + const oldKey = 'session-switch-guard-old'; + const newKey = 'session-switch-guard-new'; + const oldFinalDoc: JSONContent = { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'old session final text' }] }], + }; + const newSessionDoc: JSONContent = { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'next session draft' }] }], + }; + + // One shared, mutable Tiptap-editor stand-in: ChatInput reuses a single + // editor instance across session switches, so a deferred `ed.getJSON()` + // always reflects whatever document currently lives in the editor — not + // whatever it held back when the task was scheduled. + const editorDoc = { current: oldFinalDoc }; + // Mirrors `storageKeyForDraftRef` — flips only when `restoreNextDraft` + // actually swaps the editor's content over to the next session. + const storageKeyForDraftRef = { current: oldKey as string | undefined }; + + // Seed the "old" session's draft as if the user had already typed + // `oldFinalDoc` and ChatInput's synchronous `saveCurrentEditorDraft()` + // (unrelated to the debounce path) had already persisted it correctly. + saveDraft(oldKey, { text: oldFinalDoc, attachments: [], quotes: [], browserComments: [] }); + // Seed the "new" session's own, independent draft. + saveDraft(newKey, { text: newSessionDoc, attachments: [], quotes: [], browserComments: [] }); + + // onUpdate fires once more for the OLD session right before the voice + // stop/refine/send transaction resolves: capture `sk` eagerly, schedule + // the deferred read/write — exactly the production `onUpdate` shape. + const sk = storageKeyForDraftRef.current; + expect(sk).toBe(oldKey); + scheduler.schedule(() => { + if (storageKeyForDraftRef.current !== sk) return; + const existing = getDraft(sk!); + saveDraft( + sk!, + { + text: editorDoc.current, + attachments: existing?.attachments ?? [], + quotes: existing?.quotes ?? [], + browserComments: existing?.browserComments ?? [], + }, + { silent: true }, + ); + }); + expect(timers).toHaveLength(1); + + // The async voice transition now finishes: production's + // `restoreNextDraft()` swaps the shared editor's content over to the + // next session's document and flips the tracked storage key — all + // BEFORE the debounce timer above has fired. + editorDoc.current = newSessionDoc; + storageKeyForDraftRef.current = newKey; + + // Debounce timer fires late, after the switch already completed. + timers[0]?.(); + + // The guard must have skipped the write: the old session's draft keeps + // its correct final content instead of being overwritten with the new + // session's document. + expect(getDraft(oldKey)?.text).toEqual(oldFinalDoc); + // The new session's own draft (populated by the real restore path, not + // this stale task) must also remain untouched. + expect(getDraft(newKey)?.text).toEqual(newSessionDoc); + }); + + it('still saves normally when the debounce timer fires before any session switch', () => { + const timers: Array<() => void> = []; + const scheduler = createComposerDraftSaveScheduler({ + setTimer: (callback) => { + timers.push(callback); + return timers.length; + }, + clearTimer: () => undefined, + }); + + const key = 'session-switch-guard-no-race'; + const typedDoc: JSONContent = { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'typed while still on this session' }] }, + ], + }; + const editorDoc = { current: typedDoc }; + const storageKeyForDraftRef = { current: key as string | undefined }; + + const sk = storageKeyForDraftRef.current; + scheduler.schedule(() => { + if (storageKeyForDraftRef.current !== sk) return; + const existing = getDraft(sk!); + saveDraft( + sk!, + { + text: editorDoc.current, + attachments: existing?.attachments ?? [], + quotes: existing?.quotes ?? [], + browserComments: existing?.browserComments ?? [], + }, + { silent: true }, + ); + }); + timers[0]?.(); + + expect(getDraft(key)?.text).toEqual(typedDoc); + }); +}); + +function extractBetween(source: string, startNeedle: string, endNeedle: string): string { + const start = source.indexOf(startNeedle); + const end = source.indexOf(endNeedle, start); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + return source.slice(start, end); +} diff --git a/apps/desktop/src/renderer/lib/composerDraftSaveScheduler.ts b/apps/desktop/src/renderer/lib/composerDraftSaveScheduler.ts new file mode 100644 index 00000000000..74b3ab55dcb --- /dev/null +++ b/apps/desktop/src/renderer/lib/composerDraftSaveScheduler.ts @@ -0,0 +1,64 @@ +export interface ComposerDraftSaveScheduler { + schedule(task: () => void): void; + flush(): void; + cancel(): void; +} + +export interface ComposerDraftSaveSchedulerOptions { + delayMs?: number; + setTimer?: (callback: () => void, delayMs: number) => number; + clearTimer?: (handle: number) => void; +} + +const DEFAULT_DELAY_MS = 120; + +/** + * Coalesces draft writes while the user is typing. The latest task is retained + * and can be synchronously flushed at lifecycle boundaries (blur, send, + * session switch, and unmount). + */ +export function createComposerDraftSaveScheduler( + options: ComposerDraftSaveSchedulerOptions = {}, +): ComposerDraftSaveScheduler { + const delayMs = options.delayMs ?? DEFAULT_DELAY_MS; + const setTimer = options.setTimer ?? ((callback, delay) => window.setTimeout(callback, delay)); + const clearTimer = options.clearTimer ?? ((handle) => window.clearTimeout(handle)); + + let pendingTask: (() => void) | null = null; + let timer: number | null = null; + + const flush = (): void => { + if (timer !== null) { + clearTimer(timer); + timer = null; + } + const task = pendingTask; + pendingTask = null; + task?.(); + }; + + return { + schedule(task): void { + pendingTask = task; + if (timer !== null) return; + timer = setTimer(() => { + timer = null; + const next = pendingTask; + pendingTask = null; + next?.(); + }, delayMs); + }, + flush, + cancel(): void { + if (timer !== null) { + clearTimer(timer); + timer = null; + } + pendingTask = null; + }, + }; +} + +export const __composerDraftSaveSchedulerDefaultsForTest = { + delayMs: DEFAULT_DELAY_MS, +};