feat(app): show writing command state (#44095)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user