| author | |
| committer | |
| log | 5d6e4192bfcf9eca09741605e12c72b5970025e4 |
| tree | 9041c1daa4df47ce2558001ae4fcada8ee41c348 |
| parent | e2e95a39d309e1ffec901682cc88ea1ba276c868 |
| signature | Signed by PGP key B5690EEEBB952194 |
18 files changed, 542 insertions(+), 129 deletions(-)
apps/mobile/src/components/BrowserAppIcon.tsx created+33| ... | ... | @@ -0,0 +1,33 @@ |
| 1 | import type { ColorValue } from "react-native"; | |
| 2 | import Svg, { Circle, Path } from "react-native-svg"; | |
| 3 | import { withUniwind } from "uniwind"; | |
| 4 | ||
| 5 | const ThemedSvg = withUniwind(Svg); | |
| 6 | ||
| 7 | /** Matches the window-and-pointer icon used by the web work log. */ | |
| 8 | export function BrowserAppIcon(props: { | |
| 9 | readonly size: number; | |
| 10 | readonly color?: ColorValue; | |
| 11 | readonly colorClassName?: string; | |
| 12 | }) { | |
| 13 | return ( | |
| 14 | <ThemedSvg | |
| 15 | width={props.size} | |
| 16 | height={props.size} | |
| 17 | viewBox="0 0 24 24" | |
| 18 | fill="none" | |
| 19 | stroke="currentColor" | |
| 20 | strokeWidth={1.9} | |
| 21 | strokeLinecap="round" | |
| 22 | strokeLinejoin="round" | |
| 23 | color={props.color} | |
| 24 | colorClassName={props.colorClassName} | |
| 25 | accessible={false} | |
| 26 | > | |
| 27 | <Path d="M8.5 19H7.2C4.4 19 3 17.5 3 14.6V7.4C3 4.5 4.5 3 7.4 3h8.2C18.5 3 20 4.5 20 7.4v2.4" /> | |
| 28 | <Circle cx={7.4} cy={7.2} r={0.75} fill="currentColor" stroke="none" /> | |
| 29 | <Path d="M11.2 7.2h4.3" /> | |
| 30 | <Path d="m12.4 11.4 7.5 2.6-3.4 1.6-1.5 3.6z" fill="currentColor" stroke="none" /> | |
| 31 | </ThemedSvg> | |
| 32 | ); | |
| 33 | } |
apps/mobile/src/features/threads/thread-work-log.tsx+82-14| ... | ... | @@ -53,9 +53,12 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 53 | 53 | import { environmentThreadDetails, threadEnvironment } from "../../state/threads"; |
| 54 | 54 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 55 | 55 | import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; |
| 56 | import { isJavaScriptReplTool } from "@t3tools/shared/toolActivity"; | |
| 57 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; | |
| 56 | 58 | |
| 57 | 59 | import { AppText as Text } from "../../components/AppText"; |
| 58 | 60 | import { T3Wordmark } from "../../components/T3Wordmark"; |
| 61 | import { BrowserAppIcon } from "../../components/BrowserAppIcon"; | |
| 59 | 62 | import { cn } from "../../lib/cn"; |
| 60 | 63 | import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout"; |
| 61 | 64 | import { |
| ... | ... | @@ -111,7 +114,13 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180; |
| 111 | 114 | const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); |
| 112 | 115 | const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140); |
| 113 | 116 | const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120); |
| 114 | type WorkContentIcon = AppSymbolName | "browser" | "device" | "t3-code" | "pull-request"; | |
| 117 | type WorkContentIcon = | |
| 118 | | AppSymbolName | |
| 119 | | "browser" | |
| 120 | | "computer" | |
| 121 | | "device" | |
| 122 | | "t3-code" | |
| 123 | | "pull-request"; | |
| 115 | 124 | |
| 116 | 125 | function WorkLogIcon(props: { |
| 117 | 126 | readonly icon: WorkContentIcon; |
| ... | ... | @@ -125,6 +134,14 @@ function WorkLogIcon(props: { |
| 125 | 134 | <T3Wordmark height={10} {...(colorClassName ? { colorClassName } : { color: props.color })} /> |
| 126 | 135 | ); |
| 127 | 136 | } |
| 137 | if (props.icon === "computer") { | |
| 138 | return ( | |
| 139 | <BrowserAppIcon | |
| 140 | size={14} | |
| 141 | {...(colorClassName ? { colorClassName } : { color: props.color })} | |
| 142 | /> | |
| 143 | ); | |
| 144 | } | |
| 128 | 145 | return ( |
| 129 | 146 | <SymbolView |
| 130 | 147 | name={ |
| ... | ... | @@ -372,7 +389,7 @@ export function ShimmeringWorkContent(props: { |
| 372 | 389 | ); |
| 373 | 390 | } |
| 374 | 391 | |
| 375 | function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { | |
| 392 | function workRowSymbolName(icon: ThreadFeedActivity["icon"]): WorkContentIcon { | |
| 376 | 393 | switch (icon) { |
| 377 | 394 | case "agent": |
| 378 | 395 | return { ios: "sparkles", android: "auto_awesome" }; |
| ... | ... | @@ -385,7 +402,9 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { |
| 385 | 402 | case "command": |
| 386 | 403 | return { ios: "terminal", android: "terminal" }; |
| 387 | 404 | case "computer": |
| 388 | return { ios: "desktopcomputer", android: "desktop_windows" }; | |
| 405 | return "computer"; | |
| 406 | case "code": | |
| 407 | return "chevron.left.forwardslash.chevron.right"; | |
| 389 | 408 | case "edit": |
| 390 | 409 | return { ios: "square.and.pencil", android: "edit" }; |
| 391 | 410 | case "eye": |
| ... | ... | @@ -832,6 +851,38 @@ function WorkspacePreparationRetryButton(props: { |
| 832 | 851 | ); |
| 833 | 852 | } |
| 834 | 853 | |
| 854 | function ReplCodeText({ | |
| 855 | code, | |
| 856 | theme, | |
| 857 | }: { | |
| 858 | readonly code: string; | |
| 859 | readonly theme: "light" | "dark"; | |
| 860 | }) { | |
| 861 | const lines = useMarkdownCodeHighlight({ code, language: "typescript", theme, enabled: true }); | |
| 862 | let offset = 0; | |
| 863 | const highlighted = lines?.flatMap((line, lineIndex) => { | |
| 864 | const separator = lineIndex > 0 ? "\n" : ""; | |
| 865 | offset += separator.length; | |
| 866 | return [ | |
| 867 | separator, | |
| 868 | ...line.map((token) => { | |
| 869 | const start = offset; | |
| 870 | offset += token.content.length; | |
| 871 | return ( | |
| 872 | <Text key={start} style={token.color ? { color: token.color } : undefined}> | |
| 873 | {token.content} | |
| 874 | </Text> | |
| 875 | ); | |
| 876 | }), | |
| 877 | ]; | |
| 878 | }); | |
| 879 | return ( | |
| 880 | <Text selectable className="font-mono text-2xs leading-normal text-foreground"> | |
| 881 | {highlighted ?? code} | |
| 882 | </Text> | |
| 883 | ); | |
| 884 | } | |
| 885 | ||
| 835 | 886 | const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 836 | 887 | props: Omit< |
| 837 | 888 | ThreadWorkLogProps, |
| ... | ... | @@ -1177,19 +1228,36 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 1177 | 1228 | ) : call ? ( |
| 1178 | 1229 | [ |
| 1179 | 1230 | call.command, |
| 1180 | ...(call.args ?? []).map(([key, value]) => `${key} ${value}`), | |
| 1231 | ...(call.args ?? []).map(([key, value]) => | |
| 1232 | key === "code" && | |
| 1233 | shownItem.type === "dynamic_tool" && | |
| 1234 | isJavaScriptReplTool(shownItem.toolName) ? ( | |
| 1235 | <View key={key}> | |
| 1236 | <Text className="font-mono text-2xs leading-normal text-foreground-muted"> | |
| 1237 | {key} | |
| 1238 | </Text> | |
| 1239 | <ReplCodeText code={value} theme={props.themeAppearance} /> | |
| 1240 | </View> | |
| 1241 | ) : ( | |
| 1242 | `${key} ${value}` | |
| 1243 | ), | |
| 1244 | ), | |
| 1181 | 1245 | call.argsText, |
| 1182 | 1246 | ] |
| 1183 | .filter((line): line is string => Boolean(line)) | |
| 1184 | .map((line, index) => ( | |
| 1185 | <Text | |
| 1186 | key={`${index}:${line}`} | |
| 1187 | selectable | |
| 1188 | className="font-mono text-2xs leading-normal text-foreground" | |
| 1189 | > | |
| 1190 | {line} | |
| 1191 | </Text> | |
| 1192 | )) | |
| 1247 | .filter((line) => Boolean(line)) | |
| 1248 | .map((line, index) => | |
| 1249 | typeof line === "string" ? ( | |
| 1250 | <Text | |
| 1251 | key={`${index}:${line}`} | |
| 1252 | selectable | |
| 1253 | className="font-mono text-2xs leading-normal text-foreground" | |
| 1254 | > | |
| 1255 | {line} | |
| 1256 | </Text> | |
| 1257 | ) : ( | |
| 1258 | line | |
| 1259 | ), | |
| 1260 | ) | |
| 1193 | 1261 | ) : fullDetail ? ( |
| 1194 | 1262 | <Text selectable className="font-mono text-2xs leading-normal text-foreground-muted"> |
| 1195 | 1263 | {fullDetail} |
apps/mobile/src/lib/threadActivity.test.ts+43-5| ... | ... | @@ -1450,16 +1450,54 @@ describe("buildThreadFeed", () => { |
| 1450 | 1450 | expect(activity?.getCopyText().split("\n")[0]).toBe("Read a T3 thread"); |
| 1451 | 1451 | }); |
| 1452 | 1452 | |
| 1453 | it("uses the CUA action title in the mobile feed", () => { | |
| 1453 | it.each(["cua_repl.js", "node_repl.js", "js"])( | |
| 1454 | "uses the %s action title in the mobile feed", | |
| 1455 | (toolName) => { | |
| 1456 | const item: OrchestrationV2TurnItem = { | |
| 1457 | ...base("cua", "2026-09-23T20:20:00.000Z", 1), | |
| 1458 | type: "dynamic_tool", | |
| 1459 | toolName, | |
| 1460 | input: { | |
| 1461 | code: "await game.getAXStateAndScreenshot();", | |
| 1462 | title: "Inspect Saga music screen", | |
| 1463 | }, | |
| 1464 | }; | |
| 1465 | const feed = buildThreadFeed([projected(item, 0)]); | |
| 1466 | const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null; | |
| 1467 | expect(activity?.summary).toBe("Inspect Saga music screen"); | |
| 1468 | }, | |
| 1469 | ); | |
| 1470 | ||
| 1471 | it.each([ | |
| 1472 | [ | |
| 1473 | "failed", | |
| 1474 | "await sky.type_text({app:'T3 Code (Nightly)',text:'debug(ga.run)'});", | |
| 1475 | "Restrict the breakpoint to Git actions", | |
| 1476 | "computer", | |
| 1477 | ], | |
| 1478 | [ | |
| 1479 | "completed", | |
| 1480 | "await fetch('http://127.0.0.1/json/list');", | |
| 1481 | "Discover the live Electron debugging endpoint", | |
| 1482 | "code", | |
| 1483 | ], | |
| 1484 | ] as const)("recovers the retained js title and %s call icon", (status, code, title, icon) => { | |
| 1454 | 1485 | const item: OrchestrationV2TurnItem = { |
| 1455 | ...base("cua", "2026-09-23T20:20:00.000Z", 1), | |
| 1486 | ...base("retained-js", "2026-09-23T20:20:00.000Z", 1), | |
| 1456 | 1487 | type: "dynamic_tool", |
| 1457 | toolName: "cua_repl.js", | |
| 1458 | input: { code: "await game.getAXStateAndScreenshot();", title: "Inspect Saga music screen" }, | |
| 1488 | status, | |
| 1489 | title: "js", | |
| 1490 | toolName: "node_repl.js", | |
| 1491 | toolSource: { key: "mcp:node_repl", name: "node repl", kind: "integration" }, | |
| 1492 | input: { code, title }, | |
| 1459 | 1493 | }; |
| 1460 | 1494 | const feed = buildThreadFeed([projected(item, 0)]); |
| 1461 | 1495 | const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null; |
| 1462 | expect(activity?.summary).toBe("Inspect Saga music screen"); | |
| 1496 | expect(activity?.summary).toBe(title); | |
| 1497 | expect(activity?.icon).toBe(icon); | |
| 1498 | expect(activity?.workEntry.toolSource?.kind).toBe( | |
| 1499 | icon === "computer" ? "computer" : "integration", | |
| 1500 | ); | |
| 1463 | 1501 | }); |
| 1464 | 1502 | |
| 1465 | 1503 | it("uses canonical T3 orchestration summaries in compact work groups", () => { |
apps/mobile/src/lib/threadActivity.ts+6-3| ... | ... | @@ -57,6 +57,7 @@ import { |
| 57 | 57 | classifyToolActivity, |
| 58 | 58 | collectToolFilePaths, |
| 59 | 59 | dynamicToolTitle, |
| 60 | isJavaScriptReplTool, | |
| 60 | 61 | formatReadToolLabel, |
| 61 | 62 | formatSearchToolLabel, |
| 62 | 63 | } from "@t3tools/shared/toolActivity"; |
| ... | ... | @@ -97,6 +98,7 @@ export interface ThreadFeedActivity { |
| 97 | 98 | | "alert" |
| 98 | 99 | | "browser" |
| 99 | 100 | | "computer" |
| 101 | | "code" | |
| 100 | 102 | | "check" |
| 101 | 103 | | "command" |
| 102 | 104 | | "edit" |
| ... | ... | @@ -556,7 +558,7 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] { |
| 556 | 558 | case "assistant_message": |
| 557 | 559 | return "message"; |
| 558 | 560 | case "dynamic_tool": |
| 559 | return "wrench"; | |
| 561 | return isJavaScriptReplTool(item.toolName) ? "code" : "wrench"; | |
| 560 | 562 | case "subagent": |
| 561 | 563 | return "hammer"; |
| 562 | 564 | case "run_interrupt_request": |
| ... | ... | @@ -598,8 +600,9 @@ function itemSummary( |
| 598 | 600 | if (item.type === "system_notice") return item.message; |
| 599 | 601 | if (item.type === "compaction") return contextCompactionLabel(item); |
| 600 | 602 | const title = |
| 601 | (item.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input) : undefined) ?? | |
| 602 | item.title?.trim(); | |
| 603 | (item.type === "dynamic_tool" | |
| 604 | ? dynamicToolTitle(item.toolName, item.input, item.title) | |
| 605 | : undefined) ?? item.title?.trim(); | |
| 603 | 606 | if (item.type === "subagent") return formatSubagentDisplayTitle(title || "Subagent"); |
| 604 | 607 | if (title) return toolPresentation?.displayName ?? capitalizePhrase(title); |
| 605 | 608 | switch (item.type) { |
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+69-23| ... | ... | @@ -827,36 +827,82 @@ describe("CodexAdapterV2 process spawning", () => { |
| 827 | 827 | }); |
| 828 | 828 | |
| 829 | 829 | describe("CodexAdapterV2 dynamic tool projection", () => { |
| 830 | it.effect("uses the CUA call title while leaving other MCP titles as tool arguments", () => | |
| 830 | it.effect("keeps a failed Sky MCP call in Computer Use when its error has no metadata", () => | |
| 831 | 831 | Effect.gen(function* () { |
| 832 | const call = { | |
| 833 | type: "mcpToolCall" as const, | |
| 834 | id: "inspect", | |
| 835 | server: "cua_repl", | |
| 832 | const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({ | |
| 833 | type: "mcpToolCall", | |
| 834 | id: "failed-sky-js", | |
| 835 | server: "node_repl", | |
| 836 | 836 | tool: "js", |
| 837 | status: "completed" as const, | |
| 837 | status: "failed", | |
| 838 | 838 | arguments: { |
| 839 | code: "await game.getAXStateAndScreenshot();", | |
| 840 | title: "Inspect Saga music screen", | |
| 839 | title: "Restrict the breakpoint to Git actions", | |
| 840 | code: "await sky.type_text({app:'T3 Code (Nightly)',text:'debug(ga.run)'});", | |
| 841 | 841 | }, |
| 842 | result: { content: [] }, | |
| 843 | }; | |
| 844 | assert.equal( | |
| 845 | (yield* CodexAdapterV2.projectCodexDynamicToolItem(call)).title, | |
| 846 | "Inspect Saga music screen", | |
| 847 | ); | |
| 848 | assert.equal( | |
| 849 | (yield* CodexAdapterV2.projectCodexDynamicToolItem({ ...call, arguments: { title: " " } })) | |
| 850 | .title, | |
| 851 | "js", | |
| 852 | ); | |
| 853 | assert.equal( | |
| 854 | (yield* CodexAdapterV2.projectCodexDynamicToolItem({ ...call, server: "github" })).title, | |
| 855 | "js", | |
| 856 | ); | |
| 842 | result: { content: [{ type: "text", text: "Illegal Unicode escape sequence" }] }, | |
| 843 | }); | |
| 844 | assert.equal(projection.title, "Restrict the breakpoint to Git actions"); | |
| 845 | assert.equal(projection.toolSurface, "computer"); | |
| 846 | assert.deepEqual(projection.toolSource, { | |
| 847 | key: "computer-use", | |
| 848 | name: "Computer Use", | |
| 849 | kind: "computer", | |
| 850 | }); | |
| 857 | 851 | }).pipe(Effect.provide(NodeServices.layer)), |
| 858 | 852 | ); |
| 859 | 853 | |
| 854 | it.effect.each(["inProgress", "completed", "failed"] as const)( | |
| 855 | "preserves a bare js action's title and computer surface when %s without result metadata", | |
| 856 | (status) => | |
| 857 | Effect.gen(function* () { | |
| 858 | const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({ | |
| 859 | type: "dynamicToolCall", | |
| 860 | id: "bare-js", | |
| 861 | tool: "js", | |
| 862 | status, | |
| 863 | arguments: { code: "await sky.get_app_state(app);", title: "Read the desktop window" }, | |
| 864 | contentItems: [], | |
| 865 | success: status !== "failed", | |
| 866 | }); | |
| 867 | assert.equal(projection.title, "Read the desktop window"); | |
| 868 | assert.equal(projection.toolSurface, "computer"); | |
| 869 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 870 | ); | |
| 871 | ||
| 872 | it.effect.each(["cua_repl", "node_repl"])( | |
| 873 | "uses the %s call title while leaving other MCP titles as tool arguments", | |
| 874 | (server) => | |
| 875 | Effect.gen(function* () { | |
| 876 | const call = { | |
| 877 | type: "mcpToolCall" as const, | |
| 878 | id: "inspect", | |
| 879 | server, | |
| 880 | tool: "js", | |
| 881 | status: "completed" as const, | |
| 882 | arguments: { | |
| 883 | code: "await game.getAXStateAndScreenshot();", | |
| 884 | title: "Inspect Saga music screen", | |
| 885 | }, | |
| 886 | result: { content: [] }, | |
| 887 | }; | |
| 888 | assert.equal( | |
| 889 | (yield* CodexAdapterV2.projectCodexDynamicToolItem(call)).title, | |
| 890 | "Inspect Saga music screen", | |
| 891 | ); | |
| 892 | assert.equal( | |
| 893 | (yield* CodexAdapterV2.projectCodexDynamicToolItem({ | |
| 894 | ...call, | |
| 895 | arguments: { title: " " }, | |
| 896 | })).title, | |
| 897 | server === "cua_repl" ? "Computer use" : "Run JavaScript", | |
| 898 | ); | |
| 899 | assert.equal( | |
| 900 | (yield* CodexAdapterV2.projectCodexDynamicToolItem({ ...call, server: "github" })).title, | |
| 901 | "js", | |
| 902 | ); | |
| 903 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 904 | ); | |
| 905 | ||
| 860 | 906 | it.effect.each(["inProgress", "completed", "failed"] as const)( |
| 861 | 907 | "presents ordinary MCP calls when %s", |
| 862 | 908 | (status) => |
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+14-2| ... | ... | @@ -35,7 +35,11 @@ import { |
| 35 | 35 | import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; |
| 36 | 36 | import { AgentScope } from "@t3tools/shared/AgentScope"; |
| 37 | 37 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 38 | import { dynamicToolTitle } from "@t3tools/shared/toolActivity"; | |
| 38 | import { | |
| 39 | COMPUTER_USE_TOOL_SOURCE, | |
| 40 | dynamicToolSurface, | |
| 41 | dynamicToolTitle, | |
| 42 | } from "@t3tools/shared/toolActivity"; | |
| 39 | 43 | import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; |
| 40 | 44 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 41 | 45 | import type { |
| ... | ... | @@ -497,9 +501,17 @@ export const projectCodexDynamicToolItem = Effect.fn("CodexAdapterV2.projectDyna |
| 497 | 501 | : [trimText(item.namespace), item.tool].filter(Boolean).join("."); |
| 498 | 502 | const presentation: McpToolPresentation = |
| 499 | 503 | item.type === "mcpToolCall" ? yield* mcpToolPresentation(item) : {}; |
| 500 | const title = dynamicToolTitle(toolName, item.arguments) ?? presentation.title; | |
| 504 | const title = | |
| 505 | dynamicToolTitle(toolName, item.arguments, presentation.title) ?? presentation.title; | |
| 506 | const toolSurface = presentation.toolSurface ?? dynamicToolSurface(toolName, item.arguments); | |
| 507 | const toolSource = | |
| 508 | toolSurface === "computer" && presentation.toolSource?.kind !== "computer" | |
| 509 | ? COMPUTER_USE_TOOL_SOURCE | |
| 510 | : presentation.toolSource; | |
| 501 | 511 | const projection: CodexDynamicToolProjection = { |
| 502 | 512 | ...presentation, |
| 513 | ...(toolSurface ? { toolSurface } : {}), | |
| 514 | ...(toolSource ? { toolSource } : {}), | |
| 503 | 515 | toolName, |
| 504 | 516 | ...(title ? { title } : {}), |
| 505 | 517 | input: item.arguments, |
apps/web/src/components/Icons.tsx-25| ... | ... | @@ -760,28 +760,3 @@ export const MacAccessibilityIcon: Icon = (props) => { |
| 760 | 760 | </svg> |
| 761 | 761 | ); |
| 762 | 762 | }; |
| 763 | ||
| 764 | // Codex's Computer Use app mark, shown on computer-use rows in the work log. | |
| 765 | export const ComputerUseAppIcon: Icon = (props) => { | |
| 766 | const gradientId = `${useId().replaceAll(":", "")}-computer-use-app-gradient`; | |
| 767 | return ( | |
| 768 | <svg viewBox="0 0 24 24" aria-hidden="true" {...props}> | |
| 769 | <defs> | |
| 770 | <linearGradient id={gradientId} x1="2" y1="2" x2="22" y2="22"> | |
| 771 | <stop offset="0" stopColor="#00dff0" /> | |
| 772 | <stop offset="0.42" stopColor="#3b9cff" /> | |
| 773 | <stop offset="0.72" stopColor="#b044f5" /> | |
| 774 | <stop offset="1" stopColor="#ff78b6" /> | |
| 775 | </linearGradient> | |
| 776 | </defs> | |
| 777 | <rect x="1" y="1" width="22" height="22" rx="5" fill={`url(#${gradientId})`} /> | |
| 778 | <path | |
| 779 | d="m7.2 6.2 10.5 4.1-4.2 2.1-2 4.7z" | |
| 780 | fill="white" | |
| 781 | stroke="#315cff" | |
| 782 | strokeWidth="1.1" | |
| 783 | strokeLinejoin="round" | |
| 784 | /> | |
| 785 | </svg> | |
| 786 | ); | |
| 787 | }; |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+53-17| ... | ... | @@ -746,34 +746,70 @@ describe("deriveMessagesTimelineRows", () => { |
| 746 | 746 | expect(first.kind === "work" && first.entry.toolLifecycleStatus).toBe("inProgress"); |
| 747 | 747 | }); |
| 748 | 748 | |
| 749 | it("shows the CUA action title for a retained tool item", () => { | |
| 749 | it.each(["cua_repl.js", "node_repl.js", "js"])( | |
| 750 | "shows the %s action title for a retained tool item", | |
| 751 | (toolName) => { | |
| 752 | const fixture = makeStreamingTimelineFixture(); | |
| 753 | const source = fixture.visibleTurnItems.find((row) => row.item.type === "dynamic_tool")!; | |
| 754 | if (source.item.type !== "dynamic_tool") throw new Error("Expected tool fixture"); | |
| 755 | const item = { | |
| 756 | ...source.item, | |
| 757 | title: null, | |
| 758 | toolName, | |
| 759 | input: { | |
| 760 | code: "await game.getAXStateAndScreenshot();", | |
| 761 | title: "Inspect Saga music screen", | |
| 762 | }, | |
| 763 | }; | |
| 764 | const entries = deriveTimelineEntriesFromVisibleTurnItems({ | |
| 765 | visibleTurnItems: [{ ...source, item }], | |
| 766 | optimisticMessages: [], | |
| 767 | }); | |
| 768 | const work = entries.find((entry) => entry.kind === "work"); | |
| 769 | expect(work?.kind === "work" && workEntryDisplayLabel(work.entry, undefined)).toBe( | |
| 770 | "Inspect Saga music screen", | |
| 771 | ); | |
| 772 | const rows = deriveMessagesTimelineRows({ | |
| 773 | timelineEntries: entries, | |
| 774 | isWorking: false, | |
| 775 | runningRunId: fixture.runId, | |
| 776 | activeTurnStartedAt: fixture.time(0), | |
| 777 | turnDiffSummaries: [], | |
| 778 | supportsConversationRollback: false, | |
| 779 | }); | |
| 780 | expect(rows.find((row) => row.kind === "work")).toMatchObject({ | |
| 781 | displayLabel: "Inspect Saga music screen", | |
| 782 | }); | |
| 783 | }, | |
| 784 | ); | |
| 785 | ||
| 786 | it("recovers the failed Sky call stored as a generic js integration", () => { | |
| 750 | 787 | const fixture = makeStreamingTimelineFixture(); |
| 751 | 788 | const source = fixture.visibleTurnItems.find((row) => row.item.type === "dynamic_tool")!; |
| 752 | 789 | if (source.item.type !== "dynamic_tool") throw new Error("Expected tool fixture"); |
| 753 | 790 | const item = { |
| 754 | 791 | ...source.item, |
| 755 | title: null, | |
| 756 | toolName: "cua_repl.js", | |
| 757 | input: { code: "await game.getAXStateAndScreenshot();", title: "Inspect Saga music screen" }, | |
| 792 | type: "dynamic_tool" as const, | |
| 793 | status: "failed" as const, | |
| 794 | title: "js", | |
| 795 | toolName: "node_repl.js", | |
| 796 | toolSource: { key: "mcp:node_repl", name: "node repl", kind: "integration" as const }, | |
| 797 | input: { | |
| 798 | title: "Restrict the breakpoint to Git actions", | |
| 799 | code: "await sky.type_text({app:'T3 Code (Nightly)',text:'debug(ga.run)'});", | |
| 800 | }, | |
| 758 | 801 | }; |
| 759 | 802 | const entries = deriveTimelineEntriesFromVisibleTurnItems({ |
| 760 | 803 | visibleTurnItems: [{ ...source, item }], |
| 761 | 804 | optimisticMessages: [], |
| 762 | 805 | }); |
| 763 | const work = entries.find((entry) => entry.kind === "work"); | |
| 764 | expect(work?.kind === "work" && workEntryDisplayLabel(work.entry, undefined)).toBe( | |
| 765 | "Inspect Saga music screen", | |
| 806 | const entry = entries.find((row) => row.kind === "work"); | |
| 807 | expect(entry?.kind === "work" && workEntryDisplayLabel(entry.entry, undefined)).toBe( | |
| 808 | "Restrict the breakpoint to Git actions", | |
| 766 | 809 | ); |
| 767 | const rows = deriveMessagesTimelineRows({ | |
| 768 | timelineEntries: entries, | |
| 769 | isWorking: false, | |
| 770 | runningRunId: fixture.runId, | |
| 771 | activeTurnStartedAt: fixture.time(0), | |
| 772 | turnDiffSummaries: [], | |
| 773 | supportsConversationRollback: false, | |
| 774 | }); | |
| 775 | expect(rows.find((row) => row.kind === "work")).toMatchObject({ | |
| 776 | displayLabel: "Inspect Saga music screen", | |
| 810 | expect(entry?.kind === "work" && entry.entry).toMatchObject({ | |
| 811 | toolSurface: "computer", | |
| 812 | toolSource: { key: "computer-use", kind: "computer" }, | |
| 777 | 813 | }); |
| 778 | 814 | }); |
| 779 | 815 |
apps/web/src/components/chat/MessagesTimeline.logic.ts+4-2| ... | ... | @@ -97,7 +97,8 @@ function singleToolCallLabel(entry: WorkLogEntry): string { |
| 97 | 97 | const toolPresentation = resolveWorkEntryToolPresentation(entry, "completed"); |
| 98 | 98 | if (toolPresentation) return toolPresentation.displayName; |
| 99 | 99 | const item = entry.structuredPayload; |
| 100 | const title = item?.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input) : null; | |
| 100 | const title = | |
| 101 | item?.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input, item.title) : null; | |
| 101 | 102 | if (title) return title; |
| 102 | 103 | // A lone web search keeps its heading; the query stays in its detail. |
| 103 | 104 | if (entry.itemType === "web_search") return entry.toolTitle ?? "Web search"; |
| ... | ... | @@ -157,7 +158,8 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string |
| 157 | 158 | const providerRetry = |
| 158 | 159 | entry.projectedItem?.item.type === "error" && entry.projectedItem.item.retry !== undefined; |
| 159 | 160 | const item = entry.structuredPayload; |
| 160 | const title = item?.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input) : null; | |
| 161 | const title = | |
| 162 | item?.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input, item.title) : null; | |
| 161 | 163 | if (title) return title; |
| 162 | 164 | const compactDetail = entry.detail?.trim(); |
| 163 | 165 | const detailIsSearchOutput = |
apps/web/src/components/chat/MessagesTimeline.tsx+16-13| ... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 | import { ThreadFindTimelineContext } from "./ThreadFindProvider"; |
| 2 | 2 | import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline"; |
| 3 | 3 | import { MarkdownFindContext, useFindRevealRef } from "./markdownFindContext"; |
| 4 | import { ComputerUseAppIcon } from "~/components/Icons"; | |
| 5 | 4 | import { useChatCanvas } from "./ChatCanvasContext"; |
| 6 | 5 | import { WorkLogBlock, WorkLogButton, WorkLogDetails, WorkLogList, WorkLogRow } from "./WorkLog"; |
| 7 | 6 | import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; |
| ... | ... | @@ -67,7 +66,7 @@ import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 67 | 66 | import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; |
| 68 | 67 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 69 | 68 | import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon"; |
| 70 | import { claudeSkillInvocation } from "@t3tools/shared/toolActivity"; | |
| 69 | import { claudeSkillInvocation, isJavaScriptReplTool } from "@t3tools/shared/toolActivity"; | |
| 71 | 70 | import { observeVisibleAnimation } from "../../lib/visibleAnimation"; |
| 72 | 71 | import { |
| 73 | 72 | createContext, |
| ... | ... | @@ -148,6 +147,7 @@ import { |
| 148 | 147 | Undo2Icon, |
| 149 | 148 | HammerIcon, |
| 150 | 149 | WrenchIcon, |
| 150 | Code2Icon, | |
| 151 | 151 | XIcon, |
| 152 | 152 | ZapIcon, |
| 153 | 153 | RotateCcwIcon, |
| ... | ... | @@ -3779,13 +3779,9 @@ function ActivityShimmerOverlay({ children }: { children: ReactNode }) { |
| 3779 | 3779 | |
| 3780 | 3780 | const failedToolIconClassName = "text-tool-error-icon/40"; |
| 3781 | 3781 | |
| 3782 | /** Image icons and the gradient computer-use mark cannot take a currentColor | |
| 3783 | * tint, so failed rows using them get a trailing x instead. */ | |
| 3784 | function toolIconAcceptsTint( | |
| 3785 | iconName: WorkEntryIconName, | |
| 3786 | toolIcon: ToolActivityIcon | undefined, | |
| 3787 | ): boolean { | |
| 3788 | return toolIcon === undefined && iconName !== "computer"; | |
| 3782 | /** Image icons cannot take a currentColor tint, so failed rows get a trailing x. */ | |
| 3783 | function toolIconAcceptsTint(toolIcon: ToolActivityIcon | undefined): boolean { | |
| 3784 | return toolIcon === undefined; | |
| 3789 | 3785 | } |
| 3790 | 3786 | |
| 3791 | 3787 | function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) { |
| ... | ... | @@ -3951,7 +3947,7 @@ function LiveActivityContent({ |
| 3951 | 3947 | highlighted?: boolean; |
| 3952 | 3948 | }) { |
| 3953 | 3949 | const showTrailingFailureMark = |
| 3954 | failed && iconName !== undefined && !toolIconAcceptsTint(iconName, toolIcon); | |
| 3950 | failed && iconName !== undefined && !toolIconAcceptsTint(toolIcon); | |
| 3955 | 3951 | |
| 3956 | 3952 | return ( |
| 3957 | 3953 | <WorkLogRow |
| ... | ... | @@ -5030,6 +5026,7 @@ type WorkEntryIconName = |
| 5030 | 5026 | | "check" |
| 5031 | 5027 | | "circle-alert" |
| 5032 | 5028 | | "computer" |
| 5029 | | "code" | |
| 5033 | 5030 | | "device" |
| 5034 | 5031 | | "eye" |
| 5035 | 5032 | | "globe" |
| ... | ... | @@ -5226,9 +5223,10 @@ function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className |
| 5226 | 5223 | case "brain": |
| 5227 | 5224 | return <BrainIcon className={className} aria-hidden />; |
| 5228 | 5225 | case "browser": |
| 5229 | return <BrowserAppIcon className={className} />; | |
| 5230 | 5226 | case "computer": |
| 5231 | return <ComputerUseAppIcon className={className} />; | |
| 5227 | return <BrowserAppIcon className={className} />; | |
| 5228 | case "code": | |
| 5229 | return <Code2Icon className={className} aria-hidden />; | |
| 5232 | 5230 | case "device": |
| 5233 | 5231 | return <SmartphoneIcon className={className} aria-hidden />; |
| 5234 | 5232 | case "t3-code": |
| ... | ... | @@ -5382,6 +5380,11 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { |
| 5382 | 5380 | return "message-circle"; |
| 5383 | 5381 | } |
| 5384 | 5382 | if (workEntry.toolSurface) return workEntry.toolSurface; |
| 5383 | if ( | |
| 5384 | workEntry.structuredPayload?.type === "dynamic_tool" && | |
| 5385 | isJavaScriptReplTool(workEntry.structuredPayload.toolName) | |
| 5386 | ) | |
| 5387 | return "code"; | |
| 5385 | 5388 | const toolPresentation = resolveWorkEntryToolPresentation(workEntry); |
| 5386 | 5389 | if (toolPresentation) return toolPresentation.icon; |
| 5387 | 5390 | const action = toolGroupAction(workEntry); |
| ... | ... | @@ -5794,7 +5797,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { |
| 5794 | 5797 | ) : null} |
| 5795 | 5798 | {showFailedIndicator && |
| 5796 | 5799 | !showDestructiveRowStyle && |
| 5797 | !toolIconAcceptsTint(entryIconName, entryToolIcon) ? ( | |
| 5800 | !toolIconAcceptsTint(entryToolIcon) ? ( | |
| 5798 | 5801 | <XIcon aria-hidden className={cn("size-3 shrink-0", failedToolIconClassName)} /> |
| 5799 | 5802 | ) : null} |
| 5800 | 5803 | <TimelineRowTimestamp createdAt={workEntry.createdAt} timestampFormat={timestampFormat} /> |
apps/web/src/components/chat/V2ItemInspector.tsx+30-8| ... | ... | @@ -12,6 +12,7 @@ import { |
| 12 | 12 | turnItemOutputText, |
| 13 | 13 | } from "@t3tools/client-runtime/work-log/item-detail"; |
| 14 | 14 | import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; |
| 15 | import { isJavaScriptReplTool } from "@t3tools/shared/toolActivity"; | |
| 15 | 16 | import { ExternalLinkIcon, GitBranchIcon, RotateCcwIcon } from "lucide-react"; |
| 16 | 17 | import { memo, Suspense, use, useMemo } from "react"; |
| 17 | 18 | |
| ... | ... | @@ -43,13 +44,22 @@ interface V2ItemInspectorProps { |
| 43 | 44 | readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; |
| 44 | 45 | } |
| 45 | 46 | |
| 46 | function JsonTokens({ text }: { readonly text: string }) { | |
| 47 | function SyntaxTokens({ | |
| 48 | text, | |
| 49 | language, | |
| 50 | }: { | |
| 51 | readonly text: string; | |
| 52 | readonly language: "json" | "typescript"; | |
| 53 | }) { | |
| 47 | 54 | const { resolvedTheme } = useTheme(); |
| 48 | const highlighter = use(getSyntaxHighlighterPromise("json")); | |
| 55 | const highlighter = use(getSyntaxHighlighterPromise(language)); | |
| 49 | 56 | const { tokens } = useMemo( |
| 50 | 57 | () => |
| 51 | highlighter.codeToTokens(text, { lang: "json", theme: resolveDiffThemeName(resolvedTheme) }), | |
| 52 | [highlighter, text, resolvedTheme], | |
| 58 | highlighter.codeToTokens(text, { | |
| 59 | lang: language, | |
| 60 | theme: resolveDiffThemeName(resolvedTheme), | |
| 61 | }), | |
| 62 | [highlighter, text, language, resolvedTheme], | |
| 53 | 63 | ); |
| 54 | 64 | return tokens.flatMap((line, lineIndex) => [ |
| 55 | 65 | lineIndex > 0 ? "\n" : "", |
| ... | ... | @@ -67,9 +77,11 @@ const monoClassName = |
| 67 | 77 | function StructuredValue({ |
| 68 | 78 | value, |
| 69 | 79 | highlightJson = false, |
| 80 | language, | |
| 70 | 81 | }: { |
| 71 | 82 | readonly value: unknown; |
| 72 | 83 | readonly highlightJson?: boolean; |
| 84 | readonly language?: "typescript"; | |
| 73 | 85 | }) { |
| 74 | 86 | const text = typeof value === "string" ? value : JSON.stringify(value, null, 2); |
| 75 | 87 | const isJson = useMemo(() => { |
| ... | ... | @@ -84,10 +96,10 @@ function StructuredValue({ |
| 84 | 96 | if (!text) return null; |
| 85 | 97 | return ( |
| 86 | 98 | <pre className={cn("max-h-80 overflow-auto text-muted-foreground", monoClassName)}> |
| 87 | {isJson ? ( | |
| 99 | {language || isJson ? ( | |
| 88 | 100 | <RenderErrorBoundary fallback={text}> |
| 89 | 101 | <Suspense fallback={text}> |
| 90 | <JsonTokens text={text} /> | |
| 102 | <SyntaxTokens text={text} language={language ?? "json"} /> | |
| 91 | 103 | </Suspense> |
| 92 | 104 | </RenderErrorBoundary> |
| 93 | 105 | ) : ( |
| ... | ... | @@ -194,6 +206,7 @@ function ToolCallBody( |
| 194 | 206 | props: ToolOutputState & { |
| 195 | 207 | readonly command?: string; |
| 196 | 208 | readonly args?: unknown; |
| 209 | readonly toolName?: string | null; | |
| 197 | 210 | readonly exitCode?: number | undefined; |
| 198 | 211 | }, |
| 199 | 212 | ) { |
| ... | ... | @@ -206,7 +219,11 @@ function ToolCallBody( |
| 206 | 219 | {call.args.map(([key, value]) => ( |
| 207 | 220 | <div key={key}> |
| 208 | 221 | <span className="text-muted-foreground">{key} </span> |
| 209 | {value} | |
| 222 | {key === "code" && isJavaScriptReplTool(props.toolName) ? ( | |
| 223 | <StructuredValue value={value} language="typescript" /> | |
| 224 | ) : ( | |
| 225 | value | |
| 226 | )} | |
| 210 | 227 | </div> |
| 211 | 228 | ))} |
| 212 | 229 | </div> |
| ... | ... | @@ -349,7 +366,12 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec |
| 349 | 366 | ) : null} |
| 350 | 367 | |
| 351 | 368 | {item.type === "dynamic_tool" ? ( |
| 352 | <ToolCallBody args={item.input} {...outputState} onImageExpand={props.onImageExpand} /> | |
| 369 | <ToolCallBody | |
| 370 | toolName={item.toolName} | |
| 371 | args={item.input} | |
| 372 | {...outputState} | |
| 373 | onImageExpand={props.onImageExpand} | |
| 374 | /> | |
| 353 | 375 | ) : null} |
| 354 | 376 | |
| 355 | 377 | {item.type === "approval_request" ? <StructuredValue value={item.prompt} /> : null} |
apps/web/src/components/chat/WorkLog.tsx+18-11| ... | ... | @@ -49,20 +49,27 @@ function WorkLogLine({ icon, label, trailing, wrapLabel }: RowContent) { |
| 49 | 49 | <div |
| 50 | 50 | className={cn( |
| 51 | 51 | "flex min-h-6 min-w-0 gap-1.5 text-sm leading-relaxed select-none [&_*]:select-none", |
| 52 | // A wrapped label keeps its icon beside the first line. | |
| 53 | 52 | wrapLabel ? "items-start" : "items-center", |
| 54 | 53 | )} |
| 55 | 54 | > |
| 56 | {icon ? ( | |
| 57 | <span className="relative flex size-6 shrink-0 items-center justify-center">{icon}</span> | |
| 58 | ) : null} | |
| 59 | <div | |
| 60 | className={cn( | |
| 61 | "min-w-0 flex-1 text-secondary-label", | |
| 62 | !wrapLabel && "truncate [&_*]:whitespace-nowrap", | |
| 63 | )} | |
| 64 | > | |
| 65 | {label} | |
| 55 | <div className="flex min-w-0 flex-1 items-baseline gap-1.5"> | |
| 56 | {icon ? ( | |
| 57 | // Align with the first line's capital letters. Centering line boxes drifts | |
| 58 | // when font ascent/descent rounding changes at fractional browser zoom. | |
| 59 | <span className="relative w-6 shrink-0 leading-0"> | |
| 60 | <span className="inline-block size-6 overflow-hidden align-[calc(0.5cap_-_0.75rem)]"> | |
| 61 | <span className="flex size-full items-center justify-center">{icon}</span> | |
| 62 | </span> | |
| 63 | </span> | |
| 64 | ) : null} | |
| 65 | <div | |
| 66 | className={cn( | |
| 67 | "min-w-0 flex-1 text-secondary-label", | |
| 68 | !wrapLabel && "truncate [&_*]:whitespace-nowrap", | |
| 69 | )} | |
| 70 | > | |
| 71 | {label} | |
| 72 | </div> | |
| 66 | 73 | </div> |
| 67 | 74 | {trailing} |
| 68 | 75 | </div> |
packages/client-runtime/src/work-log/presentation.test.ts+41| ... | ... | @@ -253,6 +253,28 @@ describe("summarizeToolGroup", () => { |
| 253 | 253 | ).toBe("Used Chrome integration and ran 1 command"); |
| 254 | 254 | }); |
| 255 | 255 | |
| 256 | it.each([ | |
| 257 | [["Foo", "Foo"], "Used Foo integration and ran 1 command"], | |
| 258 | [["Foo", "Bar", "Foo"], "Used Foo and Bar integrations and ran 1 command"], | |
| 259 | ] as const)( | |
| 260 | "uses the displayed source names for integration plurality: %j", | |
| 261 | (names, expected) => { | |
| 262 | expect( | |
| 263 | summarizeToolGroup([ | |
| 264 | ...names.map((name, index) => | |
| 265 | entry(`integration-${index}`, { | |
| 266 | toolSource: { key: `integration-${index}`, name, kind: "integration" }, | |
| 267 | }), | |
| 268 | ), | |
| 269 | entry("command", { | |
| 270 | itemType: "command_execution", | |
| 271 | command: "git status", | |
| 272 | }), | |
| 273 | ]).summary, | |
| 274 | ).toBe(expected); | |
| 275 | }, | |
| 276 | ); | |
| 277 | ||
| 256 | 278 | it("omits the integration suffix for special browser and computer sources", () => { |
| 257 | 279 | expect( |
| 258 | 280 | summarizeToolGroup([ |
| ... | ... | @@ -267,6 +289,25 @@ describe("summarizeToolGroup", () => { |
| 267 | 289 | ]).summary, |
| 268 | 290 | ).toBe("Used Browser and Computer Use"); |
| 269 | 291 | }); |
| 292 | ||
| 293 | it("names Computer Use once when native-app and fallback sources share its label", () => { | |
| 294 | expect( | |
| 295 | summarizeToolGroup([ | |
| 296 | entry("inspect-app", { | |
| 297 | label: "Inspect app", | |
| 298 | toolSource: { | |
| 299 | key: "native-app:com.t3tools.t3code", | |
| 300 | name: "Computer Use", | |
| 301 | kind: "computer", | |
| 302 | }, | |
| 303 | }), | |
| 304 | entry("failed-sky", { | |
| 305 | label: "Restrict the breakpoint to Git actions", | |
| 306 | toolSource: { key: "computer-use", name: "Computer Use", kind: "computer" }, | |
| 307 | }), | |
| 308 | ]).summary, | |
| 309 | ).toBe("Used Computer Use"); | |
| 310 | }); | |
| 270 | 311 | }); |
| 271 | 312 | |
| 272 | 313 | describe("resolveWorkEntryToolPresentation", () => { |
packages/client-runtime/src/work-log/presentation.ts+2-2| ... | ... | @@ -697,7 +697,7 @@ export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEnt |
| 697 | 697 | const labels = selected.map(({ label }) => label); |
| 698 | 698 | if (sources.size > 0) { |
| 699 | 699 | const sourceValues = [...sources.values()]; |
| 700 | const sourceNames = sourceValues.map((source) => source.name); | |
| 700 | const sourceNames = [...new Set(sourceValues.map((source) => source.name))]; | |
| 701 | 701 | const formattedNames = |
| 702 | 702 | sourceNames.length < 2 |
| 703 | 703 | ? sourceNames[0]! |
| ... | ... | @@ -706,7 +706,7 @@ export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEnt |
| 706 | 706 | : `${sourceNames.slice(0, -1).join(", ")}, and ${sourceNames.at(-1)}`; |
| 707 | 707 | const allIntegrations = sourceValues.every((source) => source.kind === "integration"); |
| 708 | 708 | labels.unshift( |
| 709 | `Used ${formattedNames}${allIntegrations ? ` ${sources.size === 1 ? "integration" : "integrations"}` : ""}`, | |
| 709 | `Used ${formattedNames}${allIntegrations ? ` ${sourceNames.length === 1 ? "integration" : "integrations"}` : ""}`, | |
| 710 | 710 | ); |
| 711 | 711 | } |
| 712 | 712 | const sourcedCount = entries.filter( |
packages/client-runtime/src/work-log/toolPresentation.test.ts+43| ... | ... | @@ -3,6 +3,49 @@ import { describe, expect, it } from "@effect/vitest"; |
| 3 | 3 | import { extractToolActivityPresentation } from "./toolPresentation.ts"; |
| 4 | 4 | |
| 5 | 5 | describe("extractToolActivityPresentation", () => { |
| 6 | it("recovers computer-use presentation for retained calls without result metadata", () => { | |
| 7 | const item = { | |
| 8 | type: "dynamic_tool", | |
| 9 | toolName: "js", | |
| 10 | input: { code: "await sky.get_app_state(app);" }, | |
| 11 | }; | |
| 12 | expect(extractToolActivityPresentation(item)).toEqual({ | |
| 13 | toolSurface: "computer", | |
| 14 | toolSource: { key: "computer-use", name: "Computer Use", kind: "computer" }, | |
| 15 | }); | |
| 16 | expect(extractToolActivityPresentation({ ...item, toolSurface: "browser" })).toEqual({ | |
| 17 | toolSurface: "browser", | |
| 18 | }); | |
| 19 | expect( | |
| 20 | extractToolActivityPresentation({ ...item, input: { code: "await fs.readFile(path);" } }), | |
| 21 | ).toEqual({}); | |
| 22 | }); | |
| 23 | ||
| 24 | it("replaces a failed Sky call's generic node REPL source", () => { | |
| 25 | const source = { key: "mcp:node_repl", name: "node repl", kind: "integration" }; | |
| 26 | const item = { | |
| 27 | type: "dynamic_tool", | |
| 28 | toolName: "node_repl.js", | |
| 29 | title: "js", | |
| 30 | status: "failed", | |
| 31 | toolSource: source, | |
| 32 | input: { | |
| 33 | title: "Restrict the breakpoint to Git actions", | |
| 34 | code: "await sky.type_text({app:'T3 Code (Nightly)',text:'debug(ga.run)'});", | |
| 35 | }, | |
| 36 | }; | |
| 37 | expect(extractToolActivityPresentation(item)).toEqual({ | |
| 38 | toolSurface: "computer", | |
| 39 | toolSource: { key: "computer-use", name: "Computer Use", kind: "computer" }, | |
| 40 | }); | |
| 41 | expect( | |
| 42 | extractToolActivityPresentation({ | |
| 43 | ...item, | |
| 44 | input: { code: "await fetch('http://127.0.0.1/json/list');" }, | |
| 45 | }), | |
| 46 | ).toEqual({ toolSource: source }); | |
| 47 | }); | |
| 48 | ||
| 6 | 49 | it("retains valid image paths independently of redacted output", () => { |
| 7 | 50 | expect( |
| 8 | 51 | extractToolActivityPresentation({ viewedImagePath: " /workspace/reference.webp " }), |
packages/client-runtime/src/work-log/toolPresentation.ts+14-2| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; |
| 2 | import { COMPUTER_USE_TOOL_SOURCE, dynamicToolSurface } from "@t3tools/shared/toolActivity"; | |
| 2 | 3 | import type { |
| 3 | 4 | ToolActivityIcon, |
| 4 | 5 | ToolActivityNativeAppReference, |
| ... | ... | @@ -111,12 +112,23 @@ export function extractToolActivityPresentation( |
| 111 | 112 | payloadValue: unknown, |
| 112 | 113 | ): ExtractedToolActivityPresentation { |
| 113 | 114 | const payload = asRecord(payloadValue); |
| 115 | const retainedSource = activitySource(payload?.toolSource); | |
| 114 | 116 | const toolSurface = |
| 115 | 117 | payload?.toolSurface === "browser" || payload?.toolSurface === "computer" |
| 116 | 118 | ? payload.toolSurface |
| 117 | : undefined; | |
| 119 | : retainedSource?.kind === "computer" || retainedSource?.kind === "browser" | |
| 120 | ? retainedSource.kind | |
| 121 | : payload?.type === "dynamic_tool" | |
| 122 | ? dynamicToolSurface( | |
| 123 | typeof payload.toolName === "string" ? payload.toolName : undefined, | |
| 124 | payload.input, | |
| 125 | ) | |
| 126 | : undefined; | |
| 127 | const toolSource = | |
| 128 | toolSurface === "computer" && retainedSource?.kind !== "computer" | |
| 129 | ? COMPUTER_USE_TOOL_SOURCE | |
| 130 | : retainedSource; | |
| 118 | 131 | const toolIcon = activityIcon(payload?.toolIcon); |
| 119 | const toolSource = activitySource(payload?.toolSource); | |
| 120 | 132 | const viewedImagePath = trimmedString(payload?.viewedImagePath, 4096); |
| 121 | 133 | return { |
| 122 | 134 | ...(toolSurface ? { toolSurface } : {}), |
packages/shared/src/toolActivity.test.ts+39| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | collectToolFilePaths, |
| 7 | 7 | deriveToolActivityPresentation, |
| 8 | 8 | dynamicToolTitle, |
| 9 | dynamicToolSurface, | |
| 9 | 10 | formatReadToolLabel, |
| 10 | 11 | formatSearchToolLabel, |
| 11 | 12 | mergeToolActivityData, |
| ... | ... | @@ -123,6 +124,44 @@ describe("toolActivity", () => { |
| 123 | 124 | ).toEqual({ rawInput: { path: "src/a.ts", startLine: 4 } }); |
| 124 | 125 | }); |
| 125 | 126 | |
| 127 | it.each(["cua_repl.js", "node_repl.js", "js"])("uses the action title for %s", (toolName) => { | |
| 128 | expect(dynamicToolTitle(toolName, { title: " Read the current T3 desktop window " })).toBe( | |
| 129 | "Read the current T3 desktop window", | |
| 130 | ); | |
| 131 | const fallback = toolName === "cua_repl.js" ? "Computer use" : "Run JavaScript"; | |
| 132 | expect(dynamicToolTitle(toolName, { title: " " })).toBe(fallback); | |
| 133 | expect(dynamicToolTitle(toolName, { title: 123 })).toBe(fallback); | |
| 134 | expect(dynamicToolTitle(toolName, {})).toBe(fallback); | |
| 135 | expect(dynamicToolTitle(toolName, { code: "await sky.get_app_state(app);" })).toBe( | |
| 136 | "Computer use", | |
| 137 | ); | |
| 138 | }); | |
| 139 | ||
| 140 | it("keeps a retained action title but replaces raw REPL names", () => { | |
| 141 | expect(dynamicToolTitle("js", {}, "Read the window")).toBe("Read the window"); | |
| 142 | expect(dynamicToolTitle("js", { title: "Inspect the window" }, "Read the window")).toBe( | |
| 143 | "Inspect the window", | |
| 144 | ); | |
| 145 | expect(dynamicToolTitle("js", { code: "await sky.click(app);" }, "Js")).toBe("Computer use"); | |
| 146 | expect(dynamicToolTitle("node_repl.js", {}, "Node_repl.js")).toBe("Run JavaScript"); | |
| 147 | }); | |
| 148 | ||
| 149 | it("recognizes computer-use REPL calls before result metadata exists", () => { | |
| 150 | expect(dynamicToolSurface("cua_repl.js", {})).toBe("computer"); | |
| 151 | for (const name of ["node_repl.js", "js"]) { | |
| 152 | expect(dynamicToolSurface(name, { code: "await sky.get_app_state(app);" })).toBe("computer"); | |
| 153 | expect(dynamicToolSurface(name, { code: "const sky = await import('@oai/sky');" })).toBe( | |
| 154 | "computer", | |
| 155 | ); | |
| 156 | expect(dynamicToolSurface(name, { code: "await fs.readFile(path);" })).toBeUndefined(); | |
| 157 | } | |
| 158 | expect(dynamicToolSurface("github.js", { code: "await sky.click(app);" })).toBeUndefined(); | |
| 159 | }); | |
| 160 | ||
| 161 | it("leaves ordinary tool titles in their arguments", () => { | |
| 162 | expect(dynamicToolTitle("github.create_issue", { title: "Issue title" })).toBeUndefined(); | |
| 163 | }); | |
| 164 | ||
| 126 | 165 | it("titles Claude skill calls with the skill they load", () => { |
| 127 | 166 | expect(dynamicToolTitle("Skill", { skill: "full-send" })).toBe("Skill: full-send"); |
| 128 | 167 | expect(claudeSkillInvocation("Skill", { skill: "claude-api", args: " pricing " })).toEqual({ |
packages/shared/src/toolActivity.ts+35-2| ... | ... | @@ -25,15 +25,48 @@ export function claudeSkillInvocation( |
| 25 | 25 | return name === undefined ? undefined : { name, args: asTrimmedString(record?.args) }; |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | export function isJavaScriptReplTool(toolName: string | null | undefined): boolean { | |
| 29 | return toolName === "js" || toolName === "cua_repl.js" || toolName === "node_repl.js"; | |
| 30 | } | |
| 31 | ||
| 32 | export const COMPUTER_USE_TOOL_SOURCE = { | |
| 33 | key: "computer-use", | |
| 34 | name: "Computer Use", | |
| 35 | kind: "computer", | |
| 36 | } as const; | |
| 37 | ||
| 38 | /** Computer-use calls may fail or still be running before result metadata is available. */ | |
| 39 | export function dynamicToolSurface(toolName: string | null | undefined, input: unknown) { | |
| 40 | if (toolName === "cua_repl.js") return "computer" as const; | |
| 41 | const code = asRecord(input)?.code; | |
| 42 | return isJavaScriptReplTool(toolName) && | |
| 43 | typeof code === "string" && | |
| 44 | (/\bsky\s*\./u.test(code) || /["']@oai\/sky["']/u.test(code)) | |
| 45 | ? ("computer" as const) | |
| 46 | : undefined; | |
| 47 | } | |
| 48 | ||
| 28 | 49 | /** |
| 29 | * Activity log heading a dynamic tool derives from its input: CUA's `title`, | |
| 50 | * Activity log heading a dynamic tool derives from its input: a REPL's `title`, | |
| 30 | 51 | * or the skill a Claude `Skill` call loads. |
| 31 | 52 | */ |
| 32 | 53 | export function dynamicToolTitle( |
| 33 | 54 | toolName: string | null | undefined, |
| 34 | 55 | input: unknown, |
| 56 | retainedTitle?: string | null, | |
| 35 | 57 | ): string | undefined { |
| 36 | if (toolName === "cua_repl.js") return asTrimmedString(asRecord(input)?.title); | |
| 58 | if (isJavaScriptReplTool(toolName)) { | |
| 59 | const title = asTrimmedString(retainedTitle); | |
| 60 | const meaningfulTitle = | |
| 61 | title && title.toLowerCase() !== "js" && title.toLowerCase() !== toolName?.toLowerCase() | |
| 62 | ? title | |
| 63 | : undefined; | |
| 64 | return ( | |
| 65 | asTrimmedString(asRecord(input)?.title) ?? | |
| 66 | meaningfulTitle ?? | |
| 67 | (dynamicToolSurface(toolName, input) === "computer" ? "Computer use" : "Run JavaScript") | |
| 68 | ); | |
| 69 | } | |
| 37 | 70 | const skill = claudeSkillInvocation(toolName, input); |
| 38 | 71 | return skill === undefined ? undefined : `Skill: ${skill.name}`; |
| 39 | 72 | } |