1// @vitest-environment jsdom
2
3import {
4 ApprovalRequestId,
5 CheckpointRef,
6 EnvironmentId,
7 MessageId,
8 ProjectId,
9 RunId,
10 ThreadId,
11 TurnItemId,
12 type OrchestrationV2TurnItem,
13} from "@t3tools/contracts";
14import * as DateTime from "effect/DateTime";
15import {
16 act,
17 createRef,
18 useLayoutEffect,
19 useState,
20 type ReactNode,
21 type Ref,
22 type ReactElement,
23} from "react";
24import { renderToStaticMarkup } from "react-dom/server";
25import { createRoot } from "react-dom/client";
26import { create, type ReactTestRenderer } from "react-test-renderer";
27import { shouldUseRestingComposerLayout } from "../composerFooterLayout";
28import { useComposerFocusState } from "./useComposerFocusState";
29import { beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test";
30import type { LegendListRef } from "@legendapp/list/react";
31
32const activityTestState = vi.hoisted(() => ({
33 expanded: false,
34 expandedRuns: false,
35 subagentTooltips: false,
36}));
37
38// Expose tooltip contents in the renderer without requiring a browser portal.
39vi.mock("../ui/tooltip", async (importOriginal) => {
40 const original = await importOriginal<typeof import("../ui/tooltip")>();
41 return {
42 ...original,
43 Tooltip: (props: { children?: ReactNode }) =>
44 activityTestState.subagentTooltips ? props.children : <original.Tooltip {...props} />,
45 TooltipTrigger: (props: { children?: ReactNode; render?: ReactElement }) =>
46 activityTestState.subagentTooltips ? (
47 <>
48 {props.render}
49 {props.children}
50 </>
51 ) : (
52 <original.TooltipTrigger {...props} />
53 ),
54 TooltipPopup: (props: { children?: ReactNode }) =>
55 activityTestState.subagentTooltips ? props.children : <original.TooltipPopup {...props} />,
56 };
57});
58
59vi.mock("../DiffWorkerPoolProvider", () => ({
60 DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => children,
61}));
62
63vi.mock("./MessagesTimeline.logic", async (importOriginal) => {
64 const logic = await importOriginal<typeof import("./MessagesTimeline.logic")>();
65 return {
66 ...logic,
67 deriveMessagesTimelineRowsWithState(
68 input: Parameters<typeof logic.deriveMessagesTimelineRowsWithState>[0],
69 previous: Parameters<typeof logic.deriveMessagesTimelineRowsWithState>[1],
70 ) {
71 if (activityTestState.expandedRuns) {
72 input = { ...input, expandedRunIds: new Set([RunId.make("run-1")]) };
73 }
74 const projection = logic.deriveMessagesTimelineRowsWithState(input, previous);
75 if (!activityTestState.expanded) return projection;
76 return logic.deriveMessagesTimelineRowsWithState({
77 ...input,
78 expandedWorkGroupIds: new Set(
79 projection.rows.flatMap((row) =>
80 row.kind === "work-toggle" || row.kind === "work-live" ? [row.groupId] : [],
81 ),
82 ),
83 });
84 },
85 };
86});
87
88beforeEach(() => {
89 activityTestState.subagentTooltips = false;
90 activityTestState.expanded = false;
91 activityTestState.expandedRuns = false;
92});
93
94const legendListTestState = vi.hoisted(() => ({
95 data: undefined as unknown,
96 onScroll: undefined as (() => void) | undefined,
97}));
98
99vi.mock("@legendapp/list/react", async () => {
100 const legendListTestId = "legend-list";
101
102 const LegendList = (props: {
103 data: Array<{ id: string }>;
104 keyExtractor: (item: { id: string }) => string;
105 renderItem: (args: { item: { id: string } }) => ReactNode;
106 ListHeaderComponent?: ReactNode;
107 ListFooterComponent?: ReactNode;
108 anchoredEndSpace?: {
109 anchorIndex: number;
110 anchorMaxSize?: number;
111 anchorOffset?: number;
112 onReady?: (info: { anchorIndex: number }) => void;
113 onSizeChanged?: (size: number) => void;
114 };
115 maintainScrollAtEnd?:
116 | boolean
117 | {
118 animated?: boolean;
119 on?: {
120 dataChange?: boolean;
121 itemLayout?: boolean;
122 layout?: boolean;
123 };
124 };
125 maintainVisibleContentPosition?:
126 | boolean
127 | {
128 data?: boolean;
129 size?: boolean;
130 shouldRestorePosition?: boolean;
131 };
132 className?: string;
133 contentInsetEndAdjustment?: number;
134 onScroll?: () => void;
135 ref?: Ref<LegendListRef>;
136 }) => {
137 legendListTestState.data = props.data;
138 legendListTestState.onScroll = props.onScroll;
139 if (props.anchoredEndSpace) {
140 props.anchoredEndSpace.onSizeChanged?.(240);
141 props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex });
142 }
143 return (
144 <div
145 data-testid={legendListTestId}
146 data-class-name={props.className}
147 data-anchor-index={props.anchoredEndSpace?.anchorIndex}
148 data-anchor-max-size={props.anchoredEndSpace?.anchorMaxSize}
149 data-anchor-offset={props.anchoredEndSpace?.anchorOffset}
150 data-anchor-on-ready={Boolean(props.anchoredEndSpace?.onReady)}
151 data-content-inset-end={props.contentInsetEndAdjustment}
152 data-maintain-visible-content-position={
153 typeof props.maintainVisibleContentPosition === "object"
154 ? "object"
155 : props.maintainVisibleContentPosition
156 }
157 data-maintain-visible-content-position-data={
158 typeof props.maintainVisibleContentPosition === "object"
159 ? props.maintainVisibleContentPosition.data
160 : undefined
161 }
162 data-maintain-visible-content-position-size={
163 typeof props.maintainVisibleContentPosition === "object"
164 ? props.maintainVisibleContentPosition.size
165 : undefined
166 }
167 data-maintain-visible-content-position-restore={
168 typeof props.maintainVisibleContentPosition === "object"
169 ? Boolean(props.maintainVisibleContentPosition.shouldRestorePosition)
170 : undefined
171 }
172 data-maintain-scroll-at-end={props.maintainScrollAtEnd ? "enabled" : undefined}
173 data-maintain-scroll-at-end-animated={
174 typeof props.maintainScrollAtEnd === "object"
175 ? props.maintainScrollAtEnd.animated
176 : undefined
177 }
178 data-maintain-scroll-at-end-data-change={
179 typeof props.maintainScrollAtEnd === "object"
180 ? props.maintainScrollAtEnd.on?.dataChange
181 : undefined
182 }
183 data-maintain-scroll-at-end-item-layout={
184 typeof props.maintainScrollAtEnd === "object"
185 ? props.maintainScrollAtEnd.on?.itemLayout
186 : undefined
187 }
188 data-maintain-scroll-at-end-layout={
189 typeof props.maintainScrollAtEnd === "object"
190 ? props.maintainScrollAtEnd.on?.layout
191 : undefined
192 }
193 >
194 {props.ListHeaderComponent}
195 {props.data.map((item) => (
196 <div key={props.keyExtractor(item)}>{props.renderItem({ item })}</div>
197 ))}
198 {props.ListFooterComponent}
199 </div>
200 );
201 };
202
203 return { LegendList };
204});
205
206function MockFileDiff(props: {
207 fileDiff: { name?: string | null; prevName?: string | null };
208 renderCustomHeader?: (fileDiff: {
209 name?: string | null;
210 prevName?: string | null;
211 }) => React.ReactNode;
212}) {
213 return (
214 <div data-testid="file-diff">
215 {props.renderCustomHeader?.(props.fileDiff)}
216 {props.fileDiff.name ?? props.fileDiff.prevName ?? "diff"}
217 </div>
218 );
219}
220
221vi.mock("@pierre/diffs/react", () => {
222 return { FileDiff: MockFileDiff };
223});
224
225function matchMedia() {
226 return {
227 matches: false,
228 addEventListener: () => {},
229 removeEventListener: () => {},
230 };
231}
232
233let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline;
234let resolvePreviewAnnotationImage: typeof import("./MessagesTimeline").resolvePreviewAnnotationImage;
235
236const ElementStub = class ElementStub {};
237function stubDomGlobals() {
238 const classList = {
239 add: () => {},
240 remove: () => {},
241 toggle: () => {},
242 contains: () => false,
243 };
244
245 vi.stubGlobal("Element", ElementStub);
246 vi.stubGlobal("getComputedStyle", undefined);
247 vi.stubGlobal("localStorage", {
248 getItem: () => null,
249 setItem: () => {},
250 removeItem: () => {},
251 clear: () => {},
252 });
253 vi.stubGlobal("window", {
254 Element: ElementStub,
255 localStorage: globalThis.localStorage,
256 matchMedia,
257 addEventListener: () => {},
258 removeEventListener: () => {},
259 requestAnimationFrame: (callback: FrameRequestCallback) => {
260 callback(0);
261 return 0;
262 },
263 cancelAnimationFrame: () => {},
264 desktopBridge: undefined,
265 });
266 vi.stubGlobal("document", {
267 documentElement: {
268 classList,
269 offsetHeight: 0,
270 },
271 });
272}
273
274beforeEach(stubDomGlobals);
275// Cold transformation of the full chat dependency graph needs extra time on slower CI workers.
276beforeAll(async () => {
277 Object.defineProperty(window, "matchMedia", { value: matchMedia, configurable: true });
278 ({ MessagesTimeline, resolvePreviewAnnotationImage } = await import("./MessagesTimeline"));
279}, 120_000);
280
281const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
282const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z";
283
284describe("timeline tooltip scroll dismissal", () => {
285 it.each([
286 "hover",
287 "delayed hover",
288 "focus",
289 "hover then focus",
290 "outside timeline",
291 "wheel without scroll",
292 "pr hover",
293 "pr delayed hover",
294 "pr focus",
295 "pr hover then focus",
296 ])("handles %s through the real tooltip interactions", async (scenario) => {
297 const isPullRequest = scenario.startsWith("pr ");
298 const interaction = isPullRequest ? scenario.slice(3) : scenario;
299 vi.unstubAllGlobals();
300 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
301 vi.useFakeTimers();
302 const { Tooltip, TooltipTrigger, TooltipPopup, TooltipScrollDismissArea } =
303 await vi.importActual<typeof import("../ui/tooltip")>("../ui/tooltip");
304 const container = document.createElement("div");
305 document.body.append(container);
306 const root = createRoot(container);
307 const onMouseEnter = vi.fn();
308 const query = await import("~/state/query");
309 const querySpy = isPullRequest
310 ? vi.spyOn(query, "useEnvironmentQuery").mockReturnValue({
311 data: null,
312 dataUpdatedAt: 0,
313 error: "Pull request not found",
314 failure: null,
315 isPending: false,
316 isSuccess: false,
317 refresh: vi.fn(),
318 })
319 : null;
320 const { PullRequestLinkPreview } = await import("../pullRequest/PullRequestLinkPreview");
321 const tooltip = isPullRequest ? (
322 <PullRequestLinkPreview
323 link={<button onMouseEnter={onMouseEnter}>message link</button>}
324 originalUrl="https://example.com"
325 target={{
326 environmentId: ACTIVE_THREAD_ENVIRONMENT_ID,
327 input: {
328 projectId: ProjectId.make("project-1"),
329 repository: "pingdotgg/t3code",
330 number: 1,
331 },
332 }}
333 />
334 ) : (
335 <Tooltip>
336 <TooltipTrigger delay={50} onMouseEnter={onMouseEnter}>
337 message link
338 </TooltipTrigger>
339 <TooltipPopup>https://example.com</TooltipPopup>
340 </Tooltip>
341 );
342 try {
343 await act(async () => {
344 root.render(
345 <>
346 <TooltipScrollDismissArea>
347 <div data-testid="scrollable">
348 {interaction === "outside timeline" ? null : tooltip}
349 </div>
350 </TooltipScrollDismissArea>
351 {interaction === "outside timeline" ? tooltip : null}
352 </>,
353 );
354 });
355 const trigger = container.querySelector<HTMLButtonElement>("button")!;
356 const scrollable = container.querySelector<HTMLElement>('[data-testid="scrollable"]')!;
357 await act(async () => {
358 if (interaction !== "focus") {
359 trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
360 trigger.dispatchEvent(new MouseEvent("mouseenter"));
361 trigger.dispatchEvent(new MouseEvent("mousemove", { bubbles: true }));
362 }
363 if (interaction === "focus" || interaction === "hover then focus") {
364 document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" }));
365 trigger.focus();
366 }
367 if (interaction !== "delayed hover") {
368 await vi.advanceTimersByTimeAsync(isPullRequest ? 400 : 60);
369 }
370 });
371 expect(onMouseEnter).toHaveBeenCalledTimes(interaction === "focus" ? 0 : 1);
372 expect(
373 document.querySelector('[data-slot="tooltip-popup"][data-open]')?.textContent ?? null,
374 ).toBe(interaction === "delayed hover" ? null : "https://example.com");
375
376 await act(async () => {
377 scrollable.dispatchEvent(
378 interaction === "wheel without scroll"
379 ? new WheelEvent("wheel", { bubbles: true, deltaY: 100 })
380 : new Event("scroll"),
381 );
382 await vi.advanceTimersByTimeAsync(isPullRequest ? 1000 : 100);
383 });
384 expect(
385 document.querySelector('[data-slot="tooltip-popup"][data-open]')?.textContent ?? null,
386 ).toBe(
387 interaction === "hover" || interaction === "delayed hover" ? null : "https://example.com",
388 );
389 if (interaction === "focus" || interaction === "hover then focus") {
390 expect(document.activeElement).toBe(trigger);
391 }
392 } finally {
393 await act(async () => root.unmount());
394 querySpy?.mockRestore();
395 container.remove();
396 vi.useRealTimers();
397 vi.unstubAllGlobals();
398 }
399 });
400});
401
402function buildProps() {
403 return {
404 isWorking: false,
405 activeTurnInProgress: false,
406 listRef: createRef<LegendListRef | null>(),
407 latestRun: null,
408 turnDiffSummaries: [],
409 providerStatuses: [],
410 runs: [],
411 routeThreadKey: "environment-local:thread-1",
412 onOpenTurnDiff: () => {},
413 onOpenThread: () => {},
414 onForkFromRun: async () => {},
415 onRollbackCheckpoint: () => {},
416 supportsConversationRollback: false,
417 onRevertToTurnCount: () => {},
418 isRevertingCheckpoint: false,
419 openingVideoAttachmentId: null,
420 onImageExpand: () => {},
421 activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID,
422 markdownCwd: undefined,
423 resolvedTheme: "light" as const,
424 timestampFormat: "locale" as const,
425 workspaceRoot: undefined,
426 anchorMessageId: null,
427 onAnchorReady: () => {},
428 onAnchorSizeChanged: () => {},
429 contentInsetEndAdjustment: 0,
430 liveFollowEnabled: true,
431 onIsAtEndChange: () => {},
432 onManualNavigation: () => {},
433 };
434}
435
436function buildLongUserMessageText(tail = "deep hidden detail only after expand") {
437 return Array.from({ length: 9 }, (_, index) =>
438 index === 8 ? tail : `Line ${index + 1}: ${"verbose prompt content ".repeat(8).trim()}`,
439 ).join("\n");
440}
441
442function buildUserTimelineEntry(text: string) {
443 return {
444 id: "entry-1",
445 kind: "message" as const,
446 createdAt: MESSAGE_CREATED_AT,
447 message: {
448 id: MessageId.make("message-1"),
449 role: "user" as const,
450 text,
451 runId: null,
452 createdAt: MESSAGE_CREATED_AT,
453 updatedAt: MESSAGE_CREATED_AT,
454 streaming: false,
455 },
456 };
457}
458
459function buildAssistantTimelineEntry(text: string) {
460 const entry = buildUserTimelineEntry(text);
461 return {
462 ...entry,
463 message: {
464 ...entry.message,
465 role: "assistant" as const,
466 },
467 };
468}
469
470function buildSnapShotTimelineEntry(previewUrl?: string) {
471 const entry = buildUserTimelineEntry("First prompt.");
472 return {
473 ...entry,
474 message: {
475 ...entry.message,
476 attachments: [
477 {
478 type: "image" as const,
479 id: "attachment-1",
480 name: "screenshot.png",
481 mimeType: "image/png",
482 sizeBytes: 1,
483 ...(previewUrl ? { previewUrl } : {}),
484 source: {
485 kind: "snap-shot" as const,
486 capturedAt: "2026-03-17T19:12:28.000Z",
487 appName: "Terminal",
488 windowTitle: "t3code — Tests",
489 appIconDataUrl: "data:image/png;base64,aWNvbg==",
490 },
491 },
492 ],
493 },
494 };
495}
496
497describe("MessagesTimeline", () => {
498 it("times initial compaction in one status and transitions to working without a divider", async () => {
499 vi.unstubAllGlobals();
500 vi.useFakeTimers();
501 vi.setSystemTime(new Date("2026-01-01T00:01:22.000Z"));
502 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
503 vi.stubGlobal("requestAnimationFrame", () => 0);
504 vi.stubGlobal("cancelAnimationFrame", () => {});
505 const container = document.createElement("div");
506 document.body.append(container);
507 const root = createRoot(container);
508 const runId = RunId.make("run-1");
509 const render = (completed: boolean) =>
510 root.render(
511 <MessagesTimeline
512 {...buildProps()}
513 isWorking
514 isCompacting
515 runningRunId={runId}
516 activeTurnStartedAt="2025-12-31T23:59:40.000Z"
517 timelineEntries={[
518 {
519 id: "initial-compaction",
520 kind: "work",
521 createdAt: "2026-01-01T00:00:00.000Z",
522 entry: {
523 id: "initial-compaction",
524 runId,
525 createdAt: "2026-01-01T00:00:00.000Z",
526 tone: "info",
527 label: completed ? "Context compacted" : "Compacting context",
528 sourceActivityKind: "context-compaction",
529 toolLifecycleStatus: completed ? "completed" : "inProgress",
530 },
531 },
532 ]}
533 />,
534 );
535
536 try {
537 await act(() => render(false));
538 const status = container.querySelector('[role="status"]');
539 expect(status?.getAttribute("aria-label")).toBe("Compacting context");
540 expect(status?.textContent).toBe("Compacting for 1m 22s");
541 expect(container.querySelectorAll('[role="separator"]')).toHaveLength(0);
542
543 await act(() => vi.advanceTimersByTime(1000));
544 expect(status?.textContent).toBe("Compacting for 1m 23s");
545 await act(() => render(true));
546 expect(container.querySelector('[role="status"]')).toBe(status);
547 expect(status?.getAttribute("aria-label")).toBe("Working (context compacted)");
548 expect(status?.textContent).toBe("Working for 1m 43s (context compacted)");
549 expect(container.querySelectorAll('[role="separator"]')).toHaveLength(0);
550 expect(container.textContent).not.toContain("Compacting");
551 } finally {
552 await act(() => root.unmount());
553 container.remove();
554 vi.useRealTimers();
555 vi.unstubAllGlobals();
556 }
557 });
558
559 it("shows a system notification's actionable link without opening its inspector", async () => {
560 vi.unstubAllGlobals();
561 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
562 Object.defineProperty(window, "matchMedia", { value: matchMedia, configurable: true });
563 vi.stubGlobal("requestAnimationFrame", () => 0);
564 vi.stubGlobal("cancelAnimationFrame", () => {});
565 const item = {
566 id: TurnItemId.make("ci-failure"),
567 threadId: ThreadId.make("thread-1"),
568 runId: null,
569 nodeId: null,
570 providerThreadId: null,
571 providerTurnId: null,
572 nativeItemRef: null,
573 parentItemId: null,
574 ordinal: 0,
575 status: "completed",
576 title: null,
577 startedAt: null,
578 completedAt: null,
579 updatedAt: DateTime.makeUnsafe(MESSAGE_CREATED_AT),
580 type: "notification",
581 source: { kind: "system" },
582 outcome: "failed",
583 summary: "CI failed for PR #42",
584 detail: "[Open PR #42 in Mergebot](https://example.com/queue/pr/42)",
585 } satisfies OrchestrationV2TurnItem;
586 const container = document.createElement("div");
587 document.body.append(container);
588 const root = createRoot(container);
589 try {
590 await act(() => {
591 root.render(
592 <MessagesTimeline
593 {...buildProps()}
594 timelineEntries={[
595 {
596 id: item.id,
597 kind: "work",
598 createdAt: MESSAGE_CREATED_AT,
599 entry: {
600 id: item.id,
601 createdAt: MESSAGE_CREATED_AT,
602 runId: null,
603 label: item.summary,
604 detail: item.detail,
605 tone: "error",
606 itemType: "notification",
607 toolLifecycleStatus: "completed",
608 structuredPayload: item,
609 projectedItem: {
610 position: 0,
611 visibility: "local",
612 sourceThreadId: item.threadId,
613 sourceItemId: item.id,
614 item,
615 },
616 },
617 },
618 ]}
619 />,
620 );
621 });
622 const notice = container.querySelector('[data-v2-item-type="notification"]')!;
623 expect(notice.textContent).toContain("CI failed for PR #42");
624 expect(notice.querySelector("[aria-expanded], button")).toBeNull();
625 const link = notice.querySelector<HTMLAnchorElement>("a")!;
626 expect(link.textContent).toBe("Open PR #42 in Mergebot");
627 expect(link.href).toBe("https://example.com/queue/pr/42");
628 expect(link.closest('[hidden], [aria-hidden="true"]')).toBeNull();
629 await act(() => link.focus());
630 expect(document.activeElement).toBe(link);
631 const follow = vi.fn((event: Event) => event.preventDefault());
632 link.addEventListener("click", follow);
633 await act(() => link.click());
634 expect(follow).toHaveBeenCalledOnce();
635 } finally {
636 await act(() => root.unmount());
637 container.remove();
638 vi.unstubAllGlobals();
639 }
640 });
641
642 it("shows dynamic tool input without cached output when the row is expanded", async () => {
643 activityTestState.expanded = true;
644 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
645 vi.stubGlobal("requestAnimationFrame", () => 0);
646 vi.stubGlobal("cancelAnimationFrame", () => {});
647 let renderer: ReactTestRenderer | undefined;
648 try {
649 await act(() => {
650 renderer = create(
651 <MessagesTimeline
652 {...buildProps()}
653 timelineEntries={[
654 {
655 id: "tool-with-cached-output",
656 kind: "work",
657 createdAt: MESSAGE_CREATED_AT,
658 entry: {
659 id: "tool-with-cached-output",
660 createdAt: MESSAGE_CREATED_AT,
661 label: "Example tool",
662 toolTitle: "Example tool",
663 tone: "tool",
664 itemType: "dynamic_tool",
665 toolLifecycleStatus: "completed",
666 toolData: {
667 input: { query: "KEEP_TOOL_INPUT" },
668 output: { text: "RAW_CACHED_TOOL_OUTPUT" },
669 },
670 },
671 },
672 ]}
673 />,
674 );
675 });
676 const row = renderer!.root.findByProps({ "aria-label": "Example tool" });
677 await act(() => row.props.onClick());
678 const visible = JSON.stringify(renderer!.toJSON());
679 expect(visible).toContain("KEEP_TOOL_INPUT");
680 expect(visible).not.toContain("RAW_CACHED_TOOL_OUTPUT");
681 await act(() => row.props.onClick());
682 expect(JSON.stringify(renderer!.toJSON())).not.toContain("KEEP_TOOL_INPUT");
683 } finally {
684 await act(() => renderer?.unmount());
685 }
686 });
687
688 it("leads an unanswered question row with the question text", async () => {
689 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
690 vi.stubGlobal("requestAnimationFrame", () => 0);
691 vi.stubGlobal("cancelAnimationFrame", () => {});
692 let renderer: ReactTestRenderer | undefined;
693 try {
694 await act(() => {
695 renderer = create(
696 <MessagesTimeline
697 {...buildProps()}
698 timelineEntries={[
699 {
700 id: "question-entry",
701 kind: "work",
702 createdAt: MESSAGE_CREATED_AT,
703 entry: {
704 id: "question-work",
705 createdAt: MESSAGE_CREATED_AT,
706 label: "User input requested",
707 tone: "tool",
708 questionAnswer: {
709 requestId: ApprovalRequestId.make("question-request"),
710 answers: {},
711 questionTextById: { scope: "Which repository?" },
712 attachmentsByQuestionId: {},
713 },
714 },
715 },
716 ]}
717 />,
718 );
719 });
720 const questionToggle = renderer!.root.find(
721 (node) =>
722 node.props["aria-label"] === "Which repository?" && node.props["aria-expanded"] === false,
723 );
724 const markup = JSON.stringify(renderer!.toJSON());
725 // Heading + accessible label.
726 expect(markup.match(/Which repository\?/g)).toHaveLength(2);
727 await act(() => questionToggle.props.onClick());
728 // Expanded history adds a third occurrence alongside heading and label.
729 expect(JSON.stringify(renderer!.toJSON()).match(/Which repository\?/g)).toHaveLength(3);
730 } finally {
731 await act(() => renderer?.unmount());
732 }
733 });
734
735 it.each([
736 { toolLifecycleStatus: "inProgress", isAtEnd: true },
737 { toolLifecycleStatus: "inProgress", isAtEnd: false },
738 { toolLifecycleStatus: "completed", isAtEnd: true },
739 { toolLifecycleStatus: "completed", isAtEnd: false },
740 ] as const)(
741 "restores the composer after closing $toolLifecycleStatus tool output only at the end: $isAtEnd",
742 async ({ toolLifecycleStatus, isAtEnd }) => {
743 const frames = new Map<number, FrameRequestCallback>();
744 let nextFrame = 0;
745 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
746 frames.set(++nextFrame, callback);
747 return nextFrame;
748 });
749 vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame));
750 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
751 const flushFrame = () =>
752 act(() => {
753 const callbacks = [...frames.values()];
754 frames.clear();
755 callbacks.forEach((callback) => callback(0));
756 });
757 const props = buildProps();
758 const runId = RunId.make("tool-output-run");
759 let timelineIsAtEnd = isAtEnd;
760 props.listRef.current = {
761 getState: () => ({ isAtEnd: timelineIsAtEnd }),
762 getScrollableNode: () => null,
763 } as unknown as LegendListRef;
764 let isResting = false;
765 let composerState: ReturnType<typeof useComposerFocusState> | undefined;
766 function ThreadProbe() {
767 const composer = useComposerFocusState();
768 useLayoutEffect(() => {
769 composerState = composer;
770 isResting = shouldUseRestingComposerLayout({
771 isExistingThread: true,
772 isMobileViewport: false,
773 isScrollCollapsed: composer.isComposerScrollCollapsed,
774 hasExpandedChrome: false,
775 hasMultilinePrompt: false,
776 timelineOverflows: true,
777 });
778 });
779 return (
780 <MessagesTimeline
781 {...props}
782 isWorking={toolLifecycleStatus === "inProgress"}
783 runningRunId={toolLifecycleStatus === "inProgress" ? runId : null}
784 onToolOutputCollapsedAtEnd={composer.restoreAfterTimelineReachedEnd}
785 timelineEntries={[
786 {
787 id: "running-tool",
788 kind: "work",
789 createdAt: MESSAGE_CREATED_AT,
790 entry: {
791 id: "running-tool",
792 ...(toolLifecycleStatus === "inProgress" ? { runId } : {}),
793 createdAt: MESSAGE_CREATED_AT,
794 label: "Run command",
795 tone: "tool",
796 toolLifecycleStatus,
797 detail: "Command output",
798 },
799 },
800 ]}
801 />
802 );
803 }
804 let renderer: ReactTestRenderer | undefined;
805 try {
806 await act(() => {
807 renderer = create(<ThreadProbe />);
808 });
809 // The user scrolled up to read, so the composer is resting.
810 await act(() => composerState!.setIsComposerScrollCollapsed(true));
811 const toggle = renderer!.root.findByProps({ "aria-expanded": false });
812 await act(() => toggle.props.onClick());
813 await flushFrame();
814 await flushFrame();
815 expect(isResting).toBe(true);
816
817 timelineIsAtEnd = false;
818 await act(() => toggle.props.onClick());
819 await flushFrame();
820 timelineIsAtEnd = isAtEnd;
821 await flushFrame();
822 expect(isResting).toBe(!isAtEnd);
823 } finally {
824 await act(() => renderer?.unmount());
825 }
826 },
827 );
828
829 it.each([true, false])(
830 "keeps the end in view when tool output toggles only at the end: %s",
831 async (isAtEnd) => {
832 const frames = new Map<number, FrameRequestCallback>();
833 let nextFrame = 0;
834 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
835 frames.set(++nextFrame, callback);
836 return nextFrame;
837 });
838 vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame));
839 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
840 const flushFrame = () =>
841 act(() => {
842 const callbacks = [...frames.values()];
843 frames.clear();
844 callbacks.forEach((callback) => callback(0));
845 });
846 const viewport = { scrollTop: 400, scrollHeight: 1000 };
847 const props = buildProps();
848 props.listRef.current = {
849 getState: () => ({ isAtEnd }),
850 getScrollableNode: () => viewport,
851 } as unknown as LegendListRef;
852 let renderer: ReactTestRenderer | undefined;
853 try {
854 await act(() => {
855 renderer = create(
856 <MessagesTimeline
857 {...props}
858 timelineEntries={[
859 {
860 id: "tool",
861 kind: "work",
862 createdAt: MESSAGE_CREATED_AT,
863 entry: {
864 id: "tool",
865 createdAt: MESSAGE_CREATED_AT,
866 label: "Run command",
867 tone: "tool",
868 toolLifecycleStatus: "completed",
869 detail: "Command output",
870 },
871 },
872 ]}
873 />,
874 );
875 });
876 const toggle = renderer!.root.findByProps({ "aria-expanded": false });
877 const list = renderer!.root.find(
878 (node) => node.props.ListFooterComponent !== undefined && node.props.onItemSizeChanged,
879 );
880 for (const scrollHeight of [1300, 900]) {
881 await act(() => toggle.props.onClick());
882 // The rows grow or shrink once the list measures them.
883 viewport.scrollHeight = scrollHeight;
884 await flushFrame();
885 expect(viewport.scrollTop).toBe(isAtEnd ? scrollHeight : 400);
886 // Tool output that renders late is pinned before the next frame.
887 viewport.scrollHeight = scrollHeight + 200;
888 await act(() => list.props.onItemSizeChanged());
889 expect(viewport.scrollTop).toBe(isAtEnd ? scrollHeight + 200 : 400);
890 await flushFrame();
891 await flushFrame();
892 viewport.scrollTop = 400;
893 }
894 } finally {
895 await act(() => renderer?.unmount());
896 }
897 },
898 );
899
900 function stubFollowTimeline(routeThreadKey: string) {
901 const frames = new Map<number, FrameRequestCallback>();
902 let nextFrame = 0;
903 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
904 frames.set(++nextFrame, callback);
905 return nextFrame;
906 });
907 vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame));
908 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
909 const props = { ...buildProps(), routeThreadKey };
910 const timeline = {
911 props,
912 scrollTop: 1400,
913 flushFrame: () =>
914 act(() => {
915 const callbacks = [...frames.values()];
916 frames.clear();
917 callbacks.forEach((callback) => callback(0));
918 }),
919 };
920 const viewport = {
921 get scrollTop() {
922 return timeline.scrollTop;
923 },
924 getBoundingClientRect: () => ({ top: 0 }),
925 };
926 props.listRef.current = {
927 getScrollableNode: () => viewport,
928 getState: () => ({
929 data: legendListTestState.data,
930 scroll: timeline.scrollTop,
931 scrollLength: 600,
932 contentLength: 2000,
933 indexByKey: () => 0,
934 positionAtIndex: () => 0,
935 sizeAtIndex: () => 2000,
936 elementAtIndex: () => ({ getBoundingClientRect: () => ({ top: -timeline.scrollTop }) }),
937 }),
938 } as unknown as LegendListRef;
939 return timeline;
940 }
941
942 it("remembers a manual reading position when the chat closes before the next frame", async () => {
943 const { readTimelinePosition } = await import("./timelineScrollAnchoring");
944 const timeline = stubFollowTimeline("environment-local:manual-departure");
945 const { props } = timeline;
946 let navigateManually = () => {};
947 function ChatProbe() {
948 const [liveFollowEnabled, setLiveFollowEnabled] = useState(true);
949 navigateManually = () => setLiveFollowEnabled(false);
950 return (
951 <MessagesTimeline
952 {...props}
953 timelineEntries={[buildUserTimelineEntry("First prompt")]}
954 liveFollowEnabled={liveFollowEnabled}
955 onManualNavigation={navigateManually}
956 />
957 );
958 }
959 let renderer: ReactTestRenderer | undefined;
960 try {
961 await act(() => {
962 renderer = create(<ChatProbe />);
963 });
964 await timeline.flushFrame();
965 expect(readTimelinePosition(props.routeThreadKey)?.atEnd).toBe(true);
966
967 // The scroll event lands before the follow opt-out renders.
968 timeline.scrollTop = 1300;
969 await act(() => {
970 navigateManually();
971 legendListTestState.onScroll?.();
972 });
973 await act(() => renderer?.unmount());
974 renderer = undefined;
975
976 expect(readTimelinePosition(props.routeThreadKey)).toMatchObject({
977 atEnd: false,
978 scrollOffset: 1300,
979 });
980 } finally {
981 await act(() => renderer?.unmount());
982 }
983 });
984
985 it("loads missing history before restoring a saved reading anchor", async () => {
986 const { rememberTimelinePosition } = await import("./timelineScrollAnchoring");
987 const props = { ...buildProps(), routeThreadKey: "environment-local:refresh-old-history" };
988 rememberTimelinePosition(
989 props.routeThreadKey,
990 {
991 rowId: "entry-old",
992 offsetWithinRow: 20,
993 scrollOffset: 700,
994 atEnd: false,
995 },
996 false,
997 );
998 const viewport = {
999 scrollTop: 0,
1000 ownerDocument: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
1001 addEventListener: vi.fn(),
1002 removeEventListener: vi.fn(),
1003 getBoundingClientRect: () => ({ top: 0 }),
1004 };
1005 const scrollToIndex = vi.fn(async () => {});
1006 const scrollToOffset = vi.fn(async () => {});
1007 props.listRef.current = {
1008 getScrollableNode: () => viewport,
1009 getState: () => undefined,
1010 scrollToIndex,
1011 scrollToOffset,
1012 } as unknown as LegendListRef;
1013 const onLoadEarlier = vi.fn();
1014 const historyControls = { hasMoreHistory: true, loading: false, error: null, onLoadEarlier };
1015 const latest = buildUserTimelineEntry("Latest prompt");
1016 let renderer: ReactTestRenderer | undefined;
1017 try {
1018 await act(() => {
1019 renderer = create(
1020 <MessagesTimeline
1021 {...props}
1022 timelineEntries={[latest]}
1023 historyControls={historyControls}
1024 />,
1025 );
1026 });
1027 expect(onLoadEarlier).toHaveBeenCalledWith("entry-old");
1028 expect(scrollToOffset).not.toHaveBeenCalled();
1029 expect(scrollToIndex).not.toHaveBeenCalled();
1030 const older = {
1031 ...buildUserTimelineEntry("Old prompt"),
1032 id: "entry-old",
1033 message: { ...latest.message, id: MessageId.make("old-message"), text: "Old prompt" },
1034 };
1035 await act(() =>
1036 renderer!.update(
1037 <MessagesTimeline
1038 {...props}
1039 timelineEntries={[older, latest]}
1040 historyControls={{ ...historyControls, hasMoreHistory: false }}
1041 />,
1042 ),
1043 );
1044 expect(scrollToIndex).toHaveBeenCalledWith({
1045 index: 0,
1046 animated: false,
1047 viewPosition: 0,
1048 viewOffset: -20,
1049 });
1050 } finally {
1051 await act(() => renderer?.unmount());
1052 }
1053 });
1054
1055 it("keeps a following thread's intent while its timeline is held for the next thread", async () => {
1056 const { readTimelinePosition } = await import("./timelineScrollAnchoring");
1057 const timeline = stubFollowTimeline("environment-local:held-departure");
1058 const { props } = timeline;
1059 // Smooth follow is still catching up with the stream.
1060 timeline.scrollTop = 1300;
1061 const render = (held: boolean) => (
1062 <MessagesTimeline
1063 {...props}
1064 timelineEntries={[buildUserTimelineEntry("First prompt")]}
1065 liveFollowEnabled={!held}
1066 paintOnly={held}
1067 />
1068 );
1069 let renderer: ReactTestRenderer | undefined;
1070 try {
1071 await act(() => {
1072 renderer = create(render(false));
1073 });
1074 await timeline.flushFrame();
1075 expect(readTimelinePosition(props.routeThreadKey)?.atEnd).toBe(true);
1076
1077 await act(() => renderer?.update(render(true)));
1078 await timeline.flushFrame();
1079
1080 expect(readTimelinePosition(props.routeThreadKey)?.atEnd).toBe(true);
1081 } finally {
1082 await act(() => renderer?.unmount());
1083 }
1084 });
1085
1086 it("remembers a first send held near the top as a reading position", async () => {
1087 const { readTimelinePosition } = await import("./timelineScrollAnchoring");
1088 const timeline = stubFollowTimeline("environment-local:anchored-first-send");
1089 const { props } = timeline;
1090 timeline.scrollTop = 1300;
1091 let renderer: ReactTestRenderer | undefined;
1092 try {
1093 await act(() => {
1094 renderer = create(
1095 <MessagesTimeline
1096 {...props}
1097 timelineEntries={[buildUserTimelineEntry("First prompt")]}
1098 anchorMessageId={MessageId.make("message-1")}
1099 liveFollowEnabled
1100 />,
1101 );
1102 });
1103 await timeline.flushFrame();
1104
1105 expect(readTimelinePosition(props.routeThreadKey)).toMatchObject({
1106 atEnd: false,
1107 scrollOffset: 1300,
1108 });
1109 } finally {
1110 await act(() => renderer?.unmount());
1111 }
1112 });
1113
1114 it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => {
1115 const { MessagesTimeline } = await import("./MessagesTimeline");
1116 const markup = renderToStaticMarkup(
1117 <MessagesTimeline
1118 {...buildProps()}
1119 timelineEntries={[
1120 buildUserTimelineEntry(
1121 [
1122 'Without reading a file, do you have <global-agent-instructions scope="workspace">',
1123 'Before <nested data-value="a&b">inside</nested> after',
1124 "</global-agent-instructions> in your context?",
1125 "Comparison: 2 < 3 and 5 > 4.",
1126 ].join("\n"),
1127 ),
1128 ]}
1129 />,
1130 );
1131
1132 expect(markup).toContain("&lt;global-agent-instructions scope=&quot;workspace&quot;&gt;");
1133 expect(markup).toContain(
1134 "Before &lt;nested data-value=&quot;a&amp;b&quot;&gt;inside&lt;/nested&gt; after",
1135 );
1136 expect(markup).toContain("&lt;/global-agent-instructions&gt; in your context?");
1137 expect(markup).toContain("Comparison: 2 &lt; 3 and 5 &gt; 4.");
1138 });
1139
1140 it("preserves XML-like source inside user code spans and fences", async () => {
1141 const { MessagesTimeline } = await import("./MessagesTimeline");
1142 const markup = renderToStaticMarkup(
1143 <MessagesTimeline
1144 {...buildProps()}
1145 timelineEntries={[
1146 buildUserTimelineEntry(
1147 [
1148 'Inline `<tag attr="x">`',
1149 "",
1150 "```xml",
1151 '<root><child enabled="true" /></root>',
1152 "```",
1153 ].join("\n"),
1154 ),
1155 ]}
1156 />,
1157 );
1158
1159 expect(markup).toContain('<code data-inline-code="">&lt;tag attr=&quot;x&quot;&gt;</code>');
1160 expect(markup).toContain("&lt;root&gt;&lt;child enabled=&quot;true&quot; /&gt;&lt;/root&gt;");
1161 });
1162
1163 it("does not render markdown title attributes in user messages", async () => {
1164 const { MessagesTimeline } = await import("./MessagesTimeline");
1165 const markup = renderToStaticMarkup(
1166 <MessagesTimeline
1167 {...buildProps()}
1168 timelineEntries={[
1169 buildUserTimelineEntry(
1170 '[link](https://example.com "link tip") ![image](https://example.com/image.png "image tip")',
1171 ),
1172 ]}
1173 />,
1174 );
1175
1176 expect(markup).toContain('href="https://example.com"');
1177 expect(markup).toContain('src="https://example.com/image.png"');
1178 expect(markup).not.toContain('title="link tip"');
1179 expect(markup).not.toContain('title="image tip"');
1180 });
1181
1182 it("renders unsafe user HTML as inert source text", async () => {
1183 const { MessagesTimeline } = await import("./MessagesTimeline");
1184 const markup = renderToStaticMarkup(
1185 <MessagesTimeline
1186 {...buildProps()}
1187 timelineEntries={[
1188 buildUserTimelineEntry(
1189 '<script>globalThis.__t3Xss = 1</script><img src="x" onerror="globalThis.__t3Xss = 2">',
1190 ),
1191 ]}
1192 />,
1193 );
1194
1195 expect(markup).toContain("&lt;script&gt;globalThis.__t3Xss = 1&lt;/script&gt;");
1196 expect(markup).toContain(
1197 "&lt;img src=&quot;x&quot; onerror=&quot;globalThis.__t3Xss = 2&quot;&gt;",
1198 );
1199 expect(markup).not.toMatch(/<script(?:\s|>)/i);
1200 expect(markup).not.toMatch(/<img(?:\s|>)/i);
1201 });
1202
1203 it("continues to render sanitized raw HTML in assistant messages", async () => {
1204 const { MessagesTimeline } = await import("./MessagesTimeline");
1205 const markup = renderToStaticMarkup(
1206 <MessagesTimeline
1207 {...buildProps()}
1208 timelineEntries={[
1209 buildAssistantTimelineEntry("<details><summary>More</summary>Details</details>"),
1210 ]}
1211 />,
1212 );
1213
1214 expect(markup).toContain('data-markdown-details=""');
1215 expect(markup).toContain("More");
1216 expect(markup).not.toContain("&lt;details&gt;");
1217 });
1218
1219 it("sanitizes executable HTML while preserving supported assistant markup", async () => {
1220 const { MessagesTimeline } = await import("./MessagesTimeline");
1221 const markup = renderToStaticMarkup(
1222 <MessagesTimeline
1223 {...buildProps()}
1224 timelineEntries={[
1225 buildAssistantTimelineEntry(
1226 [
1227 '<details open onclick="globalThis.__t3Xss = 1">',
1228 "<summary>Safe details</summary>",
1229 "<script>globalThis.__t3Xss = 2</script>",
1230 '<img src="x" onerror="globalThis.__t3Xss = 3">',
1231 '<a href="javascript:globalThis.__t3Xss = 4">Unsafe link</a>',
1232 "</details>",
1233 ].join(""),
1234 ),
1235 ]}
1236 />,
1237 );
1238
1239 expect(markup).toContain('data-markdown-details=""');
1240 expect(markup).toContain("Safe details");
1241 expect(markup).not.toMatch(/<script(?:\s|>)/i);
1242 expect(markup).not.toContain("onclick=");
1243 expect(markup).not.toContain("onerror=");
1244 expect(markup).not.toContain("javascript:");
1245 expect(markup).not.toContain("globalThis.__t3Xss");
1246 });
1247 it("renders progressive history controls ahead of the bounded timeline", () => {
1248 const markup = renderToStaticMarkup(
1249 <MessagesTimeline
1250 {...buildProps()}
1251 timelineEntries={[buildUserTimelineEntry("Recent activity")]}
1252 historyControls={{
1253 hasMoreHistory: true,
1254 loading: false,
1255 error: "Earlier activity could not be loaded.",
1256 onLoadEarlier: () => {},
1257 }}
1258 />,
1259 );
1260
1261 expect(markup).toContain('aria-label="Load earlier turns"');
1262 expect(markup).toContain("Load earlier turns");
1263 expect(markup).toContain("Earlier activity could not be loaded.");
1264 expect(markup.indexOf("Load earlier turns")).toBeLessThan(markup.indexOf("Recent activity"));
1265 });
1266
1267 it("keeps an empty bounded timeline actionable while earlier history loads", () => {
1268 const markup = renderToStaticMarkup(
1269 <MessagesTimeline
1270 {...buildProps()}
1271 timelineEntries={[]}
1272 historyControls={{
1273 hasMoreHistory: true,
1274 loading: true,
1275 error: null,
1276 onLoadEarlier: () => {},
1277 }}
1278 />,
1279 );
1280
1281 expect(markup).toContain("Loading earlier turns…");
1282 expect(markup).toContain("disabled");
1283 expect(markup).not.toContain("Send a message to start the conversation.");
1284 });
1285
1286 it("uses the larger leading inset only when the top fade is enabled", () => {
1287 const timelineEntries = [buildUserTimelineEntry("Hello")];
1288
1289 const compactMarkup = renderToStaticMarkup(
1290 <MessagesTimeline {...buildProps()} timelineEntries={timelineEntries} />,
1291 );
1292 const fadedMarkup = renderToStaticMarkup(
1293 <MessagesTimeline {...buildProps()} timelineEntries={timelineEntries} topFadeEnabled />,
1294 );
1295
1296 expect(compactMarkup).toContain('class="h-3 sm:h-4"');
1297 expect(compactMarkup).not.toContain("topbar-scroll-fade");
1298 expect(fadedMarkup).toContain('class="h-[var(--workspace-titlebar-scroll-fade-height)]"');
1299 expect(fadedMarkup).toContain("topbar-scroll-fade");
1300 });
1301
1302 it("keeps assistant changed-files headers sticky below the thread header", () => {
1303 const assistantMessageId = MessageId.make("message-assistant-with-files");
1304 const runId = RunId.make("run-with-files");
1305 const markup = renderToStaticMarkup(
1306 <MessagesTimeline
1307 {...buildProps()}
1308 latestRun={{
1309 runId,
1310 status: "completed",
1311 startedAt: MESSAGE_CREATED_AT,
1312 completedAt: MESSAGE_CREATED_AT,
1313 }}
1314 timelineEntries={[
1315 {
1316 id: "entry-assistant-with-files",
1317 kind: "message",
1318 createdAt: MESSAGE_CREATED_AT,
1319 message: {
1320 id: assistantMessageId,
1321 role: "assistant",
1322 text: "Updated the fixture.",
1323 runId,
1324 createdAt: MESSAGE_CREATED_AT,
1325 updatedAt: MESSAGE_CREATED_AT,
1326 streaming: false,
1327 },
1328 },
1329 ]}
1330 turnDiffSummaries={[
1331 {
1332 runId,
1333 checkpointTurnCount: 1,
1334 checkpointRef: CheckpointRef.make("checkpoint-with-files"),
1335 status: "ready",
1336 files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }],
1337 assistantMessageId,
1338 completedAt: MESSAGE_CREATED_AT,
1339 },
1340 ]}
1341 />,
1342 );
1343
1344 expect(markup).toContain("sticky top-2 z-10");
1345 expect(markup).not.toContain("self-start");
1346 expect(markup).toContain("whitespace-nowrap");
1347 expect(markup).toContain("size-3");
1348 expect(markup).not.toContain('aria-label="Collapse all folders"');
1349 expect(markup).toContain('aria-label="Open diff"');
1350 expect(markup).toContain("1 changed file");
1351 });
1352
1353 it("treats the follow re-arm band above the content bottom as the live edge", async () => {
1354 const {
1355 resolveTimelineIsAtEnd,
1356 resolveTimelineMinimapHasPersistentGutter,
1357 resolveTimelineMinimapHeightStyle,
1358 resolveTimelineMinimapHitStripWidth,
1359 resolveTimelineMinimapIndexFromPointer,
1360 resolveTimelineMinimapInteractiveWidth,
1361 resolveTimelineMinimapNavigationInteractive,
1362 resolveTimelineMinimapTopPercent,
1363 } = await import("./MessagesTimeline.logic");
1364
1365 expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true);
1366 expect(resolveTimelineIsAtEnd(undefined)).toBeUndefined();
1367 // Within the pixel band above the content bottom counts as the end...
1368 expect(
1369 resolveTimelineIsAtEnd({
1370 isAtEnd: false,
1371 contentLength: 2000,
1372 scroll: 1170,
1373 scrollLength: 800,
1374 }),
1375 ).toBe(true);
1376 // ...but half a viewport up (LegendList's isNearEnd territory) does not.
1377 expect(
1378 resolveTimelineIsAtEnd({
1379 isAtEnd: false,
1380 contentLength: 2000,
1381 scroll: 900,
1382 scrollLength: 800,
1383 }),
1384 ).toBe(false);
1385 // LegendList can report at-end while the composer still covers the last row.
1386 expect(
1387 resolveTimelineIsAtEnd({
1388 isAtEnd: true,
1389 contentLength: 2100,
1390 scroll: 1170,
1391 scrollLength: 800,
1392 }),
1393 ).toBe(false);
1394 // Geometry missing (older state shape): fall back to the nearEnd/strict flags.
1395 expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(false);
1396 expect(resolveTimelineIsAtEnd({ isAtEnd: false })).toBe(false);
1397
1398 expect(resolveTimelineMinimapHeightStyle(5)).toBe("min(32px, calc(100vh - 18rem))");
1399 expect(resolveTimelineMinimapTopPercent(2, 5)).toBe(50);
1400 expect(
1401 resolveTimelineMinimapIndexFromPointer({
1402 itemCount: 101,
1403 railTop: 100,
1404 railHeight: 500,
1405 pointerY: 350,
1406 }),
1407 ).toBe(50);
1408 expect(
1409 resolveTimelineMinimapIndexFromPointer({
1410 itemCount: 101,
1411 railTop: 100,
1412 railHeight: 500,
1413 pointerY: 999,
1414 }),
1415 ).toBe(100);
1416 // Comfortable width: the column is capped at 768px.
1417 expect(resolveTimelineMinimapHasPersistentGutter(832, 768)).toBe(false);
1418 expect(resolveTimelineMinimapHasPersistentGutter(863, 768)).toBe(false);
1419 expect(resolveTimelineMinimapHasPersistentGutter(864, 768)).toBe(true);
1420 // Wider Chat width settings consume the gutter the minimap relies on.
1421 expect(resolveTimelineMinimapHasPersistentGutter(1400, 1152)).toBe(true);
1422 expect(resolveTimelineMinimapHasPersistentGutter(1200, 1152)).toBe(false);
1423 expect(resolveTimelineMinimapHasPersistentGutter(2560, 2560)).toBe(false);
1424
1425 // No usable gutter (zoomed in / narrow pane): the strip must go inert
1426 // instead of overlaying the centered content column.
1427 expect(resolveTimelineMinimapHitStripWidth(768, 768)).toBe(0);
1428 expect(resolveTimelineMinimapHitStripWidth(792, 768)).toBe(0);
1429 // Partial gutter: strip shrinks to what fits between the viewport edge
1430 // and the content column.
1431 expect(resolveTimelineMinimapHitStripWidth(820, 768)).toBe(14);
1432 // Full gutter: unchanged 40px-wide strip.
1433 expect(resolveTimelineMinimapHitStripWidth(872, 768)).toBe(40);
1434 expect(resolveTimelineMinimapHitStripWidth(1400, 768)).toBe(40);
1435 // Full Chat width: the column spans the viewport, so the strip is inert
1436 // however wide the window gets.
1437 expect(resolveTimelineMinimapHitStripWidth(2560, 2560)).toBe(0);
1438 // Wide Chat width on a window just wider than the column: partial strip.
1439 expect(resolveTimelineMinimapHitStripWidth(1204, 1152)).toBe(14);
1440 expect(resolveTimelineMinimapHitStripWidth(0, 0)).toBe(0);
1441 expect(resolveTimelineMinimapHitStripWidth(Number.NaN, 768)).toBe(0);
1442
1443 // Prev/next buttons reach 14px past the strip's left edge; a narrower
1444 // strip means they would sit on the content column.
1445 expect(resolveTimelineMinimapNavigationInteractive(40)).toBe(true);
1446 expect(resolveTimelineMinimapNavigationInteractive(14)).toBe(true);
1447 expect(resolveTimelineMinimapNavigationInteractive(8)).toBe(false);
1448 expect(resolveTimelineMinimapNavigationInteractive(0)).toBe(false);
1449
1450 // The collapsed target stays narrow, but an open preview keeps its full
1451 // 20rem width plus the 2rem offset from the minimap rail interactive.
1452 expect(resolveTimelineMinimapInteractiveWidth(0, false)).toBe(0);
1453 expect(resolveTimelineMinimapInteractiveWidth(14, false)).toBe(14);
1454 expect(resolveTimelineMinimapInteractiveWidth(40, false)).toBe(40);
1455 expect(resolveTimelineMinimapInteractiveWidth(0, true)).toBe("22rem");
1456 expect(resolveTimelineMinimapInteractiveWidth(14, true)).toBe("22rem");
1457 expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem");
1458 });
1459
1460 it("anchors a sent attachment message using its measured height", () => {
1461 const onAnchorReady = vi.fn();
1462 const onAnchorSizeChanged = vi.fn();
1463 // Since #7897 only the first user row after the live edge may anchor, so
1464 // the preceding row is an assistant reply rather than an older prompt.
1465 const firstEntry = buildAssistantTimelineEntry("Earlier reply.");
1466 const secondEntry = {
1467 ...buildUserTimelineEntry("Newest prompt."),
1468 id: "entry-2",
1469 message: {
1470 ...buildUserTimelineEntry("Newest prompt.").message,
1471 id: MessageId.make("message-2"),
1472 attachments: [
1473 {
1474 type: "image" as const,
1475 id: "attachment-1",
1476 name: "screenshot.png",
1477 mimeType: "image/png",
1478 sizeBytes: 1,
1479 previewUrl: "data:image/png;base64,iVBORw0KGgo=",
1480 },
1481 ],
1482 },
1483 };
1484 const markup = renderToStaticMarkup(
1485 <MessagesTimeline
1486 {...buildProps()}
1487 anchorMessageId={secondEntry.message.id}
1488 onAnchorReady={onAnchorReady}
1489 onAnchorSizeChanged={onAnchorSizeChanged}
1490 contentInsetEndAdjustment={144}
1491 timelineEntries={[firstEntry, secondEntry]}
1492 />,
1493 );
1494
1495 expect(markup).toContain('data-anchor-index="1"');
1496 expect(markup).toContain('data-anchor-offset="24"');
1497 expect(markup).toContain('data-anchor-on-ready="true"');
1498 expect(markup).not.toContain("data-anchor-max-size=");
1499 expect(markup).toContain('data-content-inset-end="144"');
1500 expect(markup).toContain("[overflow-anchor:none]");
1501 expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"');
1502 expect(markup).toContain('data-maintain-visible-content-position="object"');
1503 expect(markup).toContain('data-maintain-visible-content-position-data="true"');
1504 expect(markup).toContain('data-maintain-visible-content-position-size="true"');
1505 expect(markup).toContain('data-maintain-visible-content-position-restore="true"');
1506 expect(onAnchorReady).toHaveBeenCalledOnce();
1507 expect(onAnchorReady).toHaveBeenCalledWith(secondEntry.message.id, 1);
1508 expect(onAnchorSizeChanged).toHaveBeenCalledWith(secondEntry.message.id, 240);
1509 });
1510
1511 it("renders SnapShot window details after the preview resolves", () => {
1512 const onAnchorReady = vi.fn();
1513 const firstEntry = buildSnapShotTimelineEntry("data:image/png;base64,iVBORw0KGgo=");
1514 const markup = renderToStaticMarkup(
1515 <MessagesTimeline
1516 {...buildProps()}
1517 anchorMessageId={firstEntry.message.id}
1518 onAnchorReady={onAnchorReady}
1519 contentInsetEndAdjustment={144}
1520 timelineEntries={[firstEntry]}
1521 />,
1522 );
1523
1524 expect(markup).toContain("Terminal");
1525 expect(markup).toContain("t3code — Tests");
1526 expect(markup).toContain('src="data:image/png;base64,aWNvbg=="');
1527 expect(onAnchorReady).toHaveBeenCalledOnce();
1528 expect(onAnchorReady).toHaveBeenCalledWith(firstEntry.message.id, 0);
1529 });
1530
1531 it("does not render SnapShot window details before the preview resolves", () => {
1532 const markup = renderToStaticMarkup(
1533 <MessagesTimeline {...buildProps()} timelineEntries={[buildSnapShotTimelineEntry()]} />,
1534 );
1535
1536 expect(markup).toContain("screenshot.png");
1537 expect(markup).not.toContain("Terminal");
1538 expect(markup).not.toContain("t3code — Tests");
1539 expect(markup).not.toContain('src="data:image/png;base64,aWNvbg=="');
1540 expect(markup).not.toContain("h-28 w-52 max-w-full");
1541 });
1542
1543 it("does not reserve end space for a follow-up user message", () => {
1544 const onAnchorReady = vi.fn();
1545 const firstEntry = buildUserTimelineEntry("First prompt.");
1546 const secondEntry = {
1547 ...buildUserTimelineEntry("Newest prompt."),
1548 id: "entry-2",
1549 message: {
1550 ...buildUserTimelineEntry("Newest prompt.").message,
1551 id: MessageId.make("message-2"),
1552 },
1553 };
1554 const markup = renderToStaticMarkup(
1555 <MessagesTimeline
1556 {...buildProps()}
1557 anchorMessageId={secondEntry.message.id}
1558 onAnchorReady={onAnchorReady}
1559 timelineEntries={[firstEntry, secondEntry]}
1560 />,
1561 );
1562
1563 expect(markup).not.toContain("data-anchor-index=");
1564 expect(markup).toContain('data-maintain-scroll-at-end="enabled"');
1565 expect(onAnchorReady).not.toHaveBeenCalled();
1566 });
1567
1568 it("offers preview and download actions for PDF attachments", () => {
1569 const entry = {
1570 ...buildUserTimelineEntry("Read the report."),
1571 message: {
1572 ...buildUserTimelineEntry("Read the report.").message,
1573 attachments: [
1574 {
1575 type: "file" as const,
1576 id: "attachment-report-pdf",
1577 name: "report.pdf",
1578 mimeType: "application/pdf",
1579 sizeBytes: 42,
1580 previewUrl: "https://environment.test/api/assets/report.pdf",
1581 },
1582 ],
1583 },
1584 };
1585
1586 const markup = renderToStaticMarkup(
1587 <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />,
1588 );
1589
1590 expect(markup).toContain('aria-label="Preview report.pdf"');
1591 expect(markup).toContain('aria-label="Download report.pdf"');
1592 expect(markup).not.toContain('alt="report.pdf"');
1593 });
1594
1595 it("renders a file download button without creating its URL in advance", () => {
1596 const entry = {
1597 ...buildUserTimelineEntry("Read the report."),
1598 message: {
1599 ...buildUserTimelineEntry("Read the report.").message,
1600 attachments: [
1601 {
1602 type: "file" as const,
1603 id: "attachment-report-pdf",
1604 name: "report.pdf",
1605 mimeType: "application/pdf",
1606 sizeBytes: 42,
1607 },
1608 ],
1609 },
1610 };
1611
1612 const markup = renderToStaticMarkup(
1613 <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />,
1614 );
1615
1616 expect(markup).toContain('aria-label="Preview report.pdf"');
1617 expect(markup).toContain('aria-label="Download report.pdf"');
1618 expect(markup).not.toContain("<a ");
1619 });
1620
1621 it("does not download an optimistic file before the server supplies its attachment ID", () => {
1622 const entry = {
1623 ...buildUserTimelineEntry("Read the report."),
1624 message: {
1625 ...buildUserTimelineEntry("Read the report.").message,
1626 attachments: [
1627 {
1628 type: "file" as const,
1629 id: "composer-local-report",
1630 name: "report.pdf",
1631 mimeType: "application/pdf",
1632 sizeBytes: 42,
1633 downloadable: false,
1634 },
1635 ],
1636 },
1637 };
1638
1639 const markup = renderToStaticMarkup(
1640 <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />,
1641 );
1642
1643 expect(markup).toContain("report.pdf");
1644 expect(markup).not.toContain('aria-label="Download report.pdf"');
1645 });
1646
1647 it("renders unknown attachment types as inert rows instead of crashing", () => {
1648 const entry = {
1649 ...buildUserTimelineEntry("Play the recording."),
1650 message: {
1651 ...buildUserTimelineEntry("Play the recording.").message,
1652 attachments: [
1653 {
1654 // A newer server can introduce attachment types this build does
1655 // not know. They ride the open contract member.
1656 type: "recording",
1657 id: "attachment-voice-memo",
1658 name: "voice-memo.ogg",
1659 mimeType: "audio/ogg",
1660 sizeBytes: 42,
1661 },
1662 ],
1663 },
1664 };
1665
1666 const markup = renderToStaticMarkup(
1667 <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />,
1668 );
1669
1670 expect(markup).toContain("voice-memo.ogg");
1671 expect(markup).not.toContain('aria-label="Download voice-memo.ogg"');
1672 expect(markup).not.toContain('alt="voice-memo.ogg"');
1673 expect(markup).not.toContain("<a ");
1674 });
1675
1676 it("keeps reserved end space when tool work starts while reading history", () => {
1677 const runId = RunId.make("run-with-active-tool");
1678 const firstEntry = buildUserTimelineEntry("Run the command.");
1679 const markup = renderToStaticMarkup(
1680 <MessagesTimeline
1681 {...buildProps()}
1682 isWorking
1683 activeTurnInProgress
1684 latestRun={{
1685 runId,
1686 status: "running",
1687 startedAt: MESSAGE_CREATED_AT,
1688 completedAt: null,
1689 }}
1690 anchorMessageId={firstEntry.message.id}
1691 liveFollowEnabled={false}
1692 timelineEntries={[
1693 firstEntry,
1694 {
1695 id: "entry-active-tool",
1696 kind: "work",
1697 createdAt: MESSAGE_CREATED_AT,
1698 entry: {
1699 id: "work-active-tool",
1700 createdAt: MESSAGE_CREATED_AT,
1701 runId,
1702 label: "Run command",
1703 tone: "tool",
1704 itemType: "command_execution",
1705 command: "git status",
1706 toolLifecycleStatus: "inProgress",
1707 },
1708 },
1709 ]}
1710 />,
1711 );
1712
1713 expect(markup).toContain('data-anchor-index="0"');
1714 expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"');
1715 });
1716
1717 it("hands end-following back to the list once the send anchor is released", () => {
1718 const firstEntry = buildUserTimelineEntry("First prompt.");
1719 const secondEntry = {
1720 ...buildUserTimelineEntry("Newest prompt."),
1721 id: "entry-2",
1722 message: {
1723 ...buildUserTimelineEntry("Newest prompt.").message,
1724 id: MessageId.make("message-2"),
1725 },
1726 };
1727 const timelineEntries = [firstEntry, secondEntry];
1728
1729 // While the send anchor holds the end space open, ChatView owns streaming
1730 // scrolls and LegendList must not re-pin behind it.
1731 expect(
1732 renderToStaticMarkup(
1733 <MessagesTimeline
1734 {...buildProps()}
1735 anchorMessageId={firstEntry.message.id}
1736 timelineEntries={timelineEntries}
1737 />,
1738 ),
1739 ).not.toContain('data-maintain-scroll-at-end="enabled"');
1740
1741 // Dropping the anchor is what actually gives end-following back, so
1742 // returning to the live edge has to release it — re-enabling live follow
1743 // alone leaves nothing pinned to the stream.
1744 expect(
1745 renderToStaticMarkup(
1746 <MessagesTimeline
1747 {...buildProps()}
1748 anchorMessageId={null}
1749 timelineEntries={timelineEntries}
1750 />,
1751 ),
1752 ).toContain('data-maintain-scroll-at-end="enabled"');
1753
1754 // Reading history still wins over both.
1755 expect(
1756 renderToStaticMarkup(
1757 <MessagesTimeline
1758 {...buildProps()}
1759 anchorMessageId={null}
1760 liveFollowEnabled={false}
1761 timelineEntries={timelineEntries}
1762 />,
1763 ),
1764 ).not.toContain('data-maintain-scroll-at-end="enabled"');
1765 });
1766
1767 it("renders collapse controls for long user messages", () => {
1768 const markup = renderToStaticMarkup(
1769 <MessagesTimeline
1770 {...buildProps()}
1771 timelineEntries={[buildUserTimelineEntry(buildLongUserMessageText())]}
1772 />,
1773 );
1774
1775 expect(markup).toContain("Show full message");
1776 // LegendList owns ordinary end-follow (#5449): with live follow on and no
1777 // anchored end space, its maintainScrollAtEnd is enabled.
1778 expect(markup).toContain('data-maintain-scroll-at-end="enabled"');
1779 expect(markup).toContain('data-maintain-scroll-at-end-animated="false"');
1780 expect(markup).toContain('data-maintain-scroll-at-end-data-change="true"');
1781 expect(markup).toContain('data-maintain-scroll-at-end-item-layout="true"');
1782 expect(markup).toContain('data-maintain-scroll-at-end-layout="true"');
1783 expect(markup).toContain('data-user-message-collapsed="true"');
1784 expect(markup).toContain('data-user-message-fade="true"');
1785 expect(markup).toContain('data-user-message-footer="true"');
1786 });
1787
1788 it("does not render collapse controls for short user messages", () => {
1789 const markup = renderToStaticMarkup(
1790 <MessagesTimeline
1791 {...buildProps()}
1792 timelineEntries={[buildUserTimelineEntry("Short prompt.")]}
1793 />,
1794 );
1795
1796 expect(markup).not.toContain("Show full message");
1797 expect(markup).toContain('data-user-message-collapsible="false"');
1798 expect(markup).toContain("rounded-2xl bg-message p-3");
1799 });
1800
1801 it("identifies user-role messages sent by another agent", async () => {
1802 const { MessagesTimeline } = await import("./MessagesTimeline");
1803 const entry = buildUserTimelineEntry("Review this area");
1804 const agentMarkup = renderToStaticMarkup(
1805 <MessagesTimeline
1806 {...buildProps()}
1807 timelineEntries={[
1808 {
1809 ...entry,
1810 message: { ...entry.message, createdBy: "agent", creationSource: "provider" },
1811 },
1812 ]}
1813 />,
1814 );
1815 const userMarkup = renderToStaticMarkup(
1816 <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />,
1817 );
1818
1819 expect(agentMarkup).toContain('data-user-message-attribution="agent"');
1820 expect(agentMarkup).toContain("Sent by another agent");
1821 expect(userMarkup).not.toContain("Sent by another agent");
1822 });
1823
1824 it("keeps a subagent parent-thread link at the top of an empty timeline", async () => {
1825 const { MessagesTimeline } = await import("./MessagesTimeline");
1826 const markup = renderToStaticMarkup(
1827 <MessagesTimeline
1828 {...buildProps()}
1829 timelineEntries={[]}
1830 parentThreadLink={{
1831 threadId: ThreadId.make("thread-parent"),
1832 title: "Architecture audit",
1833 }}
1834 />,
1835 );
1836
1837 expect(markup).toContain('aria-label="Open parent thread"');
1838 expect(markup).toContain("Subagent of");
1839 expect(markup).toContain("Architecture audit");
1840 expect(markup).not.toContain("Send a message to start the conversation");
1841 });
1842
1843 it("keeps steer intent visible on committed user messages", async () => {
1844 const { MessagesTimeline } = await import("./MessagesTimeline");
1845 const entry = buildUserTimelineEntry("Adjust the current turn");
1846 const markup = renderToStaticMarkup(
1847 <MessagesTimeline
1848 {...buildProps()}
1849 timelineEntries={[
1850 { ...entry, message: { ...entry.message, inputIntent: "steer" as const } },
1851 ]}
1852 />,
1853 );
1854
1855 expect(markup).toContain("data-base-ui-tooltip-trigger");
1856 expect(markup).toContain("lucide-redo-2");
1857 expect(markup).toContain('data-user-message-intent="steer"');
1858 expect(markup).toContain("items-center justify-end gap-1");
1859 expect(markup).toContain("gap-1 text-xs leading-none text-muted-foreground");
1860 expect(markup.indexOf("Steer")).toBeLessThan(markup.indexOf("Adjust the current turn"));
1861 });
1862
1863 it("keeps compact spacing below a collapsed turn divider", () => {
1864 const runId = RunId.make("run-collapsed-spacing");
1865 const markup = renderToStaticMarkup(
1866 <MessagesTimeline
1867 {...buildProps()}
1868 timelineEntries={[
1869 buildUserTimelineEntry("Investigate spacing"),
1870 {
1871 id: "assistant-commentary-spacing",
1872 kind: "message",
1873 createdAt: "2026-03-17T19:12:30.000Z",
1874 message: {
1875 id: MessageId.make("assistant-commentary-spacing"),
1876 role: "assistant",
1877 text: "Checking the layout.",
1878 runId,
1879 createdAt: "2026-03-17T19:12:30.000Z",
1880 updatedAt: "2026-03-17T19:12:31.000Z",
1881 streaming: false,
1882 },
1883 },
1884 {
1885 id: "assistant-final-spacing",
1886 kind: "message",
1887 createdAt: "2026-03-17T19:12:32.000Z",
1888 message: {
1889 id: MessageId.make("assistant-final-spacing"),
1890 role: "assistant",
1891 text: "Spacing fixed.",
1892 runId,
1893 createdAt: "2026-03-17T19:12:32.000Z",
1894 updatedAt: "2026-03-17T19:12:33.000Z",
1895 streaming: false,
1896 },
1897 },
1898 ]}
1899 />,
1900 );
1901
1902 expect(markup).toContain('class="pb-1.5" data-timeline-row-id="turn-fold:');
1903 expect(markup).toContain('data-timeline-row-kind="turn-fold"');
1904 });
1905
1906 it("shows a collapsed disclosure for superseded attempt output", async () => {
1907 const { MessagesTimeline } = await import("./MessagesTimeline");
1908 const runId = RunId.make("run-steered");
1909 const supersededAttempt = {
1910 id: "attempt-1" as never,
1911 runId,
1912 attemptOrdinal: 1,
1913 rootNodeId: "node-attempt-1" as never,
1914 status: "superseded" as const,
1915 completedAt: null,
1916 };
1917 const activeAttempt = {
1918 id: "attempt-2" as never,
1919 runId,
1920 attemptOrdinal: 2,
1921 rootNodeId: "node-attempt-2" as never,
1922 status: "running" as const,
1923 completedAt: null,
1924 };
1925 const markup = renderToStaticMarkup(
1926 <MessagesTimeline
1927 {...buildProps()}
1928 latestRun={{
1929 runId,
1930 status: "running",
1931 startedAt: MESSAGE_CREATED_AT,
1932 completedAt: null,
1933 }}
1934 timelineEntries={[
1935 {
1936 id: "superseded-response-entry",
1937 kind: "message",
1938 createdAt: MESSAGE_CREATED_AT,
1939 attempt: supersededAttempt,
1940 message: {
1941 id: MessageId.make("superseded-response"),
1942 role: "assistant",
1943 text: "Partial response from the old attempt",
1944 runId,
1945 createdAt: MESSAGE_CREATED_AT,
1946 updatedAt: MESSAGE_CREATED_AT,
1947 streaming: false,
1948 },
1949 },
1950 {
1951 id: "active-response-entry",
1952 kind: "message",
1953 createdAt: "2026-03-17T19:12:29.000Z",
1954 attempt: activeAttempt,
1955 message: {
1956 id: MessageId.make("active-response"),
1957 role: "assistant",
1958 text: "Current response remains visible",
1959 runId,
1960 createdAt: "2026-03-17T19:12:29.000Z",
1961 updatedAt: "2026-03-17T19:12:29.000Z",
1962 streaming: true,
1963 },
1964 },
1965 ]}
1966 />,
1967 );
1968
1969 expect(markup).toContain('data-superseded-attempt-id="attempt-1"');
1970 expect(markup).toContain('aria-expanded="false"');
1971 expect(markup).toContain("Superseded attempt");
1972 expect(markup).toContain("Cut off by a steer");
1973 expect(markup).toContain("Current response remains visible");
1974 expect(markup).not.toContain("Partial response from the old attempt");
1975 });
1976
1977 it("exposes a per-response fork action for completed assistant items", async () => {
1978 const { MessagesTimeline } = await import("./MessagesTimeline");
1979 const projectedItem = {
1980 position: 0,
1981 visibility: "local",
1982 sourceThreadId: "thread-1",
1983 sourceItemId: "assistant-item-1",
1984 item: {
1985 id: "assistant-item-1",
1986 threadId: "thread-1",
1987 runId: "run-1",
1988 nodeId: null,
1989 providerThreadId: null,
1990 providerTurnId: null,
1991 nativeItemRef: null,
1992 parentItemId: null,
1993 ordinal: 0,
1994 status: "completed",
1995 title: null,
1996 startedAt: null,
1997 completedAt: null,
1998 updatedAt: {},
1999 type: "assistant_message",
2000 messageId: "assistant-message-1",
2001 text: "Done",
2002 streaming: false,
2003 },
2004 } as never;
2005 const markup = renderToStaticMarkup(
2006 <MessagesTimeline
2007 {...buildProps()}
2008 timelineEntries={[
2009 {
2010 id: "assistant-message-1",
2011 kind: "message",
2012 createdAt: MESSAGE_CREATED_AT,
2013 projectedItem,
2014 message: {
2015 id: MessageId.make("assistant-message-1"),
2016 role: "assistant",
2017 text: "Done",
2018 runId: RunId.make("run-1"),
2019 createdAt: MESSAGE_CREATED_AT,
2020 updatedAt: MESSAGE_CREATED_AT,
2021 streaming: false,
2022 },
2023 },
2024 ]}
2025 />,
2026 );
2027
2028 expect(markup).toContain('aria-label="Fork from this response"');
2029 });
2030
2031 it("renders inline terminal labels with the composer chip UI", async () => {
2032 const { MessagesTimeline } = await import("./MessagesTimeline");
2033 const markup = renderToStaticMarkup(
2034 <MessagesTimeline
2035 {...buildProps()}
2036 timelineEntries={[
2037 buildUserTimelineEntry(
2038 [
2039 buildLongUserMessageText("yoo what's @terminal-1:1-5 mean"),
2040 "",
2041 "<terminal_context>",
2042 "- Terminal 1 lines 1-5:",
2043 " 1 | julius@mac effect-http-ws-cli % bun i",
2044 " 2 | bun install v1.3.9 (cf6cdbbb)",
2045 "</terminal_context>",
2046 ].join("\n"),
2047 ),
2048 ]}
2049 />,
2050 );
2051
2052 expect(markup).toContain("Terminal 1 lines 1-5");
2053 expect(markup).toContain("lucide-terminal");
2054 expect(markup).toContain("yoo what&#x27;s");
2055 expect(markup).not.toContain("terminal_context");
2056 expect(markup).toContain("Show full message");
2057 }, 20_000);
2058
2059 it("renders chips for standalone element-pick context messages", () => {
2060 const markup = renderToStaticMarkup(
2061 <MessagesTimeline
2062 {...buildProps()}
2063 timelineEntries={[
2064 buildUserTimelineEntry(
2065 [
2066 "<element_context>",
2067 "- <SubmitButton> (Button.tsx:12):",
2068 " url: https://example.com/dashboard",
2069 " selector: button.submit",
2070 " source: /repo/src/Button.tsx:12:5",
2071 " html:",
2072 ' <button class="submit">Save</button>',
2073 "</element_context>",
2074 ].join("\n"),
2075 ),
2076 ]}
2077 />,
2078 );
2079
2080 expect(markup).toContain("SubmitButton");
2081 expect(markup).not.toContain("&lt;element_context");
2082 expect(markup).not.toContain("<element_context");
2083 });
2084
2085 it("keeps the copy button for collapsed long user messages", () => {
2086 const markup = renderToStaticMarkup(
2087 <MessagesTimeline
2088 {...buildProps()}
2089 timelineEntries={[buildUserTimelineEntry(buildLongUserMessageText())]}
2090 />,
2091 );
2092
2093 expect(markup).toContain('aria-label="Copy message"');
2094 expect(markup).toContain('data-user-message-collapsed="true"');
2095 expect(markup).toContain('data-user-message-footer="true"');
2096 });
2097
2098 it("renders context compaction entries in the normal work log", () => {
2099 const markup = renderToStaticMarkup(
2100 <MessagesTimeline
2101 {...buildProps()}
2102 timelineEntries={[
2103 {
2104 id: "entry-1",
2105 kind: "work",
2106 createdAt: "2026-03-17T19:12:28.000Z",
2107 entry: {
2108 id: "work-1",
2109 createdAt: "2026-03-17T19:12:28.000Z",
2110 label: "Context compacted",
2111 tone: "info",
2112 },
2113 },
2114 ]}
2115 />,
2116 );
2117
2118 expect(markup).toContain("Context compacted");
2119 });
2120
2121 it("does not render the transient V2 interruption request", async () => {
2122 const { MessagesTimeline } = await import("./MessagesTimeline");
2123 const markup = renderToStaticMarkup(
2124 <MessagesTimeline
2125 {...buildProps()}
2126 timelineEntries={[
2127 {
2128 id: "interrupt-request",
2129 kind: "event",
2130 createdAt: MESSAGE_CREATED_AT,
2131 projectedItem: {
2132 position: 0,
2133 visibility: "local",
2134 sourceThreadId: "thread-1",
2135 sourceItemId: "interrupt-request",
2136 item: {
2137 id: "interrupt-request",
2138 threadId: "thread-1",
2139 runId: "run-1",
2140 nodeId: null,
2141 providerThreadId: null,
2142 providerTurnId: null,
2143 nativeItemRef: null,
2144 parentItemId: null,
2145 ordinal: 0,
2146 status: "completed",
2147 title: null,
2148 startedAt: null,
2149 completedAt: null,
2150 updatedAt: {},
2151 type: "run_interrupt_request",
2152 message: "Waiting for the provider to stop.",
2153 },
2154 } as never,
2155 },
2156 ]}
2157 />,
2158 );
2159
2160 expect(markup).not.toContain('data-v2-item-type="run_interrupt_request"');
2161 expect(markup).not.toContain("Interrupt requested");
2162 expect(markup).not.toContain("Waiting for the provider to stop.");
2163 expect(markup).not.toContain("Structured details");
2164 });
2165
2166 it("renders context handoffs as from → to model endpoints instead of the summary", async () => {
2167 const { MessagesTimeline } = await import("./MessagesTimeline");
2168 const providerStatuses = [
2169 {
2170 instanceId: "codex_personal",
2171 driver: "codex",
2172 enabled: true,
2173 installed: true,
2174 version: null,
2175 status: "ready",
2176 auth: {},
2177 checkedAt: MESSAGE_CREATED_AT,
2178 models: [{ slug: "gpt-5.6-sol", name: "GPT 5.6 Sol", isCustom: false, capabilities: null }],
2179 slashCommands: [],
2180 skills: [],
2181 },
2182 {
2183 instanceId: "claudeAgent",
2184 driver: "claudeAgent",
2185 enabled: true,
2186 installed: true,
2187 version: null,
2188 status: "ready",
2189 auth: {},
2190 checkedAt: MESSAGE_CREATED_AT,
2191 models: [
2192 { slug: "claude-fable-5", name: "Claude Fable 5", isCustom: false, capabilities: null },
2193 ],
2194 slashCommands: [],
2195 skills: [],
2196 },
2197 ] as never;
2198 const buildHandoffEntry = (item: Record<string, unknown>) => ({
2199 id: "handoff-1",
2200 kind: "event" as const,
2201 createdAt: MESSAGE_CREATED_AT,
2202 projectedItem: {
2203 position: 0,
2204 visibility: "local",
2205 sourceThreadId: "thread-1",
2206 sourceItemId: "handoff-1",
2207 item: {
2208 id: "handoff-1",
2209 threadId: "thread-1",
2210 runId: "run-2",
2211 nodeId: null,
2212 providerThreadId: null,
2213 providerTurnId: null,
2214 nativeItemRef: null,
2215 parentItemId: null,
2216 ordinal: 0,
2217 status: "completed",
2218 title: "Provider handoff",
2219 startedAt: null,
2220 completedAt: null,
2221 updatedAt: {},
2222 type: "handoff",
2223 contextHandoffId: "handoff-1",
2224 fromProviderThreadIds: ["provider-thread-1"],
2225 toProviderThreadId: "provider-thread-2",
2226 strategy: "full_thread_summary",
2227 summary: "Full conversation context for provider handoff.",
2228 ...item,
2229 },
2230 } as never,
2231 });
2232
2233 const markup = renderToStaticMarkup(
2234 <MessagesTimeline
2235 {...buildProps()}
2236 providerStatuses={providerStatuses}
2237 timelineEntries={[
2238 buildHandoffEntry({
2239 fromProviderInstanceIds: ["codex_personal"],
2240 toProviderInstanceId: "claudeAgent",
2241 fromModelSelections: [{ instanceId: "codex_personal", model: "gpt-5.6-sol" }],
2242 toModel: "claude-fable-5",
2243 }),
2244 ]}
2245 />,
2246 );
2247
2248 expect(markup).toContain("Context handoff");
2249 expect(markup).toContain("GPT 5.6 Sol");
2250 expect(markup).toContain("Claude Fable 5");
2251 expect(markup).not.toContain("Full conversation context");
2252 expect(markup).not.toContain("·");
2253
2254 // Items persisted before models were stamped recover them from the
2255 // projection runs: the handoff's run is the target, the newest earlier
2256 // run per source instance is the origin.
2257 const legacyRuns = [
2258 {
2259 id: "run-1",
2260 ordinal: 1,
2261 providerInstanceId: "codex_personal",
2262 modelSelection: { instanceId: "codex_personal", model: "gpt-5.6-sol" },
2263 },
2264 {
2265 id: "run-2",
2266 ordinal: 2,
2267 providerInstanceId: "claudeAgent",
2268 modelSelection: { instanceId: "claudeAgent", model: "claude-fable-5" },
2269 },
2270 ] as never;
2271 const legacyMarkup = renderToStaticMarkup(
2272 <MessagesTimeline
2273 {...buildProps()}
2274 providerStatuses={providerStatuses}
2275 runs={legacyRuns}
2276 timelineEntries={[
2277 buildHandoffEntry({
2278 fromProviderInstanceIds: ["codex_personal"],
2279 toProviderInstanceId: "claudeAgent",
2280 }),
2281 ]}
2282 />,
2283 );
2284
2285 expect(legacyMarkup).toContain("GPT 5.6 Sol");
2286 expect(legacyMarkup).toContain("Claude Fable 5");
2287 expect(legacyMarkup).not.toContain("Full conversation context");
2288
2289 // Without run data either (e.g. cross-thread items) it falls back to
2290 // provider names.
2291 const bareMarkup = renderToStaticMarkup(
2292 <MessagesTimeline
2293 {...buildProps()}
2294 providerStatuses={providerStatuses}
2295 timelineEntries={[
2296 buildHandoffEntry({
2297 fromProviderInstanceIds: ["codex_personal"],
2298 toProviderInstanceId: "claudeAgent",
2299 }),
2300 ]}
2301 />,
2302 );
2303
2304 expect(bareMarkup).toContain("Codex Personal");
2305 expect(bareMarkup).not.toContain("Full conversation context");
2306 });
2307
2308 it("renders created threads as lean rows with inline chat links", async () => {
2309 const { MessagesTimeline } = await import("./MessagesTimeline");
2310 const markup = renderToStaticMarkup(
2311 <MessagesTimeline
2312 {...buildProps()}
2313 timelineEntries={[
2314 {
2315 id: "thread-created",
2316 kind: "event",
2317 createdAt: MESSAGE_CREATED_AT,
2318 projectedItem: {
2319 position: 0,
2320 visibility: "local",
2321 sourceThreadId: "thread-1",
2322 sourceItemId: "thread-created",
2323 item: {
2324 id: "thread-created",
2325 threadId: "thread-1",
2326 runId: "run-1",
2327 nodeId: "node-1",
2328 providerThreadId: null,
2329 providerTurnId: null,
2330 nativeItemRef: null,
2331 parentItemId: null,
2332 ordinal: 1,
2333 status: "completed",
2334 title: "Claude research thread",
2335 startedAt: null,
2336 completedAt: null,
2337 updatedAt: {},
2338 type: "thread_created",
2339 targetThreadId: "thread-2",
2340 targetRunId: "run-2",
2341 targetProviderInstanceId: "claude-default",
2342 targetModel: "claude-sonnet-4-6",
2343 },
2344 } as never,
2345 },
2346 ]}
2347 />,
2348 );
2349
2350 expect(markup).toContain('data-v2-item-type="thread_created"');
2351 expect(markup).toContain('aria-label="Open Claude research thread"');
2352 expect(markup).toContain("Claude research thread");
2353 expect(markup).toContain("Open chat");
2354 expect(markup).not.toContain("Work Log");
2355 });
2356
2357 it("keeps the collapsed summary icon neutral when the group ends in a failure", () => {
2358 const markup = renderToStaticMarkup(
2359 <MessagesTimeline
2360 {...buildProps()}
2361 timelineEntries={[
2362 {
2363 id: "entry-completed",
2364 kind: "work",
2365 createdAt: "2026-03-17T19:12:28.000Z",
2366 entry: {
2367 id: "work-completed",
2368 createdAt: "2026-03-17T19:12:28.000Z",
2369 label: "Run tests",
2370 tone: "tool",
2371 itemType: "command_execution",
2372 toolLifecycleStatus: "completed",
2373 },
2374 },
2375 {
2376 id: "entry-failed",
2377 kind: "work",
2378 createdAt: "2026-03-17T19:12:29.000Z",
2379 entry: {
2380 id: "work-failed",
2381 createdAt: "2026-03-17T19:12:29.000Z",
2382 label: "Run lint",
2383 tone: "tool",
2384 itemType: "command_execution",
2385 toolLifecycleStatus: "failed",
2386 },
2387 },
2388 ]}
2389 />,
2390 );
2391
2392 expect(markup).toContain("Ran 2 commands");
2393 expect(markup).toContain("lucide-terminal");
2394 expect(markup).not.toContain("lucide-x");
2395 expect(markup).not.toContain("text-destructive");
2396 // The failure stays discoverable for screen readers.
2397 expect(markup).toContain("tool call failed");
2398 });
2399
2400 it("keeps mixed work logs neutral after a later tool call succeeds", () => {
2401 const markup = renderToStaticMarkup(
2402 <MessagesTimeline
2403 {...buildProps()}
2404 timelineEntries={[
2405 {
2406 id: "entry-failed",
2407 kind: "work",
2408 createdAt: "2026-03-17T19:12:28.000Z",
2409 entry: {
2410 id: "work-failed",
2411 createdAt: "2026-03-17T19:12:28.000Z",
2412 label: "Run search",
2413 tone: "tool",
2414 itemType: "command_execution",
2415 toolLifecycleStatus: "failed",
2416 },
2417 },
2418 {
2419 id: "entry-info",
2420 kind: "work",
2421 createdAt: "2026-03-17T19:12:29.000Z",
2422 entry: {
2423 id: "work-info",
2424 createdAt: "2026-03-17T19:12:29.000Z",
2425 label: "Status updated",
2426 tone: "info",
2427 },
2428 },
2429 {
2430 id: "entry-completed",
2431 kind: "work",
2432 createdAt: "2026-03-17T19:12:30.000Z",
2433 entry: {
2434 id: "work-completed",
2435 createdAt: "2026-03-17T19:12:30.000Z",
2436 label: "Run tests",
2437 tone: "tool",
2438 itemType: "command_execution",
2439 toolLifecycleStatus: "completed",
2440 },
2441 },
2442 ]}
2443 />,
2444 );
2445
2446 expect(markup).toContain("Ran 2 commands and received 1 update");
2447 expect(markup).not.toContain('aria-label="Hidden work includes a failure"');
2448 });
2449
2450 it.each(
2451 (
2452 [
2453 {
2454 status: "running",
2455 progress: "Reading src/index.ts",
2456 result: null,
2457 preview: "Reading src/index.ts",
2458 },
2459 {
2460 status: "completed",
2461 progress: "Reading src/index.ts",
2462 result: "Tests should be isolated.",
2463 preview: "Tests should be isolated.",
2464 },
2465 {
2466 status: "running",
2467 progress: "Reading src/index.ts",
2468 result: "Partial streamed answer",
2469 preview: "Reading src/index.ts",
2470 },
2471 {
2472 status: "running",
2473 progress: undefined,
2474 result: "Streaming answer so far",
2475 preview: "Streaming answer so far",
2476 },
2477 {
2478 status: "cancelled",
2479 progress: "Reading src/index.ts",
2480 result: "Partial output before cancel",
2481 preview: "Partial output before cancel",
2482 },
2483 {
2484 status: "completed",
2485 progress: "Audited 12 packages",
2486 result: " \n\t ",
2487 preview: "Audited 12 packages",
2488 },
2489 ] as const
2490 ).flatMap((scenario) => [1, 2].map((count) => ({ ...scenario, count }))),
2491 )(
2492 "shows $count $status subagents with '$preview', grouping only multiple agents",
2493 async ({ status, progress, result, preview, count }) => {
2494 activityTestState.expandedRuns = true;
2495 activityTestState.subagentTooltips = true;
2496 vi.stubGlobal("HTMLElement", ElementStub);
2497 window.HTMLElement = ElementStub as typeof HTMLElement;
2498 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
2499 vi.stubGlobal("requestAnimationFrame", () => 0);
2500 vi.stubGlobal("cancelAnimationFrame", () => {});
2501 const onOpenThread = vi.fn();
2502 let renderer: ReactTestRenderer | undefined;
2503 try {
2504 await act(() => {
2505 renderer = create(
2506 <MessagesTimeline
2507 {...buildProps()}
2508 onOpenThread={onOpenThread}
2509 timelineEntries={Array.from({ length: count }, (_, index) => ({
2510 id: `subagent-progress-${index}`,
2511 kind: "event",
2512 createdAt: MESSAGE_CREATED_AT,
2513 projectedItem: {
2514 position: 0,
2515 visibility: "local",
2516 sourceThreadId: "thread-1",
2517 sourceItemId: "subagent-progress",
2518 item: {
2519 id: `subagent-progress-${index}`,
2520 threadId: "thread-1",
2521 runId: "run-1",
2522 nodeId: `node-subagent-${index}`,
2523 providerThreadId: "provider-thread-1",
2524 providerTurnId: "provider-turn-1",
2525 nativeItemRef: null,
2526 parentItemId: null,
2527 ordinal: 1,
2528 status,
2529 title: "Package audit",
2530 startedAt: null,
2531 completedAt: null,
2532 updatedAt: {},
2533 type: "subagent",
2534 subagentId: `node-subagent-${index}`,
2535 origin: "provider_native",
2536 driver: "claudeAgent",
2537 providerInstanceId: "claudeAgent",
2538 childThreadId: "thread-subagent-1",
2539 prompt: "Inspect the package",
2540 progress,
2541 result,
2542 },
2543 } as never,
2544 }))}
2545 />,
2546 );
2547 });
2548 const groupLabel = `${count} subagents`;
2549 const group = () =>
2550 renderer!.root.findAll(
2551 (node) => node.type === "button" && node.props["aria-label"] === groupLabel,
2552 )[0]!;
2553 const child = () =>
2554 renderer!.root.findAll(
2555 (node) => node.type === "button" && node.props["aria-label"] === "Open Package audit",
2556 );
2557 if (count > 1) {
2558 expect(child()).toHaveLength(0);
2559 await act(() => group().props.onClick({ nativeEvent: new Event("click") }));
2560 } else {
2561 expect(group()).toBeUndefined();
2562 }
2563 expect(child()).toHaveLength(count);
2564 const content = renderer!.root
2565 .findAll((node) => typeof node.type === "string")
2566 .flatMap((node) => node.children.filter((child) => typeof child === "string"))
2567 .join("");
2568 expect(content).toContain(preview);
2569 expect(content).not.toContain("Inspect the package");
2570 if (result?.trim() && result !== preview) expect(content).not.toContain(result);
2571 if (progress && progress !== preview) expect(content).not.toContain(progress);
2572 await act(() => child()[0]!.props.onClick());
2573 expect(onOpenThread).toHaveBeenCalledWith("thread-subagent-1");
2574 if (count > 1) {
2575 await act(() => group().props.onClick({ nativeEvent: new Event("click") }));
2576 expect(child()).toHaveLength(0);
2577 }
2578 } finally {
2579 await act(() => renderer?.unmount());
2580 vi.stubGlobal("HTMLElement", undefined);
2581 }
2582 },
2583 );
2584
2585 it("renders V2 provider retries in the normal work log", () => {
2586 activityTestState.expanded = true;
2587 const retryItem = {
2588 id: "provider-error",
2589 threadId: "thread-1",
2590 runId: "run-1",
2591 nodeId: null,
2592 providerThreadId: "provider-thread-1",
2593 providerTurnId: "provider-turn-1",
2594 nativeItemRef: null,
2595 parentItemId: null,
2596 ordinal: 99,
2597 status: "running",
2598 title: "Provider retry",
2599 startedAt: {},
2600 completedAt: null,
2601 updatedAt: {},
2602 type: "error",
2603 failure: {
2604 class: "transport_error",
2605 message: "The response stream disconnected.",
2606 code: "responseStreamDisconnected",
2607 retryable: true,
2608 },
2609 retry: {
2610 attempt: 2,
2611 maxAttempts: 5,
2612 retryDelayMs: null,
2613 },
2614 } as const;
2615 const projectedItem = {
2616 position: 0,
2617 visibility: "local",
2618 sourceThreadId: "thread-1",
2619 sourceItemId: retryItem.id,
2620 item: retryItem,
2621 } as const;
2622 const markup = renderToStaticMarkup(
2623 <MessagesTimeline
2624 {...buildProps()}
2625 isWorking
2626 activeTurnInProgress
2627 latestRun={{
2628 runId: RunId.make("run-1"),
2629 status: "running",
2630 startedAt: MESSAGE_CREATED_AT,
2631 completedAt: null,
2632 }}
2633 timelineEntries={
2634 [
2635 {
2636 id: "provider-error",
2637 kind: "work",
2638 createdAt: MESSAGE_CREATED_AT,
2639 entry: {
2640 id: retryItem.id,
2641 createdAt: MESSAGE_CREATED_AT,
2642 runId: "run-1",
2643 label: "Retrying provider (2/5)",
2644 detail: retryItem.failure.message,
2645 tone: "info",
2646 itemType: "error",
2647 toolLifecycleStatus: "inProgress",
2648 structuredPayload: retryItem,
2649 projectedItem,
2650 },
2651 },
2652 ] as never
2653 }
2654 />,
2655 );
2656
2657 expect(markup).toContain('data-v2-item-type="error"');
2658 expect(markup).toContain("Retrying provider (2/5)");
2659 // The failure message stays behind the row's expander.
2660 expect(markup).toContain('aria-expanded="false"');
2661 expect(markup).not.toContain('data-v2-event-disclosure="true"');
2662 });
2663
2664 it("keeps inherited V2 work provenance on the rendered row", async () => {
2665 activityTestState.expanded = true;
2666 const { MessagesTimeline } = await import("./MessagesTimeline");
2667 const item = {
2668 id: "command-inherited",
2669 threadId: "thread-source",
2670 runId: null,
2671 nodeId: null,
2672 providerThreadId: null,
2673 providerTurnId: null,
2674 nativeItemRef: null,
2675 parentItemId: null,
2676 ordinal: 0,
2677 status: "completed",
2678 title: null,
2679 startedAt: null,
2680 completedAt: null,
2681 updatedAt: {},
2682 type: "command_execution",
2683 input: "pwd",
2684 output: "/workspace",
2685 exitCode: 0,
2686 } as const;
2687 const projectedItem = {
2688 position: 0,
2689 visibility: "inherited",
2690 sourceThreadId: "thread-source",
2691 sourceItemId: item.id,
2692 item,
2693 } as const;
2694 const markup = renderToStaticMarkup(
2695 <MessagesTimeline
2696 {...buildProps()}
2697 timelineEntries={
2698 [
2699 {
2700 id: "context-info-entry",
2701 kind: "work",
2702 createdAt: MESSAGE_CREATED_AT,
2703 entry: {
2704 id: "context-info",
2705 createdAt: MESSAGE_CREATED_AT,
2706 label: "Session started",
2707 tone: "info",
2708 },
2709 },
2710 {
2711 id: item.id,
2712 kind: "work",
2713 createdAt: MESSAGE_CREATED_AT,
2714 entry: {
2715 id: item.id,
2716 createdAt: MESSAGE_CREATED_AT,
2717 runId: null,
2718 label: "Ran command",
2719 command: item.input,
2720 tone: "tool",
2721 itemType: item.type,
2722 toolLifecycleStatus: "completed",
2723 structuredPayload: item,
2724 projectedItem,
2725 },
2726 },
2727 ] as never
2728 }
2729 />,
2730 );
2731
2732 expect(markup).toContain('data-v2-item-type="command_execution"');
2733 expect(markup).toContain('data-v2-item-visibility="inherited"');
2734 expect(markup).toContain("Received 1 update and ran 1 command");
2735 });
2736
2737 it("renders T3 MCP dynamic tools with the product logo and pretty name", async () => {
2738 activityTestState.expanded = true;
2739 const { MessagesTimeline } = await import("./MessagesTimeline");
2740 const item = {
2741 id: "tool-t3-thread-read",
2742 threadId: "thread-source",
2743 runId: null,
2744 nodeId: null,
2745 providerThreadId: null,
2746 providerTurnId: null,
2747 nativeItemRef: null,
2748 parentItemId: null,
2749 ordinal: 0,
2750 status: "completed",
2751 title: null,
2752 startedAt: null,
2753 completedAt: null,
2754 updatedAt: {},
2755 type: "dynamic_tool",
2756 toolName: "mcp__t3-code__t3_thread_read",
2757 input: { threadId: "thread-child" },
2758 output: { messages: [] },
2759 } as const;
2760 const projectedItem = {
2761 position: 0,
2762 visibility: "local",
2763 sourceThreadId: "thread-source",
2764 sourceItemId: item.id,
2765 item,
2766 } as const;
2767 const markup = renderToStaticMarkup(
2768 <MessagesTimeline
2769 {...buildProps()}
2770 timelineEntries={
2771 [
2772 {
2773 id: "context-info-entry",
2774 kind: "work",
2775 createdAt: MESSAGE_CREATED_AT,
2776 entry: {
2777 id: "context-info",
2778 createdAt: MESSAGE_CREATED_AT,
2779 label: "Session started",
2780 tone: "info",
2781 },
2782 },
2783 {
2784 id: item.id,
2785 kind: "work",
2786 createdAt: MESSAGE_CREATED_AT,
2787 entry: {
2788 id: item.id,
2789 createdAt: MESSAGE_CREATED_AT,
2790 runId: null,
2791 label: item.toolName,
2792 tone: "tool",
2793 itemType: item.type,
2794 toolTitle: item.toolName,
2795 toolLifecycleStatus: "completed",
2796 toolData: { input: item.input, output: item.output },
2797 structuredPayload: item,
2798 projectedItem,
2799 },
2800 },
2801 ] as never
2802 }
2803 />,
2804 );
2805
2806 expect(markup).toContain("Read a T3 thread");
2807 expect(markup).not.toContain("mcp__t3-code__t3_thread_read");
2808 });
2809
2810 it("formats changed file paths from the workspace root", async () => {
2811 activityTestState.expanded = true;
2812 const { MessagesTimeline } = await import("./MessagesTimeline");
2813 const markup = renderToStaticMarkup(
2814 <MessagesTimeline
2815 {...buildProps()}
2816 timelineEntries={[
2817 {
2818 id: "context-info-entry",
2819 kind: "work",
2820 createdAt: "2026-03-17T19:12:28.000Z",
2821 entry: {
2822 id: "context-info",
2823 createdAt: "2026-03-17T19:12:28.000Z",
2824 label: "Session started",
2825 tone: "info",
2826 },
2827 },
2828 {
2829 id: "entry-1",
2830 kind: "work",
2831 createdAt: "2026-03-17T19:12:28.000Z",
2832 entry: {
2833 id: "work-1",
2834 createdAt: "2026-03-17T19:12:28.000Z",
2835 label: "Updated files",
2836 tone: "tool",
2837 itemType: "file_change",
2838 toolLifecycleStatus: "completed",
2839 changedFiles: ["C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"],
2840 },
2841 },
2842 ]}
2843 workspaceRoot="C:/Users/mike/dev-stuff/t3code"
2844 />,
2845 );
2846
2847 expect(markup).toContain("t3code/apps/web/src/session-logic.ts");
2848 expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts");
2849 });
2850
2851 it("renders review comment contexts as structured cards instead of raw tags", () => {
2852 const markup = renderToStaticMarkup(
2853 <MessagesTimeline
2854 {...buildProps()}
2855 timelineEntries={[
2856 {
2857 id: "entry-1",
2858 kind: "message",
2859 createdAt: "2026-03-17T19:12:28.000Z",
2860 message: {
2861 id: MessageId.make("message-2"),
2862 role: "user",
2863 text: [
2864 '<review_comment sectionId="turn:2" sectionTitle="Turn 2" filePath="apps/web/src/lib/contextWindow.test.ts" startIndex="3" endIndex="14" rangeLabel="+47 to +58">',
2865 "Wadduo",
2866 "```diff",
2867 "@@ -0,0 +47,2 @@",
2868 '+ it("keeps valid zero-usage snapshots", () => {',
2869 "+ expect(snapshot).not.toBeNull();",
2870 "```",
2871 "</review_comment>",
2872 ].join("\n"),
2873 runId: null,
2874 createdAt: "2026-03-17T19:12:28.000Z",
2875 updatedAt: "2026-03-17T19:12:28.000Z",
2876 streaming: false,
2877 },
2878 },
2879 ]}
2880 />,
2881 );
2882
2883 expect(markup).toContain("contextWindow.test.ts +47 to +58");
2884 expect(markup).toContain("lucide-message-circle");
2885 expect(markup).not.toContain(">Review comment<");
2886 expect(markup).not.toContain("&lt;review_comment");
2887 expect(markup).not.toContain("&lt;/review_comment&gt;");
2888 });
2889
2890 it("renders file review comments as source code instead of diffs", () => {
2891 const markup = renderToStaticMarkup(
2892 <MessagesTimeline
2893 {...buildProps()}
2894 timelineEntries={[
2895 {
2896 id: "entry-1",
2897 kind: "message",
2898 createdAt: "2026-03-17T19:12:28.000Z",
2899 message: {
2900 id: MessageId.make("message-source-comment"),
2901 role: "user",
2902 text: [
2903 '<review_comment sectionId="file:docs/plan.md" sectionTitle="File comment" filePath="docs/plan.md" startIndex="0" endIndex="1" rangeLabel="L1 to L2">',
2904 "Clarify this.",
2905 "```md",
2906 "# Plan",
2907 "- Step one",
2908 "```",
2909 "</review_comment>",
2910 ].join("\n"),
2911 runId: null,
2912 createdAt: "2026-03-17T19:12:28.000Z",
2913 updatedAt: "2026-03-17T19:12:28.000Z",
2914 streaming: false,
2915 },
2916 },
2917 ]}
2918 />,
2919 );
2920
2921 expect(markup).toContain("plan.md L1 to L2");
2922 expect(markup).not.toContain("review_comment");
2923 expect(markup).not.toContain('data-testid="file-diff"');
2924 });
2925
2926 it("collapses settled tool runs behind a generated summary toggle", () => {
2927 const markup = renderToStaticMarkup(
2928 <MessagesTimeline
2929 {...buildProps()}
2930 timelineEntries={[
2931 {
2932 id: "entry-1",
2933 kind: "work",
2934 createdAt: "2026-03-17T19:12:28.000Z",
2935 entry: {
2936 id: "work-1",
2937 createdAt: "2026-03-17T19:12:28.000Z",
2938 label: "Ran command",
2939 command: "vp lint",
2940 tone: "tool",
2941 itemType: "command_execution",
2942 toolLifecycleStatus: "completed",
2943 },
2944 },
2945 {
2946 id: "entry-2",
2947 kind: "work",
2948 createdAt: "2026-03-17T19:12:29.000Z",
2949 entry: {
2950 id: "work-2",
2951 createdAt: "2026-03-17T19:12:29.000Z",
2952 label: "Ran command",
2953 command: "vp test run",
2954 tone: "tool",
2955 itemType: "command_execution",
2956 toolLifecycleStatus: "completed",
2957 },
2958 },
2959 ]}
2960 />,
2961 );
2962
2963 expect(markup).toContain("Ran 2 commands");
2964 expect(markup).toContain('aria-expanded="false"');
2965 // Entries stay hidden until the toggle expands the group.
2966 expect(markup).not.toContain("vp lint");
2967 });
2968
2969 it("renders a muted failure marker for failed tool lifecycle entries", () => {
2970 activityTestState.expanded = true;
2971 const markup = renderToStaticMarkup(
2972 <MessagesTimeline
2973 {...buildProps()}
2974 timelineEntries={[
2975 {
2976 id: "entry-info",
2977 kind: "work",
2978 createdAt: "2026-03-17T19:12:27.000Z",
2979 entry: {
2980 id: "work-info",
2981 createdAt: "2026-03-17T19:12:27.000Z",
2982 label: "Status updated",
2983 tone: "info",
2984 },
2985 },
2986 {
2987 id: "entry-1",
2988 kind: "work",
2989 createdAt: "2026-03-17T19:12:28.000Z",
2990 entry: {
2991 id: "work-1",
2992 createdAt: "2026-03-17T19:12:28.000Z",
2993 label: "Glob",
2994 tone: "tool",
2995 toolLifecycleStatus: "failed",
2996 detail: "No files found",
2997 },
2998 },
2999 ]}
3000 />,
3001 );
3002
3003 expect(markup).toContain("lucide-zap");
3004 expect(markup).toContain('aria-label="Tool call failed"');
3005 // Ordinary tool failures render muted, not red.
3006 expect(markup).not.toContain("text-destructive");
3007 });
3008
3009 it("keeps the red treatment for severe orchestration failures", () => {
3010 const markup = renderToStaticMarkup(
3011 <MessagesTimeline
3012 {...buildProps()}
3013 timelineEntries={[
3014 {
3015 id: "entry-info",
3016 kind: "work",
3017 createdAt: "2026-03-17T19:12:27.000Z",
3018 entry: {
3019 id: "work-info",
3020 createdAt: "2026-03-17T19:12:27.000Z",
3021 label: "Status updated",
3022 tone: "info",
3023 },
3024 },
3025 {
3026 id: "entry-turn-failed",
3027 kind: "work",
3028 createdAt: "2026-03-17T19:12:28.000Z",
3029 entry: {
3030 id: "work-turn-failed",
3031 createdAt: "2026-03-17T19:12:28.000Z",
3032 label: "Provider turn start failed",
3033 tone: "error",
3034 itemType: "error",
3035 toolLifecycleStatus: "failed",
3036 },
3037 },
3038 ]}
3039 />,
3040 );
3041
3042 expect(markup).toContain("lucide-circle-alert");
3043 expect(markup).toContain("text-destructive");
3044 });
3045
3046 it.each([
3047 [
3048 "**Viewing image first** with *care*, ~~old~~ `code` and [context](https://example.com)",
3049 "Viewing image first with care, old code and context",
3050 1,
3051 ],
3052 ["first paragraph\n\nsecond paragraph", "first paragraph second paragraph", 0],
3053 ["- first\n- second", "first second", 0],
3054 ["first \nsecond", "first second", 0],
3055 ["![image description](image.png)", "image description", 0],
3056 ["![](image.png)", "Thought", 0],
3057 ["---", "Thought", 0],
3058 ] as const)(
3059 "shows plain text for a V2 reasoning preview: %s",
3060 async (markdown, expected, strongCount) => {
3061 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
3062 vi.stubGlobal("requestAnimationFrame", () => 0);
3063 vi.stubGlobal("cancelAnimationFrame", () => {});
3064 activityTestState.expanded = true;
3065 let renderer: ReactTestRenderer | undefined;
3066 try {
3067 await act(() => {
3068 renderer = create(
3069 <MessagesTimeline
3070 {...buildProps()}
3071 timelineEntries={[
3072 {
3073 id: "reasoning-preview",
3074 kind: "work",
3075 createdAt: MESSAGE_CREATED_AT,
3076 entry: {
3077 id: "reasoning-preview",
3078 createdAt: MESSAGE_CREATED_AT,
3079 label: markdown,
3080 detail: markdown,
3081 tone: "thinking",
3082 itemType: "reasoning",
3083 toolLifecycleStatus: "completed",
3084 },
3085 },
3086 ]}
3087 />,
3088 );
3089 });
3090 const previewText = () =>
3091 renderer!.root
3092 .findAllByType("span")
3093 .flatMap((node) => node.findAll(() => true))
3094 .flatMap((node) => node.children)
3095 .filter((child) => typeof child === "string")
3096 .join(" ");
3097 expect(previewText()).toContain(expected);
3098 expect(renderer!.root.findAllByType("strong")).toHaveLength(0);
3099 const row = () =>
3100 renderer!.root.findAll(
3101 (node) =>
3102 node.type === "div" &&
3103 node.props.role === "button" &&
3104 typeof node.props["aria-expanded"] === "boolean",
3105 )[0]!;
3106 await act(() => row().props.onClick());
3107 expect(renderer!.root.findAllByType("strong")).toHaveLength(strongCount);
3108 await act(() => row().props.onClick());
3109 expect(previewText()).toContain(expected);
3110 expect(renderer!.root.findAllByType("strong")).toHaveLength(0);
3111 } finally {
3112 await act(() => renderer?.unmount());
3113 }
3114 },
3115 );
3116
3117 it("expands and collapses a tool call through its header", async () => {
3118 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
3119 vi.stubGlobal("requestAnimationFrame", () => 0);
3120 vi.stubGlobal("cancelAnimationFrame", () => {});
3121 let renderer: ReactTestRenderer | undefined;
3122 try {
3123 await act(() => {
3124 renderer = create(
3125 <MessagesTimeline
3126 {...buildProps()}
3127 timelineEntries={[
3128 {
3129 id: "entry-standalone",
3130 kind: "work",
3131 createdAt: MESSAGE_CREATED_AT,
3132 entry: {
3133 id: "work-standalone",
3134 createdAt: MESSAGE_CREATED_AT,
3135 toolCallId: "call-standalone",
3136 label: "Run lint",
3137 tone: "tool",
3138 itemType: "command_execution",
3139 command: "pnpm lint",
3140 toolLifecycleStatus: "completed",
3141 },
3142 },
3143 ]}
3144 />,
3145 );
3146 });
3147 await act(() => renderer!.root.findByProps({ "aria-expanded": false }).props.onClick());
3148 const expanded = renderer!.root.findAll(
3149 (node) => node.type === "div" && node.props["aria-expanded"] === true,
3150 )[0]!;
3151 expect(expanded).toBeDefined();
3152 await act(() => expanded.props.onClick());
3153 expect(
3154 renderer!.root.findAll(
3155 (node) => node.type === "div" && node.props["aria-expanded"] === true,
3156 ),
3157 ).toHaveLength(0);
3158 } finally {
3159 await act(() => renderer?.unmount());
3160 }
3161 });
3162});