From 026a8a3930ff0524286c812f3437b4937db3c95c Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:49:41 -0700 Subject: [PATCH 1/3] test(web): chat markdown and timeline tests run in a real browser ChatMarkdown (main, assets, permissions) and MessagesTimeline tests move from jsdom/react-test-renderer/renderToStaticMarkup to Vitest browser mode, driven through the rendered DOM. Pure helpers stay in node logic tests; tests that only asserted class names or props handed to a mocked LegendList are removed. Co-Authored-By: Claude Opus 5.5 --- .../ChatMarkdown.assets.browser.test.tsx | 70 + .../components/ChatMarkdown.assets.test.tsx | 119 - .../components/ChatMarkdown.browser.test.tsx | 848 +++++ .../src/components/ChatMarkdown.logic.test.ts | 55 + ...ChatMarkdown.permissions.browser.test.tsx} | 192 +- apps/web/src/components/ChatMarkdown.test.tsx | 1391 -------- apps/web/src/components/ChatMarkdown.tsx | 2 +- .../chat/MessagesTimeline.browser.test.tsx | 1262 +++++++ .../chat/MessagesTimeline.logic.test.ts | 78 + .../components/chat/MessagesTimeline.test.tsx | 2907 ----------------- 10 files changed, 2404 insertions(+), 4520 deletions(-) create mode 100644 apps/web/src/components/ChatMarkdown.assets.browser.test.tsx delete mode 100644 apps/web/src/components/ChatMarkdown.assets.test.tsx create mode 100644 apps/web/src/components/ChatMarkdown.browser.test.tsx create mode 100644 apps/web/src/components/ChatMarkdown.logic.test.ts rename apps/web/src/components/{ChatMarkdown.permissions.test.tsx => ChatMarkdown.permissions.browser.test.tsx} (60%) delete mode 100644 apps/web/src/components/ChatMarkdown.test.tsx create mode 100644 apps/web/src/components/chat/MessagesTimeline.browser.test.tsx delete mode 100644 apps/web/src/components/chat/MessagesTimeline.test.tsx diff --git a/apps/web/src/components/ChatMarkdown.assets.browser.test.tsx b/apps/web/src/components/ChatMarkdown.assets.browser.test.tsx new file mode 100644 index 000000000000..6e6d5fc368f4 --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.assets.browser.test.tsx @@ -0,0 +1,70 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { AsyncResult } from "effect/reactivity"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const mint = vi.hoisted(() => vi.fn()); +// Atoms derived from the server config run in the real registry over a missing config. +vi.mock(import("../state/server"), async (original) => { + const actual = await original(); + const { Atom } = await import("effect/reactivity"); + const noConfig = Atom.make(null); + return { + ...actual, + serverEnvironment: { ...actual.serverEnvironment, configValueAtom: (() => noConfig) as never }, + }; +}); +vi.mock(import("../state/use-atom-query-runner"), async (original) => ({ + ...(await original()), + useAtomQueryRunner: (() => mint) as never, +})); +vi.mock(import("../state/session"), async (original) => ({ + ...(await original()), + readEnvironmentScope: () => false, + useEnvironmentScope: () => false, + readPreparedConnection: (() => ({ httpBaseUrl: "https://host.test" })) as never, +})); + +import ChatMarkdown from "./ChatMarkdown"; + +const roots: Root[] = []; +beforeEach(() => vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true)); +afterEach(async () => { + for (const root of roots.splice(0)) await act(async () => root.unmount()); + document.body.replaceChildren(); + vi.unstubAllGlobals(); +}); + +it("opens host media through server authorization before the client grant loads", async () => { + mint.mockResolvedValue( + AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }), + ); + const onImageExpand = vi.fn(); + const container = document.body.appendChild(document.createElement("div")); + const root = createRoot(container); + roots.push(root); + await act(async () => + root.render( + , + ), + ); + const link = container.querySelector('a[href="/tmp/image.png"]'); + expect(link).not.toBeNull(); + await act(async () => link!.click()); + await vi.waitFor(() => + expect(onImageExpand).toHaveBeenCalledWith( + expect.objectContaining({ + images: [expect.objectContaining({ src: "https://host.test/api/assets/image.png" })], + }), + ), + ); +}); diff --git a/apps/web/src/components/ChatMarkdown.assets.test.tsx b/apps/web/src/components/ChatMarkdown.assets.test.tsx deleted file mode 100644 index e3dd68ed929d..000000000000 --- a/apps/web/src/components/ChatMarkdown.assets.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { AsyncResult } from "effect/reactivity"; -import { act, type ComponentProps, type ReactNode } from "react"; -import { create, type ReactTestRenderer } from "react-test-renderer"; -import { expect, it, vi } from "vite-plus/test"; - -const mint = vi.hoisted(() => vi.fn()); -// Atoms derived from the server config run in a real registry over a missing -// config; every other subscription reads nothing. -vi.mock("@effect/atom-react", async () => { - const { AtomRegistry } = await import("effect/reactivity"); - const registry = AtomRegistry.make(); - const derived = new Set(["markdown-server-config-fields", "pull-request-link-mode"]); - return { - useAtomValue: (atom: { readonly label?: readonly [string, ...unknown[]] }) => - derived.has(atom.label?.[0] ?? "") ? registry.get(atom as never) : null, - }; -}); -vi.mock("../state/server", async () => { - const { Atom } = await import("effect/reactivity"); - const noConfig = Atom.make(null); - return { - serverEnvironment: { configValueAtom: () => noConfig }, - environmentServerConfigsAtom: Atom.make(new Map()), - }; -}); -vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); -vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => undefined })); -vi.mock("../hooks/useSettings", async (importOriginal) => { - const actual = await importOriginal(); - const settings = actual.getClientSettings(); - return { - ...actual, - useClientSettings: (select: (value: typeof settings) => unknown) => select(settings), - }; -}); -vi.mock("./ui/tooltip", async () => { - const { cloneElement, isValidElement } = await import("react"); - return { - Tooltip: ({ children }: { children: ReactNode }) => <>{children}, - TooltipTrigger({ - render, - children, - }: ComponentProps) { - if (!isValidElement(render)) return <>{children}; - return children === undefined ? render : cloneElement(render, undefined, children); - }, - TooltipPopup: () => null, - }; -}); -vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => mint })); -vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); -vi.mock("../state/session", async (importOriginal) => ({ - ...(await importOriginal()), - readEnvironmentScope: () => false, - useEnvironmentScope: () => false, - readPreparedConnection: () => ({ httpBaseUrl: "https://host.test" }), -})); -vi.mock("../state/entities", () => ({ - readEnvironmentSupportsServerBrowser: () => false, - readThreadShell: () => null, - useProjects: () => [], - useServerConfigs: () => new Map(), -})); -vi.mock("../remoteOpen", () => ({ - useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), -})); -vi.mock("../editorPreferences", () => ({ - useOpenInPreferredEditor: () => vi.fn(), - usePreferredEditor: () => [null, vi.fn()], -})); -vi.mock("~/lib/openPullRequestLink", () => ({ - findProjectForChangeRequest: () => undefined, - matchesLinkedPullRequestUrl: () => false, - parseChangeRequestUrl: () => null, - useOpenChangeRequestLink: () => vi.fn(), -})); - -import ChatMarkdown from "./ChatMarkdown"; - -it("opens host media through server authorization before the client grant loads", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - mint.mockResolvedValue( - AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }), - ); - const onImageExpand = vi.fn(); - const threadRef = { - environmentId: EnvironmentId.make("media-environment"), - threadId: ThreadId.make("media-thread"), - }; - let renderer: ReactTestRenderer | undefined; - try { - await act(async () => { - renderer = create( - , - ); - }); - await act(async () => { - const link = renderer!.root - .findAllByType("a") - .find((node) => node.props.href === "/tmp/image.png"); - expect(link).toBeDefined(); - link!.props.onClick({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); - }); - expect(onImageExpand).toHaveBeenCalledWith( - expect.objectContaining({ - images: [expect.objectContaining({ src: "https://host.test/api/assets/image.png" })], - }), - ); - } finally { - await act(async () => renderer?.unmount()); - vi.unstubAllGlobals(); - } -}); diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx new file mode 100644 index 000000000000..942259fd5de1 --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -0,0 +1,848 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { searchableMessageSegments } from "@t3tools/shared/threadFindText"; +import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; +import { act, type ReactElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; +import { MarkdownFindContext } from "./chat/markdownFindContext"; +import { useThreadFindHighlights } from "./chat/threadFindHighlights"; +import { setMarkdownTaskChecked } from "./files/filePreviewMode"; + +// Atoms derived from the server config run in the real registry over a missing config. +vi.mock(import("../state/server"), async (original) => { + const actual = await original(); + const { Atom } = await import("effect/reactivity"); + const noConfig = Atom.make(null); + return { + ...actual, + serverEnvironment: { ...actual.serverEnvironment, configValueAtom: (() => noConfig) as never }, + }; +}); +// A standard client session, so environment-owned file links can open. +vi.mock(import("../state/session"), async (original) => { + const { AuthStandardClientScopes } = await import("@t3tools/contracts"); + const granted = new Set(AuthStandardClientScopes); + const hasScope = (environmentId: unknown, scope: string) => + environmentId !== null && granted.has(scope); + return { + ...(await original()), + useEnvironmentScope: hasScope as never, + readEnvironmentScope: hasScope as never, + }; +}); +vi.mock(import("../remoteOpen"), async (original) => ({ + ...(await original()), + useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }) as never, +})); +// A rendered diagram is an SVG; real Mermaid layout is not what these tests exercise. +vi.mock(import("./chat/MermaidDiagram"), async (original) => ({ + ...(await original()), + MermaidDiagram: () => , +})); + +import ChatMarkdown from "./ChatMarkdown"; + +const roots: Root[] = []; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +}); + +afterEach(async () => { + for (const root of roots.splice(0)) await act(async () => root.unmount()); + document.body.replaceChildren(); + CSS.highlights.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +async function render(element: ReactElement) { + const container = document.body.appendChild(document.createElement("div")); + const root = createRoot(container); + roots.push(root); + await act(async () => root.render(element)); + return { + container, + rerender: (next: ReactElement) => act(async () => root.render(next)), + }; +} + +async function renderText(text: string, props: Partial[0]> = {}) { + return (await render()).container; +} + +function linkTexts(container: HTMLElement) { + return [...container.querySelectorAll("a")].map((link) => link.textContent); +} + +function buttonsLabelled(container: HTMLElement, label: string) { + return [...container.querySelectorAll("button")].filter( + (button) => button.getAttribute("aria-label") === label, + ); +} + +function fileOptions(container: HTMLElement) { + return [...container.querySelectorAll("button")] + .map((button) => button.getAttribute("aria-label")) + .filter((label) => label?.startsWith("File options for ")); +} + +describe("ChatMarkdown bare anchor placeholders", () => { + it.each(["", "", "", "", "", ""])( + "preserves unmatched %s without linking later blocks", + async (token) => { + const container = await renderText( + `- **"From ${token}"** appears in the header.\n\n- **Tests:** cover inheritance.\n\nThe deferred move continues on B.\n\nSee the link.`, + ); + expect(container.querySelector("strong")?.textContent).toBe(`"From ${token}"`); + expect(linkTexts(container)).toEqual(["the link"]); + expect(container.querySelectorAll("li")).toHaveLength(2); + expect([...container.querySelectorAll("p")].map((p) => p.textContent)).toContain( + "The deferred move continues on B.", + ); + }, + ); + + it.each([" ", "
more
\n"])( + "preserves a paired anchor closing in the raw block %s", + async (closing) => { + const container = await renderText(`See label\n\n${closing}\n\nfinish`); + expect(container.querySelector("p")?.textContent).toBe("See label"); + }, + ); + + it("preserves a paired anchor after comment-looking raw text", async () => { + const container = await renderText("See label -->"); + expect(container.querySelector("p")?.textContent).toBe("See label -->"); + }); + + it.each(["", '
more
', ''])( + "ignores apparent closing anchors inside %s", + async (html) => { + const container = await renderText(`Before .\n\n${html}\n\nAfter.`); + expect(container.querySelector("p")?.textContent).toBe("Before ."); + expect(container.querySelectorAll("a")).toHaveLength(0); + }, + ); + + it("preserves paired HTML anchors, details, markdown links, and inline code", async () => { + const container = await renderText( + 'Bare label, , ``, and [docs](https://example.com).\n\n
MoreDetails
', + ); + expect(linkTexts(container)).toEqual(["label", "", "docs"]); + expect(container.querySelector("code")?.textContent).toBe("
"); + expect(container.querySelector("[data-markdown-details]")?.textContent).toContain("More"); + }); +}); + +describe("ChatMarkdown context references", () => { + it("renders text and image references through the chip renderer, with readable fallback", async () => { + const text = + "See [Terminal output](t3-context://v1/terminal/term-1) and ![Error image](t3-context://v1/image/img-1)."; + const { container, rerender } = await render( + ( + + )} + />, + ); + expect([...container.querySelectorAll("button")].map((button) => button.textContent)).toEqual([ + "terminal: Terminal output", + "image: Error image", + ]); + expect(container.querySelectorAll("img, a")).toHaveLength(0); + await rerender(); + expect(container.textContent).toBe("See Terminal output and Error image."); + expect(container.querySelectorAll("img, a, button")).toHaveLength(0); + }); + + it("reads formatted context labels through nested markup instead of the context id", async () => { + const seen: Array = []; + await render( + { + seen.push(`${kind}: ${label}`); + return ; + }} + />, + ); + expect(new Set(seen)).toEqual(new Set(["terminal: Bold code"])); + }); +}); + +describe("ChatMarkdown favicon privacy", () => { + it("suppresses private link images while preserving public links across updates", async () => { + const markdown = (url: string) => ; + const { container, rerender } = await render(markdown("https://example.com")); + expect([...container.querySelectorAll("img")].map((image) => image.src)).toEqual([ + "https://www.google.com/s2/favicons?domain=example.com&sz=32", + ]); + for (const url of ["http://192.168.1.10:8080", "http://localhost:3000", "http://home.arpa"]) { + await rerender(markdown(url)); + expect(container.querySelectorAll("img")).toHaveLength(0); + } + await rerender(markdown("https://example.com")); + expect(container.querySelectorAll("img")).toHaveLength(1); + await rerender(markdown("https://github.com/pingdotgg/t3code/pull/1")); + expect(container.querySelectorAll("img")).toHaveLength(0); + expect(container.querySelector("a svg")).not.toBeNull(); + }); +}); + +describe("ChatMarkdown streaming", () => { + it("runs only a complete single-line shell block after a click", async () => { + const onRunShellCommand = vi.fn(); + const message = (text: string, isStreaming = false) => ( + + ); + const { container, rerender } = await render(message("```bash\necho hello\n```", true)); + const runButtons = () => buttonsLabelled(container, "Run in terminal"); + expect(runButtons()).toHaveLength(0); + + await rerender(message("```bash\necho hello\n```")); + await act(async () => runButtons()[0]!.click()); + expect(onRunShellCommand).toHaveBeenCalledExactlyOnceWith("echo hello"); + + for (const text of [ + "~~~bash\necho tilde\n~~~", + "> ```bash\n> echo quote\n> ```", + "````bash\necho four\n````", + ]) { + await rerender(message(text)); + expect(runButtons()).toHaveLength(1); + } + + for (const text of [ + "```bash\necho one\necho two\n```", + "```typescript\necho hello\n```", + "```bash\n\n```", + "```bash\necho hello\n\n```", + "```bash\necho hello\\\n```", + "```bash\necho safe \u202e#\n```", + "```bash\necho incomplete", + "~~~bash\necho incomplete", + "````bash\necho incomplete\n```", + '
echo html
', + ]) { + await rerender(message(text)); + expect(runButtons()).toHaveLength(0); + } + }); + + it("does not retokenize completed lines when streaming finishes", async () => { + const highlighter = await getSyntaxHighlighterPromise("typescript"); + const highlight = vi.spyOn(highlighter, "codeToHast"); + const text = "```typescript\nconst completed = 1;\nconst current = 2;"; + const { rerender } = await render(); + await vi.waitFor(() => expect(highlight).toHaveBeenCalled()); + highlight.mockClear(); + await rerender(); + expect(highlight.mock.calls.every(([code]) => !code.includes("const completed"))).toBe(true); + }); + + it("recovers highlighting after a failed fence changes without resetting its controls", async () => { + const highlighter = await getSyntaxHighlighterPromise("text"); + const codeToHast = highlighter.codeToHast.bind(highlighter); + let fail = true; + vi.spyOn(highlighter, "codeToHast").mockImplementation((...args) => { + if (fail) throw new Error("Temporary highlighter failure"); + return codeToHast(...args); + }); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { container, rerender } = await render( + , + ); + const codeBlock = container.querySelector('[data-language="text"]')!; + const initialWrap = codeBlock.dataset.wrap === "true"; + await act(async () => + buttonsLabelled(container, initialWrap ? "Disable line wrap" : "Wrap lines")[0]!.click(), + ); + expect(container.querySelectorAll(".chat-markdown-shiki")).toHaveLength(0); + + fail = false; + await rerender( + , + ); + await vi.waitFor(() => + expect(container.querySelectorAll(".chat-markdown-shiki")).toHaveLength(1), + ); + expect(container.querySelector('[data-language="text"]')).toBe(codeBlock); + expect(codeBlock.dataset.wrap).toBe(String(!initialWrap)); + }); + + it("preserves code controls and details without highlighting an unchanged fence again", async () => { + const highlighter = await getSyntaxHighlighterPromise("text"); + const highlight = vi.spyOn(highlighter, "codeToHast"); + const writeText = vi + .spyOn(navigator.clipboard, "writeText") + .mockImplementation(async () => undefined); + const text = [ + "```text", + "First code block", + "```", + "", + "
More", + "", + "Details content", + "", + "
", + "", + "Streaming reply", + ].join("\n"); + + const { container, rerender } = await render( + , + ); + const codeBlock = container.querySelector('[data-language="text"]')!; + const initialWrap = codeBlock.dataset.wrap === "true"; + await act(async () => { + buttonsLabelled(container, initialWrap ? "Disable line wrap" : "Wrap lines")[0]!.click(); + buttonsLabelled(container, "Copy code")[0]!.click(); + }); + await act(async () => + container.querySelector("[data-markdown-details-summary]")!.click(), + ); + const details = container.querySelector("[data-markdown-details]")!; + expect(details.dataset.markdownDetailsOpen).toBe("true"); + expect(container.textContent).toContain("Details content"); + expect(writeText).toHaveBeenCalledWith("First code block\n"); + expect(highlight).toHaveBeenCalledTimes(1); + + for (let index = 0; index < 10; index += 1) { + await rerender(); + } + + expect(highlight).toHaveBeenCalledTimes(1); + expect(container.querySelector('[data-language="text"]')).toBe(codeBlock); + expect(codeBlock.dataset.wrap).toBe(String(!initialWrap)); + expect(container.querySelector("[data-markdown-details]")).toBe(details); + expect(details.dataset.markdownDetailsOpen).toBe("true"); + await rerender( + , + ); + const copy = [ + ...buttonsLabelled(container, "Copied"), + ...buttonsLabelled(container, "Copy code"), + ][0]!; + await act(async () => copy.click()); + expect(writeText).toHaveBeenLastCalledWith("Updated code block\n"); + expect(highlight).toHaveBeenCalledTimes(2); + }); + + it("edits the current task text and marker after reusing a renderer", async () => { + let editedText: string | undefined; + let rerender: (next: ReactElement) => Promise = async () => {}; + const message = (text: string): ReactElement => ( + { + editedText = setMarkdownTaskChecked(text, markerOffset, checked); + void rerender(message(editedText)); + }} + /> + ); + const rendered = await render(message("- [ ] First\n- [ ] Second")); + rerender = rendered.rerender; + const { container } = rendered; + const checkboxes = () => container.querySelectorAll('input[type="checkbox"]'); + const original = checkboxes()[1]!; + await rerender(message("- [ ] A longer first task\n- [ ] Second")); + expect(checkboxes()[1]).toBe(original); + + await act(async () => original.click()); + expect(editedText).toBe("- [ ] A longer first task\n- [x] Second"); + await vi.waitFor(() => expect(checkboxes()[1]!.checked).toBe(true)); + }); +}); + +describe("ChatMarkdown skill chips", () => { + it("updates digit-leading skill labels when discovered skills change", async () => { + const text = "Use $2spec with a $20k budget."; + const { container, rerender } = await render(); + expect(container.textContent).not.toContain("2Spec"); + + await rerender( + , + ); + expect(container.textContent).toContain("2Spec"); + expect(container.textContent).toContain("$20k budget"); + expect(container.textContent).not.toContain("MoneySkill"); + + await rerender(); + expect(container.textContent).not.toContain("2Spec"); + }); +}); + +describe("ChatMarkdown file citations", () => { + it.each([true, false])( + "renders Codex file citations as file chips with parseRawHtml=%s", + async (parseRawHtml) => { + const container = await renderText( + 'Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx" purpose="output"}.', + { lineBreaks: !parseRawHtml, parseRawHtml }, + ); + expect(container.textContent).not.toContain("codex-file-citation"); + expect(fileOptions(container)).toEqual(["File options for report.xlsx"]); + }, + ); + + it("leaves an unfinished streaming citation visible until it is complete", async () => { + const container = await renderText( + 'Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx"', + { isStreaming: true }, + ); + expect(container.textContent).toContain(":codex-file-citation"); + expect(fileOptions(container)).toEqual([]); + }); + + it.each([ + ':codex-file-citation{purpose="output"}', + ':codex-file-citation-extra{path="/tmp/project/outputs/report.xlsx"}', + 'Example: \\:codex-file-citation{path="/tmp/project/outputs/report.xlsx"}', + ])("leaves %s literal", async (text) => { + const container = await renderText(text); + expect(container.textContent).toContain(text.replace("\\", "")); + expect(fileOptions(container)).toEqual([]); + }); + + it("preserves Codex file citation examples inside code", async () => { + const directive = ':codex-file-citation{path="/tmp/project/outputs/report.xlsx"}'; + const container = await renderText( + `Example: \`${directive}\`\n\n\`\`\`text\n${directive}\n\`\`\``, + ); + expect(container.textContent?.match(/:codex-file-citation/g)).toHaveLength(2); + expect(fileOptions(container)).toEqual([]); + }); + + it("does not create a nested link for citations inside link text", async () => { + const directive = ':codex-file-citation{path="/tmp/project/outputs/report.xlsx"}'; + const container = await renderText(`[See ${directive}](https://example.com)`); + expect(container.textContent).toContain("codex-file-citation"); + expect(fileOptions(container)).toEqual([]); + }); + + it("renders file citations created by over-indented list recovery", async () => { + const container = await renderText( + '- Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx"}', + ); + expect(container.querySelector("pre")).toBeNull(); + expect(container.querySelector("li")?.textContent).toContain("Created "); + expect(fileOptions(container)).toEqual(["File options for report.xlsx"]); + }); + + it("disambiguates Codex citations with the same basename", async () => { + const container = await renderText( + 'Changed :codex-file-citation{path="/tmp/project/src/index.ts"} and :codex-file-citation{path="/tmp/project/test/index.ts"}.', + ); + expect(container.textContent).toContain("index.ts · project/src"); + expect(container.textContent).toContain("index.ts · project/test"); + }); + + it("preserves rejected citations created by over-indented list recovery", async () => { + const malformed = await renderText( + 'Leading text before list.\n\n- Bad :codex-file-citation{purpose="output"}', + ); + expect(malformed.querySelector("li")?.textContent).toBe( + 'Bad :codex-file-citation{purpose="output"}', + ); + const nestedLink = await renderText( + 'Leading text before list.\n\n- [Bad :codex-file-citation{path="/tmp/project/report.xlsx"}](https://example.com)', + ); + expect(nestedLink.textContent).toContain( + 'Bad :codex-file-citation{path="/tmp/project/report.xlsx"}', + ); + }); +}); + +describe("ChatMarkdown Codex follow-up suggestions", () => { + it.each([true, false])("renders the label with parseRawHtml=%s", async (parseRawHtml) => { + const container = await renderText( + 'Done.\n\n- :codex-followup[Prepare print version]{prompt="Prepare the document for printing."}', + { lineBreaks: !parseRawHtml, parseRawHtml }, + ); + expect(container.textContent).toContain("Prepare print version"); + expect(container.textContent).not.toContain("codex-followup"); + expect(container.textContent).not.toContain("Prepare the document for printing."); + }); + + it("keeps the label of a malformed follow-up created by over-indented list recovery", async () => { + const container = await renderText("- Next :codex-followup[Prepare print version]"); + expect(container.querySelector("li")?.textContent).toBe( + "Next :codex-followup[Prepare print version]", + ); + }); +}); + +const ARTIFACT_TEMPLATE_DIRECTIVE = + '::artifact-template{skill_name="artifact-template-hello-world" skill_directory="/Users/test/.codex/skills/artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}'; + +describe("ChatMarkdown artifact-template cards", () => { + it.each([true, false])( + "offers the Codex template from its card with parseRawHtml=%s", + async (parseRawHtml) => { + const onUseArtifactTemplate = vi.fn(); + const container = await renderText(ARTIFACT_TEMPLATE_DIRECTIVE, { + parseRawHtml, + onUseArtifactTemplate, + }); + expect(container.textContent).not.toContain("::artifact-template"); + expect(container.textContent).toContain("Hello World"); + expect(container.textContent).toContain("Document template"); + expect(container.querySelector("p div")).toBeNull(); + const use = [...container.querySelectorAll("button")].find( + (button) => button.textContent === "Use template", + )!; + await act(async () => use.click()); + expect(onUseArtifactTemplate).toHaveBeenCalledWith( + expect.objectContaining({ + skillName: "artifact-template-hello-world", + displayName: "Hello World", + }), + ); + }, + ); + + it("renders a passive card outside a composer-backed timeline", async () => { + const container = await renderText(ARTIFACT_TEMPLATE_DIRECTIVE); + expect(container.textContent).toContain("Hello World"); + expect(container.textContent).not.toContain("Use template"); + }); + + it.each([ + '::artifact-template{skill_name="artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}', + ARTIFACT_TEMPLATE_DIRECTIVE.slice(0, -1), + `\\${ARTIFACT_TEMPLATE_DIRECTIVE}`, + ARTIFACT_TEMPLATE_DIRECTIVE.replace("::artifact-template", "::artifact-template-extra"), + ])("leaves %s literal", async (text) => { + const container = await renderText(text); + expect(container.textContent).toContain("::artifact-template"); + expect(container.textContent).not.toContain("Document template"); + }); + + it("preserves artifact-template examples inside code", async () => { + const container = await renderText( + `\`${ARTIFACT_TEMPLATE_DIRECTIVE}\`\n\n\`\`\`text\n${ARTIFACT_TEMPLATE_DIRECTIVE}\n\`\`\``, + ); + expect(container.textContent?.match(/::artifact-template/g)).toHaveLength(2); + expect(container.textContent).not.toContain("Document template"); + }); +}); + +describe("ChatMarkdown headings", () => { + it("exposes headings below the host heading without changing their tags", async () => { + const container = await renderText("# Top\n\n## Section\n\n###### Fine print", { + headingLevelOffset: 3, + }); + expect( + [...container.querySelectorAll("h1, h2, h6")].map((heading) => [ + heading.tagName, + heading.getAttribute("aria-level"), + ]), + ).toEqual([ + ["H1", "4"], + ["H2", "5"], + ["H6", "6"], + ]); + const flat = await renderText("# Top"); + expect(flat.querySelector("h1")?.hasAttribute("aria-level")).toBe(false); + }); + + it("never gives two headings the same id, even when a suffix matches another heading", async () => { + const container = await renderText( + '## Setup\n\n## Setup\n\n## Setup-1\n\n

Pinned

\n\n## Install\n\n## Install', + { parseRawHtml: true }, + ); + expect([...container.querySelectorAll("h2")].map((heading) => heading.id)).toEqual([ + "user-content-setup", + "user-content-setup-1", + "user-content-setup-1-1", + "user-content-install-1", + "user-content-install", + "user-content-install-2", + ]); + }); +}); + +describe("ChatMarkdown Windows file links", () => { + const environmentId = EnvironmentId.make("env-windows"); + const renderWindows = (text: string, parseRawHtml: boolean) => + renderText(text, { + cwd: "C:/Users/shawn/project", + environmentId, + lineBreaks: !parseRawHtml, + parseRawHtml, + }); + const fileHrefs = (container: HTMLElement) => + [...container.querySelectorAll(".chat-markdown-file-link")].map((chip) => + chip.getAttribute("href"), + ); + + it.each([ + ["drive paths", "[Open](C:/Users/shawn/project/src/main.ts)"], + ["backslashes", String.raw`[Open](C:\Users\shawn\project\src\main.ts)`], + ["reference links", "[Open][source]\n\n[source]: C:/Users/shawn/project/src/main.ts"], + ])("resolves %s to the workspace file", async (_, text) => { + for (const parseRawHtml of [true, false]) { + const container = await renderWindows(text, parseRawHtml); + expect(fileHrefs(container)).toEqual(["C:/Users/shawn/project/src/main.ts"]); + } + }); + + it("keeps backslashes CommonMark would read as escapes", async () => { + for (const parseRawHtml of [true, false]) { + const container = await renderWindows( + String.raw`[settings](C:\Users\shawn\.claude\settings.json)`, + parseRawHtml, + ); + expect(fileHrefs(container)).toEqual(["C:/Users/shawn/.claude/settings.json"]); + } + }); + + it("distinguishes same-named backslash paths but not the same file twice", async () => { + for (const parseRawHtml of [true, false]) { + const distinct = await renderWindows( + String.raw`[Source](C:\Users\shawn\project\src\index.ts) and [Test](C:\Users\shawn\project\test\index.ts)`, + parseRawHtml, + ); + expect(distinct.textContent).toContain("index.ts · project/src"); + expect(distinct.textContent).toContain("index.ts · project/test"); + const path = String.raw`C:\Users\shawn\project\src\main.ts`; + const same = await renderWindows(`[Source](${path}) and \`${path}\``, parseRawHtml); + expect(fileHrefs(same)).toHaveLength(2); + expect(same.textContent).not.toContain("main.ts ·"); + } + }); + + it("still rejects unsafe schemes", async () => { + for (const parseRawHtml of [true, false]) { + const container = await renderWindows( + "[unsafe](javascript:alert(1)) and [unknown](d:alert(1))", + parseRawHtml, + ); + expect(container.innerHTML).not.toContain("javascript:"); + expect(container.innerHTML).not.toContain("d:alert"); + expect(fileHrefs(container)).toEqual([]); + } + }); +}); + +describe("ChatMarkdown in-page links", () => { + it.each([true, false])( + "scrolls a table-of-contents link to its heading without touching the URL (parseRawHtml=%s)", + async (parseRawHtml) => { + const scrollIntoView = vi + .spyOn(HTMLElement.prototype, "scrollIntoView") + .mockImplementation(() => {}); + const href = window.location.href; + const container = await renderText( + "- [Operating model](#1-operating-model)\n- [Missing](#nowhere)\n\n## 1. Operating model\n\n## 1. Operating model", + { parseRawHtml }, + ); + const headings = [...container.querySelectorAll("h2")]; + expect(headings.map((heading) => heading.id)).toEqual([ + "user-content-1-operating-model", + "user-content-1-operating-model-1", + ]); + const [tocLink, missingLink] = [...container.querySelectorAll("a")]; + await act(async () => tocLink!.click()); + expect(scrollIntoView.mock.contexts).toEqual([headings[0]]); + await act(async () => missingLink!.click()); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(window.location.href).toBe(href); + }, + ); +}); + +describe("ChatMarkdown thread find", () => { + function FindProbe(props: { + container: HTMLElement; + query: string; + activeOccurrence?: number; + children: ReactElement; + }) { + useThreadFindHighlights({ + container: props.container, + query: props.query, + activeRowId: "row", + activeOccurrence: props.activeOccurrence ?? 0, + onActiveRange: () => {}, + }); + return ( +
+
+ {props.children} +
+
+ ); + } + const frame = () => + act(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); + const activeHighlights = () => + [...(CSS.highlights.get("t3-thread-find-active") ?? [])].map((range) => + (range as Range).toString(), + ); + + it("opens a disclosure only when find selects a match inside it", async () => { + const container = document.body.appendChild(document.createElement("div")); + const root = createRoot(container); + roots.push(root); + const markdown = ( + Unrelated

nothing here

", + "
Outer
Inner

needle

", + ].join("\n\n")} + /> + ); + const show = (query: string, activeOccurrence: number) => + act(() => + root.render( + + {markdown} + , + ), + ); + const openStates = () => + [...container.querySelectorAll("[data-markdown-details-open]")].map( + (node) => node.dataset.markdownDetailsOpen, + ); + + await show("", 0); + expect(container.textContent).not.toContain("nothing here"); + await show("needle", 0); + await frame(); + expect(openStates()).toEqual(["false", "false", "false"]); + expect(container.textContent).toContain("nothing here"); + expect(activeHighlights()).toEqual(["needle"]); + expect(CSS.highlights.get("t3-thread-find")?.size ?? 0).toBe(0); + + await show("needle", 1); + await frame(); + await frame(); + expect(openStates()).toEqual(["false", "true", "true"]); + expect(CSS.highlights.get("t3-thread-find-active")?.size).toBe(1); + }); + + it("keeps Mermaid diagrams rendered until find selects a match in their source", async () => { + const container = document.body.appendChild(document.createElement("div")); + const root = createRoot(container); + roots.push(root); + const diagram = "```mermaid\ngraph TD; Alpha-->Beta\n```"; + const show = (query: string) => + act(() => + root.render( + + + , + ), + ); + const diagrams = () => container.querySelectorAll('svg[aria-label="Diagram"]').length; + + await show("Needle"); + await frame(); + expect(diagrams()).toBe(2); + await show("Alpha"); + await frame(); + await frame(); + expect(diagrams()).toBe(1); + await vi.waitFor(() => expect(activeHighlights()).toEqual(["Alpha"])); + }); + + it.each([ + { + text: "```mermaid\ngraph TD; SearchSourceAlpha-->B\n```", + query: "SearchSourceAlpha", + count: 1, + }, + { + text: "★ Insight ─────\nfirst line\nsecond line", + query: "first line second", + count: 0, + lineBreaks: true, + }, + { text: '```ts title="src/needle.ts"\nconst a = 1;\n```', query: "needle", count: 0 }, + { text: "```weirdlang\nconst a = 1;\n```", query: "weirdlang", count: 0 }, + { text: "Use $test-t3-app now", query: "T3 App Testing", count: 1 }, + { text: "`/tmp/file.ts:42`", query: "file.ts · L42", count: 1, user: true, lineBreaks: true }, + { text: "> [!NOTE]\n> Searchable alert", query: "Searchable alert", count: 1 }, + { + text: "
Folded

Hidden needle

", + query: "Hidden needle", + count: 1, + }, + { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "Hello World", count: 1, withTemplate: true }, + { + text: ARTIFACT_TEMPLATE_DIRECTIVE, + query: "Document template", + count: 1, + withTemplate: true, + }, + { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "World Document", count: 0, withTemplate: true }, + { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "Use template", count: 0, withTemplate: true }, + ])( + "highlights the indexed occurrences of $query in $text", + async ({ text, query, count, lineBreaks, user, withTemplate }) => { + const skills = [{ name: "test-t3-app", displayName: "T3 App Testing" }]; + const container = document.body.appendChild(document.createElement("div")); + const root = createRoot(container); + roots.push(root); + await act(() => + root.render( + + undefined : undefined} + /> + , + ), + ); + await frame(); + const ranges = [...CSS.highlights.values()].flatMap((value) => [...value]); + expect(ranges.map((range) => (range as Range).toString())).toEqual( + Array.from({ length: count }, () => query), + ); + const segments = + searchableMessageSegments( + { role: user ? "user" : "assistant", text, streaming: false }, + undefined, + skills, + ) ?? []; + expect( + segments.reduce((sum, segment) => sum + countThreadSearchOccurrences(segment, query), 0), + ).toBe(count); + }, + ); +}); diff --git a/apps/web/src/components/ChatMarkdown.logic.test.ts b/apps/web/src/components/ChatMarkdown.logic.test.ts new file mode 100644 index 000000000000..e93edec03746 --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.logic.test.ts @@ -0,0 +1,55 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + canUseMarkdownFileShellActions, + shouldUseMarkdownFileBrowserPrimaryAction, +} from "./ChatMarkdown"; + +describe("canUseMarkdownFileShellActions", () => { + const environmentId = EnvironmentId.make("environment-1"); + + it("allows editor and file manager actions only for resolved local environments", () => { + expect(canUseMarkdownFileShellActions(environmentId, "local-exec", true)).toBe(true); + expect(canUseMarkdownFileShellActions(environmentId, "local-exec", false)).toBe(false); + expect(canUseMarkdownFileShellActions(environmentId, "remote-links", true)).toBe(false); + expect(canUseMarkdownFileShellActions(environmentId, "remote-unavailable", true)).toBe(false); + expect(canUseMarkdownFileShellActions(null, "local-exec", true)).toBe(false); + }); +}); + +describe("shouldUseMarkdownFileBrowserPrimaryAction", () => { + const html = { iconPath: "/tmp/report.html", canOpenInBrowser: true }; + + it("uses the browser only when it is the sole action, except for PDFs", () => { + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + ...html, + canOpenInEditor: false, + canOpenInPanel: false, + }), + ).toBe(true); + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + ...html, + canOpenInEditor: true, + canOpenInPanel: false, + }), + ).toBe(false); + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + ...html, + canOpenInEditor: false, + canOpenInPanel: true, + }), + ).toBe(false); + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath: "/tmp/report.pdf", + canOpenInEditor: true, + canOpenInBrowser: true, + canOpenInPanel: true, + }), + ).toBe(true); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.permissions.test.tsx b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx similarity index 60% rename from apps/web/src/components/ChatMarkdown.permissions.test.tsx rename to apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx index bc40e47c1f0c..51d380a4e354 100644 --- a/apps/web/src/components/ChatMarkdown.permissions.test.tsx +++ b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx @@ -7,12 +7,13 @@ import { type EditorId, type ThreadLinkedPullRequest, } from "@t3tools/contracts"; -import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import { AsyncResult } from "effect/reactivity"; -import { cloneElement, type ReactElement, type ReactNode } from "react"; -import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { useRightPanelStore } from "../rightPanelStore"; + const state = vi.hoisted(() => ({ allowed: true, listeners: new Set<() => void>(), @@ -23,48 +24,30 @@ const state = vi.hoisted(() => ({ openFile: vi.fn(), openExternal: vi.fn(), copy: vi.fn(), - toast: vi.fn(), linkedPullRequest: null as ThreadLinkedPullRequest | null, })); -vi.mock("@effect/atom-react", async () => { - const { AtomRegistry } = await import("effect/reactivity"); - const registry = AtomRegistry.make(); - return { useAtomValue: (atom: never) => registry.get(atom) }; -}); -vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); -vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => undefined })); -vi.mock("../hooks/useSettings", () => ({ - getClientSettings: () => DEFAULT_CLIENT_SETTINGS, - useClientSettings: (select?: (value: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => - select ? select(DEFAULT_CLIENT_SETTINGS) : DEFAULT_CLIENT_SETTINGS, -})); -vi.mock("./ui/tooltip", () => ({ - Tooltip: ({ children }: { children: ReactNode }) => children, - TooltipTrigger: ({ render, children }: { render: ReactElement; children?: ReactNode }) => - children === undefined ? render : cloneElement(render, undefined, children), - TooltipPopup: () => null, -})); -vi.mock("./chat/PierreEntryIcon", () => ({ PierreEntryIcon: () => null })); -vi.mock("./ui/toast", () => ({ - toastManager: { add: state.toast }, - stackedThreadToast: (value: unknown) => value, -})); -vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.search })); -vi.mock("../state/use-atom-command", () => ({ - useAtomCommand: (command: string) => - command === "updateMetadata" ? state.updateMetadata : state.openEditor, +vi.mock(import("../state/use-atom-query-runner"), async (original) => ({ + ...(await original()), + useAtomQueryRunner: (() => state.search) as never, })); -vi.mock("../state/session", async () => { +vi.mock(import("../state/use-atom-command"), async (original) => { + const { threadEnvironment } = await import("../state/threads"); + return { + ...(await original()), + useAtomCommand: ((command: unknown) => + command === threadEnvironment.updateMetadata + ? state.updateMetadata + : state.openEditor) as never, + }; +}); +vi.mock(import("../state/session"), async (original) => { const { useSyncExternalStore } = await import("react"); const readEnvironmentScope = (id: EnvironmentId | null, scope: AuthEnvironmentScope) => id !== null && (scope !== AuthOrchestrationOperateScope || id !== threadRef.environmentId || state.allowed); return { - environmentSession: { - sessionStateAtom: () => ({}), - preparedConnectionValueAtom: () => ({}), - }, + ...(await original()), readEnvironmentScope, useEnvironmentScope: (id: EnvironmentId | null, scope: AuthEnvironmentScope) => useSyncExternalStore( @@ -76,59 +59,57 @@ vi.mock("../state/session", async () => { ), }; }); -vi.mock("../state/server", async () => { +// Atoms derived from the server config run in the real registry over this config. +vi.mock(import("../state/server"), async (original) => { + const actual = await original(); const { Atom } = await import("effect/reactivity"); const config = Atom.make(() => serverConfig); return { - serverEnvironment: { configValueAtom: () => config }, + ...actual, + serverEnvironment: { ...actual.serverEnvironment, configValueAtom: (() => config) as never }, environmentServerConfigsAtom: Atom.make( () => new Map([[threadRef.environmentId, serverConfig]]), - ), + ) as never, }; }); -vi.mock("../state/threads", () => ({ - threadEnvironment: { updateMetadata: "updateMetadata" }, -})); -vi.mock("../state/entities", () => ({ - readEnvironmentSupportsServerBrowser: () => false, - readThreadShell: () => ({ linkedPullRequest: state.linkedPullRequest }), - useProjects: () => [{ id: linkedPullRequest.projectId, environmentId: threadRef.environmentId }], +vi.mock(import("../state/entities"), async (original) => ({ + ...(await original()), + readThreadShell: (() => ({ linkedPullRequest: state.linkedPullRequest })) as never, + useProjects: (() => [ + { id: linkedPullRequest.projectId, environmentId: threadRef.environmentId }, + ]) as never, })); -vi.mock("../rightPanelStore", () => ({ - useRightPanelStore: { getState: () => ({ openFile: state.openFile }) }, +vi.mock(import("../remoteOpen"), async (original) => ({ + ...(await original()), + useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }) as never, })); -vi.mock("../remoteOpen", () => ({ - useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), +vi.mock(import("../hooks/useLocalStorage"), async (original) => ({ + ...(await original()), + useLocalStorage: (() => ["vscode", vi.fn()]) as never, + getLocalStorageItem: (() => "vscode") as never, })); -vi.mock("../hooks/useLocalStorage", () => ({ - useLocalStorage: () => ["vscode", vi.fn()], - getLocalStorageItem: () => "vscode", - setLocalStorageItem: vi.fn(), +vi.mock(import("../localApi"), async (original) => ({ + ...(await original()), + readLocalApi: () => + ({ + contextMenu: { show: state.choose }, + shell: { openExternal: state.openExternal }, + }) as never, })); -vi.mock("../hooks/useCopyToClipboard", () => ({ - writeTextToClipboard: state.copy, - useCopyToClipboard: () => ({ copyToClipboard: state.copy, isCopied: false }), -})); -vi.mock("../localApi", () => ({ - readLocalApi: () => ({ - contextMenu: { show: state.choose }, - shell: { openExternal: state.openExternal }, - }), -})); -vi.mock("~/lib/openPullRequestLink", () => ({ +vi.mock(import("~/lib/openPullRequestLink"), async (original) => ({ + ...(await original()), resolvePullRequestPreviewTarget: () => null, - findProjectForChangeRequest: (projects: readonly { id: ProjectId }[]) => projects[0], + findProjectForChangeRequest: ((projects: readonly { id: ProjectId }[]) => projects[0]) as never, matchesLinkedPullRequestUrl: (candidate: ThreadLinkedPullRequest, href: string) => candidate.url === href, - parseChangeRequestUrl: (href: string) => + parseChangeRequestUrl: ((href: string) => href === linkedPullRequest.url ? { host: "github.com", repository: linkedPullRequest.repository, number: linkedPullRequest.number, } - : null, - useOpenChangeRequestLink: () => vi.fn(), + : null) as never, })); import ChatMarkdown from "./ChatMarkdown"; @@ -149,7 +130,8 @@ const serverConfig = { shellRevealInFileManagerKind: "xdg-open", environment: { platform: { os: "linux" }, capabilities: { threadPullRequestLinking: true } }, }; -let renderer: ReactTestRenderer | undefined; +const roots: Root[] = []; +let container: HTMLDivElement; beforeEach(() => { state.allowed = true; @@ -162,53 +144,60 @@ beforeEach(() => { state.openFile.mockReset(); state.openExternal.mockReset().mockResolvedValue(undefined); state.copy.mockReset().mockResolvedValue(undefined); - state.toast.mockReset(); + useRightPanelStore.setState({ openFile: state.openFile }); + vi.spyOn(navigator.clipboard, "writeText").mockImplementation(state.copy); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("window", {}); - vi.stubGlobal("navigator", { clipboard: { writeText: state.copy } }); }); afterEach(async () => { - await act(async () => renderer?.unmount()); - renderer = undefined; + for (const root of roots.splice(0)) await act(async () => root.unmount()); + document.body.replaceChildren(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); async function renderMarkdown(text: string) { - await act(async () => { - renderer = create(); - }); -} - -function menuEvent() { - return { preventDefault: vi.fn(), stopPropagation: vi.fn(), clientX: 10, clientY: 10 }; + container = document.body.appendChild(document.createElement("div")); + const root = createRoot(container); + roots.push(root); + await act(async () => + root.render(), + ); } function anchor() { - return renderer!.root.findByType("a"); + return container.querySelector("a")!; } async function openContextMenu() { - await act(async () => anchor().props.onContextMenu(menuEvent())); + await act(async () => { + anchor().dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }), + ); + }); } function offeredActions() { return state.choose.mock.lastCall![0].map((item: { id: string }) => item.id); } +async function setAllowed(allowed: boolean) { + await act(async () => { + state.allowed = allowed; + for (const listener of state.listeners) listener(); + }); +} + it("removes host actions after revocation while keeping file preview and copying", async () => { await renderMarkdown("[Readme](docs/readme.md)"); await openContextMenu(); expect(offeredActions()).toEqual(["open", "reveal", "copy-relative", "copy-full"]); - await act(async () => { - state.allowed = false; - for (const listener of state.listeners) listener(); - }); + await setAllowed(false); state.choose.mockResolvedValue("copy-full"); await openContextMenu(); expect(offeredActions()).toEqual(["copy-relative", "copy-full"]); - expect(state.copy).toHaveBeenCalledWith("/work/docs/readme.md"); - await act(async () => anchor().props.onClick(menuEvent())); + await vi.waitFor(() => expect(state.copy).toHaveBeenCalledWith("/work/docs/readme.md")); + await act(async () => anchor().click()); expect(state.openFile).toHaveBeenCalledWith(threadRef, "docs/readme.md", undefined); expect(state.openEditor).not.toHaveBeenCalled(); }); @@ -263,7 +252,7 @@ it.each([false, true])( await renderMarkdown(`[Review](${linkedPullRequest.url})`); await openContextMenu(); expect(offeredActions()).toEqual(["open-external", "copy-link"]); - expect(state.openExternal).toHaveBeenCalledWith(linkedPullRequest.url); + await vi.waitFor(() => expect(state.openExternal).toHaveBeenCalledWith(linkedPullRequest.url)); expect(state.updateMetadata).not.toHaveBeenCalled(); }, ); @@ -290,18 +279,17 @@ it.each(["link-to-thread", "unlink-from-thread"])( }); expect(state.updateMetadata).not.toHaveBeenCalled(); - await act(async () => { - state.allowed = true; - for (const listener of state.listeners) listener(); - }); + await setAllowed(true); state.choose.mockResolvedValue(action); await openContextMenu(); - expect(state.updateMetadata).toHaveBeenCalledExactlyOnceWith({ - environmentId: threadRef.environmentId, - input: { - threadId: threadRef.threadId, - linkedPullRequest: action === "link-to-thread" ? linkedPullRequest : null, - }, - }); + await vi.waitFor(() => + expect(state.updateMetadata).toHaveBeenCalledExactlyOnceWith({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + linkedPullRequest: action === "link-to-thread" ? linkedPullRequest : null, + }, + }), + ); }, ); diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx deleted file mode 100644 index adde7ac27bd5..000000000000 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ /dev/null @@ -1,1391 +0,0 @@ -// @vitest-environment jsdom - -import { EnvironmentId, type AuthEnvironmentScope } from "@t3tools/contracts"; -import { createRoot } from "react-dom/client"; -import { useThreadFindHighlights } from "./chat/threadFindHighlights"; -import { searchableMessageSegments } from "@t3tools/shared/threadFindText"; -import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; - -import { MarkdownFindContext } from "./chat/markdownFindContext"; -import { act, type ComponentProps, type ReactNode } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { create, type ReactTestRenderer } from "react-test-renderer"; -import { describe, expect, it, vi } from "vite-plus/test"; - -import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; -import { GitHubIcon } from "./Icons"; -import { Button } from "./ui/button"; -import { setMarkdownTaskChecked } from "./files/filePreviewMode"; - -vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => null })); -// Atoms derived from the server config run in a real registry over a missing -// config; every other subscription reads nothing. -vi.mock("@effect/atom-react", async () => { - const { AtomRegistry } = await import("effect/reactivity"); - const registry = AtomRegistry.make(); - const derived = new Set(["markdown-server-config-fields", "pull-request-link-mode"]); - return { - useAtomValue: (atom: { readonly label?: readonly [string, ...unknown[]] }) => - derived.has(atom.label?.[0] ?? "") ? registry.get(atom as never) : null, - }; -}); -vi.mock("../state/server", async () => { - const { Atom } = await import("effect/reactivity"); - const noConfig = Atom.make(null); - return { - serverEnvironment: { configValueAtom: () => noConfig }, - environmentServerConfigsAtom: Atom.make(new Map()), - }; -}); -vi.mock("./chat/MermaidDiagram", () => ({ - // Real Mermaid needs layout APIs jsdom lacks; a rendered diagram is an SVG. - MermaidDiagram: () => , -})); -vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); -vi.mock("../hooks/useSettings", async (importOriginal) => { - const actual = await importOriginal(); - const settings = actual.getClientSettings(); - return { - ...actual, - useClientSettings: (select?: (value: typeof settings) => unknown) => - select ? select(settings) : settings, - }; -}); -vi.mock("./ui/tooltip", async () => { - const { cloneElement, isValidElement } = await import("react"); - return { - Tooltip: ({ children }: { children: ReactNode }) => <>{children}, - TooltipTrigger({ - render, - children, - }: ComponentProps) { - if (!isValidElement(render)) return <>{children}; - return children === undefined ? render : cloneElement(render, undefined, children); - }, - TooltipPopup: () => null, - }; -}); -vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); -vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); -vi.mock("../state/session", async (importOriginal) => { - const actual = await importOriginal(); - const { AuthStandardClientScopes } = await import("@t3tools/contracts"); - const grantedScopes = new Set(AuthStandardClientScopes); - const hasScope = (environmentId: EnvironmentId | null, scope: AuthEnvironmentScope) => - environmentId !== null && grantedScopes.has(scope); - return { - ...actual, - useEnvironmentScope: hasScope, - readEnvironmentScope: hasScope, - }; -}); -vi.mock("../state/entities", () => ({ - readThreadShell: () => null, - useProjects: () => [], - useServerConfigs: () => new Map(), -})); -vi.mock("../remoteOpen", () => ({ - useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), -})); -vi.mock("../editorPreferences", () => ({ - useOpenInPreferredEditor: () => vi.fn(), - usePreferredEditor: () => [null, vi.fn()], -})); -vi.mock("~/lib/openPullRequestLink", () => ({ - findProjectOnChangeRequestHost: () => undefined, - parseChangeRequestUrl: () => null, - resolvePullRequestPreviewTarget: () => null, - useOpenChangeRequestLink: () => vi.fn(), -})); - -import ChatMarkdown, { - canUseMarkdownFileShellActions, - hasMarkdownFilePrimaryAction, - shouldUseMarkdownFileBrowserPrimaryAction, -} from "./ChatMarkdown"; - -function codeButton(renderer: ReactTestRenderer, label: string) { - const button = renderer.root - .findAllByType(Button) - .find((instance) => instance.props["aria-label"] === label); - if (!button) throw new Error(`Missing code button: ${label}`); - return button.props as ComponentProps; -} - -describe("ChatMarkdown bare anchor placeholders", () => { - it.each(["
", "", "", "", "", ""])( - "preserves unmatched %s without linking later blocks", - (token) => { - const text = `- **"From ${token}"** appears in the header.\n\n- **Tests:** cover inheritance.\n\nThe deferred move continues on B.\n\nSee the link.`; - const document = new DOMParser().parseFromString( - renderToStaticMarkup(), - "text/html", - ); - - expect(document.querySelector("strong")?.textContent).toBe(`"From ${token}"`); - expect([...document.querySelectorAll("a")].map((link) => link.textContent)).toEqual([ - "the link", - ]); - expect(document.querySelectorAll("li")).toHaveLength(2); - expect( - [...document.querySelectorAll("p")].map((paragraph) => paragraph.textContent), - ).toContain("The deferred move continues on B."); - }, - ); - - it.each([" ", "
more
\n"])( - "preserves a paired anchor closing in the raw block %s", - (closing) => { - const document = new DOMParser().parseFromString( - renderToStaticMarkup( - label\n\n${closing}\n\nfinish`} />, - ), - "text/html", - ); - expect(document.querySelector("p")?.textContent).toBe("See label"); - }, - ); - - it("preserves a paired anchor after comment-looking raw text", () => { - const document = new DOMParser().parseFromString( - renderToStaticMarkup( - , - ), - "text/html", - ); - expect(document.querySelector("p")?.textContent).toBe("See label -->"); - }); - - it.each(["", '
more
', ''])( - "ignores apparent closing anchors inside %s", - (html) => { - const document = new DOMParser().parseFromString( - renderToStaticMarkup( - .\n\n${html}\n\nAfter.`} />, - ), - "text/html", - ); - expect(document.querySelector("p")?.textContent).toBe("Before ."); - expect(document.querySelectorAll("a")).toHaveLength(0); - }, - ); - - it("preserves paired HTML anchors, details, markdown links, and inline code", () => { - const text = - 'Bare label, , ``, and [docs](https://example.com).\n\n
MoreDetails
'; - const document = new DOMParser().parseFromString( - renderToStaticMarkup(), - "text/html", - ); - - expect([...document.querySelectorAll("a")].map((link) => link.textContent)).toEqual([ - "label", - "", - "docs", - ]); - expect(document.querySelector("code")?.textContent).toBe("
"); - expect(document.querySelector("[data-markdown-details]")?.textContent).toContain("More"); - }); -}); - -describe("ChatMarkdown context references", () => { - it("renders text and image references through the chip renderer, with readable fallback", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - let renderer: ReactTestRenderer | undefined; - const text = - "See [Terminal output](t3-context://v1/terminal/term-1) and ![Error image](t3-context://v1/image/img-1)."; - try { - await act(async () => { - renderer = create( - ( - - )} - />, - ); - }); - expect( - renderer!.root.findAllByType("button").map((button) => button.children.join("")), - ).toEqual(["terminal: Terminal output", "image: Error image"]); - expect(renderer!.root.findAllByType("img")).toHaveLength(0); - expect(renderer!.root.findAllByType("a")).toHaveLength(0); - await act(async () => { - renderer!.update(); - }); - expect(renderer!.root.findAllByType("span").map((span) => span.children.join(""))).toEqual([ - "Terminal output", - "Error image", - ]); - expect(renderer!.root.findAllByType("img")).toHaveLength(0); - } finally { - await act(async () => { - renderer?.unmount(); - }); - vi.unstubAllGlobals(); - } - }); - - it("reads formatted context labels through nested markup instead of the context id", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - let renderer: ReactTestRenderer | undefined; - const seen: Array = []; - try { - await act(async () => { - renderer = create( - { - seen.push(`${kind}: ${label}`); - return ; - }} - />, - ); - }); - expect(seen).toEqual(["terminal: Bold code"]); - } finally { - await act(async () => { - renderer?.unmount(); - }); - vi.unstubAllGlobals(); - } - }); -}); - -describe("ChatMarkdown favicon privacy", () => { - it("suppresses private link images while preserving public links across updates", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - let renderer: ReactTestRenderer | undefined; - const markdown = (url: string) => ; - try { - await act(async () => { - renderer = create(markdown("https://example.com")); - }); - expect(renderer!.root.findAllByType("img").map((image) => image.props.src)).toEqual([ - "https://www.google.com/s2/favicons?domain=example.com&sz=32", - ]); - for (const url of ["http://192.168.1.10:8080", "http://localhost:3000", "http://home.arpa"]) { - await act(async () => { - renderer!.update(markdown(url)); - }); - expect(renderer!.root.findAllByType("img")).toHaveLength(0); - } - await act(async () => { - renderer!.update(markdown("https://example.com")); - }); - expect(renderer!.root.findAllByType("img")).toHaveLength(1); - await act(async () => { - renderer!.update(markdown("https://github.com/pingdotgg/t3code/pull/1")); - }); - expect(renderer!.root.findAllByType("img")).toHaveLength(0); - expect(renderer!.root.findAllByType(GitHubIcon)).toHaveLength(1); - } finally { - await act(async () => { - renderer?.unmount(); - }); - vi.unstubAllGlobals(); - } - }); -}); - -describe("ChatMarkdown streaming", () => { - it("runs only a complete single-line shell block after a click", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - const onRunShellCommand = vi.fn(); - let renderer: ReactTestRenderer | undefined; - const message = (text: string, isStreaming = false) => ( - - ); - try { - await act(async () => { - renderer = create(message("```bash\necho hello\n```", true)); - }); - const mounted = renderer!; - expect( - mounted.root - .findAllByType(Button) - .some((button) => button.props["aria-label"] === "Run in terminal"), - ).toBe(false); - - await act(async () => { - mounted.update(message("```bash\necho hello\n```")); - }); - await act(async () => { - codeButton(mounted, "Run in terminal").onClick?.({} as never); - }); - expect(onRunShellCommand).toHaveBeenCalledExactlyOnceWith("echo hello"); - - for (const text of [ - "~~~bash\necho tilde\n~~~", - "> ```bash\n> echo quote\n> ```", - "````bash\necho four\n````", - ]) { - await act(async () => { - mounted.update(message(text)); - }); - expect(codeButton(mounted, "Run in terminal")).toBeDefined(); - } - - for (const text of [ - "```bash\necho one\necho two\n```", - "```typescript\necho hello\n```", - "```bash\n\n```", - "```bash\necho hello\n\n```", - "```bash\necho hello\\\n```", - "```bash\necho safe \u202e#\n```", - "```bash\necho incomplete", - "~~~bash\necho incomplete", - "````bash\necho incomplete\n```", - '
echo html
', - ]) { - await act(async () => { - mounted.update(message(text)); - }); - expect( - mounted.root - .findAllByType(Button) - .some((button) => button.props["aria-label"] === "Run in terminal"), - ).toBe(false); - } - } finally { - await act(async () => renderer?.unmount()); - vi.unstubAllGlobals(); - } - }); - - it("does not retokenize completed lines when streaming finishes", async () => { - const highlighter = await getSyntaxHighlighterPromise("typescript"); - const highlight = vi.spyOn(highlighter, "codeToHast"); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - let renderer: ReactTestRenderer | undefined; - const text = "```typescript\nconst completed = 1;\nconst current = 2;"; - try { - await act(async () => { - renderer = create(); - }); - expect(highlight).toHaveBeenCalled(); - highlight.mockClear(); - await act(async () => { - renderer!.update(); - }); - expect(highlight.mock.calls.every(([code]) => !code.includes("const completed"))).toBe(true); - } finally { - await act(async () => renderer?.unmount()); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - } - }); - - it("recovers highlighting after a failed fence changes without resetting its controls", async () => { - const highlighter = await getSyntaxHighlighterPromise("text"); - const codeToHast = highlighter.codeToHast.bind(highlighter); - let fail = true; - vi.spyOn(highlighter, "codeToHast").mockImplementation((...args) => { - if (fail) throw new Error("Temporary highlighter failure"); - return codeToHast(...args); - }); - vi.spyOn(console, "error").mockImplementation(() => {}); - vi.spyOn(console, "warn").mockImplementation(() => {}); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - let renderer: ReactTestRenderer | undefined; - - try { - await act(async () => { - renderer = create( - , - ); - }); - const mounted = renderer!; - const codeBlock = mounted.root.findByProps({ "data-language": "text" }); - const initialWrap = codeBlock.props["data-wrap"] === "true"; - const wrap = codeButton(mounted, initialWrap ? "Disable line wrap" : "Wrap lines"); - await act(async () => { - wrap.onClick?.({} as Parameters>[0]); - }); - expect(mounted.root.findAllByProps({ className: "chat-markdown-shiki" })).toHaveLength(0); - - fail = false; - await act(async () => { - mounted.update( - , - ); - }); - expect(mounted.root.findAllByProps({ className: "chat-markdown-shiki" })).toHaveLength(1); - expect(mounted.root.findByProps({ "data-language": "text" })).toBe(codeBlock); - expect(codeBlock.props["data-wrap"]).toBe(String(!initialWrap)); - } finally { - await act(async () => renderer?.unmount()); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - } - }); - - it("preserves code controls and details without highlighting an unchanged fence again", async () => { - const highlighter = await getSyntaxHighlighterPromise("text"); - const highlight = vi.spyOn(highlighter, "codeToHast"); - const writeText = vi.fn(async (_text: string) => {}); - vi.stubGlobal("navigator", { clipboard: { writeText } }); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - let renderer: ReactTestRenderer | undefined; - const text = [ - "```text", - "First code block", - "```", - "", - "
More", - "", - "Details content", - "", - "
", - "", - "Streaming reply", - ].join("\n"); - - try { - await act(async () => { - renderer = create(); - }); - const mounted = renderer!; - const codeBlock = mounted.root.findByProps({ "data-language": "text" }); - const initialWrap = codeBlock.props["data-wrap"] === "true"; - const wrap = codeButton(mounted, initialWrap ? "Disable line wrap" : "Wrap lines"); - const copy = codeButton(mounted, "Copy code"); - await act(async () => { - wrap.onClick?.({} as Parameters>[0]); - copy.onClick?.({} as Parameters>[0]); - }); - - const detailsButton = mounted.root.find( - (instance) => - instance.type === "button" && instance.props["data-markdown-details-summary"] === "", - ); - await act(async () => { - detailsButton.props.onClick({ nativeEvent: new Event("click") }); - }); - const details = mounted.root.findByProps({ "data-markdown-details": "" }); - expect(details.props["data-markdown-details-open"]).toBe("true"); - expect(writeText).toHaveBeenCalledWith("First code block\n"); - expect(highlight).toHaveBeenCalledTimes(1); - - for (let index = 0; index < 10; index += 1) { - await act(async () => { - mounted.update(); - }); - } - - expect(highlight).toHaveBeenCalledTimes(1); - expect(mounted.root.findByProps({ "data-language": "text" })).toBe(codeBlock); - expect(codeBlock.props["data-wrap"]).toBe(String(!initialWrap)); - expect(mounted.root.findByProps({ "data-markdown-details": "" })).toBe(details); - expect(details.props["data-markdown-details-open"]).toBe("true"); - await act(async () => { - mounted.update( - , - ); - }); - const copyUpdated = codeButton(mounted, "Copied"); - await act(async () => { - copyUpdated.onClick?.({} as Parameters>[0]); - }); - expect(writeText).toHaveBeenLastCalledWith("Updated code block\n"); - expect(highlight).toHaveBeenCalledTimes(2); - } finally { - await act(async () => renderer?.unmount()); - vi.useRealTimers(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - } - }); - - it("edits the current task text and marker after reusing a renderer", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - let renderer: ReactTestRenderer | undefined; - let editedText: string | undefined; - const message = (text: string) => ( - { - editedText = setMarkdownTaskChecked(text, markerOffset, checked); - renderer!.update(message(editedText)); - }} - /> - ); - - try { - await act(async () => { - renderer = create(message("- [ ] First\n- [ ] Second")); - }); - const mounted = renderer!; - const originalInput = mounted.root.findAllByType("input")[1]!; - await act(async () => { - mounted.update(message("- [ ] A longer first task\n- [ ] Second")); - }); - - const input = mounted.root.findAllByType("input")[1]!; - const listItem = mounted.root.findAllByType("li")[1]!; - const { onChange } = input.props as ComponentProps<"input">; - if (!onChange) throw new Error("Task checkbox has no edit handler"); - await act(async () => { - onChange({ - currentTarget: { - checked: true, - closest: () => ({ - dataset: { listItemOffset: String(listItem.props["data-list-item-offset"]) }, - }), - }, - } as unknown as Parameters[0]); - }); - - expect(input).toBe(originalInput); - expect(editedText).toBe("- [ ] A longer first task\n- [x] Second"); - expect(mounted.root.findAllByType("input")[1]!.props.checked).toBe(true); - } finally { - await act(async () => renderer?.unmount()); - vi.unstubAllGlobals(); - } - }); -}); - -describe("canUseMarkdownFileShellActions", () => { - const environmentId = EnvironmentId.make("environment-1"); - - it("allows editor and file manager actions for local environments", () => { - expect(canUseMarkdownFileShellActions(environmentId, "local-exec", true)).toBe(true); - }); - - it("hides shell actions until the environment mode is resolved", () => { - expect(canUseMarkdownFileShellActions(environmentId, "local-exec", false)).toBe(false); - }); - - it("hides editor and file manager actions for remote environments", () => { - expect(canUseMarkdownFileShellActions(environmentId, "remote-links", true)).toBe(false); - expect(canUseMarkdownFileShellActions(environmentId, "remote-unavailable", true)).toBe(false); - }); - - it("hides shell actions when no environment owns the markdown", () => { - expect(canUseMarkdownFileShellActions(null, "local-exec", true)).toBe(false); - }); -}); - -describe("hasMarkdownFilePrimaryAction", () => { - it("keeps the chip interactive when an editor, browser, or panel can open it", () => { - expect( - hasMarkdownFilePrimaryAction({ - canOpenInEditor: true, - canOpenInBrowser: false, - canOpenInPanel: false, - }), - ).toBe(true); - expect( - hasMarkdownFilePrimaryAction({ - canOpenInEditor: false, - canOpenInBrowser: true, - canOpenInPanel: false, - }), - ).toBe(true); - expect( - hasMarkdownFilePrimaryAction({ - canOpenInEditor: false, - canOpenInBrowser: false, - canOpenInPanel: true, - }), - ).toBe(true); - }); - - it("removes the link affordance when no primary action can open the file", () => { - expect( - hasMarkdownFilePrimaryAction({ - canOpenInEditor: false, - canOpenInBrowser: false, - canOpenInPanel: false, - }), - ).toBe(false); - }); -}); - -describe("ChatMarkdown skill chips", () => { - it("updates digit-leading skill labels when discovered skills change", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - let renderer: ReactTestRenderer | undefined; - const text = "Use $2spec with a $20k budget."; - try { - await act(async () => { - renderer = create(); - }); - const mounted = renderer!; - const labels = (label: string) => - mounted.root.findAllByType("span").filter((node) => node.children.includes(label)); - expect(labels("2Spec")).toHaveLength(0); - - await act(async () => { - mounted.update( - , - ); - }); - expect(labels("2Spec")).toHaveLength(1); - expect(labels("MoneySkill")).toHaveLength(0); - - await act(async () => { - mounted.update(); - }); - expect(labels("2Spec")).toHaveLength(0); - } finally { - await act(async () => renderer?.unmount()); - vi.unstubAllGlobals(); - } - }); -}); - -describe("ChatMarkdown file option chips", () => { - it("keeps the fallback button text selectable", () => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain(" { - const html = renderToStaticMarkup( - , - ); - - expect(html).not.toContain("codex-file-citation"); - expect(html).toContain("chat-markdown-file-link"); - expect(html).toContain( - 'data-markdown-copy="[report.xlsx](/tmp/project/outputs/report.xlsx)"', - ); - expect(html).toContain("report.xlsx"); - }, - ); - - it("leaves an unfinished streaming citation visible until it is complete", () => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain(":codex-file-citation"); - expect(html).not.toContain("chat-markdown-file-link"); - }); - - it("leaves malformed and similarly named file directives literal", () => { - for (const text of [ - ':codex-file-citation{purpose="output"}', - ':codex-file-citation-extra{path="/tmp/project/outputs/report.xlsx"}', - ]) { - const html = renderToStaticMarkup(); - - expect(html).toContain(text.replaceAll('"', """)); - expect(html).not.toContain("chat-markdown-file-link"); - } - }); - - it("preserves Codex file citation examples inside code", () => { - const directive = ':codex-file-citation{path="/tmp/project/outputs/report.xlsx"}'; - const html = renderToStaticMarkup( - , - ); - - expect(html.match(/:codex-file-citation/g)).toHaveLength(2); - expect(html).not.toContain("chat-markdown-file-link"); - }); - - it("preserves escaped Codex file citations as literal text", () => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain(":codex-file-citation"); - expect(html).not.toContain("chat-markdown-file-link"); - }); - - it("does not create a nested link for citations inside link text", () => { - const directive = ':codex-file-citation{path="/tmp/project/outputs/report.xlsx"}'; - const html = renderToStaticMarkup( - , - ); - const renderedText = html.replace(/<[^>]+>/g, ""); - - expect(renderedText).toContain("codex-file-citation"); - expect(html).not.toContain("chat-markdown-file-link"); - }); - - it("renders file citations created by over-indented list recovery", () => { - const html = renderToStaticMarkup( - , - ); - - expect(html).not.toContain("
");
-    expect(html).toContain("Created ");
-    expect(html).toContain("chat-markdown-file-link");
-    expect(html).toContain("report.xlsx");
-  });
-
-  it("disambiguates Codex citations with the same basename", () => {
-    const html = renderToStaticMarkup(
-      ,
-    );
-
-    expect(html).toContain("index.ts · project/src");
-    expect(html).toContain("index.ts · project/test");
-  });
-
-  it("preserves rejected citations created by over-indented list recovery", () => {
-    const malformedHtml = renderToStaticMarkup(
-      ,
-    );
-    const nestedLinkHtml = renderToStaticMarkup(
-      ,
-    );
-    const nestedLinkText = nestedLinkHtml.replace(/<[^>]+>/g, "");
-
-    expect(malformedHtml).toContain(
-      "
  • Bad :codex-file-citation{purpose="output"}
  • ", - ); - expect(nestedLinkText).toContain( - "Bad :codex-file-citation{path="/tmp/project/report.xlsx"}", - ); - }); -}); - -describe("ChatMarkdown Codex follow-up suggestions", () => { - it.each([true, false])("renders the label with parseRawHtml=%s", (parseRawHtml) => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain("Prepare print version"); - expect(html).not.toContain("codex-followup"); - expect(html).not.toContain("Prepare the document for printing."); - }); - - it("keeps the label of a malformed follow-up created by over-indented list recovery", () => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain("
  • Next :codex-followup[Prepare print version]
  • "); - }); -}); - -const ARTIFACT_TEMPLATE_DIRECTIVE = - '::artifact-template{skill_name="artifact-template-hello-world" skill_directory="/Users/test/.codex/skills/artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}'; - -describe("ChatMarkdown artifact-template cards", () => { - it.each([true, false])("renders the Codex result card with parseRawHtml=%s", (parseRawHtml) => { - const html = renderToStaticMarkup( - undefined} - />, - ); - - expect(html).not.toContain("::artifact-template"); - expect(html).toContain("data-chat-markdown-artifact-template"); - expect(html).toContain('data-artifact-kind="document"'); - expect(html).toContain('data-markdown-copy="Hello World (Document template)\n\n"'); - expect(html).toContain('data-skill-name="artifact-template-hello-world"'); - expect(html).toContain("Hello World"); - expect(html).toContain("Document template"); - expect(html).toContain("Use template"); - expect(html).not.toContain("

    { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain("data-chat-markdown-artifact-template"); - expect(html).not.toContain("Use template"); - }); - - it("leaves malformed and unfinished artifact-template directives literal", () => { - const malformed = - '::artifact-template{skill_name="artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}'; - const unfinished = ARTIFACT_TEMPLATE_DIRECTIVE.slice(0, -1); - - for (const text of [malformed, unfinished]) { - const html = renderToStaticMarkup(); - expect(html).toContain("::artifact-template"); - expect(html).not.toContain("data-chat-markdown-artifact-template"); - } - }); - - it("leaves escaped and similarly named artifact-template directives literal", () => { - for (const text of [ - `\\${ARTIFACT_TEMPLATE_DIRECTIVE}`, - ARTIFACT_TEMPLATE_DIRECTIVE.replace("::artifact-template", "::artifact-template-extra"), - ]) { - const html = renderToStaticMarkup(); - - expect(html).toContain("::artifact-template"); - expect(html).not.toContain("data-chat-markdown-artifact-template"); - } - }); - - it("preserves artifact-template examples inside code", () => { - const html = renderToStaticMarkup( - , - ); - - expect(html.match(/::artifact-template/g)).toHaveLength(2); - expect(html).not.toContain("data-chat-markdown-artifact-template"); - }); -}); - -describe("ChatMarkdown heading levels", () => { - it("exposes headings below the host heading without changing their tags", () => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain('

    Top

    '); - expect(html).toContain('

    Section

    '); - expect(html).toContain('
    Fine print
    '); - }); - - it("leaves heading levels alone when the markdown is not nested", () => { - const html = renderToStaticMarkup(); - - expect(html).toContain('

    Top

    '); - }); -}); - -describe("shouldUseMarkdownFileBrowserPrimaryAction", () => { - it("uses the browser when it is the only available primary action", () => { - expect( - shouldUseMarkdownFileBrowserPrimaryAction({ - iconPath: "/tmp/report.html", - canOpenInEditor: false, - canOpenInBrowser: true, - canOpenInPanel: false, - }), - ).toBe(true); - }); - - it("preserves the normal editor and panel defaults for HTML files", () => { - expect( - shouldUseMarkdownFileBrowserPrimaryAction({ - iconPath: "/tmp/report.html", - canOpenInEditor: true, - canOpenInBrowser: true, - canOpenInPanel: false, - }), - ).toBe(false); - expect( - shouldUseMarkdownFileBrowserPrimaryAction({ - iconPath: "/tmp/report.html", - canOpenInEditor: false, - canOpenInBrowser: true, - canOpenInPanel: true, - }), - ).toBe(false); - }); - - it("continues to open PDF files in the browser by default", () => { - expect( - shouldUseMarkdownFileBrowserPrimaryAction({ - iconPath: "/tmp/report.pdf", - canOpenInEditor: true, - canOpenInBrowser: true, - canOpenInPanel: true, - }), - ).toBe(true); - }); -}); - -describe("ChatMarkdown Windows file links", () => { - const environmentId = EnvironmentId.make("env-windows"); - - it.each([true, false])("preserves drive paths with parseRawHtml=%s", (parseRawHtml) => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"'); - expect(html).toContain("chat-markdown-file-link"); - }); - - it.each([true, false])("normalizes backslashes with parseRawHtml=%s", (parseRawHtml) => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"'); - expect(html).toContain("chat-markdown-file-link"); - }); - - it.each([true, false])( - "keeps backslashes CommonMark would read as escapes with parseRawHtml=%s", - (parseRawHtml) => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain('href="C:/Users/shawn/.claude/settings.json"'); - }, - ); - - it.each([true, false])( - "distinguishes same-named backslash paths with parseRawHtml=%s", - (parseRawHtml) => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain("index.ts · project/src"); - expect(html).toContain("index.ts · project/test"); - }, - ); - - it.each([true, false])( - "does not disambiguate the same file in links and inline code with parseRawHtml=%s", - (parseRawHtml) => { - const path = String.raw`C:\Users\shawn\project\src\main.ts`; - const html = renderToStaticMarkup( - , - ); - - expect(html.match(/chat-markdown-file-link/g)).toHaveLength(2); - expect(html).not.toContain("main.ts ·"); - }, - ); - - it.each([true, false])("preserves reference links with parseRawHtml=%s", (parseRawHtml) => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"'); - expect(html).toContain("chat-markdown-file-link"); - }); - - it.each([true, false])("still rejects unsafe schemes with parseRawHtml=%s", (parseRawHtml) => { - const html = renderToStaticMarkup( - , - ); - - expect(html).not.toContain("javascript:"); - expect(html).not.toContain("d:alert"); - expect(html).not.toContain("chat-markdown-file-link"); - }); -}); - -it("opens a disclosure only when find selects a match inside it", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal( - "Highlight", - class extends Set { - constructor(...ranges: Range[]) { - super(ranges); - } - }, - ); - const highlights = new Map>(); - vi.stubGlobal("CSS", { highlights, escape: (value: string) => value }); - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - const openStates = () => - [...container.querySelectorAll("[data-markdown-details-open]")].map((node) => - node.getAttribute("data-markdown-details-open"), - ); - function Probe({ - activeOccurrence, - searching = true, - }: { - activeOccurrence: number; - searching?: boolean; - }) { - useThreadFindHighlights({ - container, - query: searching ? "needle" : "", - activeRowId: "row", - activeOccurrence, - onActiveRange: () => {}, - }); - return ( -
    -
    - - Unrelated

    nothing here

    ", - "
    Outer
    Inner

    needle

    ", - ].join("\n\n")} - /> -
    -
    -
    - ); - } - const frame = () => - act(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); - try { - await act(() => root.render()); - expect(container.textContent).not.toContain("nothing here"); - await act(() => root.render()); - await frame(); - expect(openStates()).toEqual(["false", "false", "false"]); - expect(container.textContent).toContain("nothing here"); - expect( - [...(highlights.get("t3-thread-find-active") ?? [])].map((range) => range.toString()), - ).toEqual(["needle"]); - expect(highlights.get("t3-thread-find")?.size).toBe(0); - - await act(() => root.render()); - await frame(); - await frame(); - expect(openStates()).toEqual(["false", "true", "true"]); - expect(highlights.get("t3-thread-find-active")?.size).toBe(1); - } finally { - await act(() => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); - } -}); - -it("keeps Mermaid diagrams rendered until find selects a match in their source", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal( - "Highlight", - class extends Set { - constructor(...ranges: Range[]) { - super(ranges); - } - }, - ); - const highlights = new Map>(); - vi.stubGlobal("CSS", { highlights, escape: (value: string) => value }); - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - const diagram = "```mermaid\ngraph TD; Alpha-->Beta\n```"; - function Probe({ query }: { query: string }) { - useThreadFindHighlights({ - container, - query, - activeRowId: "row", - activeOccurrence: 0, - onActiveRange: () => {}, - }); - return ( -
    -
    - - - -
    -
    - ); - } - const frame = () => - act(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); - const diagrams = () => container.querySelectorAll('svg[aria-label="Diagram"]').length; - try { - await act(() => root.render()); - await frame(); - expect(diagrams()).toBe(2); - await act(() => root.render()); - await frame(); - await frame(); - expect(diagrams()).toBe(1); - expect( - [...(highlights.get("t3-thread-find-active") ?? [])].map((range) => range.toString()), - ).toEqual(["Alpha"]); - } finally { - await act(() => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); - } -}); - -it.each([ - { - text: "```mermaid\ngraph TD; SearchSourceAlpha-->B\n```", - query: "SearchSourceAlpha", - count: 1, - }, - { - text: "★ Insight ─────\nfirst line\nsecond line", - query: "first line second", - count: 0, - lineBreaks: true, - }, - { text: '```ts title="src/needle.ts"\nconst a = 1;\n```', query: "needle", count: 0 }, - { text: "```weirdlang\nconst a = 1;\n```", query: "weirdlang", count: 0 }, - { text: "Use $test-t3-app now", query: "T3 App Testing", count: 1 }, - { text: "`/tmp/file.ts:42`", query: "file.ts · L42", count: 1, user: true, lineBreaks: true }, - { text: "> [!NOTE]\n> Searchable alert", query: "Searchable alert", count: 1 }, - { - text: "
    Folded

    Hidden needle

    ", - query: "Hidden needle", - count: 1, - }, - { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "Hello World", count: 1, withTemplate: true }, - { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "Document template", count: 1, withTemplate: true }, - { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "World Document", count: 0, withTemplate: true }, - { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "Use template", count: 0, withTemplate: true }, -])( - "highlights the indexed occurrences of $query in $text", - async ({ text, query, count, lineBreaks, user, withTemplate }) => { - const skills = [{ name: "test-t3-app", displayName: "T3 App Testing" }]; - const highlights = new Map>(); - vi.stubGlobal( - "Highlight", - class extends Set { - constructor(...ranges: Range[]) { - super(ranges); - } - }, - ); - vi.stubGlobal("CSS", { highlights, escape: (value: string) => value }); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - function Probe() { - useThreadFindHighlights({ - container, - query, - activeRowId: "row", - activeOccurrence: 0, - onActiveRange: () => {}, - }); - return ( -
    -
    - - undefined : undefined} - /> - -
    -
    - ); - } - try { - await act(() => root.render()); - await act(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); - const ranges = [...highlights.values()].flatMap((value) => [...value]); - expect(ranges.map((range) => range.toString())).toEqual( - Array.from({ length: count }, () => query), - ); - const segments = - searchableMessageSegments( - { role: user ? "user" : "assistant", text, streaming: false }, - undefined, - skills, - ) ?? []; - expect( - segments.reduce((sum, segment) => sum + countThreadSearchOccurrences(segment, query), 0), - ).toBe(count); - } finally { - await act(() => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); - } - }, -); - -describe("ChatMarkdown heading ids", () => { - it("never gives two headings the same id, even when a suffix matches another heading", () => { - const html = renderToStaticMarkup( - Pinned\n\n## Install\n\n## Install' - } - />, - ); - const ids = [...html.matchAll(/

    match[1]); - expect(ids).toEqual([ - "user-content-setup", - "user-content-setup-1", - "user-content-setup-1-1", - "user-content-install-1", - "user-content-install", - "user-content-install-2", - ]); - expect(new Set(ids).size).toBe(ids.length); - }); -}); - -describe("ChatMarkdown in-page links", () => { - it.each([true, false])( - "scrolls a table-of-contents link to its heading without touching the URL (parseRawHtml=%s)", - async (parseRawHtml) => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - const { createRoot } = await import("react-dom/client"); - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - window.history.replaceState(null, "", "/#/env/thread"); - const scrollIntoView = vi.fn(); - HTMLElement.prototype.scrollIntoView = scrollIntoView; - try { - await act(async () => { - root.render( - , - ); - }); - const headings = [...container.querySelectorAll("h2")]; - expect(headings.map((heading) => heading.id)).toEqual([ - "user-content-1-operating-model", - "user-content-1-operating-model-1", - ]); - - const [tocLink, missingLink] = [...container.querySelectorAll("a")]; - const click = () => new MouseEvent("click", { bubbles: true, cancelable: true }); - const tocClick = click(); - tocLink!.dispatchEvent(tocClick); - expect(tocClick.defaultPrevented).toBe(true); - expect(scrollIntoView.mock.contexts).toEqual([headings[0]]); - - const missingClick = click(); - missingLink!.dispatchEvent(missingClick); - expect(missingClick.defaultPrevented).toBe(true); - expect(scrollIntoView).toHaveBeenCalledTimes(1); - expect(window.location.hash).toBe("#/env/thread"); - } finally { - await act(async () => root.unmount()); - container.remove(); - } - }, - ); -}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 0f1a8255a18b..3adc9d9f9f3c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -257,7 +257,7 @@ export function canUseMarkdownFileShellActions( return environmentId !== null && isRemoteOpenResolved && remoteOpenMode === "local-exec"; } -export function hasMarkdownFilePrimaryAction(input: { +function hasMarkdownFilePrimaryAction(input: { canOpenInEditor: boolean; canOpenInBrowser: boolean; canOpenInPanel: boolean; diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx new file mode 100644 index 000000000000..d562fff0aa6b --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -0,0 +1,1262 @@ +import { RegistryProvider } from "@effect/atom-react"; +import { + ApprovalRequestId, + CheckpointRef, + EnvironmentId, + MessageId, + RunId, + ThreadId, +} from "@t3tools/contracts"; +import type { LegendListRef } from "@legendapp/list/react"; +import { act, createRef, useState, type ComponentProps, type ReactElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import "~/index.css"; + +import { serverEnvironment } from "../../state/server"; +import { PullRequestLinkPreview } from "../pullRequest/PullRequestLinkPreview"; +import { Tooltip, TooltipPopup, TooltipScrollDismissArea, TooltipTrigger } from "../ui/tooltip"; +import { MessagesTimeline } from "./MessagesTimeline"; +import { readTimelinePosition } from "./timelineScrollAnchoring"; +import { useComposerFocusState } from "./useComposerFocusState"; + +const pullRequestQuery = vi.hoisted(() => ({ error: "Pull request not found" })); +vi.mock(import("~/state/query"), async (original) => ({ + ...(await original()), + useEnvironmentQuery: (() => ({ + data: null, + dataUpdatedAt: 0, + error: pullRequestQuery.error, + failure: null, + isPending: false, + isSuccess: false, + refresh: () => {}, + })) as never, +})); + +const ENVIRONMENT_ID = EnvironmentId.make("environment-local"); +const CREATED_AT = "2026-03-17T19:12:28.000Z"; + +type TimelineProps = ComponentProps; +type Entry = TimelineProps["timelineEntries"][number]; + +// The timeline remembers scroll position and open disclosures per thread key, +// so every render gets a fresh thread. +let threadCount = 0; + +function baseProps(): Omit { + threadCount += 1; + return { + isWorking: false, + activeTurnInProgress: false, + listRef: createRef(), + latestRun: null, + turnDiffSummaries: [], + runs: [], + routeThreadKey: `environment-local:thread-${threadCount}`, + onOpenTurnDiff: () => {}, + onOpenThread: () => {}, + onForkFromRun: async () => {}, + onRollbackCheckpoint: () => {}, + supportsConversationRollback: false, + onRevertToTurnCount: () => {}, + isRevertingCheckpoint: false, + onImageExpand: () => {}, + activeThreadEnvironmentId: ENVIRONMENT_ID, + markdownCwd: undefined, + resolvedTheme: "light", + timestampFormat: "locale", + workspaceRoot: undefined, + anchorMessageId: null, + onAnchorReady: () => {}, + onAnchorSizeChanged: () => {}, + contentInsetEndAdjustment: 0, + liveFollowEnabled: true, + onIsAtEndChange: () => {}, + onManualNavigation: () => {}, + }; +} + +function message( + text: string, + overrides: Partial["message"]> = {}, + id = "message-1", +): Entry { + return { + id: `entry-${id}`, + kind: "message", + createdAt: CREATED_AT, + message: { + id: MessageId.make(id), + role: "user", + text, + runId: null, + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + streaming: false, + ...overrides, + }, + }; +} + +function work(id: string, entry: Record): Entry { + return { + id, + kind: "work", + createdAt: CREATED_AT, + entry: { id, createdAt: CREATED_AT, ...entry }, + } as Entry; +} + +function v2Item(type: string, fields: Record) { + const id = (fields.id as string | undefined) ?? type; + return { + position: 0, + visibility: "local", + sourceThreadId: "thread-1", + sourceItemId: id, + item: { + id, + threadId: "thread-1", + runId: "run-1", + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + status: "completed", + title: null, + startedAt: null, + completedAt: null, + updatedAt: {}, + type, + ...fields, + }, + } as never; +} + +function longText(tail = "deep hidden detail only after expand") { + return Array.from({ length: 9 }, (_, index) => + index === 8 ? tail : `Line ${index + 1}: ${"verbose prompt content ".repeat(8).trim()}`, + ).join("\n"); +} + +const roots: Root[] = []; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +}); + +afterEach(async () => { + for (const root of roots.splice(0)) await act(async () => root.unmount()); + document.body.replaceChildren(); + vi.unstubAllGlobals(); +}); + +async function renderElement(element: ReactElement) { + container = document.body.appendChild(document.createElement("div")); + container.style.cssText = "height: 1200px; width: 900px; display: flex; flex-direction: column;"; + const root = createRoot(container); + roots.push(root); + await act(async () => root.render(element)); + return container; +} + +function renderTimeline(entries: ReadonlyArray, props: Partial = {}) { + return renderElement( + , + ); +} + +function text() { + return container.textContent ?? ""; +} + +function buttons(name: string | RegExp) { + return [...container.querySelectorAll("button, [role=button]")].filter((button) => { + const label = button.getAttribute("aria-label") ?? button.textContent ?? ""; + return typeof name === "string" ? label === name : name.test(label); + }); +} + +function button(name: string | RegExp) { + const [match] = buttons(name); + if (!match) throw new Error(`Missing button: ${String(name)}`); + return match; +} + +async function click(element: HTMLElement) { + await act(async () => element.click()); +} + +describe("MessagesTimeline work rows", () => { + it("shows dynamic tool input without cached output when the row is expanded", async () => { + await renderTimeline([ + work("tool-with-cached-output", { + label: "Example tool", + toolTitle: "Example tool", + tone: "tool", + itemType: "dynamic_tool", + toolLifecycleStatus: "completed", + toolData: { + input: { query: "KEEP_TOOL_INPUT" }, + output: { text: "RAW_CACHED_TOOL_OUTPUT" }, + }, + }), + ]); + await click(button("Example tool")); + expect(text()).toContain("KEEP_TOOL_INPUT"); + expect(text()).not.toContain("RAW_CACHED_TOOL_OUTPUT"); + await click(button("Example tool")); + await vi.waitFor(() => expect(text()).not.toContain("KEEP_TOOL_INPUT")); + }); + + it("expands and collapses a tool call through its header", async () => { + await renderTimeline([ + work("work-standalone", { + toolCallId: "call-standalone", + label: "Run lint", + tone: "tool", + itemType: "command_execution", + command: "pnpm lint", + toolLifecycleStatus: "completed", + }), + ]); + const header = () => container.querySelector("[aria-expanded]")!; + expect(header().getAttribute("aria-expanded")).toBe("false"); + await click(header()); + expect(header().getAttribute("aria-expanded")).toBe("true"); + await click(header()); + expect(header().getAttribute("aria-expanded")).toBe("false"); + }); + + it("leads an unanswered question row with the question text", async () => { + await renderTimeline([ + work("question-work", { + label: "User input requested", + tone: "tool", + questionAnswer: { + requestId: ApprovalRequestId.make("question-request"), + answers: {}, + questionTextById: { scope: "Which repository?" }, + attachmentsByQuestionId: {}, + }, + }), + ]); + const toggle = button("Which repository?"); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + const before = text().match(/Which repository\?/g)?.length ?? 0; + expect(before).toBeGreaterThan(0); + await click(toggle); + expect(text().match(/Which repository\?/g)?.length).toBe(before + 1); + }); + + it("collapses settled tool runs behind a generated summary and names a failure", async () => { + await renderTimeline([ + work("work-1", { + label: "Ran command", + command: "vp lint", + tone: "tool", + itemType: "command_execution", + toolLifecycleStatus: "completed", + }), + work("work-2", { + label: "Run lint", + command: "vp test run", + tone: "tool", + itemType: "command_execution", + toolLifecycleStatus: "failed", + }), + ]); + const summary = button(/^Ran 2 commands/); + expect(summary.getAttribute("aria-label")).toContain("tool call failed"); + expect(text()).not.toContain("vp lint"); + await click(summary); + await vi.waitFor(() => expect(text()).toContain("vp lint")); + }); + + it("keeps mixed work logs neutral after a later tool call succeeds", async () => { + await renderTimeline([ + work("work-failed", { + label: "Run search", + tone: "tool", + itemType: "command_execution", + toolLifecycleStatus: "failed", + }), + work("work-info", { label: "Status updated", tone: "info" }), + work("work-completed", { + label: "Run tests", + tone: "tool", + itemType: "command_execution", + toolLifecycleStatus: "completed", + }), + ]); + expect(button("Ran 2 commands and received 1 update")).toBeDefined(); + }); + + it("marks a failed tool inside an expanded work log", async () => { + await renderTimeline([ + work("work-info", { label: "Status updated", tone: "info" }), + work("work-1", { + label: "Glob", + tone: "tool", + toolLifecycleStatus: "failed", + detail: "No files found", + }), + ]); + await click(button(/^Received 1 update/)); + await vi.waitFor(() => + expect(container.querySelector('[aria-label="Tool call failed"]')).not.toBeNull(), + ); + }); + + it("renders context compaction entries in the normal work log", async () => { + await renderTimeline([work("work-1", { label: "Context compacted", tone: "info" })]); + expect(text()).toContain("Context compacted"); + }); + + it("formats changed file paths from the workspace root", async () => { + await renderTimeline( + [ + work("context-info", { label: "Session started", tone: "info" }), + work("work-1", { + label: "Updated files", + tone: "tool", + itemType: "file_change", + toolLifecycleStatus: "completed", + changedFiles: ["C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"], + }), + ], + { workspaceRoot: "C:/Users/mike/dev-stuff/t3code" }, + ); + await click(button(/^Received 1 update/)); + await vi.waitFor(() => expect(text()).toContain("t3code/apps/web/src/session-logic.ts")); + expect(text()).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); + }); + + it("renders T3 MCP dynamic tools with their pretty name", async () => { + const item = { + id: "tool-t3-thread-read", + type: "dynamic_tool", + toolName: "mcp__t3-code__t3_thread_read", + input: { threadId: "thread-child" }, + output: { messages: [] }, + }; + await renderTimeline([ + work("context-info", { label: "Session started", tone: "info" }), + work(item.id, { + runId: null, + label: item.toolName, + tone: "tool", + itemType: item.type, + toolTitle: item.toolName, + toolLifecycleStatus: "completed", + toolData: { input: item.input, output: item.output }, + structuredPayload: v2Item("dynamic_tool", item), + projectedItem: v2Item("dynamic_tool", item), + }), + ]); + await click(button(/^Received 1 update/)); + await vi.waitFor(() => expect(text()).toContain("Read a T3 thread")); + expect(text()).not.toContain("mcp__t3-code__t3_thread_read"); + }); + + it("keeps a provider retry collapsed in the work log", async () => { + const retryItem = { + id: "provider-error", + status: "running", + title: "Provider retry", + failure: { + class: "transport_error", + message: "The response stream disconnected.", + code: "responseStreamDisconnected", + retryable: true, + }, + retry: { attempt: 2, maxAttempts: 5, retryDelayMs: null }, + }; + await renderTimeline( + [ + work("provider-error", { + runId: "run-1", + label: "Retrying provider (2/5)", + detail: retryItem.failure.message, + tone: "info", + itemType: "error", + toolLifecycleStatus: "inProgress", + projectedItem: v2Item("error", retryItem), + }), + ], + { + isWorking: true, + activeTurnInProgress: true, + latestRun: { + runId: RunId.make("run-1"), + status: "running", + startedAt: CREATED_AT, + completedAt: null, + }, + }, + ); + expect(text()).toContain("Retrying provider (2/5)"); + expect(text()).not.toContain("Structured details"); + }); + + it.each([ + [ + "**Viewing image first** with *care*, ~~old~~ `code` and [context](https://example.com)", + "Viewing image first with care, old code and context", + 1, + ], + ["first paragraph\n\nsecond paragraph", "first paragraph second paragraph", 0], + ["- first\n- second", "first second", 0], + ["first \nsecond", "first second", 0], + ["![image description](image.png)", "image description", 0], + ["![](image.png)", "Thought", 0], + ["---", "Thought", 0], + ] as const)( + "shows plain text for a V2 reasoning preview: %s", + async (markdown, expected, strongCount) => { + await renderTimeline([ + work("reasoning-preview", { + label: markdown, + detail: markdown, + tone: "thinking", + itemType: "reasoning", + toolLifecycleStatus: "completed", + }), + ]); + const row = () => container.querySelector("[role=button][aria-expanded]")!; + const preview = () => row().textContent!.replace(/\s+/g, " "); + expect(preview()).toContain(expected); + expect(container.querySelectorAll("strong")).toHaveLength(0); + await click(row()); + expect(container.querySelectorAll("strong")).toHaveLength(strongCount); + await click(row()); + expect(preview()).toContain(expected); + await vi.waitFor(() => expect(container.querySelectorAll("strong")).toHaveLength(0)); + }, + ); +}); + +describe("MessagesTimeline messages", () => { + it("preserves XML-like tags and comparisons in user messages as text", async () => { + const source = [ + 'Without reading a file, do you have ', + 'Before inside after', + " in your context?", + "Comparison: 2 < 3 and 5 > 4.", + ]; + await renderTimeline([message(source.join("\n"))]); + for (const line of source) expect(text()).toContain(line); + expect(container.querySelector("global-agent-instructions, nested")).toBeNull(); + }); + + it("preserves XML-like source inside user code spans and fences", async () => { + await renderTimeline([ + message( + [ + 'Inline ``', + "", + "```xml", + '', + "```", + ].join("\n"), + ), + ]); + expect(container.querySelector("code[data-inline-code]")?.textContent).toBe(''); + expect(text()).toContain(''); + }); + + it("does not show markdown titles in user messages", async () => { + await renderTimeline([ + message( + '[link](https://example.com "link tip") ![image](https://example.com/image.png "image tip")', + ), + ]); + expect(container.querySelector('a[href="https://example.com"]')).not.toBeNull(); + expect(container.querySelector('img[src="https://example.com/image.png"]')).not.toBeNull(); + expect(container.querySelector('[title="link tip"], [title="image tip"]')).toBeNull(); + }); + + it("renders unsafe user HTML as inert source text", async () => { + const source = + ''; + await renderTimeline([message(source)]); + expect(text()).toContain(source); + expect(container.querySelector("script, img")).toBeNull(); + expect((globalThis as { __t3Xss?: number }).__t3Xss).toBeUndefined(); + }); + + it("renders sanitized raw HTML in assistant messages", async () => { + await renderTimeline([ + message( + [ + '
    ', + "Safe details", + "", + '', + 'Unsafe link', + "
    ", + ].join(""), + { role: "assistant" }, + ), + ]); + expect(container.querySelector("[data-markdown-details]")?.textContent).toContain( + "Safe details", + ); + expect(text()).not.toContain(" { + await renderTimeline([message(longText())]); + const toggle = button("Show full message"); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + expect(buttons("Copy message")).toHaveLength(1); + await click(toggle); + expect(button("Show less").getAttribute("aria-expanded")).toBe("true"); + }); + + it("does not offer a collapse toggle for short user messages", async () => { + await renderTimeline([message("Short prompt.")]); + expect(buttons(/Show full message|Show less/)).toHaveLength(0); + }); + + it("identifies user-role messages sent by another agent", async () => { + await renderTimeline([ + message("Review this area", { createdBy: "agent", creationSource: "provider" }), + ]); + expect(text()).toContain("Sent by another agent"); + await act(async () => roots.pop()!.unmount()); + await renderTimeline([message("Review this area")]); + expect(text()).not.toContain("Sent by another agent"); + }); + + it("shows steer intent ahead of committed user messages", async () => { + await renderTimeline([message("Adjust the current turn", { inputIntent: "steer" })]); + expect(text().indexOf("Steer")).toBeGreaterThanOrEqual(0); + expect(text().indexOf("Steer")).toBeLessThan(text().indexOf("Adjust the current turn")); + }); + + it("renders inline terminal labels as chips", async () => { + await renderTimeline([ + message( + [ + longText("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"), + ), + ]); + expect(text()).toContain("Terminal 1 lines 1-5"); + expect(text()).toContain("yoo what's"); + expect(text()).not.toContain("terminal_context"); + expect(buttons("Show full message")).toHaveLength(1); + }); + + it("renders chips for standalone element-pick context messages", async () => { + await renderTimeline([ + message( + [ + "", + "- (Button.tsx:12):", + " url: https://example.com/dashboard", + " selector: button.submit", + " source: /repo/src/Button.tsx:12:5", + " html:", + ' ', + "", + ].join("\n"), + ), + ]); + expect(text()).toContain("SubmitButton"); + expect(text()).not.toContain("element_context"); + }); + + it("renders review comment contexts as structured cards instead of raw tags", async () => { + await renderTimeline([ + message( + [ + '', + "Wadduo", + "```diff", + "@@ -0,0 +47,2 @@", + '+ it("keeps valid zero-usage snapshots", () => {', + "+ expect(snapshot).not.toBeNull();", + "```", + "", + ].join("\n"), + ), + ]); + expect(text()).toContain("contextWindow.test.ts L47 to L58"); + expect(text()).not.toContain("review_comment"); + }); + + it("renders file review comments as source code", async () => { + await renderTimeline([ + message( + [ + '', + "Clarify this.", + "```md", + "# Plan", + "- Step one", + "```", + "", + ].join("\n"), + ), + ]); + expect(text()).toContain("plan.md L1 to L2"); + expect(text()).not.toContain("review_comment"); + }); + + it("collapses superseded attempt output behind a disclosure", async () => { + const runId = RunId.make("run-steered"); + const attempt = (id: string, ordinal: number, status: string) => + ({ + id, + runId, + attemptOrdinal: ordinal, + rootNodeId: `node-${id}`, + status, + completedAt: null, + }) as never; + await renderTimeline( + [ + { + ...message( + "Partial response from the old attempt", + { role: "assistant", runId }, + "superseded-response", + ), + attempt: attempt("attempt-1", 1, "superseded"), + } as Entry, + { + ...message( + "Current response remains visible", + { role: "assistant", runId, streaming: true }, + "active-response", + ), + attempt: attempt("attempt-2", 2, "running"), + } as Entry, + ], + { + latestRun: { runId, status: "running", startedAt: CREATED_AT, completedAt: null }, + }, + ); + expect(text()).toContain("Current response remains visible"); + expect(text()).not.toContain("Partial response from the old attempt"); + const disclosure = button(/Superseded attempt/); + expect(disclosure.textContent).toContain("Cut off by a steer"); + await click(disclosure); + expect(text()).toContain("Partial response from the old attempt"); + }); + + it("forks from a completed assistant response", async () => { + const onForkFromRun = vi.fn(async () => {}); + await renderTimeline( + [ + { + ...message("Done", { role: "assistant", runId: RunId.make("run-1") }, "assistant-1"), + projectedItem: v2Item("assistant_message", { + id: "assistant-item-1", + messageId: "assistant-1", + text: "Done", + streaming: false, + }), + } as Entry, + ], + { onForkFromRun }, + ); + await click(button("Fork from this response")); + expect(onForkFromRun).toHaveBeenCalledWith({ sourceThreadId: "thread-1", runId: "run-1" }); + }); + + it("opens the changed files of an assistant response", async () => { + const assistantMessageId = MessageId.make("assistant-with-files"); + const runId = RunId.make("run-with-files"); + const onOpenTurnDiff = vi.fn(); + await renderTimeline( + [message("Updated the fixture.", { role: "assistant", runId }, assistantMessageId)], + { + onOpenTurnDiff, + latestRun: { runId, status: "completed", startedAt: CREATED_AT, completedAt: CREATED_AT }, + turnDiffSummaries: [ + { + runId, + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("checkpoint-with-files"), + status: "ready", + files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }], + assistantMessageId, + completedAt: CREATED_AT, + }, + ], + }, + ); + expect(text()).toContain("1 changed file"); + await click(button("Open diff")); + expect(onOpenTurnDiff).toHaveBeenCalledWith(runId, "README.md"); + }); +}); + +describe("MessagesTimeline attachments", () => { + const withAttachment = (attachment: Record) => + message("Read the report.", { + attachments: [ + { + type: "file", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + ...attachment, + }, + ] as never, + }); + + it("offers preview and download actions for PDF attachments", async () => { + await renderTimeline([ + withAttachment({ + id: "attachment-report-pdf", + previewUrl: "https://environment.test/api/assets/report.pdf", + }), + ]); + expect(buttons("Preview report.pdf")).toHaveLength(1); + expect(buttons("Download report.pdf")).toHaveLength(1); + expect(container.querySelector('img[alt="report.pdf"]')).toBeNull(); + }); + + it("does not create a download URL before the download is requested", async () => { + await renderTimeline([withAttachment({ id: "attachment-report-pdf" })]); + expect(buttons("Download report.pdf")).toHaveLength(1); + expect(container.querySelector("a[href]")).toBeNull(); + }); + + it("does not download an optimistic file before the server supplies its attachment ID", async () => { + await renderTimeline([withAttachment({ id: "composer-local-report", downloadable: false })]); + expect(text()).toContain("report.pdf"); + expect(buttons("Download report.pdf")).toHaveLength(0); + }); + + it("renders unknown attachment types as inert rows instead of crashing", async () => { + await renderTimeline([ + withAttachment({ + type: "recording", + id: "attachment-voice-memo", + name: "voice-memo.ogg", + mimeType: "audio/ogg", + }), + ]); + expect(text()).toContain("voice-memo.ogg"); + expect(buttons("Download voice-memo.ogg")).toHaveLength(0); + expect(container.querySelector("a[href], img")).toBeNull(); + }); + + it("shows SnapShot window details only after the preview resolves", async () => { + const snapShot = (previewUrl?: string) => + message("First prompt.", { + attachments: [ + { + type: "image", + id: "attachment-1", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 1, + ...(previewUrl ? { previewUrl } : {}), + source: { + kind: "snap-shot", + capturedAt: CREATED_AT, + appName: "Terminal", + windowTitle: "t3code — Tests", + appIconDataUrl: "data:image/png;base64,aWNvbg==", + }, + }, + ] as never, + }); + await renderTimeline([snapShot()]); + expect(text()).toContain("screenshot.png"); + expect(text()).not.toContain("t3code — Tests"); + await act(async () => roots.pop()!.unmount()); + await renderTimeline([snapShot("data:image/png;base64,iVBORw0KGgo=")]); + expect(text()).toContain("Terminal"); + expect(text()).toContain("t3code — Tests"); + }); +}); + +describe("MessagesTimeline history and thread links", () => { + it("loads earlier turns from a control ahead of the bounded timeline", async () => { + const onLoadEarlier = vi.fn(); + await renderTimeline([message("Recent activity")], { + historyControls: { + hasMoreHistory: true, + loading: false, + error: "Earlier activity could not be loaded.", + onLoadEarlier, + }, + }); + expect(text()).toContain("Earlier activity could not be loaded."); + expect(text().indexOf("Load earlier turns")).toBeLessThan(text().indexOf("Recent activity")); + await click(button("Load earlier turns")); + expect(onLoadEarlier).toHaveBeenCalledOnce(); + }); + + it("keeps an empty bounded timeline busy while earlier history loads", async () => { + await renderTimeline([], { + historyControls: { + hasMoreHistory: true, + loading: true, + error: null, + onLoadEarlier: () => {}, + }, + }); + expect(text()).toContain("Loading earlier turns…"); + expect(button("Load earlier turns")).toHaveProperty("disabled", true); + expect(text()).not.toContain("Send a message to start the conversation."); + }); + + it("opens the parent thread from the top of an empty subagent timeline", async () => { + const onOpenThread = vi.fn(); + await renderTimeline([], { + onOpenThread, + parentThreadLink: { threadId: ThreadId.make("thread-parent"), title: "Architecture audit" }, + }); + expect(text()).toContain("Subagent of"); + expect(text()).toContain("Architecture audit"); + expect(text()).not.toContain("Send a message to start the conversation"); + await click(button("Open parent thread")); + expect(onOpenThread).toHaveBeenCalledWith("thread-parent"); + }); + + it("does not render the transient V2 interruption request", async () => { + await renderTimeline([ + { + id: "interrupt-request", + kind: "event", + createdAt: CREATED_AT, + projectedItem: v2Item("run_interrupt_request", { + id: "interrupt-request", + message: "Waiting for the provider to stop.", + }), + } as Entry, + ]); + expect(text()).not.toContain("Interrupt requested"); + expect(text()).not.toContain("Waiting for the provider to stop."); + }); + + it("opens created threads from a lean row", async () => { + const onOpenThread = vi.fn(); + await renderTimeline( + [ + { + id: "thread-created", + kind: "event", + createdAt: CREATED_AT, + projectedItem: v2Item("thread_created", { + id: "thread-created", + nodeId: "node-1", + ordinal: 1, + title: "Claude research thread", + targetThreadId: "thread-2", + targetRunId: "run-2", + targetProviderInstanceId: "claude-default", + targetModel: "claude-sonnet-4-6", + }), + } as Entry, + ], + { onOpenThread }, + ); + expect(text()).toContain("Claude research thread"); + expect(text()).not.toContain("Work Log"); + await click(button("Open Claude research thread")); + expect(onOpenThread).toHaveBeenCalledWith("thread-2"); + }); + + it("renders context handoffs as from → to model endpoints instead of the summary", async () => { + const provider = (instanceId: string, driver: string, slug: string, name: string) => ({ + instanceId, + driver, + enabled: true, + installed: true, + version: null, + status: "ready", + auth: {}, + checkedAt: CREATED_AT, + models: [{ slug, name, isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + }); + const providers = [ + provider("codex_personal", "codex", "gpt-5.6-sol", "GPT 5.6 Sol"), + provider("claudeAgent", "claudeAgent", "claude-fable-5", "Claude Fable 5"), + ] as never; + const handoff = (fields: Record) => + ({ + id: "handoff-1", + kind: "event", + createdAt: CREATED_AT, + projectedItem: v2Item("handoff", { + id: "handoff-1", + runId: "run-2", + title: "Provider handoff", + contextHandoffId: "handoff-1", + fromProviderThreadIds: ["provider-thread-1"], + toProviderThreadId: "provider-thread-2", + strategy: "full_thread_summary", + summary: "Full conversation context for provider handoff.", + fromProviderInstanceIds: ["codex_personal"], + toProviderInstanceId: "claudeAgent", + ...fields, + }), + }) as Entry; + const show = async (entry: Entry, props: Partial = {}) => { + for (const root of roots.splice(0)) await act(async () => root.unmount()); + await renderElement( + + + , + ); + }; + + await show( + handoff({ + fromModelSelections: [{ instanceId: "codex_personal", model: "gpt-5.6-sol" }], + toModel: "claude-fable-5", + }), + ); + expect(text()).toContain("Context handoff"); + expect(text()).toContain("GPT 5.6 Sol"); + expect(text()).toContain("Claude Fable 5"); + expect(text()).not.toContain("Full conversation context"); + + await show(handoff({}), { + runs: [ + { + id: "run-1", + ordinal: 1, + providerInstanceId: "codex_personal", + modelSelection: { instanceId: "codex_personal", model: "gpt-5.6-sol" }, + }, + { + id: "run-2", + ordinal: 2, + providerInstanceId: "claudeAgent", + modelSelection: { instanceId: "claudeAgent", model: "claude-fable-5" }, + }, + ] as never, + }); + expect(text()).toContain("GPT 5.6 Sol"); + expect(text()).toContain("Claude Fable 5"); + + await show(handoff({})); + expect(text()).toContain("Codex Personal"); + expect(text()).not.toContain("Full conversation context"); + }); +}); + +describe("MessagesTimeline subagents", () => { + it.each( + ( + [ + { + status: "running", + progress: "Reading src/index.ts", + result: null, + preview: "Reading src/index.ts", + }, + { + status: "completed", + progress: "Reading src/index.ts", + result: "Tests should be isolated.", + preview: "Tests should be isolated.", + }, + { + status: "running", + progress: "Reading src/index.ts", + result: "Partial streamed answer", + preview: "Reading src/index.ts", + }, + { + status: "running", + progress: undefined, + result: "Streaming answer so far", + preview: "Streaming answer so far", + }, + { + status: "cancelled", + progress: "Reading src/index.ts", + result: "Partial output before cancel", + preview: "Partial output before cancel", + }, + { + status: "completed", + progress: "Audited 12 packages", + result: " \n\t ", + preview: "Audited 12 packages", + }, + ] as const + ).flatMap((scenario) => [1, 2].map((count) => ({ ...scenario, count }))), + )( + "shows $count $status subagents with '$preview', grouping only multiple agents", + async ({ status, progress, result, preview, count }) => { + const onOpenThread = vi.fn(); + await renderTimeline( + Array.from( + { length: count }, + (_, index) => + ({ + id: `subagent-progress-${index}`, + kind: "event", + createdAt: CREATED_AT, + projectedItem: v2Item("subagent", { + id: `subagent-progress-${index}`, + nodeId: `node-subagent-${index}`, + providerThreadId: "provider-thread-1", + providerTurnId: "provider-turn-1", + ordinal: 1, + status, + title: "Package audit", + subagentId: `node-subagent-${index}`, + origin: "provider_native", + driver: "claudeAgent", + providerInstanceId: "claudeAgent", + childThreadId: "thread-subagent-1", + prompt: "Inspect the package", + progress, + result, + }), + }) as Entry, + ), + { onOpenThread }, + ); + const children = () => buttons("Open Package audit"); + // A settled run folds its work behind a "Worked for" summary. + const fold = buttons(/^Worked for/)[0]; + if (fold) await click(fold); + if (count > 1) { + expect(children()).toHaveLength(0); + await click(button(`${count} subagents`)); + } else { + expect(buttons(`${count} subagents`)).toHaveLength(0); + } + expect(children()).toHaveLength(count); + expect(text()).toContain(preview); + expect(text()).not.toContain("Inspect the package"); + if (result?.trim() && result !== preview) expect(text()).not.toContain(result); + if (progress && progress !== preview) expect(text()).not.toContain(progress); + await click(children()[0]!); + expect(onOpenThread).toHaveBeenCalledWith("thread-subagent-1"); + if (count > 1) { + await click(button(`${count} subagents`)); + await vi.waitFor(() => expect(children()).toHaveLength(0)); + } + }, + ); +}); + +describe("timeline tooltip scroll dismissal", () => { + const openPopup = () => + document.querySelector('[data-slot="tooltip-popup"][data-open]')?.textContent ?? null; + + async function renderTooltip(pullRequest: boolean, outside: boolean) { + const tooltip = pullRequest ? ( + message link} + originalUrl="https://example.com" + target={{ + environmentId: ENVIRONMENT_ID, + input: { projectId: "project-1" as never, repository: "pingdotgg/t3code", number: 1 }, + }} + /> + ) : ( + + message link + https://example.com + + ); + await renderElement( + <> + +
    + {outside ? null : tooltip} +
    +
    + + {outside ? tooltip : null} + , + ); + return { + trigger: container.querySelector("button")!, + scrollable: container.querySelector('[data-testid="scrollable"]')!, + }; + } + + async function scroll(scrollable: HTMLElement) { + await act(async () => { + scrollable.scrollTop += 50; + scrollable.dispatchEvent(new Event("scroll")); + }); + } + + it.each([false, true])( + "closes a hovered tooltip on scroll but keeps a focused one (pull request=%s)", + async (pullRequest) => { + const { trigger, scrollable } = await renderTooltip(pullRequest, false); + await userEvent.hover(trigger); + await vi.waitFor(() => expect(openPopup()).toBe("https://example.com")); + await scroll(scrollable); + await vi.waitFor(() => expect(openPopup()).toBeNull()); + + await userEvent.unhover(trigger); + await userEvent.keyboard("{Tab}"); + expect(document.activeElement).toBe(trigger); + await vi.waitFor(() => expect(openPopup()).toBe("https://example.com")); + await scroll(scrollable); + expect(openPopup()).toBe("https://example.com"); + expect(document.activeElement).toBe(trigger); + }, + ); + + it("leaves tooltips outside the timeline open when it scrolls", async () => { + const { trigger, scrollable } = await renderTooltip(false, true); + await userEvent.hover(trigger); + await vi.waitFor(() => expect(openPopup()).toBe("https://example.com")); + await scroll(scrollable); + expect(openPopup()).toBe("https://example.com"); + }); + + it("keeps a hovered tooltip open on a wheel that does not scroll", async () => { + const { trigger, scrollable } = await renderTooltip(false, false); + await userEvent.hover(trigger); + await vi.waitFor(() => expect(openPopup()).toBe("https://example.com")); + await act(async () => { + scrollable.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: 100 })); + }); + expect(openPopup()).toBe("https://example.com"); + }); +}); + +describe("MessagesTimeline scroll position", () => { + const tall = (index: number) => + message( + `Message ${index}\n\n${"Long paragraph. ".repeat(80)}`, + { role: "assistant" }, + `message-${index}`, + ); + const tallEntries = Array.from({ length: 12 }, (_, index) => tall(index)); + + async function waitForList(props: ReturnType) { + await vi.waitFor(() => { + const viewport = props.listRef.current?.getScrollableNode(); + expect(viewport?.scrollHeight ?? 0).toBeGreaterThan(viewport?.clientHeight ?? 0); + }); + return props.listRef.current!.getScrollableNode()!; + } + + it("remembers a manual reading position when the chat closes", async () => { + const props = baseProps(); + function Chat() { + const [liveFollowEnabled, setLiveFollowEnabled] = useState(true); + // The chat view stops following the live edge when the reader scrolls. + return ( +
    setLiveFollowEnabled(false)}> + +
    + ); + } + await renderElement(); + const viewport = await waitForList(props); + await vi.waitFor(() => expect(readTimelinePosition(props.routeThreadKey)?.atEnd).toBe(true)); + + await userEvent.wheel(viewport, { delta: { y: -600 } }); + await vi.waitFor(() => expect(readTimelinePosition(props.routeThreadKey)?.atEnd).toBe(false)); + const offset = viewport.scrollTop; + await act(async () => roots.pop()!.unmount()); + expect(readTimelinePosition(props.routeThreadKey)).toMatchObject({ + atEnd: false, + scrollOffset: offset, + }); + }); + + it("restores the composer after closing tool output only at the end", async () => { + const props = baseProps(); + function Thread() { + const composer = useComposerFocusState(); + return ( + <> + + {composer.isComposerScrollCollapsed ? "collapsed" : "resting"} + + + ); + } + await renderElement(); + const composerState = () => container.querySelector("output")!.textContent; + const viewport = await waitForList(props); + await click(button("Collapse")); + const toggle = () => button("Command output"); + await click(toggle()); + await click(toggle()); + await vi.waitFor(() => expect(composerState()).toBe("resting")); + + await click(button("Collapse")); + await userEvent.wheel(viewport, { delta: { y: -2000 } }); + await click(toggle()); + await click(toggle()); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + expect(composerState()).toBe("collapsed"); + }); +}); + +describe("MessagesTimeline send anchor", () => { + it("reports the anchored sent message once it is laid out", async () => { + const onAnchorReady = vi.fn(); + const sent = message("Newest prompt.", {}, "message-2"); + await renderTimeline([message("Earlier reply.", { role: "assistant" }), sent], { + anchorMessageId: MessageId.make("message-2"), + onAnchorReady, + contentInsetEndAdjustment: 144, + }); + await vi.waitFor(() => + expect(onAnchorReady).toHaveBeenCalledWith(MessageId.make("message-2"), 1), + ); + }); + + it("does not anchor a follow-up user message that is not the newest turn", async () => { + const onAnchorReady = vi.fn(); + await renderTimeline([message("First prompt."), message("Newest prompt.", {}, "message-2")], { + anchorMessageId: MessageId.make("message-2"), + onAnchorReady, + }); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + expect(onAnchorReady).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index a8f77bc0efd2..650b2bf504f1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -34,6 +34,14 @@ import { liveWorkEntryLabel, normalizeCompactToolLabel, resolveAssistantMessageCopyState, + resolveTimelineIsAtEnd, + resolveTimelineMinimapHasPersistentGutter, + resolveTimelineMinimapHeightStyle, + resolveTimelineMinimapHitStripWidth, + resolveTimelineMinimapIndexFromPointer, + resolveTimelineMinimapInteractiveWidth, + resolveTimelineMinimapNavigationInteractive, + resolveTimelineMinimapTopPercent, resolveWorkGroupScrollIndex, shouldFollowWorkGroupAppend, threadReadLabelPrefix, @@ -5464,3 +5472,73 @@ describe("shouldCollapseUserMessage", () => { expect(shouldCollapseUserMessage(text)).toBe(false); }); }); + +describe("timeline end and minimap geometry", () => { + it("treats the follow re-arm band above the content bottom as the live edge", () => { + expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true); + expect(resolveTimelineIsAtEnd(undefined)).toBeUndefined(); + expect( + resolveTimelineIsAtEnd({ + isAtEnd: false, + contentLength: 2000, + scroll: 1170, + scrollLength: 800, + }), + ).toBe(true); + expect( + resolveTimelineIsAtEnd({ + isAtEnd: false, + contentLength: 2000, + scroll: 900, + scrollLength: 800, + }), + ).toBe(false); + expect( + resolveTimelineIsAtEnd({ + isAtEnd: true, + contentLength: 2100, + scroll: 1170, + scrollLength: 800, + }), + ).toBe(false); + expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(false); + expect(resolveTimelineIsAtEnd({ isAtEnd: false })).toBe(false); + }); + + it("maps the minimap rail to rows and sizes its hit strip from the free gutter", () => { + expect(resolveTimelineMinimapHeightStyle(5)).toBe("min(32px, calc(100vh - 18rem))"); + expect(resolveTimelineMinimapTopPercent(2, 5)).toBe(50); + const rail = { itemCount: 101, railTop: 100, railHeight: 500 }; + expect(resolveTimelineMinimapIndexFromPointer({ ...rail, pointerY: 350 })).toBe(50); + expect(resolveTimelineMinimapIndexFromPointer({ ...rail, pointerY: 999 })).toBe(100); + + expect(resolveTimelineMinimapHasPersistentGutter(832, 768)).toBe(false); + expect(resolveTimelineMinimapHasPersistentGutter(863, 768)).toBe(false); + expect(resolveTimelineMinimapHasPersistentGutter(864, 768)).toBe(true); + expect(resolveTimelineMinimapHasPersistentGutter(1400, 1152)).toBe(true); + expect(resolveTimelineMinimapHasPersistentGutter(1200, 1152)).toBe(false); + expect(resolveTimelineMinimapHasPersistentGutter(2560, 2560)).toBe(false); + + expect(resolveTimelineMinimapHitStripWidth(768, 768)).toBe(0); + expect(resolveTimelineMinimapHitStripWidth(792, 768)).toBe(0); + expect(resolveTimelineMinimapHitStripWidth(820, 768)).toBe(14); + expect(resolveTimelineMinimapHitStripWidth(872, 768)).toBe(40); + expect(resolveTimelineMinimapHitStripWidth(1400, 768)).toBe(40); + expect(resolveTimelineMinimapHitStripWidth(2560, 2560)).toBe(0); + expect(resolveTimelineMinimapHitStripWidth(1204, 1152)).toBe(14); + expect(resolveTimelineMinimapHitStripWidth(0, 0)).toBe(0); + expect(resolveTimelineMinimapHitStripWidth(Number.NaN, 768)).toBe(0); + + expect(resolveTimelineMinimapNavigationInteractive(40)).toBe(true); + expect(resolveTimelineMinimapNavigationInteractive(14)).toBe(true); + expect(resolveTimelineMinimapNavigationInteractive(8)).toBe(false); + expect(resolveTimelineMinimapNavigationInteractive(0)).toBe(false); + + expect(resolveTimelineMinimapInteractiveWidth(0, false)).toBe(0); + expect(resolveTimelineMinimapInteractiveWidth(14, false)).toBe(14); + expect(resolveTimelineMinimapInteractiveWidth(40, false)).toBe(40); + for (const width of [0, 14, 40]) { + expect(resolveTimelineMinimapInteractiveWidth(width, true)).toBe("22rem"); + } + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx deleted file mode 100644 index b43e6e3d2024..000000000000 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ /dev/null @@ -1,2907 +0,0 @@ -// @vitest-environment jsdom - -import { - ApprovalRequestId, - CheckpointRef, - EnvironmentId, - MessageId, - ProjectId, - RunId, - ThreadId, -} from "@t3tools/contracts"; -import { - act, - createRef, - useLayoutEffect, - useState, - type ReactNode, - type Ref, - type ReactElement, -} from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { createRoot } from "react-dom/client"; -import { create, type ReactTestRenderer } from "react-test-renderer"; -import { shouldUseRestingComposerLayout } from "../composerFooterLayout"; -import { useComposerFocusState } from "./useComposerFocusState"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import type { LegendListRef } from "@legendapp/list/react"; - -const activityTestState = vi.hoisted(() => ({ - expanded: false, - expandedRuns: false, - subagentTooltips: false, -})); - -vi.mock("../ui/tooltip", async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - Tooltip: (props: { children?: ReactNode }) => - activityTestState.subagentTooltips ? props.children : , - TooltipTrigger: (props: { children?: ReactNode; render?: ReactElement }) => - activityTestState.subagentTooltips ? ( - <> - {props.render} - {props.children} - - ) : ( - - ), - TooltipPopup: (props: { children?: ReactNode }) => - activityTestState.subagentTooltips ? props.children : , - }; -}); - -vi.mock("../DiffWorkerPoolProvider", () => ({ - DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => children, -})); - -vi.mock("./MessagesTimeline.logic", async (importOriginal) => { - const logic = await importOriginal(); - return { - ...logic, - deriveMessagesTimelineRowsWithState( - input: Parameters[0], - previous: Parameters[1], - ) { - if (activityTestState.expandedRuns) { - input = { ...input, expandedRunIds: new Set([RunId.make("run-1")]) }; - } - const projection = logic.deriveMessagesTimelineRowsWithState(input, previous); - if (!activityTestState.expanded) return projection; - return logic.deriveMessagesTimelineRowsWithState({ - ...input, - expandedWorkGroupIds: new Set( - projection.rows.flatMap((row) => - row.kind === "work-toggle" || row.kind === "work-live" ? [row.groupId] : [], - ), - ), - }); - }, - }; -}); - -beforeEach(() => { - activityTestState.subagentTooltips = false; - activityTestState.expanded = false; - activityTestState.expandedRuns = false; -}); - -const legendListTestState = vi.hoisted(() => ({ - data: undefined as unknown, - onScroll: undefined as (() => void) | undefined, -})); - -vi.mock("@legendapp/list/react", async () => { - const { useLayoutEffect } = await import("react"); - const legendListTestId = "legend-list"; - - const LegendList = (props: { - data: Array<{ id: string }>; - keyExtractor: (item: { id: string }) => string; - renderItem: (args: { item: { id: string } }) => ReactNode; - ListHeaderComponent?: ReactNode; - ListFooterComponent?: ReactNode; - anchoredEndSpace?: { - anchorIndex: number; - anchorMaxSize?: number; - anchorOffset?: number; - onReady?: (info: { anchorIndex: number }) => void; - onSizeChanged?: (size: number) => void; - }; - maintainScrollAtEnd?: - | boolean - | { - animated?: boolean; - on?: { - dataChange?: boolean; - itemLayout?: boolean; - layout?: boolean; - }; - }; - maintainVisibleContentPosition?: - | boolean - | { - data?: boolean; - size?: boolean; - shouldRestorePosition?: boolean; - }; - className?: string; - contentInsetEndAdjustment?: number; - onScroll?: () => void; - ref?: Ref; - }) => { - useLayoutEffect(() => { - legendListTestState.data = props.data; - legendListTestState.onScroll = props.onScroll; - }); - if (props.anchoredEndSpace) { - props.anchoredEndSpace.onSizeChanged?.(240); - props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex }); - } - return ( -
    - {props.ListHeaderComponent} - {props.data.map((item) => ( -
    {props.renderItem({ item })}
    - ))} - {props.ListFooterComponent} -
    - ); - }; - - return { LegendList }; -}); - -function MockFileDiff(props: { - fileDiff: { name?: string | null; prevName?: string | null }; - renderCustomHeader?: (fileDiff: { - name?: string | null; - prevName?: string | null; - }) => React.ReactNode; -}) { - return ( -
    - {props.renderCustomHeader?.(props.fileDiff)} - {props.fileDiff.name ?? props.fileDiff.prevName ?? "diff"} -
    - ); -} - -vi.mock("@pierre/diffs/react", () => { - return { FileDiff: MockFileDiff }; -}); - -function matchMedia() { - return { - matches: false, - addEventListener: () => {}, - removeEventListener: () => {}, - }; -} - -let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline; - -const ElementStub = function ElementStub() {}; -function stubDomGlobals() { - const classList = { - add: () => {}, - remove: () => {}, - toggle: () => {}, - contains: () => false, - }; - - vi.stubGlobal("Element", ElementStub); - vi.stubGlobal("getComputedStyle", undefined); - vi.stubGlobal("localStorage", { - getItem: () => null, - setItem: () => {}, - removeItem: () => {}, - clear: () => {}, - }); - vi.stubGlobal("window", { - Element: ElementStub, - localStorage: globalThis.localStorage, - matchMedia, - addEventListener: () => {}, - removeEventListener: () => {}, - requestAnimationFrame: (callback: FrameRequestCallback) => { - callback(0); - return 0; - }, - cancelAnimationFrame: () => {}, - desktopBridge: undefined, - }); - vi.stubGlobal("document", { - documentElement: { - classList, - offsetHeight: 0, - }, - }); -} - -beforeEach(stubDomGlobals); -// Cold transformation of the full chat dependency graph needs extra time on slower CI workers. -beforeAll(async () => { - Object.defineProperty(window, "matchMedia", { value: matchMedia, configurable: true }); - ({ MessagesTimeline } = await import("./MessagesTimeline")); -}, 120_000); - -const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); -const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z"; - -describe("timeline tooltip scroll dismissal", () => { - it.each([ - "hover", - "delayed hover", - "focus", - "hover then focus", - "outside timeline", - "wheel without scroll", - "pr hover", - "pr delayed hover", - "pr focus", - "pr hover then focus", - ])("handles %s through the real tooltip interactions", async (scenario) => { - const isPullRequest = scenario.startsWith("pr "); - const interaction = isPullRequest ? scenario.slice(3) : scenario; - vi.unstubAllGlobals(); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.useFakeTimers(); - const { Tooltip, TooltipTrigger, TooltipPopup, TooltipScrollDismissArea } = - await vi.importActual("../ui/tooltip"); - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - const onMouseEnter = vi.fn(); - const query = await import("~/state/query"); - const querySpy = isPullRequest - ? vi.spyOn(query, "useEnvironmentQuery").mockReturnValue({ - data: null, - dataUpdatedAt: 0, - error: "Pull request not found", - failure: null, - isPending: false, - isSuccess: false, - refresh: vi.fn(), - }) - : null; - const { PullRequestLinkPreview } = await import("../pullRequest/PullRequestLinkPreview"); - const tooltip = isPullRequest ? ( - message link} - originalUrl="https://example.com" - target={{ - environmentId: ACTIVE_THREAD_ENVIRONMENT_ID, - input: { - projectId: ProjectId.make("project-1"), - repository: "pingdotgg/t3code", - number: 1, - }, - }} - /> - ) : ( - - - message link - - https://example.com - - ); - try { - await act(async () => { - root.render( - <> - -
    - {interaction === "outside timeline" ? null : tooltip} -
    -
    - {interaction === "outside timeline" ? tooltip : null} - , - ); - }); - const trigger = container.querySelector("button")!; - const scrollable = container.querySelector('[data-testid="scrollable"]')!; - await act(async () => { - if (interaction !== "focus") { - trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); - trigger.dispatchEvent(new MouseEvent("mouseenter")); - trigger.dispatchEvent(new MouseEvent("mousemove", { bubbles: true })); - } - if (interaction === "focus" || interaction === "hover then focus") { - document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" })); - trigger.focus(); - } - if (interaction !== "delayed hover") { - await vi.advanceTimersByTimeAsync(isPullRequest ? 400 : 60); - } - }); - expect(onMouseEnter).toHaveBeenCalledTimes(interaction === "focus" ? 0 : 1); - expect( - document.querySelector('[data-slot="tooltip-popup"][data-open]')?.textContent ?? null, - ).toBe(interaction === "delayed hover" ? null : "https://example.com"); - - await act(async () => { - scrollable.dispatchEvent( - interaction === "wheel without scroll" - ? new WheelEvent("wheel", { bubbles: true, deltaY: 100 }) - : new Event("scroll"), - ); - await vi.advanceTimersByTimeAsync(isPullRequest ? 1000 : 100); - }); - expect( - document.querySelector('[data-slot="tooltip-popup"][data-open]')?.textContent ?? null, - ).toBe( - interaction === "hover" || interaction === "delayed hover" ? null : "https://example.com", - ); - if (interaction === "focus" || interaction === "hover then focus") { - expect(document.activeElement).toBe(trigger); - } - } finally { - await act(async () => root.unmount()); - querySpy?.mockRestore(); - container.remove(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - } - }); -}); - -function buildProps() { - return { - isWorking: false, - activeTurnInProgress: false, - listRef: createRef(), - latestRun: null, - turnDiffSummaries: [], - runs: [], - routeThreadKey: "environment-local:thread-1", - onOpenTurnDiff: () => {}, - onOpenThread: () => {}, - onForkFromRun: async () => {}, - onRollbackCheckpoint: () => {}, - supportsConversationRollback: false, - onRevertToTurnCount: () => {}, - isRevertingCheckpoint: false, - openingVideoAttachmentId: null, - onImageExpand: () => {}, - activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID, - markdownCwd: undefined, - resolvedTheme: "light" as const, - timestampFormat: "locale" as const, - workspaceRoot: undefined, - anchorMessageId: null, - onAnchorReady: () => {}, - onAnchorSizeChanged: () => {}, - contentInsetEndAdjustment: 0, - liveFollowEnabled: true, - onIsAtEndChange: () => {}, - onManualNavigation: () => {}, - }; -} - -function buildLongUserMessageText(tail = "deep hidden detail only after expand") { - return Array.from({ length: 9 }, (_, index) => - index === 8 ? tail : `Line ${index + 1}: ${"verbose prompt content ".repeat(8).trim()}`, - ).join("\n"); -} - -function buildUserTimelineEntry(text: string) { - return { - id: "entry-1", - kind: "message" as const, - createdAt: MESSAGE_CREATED_AT, - message: { - id: MessageId.make("message-1"), - role: "user" as const, - text, - runId: null, - createdAt: MESSAGE_CREATED_AT, - updatedAt: MESSAGE_CREATED_AT, - streaming: false, - }, - }; -} - -function buildAssistantTimelineEntry(text: string) { - const entry = buildUserTimelineEntry(text); - return { - ...entry, - message: { - ...entry.message, - role: "assistant" as const, - }, - }; -} - -function buildSnapShotTimelineEntry(previewUrl?: string) { - const entry = buildUserTimelineEntry("First prompt."); - return { - ...entry, - message: { - ...entry.message, - attachments: [ - { - type: "image" as const, - id: "attachment-1", - name: "screenshot.png", - mimeType: "image/png", - sizeBytes: 1, - ...(previewUrl ? { previewUrl } : {}), - source: { - kind: "snap-shot" as const, - capturedAt: "2026-03-17T19:12:28.000Z", - appName: "Terminal", - windowTitle: "t3code — Tests", - appIconDataUrl: "data:image/png;base64,aWNvbg==", - }, - }, - ], - }, - }; -} - -describe("MessagesTimeline", () => { - it("shows dynamic tool input without cached output when the row is expanded", async () => { - activityTestState.expanded = true; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("requestAnimationFrame", () => 0); - vi.stubGlobal("cancelAnimationFrame", () => {}); - let renderer: ReactTestRenderer | undefined; - try { - await act(() => { - renderer = create( - , - ); - }); - const row = renderer!.root.findByProps({ "aria-label": "Example tool" }); - await act(() => row.props.onClick()); - const visible = JSON.stringify(renderer!.toJSON()); - expect(visible).toContain("KEEP_TOOL_INPUT"); - expect(visible).not.toContain("RAW_CACHED_TOOL_OUTPUT"); - await act(() => row.props.onClick()); - expect(JSON.stringify(renderer!.toJSON())).not.toContain("KEEP_TOOL_INPUT"); - } finally { - await act(() => renderer?.unmount()); - } - }); - - it("leads an unanswered question row with the question text", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("requestAnimationFrame", () => 0); - vi.stubGlobal("cancelAnimationFrame", () => {}); - let renderer: ReactTestRenderer | undefined; - try { - await act(() => { - renderer = create( - , - ); - }); - const questionToggle = renderer!.root.find( - (node) => - node.props["aria-label"] === "Which repository?" && node.props["aria-expanded"] === false, - ); - const markup = JSON.stringify(renderer!.toJSON()); - expect(markup.match(/Which repository\?/g)).toHaveLength(2); - await act(() => questionToggle.props.onClick()); - expect(JSON.stringify(renderer!.toJSON()).match(/Which repository\?/g)).toHaveLength(3); - } finally { - await act(() => renderer?.unmount()); - } - }); - - it.each([ - { toolLifecycleStatus: "inProgress", isAtEnd: true }, - { toolLifecycleStatus: "inProgress", isAtEnd: false }, - { toolLifecycleStatus: "completed", isAtEnd: true }, - { toolLifecycleStatus: "completed", isAtEnd: false }, - ] as const)( - "restores the composer after closing $toolLifecycleStatus tool output only at the end: $isAtEnd", - async ({ toolLifecycleStatus, isAtEnd }) => { - const frames = new Map(); - let nextFrame = 0; - vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { - frames.set(++nextFrame, callback); - return nextFrame; - }); - vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame)); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - const flushFrame = () => - act(() => { - const callbacks = [...frames.values()]; - frames.clear(); - callbacks.forEach((callback) => callback(0)); - }); - const props = buildProps(); - const runId = RunId.make("tool-output-run"); - let timelineIsAtEnd = isAtEnd; - props.listRef.current = { - getState: () => ({ isAtEnd: timelineIsAtEnd }), - getScrollableNode: () => null, - } as unknown as LegendListRef; - let isResting = false; - let composerState: ReturnType | undefined; - function ThreadProbe() { - const composer = useComposerFocusState(); - useLayoutEffect(() => { - composerState = composer; - isResting = shouldUseRestingComposerLayout({ - isExistingThread: true, - isMobileViewport: false, - isScrollCollapsed: composer.isComposerScrollCollapsed, - hasExpandedChrome: false, - hasMultilinePrompt: false, - timelineOverflows: true, - }); - }); - return ( - - ); - } - let renderer: ReactTestRenderer | undefined; - try { - await act(() => { - renderer = create(); - }); - await act(() => composerState!.setIsComposerScrollCollapsed(true)); - const toggle = renderer!.root.findByProps({ "aria-expanded": false }); - await act(() => toggle.props.onClick()); - await flushFrame(); - await flushFrame(); - expect(isResting).toBe(true); - - timelineIsAtEnd = false; - await act(() => toggle.props.onClick()); - await flushFrame(); - timelineIsAtEnd = isAtEnd; - await flushFrame(); - expect(isResting).toBe(!isAtEnd); - } finally { - await act(() => renderer?.unmount()); - } - }, - ); - - it.each([true, false])( - "keeps the end in view when tool output toggles only at the end: %s", - async (isAtEnd) => { - const frames = new Map(); - let nextFrame = 0; - vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { - frames.set(++nextFrame, callback); - return nextFrame; - }); - vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame)); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - const flushFrame = () => - act(() => { - const callbacks = [...frames.values()]; - frames.clear(); - callbacks.forEach((callback) => callback(0)); - }); - const viewport = { scrollTop: 400, scrollHeight: 1000 }; - const props = buildProps(); - props.listRef.current = { - getState: () => ({ isAtEnd }), - getScrollableNode: () => viewport, - } as unknown as LegendListRef; - let renderer: ReactTestRenderer | undefined; - try { - await act(() => { - renderer = create( - , - ); - }); - const toggle = renderer!.root.findByProps({ "aria-expanded": false }); - const list = renderer!.root.find( - (node) => node.props.ListFooterComponent !== undefined && node.props.onItemSizeChanged, - ); - for (const scrollHeight of [1300, 900]) { - await act(() => toggle.props.onClick()); - viewport.scrollHeight = scrollHeight; - await flushFrame(); - expect(viewport.scrollTop).toBe(isAtEnd ? scrollHeight : 400); - viewport.scrollHeight = scrollHeight + 200; - await act(() => list.props.onItemSizeChanged()); - expect(viewport.scrollTop).toBe(isAtEnd ? scrollHeight + 200 : 400); - await flushFrame(); - await flushFrame(); - viewport.scrollTop = 400; - } - } finally { - await act(() => renderer?.unmount()); - } - }, - ); - - function stubFollowTimeline(routeThreadKey: string) { - const frames = new Map(); - let nextFrame = 0; - vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { - frames.set(++nextFrame, callback); - return nextFrame; - }); - vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame)); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - const props = { ...buildProps(), routeThreadKey }; - const timeline = { - props, - scrollTop: 1400, - flushFrame: () => - act(() => { - const callbacks = [...frames.values()]; - frames.clear(); - callbacks.forEach((callback) => callback(0)); - }), - }; - const viewport = { - get scrollTop() { - return timeline.scrollTop; - }, - getBoundingClientRect: () => ({ top: 0 }), - }; - props.listRef.current = { - getScrollableNode: () => viewport, - getState: () => ({ - data: legendListTestState.data, - scroll: timeline.scrollTop, - scrollLength: 600, - contentLength: 2000, - indexByKey: () => 0, - positionAtIndex: () => 0, - sizeAtIndex: () => 2000, - elementAtIndex: () => ({ getBoundingClientRect: () => ({ top: -timeline.scrollTop }) }), - }), - } as unknown as LegendListRef; - return timeline; - } - - it("remembers a manual reading position when the chat closes before the next frame", async () => { - const { readTimelinePosition } = await import("./timelineScrollAnchoring"); - const timeline = stubFollowTimeline("environment-local:manual-departure"); - const { props } = timeline; - function ChatProbe() { - const [liveFollowEnabled, setLiveFollowEnabled] = useState(true); - return ( - setLiveFollowEnabled(false)} - /> - ); - } - let renderer: ReactTestRenderer | undefined; - try { - await act(() => { - renderer = create(); - }); - await timeline.flushFrame(); - expect(readTimelinePosition(props.routeThreadKey)?.atEnd).toBe(true); - - timeline.scrollTop = 1300; - await act(() => { - renderer?.root.findByType(MessagesTimeline).props.onManualNavigation?.(); - legendListTestState.onScroll?.(); - }); - await act(() => renderer?.unmount()); - renderer = undefined; - - expect(readTimelinePosition(props.routeThreadKey)).toMatchObject({ - atEnd: false, - scrollOffset: 1300, - }); - } finally { - await act(() => renderer?.unmount()); - } - }); - - it("keeps a following thread's intent while its timeline is held for the next thread", async () => { - const { readTimelinePosition } = await import("./timelineScrollAnchoring"); - const timeline = stubFollowTimeline("environment-local:held-departure"); - const { props } = timeline; - timeline.scrollTop = 1300; - const render = (held: boolean) => ( - - ); - let renderer: ReactTestRenderer | undefined; - try { - await act(() => { - renderer = create(render(false)); - }); - await timeline.flushFrame(); - expect(readTimelinePosition(props.routeThreadKey)?.atEnd).toBe(true); - - await act(() => renderer?.update(render(true))); - await timeline.flushFrame(); - - expect(readTimelinePosition(props.routeThreadKey)?.atEnd).toBe(true); - } finally { - await act(() => renderer?.unmount()); - } - }); - - it("remembers a first send held near the top as a reading position", async () => { - const { readTimelinePosition } = await import("./timelineScrollAnchoring"); - const timeline = stubFollowTimeline("environment-local:anchored-first-send"); - const { props } = timeline; - timeline.scrollTop = 1300; - let renderer: ReactTestRenderer | undefined; - try { - await act(() => { - renderer = create( - , - ); - }); - await timeline.flushFrame(); - - expect(readTimelinePosition(props.routeThreadKey)).toMatchObject({ - atEnd: false, - scrollOffset: 1300, - }); - } finally { - await act(() => renderer?.unmount()); - } - }); - - it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - ', - 'Before inside after', - " in your context?", - "Comparison: 2 < 3 and 5 > 4.", - ].join("\n"), - ), - ]} - />, - ); - - expect(markup).toContain("<global-agent-instructions scope="workspace">"); - expect(markup).toContain( - "Before <nested data-value="a&b">inside</nested> after", - ); - expect(markup).toContain("</global-agent-instructions> in your context?"); - expect(markup).toContain("Comparison: 2 < 3 and 5 > 4."); - }); - - it("preserves XML-like source inside user code spans and fences", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - `', - "", - "```xml", - '', - "```", - ].join("\n"), - ), - ]} - />, - ); - - expect(markup).toContain('<tag attr="x">'); - expect(markup).toContain("<root><child enabled="true" /></root>"); - }); - - it("does not render markdown title attributes in user messages", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('href="https://example.com"'); - expect(markup).toContain('src="https://example.com/image.png"'); - expect(markup).not.toContain('title="link tip"'); - expect(markup).not.toContain('title="image tip"'); - }); - - it("renders unsafe user HTML as inert source text", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - globalThis.__t3Xss = 1', - ), - ]} - />, - ); - - expect(markup).toContain("<script>globalThis.__t3Xss = 1</script>"); - expect(markup).toContain( - "<img src="x" onerror="globalThis.__t3Xss = 2">", - ); - expect(markup).not.toMatch(/)/i); - expect(markup).not.toMatch(/)/i); - }); - - it("continues to render sanitized raw HTML in assistant messages", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - MoreDetails"), - ]} - />, - ); - - expect(markup).toContain('data-markdown-details=""'); - expect(markup).toContain("More"); - expect(markup).not.toContain("<details>"); - }); - - it("sanitizes executable HTML while preserving supported assistant markup", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - ', - "Safe details", - "", - '', - 'Unsafe link', - "", - ].join(""), - ), - ]} - />, - ); - - expect(markup).toContain('data-markdown-details=""'); - expect(markup).toContain("Safe details"); - expect(markup).not.toMatch(/)/i); - expect(markup).not.toContain("onclick="); - expect(markup).not.toContain("onerror="); - expect(markup).not.toContain("javascript:"); - expect(markup).not.toContain("globalThis.__t3Xss"); - }); - it("renders progressive history controls ahead of the bounded timeline", () => { - const markup = renderToStaticMarkup( - {}, - }} - />, - ); - - expect(markup).toContain('aria-label="Load earlier turns"'); - expect(markup).toContain("Load earlier turns"); - expect(markup).toContain("Earlier activity could not be loaded."); - expect(markup.indexOf("Load earlier turns")).toBeLessThan(markup.indexOf("Recent activity")); - }); - - it("keeps an empty bounded timeline actionable while earlier history loads", () => { - const markup = renderToStaticMarkup( - {}, - }} - />, - ); - - expect(markup).toContain("Loading earlier turns…"); - expect(markup).toContain("disabled"); - expect(markup).not.toContain("Send a message to start the conversation."); - }); - - it("uses the larger leading inset only when the top fade is enabled", () => { - const timelineEntries = [buildUserTimelineEntry("Hello")]; - - const compactMarkup = renderToStaticMarkup( - , - ); - const fadedMarkup = renderToStaticMarkup( - , - ); - - expect(compactMarkup).toContain('class="h-3 sm:h-4"'); - expect(compactMarkup).not.toContain("topbar-scroll-fade"); - expect(fadedMarkup).toContain('class="h-[var(--workspace-titlebar-scroll-fade-height)]"'); - expect(fadedMarkup).toContain("topbar-scroll-fade"); - }); - - it("keeps assistant changed-files headers sticky below the thread header", () => { - const assistantMessageId = MessageId.make("message-assistant-with-files"); - const runId = RunId.make("run-with-files"); - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("sticky top-2 z-10"); - expect(markup).not.toContain("self-start"); - expect(markup).toContain("whitespace-nowrap"); - expect(markup).toContain("size-3"); - expect(markup).not.toContain('aria-label="Collapse all folders"'); - expect(markup).toContain('aria-label="Open diff"'); - expect(markup).toContain("1 changed file"); - }); - - it("treats the follow re-arm band above the content bottom as the live edge", async () => { - const { - resolveTimelineIsAtEnd, - resolveTimelineMinimapHasPersistentGutter, - resolveTimelineMinimapHeightStyle, - resolveTimelineMinimapHitStripWidth, - resolveTimelineMinimapIndexFromPointer, - resolveTimelineMinimapInteractiveWidth, - resolveTimelineMinimapNavigationInteractive, - resolveTimelineMinimapTopPercent, - } = await import("./MessagesTimeline.logic"); - - expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true); - expect(resolveTimelineIsAtEnd(undefined)).toBeUndefined(); - expect( - resolveTimelineIsAtEnd({ - isAtEnd: false, - contentLength: 2000, - scroll: 1170, - scrollLength: 800, - }), - ).toBe(true); - expect( - resolveTimelineIsAtEnd({ - isAtEnd: false, - contentLength: 2000, - scroll: 900, - scrollLength: 800, - }), - ).toBe(false); - expect( - resolveTimelineIsAtEnd({ - isAtEnd: true, - contentLength: 2100, - scroll: 1170, - scrollLength: 800, - }), - ).toBe(false); - expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(false); - expect(resolveTimelineIsAtEnd({ isAtEnd: false })).toBe(false); - - expect(resolveTimelineMinimapHeightStyle(5)).toBe("min(32px, calc(100vh - 18rem))"); - expect(resolveTimelineMinimapTopPercent(2, 5)).toBe(50); - expect( - resolveTimelineMinimapIndexFromPointer({ - itemCount: 101, - railTop: 100, - railHeight: 500, - pointerY: 350, - }), - ).toBe(50); - expect( - resolveTimelineMinimapIndexFromPointer({ - itemCount: 101, - railTop: 100, - railHeight: 500, - pointerY: 999, - }), - ).toBe(100); - expect(resolveTimelineMinimapHasPersistentGutter(832, 768)).toBe(false); - expect(resolveTimelineMinimapHasPersistentGutter(863, 768)).toBe(false); - expect(resolveTimelineMinimapHasPersistentGutter(864, 768)).toBe(true); - expect(resolveTimelineMinimapHasPersistentGutter(1400, 1152)).toBe(true); - expect(resolveTimelineMinimapHasPersistentGutter(1200, 1152)).toBe(false); - expect(resolveTimelineMinimapHasPersistentGutter(2560, 2560)).toBe(false); - - expect(resolveTimelineMinimapHitStripWidth(768, 768)).toBe(0); - expect(resolveTimelineMinimapHitStripWidth(792, 768)).toBe(0); - expect(resolveTimelineMinimapHitStripWidth(820, 768)).toBe(14); - expect(resolveTimelineMinimapHitStripWidth(872, 768)).toBe(40); - expect(resolveTimelineMinimapHitStripWidth(1400, 768)).toBe(40); - expect(resolveTimelineMinimapHitStripWidth(2560, 2560)).toBe(0); - expect(resolveTimelineMinimapHitStripWidth(1204, 1152)).toBe(14); - expect(resolveTimelineMinimapHitStripWidth(0, 0)).toBe(0); - expect(resolveTimelineMinimapHitStripWidth(Number.NaN, 768)).toBe(0); - - expect(resolveTimelineMinimapNavigationInteractive(40)).toBe(true); - expect(resolveTimelineMinimapNavigationInteractive(14)).toBe(true); - expect(resolveTimelineMinimapNavigationInteractive(8)).toBe(false); - expect(resolveTimelineMinimapNavigationInteractive(0)).toBe(false); - - expect(resolveTimelineMinimapInteractiveWidth(0, false)).toBe(0); - expect(resolveTimelineMinimapInteractiveWidth(14, false)).toBe(14); - expect(resolveTimelineMinimapInteractiveWidth(40, false)).toBe(40); - expect(resolveTimelineMinimapInteractiveWidth(0, true)).toBe("22rem"); - expect(resolveTimelineMinimapInteractiveWidth(14, true)).toBe("22rem"); - expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem"); - }); - - it("anchors a sent attachment message using its measured height", () => { - const onAnchorReady = vi.fn(); - const onAnchorSizeChanged = vi.fn(); - const firstEntry = buildAssistantTimelineEntry("Earlier reply."); - const secondEntry = { - ...buildUserTimelineEntry("Newest prompt."), - id: "entry-2", - message: { - ...buildUserTimelineEntry("Newest prompt.").message, - id: MessageId.make("message-2"), - attachments: [ - { - type: "image" as const, - id: "attachment-1", - name: "screenshot.png", - mimeType: "image/png", - sizeBytes: 1, - previewUrl: "data:image/png;base64,iVBORw0KGgo=", - }, - ], - }, - }; - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('data-anchor-index="1"'); - expect(markup).toContain('data-anchor-offset="24"'); - expect(markup).toContain('data-anchor-on-ready="true"'); - expect(markup).not.toContain("data-anchor-max-size="); - expect(markup).toContain('data-content-inset-end="144"'); - expect(markup).toContain("[overflow-anchor:none]"); - expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"'); - expect(markup).toContain('data-maintain-visible-content-position="object"'); - expect(markup).toContain('data-maintain-visible-content-position-data="true"'); - expect(markup).toContain('data-maintain-visible-content-position-size="true"'); - expect(markup).toContain('data-maintain-visible-content-position-restore="true"'); - expect(onAnchorReady).toHaveBeenCalledOnce(); - expect(onAnchorReady).toHaveBeenCalledWith(secondEntry.message.id, 1); - expect(onAnchorSizeChanged).toHaveBeenCalledWith(secondEntry.message.id, 240); - }); - - it("renders SnapShot window details after the preview resolves", () => { - const onAnchorReady = vi.fn(); - const firstEntry = buildSnapShotTimelineEntry("data:image/png;base64,iVBORw0KGgo="); - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("Terminal"); - expect(markup).toContain("t3code — Tests"); - expect(markup).toContain('src="data:image/png;base64,aWNvbg=="'); - expect(onAnchorReady).toHaveBeenCalledOnce(); - expect(onAnchorReady).toHaveBeenCalledWith(firstEntry.message.id, 0); - }); - - it("does not render SnapShot window details before the preview resolves", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("screenshot.png"); - expect(markup).not.toContain("Terminal"); - expect(markup).not.toContain("t3code — Tests"); - expect(markup).not.toContain('src="data:image/png;base64,aWNvbg=="'); - expect(markup).not.toContain("h-28 w-52 max-w-full"); - }); - - it("does not reserve end space for a follow-up user message", () => { - const onAnchorReady = vi.fn(); - const firstEntry = buildUserTimelineEntry("First prompt."); - const secondEntry = { - ...buildUserTimelineEntry("Newest prompt."), - id: "entry-2", - message: { - ...buildUserTimelineEntry("Newest prompt.").message, - id: MessageId.make("message-2"), - }, - }; - const markup = renderToStaticMarkup( - , - ); - - expect(markup).not.toContain("data-anchor-index="); - expect(markup).toContain('data-maintain-scroll-at-end="enabled"'); - expect(onAnchorReady).not.toHaveBeenCalled(); - }); - - it("offers preview and download actions for PDF attachments", () => { - const entry = { - ...buildUserTimelineEntry("Read the report."), - message: { - ...buildUserTimelineEntry("Read the report.").message, - attachments: [ - { - type: "file" as const, - id: "attachment-report-pdf", - name: "report.pdf", - mimeType: "application/pdf", - sizeBytes: 42, - previewUrl: "https://environment.test/api/assets/report.pdf", - }, - ], - }, - }; - - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('aria-label="Preview report.pdf"'); - expect(markup).toContain('aria-label="Download report.pdf"'); - expect(markup).not.toContain('alt="report.pdf"'); - }); - - it("renders a file download button without creating its URL in advance", () => { - const entry = { - ...buildUserTimelineEntry("Read the report."), - message: { - ...buildUserTimelineEntry("Read the report.").message, - attachments: [ - { - type: "file" as const, - id: "attachment-report-pdf", - name: "report.pdf", - mimeType: "application/pdf", - sizeBytes: 42, - }, - ], - }, - }; - - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('aria-label="Preview report.pdf"'); - expect(markup).toContain('aria-label="Download report.pdf"'); - expect(markup).not.toContain(" { - const entry = { - ...buildUserTimelineEntry("Read the report."), - message: { - ...buildUserTimelineEntry("Read the report.").message, - attachments: [ - { - type: "file" as const, - id: "composer-local-report", - name: "report.pdf", - mimeType: "application/pdf", - sizeBytes: 42, - downloadable: false, - }, - ], - }, - }; - - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("report.pdf"); - expect(markup).not.toContain('aria-label="Download report.pdf"'); - }); - - it("renders unknown attachment types as inert rows instead of crashing", () => { - const entry = { - ...buildUserTimelineEntry("Play the recording."), - message: { - ...buildUserTimelineEntry("Play the recording.").message, - attachments: [ - { - type: "recording", - id: "attachment-voice-memo", - name: "voice-memo.ogg", - mimeType: "audio/ogg", - sizeBytes: 42, - }, - ], - }, - }; - - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("voice-memo.ogg"); - expect(markup).not.toContain('aria-label="Download voice-memo.ogg"'); - expect(markup).not.toContain('alt="voice-memo.ogg"'); - expect(markup).not.toContain(" { - const runId = RunId.make("run-with-active-tool"); - const firstEntry = buildUserTimelineEntry("Run the command."); - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('data-anchor-index="0"'); - expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"'); - }); - - it("hands end-following back to the list once the send anchor is released", () => { - const firstEntry = buildUserTimelineEntry("First prompt."); - const secondEntry = { - ...buildUserTimelineEntry("Newest prompt."), - id: "entry-2", - message: { - ...buildUserTimelineEntry("Newest prompt.").message, - id: MessageId.make("message-2"), - }, - }; - const timelineEntries = [firstEntry, secondEntry]; - - expect( - renderToStaticMarkup( - , - ), - ).not.toContain('data-maintain-scroll-at-end="enabled"'); - - expect( - renderToStaticMarkup( - , - ), - ).toContain('data-maintain-scroll-at-end="enabled"'); - - expect( - renderToStaticMarkup( - , - ), - ).not.toContain('data-maintain-scroll-at-end="enabled"'); - }); - - it("renders collapse controls for long user messages", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("Show full message"); - expect(markup).toContain('data-maintain-scroll-at-end="enabled"'); - expect(markup).toContain('data-maintain-scroll-at-end-animated="false"'); - expect(markup).toContain('data-maintain-scroll-at-end-data-change="true"'); - expect(markup).toContain('data-maintain-scroll-at-end-item-layout="true"'); - expect(markup).toContain('data-maintain-scroll-at-end-layout="true"'); - expect(markup).toContain('data-user-message-collapsed="true"'); - expect(markup).toContain('data-user-message-fade="true"'); - expect(markup).toContain('data-user-message-footer="true"'); - }); - - it("does not render collapse controls for short user messages", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).not.toContain("Show full message"); - expect(markup).toContain('data-user-message-collapsible="false"'); - expect(markup).toContain("rounded-2xl bg-message p-3"); - }); - - it("identifies user-role messages sent by another agent", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const entry = buildUserTimelineEntry("Review this area"); - const agentMarkup = renderToStaticMarkup( - , - ); - const userMarkup = renderToStaticMarkup( - , - ); - - expect(agentMarkup).toContain('data-user-message-attribution="agent"'); - expect(agentMarkup).toContain("Sent by another agent"); - expect(userMarkup).not.toContain("Sent by another agent"); - }); - - it("keeps a subagent parent-thread link at the top of an empty timeline", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('aria-label="Open parent thread"'); - expect(markup).toContain("Subagent of"); - expect(markup).toContain("Architecture audit"); - expect(markup).not.toContain("Send a message to start the conversation"); - }); - - it("keeps steer intent visible on committed user messages", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const entry = buildUserTimelineEntry("Adjust the current turn"); - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("data-base-ui-tooltip-trigger"); - expect(markup).toContain("lucide-redo-2"); - expect(markup).toContain('data-user-message-intent="steer"'); - expect(markup).toContain("items-center justify-end gap-1"); - expect(markup).toContain("gap-1 text-xs leading-none text-muted-foreground"); - expect(markup.indexOf("Steer")).toBeLessThan(markup.indexOf("Adjust the current turn")); - }); - - it("keeps compact spacing below a collapsed turn divider", () => { - const runId = RunId.make("run-collapsed-spacing"); - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('class="pb-1.5" data-timeline-row-id="turn-fold:'); - expect(markup).toContain('data-timeline-row-kind="turn-fold"'); - }); - - it("shows a collapsed disclosure for superseded attempt output", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const runId = RunId.make("run-steered"); - const supersededAttempt = { - id: "attempt-1" as never, - runId, - attemptOrdinal: 1, - rootNodeId: "node-attempt-1" as never, - status: "superseded" as const, - completedAt: null, - }; - const activeAttempt = { - id: "attempt-2" as never, - runId, - attemptOrdinal: 2, - rootNodeId: "node-attempt-2" as never, - status: "running" as const, - completedAt: null, - }; - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('data-superseded-attempt-id="attempt-1"'); - expect(markup).toContain('aria-expanded="false"'); - expect(markup).toContain("Superseded attempt"); - expect(markup).toContain("Cut off by a steer"); - expect(markup).toContain("Current response remains visible"); - expect(markup).not.toContain("Partial response from the old attempt"); - }); - - it("exposes a per-response fork action for completed assistant items", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const projectedItem = { - position: 0, - visibility: "local", - sourceThreadId: "thread-1", - sourceItemId: "assistant-item-1", - item: { - id: "assistant-item-1", - threadId: "thread-1", - runId: "run-1", - nodeId: null, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: 0, - status: "completed", - title: null, - startedAt: null, - completedAt: null, - updatedAt: {}, - type: "assistant_message", - messageId: "assistant-message-1", - text: "Done", - streaming: false, - }, - } as never; - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('aria-label="Fork from this response"'); - }); - - 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"), - ), - ]} - />, - ); - - expect(markup).toContain("Terminal 1 lines 1-5"); - expect(markup).toContain("lucide-terminal"); - expect(markup).toContain("yoo what's"); - expect(markup).not.toContain("terminal_context"); - expect(markup).toContain("Show full message"); - }, 20_000); - - it("renders chips for standalone element-pick context messages", () => { - const markup = renderToStaticMarkup( - ", - "- (Button.tsx:12):", - " url: https://example.com/dashboard", - " selector: button.submit", - " source: /repo/src/Button.tsx:12:5", - " html:", - ' ', - "", - ].join("\n"), - ), - ]} - />, - ); - - expect(markup).toContain("SubmitButton"); - expect(markup).not.toContain("<element_context"); - expect(markup).not.toContain(" { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('aria-label="Copy message"'); - expect(markup).toContain('data-user-message-collapsed="true"'); - expect(markup).toContain('data-user-message-footer="true"'); - }); - - it("renders context compaction entries in the normal work log", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("Context compacted"); - }); - - it("does not render the transient V2 interruption request", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - , - ); - - expect(markup).not.toContain('data-v2-item-type="run_interrupt_request"'); - expect(markup).not.toContain("Interrupt requested"); - expect(markup).not.toContain("Waiting for the provider to stop."); - expect(markup).not.toContain("Structured details"); - }); - - it("renders context handoffs as from → to model endpoints instead of the summary", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const providerStatuses = [ - { - instanceId: "codex_personal", - driver: "codex", - enabled: true, - installed: true, - version: null, - status: "ready", - auth: {}, - checkedAt: MESSAGE_CREATED_AT, - models: [{ slug: "gpt-5.6-sol", name: "GPT 5.6 Sol", isCustom: false, capabilities: null }], - slashCommands: [], - skills: [], - }, - { - instanceId: "claudeAgent", - driver: "claudeAgent", - enabled: true, - installed: true, - version: null, - status: "ready", - auth: {}, - checkedAt: MESSAGE_CREATED_AT, - models: [ - { slug: "claude-fable-5", name: "Claude Fable 5", isCustom: false, capabilities: null }, - ], - slashCommands: [], - skills: [], - }, - ] as never; - const buildHandoffEntry = (item: Record) => ({ - id: "handoff-1", - kind: "event" as const, - createdAt: MESSAGE_CREATED_AT, - projectedItem: { - position: 0, - visibility: "local", - sourceThreadId: "thread-1", - sourceItemId: "handoff-1", - item: { - id: "handoff-1", - threadId: "thread-1", - runId: "run-2", - nodeId: null, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: 0, - status: "completed", - title: "Provider handoff", - startedAt: null, - completedAt: null, - updatedAt: {}, - type: "handoff", - contextHandoffId: "handoff-1", - fromProviderThreadIds: ["provider-thread-1"], - toProviderThreadId: "provider-thread-2", - strategy: "full_thread_summary", - summary: "Full conversation context for provider handoff.", - ...item, - }, - } as never, - }); - - const { RegistryProvider } = await import("@effect/atom-react"); - const { serverEnvironment } = await import("../../state/server"); - const renderWithProviders = (element: ReactElement) => - renderToStaticMarkup( - - {element} - , - ); - const markup = renderWithProviders( - , - ); - - expect(markup).toContain("Context handoff"); - expect(markup).toContain("GPT 5.6 Sol"); - expect(markup).toContain("Claude Fable 5"); - expect(markup).not.toContain("Full conversation context"); - expect(markup).not.toContain("·"); - - const legacyRuns = [ - { - id: "run-1", - ordinal: 1, - providerInstanceId: "codex_personal", - modelSelection: { instanceId: "codex_personal", model: "gpt-5.6-sol" }, - }, - { - id: "run-2", - ordinal: 2, - providerInstanceId: "claudeAgent", - modelSelection: { instanceId: "claudeAgent", model: "claude-fable-5" }, - }, - ] as never; - const legacyMarkup = renderWithProviders( - , - ); - - expect(legacyMarkup).toContain("GPT 5.6 Sol"); - expect(legacyMarkup).toContain("Claude Fable 5"); - expect(legacyMarkup).not.toContain("Full conversation context"); - - const bareMarkup = renderWithProviders( - , - ); - - expect(bareMarkup).toContain("Codex Personal"); - expect(bareMarkup).not.toContain("Full conversation context"); - }); - - it("renders created threads as lean rows with inline chat links", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('data-v2-item-type="thread_created"'); - expect(markup).toContain('aria-label="Open Claude research thread"'); - expect(markup).toContain("Claude research thread"); - expect(markup).toContain("Open chat"); - expect(markup).not.toContain("Work Log"); - }); - - it("keeps the collapsed summary icon neutral when the group ends in a failure", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("Ran 2 commands"); - expect(markup).toContain("lucide-terminal"); - expect(markup).not.toContain("lucide-x"); - expect(markup).not.toContain("text-destructive"); - expect(markup).toContain("tool call failed"); - }); - - it("keeps mixed work logs neutral after a later tool call succeeds", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("Ran 2 commands and received 1 update"); - expect(markup).not.toContain('aria-label="Hidden work includes a failure"'); - }); - - it.each( - ( - [ - { - status: "running", - progress: "Reading src/index.ts", - result: null, - preview: "Reading src/index.ts", - }, - { - status: "completed", - progress: "Reading src/index.ts", - result: "Tests should be isolated.", - preview: "Tests should be isolated.", - }, - { - status: "running", - progress: "Reading src/index.ts", - result: "Partial streamed answer", - preview: "Reading src/index.ts", - }, - { - status: "running", - progress: undefined, - result: "Streaming answer so far", - preview: "Streaming answer so far", - }, - { - status: "cancelled", - progress: "Reading src/index.ts", - result: "Partial output before cancel", - preview: "Partial output before cancel", - }, - { - status: "completed", - progress: "Audited 12 packages", - result: " \n\t ", - preview: "Audited 12 packages", - }, - ] as const - ).flatMap((scenario) => [1, 2].map((count) => ({ ...scenario, count }))), - )( - "shows $count $status subagents with '$preview', grouping only multiple agents", - async ({ status, progress, result, preview, count }) => { - activityTestState.expandedRuns = true; - activityTestState.subagentTooltips = true; - vi.stubGlobal("HTMLElement", ElementStub); - window.HTMLElement = ElementStub as unknown as typeof HTMLElement; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("requestAnimationFrame", () => 0); - vi.stubGlobal("cancelAnimationFrame", () => {}); - const onOpenThread = vi.fn(); - let renderer: ReactTestRenderer | undefined; - try { - await act(() => { - renderer = create( - ({ - id: `subagent-progress-${index}`, - kind: "event", - createdAt: MESSAGE_CREATED_AT, - projectedItem: { - position: 0, - visibility: "local", - sourceThreadId: "thread-1", - sourceItemId: "subagent-progress", - item: { - id: `subagent-progress-${index}`, - threadId: "thread-1", - runId: "run-1", - nodeId: `node-subagent-${index}`, - providerThreadId: "provider-thread-1", - providerTurnId: "provider-turn-1", - nativeItemRef: null, - parentItemId: null, - ordinal: 1, - status, - title: "Package audit", - startedAt: null, - completedAt: null, - updatedAt: {}, - type: "subagent", - subagentId: `node-subagent-${index}`, - origin: "provider_native", - driver: "claudeAgent", - providerInstanceId: "claudeAgent", - childThreadId: "thread-subagent-1", - prompt: "Inspect the package", - progress, - result, - }, - } as never, - }))} - />, - ); - }); - const groupLabel = `${count} subagents`; - const group = () => - renderer!.root.findAll( - (node) => node.type === "button" && node.props["aria-label"] === groupLabel, - )[0]!; - const child = () => - renderer!.root.findAll( - (node) => node.type === "button" && node.props["aria-label"] === "Open Package audit", - ); - if (count > 1) { - expect(child()).toHaveLength(0); - await act(() => group().props.onClick({ nativeEvent: new Event("click") })); - } else { - expect(group()).toBeUndefined(); - } - expect(child()).toHaveLength(count); - const content = renderer!.root - .findAll((node) => typeof node.type === "string") - .flatMap((node) => node.children.filter((child) => typeof child === "string")) - .join(""); - expect(content).toContain(preview); - expect(content).not.toContain("Inspect the package"); - if (result?.trim() && result !== preview) expect(content).not.toContain(result); - if (progress && progress !== preview) expect(content).not.toContain(progress); - await act(() => child()[0]!.props.onClick()); - expect(onOpenThread).toHaveBeenCalledWith("thread-subagent-1"); - if (count > 1) { - await act(() => group().props.onClick({ nativeEvent: new Event("click") })); - expect(child()).toHaveLength(0); - } - } finally { - await act(() => renderer?.unmount()); - vi.stubGlobal("HTMLElement", undefined); - } - }, - ); - - it("renders V2 provider retries in the normal work log", () => { - activityTestState.expanded = true; - const retryItem = { - id: "provider-error", - threadId: "thread-1", - runId: "run-1", - nodeId: null, - providerThreadId: "provider-thread-1", - providerTurnId: "provider-turn-1", - nativeItemRef: null, - parentItemId: null, - ordinal: 99, - status: "running", - title: "Provider retry", - startedAt: {}, - completedAt: null, - updatedAt: {}, - type: "error", - failure: { - class: "transport_error", - message: "The response stream disconnected.", - code: "responseStreamDisconnected", - retryable: true, - }, - retry: { - attempt: 2, - maxAttempts: 5, - retryDelayMs: null, - }, - } as const; - const projectedItem = { - position: 0, - visibility: "local", - sourceThreadId: "thread-1", - sourceItemId: retryItem.id, - item: retryItem, - } as const; - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('data-v2-item-type="error"'); - expect(markup).toContain("Retrying provider (2/5)"); - expect(markup).toContain('aria-expanded="false"'); - expect(markup).not.toContain('data-v2-event-disclosure="true"'); - }); - - it("keeps inherited V2 work provenance on the rendered row", async () => { - activityTestState.expanded = true; - const { MessagesTimeline } = await import("./MessagesTimeline"); - const item = { - id: "command-inherited", - threadId: "thread-source", - runId: null, - nodeId: null, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: 0, - status: "completed", - title: null, - startedAt: null, - completedAt: null, - updatedAt: {}, - type: "command_execution", - input: "pwd", - output: "/workspace", - exitCode: 0, - } as const; - const projectedItem = { - position: 0, - visibility: "inherited", - sourceThreadId: "thread-source", - sourceItemId: item.id, - item, - } as const; - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('data-v2-item-type="command_execution"'); - expect(markup).toContain('data-v2-item-visibility="inherited"'); - expect(markup).toContain("Received 1 update and ran 1 command"); - }); - - it("renders T3 MCP dynamic tools with the product logo and pretty name", async () => { - activityTestState.expanded = true; - const { MessagesTimeline } = await import("./MessagesTimeline"); - const item = { - id: "tool-t3-thread-read", - threadId: "thread-source", - runId: null, - nodeId: null, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: 0, - status: "completed", - title: null, - startedAt: null, - completedAt: null, - updatedAt: {}, - type: "dynamic_tool", - toolName: "mcp__t3-code__t3_thread_read", - input: { threadId: "thread-child" }, - output: { messages: [] }, - } as const; - const projectedItem = { - position: 0, - visibility: "local", - sourceThreadId: "thread-source", - sourceItemId: item.id, - item, - } as const; - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('viewBox="15.5309 37 94.3941 56.96"'); - expect(markup).toContain("Read a T3 thread"); - expect(markup).not.toContain("mcp__t3-code__t3_thread_read"); - }); - - it("formats changed file paths from the workspace root", async () => { - activityTestState.expanded = true; - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("t3code/apps/web/src/session-logic.ts"); - expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); - }); - - it("renders review comment contexts as structured cards instead of raw tags", () => { - const markup = renderToStaticMarkup( - ', - "Wadduo", - "```diff", - "@@ -0,0 +47,2 @@", - '+ it("keeps valid zero-usage snapshots", () => {', - "+ expect(snapshot).not.toBeNull();", - "```", - "", - ].join("\n"), - runId: null, - createdAt: "2026-03-17T19:12:28.000Z", - updatedAt: "2026-03-17T19:12:28.000Z", - streaming: false, - }, - }, - ]} - />, - ); - - expect(markup).toContain("contextWindow.test.ts +47 to +58"); - expect(markup).toContain("lucide-message-circle"); - expect(markup).not.toContain(">Review comment<"); - expect(markup).not.toContain("<review_comment"); - expect(markup).not.toContain("</review_comment>"); - }); - - it("renders file review comments as source code instead of diffs", () => { - const markup = renderToStaticMarkup( - ', - "Clarify this.", - "```md", - "# Plan", - "- Step one", - "```", - "", - ].join("\n"), - runId: null, - createdAt: "2026-03-17T19:12:28.000Z", - updatedAt: "2026-03-17T19:12:28.000Z", - streaming: false, - }, - }, - ]} - />, - ); - - expect(markup).toContain("plan.md L1 to L2"); - expect(markup).not.toContain("review_comment"); - expect(markup).not.toContain('data-testid="file-diff"'); - }); - - it("collapses settled tool runs behind a generated summary toggle", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("Ran 2 commands"); - expect(markup).toContain('aria-expanded="false"'); - expect(markup).not.toContain("vp lint"); - }); - - it("renders a muted failure marker for failed tool lifecycle entries", () => { - activityTestState.expanded = true; - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("lucide-zap"); - expect(markup).toContain('aria-label="Tool call failed"'); - expect(markup).not.toContain("text-destructive"); - }); - - it("keeps the red treatment for severe orchestration failures", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("lucide-circle-alert"); - expect(markup).toContain("text-destructive"); - }); - - it.each([ - [ - "**Viewing image first** with *care*, ~~old~~ `code` and [context](https://example.com)", - "Viewing image first with care, old code and context", - 1, - ], - ["first paragraph\n\nsecond paragraph", "first paragraph second paragraph", 0], - ["- first\n- second", "first second", 0], - ["first \nsecond", "first second", 0], - ["![image description](image.png)", "image description", 0], - ["![](image.png)", "Thought", 0], - ["---", "Thought", 0], - ] as const)( - "shows plain text for a V2 reasoning preview: %s", - async (markdown, expected, strongCount) => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("requestAnimationFrame", () => 0); - vi.stubGlobal("cancelAnimationFrame", () => {}); - activityTestState.expanded = true; - let renderer: ReactTestRenderer | undefined; - try { - await act(() => { - renderer = create( - , - ); - }); - const previewText = () => - renderer!.root - .findAllByType("span") - .flatMap((node) => node.findAll(() => true)) - .flatMap((node) => node.children) - .filter((child) => typeof child === "string") - .join(" "); - expect(previewText()).toContain(expected); - expect(renderer!.root.findAllByType("strong")).toHaveLength(0); - const row = () => - renderer!.root.findAll( - (node) => - node.type === "div" && - node.props.role === "button" && - typeof node.props["aria-expanded"] === "boolean", - )[0]!; - await act(() => row().props.onClick()); - expect(renderer!.root.findAllByType("strong")).toHaveLength(strongCount); - await act(() => row().props.onClick()); - expect(previewText()).toContain(expected); - expect(renderer!.root.findAllByType("strong")).toHaveLength(0); - } finally { - await act(() => renderer?.unmount()); - } - }, - ); - - it("expands and collapses a tool call through its header", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("requestAnimationFrame", () => 0); - vi.stubGlobal("cancelAnimationFrame", () => {}); - let renderer: ReactTestRenderer | undefined; - try { - await act(() => { - renderer = create( - , - ); - }); - await act(() => renderer!.root.findByProps({ "aria-expanded": false }).props.onClick()); - const expanded = renderer!.root.findAll( - (node) => node.type === "div" && node.props["aria-expanded"] === true, - )[0]!; - expect(expanded).toBeDefined(); - await act(() => expanded.props.onClick()); - expect( - renderer!.root.findAll( - (node) => node.type === "div" && node.props["aria-expanded"] === true, - ), - ).toHaveLength(0); - } finally { - await act(() => renderer?.unmount()); - } - }); -}); From 7c6b89bf505f885baf289eb296b7c22a5d2a2e84 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:54:25 -0700 Subject: [PATCH 2/3] chore(web): resolvePreviewAnnotationImage is no longer exported for tests Co-Authored-By: Claude Opus 5.5 --- 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 cd7aa5723fba..494c4dfd91cf 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2443,7 +2443,7 @@ function UserMessageIntentMarker({ ); } -export function resolvePreviewAnnotationImage(input: { +function resolvePreviewAnnotationImage(input: { record: Extract; recordsById: ReadonlyMap; userImages: ReadonlyArray; From c89f61fd2b02ad61891381dc21356f427661e4e6 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:06:28 -0700 Subject: [PATCH 3/3] test(web): timeline browser test fixtures use classes for static sizes Co-Authored-By: Claude Opus 5.5 --- .../src/components/chat/MessagesTimeline.browser.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index d562fff0aa6b..71e6cf5d0427 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -1089,9 +1089,9 @@ describe("timeline tooltip scroll dismissal", () => { await renderElement( <> -
    +
    {outside ? null : tooltip} -
    +
    {outside ? tooltip : null} @@ -1171,7 +1171,7 @@ describe("MessagesTimeline scroll position", () => { const [liveFollowEnabled, setLiveFollowEnabled] = useState(true); // The chat view stops following the live edge when the reader scrolls. return ( -
    setLiveFollowEnabled(false)}> +
    setLiveFollowEnabled(false)}>