diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c2fb44c80a9..f423aea9561 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -33,7 +33,6 @@ import { EyeIcon, GlobeIcon, HammerIcon, - type LucideIcon, SquarePenIcon, TerminalIcon, Undo2Icon, @@ -104,6 +103,11 @@ interface TimelineRowActivityState { isRevertingCheckpoint: boolean; } +interface StableRowsSnapshot { + source: MessagesTimelineRow[]; + state: StableMessagesTimelineRowsState; +} + const TimelineRowCtx = createContext(null!); const TimelineRowActivityCtx = createContext(null!); const TIMELINE_LIST_HEADER =
; @@ -1008,19 +1012,34 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte // so LegendList (and React) can skip re-rendering unchanged items. // --------------------------------------------------------------------------- +function createStableRowsSnapshot( + rows: MessagesTimelineRow[], + previous?: StableMessagesTimelineRowsState, +): StableRowsSnapshot { + return { + source: rows, + state: computeStableMessagesTimelineRows( + rows, + previous ?? { + byId: new Map(), + result: [], + }, + ), + }; +} + /** Returns a structurally-shared copy of `rows`: for each row whose content * hasn't changed since last call, the previous object reference is reused. */ function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] { - const prevState = useRef({ - byId: new Map(), - result: [], - }); + const [snapshot, setSnapshot] = useState(() => createStableRowsSnapshot(rows)); + + if (snapshot.source !== rows) { + const nextSnapshot = createStableRowsSnapshot(rows, snapshot.state); + setSnapshot(nextSnapshot); + return nextSnapshot.state.result; + } - return useMemo(() => { - const nextState = computeStableMessagesTimelineRows(rows, prevState.current); - prevState.current = nextState; - return nextState.result; - }, [rows]); + return snapshot.state.result; } // --------------------------------------------------------------------------- @@ -1072,32 +1091,17 @@ function formatMessageMeta( return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`; } -function workToneIcon(tone: TimelineWorkEntry["tone"]): { - icon: LucideIcon; - className: string; -} { +function workToneIconClass(tone: TimelineWorkEntry["tone"]): string { if (tone === "error") { - return { - icon: CircleAlertIcon, - className: "text-foreground/92", - }; + return "text-foreground/92"; } if (tone === "thinking") { - return { - icon: BotIcon, - className: "text-foreground/92", - }; + return "text-foreground/92"; } if (tone === "info") { - return { - icon: CheckIcon, - className: "text-foreground/92", - }; + return "text-foreground/92"; } - return { - icon: ZapIcon, - className: "text-foreground/92", - }; + return "text-foreground/92"; } function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { @@ -1132,29 +1136,36 @@ function workEntryRawCommand( return rawCommand === workEntry.command.trim() ? null : rawCommand; } -function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon { - if (workEntry.requestKind === "command") return TerminalIcon; - if (workEntry.requestKind === "file-read") return EyeIcon; - if (workEntry.requestKind === "file-change") return SquarePenIcon; +function WorkToneIconGlyph({ tone }: { tone: TimelineWorkEntry["tone"] }) { + if (tone === "error") return ; + if (tone === "thinking") return ; + if (tone === "info") return ; + return ; +} + +function WorkEntryIconGlyph({ workEntry }: { workEntry: TimelineWorkEntry }) { + if (workEntry.requestKind === "command") return ; + if (workEntry.requestKind === "file-read") return ; + if (workEntry.requestKind === "file-change") return ; if (workEntry.itemType === "command_execution" || workEntry.command) { - return TerminalIcon; + return ; } if (workEntry.itemType === "file_change" || (workEntry.changedFiles?.length ?? 0) > 0) { - return SquarePenIcon; + return ; } - if (workEntry.itemType === "web_search") return GlobeIcon; - if (workEntry.itemType === "image_view") return EyeIcon; + if (workEntry.itemType === "web_search") return ; + if (workEntry.itemType === "image_view") return ; switch (workEntry.itemType) { case "mcp_tool_call": - return WrenchIcon; + return ; case "dynamic_tool_call": case "collab_agent_tool_call": - return HammerIcon; + return ; } - return workToneIcon(workEntry.tone).icon; + return ; } function capitalizePhrase(value: string): string { @@ -1177,8 +1188,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workspaceRoot: string | undefined; }) { const { workEntry, workspaceRoot } = props; - const iconConfig = workToneIcon(workEntry.tone); - const EntryIcon = workEntryIcon(workEntry); + const iconClassName = workToneIconClass(workEntry.tone); const heading = toolWorkEntryHeading(workEntry); const rawPreview = workEntryPreview(workEntry, workspaceRoot); const preview = @@ -1195,10 +1205,8 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { return (
- - + +
{rawCommand ? (