Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 2 additions & 72 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -247,6 +239,7 @@ import { formatTimestamp } from "../timestampFormat";
import {
computeMessageDurationStart,
normalizeCompactToolLabel,
resolveWorkEntryIcon,
} from "./chat/MessagesTimeline.logic";
import {
deriveVisibleThreadWorkLogEntries,
Expand Down Expand Up @@ -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 {
Expand Down
57 changes: 56 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
100 changes: 100 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string>;
}

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;
Comment on lines +119 to +126
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Move dynamic/collab fallbacks ahead of generic haystack matches

On Line 119, the generic haystack.includes("task") check runs before Lines 125-126. That can still return HammerIcon for dynamic_tool_call / collab_agent_tool_call entries whose text contains “task”, undermining this PR’s icon-fallback fix.

Suggested reorder
   if (workEntry.itemType === "web_search") return GlobeIcon;
   if (workEntry.itemType === "image_view") return EyeIcon;
   if (workEntry.itemType === "mcp_tool_call") return WrenchIcon;
+  if (workEntry.itemType === "dynamic_tool_call") return WrenchIcon;
+  if (workEntry.itemType === "collab_agent_tool_call") return BotIcon;

   const haystack = [
     workEntry.label,
     workEntry.toolTitle,
@@
   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;

As per coding guidelines, "apps/web/**: Prioritize UI correctness, responsive layout behavior, and avoiding unnecessary rerenders or fragile client state transitions."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
if (workEntry.itemType === "web_search") return GlobeIcon;
if (workEntry.itemType === "image_view") return EyeIcon;
if (workEntry.itemType === "mcp_tool_call") return WrenchIcon;
if (workEntry.itemType === "dynamic_tool_call") return WrenchIcon;
if (workEntry.itemType === "collab_agent_tool_call") return BotIcon;
const haystack = [
workEntry.label,
workEntry.toolTitle,
workEntry.activityKind,
];
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;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/chat/MessagesTimeline.logic.ts` around lines 119 -
126, The generic haystack.includes("task") check is taking precedence and can
incorrectly return HammerIcon for entries that are actually dynamic_tool_call or
collab_agent_tool_call; update MessagesTimeline.logic by moving the
workEntry.itemType checks for "dynamic_tool_call" and "collab_agent_tool_call"
(WrenchIcon and BotIcon) so they run before the haystack.includes("task")
conditional, ensuring those specific itemType fallbacks are matched first; keep
the existing activityKind checks in place and preserve return values for
ListTodoIcon, HammerIcon, BotIcon, and CheckIcon.


return workEntry.tone === "info" ? CheckIcon : ZapIcon;
}
35 changes: 6 additions & 29 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
Loading