1707 lines · 58.1 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 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,  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;  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;} // ---------------------------------------------------------------------------// 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,}: MessagesTimelineProps) {  const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());  const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());   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 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?.();    if (state) {      onIsAtEndChange(state.isAtEnd);    }  }, [listRef, onIsAtEndChange]);   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}>        <LegendList<MessagesTimelineRow>          ref={listRef}          data={rows}          keyExtractor={keyExtractor}          getItemType={getItemType}          renderItem={renderItem}          estimatedItemSize={90}          initialScrollAtEnd          {...(anchoredEndSpace ? { anchoredEndSpace } : {})}          contentInsetEndAdjustment={contentInsetEndAdjustment}          maintainVisibleContentPosition={{            data: true,            size: false,          }}          onScroll={handleScroll}          className="scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5"          ListHeaderComponent={TIMELINE_LIST_HEADER}          ListFooterComponent={TIMELINE_LIST_FOOTER}        />      </TimelineRowActivityCtx>    </TimelineRowCtx>  );}); function keyExtractor(item: MessagesTimelineRow) {  return item.id;} function getItemType(item: MessagesTimelineRow) {  return item.kind === "message" ? `message:${item.message.role}` : item.kind;} // ---------------------------------------------------------------------------// 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" ||          row.kind === "work-toggle"          ? "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 === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : 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 elementContexts = [    ...displayedUserMessage.elementContexts,    ...elementContextState.contexts,  ];  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 (

Lines 1–500 of 1707.