From 90501dd6e3350df37a405e216644bb055b37656c Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:08:55 +0800 Subject: [PATCH] fix(app): count timeline tool types (#46749) --- .../app/e2e/regression/session-queue.spec.ts | 8 +++--- .../session-timeline-context-resize.spec.ts | 6 ++--- ...session-timeline-locale-projection.spec.ts | 6 ++--- ...ession-timeline-reducer-projection.spec.ts | 16 ++++++------ .../component-tests/tool-group.spec.ts | 18 ++++++------- .../session-ui/src/tools/tool-renderer.tsx | 25 +++++++++++-------- 6 files changed, 42 insertions(+), 37 deletions(-) diff --git a/packages/app/e2e/regression/session-queue.spec.ts b/packages/app/e2e/regression/session-queue.spec.ts index 450ddba3df4..492e844ebae 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 56b0f81b7de..e19658c138b 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 6be176057e9..97604241581 100644 --- a/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts @@ -16,9 +16,9 @@ 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}`) + const names = locale === "de" ? "1 Lesen, 1 Glob" : "1 \u0642\u0631\u0627\u0621\u0629, 1 Glob" + await expect(group.getByRole("button")).toHaveAccessibleName(`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-reducer-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts index b1fde4d4c03..548a8777696 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/tool-group.spec.ts b/packages/session-ui/component-tests/tool-group.spec.ts index 13eaf94a694..e7b03897c2b 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, 3 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 4 Read, Skill") + await expect(used).toHaveAccessibleName("Used 1 Read, 3 Skill") await expect( group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'), - ).toHaveText("4 Read, Skill") + ).toHaveText("1 Read, 3 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 5 Read, Skill") + await expect(used).toHaveAccessibleName("Used 2 Read, 3 Skill") await expect( group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'), - ).toHaveText("5 Read, Skill") + ).toHaveText("2 Read, 3 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 4 Shell, Read, Agent", exact: true })).toBeVisible() + await expect(group.getByRole("button", { name: "Used 1 Shell, 1 Read, 2 Agent", 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 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 4 Shell, Read, Agent", exact: true }) + const trigger = group.getByRole("button", { name: "Used 1 Shell, 1 Read, 2 Agent", 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 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 fff21e9078e..7255b10dbf0 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -499,18 +499,23 @@ export function CurrentContextToolGroup(props: { ) 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(", "), + ...tools().reduce((counts, 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 + counts.set(name, (counts.get(name) ?? 0) + 1) + return counts + }, new Map()), + ] + .map(([name, count]) => `${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() }