1816 lines · 59.5 KB · TypeScript
RawDownload
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.