| author | |
| committer | |
| log | 64dc07a8e5a6d7e34de4e85a07126a9c04e462d3 |
| tree | e01144e0650567763005fff3886abcdc66ab2a08 |
| parent | ae4ae9973cd6cebc7d557e64e876534f15c916fd |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>10 files changed, 321 insertions(+), 96 deletions(-)
apps/server/integration/fixtures/providerRuntime.ts+2-2| ... | @@ -73,7 +73,7 @@ export const codexTurnToolFixture = [ | ... | @@ -73,7 +73,7 @@ export const codexTurnToolFixture = [ |
| 73 | turnId: TURN_ID, | 73 | turnId: TURN_ID, |
| 74 | payload: { | 74 | payload: { |
| 75 | itemType: "command_execution", | 75 | itemType: "command_execution", |
| 76 | title: "Command run", | 76 | title: "Ran command", |
| 77 | detail: "echo integration", | 77 | detail: "echo integration", |
| 78 | }, | 78 | }, |
| 79 | }, | 79 | }, |
| ... | @@ -85,7 +85,7 @@ export const codexTurnToolFixture = [ | ... | @@ -85,7 +85,7 @@ export const codexTurnToolFixture = [ |
| 85 | payload: { | 85 | payload: { |
| 86 | itemType: "command_execution", | 86 | itemType: "command_execution", |
| 87 | status: "completed", | 87 | status: "completed", |
| 88 | title: "Command run", | 88 | title: "Ran command", |
| 89 | detail: "echo integration", | 89 | detail: "echo integration", |
| 90 | }, | 90 | }, |
| 91 | }, | 91 | }, |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+2-13| ... | @@ -5,6 +5,7 @@ import { | ... | @@ -5,6 +5,7 @@ import { |
| 5 | MessageId, | 5 | MessageId, |
| 6 | type OrchestrationEvent, | 6 | type OrchestrationEvent, |
| 7 | CheckpointRef, | 7 | CheckpointRef, |
| 8 | isToolLifecycleItemType, | ||
| 8 | ThreadId, | 9 | ThreadId, |
| 9 | TurnId, | 10 | TurnId, |
| 10 | type OrchestrationThreadActivity, | 11 | type OrchestrationThreadActivity, |
| ... | @@ -173,18 +174,6 @@ function requestKindFromCanonicalRequestType( | ... | @@ -173,18 +174,6 @@ function requestKindFromCanonicalRequestType( |
| 173 | } | 174 | } |
| 174 | } | 175 | } |
| 175 | 176 | ||
| 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 | |||
| 188 | function runtimeEventToActivities( | 177 | function runtimeEventToActivities( |
| 189 | event: ProviderRuntimeEvent, | 178 | event: ProviderRuntimeEvent, |
| 190 | ): ReadonlyArray<OrchestrationThreadActivity> { | 179 | ): ReadonlyArray<OrchestrationThreadActivity> { |
| ... | @@ -449,7 +438,7 @@ function runtimeEventToActivities( | ... | @@ -449,7 +438,7 @@ function runtimeEventToActivities( |
| 449 | createdAt: event.createdAt, | 438 | createdAt: event.createdAt, |
| 450 | tone: "tool", | 439 | tone: "tool", |
| 451 | kind: "tool.completed", | 440 | kind: "tool.completed", |
| 452 | summary: `${event.payload.title ?? "Tool"} complete`, | 441 | summary: event.payload.title ?? "Tool", |
| 453 | payload: { | 442 | payload: { |
| 454 | itemType: event.payload.itemType, | 443 | itemType: event.payload.itemType, |
| 455 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), | 444 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), |
apps/server/src/provider/Layers/CodexAdapter.ts+1-1| ... | @@ -164,7 +164,7 @@ function itemTitle(itemType: CanonicalItemType): string | undefined { | ... | @@ -164,7 +164,7 @@ function itemTitle(itemType: CanonicalItemType): string | undefined { |
| 164 | case "plan": | 164 | case "plan": |
| 165 | return "Plan"; | 165 | return "Plan"; |
| 166 | case "command_execution": | 166 | case "command_execution": |
| 167 | return "Command run"; | 167 | return "Ran command"; |
| 168 | case "file_change": | 168 | case "file_change": |
| 169 | return "File change"; | 169 | return "File change"; |
| 170 | case "mcp_tool_call": | 170 | case "mcp_tool_call": |
apps/server/src/provider/Layers/ProviderService.test.ts+3-3| ... | @@ -703,7 +703,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -703,7 +703,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 703 | threadId: session.threadId, | 703 | threadId: session.threadId, |
| 704 | turnId: asTurnId("turn-1"), | 704 | turnId: asTurnId("turn-1"), |
| 705 | toolKind: "command", | 705 | toolKind: "command", |
| 706 | title: "Command run", | 706 | title: "Ran command", |
| 707 | }); | 707 | }); |
| 708 | fanout.codex.emit({ | 708 | fanout.codex.emit({ |
| 709 | type: "tool.completed", | 709 | type: "tool.completed", |
| ... | @@ -713,7 +713,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -713,7 +713,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 713 | threadId: session.threadId, | 713 | threadId: session.threadId, |
| 714 | turnId: asTurnId("turn-1"), | 714 | turnId: asTurnId("turn-1"), |
| 715 | toolKind: "command", | 715 | toolKind: "command", |
| 716 | title: "Command run", | 716 | title: "Ran command", |
| 717 | }); | 717 | }); |
| 718 | fanout.codex.emit({ | 718 | fanout.codex.emit({ |
| 719 | type: "turn.completed", | 719 | type: "turn.completed", |
| ... | @@ -768,7 +768,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -768,7 +768,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 768 | threadId: session.threadId, | 768 | threadId: session.threadId, |
| 769 | turnId: asTurnId("turn-1"), | 769 | turnId: asTurnId("turn-1"), |
| 770 | toolKind: "command", | 770 | toolKind: "command", |
| 771 | title: "Command run", | 771 | title: "Ran command", |
| 772 | detail: "echo one", | 772 | detail: "echo one", |
| 773 | }, | 773 | }, |
| 774 | { | 774 | { |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+11-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { computeMessageDurationStart } from "./MessagesTimeline.logic"; | 2 | import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; |
| 3 | 3 | ||
| 4 | describe("computeMessageDurationStart", () => { | 4 | describe("computeMessageDurationStart", () => { |
| 5 | it("returns message createdAt when there is no preceding user message", () => { | 5 | it("returns message createdAt when there is no preceding user message", () => { |
| ... | @@ -133,3 +133,13 @@ describe("computeMessageDurationStart", () => { | ... | @@ -133,3 +133,13 @@ describe("computeMessageDurationStart", () => { |
| 133 | expect(computeMessageDurationStart([])).toEqual(new Map()); | 133 | expect(computeMessageDurationStart([])).toEqual(new Map()); |
| 134 | }); | 134 | }); |
| 135 | }); | 135 | }); |
| 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+4| ... | @@ -23,3 +23,7 @@ export function computeMessageDurationStart( | ... | @@ -23,3 +23,7 @@ export function computeMessageDurationStart( |
| 23 | 23 | ||
| 24 | return result; | 24 | return result; |
| 25 | } | 25 | } |
| 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+176-65| ... | @@ -10,7 +10,20 @@ import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll"; | ... | @@ -10,7 +10,20 @@ import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll"; |
| 10 | import { type TurnDiffSummary } from "../../types"; | 10 | import { type TurnDiffSummary } from "../../types"; |
| 11 | import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; | 11 | import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; |
| 12 | import ChatMarkdown from "../ChatMarkdown"; | 12 | 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"; | ||
| 14 | import { Button } from "../ui/button"; | 27 | import { Button } from "../ui/button"; |
| 15 | import { clamp } from "effect/Number"; | 28 | import { clamp } from "effect/Number"; |
| 16 | import { estimateTimelineMessageHeight } from "../timelineHeight"; | 29 | import { estimateTimelineMessageHeight } from "../timelineHeight"; |
| ... | @@ -19,7 +32,8 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; | ... | @@ -19,7 +32,8 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 19 | import { ChangedFilesTree } from "./ChangedFilesTree"; | 32 | import { ChangedFilesTree } from "./ChangedFilesTree"; |
| 20 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; | 33 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 21 | import { MessageCopyButton } from "./MessageCopyButton"; | 34 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 22 | import { computeMessageDurationStart } from "./MessagesTimeline.logic"; | 35 | import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; |
| 36 | import { cn } from "~/lib/utils"; | ||
| 23 | import { type TimestampFormat } from "../../appSettings"; | 37 | import { type TimestampFormat } from "../../appSettings"; |
| 24 | import { formatTimestamp } from "../../timestampFormat"; | 38 | import { formatTimestamp } from "../../timestampFormat"; |
| 25 | 39 | ||
| ... | @@ -289,72 +303,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -289,72 +303,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 289 | : groupedEntries; | 303 | : groupedEntries; |
| 290 | const hiddenCount = groupedEntries.length - visibleEntries.length; | 304 | const hiddenCount = groupedEntries.length - visibleEntries.length; |
| 291 | const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); | 305 | const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); |
| 292 | const groupLabel = onlyToolEntries | 306 | const showHeader = hasOverflow || !onlyToolEntries; |
| 293 | ? groupedEntries.length === 1 | 307 | const groupLabel = onlyToolEntries ? "Tool calls" : "Work log"; |
| 294 | ? "Tool call" | ||
| 295 | : `Tool calls (${groupedEntries.length})` | ||
| 296 | : groupedEntries.length === 1 | ||
| 297 | ? "Work event" | ||
| 298 | : `Work log (${groupedEntries.length})`; | ||
| 299 | 308 | ||
| 300 | return ( | 309 | return ( |
| 301 | <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2"> | 310 | <div className="rounded-xl border border-border/45 bg-card/25 px-2 py-1.5"> |
| 302 | <div className="mb-1.5 flex items-center justify-between gap-3"> | 311 | {showHeader && ( |
| 303 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | 312 | <div className="mb-1.5 flex items-center justify-between gap-2 px-0.5"> |
| 304 | {groupLabel} | 313 | <p className="text-[9px] uppercase tracking-[0.16em] text-muted-foreground/55"> |
| 305 | </p> | 314 | {groupLabel} ({groupedEntries.length}) |
| 306 | {hasOverflow && ( | 315 | </p> |
| 307 | <button | 316 | {hasOverflow && ( |
| 308 | type="button" | 317 | <button |
| 309 | className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80" | 318 | type="button" |
| 310 | onClick={() => onToggleWorkGroup(groupId)} | 319 | className="text-[9px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-foreground/75" |
| 311 | > | 320 | onClick={() => onToggleWorkGroup(groupId)} |
| 312 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} | 321 | > |
| 313 | </button> | 322 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} |
| 314 | )} | 323 | </button> |
| 315 | </div> | 324 | )} |
| 316 | <div className="space-y-1"> | 325 | </div> |
| 326 | )} | ||
| 327 | <div className="space-y-0.5"> | ||
| 317 | {visibleEntries.map((workEntry) => ( | 328 | {visibleEntries.map((workEntry) => ( |
| 318 | <div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5"> | 329 | <SimpleWorkEntryRow key={`work-row:${workEntry.id}`} workEntry={workEntry} /> |
| 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> | ||
| 358 | ))} | 330 | ))} |
| 359 | </div> | 331 | </div> |
| 360 | </div> | 332 | </div> |
| ... | @@ -664,9 +636,148 @@ function formatMessageMeta( | ... | @@ -664,9 +636,148 @@ function formatMessageMeta( |
| 664 | return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`; | 636 | return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`; |
| 665 | } | 637 | } |
| 666 | 638 | ||
| 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 | |||
| 667 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | 667 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 668 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; | 668 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; |
| 669 | if (tone === "tool") return "text-muted-foreground/70"; | 669 | if (tone === "tool") return "text-muted-foreground/70"; |
| 670 | if (tone === "thinking") return "text-muted-foreground/50"; | 670 | if (tone === "thinking") return "text-muted-foreground/50"; |
| 671 | return "text-muted-foreground/40"; | 671 | return "text-muted-foreground/40"; |
| 672 | } | 672 | } |
| 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 | }); |
apps/web/src/session-logic.test.ts+36-3| ... | @@ -400,7 +400,7 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -400,7 +400,7 @@ describe("deriveWorkLogEntries", () => { |
| 400 | makeActivity({ | 400 | makeActivity({ |
| 401 | id: "tool-complete", | 401 | id: "tool-complete", |
| 402 | createdAt: "2026-02-23T00:00:02.000Z", | 402 | createdAt: "2026-02-23T00:00:02.000Z", |
| 403 | summary: "Command run complete", | 403 | summary: "Ran command", |
| 404 | tone: "tool", | 404 | tone: "tool", |
| 405 | kind: "tool.completed", | 405 | kind: "tool.completed", |
| 406 | }), | 406 | }), |
| ... | @@ -437,7 +437,7 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -437,7 +437,7 @@ describe("deriveWorkLogEntries", () => { |
| 437 | makeActivity({ | 437 | makeActivity({ |
| 438 | id: "command-tool", | 438 | id: "command-tool", |
| 439 | kind: "tool.completed", | 439 | kind: "tool.completed", |
| 440 | summary: "Command run complete", | 440 | summary: "Ran command", |
| 441 | payload: { | 441 | payload: { |
| 442 | itemType: "command_execution", | 442 | itemType: "command_execution", |
| 443 | data: { | 443 | data: { |
| ... | @@ -453,12 +453,45 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -453,12 +453,45 @@ describe("deriveWorkLogEntries", () => { |
| 453 | expect(entry?.command).toBe("bun run lint"); | 453 | expect(entry?.command).toBe("bun run lint"); |
| 454 | }); | 454 | }); |
| 455 | 455 | ||
| 456 | it("keeps compact Codex tool metadata used for icons and labels", () => { | ||
| 457 | const activities: OrchestrationThreadActivity[] = [ | ||
| 458 | makeActivity({ | ||
| 459 | id: "tool-with-metadata", | ||
| 460 | kind: "tool.completed", | ||
| 461 | summary: "bash", | ||
| 462 | payload: { | ||
| 463 | itemType: "command_execution", | ||
| 464 | title: "bash", | ||
| 465 | status: "completed", | ||
| 466 | detail: '{ "dev": "vite dev --port 3000" } <exited with exit code 0>', | ||
| 467 | data: { | ||
| 468 | item: { | ||
| 469 | command: ["bun", "run", "dev"], | ||
| 470 | result: { | ||
| 471 | content: '{ "dev": "vite dev --port 3000" } <exited with exit code 0>', | ||
| 472 | exitCode: 0, | ||
| 473 | }, | ||
| 474 | }, | ||
| 475 | }, | ||
| 476 | }, | ||
| 477 | }), | ||
| 478 | ]; | ||
| 479 | |||
| 480 | const [entry] = deriveWorkLogEntries(activities, undefined); | ||
| 481 | expect(entry).toMatchObject({ | ||
| 482 | command: "bun run dev", | ||
| 483 | detail: '{ "dev": "vite dev --port 3000" }', | ||
| 484 | itemType: "command_execution", | ||
| 485 | toolTitle: "bash", | ||
| 486 | }); | ||
| 487 | }); | ||
| 488 | |||
| 456 | it("extracts changed file paths for file-change tool activities", () => { | 489 | it("extracts changed file paths for file-change tool activities", () => { |
| 457 | const activities: OrchestrationThreadActivity[] = [ | 490 | const activities: OrchestrationThreadActivity[] = [ |
| 458 | makeActivity({ | 491 | makeActivity({ |
| 459 | id: "file-tool", | 492 | id: "file-tool", |
| 460 | kind: "tool.completed", | 493 | kind: "tool.completed", |
| 461 | summary: "File change complete", | 494 | summary: "File change", |
| 462 | payload: { | 495 | payload: { |
| 463 | itemType: "file_change", | 496 | itemType: "file_change", |
| 464 | data: { | 497 | data: { |
apps/web/src/session-logic.ts+70-3| ... | @@ -1,9 +1,11 @@ | ... | @@ -1,9 +1,11 @@ |
| 1 | import { | 1 | import { |
| 2 | ApprovalRequestId, | 2 | ApprovalRequestId, |
| 3 | isToolLifecycleItemType, | ||
| 3 | type OrchestrationLatestTurn, | 4 | type OrchestrationLatestTurn, |
| 4 | type OrchestrationThreadActivity, | 5 | type OrchestrationThreadActivity, |
| 5 | type OrchestrationProposedPlanId, | 6 | type OrchestrationProposedPlanId, |
| 6 | type ProviderKind, | 7 | type ProviderKind, |
| 8 | type ToolLifecycleItemType, | ||
| 7 | type UserInputQuestion, | 9 | type UserInputQuestion, |
| 8 | type TurnId, | 10 | type TurnId, |
| 9 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| ... | @@ -36,6 +38,9 @@ export interface WorkLogEntry { | ... | @@ -36,6 +38,9 @@ export interface WorkLogEntry { |
| 36 | command?: string; | 38 | command?: string; |
| 37 | changedFiles?: ReadonlyArray<string>; | 39 | changedFiles?: ReadonlyArray<string>; |
| 38 | tone: "thinking" | "tool" | "info" | "error"; | 40 | tone: "thinking" | "tool" | "info" | "error"; |
| 41 | toolTitle?: string; | ||
| 42 | itemType?: ToolLifecycleItemType; | ||
| 43 | requestKind?: PendingApproval["requestKind"]; | ||
| 39 | } | 44 | } |
| 40 | 45 | ||
| 41 | export interface PendingApproval { | 46 | export interface PendingApproval { |
| ... | @@ -415,14 +420,20 @@ export function deriveWorkLogEntries( | ... | @@ -415,14 +420,20 @@ export function deriveWorkLogEntries( |
| 415 | : null; | 420 | : null; |
| 416 | const command = extractToolCommand(payload); | 421 | const command = extractToolCommand(payload); |
| 417 | const changedFiles = extractChangedFiles(payload); | 422 | const changedFiles = extractChangedFiles(payload); |
| 423 | const title = extractToolTitle(payload); | ||
| 418 | const entry: WorkLogEntry = { | 424 | const entry: WorkLogEntry = { |
| 419 | id: activity.id, | 425 | id: activity.id, |
| 420 | createdAt: activity.createdAt, | 426 | createdAt: activity.createdAt, |
| 421 | label: activity.summary, | 427 | label: activity.summary, |
| 422 | tone: activity.tone === "approval" ? "info" : activity.tone, | 428 | tone: activity.tone === "approval" ? "info" : activity.tone, |
| 423 | }; | 429 | }; |
| 430 | const itemType = extractWorkLogItemType(payload); | ||
| 431 | const requestKind = extractWorkLogRequestKind(payload); | ||
| 424 | if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { | 432 | if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { |
| 425 | entry.detail = payload.detail; | 433 | const detail = stripTrailingExitCode(payload.detail).output; |
| 434 | if (detail) { | ||
| 435 | entry.detail = detail; | ||
| 436 | } | ||
| 426 | } | 437 | } |
| 427 | if (command) { | 438 | if (command) { |
| 428 | entry.command = command; | 439 | entry.command = command; |
| ... | @@ -430,6 +441,15 @@ export function deriveWorkLogEntries( | ... | @@ -430,6 +441,15 @@ export function deriveWorkLogEntries( |
| 430 | if (changedFiles.length > 0) { | 441 | if (changedFiles.length > 0) { |
| 431 | entry.changedFiles = changedFiles; | 442 | entry.changedFiles = changedFiles; |
| 432 | } | 443 | } |
| 444 | if (title) { | ||
| 445 | entry.toolTitle = title; | ||
| 446 | } | ||
| 447 | if (itemType) { | ||
| 448 | entry.itemType = itemType; | ||
| 449 | } | ||
| 450 | if (requestKind) { | ||
| 451 | entry.requestKind = requestKind; | ||
| 452 | } | ||
| 433 | return entry; | 453 | return entry; |
| 434 | }); | 454 | }); |
| 435 | } | 455 | } |
| ... | @@ -474,6 +494,53 @@ function extractToolCommand(payload: Record<string, unknown> | null): string | n | ... | @@ -474,6 +494,53 @@ function extractToolCommand(payload: Record<string, unknown> | null): string | n |
| 474 | return candidates.find((candidate) => candidate !== null) ?? null; | 494 | return candidates.find((candidate) => candidate !== null) ?? null; |
| 475 | } | 495 | } |
| 476 | 496 | ||
| 497 | function extractToolTitle(payload: Record<string, unknown> | null): string | null { | ||
| 498 | return asTrimmedString(payload?.title); | ||
| 499 | } | ||
| 500 | |||
| 501 | function stripTrailingExitCode(value: string): { | ||
| 502 | output: string | null; | ||
| 503 | exitCode?: number | undefined; | ||
| 504 | } { | ||
| 505 | const trimmed = value.trim(); | ||
| 506 | const match = /^(?<output>[\s\S]*?)(?:\s*<exited with exit code (?<code>\d+)>)\s*$/i.exec( | ||
| 507 | trimmed, | ||
| 508 | ); | ||
| 509 | if (!match?.groups) { | ||
| 510 | return { | ||
| 511 | output: trimmed.length > 0 ? trimmed : null, | ||
| 512 | }; | ||
| 513 | } | ||
| 514 | const exitCode = Number.parseInt(match.groups.code ?? "", 10); | ||
| 515 | const normalizedOutput = match.groups.output?.trim() ?? ""; | ||
| 516 | return { | ||
| 517 | output: normalizedOutput.length > 0 ? normalizedOutput : null, | ||
| 518 | ...(Number.isInteger(exitCode) ? { exitCode } : {}), | ||
| 519 | }; | ||
| 520 | } | ||
| 521 | |||
| 522 | function extractWorkLogItemType( | ||
| 523 | payload: Record<string, unknown> | null, | ||
| 524 | ): WorkLogEntry["itemType"] | undefined { | ||
| 525 | if (typeof payload?.itemType === "string" && isToolLifecycleItemType(payload.itemType)) { | ||
| 526 | return payload.itemType; | ||
| 527 | } | ||
| 528 | return undefined; | ||
| 529 | } | ||
| 530 | |||
| 531 | function extractWorkLogRequestKind( | ||
| 532 | payload: Record<string, unknown> | null, | ||
| 533 | ): WorkLogEntry["requestKind"] | undefined { | ||
| 534 | if ( | ||
| 535 | payload?.requestKind === "command" || | ||
| 536 | payload?.requestKind === "file-read" || | ||
| 537 | payload?.requestKind === "file-change" | ||
| 538 | ) { | ||
| 539 | return payload.requestKind; | ||
| 540 | } | ||
| 541 | return requestKindFromRequestType(payload?.requestType) ?? undefined; | ||
| 542 | } | ||
| 543 | |||
| 477 | function pushChangedFile(target: string[], seen: Set<string>, value: unknown) { | 544 | function pushChangedFile(target: string[], seen: Set<string>, value: unknown) { |
| 478 | const normalized = asTrimmedString(value); | 545 | const normalized = asTrimmedString(value); |
| 479 | if (!normalized || seen.has(normalized)) { | 546 | if (!normalized || seen.has(normalized)) { |
| ... | @@ -532,9 +599,9 @@ function collectChangedFiles(value: unknown, target: string[], seen: Set<string> | ... | @@ -532,9 +599,9 @@ function collectChangedFiles(value: unknown, target: string[], seen: Set<string> |
| 532 | } | 599 | } |
| 533 | 600 | ||
| 534 | function extractChangedFiles(payload: Record<string, unknown> | null): string[] { | 601 | function extractChangedFiles(payload: Record<string, unknown> | null): string[] { |
| 535 | const data = asRecord(payload?.data); | ||
| 536 | const changedFiles: string[] = []; | 602 | const changedFiles: string[] = []; |
| 537 | collectChangedFiles(data, changedFiles, new Set<string>(), 0); | 603 | const seen = new Set<string>(); |
| 604 | collectChangedFiles(asRecord(payload?.data), changedFiles, seen, 0); | ||
| 538 | return changedFiles; | 605 | return changedFiles; |
| 539 | } | 606 | } |
| 540 | 607 |
packages/contracts/src/providerRuntime.ts+16-5| ... | @@ -93,11 +93,7 @@ const RuntimeErrorClass = Schema.Literals([ | ... | @@ -93,11 +93,7 @@ const RuntimeErrorClass = Schema.Literals([ |
| 93 | ]); | 93 | ]); |
| 94 | export type RuntimeErrorClass = typeof RuntimeErrorClass.Type; | 94 | export type RuntimeErrorClass = typeof RuntimeErrorClass.Type; |
| 95 | 95 | ||
| 96 | export const CanonicalItemType = Schema.Literals([ | 96 | export const TOOL_LIFECYCLE_ITEM_TYPES = [ |
| 97 | "user_message", | ||
| 98 | "assistant_message", | ||
| 99 | "reasoning", | ||
| 100 | "plan", | ||
| 101 | "command_execution", | 97 | "command_execution", |
| 102 | "file_change", | 98 | "file_change", |
| 103 | "mcp_tool_call", | 99 | "mcp_tool_call", |
| ... | @@ -105,6 +101,21 @@ export const CanonicalItemType = Schema.Literals([ | ... | @@ -105,6 +101,21 @@ export const CanonicalItemType = Schema.Literals([ |
| 105 | "collab_agent_tool_call", | 101 | "collab_agent_tool_call", |
| 106 | "web_search", | 102 | "web_search", |
| 107 | "image_view", | 103 | "image_view", |
| 104 | ] as const; | ||
| 105 | |||
| 106 | export const ToolLifecycleItemType = Schema.Literals(TOOL_LIFECYCLE_ITEM_TYPES); | ||
| 107 | export type ToolLifecycleItemType = typeof ToolLifecycleItemType.Type; | ||
| 108 | |||
| 109 | export function isToolLifecycleItemType(value: string): value is ToolLifecycleItemType { | ||
| 110 | return TOOL_LIFECYCLE_ITEM_TYPES.includes(value as ToolLifecycleItemType); | ||
| 111 | } | ||
| 112 | |||
| 113 | export const CanonicalItemType = Schema.Literals([ | ||
| 114 | "user_message", | ||
| 115 | "assistant_message", | ||
| 116 | "reasoning", | ||
| 117 | "plan", | ||
| 118 | ...TOOL_LIFECYCLE_ITEM_TYPES, | ||
| 108 | "review_entered", | 119 | "review_entered", |
| 109 | "review_exited", | 120 | "review_exited", |
| 110 | "context_compaction", | 121 | "context_compaction", |