From b367a4b3caffa543c8257227465482ba896b85b7 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:38:47 +1000 Subject: [PATCH] fix(session-ui): wrap execute calls and show code mode output (#56915) --- .../session-tool-projection.spec.ts | 25 ++++++++ .../session-ui/src/components/basic-tool.css | 2 + .../src/components/message-part.css | 13 ++++- .../src/storybook/current-session-fixtures.ts | 23 ++++++++ .../session-ui/src/tools/tool-renderer.tsx | 58 ++++++++++++++++++- 5 files changed, 117 insertions(+), 4 deletions(-) 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 72a94ade7b7..eb6f469524f 100644 --- a/packages/session-ui/component-tests/session-tool-projection.spec.ts +++ b/packages/session-ui/component-tests/session-tool-projection.spec.ts @@ -179,6 +179,31 @@ story("opens an execution's code and output from its View code action", async ({ await expect(dialog.getByRole("button", { name: "Copy output" })).toBeVisible() }) +story("keeps long execute calls inside the tool and shows the script output", async ({ mount }) => { + const timeline = await mount("current-session-terminal-work--execute-code") + const tool = timeline.locator('[data-timeline-part-id="tool_execute_long_call"]') + const calls = tool.locator('[data-component="execute-tool-calls"]') + const rows = calls.locator('[data-slot="execute-tool-call"]') + await expect(rows).toHaveText([/mesh\.exec/, /Output\s*3 lines/]) + + // The script output stays collapsed until its row is expanded. + const result = calls.locator('[data-slot="bash-result"]') + await expect(result).toHaveCount(0) + const top = await rows.first().evaluate((element) => element.getBoundingClientRect().top) + await rows.nth(1).click() + await expect(rows.nth(1)).toHaveAttribute("aria-expanded", "true") + await expect(result).toContainText("Cleanup finished on blade") + // Opening the output must not move the rows above it. + expect(await rows.first().evaluate((element) => element.getBoundingClientRect().top)).toBe(top) + + const overflow = await tool.evaluate((element) => { + const calls = element.querySelector('[data-component="execute-tool-calls"]') + if (!calls) return Number.NaN + return calls.getBoundingClientRect().right - element.getBoundingClientRect().right + }) + expect(overflow).toBeLessThanOrEqual(0) +}) + story("shows a failed execution's error in place of its output", async ({ mount }) => { const timeline = await mount("current-session-terminal-work--execute-failed") await timeline.getByRole("button", { name: /^Used 5 / }).click() diff --git a/packages/session-ui/src/components/basic-tool.css b/packages/session-ui/src/components/basic-tool.css index 7a8d746f41a..428627985b6 100644 --- a/packages/session-ui/src/components/basic-tool.css +++ b/packages/session-ui/src/components/basic-tool.css @@ -192,6 +192,8 @@ > [data-slot="collapsible-content"] { flex-basis: 100%; + /* In this row layout, unbreakable content would otherwise widen the content past the tool's width. */ + min-width: 0; } } diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index 6636ae6edb9..96602299f2d 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -502,14 +502,20 @@ color: var(--v2-text-text-muted); } + /* Code Mode results have no command line above them. */ + [data-slot="bash-result"]:first-child { + border-block-start: 0; + } + [data-slot="bash-command"] { color: var(--v2-text-text-base); } } } +/* Only a shell's own card; an Execute output card opens inside the content and must not move the rows above it. */ [data-component="collapsible"].tool-collapsible:has( - > [data-slot="collapsible-content"] [data-component="bash-output"][data-variant="shell"] + > [data-slot="collapsible-content"] > [data-component="bash-output"][data-variant="shell"] ) { --tool-content-gap: 8px; } @@ -666,6 +672,11 @@ color: var(--v2-state-fg-danger); } + [data-slot="execute-tool-output"] { + margin-top: 4px; + padding-inline-start: 20px; + } + [data-slot="execute-tool-error"] { margin-top: 4px; max-height: 240px; diff --git a/packages/session-ui/src/storybook/current-session-fixtures.ts b/packages/session-ui/src/storybook/current-session-fixtures.ts index db602161a69..9457c13c594 100644 --- a/packages/session-ui/src/storybook/current-session-fixtures.ts +++ b/packages/session-ui/src/storybook/current-session-fixtures.ts @@ -562,6 +562,29 @@ export const executeCodeDocument = document([ output: "[]", metadata: { toolCalls: [{ tool: "browser.tabs.list", status: "completed" }] }, }), + completedTool({ + id: "tool_execute_long_call", + name: "execute", + offset: 52_700, + args: { + code: 'return await tools.mesh.exec({ target: "blade", script: "foreach ($t in \'\\\\opencode-mesh\\\\sync\', \'\\\\opencode-mesh\\\\run\') { schtasks /delete /tn $t /f 2>&1 | Out-Null; \\"deleted $t\\" }" })', + }, + output: + "deleted \\opencode-mesh\\sync\ndeleted \\opencode-mesh\\run\nCleanup finished on blade after removing both scheduled tasks and their trigger history.", + metadata: { + toolCalls: [ + { + tool: "mesh.exec", + status: "completed", + input: { + target: "blade", + script: + "foreach ($t in '\\opencode-mesh\\sync', '\\opencode-mesh\\run') { schtasks /delete /tn $t /f 2>&1 | Out-Null; \"deleted $t\" }", + }, + }, + ], + }, + }), ], }), ] satisfies SessionMessageInfo[]) diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index 585c6fe8b2c..b3ca0f834ee 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -1932,7 +1932,7 @@ ToolRegistry.register({ const subtitle = createMemo(() => executeToolSubtitle(calls())) const code = createMemo(() => (toolInput(props.input).code ?? "").trimEnd()) - // Like the TUI, only a runtime failure shows its output; a failed nested call is marked on its row. + // A runtime failure shows its output on the failing row; a successful script shows its result below the calls. const error = createMemo(() => { if (props.status !== "error" && props.metadata.error !== true) return undefined const fallback = i18n.t("ui.toolErrorCard.failed") @@ -1941,6 +1941,14 @@ ToolRegistry.register({ return text.replace(/^Error:\s*/, "").trim() || fallback }) + const output = createMemo(() => + error() + ? "" + : stripAnsi(props.output ?? "") + .replace(/\r\n?/g, "\n") + .trim(), + ) + // A runtime failure belongs to the call that raised it; scripts can also fail outside any call. const owner = createMemo(() => calls().findLastIndex((call) => call.status === "error")) @@ -1953,7 +1961,7 @@ ToolRegistry.register({ dialog.show(() => ( 0 || !!error()} + hasContent={calls().length > 0 || !!error() || !!output()} compact allowOpenWhilePending trigger={(open) => ( @@ -1998,6 +2006,7 @@ ToolRegistry.register({ {(call, index) => } {(text) => } + {(value) => } ) @@ -2120,6 +2129,49 @@ function ExecuteDetailsSection(props: { ) } +function ExecuteOutputRow(props: { output: string }) { + const i18n = useI18n() + const [open, setOpen] = createSignal(false) + + const toggle = () => { + if (window.getSelection()?.toString()) return + setOpen((value) => !value) + } + + return ( +
+
{ + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + toggle() + }} + > + + › + + {i18n.t("ui.tool.execute.output")} + + {i18n.plural("ui.tool.execute.lines", props.output.split("\n").length)} + +
+ +
+ + {props.output} + +
+
+
+ ) +} + function ExecuteCallRow(props: { call?: ExecuteToolCall; error?: string }) { const [open, setOpen] = createSignal(false) const args = createMemo(() => (props.call ? executeToolCallArgs(props.call) : []))