Skip to content

Commit 011db0a

Browse files
zortos293juliusmarminge
authored andcommitted
Add compact Codex tool-call icons and details to the chat timeline (pingdotgg#988)
Co-authored-by: Julius Marminge <julius0216@outlook.com>
1 parent 153bcf8 commit 011db0a

10 files changed

Lines changed: 321 additions & 96 deletions

File tree

apps/server/integration/fixtures/providerRuntime.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export const codexTurnToolFixture = [
7373
turnId: TURN_ID,
7474
payload: {
7575
itemType: "command_execution",
76-
title: "Command run",
76+
title: "Ran command",
7777
detail: "echo integration",
7878
},
7979
},
@@ -85,7 +85,7 @@ export const codexTurnToolFixture = [
8585
payload: {
8686
itemType: "command_execution",
8787
status: "completed",
88-
title: "Command run",
88+
title: "Ran command",
8989
detail: "echo integration",
9090
},
9191
},

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
MessageId,
66
type OrchestrationEvent,
77
CheckpointRef,
8+
isToolLifecycleItemType,
89
ThreadId,
910
TurnId,
1011
type OrchestrationThreadActivity,
@@ -173,18 +174,6 @@ function requestKindFromCanonicalRequestType(
173174
}
174175
}
175176

176-
function isToolLifecycleItemType(itemType: string): boolean {
177-
return (
178-
itemType === "command_execution" ||
179-
itemType === "file_change" ||
180-
itemType === "mcp_tool_call" ||
181-
itemType === "dynamic_tool_call" ||
182-
itemType === "collab_agent_tool_call" ||
183-
itemType === "web_search" ||
184-
itemType === "image_view"
185-
);
186-
}
187-
188177
function runtimeEventToActivities(
189178
event: ProviderRuntimeEvent,
190179
): ReadonlyArray<OrchestrationThreadActivity> {
@@ -449,7 +438,7 @@ function runtimeEventToActivities(
449438
createdAt: event.createdAt,
450439
tone: "tool",
451440
kind: "tool.completed",
452-
summary: `${event.payload.title ?? "Tool"} complete`,
441+
summary: event.payload.title ?? "Tool",
453442
payload: {
454443
itemType: event.payload.itemType,
455444
...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),

apps/server/src/provider/Layers/CodexAdapter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ function itemTitle(itemType: CanonicalItemType): string | undefined {
164164
case "plan":
165165
return "Plan";
166166
case "command_execution":
167-
return "Command run";
167+
return "Ran command";
168168
case "file_change":
169169
return "File change";
170170
case "mcp_tool_call":

apps/server/src/provider/Layers/ProviderService.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -703,7 +703,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
703703
threadId: session.threadId,
704704
turnId: asTurnId("turn-1"),
705705
toolKind: "command",
706-
title: "Command run",
706+
title: "Ran command",
707707
});
708708
fanout.codex.emit({
709709
type: "tool.completed",
@@ -713,7 +713,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
713713
threadId: session.threadId,
714714
turnId: asTurnId("turn-1"),
715715
toolKind: "command",
716-
title: "Command run",
716+
title: "Ran command",
717717
});
718718
fanout.codex.emit({
719719
type: "turn.completed",
@@ -768,7 +768,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
768768
threadId: session.threadId,
769769
turnId: asTurnId("turn-1"),
770770
toolKind: "command",
771-
title: "Command run",
771+
title: "Ran command",
772772
detail: "echo one",
773773
},
774774
{

apps/web/src/components/chat/MessagesTimeline.logic.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { computeMessageDurationStart } from "./MessagesTimeline.logic";
2+
import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic";
33

44
describe("computeMessageDurationStart", () => {
55
it("returns message createdAt when there is no preceding user message", () => {
@@ -133,3 +133,13 @@ describe("computeMessageDurationStart", () => {
133133
expect(computeMessageDurationStart([])).toEqual(new Map());
134134
});
135135
});
136+
137+
describe("normalizeCompactToolLabel", () => {
138+
it("removes trailing completion wording from command labels", () => {
139+
expect(normalizeCompactToolLabel("Ran command complete")).toBe("Ran command");
140+
});
141+
142+
it("removes trailing completion wording from other labels", () => {
143+
expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file");
144+
});
145+
});

apps/web/src/components/chat/MessagesTimeline.logic.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,7 @@ export function computeMessageDurationStart(
2323

2424
return result;
2525
}
26+
27+
export function normalizeCompactToolLabel(value: string): string {
28+
return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
29+
}

apps/web/src/components/chat/MessagesTimeline.tsx

Lines changed: 176 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,20 @@ import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll";
1010
import { type TurnDiffSummary } from "../../types";
1111
import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
1212
import ChatMarkdown from "../ChatMarkdown";
13-
import { Undo2Icon } from "lucide-react";
13+
import {
14+
BotIcon,
15+
CheckIcon,
16+
CircleAlertIcon,
17+
EyeIcon,
18+
GlobeIcon,
19+
HammerIcon,
20+
type LucideIcon,
21+
SquarePenIcon,
22+
TerminalIcon,
23+
Undo2Icon,
24+
WrenchIcon,
25+
ZapIcon,
26+
} from "lucide-react";
1427
import { Button } from "../ui/button";
1528
import { clamp } from "effect/Number";
1629
import { estimateTimelineMessageHeight } from "../timelineHeight";
@@ -19,7 +32,8 @@ import { ProposedPlanCard } from "./ProposedPlanCard";
1932
import { ChangedFilesTree } from "./ChangedFilesTree";
2033
import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
2134
import { MessageCopyButton } from "./MessageCopyButton";
22-
import { computeMessageDurationStart } from "./MessagesTimeline.logic";
35+
import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic";
36+
import { cn } from "~/lib/utils";
2337
import { type TimestampFormat } from "../../appSettings";
2438
import { formatTimestamp } from "../../timestampFormat";
2539

@@ -289,72 +303,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({
289303
: groupedEntries;
290304
const hiddenCount = groupedEntries.length - visibleEntries.length;
291305
const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
292-
const groupLabel = onlyToolEntries
293-
? groupedEntries.length === 1
294-
? "Tool call"
295-
: `Tool calls (${groupedEntries.length})`
296-
: groupedEntries.length === 1
297-
? "Work event"
298-
: `Work log (${groupedEntries.length})`;
306+
const showHeader = hasOverflow || !onlyToolEntries;
307+
const groupLabel = onlyToolEntries ? "Tool calls" : "Work log";
299308

300309
return (
301-
<div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2">
302-
<div className="mb-1.5 flex items-center justify-between gap-3">
303-
<p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
304-
{groupLabel}
305-
</p>
306-
{hasOverflow && (
307-
<button
308-
type="button"
309-
className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"
310-
onClick={() => onToggleWorkGroup(groupId)}
311-
>
312-
{isExpanded ? "Show less" : `Show ${hiddenCount} more`}
313-
</button>
314-
)}
315-
</div>
316-
<div className="space-y-1">
310+
<div className="rounded-xl border border-border/45 bg-card/25 px-2 py-1.5">
311+
{showHeader && (
312+
<div className="mb-1.5 flex items-center justify-between gap-2 px-0.5">
313+
<p className="text-[9px] uppercase tracking-[0.16em] text-muted-foreground/55">
314+
{groupLabel} ({groupedEntries.length})
315+
</p>
316+
{hasOverflow && (
317+
<button
318+
type="button"
319+
className="text-[9px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-foreground/75"
320+
onClick={() => onToggleWorkGroup(groupId)}
321+
>
322+
{isExpanded ? "Show less" : `Show ${hiddenCount} more`}
323+
</button>
324+
)}
325+
</div>
326+
)}
327+
<div className="space-y-0.5">
317328
{visibleEntries.map((workEntry) => (
318-
<div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5">
319-
<span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
320-
<div className="min-w-0 flex-1 py-[2px]">
321-
<p className={`text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}>
322-
{workEntry.label}
323-
</p>
324-
{workEntry.command && (
325-
<pre className="mt-1 overflow-x-auto rounded-md border border-border/70 bg-background/80 px-2 py-1 font-mono text-[11px] leading-relaxed text-foreground/80">
326-
{workEntry.command}
327-
</pre>
328-
)}
329-
{workEntry.changedFiles && workEntry.changedFiles.length > 0 && (
330-
<div className="mt-1 flex flex-wrap gap-1">
331-
{workEntry.changedFiles.slice(0, 6).map((filePath) => (
332-
<span
333-
key={`${workEntry.id}:${filePath}`}
334-
className="rounded-md border border-border/70 bg-background/65 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground/85"
335-
title={filePath}
336-
>
337-
{filePath}
338-
</span>
339-
))}
340-
{workEntry.changedFiles.length > 6 && (
341-
<span className="px-1 text-[10px] text-muted-foreground/65">
342-
+{workEntry.changedFiles.length - 6} more
343-
</span>
344-
)}
345-
</div>
346-
)}
347-
{workEntry.detail &&
348-
(!workEntry.command || workEntry.detail !== workEntry.command) && (
349-
<p
350-
className="mt-1 text-[11px] leading-relaxed text-muted-foreground/75"
351-
title={workEntry.detail}
352-
>
353-
{workEntry.detail}
354-
</p>
355-
)}
356-
</div>
357-
</div>
329+
<SimpleWorkEntryRow key={`work-row:${workEntry.id}`} workEntry={workEntry} />
358330
))}
359331
</div>
360332
</div>
@@ -664,9 +636,148 @@ function formatMessageMeta(
664636
return `${formatTimestamp(createdAt, timestampFormat)}${duration}`;
665637
}
666638

639+
function workToneIcon(tone: TimelineWorkEntry["tone"]): {
640+
icon: LucideIcon;
641+
className: string;
642+
} {
643+
if (tone === "error") {
644+
return {
645+
icon: CircleAlertIcon,
646+
className: "text-foreground/92",
647+
};
648+
}
649+
if (tone === "thinking") {
650+
return {
651+
icon: BotIcon,
652+
className: "text-foreground/92",
653+
};
654+
}
655+
if (tone === "info") {
656+
return {
657+
icon: CheckIcon,
658+
className: "text-foreground/92",
659+
};
660+
}
661+
return {
662+
icon: ZapIcon,
663+
className: "text-foreground/92",
664+
};
665+
}
666+
667667
function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
668668
if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
669669
if (tone === "tool") return "text-muted-foreground/70";
670670
if (tone === "thinking") return "text-muted-foreground/50";
671671
return "text-muted-foreground/40";
672672
}
673+
674+
function workEntryPreview(
675+
workEntry: Pick<TimelineWorkEntry, "detail" | "command" | "changedFiles">,
676+
) {
677+
if (workEntry.command) return workEntry.command;
678+
if (workEntry.detail) return workEntry.detail;
679+
if ((workEntry.changedFiles?.length ?? 0) === 0) return null;
680+
const [firstPath] = workEntry.changedFiles ?? [];
681+
if (!firstPath) return null;
682+
return workEntry.changedFiles!.length === 1
683+
? firstPath
684+
: `${firstPath} +${workEntry.changedFiles!.length - 1} more`;
685+
}
686+
687+
function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon {
688+
if (workEntry.requestKind === "command") return TerminalIcon;
689+
if (workEntry.requestKind === "file-read") return EyeIcon;
690+
if (workEntry.requestKind === "file-change") return SquarePenIcon;
691+
692+
if (workEntry.itemType === "command_execution" || workEntry.command) {
693+
return TerminalIcon;
694+
}
695+
if (workEntry.itemType === "file_change" || (workEntry.changedFiles?.length ?? 0) > 0) {
696+
return SquarePenIcon;
697+
}
698+
if (workEntry.itemType === "web_search") return GlobeIcon;
699+
if (workEntry.itemType === "image_view") return EyeIcon;
700+
701+
switch (workEntry.itemType) {
702+
case "mcp_tool_call":
703+
return WrenchIcon;
704+
case "dynamic_tool_call":
705+
case "collab_agent_tool_call":
706+
return HammerIcon;
707+
}
708+
709+
return workToneIcon(workEntry.tone).icon;
710+
}
711+
712+
function capitalizePhrase(value: string): string {
713+
const trimmed = value.trim();
714+
if (trimmed.length === 0) {
715+
return value;
716+
}
717+
return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
718+
}
719+
720+
function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string {
721+
if (!workEntry.toolTitle) {
722+
return capitalizePhrase(normalizeCompactToolLabel(workEntry.label));
723+
}
724+
return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle));
725+
}
726+
727+
const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
728+
workEntry: TimelineWorkEntry;
729+
}) {
730+
const { workEntry } = props;
731+
const iconConfig = workToneIcon(workEntry.tone);
732+
const EntryIcon = workEntryIcon(workEntry);
733+
const heading = toolWorkEntryHeading(workEntry);
734+
const preview = workEntryPreview(workEntry);
735+
const displayText = preview ? `${heading} - ${preview}` : heading;
736+
const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0;
737+
const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail;
738+
739+
return (
740+
<div className="rounded-lg px-1 py-1">
741+
<div className="flex items-center gap-2 transition-[opacity,translate] duration-200">
742+
<span
743+
className={cn("flex size-5 shrink-0 items-center justify-center", iconConfig.className)}
744+
>
745+
<EntryIcon className="size-3" />
746+
</span>
747+
<div className="min-w-0 flex-1 overflow-hidden">
748+
<p
749+
className={cn(
750+
"truncate text-[11px] leading-5",
751+
workToneClass(workEntry.tone),
752+
preview ? "text-muted-foreground/70" : "",
753+
)}
754+
title={displayText}
755+
>
756+
<span className={cn("text-foreground/80", workToneClass(workEntry.tone))}>
757+
{heading}
758+
</span>
759+
{preview && <span className="text-muted-foreground/55"> - {preview}</span>}
760+
</p>
761+
</div>
762+
</div>
763+
{hasChangedFiles && !previewIsChangedFiles && (
764+
<div className="mt-1 flex flex-wrap gap-1 pl-6">
765+
{workEntry.changedFiles?.slice(0, 4).map((filePath) => (
766+
<span
767+
key={`${workEntry.id}:${filePath}`}
768+
className="rounded-md border border-border/55 bg-background/75 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground/75"
769+
title={filePath}
770+
>
771+
{filePath}
772+
</span>
773+
))}
774+
{(workEntry.changedFiles?.length ?? 0) > 4 && (
775+
<span className="px-1 text-[10px] text-muted-foreground/55">
776+
+{(workEntry.changedFiles?.length ?? 0) - 4}
777+
</span>
778+
)}
779+
</div>
780+
)}
781+
</div>
782+
);
783+
});

0 commit comments

Comments
 (0)