933 lines · 33.4 KB · TypeScript
RawDownload
import { type EnvironmentId, type MessageId, type TurnId } from "@t3tools/contracts";import {  memo,  useCallback,  useEffect,  useLayoutEffect,  useMemo,  useRef,  useState,  type ReactNode,} from "react";import {  measureElement as measureVirtualElement,  type VirtualItem,  useVirtualizer,} from "@tanstack/react-virtual";import { deriveTimelineEntries, formatElapsed } from "../../session-logic";import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll";import { type TurnDiffSummary } from "../../types";import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";import ChatMarkdown from "../ChatMarkdown";import {  BotIcon,  CheckIcon,  CircleAlertIcon,  EyeIcon,  GlobeIcon,  HammerIcon,  type LucideIcon,  SquarePenIcon,  TerminalIcon,  Undo2Icon,  WrenchIcon,  ZapIcon,} from "lucide-react";import { Button } from "../ui/button";import { clamp } from "effect/Number";import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";import { ProposedPlanCard } from "./ProposedPlanCard";import { ChangedFilesTree } from "./ChangedFilesTree";import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";import { MessageCopyButton } from "./MessageCopyButton";import {  MAX_VISIBLE_WORK_LOG_ENTRIES,  deriveMessagesTimelineRows,  estimateMessagesTimelineRowHeight,  normalizeCompactToolLabel,  type MessagesTimelineRow,} from "./MessagesTimeline.logic";import { TerminalContextInlineChip } from "./TerminalContextInlineChip";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";import {  deriveDisplayedUserMessageState,  type ParsedTerminalContextEntry,} from "~/lib/terminalContext";import { cn } from "~/lib/utils";import { type TimestampFormat } from "@t3tools/contracts/settings";import { formatTimestamp } from "../../timestampFormat";import {  buildInlineTerminalContextText,  formatInlineTerminalContextLabel,  textContainsInlineTerminalContextLabels,} from "./userMessageTerminalContexts"; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; interface MessagesTimelineProps {  hasMessages: boolean;  isWorking: boolean;  activeTurnInProgress: boolean;  activeTurnStartedAt: string | null;  scrollContainer: HTMLDivElement | null;  timelineEntries: ReturnType<typeof deriveTimelineEntries>;  completionDividerBeforeEntryId: string | null;  completionSummary: string | null;  turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>;  nowIso: string;  expandedWorkGroups: Record<string, boolean>;  onToggleWorkGroup: (groupId: string) => void;  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;  onVirtualizerSnapshot?: (snapshot: {    totalSize: number;    measurements: ReadonlyArray<{      id: string;      kind: MessagesTimelineRow["kind"];      index: number;      size: number;      start: number;      end: number;    }>;  }) => void;} export const MessagesTimeline = memo(function MessagesTimeline({  hasMessages,  isWorking,  activeTurnInProgress,  activeTurnStartedAt,  scrollContainer,  timelineEntries,  completionDividerBeforeEntryId,  completionSummary,  turnDiffSummaryByAssistantMessageId,  nowIso,  expandedWorkGroups,  onToggleWorkGroup,  onOpenTurnDiff,  revertTurnCountByUserMessageId,  onRevertUserMessage,  isRevertingCheckpoint,  onImageExpand,  activeThreadEnvironmentId,  markdownCwd,  resolvedTheme,  timestampFormat,  workspaceRoot,  onVirtualizerSnapshot,}: MessagesTimelineProps) {  const timelineRootRef = useRef<HTMLDivElement | null>(null);  const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null);   useLayoutEffect(() => {    const timelineRoot = timelineRootRef.current;    if (!timelineRoot) return;     const updateWidth = (nextWidth: number) => {      setTimelineWidthPx((previousWidth) => {        if (previousWidth !== null && Math.abs(previousWidth - nextWidth) < 0.5) {          return previousWidth;        }        return nextWidth;      });    };     updateWidth(timelineRoot.getBoundingClientRect().width);     if (typeof ResizeObserver === "undefined") return;    const observer = new ResizeObserver(() => {      updateWidth(timelineRoot.getBoundingClientRect().width);    });    observer.observe(timelineRoot);    return () => {      observer.disconnect();    };  }, [hasMessages, isWorking]);   const rows = useMemo(    () =>      deriveMessagesTimelineRows({        timelineEntries,        completionDividerBeforeEntryId,        isWorking,        activeTurnStartedAt,      }),    [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt],  );   const firstUnvirtualizedRowIndex = useMemo(() => {    const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0);    if (!activeTurnInProgress) return firstTailRowIndex;     const turnStartedAtMs =      typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN;    let firstCurrentTurnRowIndex = -1;    if (!Number.isNaN(turnStartedAtMs)) {      firstCurrentTurnRowIndex = rows.findIndex((row) => {        if (row.kind === "working") return true;        if (!row.createdAt) return false;        const rowCreatedAtMs = Date.parse(row.createdAt);        return !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs;      });    }     if (firstCurrentTurnRowIndex < 0) {      firstCurrentTurnRowIndex = rows.findIndex(        (row) => row.kind === "message" && row.message.streaming,      );    }     if (firstCurrentTurnRowIndex < 0) return firstTailRowIndex;     for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) {      const previousRow = rows[index];      if (!previousRow || previousRow.kind !== "message") continue;      if (previousRow.message.role === "user") {        return Math.min(index, firstTailRowIndex);      }      if (previousRow.message.role === "assistant" && !previousRow.message.streaming) {        break;      }    }     return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex);  }, [activeTurnInProgress, activeTurnStartedAt, rows]);   const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, {    minimum: 0,    maximum: rows.length,  });  const virtualMeasurementScopeKey =    timelineWidthPx === null ? "width:unknown" : `width:${Math.round(timelineWidthPx)}`;   const rowVirtualizer = useVirtualizer({    count: virtualizedRowCount,    getScrollElement: () => scrollContainer,    // Scope cached row measurements to the current timeline width so offscreen    // rows do not keep stale heights after wrapping changes.    getItemKey: (index: number) => {      const rowId = rows[index]?.id ?? String(index);      return `${virtualMeasurementScopeKey}:${rowId}`;    },    estimateSize: (index: number) => {      const row = rows[index];      if (!row) return 96;      return estimateMessagesTimelineRowHeight(row, {        expandedWorkGroups,        timelineWidthPx,        turnDiffSummaryByAssistantMessageId,      });    },    measureElement: measureVirtualElement,    useAnimationFrameWithResizeObserver: true,    overscan: 8,  });  useEffect(() => {    if (timelineWidthPx === null) return;    rowVirtualizer.measure();  }, [rowVirtualizer, timelineWidthPx]);  useEffect(() => {    rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {      const viewportHeight = instance.scrollRect?.height ?? 0;      const scrollOffset = instance.scrollOffset ?? 0;      const itemIntersectsViewport =        item.end > scrollOffset && item.start < scrollOffset + viewportHeight;      if (itemIntersectsViewport) {        return false;      }      const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);      return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX;    };    return () => {      rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined;    };  }, [rowVirtualizer]);  const pendingMeasureFrameRef = useRef<number | null>(null);  const onTimelineImageLoad = useCallback(() => {    if (pendingMeasureFrameRef.current !== null) return;    pendingMeasureFrameRef.current = window.requestAnimationFrame(() => {      pendingMeasureFrameRef.current = null;      rowVirtualizer.measure();    });  }, [rowVirtualizer]);  useEffect(() => {    return () => {      const frame = pendingMeasureFrameRef.current;      if (frame !== null) {        window.cancelAnimationFrame(frame);      }    };  }, []);  useLayoutEffect(() => {    if (!onVirtualizerSnapshot) {      return;    }    onVirtualizerSnapshot({      totalSize: rowVirtualizer.getTotalSize(),      measurements: rowVirtualizer.measurementsCache        .slice(0, virtualizedRowCount)        .flatMap((measurement) => {          const row = rows[measurement.index];          if (!row) {            return [];          }          return [            {              id: row.id,              kind: row.kind,              index: measurement.index,              size: measurement.size,              start: measurement.start,              end: measurement.end,            },          ];        }),    });  }, [onVirtualizerSnapshot, rowVirtualizer, rows, virtualizedRowCount]);   const virtualRows = rowVirtualizer.getVirtualItems();  const nonVirtualizedRows = rows.slice(virtualizedRowCount);  const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState<    Record<string, boolean>  >({});  const onToggleAllDirectories = useCallback((turnId: TurnId) => {    setAllDirectoriesExpandedByTurnId((current) => ({      ...current,      [turnId]: !(current[turnId] ?? true),    }));  }, []);   const renderRowContent = (row: TimelineRow) => (    <div      className="pb-4"      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" &&        (() => {          const groupId = row.id;          const groupedEntries = row.groupedEntries;          const isExpanded = expandedWorkGroups[groupId] ?? false;          const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;          const visibleEntries =            hasOverflow && !isExpanded              ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)              : groupedEntries;          const hiddenCount = groupedEntries.length - visibleEntries.length;          const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");          const showHeader = hasOverflow || !onlyToolEntries;          const groupLabel = onlyToolEntries ? "Tool calls" : "Work log";           return (            <div className="rounded-xl border border-border/45 bg-card/25 px-2 py-1.5">              {showHeader && (                <div className="mb-1.5 flex items-center justify-between gap-2 px-0.5">                  <p className="text-[9px] uppercase tracking-[0.16em] text-muted-foreground/55">                    {groupLabel} ({groupedEntries.length})                  </p>                  {hasOverflow && (                    <button                      type="button"                      className="text-[9px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-foreground/75"                      onClick={() => onToggleWorkGroup(groupId)}                    >                      {isExpanded ? "Show less" : `Show ${hiddenCount} more`}                    </button>                  )}                </div>              )}              <div className="space-y-0.5">                {visibleEntries.map((workEntry) => (                  <SimpleWorkEntryRow key={`work-row:${workEntry.id}`} workEntry={workEntry} />                ))}              </div>            </div>          );        })()}       {row.kind === "message" &&        row.message.role === "user" &&        (() => {          const userImages = row.message.attachments ?? [];          const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text);          const terminalContexts = displayedUserMessage.contexts;          const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id);          return (            <div className="flex justify-end">              <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">                {userImages.length > 0 && (                  <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">                    {userImages.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(userImages, image.id);                                if (!preview) return;                                onImageExpand(preview);                              }}                            >                              <img                                src={image.previewUrl}                                alt={image.name}                                className="block h-auto max-h-[220px] w-full object-cover"                                onLoad={onTimelineImageLoad}                                onError={onTimelineImageLoad}                              />                            </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>                )}                {(displayedUserMessage.visibleText.trim().length > 0 ||                  terminalContexts.length > 0) && (                  <UserMessageBody                    text={displayedUserMessage.visibleText}                    terminalContexts={terminalContexts}                  />                )}                <div className="mt-1.5 flex items-center justify-end gap-2">                  <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">                    {displayedUserMessage.copyText && (                      <MessageCopyButton text={displayedUserMessage.copyText} />                    )}                    {canRevertAgentWork && (                      <Button                        type="button"                        size="xs"                        variant="outline"                        disabled={isRevertingCheckpoint || isWorking}                        onClick={() => onRevertUserMessage(row.message.id)}                        title="Revert to this message"                      >                        <Undo2Icon className="size-3" />                      </Button>                    )}                  </div>                  <p className="text-right text-xs text-muted-foreground/50">                    {formatTimestamp(row.message.createdAt, timestampFormat)}                  </p>                </div>              </div>            </div>          );        })()}       {row.kind === "message" &&        row.message.role === "assistant" &&        (() => {          const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");          return (            <>              {row.showCompletionDivider && (                <div className="my-3 flex items-center gap-3">                  <span className="h-px flex-1 bg-border" />                  <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">                    {completionSummary ? `Response • ${completionSummary}` : "Response"}                  </span>                  <span className="h-px flex-1 bg-border" />                </div>              )}              <div className="min-w-0 px-1 py-0.5">                <ChatMarkdown                  text={messageText}                  cwd={markdownCwd}                  isStreaming={Boolean(row.message.streaming)}                />                {(() => {                  const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id);                  if (!turnSummary) return null;                  const checkpointFiles = turnSummary.files;                  if (checkpointFiles.length === 0) return null;                  const summaryStat = summarizeTurnDiffStats(checkpointFiles);                  const changedFileCountLabel = String(checkpointFiles.length);                  const allDirectoriesExpanded =                    allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true;                  return (                    <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">                      <div className="mb-1.5 flex items-center justify-between gap-2">                        <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">                          <span>Changed files ({changedFileCountLabel})</span>                          {hasNonZeroStat(summaryStat) && (                            <>                              <span className="mx-1">•</span>                              <DiffStatLabel                                additions={summaryStat.additions}                                deletions={summaryStat.deletions}                              />                            </>                          )}                        </p>                        <div className="flex items-center gap-1.5">                          <Button                            type="button"                            size="xs"                            variant="outline"                            data-scroll-anchor-ignore                            onClick={() => onToggleAllDirectories(turnSummary.turnId)}                          >                            {allDirectoriesExpanded ? "Collapse all" : "Expand all"}                          </Button>                          <Button                            type="button"                            size="xs"                            variant="outline"                            onClick={() =>                              onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)

Lines 1–500 of 933.