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 6f0c173928a0..fc0530234b3e 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 e7b03897c2b9..c5dabd413982 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, 3 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 1 Read, 3 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("1 Read, 3 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 2 Read, 3 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("2 Read, 3 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 1 Shell, 1 Read, 2 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("1 Shell, 1 Read, 2 Agent") + ).toHaveText("1 Shell, 1 Read, 2 Agents") const gap = await group.evaluate((element) => { const title = element.querySelector('[data-component="context-tool-group-trigger"]')!.getBoundingClientRect() const arrow = element.querySelector('[data-slot="collapsible-arrow-icon"]')!.getBoundingClientRect() @@ -88,9 +88,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 1 Shell, 1 Read, 2 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("1 Shell, 1 Read, 2 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 02e8021cd7b3..dcd8011d7069 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -507,11 +507,20 @@ export function CurrentContextToolGroup(props: { : tool.name === "subagent" ? i18n.t("ui.tool.agent.default") : getToolInfo(tool.name, input, currentToolMetadata(tool)).title - counts.set(name, (counts.get(name) ?? 0) + 1) + const current = counts.get(name) + counts.set(name, { + count: (current?.count ?? 0) + 1, + plural: + tool.name === "skill" + ? "ui.messagePart.tools.skill" + : tool.name === "subagent" + ? "ui.messagePart.tools.agent" + : undefined, + }) return counts - }, new Map()), + }, new Map()), ] - .map(([name, count]) => `${count} ${name}`) + .map(([name, item]) => (item.plural ? i18n.plural(item.plural, item.count) : `${item.count} ${name}`)) .join(", "), ) const label = createMemo(() => { diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 5c7608585973..99f41109508f 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",