Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions apps/web/src/components/chat/chatViewSend.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("../../state/session")>()),
readPreparedConnection: () => ({ httpBaseUrl: "http://localhost" }),
}));
vi.mock("../ChatView.logic", () => ({
prepareRevertedMessageAttachments: async () => [],
waitForRevertedMessage: async (
_ref: unknown,
_count: unknown,
_id: unknown,
revert: () => Promise<void>,
) => 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([]);
});
13 changes: 12 additions & 1 deletion apps/web/src/components/chat/chatViewSend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -219,14 +220,24 @@ 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
: currentPrompt.length > 0
? `${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) => {
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/components/chat/composerPromptHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ function stripInlineTerminalLabels(prompt: string, headers: ReadonlyArray<string
return result;
}

export function recallableComposerPrompt(messageText: string): string {
export function recallableComposerPrompt(
messageText: string,
options?: { readonly preserveThreadReferences: boolean },
): string {
let prompt = messageText.trim();
if (prompt.startsWith(CLAUDE_ULTRATHINK_PREFIX)) {
prompt = prompt.slice(CLAUDE_ULTRATHINK_PREFIX.length);
Expand Down Expand Up @@ -99,8 +102,9 @@ export function recallableComposerPrompt(messageText: string): string {
break;
}

// Recall strips chips because their backing records are absent.
// History recall has no backing records; rewinds restore thread records separately.
for (const reference of collectComposerContextReferences(prompt).toReversed()) {
if (options?.preserveThreadReferences && reference.kind === "thread") continue;
let { start, end } = reference;
if (prompt[end] === " ") end += 1;
else if (prompt[start - 1] === " ") start -= 1;
Expand Down
Loading