From 5d6e4192bfcf9eca09741605e12c72b5970025e4 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 12 Oct 2026 20:08:05 -0500 Subject: [PATCH] fix: show computer-use action titles and align app icons (#18865) --- apps/mobile/src/components/BrowserAppIcon.tsx | 33 +++++++ .../src/features/threads/thread-work-log.tsx | 96 ++++++++++++++++--- apps/mobile/src/lib/threadActivity.test.ts | 48 +++++++++- apps/mobile/src/lib/threadActivity.ts | 9 +- .../Adapters/CodexAdapterV2.test.ts | 92 +++++++++++++----- .../Adapters/CodexAdapterV2.ts | 16 +++- apps/web/src/components/Icons.tsx | 25 ----- .../chat/MessagesTimeline.logic.test.ts | 70 ++++++++++---- .../components/chat/MessagesTimeline.logic.ts | 6 +- .../src/components/chat/MessagesTimeline.tsx | 29 +++--- .../src/components/chat/V2ItemInspector.tsx | 38 ++++++-- apps/web/src/components/chat/WorkLog.tsx | 29 +++--- .../src/work-log/presentation.test.ts | 41 ++++++++ .../src/work-log/presentation.ts | 4 +- .../src/work-log/toolPresentation.test.ts | 43 +++++++++ .../src/work-log/toolPresentation.ts | 16 +++- packages/shared/src/toolActivity.test.ts | 39 ++++++++ packages/shared/src/toolActivity.ts | 37 ++++++- 18 files changed, 542 insertions(+), 129 deletions(-) create mode 100644 apps/mobile/src/components/BrowserAppIcon.tsx diff --git a/apps/mobile/src/components/BrowserAppIcon.tsx b/apps/mobile/src/components/BrowserAppIcon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8c09e8e77a525bf1496b31a27fb699aabfa88593 --- /dev/null +++ b/apps/mobile/src/components/BrowserAppIcon.tsx @@ -0,0 +1,33 @@ +import type { ColorValue } from "react-native"; +import Svg, { Circle, Path } from "react-native-svg"; +import { withUniwind } from "uniwind"; + +const ThemedSvg = withUniwind(Svg); + +/** Matches the window-and-pointer icon used by the web work log. */ +export function BrowserAppIcon(props: { + readonly size: number; + readonly color?: ColorValue; + readonly colorClassName?: string; +}) { + return ( + + + + + + + ); +} diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 1df6d6780e1926ca54d0b377b7cdec3d5be5ab31..a5e00b6336e939b472f67ed7c666e848b1c8bcd6 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -53,9 +53,12 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { environmentThreadDetails, threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; +import { isJavaScriptReplTool } from "@t3tools/shared/toolActivity"; +import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; import { AppText as Text } from "../../components/AppText"; import { T3Wordmark } from "../../components/T3Wordmark"; +import { BrowserAppIcon } from "../../components/BrowserAppIcon"; import { cn } from "../../lib/cn"; import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout"; import { @@ -111,7 +114,13 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180; const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140); const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120); -type WorkContentIcon = AppSymbolName | "browser" | "device" | "t3-code" | "pull-request"; +type WorkContentIcon = + | AppSymbolName + | "browser" + | "computer" + | "device" + | "t3-code" + | "pull-request"; function WorkLogIcon(props: { readonly icon: WorkContentIcon; @@ -125,6 +134,14 @@ function WorkLogIcon(props: { ); } + if (props.icon === "computer") { + return ( + + ); + } return ( { + const separator = lineIndex > 0 ? "\n" : ""; + offset += separator.length; + return [ + separator, + ...line.map((token) => { + const start = offset; + offset += token.content.length; + return ( + + {token.content} + + ); + }), + ]; + }); + return ( + + {highlighted ?? code} + + ); +} + const ThreadWorkLogRow = memo(function ThreadWorkLogRow( props: Omit< ThreadWorkLogProps, @@ -1177,19 +1228,36 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( ) : call ? ( [ call.command, - ...(call.args ?? []).map(([key, value]) => `${key} ${value}`), + ...(call.args ?? []).map(([key, value]) => + key === "code" && + shownItem.type === "dynamic_tool" && + isJavaScriptReplTool(shownItem.toolName) ? ( + + + {key} + + + + ) : ( + `${key} ${value}` + ), + ), call.argsText, ] - .filter((line): line is string => Boolean(line)) - .map((line, index) => ( - - {line} - - )) + .filter((line) => Boolean(line)) + .map((line, index) => + typeof line === "string" ? ( + + {line} + + ) : ( + line + ), + ) ) : fullDetail ? ( {fullDetail} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index fca9430b3df354f3802df74cf2876cdc6e3f937a..95cf9fdd6ed68b86a8ded82da8f89f873e7c196a 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1450,16 +1450,54 @@ describe("buildThreadFeed", () => { expect(activity?.getCopyText().split("\n")[0]).toBe("Read a T3 thread"); }); - it("uses the CUA action title in the mobile feed", () => { + it.each(["cua_repl.js", "node_repl.js", "js"])( + "uses the %s action title in the mobile feed", + (toolName) => { + const item: OrchestrationV2TurnItem = { + ...base("cua", "2026-09-23T20:20:00.000Z", 1), + type: "dynamic_tool", + toolName, + input: { + code: "await game.getAXStateAndScreenshot();", + title: "Inspect Saga music screen", + }, + }; + const feed = buildThreadFeed([projected(item, 0)]); + const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null; + expect(activity?.summary).toBe("Inspect Saga music screen"); + }, + ); + + it.each([ + [ + "failed", + "await sky.type_text({app:'T3 Code (Nightly)',text:'debug(ga.run)'});", + "Restrict the breakpoint to Git actions", + "computer", + ], + [ + "completed", + "await fetch('http://127.0.0.1/json/list');", + "Discover the live Electron debugging endpoint", + "code", + ], + ] as const)("recovers the retained js title and %s call icon", (status, code, title, icon) => { const item: OrchestrationV2TurnItem = { - ...base("cua", "2026-09-23T20:20:00.000Z", 1), + ...base("retained-js", "2026-09-23T20:20:00.000Z", 1), type: "dynamic_tool", - toolName: "cua_repl.js", - input: { code: "await game.getAXStateAndScreenshot();", title: "Inspect Saga music screen" }, + status, + title: "js", + toolName: "node_repl.js", + toolSource: { key: "mcp:node_repl", name: "node repl", kind: "integration" }, + input: { code, title }, }; const feed = buildThreadFeed([projected(item, 0)]); const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null; - expect(activity?.summary).toBe("Inspect Saga music screen"); + expect(activity?.summary).toBe(title); + expect(activity?.icon).toBe(icon); + expect(activity?.workEntry.toolSource?.kind).toBe( + icon === "computer" ? "computer" : "integration", + ); }); it("uses canonical T3 orchestration summaries in compact work groups", () => { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index edaaf2b0fab83fbe11b35e2f02b385074ae0746e..25d102d16d096ff0fe068bdeebe81d8180eb407d 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -57,6 +57,7 @@ import { classifyToolActivity, collectToolFilePaths, dynamicToolTitle, + isJavaScriptReplTool, formatReadToolLabel, formatSearchToolLabel, } from "@t3tools/shared/toolActivity"; @@ -97,6 +98,7 @@ export interface ThreadFeedActivity { | "alert" | "browser" | "computer" + | "code" | "check" | "command" | "edit" @@ -556,7 +558,7 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] { case "assistant_message": return "message"; case "dynamic_tool": - return "wrench"; + return isJavaScriptReplTool(item.toolName) ? "code" : "wrench"; case "subagent": return "hammer"; case "run_interrupt_request": @@ -598,8 +600,9 @@ function itemSummary( if (item.type === "system_notice") return item.message; if (item.type === "compaction") return contextCompactionLabel(item); const title = - (item.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input) : undefined) ?? - item.title?.trim(); + (item.type === "dynamic_tool" + ? dynamicToolTitle(item.toolName, item.input, item.title) + : undefined) ?? item.title?.trim(); if (item.type === "subagent") return formatSubagentDisplayTitle(title || "Subagent"); if (title) return toolPresentation?.displayName ?? capitalizePhrase(title); switch (item.type) { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 9a1c8f6c900f047bfdb153874504fb8c821ec516..14bb6a03d8111163e2e963261842c61bdd50df72 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -827,36 +827,82 @@ describe("CodexAdapterV2 process spawning", () => { }); describe("CodexAdapterV2 dynamic tool projection", () => { - it.effect("uses the CUA call title while leaving other MCP titles as tool arguments", () => + it.effect("keeps a failed Sky MCP call in Computer Use when its error has no metadata", () => Effect.gen(function* () { - const call = { - type: "mcpToolCall" as const, - id: "inspect", - server: "cua_repl", + const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({ + type: "mcpToolCall", + id: "failed-sky-js", + server: "node_repl", tool: "js", - status: "completed" as const, + status: "failed", arguments: { - code: "await game.getAXStateAndScreenshot();", - title: "Inspect Saga music screen", + title: "Restrict the breakpoint to Git actions", + code: "await sky.type_text({app:'T3 Code (Nightly)',text:'debug(ga.run)'});", }, - result: { content: [] }, - }; - assert.equal( - (yield* CodexAdapterV2.projectCodexDynamicToolItem(call)).title, - "Inspect Saga music screen", - ); - assert.equal( - (yield* CodexAdapterV2.projectCodexDynamicToolItem({ ...call, arguments: { title: " " } })) - .title, - "js", - ); - assert.equal( - (yield* CodexAdapterV2.projectCodexDynamicToolItem({ ...call, server: "github" })).title, - "js", - ); + result: { content: [{ type: "text", text: "Illegal Unicode escape sequence" }] }, + }); + assert.equal(projection.title, "Restrict the breakpoint to Git actions"); + assert.equal(projection.toolSurface, "computer"); + assert.deepEqual(projection.toolSource, { + key: "computer-use", + name: "Computer Use", + kind: "computer", + }); }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect.each(["inProgress", "completed", "failed"] as const)( + "preserves a bare js action's title and computer surface when %s without result metadata", + (status) => + Effect.gen(function* () { + const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({ + type: "dynamicToolCall", + id: "bare-js", + tool: "js", + status, + arguments: { code: "await sky.get_app_state(app);", title: "Read the desktop window" }, + contentItems: [], + success: status !== "failed", + }); + assert.equal(projection.title, "Read the desktop window"); + assert.equal(projection.toolSurface, "computer"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect.each(["cua_repl", "node_repl"])( + "uses the %s call title while leaving other MCP titles as tool arguments", + (server) => + Effect.gen(function* () { + const call = { + type: "mcpToolCall" as const, + id: "inspect", + server, + tool: "js", + status: "completed" as const, + arguments: { + code: "await game.getAXStateAndScreenshot();", + title: "Inspect Saga music screen", + }, + result: { content: [] }, + }; + assert.equal( + (yield* CodexAdapterV2.projectCodexDynamicToolItem(call)).title, + "Inspect Saga music screen", + ); + assert.equal( + (yield* CodexAdapterV2.projectCodexDynamicToolItem({ + ...call, + arguments: { title: " " }, + })).title, + server === "cua_repl" ? "Computer use" : "Run JavaScript", + ); + assert.equal( + (yield* CodexAdapterV2.projectCodexDynamicToolItem({ ...call, server: "github" })).title, + "js", + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect.each(["inProgress", "completed", "failed"] as const)( "presents ordinary MCP calls when %s", (status) => diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index d8ef1461ef73c440c82fad0e81e8703cd98d6542..5a51136396ad8c4dc5fa333551a5a730733ac25a 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -35,7 +35,11 @@ import { import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { dynamicToolTitle } from "@t3tools/shared/toolActivity"; +import { + COMPUTER_USE_TOOL_SOURCE, + dynamicToolSurface, + dynamicToolTitle, +} from "@t3tools/shared/toolActivity"; import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import type { @@ -497,9 +501,17 @@ export const projectCodexDynamicToolItem = Effect.fn("CodexAdapterV2.projectDyna : [trimText(item.namespace), item.tool].filter(Boolean).join("."); const presentation: McpToolPresentation = item.type === "mcpToolCall" ? yield* mcpToolPresentation(item) : {}; - const title = dynamicToolTitle(toolName, item.arguments) ?? presentation.title; + const title = + dynamicToolTitle(toolName, item.arguments, presentation.title) ?? presentation.title; + const toolSurface = presentation.toolSurface ?? dynamicToolSurface(toolName, item.arguments); + const toolSource = + toolSurface === "computer" && presentation.toolSource?.kind !== "computer" + ? COMPUTER_USE_TOOL_SOURCE + : presentation.toolSource; const projection: CodexDynamicToolProjection = { ...presentation, + ...(toolSurface ? { toolSurface } : {}), + ...(toolSource ? { toolSource } : {}), toolName, ...(title ? { title } : {}), input: item.arguments, diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 909b9c7456f3d16c373256513278fd0a558614dc..4e64cd251ea0be2df9694606883adf2bb1264c1a 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -760,28 +760,3 @@ export const MacAccessibilityIcon: Icon = (props) => { ); }; - -// Codex's Computer Use app mark, shown on computer-use rows in the work log. -export const ComputerUseAppIcon: Icon = (props) => { - const gradientId = `${useId().replaceAll(":", "")}-computer-use-app-gradient`; - return ( - - ); -}; diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 45613d72aac59b3e4e019b5317e2c720b461da57..68930da0252e6d3a2b9d4f439d8c1345c7917811 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -746,34 +746,70 @@ describe("deriveMessagesTimelineRows", () => { expect(first.kind === "work" && first.entry.toolLifecycleStatus).toBe("inProgress"); }); - it("shows the CUA action title for a retained tool item", () => { + it.each(["cua_repl.js", "node_repl.js", "js"])( + "shows the %s action title for a retained tool item", + (toolName) => { + const fixture = makeStreamingTimelineFixture(); + const source = fixture.visibleTurnItems.find((row) => row.item.type === "dynamic_tool")!; + if (source.item.type !== "dynamic_tool") throw new Error("Expected tool fixture"); + const item = { + ...source.item, + title: null, + toolName, + input: { + code: "await game.getAXStateAndScreenshot();", + title: "Inspect Saga music screen", + }, + }; + const entries = deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [{ ...source, item }], + optimisticMessages: [], + }); + const work = entries.find((entry) => entry.kind === "work"); + expect(work?.kind === "work" && workEntryDisplayLabel(work.entry, undefined)).toBe( + "Inspect Saga music screen", + ); + const rows = deriveMessagesTimelineRows({ + timelineEntries: entries, + isWorking: false, + runningRunId: fixture.runId, + activeTurnStartedAt: fixture.time(0), + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.find((row) => row.kind === "work")).toMatchObject({ + displayLabel: "Inspect Saga music screen", + }); + }, + ); + + it("recovers the failed Sky call stored as a generic js integration", () => { const fixture = makeStreamingTimelineFixture(); const source = fixture.visibleTurnItems.find((row) => row.item.type === "dynamic_tool")!; if (source.item.type !== "dynamic_tool") throw new Error("Expected tool fixture"); const item = { ...source.item, - title: null, - toolName: "cua_repl.js", - input: { code: "await game.getAXStateAndScreenshot();", title: "Inspect Saga music screen" }, + type: "dynamic_tool" as const, + status: "failed" as const, + title: "js", + toolName: "node_repl.js", + toolSource: { key: "mcp:node_repl", name: "node repl", kind: "integration" as const }, + input: { + title: "Restrict the breakpoint to Git actions", + code: "await sky.type_text({app:'T3 Code (Nightly)',text:'debug(ga.run)'});", + }, }; const entries = deriveTimelineEntriesFromVisibleTurnItems({ visibleTurnItems: [{ ...source, item }], optimisticMessages: [], }); - const work = entries.find((entry) => entry.kind === "work"); - expect(work?.kind === "work" && workEntryDisplayLabel(work.entry, undefined)).toBe( - "Inspect Saga music screen", + const entry = entries.find((row) => row.kind === "work"); + expect(entry?.kind === "work" && workEntryDisplayLabel(entry.entry, undefined)).toBe( + "Restrict the breakpoint to Git actions", ); - const rows = deriveMessagesTimelineRows({ - timelineEntries: entries, - isWorking: false, - runningRunId: fixture.runId, - activeTurnStartedAt: fixture.time(0), - turnDiffSummaries: [], - supportsConversationRollback: false, - }); - expect(rows.find((row) => row.kind === "work")).toMatchObject({ - displayLabel: "Inspect Saga music screen", + expect(entry?.kind === "work" && entry.entry).toMatchObject({ + toolSurface: "computer", + toolSource: { key: "computer-use", kind: "computer" }, }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 684b0e641f83a8d01d3fdc05ea966154c4e1e1c9..6eeda65512c1673c5eb55725f184a616900ea2dc 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -97,7 +97,8 @@ function singleToolCallLabel(entry: WorkLogEntry): string { const toolPresentation = resolveWorkEntryToolPresentation(entry, "completed"); if (toolPresentation) return toolPresentation.displayName; const item = entry.structuredPayload; - const title = item?.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input) : null; + const title = + item?.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input, item.title) : null; if (title) return title; // A lone web search keeps its heading; the query stays in its detail. if (entry.itemType === "web_search") return entry.toolTitle ?? "Web search"; @@ -157,7 +158,8 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string const providerRetry = entry.projectedItem?.item.type === "error" && entry.projectedItem.item.retry !== undefined; const item = entry.structuredPayload; - const title = item?.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input) : null; + const title = + item?.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input, item.title) : null; if (title) return title; const compactDetail = entry.detail?.trim(); const detailIsSearchOutput = diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index bac737b66ba01438424c8c8f9693b6cd93c58ffb..d0b259d8f022421c6007aa1b99942544e3929655 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,7 +1,6 @@ import { ThreadFindTimelineContext } from "./ThreadFindProvider"; import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline"; import { MarkdownFindContext, useFindRevealRef } from "./markdownFindContext"; -import { ComputerUseAppIcon } from "~/components/Icons"; import { useChatCanvas } from "./ChatCanvasContext"; import { WorkLogBlock, WorkLogButton, WorkLogDetails, WorkLogList, WorkLogRow } from "./WorkLog"; import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; @@ -67,7 +66,7 @@ import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon"; -import { claudeSkillInvocation } from "@t3tools/shared/toolActivity"; +import { claudeSkillInvocation, isJavaScriptReplTool } from "@t3tools/shared/toolActivity"; import { observeVisibleAnimation } from "../../lib/visibleAnimation"; import { createContext, @@ -148,6 +147,7 @@ import { Undo2Icon, HammerIcon, WrenchIcon, + Code2Icon, XIcon, ZapIcon, RotateCcwIcon, @@ -3779,13 +3779,9 @@ function ActivityShimmerOverlay({ children }: { children: ReactNode }) { const failedToolIconClassName = "text-tool-error-icon/40"; -/** Image icons and the gradient computer-use mark cannot take a currentColor - * tint, so failed rows using them get a trailing x instead. */ -function toolIconAcceptsTint( - iconName: WorkEntryIconName, - toolIcon: ToolActivityIcon | undefined, -): boolean { - return toolIcon === undefined && iconName !== "computer"; +/** Image icons cannot take a currentColor tint, so failed rows get a trailing x. */ +function toolIconAcceptsTint(toolIcon: ToolActivityIcon | undefined): boolean { + return toolIcon === undefined; } function WorkingTimelineRow({ row }: { row: Extract }) { @@ -3951,7 +3947,7 @@ function LiveActivityContent({ highlighted?: boolean; }) { const showTrailingFailureMark = - failed && iconName !== undefined && !toolIconAcceptsTint(iconName, toolIcon); + failed && iconName !== undefined && !toolIconAcceptsTint(toolIcon); return ( ; case "browser": - return ; case "computer": - return ; + return ; + case "code": + return ; case "device": return ; case "t3-code": @@ -5382,6 +5380,11 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { return "message-circle"; } if (workEntry.toolSurface) return workEntry.toolSurface; + if ( + workEntry.structuredPayload?.type === "dynamic_tool" && + isJavaScriptReplTool(workEntry.structuredPayload.toolName) + ) + return "code"; const toolPresentation = resolveWorkEntryToolPresentation(workEntry); if (toolPresentation) return toolPresentation.icon; const action = toolGroupAction(workEntry); @@ -5794,7 +5797,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { ) : null} {showFailedIndicator && !showDestructiveRowStyle && - !toolIconAcceptsTint(entryIconName, entryToolIcon) ? ( + !toolIconAcceptsTint(entryToolIcon) ? ( ) : null} diff --git a/apps/web/src/components/chat/V2ItemInspector.tsx b/apps/web/src/components/chat/V2ItemInspector.tsx index 93befc5c75a53a86438eff0262f9e7c82f1f5cd8..2bc30383254826803fe88a31f9cdb2d935983675 100644 --- a/apps/web/src/components/chat/V2ItemInspector.tsx +++ b/apps/web/src/components/chat/V2ItemInspector.tsx @@ -12,6 +12,7 @@ import { turnItemOutputText, } from "@t3tools/client-runtime/work-log/item-detail"; import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; +import { isJavaScriptReplTool } from "@t3tools/shared/toolActivity"; import { ExternalLinkIcon, GitBranchIcon, RotateCcwIcon } from "lucide-react"; import { memo, Suspense, use, useMemo } from "react"; @@ -43,13 +44,22 @@ interface V2ItemInspectorProps { readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; } -function JsonTokens({ text }: { readonly text: string }) { +function SyntaxTokens({ + text, + language, +}: { + readonly text: string; + readonly language: "json" | "typescript"; +}) { const { resolvedTheme } = useTheme(); - const highlighter = use(getSyntaxHighlighterPromise("json")); + const highlighter = use(getSyntaxHighlighterPromise(language)); const { tokens } = useMemo( () => - highlighter.codeToTokens(text, { lang: "json", theme: resolveDiffThemeName(resolvedTheme) }), - [highlighter, text, resolvedTheme], + highlighter.codeToTokens(text, { + lang: language, + theme: resolveDiffThemeName(resolvedTheme), + }), + [highlighter, text, language, resolvedTheme], ); return tokens.flatMap((line, lineIndex) => [ lineIndex > 0 ? "\n" : "", @@ -67,9 +77,11 @@ const monoClassName = function StructuredValue({ value, highlightJson = false, + language, }: { readonly value: unknown; readonly highlightJson?: boolean; + readonly language?: "typescript"; }) { const text = typeof value === "string" ? value : JSON.stringify(value, null, 2); const isJson = useMemo(() => { @@ -84,10 +96,10 @@ function StructuredValue({ if (!text) return null; return (
-      {isJson ? (
+      {language || isJson ? (
         
           
-            
+            
           
         
       ) : (
@@ -194,6 +206,7 @@ function ToolCallBody(
   props: ToolOutputState & {
     readonly command?: string;
     readonly args?: unknown;
+    readonly toolName?: string | null;
     readonly exitCode?: number | undefined;
   },
 ) {
@@ -206,7 +219,11 @@ function ToolCallBody(
           {call.args.map(([key, value]) => (
             
{key} - {value} + {key === "code" && isJavaScriptReplTool(props.toolName) ? ( + + ) : ( + value + )}
))} @@ -349,7 +366,12 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec ) : null} {item.type === "dynamic_tool" ? ( - + ) : null} {item.type === "approval_request" ? : null} diff --git a/apps/web/src/components/chat/WorkLog.tsx b/apps/web/src/components/chat/WorkLog.tsx index 1c588937813ad916a000dfc41c765826f72aaef4..378387c4261ecea2b11f818ed0f25eaa73956f06 100644 --- a/apps/web/src/components/chat/WorkLog.tsx +++ b/apps/web/src/components/chat/WorkLog.tsx @@ -49,20 +49,27 @@ function WorkLogLine({ icon, label, trailing, wrapLabel }: RowContent) {
- {icon ? ( - {icon} - ) : null} -
- {label} +
+ {icon ? ( + // Align with the first line's capital letters. Centering line boxes drifts + // when font ascent/descent rounding changes at fractional browser zoom. + + + {icon} + + + ) : null} +
+ {label} +
{trailing}
diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 241215310aad7f5f2a8b8deed9c297b6700bf53d..4b0078fdb73760d822df6cf20312e7b1534fb771 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -253,6 +253,28 @@ describe("summarizeToolGroup", () => { ).toBe("Used Chrome integration and ran 1 command"); }); + it.each([ + [["Foo", "Foo"], "Used Foo integration and ran 1 command"], + [["Foo", "Bar", "Foo"], "Used Foo and Bar integrations and ran 1 command"], + ] as const)( + "uses the displayed source names for integration plurality: %j", + (names, expected) => { + expect( + summarizeToolGroup([ + ...names.map((name, index) => + entry(`integration-${index}`, { + toolSource: { key: `integration-${index}`, name, kind: "integration" }, + }), + ), + entry("command", { + itemType: "command_execution", + command: "git status", + }), + ]).summary, + ).toBe(expected); + }, + ); + it("omits the integration suffix for special browser and computer sources", () => { expect( summarizeToolGroup([ @@ -267,6 +289,25 @@ describe("summarizeToolGroup", () => { ]).summary, ).toBe("Used Browser and Computer Use"); }); + + it("names Computer Use once when native-app and fallback sources share its label", () => { + expect( + summarizeToolGroup([ + entry("inspect-app", { + label: "Inspect app", + toolSource: { + key: "native-app:com.t3tools.t3code", + name: "Computer Use", + kind: "computer", + }, + }), + entry("failed-sky", { + label: "Restrict the breakpoint to Git actions", + toolSource: { key: "computer-use", name: "Computer Use", kind: "computer" }, + }), + ]).summary, + ).toBe("Used Computer Use"); + }); }); describe("resolveWorkEntryToolPresentation", () => { diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 90c33b789a64fd57910815ab2a9e2ff572031f53..cb5ac00b0e9645d29c0741b31e7773d12b4d4eb5 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -697,7 +697,7 @@ export function summarizeToolGroup(entries: ReadonlyArray label); if (sources.size > 0) { const sourceValues = [...sources.values()]; - const sourceNames = sourceValues.map((source) => source.name); + const sourceNames = [...new Set(sourceValues.map((source) => source.name))]; const formattedNames = sourceNames.length < 2 ? sourceNames[0]! @@ -706,7 +706,7 @@ export function summarizeToolGroup(entries: ReadonlyArray source.kind === "integration"); labels.unshift( - `Used ${formattedNames}${allIntegrations ? ` ${sources.size === 1 ? "integration" : "integrations"}` : ""}`, + `Used ${formattedNames}${allIntegrations ? ` ${sourceNames.length === 1 ? "integration" : "integrations"}` : ""}`, ); } const sourcedCount = entries.filter( diff --git a/packages/client-runtime/src/work-log/toolPresentation.test.ts b/packages/client-runtime/src/work-log/toolPresentation.test.ts index a794678a372148158c9337d8827fede1b9ce70fb..37733eca4c88b60efa3f46607b75405e88212708 100644 --- a/packages/client-runtime/src/work-log/toolPresentation.test.ts +++ b/packages/client-runtime/src/work-log/toolPresentation.test.ts @@ -3,6 +3,49 @@ import { describe, expect, it } from "@effect/vitest"; import { extractToolActivityPresentation } from "./toolPresentation.ts"; describe("extractToolActivityPresentation", () => { + it("recovers computer-use presentation for retained calls without result metadata", () => { + const item = { + type: "dynamic_tool", + toolName: "js", + input: { code: "await sky.get_app_state(app);" }, + }; + expect(extractToolActivityPresentation(item)).toEqual({ + toolSurface: "computer", + toolSource: { key: "computer-use", name: "Computer Use", kind: "computer" }, + }); + expect(extractToolActivityPresentation({ ...item, toolSurface: "browser" })).toEqual({ + toolSurface: "browser", + }); + expect( + extractToolActivityPresentation({ ...item, input: { code: "await fs.readFile(path);" } }), + ).toEqual({}); + }); + + it("replaces a failed Sky call's generic node REPL source", () => { + const source = { key: "mcp:node_repl", name: "node repl", kind: "integration" }; + const item = { + type: "dynamic_tool", + toolName: "node_repl.js", + title: "js", + status: "failed", + toolSource: source, + input: { + title: "Restrict the breakpoint to Git actions", + code: "await sky.type_text({app:'T3 Code (Nightly)',text:'debug(ga.run)'});", + }, + }; + expect(extractToolActivityPresentation(item)).toEqual({ + toolSurface: "computer", + toolSource: { key: "computer-use", name: "Computer Use", kind: "computer" }, + }); + expect( + extractToolActivityPresentation({ + ...item, + input: { code: "await fetch('http://127.0.0.1/json/list');" }, + }), + ).toEqual({ toolSource: source }); + }); + it("retains valid image paths independently of redacted output", () => { expect( extractToolActivityPresentation({ viewedImagePath: " /workspace/reference.webp " }), diff --git a/packages/client-runtime/src/work-log/toolPresentation.ts b/packages/client-runtime/src/work-log/toolPresentation.ts index 95ad72cfcca1c76694250a79530ec17b46f22a04..f69ddbd3c65162cd14fce623b2eb893009b5ab53 100644 --- a/packages/client-runtime/src/work-log/toolPresentation.ts +++ b/packages/client-runtime/src/work-log/toolPresentation.ts @@ -1,4 +1,5 @@ import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; +import { COMPUTER_USE_TOOL_SOURCE, dynamicToolSurface } from "@t3tools/shared/toolActivity"; import type { ToolActivityIcon, ToolActivityNativeAppReference, @@ -111,12 +112,23 @@ export function extractToolActivityPresentation( payloadValue: unknown, ): ExtractedToolActivityPresentation { const payload = asRecord(payloadValue); + const retainedSource = activitySource(payload?.toolSource); const toolSurface = payload?.toolSurface === "browser" || payload?.toolSurface === "computer" ? payload.toolSurface - : undefined; + : retainedSource?.kind === "computer" || retainedSource?.kind === "browser" + ? retainedSource.kind + : payload?.type === "dynamic_tool" + ? dynamicToolSurface( + typeof payload.toolName === "string" ? payload.toolName : undefined, + payload.input, + ) + : undefined; + const toolSource = + toolSurface === "computer" && retainedSource?.kind !== "computer" + ? COMPUTER_USE_TOOL_SOURCE + : retainedSource; const toolIcon = activityIcon(payload?.toolIcon); - const toolSource = activitySource(payload?.toolSource); const viewedImagePath = trimmedString(payload?.viewedImagePath, 4096); return { ...(toolSurface ? { toolSurface } : {}), diff --git a/packages/shared/src/toolActivity.test.ts b/packages/shared/src/toolActivity.test.ts index fe19052fa5bc005aecaaf07213fb45cfce76293a..e18eeda8ca80b9b5ee1b20f2e53c282ca0329c90 100644 --- a/packages/shared/src/toolActivity.test.ts +++ b/packages/shared/src/toolActivity.test.ts @@ -6,6 +6,7 @@ import { collectToolFilePaths, deriveToolActivityPresentation, dynamicToolTitle, + dynamicToolSurface, formatReadToolLabel, formatSearchToolLabel, mergeToolActivityData, @@ -123,6 +124,44 @@ describe("toolActivity", () => { ).toEqual({ rawInput: { path: "src/a.ts", startLine: 4 } }); }); + it.each(["cua_repl.js", "node_repl.js", "js"])("uses the action title for %s", (toolName) => { + expect(dynamicToolTitle(toolName, { title: " Read the current T3 desktop window " })).toBe( + "Read the current T3 desktop window", + ); + const fallback = toolName === "cua_repl.js" ? "Computer use" : "Run JavaScript"; + expect(dynamicToolTitle(toolName, { title: " " })).toBe(fallback); + expect(dynamicToolTitle(toolName, { title: 123 })).toBe(fallback); + expect(dynamicToolTitle(toolName, {})).toBe(fallback); + expect(dynamicToolTitle(toolName, { code: "await sky.get_app_state(app);" })).toBe( + "Computer use", + ); + }); + + it("keeps a retained action title but replaces raw REPL names", () => { + expect(dynamicToolTitle("js", {}, "Read the window")).toBe("Read the window"); + expect(dynamicToolTitle("js", { title: "Inspect the window" }, "Read the window")).toBe( + "Inspect the window", + ); + expect(dynamicToolTitle("js", { code: "await sky.click(app);" }, "Js")).toBe("Computer use"); + expect(dynamicToolTitle("node_repl.js", {}, "Node_repl.js")).toBe("Run JavaScript"); + }); + + it("recognizes computer-use REPL calls before result metadata exists", () => { + expect(dynamicToolSurface("cua_repl.js", {})).toBe("computer"); + for (const name of ["node_repl.js", "js"]) { + expect(dynamicToolSurface(name, { code: "await sky.get_app_state(app);" })).toBe("computer"); + expect(dynamicToolSurface(name, { code: "const sky = await import('@oai/sky');" })).toBe( + "computer", + ); + expect(dynamicToolSurface(name, { code: "await fs.readFile(path);" })).toBeUndefined(); + } + expect(dynamicToolSurface("github.js", { code: "await sky.click(app);" })).toBeUndefined(); + }); + + it("leaves ordinary tool titles in their arguments", () => { + expect(dynamicToolTitle("github.create_issue", { title: "Issue title" })).toBeUndefined(); + }); + it("titles Claude skill calls with the skill they load", () => { expect(dynamicToolTitle("Skill", { skill: "full-send" })).toBe("Skill: full-send"); expect(claudeSkillInvocation("Skill", { skill: "claude-api", args: " pricing " })).toEqual({ diff --git a/packages/shared/src/toolActivity.ts b/packages/shared/src/toolActivity.ts index d281462cb08d32a0bfab6b74ae82b2957abff83a..d93c5fd3e1bc7aa1eea6d882ffe35455053bf691 100644 --- a/packages/shared/src/toolActivity.ts +++ b/packages/shared/src/toolActivity.ts @@ -25,15 +25,48 @@ export function claudeSkillInvocation( return name === undefined ? undefined : { name, args: asTrimmedString(record?.args) }; } +export function isJavaScriptReplTool(toolName: string | null | undefined): boolean { + return toolName === "js" || toolName === "cua_repl.js" || toolName === "node_repl.js"; +} + +export const COMPUTER_USE_TOOL_SOURCE = { + key: "computer-use", + name: "Computer Use", + kind: "computer", +} as const; + +/** Computer-use calls may fail or still be running before result metadata is available. */ +export function dynamicToolSurface(toolName: string | null | undefined, input: unknown) { + if (toolName === "cua_repl.js") return "computer" as const; + const code = asRecord(input)?.code; + return isJavaScriptReplTool(toolName) && + typeof code === "string" && + (/\bsky\s*\./u.test(code) || /["']@oai\/sky["']/u.test(code)) + ? ("computer" as const) + : undefined; +} + /** - * Activity log heading a dynamic tool derives from its input: CUA's `title`, + * Activity log heading a dynamic tool derives from its input: a REPL's `title`, * or the skill a Claude `Skill` call loads. */ export function dynamicToolTitle( toolName: string | null | undefined, input: unknown, + retainedTitle?: string | null, ): string | undefined { - if (toolName === "cua_repl.js") return asTrimmedString(asRecord(input)?.title); + if (isJavaScriptReplTool(toolName)) { + const title = asTrimmedString(retainedTitle); + const meaningfulTitle = + title && title.toLowerCase() !== "js" && title.toLowerCase() !== toolName?.toLowerCase() + ? title + : undefined; + return ( + asTrimmedString(asRecord(input)?.title) ?? + meaningfulTitle ?? + (dynamicToolSurface(toolName, input) === "computer" ? "Computer use" : "Run JavaScript") + ); + } const skill = claudeSkillInvocation(toolName, input); return skill === undefined ? undefined : `Skill: ${skill.name}`; } -- 2.54.0