2057 lines · 69.9 KB · TypeScript
RawDownload
import {  type EnvironmentId,  type MessageId,  type ScopedThreadRef,  type ServerProviderSkill,  type TurnId,} from "@t3tools/contracts";import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";import {  createContext,  Fragment,  memo,  use,  useCallback,  useEffect,  useMemo,  useRef,  useState,  type KeyboardEvent,  type MouseEvent,  type ReactNode,} from "react";import { flushSync } from "react-dom";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,  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,  deriveMessagesTimelineRows,  normalizeCompactToolLabel,  resolveAssistantMessageCopyState,  resolveTimelineIsAtEnd,  resolveTimelineMinimapHasPersistentGutter,  resolveTimelineMinimapHeightStyle,  resolveTimelineMinimapIndexFromPointer,  resolveTimelineMinimapTopPercent,  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, 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;  onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => 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;  runningTurnId: TurnId | 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">>;  anchorMessageId: MessageId | null;  onAnchorReady: (messageId: MessageId, anchorIndex: number) => void;  onAnchorSizeChanged: (messageId: MessageId, size: number) => void;  contentInsetEndAdjustment: number;  onIsAtEndChange: (isAtEnd: boolean) => void;  onManualNavigation: () => void;} // ---------------------------------------------------------------------------// MessagesTimeline — list owner// --------------------------------------------------------------------------- export const MessagesTimeline = memo(function MessagesTimeline({  isWorking,  activeTurnInProgress,  activeTurnStartedAt,  listRef,  timelineEntries,  latestTurn,  runningTurnId,  turnDiffSummaryByAssistantMessageId,  routeThreadKey,  onOpenTurnDiff,  revertTurnCountByUserMessageId,  onRevertUserMessage,  isRevertingCheckpoint,  onImageExpand,  activeThreadEnvironmentId,  markdownCwd,  resolvedTheme,  timestampFormat,  workspaceRoot,  skills = EMPTY_TIMELINE_SKILLS,  anchorMessageId,  onAnchorReady,  onAnchorSizeChanged,  contentInsetEndAdjustment,  onIsAtEndChange,  onManualNavigation,}: MessagesTimelineProps) {  const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());  const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());  const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());   const onToggleTurnFold = useCallback((turnId: TurnId) => {    setExpandedTurnIds((existing) => {      const next = new Set(existing);      if (next.has(turnId)) {        next.delete(turnId);      } else {        next.add(turnId);      }      return next;    });  }, []);  const onToggleWorkGroup = useCallback(    (groupId: string, anchorElement?: HTMLElement) => {      const anchorBottomBeforeToggle = anchorElement?.getBoundingClientRect().bottom ?? null;       flushSync(() => {        setExpandedWorkGroupIds((existing) => {          const next = new Set(existing);          if (next.has(groupId)) {            next.delete(groupId);          } else {            next.add(groupId);          }          return next;        });      });       if (anchorBottomBeforeToggle === null || !anchorElement) {        return;      }       const delta = anchorElement.getBoundingClientRect().bottom - anchorBottomBeforeToggle;      if (Math.abs(delta) < 0.5) {        return;      }       const list = listRef.current;      const currentScroll = list?.getState?.().scroll;      if (list && typeof currentScroll === "number") {        list.scrollToOffset({ offset: currentScroll + delta, animated: false });      }    },    [listRef],  );   // 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 handleAnchorReady = useCallback(    (info: { anchorIndex: number | undefined }) => {      if (anchorMessageId !== null && info.anchorIndex !== undefined) {        onAnchorReady(anchorMessageId, info.anchorIndex);      }    },    [anchorMessageId, onAnchorReady],  );  const handleAnchorSizeChanged = useCallback(    (size: number) => {      if (anchorMessageId !== null) {        onAnchorSizeChanged(anchorMessageId, size);      }    },    [anchorMessageId, onAnchorSizeChanged],  );  const anchoredEndSpace = useMemo(() => {    const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) =>      row.kind === "message" ? row.message.id : null,    );    return config      ? { ...config, onReady: handleAnchorReady, onSizeChanged: handleAnchorSizeChanged }      : undefined;  }, [anchorMessageId, handleAnchorReady, handleAnchorSizeChanged, rows]);   const handleScroll = useCallback(() => {    const state = listRef.current?.getState?.();    const isAtEnd = resolveTimelineIsAtEnd(state);    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) {        continue;      }       const rowTop = resolveTimelineRowTop(state, item.rowIndex);      const rowHeight = resolveTimelineRowHeight(state, item.rowIndex);      const inView =        rowTop !== null &&        rowTop < scrollBottom &&        rowTop + Math.max(1, rowHeight ?? 1) > scrollTop;       strip.dataset.inView = inView ? "true" : "false";    }  }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange]);   useEffect(() => {    const frame = requestAnimationFrame(handleScroll);    return () => cancelAnimationFrame(frame);  }, [handleScroll, rows.length]);   useEffect(() => {    if (!timelineViewportElement) {      return;    }     const measure = () => {      const viewportWidth = timelineViewportElement.getBoundingClientRect().width;      const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter(viewportWidth);      setMinimapHasPersistentGutter((current) =>        current === nextHasPersistentGutter ? current : nextHasPersistentGutter,      );    };     const frame = requestAnimationFrame(measure);     const observer = new ResizeObserver(measure);    observer.observe(timelineViewportElement);     return () => {      cancelAnimationFrame(frame);      observer.disconnect();    };  }, [timelineViewportElement, rows.length]);   const sharedState = useMemo<TimelineRowSharedState>(    () => ({      timestampFormat,      routeThreadKey,      threadRef: parseScopedThreadKey(routeThreadKey),      markdownCwd,      resolvedTheme,      workspaceRoot,      skills,      activeThreadEnvironmentId,      onRevertUserMessage,      onImageExpand,      onOpenTurnDiff,      onToggleTurnFold,      onToggleWorkGroup,    }),    [      timestampFormat,      routeThreadKey,      markdownCwd,      resolvedTheme,      workspaceRoot,      skills,      activeThreadEnvironmentId,      onRevertUserMessage,      onImageExpand,      onOpenTurnDiff,      onToggleTurnFold,      onToggleWorkGroup,    ],  );  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}>        <div ref={setTimelineViewportElement} className="relative h-full min-h-0">          <LegendList<MessagesTimelineRow>            ref={listRef}            data={rows}            keyExtractor={keyExtractor}            getItemType={getItemType}            renderItem={renderItem}            estimatedItemSize={90}            initialScrollAtEnd            {...(anchoredEndSpace ? { anchoredEndSpace } : {})}            contentInsetEndAdjustment={contentInsetEndAdjustment}            maintainScrollAtEnd={              anchoredEndSpace                ? false                : {                    animated: false,                    on: {                      dataChange: true,                      itemLayout: true,                      layout: true,                    },                  }            }            maintainVisibleContentPosition={{              data: true,              size: false,            }}            onScroll={handleScroll}

Lines 1–500 of 2057.