1221 lines · 41.3 KB · TypeScript
RawDownload
import {  type EnvironmentId,  type MessageId,  type ServerProviderSkill,  type TurnId,} from "@t3tools/contracts";import {  createContext,  memo,  use,  useCallback,  useEffect,  useMemo,  useRef,  useState,  type ReactNode,} from "react";import { LegendList, type LegendListRef } from "@legendapp/list/react";import { deriveTimelineEntries, formatElapsed } from "../../session-logic";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 { 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,} 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 { useUiStateStore } from "~/uiStateStore";import { type TimestampFormat } from "@t3tools/contracts/settings";import { formatTimestamp } from "../../timestampFormat"; import {  buildInlineTerminalContextText,  formatInlineTerminalContextLabel,  textContainsInlineTerminalContextLabels,} from "./userMessageTerminalContexts";import { SkillInlineText } from "./SkillInlineText";import { formatWorkspaceRelativePath } from "../../filePathDisplay"; // ---------------------------------------------------------------------------// 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;  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;} interface TimelineRowActivityState {  isWorking: boolean;  isRevertingCheckpoint: 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;  activeTurnId?: TurnId | null;  activeTurnStartedAt: string | null;  listRef: React.RefObject<LegendListRef | null>;  timelineEntries: ReturnType<typeof deriveTimelineEntries>;  completionDividerBeforeEntryId: string | null;  completionSummary: string | 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,  activeTurnId,  activeTurnStartedAt,  listRef,  timelineEntries,  completionDividerBeforeEntryId,  completionSummary,  turnDiffSummaryByAssistantMessageId,  routeThreadKey,  onOpenTurnDiff,  revertTurnCountByUserMessageId,  onRevertUserMessage,  isRevertingCheckpoint,  onImageExpand,  activeThreadEnvironmentId,  markdownCwd,  resolvedTheme,  timestampFormat,  workspaceRoot,  skills = EMPTY_TIMELINE_SKILLS,  onIsAtEndChange,}: MessagesTimelineProps) {  const rawRows = useMemo(    () =>      deriveMessagesTimelineRows({        timelineEntries,        completionDividerBeforeEntryId,        completionSummary,        isWorking,        activeTurnInProgress,        activeTurnId: activeTurnId ?? null,        activeTurnStartedAt,        turnDiffSummaryByAssistantMessageId,        revertTurnCountByUserMessageId,      }),    [      timelineEntries,      completionDividerBeforeEntryId,      completionSummary,      isWorking,      activeTurnInProgress,      activeTurnId,      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,      markdownCwd,      resolvedTheme,      workspaceRoot,      skills,      activeThreadEnvironmentId,      onRevertUserMessage,      onImageExpand,      onOpenTurnDiff,    }),    [      timestampFormat,      routeThreadKey,      markdownCwd,      resolvedTheme,      workspaceRoot,      skills,      activeThreadEnvironmentId,      onRevertUserMessage,      onImageExpand,      onOpenTurnDiff,    ],  );  const activityState = useMemo<TimelineRowActivityState>(    () => ({      isWorking,      isRevertingCheckpoint,    }),    [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          maintainScrollAtEndThreshold={0.1}          maintainVisibleContentPosition          onScroll={handleScroll}          className="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(        "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 === "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 canRevertAgentWork = typeof row.revertTurnCount === "number";   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;                      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>        )}        <CollapsibleUserMessageBody          text={displayedUserMessage.visibleText}          terminalContexts={terminalContexts}          skills={ctx.skills}          footer={            <>              <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 && <RevertUserMessageButton messageId={row.message.id} />}              </div>              <p className="text-right text-xs text-muted-foreground/50">                {formatTimestamp(row.message.createdAt, ctx.timestampFormat)}              </p>            </>          }        />      </div>    </div>  );} function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {  const ctx = use(TimelineRowCtx);  const activity = use(TimelineRowActivityCtx);   return (    <Button      type="button"      size="xs"      variant="outline"      disabled={activity.isRevertingCheckpoint || activity.isWorking}      onClick={() => ctx.onRevertUserMessage(messageId)}      title="Revert to this message"    >      <Undo2Icon className="size-3" />    </Button>  );} function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {  const ctx = use(TimelineRowCtx);  const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");   return (    <>      {row.showCompletionDivider && (        <AssistantCompletionDivider completionSummary={row.completionSummary} />      )}      <div className="min-w-0 px-1 py-0.5">        <ChatMarkdown          text={messageText}          cwd={ctx.markdownCwd}          isStreaming={Boolean(row.message.streaming)}          skills={ctx.skills}        />        <AssistantChangedFilesSection          turnSummary={row.assistantTurnDiffSummary}          routeThreadKey={ctx.routeThreadKey}          resolvedTheme={ctx.resolvedTheme}          onOpenTurnDiff={ctx.onOpenTurnDiff}        />        <div className="mt-1.5 flex items-center gap-2">          <p className="text-[10px] text-muted-foreground/30">            {row.message.streaming ? (              <LiveMessageMeta                createdAt={row.message.createdAt}                durationStart={row.durationStart}                timestampFormat={ctx.timestampFormat}              />            ) : (              formatMessageMeta(                row.message.createdAt,                formatElapsed(row.durationStart, row.message.completedAt),                ctx.timestampFormat,              )            )}          </p>          <AssistantCopyButton row={row} />        </div>      </div>    </>  );} function AssistantCompletionDivider({ completionSummary }: { completionSummary: string | null }) {  return (    <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>  );} function AssistantCopyButton({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {  const assistantCopyState = resolveAssistantMessageCopyState({    text: row.message.text ?? null,    showCopyButton: row.showAssistantCopyButton,    streaming: row.assistantCopyStreaming,  });   if (!assistantCopyState.visible) {    return null;  }   return (    <div className="flex items-center opacity-0 transition-opacity duration-200  group-hover/assistant:opacity-100">      <MessageCopyButton        text={assistantCopyState.text ?? ""}        size="icon-xs"        variant="outline"        className="border-border/50 bg-background/35 text-muted-foreground/45 shadow-none hover:border-border/70 hover:bg-background/55 hover:text-muted-foreground/70"      />    </div>  );} function ProposedPlanTimelineRow({  row,}: {  row: Extract<TimelineRow, { kind: "proposed-plan" }>;}) {  const ctx = use(TimelineRowCtx);   return (    <div className="min-w-0 px-1 py-0.5">      <ProposedPlanCard        planMarkdown={row.proposedPlan.planMarkdown}        environmentId={ctx.activeThreadEnvironmentId}

Lines 1–500 of 1221.