feat(app): show writing command state (#44095)

This commit is contained in:
Luke Parker
2026-08-22 20:21:33 +10:00
committed by GitHub
parent 8f54597ef2
commit 100f23ca99
6 changed files with 112 additions and 7 deletions
@@ -259,6 +259,18 @@ export function event(
return makeEvent(type, data)
}
export function toolInputStarted(data: Extract<OpenCodeEvent, { type: "session.tool.input.started" }>["data"]) {
return makeEvent("session.tool.input.started", data)
}
export function toolInputEnded(data: Extract<OpenCodeEvent, { type: "session.tool.input.ended" }>["data"]) {
return makeEvent("session.tool.input.ended", data)
}
export function toolCalled(data: Extract<OpenCodeEvent, { type: "session.tool.called" }>["data"]) {
return makeEvent("session.tool.called", data)
}
export function validateTimelineEvent(input: unknown): OpenCodeEvent {
if (!input || typeof input !== "object") throw new Error("Timeline event must be an object")
if (!("type" in input) || typeof input.type !== "string") throw new Error("Timeline event requires a type")
@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test"
import {
assistantID,
assistantMessage,
completedAssistantInfo,
messageUpdated,
@@ -8,9 +9,13 @@ import {
renderedPartID,
setupTimeline,
shell,
sessionID,
status,
stepStarted,
textPart,
toolCalled,
toolInputEnded,
toolInputStarted,
userMessage,
} from "../performance/timeline-stability/fixture"
@@ -34,6 +39,55 @@ for (const expanded of [false, true]) {
})
}
test("transitions a streaming shell from writing through command execution", async ({ page }) => {
const id = "prt_shell_streaming_input"
const command = "printf ready"
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([], { completed: false })],
})
await timeline.send(toolInputStarted({ sessionID, assistantMessageID: assistantID, id, name: "shell" }))
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
const title = tool.locator('[data-slot="basic-tool-tool-title"]')
const titleShimmer = title.locator('[data-component="text-shimmer"]')
const subtitle = tool.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(titleShimmer).toHaveAttribute("aria-label", "Shell")
await expect(titleShimmer).toHaveAttribute("data-active", "true")
await expect(subtitle).toHaveText("Writing command...")
await expect(subtitle.locator('[data-component="text-shimmer"]')).toHaveCount(0)
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveCount(0)
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
await expect(tool.locator('[data-component="tool-trigger"]')).toHaveCSS("gap", "6px")
await expect(title).toHaveCSS("font-size", "13px")
await expect(title).toHaveCSS("font-family", "Inter, sans-serif")
await expect(title).toHaveCSS("font-weight", "530")
await expect(title).toHaveCSS("line-height", "16px")
await expect(title).toHaveCSS("color", "rgb(22, 22, 22)")
await expect(subtitle).toHaveCSS("font-size", "13px")
await expect(subtitle).toHaveCSS("font-family", "Inter, sans-serif")
await expect(subtitle).toHaveCSS("font-weight", "440")
await expect(subtitle).toHaveCSS("line-height", "16px")
await expect(subtitle).toHaveCSS("color", "rgb(92, 92, 92)")
const input = JSON.stringify({ command })
await timeline.send(toolInputEnded({ sessionID, assistantMessageID: assistantID, id, text: input }))
await expect(titleShimmer).toHaveAttribute("data-active", "true")
await expect(subtitle).toHaveText(command)
await expect(tool).not.toContainText("Writing command...")
await timeline.send(
toolCalled({
sessionID,
assistantMessageID: assistantID,
id,
input: { command },
executed: true,
}),
)
await expect(titleShimmer).toHaveAttribute("data-active", "false")
await expect(subtitle).toHaveText(command)
})
test("shows and expands a running shell command without shimmering it", async ({ page }) => {
const id = "prt_shell_running_command"
const command = "sleep 10 && echo done"
@@ -43,9 +97,11 @@ test("shows and expands a running shell command without shimmering it", async ({
})
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "false")
await expect(tool).not.toContainText("Writing command...")
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command)
await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0)
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
await tool.locator('[data-slot="collapsible-trigger"]').click()
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
@@ -170,6 +170,31 @@
}
}
[data-component="collapsible"].tool-collapsible[data-compact="true"] > [data-slot="collapsible-trigger"] {
height: 28px;
[data-component="tool-trigger"],
[data-slot="basic-tool-tool-info-main"] {
gap: 6px;
}
[data-slot="basic-tool-tool-title"] {
font-family: var(--v2-font-family-sans);
font-size: 13px;
font-weight: 530;
line-height: var(--v2-line-height-compact, 16px);
letter-spacing: -0.04px;
}
[data-slot="basic-tool-tool-subtitle"] {
font-family: var(--v2-font-family-sans);
font-size: 13px;
font-weight: 440;
line-height: var(--v2-line-height-compact, 16px);
letter-spacing: -0.04px;
}
}
[data-component="task-tool-card"] {
width: 100%;
min-width: 0;
@@ -43,6 +43,7 @@ export interface BasicToolProps {
triggerHref?: string
triggerAsLink?: boolean
clickable?: boolean
compact?: boolean
}
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
@@ -260,6 +261,7 @@ export function BasicTool(props: BasicToolProps) {
open={open()}
onOpenChange={props.locked ? undefined : handleOpenChange}
class="tool-collapsible"
data-compact={props.compact ? "true" : undefined}
data-rail={props.rail === false ? "false" : undefined}
>
<Show
@@ -1229,9 +1229,8 @@ ToolRegistry.register({
name: "shell",
render(props) {
const i18n = useI18n()
const pending = () =>
props.status === "streaming" || props.status === "running" || props.metadata.status === "running"
const sawPending = pending()
const streaming = () => props.status === "streaming"
const sawStreaming = streaming()
const command = () => {
if (typeof props.input.command === "string") return props.input.command
if (typeof props.metadata.command === "string") return props.metadata.command
@@ -1246,15 +1245,25 @@ ToolRegistry.register({
{...props}
icon="console"
rail={false}
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.shell")} active={pending()} />
<TextShimmer text={i18n.t("ui.tool.shell")} active={streaming()} />
</span>
<Show when={!open() && command()}>
<ShellSubmessage text={command()} animate={sawPending} />
<Show when={!open()}>
<Show
when={command()}
fallback={
<Show when={streaming()}>
<span data-slot="basic-tool-tool-subtitle">{i18n.t("ui.tool.shell.writingCommand")}</span>
</Show>
}
>
{(command) => <ShellSubmessage text={command()} animate={sawStreaming} />}
</Show>
</Show>
</div>
</div>
+1
View File
@@ -162,6 +162,7 @@ const source = {
"ui.tool.websearch": "Web Search",
"ui.tool.websearch.provider": "{{provider}} Web Search",
"ui.tool.shell": "Shell",
"ui.tool.shell.writingCommand": "Writing command...",
"ui.tool.execute": "Execute",
"ui.tool.patch": "Patch",
"ui.tool.questions": "Questions",