| ... | @@ -0,0 +1,160 @@ |
| 1 | import "../../index.css"; |
| 2 | |
| 3 | import { EnvironmentId } from "@t3tools/contracts"; |
| 4 | import { createRef } from "react"; |
| 5 | import type { LegendListRef } from "@legendapp/list/react"; |
| 6 | import { page } from "vitest/browser"; |
| 7 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 8 | import { render } from "vitest-browser-react"; |
| 9 | |
| 10 | const scrollToEndSpy = vi.fn(); |
| 11 | const getStateSpy = vi.fn(() => ({ isAtEnd: true })); |
| 12 | |
| 13 | vi.mock("@legendapp/list/react", async () => { |
| 14 | const React = await import("react"); |
| 15 | |
| 16 | const LegendList = React.forwardRef(function MockLegendList( |
| 17 | props: { |
| 18 | data: Array<{ id: string }>; |
| 19 | keyExtractor: (item: { id: string }) => string; |
| 20 | renderItem: (args: { item: { id: string } }) => React.ReactNode; |
| 21 | ListHeaderComponent?: React.ReactNode; |
| 22 | ListFooterComponent?: React.ReactNode; |
| 23 | }, |
| 24 | ref: React.ForwardedRef<LegendListRef>, |
| 25 | ) { |
| 26 | React.useImperativeHandle( |
| 27 | ref, |
| 28 | () => |
| 29 | ({ |
| 30 | scrollToEnd: scrollToEndSpy, |
| 31 | getState: getStateSpy, |
| 32 | }) as unknown as LegendListRef, |
| 33 | ); |
| 34 | |
| 35 | return ( |
| 36 | <div data-testid="legend-list"> |
| 37 | {props.ListHeaderComponent} |
| 38 | {props.data.map((item) => ( |
| 39 | <div key={props.keyExtractor(item)}>{props.renderItem({ item })}</div> |
| 40 | ))} |
| 41 | {props.ListFooterComponent} |
| 42 | </div> |
| 43 | ); |
| 44 | }); |
| 45 | |
| 46 | return { LegendList }; |
| 47 | }); |
| 48 | |
| 49 | import { MessagesTimeline } from "./MessagesTimeline"; |
| 50 | |
| 51 | function buildProps() { |
| 52 | return { |
| 53 | isWorking: false, |
| 54 | activeTurnInProgress: false, |
| 55 | activeTurnId: null, |
| 56 | activeTurnStartedAt: null, |
| 57 | listRef: createRef<LegendListRef | null>(), |
| 58 | completionDividerBeforeEntryId: null, |
| 59 | completionSummary: null, |
| 60 | turnDiffSummaryByAssistantMessageId: new Map(), |
| 61 | routeThreadKey: "environment-local:thread-1", |
| 62 | onOpenTurnDiff: vi.fn(), |
| 63 | revertTurnCountByUserMessageId: new Map(), |
| 64 | onRevertUserMessage: vi.fn(), |
| 65 | isRevertingCheckpoint: false, |
| 66 | onImageExpand: vi.fn(), |
| 67 | activeThreadEnvironmentId: EnvironmentId.make("environment-local"), |
| 68 | markdownCwd: undefined, |
| 69 | resolvedTheme: "dark" as const, |
| 70 | timestampFormat: "24-hour" as const, |
| 71 | workspaceRoot: undefined, |
| 72 | onIsAtEndChange: vi.fn(), |
| 73 | }; |
| 74 | } |
| 75 | |
| 76 | describe("MessagesTimeline", () => { |
| 77 | afterEach(() => { |
| 78 | scrollToEndSpy.mockReset(); |
| 79 | getStateSpy.mockClear(); |
| 80 | vi.restoreAllMocks(); |
| 81 | document.body.innerHTML = ""; |
| 82 | }); |
| 83 | |
| 84 | it("renders activity rows instead of the empty placeholder when a thread has non-message timeline data", async () => { |
| 85 | const screen = await render( |
| 86 | <MessagesTimeline |
| 87 | {...buildProps()} |
| 88 | timelineEntries={[ |
| 89 | { |
| 90 | id: "work-1", |
| 91 | kind: "work", |
| 92 | createdAt: "2026-04-13T12:00:00.000Z", |
| 93 | entry: { |
| 94 | id: "work-1", |
| 95 | createdAt: "2026-04-13T12:00:00.000Z", |
| 96 | label: "thinking", |
| 97 | detail: "Inspecting repository state", |
| 98 | tone: "thinking", |
| 99 | }, |
| 100 | }, |
| 101 | ]} |
| 102 | />, |
| 103 | ); |
| 104 | |
| 105 | try { |
| 106 | await expect |
| 107 | .element(page.getByText("Send a message to start the conversation.")) |
| 108 | .not.toBeInTheDocument(); |
| 109 | await expect.element(page.getByText("Thinking - Inspecting repository state")).toBeVisible(); |
| 110 | } finally { |
| 111 | await screen.unmount(); |
| 112 | } |
| 113 | }); |
| 114 | |
| 115 | it("snaps to the bottom when timeline rows appear after an initially empty render", async () => { |
| 116 | const requestAnimationFrameSpy = vi |
| 117 | .spyOn(window, "requestAnimationFrame") |
| 118 | .mockImplementation((callback: FrameRequestCallback) => { |
| 119 | callback(0); |
| 120 | return 1; |
| 121 | }); |
| 122 | vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); |
| 123 | |
| 124 | const props = buildProps(); |
| 125 | const screen = await render(<MessagesTimeline {...props} timelineEntries={[]} />); |
| 126 | |
| 127 | try { |
| 128 | await expect |
| 129 | .element(page.getByText("Send a message to start the conversation.")) |
| 130 | .toBeVisible(); |
| 131 | |
| 132 | await screen.rerender( |
| 133 | <MessagesTimeline |
| 134 | {...props} |
| 135 | timelineEntries={[ |
| 136 | { |
| 137 | id: "work-1", |
| 138 | kind: "work", |
| 139 | createdAt: "2026-04-13T12:00:00.000Z", |
| 140 | entry: { |
| 141 | id: "work-1", |
| 142 | createdAt: "2026-04-13T12:00:00.000Z", |
| 143 | label: "thinking", |
| 144 | detail: "Inspecting repository state", |
| 145 | tone: "thinking", |
| 146 | }, |
| 147 | }, |
| 148 | ]} |
| 149 | />, |
| 150 | ); |
| 151 | |
| 152 | await expect.element(page.getByText("Thinking - Inspecting repository state")).toBeVisible(); |
| 153 | expect(props.onIsAtEndChange).toHaveBeenCalledWith(true); |
| 154 | expect(scrollToEndSpy).toHaveBeenCalledWith({ animated: false }); |
| 155 | expect(requestAnimationFrameSpy).toHaveBeenCalled(); |
| 156 | } finally { |
| 157 | await screen.unmount(); |
| 158 | } |
| 159 | }); |
| 160 | }); |