From 549a8077f61a89ce5f90cd015fc54a0f8b6857ce Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 13:32:58 +0000 Subject: [PATCH 1/4] test(web): queued message edit keeps thread mention records Reproduces #30850: beginning an edit of a queued message whose context carries a thread record leaves the edit draft without that thread context, so the mention renders as an unavailable context chip. AI-assisted; verified with the tests and tools listed above. --- .../chat/useQueuedRunEdit.browser.test.tsx | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx index cc6e04885c623..4b7b7f5eecdcc 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx +++ b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx @@ -9,11 +9,16 @@ import { } from "@t3tools/contracts"; import { useImperativeHandle, type RefObject } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; -import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; +import { + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + threadContextRecord, +} from "@t3tools/shared/composerContextReferences"; import { toastManager } from "../ui/toast"; import { act, render } from "../../../test/render"; import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore"; +import { formatInlineContextReference } from "../../lib/composerContextReferences"; +import { threadContextReference } from "../../lib/composerContextRecords"; import { deriveComposerSendState } from "../ChatView.logic"; import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { AppAtomRegistryProvider, appAtomRegistry } from "../../rpc/atomRegistry"; @@ -74,8 +79,8 @@ const RUN = RunId.make("run-1"); type HookResult = ReturnType; -function projection(runs: ReadonlyArray) { - return { runs, messages: [] } as unknown as ThreadStructure; +function projection(runs: ReadonlyArray, messages: ReadonlyArray) { + return { runs, messages } as unknown as ThreadStructure; } function setRuns(ref: ScopedThreadRef, status: string) { @@ -86,13 +91,14 @@ function Harness(props: { readonly threadRef: ScopedThreadRef; readonly runStatus: string; readonly hookRef: RefObject; + readonly messages: ReadonlyArray; }) { const result = useQueuedRunEdit({ environmentId: ENV, routeThreadRef: props.threadRef, threadDraftTarget: props.threadRef, activeThreadId: props.threadRef.threadId, - serverProjection: projection([{ id: RUN, status: props.runStatus }]), + serverProjection: projection([{ id: RUN, status: props.runStatus }], props.messages), promptRef: { current: "" }, composerRef: { current: null }, }); @@ -127,10 +133,15 @@ function Harness(props: { ) : null; } -function view(threadRef: ScopedThreadRef, runStatus: string, hook: { current: HookResult | null }) { +function view( + threadRef: ScopedThreadRef, + runStatus: string, + hook: { current: HookResult | null }, + messages: ReadonlyArray = [], +) { return ( - + ); } @@ -265,6 +276,21 @@ describe("useQueuedRunEdit", () => { ); }); + it("opens an edit with the thread records its mentions refer to", async () => { + const { hook } = setup(); + const mentioned = threadContextRecord(THREAD_B, "Support Any File Uploads"); + const messageId = MessageId.make("message-1"); + const text = `wait for ${formatInlineContextReference(threadContextReference(mentioned))} to merge`; + const messages = [{ id: messageId, text, context: { version: 1, records: [mentioned] } }]; + await using _screen = await render(view(THREAD_A, "queued", hook, messages)); + + await act(() => requireHook(hook).beginEdit({ runId: RUN, messageId, text, attachments: [] })); + + const draft = useComposerDraftStore.getState().getComposerDraft(requireHook(hook).draftTarget); + expect(draft?.prompt).toBe(text); + expect(draft?.threadContexts).toEqual([mentioned]); + }); + it("keeps an open edit when the user leaves the thread and comes back", async () => { const { hook } = setup(); await using screen = await render(view(THREAD_A, "queued", hook)); From a2b706e90209588578698b44b55d1d5f46429318 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 13:39:25 +0000 Subject: [PATCH 2/4] fix(web): editing a queued message keeps its thread mention chips Opening "Edit queued message" seeded the edit draft with the prompt text only, so thread and machine mentions had no record behind them and rendered as "Unavailable context" chips. The edit draft now starts with the message's thread and environment records, and the save takes those kinds from the draft alone, so a mention removed while editing is no longer sent. AI-assisted; verified with the tests and tools listed above. --- .../chat/useQueuedRunEdit.browser.test.tsx | 30 +++++++++++++++++++ .../src/components/chat/useQueuedRunEdit.ts | 25 +++++++++++++--- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx index 4b7b7f5eecdcc..4d80c96f0b86f 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx +++ b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx @@ -291,6 +291,36 @@ describe("useQueuedRunEdit", () => { expect(draft?.threadContexts).toEqual([mentioned]); }); + it("saves only the thread mentions still in the edited message", async () => { + const { hook } = setup(); + mocks.editQueuedRun.mockResolvedValue({ _tag: "Success" }); + const kept = threadContextRecord(THREAD_B, "Kept"); + const removed = threadContextRecord(THREAD_A, "Removed"); + const messageId = MessageId.make("message-1"); + const text = [kept, removed] + .map((record) => formatInlineContextReference(threadContextReference(record))) + .join(" and "); + const messages = [{ id: messageId, text, context: { version: 1, records: [kept, removed] } }]; + await using _screen = await render(view(THREAD_A, "queued", hook, messages)); + await act(() => requireHook(hook).beginEdit({ runId: RUN, messageId, text, attachments: [] })); + const target = requireHook(hook).draftTarget; + await act(() => useComposerDraftStore.getState().setThreadContexts(target, [kept])); + + const draft = useComposerDraftStore.getState().getComposerDraft(target); + await requireHook(hook).saveEdit( + { ...savedContent(draft?.prompt ?? ""), threadContexts: draft?.threadContexts ?? [] }, + () => {}, + ); + + expect(mocks.editQueuedRun).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + edit: expect.objectContaining({ context: { version: 1, records: [kept] } }), + }), + }), + ); + }); + it("keeps an open edit when the user leaves the thread and comes back", async () => { const { hook } = setup(); await using screen = await render(view(THREAD_A, "queued", hook)); diff --git a/apps/web/src/components/chat/useQueuedRunEdit.ts b/apps/web/src/components/chat/useQueuedRunEdit.ts index d9185775ea000..c924bbf5ad308 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.ts +++ b/apps/web/src/components/chat/useQueuedRunEdit.ts @@ -7,6 +7,7 @@ import type { ThreadStructure } from "@t3tools/client-runtime/state/shell"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type ChatAttachment, + type ComposerContextRecord, type EnvironmentContextRecord, type EnvironmentId, type MessageId, @@ -83,6 +84,9 @@ interface QueuedRunEditContent { readonly environmentContexts: ReadonlyArray; } +const isDraftOwnedRecord = (record: ComposerContextRecord) => + record.kind === "thread" || record.kind === "environment"; + function queuedEditDraftTarget(routeThreadRef: ScopedThreadRef, runId: RunId) { return DraftId.make(`queued-edit:${scopedThreadKey(routeThreadRef)}:${runId}`); } @@ -125,10 +129,12 @@ async function submitQueuedRunEdit(input: { readImage: readFileAsDataUrl, uploadFiles, }); + // The edit draft owns thread and machine mentions, so a chip removed while editing is dropped. const keptRecords = (editing.context?.records ?? []).filter( (record) => - !("attachmentId" in record) || - editing.existingAttachments.some((attachment) => attachment.id === record.attachmentId), + !isDraftOwnedRecord(record) && + (!("attachmentId" in record) || + editing.existingAttachments.some((attachment) => attachment.id === record.attachmentId)), ); const newRecords = buildMessageContext({ @@ -231,9 +237,21 @@ export function useQueuedRunEdit(input: { if (editing !== null && editing.runId !== request.runId) clearEditDraft(editing.runId); const target = queuedEditDraftTarget(routeThreadRef, request.runId); const text = stripAttachmentOnlyBootstrapPrompt(request.text); + const context = serverProjection?.messages.find( + (message) => message.id === request.messageId, + )?.context; + const records = context?.records ?? []; const store = useComposerDraftStore.getState(); store.clearComposerContent(target); store.setPrompt(target, text); + store.setThreadContexts( + target, + records.filter((record): record is ThreadContextRecord => record.kind === "thread"), + ); + store.setEnvironmentContexts( + target, + records.filter((record): record is EnvironmentContextRecord => record.kind === "environment"), + ); const threadRef = { environmentId, threadId: activeThreadId }; queuedRunEdits.begin( editKey, @@ -243,8 +261,7 @@ export function useQueuedRunEdit(input: { messageId: request.messageId, originalText: text, existingAttachments: request.attachments, - context: serverProjection?.messages.find((message) => message.id === request.messageId) - ?.context, + context, }, { runs: makeQueuedRunsAtom({ From a3e45646e5b0b97c037c892384668cd30ea85e0e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 13:54:10 +0000 Subject: [PATCH 3/4] fix(web): queued edits seed only referenced mentions and drop the draft on recovery Opening a queued-message edit seeded every thread and machine record from the message context, and the store re-inserted any record the text did not reference, bringing back removed mentions and making an untouched edit look changed. Seed only records whose reference appears in the message text. When the run left the queue and the edit was kept, moving it to the thread draft left the edit draft's mention records behind, so the queued-edit draft persisted forever. Clear the edit draft after the move too. AI-assisted; verified with the tests and tools listed above. --- .../components/chat/queuedMessageEdit.test.ts | 13 ++++++++ .../src/components/chat/queuedMessageEdit.ts | 3 +- .../chat/useQueuedRunEdit.browser.test.tsx | 33 +++++++++++++++---- .../src/components/chat/useQueuedRunEdit.ts | 4 ++- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/queuedMessageEdit.test.ts b/apps/web/src/components/chat/queuedMessageEdit.test.ts index fd5cc6ac17062..cb63f4113b898 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.test.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.test.ts @@ -1,5 +1,6 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; import { beforeEach, describe, expect, it } from "vite-plus/test"; import { DraftId, @@ -86,6 +87,18 @@ describe("queued message file edits", () => { expect(store.getComposerDraft(editTarget)).toBeNull(); }); + it("removes a kept edit draft that held a thread mention", () => { + const store = useComposerDraftStore.getState(); + store.setPrompt(editTarget, "Edited message"); + store.setThreadContexts(editTarget, [ + threadContextRecord(scopeThreadRef(environmentId, ThreadId.make("thread:other")), "Other"), + ]); + expect( + recoverQueuedMessageEdit({ editTarget, threadTarget, originalText: "Original message" }), + ).toBe("kept"); + expect(store.getComposerDraft(editTarget)).toBeNull(); + }); + it("does not overwrite a separate draft when the queued run leaves", () => { const store = useComposerDraftStore.getState(); store.setPrompt(threadTarget, "Separate draft"); diff --git a/apps/web/src/components/chat/queuedMessageEdit.ts b/apps/web/src/components/chat/queuedMessageEdit.ts index 5d1d9fce27103..e4d83255b9a3e 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.ts @@ -23,9 +23,8 @@ export function recoverQueuedMessageEdit(input: { }); if (recovery === "kept") { store.moveComposerPromptAndImages(input.editTarget, input.threadTarget); - } else { - store.clearComposerContent(input.editTarget); } + store.clearComposerContent(input.editTarget); return recovery; } diff --git a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx index 4d80c96f0b86f..ebbf8cf8e7d8a 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx +++ b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx @@ -18,7 +18,11 @@ import { toastManager } from "../ui/toast"; import { act, render } from "../../../test/render"; import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore"; import { formatInlineContextReference } from "../../lib/composerContextReferences"; -import { threadContextReference } from "../../lib/composerContextRecords"; +import { + environmentContextRecord, + environmentContextReference, + threadContextReference, +} from "../../lib/composerContextRecords"; import { deriveComposerSendState } from "../ChatView.logic"; import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { AppAtomRegistryProvider, appAtomRegistry } from "../../rpc/atomRegistry"; @@ -276,19 +280,36 @@ describe("useQueuedRunEdit", () => { ); }); - it("opens an edit with the thread records its mentions refer to", async () => { + it("opens an edit with the thread and machine records its mentions refer to", async () => { + const { hook } = setup(); + const thread = threadContextRecord(THREAD_B, "Support Any File Uploads"); + const machine = environmentContextRecord(ENV, "Build box"); + const messageId = MessageId.make("message-1"); + const text = `wait for ${formatInlineContextReference(threadContextReference(thread))} on ${formatInlineContextReference(environmentContextReference(machine))}`; + const messages = [{ id: messageId, text, context: { version: 1, records: [thread, machine] } }]; + await using _screen = await render(view(THREAD_A, "queued", hook, messages)); + + await act(() => requireHook(hook).beginEdit({ runId: RUN, messageId, text, attachments: [] })); + + const draft = useComposerDraftStore.getState().getComposerDraft(requireHook(hook).draftTarget); + expect(draft?.prompt).toBe(text); + expect(draft?.threadContexts).toEqual([thread]); + expect(draft?.environmentContexts).toEqual([machine]); + }); + + it("opens an edit without records the message text no longer mentions", async () => { const { hook } = setup(); - const mentioned = threadContextRecord(THREAD_B, "Support Any File Uploads"); + const unreferenced = threadContextRecord(THREAD_B, "Removed earlier"); const messageId = MessageId.make("message-1"); - const text = `wait for ${formatInlineContextReference(threadContextReference(mentioned))} to merge`; - const messages = [{ id: messageId, text, context: { version: 1, records: [mentioned] } }]; + const text = "plain text"; + const messages = [{ id: messageId, text, context: { version: 1, records: [unreferenced] } }]; await using _screen = await render(view(THREAD_A, "queued", hook, messages)); await act(() => requireHook(hook).beginEdit({ runId: RUN, messageId, text, attachments: [] })); const draft = useComposerDraftStore.getState().getComposerDraft(requireHook(hook).draftTarget); expect(draft?.prompt).toBe(text); - expect(draft?.threadContexts).toEqual([mentioned]); + expect(draft?.threadContexts).toEqual([]); }); it("saves only the thread mentions still in the edited message", async () => { diff --git a/apps/web/src/components/chat/useQueuedRunEdit.ts b/apps/web/src/components/chat/useQueuedRunEdit.ts index c924bbf5ad308..b4e52c6499812 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.ts +++ b/apps/web/src/components/chat/useQueuedRunEdit.ts @@ -41,6 +41,7 @@ import { startAttachmentUpload, } from "../../lib/attachmentUploadQueue"; import { buildMessageContext } from "../../lib/composerContextRecords"; +import { collectInlineContextIds } from "../../lib/composerContextReferences"; import { readFileAsDataUrl } from "../../lib/imageCompression"; import type { TerminalContextDraft } from "../../lib/terminalContext"; import type { ReviewCommentContext } from "../../reviewCommentContext"; @@ -240,7 +241,8 @@ export function useQueuedRunEdit(input: { const context = serverProjection?.messages.find( (message) => message.id === request.messageId, )?.context; - const records = context?.records ?? []; + const referenced = new Set(collectInlineContextIds(text)); + const records = (context?.records ?? []).filter((record) => referenced.has(record.contextId)); const store = useComposerDraftStore.getState(); store.clearComposerContent(target); store.setPrompt(target, text); From e83f8db7bea81afe85e7d0468068458b6145a7a5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 23:16:53 +0000 Subject: [PATCH 4/4] test(web): cover unreferenced queued edit records in the open-edit case AI-assisted; verified with the tests and tools listed above. --- .../chat/useQueuedRunEdit.browser.test.tsx | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx index 9a6d878c46943..0320dd6d6bb18 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx +++ b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx @@ -277,13 +277,16 @@ describe("useQueuedRunEdit", () => { ); }); - it("opens an edit with the thread and machine records its mentions refer to", async () => { + it("opens an edit with only the thread and machine records its text still mentions", async () => { const { hook } = setup(); const thread = threadContextRecord(THREAD_B, "Support Any File Uploads"); const machine = environmentContextRecord(ENV, "Build box"); + const unreferenced = threadContextRecord(THREAD_A, "Removed earlier"); const messageId = MessageId.make("message-1"); const text = `wait for ${formatInlineContextReference(threadContextReference(thread))} on ${formatInlineContextReference(environmentContextReference(machine))}`; - const messages = [{ id: messageId, text, context: { version: 1, records: [thread, machine] } }]; + const messages = [ + { id: messageId, text, context: { version: 1, records: [thread, machine, unreferenced] } }, + ]; await using _screen = await render(view(THREAD_A, "queued", hook, messages)); await act(() => requireHook(hook).beginEdit({ runId: RUN, messageId, text, attachments: [] })); @@ -294,21 +297,6 @@ describe("useQueuedRunEdit", () => { expect(draft?.environmentContexts).toEqual([machine]); }); - it("opens an edit without records the message text no longer mentions", async () => { - const { hook } = setup(); - const unreferenced = threadContextRecord(THREAD_B, "Removed earlier"); - const messageId = MessageId.make("message-1"); - const text = "plain text"; - const messages = [{ id: messageId, text, context: { version: 1, records: [unreferenced] } }]; - await using _screen = await render(view(THREAD_A, "queued", hook, messages)); - - await act(() => requireHook(hook).beginEdit({ runId: RUN, messageId, text, attachments: [] })); - - const draft = useComposerDraftStore.getState().getComposerDraft(requireHook(hook).draftTarget); - expect(draft?.prompt).toBe(text); - expect(draft?.threadContexts).toEqual([]); - }); - it("saves only the thread mentions still in the edited message", async () => { const { hook } = setup(); mocks.editQueuedRun.mockResolvedValue({ _tag: "Success" });