diff --git a/apps/web/src/components/chat/chatViewSend.test.ts b/apps/web/src/components/chat/chatViewSend.test.ts new file mode 100644 index 0000000000000..f6b726a19b482 --- /dev/null +++ b/apps/web/src/components/chat/chatViewSend.test.ts @@ -0,0 +1,131 @@ +import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; +import { AsyncResult } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; +import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; +import { formatInlineContextReference } from "../../lib/composerContextReferences"; +import { buildMessageContext, threadContextReference } from "../../lib/composerContextRecords"; +import { rewindToMessage } from "./chatViewSend"; + +vi.mock("../../state/session", async (importOriginal) => ({ + ...(await importOriginal()), + readPreparedConnection: () => ({ httpBaseUrl: "http://localhost" }), +})); +vi.mock("../ChatView.logic", () => ({ + prepareRevertedMessageAttachments: async () => [], + waitForRevertedMessage: async ( + _ref: unknown, + _count: unknown, + _id: unknown, + revert: () => Promise, + ) => revert(), +})); + +it("restores sent thread mentions without duplicating references or changing the draft after a failed rewind", async () => { + const environmentId = EnvironmentId.make("mention-env"); + const threadId = ThreadId.make("mention-source"); + const target = DraftId.make("mention-rewind"); + const store = useComposerDraftStore.getState(); + using _draft = { [Symbol.dispose]: () => store.clearComposerContent(target) }; + const record = threadContextRecord( + { environmentId, threadId: ThreadId.make("mention-target") }, + "Target thread", + ); + const text = `Wait for ${formatInlineContextReference(threadContextReference(record))} to merge`; + const context = buildMessageContext({ + terminalContexts: [], + reviewComments: [], + previewAnnotations: [], + attachments: [], + threadContexts: [record], + }); + const message = { + id: MessageId.make("mention-message"), + role: "user" as const, + text, + context, + runId: null, + streaming: false, + createdAt: "2026-11-04T00:00:00.000Z", + updatedAt: "2026-11-04T00:00:00.000Z", + }; + store.setPrompt(target, "Existing draft"); + const input = { + message, + environmentId, + routeThreadRef: { environmentId, threadId }, + threadId, + turnCount: 0, + restoreFiles: false, + checkpoint: undefined, + draftTarget: target, + composerHasPendingAttachments: false, + createAssetUrl: async () => { + throw new Error("No attachments expected"); + }, + revertThreadCheckpoint: async () => AsyncResult.success(undefined), + }; + expect( + await rewindToMessage({ + ...input, + revertThreadCheckpoint: async () => { + throw new Error("Rewind rejected"); + }, + }), + ).toEqual({ error: "Rewind rejected" }); + expect(store.getComposerDraft(target)?.prompt).toBe("Existing draft"); + expect(store.getComposerDraft(target)?.threadContexts).toEqual([]); + expect(await rewindToMessage(input)).toEqual({ + error: null, + prompt: `Existing draft\n\n${text}`, + }); + const draft = store.getComposerDraft(target); + expect(draft?.threadContexts).toEqual([record]); + const savedContext = buildMessageContext({ + terminalContexts: [], + reviewComments: [], + previewAnnotations: [], + attachments: [], + threadContexts: draft?.threadContexts ?? [], + }); + expect(savedContext?.records).toEqual(context?.records); + store.clearComposerContent(target); + expect( + await rewindToMessage({ ...input, message: { ...message, context: savedContext } }), + ).toEqual({ error: null, prompt: text }); + expect(store.getComposerDraft(target)?.threadContexts).toEqual([record]); +}); + +it("keeps a missing thread reference unresolved instead of fabricating its metadata", async () => { + const environmentId = EnvironmentId.make("missing-env"); + const threadId = ThreadId.make("source"); + const target = DraftId.make("missing-thread-rewind"); + const store = useComposerDraftStore.getState(); + using _draft = { [Symbol.dispose]: () => store.clearComposerContent(target) }; + const text = "See [Deleted thread](t3-context://v1/thread/deleted-thread)"; + const result = await rewindToMessage({ + message: { + id: MessageId.make("missing-message"), + role: "user", + text, + runId: null, + streaming: false, + createdAt: "2026-11-04T00:00:00.000Z", + updatedAt: "2026-11-04T00:00:00.000Z", + }, + environmentId, + routeThreadRef: { environmentId, threadId }, + threadId, + turnCount: 0, + restoreFiles: false, + checkpoint: undefined, + draftTarget: target, + composerHasPendingAttachments: false, + createAssetUrl: async () => { + throw new Error("No attachments expected"); + }, + revertThreadCheckpoint: async () => AsyncResult.success(undefined), + }); + expect(result).toEqual({ error: null, prompt: text }); + expect(store.getComposerDraft(target)?.threadContexts).toEqual([]); +}); diff --git a/apps/web/src/components/chat/chatViewSend.ts b/apps/web/src/components/chat/chatViewSend.ts index 81a00823a98be..29040d46cb1e5 100644 --- a/apps/web/src/components/chat/chatViewSend.ts +++ b/apps/web/src/components/chat/chatViewSend.ts @@ -44,6 +44,7 @@ import { waitForRevertedMessage, } from "../ChatView.logic"; import { readPreparedConnection } from "../../state/session"; +import { asKnownContextRecord, resolveUserMessageContext } from "../../lib/composerContextRecords"; import { recallableComposerPrompt } from "./composerPromptHistory"; // Kept per route so a send in flight, and the multiple-model selections, survive @@ -219,7 +220,9 @@ export async function rewindToMessage(input: { if (result._tag === "Failure") throw squashAtomCommandFailure(result); }); const currentPrompt = store.getComposerDraft(draftTarget)?.prompt ?? ""; - const restoredPrompt = recallableComposerPrompt(message.text); + const restoredPrompt = recallableComposerPrompt(message.text, { + preserveThreadReferences: true, + }); const nextPrompt = restoredPrompt.length === 0 ? currentPrompt @@ -227,6 +230,14 @@ export async function rewindToMessage(input: { ? `${currentPrompt}\n\n${restoredPrompt}` : restoredPrompt; store.setPrompt(draftTarget, nextPrompt); + store.addThreadContexts( + draftTarget, + resolveUserMessageContext(message).records.flatMap((record) => { + const known = asKnownContextRecord(record); + return known?.kind === "thread" ? [known] : []; + }), + { appendReference: false }, + ); const images: ComposerImageAttachment[] = []; const restoredFiles: ComposerFileAttachment[] = []; files.forEach((file, index) => { diff --git a/apps/web/src/components/chat/composerPromptHistory.ts b/apps/web/src/components/chat/composerPromptHistory.ts index 0d3f68b674b5e..6a701839cd24a 100644 --- a/apps/web/src/components/chat/composerPromptHistory.ts +++ b/apps/web/src/components/chat/composerPromptHistory.ts @@ -70,7 +70,10 @@ function stripInlineTerminalLabels(prompt: string, headers: ReadonlyArray