From 4d1c135684e5f2f0ce5b474e63602bf2bd40d3c3 Mon Sep 17 00:00:00 2001 From: Guillermo Casanova Date: Sun, 11 Oct 2026 23:32:43 -0300 Subject: [PATCH 1/3] fix(web): deleted composer chips release their payloads on thread switch --- apps/web/src/components/chat/ChatComposer.tsx | 34 ++++------- .../composerContextUndo.retention.test.tsx | 61 +++++++++++++++++++ .../components/chat/composerContextUndo.ts | 37 ++++++++++- 3 files changed, 108 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/components/chat/composerContextUndo.retention.test.tsx diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9162c4b67e0d..f40818b60bd9 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -144,7 +144,7 @@ import { import { ComposerActivityRow } from "./ComposerActivityStatus"; import { reconcileAttachmentContextReferences, - type RetainedAttachmentContextPayloads, + useRetainedContextPayloads, } from "./composerContextUndo"; import type { ThreadSyncPhase } from "../../threadSync"; import { ComposerBanner } from "./ComposerBanner"; @@ -3709,20 +3709,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsComposerScrollCollapsed(false); }, [setIsComposerScrollCollapsed]); - /** - * Payloads for chips the prompt no longer references. History undo restores the - * reference text but knows nothing about the draft records behind it, so a delete keeps its - * payload here and an undo puts it back rather than leaving a dangling chip. - */ - const removedContextPayloadsRef = useRef<{ - terminals: Map; - reviewComments: Map; - threads: Map; - }>({ terminals: new Map(), reviewComments: new Map(), threads: new Map() }); - const removedAttachmentContextPayloadsRef = useRef({ - files: new Map(), - previewAnnotations: new Map(), - }); + const retainedContextPayloads = useRetainedContextPayloads(composerDraftTargetKey); const onPromptChange = useCallback( ( @@ -3758,7 +3745,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptHistoryPositionRef.current = null; } const referenced = new Set(contextIds); - const retained = removedContextPayloadsRef.current; // An undone delete brings the reference back; restore the payload it points at. const liveTerminalIds = new Set( @@ -3766,7 +3752,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const restoredTerminals = [...referenced].flatMap((contextId) => { if (liveTerminalIds.has(contextId)) return []; - const context = retained.terminals.get(contextId); + const context = retainedContextPayloads.terminals.get(contextId); return context ? [context] : []; }); const nextTerminals = [ @@ -3777,7 +3763,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ]; for (const context of composerTerminalContexts) { const contextId = terminalContextReference(context).contextId; - if (!referenced.has(contextId)) retained.terminals.set(contextId, context); + if (!referenced.has(contextId)) retainedContextPayloads.terminals.set(contextId, context); } if ( nextTerminals.length !== composerTerminalContexts.length || @@ -3791,7 +3777,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const restoredThreads = [...referenced].flatMap((contextId) => { if (liveThreadIds.has(contextId)) return []; - const record = retained.threads.get(contextId); + const record = retainedContextPayloads.threads.get(contextId); return record ? [record] : []; }); const nextThreads = [ @@ -3799,7 +3785,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ...restoredThreads, ]; for (const record of composerThreadContexts) { - if (!referenced.has(record.contextId)) retained.threads.set(record.contextId, record); + if (!referenced.has(record.contextId)) + retainedContextPayloads.threads.set(record.contextId, record); } if (nextThreads.length !== composerThreadContexts.length || restoredThreads.length > 0) { setComposerDraftThreadContexts(composerDraftTarget, nextThreads); @@ -3808,7 +3795,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) for (const comment of composerReviewComments) { const contextId = reviewCommentContextId(comment.id); if (!referenced.has(contextId)) { - retained.reviewComments.set(contextId, comment); + retainedContextPayloads.reviewComments.set(contextId, comment); removeComposerDraftReviewComment(composerDraftTarget, comment.id); } } @@ -3817,7 +3804,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); for (const contextId of referenced) { if (liveReviewIds.has(contextId)) continue; - const comment = retained.reviewComments.get(contextId); + const comment = retainedContextPayloads.reviewComments.get(contextId); if (comment) { addComposerDraftReviewComment(composerDraftTarget, comment, { appendReference: false }); } @@ -3828,7 +3815,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) files: composerFiles, images: composerImages, previewAnnotations: composerPreviewAnnotations, - retained: removedAttachmentContextPayloadsRef.current, + retained: retainedContextPayloads, }); for (const annotationId of attachmentChanges.annotationIdsToRemove) { // Keep the upload queue entry alive: undo restores the image that owns it. @@ -3878,6 +3865,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) addComposerDraftImages, addComposerDraftFiles, attachmentDraftTarget, + retainedContextPayloads, ], ); diff --git a/apps/web/src/components/chat/composerContextUndo.retention.test.tsx b/apps/web/src/components/chat/composerContextUndo.retention.test.tsx new file mode 100644 index 000000000000..bb6cda42eb06 --- /dev/null +++ b/apps/web/src/components/chat/composerContextUndo.retention.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import { act, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it } from "vite-plus/test"; + +import type { ComposerFileAttachment } from "../../composerDraftStore"; +import { type RetainedContextPayloads, useRetainedContextPayloads } from "./composerContextUndo"; + +const file = { + type: "file", + id: "file-1", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 5, + file: new File(["notes"], "notes.txt", { type: "text/plain" }), +} satisfies ComposerFileAttachment; + +let container: HTMLDivElement; +let root: Root; +let current: RetainedContextPayloads; + +function Probe({ targetKey }: { targetKey: string }) { + const retained = useRetainedContextPayloads(targetKey); + useEffect(() => { + current = retained; + }); + return null; +} + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); +}); + +it("keeps deleted payloads for the open draft and releases them when the draft changes", () => { + act(() => root.render()); + const retained = current; + retained.files.set("file:file-1", file); + + act(() => root.render()); + expect(current.files.get("file:file-1")).toBe(file); + + act(() => root.render()); + expect(retained.files.size).toBe(0); + expect(current.files.size).toBe(0); +}); + +it("releases deleted payloads when the composer unmounts", () => { + act(() => root.render()); + const retained = current; + retained.files.set("file:file-1", file); + + act(() => root.unmount()); + expect(retained.files.size).toBe(0); + root = createRoot(container); +}); diff --git a/apps/web/src/components/chat/composerContextUndo.ts b/apps/web/src/components/chat/composerContextUndo.ts index 4ed6371e5af9..3b88c598d416 100644 --- a/apps/web/src/components/chat/composerContextUndo.ts +++ b/apps/web/src/components/chat/composerContextUndo.ts @@ -1,7 +1,10 @@ -import type { PreviewAnnotationPayload } from "@t3tools/contracts"; +import type { PreviewAnnotationPayload, ThreadContextRecord } from "@t3tools/contracts"; +import { useEffect, useState } from "react"; import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore"; import { fileContextReference, previewAnnotationContextId } from "../../lib/composerContextRecords"; +import type { TerminalContextDraft } from "../../lib/terminalContext"; +import type { ReviewCommentContext } from "../../reviewCommentContext"; interface RetainedPreviewAnnotation { annotation: PreviewAnnotationPayload; @@ -13,6 +16,38 @@ export interface RetainedAttachmentContextPayloads { previewAnnotations: Map; } +export interface RetainedContextPayloads extends RetainedAttachmentContextPayloads { + terminals: Map; + reviewComments: Map; + threads: Map; +} + +/** + * Payloads for chips the prompt no longer references. History undo restores the + * reference text but knows nothing about the draft records behind it, so a delete keeps its + * payload here and an undo puts it back rather than leaving a dangling chip. + * + * Scoped to one draft: switching drafts or unmounting releases them, so deleted files do not + * stay reachable for the composer's whole lifetime. + */ +export function useRetainedContextPayloads(draftTargetKey: string): RetainedContextPayloads { + const [retained] = useState(() => ({ + terminals: new Map(), + reviewComments: new Map(), + threads: new Map(), + files: new Map(), + previewAnnotations: new Map(), + })); + useEffect( + () => () => { + for (const payloads of Object.values(retained)) payloads.clear(); + }, + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Switching drafts releases the previous draft's payloads. + [draftTargetKey, retained], + ); + return retained; +} + export function reconcileAttachmentContextReferences(input: { referencedContextIds: ReadonlySet; files: ReadonlyArray; From 48816a3d9ca7b3cdf6784fae14193a4d636e7117 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 02:36:49 +0000 Subject: [PATCH 2/3] fix(web): end composer undo history when switching drafts --- ...rPromptEditorTiptap.input.browser.test.tsx | 158 +++++++++++++++++- .../components/ComposerPromptEditorTiptap.tsx | 5 +- apps/web/src/components/chat/ChatComposer.tsx | 1 + .../composerContextUndo.retention.test.tsx | 61 ------- .../chat/composerContextUndo.test.ts | 52 +----- .../components/chat/composerContextUndo.ts | 27 +-- 6 files changed, 176 insertions(+), 128 deletions(-) delete mode 100644 apps/web/src/components/chat/composerContextUndo.retention.test.tsx diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 8af19e152c76..928aaaa34d40 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -1,10 +1,22 @@ import type { Editor } from "@tiptap/core"; -import { act, createRef } from "react"; +import { act, createElement, createRef, useEffect, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { serializeEditorDoc } from "~/composer-rich-text-doc"; -import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; +import { + ComposerPromptEditor, + type ComposerPromptEditorHandle, + type ComposerPromptEditorProps, +} from "./ComposerPromptEditor"; +import type { ComposerFileAttachment } from "../composerDraftStore"; +import { formatInlineContextReference } from "../lib/composerContextReferences"; +import { fileContextReference } from "../lib/composerContextRecords"; +import { + reconcileAttachmentContextReferences, + type RetainedContextPayloads, + useRetainedContextPayloads, +} from "./chat/composerContextUndo"; let root: Root; let container: HTMLDivElement; @@ -135,3 +147,143 @@ it.each([false, true])( expect(serializeEditorDoc(editor.state.doc).value).toBe('"word"'); }, ); + +const file = { + type: "file", + id: "file-1", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 5, + file: new File(["notes"], "notes.txt", { type: "text/plain" }), +} satisfies ComposerFileAttachment; + +describe("composer context undo lifetime", () => { + async function mountComposer(destinationValue = "") { + const initialValue = `Draft A ${formatInlineContextReference(fileContextReference(file))}`; + const editorRef = createRef(); + let current: { value: string; files: ComposerFileAttachment[] }; + let retained: RetainedContextPayloads; + + function Composer({ targetKey }: { targetKey: string }) { + const [drafts, setDrafts] = useState< + Record + >({ + a: { value: initialValue, files: [file] }, + b: { value: destinationValue, files: [] }, + }); + const payloads = useRetainedContextPayloads(targetKey); + const draft = drafts[targetKey]!; + useEffect(() => { + current = draft; + retained = payloads; + }); + const editorProps = { + draftKey: targetKey, + editorRef, + value: draft.value, + cursor: 0, + contextRecords: new Map( + draft.files.map((entry) => [ + fileContextReference(entry).contextId, + { kind: "file" as const, record: entry }, + ]), + ), + skills: [], + disabled: false, + placeholder: "", + onChange: (value, _cursor, _expandedCursor, _adjacent, contextIds) => { + const changes = reconcileAttachmentContextReferences({ + referencedContextIds: new Set(contextIds), + files: draft.files, + images: [], + previewAnnotations: [], + retained: payloads, + }); + setDrafts({ + ...drafts, + [targetKey]: { + value, + files: [ + ...draft.files.filter((entry) => !changes.filesToRemove.includes(entry.id)), + ...changes.filesToRestore, + ], + }, + }); + }, + onPaste: () => {}, + } satisfies ComposerPromptEditorProps; + return createElement(ComposerPromptEditor, editorProps); + } + + const render = async (targetKey: string) => { + await act(async () => root.render(createElement(Composer, { targetKey }))); + }; + const editor = () => { + const element = container.querySelector(".tiptap"); + if (!element) throw new Error("Composer did not mount"); + return element.editor; + }; + await render("a"); + return { + initialValue, + render, + editor, + draft: () => current, + retained: () => retained, + deleteDraft: async () => { + await act(async () => { + const currentEditor = editor(); + currentEditor.view.dispatch( + currentEditor.state.tr.delete(1, currentEditor.state.doc.content.size - 1), + ); + }); + }, + undo: async () => { + await act(async () => { + editor().commands.undo(); + }); + }, + }; + } + + it("restores a deleted chip and its file bytes on same-draft undo, then releases them on unmount", async () => { + const composer = await mountComposer(); + await composer.deleteDraft(); + expect(composer.draft()).toEqual({ value: "", files: [] }); + const retained = composer.retained(); + expect(retained.files.get(fileContextReference(file).contextId)?.file).toBe(file.file); + + await composer.render("a"); + await composer.undo(); + expect(serializeEditorDoc(composer.editor().state.doc).value).toBe(composer.initialValue); + expect(composer.draft()).toEqual({ value: composer.initialValue, files: [file] }); + expect(composer.draft().files[0]?.file).toBe(file.file); + + await composer.deleteDraft(); + await act(async () => root.unmount()); + expect(retained.files.size).toBe(0); + root = createRoot(container); + }); + + it.each(["", "Draft B"])( + "releases deleted payloads and prevents undo into the previous draft when switching to %j", + async (destinationValue) => { + const composer = await mountComposer(destinationValue); + await composer.deleteDraft(); + expect(composer.draft()).toEqual({ value: "", files: [] }); + const retained = composer.retained(); + expect(retained.files.get(fileContextReference(file).contextId)?.file).toBe(file.file); + + await composer.render("b"); + expect(retained.files.size).toBe(0); + await composer.undo(); + expect(serializeEditorDoc(composer.editor().state.doc).value).toBe(destinationValue); + expect(composer.draft()).toEqual({ value: destinationValue, files: [] }); + + await composer.render("a"); + await composer.undo(); + expect(serializeEditorDoc(composer.editor().state.doc).value).toBe(""); + expect(composer.draft()).toEqual({ value: "", files: [] }); + }, + ); +}); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 14562f73cd47..4ae09cc9990f 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -136,6 +136,7 @@ export interface ComposerPromptEditorHandle { } export interface ComposerPromptEditorProps { + draftKey?: string; value: string; cursor: number; richTextEnabled?: boolean; @@ -744,10 +745,10 @@ const ComposerMarkersExtension = Extension.create({ type TiptapEditor = NonNullable>; export function ComposerPromptEditorTiptap(props: ComposerPromptEditorProps) { - // Extensions are creation-time, so changing text mode remounts the editor. + // Draft switches end undo history; text mode changes require new extensions. return ( ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9603f3c9bd58..6989f72dad94 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -7046,6 +7046,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : null} { - current = retained; - }); - return null; -} - -beforeEach(() => { - (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - container = document.createElement("div"); - root = createRoot(container); -}); - -afterEach(() => { - act(() => root.unmount()); -}); - -it("keeps deleted payloads for the open draft and releases them when the draft changes", () => { - act(() => root.render()); - const retained = current; - retained.files.set("file:file-1", file); - - act(() => root.render()); - expect(current.files.get("file:file-1")).toBe(file); - - act(() => root.render()); - expect(retained.files.size).toBe(0); - expect(current.files.size).toBe(0); -}); - -it("releases deleted payloads when the composer unmounts", () => { - act(() => root.render()); - const retained = current; - retained.files.set("file:file-1", file); - - act(() => root.unmount()); - expect(retained.files.size).toBe(0); - root = createRoot(container); -}); diff --git a/apps/web/src/components/chat/composerContextUndo.test.ts b/apps/web/src/components/chat/composerContextUndo.test.ts index 6405444f67ad..a763b47a0d6d 100644 --- a/apps/web/src/components/chat/composerContextUndo.test.ts +++ b/apps/web/src/components/chat/composerContextUndo.test.ts @@ -1,26 +1,14 @@ import type { PreviewAnnotationPayload } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore"; -import { - buildMessageContext, - fileContextReference, - previewAnnotationContextId, -} from "../../lib/composerContextRecords"; +import type { ComposerImageAttachment } from "../../composerDraftStore"; +import { buildMessageContext, previewAnnotationContextId } from "../../lib/composerContextRecords"; import { reconcileAttachmentContextReferences, type RetainedAttachmentContextPayloads, } from "./composerContextUndo"; const binary = new File(["binary"], "context.png", { type: "image/png" }); -const file = { - type: "file", - id: "file-1", - name: "notes.txt", - mimeType: "text/plain", - sizeBytes: 5, - file: new File(["notes"], "notes.txt", { type: "text/plain" }), -} satisfies ComposerFileAttachment; const image = { type: "image", id: "annotation-1", @@ -53,42 +41,6 @@ function retention(): RetainedAttachmentContextPayloads { } describe("reconcileAttachmentContextReferences", () => { - it("restores file bytes after deleting and undoing its chip", () => { - const retained = retention(); - const removed = reconcileAttachmentContextReferences({ - referencedContextIds: new Set(), - files: [file], - images: [], - previewAnnotations: [], - retained, - }); - expect(removed.filesToRemove).toEqual(["file-1"]); - - const restored = reconcileAttachmentContextReferences({ - referencedContextIds: new Set([fileContextReference(file).contextId]), - files: [], - images: [], - previewAnnotations: [], - retained, - }); - expect(restored.filesToRestore).toEqual([file]); - expect(restored.filesToRestore[0]?.file).toBe(file.file); - expect( - buildMessageContext({ - terminalContexts: [], - reviewComments: [], - previewAnnotations: [], - attachments: [{ attachment: restored.filesToRestore[0]!, attachmentId: "uploaded-file" }], - })?.records, - ).toEqual([ - expect.objectContaining({ - kind: "file", - contextId: "file_file-1", - attachmentId: "uploaded-file", - }), - ]); - }); - it("restores an annotation and its screenshot attachment after undo", () => { const retained = retention(); const removed = reconcileAttachmentContextReferences({ diff --git a/apps/web/src/components/chat/composerContextUndo.ts b/apps/web/src/components/chat/composerContextUndo.ts index 3b88c598d416..9fc27ed079c8 100644 --- a/apps/web/src/components/chat/composerContextUndo.ts +++ b/apps/web/src/components/chat/composerContextUndo.ts @@ -22,28 +22,31 @@ export interface RetainedContextPayloads extends RetainedAttachmentContextPayloa threads: Map; } -/** - * Payloads for chips the prompt no longer references. History undo restores the - * reference text but knows nothing about the draft records behind it, so a delete keeps its - * payload here and an undo puts it back rather than leaving a dangling chip. - * - * Scoped to one draft: switching drafts or unmounting releases them, so deleted files do not - * stay reachable for the composer's whole lifetime. - */ -export function useRetainedContextPayloads(draftTargetKey: string): RetainedContextPayloads { - const [retained] = useState(() => ({ +function createRetainedContextPayloads(): RetainedContextPayloads { + return { terminals: new Map(), reviewComments: new Map(), threads: new Map(), files: new Map(), previewAnnotations: new Map(), + }; +} + +// Deleted chips keep their payloads until their draft's undo history ends. +export function useRetainedContextPayloads(draftTargetKey: string): RetainedContextPayloads { + const [draft, setDraft] = useState(() => ({ + targetKey: draftTargetKey, + payloads: createRetainedContextPayloads(), })); + if (draft.targetKey !== draftTargetKey) { + setDraft({ targetKey: draftTargetKey, payloads: createRetainedContextPayloads() }); + } + const retained = draft.payloads; useEffect( () => () => { for (const payloads of Object.values(retained)) payloads.clear(); }, - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Switching drafts releases the previous draft's payloads. - [draftTargetKey, retained], + [retained], ); return retained; } From 7e2c603b7e308a59ba7acf2d77df9ef4dc30c220 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 02:43:59 +0000 Subject: [PATCH 3/3] fix(web): preserve composer focus across draft changes --- ...rPromptEditorTiptap.input.browser.test.tsx | 25 +++++++++++++++++++ .../components/ComposerPromptEditorTiptap.tsx | 21 +++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 928aaaa34d40..fcf3f62455eb 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -2,6 +2,7 @@ import type { Editor } from "@tiptap/core"; import { act, createElement, createRef, useEffect, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; import { serializeEditorDoc } from "~/composer-rich-text-doc"; import { @@ -246,6 +247,30 @@ describe("composer context undo lifetime", () => { }; } + it("preserves typing focus across draft switches without taking focus from another control", async () => { + const composer = await mountComposer("Draft B"); + const previousEditor = composer.editor(); + await act(async () => { + previousEditor.commands.setTextSelection(previousEditor.state.doc.content.size - 1); + previousEditor.view.focus(); + }); + expect(document.activeElement).toBe(previousEditor.view.dom); + + await composer.render("b"); + const nextEditor = composer.editor(); + expect(nextEditor).not.toBe(previousEditor); + expect(document.activeElement).toBe(nextEditor.view.dom); + await act(async () => userEvent.keyboard("x")); + expect(composer.draft().value).toBe("xDraft B"); + + const button = document.createElement("button"); + container.append(button); + button.focus(); + await composer.render("a"); + expect(document.activeElement).toBe(button); + expect(composer.draft().value).toBe(composer.initialValue); + }); + it("restores a deleted chip and its file bytes on same-draft undo, then releases them on unmount", async () => { const composer = await mountComposer(); await composer.deleteDraft(); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 4ae09cc9990f..0ab9d022068a 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -43,6 +43,7 @@ import { useRef, useState, type KeyboardEvent as ReactKeyboardEvent, + type RefObject, } from "react"; import { EditorContent, useEditor } from "@tiptap/react"; @@ -745,11 +746,13 @@ const ComposerMarkersExtension = Extension.create({ type TiptapEditor = NonNullable>; export function ComposerPromptEditorTiptap(props: ComposerPromptEditorProps) { + const restoreFocusRef = useRef(false); // Draft switches end undo history; text mode changes require new extensions. return ( ); } @@ -880,7 +883,9 @@ function createComposerExtensions(richText: boolean) { const RICH_TEXT_EXTENSIONS = createComposerExtensions(true); const PLAIN_TEXT_EXTENSIONS = createComposerExtensions(false); -function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { +function ComposerPromptEditorTiptapInner( + props: ComposerPromptEditorProps & { restoreFocusRef: RefObject }, +) { const { value, cursor, @@ -909,6 +914,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { onCitationDraftSave, onPaste, editorRef, + restoreFocusRef, } = props; const richText = !literalText && (richTextEnabled ?? false); const clampEditorCursor = (text: string, position: number) => @@ -1580,6 +1586,19 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { value, ]); + useLayoutEffect(() => { + if (!editor) return; + if (restoreFocusRef.current) { + restoreFocusRef.current = false; + editor.view.focus(); + scrollTiptapCaretIntoView(editor); + } + return () => { + // Capture focus before the keyed editor's DOM is removed. + restoreFocusRef.current = editor.view.hasFocus(); + }; + }, [editor, restoreFocusRef]); + const focusAt = (nextCursor: number) => { if (!editor) return; // view.focus() writes the state selection to the DOM as it focuses. A