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
98 changes: 38 additions & 60 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,8 @@ import { formatTimestamp } from "../timestampFormat";
import {
computeMessageDurationStart,
normalizeCompactToolLabel,
resolveWorkEntryIconKind,
type WorkEntryIconKind,
} from "./chat/MessagesTimeline.logic";
import {
deriveVisibleThreadWorkLogEntries,
Expand Down Expand Up @@ -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 {
Expand Down
46 changes: 45 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,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", () => {
Expand Down Expand Up @@ -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");
});
});
108 changes: 108 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> | 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<TimelineDurationMessage>,
): Map<string, string> {
Expand All @@ -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;
}
67 changes: 48 additions & 19 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,17 @@ import {
BotIcon,
CheckIcon,
CircleAlertIcon,
DatabaseIcon,
EyeIcon,
FileIcon,
FolderIcon,
GlobeIcon,
HammerIcon,
type LucideIcon,
ListTodoIcon,
SearchIcon,
SquarePenIcon,
TargetIcon,
TerminalIcon,
Undo2Icon,
WrenchIcon,
Expand All @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
Loading