import { ApprovalRequestId, CheckpointRef, EnvironmentId, MessageId, TurnId,} from "@t3tools/contracts";import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react";import { renderToStaticMarkup } from "react-dom/server";import { create, type ReactTestRenderer } from "react-test-renderer";import { beforeAll, describe, expect, it, vi } from "vite-plus/test";import type { LegendListRef, MaintainScrollAtEndOptions } from "@legendapp/list/react";import { shouldUseRestingComposerLayout } from "../composerFooterLayout";import { useComposerFocusState } from "./useComposerFocusState"; vi.mock("@legendapp/list/react", async () => { const legendListTestId = "legend-list"; const LegendList = (props: { data: Array<{ id: string }>; keyExtractor: (item: { id: string }) => string; renderItem: (args: { item: { id: string } }) => ReactNode; ListHeaderComponent?: ReactNode; ListFooterComponent?: ReactNode; anchoredEndSpace?: { anchorIndex: number; anchorMaxSize?: number; anchorOffset?: number; onReady?: (info: { anchorIndex: number }) => void; }; contentInsetEndAdjustment?: number; className?: string; maintainScrollAtEnd?: boolean | MaintainScrollAtEndOptions; maintainVisibleContentPosition?: | boolean | { data?: boolean; size?: boolean; shouldRestorePosition?: (item: { id: string }) => boolean; }; ref?: Ref<LegendListRef>; }) => { if (props.anchoredEndSpace) { props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex }); } return ( <div data-testid={legendListTestId} data-anchor-index={props.anchoredEndSpace?.anchorIndex} data-anchor-max-size={props.anchoredEndSpace?.anchorMaxSize} data-anchor-offset={props.anchoredEndSpace?.anchorOffset} data-anchor-on-ready={Boolean(props.anchoredEndSpace?.onReady)} data-content-inset-end={props.contentInsetEndAdjustment} data-class-name={props.className} data-maintain-scroll-at-end={props.maintainScrollAtEnd ? "enabled" : undefined} data-maintain-scroll-at-end-animated={ typeof props.maintainScrollAtEnd === "object" ? props.maintainScrollAtEnd.animated : undefined } data-maintain-scroll-at-end-data-change={ typeof props.maintainScrollAtEnd === "object" ? props.maintainScrollAtEnd.on?.dataChange : undefined } data-maintain-scroll-at-end-footer-layout={ typeof props.maintainScrollAtEnd === "object" ? props.maintainScrollAtEnd.on?.footerLayout : undefined } data-maintain-scroll-at-end-item-layout={ typeof props.maintainScrollAtEnd === "object" ? props.maintainScrollAtEnd.on?.itemLayout : undefined } data-maintain-scroll-at-end-layout={ typeof props.maintainScrollAtEnd === "object" ? props.maintainScrollAtEnd.on?.layout : undefined } data-maintain-visible-content-position={ typeof props.maintainVisibleContentPosition === "object" ? "object" : props.maintainVisibleContentPosition } data-maintain-visible-content-position-data={ typeof props.maintainVisibleContentPosition === "object" ? props.maintainVisibleContentPosition.data : undefined } data-maintain-visible-content-position-size={ typeof props.maintainVisibleContentPosition === "object" ? props.maintainVisibleContentPosition.size : undefined } data-maintain-visible-content-position-restore={ typeof props.maintainVisibleContentPosition === "object" ? Boolean(props.maintainVisibleContentPosition.shouldRestorePosition) : undefined } > {props.ListHeaderComponent} {props.data.map((item) => ( <div key={props.keyExtractor(item)}>{props.renderItem({ item })}</div> ))} {props.ListFooterComponent} </div> ); }; return { LegendList };}); function MockFileDiff(props: { fileDiff: { name?: string | null; prevName?: string | null }; renderCustomHeader?: (fileDiff: { name?: string | null; prevName?: string | null; }) => React.ReactNode;}) { return ( <div data-testid="file-diff"> {props.renderCustomHeader?.(props.fileDiff)} {props.fileDiff.name ?? props.fileDiff.prevName ?? "diff"} </div> );} vi.mock("@pierre/diffs/react", () => { return { FileDiff: MockFileDiff };}); vi.mock("../DiffWorkerPoolProvider", () => ({ DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => children,})); function matchMedia() { return { matches: false, addEventListener: () => {}, removeEventListener: () => {}, };} let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline; beforeAll(async () => { const classList = { add: () => {}, remove: () => {}, toggle: () => {}, contains: () => false, }; vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, removeItem: () => {}, clear: () => {}, }); vi.stubGlobal("window", { matchMedia, addEventListener: () => {}, removeEventListener: () => {}, requestAnimationFrame: (callback: FrameRequestCallback) => { callback(0); return 0; }, cancelAnimationFrame: () => {}, desktopBridge: undefined, }); vi.stubGlobal("document", { documentElement: { classList, offsetHeight: 0, }, }); ({ MessagesTimeline } = await import("./MessagesTimeline"));}, 30_000); const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local");const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z"; function buildProps() { return { isWorking: false, activeTurnStartedAt: null, listRef: createRef<LegendListRef | null>(), latestTurn: null, runningTurnId: null, turnDiffSummaries: [], routeThreadKey: "environment-local:thread-1", onOpenTurnDiff: () => {}, supportsConversationRollback: false, onRevertToTurnCount: () => {}, isRevertingCheckpoint: false, onImageExpand: () => {}, activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID, markdownCwd: undefined, resolvedTheme: "light" as const, timestampFormat: "locale" as const, workspaceRoot: undefined, anchorMessageId: null, onAnchorReady: () => {}, contentInsetEndAdjustment: 0, liveFollowEnabled: true, onIsAtEndChange: () => {}, onManualNavigation: () => {}, };} function buildLongUserMessageText(tail = "deep hidden detail only after expand") { return Array.from({ length: 9 }, (_, index) => index === 8 ? tail : `Line ${index + 1}: ${"verbose prompt content ".repeat(8).trim()}`, ).join("\n");} function buildUserTimelineEntry(text: string) { return { id: "entry-1", kind: "message" as const, createdAt: MESSAGE_CREATED_AT, message: { id: MessageId.make("message-1"), role: "user" as const, text, turnId: null, createdAt: MESSAGE_CREATED_AT, updatedAt: MESSAGE_CREATED_AT, streaming: false, }, };} function buildAssistantTimelineEntry(text: string) { const entry = buildUserTimelineEntry(text); return { ...entry, message: { ...entry.message, role: "assistant" as const, }, };} function buildSnapShotTimelineEntry(previewUrl?: string) { const entry = buildUserTimelineEntry("First prompt."); return { ...entry, message: { ...entry.message, attachments: [ { type: "image" as const, id: "attachment-1", name: "screenshot.png", mimeType: "image/png", sizeBytes: 1, ...(previewUrl ? { previewUrl } : {}), source: { kind: "snap-shot" as const, capturedAt: "2026-03-17T19:12:28.000Z", appName: "Terminal", windowTitle: "t3code — Tests", appIconDataUrl: "data:image/png;base64,aWNvbg==", }, }, ], }, };} describe("MessagesTimeline", () => { it("renders previous and next controls with the minimap", () => { const first = buildUserTimelineEntry("First turn"); const secondBase = buildUserTimelineEntry("Second turn"); const second = { ...secondBase, id: "entry-2", message: { ...secondBase.message, id: MessageId.make("message-2"), }, }; const markup = renderToStaticMarkup( <MessagesTimeline {...buildProps()} timelineEntries={[first, second]} />, ); expect(markup).toContain('aria-label="Previous turn"'); expect(markup).toContain('aria-label="Next turn"'); }); // Expanding history uses this suite's existing test renderer, deprecated in // React 19. Migrate these interaction tests together when a DOM test setup is added. it.each([{}, { text: "Text-only answer", file: "Answer with a file" }])( "renders attachment-only question history alongside text answers: %j", async (answers) => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("requestAnimationFrame", () => 0); vi.stubGlobal("cancelAnimationFrame", () => {}); let renderer: ReactTestRenderer | undefined; try { await act(() => { renderer = create( <MessagesTimeline {...buildProps()} timelineEntries={[ { id: "answer-entry", kind: "work", createdAt: MESSAGE_CREATED_AT, entry: { id: "answer-work", createdAt: MESSAGE_CREATED_AT, label: "Question answer submitted", tone: "info", questionAnswer: { requestId: ApprovalRequestId.make("question-request"), answers, questionTextById: { file: "Provide a spec", image: "Provide a screenshot" }, attachmentsByQuestionId: { file: [ { type: "file", id: "spec", name: "spec.txt", mimeType: "text/plain", sizeBytes: 4, }, ], image: [ { type: "image", id: "shot", name: "shot.png", mimeType: "image/png", sizeBytes: 4, }, ], }, }, }, }, ]} />, ); }); const toggle = renderer!.root.findByProps({ "aria-expanded": false }); await act(() => toggle.props.onClick()); const markup = JSON.stringify(renderer!.toJSON()); expect(markup.match(/Provide a spec/g)).toHaveLength(1); expect(markup.match(/spec\.txt/g)).toHaveLength(1); expect(markup).toContain("Provide a screenshot"); expect(markup).toContain("shot.png"); for (const answer of Object.values(answers)) expect(markup).toContain(answer); } finally { await act(() => renderer?.unmount()); } }, ); it.each([ { toolLifecycleStatus: "inProgress", isAtEnd: true }, { toolLifecycleStatus: "inProgress", isAtEnd: false }, { toolLifecycleStatus: "completed", isAtEnd: true }, { toolLifecycleStatus: "completed", isAtEnd: false }, ] as const)( "restores the composer after closing $toolLifecycleStatus tool output only at the end: $isAtEnd", async ({ toolLifecycleStatus, isAtEnd }) => { const frames = new Map<number, FrameRequestCallback>(); let nextFrame = 0; vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { frames.set(++nextFrame, callback); return nextFrame; }); vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame)); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const flushFrame = () => act(() => { const callbacks = [...frames.values()]; frames.clear(); callbacks.forEach((callback) => callback(0)); }); const props = buildProps(); let timelineIsAtEnd = isAtEnd; props.listRef.current = { getState: () => ({ isAtEnd: timelineIsAtEnd }), getScrollableNode: () => null, } as unknown as LegendListRef; let isResting = false; let composerState: ReturnType<typeof useComposerFocusState> | undefined; function ThreadProbe() { const composer = useComposerFocusState(); useLayoutEffect(() => { composerState = composer; isResting = shouldUseRestingComposerLayout({ isExistingThread: true, isMobileViewport: false, isScrollCollapsed: composer.isComposerScrollCollapsed, hasExpandedChrome: false, hasMultilinePrompt: false, timelineOverflows: true, }); }); return ( <MessagesTimeline {...props} isWorking={toolLifecycleStatus === "inProgress"} onToolOutputCollapsedAtEnd={composer.restoreAfterTimelineReachedEnd} timelineEntries={[ { id: "running-tool", kind: "work", createdAt: MESSAGE_CREATED_AT, entry: { id: "running-tool", createdAt: MESSAGE_CREATED_AT, label: "Run command", tone: "tool", toolLifecycleStatus, detail: "Command output", }, }, ]} /> ); } let renderer: ReactTestRenderer | undefined; try { await act(() => { renderer = create(<ThreadProbe />); }); // The user scrolled up to read, so the composer is resting. await act(() => composerState!.setIsComposerScrollCollapsed(true)); const toggle = renderer!.root.findByProps({ "aria-expanded": false }); await act(() => toggle.props.onClick()); await flushFrame(); await flushFrame(); expect(isResting).toBe(true); timelineIsAtEnd = false; await act(() => toggle.props.onClick()); await flushFrame(); timelineIsAtEnd = isAtEnd; await flushFrame(); expect(isResting).toBe(!isAtEnd); } finally { await act(() => renderer?.unmount()); } }, ); it("renders elapsed time for a completed turn", () => { const turnId = TurnId.make("turn-with-fold"); const assistantEntry = buildAssistantTimelineEntry("Done."); const markup = renderToStaticMarkup( <MessagesTimeline {...buildProps()} latestTurn={{ turnId, state: "completed", startedAt: "2026-03-17T19:12:20.000Z", completedAt: "2026-03-17T19:12:28.000Z", }} timelineEntries={[ { id: "work-entry-with-fold", kind: "work", createdAt: "2026-03-17T19:12:22.000Z", entry: { id: "work-with-fold", createdAt: "2026-03-17T19:12:22.000Z", turnId, label: "Ran command", tone: "tool", toolLifecycleStatus: "completed", }, }, { ...assistantEntry, message: { ...assistantEntry.message, turnId }, }, ]} />, ); expect(markup).toContain("Worked for 8.0s"); }); it("keeps assistant changed-files headers sticky below the thread header", () => { const assistantMessageId = MessageId.make("message-assistant-with-files"); const turnId = TurnId.make("turn-with-files"); const markup = renderToStaticMarkup( <MessagesTimeline {...buildProps()} latestTurn={{ turnId, state: "completed", startedAt: MESSAGE_CREATED_AT,Lines 1–500 of 1816. Load more
import { ApprovalRequestId, CheckpointRef, EnvironmentId, MessageId, TurnId, } from "@t3tools/contracts"; import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; import type { LegendListRef, MaintainScrollAtEndOptions } from "@legendapp/list/react"; import { shouldUseRestingComposerLayout } from "../composerFooterLayout"; import { useComposerFocusState } from "./useComposerFocusState"; vi.mock("@legendapp/list/react", async () => { const legendListTestId = "legend-list"; const LegendList = (props: { data: Array<{ id: string }>; keyExtractor: (item: { id: string }) => string; renderItem: (args: { item: { id: string } }) => ReactNode; ListHeaderComponent?: ReactNode; ListFooterComponent?: ReactNode; anchoredEndSpace?: { anchorIndex: number; anchorMaxSize?: number; anchorOffset?: number; onReady?: (info: { anchorIndex: number }) => void; }; contentInsetEndAdjustment?: number; className?: string; maintainScrollAtEnd?: boolean | MaintainScrollAtEndOptions; maintainVisibleContentPosition?: | boolean | { data?: boolean; size?: boolean; shouldRestorePosition?: (item: { id: string }) => boolean; }; ref?: Ref<LegendListRef>; }) => { if (props.anchoredEndSpace) { props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex }); } return ( <div data-testid={legendListTestId} data-anchor-index={props.anchoredEndSpace?.anchorIndex} data-anchor-max-size={props.anchoredEndSpace?.anchorMaxSize} data-anchor-offset={props.anchoredEndSpace?.anchorOffset} data-anchor-on-ready={Boolean(props.anchoredEndSpace?.onReady)} data-content-inset-end={props.contentInsetEndAdjustment} data-class-name={props.className} data-maintain-scroll-at-end={props.maintainScrollAtEnd ? "enabled" : undefined} data-maintain-scroll-at-end-animated={ typeof props.maintainScrollAtEnd === "object" ? props.maintainScrollAtEnd.animated : undefined } data-maintain-scroll-at-end-data-change={ typeof props.maintainScrollAtEnd === "object" ? props.maintainScrollAtEnd.on?.dataChange : undefined } data-maintain-scroll-at-end-footer-layout={ typeof props.maintainScrollAtEnd === "object" ? props.maintainScrollAtEnd.on?.footerLayout : undefined } data-maintain-scroll-at-end-item-layout={ typeof props.maintainScrollAtEnd === "object" ? props.maintainScrollAtEnd.on?.itemLayout : undefined } data-maintain-scroll-at-end-layout={ typeof props.maintainScrollAtEnd === "object" ? props.maintainScrollAtEnd.on?.layout : undefined } data-maintain-visible-content-position={ typeof props.maintainVisibleContentPosition === "object" ? "object" : props.maintainVisibleContentPosition } data-maintain-visible-content-position-data={ typeof props.maintainVisibleContentPosition === "object" ? props.maintainVisibleContentPosition.data : undefined } data-maintain-visible-content-position-size={ typeof props.maintainVisibleContentPosition === "object" ? props.maintainVisibleContentPosition.size : undefined } data-maintain-visible-content-position-restore={ typeof props.maintainVisibleContentPosition === "object" ? Boolean(props.maintainVisibleContentPosition.shouldRestorePosition) : undefined } > {props.ListHeaderComponent} {props.data.map((item) => ( <div key={props.keyExtractor(item)}>{props.renderItem({ item })}</div> ))} {props.ListFooterComponent} </div> ); }; return { LegendList }; }); function MockFileDiff(props: { fileDiff: { name?: string | null; prevName?: string | null }; renderCustomHeader?: (fileDiff: { name?: string | null; prevName?: string | null; }) => React.ReactNode; }) { return ( <div data-testid="file-diff"> {props.renderCustomHeader?.(props.fileDiff)} {props.fileDiff.name ?? props.fileDiff.prevName ?? "diff"} </div> ); } vi.mock("@pierre/diffs/react", () => { return { FileDiff: MockFileDiff }; }); vi.mock("../DiffWorkerPoolProvider", () => ({ DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => children, })); function matchMedia() { return { matches: false, addEventListener: () => {}, removeEventListener: () => {}, }; } let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline; beforeAll(async () => { const classList = { add: () => {}, remove: () => {}, toggle: () => {}, contains: () => false, }; vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, removeItem: () => {}, clear: () => {}, }); vi.stubGlobal("window", { matchMedia, addEventListener: () => {}, removeEventListener: () => {}, requestAnimationFrame: (callback: FrameRequestCallback) => { callback(0); return 0; }, cancelAnimationFrame: () => {}, desktopBridge: undefined, }); vi.stubGlobal("document", { documentElement: { classList, offsetHeight: 0, }, }); ({ MessagesTimeline } = await import("./MessagesTimeline")); }, 30_000); const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z"; function buildProps() { return { isWorking: false, activeTurnStartedAt: null, listRef: createRef<LegendListRef | null>(), latestTurn: null, runningTurnId: null, turnDiffSummaries: [], routeThreadKey: "environment-local:thread-1", onOpenTurnDiff: () => {}, supportsConversationRollback: false, onRevertToTurnCount: () => {}, isRevertingCheckpoint: false, onImageExpand: () => {}, activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID, markdownCwd: undefined, resolvedTheme: "light" as const, timestampFormat: "locale" as const, workspaceRoot: undefined, anchorMessageId: null, onAnchorReady: () => {}, contentInsetEndAdjustment: 0, liveFollowEnabled: true, onIsAtEndChange: () => {}, onManualNavigation: () => {}, }; } function buildLongUserMessageText(tail = "deep hidden detail only after expand") { return Array.from({ length: 9 }, (_, index) => index === 8 ? tail : `Line ${index + 1}: ${"verbose prompt content ".repeat(8).trim()}`, ).join("\n"); } function buildUserTimelineEntry(text: string) { return { id: "entry-1", kind: "message" as const, createdAt: MESSAGE_CREATED_AT, message: { id: MessageId.make("message-1"), role: "user" as const, text, turnId: null, createdAt: MESSAGE_CREATED_AT, updatedAt: MESSAGE_CREATED_AT, streaming: false, }, }; } function buildAssistantTimelineEntry(text: string) { const entry = buildUserTimelineEntry(text); return { ...entry, message: { ...entry.message, role: "assistant" as const, }, }; } function buildSnapShotTimelineEntry(previewUrl?: string) { const entry = buildUserTimelineEntry("First prompt."); return { ...entry, message: { ...entry.message, attachments: [ { type: "image" as const, id: "attachment-1", name: "screenshot.png", mimeType: "image/png", sizeBytes: 1, ...(previewUrl ? { previewUrl } : {}), source: { kind: "snap-shot" as const, capturedAt: "2026-03-17T19:12:28.000Z", appName: "Terminal", windowTitle: "t3code — Tests", appIconDataUrl: "data:image/png;base64,aWNvbg==", }, }, ], }, }; } describe("MessagesTimeline", () => { it("renders previous and next controls with the minimap", () => { const first = buildUserTimelineEntry("First turn"); const secondBase = buildUserTimelineEntry("Second turn"); const second = { ...secondBase, id: "entry-2", message: { ...secondBase.message, id: MessageId.make("message-2"), }, }; const markup = renderToStaticMarkup( <MessagesTimeline {...buildProps()} timelineEntries={[first, second]} />, ); expect(markup).toContain('aria-label="Previous turn"'); expect(markup).toContain('aria-label="Next turn"'); }); // Expanding history uses this suite's existing test renderer, deprecated in // React 19. Migrate these interaction tests together when a DOM test setup is added. it.each([{}, { text: "Text-only answer", file: "Answer with a file" }])( "renders attachment-only question history alongside text answers: %j", async (answers) => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("requestAnimationFrame", () => 0); vi.stubGlobal("cancelAnimationFrame", () => {}); let renderer: ReactTestRenderer | undefined; try { await act(() => { renderer = create( <MessagesTimeline {...buildProps()} timelineEntries={[ { id: "answer-entry", kind: "work", createdAt: MESSAGE_CREATED_AT, entry: { id: "answer-work", createdAt: MESSAGE_CREATED_AT, label: "Question answer submitted", tone: "info", questionAnswer: { requestId: ApprovalRequestId.make("question-request"), answers, questionTextById: { file: "Provide a spec", image: "Provide a screenshot" }, attachmentsByQuestionId: { file: [ { type: "file", id: "spec", name: "spec.txt", mimeType: "text/plain", sizeBytes: 4, }, ], image: [ { type: "image", id: "shot", name: "shot.png", mimeType: "image/png", sizeBytes: 4, }, ], }, }, }, }, ]} />, ); }); const toggle = renderer!.root.findByProps({ "aria-expanded": false }); await act(() => toggle.props.onClick()); const markup = JSON.stringify(renderer!.toJSON()); expect(markup.match(/Provide a spec/g)).toHaveLength(1); expect(markup.match(/spec\.txt/g)).toHaveLength(1); expect(markup).toContain("Provide a screenshot"); expect(markup).toContain("shot.png"); for (const answer of Object.values(answers)) expect(markup).toContain(answer); } finally { await act(() => renderer?.unmount()); } }, ); it.each([ { toolLifecycleStatus: "inProgress", isAtEnd: true }, { toolLifecycleStatus: "inProgress", isAtEnd: false }, { toolLifecycleStatus: "completed", isAtEnd: true }, { toolLifecycleStatus: "completed", isAtEnd: false }, ] as const)( "restores the composer after closing $toolLifecycleStatus tool output only at the end: $isAtEnd", async ({ toolLifecycleStatus, isAtEnd }) => { const frames = new Map<number, FrameRequestCallback>(); let nextFrame = 0; vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { frames.set(++nextFrame, callback); return nextFrame; }); vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame)); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const flushFrame = () => act(() => { const callbacks = [...frames.values()]; frames.clear(); callbacks.forEach((callback) => callback(0)); }); const props = buildProps(); let timelineIsAtEnd = isAtEnd; props.listRef.current = { getState: () => ({ isAtEnd: timelineIsAtEnd }), getScrollableNode: () => null, } as unknown as LegendListRef; let isResting = false; let composerState: ReturnType<typeof useComposerFocusState> | undefined; function ThreadProbe() { const composer = useComposerFocusState(); useLayoutEffect(() => { composerState = composer; isResting = shouldUseRestingComposerLayout({ isExistingThread: true, isMobileViewport: false, isScrollCollapsed: composer.isComposerScrollCollapsed, hasExpandedChrome: false, hasMultilinePrompt: false, timelineOverflows: true, }); }); return ( <MessagesTimeline {...props} isWorking={toolLifecycleStatus === "inProgress"} onToolOutputCollapsedAtEnd={composer.restoreAfterTimelineReachedEnd} timelineEntries={[ { id: "running-tool", kind: "work", createdAt: MESSAGE_CREATED_AT, entry: { id: "running-tool", createdAt: MESSAGE_CREATED_AT, label: "Run command", tone: "tool", toolLifecycleStatus, detail: "Command output", }, }, ]} /> ); } let renderer: ReactTestRenderer | undefined; try { await act(() => { renderer = create(<ThreadProbe />); }); // The user scrolled up to read, so the composer is resting. await act(() => composerState!.setIsComposerScrollCollapsed(true)); const toggle = renderer!.root.findByProps({ "aria-expanded": false }); await act(() => toggle.props.onClick()); await flushFrame(); await flushFrame(); expect(isResting).toBe(true); timelineIsAtEnd = false; await act(() => toggle.props.onClick()); await flushFrame(); timelineIsAtEnd = isAtEnd; await flushFrame(); expect(isResting).toBe(!isAtEnd); } finally { await act(() => renderer?.unmount()); } }, ); it("renders elapsed time for a completed turn", () => { const turnId = TurnId.make("turn-with-fold"); const assistantEntry = buildAssistantTimelineEntry("Done."); const markup = renderToStaticMarkup( <MessagesTimeline {...buildProps()} latestTurn={{ turnId, state: "completed", startedAt: "2026-03-17T19:12:20.000Z", completedAt: "2026-03-17T19:12:28.000Z", }} timelineEntries={[ { id: "work-entry-with-fold", kind: "work", createdAt: "2026-03-17T19:12:22.000Z", entry: { id: "work-with-fold", createdAt: "2026-03-17T19:12:22.000Z", turnId, label: "Ran command", tone: "tool", toolLifecycleStatus: "completed", }, }, { ...assistantEntry, message: { ...assistantEntry.message, turnId }, }, ]} />, ); expect(markup).toContain("Worked for 8.0s"); }); it("keeps assistant changed-files headers sticky below the thread header", () => { const assistantMessageId = MessageId.make("message-assistant-with-files"); const turnId = TurnId.make("turn-with-files"); const markup = renderToStaticMarkup( <MessagesTimeline {...buildProps()} latestTurn={{ turnId, state: "completed", startedAt: MESSAGE_CREATED_AT,