From 690cc8f753620b401a0a6373fabbaf3273df70dd Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 27 Jul 2026 15:06:29 -0700 Subject: [PATCH] feat(composer): terse tool-call headline for the activity pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite the tool branch of getActivityHeadline (agentSessionTranscriptPresentation.ts) to use the descriptor's terse action tier — verb + compact object, e.g. "Read foo.ts" — instead of "label · full preview" ("Read file · src/agents/ui/foo.ts"), so the composer pill's 200px cap shows the informative part of the action instead of ellipsizing a long preview - Basename path-like objects only (file-read / file-edit / skill-read); shell commands stay whole since they legitimately contain "/", and file edits reuse the already-basenamed fileEditSummary.filename - Fall back to the previous "label · preview" format when a descriptor carries no action (older descriptors) - Export the existing private basename() from agentSessionFileEditDiff.ts rather than adding a third copy - Document the terse format on deriveActivityPillLabel (composerLiveActivity.ts); no logic change — the pill picks it up via getActivityHeadline, whose only production consumer is the pill - Tests: update presentation expectations ("Send Message · abc" → "Sent abc"), add cases for file-read/file-edit basenaming, shell commands kept whole, and the no-action fallback; add a pill-level terse-headline case to composerLiveActivity.test.mjs Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../agents/ui/agentSessionFileEditDiff.ts | 3 +- ...gentSessionTranscriptPresentation.test.mjs | 75 ++++++++++++++++++- .../ui/agentSessionTranscriptPresentation.ts | 47 +++++++++++- .../channels/ui/composerLiveActivity.test.mjs | 37 +++++++++ .../channels/ui/composerLiveActivity.ts | 4 + 5 files changed, 159 insertions(+), 7 deletions(-) diff --git a/desktop/src/features/agents/ui/agentSessionFileEditDiff.ts b/desktop/src/features/agents/ui/agentSessionFileEditDiff.ts index a7624ebbc..1c474c644 100644 --- a/desktop/src/features/agents/ui/agentSessionFileEditDiff.ts +++ b/desktop/src/features/agents/ui/agentSessionFileEditDiff.ts @@ -197,7 +197,8 @@ function getDiffStats( return null; } -function basename(path: string) { +/** Final path segment (cross-platform); returns the input when empty. */ +export function basename(path: string) { const parts = path.replace(/\\/g, "/").split("/"); return parts[parts.length - 1] || path; } diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs index 3118538b5..4b6a33314 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs @@ -40,8 +40,9 @@ function makeMessage(overrides = {}) { }; } -test("getActivityHeadline formats tool titles and assistant text", () => { - assert.equal(getActivityHeadline(makeTool()), "Send Message · abc"); +test("getActivityHeadline formats tool actions tersely and assistant text", () => { + // Tool items use the terse action tier (verb + object), not label · preview. + assert.equal(getActivityHeadline(makeTool()), "Sent abc"); assert.equal( getActivityHeadline(makeMessage({ text: "First line\nSecond line" })), "First line", @@ -49,6 +50,72 @@ test("getActivityHeadline formats tool titles and assistant text", () => { assert.equal(getActivityHeadline(makeMessage({ text: " " })), "Responding"); }); +test("getActivityHeadline basenames path-like tool objects", () => { + assert.equal( + getActivityHeadline( + makeTool({ + title: "Read file", + toolName: "dev__read_file", + buzzToolName: null, + args: { path: "src/agents/ui/foo.ts" }, + descriptor: { + renderClass: "file-read", + label: "Read file", + preview: "src/agents/ui/foo.ts", + action: { verb: "Read", object: "src/agents/ui/foo.ts" }, + source: "harness", + groupKey: "read_file", + }, + }), + ), + "Read foo.ts", + ); + assert.equal( + getActivityHeadline( + makeTool({ + title: "Edit file", + toolName: "dev__str_replace", + buzzToolName: null, + args: { path: "desktop/src/features/channels/ui/ChannelPane.tsx" }, + descriptor: { + renderClass: "file-edit", + label: "Edited file", + preview: "desktop/src/features/channels/ui/ChannelPane.tsx", + action: { + verb: "Edited", + object: "desktop/src/features/channels/ui/ChannelPane.tsx", + }, + source: "harness", + groupKey: "file-edit:str_replace", + }, + }), + ), + "Edited ChannelPane.tsx", + ); +}); + +test("getActivityHeadline keeps shell commands whole (no basenaming)", () => { + assert.equal( + getActivityHeadline( + makeTool({ + title: "Shell", + toolName: "dev__shell", + buzzToolName: null, + args: { command: "cat src/agents/ui/foo.ts" }, + descriptor: { + renderClass: "shell", + label: "Ran command", + preview: "cat src/agents/ui/foo.ts", + action: { verb: "Ran", object: "cat src/agents/ui/foo.ts" }, + source: "harness", + groupKey: "shell:command", + }, + }), + ), + "Ran cat src/agents/ui/foo.ts", + ); +}); + test("isMeaningfulItem ignores lifecycle noise and raw JSON-RPC metadata", () => { assert.equal( isMeaningfulItem({ @@ -111,7 +178,7 @@ test("isMeaningfulItem ignores lifecycle noise and raw JSON-RPC metadata", () => ); }); -test("getActivityHeadline uses semantic tool descriptors", () => { +test("getActivityHeadline falls back to label · preview when a descriptor has no action", () => { assert.equal( getActivityHeadline( makeTool({ @@ -225,7 +292,7 @@ test("two-tier headline: metadata excluded when spine work is present", () => { "System prompt should not headline when spine work exists", ); assert.ok( - headlines.some((h) => h?.includes("Send Message")), + headlines.some((h) => h?.includes("Sent")), "Tool headline should appear", ); }); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts index a77a21ff8..a9541c030 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts @@ -1,5 +1,9 @@ import type { TranscriptItem } from "./agentSessionTypes"; -import { buildCompactToolSummary } from "./agentSessionToolSummary"; +import { basename } from "./agentSessionFileEditDiff"; +import { + buildCompactToolSummary, + type CompactToolSummary, +} from "./agentSessionToolSummary"; /** * Whether a polished activity row should render the opt-in timestamp footer. @@ -26,10 +30,49 @@ const LIFECYCLE_NOISE = new Set([ "wire parse error", ]); -/** Human-readable headline for a single transcript item. */ +/** + * Render classes whose action object is a filesystem path — compact to the + * basename for terse headlines. Shell commands are deliberately excluded: + * they legitimately contain "/" (e.g. `cat src/foo.ts`) and must not be + * reduced to their last path segment. + */ +const PATHLIKE_RENDER_CLASSES = new Set([ + "file-read", + "file-edit", + "skill-read", +]); + +function terseActionObject(summary: CompactToolSummary): string | null { + if (summary.fileEditSummary) { + return summary.fileEditSummary.filename; + } + const object = summary.action?.object ?? null; + if ( + object && + PATHLIKE_RENDER_CLASSES.has(summary.kind) && + /[\\/]/.test(object) + ) { + return basename(object); + } + return object; +} + +/** + * Human-readable headline for a single transcript item. + * + * Tool items use the terse action tier — verb + compact object (file paths + * reduced to their basename), e.g. "Read foo.ts" rather than + * "Read file · src/agents/ui/foo.ts" — so the composer pill's 200px cap + * shows the informative part instead of ellipsizing a long preview. + */ export function getActivityHeadline(item: TranscriptItem): string | null { if (item.type === "tool") { const summary = buildCompactToolSummary(item); + if (summary.action) { + return [summary.action.verb, terseActionObject(summary)] + .filter(Boolean) + .join(" "); + } return [summary.label, summary.preview].filter(Boolean).join(" · "); } diff --git a/desktop/src/features/channels/ui/composerLiveActivity.test.mjs b/desktop/src/features/channels/ui/composerLiveActivity.test.mjs index c65cca267..b8962465e 100644 --- a/desktop/src/features/channels/ui/composerLiveActivity.test.mjs +++ b/desktop/src/features/channels/ui/composerLiveActivity.test.mjs @@ -48,6 +48,32 @@ const lifecycleMeta = (acpSource, timestamp, channelId = CHANNEL) => ({ channelId, }); +/** Tool item with a classifier descriptor: headlines tersely (verb + object). */ +const fileReadTool = (path, timestamp, channelId = CHANNEL) => ({ + id: `tool-${path}-${timestamp}`, + type: "tool", + renderClass: "file-read", + title: "Read file", + toolName: "dev__read_file", + buzzToolName: null, + status: "completed", + args: { path }, + result: "", + isError: false, + timestamp, + startedAt: timestamp, + completedAt: timestamp, + descriptor: { + renderClass: "file-read", + label: "Read file", + preview: path, + action: { verb: "Read", object: path }, + source: "harness", + groupKey: "read_file", + }, + channelId, +}); + /** Streaming assistant message: headline is the (growing) first line. */ const assistantMessage = (id, text, timestamp, channelId = CHANNEL) => ({ id, @@ -71,6 +97,17 @@ test("deriveActivityPillLabel returns the newest headline, no rotation", () => { assert.deepEqual(headline, { id: editing.id, label: "Editing ChannelPane" }); }); +test("deriveActivityPillLabel headlines tool items tersely (verb + basename)", () => { + const read = fileReadTool("src/agents/ui/foo.ts", secondsBeforeNow(2)); + const headline = deriveActivityPillLabel({ + channelId: CHANNEL, + transcript: [read], + }); + // Terse action tier, not "Read file · src/agents/ui/foo.ts" — the pill's + // narrow cap must show the informative part of the action. + assert.deepEqual(headline, { id: read.id, label: "Read foo.ts" }); +}); + test("deriveActivityPillLabel keeps the last action headline regardless of age", () => { // A quiet stretch (long tool call, thinking gap) must NOT decay the label // to the generic placeholder — the last real action stays informative. diff --git a/desktop/src/features/channels/ui/composerLiveActivity.ts b/desktop/src/features/channels/ui/composerLiveActivity.ts index 20157bde1..2248e700d 100644 --- a/desktop/src/features/channels/ui/composerLiveActivity.ts +++ b/desktop/src/features/channels/ui/composerLiveActivity.ts @@ -28,6 +28,10 @@ export type ActivityPillHeadline = { /** * Latest action headline for a working agent's composer pill. * + * Tool items headline in the terse action format from getActivityHeadline — + * verb + compact object ("Read foo.ts"), not "label · full preview" — so the + * pill's narrow cap shows the informative part of the action. + * * Channel-scoped, two-tier scan (spine items headline over metadata reads, * mirroring the session transcript's noise gate), newest wins. The headline * PERSISTS regardless of age — while a turn is in progress, the last real