From d5d74103eed723b4101b248f3427d96539958e75 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 2 Sep 2026 14:47:31 +0800 Subject: [PATCH] fix(session-ui): count timeline tool types --- .../app/e2e/regression/session-queue.spec.ts | 8 ++-- .../session-timeline-context-resize.spec.ts | 6 +-- ...session-timeline-locale-projection.spec.ts | 4 +- ...ession-timeline-reducer-projection.spec.ts | 16 +++---- .../component-tests/patch-group.spec.ts | 2 +- .../component-tests/read-image.spec.ts | 2 +- .../component-tests/session-lifecycle.spec.ts | 6 +-- .../session-tool-projection.spec.ts | 14 +++--- .../component-tests/tool-disclosure.spec.ts | 8 ++-- .../component-tests/tool-group.spec.ts | 18 ++++---- .../timeline/research-and-agents.stories.tsx | 16 ++++++- .../session-ui/src/tools/tool-renderer.tsx | 46 +++++++++++++------ packages/ui/src/i18n/en.ts | 4 ++ 13 files changed, 94 insertions(+), 56 deletions(-) diff --git a/packages/app/e2e/regression/session-queue.spec.ts b/packages/app/e2e/regression/session-queue.spec.ts index 450ddba3df40..492e844ebae9 100644 --- a/packages/app/e2e/regression/session-queue.spec.ts +++ b/packages/app/e2e/regression/session-queue.spec.ts @@ -308,8 +308,8 @@ for (const delivery of ["steer", "queue"] as const) { }) const tools = page.locator('[data-timeline-part-ids="tool_queue_read,tool_queue_grep"]') await expect(tools).toBeVisible() - await expect(tools).toHaveText(/^Used\s*2 Read, Grep$/) - await expect(tools.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Read, Grep") + await expect(tools).toHaveText(/^Used\s*1 Read, 1 Grep$/) + await expect(tools.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Read, 1 Grep") await expect(thinking).toHaveCount(0) await expect(pending).toBeVisible() expect(mock.rows.map((row) => ({ id: row.id, delivery: row.delivery }))).toEqual([ @@ -318,7 +318,7 @@ for (const delivery of ["steer", "queue"] as const) { await transcript.screenshot({ path: testInfo.outputPath("pending-steer.png") }) // Soft assertions let delivery run too, even when the pending ordering regresses. - await expect.soft(tools.or(pending)).toHaveText([/^Used\s*2 Read, Grep$/, /U2: Also check the retry path\./]) + await expect.soft(tools.or(pending)).toHaveText([/^Used\s*1 Read, 1 Grep$/, /U2: Also check the retry path\./]) await expect .soft(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools })) .toHaveAttribute("data-message-id", userID) @@ -350,7 +350,7 @@ for (const delivery of ["steer", "queue"] as const) { await expect(response).toHaveAttribute("data-message-id", inboxID) await expect(thinking).toHaveCount(0) await expect(tools.or(pending).or(response)).toHaveText([ - /^Used\s*2 Read, Grep$/, + /^Used\s*1 Read, 1 Grep$/, /U2: Also check the retry path\./, /A3: Now checking the retry path for U2\./, ]) diff --git a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts index 56b0f81b7de5..e19658c138b5 100644 --- a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts +++ b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts @@ -55,7 +55,7 @@ test.describe("regression: session timeline context group resize", () => { await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 }) const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first() await expectAppVisible(context) - await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List") + await expect(context.getByRole("button")).toHaveAccessibleName("Used 1 Read, 1 Glob, 1 Grep, 1 List") const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]` const regions = defineVisualRegions({ @@ -88,7 +88,7 @@ test.describe("regression: session timeline context group resize", () => { await page.waitForTimeout(delay) } - await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List") + await expect(context.getByRole("button")).toHaveAccessibleName("Used 1 Read, 1 Glob, 1 Grep, 1 List") await page.waitForTimeout(700) const trace = await stopVisualProbe(page) const labels = trace.samples @@ -107,7 +107,7 @@ test.describe("regression: session timeline context group resize", () => { ]), ) - expect(labels).toEqual(["Used 4 Read, Glob, Grep, List"]) + expect(labels).toEqual(["Used 1 Read, 1 Glob, 1 Grep, 1 List"]) expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([]) }) }) diff --git a/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts b/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts index 6be176057e91..ae48196d17f2 100644 --- a/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts @@ -17,8 +17,8 @@ for (const locale of ["de", "ar"] as const) { const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`) const names = locale === "de" ? "Lesen, Glob" : "\u0642\u0631\u0627\u0621\u0629, Glob" - await expect(group.getByRole("button")).toHaveAccessibleName(`Used 2 ${names}`) - await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(`2 ${names}`) + await expect(group.getByRole("button")).toHaveAccessibleName(`Used 1 ${names.replace(", ", ", 1 ")}`) + await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(`1 ${names.replace(", ", ", 1 ")}`) await expect(page.locator("html")).toHaveAttribute("lang", locale) }) } diff --git a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts index b1fde4d4c039..548a8777696e 100644 --- a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts @@ -45,10 +45,10 @@ test("expands a mixed collapsed tool stack without expanding its individual call const group = page.locator( '[data-timeline-part-ids="prt_stack_shell_1,prt_stack_explore,prt_stack_patch,prt_stack_shell_2"]', ) - const summary = group.getByRole("button", { name: "Used 4 Shell, Agent, Patch", exact: true }) + const summary = group.getByRole("button", { name: "Used 2 Shell, 1 Agent, 1 Patch", exact: true }) await expect(summary).toHaveAttribute("aria-expanded", "false") await expect(summary).toHaveCSS("height", "28px") - await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("4 Shell, Agent, Patch") + await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Shell, 1 Agent, 1 Patch") await expect(summary.locator('[data-component="tag"]')).toHaveCount(0) await summary.click() await expect(summary).toHaveAttribute("aria-expanded", "true") @@ -75,8 +75,8 @@ test("leaves tools expanded by settings outside the collapsed stack", async ({ p await expect(page.locator('[data-timeline-part-id="prt_expanded_shell"]')).toBeVisible() const group = page.locator('[data-timeline-part-ids="prt_collapsed_patch,prt_collapsed_read"]') - await expect(group.getByRole("button", { name: "Used 2 Patch, Read", exact: true })).toBeVisible() - await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Patch, Read") + await expect(group.getByRole("button", { name: "Used 1 Patch, 1 Read", exact: true })).toBeVisible() + await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Patch, 1 Read") await expect(page.locator('[data-timeline-spacing="tool"]')).toHaveCSS("padding-top", "8px") }) @@ -114,7 +114,7 @@ test("combines follow-up patches into one three-file stack inside Used", async ( ], }) const group = page.locator('[data-component="collapsed-tool-group"]') - await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click() + await group.getByRole("button", { name: "Used 1 Shell, 1 Patch", exact: true }).click() await expect(group.getByText("2 files", { exact: true })).toBeVisible() await timeline.send( partUpdated( @@ -129,7 +129,7 @@ test("combines follow-up patches into one three-file stack inside Used", async ( ), ), ) - await expect(group.getByRole("button", { name: "Used 3 Shell, Patch", exact: true })).toHaveAttribute( + await expect(group.getByRole("button", { name: "Used 1 Shell, 2 Patch", exact: true })).toHaveAttribute( "aria-expanded", "true", ) @@ -162,8 +162,8 @@ test("keeps failed search calls and their error cards inside the collapsed stack await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) const group = page.locator('[data-timeline-part-ids="prt_error_glob,prt_error_grep"]') - const summary = group.getByRole("button", { name: "Used 2 Glob, Grep", exact: true }) - await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Glob, Grep") + const summary = group.getByRole("button", { name: "Used 1 Glob, 1 Grep", exact: true }) + await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Glob, 1 Grep") await summary.click() await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2) const glob = group.locator('[data-timeline-part-id="prt_error_glob"]') diff --git a/packages/session-ui/component-tests/patch-group.spec.ts b/packages/session-ui/component-tests/patch-group.spec.ts index b3c8cfd246f0..3e5477e18ad2 100644 --- a/packages/session-ui/component-tests/patch-group.spec.ts +++ b/packages/session-ui/component-tests/patch-group.spec.ts @@ -12,7 +12,7 @@ story("merges follow-up patches into one stack with a distinct file count", asyn await root.getByRole("button", { name: "Start follow-up patch" }).click() await expect( group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'), - ).toHaveText(/^3 /) + ).toHaveText("1 Shell, 2 Patch") await expect(patches).toHaveCount(1) await expect(patches.getByText("2 files", { exact: true })).toBeVisible() await root.getByRole("button", { name: "Finish follow-up patch" }).click() diff --git a/packages/session-ui/component-tests/read-image.spec.ts b/packages/session-ui/component-tests/read-image.spec.ts index 956896fae830..70c1eaabacfc 100644 --- a/packages/session-ui/component-tests/read-image.spec.ts +++ b/packages/session-ui/component-tests/read-image.spec.ts @@ -9,7 +9,7 @@ const png = Buffer.from( story.beforeEach(async ({ mount }) => { const root = await mount("current-tool-group--mixed-tools") - await expect(root.getByRole("button", { name: "Used 4 Shell, Read, Agent", exact: true })).toBeVisible() + await expect(root.getByRole("button", { name: "Used 1 Shell, 1 Read, 2 Agents", exact: true })).toBeVisible() }) for (const grouped of [true, false]) { diff --git a/packages/session-ui/component-tests/session-lifecycle.spec.ts b/packages/session-ui/component-tests/session-lifecycle.spec.ts index 57d426b82720..b5a9aa44b5f8 100644 --- a/packages/session-ui/component-tests/session-lifecycle.spec.ts +++ b/packages/session-ui/component-tests/session-lifecycle.spec.ts @@ -19,7 +19,7 @@ for (const tool of ["shell", "execute", "subagent"]) { await expect(group).toHaveAttribute("data-timeline-part-ids", "tool_context_lifecycle,tool_shell_lifecycle") await expect( group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'), - ).toHaveText(/^2 /) + ).toHaveText(`1 Read, 1 ${tool === "subagent" ? "Agent" : tool[0]!.toUpperCase() + tool.slice(1)}`) await expect(timeline.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(1) await expect(trigger).toHaveAttribute("aria-expanded", String(open)) expect(await original!.evaluate((node) => node.isConnected)).toBe(true) @@ -187,10 +187,10 @@ for (const locale of ["de", "ar"] as const) { await timeline.getByRole("button", { name: "Complete read" }).click() await timeline.getByRole("button", { name: "Complete glob" }).click() const group = timeline.locator('[data-timeline-part-ids="tool_context_read,tool_context_glob"]') - await expect(group.getByRole("button")).toHaveAccessibleName(/^Used 2 /) + await expect(group.getByRole("button")).toHaveAccessibleName(/^Used 1 .+, 1 /) await expect( group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'), - ).toHaveText(/^2 /) + ).toHaveText(/^1 .+, 1 /) await expect(page.locator("html")).toHaveAttribute("lang", locale) }) } diff --git a/packages/session-ui/component-tests/session-tool-projection.spec.ts b/packages/session-ui/component-tests/session-tool-projection.spec.ts index 5f36d1473a11..68a21dfddbfc 100644 --- a/packages/session-ui/component-tests/session-tool-projection.spec.ts +++ b/packages/session-ui/component-tests/session-tool-projection.spec.ts @@ -44,7 +44,7 @@ story("renders every tool error outcome without leaking hidden tools", async ({ const group = timeline.locator(`[data-timeline-part-ids="${names.map((name) => `tool_error_${name}`).join(",")}"]`) await expect( group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'), - ).toHaveText(new RegExp(`^${names.length} `)) + ).toHaveText("1 Shell, 1 Edit, 1 Write, 1 Patch, 1 Webfetch, 1 Web Search, 1 Agent, 1 Skill, 1 mcp_probe") await group.getByRole("button").click() await expect(timeline.locator('[data-kind="tool-error-card"]')).toHaveCount(names.length + 1) const dismissed = timeline.locator('[data-timeline-part-id="tool_error_question_dismissed"]') @@ -70,7 +70,7 @@ story("transitions shell and question through running error outcomes", async ({ // Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts story("labels all web search provider variants", async ({ mount }) => { const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "providers" } }) - await timeline.getByRole("button", { name: "Used 3 Parallel Web Search, Exa Web Search, Web Search" }).click() + await timeline.getByRole("button", { name: "Used 1 Parallel Web Search, 1 Exa Web Search, 1 Web Search" }).click() const tools = timeline.locator('[data-component="context-tool-group-list"]') await expect(tools.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible() await expect(tools.getByRole("button", { name: /Exa Web Search/ })).toBeVisible() @@ -102,11 +102,11 @@ story("labels read tools from their path input", async ({ mount }) => { // Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts story("labels skill tools from IDs and result metadata", async ({ mount }) => { const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "skills" } }) - const group = timeline.locator('[data-timeline-part-ids="tool_skill_id,tool_skill_name"]') - await expect(group.getByRole("button")).toHaveAccessibleName("Used 2 Skill") + const group = timeline.locator('[data-timeline-part-ids="tool_skill_id,tool_skill_name,tool_skill_agent"]') + await expect(group.getByRole("button")).toHaveAccessibleName("Used 2 Skills, 1 Agent") await expect( group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'), - ).toHaveText("2 Skill") + ).toHaveText("2 Skills, 1 Agent") await group.getByRole("button").click() const loaded = group.locator('[data-component="tool-loaded-item"]') await expect(loaded).toHaveCount(1) @@ -128,10 +128,10 @@ story("groups every collapsed tool until visible text separates the stack", asyn '[data-timeline-part-ids="tool_boundary_glob,tool_boundary_grep,tool_boundary_shell,tool_boundary_list"]', ) await expect(group).toBeVisible() - await expect(group.getByRole("button")).toHaveAccessibleName("Used 4 Glob, Grep, Shell, List") + await expect(group.getByRole("button")).toHaveAccessibleName("Used 1 Glob, 1 Grep, 1 Shell, 1 List") await expect( group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'), - ).toHaveText("4 Glob, Grep, Shell, List") + ).toHaveText("1 Glob, 1 Grep, 1 Shell, 1 List") await expect(timeline.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(3) await expect(timeline.locator('[data-timeline-spacing="content"]')).toHaveCount(2) await expect(timeline.locator('[data-timeline-spacing="content"]').nth(0)).toHaveCSS("padding-top", "16px") diff --git a/packages/session-ui/component-tests/tool-disclosure.spec.ts b/packages/session-ui/component-tests/tool-disclosure.spec.ts index 8fcd1f8ca564..2c3b66ad8586 100644 --- a/packages/session-ui/component-tests/tool-disclosure.spec.ts +++ b/packages/session-ui/component-tests/tool-disclosure.spec.ts @@ -6,8 +6,8 @@ for (const open of [true, false]) { async ({ mount }, info) => { const root = await mount("current-session-file-changes--appending-tool-calls") const group = root.locator('[data-component="collapsed-tool-group"]') - const trigger = group.getByRole("button", { name: /^Used \d+ Shell, Patch$/ }) - await expect(trigger).toHaveAccessibleName("Used 2 Shell, Patch") + const trigger = group.getByRole("button", { name: /^Used 1 Shell, \d+ Patch$/ }) + await expect(trigger).toHaveAccessibleName("Used 1 Shell, 1 Patch") await trigger.click() const shell = group.locator('[data-timeline-part-id="tool_shell_existing"] [data-slot="collapsible-trigger"]') await group.locator('[data-timeline-part-id="tool_patch_existing"]').evaluate((element) => { @@ -33,8 +33,8 @@ for (const open of [true, false]) { await root.getByRole("button", { name: "Append tool call", exact: true }).click() await expect( group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'), - ).toHaveText(`${count} Shell, Patch`) - await expect(trigger).toHaveAccessibleName(`Used ${count} Shell, Patch`) + ).toHaveText(`1 Shell, ${count - 1} Patch`) + await expect(trigger).toHaveAccessibleName(`Used 1 Shell, ${count - 1} Patch`) await expect(diff).toBeVisible() await root .locator('[data-component="session-timeline"]') diff --git a/packages/session-ui/component-tests/tool-group.spec.ts b/packages/session-ui/component-tests/tool-group.spec.ts index e924f34d1e21..39b9b719f349 100644 --- a/packages/session-ui/component-tests/tool-group.spec.ts +++ b/packages/session-ui/component-tests/tool-group.spec.ts @@ -6,14 +6,14 @@ for (const reasoningDefaultOpen of [false, true]) { async ({ mount }) => { const root = await mount("current-tool-group--mixed-reasoning", { args: { reasoningDefaultOpen } }) const group = root.locator('[data-component="collapsed-tool-group"]') - const used = group.getByRole("button", { name: /^Used \d+ Read, Skill$/ }) + const used = group.getByRole("button", { name: /^Used \d+ Read, \d+ Skills?$/ }) const first = group.locator('[data-timeline-part-id="reasoning_first"]') const second = group.locator('[data-timeline-part-id="reasoning_second"]') await expect(used).toHaveAttribute("aria-expanded", "true") - await expect(used).toHaveAccessibleName("Used 4 Read, Skill") + await expect(used).toHaveAccessibleName("Used 1 Read, 3 Skills") await expect( group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'), - ).toHaveText("4 Read, Skill") + ).toHaveText("1 Read, 3 Skills") await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveText([ /Read.*group\.ts/, /Thought/, @@ -34,10 +34,10 @@ for (const reasoningDefaultOpen of [false, true]) { ) await first.getByRole("button", { name: "Thought", exact: true }).click() await root.getByRole("button", { name: "Append follow-up read", exact: true }).click() - await expect(used).toHaveAccessibleName("Used 5 Read, Skill") + await expect(used).toHaveAccessibleName("Used 2 Read, 3 Skills") await expect( group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'), - ).toHaveText("5 Read, Skill") + ).toHaveText("2 Read, 3 Skills") await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveText([ /Read.*group\.ts/, /Thought/, @@ -70,10 +70,10 @@ for (const reasoningDefaultOpen of [false, true]) { story("summarizes subagents as Agent while retaining their card titles", async ({ mount }) => { const root = await mount("current-tool-group--mixed-tools") const group = root.locator('[data-component="collapsed-tool-group"]') - await expect(group.getByRole("button", { name: "Used 4 Shell, Read, Agent", exact: true })).toBeVisible() + await expect(group.getByRole("button", { name: "Used 1 Shell, 1 Read, 2 Agents", exact: true })).toBeVisible() await expect( group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'), - ).toHaveText("4 Shell, Read, Agent") + ).toHaveText("1 Shell, 1 Read, 2 Agents") await expect(group.locator('[data-component="task-tool-title"]')).toHaveText(["General", "Explore"]) }) @@ -82,9 +82,9 @@ for (const width of [840, 390]) { await page.setViewportSize({ width, height: 600 }) const root = await mount("current-tool-group--mixed-tools") const group = root.locator('[data-component="collapsed-tool-group"]') - const trigger = group.getByRole("button", { name: "Used 4 Shell, Read, Agent", exact: true }) + const trigger = group.getByRole("button", { name: "Used 1 Shell, 1 Read, 2 Agents", exact: true }) const header = group.locator('[data-component="context-tool-group-trigger"]') - await expect(header.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("4 Shell, Read, Agent") + await expect(header.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Shell, 1 Read, 2 Agents") await expect(header.locator('[data-component="tag"]')).toHaveCount(0) await expect(trigger).toHaveAttribute("aria-expanded", "true") for (const action of ["click", "Enter", "Space"] as const) { diff --git a/packages/session-ui/src/timeline/research-and-agents.stories.tsx b/packages/session-ui/src/timeline/research-and-agents.stories.tsx index 603543d6b94b..2c438e71c21d 100644 --- a/packages/session-ui/src/timeline/research-and-agents.stories.tsx +++ b/packages/session-ui/src/timeline/research-and-agents.stories.tsx @@ -193,8 +193,22 @@ const LoadingSpecializedSkills = { title="Loading specialized skills" description="Active and completed skills display their identifier or resolved name." document={storyDocument([ - storyTool("tool_skill_id", "skill", "running", { id: "frontend-design" }), + storyTool("tool_skill_id", "skill", "completed", { id: "frontend-design" }), storyTool("tool_skill_name", "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }), + storyTool( + "tool_skill_agent", + "subagent", + "completed", + { + description: "Review skill usage", + agent: "explore", + prompt: "Review the loaded skills.", + }, + { + output: "The loaded skills are ready.", + metadata: { sessionID: "session_child_review", status: "completed" }, + }, + ), ])} /> ), diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index fff21e9078ef..f2659b41a148 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -497,20 +497,40 @@ export function CurrentContextToolGroup(props: { const pending = createMemo( () => props.busy || tools().some((tool) => tool.state.status === "streaming" || tool.state.status === "running"), ) - const names = createMemo(() => - [ - ...new Set( - tools().map((tool) => { - const input = currentToolInput(tool) - if (tool.name === "skill") return i18n.t("ui.tool.skill") - if (tool.name === "subagent") return i18n.t("ui.tool.agent.default") - return getToolInfo(tool.name, input, currentToolMetadata(tool)).title - }), - ), - ].join(", "), - ) + const names = createMemo(() => { + const counts = new Map< + string, + { count: number; plural?: "ui.messagePart.tools.skill" | "ui.messagePart.tools.agent" } + >() + tools().forEach((tool) => { + const input = currentToolInput(tool) + const name = + tool.name === "skill" + ? i18n.t("ui.tool.skill") + : tool.name === "subagent" + ? i18n.t("ui.tool.agent.default") + : getToolInfo(tool.name, input, currentToolMetadata(tool)).title + const current = counts.get(name) + if (current) { + current.count++ + return + } + counts.set(name, { + count: 1, + plural: + tool.name === "skill" + ? "ui.messagePart.tools.skill" + : tool.name === "subagent" + ? "ui.messagePart.tools.agent" + : undefined, + }) + }) + return [...counts.entries()] + .map(([name, item]) => (item.plural ? i18n.plural(item.plural, item.count) : `${item.count} ${name}`)) + .join(", ") + }) const label = createMemo(() => { - const title = `${tools().length} ${names()}` + const title = names() const text = i18n.t("ui.messagePart.tools.used", { tools: title }) const index = text.indexOf(title) return { text, title, before: text.slice(0, index).trim(), after: text.slice(index + title.length).trim() } diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 31a7cebd2b7d..649fbc920889 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -111,6 +111,10 @@ const source = { "ui.messagePart.context.match.one": "({{count}} match)", "ui.messagePart.context.match.other": "({{count}} matches)", "ui.messagePart.tools.used": "Used {{tools}}", + "ui.messagePart.tools.skill.one": "{{count}} Skill", + "ui.messagePart.tools.skill.other": "{{count}} Skills", + "ui.messagePart.tools.agent.one": "{{count}} Agent", + "ui.messagePart.tools.agent.other": "{{count}} Agents", "ui.list.loading": "Loading", "ui.list.empty": "No results",