2521 lines · 87.4 KB · TypeScript
RawDownload
import {  type ChatFileAttachment,  type EnvironmentId,  type MessageId,  type ScopedThreadRef,  type ServerProviderSkill,  type TurnId,} from "@t3tools/contracts";import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime";import {  emptyAgentPanelModel,  formatSubagentTokenCount,} from "@t3tools/client-runtime/state/subagentRuntime"; const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel();const NOOP_OPEN_AGENTS = () => {};const NOOP_USE_ARTIFACT_TEMPLATE = () => {};const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {};import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";import {  createContext,  Fragment,  memo,  use,  useCallback,  useEffect,  useLayoutEffect,  useMemo,  useRef,  useState,  type KeyboardEvent,  type MouseEvent,  type ReactNode,} from "react";import { LegendList, type LegendListRef } from "@legendapp/list/react";import { FileDiff } from "@pierre/diffs/react";import {  deriveTimelineEntries,  workEntryDisplayIndicatesToolFailure,  workEntrySignalsSevereFailure,  workLogEntryIsToolLike,} from "../../session-logic";import {  type ChatImageAttachment,  isFileAttachment,  isImageAttachment,  isVideoAttachment,  type TurnDiffSummary,} from "../../types";import {  getRenderablePatch,  resolveDiffThemeName,  resolveFileDiffPath,} from "../../lib/diffRendering";import ChatMarkdown from "../ChatMarkdown";import {  BotIcon,  CheckIcon,  ChevronDownIcon,  ChevronRightIcon,  CircleAlertIcon,  DownloadIcon,  EyeIcon,  FileIcon,  GlobeIcon,  HammerIcon,  MessageCircleIcon,  MousePointerClickIcon,  PaintbrushIcon,  PlayIcon,  SearchIcon,  SquarePenIcon,  TerminalIcon,  Undo2Icon,  WrenchIcon,  XIcon,  ZapIcon,} from "lucide-react";import { Button } from "../ui/button";import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";import { ProposedPlanCard } from "./ProposedPlanCard";import { ChangedFilesCard } from "./ChangedFilesTree";import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation";import {  CHAT_TIMELINE_ANCHOR_OFFSET,  keepTimelineEndVisibleAfterOverlayGrowth,} from "./timelineScrollAnchoring";import { MessageCopyButton } from "./MessageCopyButton";import {  computeStableMessagesTimelineRows,  deriveMessagesTimelineRows,  normalizeCompactToolLabel,  resolveAssistantMessageCopyState,  resolveTimelineIsAtEnd,  resolveTimelineMinimapHasPersistentGutter,  resolveTimelineMinimapHeightStyle,  resolveTimelineMinimapHitStripWidth,  resolveTimelineMinimapIndexFromPointer,  resolveTimelineMinimapInteractiveWidth,  resolveTimelineMinimapTopPercent,  shouldPreserveAssistantLineBreaks,  toolGroupAction,  workEntryIsVisibleInGroup,  type StableMessagesTimelineRowsState,  type MessagesTimelineRow,  TIMELINE_MINIMAP_MIN_ITEMS,  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, formatDayAwareTimestamp } 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;  onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;  onImageExpand: (preview: ExpandedImagePreview) => void;  onFileOpen: (attachment: ChatFileAttachment) => void;  openingVideoAttachmentId: string | null;  onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;  onToggleTurnFold: (turnId: TurnId) => void;  onToggleWorkGroup: (groupId: string, anchorKey: string) => void;  agentPanelModel: AgentPanelModel;  onOpenAgents: () => void;} interface TimelineRowActivityState {  isWorking: boolean;  isPreparingWorktree: boolean;  isRevertingCheckpoint: boolean;  latestTurnId: TurnId | null;} const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!);const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;const TIMELINE_LIST_FADE_HEADER = (  <div className="h-[var(--workspace-titlebar-scroll-fade-height)]" />); // Header row shown when older turns exist beyond the loaded window. Plain// button, no spinner animation; the label change is the loading indicator.function TimelineLoadEarlierHeader({  loading,  onLoadEarlier,  fade,}: {  loading: boolean;  onLoadEarlier: () => void;  fade: boolean;}) {  return (    <div className={fade ? "pt-[var(--workspace-titlebar-scroll-fade-height)]" : "pt-3 sm:pt-4"}>      <div className="mx-auto w-full max-w-3xl pb-2">        <button          type="button"          onClick={onLoadEarlier}          disabled={loading}          className="w-full py-1.5 text-xs text-muted-foreground/60 hover:text-foreground disabled:cursor-default"        >          {loading ? "Loading earlier turns…" : "Load earlier turns"}        </button>      </div>    </div>  );}const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />;const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];const TIMELINE_MAINTAIN_SCROLL_AT_END = {  animated: false,  on: {    dataChange: true,    itemLayout: true,    layout: true,  },} as const; // ---------------------------------------------------------------------------// Props (public API)// --------------------------------------------------------------------------- interface MessagesTimelineProps {  agentPanelModel?: AgentPanelModel;  onOpenAgents?: () => void;  isWorking: boolean;  isPreparingWorktree?: boolean;  activeTurnStartedAt: string | null;  listRef: React.RefObject<LegendListRef | null>;  timelineEntries: ReturnType<typeof deriveTimelineEntries>;  latestTurn: TimelineLatestTurn | null;  runningTurnId: TurnId | null;  turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>;  routeThreadKey: string;  onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;  revertTurnCountByUserMessageId: Map<MessageId, number>;  onRevertUserMessage: (messageId: MessageId) => void;  onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;  isRevertingCheckpoint: boolean;  onImageExpand: (preview: ExpandedImagePreview) => void;  onFileOpen?: (attachment: ChatFileAttachment) => void;  openingVideoAttachmentId: string | null;  activeThreadEnvironmentId: EnvironmentId;  markdownCwd: string | undefined;  resolvedTheme: "light" | "dark";  timestampFormat: TimestampFormat;  workspaceRoot: string | undefined;  skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;  anchorMessageId: MessageId | null;  onAnchorReady: (messageId: MessageId, anchorIndex: number) => void;  contentInsetEndAdjustment: number;  /**   * Whether the timeline should keep pinning to the live edge as content   * grows. Off while the user is reading history; LegendList's own   * maintainScrollAtEnd would otherwise re-pin regardless of ChatView's   * scroll-mode refs whenever the user drifts near the bottom.   */  liveFollowEnabled: boolean;  onIsAtEndChange: (isAtEnd: boolean) => void;  onManualNavigation: () => void;  hideEmptyPlaceholder?: boolean;  topFadeEnabled?: boolean;  /** Non-null when older turns exist beyond the loaded window. */  loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null;} // ---------------------------------------------------------------------------// MessagesTimeline — list owner// --------------------------------------------------------------------------- export const MessagesTimeline = memo(function MessagesTimeline({  isWorking,  isPreparingWorktree = false,  activeTurnStartedAt,  agentPanelModel = EMPTY_AGENT_PANEL_MODEL,  onOpenAgents = NOOP_OPEN_AGENTS,  listRef,  timelineEntries,  latestTurn,  runningTurnId,  turnDiffSummaryByAssistantMessageId,  routeThreadKey,  onOpenTurnDiff,  revertTurnCountByUserMessageId,  onRevertUserMessage,  onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE,  isRevertingCheckpoint,  onImageExpand,  onFileOpen = NOOP_OPEN_ATTACHMENT,  openingVideoAttachmentId,  activeThreadEnvironmentId,  markdownCwd,  resolvedTheme,  timestampFormat,  workspaceRoot,  skills = EMPTY_TIMELINE_SKILLS,  anchorMessageId,  onAnchorReady,  contentInsetEndAdjustment,  liveFollowEnabled,  onIsAtEndChange,  onManualNavigation,  hideEmptyPlaceholder = false,  topFadeEnabled = false,  loadEarlier = null,}: MessagesTimelineProps) {  const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());  const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());  const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);  const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());  const disclosureAnchorKeyRef = useRef<string | null>(null);  const disclosureSettleFrameRef = useRef<number | null>(null);  const disclosureSettleSecondFrameRef = useRef<number | null>(null);  const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustment);   useLayoutEffect(() => {    keepTimelineEndVisibleAfterOverlayGrowth({      timeline: listRef.current,      previousOverlayHeight: previousContentInsetEndAdjustmentRef.current,      overlayHeight: contentInsetEndAdjustment,      followingEnd: liveFollowEnabled && anchorMessageId === null,    });    previousContentInsetEndAdjustmentRef.current = contentInsetEndAdjustment;  }, [anchorMessageId, contentInsetEndAdjustment, listRef, liveFollowEnabled]);   useEffect(() => {    return () => {      if (disclosureSettleFrameRef.current !== null) {        cancelAnimationFrame(disclosureSettleFrameRef.current);      }      if (disclosureSettleSecondFrameRef.current !== null) {        cancelAnimationFrame(disclosureSettleSecondFrameRef.current);      }    };  }, []);   const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string) => {    disclosureAnchorKeyRef.current = anchorKey;    setDisclosureToggleSettling(true);    if (disclosureSettleFrameRef.current !== null) {      cancelAnimationFrame(disclosureSettleFrameRef.current);    }    if (disclosureSettleSecondFrameRef.current !== null) {      cancelAnimationFrame(disclosureSettleSecondFrameRef.current);    }    disclosureSettleFrameRef.current = requestAnimationFrame(() => {      disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {        disclosureAnchorKeyRef.current = null;        setDisclosureToggleSettling(false);        disclosureSettleFrameRef.current = null;        disclosureSettleSecondFrameRef.current = null;      });    });  }, []);   const shouldRestoreVisibleContentPosition = useCallback((row: MessagesTimelineRow) => {    const disclosureAnchorKey = disclosureAnchorKeyRef.current;    return disclosureAnchorKey === null || row.id === disclosureAnchorKey;  }, []);   const maintainVisibleContentPosition = useMemo(    () => ({      data: true,      size: true,      shouldRestorePosition: shouldRestoreVisibleContentPosition,    }),    [shouldRestoreVisibleContentPosition],  );   const onToggleTurnFold = useCallback(    (turnId: TurnId) => {      suspendEndScrollMaintenanceForDisclosure(`turn-fold:${turnId}`);      setExpandedTurnIds((existing) => {        const next = new Set(existing);        if (next.has(turnId)) {          next.delete(turnId);        } else {          next.add(turnId);        }        return next;      });    },    [suspendEndScrollMaintenanceForDisclosure],  );  const onToggleWorkGroup = useCallback(    (groupId: string, anchorKey: string) => {      suspendEndScrollMaintenanceForDisclosure(anchorKey);      setExpandedWorkGroupIds((existing) => {        const next = new Set(existing);        if (next.has(groupId)) {          next.delete(groupId);        } else {          next.add(groupId);        }        return next;      });    },    [suspendEndScrollMaintenanceForDisclosure],  );   // 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,        runningTurnId,        expandedTurnIds,        expandedWorkGroupIds,        isWorking,        activeTurnStartedAt,        turnDiffSummaryByAssistantMessageId,        revertTurnCountByUserMessageId,      }),    [      timelineEntries,      latestTurn,      runningTurnId,      expandedTurnIds,      expandedWorkGroupIds,      isWorking,      activeTurnStartedAt,      turnDiffSummaryByAssistantMessageId,      revertTurnCountByUserMessageId,    ],  );  const rows = useStableRows(rawRows);  const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);  const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(    null,  );  const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);  const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0);  const handleAnchorReady = useCallback(    (info: { anchorIndex: number | undefined }) => {      if (anchorMessageId !== null && info.anchorIndex !== undefined) {        onAnchorReady(anchorMessageId, info.anchorIndex);      }    },    [anchorMessageId, onAnchorReady],  );  const anchoredEndSpace = useMemo(() => {    const config = resolveChatListAnchoredEndSpace(      rows,      anchorMessageId,      (row) => (row.kind === "message" && row.message.role === "user" ? row.message.id : null),      { anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET },    );    return config ? { ...config, onReady: handleAnchorReady } : undefined;  }, [anchorMessageId, handleAnchorReady, rows]);   const handleScroll = useCallback(() => {    const state = listRef.current?.getState?.();    const isAtEnd = resolveTimelineIsAtEnd(state, contentInsetEndAdjustment);    if (isAtEnd !== undefined) {      onIsAtEndChange(isAtEnd);    }    if (!state || minimapItems.length === 0) {      return;    }     const scrollTop = state.scroll ?? 0;    const scrollBottom = scrollTop + (state.scrollLength ?? 0);     for (const item of minimapItems) {      const strip = minimapStripMap.get(item.id);      if (!strip) {

Lines 1–500 of 2521.