| 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,9 +53,12 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 53 | import { environmentThreadDetails, threadEnvironment } from "../../state/threads"; | 53 | import { environmentThreadDetails, threadEnvironment } from "../../state/threads"; |
| 54 | import { useAtomCommand } from "../../state/use-atom-command"; | 54 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 55 | import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; | 55 | import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; |
| 56 | import { isJavaScriptReplTool } from "@t3tools/shared/toolActivity"; | ||
| 57 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; | ||
| 56 | 58 | ||
| 57 | import { AppText as Text } from "../../components/AppText"; | 59 | import { AppText as Text } from "../../components/AppText"; |
| 58 | import { T3Wordmark } from "../../components/T3Wordmark"; | 60 | import { T3Wordmark } from "../../components/T3Wordmark"; |
| 61 | import { BrowserAppIcon } from "../../components/BrowserAppIcon"; | ||
| 59 | import { cn } from "../../lib/cn"; | 62 | import { cn } from "../../lib/cn"; |
| 60 | import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout"; | 63 | import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout"; |
| 61 | import { | 64 | import { |
| ... | @@ -111,7 +114,13 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180; | ... | @@ -111,7 +114,13 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180; |
| 111 | const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); | 114 | const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); |
| 112 | const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140); | 115 | const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140); |
| 113 | const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120); | 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 | function WorkLogIcon(props: { | 125 | function WorkLogIcon(props: { |
| 117 | readonly icon: WorkContentIcon; | 126 | readonly icon: WorkContentIcon; |
| ... | @@ -125,6 +134,14 @@ function WorkLogIcon(props: { | ... | @@ -125,6 +134,14 @@ function WorkLogIcon(props: { |
| 125 | <T3Wordmark height={10} {...(colorClassName ? { colorClassName } : { color: props.color })} /> | 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 | return ( | 145 | return ( |
| 129 | <SymbolView | 146 | <SymbolView |
| 130 | name={ | 147 | name={ |
| ... | @@ -372,7 +389,7 @@ export function ShimmeringWorkContent(props: { | ... | @@ -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 | switch (icon) { | 393 | switch (icon) { |
| 377 | case "agent": | 394 | case "agent": |
| 378 | return { ios: "sparkles", android: "auto_awesome" }; | 395 | return { ios: "sparkles", android: "auto_awesome" }; |
| ... | @@ -385,7 +402,9 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { | ... | @@ -385,7 +402,9 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { |
| 385 | case "command": | 402 | case "command": |
| 386 | return { ios: "terminal", android: "terminal" }; | 403 | return { ios: "terminal", android: "terminal" }; |
| 387 | case "computer": | 404 | case "computer": |
| 388 | return { ios: "desktopcomputer", android: "desktop_windows" }; | 405 | return "computer"; |
| 406 | case "code": | ||
| 407 | return "chevron.left.forwardslash.chevron.right"; | ||
| 389 | case "edit": | 408 | case "edit": |
| 390 | return { ios: "square.and.pencil", android: "edit" }; | 409 | return { ios: "square.and.pencil", android: "edit" }; |
| 391 | case "eye": | 410 | case "eye": |
| ... | @@ -832,6 +851,38 @@ function WorkspacePreparationRetryButton(props: { | ... | @@ -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 | const ThreadWorkLogRow = memo(function ThreadWorkLogRow( | 886 | const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 836 | props: Omit< | 887 | props: Omit< |
| 837 | ThreadWorkLogProps, | 888 | ThreadWorkLogProps, |
| ... | @@ -1177,19 +1228,36 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( | ... | @@ -1177,19 +1228,36 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 1177 | ) : call ? ( | 1228 | ) : call ? ( |
| 1178 | [ | 1229 | [ |
| 1179 | call.command, | 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 | call.argsText, | 1245 | call.argsText, |
| 1182 | ] | 1246 | ] |
| 1183 | .filter((line): line is string => Boolean(line)) | 1247 | .filter((line) => Boolean(line)) |
| 1184 | .map((line, index) => ( | 1248 | .map((line, index) => |
| 1185 | <Text | 1249 | typeof line === "string" ? ( |
| 1186 | key={`${index}:${line}`} | 1250 | <Text |
| 1187 | selectable | 1251 | key={`${index}:${line}`} |
| 1188 | className="font-mono text-2xs leading-normal text-foreground" | 1252 | selectable |
| 1189 | > | 1253 | className="font-mono text-2xs leading-normal text-foreground" |
| 1190 | {line} | 1254 | > |
| 1191 | </Text> | 1255 | {line} |
| 1192 | )) | 1256 | </Text> |
| 1257 | ) : ( | ||
| 1258 | line | ||
| 1259 | ), | ||
| 1260 | ) | ||
| 1193 | ) : fullDetail ? ( | 1261 | ) : fullDetail ? ( |
| 1194 | <Text selectable className="font-mono text-2xs leading-normal text-foreground-muted"> | 1262 | <Text selectable className="font-mono text-2xs leading-normal text-foreground-muted"> |
| 1195 | {fullDetail} | 1263 | {fullDetail} |
apps/mobile/src/lib/threadActivity.test.ts+43-5| ... | @@ -1450,16 +1450,54 @@ describe("buildThreadFeed", () => { | ... | @@ -1450,16 +1450,54 @@ describe("buildThreadFeed", () => { |
| 1450 | expect(activity?.getCopyText().split("\n")[0]).toBe("Read a T3 thread"); | 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 | const item: OrchestrationV2TurnItem = { | 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 | type: "dynamic_tool", | 1487 | type: "dynamic_tool", |
| 1457 | toolName: "cua_repl.js", | 1488 | status, |
| 1458 | input: { code: "await game.getAXStateAndScreenshot();", title: "Inspect Saga music screen" }, | 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 | const feed = buildThreadFeed([projected(item, 0)]); | 1494 | const feed = buildThreadFeed([projected(item, 0)]); |
| 1461 | const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null; | 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 | it("uses canonical T3 orchestration summaries in compact work groups", () => { | 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,6 +57,7 @@ import { |
| 57 | classifyToolActivity, | 57 | classifyToolActivity, |
| 58 | collectToolFilePaths, | 58 | collectToolFilePaths, |
| 59 | dynamicToolTitle, | 59 | dynamicToolTitle, |
| 60 | isJavaScriptReplTool, | ||
| 60 | formatReadToolLabel, | 61 | formatReadToolLabel, |
| 61 | formatSearchToolLabel, | 62 | formatSearchToolLabel, |
| 62 | } from "@t3tools/shared/toolActivity"; | 63 | } from "@t3tools/shared/toolActivity"; |
| ... | @@ -97,6 +98,7 @@ export interface ThreadFeedActivity { | ... | @@ -97,6 +98,7 @@ export interface ThreadFeedActivity { |
| 97 | | "alert" | 98 | | "alert" |
| 98 | | "browser" | 99 | | "browser" |
| 99 | | "computer" | 100 | | "computer" |
| 101 | | "code" | ||
| 100 | | "check" | 102 | | "check" |
| 101 | | "command" | 103 | | "command" |
| 102 | | "edit" | 104 | | "edit" |
| ... | @@ -556,7 +558,7 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] { | ... | @@ -556,7 +558,7 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] { |
| 556 | case "assistant_message": | 558 | case "assistant_message": |
| 557 | return "message"; | 559 | return "message"; |
| 558 | case "dynamic_tool": | 560 | case "dynamic_tool": |
| 559 | return "wrench"; | 561 | return isJavaScriptReplTool(item.toolName) ? "code" : "wrench"; |
| 560 | case "subagent": | 562 | case "subagent": |
| 561 | return "hammer"; | 563 | return "hammer"; |
| 562 | case "run_interrupt_request": | 564 | case "run_interrupt_request": |
| ... | @@ -598,8 +600,9 @@ function itemSummary( | ... | @@ -598,8 +600,9 @@ function itemSummary( |
| 598 | if (item.type === "system_notice") return item.message; | 600 | if (item.type === "system_notice") return item.message; |
| 599 | if (item.type === "compaction") return contextCompactionLabel(item); | 601 | if (item.type === "compaction") return contextCompactionLabel(item); |
| 600 | const title = | 602 | const title = |
| 601 | (item.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input) : undefined) ?? | 603 | (item.type === "dynamic_tool" |
| 602 | item.title?.trim(); | 604 | ? dynamicToolTitle(item.toolName, item.input, item.title) |
| 605 | : undefined) ?? item.title?.trim(); | ||
| 603 | if (item.type === "subagent") return formatSubagentDisplayTitle(title || "Subagent"); | 606 | if (item.type === "subagent") return formatSubagentDisplayTitle(title || "Subagent"); |
| 604 | if (title) return toolPresentation?.displayName ?? capitalizePhrase(title); | 607 | if (title) return toolPresentation?.displayName ?? capitalizePhrase(title); |
| 605 | switch (item.type) { | 608 | switch (item.type) { |
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+69-23| ... | @@ -827,36 +827,82 @@ describe("CodexAdapterV2 process spawning", () => { | ... | @@ -827,36 +827,82 @@ describe("CodexAdapterV2 process spawning", () => { |
| 827 | }); | 827 | }); |
| 828 | 828 | ||
| 829 | describe("CodexAdapterV2 dynamic tool projection", () => { | 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 | Effect.gen(function* () { | 831 | Effect.gen(function* () { |
| 832 | const call = { | 832 | const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({ |
| 833 | type: "mcpToolCall" as const, | 833 | type: "mcpToolCall", |
| 834 | id: "inspect", | 834 | id: "failed-sky-js", |
| 835 | server: "cua_repl", | 835 | server: "node_repl", |
| 836 | tool: "js", | 836 | tool: "js", |
| 837 | status: "completed" as const, | 837 | status: "failed", |
| 838 | arguments: { | 838 | arguments: { |
| 839 | code: "await game.getAXStateAndScreenshot();", | 839 | title: "Restrict the breakpoint to Git actions", |
| 840 | title: "Inspect Saga music screen", | 840 | code: "await sky.type_text({app:'T3 Code (Nightly)',text:'debug(ga.run)'});", |
| 841 | }, | 841 | }, |
| 842 | result: { content: [] }, | 842 | result: { content: [{ type: "text", text: "Illegal Unicode escape sequence" }] }, |
| 843 | }; | 843 | }); |
| 844 | assert.equal( | 844 | assert.equal(projection.title, "Restrict the breakpoint to Git actions"); |
| 845 | (yield* CodexAdapterV2.projectCodexDynamicToolItem(call)).title, | 845 | assert.equal(projection.toolSurface, "computer"); |
| 846 | "Inspect Saga music screen", | 846 | assert.deepEqual(projection.toolSource, { |
| 847 | ); | 847 | key: "computer-use", |
| 848 | assert.equal( | 848 | name: "Computer Use", |
| 849 | (yield* CodexAdapterV2.projectCodexDynamicToolItem({ ...call, arguments: { title: " " } })) | 849 | kind: "computer", |
| 850 | .title, | 850 | }); |
| 851 | "js", | ||
| 852 | ); | ||
| 853 | assert.equal( | ||
| 854 | (yield* CodexAdapterV2.projectCodexDynamicToolItem({ ...call, server: "github" })).title, | ||
| 855 | "js", | ||
| 856 | ); | ||
| 857 | }).pipe(Effect.provide(NodeServices.layer)), | 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 | it.effect.each(["inProgress", "completed", "failed"] as const)( | 906 | it.effect.each(["inProgress", "completed", "failed"] as const)( |
| 861 | "presents ordinary MCP calls when %s", | 907 | "presents ordinary MCP calls when %s", |
| 862 | (status) => | 908 | (status) => |
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+14-2| ... | @@ -35,7 +35,11 @@ import { | ... | @@ -35,7 +35,11 @@ import { |
| 35 | import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; | 35 | import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; |
| 36 | import { AgentScope } from "@t3tools/shared/AgentScope"; | 36 | import { AgentScope } from "@t3tools/shared/AgentScope"; |
| 37 | import * as HostProcess from "@t3tools/shared/HostProcess"; | 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 | import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; | 43 | import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; |
| 40 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | 44 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 41 | import type { | 45 | import type { |
| ... | @@ -497,9 +501,17 @@ export const projectCodexDynamicToolItem = Effect.fn("CodexAdapterV2.projectDyna | ... | @@ -497,9 +501,17 @@ export const projectCodexDynamicToolItem = Effect.fn("CodexAdapterV2.projectDyna |
| 497 | : [trimText(item.namespace), item.tool].filter(Boolean).join("."); | 501 | : [trimText(item.namespace), item.tool].filter(Boolean).join("."); |
| 498 | const presentation: McpToolPresentation = | 502 | const presentation: McpToolPresentation = |
| 499 | item.type === "mcpToolCall" ? yield* mcpToolPresentation(item) : {}; | 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 | const projection: CodexDynamicToolProjection = { | 511 | const projection: CodexDynamicToolProjection = { |
| 502 | ...presentation, | 512 | ...presentation, |
| 513 | ...(toolSurface ? { toolSurface } : {}), | ||
| 514 | ...(toolSource ? { toolSource } : {}), | ||
| 503 | toolName, | 515 | toolName, |
| 504 | ...(title ? { title } : {}), | 516 | ...(title ? { title } : {}), |
| 505 | input: item.arguments, | 517 | input: item.arguments, |
apps/web/src/components/Icons.tsx-25| ... | @@ -760,28 +760,3 @@ export const MacAccessibilityIcon: Icon = (props) => { | ... | @@ -760,28 +760,3 @@ export const MacAccessibilityIcon: Icon = (props) => { |
| 760 | </svg> | 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,34 +746,70 @@ describe("deriveMessagesTimelineRows", () => { |
| 746 | expect(first.kind === "work" && first.entry.toolLifecycleStatus).toBe("inProgress"); | 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 | const fixture = makeStreamingTimelineFixture(); | 787 | const fixture = makeStreamingTimelineFixture(); |
| 751 | const source = fixture.visibleTurnItems.find((row) => row.item.type === "dynamic_tool")!; | 788 | const source = fixture.visibleTurnItems.find((row) => row.item.type === "dynamic_tool")!; |
| 752 | if (source.item.type !== "dynamic_tool") throw new Error("Expected tool fixture"); | 789 | if (source.item.type !== "dynamic_tool") throw new Error("Expected tool fixture"); |
| 753 | const item = { | 790 | const item = { |
| 754 | ...source.item, | 791 | ...source.item, |
| 755 | title: null, | 792 | type: "dynamic_tool" as const, |
| 756 | toolName: "cua_repl.js", | 793 | status: "failed" as const, |
| 757 | input: { code: "await game.getAXStateAndScreenshot();", title: "Inspect Saga music screen" }, | 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 | const entries = deriveTimelineEntriesFromVisibleTurnItems({ | 802 | const entries = deriveTimelineEntriesFromVisibleTurnItems({ |
| 760 | visibleTurnItems: [{ ...source, item }], | 803 | visibleTurnItems: [{ ...source, item }], |
| 761 | optimisticMessages: [], | 804 | optimisticMessages: [], |
| 762 | }); | 805 | }); |
| 763 | const work = entries.find((entry) => entry.kind === "work"); | 806 | const entry = entries.find((row) => row.kind === "work"); |
| 764 | expect(work?.kind === "work" && workEntryDisplayLabel(work.entry, undefined)).toBe( | 807 | expect(entry?.kind === "work" && workEntryDisplayLabel(entry.entry, undefined)).toBe( |
| 765 | "Inspect Saga music screen", | 808 | "Restrict the breakpoint to Git actions", |
| 766 | ); | 809 | ); |
| 767 | const rows = deriveMessagesTimelineRows({ | 810 | expect(entry?.kind === "work" && entry.entry).toMatchObject({ |
| 768 | timelineEntries: entries, | 811 | toolSurface: "computer", |
| 769 | isWorking: false, | 812 | toolSource: { key: "computer-use", kind: "computer" }, |
| 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", | ||
| 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,7 +97,8 @@ function singleToolCallLabel(entry: WorkLogEntry): string { |
| 97 | const toolPresentation = resolveWorkEntryToolPresentation(entry, "completed"); | 97 | const toolPresentation = resolveWorkEntryToolPresentation(entry, "completed"); |
| 98 | if (toolPresentation) return toolPresentation.displayName; | 98 | if (toolPresentation) return toolPresentation.displayName; |
| 99 | const item = entry.structuredPayload; | 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 | if (title) return title; | 102 | if (title) return title; |
| 102 | // A lone web search keeps its heading; the query stays in its detail. | 103 | // A lone web search keeps its heading; the query stays in its detail. |
| 103 | if (entry.itemType === "web_search") return entry.toolTitle ?? "Web search"; | 104 | if (entry.itemType === "web_search") return entry.toolTitle ?? "Web search"; |
| ... | @@ -157,7 +158,8 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string | ... | @@ -157,7 +158,8 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string |
| 157 | const providerRetry = | 158 | const providerRetry = |
| 158 | entry.projectedItem?.item.type === "error" && entry.projectedItem.item.retry !== undefined; | 159 | entry.projectedItem?.item.type === "error" && entry.projectedItem.item.retry !== undefined; |
| 159 | const item = entry.structuredPayload; | 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 | if (title) return title; | 163 | if (title) return title; |
| 162 | const compactDetail = entry.detail?.trim(); | 164 | const compactDetail = entry.detail?.trim(); |
| 163 | const detailIsSearchOutput = | 165 | const detailIsSearchOutput = |
apps/web/src/components/chat/MessagesTimeline.tsx+16-13| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | import { ThreadFindTimelineContext } from "./ThreadFindProvider"; | 1 | import { ThreadFindTimelineContext } from "./ThreadFindProvider"; |
| 2 | import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline"; | 2 | import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline"; |
| 3 | import { MarkdownFindContext, useFindRevealRef } from "./markdownFindContext"; | 3 | import { MarkdownFindContext, useFindRevealRef } from "./markdownFindContext"; |
| 4 | import { ComputerUseAppIcon } from "~/components/Icons"; | ||
| 5 | import { useChatCanvas } from "./ChatCanvasContext"; | 4 | import { useChatCanvas } from "./ChatCanvasContext"; |
| 6 | import { WorkLogBlock, WorkLogButton, WorkLogDetails, WorkLogList, WorkLogRow } from "./WorkLog"; | 5 | import { WorkLogBlock, WorkLogButton, WorkLogDetails, WorkLogList, WorkLogRow } from "./WorkLog"; |
| 7 | import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; | 6 | import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; |
| ... | @@ -67,7 +66,7 @@ import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; | ... | @@ -67,7 +66,7 @@ import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 67 | import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; | 66 | import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; |
| 68 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; | 67 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 69 | import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon"; | 68 | import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon"; |
| 70 | import { claudeSkillInvocation } from "@t3tools/shared/toolActivity"; | 69 | import { claudeSkillInvocation, isJavaScriptReplTool } from "@t3tools/shared/toolActivity"; |
| 71 | import { observeVisibleAnimation } from "../../lib/visibleAnimation"; | 70 | import { observeVisibleAnimation } from "../../lib/visibleAnimation"; |
| 72 | import { | 71 | import { |
| 73 | createContext, | 72 | createContext, |
| ... | @@ -148,6 +147,7 @@ import { | ... | @@ -148,6 +147,7 @@ import { |
| 148 | Undo2Icon, | 147 | Undo2Icon, |
| 149 | HammerIcon, | 148 | HammerIcon, |
| 150 | WrenchIcon, | 149 | WrenchIcon, |
| 150 | Code2Icon, | ||
| 151 | XIcon, | 151 | XIcon, |
| 152 | ZapIcon, | 152 | ZapIcon, |
| 153 | RotateCcwIcon, | 153 | RotateCcwIcon, |
| ... | @@ -3779,13 +3779,9 @@ function ActivityShimmerOverlay({ children }: { children: ReactNode }) { | ... | @@ -3779,13 +3779,9 @@ function ActivityShimmerOverlay({ children }: { children: ReactNode }) { |
| 3779 | 3779 | ||
| 3780 | const failedToolIconClassName = "text-tool-error-icon/40"; | 3780 | const failedToolIconClassName = "text-tool-error-icon/40"; |
| 3781 | 3781 | ||
| 3782 | /** Image icons and the gradient computer-use mark cannot take a currentColor | 3782 | /** Image icons cannot take a currentColor tint, so failed rows get a trailing x. */ |
| 3783 | * tint, so failed rows using them get a trailing x instead. */ | 3783 | function toolIconAcceptsTint(toolIcon: ToolActivityIcon | undefined): boolean { |
| 3784 | function toolIconAcceptsTint( | 3784 | return toolIcon === undefined; |
| 3785 | iconName: WorkEntryIconName, | ||
| 3786 | toolIcon: ToolActivityIcon | undefined, | ||
| 3787 | ): boolean { | ||
| 3788 | return toolIcon === undefined && iconName !== "computer"; | ||
| 3789 | } | 3785 | } |
| 3790 | 3786 | ||
| 3791 | function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) { | 3787 | function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) { |
| ... | @@ -3951,7 +3947,7 @@ function LiveActivityContent({ | ... | @@ -3951,7 +3947,7 @@ function LiveActivityContent({ |
| 3951 | highlighted?: boolean; | 3947 | highlighted?: boolean; |
| 3952 | }) { | 3948 | }) { |
| 3953 | const showTrailingFailureMark = | 3949 | const showTrailingFailureMark = |
| 3954 | failed && iconName !== undefined && !toolIconAcceptsTint(iconName, toolIcon); | 3950 | failed && iconName !== undefined && !toolIconAcceptsTint(toolIcon); |
| 3955 | 3951 | ||
| 3956 | return ( | 3952 | return ( |
| 3957 | <WorkLogRow | 3953 | <WorkLogRow |
| ... | @@ -5030,6 +5026,7 @@ type WorkEntryIconName = | ... | @@ -5030,6 +5026,7 @@ type WorkEntryIconName = |
| 5030 | | "check" | 5026 | | "check" |
| 5031 | | "circle-alert" | 5027 | | "circle-alert" |
| 5032 | | "computer" | 5028 | | "computer" |
| 5029 | | "code" | ||
| 5033 | | "device" | 5030 | | "device" |
| 5034 | | "eye" | 5031 | | "eye" |
| 5035 | | "globe" | 5032 | | "globe" |
| ... | @@ -5226,9 +5223,10 @@ function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className | ... | @@ -5226,9 +5223,10 @@ function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className |
| 5226 | case "brain": | 5223 | case "brain": |
| 5227 | return <BrainIcon className={className} aria-hidden />; | 5224 | return <BrainIcon className={className} aria-hidden />; |
| 5228 | case "browser": | 5225 | case "browser": |
| 5229 | return <BrowserAppIcon className={className} />; | ||
| 5230 | case "computer": | 5226 | case "computer": |
| 5231 | return <ComputerUseAppIcon className={className} />; | 5227 | return <BrowserAppIcon className={className} />; |
| 5228 | case "code": | ||
| 5229 | return <Code2Icon className={className} aria-hidden />; | ||
| 5232 | case "device": | 5230 | case "device": |
| 5233 | return <SmartphoneIcon className={className} aria-hidden />; | 5231 | return <SmartphoneIcon className={className} aria-hidden />; |
| 5234 | case "t3-code": | 5232 | case "t3-code": |
| ... | @@ -5382,6 +5380,11 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { | ... | @@ -5382,6 +5380,11 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { |
| 5382 | return "message-circle"; | 5380 | return "message-circle"; |
| 5383 | } | 5381 | } |
| 5384 | if (workEntry.toolSurface) return workEntry.toolSurface; | 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 | const toolPresentation = resolveWorkEntryToolPresentation(workEntry); | 5388 | const toolPresentation = resolveWorkEntryToolPresentation(workEntry); |
| 5386 | if (toolPresentation) return toolPresentation.icon; | 5389 | if (toolPresentation) return toolPresentation.icon; |
| 5387 | const action = toolGroupAction(workEntry); | 5390 | const action = toolGroupAction(workEntry); |
| ... | @@ -5794,7 +5797,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { | ... | @@ -5794,7 +5797,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { |
| 5794 | ) : null} | 5797 | ) : null} |
| 5795 | {showFailedIndicator && | 5798 | {showFailedIndicator && |
| 5796 | !showDestructiveRowStyle && | 5799 | !showDestructiveRowStyle && |
| 5797 | !toolIconAcceptsTint(entryIconName, entryToolIcon) ? ( | 5800 | !toolIconAcceptsTint(entryToolIcon) ? ( |
| 5798 | <XIcon aria-hidden className={cn("size-3 shrink-0", failedToolIconClassName)} /> | 5801 | <XIcon aria-hidden className={cn("size-3 shrink-0", failedToolIconClassName)} /> |
| 5799 | ) : null} | 5802 | ) : null} |
| 5800 | <TimelineRowTimestamp createdAt={workEntry.createdAt} timestampFormat={timestampFormat} /> | 5803 | <TimelineRowTimestamp createdAt={workEntry.createdAt} timestampFormat={timestampFormat} /> |
apps/web/src/components/chat/V2ItemInspector.tsx+30-8| ... | @@ -12,6 +12,7 @@ import { | ... | @@ -12,6 +12,7 @@ import { |
| 12 | turnItemOutputText, | 12 | turnItemOutputText, |
| 13 | } from "@t3tools/client-runtime/work-log/item-detail"; | 13 | } from "@t3tools/client-runtime/work-log/item-detail"; |
| 14 | import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; | 14 | import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; |
| 15 | import { isJavaScriptReplTool } from "@t3tools/shared/toolActivity"; | ||
| 15 | import { ExternalLinkIcon, GitBranchIcon, RotateCcwIcon } from "lucide-react"; | 16 | import { ExternalLinkIcon, GitBranchIcon, RotateCcwIcon } from "lucide-react"; |
| 16 | import { memo, Suspense, use, useMemo } from "react"; | 17 | import { memo, Suspense, use, useMemo } from "react"; |
| 17 | 18 | ||
| ... | @@ -43,13 +44,22 @@ interface V2ItemInspectorProps { | ... | @@ -43,13 +44,22 @@ interface V2ItemInspectorProps { |
| 43 | readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; | 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 | const { resolvedTheme } = useTheme(); | 54 | const { resolvedTheme } = useTheme(); |
| 48 | const highlighter = use(getSyntaxHighlighterPromise("json")); | 55 | const highlighter = use(getSyntaxHighlighterPromise(language)); |
| 49 | const { tokens } = useMemo( | 56 | const { tokens } = useMemo( |
| 50 | () => | 57 | () => |
| 51 | highlighter.codeToTokens(text, { lang: "json", theme: resolveDiffThemeName(resolvedTheme) }), | 58 | highlighter.codeToTokens(text, { |
| 52 | [highlighter, text, resolvedTheme], | 59 | lang: language, |
| 60 | theme: resolveDiffThemeName(resolvedTheme), | ||
| 61 | }), | ||
| 62 | [highlighter, text, language, resolvedTheme], | ||
| 53 | ); | 63 | ); |
| 54 | return tokens.flatMap((line, lineIndex) => [ | 64 | return tokens.flatMap((line, lineIndex) => [ |
| 55 | lineIndex > 0 ? "\n" : "", | 65 | lineIndex > 0 ? "\n" : "", |
| ... | @@ -67,9 +77,11 @@ const monoClassName = | ... | @@ -67,9 +77,11 @@ const monoClassName = |
| 67 | function StructuredValue({ | 77 | function StructuredValue({ |
| 68 | value, | 78 | value, |
| 69 | highlightJson = false, | 79 | highlightJson = false, |
| 80 | language, | ||
| 70 | }: { | 81 | }: { |
| 71 | readonly value: unknown; | 82 | readonly value: unknown; |
| 72 | readonly highlightJson?: boolean; | 83 | readonly highlightJson?: boolean; |
| 84 | readonly language?: "typescript"; | ||
| 73 | }) { | 85 | }) { |
| 74 | const text = typeof value === "string" ? value : JSON.stringify(value, null, 2); | 86 | const text = typeof value === "string" ? value : JSON.stringify(value, null, 2); |
| 75 | const isJson = useMemo(() => { | 87 | const isJson = useMemo(() => { |
| ... | @@ -84,10 +96,10 @@ function StructuredValue({ | ... | @@ -84,10 +96,10 @@ function StructuredValue({ |
| 84 | if (!text) return null; | 96 | if (!text) return null; |
| 85 | return ( | 97 | return ( |
| 86 | <pre className={cn("max-h-80 overflow-auto text-muted-foreground", monoClassName)}> | 98 | <pre className={cn("max-h-80 overflow-auto text-muted-foreground", monoClassName)}> |
| 87 | {isJson ? ( | 99 | {language || isJson ? ( |
| 88 | <RenderErrorBoundary fallback={text}> | 100 | <RenderErrorBoundary fallback={text}> |
| 89 | <Suspense fallback={text}> | 101 | <Suspense fallback={text}> |
| 90 | <JsonTokens text={text} /> | 102 | <SyntaxTokens text={text} language={language ?? "json"} /> |
| 91 | </Suspense> | 103 | </Suspense> |
| 92 | </RenderErrorBoundary> | 104 | </RenderErrorBoundary> |
| 93 | ) : ( | 105 | ) : ( |
| ... | @@ -194,6 +206,7 @@ function ToolCallBody( | ... | @@ -194,6 +206,7 @@ function ToolCallBody( |
| 194 | props: ToolOutputState & { | 206 | props: ToolOutputState & { |
| 195 | readonly command?: string; | 207 | readonly command?: string; |
| 196 | readonly args?: unknown; | 208 | readonly args?: unknown; |
| 209 | readonly toolName?: string | null; | ||
| 197 | readonly exitCode?: number | undefined; | 210 | readonly exitCode?: number | undefined; |
| 198 | }, | 211 | }, |
| 199 | ) { | 212 | ) { |
| ... | @@ -206,7 +219,11 @@ function ToolCallBody( | ... | @@ -206,7 +219,11 @@ function ToolCallBody( |
| 206 | {call.args.map(([key, value]) => ( | 219 | {call.args.map(([key, value]) => ( |
| 207 | <div key={key}> | 220 | <div key={key}> |
| 208 | <span className="text-muted-foreground">{key} </span> | 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 | </div> | 227 | </div> |
| 211 | ))} | 228 | ))} |
| 212 | </div> | 229 | </div> |
| ... | @@ -349,7 +366,12 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec | ... | @@ -349,7 +366,12 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec |
| 349 | ) : null} | 366 | ) : null} |
| 350 | 367 | ||
| 351 | {item.type === "dynamic_tool" ? ( | 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 | ) : null} | 375 | ) : null} |
| 354 | 376 | ||
| 355 | {item.type === "approval_request" ? <StructuredValue value={item.prompt} /> : null} | 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,20 +49,27 @@ function WorkLogLine({ icon, label, trailing, wrapLabel }: RowContent) { |
| 49 | <div | 49 | <div |
| 50 | className={cn( | 50 | className={cn( |
| 51 | "flex min-h-6 min-w-0 gap-1.5 text-sm leading-relaxed select-none [&_*]:select-none", | 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 | wrapLabel ? "items-start" : "items-center", | 52 | wrapLabel ? "items-start" : "items-center", |
| 54 | )} | 53 | )} |
| 55 | > | 54 | > |
| 56 | {icon ? ( | 55 | <div className="flex min-w-0 flex-1 items-baseline gap-1.5"> |
| 57 | <span className="relative flex size-6 shrink-0 items-center justify-center">{icon}</span> | 56 | {icon ? ( |
| 58 | ) : null} | 57 | // Align with the first line's capital letters. Centering line boxes drifts |
| 59 | <div | 58 | // when font ascent/descent rounding changes at fractional browser zoom. |
| 60 | className={cn( | 59 | <span className="relative w-6 shrink-0 leading-0"> |
| 61 | "min-w-0 flex-1 text-secondary-label", | 60 | <span className="inline-block size-6 overflow-hidden align-[calc(0.5cap_-_0.75rem)]"> |
| 62 | !wrapLabel && "truncate [&_*]:whitespace-nowrap", | 61 | <span className="flex size-full items-center justify-center">{icon}</span> |
| 63 | )} | 62 | </span> |
| 64 | > | 63 | </span> |
| 65 | {label} | 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 | </div> | 73 | </div> |
| 67 | {trailing} | 74 | {trailing} |
| 68 | </div> | 75 | </div> |
packages/client-runtime/src/work-log/presentation.test.ts+41| ... | @@ -253,6 +253,28 @@ describe("summarizeToolGroup", () => { | ... | @@ -253,6 +253,28 @@ describe("summarizeToolGroup", () => { |
| 253 | ).toBe("Used Chrome integration and ran 1 command"); | 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 | it("omits the integration suffix for special browser and computer sources", () => { | 278 | it("omits the integration suffix for special browser and computer sources", () => { |
| 257 | expect( | 279 | expect( |
| 258 | summarizeToolGroup([ | 280 | summarizeToolGroup([ |
| ... | @@ -267,6 +289,25 @@ describe("summarizeToolGroup", () => { | ... | @@ -267,6 +289,25 @@ describe("summarizeToolGroup", () => { |
| 267 | ]).summary, | 289 | ]).summary, |
| 268 | ).toBe("Used Browser and Computer Use"); | 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 | describe("resolveWorkEntryToolPresentation", () => { | 313 | describe("resolveWorkEntryToolPresentation", () => { |
packages/client-runtime/src/work-log/presentation.ts+2-2| ... | @@ -697,7 +697,7 @@ export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEnt | ... | @@ -697,7 +697,7 @@ export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEnt |
| 697 | const labels = selected.map(({ label }) => label); | 697 | const labels = selected.map(({ label }) => label); |
| 698 | if (sources.size > 0) { | 698 | if (sources.size > 0) { |
| 699 | const sourceValues = [...sources.values()]; | 699 | const sourceValues = [...sources.values()]; |
| 700 | const sourceNames = sourceValues.map((source) => source.name); | 700 | const sourceNames = [...new Set(sourceValues.map((source) => source.name))]; |
| 701 | const formattedNames = | 701 | const formattedNames = |
| 702 | sourceNames.length < 2 | 702 | sourceNames.length < 2 |
| 703 | ? sourceNames[0]! | 703 | ? sourceNames[0]! |
| ... | @@ -706,7 +706,7 @@ export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEnt | ... | @@ -706,7 +706,7 @@ export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEnt |
| 706 | : `${sourceNames.slice(0, -1).join(", ")}, and ${sourceNames.at(-1)}`; | 706 | : `${sourceNames.slice(0, -1).join(", ")}, and ${sourceNames.at(-1)}`; |
| 707 | const allIntegrations = sourceValues.every((source) => source.kind === "integration"); | 707 | const allIntegrations = sourceValues.every((source) => source.kind === "integration"); |
| 708 | labels.unshift( | 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 | const sourcedCount = entries.filter( | 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,6 +3,49 @@ import { describe, expect, it } from "@effect/vitest"; |
| 3 | import { extractToolActivityPresentation } from "./toolPresentation.ts"; | 3 | import { extractToolActivityPresentation } from "./toolPresentation.ts"; |
| 4 | 4 | ||
| 5 | describe("extractToolActivityPresentation", () => { | 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 | it("retains valid image paths independently of redacted output", () => { | 49 | it("retains valid image paths independently of redacted output", () => { |
| 7 | expect( | 50 | expect( |
| 8 | extractToolActivityPresentation({ viewedImagePath: " /workspace/reference.webp " }), | 51 | extractToolActivityPresentation({ viewedImagePath: " /workspace/reference.webp " }), |
packages/client-runtime/src/work-log/toolPresentation.ts+14-2| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; | 1 | import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; |
| 2 | import { COMPUTER_USE_TOOL_SOURCE, dynamicToolSurface } from "@t3tools/shared/toolActivity"; | ||
| 2 | import type { | 3 | import type { |
| 3 | ToolActivityIcon, | 4 | ToolActivityIcon, |
| 4 | ToolActivityNativeAppReference, | 5 | ToolActivityNativeAppReference, |
| ... | @@ -111,12 +112,23 @@ export function extractToolActivityPresentation( | ... | @@ -111,12 +112,23 @@ export function extractToolActivityPresentation( |
| 111 | payloadValue: unknown, | 112 | payloadValue: unknown, |
| 112 | ): ExtractedToolActivityPresentation { | 113 | ): ExtractedToolActivityPresentation { |
| 113 | const payload = asRecord(payloadValue); | 114 | const payload = asRecord(payloadValue); |
| 115 | const retainedSource = activitySource(payload?.toolSource); | ||
| 114 | const toolSurface = | 116 | const toolSurface = |
| 115 | payload?.toolSurface === "browser" || payload?.toolSurface === "computer" | 117 | payload?.toolSurface === "browser" || payload?.toolSurface === "computer" |
| 116 | ? payload.toolSurface | 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 | const toolIcon = activityIcon(payload?.toolIcon); | 131 | const toolIcon = activityIcon(payload?.toolIcon); |
| 119 | const toolSource = activitySource(payload?.toolSource); | ||
| 120 | const viewedImagePath = trimmedString(payload?.viewedImagePath, 4096); | 132 | const viewedImagePath = trimmedString(payload?.viewedImagePath, 4096); |
| 121 | return { | 133 | return { |
| 122 | ...(toolSurface ? { toolSurface } : {}), | 134 | ...(toolSurface ? { toolSurface } : {}), |
packages/shared/src/toolActivity.test.ts+39| ... | @@ -6,6 +6,7 @@ import { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | collectToolFilePaths, | 6 | collectToolFilePaths, |
| 7 | deriveToolActivityPresentation, | 7 | deriveToolActivityPresentation, |
| 8 | dynamicToolTitle, | 8 | dynamicToolTitle, |
| 9 | dynamicToolSurface, | ||
| 9 | formatReadToolLabel, | 10 | formatReadToolLabel, |
| 10 | formatSearchToolLabel, | 11 | formatSearchToolLabel, |
| 11 | mergeToolActivityData, | 12 | mergeToolActivityData, |
| ... | @@ -123,6 +124,44 @@ describe("toolActivity", () => { | ... | @@ -123,6 +124,44 @@ describe("toolActivity", () => { |
| 123 | ).toEqual({ rawInput: { path: "src/a.ts", startLine: 4 } }); | 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 | it("titles Claude skill calls with the skill they load", () => { | 165 | it("titles Claude skill calls with the skill they load", () => { |
| 127 | expect(dynamicToolTitle("Skill", { skill: "full-send" })).toBe("Skill: full-send"); | 166 | expect(dynamicToolTitle("Skill", { skill: "full-send" })).toBe("Skill: full-send"); |
| 128 | expect(claudeSkillInvocation("Skill", { skill: "claude-api", args: " pricing " })).toEqual({ | 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,15 +25,48 @@ export function claudeSkillInvocation( |
| 25 | return name === undefined ? undefined : { name, args: asTrimmedString(record?.args) }; | 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 | * or the skill a Claude `Skill` call loads. | 51 | * or the skill a Claude `Skill` call loads. |
| 31 | */ | 52 | */ |
| 32 | export function dynamicToolTitle( | 53 | export function dynamicToolTitle( |
| 33 | toolName: string | null | undefined, | 54 | toolName: string | null | undefined, |
| 34 | input: unknown, | 55 | input: unknown, |
| 56 | retainedTitle?: string | null, | ||
| 35 | ): string | undefined { | 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 | const skill = claudeSkillInvocation(toolName, input); | 70 | const skill = claudeSkillInvocation(toolName, input); |
| 38 | return skill === undefined ? undefined : `Skill: ${skill.name}`; | 71 | return skill === undefined ? undefined : `Skill: ${skill.name}`; |
| 39 | } | 72 | } |