authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-10-12 20:08:05-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-12 20:08:05-05:00
log5d6e4192bfcf9eca09741605e12c72b5970025e4
tree9041c1daa4df47ce2558001ae4fcada8ee41c348
parente2e95a39d309e1ffec901682cc88ea1ba276c868
signature Signed by PGP key B5690EEEBB952194

fix: show computer-use action titles and align app icons (#18865)


18 files changed, 542 insertions(+), 129 deletions(-)

apps/mobile/src/components/BrowserAppIcon.tsx created+33
...@@ -0,0 +1,33 @@
1import type { ColorValue } from "react-native";
2import Svg, { Circle, Path } from "react-native-svg";
3import { withUniwind } from "uniwind";
4
5const ThemedSvg = withUniwind(Svg);
6
7/** Matches the window-and-pointer icon used by the web work log. */
8export 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";
53import { environmentThreadDetails, threadEnvironment } from "../../state/threads";53import { environmentThreadDetails, threadEnvironment } from "../../state/threads";
54import { useAtomCommand } from "../../state/use-atom-command";54import { useAtomCommand } from "../../state/use-atom-command";
55import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";55import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";
56import { isJavaScriptReplTool } from "@t3tools/shared/toolActivity";
57import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
5658
57import { AppText as Text } from "../../components/AppText";59import { AppText as Text } from "../../components/AppText";
58import { T3Wordmark } from "../../components/T3Wordmark";60import { T3Wordmark } from "../../components/T3Wordmark";
61import { BrowserAppIcon } from "../../components/BrowserAppIcon";
59import { cn } from "../../lib/cn";62import { cn } from "../../lib/cn";
60import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout";63import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout";
61import {64import {
...@@ -111,7 +114,13 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180;...@@ -111,7 +114,13 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180;
111const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);114const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
112const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);115const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
113const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);116const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);
114type WorkContentIcon = AppSymbolName | "browser" | "device" | "t3-code" | "pull-request";117type WorkContentIcon =
118 | AppSymbolName
119 | "browser"
120 | "computer"
121 | "device"
122 | "t3-code"
123 | "pull-request";
115124
116function WorkLogIcon(props: {125function 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 <SymbolView146 <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}
374391
375function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName {392function 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}
834853
854function 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
835const ThreadWorkLogRow = memo(function ThreadWorkLogRow(886const 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 <Text1249 typeof line === "string" ? (
1186 key={`${index}:${line}`}1250 <Text
1187 selectable1251 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 });
14521452
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 });
14641502
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});
828828
829describe("CodexAdapterV2 dynamic tool projection", () => {829describe("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 );
859853
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 {
35import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens";35import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens";
36import { AgentScope } from "@t3tools/shared/AgentScope";36import { AgentScope } from "@t3tools/shared/AgentScope";
37import * as HostProcess from "@t3tools/shared/HostProcess";37import * as HostProcess from "@t3tools/shared/HostProcess";
38import { dynamicToolTitle } from "@t3tools/shared/toolActivity";38import {
39 COMPUTER_USE_TOOL_SOURCE,
40 dynamicToolSurface,
41 dynamicToolTitle,
42} from "@t3tools/shared/toolActivity";
39import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model";43import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model";
40import { resolveSpawnCommand } from "@t3tools/shared/shell";44import { resolveSpawnCommand } from "@t3tools/shared/shell";
41import type {45import 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.
765export 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 });
748748
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 });
779815
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 @@
1import { ThreadFindTimelineContext } from "./ThreadFindProvider";1import { ThreadFindTimelineContext } from "./ThreadFindProvider";
2import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline";2import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline";
3import { MarkdownFindContext, useFindRevealRef } from "./markdownFindContext";3import { MarkdownFindContext, useFindRevealRef } from "./markdownFindContext";
4import { ComputerUseAppIcon } from "~/components/Icons";
5import { useChatCanvas } from "./ChatCanvasContext";4import { useChatCanvas } from "./ChatCanvasContext";
6import { WorkLogBlock, WorkLogButton, WorkLogDetails, WorkLogList, WorkLogRow } from "./WorkLog";5import { WorkLogBlock, WorkLogButton, WorkLogDetails, WorkLogList, WorkLogRow } from "./WorkLog";
7import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";6import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
...@@ -67,7 +66,7 @@ import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";...@@ -67,7 +66,7 @@ import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
67import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";66import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";
68import { formatDuration } from "@t3tools/shared/orchestrationTiming";67import { formatDuration } from "@t3tools/shared/orchestrationTiming";
69import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon";68import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon";
70import { claudeSkillInvocation } from "@t3tools/shared/toolActivity";69import { claudeSkillInvocation, isJavaScriptReplTool } from "@t3tools/shared/toolActivity";
71import { observeVisibleAnimation } from "../../lib/visibleAnimation";70import { observeVisibleAnimation } from "../../lib/visibleAnimation";
72import {71import {
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 }) {
37793779
3780const failedToolIconClassName = "text-tool-error-icon/40";3780const failedToolIconClassName = "text-tool-error-icon/40";
37813781
3782/** Image icons and the gradient computer-use mark cannot take a currentColor3782/** 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. */3783function toolIconAcceptsTint(toolIcon: ToolActivityIcon | undefined): boolean {
3784function toolIconAcceptsTint(3784 return toolIcon === undefined;
3785 iconName: WorkEntryIconName,
3786 toolIcon: ToolActivityIcon | undefined,
3787): boolean {
3788 return toolIcon === undefined && iconName !== "computer";
3789}3785}
37903786
3791function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {3787function 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);
39553951
3956 return (3952 return (
3957 <WorkLogRow3953 <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";
14import { formatDiffCount } from "@t3tools/client-runtime/diff-count";14import { formatDiffCount } from "@t3tools/client-runtime/diff-count";
15import { isJavaScriptReplTool } from "@t3tools/shared/toolActivity";
15import { ExternalLinkIcon, GitBranchIcon, RotateCcwIcon } from "lucide-react";16import { ExternalLinkIcon, GitBranchIcon, RotateCcwIcon } from "lucide-react";
16import { memo, Suspense, use, useMemo } from "react";17import { memo, Suspense, use, useMemo } from "react";
1718
...@@ -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}
4546
46function JsonTokens({ text }: { readonly text: string }) {47function 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 =
67function StructuredValue({77function 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}
350367
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}
354376
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 <div49 <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 <div58 // 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 });
255255
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});
271312
272describe("resolveWorkEntryToolPresentation", () => {313describe("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 < 2702 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";
3import { extractToolActivityPresentation } from "./toolPresentation.ts";3import { extractToolActivityPresentation } from "./toolPresentation.ts";
44
5describe("extractToolActivityPresentation", () => {5describe("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 @@
1import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";1import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
2import { COMPUTER_USE_TOOL_SOURCE, dynamicToolSurface } from "@t3tools/shared/toolActivity";
2import type {3import 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.toolSurface118 ? 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 });
125126
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}
2727
28export function isJavaScriptReplTool(toolName: string | null | undefined): boolean {
29 return toolName === "js" || toolName === "cua_repl.js" || toolName === "node_repl.js";
30}
31
32export 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. */
39export 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 */
32export function dynamicToolTitle(53export 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}