1705 lines · 58.0 KB · TypeScript
RawDownload
import {  type EnvironmentId,  type MessageId,  type ScopedThreadRef,  type ServerProviderSkill,  type TurnId,} from "@t3tools/contracts";import { parseScopedThreadKey } from "@t3tools/client-runtime";import {  createContext,  Fragment,  memo,  use,  useCallback,  useEffect,  useLayoutEffect,  useMemo,  useRef,  useState,  type KeyboardEvent,  type ReactNode,} from "react";import { LegendList, type LegendListRef } from "@legendapp/list/react";import { FileDiff } from "@pierre/diffs/react";import {  deriveTimelineEntries,  workEntryIndicatesToolFailure,  workEntryIndicatesToolNeutralStatus,  workEntryIndicatesToolSuccess,  workLogEntryIsToolLike,} from "../../session-logic";import { type TurnDiffSummary } from "../../types";import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";import {  getRenderablePatch,  resolveDiffThemeName,  resolveFileDiffPath,} from "../../lib/diffRendering";import ChatMarkdown from "../ChatMarkdown";import {  BotIcon,  CheckIcon,  ChevronDownIcon,  ChevronRightIcon,  ChevronUpIcon,  CircleAlertIcon,  EyeIcon,  GlobeIcon,  HammerIcon,  MessageCircleIcon,  MousePointerClickIcon,  PaintbrushIcon,  MinusIcon,  SquarePenIcon,  TerminalIcon,  Undo2Icon,  WrenchIcon,  XIcon,  ZapIcon,} from "lucide-react";import { Button } from "../ui/button";import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";import { ProposedPlanCard } from "./ProposedPlanCard";import { ChangedFilesTree } from "./ChangedFilesTree";import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";import { MessageCopyButton } from "./MessageCopyButton";import {  computeStableMessagesTimelineRows,  MAX_VISIBLE_WORK_LOG_ENTRIES,  deriveMessagesTimelineRows,  normalizeCompactToolLabel,  resolveAssistantMessageCopyState,  type StableMessagesTimelineRowsState,  type MessagesTimelineRow,  type TimelineLatestTurn,} from "./MessagesTimeline.logic";import { TerminalContextInlineChip } from "./TerminalContextInlineChip";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";import {  deriveDisplayedUserMessageState,  type ParsedTerminalContextEntry,} from "~/lib/terminalContext";import {  extractTrailingElementContexts,  type ParsedElementContextEntry,} from "~/lib/elementContext";import {  extractTrailingPreviewAnnotation,  type ParsedPreviewAnnotation,} from "~/lib/previewAnnotation";import { cn } from "~/lib/utils";import { useUiStateStore } from "~/uiStateStore";import { type TimestampFormat } from "@t3tools/contracts/settings";import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat"; import {  buildInlineTerminalContextText,  formatInlineTerminalContextLabel,  textContainsInlineTerminalContextLabels,} from "./userMessageTerminalContexts";import { SkillInlineText } from "./SkillInlineText";import { formatWorkspaceRelativePath } from "../../filePathDisplay";import {  buildReviewCommentRenderablePatch,  formatReviewCommentFence,  parseReviewCommentMessageSegments,  type ReviewCommentContext,} from "../../reviewCommentContext"; // ---------------------------------------------------------------------------// Context — shared state consumed by every row component via Context.// Propagates through LegendList's memo boundaries for shared callbacks and// non-row-scoped state. `nowIso` is intentionally excluded — self-ticking// components (WorkingTimer, LiveElapsed) handle it.// --------------------------------------------------------------------------- interface TimelineRowSharedState {  timestampFormat: TimestampFormat;  routeThreadKey: string;  threadRef: ScopedThreadRef | null;  markdownCwd: string | undefined;  resolvedTheme: "light" | "dark";  workspaceRoot: string | undefined;  skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;  activeThreadEnvironmentId: EnvironmentId;  onRevertUserMessage: (messageId: MessageId) => void;  onImageExpand: (preview: ExpandedImagePreview) => void;  onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;  onToggleTurnFold: (turnId: TurnId) => void;} interface TimelineRowActivityState {  isWorking: boolean;  isRevertingCheckpoint: boolean;  activeTurnInProgress: boolean;} const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!);const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />;const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; // ---------------------------------------------------------------------------// Props (public API)// --------------------------------------------------------------------------- interface MessagesTimelineProps {  isWorking: boolean;  activeTurnInProgress: boolean;  activeTurnStartedAt: string | null;  listRef: React.RefObject<LegendListRef | null>;  timelineEntries: ReturnType<typeof deriveTimelineEntries>;  latestTurn: TimelineLatestTurn | null;  turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>;  routeThreadKey: string;  onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;  revertTurnCountByUserMessageId: Map<MessageId, number>;  onRevertUserMessage: (messageId: MessageId) => void;  isRevertingCheckpoint: boolean;  onImageExpand: (preview: ExpandedImagePreview) => void;  activeThreadEnvironmentId: EnvironmentId;  markdownCwd: string | undefined;  resolvedTheme: "light" | "dark";  timestampFormat: TimestampFormat;  workspaceRoot: string | undefined;  skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;  onIsAtEndChange: (isAtEnd: boolean) => void;} // ---------------------------------------------------------------------------// MessagesTimeline — list owner// --------------------------------------------------------------------------- export const MessagesTimeline = memo(function MessagesTimeline({  isWorking,  activeTurnInProgress,  activeTurnStartedAt,  listRef,  timelineEntries,  latestTurn,  turnDiffSummaryByAssistantMessageId,  routeThreadKey,  onOpenTurnDiff,  revertTurnCountByUserMessageId,  onRevertUserMessage,  isRevertingCheckpoint,  onImageExpand,  activeThreadEnvironmentId,  markdownCwd,  resolvedTheme,  timestampFormat,  workspaceRoot,  skills = EMPTY_TIMELINE_SKILLS,  onIsAtEndChange,}: MessagesTimelineProps) {  const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());   // Toggling a fold inserts/removes rows between the fold row and the final  // message — everything above the trigger is unchanged, so the trigger stays  // put as long as the list doesn't re-anchor. maintainScrollAtEnd would do  // exactly that (pin the bottom content when row data changes while scrolled  // to the end), yanking the trigger out of view. Suppress it for the frames  // in which the toggle's data change and item measurements settle.  const [foldToggleSettling, setFoldToggleSettling] = useState(false);  const onToggleTurnFold = useCallback((turnId: TurnId) => {    setFoldToggleSettling(true);    setExpandedTurnIds((existing) => {      const next = new Set(existing);      if (next.has(turnId)) {        next.delete(turnId);      } else {        next.add(turnId);      }      return next;    });  }, []);  useEffect(() => {    if (!foldToggleSettling) {      return;    }    let secondFrameId: number | null = null;    const firstFrameId = window.requestAnimationFrame(() => {      secondFrameId = window.requestAnimationFrame(() => {        setFoldToggleSettling(false);      });    });    return () => {      window.cancelAnimationFrame(firstFrameId);      if (secondFrameId !== null) {        window.cancelAnimationFrame(secondFrameId);      }    };  }, [foldToggleSettling]);   // An in-session interrupt leaves its turn expanded so the user keeps their  // place; the next turn (or a reload, since this is local state) folds it.  const previousLatestTurnRef = useRef(latestTurn);  useEffect(() => {    const previous = previousLatestTurnRef.current;    previousLatestTurnRef.current = latestTurn;    if (!latestTurn || previous?.turnId === undefined) {      return;    }    if (latestTurn.turnId === previous.turnId) {      if (previous.state === "running" && latestTurn.state === "interrupted") {        setExpandedTurnIds((existing) => {          const next = new Set(existing);          next.add(latestTurn.turnId);          return next;        });      }      return;    }    setExpandedTurnIds((existing) => {      if (!existing.has(previous.turnId)) {        return existing;      }      const next = new Set(existing);      next.delete(previous.turnId);      return next;    });  }, [latestTurn]);   const rawRows = useMemo(    () =>      deriveMessagesTimelineRows({        timelineEntries,        latestTurn,        expandedTurnIds,        isWorking,        activeTurnStartedAt,        turnDiffSummaryByAssistantMessageId,        revertTurnCountByUserMessageId,      }),    [      timelineEntries,      latestTurn,      expandedTurnIds,      isWorking,      activeTurnStartedAt,      turnDiffSummaryByAssistantMessageId,      revertTurnCountByUserMessageId,    ],  );  const rows = useStableRows(rawRows);   const handleScroll = useCallback(() => {    const state = listRef.current?.getState?.();    if (state) {      onIsAtEndChange(state.isAtEnd);    }  }, [listRef, onIsAtEndChange]);   const previousRowCountRef = useRef(rows.length);  useEffect(() => {    const previousRowCount = previousRowCountRef.current;    previousRowCountRef.current = rows.length;     if (previousRowCount > 0 || rows.length === 0) {      return;    }     onIsAtEndChange(true);    const frameId = window.requestAnimationFrame(() => {      void listRef.current?.scrollToEnd?.({ animated: false });    });    return () => {      window.cancelAnimationFrame(frameId);    };  }, [listRef, onIsAtEndChange, rows.length]);   const sharedState = useMemo<TimelineRowSharedState>(    () => ({      timestampFormat,      routeThreadKey,      threadRef: parseScopedThreadKey(routeThreadKey),      markdownCwd,      resolvedTheme,      workspaceRoot,      skills,      activeThreadEnvironmentId,      onRevertUserMessage,      onImageExpand,      onOpenTurnDiff,      onToggleTurnFold,    }),    [      timestampFormat,      routeThreadKey,      markdownCwd,      resolvedTheme,      workspaceRoot,      skills,      activeThreadEnvironmentId,      onRevertUserMessage,      onImageExpand,      onOpenTurnDiff,      onToggleTurnFold,    ],  );  const activityState = useMemo<TimelineRowActivityState>(    () => ({      isWorking,      isRevertingCheckpoint,      activeTurnInProgress,    }),    [activeTurnInProgress, isRevertingCheckpoint, isWorking],  );   // Stable renderItem — no closure deps. Row components read shared state  // from TimelineRowCtx, which propagates through LegendList's memo.  const renderItem = useCallback(    ({ item }: { item: MessagesTimelineRow }) => (      <div className="mx-auto w-full min-w-0 max-w-3xl overflow-x-clip" data-timeline-root="true">        <TimelineRowContent row={item} />      </div>    ),    [],  );   if (rows.length === 0 && !isWorking) {    return (      <div className="flex h-full items-center justify-center">        <p className="text-sm text-muted-foreground/30">          Send a message to start the conversation.        </p>      </div>    );  }   return (    <TimelineRowCtx value={sharedState}>      <TimelineRowActivityCtx value={activityState}>        <LegendList<MessagesTimelineRow>          ref={listRef}          data={rows}          keyExtractor={keyExtractor}          renderItem={renderItem}          estimatedItemSize={90}          initialScrollAtEnd          maintainScrollAtEnd={!foldToggleSettling}          maintainScrollAtEndThreshold={0.1}          maintainVisibleContentPosition          onScroll={handleScroll}          className="scrollbar-gutter-both h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5"          ListHeaderComponent={TIMELINE_LIST_HEADER}          ListFooterComponent={TIMELINE_LIST_FOOTER}        />      </TimelineRowActivityCtx>    </TimelineRowCtx>  );}); function keyExtractor(item: MessagesTimelineRow) {  return item.id;} // ---------------------------------------------------------------------------// TimelineRowContent — the actual row component// --------------------------------------------------------------------------- type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"];type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number];type TimelineRow = MessagesTimelineRow; const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) {  return (    <div      className={cn(        // Commentary (non-terminal assistant) rows carry no metadata row, so        // they sit closer to the work that follows them.        (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) ||          row.kind === "work"          ? "pb-2"          : "pb-4",        row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,      )}      data-timeline-row-id={row.id}      data-timeline-row-kind={row.kind}      data-message-id={row.kind === "message" ? row.message.id : undefined}      data-message-role={row.kind === "message" ? row.message.role : undefined}    >      {row.kind === "work" ? <WorkGroupSection groupedEntries={row.groupedEntries} /> : null}      {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}      {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}      {row.kind === "message" && row.message.role === "assistant" ? (        <AssistantTimelineRow row={row} />      ) : null}      {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}      {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}    </div>  );}); function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {  const ctx = use(TimelineRowCtx);  const userImages = row.message.attachments ?? [];  const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text);  const terminalContexts = displayedUserMessage.contexts;  const previewAnnotations: ParsedPreviewAnnotation[] = [];  let visibleText = displayedUserMessage.visibleText;  while (true) {    const extracted = extractTrailingPreviewAnnotation(visibleText);    if (!extracted.annotation) break;    previewAnnotations.unshift(extracted.annotation);    visibleText = extracted.promptText;  }  const elementContextState = extractTrailingElementContexts(visibleText);  const previewImages = userImages.filter((image) => image.name.startsWith("preview-annotation-"));  const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-"));  const canRevertAgentWork = typeof row.revertTurnCount === "number";   return (    <div className="group flex flex-col items-end gap-1">      <div className="relative max-w-[80%] rounded-2xl border border-border bg-secondary p-3">        {regularImages.length > 0 && (          <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">            {regularImages.map((image: NonNullable<TimelineMessage["attachments"]>[number]) => (              <div                key={image.id}                className="overflow-hidden rounded-lg border border-border/80 bg-background/70"              >                {image.previewUrl ? (                  <button                    type="button"                    className="h-full w-full cursor-zoom-in"                    aria-label={`Preview ${image.name}`}                    onClick={() => {                      const preview = buildExpandedImagePreview(regularImages, image.id);                      if (!preview) return;                      ctx.onImageExpand(preview);                    }}                  >                    <img                      src={image.previewUrl}                      alt={image.name}                      className="block h-auto max-h-[220px] w-full object-cover"                    />                  </button>                ) : (                  <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">                    {image.name}                  </div>                )}              </div>            ))}          </div>        )}        {previewAnnotations.map((annotation, index) => (          <UserMessagePreviewAnnotationCard            key={annotation.id}            annotation={annotation}            image={previewImages[index] ?? null}          />        ))}        {elementContextState.contexts.length > 0 ? (          <div className="mb-2 flex flex-wrap gap-1.5">            {elementContextState.contexts.map((context) => (

Lines 1–500 of 1705.