Compare commits

...
Author SHA1 Message Date
Hona 62daf48a8a feat(app): preview Code Mode child calls 2026-09-18 01:30:50 +00:00
4 changed files with 248 additions and 23 deletions
@@ -0,0 +1,31 @@
import { expect, story } from "../../storybook/playwright/story"
story("shows Code Mode child calls and expands their inputs", async ({ mount }) => {
const timeline = await mount("current-session-terminal-work--execute-code")
const calls = timeline.locator('[data-component="execute-tool-call"]')
await expect(calls).toHaveCount(3)
const completed = calls.nth(0)
const trigger = completed.getByRole("button")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(trigger).toContainText("planetscale.planetscale_execute_write_query")
await expect(trigger).toContainText("organization=anomalyco")
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(completed.locator("dt")).toHaveText([
"organization",
"database",
"branch",
"confirm_destructive",
"query",
])
await expect(completed.locator("dd")).toContainText([
"anomalyco",
"opencode",
"production",
"true",
"UPDATE workspace",
])
await expect(calls.nth(2)).toHaveAttribute("data-status", "completed")
})
@@ -200,6 +200,107 @@
}
}
[data-component="execute-tool-calls"] {
margin-inline-start: 12px;
padding-inline-start: 12px;
border-inline-start: 1px solid var(--v2-border-border-weak);
}
[data-component="execute-tool-call"] {
min-width: 0;
&[data-open="true"] [data-slot="execute-tool-call-status"] [data-slot="icon-svg"] {
transform: rotate(90deg);
}
&[data-status="error"] [data-slot="execute-tool-call-status"] {
color: var(--text-error);
}
[data-slot="execute-tool-call-trigger"] {
width: 100%;
height: 28px;
min-width: 0;
padding: 0;
display: flex;
align-items: center;
gap: 6px;
border: 0;
background: transparent;
color: var(--v2-text-text-muted);
text-align: start;
cursor: pointer;
&:disabled {
cursor: default;
}
&:hover:not(:disabled),
&:focus-visible {
color: var(--v2-text-text-base);
}
}
[data-slot="execute-tool-call-status"] {
width: 16px;
height: 16px;
flex: 0 0 16px;
display: inline-flex;
align-items: center;
justify-content: center;
[data-slot="icon-svg"] {
transition: transform 0.15s ease;
}
[data-component="session-progress-indicator-v2"] {
width: 16px;
height: 16px;
}
}
[data-slot="execute-tool-call-title"] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-mono);
font-size: 13px;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-compact);
color: inherit;
}
[data-slot="execute-tool-call-details"] {
margin: 0;
padding: 2px 0 8px 22px;
min-width: 0;
}
[data-slot="execute-tool-call-detail"] {
min-width: 0;
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
column-gap: 8px;
font-family: var(--font-family-mono);
font-size: 13px;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-base);
dt {
color: var(--v2-text-text-muted);
}
dd {
min-width: 0;
margin: 0;
color: var(--v2-text-text-base);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
}
}
[data-component="task-tool-card"] {
width: 100%;
min-width: 0;
@@ -545,6 +545,31 @@ export const executeCodeDocument = document([
code: 'const greeting = "Code Mode execute completed"\nreturn { greeting, timestamp: new Date().toISOString() }',
},
output: '{\n "greeting": "Code Mode execute completed",\n "timestamp": "2026-08-17T09:01:43.590Z"\n}',
metadata: {
toolCalls: [
{
tool: "planetscale.planetscale_execute_write_query",
status: "completed",
input: {
organization: "anomalyco",
database: "opencode",
branch: "production",
confirm_destructive: true,
query: "UPDATE workspace SET time_deleted = UTC_TIMESTAMP(3) WHERE time_deleted IS NULL LIMIT 50000",
},
},
{
tool: "planetscale.planetscale_execute_write_query",
status: "completed",
input: { organization: "anomalyco", database: "opencode", branch: "production" },
},
{
tool: "planetscale.planetscale_execute_write_query",
status: "completed",
input: { organization: "anomalyco", database: "opencode", branch: "production", limit: 50000 },
},
],
},
}),
],
}),
+91 -23
View File
@@ -1724,37 +1724,105 @@ ToolRegistry.register({
const pending = () => props.status === "streaming" || props.status === "running"
const code = createMemo(() => (typeof props.input.code === "string" ? props.input.code : ""))
const output = () => stripAnsi(props.output ?? "").replace(/\r\n?/g, "\n")
const calls = createMemo(() => executeCalls(props.metadata.toolCalls))
const sawPending = pending()
return (
<BasicTool
{...props}
icon="console"
rail={false}
hasContent
compact
allowOpenWhilePending
trigger={(open) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer text={i18n.t("ui.tool.execute")} active={pending()} />
</span>
<Show when={!open() && code()}>
<ShellSubmessage text={code().split("\n", 1)[0]} animate={sawPending} />
</Show>
<div data-component="execute-tool">
<BasicTool
{...props}
icon="console"
rail={false}
hasContent
compact
allowOpenWhilePending
trigger={(open) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer text={i18n.t("ui.tool.execute")} active={pending()} />
</span>
<Show when={!open() && code()}>
<ShellSubmessage text={code().split("\n", 1)[0]} animate={sawPending} />
</Show>
</div>
</div>
)}
>
<ConsoleOutput copy={code()} variant="shell">
<span data-slot="bash-command">{code()}</span>
<Show when={output()}>{(value) => <span data-slot="bash-result">{value()}</span>}</Show>
</ConsoleOutput>
</BasicTool>
<Show when={calls().length > 0}>
<div data-component="execute-tool-calls">
<Index each={calls()}>{(call) => <ExecuteCallRow call={call} />}</Index>
</div>
)}
>
<ConsoleOutput copy={code()} variant="shell">
<span data-slot="bash-command">{code()}</span>
<Show when={output()}>{(value) => <span data-slot="bash-result">{value()}</span>}</Show>
</ConsoleOutput>
</BasicTool>
</Show>
</div>
)
},
})
type ExecuteCall = {
tool: string
status: "running" | "completed" | "error"
input?: Record<string, unknown>
}
function executeCalls(value: unknown): ExecuteCall[] {
if (!Array.isArray(value)) return []
return value.flatMap((call) => {
if (!record(call) || typeof call.tool !== "string") return []
if (call.status !== "running" && call.status !== "completed" && call.status !== "error") return []
return [{ tool: call.tool, status: call.status, input: record(call.input) ? call.input : undefined }]
})
}
function ExecuteCallRow(props: { call: () => ExecuteCall }) {
const [open, setOpen] = createSignal(false)
const input = createMemo(() => Object.entries(props.call().input ?? {}))
const summary = createMemo(() => {
const args = input()
.filter(([, value]) => typeof value === "string" || typeof value === "number" || typeof value === "boolean")
.map(([key, value]) => `${key}=${String(value)}`)
.join(", ")
.replace(/\s+/g, " ")
return `${props.call().tool}${args ? ` [${args}]` : ""}`
})
return (
<div data-component="execute-tool-call" data-status={props.call().status} data-open={open() ? "true" : "false"}>
<button
type="button"
data-slot="execute-tool-call-trigger"
disabled={input().length === 0}
aria-expanded={input().length > 0 ? open() : undefined}
onClick={() => setOpen((value) => !value)}
>
<span data-slot="execute-tool-call-status" aria-hidden="true">
<Show when={props.call().status !== "running"} fallback={<SessionProgressIndicatorV2 />}>
<Show when={props.call().status === "error"} fallback={<Icon name="chevron-right" size="small" />}>
×
</Show>
</Show>
</span>
<span data-slot="execute-tool-call-title">{open() ? props.call().tool : summary()}</span>
</button>
<Show when={open()}>
<dl data-slot="execute-tool-call-details">
<For each={input()}>
{([key, value]) => (
<div data-slot="execute-tool-call-detail">
<dt>{key}</dt>
<dd>{typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? String(value))}</dd>
</div>
)}
</For>
</dl>
</Show>
</div>
)
}
ToolRegistry.register({
name: "shell",
render(props) {