From ea582fc133170b7a798d211b74f4e940f20728cb Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:56:53 +1000 Subject: [PATCH] feat(app): stack collapsed tool calls (#45176) --- .../session-timeline-accessibility.spec.ts | 2 +- .../session-timeline-context-resize.spec.ts | 19 +- .../session-timeline-file-projection.spec.ts | 1 + .../session-timeline-lifecycle-state.spec.ts | 4 +- ...session-timeline-locale-projection.spec.ts | 16 +- .../session-timeline-projection.spec.ts | 15 +- ...ession-timeline-reducer-projection.spec.ts | 111 ++++++++- .../session-timeline-tool-projection.spec.ts | 19 +- .../subagent-child-navigation.spec.ts | 6 +- .../app/src/session/timeline/controller.tsx | 2 + .../app/src/session/timeline/projection.ts | 4 + .../session-ui/src/components/basic-tool.css | 14 +- .../src/components/message-part.css | 52 +++- .../src/components/tool-error-card.css | 2 +- .../src/components/tool-error-card.tsx | 3 +- .../src/message/current-message.test.ts | 3 +- .../src/message/current-tool-state.ts | 2 +- .../session-ui/src/timeline/projection.ts | 62 ++++- .../src/timeline/rows-current.test.ts | 224 +++++++++++++++++- .../src/timeline/session-timeline-row.tsx | 4 +- .../src/timeline/session-timeline.tsx | 2 + .../session-ui/src/timeline/timeline-row.ts | 2 + .../session-ui/src/tools/tool-renderer.tsx | 133 +++++++---- packages/ui/src/i18n/en.ts | 1 + packages/ui/src/icons/icon/icon.tsx | 4 + 25 files changed, 579 insertions(+), 128 deletions(-) diff --git a/packages/app/e2e/regression/session-timeline-accessibility.spec.ts b/packages/app/e2e/regression/session-timeline-accessibility.spec.ts index 598763c0225..05c89cd4353 100644 --- a/packages/app/e2e/regression/session-timeline-accessibility.spec.ts +++ b/packages/app/e2e/regression/session-timeline-accessibility.spec.ts @@ -9,7 +9,7 @@ test("space activates a focused timeline button instead of scrolling", async ({ reducedMotion: true, }) const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) - const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`) + const trigger = page.getByRole("button", { name: "Used Shell" }) await trigger.focus() const before = await scroller.evaluate((element) => element.scrollTop) await trigger.press("Space") 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 67b6c4576da..1e36e77368a 100644 --- a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts +++ b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts @@ -40,7 +40,7 @@ test.describe("regression: session timeline context group resize", () => { expect(samples.at(-1)?.expanded).toBe("true") }) - test("paints a stable exploring to explored transition", async ({ page }) => { + test("keeps a grouped tool summary stable as its calls complete", async ({ page }) => { const events: OpenCodeEvent[] = [] await page.setViewportSize({ width: 1400, height: 900 }) await mockServer(page, events, [ @@ -55,13 +55,12 @@ 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.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Exploring") + await expect(context.getByRole("button")).toHaveAccessibleName("Used Read, Glob, Grep, List") const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]` const regions = defineVisualRegions({ status: { - selector: `${contextSelector} [data-component="tool-status-title"]`, - opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'], + selector: `${contextSelector} [data-component="context-tool-group-trigger"]`, }, context: { selector: contextSelector, closest: '[data-timeline-row="AssistantPart"]' }, following: { @@ -89,7 +88,7 @@ test.describe("regression: session timeline context group resize", () => { await page.waitForTimeout(delay) } - await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored") + await expect(context.getByRole("button")).toHaveAccessibleName("Used Read, Glob, Grep, List") await page.waitForTimeout(700) const trace = await stopVisualProbe(page) const labels = trace.samples @@ -108,7 +107,7 @@ test.describe("regression: session timeline context group resize", () => { ]), ) - expect(labels).toEqual(["Exploring", "Explored"]) + expect(labels).toEqual(["Used Read, Glob, Grep, List"]) expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([]) }) }) @@ -209,13 +208,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" = const content: SessionMessageAssistant["content"] = target ? [ toolContent( - contextTool( - contextIDs[0]!, - assistantID, - "read", - { path: "src/recent-a.ts", offset: 0, limit: 120 }, - status, - ), + contextTool(contextIDs[0]!, assistantID, "read", { path: "src/recent-a.ts", offset: 0, limit: 120 }, status), ), toolContent(contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status)), toolContent( diff --git a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts index df0d8c77c98..dede6b12a2c 100644 --- a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts @@ -83,6 +83,7 @@ test("keeps an expanded file diff header at the same viewport position", async ( const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("") const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n") await setupTimeline(page, { + settings: { editToolPartsExpanded: true }, messages: [ userMessage([userText("Preceding context ".repeat(120))]), assistantMessage([ diff --git a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts index f81dd081adc..b83009c2e0b 100644 --- a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts @@ -26,7 +26,9 @@ for (const expanded of [false, true]) { messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])], settings: { shellToolPartsExpanded: expanded }, }) - const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`) + const trigger = expanded + ? page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`) + : page.getByRole("button", { name: "Used Shell" }) await expect(trigger).toHaveAttribute("aria-expanded", String(expanded)) await trigger.click() await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded)) 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 0f6d57f17f9..30405aff356 100644 --- a/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts @@ -1,12 +1,9 @@ import { expect, test } from "@playwright/test" import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" -for (const profile of [ - { locale: "de", label: "Erkundung abgeschlossen" }, - { locale: "ar", label: "تم الاستكشاف" }, -] as const) { - test(`projects translated context status in ${profile.locale}`, async ({ page }) => { - const ids = [`prt_locale_${profile.locale}_01_read`, `prt_locale_${profile.locale}_02_glob`] +for (const locale of ["de", "ar"] as const) { + test(`projects localized tool names with an English fallback in ${locale}`, async ({ page }) => { + const ids = [`prt_locale_${locale}_01_read`, `prt_locale_${locale}_02_glob`] await setupTimeline(page, { messages: [ userMessage(), @@ -15,11 +12,12 @@ for (const profile of [ toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }), ]), ], - locale: profile.locale, + locale, }) const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`) - await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", profile.label) - await expect(page.locator("html")).toHaveAttribute("lang", profile.locale) + await expect(group.getByRole("button")).toHaveAccessibleName(/^Used /) + await expect(group.locator('[data-component="tag"]')).toHaveText("2") + await expect(page.locator("html")).toHaveAttribute("lang", locale) }) } diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts index 15fad5bd031..0b26de7f128 100644 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -53,9 +53,14 @@ test.describe("session timeline projection", () => { ] await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) - await expect( - page.locator('[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list"]'), - ).toBeVisible() + const first = page.locator( + '[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list,prt_webfetch,prt_websearch,prt_task,prt_bash,prt_edit,prt_write,prt_patch"]', + ) + const second = page.locator('[data-timeline-part-ids="prt_skill,prt_custom"]') + await expect(first).toBeVisible() + await expect(second).toBeVisible() + await first.getByRole("button").click() + await second.getByRole("button").click() for (const id of [ "prt_webfetch", "prt_websearch", @@ -78,8 +83,7 @@ test.describe("session timeline projection", () => { await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0) await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0) const edit = page.locator('[data-timeline-part-id="prt_edit"]') - await expect(edit.locator('[data-component="apply-patch-tool"]')).toBeVisible() - await expect(edit.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit") + await expect(edit).toContainText("Edit") await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0) }) @@ -87,6 +91,7 @@ test.describe("session timeline projection", () => { const first = "prt_patch_first" const second = "prt_patch_second" const timeline = await setupTimeline(page, { + settings: { editToolPartsExpanded: true }, messages: [ userMessage(), assistantMessage([ 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 22c94ae1c65..5603c2ef797 100644 --- a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts @@ -13,7 +13,7 @@ import { userMessage, } from "../performance/timeline-stability/fixture" -test("groups singleton and separated context operations at correct boundaries", async ({ page }) => { +test("groups every collapsed tool until visible text separates the stack", async ({ page }) => { const parts = [ toolPart("prt_boundary_01_read", "read", "completed", { path: "src/a.ts" }), textPart("prt_boundary_02_text", "Boundary text"), @@ -25,9 +25,112 @@ test("groups singleton and separated context operations at correct boundaries", await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible() - await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible() - await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible() - await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5) + const group = page.locator( + '[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep,prt_boundary_05_shell,prt_boundary_06_list"]', + ) + await expect(group).toBeVisible() + await expect(group.getByRole("button")).toHaveAccessibleName("Used Glob, Grep, Shell, List") + await expect(group.locator('[data-component="tag"]')).toHaveText("4") + await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(3) + await expect(page.locator('[data-timeline-spacing="content"]')).toHaveCount(2) + await expect(page.locator('[data-timeline-spacing="content"]').nth(0)).toHaveCSS("padding-top", "16px") +}) + +test("expands a mixed collapsed tool stack without expanding its individual calls", async ({ page }) => { + const parts = [ + shell("prt_stack_shell_1", "completed", "first"), + toolPart("prt_stack_explore", "subagent", "completed", { + agent: "explore", + description: "Inspect the project", + prompt: "Explore the project", + }), + toolPart("prt_stack_patch", "patch", "completed", { patchText: "Update src/value.ts" }), + shell("prt_stack_shell_2", "completed", "second"), + ] + await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) + + 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 Shell, Explore, Patch" }) + await expect(summary).toHaveAttribute("aria-expanded", "false") + await expect(summary).toHaveCSS("height", "28px") + await expect(group.locator('[data-component="tag"]')).toHaveText("4") + await summary.click() + await expect(summary).toHaveAttribute("aria-expanded", "true") + await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveCount(4) + await expect(group.locator('[data-timeline-part-id="prt_stack_shell_1"]')).toBeVisible() + await expect(group.locator('[data-timeline-part-id="prt_stack_patch"]')).toBeVisible() + await expect(group.locator('[data-component="context-tool-group-list"]')).toHaveCSS("row-gap", "8px") + const content = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-content"]') + await expect(content).toHaveCSS("margin-left", "0px") + await expect(content).toHaveCSS("padding-left", "12px") + await expect.poll(() => content.evaluate((element) => getComputedStyle(element, "::before").content)).toBe("none") +}) + +test("leaves tools expanded by settings outside the collapsed stack", async ({ page }) => { + const parts = [ + shell("prt_expanded_shell", "completed", "expanded"), + toolPart("prt_collapsed_patch", "patch", "completed", { patchText: "Update src/value.ts" }), + toolPart("prt_collapsed_read", "read", "completed", { path: "src/value.ts" }), + ] + await setupTimeline(page, { + messages: [userMessage(), assistantMessage(parts)], + settings: { shellToolPartsExpanded: true }, + }) + + 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 Patch, Read" })).toBeVisible() + await expect(group.locator('[data-component="tag"]')).toHaveText("2") + await expect(page.locator('[data-timeline-spacing="tool"]')).toHaveCSS("padding-top", "8px") +}) + +test("keeps failed search calls and their error cards inside the collapsed stack", async ({ page }) => { + const parts = [ + toolPart( + "prt_error_glob", + "glob", + "error", + { path: "C:/Users", pattern: "*.ts" }, + { + error: "Invalid tool input", + }, + ), + toolPart( + "prt_error_grep", + "grep", + "error", + { path: "C:/Users", pattern: "value" }, + { + error: "Search timed out after 30 seconds", + }, + ), + ] + 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 Glob, Grep" }) + await expect(group.locator('[data-component="tag"]')).toHaveText("2") + 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"]') + await expect(glob).toContainText("Invalid tool input") + await expect(glob.locator('[data-component="tool-error-card-icon"]')).toBeVisible() + await expect(glob.locator('[data-component="tool-error-card-icon"] use')).toHaveAttribute( + "href", + "#opencode-v2-icon-circle-exclamation", + ) + await expect + .poll(() => + glob + .locator('[data-kind="tool-error-card"]') + .evaluate((element) => getComputedStyle(element, "::before").display), + ) + .toBe("none") + await expect(group.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText( + "Search timed out after 30 seconds", + ) }) test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => { diff --git a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts index af21a50b909..425565ac1ae 100644 --- a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts @@ -21,6 +21,9 @@ test("renders every tool error outcome without leaking hidden tools", async ({ p ) await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) + const group = page.locator(`[data-timeline-part-ids="${ordinary.map((_, index) => `prt_error_${index}`).join(",")}"]`) + await expect(group.locator('[data-component="tag"]')).toHaveText(String(ordinary.length)) + await group.getByRole("button").click() await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1) await expect(page.getByText(/dismissed/i)).toBeVisible() await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0) @@ -33,6 +36,7 @@ test("transitions shell and question through running error outcomes", async ({ p const shellID = "prt_transition_error_shell" const questionID = "prt_transition_error_question" const timeline = await setupTimeline(page, { + settings: { shellToolPartsExpanded: true }, messages: [ userMessage(), assistantMessage( @@ -44,7 +48,6 @@ test("transitions shell and question through running error outcomes", async ({ p ), ], }) - await timeline.waitForPart(shellID) await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })), 120) await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180) @@ -68,6 +71,7 @@ test("preserves surviving grouped patch state when its first patch fails", async const failed = "prt_grouped_patch_failed" const surviving = "prt_grouped_patch_surviving" const timeline = await setupTimeline(page, { + settings: { editToolPartsExpanded: true }, messages: [ userMessage(), assistantMessage( @@ -147,10 +151,12 @@ test("labels all web search provider variants", async ({ page }) => { toolPart("prt_search_generic", "websearch", "completed", { query: "generic" }), ] await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) + await page.getByRole("button", { name: "Used Parallel Web Search, Exa Web Search, Web Search" }).click() - await expect(page.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible() - await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible() - await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible() + const tools = page.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() + await expect(tools.getByRole("button", { name: /^Web Search/ })).toBeVisible() }) test("labels completed searches with result counts", async ({ page }) => { @@ -201,6 +207,11 @@ test("labels skill tools from IDs and result metadata", async ({ page }) => { ], }) + const group = page.locator(`[data-timeline-part-ids="${pending},${completed}"]`) + await expect(group.getByRole("button")).toHaveAccessibleName("Used Skill") + await expect(group.locator('[data-component="tag"]')).toHaveText("2") + await group.getByRole("button").click() + for (const [id, name] of [ [pending, "frontend-design"], [completed, "OpenCode"], diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts index 937dfab6015..60c63843298 100644 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -42,8 +42,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) => const release = Promise.withResolvers() await page.route( (url) => - url.pathname === `/api/session/${childID}/message` && - url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"), + url.pathname === `/api/session/${childID}/message` && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"), async (route) => { requested.resolve() await release.promise @@ -53,6 +52,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 Explore" }).click() await page.locator(`a[href="${sessionHref(childID)}"]`).click() await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))]) await Promise.all([ @@ -77,6 +77,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 Explore" }).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,6 +195,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 Explore" }).click() const card = page.locator(`a[href="${sessionHref(childID)}"]`) await expect(card).toBeVisible() diff --git a/packages/app/src/session/timeline/controller.tsx b/packages/app/src/session/timeline/controller.tsx index e84e800c780..bfd40eb4844 100644 --- a/packages/app/src/session/timeline/controller.tsx +++ b/packages/app/src/session/timeline/controller.tsx @@ -105,6 +105,8 @@ export function createTimelineController(input: { session: TimelineSessionSource sessionMessages: projectedMessages, status: input.session.data.status, showReasoningSummaries: settings.general.showReasoningSummaries, + shellToolDefaultOpen: settings.general.shellToolPartsExpanded, + editToolDefaultOpen: settings.general.editToolPartsExpanded, pendingUserMessageIDs, }) const [pending, setPending] = createStore({ rename: false }) diff --git a/packages/app/src/session/timeline/projection.ts b/packages/app/src/session/timeline/projection.ts index 361324560ba..9884bb906ba 100644 --- a/packages/app/src/session/timeline/projection.ts +++ b/packages/app/src/session/timeline/projection.ts @@ -8,6 +8,8 @@ export function createTimelineProjection(input: { sessionMessages: Accessor status: Accessor showReasoningSummaries: Accessor + shellToolDefaultOpen: Accessor + editToolDefaultOpen: Accessor pendingUserMessageIDs: Accessor> }) { const sessionMessageByID = createMemo( @@ -81,6 +83,8 @@ export function createTimelineProjection(input: { input.showReasoningSummaries(), input.status(), input.pendingUserMessageIDs(), + input.shellToolDefaultOpen(), + input.editToolDefaultOpen(), ), ) const activeMessageID = createMemo(() => projection().activeMessageID) diff --git a/packages/session-ui/src/components/basic-tool.css b/packages/session-ui/src/components/basic-tool.css index 5db915b9df6..0fce2044a36 100644 --- a/packages/session-ui/src/components/basic-tool.css +++ b/packages/session-ui/src/components/basic-tool.css @@ -284,19 +284,7 @@ [data-component="collapsible"].tool-collapsible:not([data-rail="false"]) { > [data-slot="collapsible-content"] { - position: relative; - margin-inline-start: 12px; - padding-inline-start: 16px; - - &::before { - content: ""; - position: absolute; - inset-inline-start: 0; - top: 0; - bottom: 12px; - width: 0.5px; - background-color: var(--v2-border-border-muted, rgba(0, 0, 0, 0.08)); - } + padding-inline-start: 12px; } } diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index 86a3b8534b9..6fcef2b4c50 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -263,6 +263,10 @@ } } +[data-timeline-row="AssistantPart"][data-timeline-spacing="content"] [data-component="text-part"] { + margin-top: 0; +} + [data-component="reasoning-part"] { width: 100%; color: var(--v2-text-text-muted); @@ -663,28 +667,66 @@ cursor: default; [data-slot="context-tool-group-title"] { + display: flex; + align-items: center; + gap: 8px; flex-shrink: 1; min-width: 0; } + [data-slot="context-tool-group-prefix"] { + flex-shrink: 0; + color: var(--v2-text-text-muted); + font-family: var(--v2-font-family-sans); + font-size: 13px; + font-weight: 440; + line-height: var(--line-height-compact); + letter-spacing: -0.04px; + } + + [data-slot="basic-tool-tool-title"] { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + [data-component="tag"] { + flex-shrink: 0; + border: 0; + background: var(--v2-background-bg-layer-03); + } + [data-slot="collapsible-arrow"] { color: var(--icon-weaker); cursor: pointer; } } +[data-component="collapsed-tool-group"] > [data-component="collapsible"].tool-collapsible { + --tool-content-gap: 8px; +} + [data-component="context-tool-group-list"] { - /* The 28px compact trigger centers a 16px line box, already leaving 6px above this list. */ - padding: 4px 0 0 12px; + padding: 0; display: flex; flex-direction: column; - /* 16px line boxes with 13px gaps reproduce the design's 29px row pitch for 13px solid rows. */ - gap: 13px; + gap: 8px; [data-slot="context-tool-group-item"] { min-width: 0; + min-height: 28px; + display: flex; + flex-direction: column; + justify-content: center; padding: 0; - opacity: 0.8; + + > [data-component="tool-part-wrapper"] { + width: 100%; + } + + > [data-component="tool-part-wrapper"] > [data-component="collapsible"] > [data-slot="collapsible-trigger"] { + height: 28px; + } [data-slot="basic-tool-tool-info-structured"] { gap: 6px; diff --git a/packages/session-ui/src/components/tool-error-card.css b/packages/session-ui/src/components/tool-error-card.css index d62f7bf6eca..a0d4519c860 100644 --- a/packages/session-ui/src/components/tool-error-card.css +++ b/packages/session-ui/src/components/tool-error-card.css @@ -4,7 +4,7 @@ --card-pad-r: 0px; &::before { - border-radius: 0; + display: none; } /* Figma's leading-none would clip Inter descenders at 13px; keep the compact metric. */ diff --git a/packages/session-ui/src/components/tool-error-card.tsx b/packages/session-ui/src/components/tool-error-card.tsx index 05221978df3..f27c267cee2 100644 --- a/packages/session-ui/src/components/tool-error-card.tsx +++ b/packages/session-ui/src/components/tool-error-card.tsx @@ -97,8 +97,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
- {/* 20px-viewBox path at 16px: 1.25 renders the 1px stroke Figma specifies. */} - +
diff --git a/packages/session-ui/src/message/current-message.test.ts b/packages/session-ui/src/message/current-message.test.ts index a1337f87d95..c94f7c724a1 100644 --- a/packages/session-ui/src/message/current-message.test.ts +++ b/packages/session-ui/src/message/current-message.test.ts @@ -26,7 +26,8 @@ describe("current content default open", () => { test("uses the file-change disclosure preference", () => { expect(currentContentDefaultOpen(tool("edit"), false, true)).toBe(true) expect(currentContentDefaultOpen(tool("write"), false, false)).toBe(false) - expect(currentContentDefaultOpen(tool("patch"), false, false)).toBe(true) + expect(currentContentDefaultOpen(tool("patch"), false, false)).toBe(false) + expect(currentContentDefaultOpen(tool("patch"), false, true)).toBe(true) }) test("collapses errored tools regardless of disclosure preferences", () => { diff --git a/packages/session-ui/src/message/current-tool-state.ts b/packages/session-ui/src/message/current-tool-state.ts index b8ef9a7cd6f..d2da83d8c2a 100644 --- a/packages/session-ui/src/message/current-tool-state.ts +++ b/packages/session-ui/src/message/current-tool-state.ts @@ -38,7 +38,7 @@ export function currentContentDefaultOpen( // Errored tools render the error card, which starts collapsed. if (content.state.status === "error") return false if (content.name === "shell" || content.name === "execute") return shellExpanded - if (content.name === "patch") return true + if (content.name === "patch") return editExpanded if (content.name !== "edit" && content.name !== "write") return undefined if (!editExpanded) return false const files = currentToolMetadata(content).files diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index ffab708ec2c..0fae645c87c 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -8,6 +8,7 @@ import type { } from "@opencode-ai/client/promise" import { Option, Schema } from "effect" import { createMemo, type Accessor } from "solid-js" +import { currentContentDefaultOpen } from "../message/current-tool-state" import { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap } from "./timeline-row" export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap } @@ -18,13 +19,14 @@ type Content = SessionMessageAssistant["content"][number] type GroupRow = Extract type PriorGroup = { index: number; row: GroupRow } -const contextTools = new Set(["read", "glob", "grep", "list"]) const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)) export type TimelineProjectionInput = { sessionMessages: SessionMessageInfo[] status: SessionStatus showReasoningSummaries: boolean + shellToolDefaultOpen?: boolean + editToolDefaultOpen?: boolean pendingUserMessageIDs?: ReadonlySet previousRows?: TimelineRow.TimelineRow[] } @@ -36,6 +38,8 @@ export function createTimelineProjection(input: TimelineProjectionInput) { input.showReasoningSummaries, input.status, input.pendingUserMessageIDs, + input.shellToolDefaultOpen ?? false, + input.editToolDefaultOpen ?? false, ) const rows = reuseTimelineRows(input.previousRows, projection.rows) const rowByKey = new Map(rows.map((row) => [TimelineRow.key(row), row] as const)) @@ -67,6 +71,8 @@ export function createReactiveTimelineProjection(input: { sessionMessages: Accessor status: Accessor showReasoningSummaries: Accessor + shellToolDefaultOpen?: Accessor + editToolDefaultOpen?: Accessor pendingUserMessageIDs?: Accessor> }) { const sessionMessageByID = createMemo( @@ -80,6 +86,8 @@ export function createReactiveTimelineProjection(input: { input.showReasoningSummaries(), input.status(), input.pendingUserMessageIDs?.(), + input.shellToolDefaultOpen?.() ?? false, + input.editToolDefaultOpen?.() ?? false, ), ) const activeMessageID = createMemo(() => projection().activeMessageID) @@ -128,6 +136,8 @@ export namespace Timeline { showReasoning: boolean, status: SessionStatus, pendingUserMessageIDs?: ReadonlySet, + shellToolDefaultOpen = false, + editToolDefaultOpen = false, ) { type Turn = { id: string @@ -204,6 +214,8 @@ export namespace Timeline { showReasoning, status, turn.id === activeMessageID, + shellToolDefaultOpen, + editToolDefaultOpen, ) }), ], @@ -218,6 +230,8 @@ export namespace Timeline { showReasoning: boolean, status: SessionStatus, isActive: boolean, + shellToolDefaultOpen = false, + editToolDefaultOpen = false, ) { const rows: TimelineRow.TimelineRow[] = [] const assistantMessages = entries.flatMap((entry) => (entry.type === "assistant" ? [entry.message] : [])) @@ -237,6 +251,7 @@ export namespace Timeline { if (userMessage) rows.push(new TimelineRow.UserMessage({ userMessageID: turnID })) let assistantGroupIndex = 0 + let previousAssistantTool = false // An assistant message can produce several rows because its content parts are // rendered separately. Notices end a segment so none of those rows cross it. const appendAssistantSegment = (messages: SessionMessageAssistant[]) => { @@ -248,17 +263,23 @@ export namespace Timeline { const interruptedAt = messages.findIndex((message) => isInterrupted(message.error)) const before = interruptedAt < 0 ? refs : refs.filter((ref) => ref.messageIndex <= interruptedAt) const after = interruptedAt < 0 ? [] : refs.filter((ref) => ref.messageIndex > interruptedAt) - const appendGroups = (items: typeof refs) => - groupContent(items).forEach((group) => { + const appendGroups = (items: typeof refs) => { + let offset = 0 + groupContent(items, shellToolDefaultOpen, editToolDefaultOpen).forEach((group) => { + const tool = group.type !== "part" || items[offset]?.content.type === "tool" + offset += group.type === "part" ? 1 : group.refs.length rows.push( new TimelineRow.AssistantPart({ userMessageID: turnID, group, previousAssistantPart: assistantGroupIndex > 0, + spacing: assistantGroupIndex > 0 ? (previousAssistantTool && tool ? "tool" : "content") : undefined, }), ) assistantGroupIndex += 1 + previousAssistantTool = tool }) + } appendGroups(before) if (interruptedAt >= 0) { @@ -444,6 +465,7 @@ function stabilizeGroupKey( return new TimelineRow.AssistantPart({ userMessageID: row.userMessageID, previousAssistantPart: row.previousAssistantPart, + spacing: row.spacing, group: { ...row.group, key: existing.row.group.key }, }) } @@ -462,7 +484,11 @@ function renderable(content: Content, showReasoning: boolean) { return true } -function groupContent(items: { messageID: string; partID: string; content: Content }[]): PartGroup[] { +function groupContent( + items: { messageID: string; partID: string; content: Content }[], + shellToolDefaultOpen: boolean, + editToolDefaultOpen: boolean, +): PartGroup[] { const groups: PartGroup[] = [] let adjacent: { type: "context" | "patch" | "edit"; refs: PartRef[] } | undefined const flush = () => { @@ -482,13 +508,7 @@ function groupContent(items: { messageID: string; partID: string; content: Conte items.forEach((item) => { const type = - item.content.type === "tool" && contextTools.has(item.content.name) && !hasLoadedFiles(item.content) - ? "context" - : item.content.type === "tool" && item.content.name === "patch" && item.content.state.status !== "error" - ? "patch" - : item.content.type === "tool" && item.content.name === "edit" && item.content.state.status !== "error" - ? "edit" - : undefined + item.content.type === "tool" ? toolGroupType(item.content, shellToolDefaultOpen, editToolDefaultOpen) : undefined if (type) { if (adjacent?.type !== type) flush() adjacent ??= { type, refs: [] } @@ -506,6 +526,26 @@ function groupContent(items: { messageID: string; partID: string; content: Conte return groups } +function toolGroupType(content: Extract, shellExpanded: boolean, editExpanded: boolean) { + if (content.name === "question" || hasLoadedFiles(content)) return undefined + if (content.state.status === "error") { + if ((content.name === "shell" || content.name === "execute") && shellExpanded) return undefined + if ((content.name === "edit" || content.name === "write" || content.name === "patch") && editExpanded) + return undefined + return "context" + } + if ( + (content.state.status !== "completed" || + ("metadata" in content.state && content.state.metadata?.status === "running")) && + (content.name === "shell" || content.name === "execute" || content.name === "subagent") + ) + return undefined + if (currentContentDefaultOpen(content, shellExpanded, editExpanded) !== true) return "context" + if (content.name === "patch") return "patch" + if (content.name === "edit") return "edit" + return undefined +} + function hasLoadedFiles(content: Extract) { if (content.name !== "read" || content.state.status !== "completed") return false const loaded = content.state.metadata?.loaded diff --git a/packages/session-ui/src/timeline/rows-current.test.ts b/packages/session-ui/src/timeline/rows-current.test.ts index 2dac43282a3..7173a7b9e7b 100644 --- a/packages/session-ui/src/timeline/rows-current.test.ts +++ b/packages/session-ui/src/timeline/rows-current.test.ts @@ -514,7 +514,9 @@ describe("current session timeline rows", () => { assistant("msg_assistant_3", "grep"), ] satisfies SessionMessageInfo[] - const keys = Timeline.constructSessionMessageRows(source, false, { type: "idle" }).rows.map(TimelineRow.key) + const keys = Timeline.constructSessionMessageRows(source, false, { type: "idle" }, undefined, true).rows.map( + TimelineRow.key, + ) expect(keys).toEqual([ "user-message:msg_user", @@ -595,7 +597,7 @@ describe("current session timeline rows", () => { }, ] satisfies SessionMessageInfo[] - const result = Timeline.constructSessionMessageRows(source, false, { type: "idle" }) + const result = Timeline.constructSessionMessageRows(source, false, { type: "idle" }, undefined, false, true) const groups = result.rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group] : [])) expect(groups).toEqual([ @@ -628,6 +630,224 @@ describe("current session timeline rows", () => { ]) }) + test("groups every consecutive collapsed tool in chronological order", () => { + const source = [ + { id: "msg_user", type: "user", text: "work", time: { created: 1 } }, + { + id: "msg_assistant", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [ + ...["shell", "subagent", "patch", "shell", "edit", "write", "grep"].map( + (name, index): SessionMessageAssistantTool => ({ + type: "tool" as const, + id: `tool_${index}`, + name, + state: { + status: "completed" as const, + input: {}, + content: [{ type: "text" as const, text: "done" }], + metadata: {}, + }, + time: { created: index + 2, completed: index + 3 }, + }), + ), + { type: "text" as const, text: "finished" }, + { + type: "tool" as const, + id: "tool_after_text", + name: "shell", + state: { + status: "completed" as const, + input: {}, + content: [{ type: "text" as const, text: "done" }], + metadata: {}, + }, + time: { created: 10, completed: 11 }, + }, + ], + time: { created: 2 }, + }, + ] satisfies SessionMessageInfo[] + + const groups = Timeline.constructSessionMessageRows(source, false, { type: "idle" }).rows.flatMap((row) => + row._tag === "AssistantPart" ? [row.group] : [], + ) + + expect(groups.map((group) => group.type)).toEqual(["context", "part", "context"]) + expect(groups[0]?.type === "context" ? groups[0].refs.map((ref) => ref.partID) : []).toEqual([ + "tool_0", + "tool_1", + "tool_2", + "tool_3", + "tool_4", + "tool_5", + "tool_6", + ]) + expect(groups[2]?.type === "context" ? groups[2].refs.map((ref) => ref.partID) : []).toEqual(["tool_after_text"]) + }) + + test.each([ + { shell: false, edit: false, types: ["context"] }, + { shell: true, edit: false, types: ["part", "context"] }, + { shell: false, edit: true, types: ["context", "file", "part", "file", "context"] }, + { shell: true, edit: true, types: ["part", "file", "part", "file", "context"] }, + ])("keeps tools expanded by settings outside collapsed groups ($shell, $edit)", ({ shell, edit, types }) => { + const source = [ + { id: "msg_user", type: "user", text: "work", time: { created: 1 } }, + { + id: "msg_assistant", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: ["shell", "edit", "write", "patch", "read"].map( + (name, index): SessionMessageAssistantTool => ({ + type: "tool" as const, + id: `tool_${name}`, + name, + state: { + status: "completed" as const, + input: {}, + content: [{ type: "text" as const, text: "done" }], + metadata: {}, + }, + time: { created: index + 2, completed: index + 3 }, + }), + ), + time: { created: 2 }, + }, + ] satisfies SessionMessageInfo[] + + const rows = Timeline.constructSessionMessageRows(source, false, { type: "idle" }, undefined, shell, edit).rows + + expect(rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group.type] : []))).toEqual([...types]) + }) + + test("keeps active and background work visible outside collapsed stacks", () => { + const source: SessionMessageInfo[] = [ + { id: "msg_user", type: "user", text: "work", time: { created: 1 } }, + { + id: "msg_assistant", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [ + { + type: "tool", + id: "tool_running_shell", + name: "shell", + state: { status: "running", input: {}, metadata: {} }, + time: { created: 2 }, + }, + { + type: "tool", + id: "tool_background_agent", + name: "subagent", + state: { + status: "completed", + input: {}, + content: [{ type: "text", text: "running" }], + metadata: { status: "running" }, + }, + time: { created: 3, completed: 4 }, + }, + { + type: "tool", + id: "tool_completed_shell", + name: "shell", + state: { + status: "completed", + input: {}, + content: [{ type: "text", text: "done" }], + metadata: {}, + }, + time: { created: 5, completed: 6 }, + }, + ], + time: { created: 2 }, + }, + ] + + expect( + Timeline.constructSessionMessageRows(source, false, { type: "busy" }).rows.flatMap((row) => + row._tag === "AssistantPart" ? [row.group.type] : [], + ), + ).toEqual(["part", "part", "context"]) + }) + + test("keeps failed calls inside a collapsed mixed-tool stack", () => { + const source: SessionMessageInfo[] = [ + { id: "msg_user", type: "user", text: "search", time: { created: 1 } }, + { + id: "msg_assistant", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [ + { + type: "tool", + id: "tool_glob_failed", + name: "glob", + state: { + status: "error", + input: { pattern: "*.ts" }, + error: { type: "ToolError", message: "Invalid tool input" }, + metadata: {}, + }, + time: { created: 2, completed: 3 }, + }, + { + type: "tool", + id: "tool_grep_failed", + name: "grep", + state: { + status: "error", + input: { pattern: "value" }, + error: { type: "ToolError", message: "Search timed out" }, + metadata: {}, + }, + time: { created: 4, completed: 5 }, + }, + { + type: "tool", + id: "tool_shell_failed", + name: "shell", + state: { + status: "error", + input: { command: "exit 1" }, + error: { type: "ToolError", message: "Command failed" }, + metadata: {}, + }, + time: { created: 6, completed: 7 }, + }, + ], + time: { created: 2, completed: 8 }, + }, + ] + + const groups = Timeline.constructSessionMessageRows(source, false, { type: "idle" }).rows.flatMap((row) => + row._tag === "AssistantPart" ? [row.group] : [], + ) + + expect(groups).toEqual([ + { + type: "context", + key: "context:msg_assistant:tool_glob_failed", + refs: [ + { messageID: "msg_assistant", partID: "tool_glob_failed" }, + { messageID: "msg_assistant", partID: "tool_grep_failed" }, + { messageID: "msg_assistant", partID: "tool_shell_failed" }, + ], + }, + ]) + expect( + Timeline.constructSessionMessageRows(source, false, { type: "idle" }, undefined, true).rows.flatMap((row) => + row._tag === "AssistantPart" ? [row.group.type] : [], + ), + ).toEqual(["context", "part"]) + }) + test("places a divider after interrupted output unless the turn compacts", () => { const messages = [ { id: "msg_user", type: "user", text: "continue", time: { created: 1 } }, diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index 26a83863dc7..9d9c9cc3458 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -229,10 +229,12 @@ export function createSessionTimelineRowRenderer(input: { id={props.row._tag === "UserMessage" ? input.anchor?.(props.row.userMessageID) : undefined} data-message-id={props.row.userMessageID} data-timeline-row={props.row._tag} + data-timeline-spacing={props.row._tag === "AssistantPart" ? props.row.spacing : undefined} classList={{ "min-w-0 w-full max-w-full": true, "md:max-w-[1000px] md:mx-auto": input.centered?.(), - "pt-3": props.row._tag === "AssistantPart" && props.row.previousAssistantPart, + "pt-2": props.row._tag === "AssistantPart" && props.row.spacing === "tool", + "pt-4": props.row._tag === "AssistantPart" && props.row.spacing === "content", }} >
diff --git a/packages/session-ui/src/timeline/session-timeline.tsx b/packages/session-ui/src/timeline/session-timeline.tsx index 1185c217794..d70dde80635 100644 --- a/packages/session-ui/src/timeline/session-timeline.tsx +++ b/packages/session-ui/src/timeline/session-timeline.tsx @@ -22,6 +22,8 @@ export function SessionTimeline(props: SessionTimelineProps) { sessionMessages: () => props.document.messages, status: () => props.document.status, showReasoningSummaries: () => props.showReasoningSummaries ?? true, + shellToolDefaultOpen: () => props.shellToolDefaultOpen ?? false, + editToolDefaultOpen: () => props.editToolDefaultOpen ?? false, }) const [toolOpen, setToolOpen] = createStore>({}) const renderer = createSessionTimelineRowRenderer({ diff --git a/packages/session-ui/src/timeline/timeline-row.ts b/packages/session-ui/src/timeline/timeline-row.ts index 395a40ecb7b..1f6ecd6d709 100644 --- a/packages/session-ui/src/timeline/timeline-row.ts +++ b/packages/session-ui/src/timeline/timeline-row.ts @@ -49,6 +49,7 @@ export namespace TimelineRow { userMessageID: string group: PartGroup previousAssistantPart: boolean + spacing?: "tool" | "content" }> {} export class Thinking extends Data.TaggedClass("Thinking")<{ @@ -118,6 +119,7 @@ export type TimelineRowMap = { userMessageID: string group: PartGroup previousAssistantPart: boolean + spacing?: "tool" | "content" } Thinking: { userMessageID: string; reasoningHeading?: string } Retry: { userMessageID: string } diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index d63dc6cf434..6ec88a633d9 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -20,6 +20,7 @@ import { useFileComponent } from "@opencode-ai/ui/context/file" import { type UiI18n, useI18n } from "@opencode-ai/ui/context/i18n" import { BasicTool, GenericTool } from "../components/basic-tool" import { Accordion } from "@opencode-ai/ui/accordion" +import { Badge } from "@opencode-ai/ui/badge" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { Collapsible } from "@opencode-ai/ui/collapsible" import { FileIcon } from "@opencode-ai/ui/file-icon" @@ -32,13 +33,16 @@ import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { TextShimmer } from "@opencode-ai/ui/text-shimmer" -import { AnimatedCountList } from "../components/tool-count-summary" -import { ToolStatusTitle } from "../components/tool-status-title" import { changedFileDiff, patchFileGroups } from "../components/apply-patch-file" import { animate } from "motion" import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2" import type { SessionMessageAssistantTool, SessionMessageShell } from "@opencode-ai/client/promise" -import { currentToolInput, currentToolMetadata } from "../message/current-tool-state" +import { + currentToolError, + currentToolInput, + currentToolMetadata, + currentToolOutput, +} from "../message/current-tool-state" import { writeClipboard } from "../message/message-content" function ShellSubmessage(props: { text: string; animate?: boolean }) { @@ -477,50 +481,50 @@ export function CurrentContextToolGroup(props: { () => props.busy || props.tools.some((tool) => tool.state.status === "streaming" || tool.state.status === "running"), ) - const summary = createMemo(() => ({ - read: props.tools.filter((tool) => tool.name === "read").length, - search: props.tools.filter((tool) => tool.name === "glob" || tool.name === "grep").length, - list: props.tools.filter((tool) => tool.name === "list").length, - })) + const names = createMemo(() => + [ + ...new Set( + props.tools.map((tool) => { + const input = currentToolInput(tool) + if (tool.name === "skill") return i18n.t("ui.tool.skill") + if (tool.name === "subagent" && typeof input.agent === "string" && input.agent) + return input.agent[0]!.toUpperCase() + input.agent.slice(1) + return getToolInfo(tool.name, input, currentToolMetadata(tool)).title + }), + ), + ].join(", "), + ) + const label = createMemo(() => { + const tools = names() + const text = i18n.t("ui.messagePart.tools.used", { tools }) + const index = text.indexOf(tools) + return { text, before: text.slice(0, index).trim(), after: text.slice(index + tools.length).trim() } + }) const change = (open: boolean) => { props.onOpenChange(open) props.onSizeChange?.() } return ( -
tool.id).join(",")}> +
tool.id).join(",")}> - - - - - - - +
+ + + {(before) => {before()}} + + {names()} + + {(after) => {after()}} + + {props.tools.length}
} @@ -529,32 +533,57 @@ export function CurrentContextToolGroup(props: { {(tool) => { const trigger = createMemo(() => currentContextToolTrigger(tool(), i18n)) - const running = () => tool().state.status === "streaming" || tool().state.status === "running" return (
-
-
-
-
-
- - - - - {trigger().subtitle} + + } + > +
+
+
+
+
+ + + + + {(subtitle) => {subtitle()}} + + + {(arg) => {arg}} + +
+ + {(matches) => ( + <> + + {matches()} + + )} - - {(arg) => {arg}} -
- - - {trigger().matches} -
-
+
) }} diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index d5d8ab33c7a..ca67f518df2 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -110,6 +110,7 @@ const source = { "ui.messagePart.context.list.other": "{{count}} lists", "ui.messagePart.context.match.one": "({{count}} match)", "ui.messagePart.context.match.other": "({{count}} matches)", + "ui.messagePart.tools.used": "Used {{tools}}", "ui.list.loading": "Loading", "ui.list.empty": "No results", diff --git a/packages/ui/src/icons/icon/icon.tsx b/packages/ui/src/icons/icon/icon.tsx index 2de269e61b0..53ae3ec9bb8 100644 --- a/packages/ui/src/icons/icon/icon.tsx +++ b/packages/ui/src/icons/icon/icon.tsx @@ -32,6 +32,10 @@ const icons = { viewBox: "0 0 16 16", body: ``, }, + "circle-exclamation": { + viewBox: "0 0 16 16", + body: ``, + }, "sidebar-right": { viewBox: "0 0 20 20", body: ``,