From 5fcdf81b805f1fcfca33b831db358dec1522962d Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Sun, 15 Mar 2026 05:43:23 +0000 Subject: [PATCH] Extract shared icon resolution to fix incorrect hammer icons for tool calls --- apps/web/src/components/ChatView.tsx | 98 ++++++---------- .../chat/MessagesTimeline.logic.test.ts | 46 +++++++- .../components/chat/MessagesTimeline.logic.ts | 108 ++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 67 ++++++++--- 4 files changed, 239 insertions(+), 80 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2a9a2760..48c03118 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -247,6 +247,8 @@ import { formatTimestamp } from "../timestampFormat"; import { computeMessageDurationStart, normalizeCompactToolLabel, + resolveWorkEntryIconKind, + type WorkEntryIconKind, } from "./chat/MessagesTimeline.logic"; import { deriveVisibleThreadWorkLogEntries, @@ -373,70 +375,46 @@ 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; + const iconKind = resolveWorkEntryIconKind(workEntry); + if (!iconKind) { + return workToneIcon(workEntry.tone).icon; } - if (workEntry.itemType === "web_search") return GlobeIcon; - if (workEntry.itemType === "image_view") return EyeIcon; + return iconKindToLucideIcon(iconKind); +} - switch (workEntry.itemType) { - case "mcp_tool_call": - return WrenchIcon; - case "dynamic_tool_call": - case "collab_agent_tool_call": +function iconKindToLucideIcon(iconKind: WorkEntryIconKind): LucideIcon { + switch (iconKind) { + case "bot": + return BotIcon; + case "check": + return CheckIcon; + case "database": + return DatabaseIcon; + case "eye": + return EyeIcon; + case "file": + return FileIcon; + case "folder": + return FolderIcon; + case "globe": + return GlobeIcon; + case "hammer": return HammerIcon; + case "list-todo": + return ListTodoIcon; + case "search": + return SearchIcon; + case "square-pen": + return SquarePenIcon; + case "target": + return TargetIcon; + case "terminal": + return TerminalIcon; + case "wrench": + return WrenchIcon; + case "zap": + return ZapIcon; } - - 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; } 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..be04853a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; +import { + computeMessageDurationStart, + normalizeCompactToolLabel, + resolveWorkEntryIconKind, +} from "./MessagesTimeline.logic"; describe("computeMessageDurationStart", () => { it("returns message createdAt when there is no preceding user message", () => { @@ -143,3 +147,43 @@ describe("normalizeCompactToolLabel", () => { expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file"); }); }); + +describe("resolveWorkEntryIconKind", () => { + it("prefers search heuristics before generic dynamic tool call fallbacks", () => { + expect( + resolveWorkEntryIconKind({ + label: "Context7-resolve-library-id", + detail: "Available Libraries: Next.js", + itemType: "dynamic_tool_call", + }), + ).toBe("search"); + }); + + it("keeps sql tools on a database icon", () => { + expect( + resolveWorkEntryIconKind({ + label: "Sql", + detail: "1 row(s) returned", + itemType: "dynamic_tool_call", + }), + ).toBe("database"); + }); + + it("falls back to hammer for generic dynamic tool calls", () => { + expect( + resolveWorkEntryIconKind({ + label: "Tool call", + itemType: "dynamic_tool_call", + }), + ).toBe("hammer"); + }); + + it("keeps mcp tool calls on a wrench when no stronger heuristic matches", () => { + expect( + resolveWorkEntryIconKind({ + label: "Tool call", + itemType: "mcp_tool_call", + }), + ).toBe("wrench"); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 726d6188..a4489906 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -5,6 +5,43 @@ export interface TimelineDurationMessage { completedAt?: string | undefined; } +export type WorkEntryIconKind = + | "bot" + | "check" + | "database" + | "eye" + | "file" + | "folder" + | "globe" + | "hammer" + | "list-todo" + | "search" + | "square-pen" + | "target" + | "terminal" + | "wrench" + | "zap"; + +export interface WorkEntryIconSource { + label: string; + toolTitle?: string | undefined; + detail?: string | undefined; + output?: string | undefined; + command?: string | undefined; + changedFiles?: ReadonlyArray | undefined; + itemType?: + | "command_execution" + | "file_change" + | "mcp_tool_call" + | "dynamic_tool_call" + | "collab_agent_tool_call" + | "web_search" + | "image_view" + | undefined; + requestKind?: "command" | "file-read" | "file-change" | undefined; + activityKind?: string | undefined; +} + export function computeMessageDurationStart( messages: ReadonlyArray, ): Map { @@ -27,3 +64,74 @@ export function computeMessageDurationStart( export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } + +export function resolveWorkEntryIconKind(workEntry: WorkEntryIconSource): WorkEntryIconKind | null { + if (workEntry.requestKind === "command") return "terminal"; + if (workEntry.requestKind === "file-read") return "eye"; + if (workEntry.requestKind === "file-change") return "square-pen"; + + if (workEntry.itemType === "command_execution" || workEntry.command) { + return "terminal"; + } + if (workEntry.itemType === "file_change" || (workEntry.changedFiles?.length ?? 0) > 0) { + return "square-pen"; + } + if (workEntry.itemType === "web_search") return "globe"; + if (workEntry.itemType === "image_view") return "eye"; + + 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 "target"; + } + if ( + haystack.includes("bash") || + haystack.includes("read_bash") || + haystack.includes("write_bash") || + haystack.includes("stop_bash") || + haystack.includes("list_bash") + ) { + return "terminal"; + } + if (haystack.includes("sql")) return "database"; + if ( + haystack.includes("context7") || + haystack.includes("resolve-library-id") || + haystack.includes("search") + ) { + return "search"; + } + if (haystack.includes("view")) return "eye"; + if (haystack.includes("apply_patch")) return "square-pen"; + if (haystack.includes("skill")) return "zap"; + if (haystack.includes("ask_user") || haystack.includes("approval")) return "bot"; + if (haystack.includes("store_memory")) return "folder"; + if (haystack.includes("edit") || haystack.includes("patch")) return "wrench"; + if (haystack.includes("file")) return "file"; + + if (haystack.includes("task")) return "hammer"; + + if (workEntry.activityKind === "turn.plan.updated") return "list-todo"; + if (workEntry.activityKind === "task.progress") return "hammer"; + if (workEntry.activityKind === "approval.requested") return "bot"; + if (workEntry.activityKind === "approval.resolved") return "check"; + + if (workEntry.itemType === "mcp_tool_call") return "wrench"; + if ( + workEntry.itemType === "dynamic_tool_call" || + workEntry.itemType === "collab_agent_tool_call" + ) { + return "hammer"; + } + + return null; +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e3080104..828ab45b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -14,11 +14,17 @@ import { BotIcon, CheckIcon, CircleAlertIcon, + DatabaseIcon, EyeIcon, + FileIcon, + FolderIcon, GlobeIcon, HammerIcon, type LucideIcon, + ListTodoIcon, + SearchIcon, SquarePenIcon, + TargetIcon, TerminalIcon, Undo2Icon, WrenchIcon, @@ -32,7 +38,12 @@ 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, + resolveWorkEntryIconKind, + type WorkEntryIconKind, +} from "./MessagesTimeline.logic"; import { cn } from "~/lib/utils"; import { type TimestampFormat } from "../../appSettings"; import { formatTimestamp } from "../../timestampFormat"; @@ -685,28 +696,46 @@ 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; + const iconKind = resolveWorkEntryIconKind(workEntry); + if (!iconKind) { + return workToneIcon(workEntry.tone).icon; } - 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; + return iconKindToLucideIcon(iconKind); +} - switch (workEntry.itemType) { - case "mcp_tool_call": - return WrenchIcon; - case "dynamic_tool_call": - case "collab_agent_tool_call": +function iconKindToLucideIcon(iconKind: WorkEntryIconKind): LucideIcon { + switch (iconKind) { + case "bot": + return BotIcon; + case "check": + return CheckIcon; + case "database": + return DatabaseIcon; + case "eye": + return EyeIcon; + case "file": + return FileIcon; + case "folder": + return FolderIcon; + case "globe": + return GlobeIcon; + case "hammer": return HammerIcon; + case "list-todo": + return ListTodoIcon; + case "search": + return SearchIcon; + case "square-pen": + return SquarePenIcon; + case "target": + return TargetIcon; + case "terminal": + return TerminalIcon; + case "wrench": + return WrenchIcon; + case "zap": + return ZapIcon; } - - return workToneIcon(workEntry.tone).icon; } function capitalizePhrase(value: string): string {