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 useContext.// 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 { activeTurnInProgress: boolean; activeTurnId: TurnId | null; isWorking: boolean; isRevertingCheckpoint: boolean; completionSummary: string | 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_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, isWorking, activeTurnStartedAt, turnDiffSummaryByAssistantMessageId, revertTurnCountByUserMessageId, }), [ timelineEntries, completionDividerBeforeEntryId, isWorking, 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>( () => ({ activeTurnInProgress, activeTurnId: activeTurnId ?? null, isWorking, isRevertingCheckpoint, completionSummary, }), [activeTurnInProgress, activeTurnId, completionSummary, 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.Provider value={sharedState}> <TimelineRowActivityCtx.Provider 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.Provider> </TimelineRowCtx.Provider> );}); 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> )} {(displayedUserMessage.visibleText.trim().length > 0 || terminalContexts.length > 0) && ( <UserMessageBody text={displayedUserMessage.visibleText} terminalContexts={terminalContexts} skills={ctx.skills} /> )} <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 && <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> </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 />} <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() { const activity = use(TimelineRowActivityCtx); 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"> {activity.completionSummary ? `Response • ${activity.completionSummary}` : "Response"} </span> <span className="h-px flex-1 bg-border" /> </div> );} function AssistantCopyButton({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { const activity = use(TimelineRowActivityCtx); const assistantTurnStillInProgress = activity.activeTurnInProgress && activity.activeTurnId !== null && row.message.turnId === activity.activeTurnId; const assistantCopyState = resolveAssistantMessageCopyState({ text: row.message.text ?? null, showCopyButton: row.showAssistantCopyButton, streaming: row.message.streaming || assistantTurnStillInProgress, }); 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); Lines 1–500 of 1144. Load more
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 useContext. // 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 { activeTurnInProgress: boolean; activeTurnId: TurnId | null; isWorking: boolean; isRevertingCheckpoint: boolean; completionSummary: string | 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_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, isWorking, activeTurnStartedAt, turnDiffSummaryByAssistantMessageId, revertTurnCountByUserMessageId, }), [ timelineEntries, completionDividerBeforeEntryId, isWorking, 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>( () => ({ activeTurnInProgress, activeTurnId: activeTurnId ?? null, isWorking, isRevertingCheckpoint, completionSummary, }), [activeTurnInProgress, activeTurnId, completionSummary, 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.Provider value={sharedState}> <TimelineRowActivityCtx.Provider 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.Provider> </TimelineRowCtx.Provider> ); }); 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> )} {(displayedUserMessage.visibleText.trim().length > 0 || terminalContexts.length > 0) && ( <UserMessageBody text={displayedUserMessage.visibleText} terminalContexts={terminalContexts} skills={ctx.skills} /> )} <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 && <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> </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 />} <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() { const activity = use(TimelineRowActivityCtx); 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"> {activity.completionSummary ? `Response • ${activity.completionSummary}` : "Response"} </span> <span className="h-px flex-1 bg-border" /> </div> ); } function AssistantCopyButton({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { const activity = use(TimelineRowActivityCtx); const assistantTurnStillInProgress = activity.activeTurnInProgress && activity.activeTurnId !== null && row.message.turnId === activity.activeTurnId; const assistantCopyState = resolveAssistantMessageCopyState({ text: row.message.text ?? null, showCopyButton: row.showAssistantCopyButton, streaming: row.message.streaming || assistantTurnStillInProgress, }); 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);