Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d46f16d
feat(web): add an optional citation tray
ElliotDrel Nov 7, 2026
789c0c9
fix(web): preserve tray citations while answering questions
ElliotDrel Nov 7, 2026
71677c4
fix(web): preserve citation trays on failed sends
ElliotDrel Nov 7, 2026
c5f65e8
fix(web): preserve citation tray edits and trim closed tray work
ElliotDrel Nov 7, 2026
b7b3772
docs(web): correct citation tray settings location
ElliotDrel Nov 7, 2026
e42a89b
test(web): use distinct citation tray action labels
ElliotDrel Nov 7, 2026
2f04bff
fix(web): tray citations open the comment popup
ElliotDrel Nov 7, 2026
b621458
fix(web): tray citations survive questions, uploads, limits, and prom…
ElliotDrel Nov 7, 2026
7c494ed
fix(web): tray actions use icon buttons with tooltips
ElliotDrel Nov 7, 2026
9de910e
Merge remote-tracking branch 'origin/main' into t3/cite-citation-tray…
ElliotDrel Nov 9, 2026
305676d
test(web): consolidate queued draft recovery cases
ElliotDrel Nov 9, 2026
c46258e
Merge origin/main into t3/cite-citation-tray
ElliotDrel Nov 9, 2026
0dcdbe5
Merge origin/main into t3/cite-citation-tray
ElliotDrel Nov 9, 2026
4efd508
merge: update citation tray with main
ElliotDrel Nov 10, 2026
2400ad4
fix(web): keep tray citations out of local slash commands
ElliotDrel Nov 11, 2026
d2c3289
merge: preserve tray citations with pending-answer submission
ElliotDrel Nov 11, 2026
52bb122
merge: keep tray persistence and checkout draft coverage
ElliotDrel Nov 11, 2026
7bda34f
merge: retain tray quote previews alongside sent embeds
ElliotDrel Nov 11, 2026
86baee6
Merge remote-tracking branch 'github/main' into t3/cite-citation-tray
maria-rcks Nov 11, 2026
a90ee56
fix(web): preserve tray comments when dismissed
maria-rcks Nov 11, 2026
9a7b62e
fix(web): retain edited comments during tray actions
maria-rcks Nov 11, 2026
3daafd7
merge: preserve tray and reply recovery with current main
ElliotDrel Nov 12, 2026
0954e14
merge: preserve tray recovery with current main
ElliotDrel Nov 13, 2026
6d8d70c
merge: retain tray content with unified draft transfers
ElliotDrel Nov 14, 2026
0101bad
merge: retain tray copying with image-only failure notices
ElliotDrel Nov 15, 2026
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
47 changes: 43 additions & 4 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
import { deriveProviderInstanceEntries } from "../providerInstances";
import { ensureInlineContextReferences } from "../lib/composerContextReferences";
import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
import {
responseContextRecord,
responseContextReference,
Expand Down Expand Up @@ -68,6 +69,17 @@ import {
const environmentId = EnvironmentId.make("environment-local");
const projectId = ProjectId.make("project-1");
const threadId = ThreadId.make("thread-1");
const trayCitation = {
version: 1 as const,
environmentId,
threadId,
messageId: MessageId.make("source"),
text: "Source",
start: 0,
end: 6,
prefix: "",
suffix: "",
};
const now = "2026-03-29T00:00:00.000Z";

function makeThread(overrides: Partial<Thread> = {}): Thread {
Expand Down Expand Up @@ -133,6 +145,12 @@ describe("deriveComposerSendState", () => {
],
});

for (const citationCount of [0, 1]) {
expect(
deriveComposerSendState({ prompt: "", imageCount: 0, terminalContexts: [], citationCount })
.hasSendableContent,
).toBe(citationCount > 0);
}
expect(state.trimmedPrompt).toBe("yoo waddup");
expect(state.expiredTerminalContextCount).toBe(1);
expect(state.hasSendableContent).toBe(true);
Expand Down Expand Up @@ -1154,10 +1172,18 @@ describe("snapshotComposerSend", () => {

it("drops expired terminal contexts from the contexts and from the prompt", () => {
const snapshot = snapshotComposerSend({
prompt: ensureInlineContextReferences("run", [
draftPrompt: ensureInlineContextReferences("run", [
terminalContextReference(expired),
terminalContextReference(live),
]),
trayCitations: [trayCitation],
prompt:
serializeAssistantCitation(trayCitation) +
"\n" +
ensureInlineContextReferences("run", [
terminalContextReference(expired),
terminalContextReference(live),
]),
images: [],
files: [],
terminalContexts: [expired, live],
Expand All @@ -1169,9 +1195,15 @@ describe("snapshotComposerSend", () => {
replyTo: null,
});

expect(snapshot.draftPrompt).toBe(
ensureInlineContextReferences("run", [terminalContextReference(live)]).trim(),
);
expect(snapshot.trayCitations).toEqual([trayCitation]);
expect(snapshot.terminalContexts).toEqual([live]);
expect(snapshot.messageText).toBe(
ensureInlineContextReferences("run", [terminalContextReference(live)]).trim(),
serializeAssistantCitation(trayCitation) +
"\n" +
ensureInlineContextReferences("run", [terminalContextReference(live)]).trim(),
);
});

Expand All @@ -1193,7 +1225,13 @@ describe("snapshotComposerSend", () => {
});
const reference = responseContextReference(replyTo);
const snapshot = snapshotComposerSend({
prompt: ensureInlineContextReferences("", [reference]) + " why there?",
prompt:
serializeAssistantCitation(replyTo.citation) +
"\n" +
ensureInlineContextReferences("", [reference]) +
" why there?",
draftPrompt: "why there?",
trayCitations: [replyTo.citation],
images: [],
files: [],
terminalContexts: [],
Expand All @@ -1206,8 +1244,9 @@ describe("snapshotComposerSend", () => {
});

expect(snapshot.messageText).toContain(`t3-context://v1/response/${replyTo.contextId}`);
expect(snapshot.draftText).toBe("why there?");
expect(snapshot.draftPrompt).toBe("why there?");
expect(snapshot.replyTo).toBe(replyTo);
expect(snapshot.trayCitations).toEqual([replyTo.citation]);
});
});

Expand Down
35 changes: 21 additions & 14 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { toServerClock } from "@t3tools/client-runtime/clock";
import { type OrchestrationV2ProviderTurn, type RunAttemptId } from "@t3tools/contracts";
import {
type AssistantCitation,
type OrchestrationV2ProviderTurn,
type RunAttemptId,
} from "@t3tools/contracts";
import {
deriveLatestThreadRun,
deriveThreadRuntime,
Expand Down Expand Up @@ -723,6 +727,7 @@ export function cloneComposerImageForRetry(
export function deriveComposerSendState(options: {
prompt: string;
imageCount: number;
citationCount?: number;
existingAttachmentCount?: number;
terminalContexts: ReadonlyArray<TerminalContextDraft>;
elementContextCount?: number;
Expand All @@ -744,6 +749,7 @@ export function deriveComposerSendState(options: {
hasSendableContent:
trimmedPrompt.length > 0 ||
options.imageCount > 0 ||
(options.citationCount ?? 0) > 0 ||
(options.existingAttachmentCount ?? 0) > 0 ||
sendableTerminalContexts.length > 0 ||
elementContextCount > 0,
Expand Down Expand Up @@ -1263,6 +1269,8 @@ export function makeModelDisabledReason(

/** What the composer held when the user pressed send, after a direct annotation merged in. */
export interface ComposerSendContents {
readonly draftPrompt?: string;
readonly trayCitations?: ReadonlyArray<AssistantCitation>;
readonly prompt: string;
readonly images: ReadonlyArray<ComposerImageAttachment>;
readonly files: ReadonlyArray<ComposerFileAttachment>;
Expand All @@ -1285,15 +1293,18 @@ export function snapshotComposerSend(contents: ComposerSendContents) {
const images = [...contents.images];
const files = [...contents.files];
const terminalContexts = [...contents.sendableTerminalContexts];
const messageText = contents.terminalContexts
.filter((context) => !terminalContexts.includes(context))
.reduce(
(text, context) =>
removeInlineContextReference(text, terminalContextReference(context).contextId).prompt,
contents.prompt,
)
.trim();
const cleanPrompt = (prompt: string) =>
contents.terminalContexts
.filter((context) => !terminalContexts.includes(context))
.reduce(
(text, context) =>
removeInlineContextReference(text, terminalContextReference(context).contextId).prompt,
prompt,
)
.trim();
return {
draftPrompt: cleanPrompt(contents.draftPrompt ?? contents.prompt),
trayCitations: [...(contents.trayCitations ?? [])],
images,
files,
attachments: [...images, ...files],
Expand All @@ -1303,11 +1314,7 @@ export function snapshotComposerSend(contents: ComposerSendContents) {
threadContexts: [...contents.threadContexts],
environmentContexts: [...contents.environmentContexts],
replyTo: contents.replyTo,
messageText,
/** What a failed send puts back in the composer: the reply returns above it, not in the text. */
draftText: contents.replyTo
? removeInlineContextReference(messageText, contents.replyTo.contextId).prompt.trimStart()
: messageText,
messageText: cleanPrompt(contents.prompt),
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
import { AssistantCitationTray } from "./chat/AssistantCitationTray";
import { type AssistantCitation, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
import {
serializeAssistantCitation,
withAssistantCitationComment,
} from "@t3tools/shared/assistantCitations";
import { createRef, useState } from "react";
import { createRef, useRef, useState } from "react";
import { expect, it } from "vite-plus/test";

import { page, userEvent } from "vite-plus/test/browser";
Expand Down Expand Up @@ -107,6 +108,137 @@ it("keeps an unsaved citation comment when a question takes the composer", async
await expect.element(page.getByLabelText("Edit citation comment")).toBeInTheDocument();
});

function TrayComposer({ question = false }: { question?: boolean }) {
const editorRef = useRef<ComposerPromptEditorHandle>(null);
const [prompt, setPrompt] = useState(promptBefore);
const [answer, setAnswer] = useState("");
const [tray, setTray] = useState<ReadonlyArray<AssistantCitation>>([]);
return (
<>
<div data-assistant-citation-viewport="">
<p
data-assistant-citation-source={citation.messageId}
data-assistant-citation-environment={citation.environmentId}
data-assistant-citation-thread={citation.threadId}
>
hello world
</p>
</div>
<button
onClick={() => {
const sourceAnchor = findAssistantCitationSourceAnchor(document, citation);
if (!sourceAnchor) throw new Error("Citation source did not resolve");
if (question) setAnswer(promptBefore);
else setPrompt(promptBefore);
editorRef.current?.requestCitationComment({
previousValue: "See ",
value: promptBefore,
citationStart: 4,
sourceAnchor,
insertedSpaces: { before: false, after: true },
destination: "tray",
});
}}
>
Cite to tray
</button>
<output data-tray-count={tray.length} data-prompt={prompt} data-answer={answer} />
<AssistantCitationTray
citations={tray}
onChange={setTray}
onInsertInline={
question
? undefined
: (item) => {
setPrompt((text) => `${text}${serializeAssistantCitation(item)}`);
return true;
}
}
/>
<ComposerPromptEditor
editorRef={editorRef}
value={question ? answer : prompt}
cursor={0}
contextRecords={new Map()}
skills={[]}
disabled={false}
placeholder=""
onChange={question ? setAnswer : setPrompt}
onPaste={() => {}}
onMoveCitationToTray={question ? undefined : (item) => setTray((items) => [...items, item])}
/>
</>
);
}

it("opens a source comment for tray citations and preserves saved and cancelled citations", async () => {
await using screen = await render(<TrayComposer />);
await page.getByRole("button", { name: "Cite to tray", exact: true }).click();
await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible();
await page.getByLabelText("Comment on selected text").fill("Keep this comment");
await page.getByRole("button", { name: "Save", exact: true }).click();
const state = () => screen.container.querySelector("output")?.dataset;
expect(state()?.trayCount).toBe("1");
expect(state()?.prompt).not.toContain("t3-citation");
await page.getByRole("button", { name: "1 annotation", exact: true }).click();
await page.getByRole("button", { name: "Edit annotation 1", exact: true }).click();
await page.getByLabelText("Comment on selected text").fill("Keep the dismissed edit");
await page.getByText("hello world", { exact: true }).click();
await page.getByRole("button", { name: "1 annotation", exact: true }).click();
await expect.element(page.getByText("User comment: Keep the dismissed edit")).toBeVisible();
await page.getByRole("button", { name: "Edit annotation 1", exact: true }).click();
await page.getByLabelText("Comment on selected text").fill("Discard the cancelled edit");
await page.getByRole("button", { name: "Cancel", exact: true }).click();
await expect.element(page.getByText("User comment: Keep the dismissed edit")).toBeVisible();
await page.getByRole("button", { name: "Cite to tray", exact: true }).click();
await page.getByRole("button", { name: "Save", exact: true }).click();
await page.getByRole("button", { name: "2 annotations", exact: true }).click();
await page.getByRole("button", { name: "Edit annotation 1", exact: true }).click();
await page.getByLabelText("Comment on selected text").fill("Keep the edit after deletion");
await page.getByRole("button", { name: "Delete annotation 2", exact: true }).click();
await expect.element(page.getByText("User comment: Keep the edit after deletion")).toBeVisible();
await screen.rerender(<TrayComposer question />);
await expect
.element(page.getByRole("button", { name: "Insert annotation 1 inline", exact: true }))
.not.toBeInTheDocument();
expect(state()?.trayCount).toBe("1");
await screen.rerender(<TrayComposer />);
await page.getByRole("button", { name: "Insert annotation 1 inline", exact: true }).click();
expect(state()?.trayCount).toBe("0");
expect(state()?.prompt).toContain(
serializeAssistantCitation(
withAssistantCitationComment(citation, "Keep the edit after deletion"),
),
);
await page.getByRole("button", { name: "Cite to tray", exact: true }).click();
await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible();
await page.getByLabelText("Comment on selected text").fill("Discard this comment");
await page.getByRole("button", { name: "Cancel", exact: true }).click();
expect(state()?.trayCount).toBe("1");
expect(state()?.prompt).toBe("See ");
await page.getByRole("button", { name: "1 annotation", exact: true }).click();
await page.getByRole("button", { name: "Insert annotation 1 inline", exact: true }).click();
expect(state()?.prompt).toContain(serializeAssistantCitation(citation));
expect(state()?.prompt).not.toContain("Discard this comment");
});

it.each(["Save", "dismiss"])(
"keeps question citations inline on %s when tray transfer is unavailable",
async (action) => {
await using screen = await render(<TrayComposer question />);
await page.getByRole("button", { name: "Cite to tray", exact: true }).click();
await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible();
await page.getByLabelText("Comment on selected text").fill("Question comment");
if (action === "Save") await page.getByRole("button", { name: "Save", exact: true }).click();
else await page.getByText("hello world", { exact: true }).click();
const state = screen.container.querySelector("output")?.dataset;
expect(state?.trayCount).toBe("0");
expect(state?.answer).toContain(
serializeAssistantCitation(withAssistantCitationComment(citation, "Question comment")),
);
},
);

it("clears the sent prompt when an empty citation comment sends it", async () => {
const editorRef = createRef<ComposerPromptEditorHandle>();
await using screen = await render(<Composer question={false} editorRef={editorRef} />);
Expand Down
Loading
Loading