From 8c5eca5bb234c491ad00ca3e146bf427269e2ed5 Mon Sep 17 00:00:00 2001 From: David Hill <1879069+iamdavidhill@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:15:24 -0600 Subject: [PATCH] fix(session-ui): show one count before used (#48038) --- .../app/e2e/regression/patch-border.spec.ts | 2 +- .../app/e2e/regression/session-queue.spec.ts | 8 ++--- .../session-timeline-accessibility.spec.ts | 2 +- .../session-timeline-collapse-state.spec.ts | 8 ++--- .../session-timeline-context-resize.spec.ts | 6 ++-- ...session-timeline-locale-projection.spec.ts | 4 +-- .../session-timeline-notices.spec.ts | 2 +- ...sion-timeline-reasoning-projection.spec.ts | 2 +- ...ession-timeline-reducer-projection.spec.ts | 14 ++++---- .../session-timeline-working.spec.ts | 2 +- .../subagent-child-navigation.spec.ts | 6 ++-- .../component-tests/file-tool.spec.ts | 2 +- .../component-tests/patch-group.spec.ts | 4 +-- .../component-tests/read-image.spec.ts | 2 +- .../component-tests/session-lifecycle.spec.ts | 16 ++++----- .../session-timeline-reasoning.spec.ts | 4 +-- .../component-tests/session-timeline.spec.ts | 2 +- .../session-tool-projection.spec.ts | 14 ++++---- .../component-tests/tool-disclosure.spec.ts | 8 ++--- .../component-tests/tool-group.spec.ts | 18 +++++----- .../session-ui/src/tools/tool-renderer.tsx | 34 +++++++++---------- packages/ui/src/i18n/en.ts | 7 ++-- 22 files changed, 84 insertions(+), 83 deletions(-) diff --git a/packages/app/e2e/regression/patch-border.spec.ts b/packages/app/e2e/regression/patch-border.spec.ts index 53ea64c3f1b1..d633eb202bba 100644 --- a/packages/app/e2e/regression/patch-border.spec.ts +++ b/packages/app/e2e/regression/patch-border.spec.ts @@ -39,7 +39,7 @@ for (const width of [1400, 390]) { reducedMotion: true, viewport: { width, height: 900 }, }) - await page.getByRole("button", { name: "Used 1 Patch", exact: true }).click() + await page.getByRole("button", { name: "1 used Patch", exact: true }).click() const patch = page.locator('[data-component="apply-patch-tool"]') const trigger = patch.getByRole("button", { name: /patch-border.ts/ }) await expect(trigger).toHaveAttribute("aria-expanded", "false") diff --git a/packages/app/e2e/regression/session-queue.spec.ts b/packages/app/e2e/regression/session-queue.spec.ts index a17aa78b1033..4889e1b91697 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*1 Read, 1 Grep$/) - await expect(tools.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Read, 1 Grep") + await expect(tools).toHaveText(/^2 used\s*Read, Grep$/) + await expect(tools.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Read, 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*1 Read, 1 Grep$/, /U2: Also check the retry path\./]) + await expect.soft(tools.or(pending)).toHaveText([/^2 used\s*Read, 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*1 Read, 1 Grep$/, + /^2 used\s*Read, Grep$/, /U2: Also check the retry path\./, /A3: Now checking the retry path for U2\./, ]) diff --git a/packages/app/e2e/regression/session-timeline-accessibility.spec.ts b/packages/app/e2e/regression/session-timeline-accessibility.spec.ts index 1c5eacad2834..715e67059dbf 100644 --- a/packages/app/e2e/regression/session-timeline-accessibility.spec.ts +++ b/packages/app/e2e/regression/session-timeline-accessibility.spec.ts @@ -25,7 +25,7 @@ test("space activates a focused timeline button instead of scrolling", async ({ seedHistory: true, }) const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) - const trigger = page.getByRole("button", { name: "Used 1 Shell", exact: true }) + const trigger = page.getByRole("button", { name: "1 used Shell", exact: true }) await expect .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight)) .toBeGreaterThan(300) diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts index 91d611df28ab..c40021844144 100644 --- a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -93,8 +93,8 @@ test.describe("regression: session timeline local row state", () => { await expectSessionTitle(page, title) const group = page.locator('[data-component="collapsed-tool-group"]') - const summary = group.getByRole("button", { name: /^Used \d+ Patch$/ }) - await expect(summary).toHaveAccessibleName("Used 1 Patch") + const summary = group.getByRole("button", { name: /^\d+ used Patch$/ }) + await expect(summary).toHaveAccessibleName("1 used Patch") await summary.click() await group.locator(`[data-timeline-part-id="${editPartID}"]`).evaluate((element) => { element.setAttribute("data-disclosure-probe", "existing") @@ -110,8 +110,8 @@ test.describe("regression: session timeline local row state", () => { if (count === 3) await trigger.click() const id = `prt_patch_${count}` events.push(...toolEvents({ ...part, id, callID: id })) - await expect(summary).toHaveAccessibleName(`Used ${count} Patch`) - await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(`${count} Patch`) + await expect(summary).toHaveAccessibleName(`${count} used Patch`) + await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Patch") await expect(group).toHaveAttribute("data-timeline-part-ids", new RegExp(`${id}$`)) await expect(trigger).toHaveAttribute("aria-expanded", String(count === 2)) await expect(summary).toHaveAttribute("aria-expanded", "true") 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 87dedc5385f0..c0033167831a 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 1 Read, 1 Glob, 1 Grep, 1 List") + await expect(context.getByRole("button")).toHaveAccessibleName("4 used Read, Glob, Grep, 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 1 Read, 1 Glob, 1 Grep, 1 List") + await expect(context.getByRole("button")).toHaveAccessibleName("4 used Read, Glob, Grep, 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 1 Read, 1 Glob, 1 Grep, 1 List"]) + expect(labels).toEqual(["4 used Read, Glob, Grep, 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 976042415810..f5ce8244caf9 100644 --- a/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts @@ -16,8 +16,8 @@ for (const locale of ["de", "ar"] as const) { }) const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`) - const names = locale === "de" ? "1 Lesen, 1 Glob" : "1 \u0642\u0631\u0627\u0621\u0629, 1 Glob" - await expect(group.getByRole("button")).toHaveAccessibleName(`Used ${names}`) + const names = locale === "de" ? "Lesen, Glob" : "\u0642\u0631\u0627\u0621\u0629, Glob" + await expect(group.getByRole("button")).toHaveAccessibleName(`2 used ${names}`) await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(names) await expect(page.locator("html")).toHaveAttribute("lang", locale) }) diff --git a/packages/app/e2e/regression/session-timeline-notices.spec.ts b/packages/app/e2e/regression/session-timeline-notices.spec.ts index d340e0067c60..3eca82001479 100644 --- a/packages/app/e2e/regression/session-timeline-notices.spec.ts +++ b/packages/app/e2e/regression/session-timeline-notices.spec.ts @@ -386,7 +386,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({ const used = page .locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]') .locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]') - await expect(used).toHaveText(/^Used\s*2 Agent, 1 Shell$/) + await expect(used).toHaveText(/^3 used\s*Agent, Shell$/) await expect(used).toHaveAttribute("aria-expanded", "false") await used.click() await expect(used).toHaveAttribute("aria-expanded", "true") diff --git a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts index f1b0ce4d1934..2c96cb9ee5ee 100644 --- a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts @@ -46,7 +46,7 @@ test("changes timeline presets and saves custom thinking details", async ({ page .toEqual({ placement: "grouped", details: "collapsed" }) await settings.getByRole("button", { name: "Back to app", exact: true }).click() await expect(settings).toBeHidden() - await page.getByRole("button", { name: "Used 1 Thought", exact: true }).click() + await page.getByRole("button", { name: "1 used Thought", exact: true }).click() await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false") await part.getByRole("button").click() await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible() 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 08ce433dc76f..d31086dc98f9 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 2 Shell, 1 Agent, 1 Patch", exact: true }) + const summary = group.getByRole("button", { name: "4 used Shell, Agent, 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("2 Shell, 1 Agent, 1 Patch") + await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Shell, Agent, 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 1 Patch, 1 Read", exact: true })).toBeVisible() - await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Patch, 1 Read") + await expect(group.getByRole("button", { name: "2 used Patch, Read", exact: true })).toBeVisible() + await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Patch, 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 1 Shell, 1 Patch", exact: true }).click() + await group.getByRole("button", { name: "2 used Shell, 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 1 Shell, 2 Patch", exact: true })).toHaveAttribute( + await expect(group.getByRole("button", { name: "3 used Shell, Patch", exact: true })).toHaveAttribute( "aria-expanded", "true", ) @@ -162,7 +162,7 @@ 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-component="collapsed-tool-group"]') - const summary = group.getByRole("button", { name: "Used 1 Glob, 1 Grep", exact: true }) + const summary = group.getByRole("button", { name: "2 used Glob, Grep", exact: true }) await expect(summary).toHaveAttribute("aria-expanded", "false") await summary.click() await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2) diff --git a/packages/app/e2e/regression/session-timeline-working.spec.ts b/packages/app/e2e/regression/session-timeline-working.spec.ts index abebeabf954d..3a2bfa3b9f87 100644 --- a/packages/app/e2e/regression/session-timeline-working.spec.ts +++ b/packages/app/e2e/regression/session-timeline-working.spec.ts @@ -181,7 +181,7 @@ for (const grouped of [false, true]) { await expect(working).toHaveCount(0) return } - const trigger = group.getByRole("button", { name: "Used 2 Shell", exact: true, includeHidden: true }) + const trigger = group.getByRole("button", { name: "2 used Shell", exact: true, includeHidden: true }) await expect(trigger).toHaveAttribute("aria-expanded", "false") await expect(working).toBeVisible() await trigger.click() diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts index 2a3fe123d6f6..e25465810a52 100644 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -51,7 +51,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) => await page.goto(sessionHref(parentID)) await expectSessionTitle(page, parentTitle) - await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click() + await page.getByRole("button", { name: "1 used Agent", exact: true }).click() await page.locator(`a[href="${sessionHref(childID)}"]`).click() await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))]) await Promise.all([ @@ -76,7 +76,7 @@ test("keeps the parent visible while the child session resolves", async ({ page await page.goto(sessionHref(parentID)) await expectSessionTitle(page, parentTitle) - await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click() + await page.getByRole("button", { name: "1 used Agent", exact: true }).click() await page.locator(`a[href="${sessionHref(childID)}"]`).click() await requested.promise await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally( @@ -194,7 +194,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) { async function openChildFromParent(page: Page) { await page.goto(sessionHref(parentID)) await expectSessionTitle(page, parentTitle) - await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click() + await page.getByRole("button", { name: "1 used Agent", exact: true }).click() const card = page.locator(`a[href="${sessionHref(childID)}"]`) await expect(card).toBeVisible() diff --git a/packages/session-ui/component-tests/file-tool.spec.ts b/packages/session-ui/component-tests/file-tool.spec.ts index 4a207f54680d..fb9d5a5dfdcc 100644 --- a/packages/session-ui/component-tests/file-tool.spec.ts +++ b/packages/session-ui/component-tests/file-tool.spec.ts @@ -7,7 +7,7 @@ for (const theme of ["light", "dark"]) { globals: { theme }, }) const group = root.locator('[data-component="collapsed-tool-group"]').filter({ hasText: "Patch" }) - const disclosure = group.getByRole("button", { name: /^Used .*Edit.*Write.*Patch$/ }) + const disclosure = group.getByRole("button", { name: /^\d+ used .*Edit.*Write.*Patch$/ }) await disclosure.click() for (const name of ["edit", "write", "patch"]) { const tool = group.locator(`[data-timeline-part-id="tool_family_${name}"]`) diff --git a/packages/session-ui/component-tests/patch-group.spec.ts b/packages/session-ui/component-tests/patch-group.spec.ts index b3c8cfd246f0..cc35bd74e4a7 100644 --- a/packages/session-ui/component-tests/patch-group.spec.ts +++ b/packages/session-ui/component-tests/patch-group.spec.ts @@ -11,8 +11,8 @@ story("merges follow-up patches into one stack with a distinct file count", asyn await expect(first).toHaveAttribute("aria-expanded", "true") 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 /) + group.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-prefix"]'), + ).toHaveText("3 used") 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..8e51837746df 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: "4 used Shell, Read, Agent", 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..13c9be1bbe17 100644 --- a/packages/session-ui/component-tests/session-lifecycle.spec.ts +++ b/packages/session-ui/component-tests/session-lifecycle.spec.ts @@ -18,8 +18,8 @@ for (const tool of ["shell", "execute", "subagent"]) { if (action) await timeline.getByRole("button", { name: action, exact: true }).click() 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 /) + group.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-prefix"]'), + ).toHaveText("2 used") 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) @@ -35,7 +35,7 @@ for (const expanded of [false, true]) { const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { expanded } }) const trigger = expanded ? timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"] [data-slot="collapsible-trigger"]') - : timeline.getByRole("button", { name: "Used 1 Shell", exact: true }) + : timeline.getByRole("button", { name: "1 used Shell", exact: true }) await expect(trigger).toHaveAttribute("aria-expanded", String(expanded)) await trigger.click() await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded)) @@ -83,7 +83,7 @@ story("transitions a streaming shell from writing through command execution", as await expect(subtitle).toHaveText("printf ready") await expect(tool).not.toContainText("Writing command…") await timeline.getByRole("button", { name: "Complete command" }).click() - const summary = timeline.getByRole("button", { name: "Used 1 Shell", exact: true }) + const summary = timeline.getByRole("button", { name: "1 used Shell", exact: true }) await expect(summary).toHaveAttribute("aria-expanded", "false") await summary.click() await expect(subtitle).toHaveText("printf ready") @@ -130,7 +130,7 @@ for (const open of [false, true]) { await expect(thought).not.toContainText("Inspecting stability") await expect(thought).toHaveAttribute("aria-expanded", String(open)) await timeline.getByRole("button", { name: "Finish session" }).click() - const used = group.getByRole("button", { name: "Used 1 Shell", exact: true }) + const used = group.getByRole("button", { name: "1 used Shell", exact: true }) await expect(used).toHaveAttribute("aria-expanded", "false") await used.click() await expect(used).toHaveAttribute("aria-expanded", "true") @@ -141,7 +141,7 @@ for (const open of [false, true]) { ) await expect( group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'), - ).toHaveText("1 Shell") + ).toHaveText("Shell") await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) await expect(used).toHaveAttribute("aria-expanded", "true") if (!open) await thought.click() @@ -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(/^2 used /) await expect( group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'), - ).toHaveText(/^2 /) + ).toHaveText(locale === "de" ? "Lesen, Glob" : "\u0642\u0631\u0627\u0621\u0629, Glob") await expect(page.locator("html")).toHaveAttribute("lang", locale) }) } diff --git a/packages/session-ui/component-tests/session-timeline-reasoning.spec.ts b/packages/session-ui/component-tests/session-timeline-reasoning.spec.ts index b1a0d7eaaa1a..b8088fb4b21e 100644 --- a/packages/session-ui/component-tests/session-timeline-reasoning.spec.ts +++ b/packages/session-ui/component-tests/session-timeline-reasoning.spec.ts @@ -50,11 +50,11 @@ for (const mode of ["hidden", "compact", "full"] as const) { if (following === "tool") { const group = timeline.locator('[data-component="collapsed-tool-group"]') const trigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]') - await expect(trigger).toHaveText(/^Used\s*1 Skill$/) + await expect(trigger).toHaveText(/^1 used\s*Skill$/) await expect(trigger).toHaveAttribute("aria-expanded", "false") await expect( group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'), - ).toHaveText("1 Skill") + ).toHaveText("Skill") await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeHidden() await trigger.click() await expect(trigger).toHaveAttribute("aria-expanded", "true") diff --git a/packages/session-ui/component-tests/session-timeline.spec.ts b/packages/session-ui/component-tests/session-timeline.spec.ts index 378eb8e63fa2..7e934eb33a35 100644 --- a/packages/session-ui/component-tests/session-timeline.spec.ts +++ b/packages/session-ui/component-tests/session-timeline.spec.ts @@ -53,7 +53,7 @@ story("space activates a focused timeline button instead of scrolling", async ({ await page.setViewportSize({ width: 800, height: 240 }) const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { scenario: "collapsed" } }) await expect.poll(() => page.evaluate(() => document.documentElement.scrollHeight - innerHeight)).toBeGreaterThan(0) - const trigger = timeline.getByRole("button", { name: "Used 1 Shell", exact: true }) + const trigger = timeline.getByRole("button", { name: "1 used Shell", exact: true }) await expect(trigger).toHaveAttribute("aria-expanded", "false") await trigger.focus() const before = await page.evaluate(() => window.scrollY) 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 a898dfd847d9..190012d74121 100644 --- a/packages/session-ui/component-tests/session-tool-projection.spec.ts +++ b/packages/session-ui/component-tests/session-tool-projection.spec.ts @@ -45,8 +45,8 @@ story("renders every tool error outcome without leaking hidden tools", async ({ const names = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"] 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} `)) + group.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-prefix"]'), + ).toHaveText(`${names.length} used`) 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"]') @@ -72,7 +72,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: "3 used Parallel Web Search, Exa Web Search, 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() @@ -105,10 +105,10 @@ story("labels read tools from their path input", async ({ mount }) => { 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") + await expect(group.getByRole("button")).toHaveAccessibleName("2 used Skill") await expect( group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'), - ).toHaveText("2 Skill") + ).toHaveText("Skill") await group.getByRole("button").click() const loaded = group.locator('[data-component="tool-loaded-item"]') await expect(loaded).toHaveCount(1) @@ -130,10 +130,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("4 used Glob, Grep, Shell, List") await expect( group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'), - ).toHaveText("4 Glob, Grep, Shell, List") + ).toHaveText("Glob, Grep, Shell, 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..07422ec2eeb6 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: /^\d+ used Shell, Patch$/ }) + await expect(trigger).toHaveAccessibleName("2 used Shell, 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("Shell, Patch") + await expect(trigger).toHaveAccessibleName(`${count} used Shell, 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..ca1a98e12a8d 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: /^\d+ used Read, Skill$/ }) 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("4 used Read, Skill") await expect( group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'), - ).toHaveText("1 Read, 3 Skill") + ).toHaveText("Read, Skill") 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("5 used Read, Skill") await expect( group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'), - ).toHaveText("2 Read, 3 Skill") + ).toHaveText("Read, Skill") 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: "4 used Shell, Read, Agent", 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("Shell, Read, Agent") 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: "4 used Shell, Read, Agent", 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("Shell, Read, Agent") 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/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index eb583cad2e95..ffa5bac0a316 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -516,22 +516,21 @@ export function CurrentContextToolGroup(props: { ) const names = createMemo(() => [ - ...props.parts.reduce((counts, part) => { - if (part.type !== "tool" && part.type !== "shell") return counts - const name = - part.type !== "tool" - ? i18n.t("ui.tool.shell") - : part.name === "skill" - ? i18n.t("ui.tool.skill") - : part.name === "subagent" - ? i18n.t("ui.tool.agent.default") - : getToolInfo(part.name, currentToolInput(part), currentToolMetadata(part)).title - counts.set(name, (counts.get(name) ?? 0) + 1) - return counts - }, new Map()), - ] - .map(([name, count]) => `${count} ${name}`) - .join(", "), + ...new Set( + props.parts.flatMap((part) => { + if (part.type !== "tool" && part.type !== "shell") return [] + return [ + part.type !== "tool" + ? i18n.t("ui.tool.shell") + : part.name === "skill" + ? i18n.t("ui.tool.skill") + : part.name === "subagent" + ? i18n.t("ui.tool.agent.default") + : getToolInfo(part.name, currentToolInput(part), currentToolMetadata(part)).title, + ] + }), + ), + ].join(", "), ) const label = createMemo(() => { const thoughts = props.parts.filter((part) => part.type === "reasoning").length @@ -540,7 +539,8 @@ export function CurrentContextToolGroup(props: { return { text: title, title, before: "", after: "" } } const title = names() || i18n.plural("ui.messagePart.context.thought", thoughts) - const text = i18n.t("ui.messagePart.tools.used", { tools: title }) + const count = props.parts.filter((part) => part.type === "tool" || part.type === "shell").length || thoughts + const text = i18n.plural("ui.messagePart.tools.used", count, { 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 24e180694ac6..1c709cf5c49c 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -114,11 +114,12 @@ const source = { "ui.messagePart.context.list.other": "{{count}} lists", "ui.messagePart.context.notice.one": "{{count}} Notice", "ui.messagePart.context.notice.other": "{{count}} Notices", - "ui.messagePart.context.thought.one": "{{count}} Thought", - "ui.messagePart.context.thought.other": "{{count}} Thoughts", + "ui.messagePart.context.thought.one": "Thought", + "ui.messagePart.context.thought.other": "Thoughts", "ui.messagePart.context.match.one": "({{count}} match)", "ui.messagePart.context.match.other": "({{count}} matches)", - "ui.messagePart.tools.used": "Used {{tools}}", + "ui.messagePart.tools.used.one": "{{count}} used {{tools}}", + "ui.messagePart.tools.used.other": "{{count}} used {{tools}}", "ui.list.loading": "Loading", "ui.list.empty": "No results",