From ee80eb6417f213ca80ad646cf9d4bf8a84c60776 Mon Sep 17 00:00:00 2001 From: Xavier Date: Fri, 27 Mar 2026 21:10:16 -0300 Subject: [PATCH 01/26] Add assistant response copy action --- apps/web/package.json | 2 + apps/web/src/components/ChatView.browser.tsx | 186 +++++++++++++++ apps/web/src/components/ChatView.tsx | 2 + .../src/components/chat/MessageCopyButton.tsx | 13 +- .../components/chat/MessagesTimeline.test.tsx | 218 +++++++++++------- .../src/components/chat/MessagesTimeline.tsx | 43 +++- apps/web/src/lib/assistantMessageCopy.test.ts | 60 +++++ apps/web/src/lib/assistantMessageCopy.ts | 168 ++++++++++++++ apps/web/src/routes/_chat.settings.tsx | 57 +++++ bun.lock | 2 + packages/contracts/src/settings.ts | 7 + 11 files changed, 659 insertions(+), 99 deletions(-) create mode 100644 apps/web/src/lib/assistantMessageCopy.test.ts create mode 100644 apps/web/src/lib/assistantMessageCopy.ts diff --git a/apps/web/package.json b/apps/web/package.json index 5127faf8279..1bf5ff1afab 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,7 +38,9 @@ "react-dom": "^19.0.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", "tailwind-merge": "^3.4.0", + "unified": "^11.0.5", "zustand": "^5.0.11" }, "devDependencies": { diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 7bf3ecf26c2..1a0e8209344 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -39,6 +39,7 @@ const PROJECT_ID = "project-1" as ProjectId; const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; +const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; interface WsRequestEnvelope { id: string; @@ -278,6 +279,29 @@ function createSnapshotForTargetUser(options: { }; } +function createSnapshotForTargetAssistantMessage(options: { + targetAssistantId: MessageId; + targetText: string; +}): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-assistant-copy-target" as MessageId, + targetText: "assistant copy target", + }); + + return { + ...snapshot, + threads: snapshot.threads.map((thread) => + Object.assign({}, thread, { + messages: thread.messages.map((message) => + message.id === options.targetAssistantId + ? Object.assign({}, message, { text: options.targetText }) + : message, + ), + }), + ), + }; +} + function buildFixture(snapshot: OrchestrationReadModel): TestFixture { return { snapshot, @@ -619,6 +643,25 @@ async function waitForSendButton(): Promise { ); } +function installClipboardWriteTextSpy() { + const writeText = vi.fn<(value: string) => Promise>().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + return writeText; +} + +function setAssistantResponseCopyFormat(format: "markdown" | "plain-text"): void { + localStorage.setItem( + CLIENT_SETTINGS_STORAGE_KEY, + JSON.stringify({ + ...DEFAULT_CLIENT_SETTINGS, + assistantResponseCopyFormat: format, + }), + ); +} + async function waitForInteractionModeButton( expectedLabel: "Chat" | "Plan", ): Promise { @@ -1228,6 +1271,149 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("copies the raw assistant markdown by default", async () => { + const assistantMessageId = "msg-assistant-21" as MessageId; + const assistantText = [ + "# Copy me", + "", + "Paragraph with [docs](https://example.com/docs).", + "", + "```ts", + "const value = 1;", + "```", + ].join("\n"); + const writeText = installClipboardWriteTextSpy(); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetAssistantMessage({ + targetAssistantId: assistantMessageId, + targetText: assistantText, + }), + }); + + try { + const assistantRow = await waitForElement( + () => + document.querySelector( + `[data-message-id="${assistantMessageId}"][data-message-role="assistant"]`, + ), + "Unable to find assistant response row.", + ); + const copyButton = await waitForElement( + () => assistantRow.querySelector('button[aria-label="Copy response"]'), + "Unable to find assistant copy button.", + ); + + copyButton.click(); + + await vi.waitFor( + () => { + expect(writeText).toHaveBeenCalledWith(assistantText); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("copies assistant responses as plain text when the setting is enabled", async () => { + const assistantMessageId = "msg-assistant-21" as MessageId; + const assistantText = [ + "# Copy me", + "", + "Paragraph with [docs](https://example.com/docs).", + "", + "```ts", + "const value = 1;", + "```", + ].join("\n"); + const writeText = installClipboardWriteTextSpy(); + setAssistantResponseCopyFormat("plain-text"); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetAssistantMessage({ + targetAssistantId: assistantMessageId, + targetText: assistantText, + }), + }); + + try { + const assistantRow = await waitForElement( + () => + document.querySelector( + `[data-message-id="${assistantMessageId}"][data-message-role="assistant"]`, + ), + "Unable to find assistant response row.", + ); + const copyButton = await waitForElement( + () => assistantRow.querySelector('button[aria-label="Copy response"]'), + "Unable to find assistant copy button.", + ); + + copyButton.click(); + + await vi.waitFor( + () => { + expect(writeText).toHaveBeenCalledWith( + ["Copy me", "", "Paragraph with docs.", "", "const value = 1;"].join("\n"), + ); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps markdown code-block copy scoped to the code block", async () => { + const assistantMessageId = "msg-assistant-21" as MessageId; + const assistantText = [ + "# Copy me", + "", + "Paragraph with [docs](https://example.com/docs).", + "", + "```ts", + "const value = 1;", + "```", + ].join("\n"); + const writeText = installClipboardWriteTextSpy(); + setAssistantResponseCopyFormat("plain-text"); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetAssistantMessage({ + targetAssistantId: assistantMessageId, + targetText: assistantText, + }), + }); + + try { + const assistantRow = await waitForElement( + () => + document.querySelector( + `[data-message-id="${assistantMessageId}"][data-message-role="assistant"]`, + ), + "Unable to find assistant response row.", + ); + const codeCopyButton = await waitForElement( + () => assistantRow.querySelector('button[aria-label="Copy code"]'), + "Unable to find code-block copy button.", + ); + + codeCopyButton.click(); + + await vi.waitFor( + () => { + expect(writeText).toHaveBeenCalled(); + expect(writeText.mock.calls.at(-1)?.[0].trim()).toBe("const value = 1;"); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("runs project scripts from local draft threads at the project cwd", async () => { useComposerDraftStore.setState({ draftThreadsByThreadId: { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1d926bf308e..42c0758b619 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -257,6 +257,7 @@ export default function ChatView({ threadId }: ChatViewProps) { (store) => store.setStickyModelSelection, ); const timestampFormat = settings.timestampFormat; + const assistantResponseCopyFormat = settings.assistantResponseCopyFormat; const navigate = useNavigate(); const rawSearch = useSearch({ strict: false, @@ -3634,6 +3635,7 @@ export default function ChatView({ threadId }: ChatViewProps) { onImageExpand={onExpandTimelineImage} markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} + assistantResponseCopyFormat={assistantResponseCopyFormat} timestampFormat={timestampFormat} workspaceRoot={activeProject?.cwd ?? undefined} /> diff --git a/apps/web/src/components/chat/MessageCopyButton.tsx b/apps/web/src/components/chat/MessageCopyButton.tsx index cf1e7989123..42b9a4b087c 100644 --- a/apps/web/src/components/chat/MessageCopyButton.tsx +++ b/apps/web/src/components/chat/MessageCopyButton.tsx @@ -3,7 +3,13 @@ import { CopyIcon, CheckIcon } from "lucide-react"; import { Button } from "../ui/button"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -export const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) { +export const MessageCopyButton = memo(function MessageCopyButton({ + text, + label = "Copy message", +}: { + text: string | (() => string); + label?: string; +}) { const { copyToClipboard, isCopied } = useCopyToClipboard(); return ( @@ -11,8 +17,9 @@ export const MessageCopyButton = memo(function MessageCopyButton({ text }: { tex type="button" size="xs" variant="outline" - onClick={() => copyToClipboard(text)} - title="Copy message" + onClick={() => copyToClipboard(typeof text === "function" ? text() : text)} + title={isCopied ? label.replace(/^Copy\s+/i, "Copied ") : label} + aria-label={isCopied ? label.replace(/^Copy\s+/i, "Copied ") : label} > {isCopied ? : } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 692438c74aa..bca95fe9ef8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,6 +1,15 @@ import { MessageId } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vitest"; +import { deriveTimelineEntries } from "../../session-logic"; + +vi.mock("../../hooks/useTheme", () => ({ + useTheme: () => ({ + theme: "light", + resolvedTheme: "light", + setTheme: () => {}, + }), +})); function matchMedia() { return { @@ -42,55 +51,60 @@ beforeAll(() => { }); }); +async function renderTimeline(timelineEntries: ReturnType) { + const { MessagesTimeline } = await import("./MessagesTimeline"); + return renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + assistantResponseCopyFormat="markdown" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); +} + describe("MessagesTimeline", () => { it("renders inline terminal labels with the composer chip UI", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - ", - "- Terminal 1 lines 1-5:", - " 1 | julius@mac effect-http-ws-cli % bun i", - " 2 | bun install v1.3.9 (cf6cdbbb)", - "", - ].join("\n"), - createdAt: "2026-03-17T19:12:28.000Z", - streaming: false, - }, - }, - ]} - completionDividerBeforeEntryId={null} - completionSummary={null} - turnDiffSummaryByAssistantMessageId={new Map()} - nowIso="2026-03-17T19:12:30.000Z" - expandedWorkGroups={{}} - onToggleWorkGroup={() => {}} - onOpenTurnDiff={() => {}} - revertTurnCountByUserMessageId={new Map()} - onRevertUserMessage={() => {}} - isRevertingCheckpoint={false} - onImageExpand={() => {}} - markdownCwd={undefined} - resolvedTheme="light" - timestampFormat="locale" - workspaceRoot={undefined} - />, - ); + const markup = await renderTimeline([ + { + id: "entry-1", + kind: "message", + createdAt: "2026-03-17T19:12:28.000Z", + message: { + id: MessageId.makeUnsafe("message-2"), + role: "user", + text: [ + "yoo what's @terminal-1:1-5 mean", + "", + "", + "- Terminal 1 lines 1-5:", + " 1 | julius@mac effect-http-ws-cli % bun i", + " 2 | bun install v1.3.9 (cf6cdbbb)", + "", + ].join("\n"), + createdAt: "2026-03-17T19:12:28.000Z", + streaming: false, + }, + }, + ]); expect(markup).toContain("Terminal 1 lines 1-5"); expect(markup).toContain("lucide-terminal"); @@ -98,46 +112,80 @@ describe("MessagesTimeline", () => { }); it("renders context compaction entries in the normal work log", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - {}} - onOpenTurnDiff={() => {}} - revertTurnCountByUserMessageId={new Map()} - onRevertUserMessage={() => {}} - isRevertingCheckpoint={false} - onImageExpand={() => {}} - markdownCwd={undefined} - resolvedTheme="light" - timestampFormat="locale" - workspaceRoot={undefined} - />, - ); + const markup = await renderTimeline([ + { + id: "entry-1", + kind: "work", + createdAt: "2026-03-17T19:12:28.000Z", + entry: { + id: "work-1", + createdAt: "2026-03-17T19:12:28.000Z", + label: "Context compacted", + tone: "info", + }, + }, + ]); expect(markup).toContain("Context compacted"); expect(markup).toContain("Work log"); }); + + it("renders a copy control for completed assistant messages", async () => { + const markup = await renderTimeline([ + { + id: "entry-1", + kind: "message", + createdAt: "2026-03-17T19:12:28.000Z", + message: { + id: MessageId.makeUnsafe("assistant-complete"), + role: "assistant", + text: "Completed response", + createdAt: "2026-03-17T19:12:28.000Z", + completedAt: "2026-03-17T19:12:30.000Z", + streaming: false, + }, + }, + ]); + + expect(markup).toContain("Copy response"); + }); + + it("does not render a copy control for streaming assistant messages", async () => { + const markup = await renderTimeline([ + { + id: "entry-1", + kind: "message", + createdAt: "2026-03-17T19:12:28.000Z", + message: { + id: MessageId.makeUnsafe("assistant-streaming"), + role: "assistant", + text: "Partial response", + createdAt: "2026-03-17T19:12:28.000Z", + streaming: true, + }, + }, + ]); + + expect(markup).not.toContain("Copy response"); + }); + + it("does not render a copy control for empty completed assistant messages", async () => { + const markup = await renderTimeline([ + { + id: "entry-1", + kind: "message", + createdAt: "2026-03-17T19:12:28.000Z", + message: { + id: MessageId.makeUnsafe("assistant-empty"), + role: "assistant", + text: " ", + createdAt: "2026-03-17T19:12:28.000Z", + completedAt: "2026-03-17T19:12:30.000Z", + streaming: false, + }, + }, + ]); + + expect(markup).not.toContain("Copy response"); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f3174030eff..1c8d72e95e7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -48,13 +48,17 @@ import { type ParsedTerminalContextEntry, } from "~/lib/terminalContext"; import { cn } from "~/lib/utils"; -import { type TimestampFormat } from "@t3tools/contracts/settings"; +import { + type AssistantResponseCopyFormat, + type TimestampFormat, +} from "@t3tools/contracts/settings"; import { formatTimestamp } from "../../timestampFormat"; import { buildInlineTerminalContextText, formatInlineTerminalContextLabel, textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; +import { resolveAssistantMessageCopyText } from "../../lib/assistantMessageCopy"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; @@ -79,6 +83,7 @@ interface MessagesTimelineProps { onImageExpand: (preview: ExpandedImagePreview) => void; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; + assistantResponseCopyFormat: AssistantResponseCopyFormat; timestampFormat: TimestampFormat; workspaceRoot: string | undefined; } @@ -103,6 +108,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, markdownCwd, resolvedTheme, + assistantResponseCopyFormat, timestampFormat, workspaceRoot, }: MessagesTimelineProps) { @@ -448,7 +454,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )} -
+
); })()} -

- {formatMessageMeta( - row.message.createdAt, - row.message.streaming - ? formatElapsed(row.durationStart, nowIso) - : formatElapsed(row.durationStart, row.message.completedAt), - timestampFormat, - )} -

+
+
+ {!row.message.streaming && row.message.text.trim().length > 0 ? ( + + resolveAssistantMessageCopyText( + row.message.text, + assistantResponseCopyFormat, + ) + } + label="Copy response" + /> + ) : null} +
+

+ {formatMessageMeta( + row.message.createdAt, + row.message.streaming + ? formatElapsed(row.durationStart, nowIso) + : formatElapsed(row.durationStart, row.message.completedAt), + timestampFormat, + )} +

