diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2a9a2760..ed39e8c6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -141,25 +141,17 @@ import { ChevronLeftIcon, ChevronRightIcon, CircleAlertIcon, - DatabaseIcon, - EyeIcon, FileIcon, FolderIcon, DiffIcon, EllipsisIcon, FolderClosedIcon, - GlobeIcon, - HammerIcon, ListTodoIcon, LockIcon, LockOpenIcon, type LucideIcon, - SearchIcon, - SquarePenIcon, TerminalIcon, - TargetIcon, Undo2Icon, - WrenchIcon, XIcon, CopyIcon, CheckIcon, @@ -247,6 +239,7 @@ import { formatTimestamp } from "../timestampFormat"; import { computeMessageDurationStart, normalizeCompactToolLabel, + resolveWorkEntryIcon, } from "./chat/MessagesTimeline.logic"; import { deriveVisibleThreadWorkLogEntries, @@ -373,70 +366,7 @@ function workEntryPreview(workEntry: { } function workEntryIcon(workEntry: WorkLogEntry): LucideIcon { - if (workEntry.requestKind === "command") return TerminalIcon; - if (workEntry.requestKind === "file-read") return EyeIcon; - if (workEntry.requestKind === "file-change") return SquarePenIcon; - - if (workEntry.itemType === "command_execution" || workEntry.command) { - return TerminalIcon; - } - if (workEntry.itemType === "file_change" || (workEntry.changedFiles?.length ?? 0) > 0) { - return SquarePenIcon; - } - if (workEntry.itemType === "web_search") return GlobeIcon; - if (workEntry.itemType === "image_view") return EyeIcon; - - switch (workEntry.itemType) { - case "mcp_tool_call": - return WrenchIcon; - case "dynamic_tool_call": - case "collab_agent_tool_call": - return HammerIcon; - } - - const haystack = [ - workEntry.label, - workEntry.toolTitle, - workEntry.detail, - workEntry.output, - workEntry.command, - ] - .filter((value): value is string => typeof value === "string" && value.length > 0) - .join(" ") - .toLowerCase(); - - if (haystack.includes("report_intent") || haystack.includes("intent logged")) { - return TargetIcon; - } - if ( - haystack.includes("bash") || - haystack.includes("read_bash") || - haystack.includes("write_bash") || - haystack.includes("stop_bash") || - haystack.includes("list_bash") - ) { - return TerminalIcon; - } - if (haystack.includes("sql")) return DatabaseIcon; - if (haystack.includes("view")) return EyeIcon; - if (haystack.includes("apply_patch")) return SquarePenIcon; - if (haystack.includes("rg") || haystack.includes("glob") || haystack.includes("search")) { - return SearchIcon; - } - if (haystack.includes("skill")) return ZapIcon; - if (haystack.includes("ask_user") || haystack.includes("approval")) return BotIcon; - if (haystack.includes("store_memory")) return FolderIcon; - if (haystack.includes("edit") || haystack.includes("patch")) return WrenchIcon; - if (haystack.includes("file")) return FileIcon; - - if (haystack.includes("task")) return HammerIcon; - - if (workEntry.activityKind === "turn.plan.updated") return ListTodoIcon; - if (workEntry.activityKind === "task.progress") return HammerIcon; - if (workEntry.activityKind === "approval.requested") return BotIcon; - if (workEntry.activityKind === "approval.resolved") return CheckIcon; - - return workToneIcon(workEntry.tone).icon; + return resolveWorkEntryIcon(workEntry); } function capitalizePhrase(value: string): string { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index dee42a85..7f9bbb76 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; +import { BotIcon, HammerIcon, SearchIcon, TerminalIcon, WrenchIcon } from "lucide-react"; +import { + computeMessageDurationStart, + normalizeCompactToolLabel, + resolveWorkEntryIcon, +} from "./MessagesTimeline.logic"; describe("computeMessageDurationStart", () => { it("returns message createdAt when there is no preceding user message", () => { @@ -143,3 +148,53 @@ describe("normalizeCompactToolLabel", () => { expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file"); }); }); + +describe("resolveWorkEntryIcon", () => { + it("uses the tool name for dynamic bash calls", () => { + expect( + resolveWorkEntryIcon({ + tone: "tool", + itemType: "dynamic_tool_call", + toolTitle: "bash", + }), + ).toBe(TerminalIcon); + }); + + it("uses search styling for dynamic search tools", () => { + expect( + resolveWorkEntryIcon({ + tone: "tool", + itemType: "dynamic_tool_call", + detail: "rg TODO src", + }), + ).toBe(SearchIcon); + }); + + it("falls back to a generic tool icon for dynamic tools", () => { + expect( + resolveWorkEntryIcon({ + tone: "tool", + itemType: "dynamic_tool_call", + toolTitle: "custom helper", + }), + ).toBe(WrenchIcon); + }); + + it("uses a bot fallback for collab agent calls", () => { + expect( + resolveWorkEntryIcon({ + tone: "tool", + itemType: "collab_agent_tool_call", + }), + ).toBe(BotIcon); + }); + + it("keeps task progress on the hammer icon", () => { + expect( + resolveWorkEntryIcon({ + tone: "tool", + activityKind: "task.progress", + }), + ).toBe(HammerIcon); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 726d6188..7b6f3525 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,3 +1,22 @@ +import { + BotIcon, + CheckIcon, + DatabaseIcon, + EyeIcon, + FileIcon, + FolderIcon, + GlobeIcon, + HammerIcon, + type LucideIcon, + ListTodoIcon, + SearchIcon, + SquarePenIcon, + TargetIcon, + TerminalIcon, + WrenchIcon, + ZapIcon, +} from "lucide-react"; + export interface TimelineDurationMessage { id: string; role: "user" | "assistant" | "system"; @@ -27,3 +46,84 @@ export function computeMessageDurationStart( export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } + +export interface WorkEntryIconInput { + tone: "thinking" | "tool" | "info" | "error"; + activityKind?: string; + itemType?: + | "command_execution" + | "file_change" + | "mcp_tool_call" + | "dynamic_tool_call" + | "collab_agent_tool_call" + | "web_search" + | "image_view"; + requestKind?: "command" | "file-read" | "file-change"; + label?: string; + toolTitle?: string; + detail?: string; + output?: string; + command?: string; + changedFiles?: ReadonlyArray; +} + +export function resolveWorkEntryIcon(workEntry: WorkEntryIconInput): LucideIcon { + if (workEntry.requestKind === "command") return TerminalIcon; + if (workEntry.requestKind === "file-read") return EyeIcon; + if (workEntry.requestKind === "file-change") return SquarePenIcon; + + if (workEntry.itemType === "command_execution" || workEntry.command) { + return TerminalIcon; + } + if (workEntry.itemType === "file_change" || (workEntry.changedFiles?.length ?? 0) > 0) { + return SquarePenIcon; + } + if (workEntry.itemType === "web_search") return GlobeIcon; + if (workEntry.itemType === "image_view") return EyeIcon; + if (workEntry.itemType === "mcp_tool_call") return WrenchIcon; + + const haystack = [ + workEntry.label, + workEntry.toolTitle, + workEntry.detail, + workEntry.output, + workEntry.command, + ] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join(" ") + .toLowerCase(); + + if (haystack.includes("report_intent") || haystack.includes("intent logged")) { + return TargetIcon; + } + if ( + haystack.includes("bash") || + haystack.includes("read_bash") || + haystack.includes("write_bash") || + haystack.includes("stop_bash") || + haystack.includes("list_bash") + ) { + return TerminalIcon; + } + if (haystack.includes("sql")) return DatabaseIcon; + if (haystack.includes("view")) return EyeIcon; + if (haystack.includes("apply_patch")) return SquarePenIcon; + if (haystack.includes("rg") || haystack.includes("glob") || haystack.includes("search")) { + return SearchIcon; + } + if (haystack.includes("skill")) return ZapIcon; + if (haystack.includes("ask_user") || haystack.includes("approval")) return BotIcon; + if (haystack.includes("store_memory")) return FolderIcon; + if (haystack.includes("edit") || haystack.includes("patch")) return WrenchIcon; + if (haystack.includes("file")) return FileIcon; + if (haystack.includes("task")) return HammerIcon; + + if (workEntry.activityKind === "turn.plan.updated") return ListTodoIcon; + if (workEntry.activityKind === "task.progress") return HammerIcon; + if (workEntry.activityKind === "approval.requested") return BotIcon; + if (workEntry.activityKind === "approval.resolved") return CheckIcon; + if (workEntry.itemType === "dynamic_tool_call") return WrenchIcon; + if (workEntry.itemType === "collab_agent_tool_call") return BotIcon; + + return workEntry.tone === "info" ? CheckIcon : ZapIcon; +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e3080104..bab5e943 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -14,14 +14,8 @@ import { BotIcon, CheckIcon, CircleAlertIcon, - EyeIcon, - GlobeIcon, - HammerIcon, type LucideIcon, - SquarePenIcon, - TerminalIcon, Undo2Icon, - WrenchIcon, ZapIcon, } from "lucide-react"; import { Button } from "../ui/button"; @@ -32,7 +26,11 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; -import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; +import { + computeMessageDurationStart, + normalizeCompactToolLabel, + resolveWorkEntryIcon, +} from "./MessagesTimeline.logic"; import { cn } from "~/lib/utils"; import { type TimestampFormat } from "../../appSettings"; import { formatTimestamp } from "../../timestampFormat"; @@ -685,28 +683,7 @@ function workEntryPreview( } 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; - - if (workEntry.itemType === "command_execution" || workEntry.command) { - return TerminalIcon; - } - if (workEntry.itemType === "file_change" || (workEntry.changedFiles?.length ?? 0) > 0) { - return SquarePenIcon; - } - if (workEntry.itemType === "web_search") return GlobeIcon; - if (workEntry.itemType === "image_view") return EyeIcon; - - switch (workEntry.itemType) { - case "mcp_tool_call": - return WrenchIcon; - case "dynamic_tool_call": - case "collab_agent_tool_call": - return HammerIcon; - } - - return workToneIcon(workEntry.tone).icon; + return resolveWorkEntryIcon(workEntry); } function capitalizePhrase(value: string): string {