diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index b409615dc01..238abbf7954 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -259,6 +259,18 @@ export function event( return makeEvent(type, data) } +export function toolInputStarted(data: Extract["data"]) { + return makeEvent("session.tool.input.started", data) +} + +export function toolInputEnded(data: Extract["data"]) { + return makeEvent("session.tool.input.ended", data) +} + +export function toolCalled(data: Extract["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") 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 9a648c22626..1f190ceb9ea 100644 --- a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts @@ -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") diff --git a/packages/session-ui/src/components/basic-tool.css b/packages/session-ui/src/components/basic-tool.css index 70ca2140af0..9bd2f94dd46 100644 --- a/packages/session-ui/src/components/basic-tool.css +++ b/packages/session-ui/src/components/basic-tool.css @@ -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; diff --git a/packages/session-ui/src/components/basic-tool.tsx b/packages/session-ui/src/components/basic-tool.tsx index ac8302823d5..5c88022cffb 100644 --- a/packages/session-ui/src/components/basic-tool.tsx +++ b/packages/session-ui/src/components/basic-tool.tsx @@ -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} > - 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) => (
- + - - + + + {i18n.t("ui.tool.shell.writingCommand")} + + } + > + {(command) => } +
diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 1cc0c9edf6b..c358ec1d136 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -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",