From 43bd6677392b80cd12742f865c277c9939239543 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 2 Oct 2026 20:02:22 -0700 Subject: [PATCH] fix(web): Claude skill calls show the skill name (#14980) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/mobile/src/lib/threadActivity.ts | 4 +-- .../Adapters/ClaudeAdapterV2.test.ts | 7 ++++- .../Adapters/ClaudeAdapterV2.ts | 11 +++++-- .../Adapters/CodexAdapterV2.ts | 4 +-- .../components/chat/MessagesTimeline.logic.ts | 8 ++--- .../src/components/chat/MessagesTimeline.tsx | 31 +++++++++++++------ packages/shared/src/toolActivity.test.ts | 12 +++++++ packages/shared/src/toolActivity.ts | 23 +++++++++++--- 8 files changed, 75 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index c453ebf2312d5c881666c1ca038a732d55f7b85d..259b76c9b173a651375b04095bec07423aa20ff2 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -48,7 +48,7 @@ import { RunId, ThreadId } from "@t3tools/contracts"; import { classifyToolActivity, collectToolFilePaths, - computerUseToolTitle, + dynamicToolTitle, formatReadToolLabel, formatSearchToolLabel, } from "@t3tools/shared/toolActivity"; @@ -540,7 +540,7 @@ function itemSummary( if (item.type === "system_notice") return item.message; if (item.type === "compaction") return contextCompactionLabel(item); const title = - (item.type === "dynamic_tool" ? computerUseToolTitle(item.toolName, item.input) : undefined) ?? + (item.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input) : undefined) ?? item.title?.trim(); if (item.type === "subagent") return formatSubagentDisplayTitle(title || "Subagent"); if (title) return toolPresentation?.displayName ?? capitalizePhrase(title); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 95f8753ae68890ea103fcd6a59be72a24993918e..a4164c936fdf9e3f02cbd37cad236db1a5335fa6 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2771,7 +2771,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, IdAllocator.layer))), ); - it.effect("retains image preview paths on Claude Read tool completion", () => + it.effect("titles Claude reads, searches, and skills on tool completion", () => Effect.gen(function* () { const harness = yield* makeWakeHarness; const now = yield* DateTime.now; @@ -2789,6 +2789,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { { id: "image", name: "Read", input: { file_path: " /workspace/reference.png " } }, { id: "text", name: "Read", input: { file_path: "/workspace/README.md" } }, { id: "search", name: "Grep", input: { pattern: "TODO", path: "/workspace/src" } }, + { id: "skill", name: "Skill", input: { skill: "full-send" } }, { id: "write", name: "Write", @@ -2859,6 +2860,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { items.find((item) => item.nativeItemRef?.nativeId === "search")?.title, "Searched TODO in src", ); + assert.equal( + items.find((item) => item.nativeItemRef?.nativeId === "skill")?.title, + "Skill: full-send", + ); for (const item of items.filter((item) => item.nativeItemRef?.nativeId !== "image")) assert.notProperty(item, "viewedImagePath"); }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, IdAllocator.layer))), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 99f77ee0779ce20e2b8105260284636243f12528..2cf91c6195949544b6ad92f127e575760ef27a4f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1,7 +1,11 @@ import * as NodeCrypto from "node:crypto"; import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; -import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; +import { + dynamicToolTitle, + formatReadToolLabel, + formatSearchToolLabel, +} from "@t3tools/shared/toolActivity"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsage.ts"; import { @@ -3726,7 +3730,10 @@ export function makeClaudeAdapterV2( title: readPath !== undefined ? formatReadToolLabel(readPath) - : (searchTitle ?? input.presentation?.title ?? null), + : (searchTitle ?? + dynamicToolTitle(input.toolName, nativeToolInput) ?? + input.presentation?.title ?? + null), startedAt: input.startedAt, completedAt, updatedAt: input.updatedAt, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 88cd870f53c2cad33625f0a94293dc7f4d488646..19c3486f5af783dad1df32b1f14c443d287ee55e 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -30,7 +30,7 @@ import { } from "@t3tools/contracts"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; -import { computerUseToolTitle } from "@t3tools/shared/toolActivity"; +import { dynamicToolTitle } from "@t3tools/shared/toolActivity"; import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import type { @@ -489,7 +489,7 @@ export function projectCodexDynamicToolItem( item.type === "mcpToolCall" ? `${item.server}.${item.tool}` : [trimText(item.namespace), item.tool].filter(Boolean).join("."); - const title = computerUseToolTitle(toolName, item.arguments); + const title = dynamicToolTitle(toolName, item.arguments); const projection: CodexDynamicToolProjection = { ...(item.type === "mcpToolCall" ? mcpToolPresentation(item) : {}), toolName, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index f7eb34f1b58487ed2b30920b3310432c8517dbe6..b035839c4ae47f82be96e242dca27f18241c0db2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -48,7 +48,7 @@ import { type T3McpToolPresentation, } from "@t3tools/shared/t3McpToolPresentation"; import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput"; -import { computerUseToolTitle } from "@t3tools/shared/toolActivity"; +import { dynamicToolTitle } from "@t3tools/shared/toolActivity"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; import { collectToolFilePaths, @@ -81,8 +81,7 @@ function singleToolCallLabel(entry: WorkLogEntry): string { const toolPresentation = resolveWorkEntryToolPresentation(entry, "completed"); if (toolPresentation) return toolPresentation.displayName; const item = entry.structuredPayload; - const title = - item?.type === "dynamic_tool" ? computerUseToolTitle(item.toolName, item.input) : null; + const title = item?.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input) : null; if (title) return title; // A lone web search keeps its heading; the query stays in its detail. if (entry.itemType === "web_search") return entry.toolTitle ?? "Web search"; @@ -142,8 +141,7 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string const providerRetry = entry.projectedItem?.item.type === "error" && entry.projectedItem.item.retry !== undefined; const item = entry.structuredPayload; - const title = - item?.type === "dynamic_tool" ? computerUseToolTitle(item.toolName, item.input) : null; + const title = item?.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input) : null; if (title) return title; const compactDetail = entry.detail?.trim(); const detailIsSearchOutput = diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index fd71d616bae9c0db2b652b92efc184a92ca96b6f..5dec1b6d411d98dfce6146f8f3c67ac131173fc9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -57,6 +57,7 @@ import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon"; +import { claudeSkillInvocation } from "@t3tools/shared/toolActivity"; import { observeVisibleAnimation } from "../../lib/visibleAnimation"; import { createContext, @@ -5034,8 +5035,19 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { ? getQuestionAnswerPreview(workEntry.questionAnswer) : null; const viewedImagePath = workEntryViewedImagePath(workEntry); - const isRead = toolGroupAction(workEntry) === "read"; - const readOutput = isRead ? workEntryReadOutput(workEntry, workspaceRoot) : null; + const payload = workEntry.structuredPayload; + const skill = + payload?.type === "dynamic_tool" + ? claudeSkillInvocation(payload.toolName, payload.input) + : undefined; + // Reads and skills expand to plain text instead of the item inspector. A + // skill's heading already names it, so only its arguments are left to show. + const plainOutput = + toolGroupAction(workEntry) === "read" + ? workEntryReadOutput(workEntry, workspaceRoot) + : skill + ? (skill.args ?? null) + : undefined; const viewedImage = viewedImagePath && threadRef ? resolveViewedImageAsset(viewedImagePath, { @@ -5055,8 +5067,8 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { ); const expandedBody = expanded && !isReasoning - ? isRead - ? readOutput + ? plainOutput !== undefined + ? plainOutput : buildToolCallExpandedBody( workEntry, workspaceRoot, @@ -5064,9 +5076,10 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { viewedImage ? viewedImagePath : null, ) : null; - const canExpandProjectedItem = isRead - ? Boolean(readOutput || viewedImage || workEntry.questionAnswer) - : canExpand || workEntry.projectedItem !== undefined; + const canExpandProjectedItem = + plainOutput !== undefined + ? Boolean(plainOutput || viewedImage || workEntry.questionAnswer) + : canExpand || workEntry.projectedItem !== undefined; // Reserve destructive row styling for severe failures, not routine tool errors. const iconWrapperClass = cn( "flex size-4 items-center justify-center", @@ -5237,9 +5250,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { !isReasoning && !workEntry.questionAnswer && canExpandProjectedItem && - (expandedBody || (workEntry.projectedItem && !isRead)) ? ( + (expandedBody || (workEntry.projectedItem && plainOutput === undefined)) ? ( - {workEntry.projectedItem && !isRead ? ( + {workEntry.projectedItem && plainOutput === undefined ? ( { mergeToolActivityData({ rawInput: { path: "src/a.ts" } }, { rawInput: { startLine: 4 } }), ).toEqual({ rawInput: { path: "src/a.ts", startLine: 4 } }); }); + + it("titles Claude skill calls with the skill they load", () => { + expect(dynamicToolTitle("Skill", { skill: "full-send" })).toBe("Skill: full-send"); + expect(claudeSkillInvocation("Skill", { skill: "claude-api", args: " pricing " })).toEqual({ + name: "claude-api", + args: "pricing", + }); + expect(dynamicToolTitle("Skill", { skill: " " })).toBeUndefined(); + expect(dynamicToolTitle("Read", { skill: "full-send" })).toBeUndefined(); + }); }); diff --git a/packages/shared/src/toolActivity.ts b/packages/shared/src/toolActivity.ts index 04f746a01d1a6ca85d0c4596aa32097e4cba9860..d281462cb08d32a0bfab6b74ae82b2957abff83a 100644 --- a/packages/shared/src/toolActivity.ts +++ b/packages/shared/src/toolActivity.ts @@ -14,13 +14,28 @@ function asTrimmedString(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -/** CUA's `title` describes the action shown in the activity log. */ -export function computerUseToolTitle( +/** A Claude `Skill` call: the skill it loads and the arguments it passes, if any. */ +export function claudeSkillInvocation( + toolName: string | null | undefined, + input: unknown, +): { readonly name: string; readonly args: string | undefined } | undefined { + if (toolName !== "Skill") return undefined; + const record = asRecord(input); + const name = asTrimmedString(record?.skill); + return name === undefined ? undefined : { name, args: asTrimmedString(record?.args) }; +} + +/** + * Activity log heading a dynamic tool derives from its input: CUA's `title`, + * or the skill a Claude `Skill` call loads. + */ +export function dynamicToolTitle( toolName: string | null | undefined, input: unknown, ): string | undefined { - if (toolName !== "cua_repl.js") return undefined; - return asTrimmedString(asRecord(input)?.title); + if (toolName === "cua_repl.js") return asTrimmedString(asRecord(input)?.title); + const skill = claudeSkillInvocation(toolName, input); + return skill === undefined ? undefined : `Skill: ${skill.name}`; } function recordHasKeys( -- 2.54.0