+
); diff --git a/apps/web/src/lib/assistantMessageCopy.test.ts b/apps/web/src/lib/assistantMessageCopy.test.ts new file mode 100644 index 00000000000..a588b0c5ccd --- /dev/null +++ b/apps/web/src/lib/assistantMessageCopy.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { markdownToPlainText, resolveAssistantMessageCopyText } from "./assistantMessageCopy"; + +describe("assistantMessageCopy", () => { + it("returns the raw assistant markdown unchanged in markdown mode", () => { + const markdown = ["# Heading", "", "- item", "", "```ts", "console.log('hi');", "```"].join( + "\n", + ); + + expect(resolveAssistantMessageCopyText(markdown, "markdown")).toBe(markdown); + }); + + it("serializes markdown into stable plain text", () => { + const markdown = [ + "# Heading", + "", + "Paragraph with [docs](https://example.com/docs) and [](https://example.com/fallback).", + "", + "> Quoted **text**", + "", + "- first item", + "- second item", + "", + "1. ordered", + "2. next", + "", + "| Name | Value |", + "| --- | --- |", + "| One | 1 |", + "", + "```ts", + "const value = 1;", + "console.log(value);", + "```", + ].join("\n"); + + expect(markdownToPlainText(markdown)).toBe( + [ + "Heading", + "", + "Paragraph with docs and https://example.com/fallback.", + "", + "Quoted text", + "", + "- first item", + "- second item", + "", + "1. ordered", + "2. next", + "", + "Name | Value", + "One | 1", + "", + "const value = 1;", + "console.log(value);", + ].join("\n"), + ); + }); +}); diff --git a/apps/web/src/lib/assistantMessageCopy.ts b/apps/web/src/lib/assistantMessageCopy.ts new file mode 100644 index 00000000000..2960823baf1 --- /dev/null +++ b/apps/web/src/lib/assistantMessageCopy.ts @@ -0,0 +1,168 @@ +import { type AssistantResponseCopyFormat } from "@t3tools/contracts/settings"; +import remarkGfm from "remark-gfm"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; + +type MarkdownNode = { + type: string; + value?: string; + alt?: string; + url?: string; + ordered?: boolean; + start?: number | null; + children?: MarkdownNode[]; +}; + +const markdownProcessor = unified().use(remarkParse).use(remarkGfm); + +export function resolveAssistantMessageCopyText( + messageText: string, + format: AssistantResponseCopyFormat, +): string { + if (format === "markdown") { + return messageText; + } + + return markdownToPlainText(messageText); +} + +export function markdownToPlainText(markdown: string): string { + const normalizedMarkdown = markdown.replace(/\r\n/g, "\n"); + if (normalizedMarkdown.trim().length === 0) { + return ""; + } + + const tree = markdownProcessor.parse(normalizedMarkdown) as MarkdownNode; + return normalizePlainText(renderBlockChildren(tree.children ?? [])); +} + +function renderBlockChildren(nodes: readonly MarkdownNode[]): string { + return nodes + .map((node) => renderBlock(node)) + .filter((block) => block.length > 0) + .join("\n\n"); +} + +function renderBlock(node: MarkdownNode): string { + switch (node.type) { + case "root": + case "blockquote": + return renderBlockChildren(node.children ?? []); + case "paragraph": + case "heading": + return normalizeInlineText(renderInlineChildren(node.children ?? [])); + case "code": + return normalizeCodeBlock(node.value ?? ""); + case "list": + return renderList(node); + case "table": + return renderTable(node); + case "html": + case "thematicBreak": + return ""; + default: + return normalizeInlineText(renderInline(node)); + } +} + +function renderList(node: MarkdownNode): string { + const items = node.children ?? []; + const ordered = Boolean(node.ordered); + const start = typeof node.start === "number" ? node.start : 1; + + return items + .map((item, index) => renderListItem(item, ordered ? `${start + index}. ` : "- ")) + .filter((item) => item.length > 0) + .join("\n"); +} + +function renderListItem(node: MarkdownNode, marker: string): string { + const blocks = (node.children ?? []) + .map((child) => renderBlock(child)) + .filter((block) => block.length > 0); + + if (blocks.length === 0) { + return marker.trimEnd(); + } + + const continuationPrefix = " ".repeat(marker.length); + const firstBlock = blocks[0]!; + const remainingBlocks = blocks.slice(1); + const firstBlockLines = splitLines(firstBlock); + const lines = firstBlockLines.map((line, index) => + index === 0 ? `${marker}${line}` : `${continuationPrefix}${line}`, + ); + + for (const block of remainingBlocks) { + for (const line of splitLines(block)) { + lines.push(` ${line}`); + } + } + + return lines.join("\n"); +} + +function renderTable(node: MarkdownNode): string { + return (node.children ?? []) + .map((row) => + (row.children ?? []) + .map((cell) => normalizeInlineText(renderInlineChildren(cell.children ?? []))) + .join(" | "), + ) + .filter((row) => row.length > 0) + .join("\n"); +} + +function renderInlineChildren(nodes: readonly MarkdownNode[]): string { + return nodes.map((node) => renderInline(node)).join(""); +} + +function renderInline(node: MarkdownNode): string { + switch (node.type) { + case "text": + case "inlineCode": + return node.value ?? ""; + case "break": + return "\n"; + case "link": { + const label = normalizeInlineText(renderInlineChildren(node.children ?? [])); + if (label.length > 0) { + return label; + } + return typeof node.url === "string" ? node.url : ""; + } + case "image": + return node.alt?.trim() || node.url || ""; + case "delete": + case "emphasis": + case "strong": + case "paragraph": + case "heading": + return renderInlineChildren(node.children ?? []); + default: + return renderInlineChildren(node.children ?? []); + } +} + +function normalizeCodeBlock(value: string): string { + return value.replace(/\r\n/g, "\n").replace(/\n+$/g, ""); +} + +function normalizeInlineText(value: string): string { + return value + .replace(/[ \t]+\n/g, "\n") + .replace(/\n[ \t]+/g, "\n") + .replace(/[ \t]{2,}/g, " ") + .trim(); +} + +function normalizePlainText(value: string): string { + return value + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function splitLines(value: string): string[] { + return value.replace(/\r\n/g, "\n").split("\n"); +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 3e92891a54e..9fc82226f64 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -75,6 +75,11 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const ASSISTANT_RESPONSE_COPY_FORMAT_LABELS = { + markdown: "Raw markdown", + "plain-text": "Rendered plain text", +} as const; + const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; type InstallProviderSettings = { @@ -361,6 +366,10 @@ function SettingsRouteView() { ); const changedSettingLabels = [ ...(theme !== "system" ? ["Theme"] : []), + ...(settings.assistantResponseCopyFormat !== + DEFAULT_UNIFIED_SETTINGS.assistantResponseCopyFormat + ? ["Assistant copy format"] + : []), ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), @@ -638,6 +647,54 @@ function SettingsRouteView() { } /> + + updateSettings({ + assistantResponseCopyFormat: + DEFAULT_UNIFIED_SETTINGS.assistantResponseCopyFormat, + }) + } + /> + ) : null + } + control={ + + } + /> + DEFAULT_ASSISTANT_RESPONSE_COPY_FORMAT), + ), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( From 00f34deb7854dd9c3608413f51841c3d1d4f23d2 Mon Sep 17 00:00:00 2001 From: Xavier Date: Fri, 27 Mar 2026 21:17:44 -0300 Subject: [PATCH 02/26] Fix ordered list copy indentation --- apps/web/src/lib/assistantMessageCopy.test.ts | 14 ++++++++++++++ apps/web/src/lib/assistantMessageCopy.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/assistantMessageCopy.test.ts b/apps/web/src/lib/assistantMessageCopy.test.ts index a588b0c5ccd..0d3de5db6b2 100644 --- a/apps/web/src/lib/assistantMessageCopy.test.ts +++ b/apps/web/src/lib/assistantMessageCopy.test.ts @@ -57,4 +57,18 @@ describe("assistantMessageCopy", () => { ].join("\n"), ); }); + + it("aligns continuation blocks for ordered lists with wide markers", () => { + const markdown = [ + "10. first paragraph", + "", + " ```ts", + " const value = 1;", + " ```", + ].join("\n"); + + expect(markdownToPlainText(markdown)).toBe( + ["10. first paragraph", " const value = 1;"].join("\n"), + ); + }); }); diff --git a/apps/web/src/lib/assistantMessageCopy.ts b/apps/web/src/lib/assistantMessageCopy.ts index 2960823baf1..3ae810a9451 100644 --- a/apps/web/src/lib/assistantMessageCopy.ts +++ b/apps/web/src/lib/assistantMessageCopy.ts @@ -95,7 +95,7 @@ function renderListItem(node: MarkdownNode, marker: string): string { for (const block of remainingBlocks) { for (const line of splitLines(block)) { - lines.push(` ${line}`); + lines.push(`${continuationPrefix}${line}`); } } From 45a53c262a7034a85e54c003ec4d3d4c31e19e23 Mon Sep 17 00:00:00 2001 From: Xavier Date: Fri, 27 Mar 2026 21:55:23 -0300 Subject: [PATCH 03/26] Refine assistant copy footer behavior --- apps/web/src/components/ChatView.browser.tsx | 39 +++++++++++-------- .../components/chat/MessagesTimeline.test.tsx | 30 +++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 27 ++++++++----- .../web/src/components/timelineHeight.test.ts | 16 +++++++- apps/web/src/components/timelineHeight.ts | 8 +++- 5 files changed, 89 insertions(+), 31 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 1a0e8209344..e9a2276fd3c 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -87,7 +87,7 @@ const ATTACHMENT_VIEWPORT_MATRIX = [ { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, ] as const satisfies readonly ViewportSpec[]; -interface UserRowMeasurement { +interface MessageRowMeasurement { measuredRowHeightPx: number; timelineWidthMeasuredPx: number; renderedInVirtualizedRegion: boolean; @@ -96,7 +96,10 @@ interface UserRowMeasurement { interface MountedChatView { [Symbol.asyncDispose]: () => Promise; cleanup: () => Promise; - measureUserRow: (targetMessageId: MessageId) => Promise; + measureMessageRow: ( + targetMessageId: MessageId, + role: "user" | "assistant", + ) => Promise; setViewport: (viewport: ViewportSpec) => Promise; router: ReturnType; } @@ -747,12 +750,13 @@ async function waitForImagesToLoad(scope: ParentNode): Promise { await waitForLayout(); } -async function measureUserRow(options: { +async function measureMessageRow(options: { host: HTMLElement; targetMessageId: MessageId; -}): Promise { - const { host, targetMessageId } = options; - const rowSelector = `[data-message-id="${targetMessageId}"][data-message-role="user"]`; + role: "user" | "assistant"; +}): Promise { + const { host, targetMessageId, role } = options; + const rowSelector = `[data-message-id="${targetMessageId}"][data-message-role="${role}"]`; const scrollContainer = await waitForElement( () => host.querySelector("div.overflow-y-auto.overscroll-y-contain"), @@ -766,7 +770,7 @@ async function measureUserRow(options: { scrollContainer.dispatchEvent(new Event("scroll")); await waitForLayout(); row = host.querySelector(rowSelector); - expect(row, "Unable to locate targeted user message row.").toBeTruthy(); + expect(row, `Unable to locate targeted ${role} message row.`).toBeTruthy(); }, { timeout: 8_000, @@ -795,12 +799,14 @@ async function measureUserRow(options: { scrollContainer.dispatchEvent(new Event("scroll")); await nextFrame(); const measuredRow = host.querySelector(rowSelector); - expect(measuredRow, "Unable to measure targeted user row height.").toBeTruthy(); + expect(measuredRow, `Unable to measure targeted ${role} row height.`).toBeTruthy(); timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width; measuredRowHeightPx = measuredRow!.getBoundingClientRect().height; renderedInVirtualizedRegion = measuredRow!.closest("[data-index]") instanceof HTMLElement; expect(timelineWidthMeasuredPx, "Unable to measure timeline width.").toBeGreaterThan(0); - expect(measuredRowHeightPx, "Unable to measure targeted user row height.").toBeGreaterThan(0); + expect(measuredRowHeightPx, `Unable to measure targeted ${role} row height.`).toBeGreaterThan( + 0, + ); }, { timeout: 4_000, @@ -853,7 +859,8 @@ async function mountChatView(options: { return { [Symbol.asyncDispose]: cleanup, cleanup, - measureUserRow: async (targetMessageId: MessageId) => measureUserRow({ host, targetMessageId }), + measureMessageRow: async (targetMessageId: MessageId, role: "user" | "assistant") => + measureMessageRow({ host, targetMessageId, role }), setViewport: async (viewport: ViewportSpec) => { await setViewport(viewport); await waitForProductionStyles(); @@ -866,14 +873,14 @@ async function measureUserRowAtViewport(options: { snapshot: OrchestrationReadModel; targetMessageId: MessageId; viewport: ViewportSpec; -}): Promise { +}): Promise { const mounted = await mountChatView({ viewport: options.viewport, snapshot: options.snapshot, }); try { - return await mounted.measureUserRow(options.targetMessageId); + return await mounted.measureMessageRow(options.targetMessageId, "user"); } finally { await mounted.cleanup(); } @@ -940,7 +947,7 @@ describe("ChatView timeline estimator parity (full app)", () => { try { const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = - await mounted.measureUserRow(targetMessageId); + await mounted.measureMessageRow(targetMessageId, "user"); expect(renderedInVirtualizedRegion).toBe(true); @@ -971,12 +978,12 @@ describe("ChatView timeline estimator parity (full app)", () => { try { const measurements: Array< - UserRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number } + MessageRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number } > = []; for (const viewport of TEXT_VIEWPORT_MATRIX) { await mounted.setViewport(viewport); - const measurement = await mounted.measureUserRow(targetMessageId); + const measurement = await mounted.measureMessageRow(targetMessageId, "user"); const estimatedHeightPx = estimateTimelineMessageHeight( { role: "user", text: userText, attachments: [] }, { timelineWidthPx: measurement.timelineWidthMeasuredPx }, @@ -1060,7 +1067,7 @@ describe("ChatView timeline estimator parity (full app)", () => { try { const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = - await mounted.measureUserRow(targetMessageId); + await mounted.measureMessageRow(targetMessageId, "user"); expect(renderedInVirtualizedRegion).toBe(true); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index bca95fe9ef8..c7f28449ad2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -51,7 +51,10 @@ beforeAll(() => { }); }); -async function renderTimeline(timelineEntries: ReturnType) { +async function renderTimeline( + timelineEntries: ReturnType, + assistantResponseCopyFormat: "markdown" | "plain-text" = "markdown", +) { const { MessagesTimeline } = await import("./MessagesTimeline"); return renderToStaticMarkup( {}} markdownCwd={undefined} resolvedTheme="light" - assistantResponseCopyFormat="markdown" + assistantResponseCopyFormat={assistantResponseCopyFormat} timestampFormat="locale" workspaceRoot={undefined} />, @@ -188,4 +191,27 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("Copy response"); }); + + it("does not render a copy control when plain-text resolution is empty", async () => { + const markup = await renderTimeline( + [ + { + id: "entry-1", + kind: "message", + createdAt: "2026-03-17T19:12:28.000Z", + message: { + id: MessageId.makeUnsafe("assistant-plain-text-empty"), + role: "assistant", + text: "---", + createdAt: "2026-03-17T19:12:28.000Z", + completedAt: "2026-03-17T19:12:30.000Z", + streaming: false, + }, + }, + ], + "plain-text", + ); + + expect(markup).not.toContain("Copy response"); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1c8d72e95e7..f8cfffbbe82 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -443,6 +443,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({ row.message.role === "assistant" && (() => { const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); + let assistantCopyText: string | null = null; + const getAssistantCopyText = () => { + if (assistantCopyText !== null) { + return assistantCopyText; + } + assistantCopyText = resolveAssistantMessageCopyText( + row.message.text, + assistantResponseCopyFormat, + ); + return assistantCopyText; + }; + const showAssistantCopyButton = + !row.message.streaming && + row.message.text.trim().length > 0 && + getAssistantCopyText().trim().length > 0; return ( <> {row.showCompletionDivider && ( @@ -518,16 +533,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ })()}
- {!row.message.streaming && row.message.text.trim().length > 0 ? ( - - resolveAssistantMessageCopyText( - row.message.text, - assistantResponseCopyFormat, - ) - } - label="Copy response" - /> + {showAssistantCopyButton ? ( + ) : null}

diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts index 9b1331a9d65..06f29236bb2 100644 --- a/apps/web/src/components/timelineHeight.test.ts +++ b/apps/web/src/components/timelineHeight.test.ts @@ -10,6 +10,17 @@ describe("estimateTimelineMessageHeight", () => { estimateTimelineMessageHeight({ role: "assistant", text: "a".repeat(144), + streaming: false, + }), + ).toBe(140); + }); + + it("keeps the smaller assistant base height while streaming", () => { + expect( + estimateTimelineMessageHeight({ + role: "assistant", + text: "a".repeat(144), + streaming: true, }), ).toBe(122); }); @@ -130,9 +141,10 @@ describe("estimateTimelineMessageHeight", () => { const message = { role: "assistant" as const, text: "a".repeat(200), + streaming: false, }; - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(188); - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(122); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(206); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(140); }); }); diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 998a2a0b7f0..454003969e2 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -5,6 +5,7 @@ const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; const USER_CHARS_PER_LINE_FALLBACK = 56; const LINE_HEIGHT_PX = 22; const ASSISTANT_BASE_HEIGHT_PX = 78; +const ASSISTANT_COMPLETED_ACTION_BASE_HEIGHT_PX = 96; const USER_BASE_HEIGHT_PX = 96; const ATTACHMENTS_PER_ROW = 2; // Attachment thumbnails render with `max-h-[220px]` plus ~8px row gap. @@ -20,6 +21,7 @@ const MIN_ASSISTANT_CHARS_PER_LINE = 20; interface TimelineMessageHeightInput { role: "user" | "assistant" | "system"; text: string; + streaming?: boolean; attachments?: ReadonlyArray<{ id: string }>; } @@ -73,7 +75,11 @@ export function estimateTimelineMessageHeight( if (message.role === "assistant") { const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); - return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX; + const assistantBaseHeightPx = + message.streaming !== true && message.text.trim().length > 0 + ? ASSISTANT_COMPLETED_ACTION_BASE_HEIGHT_PX + : ASSISTANT_BASE_HEIGHT_PX; + return assistantBaseHeightPx + estimatedLines * LINE_HEIGHT_PX; } if (message.role === "user") { From 484fcc552ffab31f64171f41130feda66fad93f4 Mon Sep 17 00:00:00 2001 From: Xavier Date: Fri, 27 Mar 2026 22:33:38 -0300 Subject: [PATCH 04/26] Address assistant copy review feedback --- .../components/chat/MessagesTimeline.test.tsx | 23 +++++++++++ .../src/components/chat/MessagesTimeline.tsx | 34 ++++++++-------- .../web/src/components/timelineHeight.test.ts | 13 ++++++ apps/web/src/components/timelineHeight.ts | 6 ++- apps/web/src/lib/assistantMessageCopy.test.ts | 21 +++++++++- apps/web/src/lib/assistantMessageCopy.ts | 40 +++++++++++++++++-- 6 files changed, 115 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index c7f28449ad2..d443bf32655 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -214,4 +214,27 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("Copy response"); }); + + it("renders a copy control for html-only assistant messages in plain-text mode", async () => { + const markup = await renderTimeline( + [ + { + id: "entry-1", + kind: "message", + createdAt: "2026-03-17T19:12:28.000Z", + message: { + id: MessageId.makeUnsafe("assistant-html-only"), + role: "assistant", + text: "

Example
", + createdAt: "2026-03-17T19:12:28.000Z", + completedAt: "2026-03-17T19:12:30.000Z", + streaming: false, + }, + }, + ], + "plain-text", + ); + + expect(markup).toContain("Copy response"); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f8cfffbbe82..b4f6a6d668d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -58,7 +58,10 @@ import { formatInlineTerminalContextLabel, textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; -import { resolveAssistantMessageCopyText } from "../../lib/assistantMessageCopy"; +import { + hasAssistantResponseCopyText, + resolveAssistantMessageCopyText, +} from "../../lib/assistantMessageCopy"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; @@ -259,7 +262,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ if (row.kind === "work") return 112; if (row.kind === "proposed-plan") return estimateTimelineProposedPlanHeight(row.proposedPlan); if (row.kind === "working") return 40; - return estimateTimelineMessageHeight(row.message, { timelineWidthPx }); + return estimateTimelineMessageHeight(row.message, { + timelineWidthPx, + assistantResponseCopyFormat, + }); }, measureElement: measureVirtualElement, useAnimationFrameWithResizeObserver: true, @@ -443,21 +449,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ row.message.role === "assistant" && (() => { const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); - let assistantCopyText: string | null = null; - const getAssistantCopyText = () => { - if (assistantCopyText !== null) { - return assistantCopyText; - } - assistantCopyText = resolveAssistantMessageCopyText( - row.message.text, - assistantResponseCopyFormat, - ); - return assistantCopyText; - }; const showAssistantCopyButton = !row.message.streaming && - row.message.text.trim().length > 0 && - getAssistantCopyText().trim().length > 0; + hasAssistantResponseCopyText(row.message.text, assistantResponseCopyFormat); return ( <> {row.showCompletionDivider && ( @@ -534,7 +528,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({
{showAssistantCopyButton ? ( - + + resolveAssistantMessageCopyText( + row.message.text, + assistantResponseCopyFormat, + ) + } + label="Copy response" + /> ) : null}

diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts index 06f29236bb2..9fb9f414123 100644 --- a/apps/web/src/components/timelineHeight.test.ts +++ b/apps/web/src/components/timelineHeight.test.ts @@ -25,6 +25,19 @@ describe("estimateTimelineMessageHeight", () => { ).toBe(122); }); + it("keeps the smaller assistant base height when plain-text copy resolves empty", () => { + expect( + estimateTimelineMessageHeight( + { + role: "assistant", + text: "---", + streaming: false, + }, + { timelineWidthPx: null, assistantResponseCopyFormat: "plain-text" }, + ), + ).toBe(100); + }); + it("uses assistant sizing rules for system messages", () => { expect( estimateTimelineMessageHeight({ diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 454003969e2..69dda9fb376 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -1,4 +1,6 @@ +import { type AssistantResponseCopyFormat } from "@t3tools/contracts/settings"; import { deriveDisplayedUserMessageState } from "../lib/terminalContext"; +import { hasAssistantResponseCopyText } from "../lib/assistantMessageCopy"; import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts"; const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; @@ -27,6 +29,7 @@ interface TimelineMessageHeightInput { interface TimelineHeightEstimateLayout { timelineWidthPx: number | null; + assistantResponseCopyFormat?: AssistantResponseCopyFormat; } function estimateWrappedLineCount(text: string, charsPerLine: number): number { @@ -76,7 +79,8 @@ export function estimateTimelineMessageHeight( const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); const assistantBaseHeightPx = - message.streaming !== true && message.text.trim().length > 0 + message.streaming !== true && + hasAssistantResponseCopyText(message.text, layout.assistantResponseCopyFormat ?? "markdown") ? ASSISTANT_COMPLETED_ACTION_BASE_HEIGHT_PX : ASSISTANT_BASE_HEIGHT_PX; return assistantBaseHeightPx + estimatedLines * LINE_HEIGHT_PX; diff --git a/apps/web/src/lib/assistantMessageCopy.test.ts b/apps/web/src/lib/assistantMessageCopy.test.ts index 0d3de5db6b2..11d92cf840c 100644 --- a/apps/web/src/lib/assistantMessageCopy.test.ts +++ b/apps/web/src/lib/assistantMessageCopy.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { markdownToPlainText, resolveAssistantMessageCopyText } from "./assistantMessageCopy"; +import { + hasAssistantResponseCopyText, + markdownToPlainText, + resolveAssistantMessageCopyText, +} from "./assistantMessageCopy"; describe("assistantMessageCopy", () => { it("returns the raw assistant markdown unchanged in markdown mode", () => { @@ -71,4 +75,19 @@ describe("assistantMessageCopy", () => { ["10. first paragraph", " const value = 1;"].join("\n"), ); }); + + it("preserves gfm task state in copied plain text", () => { + const markdown = ["- [x] done", "- [ ] todo", "", "1. [x] ship it"].join("\n"); + + expect(markdownToPlainText(markdown)).toBe( + ["- [x] done", "- [ ] todo", "", "1. [x] ship it"].join("\n"), + ); + }); + + it("preserves visible raw html text in plain-text mode", () => { + const markdown = ["

", "Example", "
"].join("\n"); + + expect(markdownToPlainText(markdown)).toBe(markdown); + expect(hasAssistantResponseCopyText(markdown, "plain-text")).toBe(true); + }); }); diff --git a/apps/web/src/lib/assistantMessageCopy.ts b/apps/web/src/lib/assistantMessageCopy.ts index 3ae810a9451..2c6b96cf043 100644 --- a/apps/web/src/lib/assistantMessageCopy.ts +++ b/apps/web/src/lib/assistantMessageCopy.ts @@ -10,10 +10,13 @@ type MarkdownNode = { url?: string; ordered?: boolean; start?: number | null; + checked?: boolean | null; children?: MarkdownNode[]; }; const markdownProcessor = unified().use(remarkParse).use(remarkGfm); +const plainTextCache = new Map(); +const MAX_PLAIN_TEXT_CACHE_ENTRIES = 500; export function resolveAssistantMessageCopyText( messageText: string, @@ -23,7 +26,18 @@ export function resolveAssistantMessageCopyText( return messageText; } - return markdownToPlainText(messageText); + return getCachedPlainText(messageText); +} + +export function hasAssistantResponseCopyText( + messageText: string, + format: AssistantResponseCopyFormat, +): boolean { + if (messageText.trim().length === 0) { + return false; + } + + return resolveAssistantMessageCopyText(messageText, format).trim().length > 0; } export function markdownToPlainText(markdown: string): string { @@ -58,6 +72,7 @@ function renderBlock(node: MarkdownNode): string { case "table": return renderTable(node); case "html": + return normalizeCodeBlock(node.value ?? ""); case "thematicBreak": return ""; default: @@ -80,17 +95,19 @@ function renderListItem(node: MarkdownNode, marker: string): string { const blocks = (node.children ?? []) .map((child) => renderBlock(child)) .filter((block) => block.length > 0); + const taskPrefix = node.checked === true ? "[x] " : node.checked === false ? "[ ] " : ""; + const contentPrefix = `${marker}${taskPrefix}`; if (blocks.length === 0) { - return marker.trimEnd(); + return contentPrefix.trimEnd(); } - const continuationPrefix = " ".repeat(marker.length); + const continuationPrefix = " ".repeat(contentPrefix.length); const firstBlock = blocks[0]!; const remainingBlocks = blocks.slice(1); const firstBlockLines = splitLines(firstBlock); const lines = firstBlockLines.map((line, index) => - index === 0 ? `${marker}${line}` : `${continuationPrefix}${line}`, + index === 0 ? `${contentPrefix}${line}` : `${continuationPrefix}${line}`, ); for (const block of remainingBlocks) { @@ -121,6 +138,7 @@ function renderInline(node: MarkdownNode): string { switch (node.type) { case "text": case "inlineCode": + case "html": return node.value ?? ""; case "break": return "\n"; @@ -166,3 +184,17 @@ function normalizePlainText(value: string): string { function splitLines(value: string): string[] { return value.replace(/\r\n/g, "\n").split("\n"); } + +function getCachedPlainText(markdown: string): string { + const cached = plainTextCache.get(markdown); + if (typeof cached === "string") { + return cached; + } + + const plainText = markdownToPlainText(markdown); + if (plainTextCache.size >= MAX_PLAIN_TEXT_CACHE_ENTRIES) { + plainTextCache.clear(); + } + plainTextCache.set(markdown, plainText); + return plainText; +} From 65c6a83df86ff785f0db6042d42f231b3e56bd0b Mon Sep 17 00:00:00 2001 From: Xavier Date: Fri, 27 Mar 2026 22:51:18 -0300 Subject: [PATCH 05/26] Re-measure timeline on copy format change --- apps/web/src/components/chat/MessagesTimeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b4f6a6d668d..6acb41f824b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -274,7 +274,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ useEffect(() => { if (timelineWidthPx === null) return; rowVirtualizer.measure(); - }, [rowVirtualizer, timelineWidthPx]); + }, [assistantResponseCopyFormat, rowVirtualizer, timelineWidthPx]); useEffect(() => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { const viewportHeight = instance.scrollRect?.height ?? 0; From 48038957c09a26f608a26e674b64cbf89751b839 Mon Sep 17 00:00:00 2001 From: Xavier Date: Fri, 27 Mar 2026 22:59:57 -0300 Subject: [PATCH 06/26] Use LRU cache for assistant plain text --- apps/web/src/lib/assistantMessageCopy.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/web/src/lib/assistantMessageCopy.ts b/apps/web/src/lib/assistantMessageCopy.ts index 2c6b96cf043..6f134d99729 100644 --- a/apps/web/src/lib/assistantMessageCopy.ts +++ b/apps/web/src/lib/assistantMessageCopy.ts @@ -2,6 +2,7 @@ import { type AssistantResponseCopyFormat } from "@t3tools/contracts/settings"; import remarkGfm from "remark-gfm"; import remarkParse from "remark-parse"; import { unified } from "unified"; +import { LRUCache } from "./lruCache"; type MarkdownNode = { type: string; @@ -15,8 +16,12 @@ type MarkdownNode = { }; const markdownProcessor = unified().use(remarkParse).use(remarkGfm); -const plainTextCache = new Map(); const MAX_PLAIN_TEXT_CACHE_ENTRIES = 500; +const MAX_PLAIN_TEXT_CACHE_MEMORY_BYTES = 5 * 1024 * 1024; +const plainTextCache = new LRUCache( + MAX_PLAIN_TEXT_CACHE_ENTRIES, + MAX_PLAIN_TEXT_CACHE_MEMORY_BYTES, +); export function resolveAssistantMessageCopyText( messageText: string, @@ -187,14 +192,11 @@ function splitLines(value: string): string[] { function getCachedPlainText(markdown: string): string { const cached = plainTextCache.get(markdown); - if (typeof cached === "string") { + if (cached !== null) { return cached; } const plainText = markdownToPlainText(markdown); - if (plainTextCache.size >= MAX_PLAIN_TEXT_CACHE_ENTRIES) { - plainTextCache.clear(); - } - plainTextCache.set(markdown, plainText); + plainTextCache.set(markdown, plainText, plainText.length * 2); return plainText; } From 0897f669a5d7315f1866e78ca141cce3a03c07a5 Mon Sep 17 00:00:00 2001 From: Xavier Date: Fri, 27 Mar 2026 23:13:31 -0300 Subject: [PATCH 07/26] Preserve leading indentation in plain text copy --- apps/web/src/lib/assistantMessageCopy.test.ts | 6 ++++++ apps/web/src/lib/assistantMessageCopy.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/assistantMessageCopy.test.ts b/apps/web/src/lib/assistantMessageCopy.test.ts index 11d92cf840c..2513f34ef4c 100644 --- a/apps/web/src/lib/assistantMessageCopy.test.ts +++ b/apps/web/src/lib/assistantMessageCopy.test.ts @@ -90,4 +90,10 @@ describe("assistantMessageCopy", () => { expect(markdownToPlainText(markdown)).toBe(markdown); expect(hasAssistantResponseCopyText(markdown, "plain-text")).toBe(true); }); + + it("preserves leading indentation for top-level code blocks", () => { + const markdown = ["```py", " print('hi')", "```"].join("\n"); + + expect(markdownToPlainText(markdown)).toBe(" print('hi')"); + }); }); diff --git a/apps/web/src/lib/assistantMessageCopy.ts b/apps/web/src/lib/assistantMessageCopy.ts index 6f134d99729..0d4c697637f 100644 --- a/apps/web/src/lib/assistantMessageCopy.ts +++ b/apps/web/src/lib/assistantMessageCopy.ts @@ -183,7 +183,7 @@ function normalizePlainText(value: string): string { return value .replace(/[ \t]+\n/g, "\n") .replace(/\n{3,}/g, "\n\n") - .trim(); + .replace(/\n+$/g, ""); } function splitLines(value: string): string[] { From 059279bc09c64b5892cd7b6359ddbf8120337df2 Mon Sep 17 00:00:00 2001 From: Xavier Date: Fri, 27 Mar 2026 23:34:16 -0300 Subject: [PATCH 08/26] Collapse soft line breaks in plain text copy --- apps/web/src/lib/assistantMessageCopy.test.ts | 6 ++++++ apps/web/src/lib/assistantMessageCopy.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/apps/web/src/lib/assistantMessageCopy.test.ts b/apps/web/src/lib/assistantMessageCopy.test.ts index 2513f34ef4c..b7f0bcfaf46 100644 --- a/apps/web/src/lib/assistantMessageCopy.test.ts +++ b/apps/web/src/lib/assistantMessageCopy.test.ts @@ -96,4 +96,10 @@ describe("assistantMessageCopy", () => { expect(markdownToPlainText(markdown)).toBe(" print('hi')"); }); + + it("collapses soft-wrapped paragraph newlines but preserves explicit breaks", () => { + const markdown = ["soft", "wrap", "", "hard ", "break"].join("\n"); + + expect(markdownToPlainText(markdown)).toBe(["soft wrap", "", "hard", "break"].join("\n")); + }); }); diff --git a/apps/web/src/lib/assistantMessageCopy.ts b/apps/web/src/lib/assistantMessageCopy.ts index 0d4c697637f..60927595e23 100644 --- a/apps/web/src/lib/assistantMessageCopy.ts +++ b/apps/web/src/lib/assistantMessageCopy.ts @@ -142,6 +142,7 @@ function renderInlineChildren(nodes: readonly MarkdownNode[]): string { function renderInline(node: MarkdownNode): string { switch (node.type) { case "text": + return (node.value ?? "").replace(/\r?\n+/g, " "); case "inlineCode": case "html": return node.value ?? ""; From 11ec9d73f30ab7be91146636cc3887ed0216d143 Mon Sep 17 00:00:00 2001 From: Xavier Date: Fri, 27 Mar 2026 23:44:37 -0300 Subject: [PATCH 09/26] feat(web): add queue and steer follow-up behavior --- apps/web/src/components/ChatView.browser.tsx | 521 +++++++++++++ .../web/src/components/ChatView.logic.test.ts | 109 ++- apps/web/src/components/ChatView.logic.ts | 107 ++- apps/web/src/components/ChatView.tsx | 709 +++++++++++++++--- .../src/components/ComposerPromptEditor.tsx | 29 +- .../chat/ComposerQueuedFollowUpsPanel.tsx | 230 ++++++ apps/web/src/composerDraftStore.test.ts | 274 +++++++ apps/web/src/composerDraftStore.ts | 537 ++++++++++++- apps/web/src/routes/_chat.settings.tsx | 49 ++ packages/contracts/src/settings.test.ts | 15 + packages/contracts/src/settings.ts | 7 + 11 files changed, 2453 insertions(+), 134 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerQueuedFollowUpsPanel.tsx create mode 100644 packages/contracts/src/settings.test.ts diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 7bf3ecf26c2..48bc79d5aa4 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -619,6 +619,163 @@ async function waitForSendButton(): Promise { ); } +async function waitForComposerSubmitButton(label: string): Promise { + return waitForElement( + () => + document.querySelector(`button[type="submit"][aria-label="${label}"]`) ?? + Array.from(document.querySelectorAll('button[type="submit"]')).find( + (button) => button.textContent?.trim() === label, + ) ?? + null, + `Unable to find ${label} composer submit button.`, + ); +} + +async function waitForQueuedFollowUpsPanel(): Promise { + return waitForElement( + () => document.querySelector('[data-testid="queued-follow-ups-panel"]'), + "Unable to find queued follow-ups panel.", + ); +} + +async function openQueuedFollowUpActionsMenu(index: number): Promise { + const button = await waitForElement( + () => + document.querySelectorAll( + 'button[aria-label^="More queued follow-up actions"]', + )[index] ?? null, + `Unable to find queued follow-up actions button at index ${index}.`, + ); + button.click(); + return button; +} + +async function dragQueuedFollowUp(options: { + fromPrompt: string; + toPrompt: string; + position: "before" | "after"; +}): Promise { + const fromItem = await waitForElement( + () => + Array.from(document.querySelectorAll('[data-testid^="queued-follow-up-"]')).find( + (element) => element.textContent?.includes(options.fromPrompt), + ) ?? null, + `Unable to find queued follow-up row for ${options.fromPrompt}.`, + ); + const toItem = await waitForElement( + () => + Array.from(document.querySelectorAll('[data-testid^="queued-follow-up-"]')).find( + (element) => element.textContent?.includes(options.toPrompt), + ) ?? null, + `Unable to find queued follow-up row for ${options.toPrompt}.`, + ); + const dragHandle = fromItem.querySelector('[draggable="true"]'); + if (!dragHandle) { + throw new Error(`Unable to find drag handle for queued follow-up ${options.fromPrompt}.`); + } + + const dataTransfer = new DataTransfer(); + const targetBounds = toItem.getBoundingClientRect(); + const clientY = options.position === "before" ? targetBounds.top + 2 : targetBounds.bottom - 2; + + dragHandle.dispatchEvent( + new DragEvent("dragstart", { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); + await waitForLayout(); + toItem.dispatchEvent( + new DragEvent("dragover", { + bubbles: true, + cancelable: true, + dataTransfer, + clientY, + }), + ); + await waitForLayout(); + toItem.dispatchEvent( + new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer, + clientY, + }), + ); + await waitForLayout(); + dragHandle.dispatchEvent( + new DragEvent("dragend", { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); +} + +async function waitForDraftPrompt(prompt: string): Promise { + await vi.waitFor( + () => { + expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.prompt ?? "").toBe( + prompt, + ); + }, + { timeout: 8_000, interval: 16 }, + ); +} + +async function setComposerPrompt(prompt: string): Promise { + useComposerDraftStore.getState().setPrompt(THREAD_ID, prompt); + await waitForDraftPrompt(prompt); + await vi.waitFor( + () => { + expect(document.body.textContent ?? "").toContain(prompt); + }, + { timeout: 8_000, interval: 16 }, + ); + await waitForLayout(); +} + +async function queueFollowUpFromComposer(prompt: string): Promise { + await setComposerPrompt(prompt); + const submitButton = await waitForComposerSubmitButton("Queue follow-up"); + await vi.waitFor( + () => { + expect(submitButton.disabled).toBe(false); + }, + { timeout: 8_000, interval: 16 }, + ); + submitButton.click(); + await waitForDraftPrompt(""); + await waitForLayout(); +} + +function setClientSettings(settings: Partial): void { + localStorage.setItem("t3code:client-settings:v1", JSON.stringify(settings)); +} + +function getTurnStartRequests(): Array { + return wsRequests.flatMap((request) => { + const command = (request as { command?: { type?: string } }).command; + if ( + request._tag !== ORCHESTRATION_WS_METHODS.dispatchCommand || + !command || + command.type !== "thread.turn.start" + ) { + return []; + } + return [request as WsRequestEnvelope["body"] & { command: { type: string } }]; + }); +} + +function getQueuedFollowUpPrompts(): string[] { + return ( + useComposerDraftStore + .getState() + .queuedFollowUpsByThreadId[THREAD_ID]?.map((followUp) => followUp.prompt) ?? [] + ); +} + async function waitForInteractionModeButton( expectedLabel: "Chat" | "Plan", ): Promise { @@ -867,6 +1024,7 @@ describe("ChatView timeline estimator parity (full app)", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + queuedFollowUpsByThreadId: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); @@ -1758,6 +1916,369 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("persists the running follow-up behavior setting across remounts", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-setting-persist" as MessageId, + targetText: "follow-up setting persist target", + sessionStatus: "running", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot, + }); + + try { + await vi.waitFor( + () => { + expect( + useComposerDraftStore.getState().queuedFollowUpsByThreadId[THREAD_ID] ?? [], + ).toHaveLength(0); + expect( + JSON.parse(localStorage.getItem("t3code:client-settings:v1") ?? "{}").followUpBehavior, + ).toBe("queue"); + }, + { timeout: 8_000, interval: 16 }, + ); + await setComposerPrompt("queued setting persists"); + await waitForComposerSubmitButton("Queue follow-up"); + } finally { + await mounted.cleanup(); + } + + const remounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot, + }); + + try { + await setComposerPrompt("queued setting persists"); + await waitForComposerSubmitButton("Queue follow-up"); + } finally { + await remounted.cleanup(); + } + }); + + it("steers follow-ups by default while a turn is running", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-steer-default" as MessageId, + targetText: "follow-up steer default target", + sessionStatus: "running", + }), + }); + + try { + await setComposerPrompt("steer this run"); + const submitButton = await waitForComposerSubmitButton("Steer follow-up"); + submitButton.click(); + + await vi.waitFor( + () => { + expect(getTurnStartRequests()).toHaveLength(1); + expect(document.body.textContent ?? "").not.toContain("steer this run"); + }, + { timeout: 8_000, interval: 16 }, + ); + expect(getQueuedFollowUpPrompts()).toEqual([]); + } finally { + await mounted.cleanup(); + } + }); + + it("auto-sends only the queued head after the current run settles", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const runningSnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-auto-send" as MessageId, + targetText: "follow-up auto-send target", + sessionStatus: "running", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: runningSnapshot, + }); + + try { + await setComposerPrompt("queued head"); + await waitForComposerSubmitButton("Queue follow-up"); + + await queueFollowUpFromComposer("queued head"); + await queueFollowUpFromComposer("queued tail"); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual(["queued head", "queued tail"]); + }, + { timeout: 8_000, interval: 16 }, + ); + + const readySnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-auto-send" as MessageId, + targetText: "follow-up auto-send target", + sessionStatus: "ready", + }); + fixture.snapshot = readySnapshot; + useStore.getState().syncServerReadModel(readySnapshot); + + await vi.waitFor( + () => { + expect(getTurnStartRequests()).toHaveLength(1); + expect(getQueuedFollowUpPrompts()).toEqual(["queued tail"]); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("supports steering and deleting queued follow-ups from the panel", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-panel-actions" as MessageId, + targetText: "follow-up panel actions target", + sessionStatus: "running", + }), + }); + + try { + await setComposerPrompt("panel first"); + await waitForComposerSubmitButton("Queue follow-up"); + + await queueFollowUpFromComposer("panel first"); + await queueFollowUpFromComposer("panel second"); + + const panel = await waitForQueuedFollowUpsPanel(); + const steerButton = Array.from(panel.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Steer", + ); + expect(steerButton).toBeTruthy(); + steerButton?.click(); + + await vi.waitFor( + () => { + expect(getTurnStartRequests()).toHaveLength(1); + expect(getQueuedFollowUpPrompts()).toEqual(["panel second"]); + expect(document.body.textContent ?? "").not.toContain("panel first"); + }, + { timeout: 8_000, interval: 16 }, + ); + + const deleteButton = await waitForElement( + () => + document.querySelector( + 'button[aria-label^="Delete queued follow-up"]', + ), + "Unable to find delete queued follow-up button.", + ); + deleteButton.click(); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual([]); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("restores a queued follow-up into the composer from the panel", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-panel-edit" as MessageId, + targetText: "follow-up panel edit target", + sessionStatus: "running", + }), + }); + + try { + await setComposerPrompt("queued edit item"); + await waitForComposerSubmitButton("Queue follow-up"); + await queueFollowUpFromComposer("queued edit item"); + + await openQueuedFollowUpActionsMenu(0); + const editButton = await waitForElement( + () => + document.querySelector('button[aria-label^="Edit queued follow-up"]'), + "Unable to find edit queued follow-up button.", + ); + editButton.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual([]); + expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.prompt).toBe( + "queued edit item", + ); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("requeues an edited follow-up back near its original queued position", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-panel-edit-position" as MessageId, + targetText: "follow-up panel edit order target", + sessionStatus: "running", + }), + }); + + try { + await queueFollowUpFromComposer("queue first"); + await queueFollowUpFromComposer("queue second"); + await queueFollowUpFromComposer("queue third"); + + await openQueuedFollowUpActionsMenu(1); + const editButton = await waitForElement( + () => + document.querySelector('button[aria-label^="Edit queued follow-up"]'), + "Unable to find the queued follow-up edit button.", + ); + editButton.click(); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual(["queue first", "queue third"]); + expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.prompt).toBe( + "queue second", + ); + }, + { timeout: 8_000, interval: 16 }, + ); + + await queueFollowUpFromComposer("queue second edited"); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual([ + "queue first", + "queue second edited", + "queue third", + ]); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("lets queued follow-ups reorder by dragging from the panel handle", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-panel-move" as MessageId, + targetText: "follow-up panel move target", + sessionStatus: "running", + }), + }); + + try { + await queueFollowUpFromComposer("move first"); + await queueFollowUpFromComposer("move second"); + await queueFollowUpFromComposer("move third"); + + await dragQueuedFollowUp({ + fromPrompt: "move second", + toPrompt: "move first", + position: "before", + }); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual(["move second", "move first", "move third"]); + }, + { timeout: 8_000, interval: 16 }, + ); + + await dragQueuedFollowUp({ + fromPrompt: "move first", + toPrompt: "move third", + position: "after", + }); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual(["move second", "move third", "move first"]); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("uses Ctrl+Shift+Enter to submit the opposite follow-up behavior once", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-shortcut-opposite" as MessageId, + targetText: "follow-up shortcut target", + sessionStatus: "running", + }), + }); + + try { + await setComposerPrompt("shortcut steer once"); + await waitForComposerSubmitButton("Queue follow-up"); + + const composerEditor = await waitForComposerEditor(); + composerEditor.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor( + () => { + expect(getTurnStartRequests()).toHaveLength(1); + expect(getQueuedFollowUpPrompts()).toEqual([]); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("keeps the new thread selected after clicking the new-thread button", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index bf72ec0b84a..1eff5912a47 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,7 +1,15 @@ import { ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { buildExpiredTerminalContextToastCopy, deriveComposerSendState } from "./ChatView.logic"; +import { + buildExpiredTerminalContextToastCopy, + buildQueuedFollowUpDraft, + canAutoDispatchQueuedFollowUp, + deriveComposerSendState, + followUpBehaviorShortcutLabel, + resolveFollowUpBehavior, + shouldInvertFollowUpBehaviorFromKeyEvent, +} from "./ChatView.logic"; describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { @@ -67,3 +75,102 @@ describe("buildExpiredTerminalContextToastCopy", () => { }); }); }); + +describe("follow-up behavior helpers", () => { + it("inverts the configured behavior when requested", () => { + expect(resolveFollowUpBehavior("steer", false)).toBe("steer"); + expect(resolveFollowUpBehavior("steer", true)).toBe("queue"); + expect(resolveFollowUpBehavior("queue", true)).toBe("steer"); + }); + + it("detects the opposite-submit keyboard shortcut across platforms", () => { + expect( + shouldInvertFollowUpBehaviorFromKeyEvent( + { + ctrlKey: true, + metaKey: false, + shiftKey: true, + altKey: false, + }, + "Win32", + ), + ).toBe(true); + expect( + shouldInvertFollowUpBehaviorFromKeyEvent( + { + ctrlKey: false, + metaKey: true, + shiftKey: true, + altKey: false, + }, + "MacIntel", + ), + ).toBe(true); + expect( + shouldInvertFollowUpBehaviorFromKeyEvent( + { + ctrlKey: false, + metaKey: false, + shiftKey: true, + altKey: false, + }, + "Win32", + ), + ).toBe(false); + expect(followUpBehaviorShortcutLabel("MacIntel")).toBe("Cmd+Shift+Enter"); + expect(followUpBehaviorShortcutLabel("Win32")).toBe("Ctrl+Shift+Enter"); + }); + + it("builds a queued follow-up snapshot and auto-dispatch rules", () => { + const snapshot = buildQueuedFollowUpDraft({ + prompt: "next step", + attachments: [], + terminalContexts: [ + { + id: "ctx-1", + threadId: ThreadId.makeUnsafe("thread-1"), + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 1, + lineEnd: 1, + text: "hello", + createdAt: "2026-03-27T12:00:00.000Z", + }, + ], + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-03-27T12:00:00.000Z", + }); + + expect(snapshot.id).toBeTruthy(); + expect(snapshot.terminalContexts[0]?.text).toBe("hello"); + expect( + canAutoDispatchQueuedFollowUp({ + phase: "ready", + queuedFollowUpCount: 2, + isConnecting: false, + isSendBusy: false, + isRevertingCheckpoint: false, + hasThreadError: false, + hasPendingApproval: false, + hasPendingUserInput: false, + }), + ).toBe(true); + expect( + canAutoDispatchQueuedFollowUp({ + phase: "running", + queuedFollowUpCount: 2, + isConnecting: false, + isSendBusy: false, + isRevertingCheckpoint: false, + hasThreadError: false, + hasPendingApproval: false, + hasPendingUserInput: false, + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0a9f242ed09..b548c678c94 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,13 +1,26 @@ -import { ProjectId, type ModelSelection, type ThreadId } from "@t3tools/contracts"; +import { + ProjectId, + ProviderInteractionMode, + RuntimeMode, + type ModelSelection, + type ThreadId, +} from "@t3tools/contracts"; +import { type FollowUpBehavior } from "@t3tools/contracts/settings"; import { type ChatMessage, type Thread } from "../types"; import { randomUUID } from "~/lib/utils"; -import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; +import { + type ComposerImageAttachment, + type DraftThreadState, + type PersistedComposerImageAttachment, + type QueuedFollowUpDraft, +} from "../composerDraftStore"; import { Schema } from "effect"; import { filterTerminalContextsWithText, stripInlineTerminalContextPlaceholders, type TerminalContextDraft, } from "../lib/terminalContext"; +import { isMacPlatform } from "../lib/utils"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; const WORKTREE_BRANCH_PREFIX = "t3code"; @@ -160,3 +173,93 @@ export function buildExpiredTerminalContextToastCopy( description: "Re-add it if you want that terminal output included.", }; } + +export function resolveFollowUpBehavior( + followUpBehavior: FollowUpBehavior, + invert: boolean, +): FollowUpBehavior { + if (!invert) { + return followUpBehavior; + } + return followUpBehavior === "queue" ? "steer" : "queue"; +} + +export function shouldInvertFollowUpBehaviorFromKeyEvent( + event: Pick, + platform = navigator.platform, +): boolean { + if (!event.shiftKey || event.altKey) { + return false; + } + if (isMacPlatform(platform)) { + return event.metaKey && !event.ctrlKey; + } + return event.ctrlKey && !event.metaKey; +} + +export function followUpBehaviorShortcutLabel(platform = navigator.platform): string { + return isMacPlatform(platform) ? "Cmd+Shift+Enter" : "Ctrl+Shift+Enter"; +} + +export function buildQueuedFollowUpDraft(input: { + prompt: string; + attachments: ReadonlyArray; + terminalContexts: ReadonlyArray; + modelSelection: ModelSelection; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + createdAt: string; +}): QueuedFollowUpDraft { + return { + id: randomUUID(), + createdAt: input.createdAt, + prompt: input.prompt, + attachments: [...input.attachments], + terminalContexts: input.terminalContexts.map((context) => ({ ...context })), + modelSelection: input.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + }; +} + +export function canAutoDispatchQueuedFollowUp(input: { + phase: "disconnected" | "connecting" | "ready" | "running"; + queuedFollowUpCount: number; + isConnecting: boolean; + isSendBusy: boolean; + isRevertingCheckpoint: boolean; + hasThreadError: boolean; + hasPendingApproval: boolean; + hasPendingUserInput: boolean; +}): boolean { + return ( + input.phase === "ready" && + input.queuedFollowUpCount > 0 && + !input.isConnecting && + !input.isSendBusy && + !input.isRevertingCheckpoint && + !input.hasThreadError && + !input.hasPendingApproval && + !input.hasPendingUserInput + ); +} + +export function describeQueuedFollowUp( + followUp: Pick, +): string { + const trimmedPrompt = stripInlineTerminalContextPlaceholders(followUp.prompt).trim(); + if (trimmedPrompt.length > 0) { + return trimmedPrompt; + } + if (followUp.attachments.length > 0) { + return followUp.attachments.length === 1 + ? "1 image attached" + : `${followUp.attachments.length} images attached`; + } + if (followUp.terminalContexts.length > 0) { + return followUp.terminalContexts.length === 1 + ? (followUp.terminalContexts[0]?.terminalLabel ?? "1 terminal context") + : `${followUp.terminalContexts.length} terminal contexts`; + } + return "Follow-up"; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1d926bf308e..173c5679532 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -87,6 +87,8 @@ import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { BotIcon, + Clock3Icon, + CornerDownRightIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, @@ -126,10 +128,13 @@ import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, type DraftThreadEnvMode, + hydrateComposerImagesFromPersistedAttachments, type PersistedComposerImageAttachment, + type QueuedFollowUpDraft, useComposerDraftStore, useEffectiveComposerModelState, useComposerThreadDraft, + useQueuedFollowUps, } from "../composerDraftStore"; import { appendTerminalContextsToPrompt, @@ -155,6 +160,7 @@ import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu" import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; +import { ComposerQueuedFollowUpsPanel } from "./chat/ComposerQueuedFollowUpsPanel"; import { getComposerProviderState, renderProviderTraitsMenuContent, @@ -164,18 +170,23 @@ import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { buildExpiredTerminalContextToastCopy, + buildQueuedFollowUpDraft, buildLocalDraftThread, buildTemporaryWorktreeBranchName, + canAutoDispatchQueuedFollowUp, cloneComposerImageForRetry, collectUserMessageBlobPreviewUrls, deriveComposerSendState, + followUpBehaviorShortcutLabel, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, PullRequestDialogState, readFileAsDataUrl, + resolveFollowUpBehavior, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, SendPhase, + shouldInvertFollowUpBehaviorFromKeyEvent, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; @@ -245,6 +256,18 @@ interface PendingPullRequestSetupRequest { scriptId: string; } +type FollowUpSubmissionSnapshot = QueuedFollowUpDraft; + +function persistedModelOptionsFromSelection( + modelSelection: ModelSelection, +): Parameters[0]["modelOptions"] { + return modelSelection.options + ? { + [modelSelection.provider]: modelSelection.options, + } + : null; +} + export default function ChatView({ threadId }: ChatViewProps) { const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); @@ -257,6 +280,7 @@ export default function ChatView({ threadId }: ChatViewProps) { (store) => store.setStickyModelSelection, ); const timestampFormat = settings.timestampFormat; + const queuedFollowUps = useQueuedFollowUps(threadId); const navigate = useNavigate(); const rawSearch = useSearch({ strict: false, @@ -306,6 +330,14 @@ export default function ChatView({ threadId }: ChatViewProps) { const syncComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.syncPersistedAttachments, ); + const enqueueQueuedFollowUp = useComposerDraftStore((store) => store.enqueueQueuedFollowUp); + const reorderQueuedFollowUp = useComposerDraftStore((store) => store.reorderQueuedFollowUp); + const updateQueuedFollowUp = useComposerDraftStore((store) => store.updateQueuedFollowUp); + const removeQueuedFollowUp = useComposerDraftStore((store) => store.removeQueuedFollowUp); + const shiftQueuedFollowUp = useComposerDraftStore((store) => store.shiftQueuedFollowUp); + const restoreQueuedFollowUpToDraft = useComposerDraftStore( + (store) => store.restoreQueuedFollowUpToDraft, + ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const getDraftThreadByProjectId = useComposerDraftStore( @@ -324,6 +356,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); + const [hiddenTimelineMessageIds, setHiddenTimelineMessageIds] = useState>( + () => new Set(), + ); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const composerTerminalContextsRef = useRef(composerTerminalContexts); @@ -396,6 +431,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); + const autoDispatchQueuedFollowUpIdRef = useRef(null); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { @@ -667,6 +703,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const isSendBusy = sendPhase !== "idle"; const isPreparingWorktree = sendPhase === "preparing-worktree"; const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const followUpBehavior = settings.followUpBehavior; const nowIso = new Date(nowTick).toISOString(); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, @@ -753,11 +790,33 @@ export default function ChatView({ threadId }: ChatViewProps) { hasActionableProposedPlan(activeProposedPlan); const activePendingApproval = pendingApprovals[0] ?? null; const isComposerApprovalState = activePendingApproval !== null; + const canUseRunningFollowUps = + phase === "running" && !isComposerApprovalState && pendingUserInputs.length === 0; const hasComposerHeader = isComposerApprovalState || pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; + const runningFollowUpActionLabel = + followUpBehavior === "queue" ? "Queue follow-up" : "Steer follow-up"; + const runningFollowUpShortcutRows = + followUpBehavior === "queue" + ? [ + { label: "Queue", shortcut: "Enter", active: true }, + { + label: "Steer", + shortcut: followUpBehaviorShortcutLabel(), + active: false, + }, + ] + : [ + { label: "Steer", shortcut: "Enter", active: true }, + { + label: "Queue", + shortcut: followUpBehaviorShortcutLabel(), + active: false, + }, + ]; const lastSyncedPendingInputRef = useRef<{ requestId: string | null; questionId: string | null; @@ -823,6 +882,9 @@ export default function ChatView({ threadId }: ChatViewProps) { } }; }, [clearAttachmentPreviewHandoffs]); + useEffect(() => { + setHiddenTimelineMessageIds(new Set()); + }, [activeThread?.id]); const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { if (previewUrls.length === 0) return; @@ -907,15 +969,30 @@ export default function ChatView({ threadId }: ChatViewProps) { }); if (optimisticUserMessages.length === 0) { - return serverMessagesWithPreviewHandoff; + return hiddenTimelineMessageIds.size === 0 + ? serverMessagesWithPreviewHandoff + : serverMessagesWithPreviewHandoff.filter( + (message) => !hiddenTimelineMessageIds.has(message.id), + ); } - const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id)); + const visibleServerMessages = + hiddenTimelineMessageIds.size === 0 + ? serverMessagesWithPreviewHandoff + : serverMessagesWithPreviewHandoff.filter( + (message) => !hiddenTimelineMessageIds.has(message.id), + ); + const serverIds = new Set(visibleServerMessages.map((message) => message.id)); const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id)); if (pendingMessages.length === 0) { - return serverMessagesWithPreviewHandoff; + return visibleServerMessages; } - return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; - }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); + return [...visibleServerMessages, ...pendingMessages]; + }, [ + serverMessages, + attachmentPreviewHandoffByMessageId, + hiddenTimelineMessageIds, + optimisticUserMessages, + ]); const timelineEntries = useMemo( () => deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), @@ -2428,8 +2505,8 @@ export default function ChatView({ threadId }: ChatViewProps) { [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], ); - const onSend = async (e?: { preventDefault: () => void }) => { - e?.preventDefault(); + const onSend = async (input?: { preventDefault?: () => void; keyboardEvent?: KeyboardEvent }) => { + input?.preventDefault?.(); const api = readNativeApi(); if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; if (activePendingProgress) { @@ -2463,6 +2540,47 @@ export default function ChatView({ threadId }: ChatViewProps) { }); return; } + if (canUseRunningFollowUps && isServerThread) { + const createdAt = new Date().toISOString(); + const followUpSnapshot = await createFollowUpSnapshotFromComposer(createdAt); + if (!followUpSnapshot) { + return; + } + const effectiveBehavior = resolveFollowUpBehavior( + followUpBehavior, + Boolean( + input?.keyboardEvent && shouldInvertFollowUpBehaviorFromKeyEvent(input.keyboardEvent), + ), + ); + + if (effectiveBehavior === "queue") { + enqueueQueuedFollowUp(activeThread.id, followUpSnapshot); + promptRef.current = ""; + clearComposerDraftContent(activeThread.id); + setComposerHighlightedItemId(null); + setComposerCursor(0); + setComposerTrigger(null); + return; + } + + promptRef.current = ""; + clearComposerDraftContent(activeThread.id); + setComposerHighlightedItemId(null); + setComposerCursor(0); + setComposerTrigger(null); + + const dispatched = await dispatchServerThreadSnapshot({ + threadId: activeThread.id, + snapshot: followUpSnapshot, + errorMessage: "Failed to send follow-up.", + suppressOptimisticMessage: true, + hideServerMessage: true, + }); + if (!dispatched) { + restoreFollowUpSnapshotToComposer(followUpSnapshot); + } + return; + } const standaloneSlashCommand = composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) @@ -2915,6 +3033,224 @@ export default function ChatView({ threadId }: ChatViewProps) { setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0)); }, [activePendingProgress, setActivePendingUserInputQuestionIndex]); + const createFollowUpSnapshotFromComposer = useCallback( + async (createdAt: string): Promise => { + const promptForSend = promptRef.current; + const { + trimmedPrompt, + sendableTerminalContexts, + expiredTerminalContextCount, + hasSendableContent, + } = deriveComposerSendState({ + prompt: promptForSend, + imageCount: composerImagesRef.current.length, + terminalContexts: composerTerminalContextsRef.current, + }); + if (!hasSendableContent) { + if (expiredTerminalContextCount > 0) { + const toastCopy = buildExpiredTerminalContextToastCopy( + expiredTerminalContextCount, + "empty", + ); + toastManager.add({ + type: "warning", + title: toastCopy.title, + description: toastCopy.description, + }); + } + return null; + } + const persistedAttachments = await Promise.all( + composerImagesRef.current.map(async (image) => ({ + id: image.id, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: await readFileAsDataUrl(image.file), + })), + ); + return buildQueuedFollowUpDraft({ + prompt: trimmedPrompt, + attachments: persistedAttachments, + terminalContexts: sendableTerminalContexts, + modelSelection: selectedModelSelection, + runtimeMode, + interactionMode, + createdAt, + }); + }, + [interactionMode, runtimeMode, selectedModelSelection], + ); + + const restoreFollowUpSnapshotToComposer = useCallback( + (snapshot: FollowUpSubmissionSnapshot) => { + setComposerDraftPrompt(threadId, snapshot.prompt); + useComposerDraftStore.setState((state) => { + const currentDraft = state.draftsByThreadId[threadId]; + return { + draftsByThreadId: { + ...state.draftsByThreadId, + [threadId]: { + ...(currentDraft ?? composerDraft), + prompt: snapshot.prompt, + images: hydrateComposerImagesFromPersistedAttachments(snapshot.attachments), + nonPersistedImageIds: [], + persistedAttachments: [...snapshot.attachments], + terminalContexts: snapshot.terminalContexts.map((context) => ({ ...context })), + modelSelectionByProvider: { + ...(currentDraft?.modelSelectionByProvider ?? + composerDraft.modelSelectionByProvider), + [snapshot.modelSelection.provider]: snapshot.modelSelection, + }, + activeProvider: snapshot.modelSelection.provider, + runtimeMode: snapshot.runtimeMode, + interactionMode: snapshot.interactionMode, + }, + }, + }; + }); + promptRef.current = snapshot.prompt; + setComposerCursor(collapseExpandedComposerCursor(snapshot.prompt, snapshot.prompt.length)); + setComposerTrigger(detectComposerTrigger(snapshot.prompt, snapshot.prompt.length)); + }, + [composerDraft, setComposerDraftPrompt, threadId], + ); + + const dispatchServerThreadSnapshot = useCallback( + async (input: { + threadId: ThreadId; + snapshot: FollowUpSubmissionSnapshot; + errorMessage: string; + suppressOptimisticMessage?: boolean; + hideServerMessage?: boolean; + sourceProposedPlan?: { + threadId: ThreadId; + planId: string; + }; + onAfterDispatch?: () => void; + }) => { + const api = readNativeApi(); + if (!api) { + return false; + } + const snapshotProvider = input.snapshot.modelSelection.provider; + const snapshotModel = input.snapshot.modelSelection.model; + const snapshotModels = getProviderModels(providerStatuses, snapshotProvider); + const snapshotProviderState = getComposerProviderState({ + provider: snapshotProvider, + model: snapshotModel, + models: snapshotModels, + prompt: input.snapshot.prompt, + modelOptions: persistedModelOptionsFromSelection(input.snapshot.modelSelection), + }); + const promptWithTerminalContexts = appendTerminalContextsToPrompt( + input.snapshot.prompt, + input.snapshot.terminalContexts, + ); + const outgoingMessageText = formatOutgoingPrompt({ + provider: snapshotProvider, + model: snapshotModel, + models: snapshotModels, + effort: snapshotProviderState.promptEffort, + text: promptWithTerminalContexts || IMAGE_ONLY_BOOTSTRAP_PROMPT, + }); + const optimisticAttachments = input.snapshot.attachments.map((attachment) => ({ + type: "image" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + previewUrl: attachment.dataUrl, + })); + const messageIdForSend = newMessageId(); + const dispatchCreatedAt = new Date().toISOString(); + + sendInFlightRef.current = true; + beginSendPhase("sending-turn"); + setThreadError(input.threadId, null); + if (input.hideServerMessage) { + setHiddenTimelineMessageIds((existing) => new Set(existing).add(messageIdForSend)); + } + if (!input.suppressOptimisticMessage) { + setOptimisticUserMessages((existing) => [ + ...existing, + { + id: messageIdForSend, + role: "user", + text: outgoingMessageText, + ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), + createdAt: dispatchCreatedAt, + streaming: false, + }, + ]); + } + shouldAutoScrollRef.current = true; + forceStickToBottom(); + + try { + await persistThreadSettingsForNextTurn({ + threadId: input.threadId, + createdAt: dispatchCreatedAt, + modelSelection: input.snapshot.modelSelection, + runtimeMode: input.snapshot.runtimeMode, + interactionMode: input.snapshot.interactionMode, + }); + setComposerDraftInteractionMode(input.threadId, input.snapshot.interactionMode); + await api.orchestration.dispatchCommand({ + type: "thread.turn.start", + commandId: newCommandId(), + threadId: input.threadId, + message: { + messageId: messageIdForSend, + role: "user", + text: outgoingMessageText, + attachments: input.snapshot.attachments.map((attachment) => ({ + type: "image" as const, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + dataUrl: attachment.dataUrl, + })), + }, + modelSelection: input.snapshot.modelSelection, + runtimeMode: input.snapshot.runtimeMode, + interactionMode: input.snapshot.interactionMode, + ...(input.sourceProposedPlan ? { sourceProposedPlan: input.sourceProposedPlan } : {}), + createdAt: dispatchCreatedAt, + }); + input.onAfterDispatch?.(); + sendInFlightRef.current = false; + return true; + } catch (err) { + if (input.hideServerMessage) { + setHiddenTimelineMessageIds((existing) => { + const next = new Set(existing); + next.delete(messageIdForSend); + return next; + }); + } + if (!input.suppressOptimisticMessage) { + setOptimisticUserMessages((existing) => + existing.filter((message) => message.id !== messageIdForSend), + ); + } + setThreadError(input.threadId, err instanceof Error ? err.message : input.errorMessage); + sendInFlightRef.current = false; + resetSendPhase(); + return false; + } + }, + [ + beginSendPhase, + forceStickToBottom, + persistThreadSettingsForNextTurn, + providerStatuses, + resetSendPhase, + setComposerDraftInteractionMode, + setThreadError, + ], + ); + const onSubmitPlanFollowUp = useCallback( async ({ text, @@ -2940,110 +3276,189 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } - const threadIdForSend = activeThread.id; - const messageIdForSend = newMessageId(); - const messageCreatedAt = new Date().toISOString(); - const outgoingMessageText = formatOutgoingPrompt({ - provider: selectedProvider, - model: selectedModel, - models: selectedProviderModels, - effort: selectedPromptEffort, - text: trimmed, - }); - - sendInFlightRef.current = true; - beginSendPhase("sending-turn"); - setThreadError(threadIdForSend, null); - setOptimisticUserMessages((existing) => [ - ...existing, - { - id: messageIdForSend, - role: "user", - text: outgoingMessageText, - createdAt: messageCreatedAt, - streaming: false, - }, - ]); - shouldAutoScrollRef.current = true; - forceStickToBottom(); - - try { - await persistThreadSettingsForNextTurn({ - threadId: threadIdForSend, - createdAt: messageCreatedAt, + await dispatchServerThreadSnapshot({ + threadId: activeThread.id, + snapshot: { + id: randomUUID(), + createdAt: new Date().toISOString(), + prompt: trimmed, + attachments: [], + terminalContexts: [], modelSelection: selectedModelSelection, runtimeMode, interactionMode: nextInteractionMode, - }); + }, + errorMessage: "Failed to send plan follow-up.", + ...(nextInteractionMode === "default" && activeProposedPlan + ? { + sourceProposedPlan: { + threadId: activeThread.id, + planId: activeProposedPlan.id, + }, + } + : {}), + onAfterDispatch: () => { + if (nextInteractionMode === "default") { + planSidebarDismissedForTurnRef.current = null; + setPlanSidebarOpen(true); + } + }, + }); + }, + [ + activeThread, + activeProposedPlan, + dispatchServerThreadSnapshot, + isConnecting, + isSendBusy, + isServerThread, + runtimeMode, + selectedModelSelection, + ], + ); - // Keep the mode toggle and plan-follow-up banner in sync immediately - // while the same-thread implementation turn is starting. - setComposerDraftInteractionMode(threadIdForSend, nextInteractionMode); + const onDeleteQueuedFollowUp = useCallback( + (followUpId: string) => { + removeQueuedFollowUp(threadId, followUpId); + }, + [removeQueuedFollowUp, threadId], + ); - await api.orchestration.dispatchCommand({ - type: "thread.turn.start", - commandId: newCommandId(), - threadId: threadIdForSend, - message: { - messageId: messageIdForSend, - role: "user", - text: outgoingMessageText, - attachments: [], - }, - modelSelection: selectedModelSelection, - runtimeMode, - interactionMode: nextInteractionMode, - ...(nextInteractionMode === "default" && activeProposedPlan - ? { - sourceProposedPlan: { - threadId: activeThread.id, - planId: activeProposedPlan.id, - }, - } - : {}), - createdAt: messageCreatedAt, - }); - // Optimistically open the plan sidebar when implementing (not refining). - // "default" mode here means the agent is executing the plan, which produces - // step-tracking activities that the sidebar will display. - if (nextInteractionMode === "default") { - planSidebarDismissedForTurnRef.current = null; - setPlanSidebarOpen(true); - } - sendInFlightRef.current = false; - } catch (err) { - setOptimisticUserMessages((existing) => - existing.filter((message) => message.id !== messageIdForSend), - ); - setThreadError( - threadIdForSend, - err instanceof Error ? err.message : "Failed to send plan follow-up.", + const onReorderQueuedFollowUp = useCallback( + (followUpId: string, targetIndex: number) => { + reorderQueuedFollowUp(threadId, followUpId, targetIndex); + }, + [reorderQueuedFollowUp, threadId], + ); + + const onEditQueuedFollowUp = useCallback( + (followUpId: string) => { + const followUp = queuedFollowUps.find((entry) => entry.id === followUpId); + if (!followUp) { + return; + } + restoreQueuedFollowUpToDraft(threadId, followUpId); + promptRef.current = followUp.prompt; + setComposerCursor(collapseExpandedComposerCursor(followUp.prompt, followUp.prompt.length)); + setComposerTrigger(detectComposerTrigger(followUp.prompt, followUp.prompt.length)); + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAt( + collapseExpandedComposerCursor(followUp.prompt, followUp.prompt.length), ); - sendInFlightRef.current = false; - resetSendPhase(); + }); + }, + [queuedFollowUps, restoreQueuedFollowUpToDraft, threadId], + ); + + const onSteerQueuedFollowUp = useCallback( + async (followUpId: string) => { + if ( + !activeThread || + !isServerThread || + isSendBusy || + isConnecting || + sendInFlightRef.current + ) { + return; + } + const followUp = queuedFollowUps.find((entry) => entry.id === followUpId); + if (!followUp) { + return; + } + updateQueuedFollowUp(threadId, followUpId, (existing) => { + const { lastSendError: _lastSendError, ...rest } = existing; + return { ...rest }; + }); + const dispatched = await dispatchServerThreadSnapshot({ + threadId: activeThread.id, + snapshot: followUp, + errorMessage: "Failed to steer queued follow-up.", + suppressOptimisticMessage: phase === "running", + hideServerMessage: true, + }); + if (dispatched) { + removeQueuedFollowUp(threadId, followUpId); + } else { + updateQueuedFollowUp(threadId, followUpId, (existing) => ({ + ...existing, + lastSendError: "Steer failed. Review the thread error or edit this follow-up.", + })); } }, [ activeThread, - activeProposedPlan, - beginSendPhase, - forceStickToBottom, + dispatchServerThreadSnapshot, isConnecting, isSendBusy, isServerThread, - persistThreadSettingsForNextTurn, - resetSendPhase, - runtimeMode, - selectedPromptEffort, - selectedModelSelection, - selectedProvider, - selectedProviderModels, - setComposerDraftInteractionMode, - setThreadError, - selectedModel, + phase, + queuedFollowUps, + removeQueuedFollowUp, + threadId, + updateQueuedFollowUp, ], ); + useEffect(() => { + if (!activeThread || !isServerThread) { + autoDispatchQueuedFollowUpIdRef.current = null; + return; + } + const nextQueuedFollowUp = queuedFollowUps[0]; + if (!nextQueuedFollowUp || nextQueuedFollowUp.lastSendError) { + autoDispatchQueuedFollowUpIdRef.current = null; + return; + } + if ( + !canAutoDispatchQueuedFollowUp({ + phase, + queuedFollowUpCount: queuedFollowUps.length, + isConnecting, + isSendBusy, + isRevertingCheckpoint, + hasThreadError: Boolean(activeThread.error), + hasPendingApproval: activePendingApproval !== null, + hasPendingUserInput: activePendingUserInput !== null, + }) + ) { + return; + } + if (autoDispatchQueuedFollowUpIdRef.current === nextQueuedFollowUp.id) { + return; + } + autoDispatchQueuedFollowUpIdRef.current = nextQueuedFollowUp.id; + void (async () => { + const dispatched = await dispatchServerThreadSnapshot({ + threadId: activeThread.id, + snapshot: nextQueuedFollowUp, + errorMessage: "Failed to send queued follow-up.", + }); + if (dispatched) { + shiftQueuedFollowUp(threadId); + } else { + updateQueuedFollowUp(threadId, nextQueuedFollowUp.id, (existing) => ({ + ...existing, + lastSendError: "Auto-send failed. Steer, edit, or delete this follow-up.", + })); + } + autoDispatchQueuedFollowUpIdRef.current = null; + })(); + }, [ + activePendingApproval, + activePendingUserInput, + activeThread, + dispatchServerThreadSnapshot, + isConnecting, + isRevertingCheckpoint, + isSendBusy, + isServerThread, + phase, + queuedFollowUps, + shiftQueuedFollowUp, + threadId, + updateQueuedFollowUp, + ]); + const onImplementPlanInNewThread = useCallback(async () => { const api = readNativeApi(); if ( @@ -3483,8 +3898,8 @@ export default function ChatView({ threadId }: ChatViewProps) { } } - if (key === "Enter" && !event.shiftKey) { - void onSend(); + if (key === "Enter" && (!event.shiftKey || shouldInvertFollowUpBehaviorFromKeyEvent(event))) { + void onSend({ keyboardEvent: event }); return true; } return false; @@ -3662,9 +4077,18 @@ export default function ChatView({ threadId }: ChatViewProps) { className="mx-auto w-full min-w-0 max-w-3xl" data-chat-composer-form="true" > + { + void onSteerQueuedFollowUp(followUpId); + }} + />
) : phase === "running" ? ( - + } + /> + +
+
+ {runningFollowUpShortcutRows.map((row) => ( +
+ + {row.label} + + + {row.shortcut} + +
+ ))} +
+
+
+ + ) : ( + + + + ) ) : pendingUserInputs.length === 0 ? ( showPlanFollowUpPrompt ? ( prompt.trim().length > 0 ? ( diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 338d9f7bf1a..6b5d5f26f0f 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -896,6 +896,8 @@ function ComposerPromptEditorInner({ const initialCursor = clampCollapsedComposerCursor(value, cursor); const terminalContextsSignature = terminalContextSignature(terminalContexts); const terminalContextsSignatureRef = useRef(terminalContextsSignature); + const controlledValueRef = useRef(value); + const controlledTerminalContextIdsRef = useRef(terminalContexts.map((context) => context.id)); const snapshotRef = useRef({ value, cursor: initialCursor, @@ -912,6 +914,11 @@ function ComposerPromptEditorInner({ onChangeRef.current = onChange; }, [onChange]); + useEffect(() => { + controlledValueRef.current = value; + controlledTerminalContextIdsRef.current = terminalContexts.map((context) => context.id); + }, [terminalContexts, value]); + useEffect(() => { editor.setEditable(!disabled); }, [disabled, editor]); @@ -961,23 +968,25 @@ function ComposerPromptEditorInner({ (nextCursor: number) => { const rootElement = editor.getRootElement(); if (!rootElement) return; - const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); + const controlledValue = controlledValueRef.current; + const controlledTerminalContextIds = controlledTerminalContextIdsRef.current; + const boundedCursor = clampCollapsedComposerCursor(controlledValue, nextCursor); rootElement.focus(); editor.update(() => { $setSelectionAtComposerOffset(boundedCursor); }); snapshotRef.current = { - value: snapshotRef.current.value, + value: controlledValue, cursor: boundedCursor, - expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), - terminalContextIds: snapshotRef.current.terminalContextIds, + expandedCursor: expandCollapsedComposerCursor(controlledValue, boundedCursor), + terminalContextIds: controlledTerminalContextIds, }; onChangeRef.current( - snapshotRef.current.value, + controlledValue, boundedCursor, snapshotRef.current.expandedCursor, false, - snapshotRef.current.terminalContextIds, + controlledTerminalContextIds, ); }, [editor], @@ -1025,12 +1034,8 @@ function ComposerPromptEditorInner({ }, focusAt, focusAtEnd: () => { - focusAt( - collapseExpandedComposerCursor( - snapshotRef.current.value, - snapshotRef.current.value.length, - ), - ); + const controlledValue = controlledValueRef.current; + focusAt(collapseExpandedComposerCursor(controlledValue, controlledValue.length)); }, readSnapshot, }), diff --git a/apps/web/src/components/chat/ComposerQueuedFollowUpsPanel.tsx b/apps/web/src/components/chat/ComposerQueuedFollowUpsPanel.tsx new file mode 100644 index 00000000000..ea5532cf01b --- /dev/null +++ b/apps/web/src/components/chat/ComposerQueuedFollowUpsPanel.tsx @@ -0,0 +1,230 @@ +import type * as React from "react"; +import { memo, useEffect, useRef, useState } from "react"; +import { CornerDownRightIcon, EllipsisIcon, PencilIcon, Trash2Icon } from "lucide-react"; +import { type QueuedFollowUpDraft } from "../../composerDraftStore"; +import { describeQueuedFollowUp } from "../ChatView.logic"; +import { Button } from "../ui/button"; + +function resolveDropPosition( + event: Pick, "clientY" | "currentTarget">, +): "before" | "after" { + const bounds = event.currentTarget.getBoundingClientRect(); + return event.clientY <= bounds.top + bounds.height / 2 ? "before" : "after"; +} + +function resolveTargetIndex( + currentIndex: number, + hoveredIndex: number, + position: "before" | "after", +): number { + if (position === "before") { + return currentIndex < hoveredIndex ? hoveredIndex - 1 : hoveredIndex; + } + return currentIndex < hoveredIndex ? hoveredIndex : hoveredIndex + 1; +} + +function QueuedFollowUpSummaryIcon() { + return ( + + ); +} + +function DragGripDots() { + return ( +
) : phase === "running" ? ( - composerSendState.hasSendableContent ? ( - - - {followUpBehavior === "queue" ? ( - - ) : ( - - )} - - } - /> - -
-
- {runningFollowUpShortcutRows.map((row) => ( -
- - {row.label} - - + {composerSendState.hasSendableContent ? ( + + + {followUpBehavior === "queue" ? ( + + ) : ( + + )} + + } + /> + +
+
+ {runningFollowUpShortcutRows.map((row) => ( +
- {row.shortcut} - -
- ))} + + {row.label} + + + {row.shortcut} + +
+ ))} +
-
- - - ) : ( + + + ) : null} - ) + ) : pendingUserInputs.length === 0 ? ( showPlanFollowUpPrompt ? ( prompt.trim().length > 0 ? ( diff --git a/apps/web/src/components/chat/ComposerQueuedFollowUpsPanel.tsx b/apps/web/src/components/chat/ComposerQueuedFollowUpsPanel.tsx index 6e133c40d58..6e0f0ef4b38 100644 --- a/apps/web/src/components/chat/ComposerQueuedFollowUpsPanel.tsx +++ b/apps/web/src/components/chat/ComposerQueuedFollowUpsPanel.tsx @@ -70,10 +70,6 @@ export const ComposerQueuedFollowUpsPanel = memo(function ComposerQueuedFollowUp onReorder: (followUpId: string, targetIndex: number) => void; onSteer: (followUpId: string) => void; }) { - if (queuedFollowUps.length === 0) { - return null; - } - const [actionsOpenFollowUpId, setActionsOpenFollowUpId] = useState(null); const [draggedFollowUpId, setDraggedFollowUpId] = useState(null); const [dropIndicator, setDropIndicator] = useState<{ @@ -94,6 +90,10 @@ export const ComposerQueuedFollowUpsPanel = memo(function ComposerQueuedFollowUp }; }, []); + if (queuedFollowUps.length === 0) { + return null; + } + const draggedIndex = draggedFollowUpId === null ? -1 From b263589192eb4347d16d22314437b4694de424b3 Mon Sep 17 00:00:00 2001 From: Xavier Date: Sat, 28 Mar 2026 17:53:13 -0300 Subject: [PATCH 12/26] fix: revoke queued follow-up preview urls on clear --- apps/web/src/composerDraftStore.test.ts | 4 ++-- apps/web/src/composerDraftStore.ts | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 6ae7799832d..5cbba5dfd2e 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -194,7 +194,7 @@ describe("composerDraftStore clearComposerContent", () => { URL.revokeObjectURL = originalRevokeObjectUrl; }); - it("does not revoke blob preview URLs when clearing composer content", () => { + it("revokes blob preview URLs when clearing composer content", () => { const first = makeImage({ id: "img-optimistic", previewUrl: "blob:optimistic", @@ -205,7 +205,7 @@ describe("composerDraftStore clearComposerContent", () => { const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; expect(draft).toBeUndefined(); - expect(revokeSpy).not.toHaveBeenCalledWith("blob:optimistic"); + expect(revokeSpy).toHaveBeenCalledWith("blob:optimistic"); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 8f9f918f2d8..3a6bc020323 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -2169,6 +2169,9 @@ export const useComposerDraftStore = create()( if (!current) { return state; } + for (const image of current.images) { + revokeObjectPreviewUrl(image.previewUrl); + } const nextDraft: ComposerThreadDraftState = { ...current, prompt: "", From 775ee4286c8af2cfba9df3abf8d80323c6d4e42c Mon Sep 17 00:00:00 2001 From: Xavier Date: Sat, 28 Mar 2026 18:11:03 -0300 Subject: [PATCH 13/26] fix: harden queued follow-up composer flow --- apps/web/src/components/ChatView.browser.tsx | 100 +++++++++++++++++++ apps/web/src/components/ChatView.tsx | 20 +++- 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 490b4958fc5..f45e1265da5 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -460,6 +460,22 @@ function getQueuedFollowUpPrompts(): string[] { ); } +function getQueuedFollowUpEnqueueRequests(): Array< + WsRequestEnvelope["body"] & { command: { type: string } } +> { + return wsRequests.flatMap((request) => { + const command = (request as { command?: { type?: string } }).command; + if ( + request._tag !== ORCHESTRATION_WS_METHODS.dispatchCommand || + !command || + command.type !== "thread.queued-follow-up.enqueue" + ) { + return []; + } + return [request as WsRequestEnvelope["body"] & { command: { type: string } }]; + }); +} + function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { const customResult = customWsRpcResolver?.(body); if (customResult !== undefined) { @@ -2254,6 +2270,38 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("does not enqueue duplicate follow-ups on rapid repeated submit", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-no-duplicate-queue" as MessageId, + targetText: "follow-up duplicate queue target", + sessionStatus: "running", + }), + }); + + try { + await setComposerPrompt("rapid queue"); + const submitButton = await waitForComposerSubmitButton("Queue follow-up"); + + submitButton.click(); + submitButton.click(); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual(["rapid queue"]); + expect(getQueuedFollowUpEnqueueRequests()).toHaveLength(1); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("supports steering and deleting queued follow-ups from the panel", async () => { setClientSettings({ followUpBehavior: "queue", @@ -2484,6 +2532,58 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("appends a new queued follow-up after requeueing an edited item", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-panel-edit-append" as MessageId, + targetText: "follow-up panel edit append target", + sessionStatus: "running", + }), + }); + + try { + await queueFollowUpFromComposer("queue first"); + await queueFollowUpFromComposer("queue second"); + await queueFollowUpFromComposer("queue third"); + + await openQueuedFollowUpActionsMenu(1); + const editButton = await waitForElement( + () => + document.querySelector('button[aria-label^="Edit queued follow-up"]'), + "Unable to find the queued follow-up edit button.", + ); + editButton.click(); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual(["queue first", "queue third"]); + }, + { timeout: 8_000, interval: 16 }, + ); + + await queueFollowUpFromComposer("queue second edited"); + await queueFollowUpFromComposer("queue fourth"); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual([ + "queue first", + "queue second edited", + "queue third", + "queue fourth", + ]); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("lets queued follow-ups reorder by dragging from the panel handle", async () => { setClientSettings({ followUpBehavior: "queue", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6d458ef7569..2b7c8420798 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2622,16 +2622,24 @@ export default function ChatView({ threadId }: ChatViewProps) { } if (canUseRunningFollowUps && isServerThread) { const createdAt = new Date().toISOString(); - const followUpSnapshot = await createFollowUpSnapshotFromComposer(createdAt); - if (!followUpSnapshot) { - return; - } const effectiveBehavior = resolveFollowUpBehavior( followUpBehavior, Boolean( input?.keyboardEvent && shouldInvertFollowUpBehaviorFromKeyEvent(input.keyboardEvent), ), ); + if (effectiveBehavior === "queue") { + sendInFlightRef.current = true; + beginSendPhase("sending-turn"); + } + const followUpSnapshot = await createFollowUpSnapshotFromComposer(createdAt); + if (!followUpSnapshot) { + if (effectiveBehavior === "queue") { + sendInFlightRef.current = false; + resetSendPhase(); + } + return; + } if (effectiveBehavior === "queue") { const queuedFollowUpEdit = composerDraft.queuedFollowUpEdit; @@ -2681,6 +2689,9 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThread.id, err instanceof Error ? err.message : "Failed to queue follow-up.", ); + } finally { + sendInFlightRef.current = false; + resetSendPhase(); } return; } @@ -3237,6 +3248,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ...state.draftsByThreadId, [threadId]: { ...(currentDraft ?? composerDraft), + queuedFollowUpEdit: null, prompt: snapshot.prompt, images: hydrateComposerImagesFromPersistedAttachments(hydratedAttachments), nonPersistedImageIds: [], From 72d67ca4fb81ff2f8347fa7e3dd78b540567a1ba Mon Sep 17 00:00:00 2001 From: Xavier Date: Sat, 28 Mar 2026 18:24:14 -0300 Subject: [PATCH 14/26] Add in-thread search for chat conversations --- apps/web/src/components/ChatMarkdown.test.tsx | 27 + apps/web/src/components/ChatMarkdown.tsx | 21 +- .../ChatView.threadSearch.browser.tsx | 516 +++++++++++++ apps/web/src/components/ChatView.tsx | 171 ++++- .../chat/MessagesTimeline.logic.test.ts | 151 +++- .../components/chat/MessagesTimeline.logic.ts | 99 +++ .../components/chat/MessagesTimeline.test.tsx | 209 ++++- .../src/components/chat/MessagesTimeline.tsx | 723 +++++++++--------- .../components/chat/ProposedPlanCard.test.tsx | 28 + .../src/components/chat/ProposedPlanCard.tsx | 27 +- .../chat/ThreadSearchBar.browser.tsx | 122 +++ .../src/components/chat/ThreadSearchBar.tsx | 116 +++ .../src/components/chat/threadSearch.test.ts | 180 +++++ apps/web/src/components/chat/threadSearch.ts | 163 ++++ .../chat/threadSearchHighlight.test.tsx | 66 ++ .../components/chat/threadSearchHighlight.tsx | 174 +++++ 16 files changed, 2392 insertions(+), 401 deletions(-) create mode 100644 apps/web/src/components/ChatMarkdown.test.tsx create mode 100644 apps/web/src/components/ChatView.threadSearch.browser.tsx create mode 100644 apps/web/src/components/chat/ProposedPlanCard.test.tsx create mode 100644 apps/web/src/components/chat/ThreadSearchBar.browser.tsx create mode 100644 apps/web/src/components/chat/ThreadSearchBar.tsx create mode 100644 apps/web/src/components/chat/threadSearch.test.ts create mode 100644 apps/web/src/components/chat/threadSearch.ts create mode 100644 apps/web/src/components/chat/threadSearchHighlight.test.tsx create mode 100644 apps/web/src/components/chat/threadSearchHighlight.tsx diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx new file mode 100644 index 00000000000..38c53991027 --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -0,0 +1,27 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../hooks/useTheme", () => ({ + useTheme: () => ({ + theme: "light", + resolvedTheme: "light", + }), +})); + +describe("ChatMarkdown", () => { + it("highlights assistant markdown text matches", async () => { + const { default: ChatMarkdown } = await import("./ChatMarkdown"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-thread-search-highlight="active"'); + expect(markup).toContain("highlight<"); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b364a8e3a16..68dbd915db1 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -23,6 +23,7 @@ import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkTarget } from "../markdown-links"; import { readNativeApi } from "../nativeApi"; +import { createThreadSearchHighlightRehypePlugin } from "./chat/threadSearchHighlight"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -49,6 +50,8 @@ interface ChatMarkdownProps { text: string; cwd: string | undefined; isStreaming?: boolean; + searchQuery?: string; + searchActive?: boolean; } const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; @@ -235,9 +238,19 @@ function SuspenseShikiCodeBlock({ ); } -function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { +function ChatMarkdown({ + text, + cwd, + isStreaming = false, + searchQuery = "", + searchActive = false, +}: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const diffThemeName = resolveDiffThemeName(resolvedTheme); + const searchHighlightPlugin = useMemo( + () => createThreadSearchHighlightRehypePlugin(searchQuery, { active: searchActive }), + [searchActive, searchQuery], + ); const markdownComponents = useMemo( () => ({ a({ node: _node, href, ...props }) { @@ -290,7 +303,11 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { return (
- + {text}
diff --git a/apps/web/src/components/ChatView.threadSearch.browser.tsx b/apps/web/src/components/ChatView.threadSearch.browser.tsx new file mode 100644 index 00000000000..5074fe23fc0 --- /dev/null +++ b/apps/web/src/components/ChatView.threadSearch.browser.tsx @@ -0,0 +1,516 @@ +import "../index.css"; + +import { + ORCHESTRATION_WS_METHODS, + DEFAULT_SERVER_SETTINGS, + type MessageId, + type OrchestrationReadModel, + type ProjectId, + type ServerConfig, + type ThreadId, + type WsWelcomePayload, + WS_CHANNELS, + WS_METHODS, +} from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; +import { HttpResponse, http, ws } from "msw"; +import { setupWorker } from "msw/browser"; +import { page } from "vitest/browser"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { useComposerDraftStore } from "../composerDraftStore"; +import { getRouter } from "../router"; +import { useStore } from "../store"; +import { isMacPlatform } from "../lib/utils"; + +const THREAD_ID = "thread-search-browser" as ThreadId; +const PROJECT_ID = "project-1" as ProjectId; +const NOW_ISO = "2026-03-04T12:00:00.000Z"; +const BASE_TIME_MS = Date.parse(NOW_ISO); + +interface TestFixture { + snapshot: OrchestrationReadModel; + serverConfig: ServerConfig; + welcome: WsWelcomePayload; +} + +let fixture: TestFixture; +const wsLink = ws.link(/ws(s)?:\/\/.*/); + +function isoAt(offsetSeconds: number): string { + return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString(); +} + +function createBaseServerConfig(): ServerConfig { + return { + cwd: "/repo/project", + keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", + keybindings: [], + issues: [], + providers: [ + { + provider: "codex", + enabled: true, + installed: true, + version: "0.116.0", + status: "ready", + authStatus: "authenticated", + checkedAt: NOW_ISO, + models: [], + }, + ], + availableEditors: [], + settings: { + ...DEFAULT_SERVER_SETTINGS, + ...DEFAULT_CLIENT_SETTINGS, + }, + }; +} + +function createSearchSnapshot(): OrchestrationReadModel { + const messages: Array = []; + + for (let index = 0; index < 24; index += 1) { + const userId = `user-${index}` as MessageId; + const assistantId = `assistant-${index}` as MessageId; + + const userText = + index === 0 + ? "virtualized alpha marker near the top" + : index === 8 + ? "second alpha marker closer to the middle" + : `filler user message ${index}`; + + messages.push({ + id: userId, + role: "user", + text: userText, + turnId: null, + streaming: false, + createdAt: isoAt(messages.length * 3), + updatedAt: isoAt(messages.length * 3 + 1), + }); + messages.push({ + id: assistantId, + role: "assistant", + text: `assistant filler ${index}`, + turnId: null, + streaming: false, + createdAt: isoAt(messages.length * 3), + updatedAt: isoAt(messages.length * 3 + 1), + }); + } + + return { + snapshotSequence: 1, + projects: [ + { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/repo/project", + defaultModelSelection: { + provider: "codex", + model: "gpt-5", + }, + scripts: [], + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + deletedAt: null, + }, + ], + threads: [ + { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread search test thread", + modelSelection: { + provider: "codex", + model: "gpt-5", + }, + interactionMode: "default", + runtimeMode: "full-access", + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + archivedAt: null, + deletedAt: null, + messages, + activities: [], + proposedPlans: [], + checkpoints: [], + session: { + threadId: THREAD_ID, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW_ISO, + }, + }, + ], + updatedAt: NOW_ISO, + }; +} + +function buildFixture(): TestFixture { + return { + snapshot: createSearchSnapshot(), + serverConfig: createBaseServerConfig(), + welcome: { + cwd: "/repo/project", + projectName: "Project", + bootstrapProjectId: PROJECT_ID, + bootstrapThreadId: THREAD_ID, + }, + }; +} + +function resolveWsRpc(tag: string): unknown { + if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { + return fixture.snapshot; + } + if (tag === WS_METHODS.serverGetConfig) { + return fixture.serverConfig; + } + if (tag === WS_METHODS.gitListBranches) { + return { + isRepo: true, + hasOriginRemote: true, + branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], + }; + } + if (tag === WS_METHODS.gitStatus) { + return { + branch: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + } + if (tag === WS_METHODS.projectsSearchEntries) { + return { entries: [], truncated: false }; + } + return {}; +} + +const worker = setupWorker( + wsLink.addEventListener("connection", ({ client }) => { + client.send( + JSON.stringify({ + type: "push", + sequence: 1, + channel: WS_CHANNELS.serverWelcome, + data: fixture.welcome, + }), + ); + client.addEventListener("message", (event) => { + const rawData = event.data; + if (typeof rawData !== "string") return; + let request: { id: string; body: { _tag: string; [key: string]: unknown } }; + try { + request = JSON.parse(rawData); + } catch { + return; + } + const method = request.body?._tag; + if (typeof method !== "string") return; + client.send( + JSON.stringify({ + id: request.id, + result: resolveWsRpc(method), + }), + ); + }); + }), + http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), + http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), +); + +async function waitForElement( + query: () => T | null, + errorMessage: string, +): Promise { + let element: T | null = null; + await vi.waitFor( + () => { + element = query(); + expect(element, errorMessage).toBeTruthy(); + }, + { timeout: 8_000, interval: 16 }, + ); + return element!; +} + +async function waitForComposerEditor(): Promise { + return waitForElement( + () => document.querySelector('[data-testid="composer-editor"]'), + "ChatView should render the composer editor", + ); +} + +async function waitForSearchInput(): Promise { + return waitForElement( + () => document.querySelector('[data-testid="thread-search-input"]'), + "Thread search input should be visible", + ); +} + +function dispatchThreadSearchShortcut() { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "f", + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); +} + +function dispatchSearchInputKey(key: string, options: { shiftKey?: boolean } = {}) { + const input = document.querySelector('[data-testid="thread-search-input"]'); + if (!input) { + throw new Error("Thread search input is not mounted"); + } + input.dispatchEvent( + new KeyboardEvent("keydown", { + key, + shiftKey: options.shiftKey ?? false, + bubbles: true, + cancelable: true, + }), + ); +} + +async function mountApp(): Promise<{ cleanup: () => Promise }> { + const host = document.createElement("div"); + host.style.position = "fixed"; + host.style.inset = "0"; + host.style.width = "100vw"; + host.style.height = "100vh"; + host.style.display = "grid"; + host.style.overflow = "hidden"; + document.body.append(host); + + const router = getRouter(createMemoryHistory({ initialEntries: [`/${THREAD_ID}`] })); + const screen = await render(, { container: host }); + await waitForComposerEditor(); + + return { + cleanup: async () => { + await screen.unmount(); + host.remove(); + }, + }; +} + +async function waitForActiveMessageRow(messageId: string): Promise { + return waitForElement( + () => + document.querySelector( + `[data-message-id="${messageId}"][data-search-match-state="active"]`, + ), + `Message row ${messageId} should be the active search result`, + ); +} + +async function waitForActiveSearchHighlight(messageId: string, text: string): Promise { + return waitForElement(() => { + const row = document.querySelector( + `[data-message-id="${messageId}"][data-search-match-state="active"]`, + ); + if (!row) { + return null; + } + return ( + Array.from( + row.querySelectorAll('mark[data-thread-search-highlight="active"]'), + ).find((element) => element.textContent?.toLowerCase() === text.toLowerCase()) ?? null + ); + }, `Message row ${messageId} should highlight "${text}" inline`); +} + +async function waitForMessageRow(messageId: string): Promise { + return waitForElement( + () => document.querySelector(`[data-message-id="${messageId}"]`), + `Message row ${messageId} should be rendered`, + ); +} + +async function waitForAnyTimelineRow(): Promise { + return waitForElement( + () => document.querySelector("[data-timeline-row-id]"), + "At least one timeline row should be rendered", + ); +} + +describe("ChatView thread search", () => { + beforeAll(async () => { + fixture = buildFixture(); + await worker.start({ + onUnhandledRequest: "bypass", + quiet: true, + serviceWorker: { url: "/mockServiceWorker.js" }, + }); + }); + + afterAll(async () => { + await worker.stop(); + }); + + beforeEach(() => { + fixture = buildFixture(); + localStorage.clear(); + document.body.innerHTML = ""; + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, + }); + useStore.setState({ + projects: [], + threads: [], + threadsHydrated: false, + }); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("opens with Cmd/Ctrl+F and restores composer focus when dismissed", async () => { + const mounted = await mountApp(); + + try { + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + + dispatchThreadSearchShortcut(); + + const searchInput = await waitForSearchInput(); + await page.getByTestId("thread-search-input").fill("alpha marker"); + await waitForActiveSearchHighlight("user-0", "alpha marker"); + await vi.waitFor(() => { + expect(document.activeElement).toBe(searchInput); + }); + + dispatchSearchInputKey("Escape"); + + await vi.waitFor(() => { + expect(document.querySelector('[data-testid="thread-search-input"]')).toBeNull(); + expect(document.activeElement?.getAttribute("data-testid")).toBe("composer-editor"); + expect(document.querySelector('[data-thread-search-highlight="active"]')).toBeNull(); + expect(document.querySelector('[data-search-match-state="active"]')).toBeNull(); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("does not shift the thread layout when opened", async () => { + const mounted = await mountApp(); + + try { + const firstRenderedRow = await waitForAnyTimelineRow(); + const trackedRowId = firstRenderedRow.dataset.timelineRowId; + const beforeTop = firstRenderedRow.getBoundingClientRect().top; + + dispatchThreadSearchShortcut(); + await waitForSearchInput(); + + await vi.waitFor(() => { + const afterTop = document + .querySelector(`[data-timeline-row-id="${trackedRowId}"]`) + ?.getBoundingClientRect().top; + expect(afterTop).toBeDefined(); + expect(Math.abs((afterTop ?? 0) - beforeTop)).toBeLessThan(1); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("shows the no-match state and disables result navigation", async () => { + const mounted = await mountApp(); + + try { + dispatchThreadSearchShortcut(); + const searchInput = await waitForSearchInput(); + searchInput.focus(); + await page.getByTestId("thread-search-input").fill("does-not-exist"); + + await vi.waitFor(() => { + expect(document.querySelector('[data-testid="thread-search-count"]')?.textContent).toBe( + "No matches", + ); + }); + await expect.element(page.getByLabelText("Previous search result")).toBeDisabled(); + await expect.element(page.getByLabelText("Next search result")).toBeDisabled(); + } finally { + await mounted.cleanup(); + } + }); + + it("cycles between matches with Enter, Shift+Enter, and the next button", async () => { + const mounted = await mountApp(); + + try { + dispatchThreadSearchShortcut(); + await page.getByTestId("thread-search-input").fill("alpha marker"); + + await vi.waitFor(() => { + expect(document.querySelector('[data-testid="thread-search-count"]')?.textContent).toBe( + "1 / 2", + ); + }); + await waitForActiveMessageRow("user-0"); + await waitForActiveSearchHighlight("user-0", "alpha marker"); + + dispatchSearchInputKey("Enter"); + await waitForActiveMessageRow("user-8"); + await waitForActiveSearchHighlight("user-8", "alpha marker"); + + dispatchSearchInputKey("Enter", { shiftKey: true }); + await waitForActiveMessageRow("user-0"); + await waitForActiveSearchHighlight("user-0", "alpha marker"); + + await page.getByLabelText("Next search result").click(); + await waitForActiveMessageRow("user-8"); + await waitForActiveSearchHighlight("user-8", "alpha marker"); + } finally { + await mounted.cleanup(); + } + }); + + it("pulls an older virtualized match into the DOM when selected", async () => { + const mounted = await mountApp(); + + try { + expect(document.body.textContent ?? "").not.toContain( + "virtualized alpha marker near the top", + ); + + dispatchThreadSearchShortcut(); + await page.getByTestId("thread-search-input").fill("virtualized alpha marker near the top"); + + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("virtualized alpha marker near the top"); + }); + await waitForActiveMessageRow("user-0"); + await waitForActiveSearchHighlight("user-0", "virtualized alpha marker near the top"); + } finally { + await mounted.cleanup(); + } + }); +}); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1d926bf308e..50093b722cc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -22,7 +22,15 @@ import { RuntimeMode, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useDeferredValue, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; @@ -99,7 +107,7 @@ import { import { Button } from "./ui/button"; import { Separator } from "./ui/separator"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; -import { cn, randomUUID } from "~/lib/utils"; +import { cn, isMacPlatform, randomUUID } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; @@ -145,6 +153,7 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; +import { buildTimelineRows } from "./chat/MessagesTimeline.logic"; import { ChatHeader } from "./chat/ChatHeader"; import { ContextWindowMeter } from "./chat/ContextWindowMeter"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; @@ -162,6 +171,13 @@ import { } from "./chat/composerProviderRegistry"; import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; +import { ThreadSearchBar } from "./chat/ThreadSearchBar"; +import { + buildThreadSearchIndex, + createEmptyThreadSearchLookupState, + findThreadSearchLookupState, + type ThreadSearchLookupState, +} from "./chat/threadSearch"; import { buildExpiredTerminalContextToastCopy, buildLocalDraftThread, @@ -206,6 +222,23 @@ function formatOutgoingPrompt(params: { const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; +const THREAD_SEARCH_INPUT_SELECTOR = "[data-testid='thread-search-input']"; + +function isThreadSearchShortcut(event: KeyboardEvent, platform = navigator.platform): boolean { + if (event.key.toLowerCase() !== "f") { + return false; + } + if (event.shiftKey || event.altKey) { + return false; + } + return isMacPlatform(platform) + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey; +} + +function isThreadSearchInputTarget(target: EventTarget | null): boolean { + return target instanceof HTMLElement && target.closest(THREAD_SEARCH_INPUT_SELECTOR) !== null; +} const extendReplacementRangeForTrailingSpace = ( text: string, @@ -321,6 +354,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const promptRef = useRef(prompt); const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const [threadSearchOpen, setThreadSearchOpen] = useState(false); + const [threadSearchQuery, setThreadSearchQuery] = useState(""); + const [activeThreadSearchResultIndex, setActiveThreadSearchResultIndex] = useState(-1); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); @@ -398,6 +434,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const sendInFlightRef = useRef(false); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); + const threadSearchInputRef = useRef(null); + const threadSearchRestoreFocusRef = useRef(null); const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { messagesScrollRef.current = element; setMessagesScrollElement(element); @@ -1009,6 +1047,57 @@ export default function ChatView({ threadId }: ChatViewProps) { latestTurnSettled, timelineEntries, ]); + const timelineRows = useMemo( + () => + buildTimelineRows({ + timelineEntries, + completionDividerBeforeEntryId, + isWorking, + activeTurnStartedAt: activeWorkStartedAt, + }), + [activeWorkStartedAt, completionDividerBeforeEntryId, isWorking, timelineEntries], + ); + const deferredThreadSearchQuery = useDeferredValue(threadSearchQuery); + const threadSearchIndex = useMemo(() => buildThreadSearchIndex(timelineRows), [timelineRows]); + const threadSearchLookupStateRef = useRef( + createEmptyThreadSearchLookupState(threadSearchIndex), + ); + const threadSearchResults = useMemo(() => { + const nextLookupState = findThreadSearchLookupState( + threadSearchIndex, + deferredThreadSearchQuery, + threadSearchLookupStateRef.current, + ); + threadSearchLookupStateRef.current = nextLookupState; + return nextLookupState.results; + }, [deferredThreadSearchQuery, threadSearchIndex]); + const visibleThreadSearchResults = useMemo( + () => (threadSearchOpen ? threadSearchResults : []), + [threadSearchOpen, threadSearchResults], + ); + const matchedThreadSearchRowIds = useMemo( + () => new Set(visibleThreadSearchResults.map((result) => result.rowId)), + [visibleThreadSearchResults], + ); + const activeThreadSearchRowId = + threadSearchOpen && activeThreadSearchResultIndex >= 0 + ? (visibleThreadSearchResults[activeThreadSearchResultIndex]?.rowId ?? null) + : null; + useEffect(() => { + const normalizedQuery = threadSearchQuery.trim(); + setActiveThreadSearchResultIndex(normalizedQuery.length > 0 ? 0 : -1); + }, [threadSearchQuery]); + useEffect(() => { + setActiveThreadSearchResultIndex((current) => { + if (visibleThreadSearchResults.length === 0) { + return -1; + } + if (current < 0) { + return 0; + } + return Math.min(current, visibleThreadSearchResults.length - 1); + }); + }, [visibleThreadSearchResults]); const gitCwd = activeProject ? projectScriptCwd({ project: { cwd: activeProject.cwd }, @@ -1237,6 +1326,54 @@ export default function ChatView({ threadId }: ChatViewProps) { focusComposer(); }); }, [focusComposer]); + const focusThreadSearchInput = useCallback((select = false) => { + window.requestAnimationFrame(() => { + const input = threadSearchInputRef.current; + if (!input) { + return; + } + input.focus(); + if (select) { + input.select(); + } + }); + }, []); + const openThreadSearch = useCallback( + (select = true) => { + threadSearchRestoreFocusRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + setThreadSearchOpen(true); + focusThreadSearchInput(select); + }, + [focusThreadSearchInput], + ); + const closeThreadSearch = useCallback(() => { + setThreadSearchOpen(false); + const focusTarget = threadSearchRestoreFocusRef.current; + threadSearchRestoreFocusRef.current = null; + if (focusTarget && focusTarget.isConnected) { + window.requestAnimationFrame(() => { + focusTarget.focus(); + }); + } + }, []); + const stepThreadSearch = useCallback( + (direction: 1 | -1) => { + if (visibleThreadSearchResults.length === 0) { + return; + } + setActiveThreadSearchResultIndex((current) => { + if (current < 0) { + return direction > 0 ? 0 : visibleThreadSearchResults.length - 1; + } + return ( + (current + direction + visibleThreadSearchResults.length) % + visibleThreadSearchResults.length + ); + }); + }, + [visibleThreadSearchResults.length], + ); const addTerminalContextToDraft = useCallback( (selection: TerminalContextSelection) => { if (!activeThread) { @@ -2199,6 +2336,12 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { if (!activeThreadId || event.defaultPrevented) return; + if (isThreadSearchShortcut(event) && !isTerminalFocused() && !expandedImage) { + event.preventDefault(); + event.stopPropagation(); + openThreadSearch(!isThreadSearchInputTarget(event.target)); + return; + } const shortcutContext = { terminalFocus: isTerminalFocused(), terminalOpen: Boolean(terminalState.terminalOpen), @@ -2268,7 +2411,9 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThreadId, closeTerminal, createNewTerminal, + expandedImage, setTerminalOpen, + openThreadSearch, runProjectScript, splitTerminal, keybindings, @@ -3598,6 +3743,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
{/* Messages Wrapper */}
+ {threadSearchOpen && ( +
+ stepThreadSearch(1)} + onPrevious={() => stepThreadSearch(-1)} + onClose={closeThreadSearch} + /> +
+ )} {/* Messages */}
0} - isWorking={isWorking} + rows={timelineRows} activeTurnInProgress={isWorking || !latestTurnSettled} activeTurnStartedAt={activeWorkStartedAt} scrollContainer={messagesScrollElement} - timelineEntries={timelineEntries} - completionDividerBeforeEntryId={completionDividerBeforeEntryId} completionSummary={completionSummary} turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} nowIso={nowIso} @@ -3636,6 +3792,9 @@ export default function ChatView({ threadId }: ChatViewProps) { resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} workspaceRoot={activeProject?.cwd ?? undefined} + activeSearchRowId={activeThreadSearchRowId} + matchedSearchRowIds={matchedThreadSearchRowIds} + searchQuery={deferredThreadSearchQuery} />
diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index dee42a8586c..1bb6dea073e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,5 +1,10 @@ +import { MessageId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; +import { + buildTimelineRows, + computeMessageDurationStart, + normalizeCompactToolLabel, +} from "./MessagesTimeline.logic"; describe("computeMessageDurationStart", () => { it("returns message createdAt when there is no preceding user message", () => { @@ -143,3 +148,147 @@ describe("normalizeCompactToolLabel", () => { expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file"); }); }); + +describe("buildTimelineRows", () => { + it("groups adjacent work entries, preserves plans, and appends the working row", () => { + const rows = buildTimelineRows({ + timelineEntries: [ + { + id: "message-1", + kind: "message", + createdAt: "2026-01-01T00:00:00Z", + message: { + id: MessageId.makeUnsafe("message-1"), + role: "user", + text: "hello", + createdAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + }, + { + id: "work-1", + kind: "work", + createdAt: "2026-01-01T00:00:01Z", + entry: { + id: "work-1", + createdAt: "2026-01-01T00:00:01Z", + label: "Ran command", + tone: "tool", + }, + }, + { + id: "work-2", + kind: "work", + createdAt: "2026-01-01T00:00:02Z", + entry: { + id: "work-2", + createdAt: "2026-01-01T00:00:02Z", + label: "Updated file", + tone: "info", + }, + }, + { + id: "plan-1", + kind: "proposed-plan", + createdAt: "2026-01-01T00:00:03Z", + proposedPlan: { + id: "plan-1" as never, + turnId: null, + planMarkdown: "1. Ship it", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-01-01T00:00:03Z", + updatedAt: "2026-01-01T00:00:03Z", + }, + }, + ], + completionDividerBeforeEntryId: null, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:04Z", + }); + + expect(rows).toEqual([ + { + kind: "message", + id: "message-1", + createdAt: "2026-01-01T00:00:00Z", + message: { + id: MessageId.makeUnsafe("message-1"), + role: "user", + text: "hello", + createdAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + durationStart: "2026-01-01T00:00:00Z", + showCompletionDivider: false, + }, + { + kind: "work", + id: "work-1", + createdAt: "2026-01-01T00:00:01Z", + groupedEntries: [ + { + id: "work-1", + createdAt: "2026-01-01T00:00:01Z", + label: "Ran command", + tone: "tool", + }, + { + id: "work-2", + createdAt: "2026-01-01T00:00:02Z", + label: "Updated file", + tone: "info", + }, + ], + }, + { + kind: "proposed-plan", + id: "plan-1", + createdAt: "2026-01-01T00:00:03Z", + proposedPlan: { + id: "plan-1" as never, + turnId: null, + planMarkdown: "1. Ship it", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-01-01T00:00:03Z", + updatedAt: "2026-01-01T00:00:03Z", + }, + }, + { + kind: "working", + id: "working-indicator-row", + createdAt: "2026-01-01T00:00:04Z", + }, + ]); + }); + + it("marks the matching assistant row with the completion divider", () => { + const rows = buildTimelineRows({ + timelineEntries: [ + { + id: "assistant-1", + kind: "message", + createdAt: "2026-01-01T00:00:00Z", + message: { + id: MessageId.makeUnsafe("assistant-1"), + role: "assistant", + text: "Done", + createdAt: "2026-01-01T00:00:00Z", + completedAt: "2026-01-01T00:00:05Z", + streaming: false, + }, + }, + ], + completionDividerBeforeEntryId: "assistant-1", + isWorking: false, + activeTurnStartedAt: null, + }); + + expect(rows[0]).toMatchObject({ + kind: "message", + id: "assistant-1", + showCompletionDivider: true, + }); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 726d61888e5..8ab15da4fa3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,3 +1,5 @@ +import type { TimelineEntry } from "../../session-logic"; + export interface TimelineDurationMessage { id: string; role: "user" | "assistant" | "system"; @@ -27,3 +29,100 @@ export function computeMessageDurationStart( export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } + +type TimelineMessage = Extract["message"]; +type TimelineProposedPlan = Extract["proposedPlan"]; +type TimelineWorkEntry = Extract["entry"]; + +export type TimelineRow = + | { + kind: "work"; + id: string; + createdAt: string; + groupedEntries: TimelineWorkEntry[]; + } + | { + kind: "message"; + id: string; + createdAt: string; + message: TimelineMessage; + durationStart: string; + showCompletionDivider: boolean; + } + | { + kind: "proposed-plan"; + id: string; + createdAt: string; + proposedPlan: TimelineProposedPlan; + } + | { kind: "working"; id: string; createdAt: string | null }; + +export function buildTimelineRows(input: { + timelineEntries: ReadonlyArray; + completionDividerBeforeEntryId: string | null; + isWorking: boolean; + activeTurnStartedAt: string | null; +}): TimelineRow[] { + const nextRows: TimelineRow[] = []; + const durationStartByMessageId = computeMessageDurationStart( + input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), + ); + + for (let index = 0; index < input.timelineEntries.length; index += 1) { + const timelineEntry = input.timelineEntries[index]; + if (!timelineEntry) { + continue; + } + + if (timelineEntry.kind === "work") { + const groupedEntries = [timelineEntry.entry]; + let cursor = index + 1; + while (cursor < input.timelineEntries.length) { + const nextEntry = input.timelineEntries[cursor]; + if (!nextEntry || nextEntry.kind !== "work") break; + groupedEntries.push(nextEntry.entry); + cursor += 1; + } + nextRows.push({ + kind: "work", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + groupedEntries, + }); + index = cursor - 1; + continue; + } + + if (timelineEntry.kind === "proposed-plan") { + nextRows.push({ + kind: "proposed-plan", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + proposedPlan: timelineEntry.proposedPlan, + }); + continue; + } + + nextRows.push({ + kind: "message", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + message: timelineEntry.message, + durationStart: + durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt, + showCompletionDivider: + timelineEntry.message.role === "assistant" && + input.completionDividerBeforeEntryId === timelineEntry.id, + }); + } + + if (input.isWorking) { + nextRows.push({ + kind: "working", + id: "working-indicator-row", + createdAt: input.activeTurnStartedAt, + }); + } + + return nextRows; +} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 692438c74aa..0777c31fc0a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,6 +1,14 @@ import { MessageId } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vitest"; +import { buildTimelineRows } from "./MessagesTimeline.logic"; + +vi.mock("../../hooks/useTheme", () => ({ + useTheme: () => ({ + theme: "light", + resolvedTheme: "light", + }), +})); function matchMedia() { return { @@ -45,36 +53,39 @@ beforeAll(() => { describe("MessagesTimeline", () => { it("renders inline terminal labels with the composer chip UI", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); + const rows = buildTimelineRows({ + timelineEntries: [ + { + id: "entry-1", + kind: "message", + createdAt: "2026-03-17T19:12:28.000Z", + message: { + id: MessageId.makeUnsafe("message-2"), + role: "user", + text: [ + "yoo what's @terminal-1:1-5 mean", + "", + "", + "- Terminal 1 lines 1-5:", + " 1 | julius@mac effect-http-ws-cli % bun i", + " 2 | bun install v1.3.9 (cf6cdbbb)", + "", + ].join("\n"), + createdAt: "2026-03-17T19:12:28.000Z", + streaming: false, + }, + }, + ], + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnStartedAt: null, + }); const markup = renderToStaticMarkup( ", - "- Terminal 1 lines 1-5:", - " 1 | julius@mac effect-http-ws-cli % bun i", - " 2 | bun install v1.3.9 (cf6cdbbb)", - "", - ].join("\n"), - createdAt: "2026-03-17T19:12:28.000Z", - streaming: false, - }, - }, - ]} - completionDividerBeforeEntryId={null} completionSummary={null} turnDiffSummaryByAssistantMessageId={new Map()} nowIso="2026-03-17T19:12:30.000Z" @@ -89,6 +100,9 @@ describe("MessagesTimeline", () => { resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} + activeSearchRowId={null} + matchedSearchRowIds={new Set()} + searchQuery="" />, ); @@ -99,27 +113,30 @@ describe("MessagesTimeline", () => { it("renders context compaction entries in the normal work log", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); + const rows = buildTimelineRows({ + timelineEntries: [ + { + id: "entry-1", + kind: "work", + createdAt: "2026-03-17T19:12:28.000Z", + entry: { + id: "work-1", + createdAt: "2026-03-17T19:12:28.000Z", + label: "Context compacted", + tone: "info", + }, + }, + ], + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnStartedAt: null, + }); const markup = renderToStaticMarkup( { resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} + activeSearchRowId={null} + matchedSearchRowIds={new Set()} + searchQuery="" />, ); expect(markup).toContain("Context compacted"); expect(markup).toContain("Work log"); }); + + it("renders active inline search highlights without row-level emphasis", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const rows = buildTimelineRows({ + timelineEntries: [ + { + id: "message-1", + kind: "message", + createdAt: "2026-03-17T19:12:28.000Z", + message: { + id: MessageId.makeUnsafe("message-1"), + role: "user", + text: "Search target", + createdAt: "2026-03-17T19:12:28.000Z", + streaming: false, + }, + }, + ], + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnStartedAt: null, + }); + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + activeSearchRowId="message-1" + matchedSearchRowIds={new Set(["message-1"])} + searchQuery="Search" + />, + ); + + expect(markup).toContain('data-timeline-row-id="message-1"'); + expect(markup).toContain('data-search-match-state="active"'); + expect(markup).toContain('data-thread-search-highlight="active"'); + expect(markup).toContain(" { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const rows = buildTimelineRows({ + timelineEntries: [ + { + id: "assistant-row-1", + kind: "message", + createdAt: "2026-03-17T19:12:28.000Z", + message: { + id: MessageId.makeUnsafe("assistant-message-1"), + role: "assistant", + text: "The **highlight** should also appear in assistant markdown.", + createdAt: "2026-03-17T19:12:28.000Z", + streaming: false, + }, + }, + ], + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnStartedAt: null, + }); + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + activeSearchRowId="assistant-row-1" + matchedSearchRowIds={new Set(["assistant-row-1"])} + searchQuery="highlight" + />, + ); + + expect(markup).toContain('data-timeline-row-id="assistant-row-1"'); + expect(markup).toContain('data-thread-search-highlight="active"'); + expect(markup).toContain("highlight<"); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f3174030eff..e8b88417f86 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -14,7 +14,7 @@ import { type VirtualItem, useVirtualizer, } from "@tanstack/react-virtual"; -import { deriveTimelineEntries, formatElapsed } from "../../session-logic"; +import { formatElapsed } from "../../session-logic"; import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll"; import { type TurnDiffSummary } from "../../types"; import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; @@ -41,7 +41,7 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; -import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; +import { normalizeCompactToolLabel, type TimelineRow } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; import { deriveDisplayedUserMessageState, @@ -55,18 +55,16 @@ import { formatInlineTerminalContextLabel, textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; +import { renderHighlightedText } from "./threadSearchHighlight"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; interface MessagesTimelineProps { - hasMessages: boolean; - isWorking: boolean; + rows: ReadonlyArray; activeTurnInProgress: boolean; activeTurnStartedAt: string | null; scrollContainer: HTMLDivElement | null; - timelineEntries: ReturnType; - completionDividerBeforeEntryId: string | null; completionSummary: string | null; turnDiffSummaryByAssistantMessageId: Map; nowIso: string; @@ -81,16 +79,16 @@ interface MessagesTimelineProps { resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; workspaceRoot: string | undefined; + activeSearchRowId: string | null; + matchedSearchRowIds: ReadonlySet; + searchQuery: string; } export const MessagesTimeline = memo(function MessagesTimeline({ - hasMessages, - isWorking, + rows, activeTurnInProgress, activeTurnStartedAt, scrollContainer, - timelineEntries, - completionDividerBeforeEntryId, completionSummary, turnDiffSummaryByAssistantMessageId, nowIso, @@ -105,9 +103,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ resolvedTheme, timestampFormat, workspaceRoot, + activeSearchRowId, + matchedSearchRowIds, + searchQuery, }: MessagesTimelineProps) { const timelineRootRef = useRef(null); const [timelineWidthPx, setTimelineWidthPx] = useState(null); + const isWorking = rows.some((row) => row.kind === "working"); + const hasRows = rows.some((row) => row.kind !== "working"); useLayoutEffect(() => { const timelineRoot = timelineRootRef.current; @@ -132,72 +135,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return () => { observer.disconnect(); }; - }, [hasMessages, isWorking]); - - const rows = useMemo(() => { - const nextRows: TimelineRow[] = []; - const durationStartByMessageId = computeMessageDurationStart( - timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), - ); - - for (let index = 0; index < timelineEntries.length; index += 1) { - const timelineEntry = timelineEntries[index]; - if (!timelineEntry) { - continue; - } - - if (timelineEntry.kind === "work") { - const groupedEntries = [timelineEntry.entry]; - let cursor = index + 1; - while (cursor < timelineEntries.length) { - const nextEntry = timelineEntries[cursor]; - if (!nextEntry || nextEntry.kind !== "work") break; - groupedEntries.push(nextEntry.entry); - cursor += 1; - } - nextRows.push({ - kind: "work", - id: timelineEntry.id, - createdAt: timelineEntry.createdAt, - groupedEntries, - }); - index = cursor - 1; - continue; - } - - if (timelineEntry.kind === "proposed-plan") { - nextRows.push({ - kind: "proposed-plan", - id: timelineEntry.id, - createdAt: timelineEntry.createdAt, - proposedPlan: timelineEntry.proposedPlan, - }); - continue; - } - - nextRows.push({ - kind: "message", - id: timelineEntry.id, - createdAt: timelineEntry.createdAt, - message: timelineEntry.message, - durationStart: - durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt, - showCompletionDivider: - timelineEntry.message.role === "assistant" && - completionDividerBeforeEntryId === timelineEntry.id, - }); - } - - if (isWorking) { - nextRows.push({ - kind: "working", - id: "working-indicator-row", - createdAt: activeTurnStartedAt, - }); - } - - return nextRows; - }, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]); + }, [rows]); const firstUnvirtualizedRowIndex = useMemo(() => { const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0); @@ -291,6 +229,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }; }, []); + const rowIndexById = useMemo( + () => new Map(rows.map((row, index) => [row.id, index] as const)), + [rows], + ); + const virtualRows = rowVirtualizer.getVirtualItems(); const nonVirtualizedRows = rows.slice(virtualizedRowCount); const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState< @@ -303,257 +246,326 @@ export const MessagesTimeline = memo(function MessagesTimeline({ })); }, []); - const renderRowContent = (row: TimelineRow) => ( -
- {row.kind === "work" && - (() => { - const groupId = row.id; - const groupedEntries = row.groupedEntries; - const isExpanded = expandedWorkGroups[groupId] ?? false; - const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; - const visibleEntries = - hasOverflow && !isExpanded - ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) - : groupedEntries; - const hiddenCount = groupedEntries.length - visibleEntries.length; - const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); - const showHeader = hasOverflow || !onlyToolEntries; - const groupLabel = onlyToolEntries ? "Tool calls" : "Work log"; - - return ( -
- {showHeader && ( -
-

- {groupLabel} ({groupedEntries.length}) -

- {hasOverflow && ( - - )} -
- )} -
- {visibleEntries.map((workEntry) => ( - - ))} -
-
- ); - })()} - - {row.kind === "message" && - row.message.role === "user" && - (() => { - const userImages = row.message.attachments ?? []; - const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); - const terminalContexts = displayedUserMessage.contexts; - const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); - return ( -
-
- {userImages.length > 0 && ( -
- {userImages.map( - (image: NonNullable[number]) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} -
- ), - )} -
- )} - {(displayedUserMessage.visibleText.trim().length > 0 || - terminalContexts.length > 0) && ( - - )} -
-
- {displayedUserMessage.copyText && ( - - )} - {canRevertAgentWork && ( - + {isExpanded ? "Show less" : `Show ${hiddenCount} more`} + )}
-

- {formatTimestamp(row.message.createdAt, timestampFormat)} -

+ )} +
+ {visibleEntries.map((workEntry) => ( + + ))}
-
- ); - })()} - - {row.kind === "message" && - row.message.role === "assistant" && - (() => { - const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); - return ( - <> - {row.showCompletionDivider && ( -
- - - {completionSummary ? `Response • ${completionSummary}` : "Response"} - - -
- )} -
- - {(() => { - const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); - if (!turnSummary) return null; - const checkpointFiles = turnSummary.files; - if (checkpointFiles.length === 0) return null; - const summaryStat = summarizeTurnDiffStats(checkpointFiles); - const changedFileCountLabel = String(checkpointFiles.length); - const allDirectoriesExpanded = - allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true; - return ( -
-
-

- Changed files ({changedFileCountLabel}) - {hasNonZeroStat(summaryStat) && ( - <> - • - - - )} -

-
- - -
+ ); + })()} + + {row.kind === "message" && + row.message.role === "user" && + (() => { + const userImages = row.message.attachments ?? []; + const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); + const terminalContexts = displayedUserMessage.contexts; + const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); + return ( +
+
+
+ {userImages.length > 0 && ( +
+ {userImages.map( + (image: NonNullable[number]) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {renderHighlightedText( + image.name, + rowSearchQuery, + `user-image-name:${row.id}:${image.id}`, + { active: rowSearchActive }, + )} +
+ )} +
+ ), + )}
- 0 || + terminalContexts.length > 0) && ( + + )} +
+
+
+ {displayedUserMessage.copyText && ( + + )} + {canRevertAgentWork && ( + + )}
- ); - })()} -

- {formatMessageMeta( - row.message.createdAt, - row.message.streaming - ? formatElapsed(row.durationStart, nowIso) - : formatElapsed(row.durationStart, row.message.completedAt), - timestampFormat, - )} -

+

+ {formatTimestamp(row.message.createdAt, timestampFormat)} +

+
+
- - ); - })()} - - {row.kind === "proposed-plan" && ( -
- -
- )} + ); + })()} - {row.kind === "working" && ( -
-
- - - - - - - {row.createdAt - ? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}` - : "Working..."} - + {row.kind === "message" && + row.message.role === "assistant" && + (() => { + const messageText = + row.message.text || (row.message.streaming ? "" : "(empty response)"); + return ( + <> + {row.showCompletionDivider && ( +
+ + + {completionSummary ? `Response • ${completionSummary}` : "Response"} + + +
+ )} +
+
+ +
+ {(() => { + const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); + if (!turnSummary) return null; + const checkpointFiles = turnSummary.files; + if (checkpointFiles.length === 0) return null; + const summaryStat = summarizeTurnDiffStats(checkpointFiles); + const changedFileCountLabel = String(checkpointFiles.length); + const allDirectoriesExpanded = + allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true; + return ( +
+
+

+ Changed files ({changedFileCountLabel}) + {hasNonZeroStat(summaryStat) && ( + <> + • + + + )} +

+
+ + +
+
+ +
+ ); + })()} +

+ {formatMessageMeta( + row.message.createdAt, + row.message.streaming + ? formatElapsed(row.durationStart, nowIso) + : formatElapsed(row.durationStart, row.message.completedAt), + timestampFormat, + )} +

+
+ + ); + })()} + + {row.kind === "proposed-plan" && ( +
+
+ +
-
- )} -
- ); + )} + + {row.kind === "working" && ( +
+
+ + + + + + + {row.createdAt + ? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}` + : "Working..."} + +
+
+ )} +
+ ); + }; - if (!hasMessages && !isWorking) { + if (!hasRows && !isWorking) { return (

@@ -597,32 +609,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); }); -type TimelineEntry = ReturnType[number]; -type TimelineMessage = Extract["message"]; -type TimelineProposedPlan = Extract["proposedPlan"]; -type TimelineWorkEntry = Extract["entry"]; -type TimelineRow = - | { - kind: "work"; - id: string; - createdAt: string; - groupedEntries: TimelineWorkEntry[]; - } - | { - kind: "message"; - id: string; - createdAt: string; - message: TimelineMessage; - durationStart: string; - showCompletionDivider: boolean; - } - | { - kind: "proposed-plan"; - id: string; - createdAt: string; - proposedPlan: TimelineProposedPlan; - } - | { kind: "working"; id: string; createdAt: string | null }; +type TimelineMessage = Extract["message"]; +type TimelineProposedPlan = Extract["proposedPlan"]; +type TimelineWorkEntry = Extract["groupedEntries"][number]; function estimateTimelineProposedPlanHeight(proposedPlan: TimelineProposedPlan): number { const estimatedLines = Math.max(1, Math.ceil(proposedPlan.planMarkdown.length / 72)); @@ -675,6 +664,8 @@ const UserMessageTerminalContextInlineLabel = memo( const UserMessageBody = memo(function UserMessageBody(props: { text: string; terminalContexts: ParsedTerminalContextEntry[]; + searchQuery: string; + searchActive: boolean; }) { if (props.terminalContexts.length > 0) { const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels( @@ -697,7 +688,12 @@ const UserMessageBody = memo(function UserMessageBody(props: { if (matchIndex > cursor) { inlineNodes.push( - {props.text.slice(cursor, matchIndex)} + {renderHighlightedText( + props.text.slice(cursor, matchIndex), + props.searchQuery, + `user-terminal-before:${context.header}:${cursor}`, + { active: props.searchActive }, + )} , ); } @@ -714,7 +710,12 @@ const UserMessageBody = memo(function UserMessageBody(props: { if (cursor < props.text.length) { inlineNodes.push( - {props.text.slice(cursor)} + {renderHighlightedText( + props.text.slice(cursor), + props.searchQuery, + `user-terminal-rest:${cursor}`, + { active: props.searchActive }, + )} , ); } @@ -742,7 +743,13 @@ const UserMessageBody = memo(function UserMessageBody(props: { } if (props.text.length > 0) { - inlineNodes.push({props.text}); + inlineNodes.push( + + {renderHighlightedText(props.text, props.searchQuery, "user-terminal-inline-text", { + active: props.searchActive, + })} + , + ); } else if (inlinePrefix.length === 0) { return null; } @@ -760,7 +767,9 @@ const UserMessageBody = memo(function UserMessageBody(props: { return (

-      {props.text}
+      {renderHighlightedText(props.text, props.searchQuery, "user-message-body", {
+        active: props.searchActive,
+      })}
     
); }); @@ -855,8 +864,10 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; + searchQuery: string; + searchActive: boolean; }) { - const { workEntry } = props; + const { workEntry, searchActive, searchQuery } = props; const iconConfig = workToneIcon(workEntry.tone); const EntryIcon = workEntryIcon(workEntry); const heading = toolWorkEntryHeading(workEntry); @@ -883,9 +894,18 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { title={displayText} > - {heading} + {renderHighlightedText(heading, searchQuery, `work-heading:${workEntry.id}`, { + active: searchActive, + })} - {preview && - {preview}} + {preview && ( + + {" - "} + {renderHighlightedText(preview, searchQuery, `work-preview:${workEntry.id}`, { + active: searchActive, + })} + + )}

@@ -897,7 +917,14 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { className="rounded-md border border-border/55 bg-background/75 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground/75" title={filePath} > - {filePath} + {renderHighlightedText( + filePath, + searchQuery, + `work-file:${workEntry.id}:${filePath}`, + { + active: searchActive, + }, + )} ))} {(workEntry.changedFiles?.length ?? 0) > 4 && ( diff --git a/apps/web/src/components/chat/ProposedPlanCard.test.tsx b/apps/web/src/components/chat/ProposedPlanCard.test.tsx new file mode 100644 index 00000000000..510a5be5eb5 --- /dev/null +++ b/apps/web/src/components/chat/ProposedPlanCard.test.tsx @@ -0,0 +1,28 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../hooks/useTheme", () => ({ + useTheme: () => ({ + theme: "light", + resolvedTheme: "light", + }), +})); + +describe("ProposedPlanCard", () => { + it("highlights matches in the rendered plan title", async () => { + const { ProposedPlanCard } = await import("./ProposedPlanCard"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-thread-search-highlight="active"'); + expect(markup).toContain("Seed<"); + }); +}); diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index c8956b9cfa3..a2e9256c065 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -25,15 +25,20 @@ import { } from "../ui/dialog"; import { toastManager } from "../ui/toast"; import { readNativeApi } from "~/nativeApi"; +import { renderHighlightedText } from "./threadSearchHighlight"; export const ProposedPlanCard = memo(function ProposedPlanCard({ planMarkdown, cwd, workspaceRoot, + searchQuery = "", + searchActive = false, }: { planMarkdown: string; cwd: string | undefined; workspaceRoot: string | undefined; + searchQuery?: string; + searchActive?: boolean; }) { const [expanded, setExpanded] = useState(false); const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); @@ -118,7 +123,11 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
Plan -

{title}

+

+ {renderHighlightedText(title, searchQuery, `proposed-plan-title:${title}`, { + active: searchActive, + })} +

{canCollapse && !expanded ? ( - + ) : ( - + )} {canCollapse && !expanded ? (
diff --git a/apps/web/src/components/chat/ThreadSearchBar.browser.tsx b/apps/web/src/components/chat/ThreadSearchBar.browser.tsx new file mode 100644 index 00000000000..fce3c94ab83 --- /dev/null +++ b/apps/web/src/components/chat/ThreadSearchBar.browser.tsx @@ -0,0 +1,122 @@ +import "../../index.css"; + +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { ThreadSearchBar } from "./ThreadSearchBar"; + +function dispatchInputKey( + input: HTMLInputElement, + key: string, + options: { shiftKey?: boolean } = {}, +) { + input.dispatchEvent( + new KeyboardEvent("keydown", { + key, + shiftKey: options.shiftKey ?? false, + bubbles: true, + cancelable: true, + }), + ); +} + +async function mountBar(props?: { + query?: string; + resultCount?: number; + activeResultIndex?: number; +}) { + const host = document.createElement("div"); + document.body.append(host); + const onQueryChange = vi.fn(); + const onNext = vi.fn(); + const onPrevious = vi.fn(); + const onClose = vi.fn(); + const inputRef = { current: null as HTMLInputElement | null }; + const screen = await render( + , + { container: host }, + ); + + return { + inputRef, + onQueryChange, + onNext, + onPrevious, + onClose, + cleanup: async () => { + await screen.unmount(); + host.remove(); + }, + }; +} + +describe("ThreadSearchBar", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("renders count states for empty, missing, and active results", async () => { + const empty = await mountBar(); + try { + await expect + .element(page.getByTestId("thread-search-count")) + .toHaveTextContent("Type to search"); + } finally { + await empty.cleanup(); + } + + const noMatches = await mountBar({ query: "needle", resultCount: 0, activeResultIndex: -1 }); + try { + await expect.element(page.getByTestId("thread-search-count")).toHaveTextContent("No matches"); + } finally { + await noMatches.cleanup(); + } + + const active = await mountBar({ query: "needle", resultCount: 3, activeResultIndex: 1 }); + try { + await expect.element(page.getByTestId("thread-search-count")).toHaveTextContent("2 / 3"); + } finally { + await active.cleanup(); + } + }); + + it("routes Enter, Shift+Enter, and Escape to the expected callbacks", async () => { + const mounted = await mountBar({ query: "needle", resultCount: 2, activeResultIndex: 0 }); + + try { + const input = document.querySelector('[data-testid="thread-search-input"]'); + expect(input).toBeTruthy(); + input!.focus(); + dispatchInputKey(input!, "Enter"); + dispatchInputKey(input!, "Enter", { shiftKey: true }); + dispatchInputKey(input!, "Escape"); + + expect(mounted.onNext).toHaveBeenCalledTimes(1); + expect(mounted.onPrevious).toHaveBeenCalledTimes(1); + expect(mounted.onClose).toHaveBeenCalledTimes(1); + } finally { + await mounted.cleanup(); + } + }); + + it("disables navigation buttons when there are no results", async () => { + const mounted = await mountBar({ query: "needle", resultCount: 0, activeResultIndex: -1 }); + + try { + await expect.element(page.getByLabelText("Previous search result")).toBeDisabled(); + await expect.element(page.getByLabelText("Next search result")).toBeDisabled(); + } finally { + await mounted.cleanup(); + } + }); +}); diff --git a/apps/web/src/components/chat/ThreadSearchBar.tsx b/apps/web/src/components/chat/ThreadSearchBar.tsx new file mode 100644 index 00000000000..4529eca277a --- /dev/null +++ b/apps/web/src/components/chat/ThreadSearchBar.tsx @@ -0,0 +1,116 @@ +import { ChevronDownIcon, ChevronUpIcon, SearchIcon, XIcon } from "lucide-react"; +import type { KeyboardEvent as ReactKeyboardEvent, RefObject } from "react"; + +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Kbd } from "../ui/kbd"; + +interface ThreadSearchBarProps { + query: string; + resultCount: number; + activeResultIndex: number; + inputRef: RefObject; + onQueryChange: (value: string) => void; + onNext: () => void; + onPrevious: () => void; + onClose: () => void; +} + +export function ThreadSearchBar({ + query, + resultCount, + activeResultIndex, + inputRef, + onQueryChange, + onNext, + onPrevious, + onClose, +}: ThreadSearchBarProps) { + const countLabel = + query.trim().length === 0 + ? "Type to search" + : resultCount === 0 + ? "No matches" + : `${Math.min(activeResultIndex + 1, resultCount)} / ${resultCount}`; + + const onKeyDown = (event: ReactKeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onClose(); + return; + } + if (event.key !== "Enter") { + return; + } + event.preventDefault(); + event.stopPropagation(); + if (event.shiftKey) { + onPrevious(); + return; + } + onNext(); + }; + + return ( +
+
+ +
+
+ onQueryChange(event.target.value)} + onKeyDown={onKeyDown} + placeholder="Find in thread" + aria-label="Find in thread" + nativeInput + size="sm" + type="search" + data-testid="thread-search-input" + /> +
+
+ + {countLabel} + + Enter +
+
+ + + +
+
+ ); +} diff --git a/apps/web/src/components/chat/threadSearch.test.ts b/apps/web/src/components/chat/threadSearch.test.ts new file mode 100644 index 00000000000..528b5964e8e --- /dev/null +++ b/apps/web/src/components/chat/threadSearch.test.ts @@ -0,0 +1,180 @@ +import { MessageId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import type { TimelineRow } from "./MessagesTimeline.logic"; +import { + buildThreadSearchIndex, + findThreadSearchLookupState, + findThreadSearchResults, + findThreadSearchResultsFromIndex, +} from "./threadSearch"; + +const rows: TimelineRow[] = [ + { + kind: "message", + id: "message-row", + createdAt: "2026-03-28T12:00:00.000Z", + durationStart: "2026-03-28T12:00:00.000Z", + showCompletionDivider: false, + message: { + id: MessageId.makeUnsafe("message-1"), + role: "assistant", + text: "Needle in the response. Another needle is here.", + createdAt: "2026-03-28T12:00:00.000Z", + streaming: false, + attachments: [ + { + type: "image", + id: "attachment-1", + name: "needle-diagram.png", + mimeType: "image/png", + sizeBytes: 128, + }, + ], + }, + }, + { + kind: "work", + id: "work-row", + createdAt: "2026-03-28T12:00:10.000Z", + groupedEntries: [ + { + id: "work-1", + createdAt: "2026-03-28T12:00:10.000Z", + label: "Updated README", + detail: "Added the migration note", + command: "bun run lint", + changedFiles: ["README.md"], + tone: "info", + }, + ], + }, + { + kind: "proposed-plan", + id: "plan-row", + createdAt: "2026-03-28T12:00:20.000Z", + proposedPlan: { + id: "plan-1" as never, + turnId: null, + planMarkdown: "1. Add thread search\n2. Jump to the matching row", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-03-28T12:00:20.000Z", + updatedAt: "2026-03-28T12:00:20.000Z", + }, + }, + { + kind: "working", + id: "working-row", + createdAt: "2026-03-28T12:00:30.000Z", + }, +]; + +describe("findThreadSearchResults", () => { + it("builds a normalized reusable search index once per row set", () => { + expect(buildThreadSearchIndex(rows)).toEqual([ + { + rowId: "message-row", + rowIndex: 0, + normalizedTexts: ["needle in the response. another needle is here.", "needle-diagram.png"], + }, + { + rowId: "work-row", + rowIndex: 1, + normalizedTexts: [ + "updated readme", + "added the migration note", + "bun run lint", + "readme.md", + ], + }, + { + rowId: "plan-row", + rowIndex: 2, + normalizedTexts: ["1. add thread search\n2. jump to the matching row"], + }, + { + rowId: "working-row", + rowIndex: 3, + normalizedTexts: [], + }, + ]); + }); + + it("finds message matches case-insensitively and counts repeated hits", () => { + expect(findThreadSearchResults(rows, "needle")).toEqual([ + { + rowId: "message-row", + rowIndex: 0, + matchCount: 3, + }, + ]); + }); + + it("matches work log details and changed files", () => { + expect(findThreadSearchResults(rows, "readme")).toEqual([ + { + rowId: "work-row", + rowIndex: 1, + matchCount: 2, + }, + ]); + }); + + it("matches proposed plans and ignores the working indicator", () => { + expect(findThreadSearchResults(rows, "thread search")).toEqual([ + { + rowId: "plan-row", + rowIndex: 2, + matchCount: 1, + }, + ]); + expect(findThreadSearchResults(rows, "working")).toEqual([]); + }); + + it("returns no results for empty queries", () => { + expect(findThreadSearchResults(rows, " ")).toEqual([]); + }); + + it("returns matching rows in timeline order when several rows match", () => { + expect(findThreadSearchResults(rows, "row")).toEqual([ + { + rowId: "plan-row", + rowIndex: 2, + matchCount: 1, + }, + ]); + }); + + it("reuses the prebuilt index for result lookup", () => { + const index = buildThreadSearchIndex(rows); + expect(findThreadSearchResultsFromIndex(index, "needle")).toEqual( + findThreadSearchResults(rows, "needle"), + ); + }); + + it("narrows from the previous matching rows when the query extends", () => { + const index = buildThreadSearchIndex(rows); + const previousState = findThreadSearchLookupState(index, "need"); + const nextState = findThreadSearchLookupState(index, "needle", previousState); + + expect(previousState.matchingEntries.map((entry) => entry.rowId)).toEqual(["message-row"]); + expect(nextState.matchingEntries.map((entry) => entry.rowId)).toEqual(["message-row"]); + expect(nextState.results).toEqual([ + { + rowId: "message-row", + rowIndex: 0, + matchCount: 3, + }, + ]); + }); + + it("rescans the full index when the query broadens", () => { + const index = buildThreadSearchIndex(rows); + const previousState = findThreadSearchLookupState(index, "thread search"); + const nextState = findThreadSearchLookupState(index, "e", previousState); + + expect(previousState.matchingEntries.map((entry) => entry.rowId)).toEqual(["plan-row"]); + expect(nextState.results).toEqual(findThreadSearchResultsFromIndex(index, "e")); + }); +}); diff --git a/apps/web/src/components/chat/threadSearch.ts b/apps/web/src/components/chat/threadSearch.ts new file mode 100644 index 00000000000..203bf876b63 --- /dev/null +++ b/apps/web/src/components/chat/threadSearch.ts @@ -0,0 +1,163 @@ +import type { TimelineRow } from "./MessagesTimeline.logic"; + +export interface ThreadSearchResult { + rowId: string; + rowIndex: number; + matchCount: number; +} + +export interface ThreadSearchIndexEntry { + rowId: string; + rowIndex: number; + normalizedTexts: readonly string[]; +} + +export interface ThreadSearchLookupState { + normalizedQuery: string; + sourceIndex: ReadonlyArray; + matchingEntries: ReadonlyArray; + results: ReadonlyArray; +} + +function normalizeThreadSearchText(value: string): string { + return value.toLocaleLowerCase(); +} + +function countMatches(haystack: string, needle: string): number { + if (needle.length === 0) { + return 0; + } + + let count = 0; + let searchStart = 0; + while (searchStart <= haystack.length - needle.length) { + const matchIndex = haystack.indexOf(needle, searchStart); + if (matchIndex < 0) { + break; + } + count += 1; + searchStart = matchIndex + needle.length; + } + return count; +} + +function collectRowSearchText(row: TimelineRow): string[] { + switch (row.kind) { + case "message": + return [ + row.message.text, + ...(row.message.attachments?.map((attachment) => attachment.name) ?? []), + ]; + case "proposed-plan": + return [row.proposedPlan.planMarkdown]; + case "work": + return row.groupedEntries.flatMap((entry) => [ + entry.label, + entry.detail ?? "", + entry.command ?? "", + ...(entry.changedFiles ?? []), + ]); + case "working": + return []; + } +} + +export function buildThreadSearchIndex( + rows: ReadonlyArray, +): ReadonlyArray { + return rows.map((row, rowIndex) => ({ + rowId: row.id, + rowIndex, + normalizedTexts: collectRowSearchText(row).flatMap((value) => { + const nextValue = normalizeThreadSearchText(value.trim()); + return nextValue.length > 0 ? [nextValue] : []; + }), + })); +} + +function searchCandidateEntries( + candidateEntries: ReadonlyArray, + normalizedQuery: string, +): { + matchingEntries: ReadonlyArray; + results: ReadonlyArray; +} { + const matchingEntries: ThreadSearchIndexEntry[] = []; + const results = candidateEntries.flatMap((entry) => { + const matchCount = entry.normalizedTexts.reduce((total, value) => { + if (!value.includes(normalizedQuery)) { + return total; + } + return total + countMatches(value, normalizedQuery); + }, 0); + if (matchCount <= 0) { + return []; + } + matchingEntries.push(entry); + return [ + { + rowId: entry.rowId, + rowIndex: entry.rowIndex, + matchCount, + } satisfies ThreadSearchResult, + ]; + }); + + return { + matchingEntries, + results, + }; +} + +export function createEmptyThreadSearchLookupState( + index: ReadonlyArray, +): ThreadSearchLookupState { + return { + normalizedQuery: "", + sourceIndex: index, + matchingEntries: [], + results: [], + }; +} + +export function findThreadSearchLookupState( + index: ReadonlyArray, + query: string, + previousState?: ThreadSearchLookupState | null, +): ThreadSearchLookupState { + const normalizedQuery = normalizeThreadSearchText(query.trim()); + if (normalizedQuery.length === 0) { + return createEmptyThreadSearchLookupState(index); + } + + const canNarrowFromPrevious = + previousState !== undefined && + previousState !== null && + previousState.sourceIndex === index && + previousState.normalizedQuery.length > 0 && + normalizedQuery.startsWith(previousState.normalizedQuery); + + const candidateEntries = canNarrowFromPrevious ? previousState.matchingEntries : index; + const { matchingEntries, results } = searchCandidateEntries(candidateEntries, normalizedQuery); + return { + normalizedQuery, + sourceIndex: index, + matchingEntries, + results, + }; +} + +export function findThreadSearchResultsFromIndex( + index: ReadonlyArray, + query: string, + previousState?: ThreadSearchLookupState | null, +): ReadonlyArray { + return findThreadSearchLookupState(index, query, previousState).results; +} + +export function findThreadSearchResults( + rows: ReadonlyArray, + query: string, +): ReadonlyArray { + return findThreadSearchResultsFromIndex(buildThreadSearchIndex(rows), query); +} diff --git a/apps/web/src/components/chat/threadSearchHighlight.test.tsx b/apps/web/src/components/chat/threadSearchHighlight.test.tsx new file mode 100644 index 00000000000..36d1a5fbee8 --- /dev/null +++ b/apps/web/src/components/chat/threadSearchHighlight.test.tsx @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { createThreadSearchHighlightRehypePlugin } from "./threadSearchHighlight"; + +describe("createThreadSearchHighlightRehypePlugin", () => { + it("ignores malformed tree children without crashing", () => { + const plugin = createThreadSearchHighlightRehypePlugin("alpha", { active: true }); + if (!plugin) { + throw new Error("Expected highlight plugin to be created."); + } + const transform = plugin(); + + const tree = { + type: "root", + children: [ + undefined, + { + type: "element", + tagName: "p", + children: [{ type: "text", value: "alpha beta alpha" }], + }, + { + type: "element", + tagName: "hr", + }, + ], + }; + + expect(() => transform(tree)).not.toThrow(); + expect(tree.children).toEqual( + expect.arrayContaining([ + { + type: "element", + tagName: "p", + children: [ + { + type: "element", + tagName: "mark", + properties: { + "data-thread-search-highlight": "active", + className: + "rounded-[0.35rem] bg-warning px-[0.12rem] py-[0.04rem] text-black ring-1 ring-warning/45", + }, + children: [{ type: "text", value: "alpha" }], + }, + { type: "text", value: " beta " }, + { + type: "element", + tagName: "mark", + properties: { + "data-thread-search-highlight": "active", + className: + "rounded-[0.35rem] bg-warning px-[0.12rem] py-[0.04rem] text-black ring-1 ring-warning/45", + }, + children: [{ type: "text", value: "alpha" }], + }, + ], + }, + { + type: "element", + tagName: "hr", + }, + ]), + ); + }); +}); diff --git a/apps/web/src/components/chat/threadSearchHighlight.tsx b/apps/web/src/components/chat/threadSearchHighlight.tsx new file mode 100644 index 00000000000..79b99588929 --- /dev/null +++ b/apps/web/src/components/chat/threadSearchHighlight.tsx @@ -0,0 +1,174 @@ +import type { ReactNode } from "react"; + +const MATCH_HIGHLIGHT_CLASS_NAME = + "rounded-[0.35rem] bg-warning/38 px-[0.12rem] py-[0.04rem] text-inherit ring-1 ring-warning/18"; +const ACTIVE_HIGHLIGHT_CLASS_NAME = + "rounded-[0.35rem] bg-warning px-[0.12rem] py-[0.04rem] text-black ring-1 ring-warning/45"; + +interface TextMatchRange { + start: number; + end: number; +} + +interface HNode { + type: string; + value?: string; + tagName?: string; + properties?: Record; + children?: unknown; +} + +function normalizeQuery(query: string): string { + return query.trim().toLocaleLowerCase(); +} + +function findMatchRanges(text: string, query: string): TextMatchRange[] { + const normalizedQuery = normalizeQuery(query); + if (normalizedQuery.length === 0) { + return []; + } + + const normalizedText = text.toLocaleLowerCase(); + const ranges: TextMatchRange[] = []; + let searchStart = 0; + + while (searchStart <= normalizedText.length - normalizedQuery.length) { + const matchIndex = normalizedText.indexOf(normalizedQuery, searchStart); + if (matchIndex < 0) { + break; + } + ranges.push({ + start: matchIndex, + end: matchIndex + normalizedQuery.length, + }); + searchStart = matchIndex + normalizedQuery.length; + } + + return ranges; +} + +export function renderHighlightedText( + text: string, + query: string, + keyPrefix: string, + options?: { active?: boolean }, +): ReactNode { + const ranges = findMatchRanges(text, query); + if (ranges.length === 0) { + return text; + } + + const nodes: ReactNode[] = []; + let cursor = 0; + for (const [index, range] of ranges.entries()) { + if (range.start > cursor) { + nodes.push(text.slice(cursor, range.start)); + } + nodes.push( + + {text.slice(range.start, range.end)} + , + ); + cursor = range.end; + } + + if (cursor < text.length) { + nodes.push(text.slice(cursor)); + } + + return nodes; +} + +function buildHastHighlightNode(value: string, active: boolean): HNode { + return { + type: "element", + tagName: "mark", + properties: { + "data-thread-search-highlight": active ? "active" : "match", + className: active ? ACTIVE_HIGHLIGHT_CLASS_NAME : MATCH_HIGHLIGHT_CLASS_NAME, + }, + children: [ + { + type: "text", + value, + }, + ], + }; +} + +function splitTextNode(node: HNode, query: string, active: boolean): HNode[] { + const value = typeof node.value === "string" ? node.value : ""; + const ranges = findMatchRanges(value, query); + if (ranges.length === 0) { + return [node]; + } + + const parts: HNode[] = []; + let cursor = 0; + for (const range of ranges) { + if (range.start > cursor) { + parts.push({ + type: "text", + value: value.slice(cursor, range.start), + }); + } + parts.push(buildHastHighlightNode(value.slice(range.start, range.end), active)); + cursor = range.end; + } + + if (cursor < value.length) { + parts.push({ + type: "text", + value: value.slice(cursor), + }); + } + + return parts; +} + +function isHNode(value: unknown): value is HNode { + return typeof value === "object" && value !== null && typeof (value as HNode).type === "string"; +} + +function visitTree(node: HNode, query: string, active: boolean): void { + const rawChildren = Array.isArray(node.children) ? node.children.filter(isHNode) : null; + if (!rawChildren || rawChildren.length === 0) { + return; + } + + const nextChildren: HNode[] = []; + for (const child of rawChildren) { + if (child.type === "text") { + nextChildren.push(...splitTextNode(child, query, active)); + continue; + } + + visitTree(child, query, active); + nextChildren.push(child); + } + + node.children = nextChildren; +} + +export function createThreadSearchHighlightRehypePlugin( + query: string, + options?: { active?: boolean }, +): (() => (tree: unknown) => void) | undefined { + const normalizedQuery = normalizeQuery(query); + if (normalizedQuery.length === 0) { + return undefined; + } + + return () => { + return (tree: unknown) => { + if (!isHNode(tree)) { + return; + } + visitTree(tree, normalizedQuery, options?.active ?? false); + }; + }; +} From 7828e85e7f3ec10e44c55ee6244ee4259ee956e3 Mon Sep 17 00:00:00 2001 From: Xavier Date: Sat, 28 Mar 2026 18:42:43 -0300 Subject: [PATCH 15/26] Address thread search review feedback --- .../ChatView.threadSearch.browser.tsx | 83 +++++++++++++++++-- apps/web/src/components/ChatView.tsx | 3 + .../components/chat/MessagesTimeline.test.tsx | 62 ++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 8 +- .../src/components/chat/threadSearch.test.ts | 14 +++- apps/web/src/components/chat/threadSearch.ts | 23 +++-- 6 files changed, 175 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/ChatView.threadSearch.browser.tsx b/apps/web/src/components/ChatView.threadSearch.browser.tsx index 5074fe23fc0..d0ab43155b8 100644 --- a/apps/web/src/components/ChatView.threadSearch.browser.tsx +++ b/apps/web/src/components/ChatView.threadSearch.browser.tsx @@ -26,6 +26,7 @@ import { useStore } from "../store"; import { isMacPlatform } from "../lib/utils"; const THREAD_ID = "thread-search-browser" as ThreadId; +const SECOND_THREAD_ID = "thread-search-browser-second" as ThreadId; const PROJECT_ID = "project-1" as ProjectId; const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); @@ -152,6 +153,47 @@ function createSearchSnapshot(): OrchestrationReadModel { updatedAt: NOW_ISO, }, }, + { + id: SECOND_THREAD_ID, + projectId: PROJECT_ID, + title: "Second thread", + modelSelection: { + provider: "codex", + model: "gpt-5", + }, + interactionMode: "default", + runtimeMode: "full-access", + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + archivedAt: null, + deletedAt: null, + messages: [ + { + id: "second-thread-message-1" as MessageId, + role: "assistant", + text: "This second thread should not inherit any stale search state.", + turnId: null, + streaming: false, + createdAt: isoAt(500), + updatedAt: isoAt(501), + }, + ], + activities: [], + proposedPlans: [], + checkpoints: [], + session: { + threadId: SECOND_THREAD_ID, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW_ISO, + }, + }, ], updatedAt: NOW_ISO, }; @@ -291,7 +333,10 @@ function dispatchSearchInputKey(key: string, options: { shiftKey?: boolean } = { ); } -async function mountApp(): Promise<{ cleanup: () => Promise }> { +async function mountApp(): Promise<{ + cleanup: () => Promise; + router: ReturnType; +}> { const host = document.createElement("div"); host.style.position = "fixed"; host.style.inset = "0"; @@ -306,6 +351,7 @@ async function mountApp(): Promise<{ cleanup: () => Promise }> { await waitForComposerEditor(); return { + router, cleanup: async () => { await screen.unmount(); host.remove(); @@ -339,13 +385,6 @@ async function waitForActiveSearchHighlight(messageId: string, text: string): Pr }, `Message row ${messageId} should highlight "${text}" inline`); } -async function waitForMessageRow(messageId: string): Promise { - return waitForElement( - () => document.querySelector(`[data-message-id="${messageId}"]`), - `Message row ${messageId} should be rendered`, - ); -} - async function waitForAnyTimelineRow(): Promise { return waitForElement( () => document.querySelector("[data-timeline-row-id]"), @@ -513,4 +552,32 @@ describe("ChatView thread search", () => { await mounted.cleanup(); } }); + + it("resets the search UI and query when navigating to another thread", async () => { + const mounted = await mountApp(); + + try { + dispatchThreadSearchShortcut(); + await page.getByTestId("thread-search-input").fill("alpha marker"); + await waitForActiveSearchHighlight("user-0", "alpha marker"); + + await mounted.router.navigate({ + to: "/$threadId", + params: { threadId: SECOND_THREAD_ID }, + }); + + await waitForElement( + () => document.querySelector('[data-message-id="second-thread-message-1"]'), + "Second thread content should be rendered after navigation", + ); + + await vi.waitFor(() => { + expect(document.querySelector('[data-testid="thread-search-input"]')).toBeNull(); + expect(document.querySelector('[data-search-match-state="active"]')).toBeNull(); + expect(document.querySelector('[data-thread-search-highlight="active"]')).toBeNull(); + }); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 50093b722cc..b650374c420 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2147,6 +2147,9 @@ export default function ChatView({ threadId }: ChatViewProps) { dragDepthRef.current = 0; setIsDragOverComposer(false); setExpandedImage(null); + setThreadSearchOpen(false); + setThreadSearchQuery(""); + setActiveThreadSearchResultIndex(-1); }, [threadId]); useEffect(() => { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 0777c31fc0a..5f9b78e70f2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -215,6 +215,68 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("bg-warning/12"); }); + it("exposes hidden work log matches while searching overflowed groups", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const rows = buildTimelineRows({ + timelineEntries: [ + { + id: "work-entry-1", + kind: "work", + createdAt: "2026-03-17T19:12:28.000Z", + entry: { + id: "work-1", + createdAt: "2026-03-17T19:12:28.000Z", + label: "Seeded hidden match", + tone: "info", + }, + }, + ...Array.from({ length: 6 }, (_, index) => ({ + id: `work-entry-${index + 2}`, + kind: "work" as const, + createdAt: `2026-03-17T19:12:${String(29 + index).padStart(2, "0")}.000Z`, + entry: { + id: `work-${index + 2}`, + createdAt: `2026-03-17T19:12:${String(29 + index).padStart(2, "0")}.000Z`, + label: `Visible filler ${index + 1}`, + tone: "info" as const, + }, + })), + ], + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnStartedAt: null, + }); + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + activeSearchRowId="work-entry-1" + matchedSearchRowIds={new Set(["work-entry-1"])} + searchQuery="Seeded" + />, + ); + + expect(markup).toContain("Seeded hidden match"); + expect(markup).toContain('data-thread-search-highlight="active"'); + expect(markup).not.toContain("Show 1 more"); + }); + it("renders assistant markdown search highlights", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const rows = buildTimelineRows({ diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e8b88417f86..3375f41cc2f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -135,7 +135,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return () => { observer.disconnect(); }; - }, [rows]); + }, [hasRows, isWorking]); const firstUnvirtualizedRowIndex = useMemo(() => { const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0); @@ -304,8 +304,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const groupedEntries = row.groupedEntries; const isExpanded = expandedWorkGroups[groupId] ?? false; const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; + const searchExpanded = + rowSearchState !== null && rowSearchQuery.trim().length > 0 && hasOverflow; const visibleEntries = - hasOverflow && !isExpanded + hasOverflow && !isExpanded && !searchExpanded ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : groupedEntries; const hiddenCount = groupedEntries.length - visibleEntries.length; @@ -320,7 +322,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({

{groupLabel} ({groupedEntries.length})

- {hasOverflow && ( + {hasOverflow && !searchExpanded && (
-
- {canCollapse && !expanded ? ( +
+ {canCollapse && !showExpandedPlan ? ( )} - {canCollapse && !expanded ? ( + {canCollapse && !showExpandedPlan ? (
) : null}
- {canCollapse ? ( + {canCollapse && !searchExpanded ? (
- ) : ( -
- {renderHighlightedText( - image.name, - rowSearchQuery, - `user-image-name:${row.id}:${image.id}`, - { active: rowSearchActive }, - )} -
- )} -
- ), - )} -
- )} - {(displayedUserMessage.visibleText.trim().length > 0 || - terminalContexts.length > 0) && ( - - )} -
-
-
- {displayedUserMessage.copyText && ( - + {row.kind === "message" && + row.message.role === "user" && + (() => { + const userImages = row.message.attachments ?? []; + const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); + const terminalContexts = displayedUserMessage.contexts; + const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); + return ( +
+
+
+ {userImages.length > 0 && ( +
+ {userImages.map( + (image: NonNullable[number]) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {renderHighlightedText( + image.name, + rowSearchQuery, + `user-image-name:${row.id}:${image.id}`, + { active: rowSearchActive }, + )} +
+ )} +
+ ), + )} +
)} - {canRevertAgentWork && ( - + {(displayedUserMessage.visibleText.trim().length > 0 || + terminalContexts.length > 0) && ( + )}
-

- {formatTimestamp(row.message.createdAt, timestampFormat)} -

+
+
+ {displayedUserMessage.copyText && ( + + )} + {canRevertAgentWork && ( + + )} +
+

+ {formatTimestamp(row.message.createdAt, timestampFormat)} +

+
-
- ); - })()} + ); + })()} {row.kind === "message" && row.message.role === "assistant" && diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 1d6d5f6eaf7..a6b6577f489 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -94,8 +94,6 @@ const ASSISTANT_RESPONSE_COPY_FORMAT_LABELS = { markdown: "Raw markdown", "plain-text": "Rendered plain text", } as const; - -const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; type InstallProviderSettings = { provider: ProviderKind; title: string; diff --git a/apps/web/src/rpc/client.ts b/apps/web/src/rpc/client.ts index 117f7c0aafc..eda835b89a6 100644 --- a/apps/web/src/rpc/client.ts +++ b/apps/web/src/rpc/client.ts @@ -1,12 +1,12 @@ import { WsRpcGroup } from "@t3tools/contracts"; -import { Effect, Layer, ManagedRuntime } from "effect"; +import { type Effect, ManagedRuntime } from "effect"; import { AtomRpc } from "effect/unstable/reactivity"; import { createWsRpcProtocolLayer } from "./protocol"; export class WsRpcAtomClient extends AtomRpc.Service()("WsRpcAtomClient", { group: WsRpcGroup, - protocol: Layer.suspend(() => createWsRpcProtocolLayer()), + protocol: createWsRpcProtocolLayer(), }) {} let sharedRuntime: ManagedRuntime.ManagedRuntime | null = null; diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 4312393d700..2b84a28db51 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -499,6 +499,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve archivedAt: null, deletedAt: null, messages: [], + queuedFollowUps: [], proposedPlans: [], activities: [], checkpoints: [],