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";
5353import { environmentThreadDetails, threadEnvironment } from "../../state/threads";
5454import { useAtomCommand } from "../../state/use-atom-command";
5555import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";
56import { isJavaScriptReplTool } from "@t3tools/shared/toolActivity";
57import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
5658
5759import { AppText as Text } from "../../components/AppText";
5860import { T3Wordmark } from "../../components/T3Wordmark";
61import { BrowserAppIcon } from "../../components/BrowserAppIcon";
5962import { cn } from "../../lib/cn";
6063import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout";
6164import {
......@@ -111,7 +114,13 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180;
111114const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
112115const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
113116const 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
116125function WorkLogIcon(props: {
117126 readonly icon: WorkContentIcon;
......@@ -125,6 +134,14 @@ function WorkLogIcon(props: {
125134 <T3Wordmark height={10} {...(colorClassName ? { colorClassName } : { color: props.color })} />
126135 );
127136 }
137 if (props.icon === "computer") {
138 return (
139 <BrowserAppIcon
140 size={14}
141 {...(colorClassName ? { colorClassName } : { color: props.color })}
142 />
143 );
144 }
128145 return (
129146 <SymbolView
130147 name={
......@@ -372,7 +389,7 @@ export function ShimmeringWorkContent(props: {
372389 );
373390}
374391
375function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName {
392function workRowSymbolName(icon: ThreadFeedActivity["icon"]): WorkContentIcon {
376393 switch (icon) {
377394 case "agent":
378395 return { ios: "sparkles", android: "auto_awesome" };
......@@ -385,7 +402,9 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName {
385402 case "command":
386403 return { ios: "terminal", android: "terminal" };
387404 case "computer":
388 return { ios: "desktopcomputer", android: "desktop_windows" };
405 return "computer";
406 case "code":
407 return "chevron.left.forwardslash.chevron.right";
389408 case "edit":
390409 return { ios: "square.and.pencil", android: "edit" };
391410 case "eye":
......@@ -832,6 +851,38 @@ function WorkspacePreparationRetryButton(props: {
832851 );
833852}
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
835886const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
836887 props: Omit<
837888 ThreadWorkLogProps,
......@@ -1177,19 +1228,36 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
11771228 ) : call ? (
11781229 [
11791230 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 ),
11811245 call.argsText,
11821246 ]
1183 .filter((line): line is string => Boolean(line))
1184 .map((line, index) => (
1185 <Text
1186 key={`${index}:${line}`}
1187 selectable
1188 className="font-mono text-2xs leading-normal text-foreground"
1189 >
1190 {line}
1191 </Text>
1192 ))
1247 .filter((line) => Boolean(line))
1248 .map((line, index) =>
1249 typeof line === "string" ? (
1250 <Text
1251 key={`${index}:${line}`}
1252 selectable
1253 className="font-mono text-2xs leading-normal text-foreground"
1254 >
1255 {line}
1256 </Text>
1257 ) : (
1258 line
1259 ),
1260 )
11931261 ) : fullDetail ? (
11941262 <Text selectable className="font-mono text-2xs leading-normal text-foreground-muted">
11951263 {fullDetail}
apps/mobile/src/lib/threadActivity.test.ts+43-5
......@@ -1450,16 +1450,54 @@ describe("buildThreadFeed", () => {
14501450 expect(activity?.getCopyText().split("\n")[0]).toBe("Read a T3 thread");
14511451 });
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) => {
14541485 const item: OrchestrationV2TurnItem = {
1455 ...base("cua", "2026-09-23T20:20:00.000Z", 1),
1486 ...base("retained-js", "2026-09-23T20:20:00.000Z", 1),
14561487 type: "dynamic_tool",
1457 toolName: "cua_repl.js",
1458 input: { code: "await game.getAXStateAndScreenshot();", title: "Inspect Saga music screen" },
1488 status,
1489 title: "js",
1490 toolName: "node_repl.js",
1491 toolSource: { key: "mcp:node_repl", name: "node repl", kind: "integration" },
1492 input: { code, title },
14591493 };
14601494 const feed = buildThreadFeed([projected(item, 0)]);
14611495 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 );
14631501 });
14641502
14651503 it("uses canonical T3 orchestration summaries in compact work groups", () => {
apps/mobile/src/lib/threadActivity.ts+6-3
......@@ -57,6 +57,7 @@ import {
5757 classifyToolActivity,
5858 collectToolFilePaths,
5959 dynamicToolTitle,
60 isJavaScriptReplTool,
6061 formatReadToolLabel,
6162 formatSearchToolLabel,
6263} from "@t3tools/shared/toolActivity";
......@@ -97,6 +98,7 @@ export interface ThreadFeedActivity {
9798 | "alert"
9899 | "browser"
99100 | "computer"
101 | "code"
100102 | "check"
101103 | "command"
102104 | "edit"
......@@ -556,7 +558,7 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] {
556558 case "assistant_message":
557559 return "message";
558560 case "dynamic_tool":
559 return "wrench";
561 return isJavaScriptReplTool(item.toolName) ? "code" : "wrench";
560562 case "subagent":
561563 return "hammer";
562564 case "run_interrupt_request":
......@@ -598,8 +600,9 @@ function itemSummary(
598600 if (item.type === "system_notice") return item.message;
599601 if (item.type === "compaction") return contextCompactionLabel(item);
600602 const title =
601 (item.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input) : undefined) ??
602 item.title?.trim();
603 (item.type === "dynamic_tool"
604 ? dynamicToolTitle(item.toolName, item.input, item.title)
605 : undefined) ?? item.title?.trim();
603606 if (item.type === "subagent") return formatSubagentDisplayTitle(title || "Subagent");
604607 if (title) return toolPresentation?.displayName ?? capitalizePhrase(title);
605608 switch (item.type) {
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+69-23
......@@ -827,36 +827,82 @@ describe("CodexAdapterV2 process spawning", () => {
827827});
828828
829829describe("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", () =>
831831 Effect.gen(function* () {
832 const call = {
833 type: "mcpToolCall" as const,
834 id: "inspect",
835 server: "cua_repl",
832 const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({
833 type: "mcpToolCall",
834 id: "failed-sky-js",
835 server: "node_repl",
836836 tool: "js",
837 status: "completed" as const,
837 status: "failed",
838838 arguments: {
839 code: "await game.getAXStateAndScreenshot();",
840 title: "Inspect Saga music screen",
839 title: "Restrict the breakpoint to Git actions",
840 code: "await sky.type_text({app:'T3 Code (Nightly)',text:'debug(ga.run)'});",
841841 },
842 result: { content: [] },
843 };
844 assert.equal(
845 (yield* CodexAdapterV2.projectCodexDynamicToolItem(call)).title,
846 "Inspect Saga music screen",
847 );
848 assert.equal(
849 (yield* CodexAdapterV2.projectCodexDynamicToolItem({ ...call, arguments: { title: " " } }))
850 .title,
851 "js",
852 );
853 assert.equal(
854 (yield* CodexAdapterV2.projectCodexDynamicToolItem({ ...call, server: "github" })).title,
855 "js",
856 );
842 result: { content: [{ type: "text", text: "Illegal Unicode escape sequence" }] },
843 });
844 assert.equal(projection.title, "Restrict the breakpoint to Git actions");
845 assert.equal(projection.toolSurface, "computer");
846 assert.deepEqual(projection.toolSource, {
847 key: "computer-use",
848 name: "Computer Use",
849 kind: "computer",
850 });
857851 }).pipe(Effect.provide(NodeServices.layer)),
858852 );
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
860906 it.effect.each(["inProgress", "completed", "failed"] as const)(
861907 "presents ordinary MCP calls when %s",
862908 (status) =>
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+14-2
......@@ -35,7 +35,11 @@ import {
3535import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens";
3636import { AgentScope } from "@t3tools/shared/AgentScope";
3737import * 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";
3943import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model";
4044import { resolveSpawnCommand } from "@t3tools/shared/shell";
4145import type {
......@@ -497,9 +501,17 @@ export const projectCodexDynamicToolItem = Effect.fn("CodexAdapterV2.projectDyna
497501 : [trimText(item.namespace), item.tool].filter(Boolean).join(".");
498502 const presentation: McpToolPresentation =
499503 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;
501511 const projection: CodexDynamicToolProjection = {
502512 ...presentation,
513 ...(toolSurface ? { toolSurface } : {}),
514 ...(toolSource ? { toolSource } : {}),
503515 toolName,
504516 ...(title ? { title } : {}),
505517 input: item.arguments,
apps/web/src/components/Icons.tsx-25
......@@ -760,28 +760,3 @@ export const MacAccessibilityIcon: Icon = (props) => {
760760 </svg>
761761 );
762762};
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", () => {
746746 expect(first.kind === "work" && first.entry.toolLifecycleStatus).toBe("inProgress");
747747 });
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", () => {
750787 const fixture = makeStreamingTimelineFixture();
751788 const source = fixture.visibleTurnItems.find((row) => row.item.type === "dynamic_tool")!;
752789 if (source.item.type !== "dynamic_tool") throw new Error("Expected tool fixture");
753790 const item = {
754791 ...source.item,
755 title: null,
756 toolName: "cua_repl.js",
757 input: { code: "await game.getAXStateAndScreenshot();", title: "Inspect Saga music screen" },
792 type: "dynamic_tool" as const,
793 status: "failed" as const,
794 title: "js",
795 toolName: "node_repl.js",
796 toolSource: { key: "mcp:node_repl", name: "node repl", kind: "integration" as const },
797 input: {
798 title: "Restrict the breakpoint to Git actions",
799 code: "await sky.type_text({app:'T3 Code (Nightly)',text:'debug(ga.run)'});",
800 },
758801 };
759802 const entries = deriveTimelineEntriesFromVisibleTurnItems({
760803 visibleTurnItems: [{ ...source, item }],
761804 optimisticMessages: [],
762805 });
763 const work = entries.find((entry) => entry.kind === "work");
764 expect(work?.kind === "work" && workEntryDisplayLabel(work.entry, undefined)).toBe(
765 "Inspect Saga music screen",
806 const entry = entries.find((row) => row.kind === "work");
807 expect(entry?.kind === "work" && workEntryDisplayLabel(entry.entry, undefined)).toBe(
808 "Restrict the breakpoint to Git actions",
766809 );
767 const rows = deriveMessagesTimelineRows({
768 timelineEntries: entries,
769 isWorking: false,
770 runningRunId: fixture.runId,
771 activeTurnStartedAt: fixture.time(0),
772 turnDiffSummaries: [],
773 supportsConversationRollback: false,
774 });
775 expect(rows.find((row) => row.kind === "work")).toMatchObject({
776 displayLabel: "Inspect Saga music screen",
810 expect(entry?.kind === "work" && entry.entry).toMatchObject({
811 toolSurface: "computer",
812 toolSource: { key: "computer-use", kind: "computer" },
777813 });
778814 });
779815
apps/web/src/components/chat/MessagesTimeline.logic.ts+4-2
......@@ -97,7 +97,8 @@ function singleToolCallLabel(entry: WorkLogEntry): string {
9797 const toolPresentation = resolveWorkEntryToolPresentation(entry, "completed");
9898 if (toolPresentation) return toolPresentation.displayName;
9999 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;
101102 if (title) return title;
102103 // A lone web search keeps its heading; the query stays in its detail.
103104 if (entry.itemType === "web_search") return entry.toolTitle ?? "Web search";
......@@ -157,7 +158,8 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string
157158 const providerRetry =
158159 entry.projectedItem?.item.type === "error" && entry.projectedItem.item.retry !== undefined;
159160 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;
161163 if (title) return title;
162164 const compactDetail = entry.detail?.trim();
163165 const detailIsSearchOutput =
apps/web/src/components/chat/MessagesTimeline.tsx+16-13
......@@ -1,7 +1,6 @@
11import { ThreadFindTimelineContext } from "./ThreadFindProvider";
22import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline";
33import { MarkdownFindContext, useFindRevealRef } from "./markdownFindContext";
4import { ComputerUseAppIcon } from "~/components/Icons";
54import { useChatCanvas } from "./ChatCanvasContext";
65import { WorkLogBlock, WorkLogButton, WorkLogDetails, WorkLogList, WorkLogRow } from "./WorkLog";
76import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
......@@ -67,7 +66,7 @@ import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
6766import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";
6867import { formatDuration } from "@t3tools/shared/orchestrationTiming";
6968import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon";
70import { claudeSkillInvocation } from "@t3tools/shared/toolActivity";
69import { claudeSkillInvocation, isJavaScriptReplTool } from "@t3tools/shared/toolActivity";
7170import { observeVisibleAnimation } from "../../lib/visibleAnimation";
7271import {
7372 createContext,
......@@ -148,6 +147,7 @@ import {
148147 Undo2Icon,
149148 HammerIcon,
150149 WrenchIcon,
150 Code2Icon,
151151 XIcon,
152152 ZapIcon,
153153 RotateCcwIcon,
......@@ -3779,13 +3779,9 @@ function ActivityShimmerOverlay({ children }: { children: ReactNode }) {
37793779
37803780const failedToolIconClassName = "text-tool-error-icon/40";
37813781
3782/** Image icons and the gradient computer-use mark cannot take a currentColor
3783 * tint, so failed rows using them get a trailing x instead. */
3784function toolIconAcceptsTint(
3785 iconName: WorkEntryIconName,
3786 toolIcon: ToolActivityIcon | undefined,
3787): boolean {
3788 return toolIcon === undefined && iconName !== "computer";
3782/** Image icons cannot take a currentColor tint, so failed rows get a trailing x. */
3783function toolIconAcceptsTint(toolIcon: ToolActivityIcon | undefined): boolean {
3784 return toolIcon === undefined;
37893785}
37903786
37913787function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
......@@ -3951,7 +3947,7 @@ function LiveActivityContent({
39513947 highlighted?: boolean;
39523948}) {
39533949 const showTrailingFailureMark =
3954 failed && iconName !== undefined && !toolIconAcceptsTint(iconName, toolIcon);
3950 failed && iconName !== undefined && !toolIconAcceptsTint(toolIcon);
39553951
39563952 return (
39573953 <WorkLogRow
......@@ -5030,6 +5026,7 @@ type WorkEntryIconName =
50305026 | "check"
50315027 | "circle-alert"
50325028 | "computer"
5029 | "code"
50335030 | "device"
50345031 | "eye"
50355032 | "globe"
......@@ -5226,9 +5223,10 @@ function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className
52265223 case "brain":
52275224 return <BrainIcon className={className} aria-hidden />;
52285225 case "browser":
5229 return <BrowserAppIcon className={className} />;
52305226 case "computer":
5231 return <ComputerUseAppIcon className={className} />;
5227 return <BrowserAppIcon className={className} />;
5228 case "code":
5229 return <Code2Icon className={className} aria-hidden />;
52325230 case "device":
52335231 return <SmartphoneIcon className={className} aria-hidden />;
52345232 case "t3-code":
......@@ -5382,6 +5380,11 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
53825380 return "message-circle";
53835381 }
53845382 if (workEntry.toolSurface) return workEntry.toolSurface;
5383 if (
5384 workEntry.structuredPayload?.type === "dynamic_tool" &&
5385 isJavaScriptReplTool(workEntry.structuredPayload.toolName)
5386 )
5387 return "code";
53855388 const toolPresentation = resolveWorkEntryToolPresentation(workEntry);
53865389 if (toolPresentation) return toolPresentation.icon;
53875390 const action = toolGroupAction(workEntry);
......@@ -5794,7 +5797,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
57945797 ) : null}
57955798 {showFailedIndicator &&
57965799 !showDestructiveRowStyle &&
5797 !toolIconAcceptsTint(entryIconName, entryToolIcon) ? (
5800 !toolIconAcceptsTint(entryToolIcon) ? (
57985801 <XIcon aria-hidden className={cn("size-3 shrink-0", failedToolIconClassName)} />
57995802 ) : null}
58005803 <TimelineRowTimestamp createdAt={workEntry.createdAt} timestampFormat={timestampFormat} />
apps/web/src/components/chat/V2ItemInspector.tsx+30-8
......@@ -12,6 +12,7 @@ import {
1212 turnItemOutputText,
1313} from "@t3tools/client-runtime/work-log/item-detail";
1414import { formatDiffCount } from "@t3tools/client-runtime/diff-count";
15import { isJavaScriptReplTool } from "@t3tools/shared/toolActivity";
1516import { ExternalLinkIcon, GitBranchIcon, RotateCcwIcon } from "lucide-react";
1617import { memo, Suspense, use, useMemo } from "react";
1718
......@@ -43,13 +44,22 @@ interface V2ItemInspectorProps {
4344 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
4445}
4546
46function JsonTokens({ text }: { readonly text: string }) {
47function SyntaxTokens({
48 text,
49 language,
50}: {
51 readonly text: string;
52 readonly language: "json" | "typescript";
53}) {
4754 const { resolvedTheme } = useTheme();
48 const highlighter = use(getSyntaxHighlighterPromise("json"));
55 const highlighter = use(getSyntaxHighlighterPromise(language));
4956 const { tokens } = useMemo(
5057 () =>
51 highlighter.codeToTokens(text, { lang: "json", theme: resolveDiffThemeName(resolvedTheme) }),
52 [highlighter, text, resolvedTheme],
58 highlighter.codeToTokens(text, {
59 lang: language,
60 theme: resolveDiffThemeName(resolvedTheme),
61 }),
62 [highlighter, text, language, resolvedTheme],
5363 );
5464 return tokens.flatMap((line, lineIndex) => [
5565 lineIndex > 0 ? "\n" : "",
......@@ -67,9 +77,11 @@ const monoClassName =
6777function StructuredValue({
6878 value,
6979 highlightJson = false,
80 language,
7081}: {
7182 readonly value: unknown;
7283 readonly highlightJson?: boolean;
84 readonly language?: "typescript";
7385}) {
7486 const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
7587 const isJson = useMemo(() => {
......@@ -84,10 +96,10 @@ function StructuredValue({
8496 if (!text) return null;
8597 return (
8698 <pre className={cn("max-h-80 overflow-auto text-muted-foreground", monoClassName)}>
87 {isJson ? (
99 {language || isJson ? (
88100 <RenderErrorBoundary fallback={text}>
89101 <Suspense fallback={text}>
90 <JsonTokens text={text} />
102 <SyntaxTokens text={text} language={language ?? "json"} />
91103 </Suspense>
92104 </RenderErrorBoundary>
93105 ) : (
......@@ -194,6 +206,7 @@ function ToolCallBody(
194206 props: ToolOutputState & {
195207 readonly command?: string;
196208 readonly args?: unknown;
209 readonly toolName?: string | null;
197210 readonly exitCode?: number | undefined;
198211 },
199212) {
......@@ -206,7 +219,11 @@ function ToolCallBody(
206219 {call.args.map(([key, value]) => (
207220 <div key={key}>
208221 <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 )}
210227 </div>
211228 ))}
212229 </div>
......@@ -349,7 +366,12 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
349366 ) : null}
350367
351368 {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 />
353375 ) : null}
354376
355377 {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) {
4949 <div
5050 className={cn(
5151 "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.
5352 wrapLabel ? "items-start" : "items-center",
5453 )}
5554 >
56 {icon ? (
57 <span className="relative flex size-6 shrink-0 items-center justify-center">{icon}</span>
58 ) : null}
59 <div
60 className={cn(
61 "min-w-0 flex-1 text-secondary-label",
62 !wrapLabel && "truncate [&_*]:whitespace-nowrap",
63 )}
64 >
65 {label}
55 <div className="flex min-w-0 flex-1 items-baseline gap-1.5">
56 {icon ? (
57 // Align with the first line's capital letters. Centering line boxes drifts
58 // when font ascent/descent rounding changes at fractional browser zoom.
59 <span className="relative w-6 shrink-0 leading-0">
60 <span className="inline-block size-6 overflow-hidden align-[calc(0.5cap_-_0.75rem)]">
61 <span className="flex size-full items-center justify-center">{icon}</span>
62 </span>
63 </span>
64 ) : null}
65 <div
66 className={cn(
67 "min-w-0 flex-1 text-secondary-label",
68 !wrapLabel && "truncate [&_*]:whitespace-nowrap",
69 )}
70 >
71 {label}
72 </div>
6673 </div>
6774 {trailing}
6875 </div>
packages/client-runtime/src/work-log/presentation.test.ts+41
......@@ -253,6 +253,28 @@ describe("summarizeToolGroup", () => {
253253 ).toBe("Used Chrome integration and ran 1 command");
254254 });
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
256278 it("omits the integration suffix for special browser and computer sources", () => {
257279 expect(
258280 summarizeToolGroup([
......@@ -267,6 +289,25 @@ describe("summarizeToolGroup", () => {
267289 ]).summary,
268290 ).toBe("Used Browser and Computer Use");
269291 });
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 });
270311});
271312
272313describe("resolveWorkEntryToolPresentation", () => {
packages/client-runtime/src/work-log/presentation.ts+2-2
......@@ -697,7 +697,7 @@ export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEnt
697697 const labels = selected.map(({ label }) => label);
698698 if (sources.size > 0) {
699699 const sourceValues = [...sources.values()];
700 const sourceNames = sourceValues.map((source) => source.name);
700 const sourceNames = [...new Set(sourceValues.map((source) => source.name))];
701701 const formattedNames =
702702 sourceNames.length < 2
703703 ? sourceNames[0]!
......@@ -706,7 +706,7 @@ export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEnt
706706 : `${sourceNames.slice(0, -1).join(", ")}, and ${sourceNames.at(-1)}`;
707707 const allIntegrations = sourceValues.every((source) => source.kind === "integration");
708708 labels.unshift(
709 `Used ${formattedNames}${allIntegrations ? ` ${sources.size === 1 ? "integration" : "integrations"}` : ""}`,
709 `Used ${formattedNames}${allIntegrations ? ` ${sourceNames.length === 1 ? "integration" : "integrations"}` : ""}`,
710710 );
711711 }
712712 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";
33import { extractToolActivityPresentation } from "./toolPresentation.ts";
44
55describe("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
649 it("retains valid image paths independently of redacted output", () => {
750 expect(
851 extractToolActivityPresentation({ viewedImagePath: " /workspace/reference.webp " }),
packages/client-runtime/src/work-log/toolPresentation.ts+14-2
......@@ -1,4 +1,5 @@
11import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
2import { COMPUTER_USE_TOOL_SOURCE, dynamicToolSurface } from "@t3tools/shared/toolActivity";
23import type {
34 ToolActivityIcon,
45 ToolActivityNativeAppReference,
......@@ -111,12 +112,23 @@ export function extractToolActivityPresentation(
111112 payloadValue: unknown,
112113): ExtractedToolActivityPresentation {
113114 const payload = asRecord(payloadValue);
115 const retainedSource = activitySource(payload?.toolSource);
114116 const toolSurface =
115117 payload?.toolSurface === "browser" || payload?.toolSurface === "computer"
116118 ? 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;
118131 const toolIcon = activityIcon(payload?.toolIcon);
119 const toolSource = activitySource(payload?.toolSource);
120132 const viewedImagePath = trimmedString(payload?.viewedImagePath, 4096);
121133 return {
122134 ...(toolSurface ? { toolSurface } : {}),
packages/shared/src/toolActivity.test.ts+39
......@@ -6,6 +6,7 @@ import {
66 collectToolFilePaths,
77 deriveToolActivityPresentation,
88 dynamicToolTitle,
9 dynamicToolSurface,
910 formatReadToolLabel,
1011 formatSearchToolLabel,
1112 mergeToolActivityData,
......@@ -123,6 +124,44 @@ describe("toolActivity", () => {
123124 ).toEqual({ rawInput: { path: "src/a.ts", startLine: 4 } });
124125 });
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
126165 it("titles Claude skill calls with the skill they load", () => {
127166 expect(dynamicToolTitle("Skill", { skill: "full-send" })).toBe("Skill: full-send");
128167 expect(claudeSkillInvocation("Skill", { skill: "claude-api", args: " pricing " })).toEqual({
packages/shared/src/toolActivity.ts+35-2
......@@ -25,15 +25,48 @@ export function claudeSkillInvocation(
2525 return name === undefined ? undefined : { name, args: asTrimmedString(record?.args) };
2626}
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
2849/**
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`,
3051 * or the skill a Claude `Skill` call loads.
3152 */
3253export function dynamicToolTitle(
3354 toolName: string | null | undefined,
3455 input: unknown,
56 retainedTitle?: string | null,
3557): 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 }
3770 const skill = claudeSkillInvocation(toolName, input);
3871 return skill === undefined ? undefined : `Skill: ${skill.name}`;
3972}