831 lines · 28.0 KB · TypeScript
RawDownload
import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts";import { createRef, type ReactNode, type Ref } from "react";import { renderToStaticMarkup } from "react-dom/server";import { beforeAll, describe, expect, it, vi } from "vite-plus/test";import type { LegendListRef } from "@legendapp/list/react"; 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      | {          animated?: boolean;          on?: {            dataChange?: boolean;            itemLayout?: boolean;            layout?: boolean;          };        };    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-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 };}); 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,    activeTurnInProgress: false,    activeTurnStartedAt: null,    listRef: createRef<LegendListRef | null>(),    latestTurn: null,    runningTurnId: null,    turnDiffSummaryByAssistantMessageId: new Map(),    routeThreadKey: "environment-local:thread-1",    onOpenTurnDiff: () => {},    revertTurnCountByUserMessageId: new Map(),    onRevertUserMessage: () => {},    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,    },  };} describe("MessagesTimeline", () => {  it("uses the larger leading inset only when the top fade is enabled", () => {    const timelineEntries = [buildUserTimelineEntry("Hello")];     const compactMarkup = renderToStaticMarkup(      <MessagesTimeline {...buildProps()} timelineEntries={timelineEntries} />,    );    const fadedMarkup = renderToStaticMarkup(      <MessagesTimeline {...buildProps()} timelineEntries={timelineEntries} topFadeEnabled />,    );     expect(compactMarkup).toContain('class="h-3 sm:h-4"');    expect(compactMarkup).not.toContain("topbar-scroll-fade");    expect(fadedMarkup).toContain('class="h-10 sm:h-12"');    expect(fadedMarkup).toContain("topbar-scroll-fade");  });   it("keeps assistant changed-files headers sticky below the thread header", () => {    const assistantMessageId = MessageId.make("message-assistant-with-files");    const turnId = TurnId.make("turn-with-files");    const markup = renderToStaticMarkup(      <MessagesTimeline        {...buildProps()}        latestTurn={{          turnId,          state: "completed",          startedAt: MESSAGE_CREATED_AT,          completedAt: MESSAGE_CREATED_AT,        }}        timelineEntries={[          {            id: "entry-assistant-with-files",            kind: "message",            createdAt: MESSAGE_CREATED_AT,            message: {              id: assistantMessageId,              role: "assistant",              text: "Updated the fixture.",              turnId,              createdAt: MESSAGE_CREATED_AT,              updatedAt: MESSAGE_CREATED_AT,              streaming: false,            },          },        ]}        turnDiffSummaryByAssistantMessageId={          new Map([            [              assistantMessageId,              {                turnId,                checkpointTurnCount: 1,                checkpointRef: CheckpointRef.make("checkpoint-with-files"),                status: "ready",                files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }],                assistantMessageId,                completedAt: MESSAGE_CREATED_AT,              },            ],          ])        }      />,    );     expect(markup).toContain("sticky top-2 z-10");    expect(markup).not.toContain("self-start");    expect(markup).toContain("whitespace-nowrap");    expect(markup).toContain("!size-[22px]");    expect(markup).toContain("size-3");    expect(markup).toContain('aria-label="Collapse all folders"');    expect(markup).toContain('aria-label="Open diff"');    expect(markup).toContain("1 changed file");  });   it("treats only the strict list end as the live edge", async () => {    const {      resolveTimelineIsAtEnd,      resolveTimelineMinimapHasPersistentGutter,      resolveTimelineMinimapHeightStyle,      resolveTimelineMinimapHitStripWidth,      resolveTimelineMinimapIndexFromPointer,      resolveTimelineMinimapInteractiveWidth,      resolveTimelineMinimapTopPercent,    } = await import("./MessagesTimeline.logic");     expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true);    expect(resolveTimelineIsAtEnd(undefined)).toBeUndefined();    // Within the pixel band above the content bottom counts as the end...    expect(      resolveTimelineIsAtEnd({        isAtEnd: false,        contentLength: 2000,        scroll: 1170,        scrollLength: 800,      }),    ).toBe(true);    // ...but half a viewport up (LegendList's isNearEnd territory) does not.    expect(      resolveTimelineIsAtEnd({        isAtEnd: false,        contentLength: 2000,        scroll: 900,        scrollLength: 800,      }),    ).toBe(false);    // The composer inset is part of contentLength and must not count as    // distance-to-end.    expect(      resolveTimelineIsAtEnd(        { isAtEnd: false, contentLength: 2100, scroll: 1170, scrollLength: 800 },        100,      ),    ).toBe(true);    // Geometry missing (older state shape): fall back to the strict flag.    expect(resolveTimelineIsAtEnd({ isAtEnd: false })).toBe(false);     expect(resolveTimelineMinimapHeightStyle(5)).toBe("min(32px, calc(100vh - 18rem))");    expect(resolveTimelineMinimapTopPercent(2, 5)).toBe(50);    expect(      resolveTimelineMinimapIndexFromPointer({        itemCount: 101,        railTop: 100,        railHeight: 500,        pointerY: 350,      }),    ).toBe(50);    expect(      resolveTimelineMinimapIndexFromPointer({        itemCount: 101,        railTop: 100,        railHeight: 500,        pointerY: 999,      }),    ).toBe(100);    expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false);    expect(resolveTimelineMinimapHasPersistentGutter(863)).toBe(false);    expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true);     // No usable gutter (zoomed in / narrow pane): the strip must go inert    // instead of overlaying the centered content column.    expect(resolveTimelineMinimapHitStripWidth(768)).toBe(0);    expect(resolveTimelineMinimapHitStripWidth(792)).toBe(0);    // Partial gutter: strip shrinks to what fits between the viewport edge    // and the content column.    expect(resolveTimelineMinimapHitStripWidth(820)).toBe(14);    // Full gutter: unchanged 40px-wide strip.    expect(resolveTimelineMinimapHitStripWidth(872)).toBe(40);    expect(resolveTimelineMinimapHitStripWidth(1400)).toBe(40);    expect(resolveTimelineMinimapHitStripWidth(0)).toBe(0);    expect(resolveTimelineMinimapHitStripWidth(Number.NaN)).toBe(0);     // The collapsed target stays narrow, but an open preview keeps its full    // 20rem width plus the 2rem offset from the minimap rail interactive.    expect(resolveTimelineMinimapInteractiveWidth(0, false)).toBe(0);    expect(resolveTimelineMinimapInteractiveWidth(14, false)).toBe(14);    expect(resolveTimelineMinimapInteractiveWidth(40, false)).toBe(40);    expect(resolveTimelineMinimapInteractiveWidth(0, true)).toBe("22rem");    expect(resolveTimelineMinimapInteractiveWidth(14, true)).toBe("22rem");    expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem");  });   it("anchors a sent attachment message using its measured height", () => {    const onAnchorReady = vi.fn();    const firstEntry = buildUserTimelineEntry("First prompt.");    const secondEntry = {      ...buildUserTimelineEntry("Newest prompt."),      id: "entry-2",      message: {        ...buildUserTimelineEntry("Newest prompt.").message,        id: MessageId.make("message-2"),        attachments: [          {            type: "image" as const,            id: "attachment-1",            name: "screenshot.png",            mimeType: "image/png",            sizeBytes: 1,            previewUrl: "data:image/png;base64,iVBORw0KGgo=",          },        ],      },    };    const markup = renderToStaticMarkup(      <MessagesTimeline        {...buildProps()}        anchorMessageId={secondEntry.message.id}        onAnchorReady={onAnchorReady}        contentInsetEndAdjustment={144}        timelineEntries={[firstEntry, secondEntry]}      />,    );     expect(markup).toContain('data-anchor-index="1"');    expect(markup).toContain('data-anchor-offset="16"');    expect(markup).toContain('data-anchor-on-ready="true"');    expect(markup).not.toContain("data-anchor-max-size=");    expect(markup).toContain('data-content-inset-end="144"');    expect(markup).toContain("[overflow-anchor:none]");    expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"');    expect(markup).toContain('data-maintain-visible-content-position="object"');    expect(markup).toContain('data-maintain-visible-content-position-data="true"');    expect(markup).toContain('data-maintain-visible-content-position-size="true"');    expect(markup).toContain('data-maintain-visible-content-position-restore="true"');    expect(onAnchorReady).toHaveBeenCalledOnce();    expect(onAnchorReady).toHaveBeenCalledWith(secondEntry.message.id, 1);  });   it("renders collapse controls for long user messages", () => {    const markup = renderToStaticMarkup(      <MessagesTimeline        {...buildProps()}        timelineEntries={[buildUserTimelineEntry(buildLongUserMessageText())]}      />,    );     expect(markup).toContain("Show full message");    expect(markup).toContain('data-maintain-scroll-at-end="enabled"');    expect(markup).toContain('data-maintain-scroll-at-end-animated="false"');    expect(markup).toContain('data-maintain-scroll-at-end-data-change="true"');    expect(markup).toContain('data-maintain-scroll-at-end-item-layout="true"');    expect(markup).toContain('data-maintain-scroll-at-end-layout="true"');    expect(markup).toContain('data-user-message-collapsed="true"');    expect(markup).toContain('data-user-message-fade="true"');    expect(markup).toContain('data-user-message-footer="true"');  });   it("does not render collapse controls for short user messages", () => {    const markup = renderToStaticMarkup(      <MessagesTimeline        {...buildProps()}        timelineEntries={[buildUserTimelineEntry("Short prompt.")]}      />,    );     expect(markup).not.toContain("Show full message");    expect(markup).toContain('data-user-message-collapsible="false"');    expect(markup).toContain("rounded-2xl bg-message p-3");  });   it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => {    const { MessagesTimeline } = await import("./MessagesTimeline");    const markup = renderToStaticMarkup(      <MessagesTimeline        {...buildProps()}        timelineEntries={[          buildUserTimelineEntry(            [              'Without reading a file, do you have <global-agent-instructions scope="workspace">',              'Before <nested data-value="a&b">inside</nested> after',              "</global-agent-instructions> in your context?",              "Comparison: 2 < 3 and 5 > 4.",            ].join("\n"),          ),        ]}      />,    );     expect(markup).toContain("&lt;global-agent-instructions scope=&quot;workspace&quot;&gt;");    expect(markup).toContain(      "Before &lt;nested data-value=&quot;a&amp;b&quot;&gt;inside&lt;/nested&gt; after",    );    expect(markup).toContain("&lt;/global-agent-instructions&gt; in your context?");

Lines 1–500 of 831.