diff --git a/packages/app/e2e/perf/perf-probe.spec.ts b/packages/app/e2e/perf/perf-probe.spec.ts index dd504ae7..3902474b 100644 --- a/packages/app/e2e/perf/perf-probe.spec.ts +++ b/packages/app/e2e/perf/perf-probe.spec.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises" import path from "node:path" +import type { Locator, Page } from "@playwright/test" import { raw } from "../../../opencode/test/lib/llm-server" import { test, expect } from "../fixtures" import { cleanupSession, waitSessionIdle, waitSessionSaved, waitTerminalFocusIdle, withSession } from "../actions" @@ -616,6 +617,92 @@ async function seedHeavyBashSession(input: { project: PerfProject; llm: PerfLlm; return session } +async function revealTrowBodyIfPresent(page: Page) { + const summary = page.locator('[data-slot="trow-summary"]').first() + if (!(await summary.isVisible({ timeout: 1_000 }).catch(() => false))) return + const body = page.locator('[data-slot="trow-body"]').first() + if (!(await body.isVisible().catch(() => false))) await summary.click() + await expect(body).toBeVisible() +} + +function expandableToolTriggers(page: Page) { + return page + .locator('[data-slot="collapsible-trigger"]') + .filter({ has: page.locator('[data-component="tool-trigger"]') }) +} + +async function visibleExpandableToolTrigger(page: Page) { + const triggers = expandableToolTriggers(page) + const count = await triggers.count() + for (let index = 0; index < count; index += 1) { + const trigger = triggers.nth(index) + if (!(await trigger.isVisible().catch(() => false))) continue + if ((await trigger.getAttribute("data-hide-details").catch(() => null)) === "true") continue + return trigger + } + return undefined +} + +async function visibleToolTrigger(page: Page) { + const triggers = expandableToolTriggers(page) + const count = await triggers.count() + for (let index = 0; index < count; index += 1) { + const trigger = triggers.nth(index) + if (await trigger.isVisible().catch(() => false)) return trigger + } + return undefined +} + +async function exerciseToolExpandCycle(page: Page) { + const trowDetails = page.locator('[data-component="session-turn-trow-block"] details').first() + const trowSummary = trowDetails.locator('[data-slot="trow-summary"]').first() + let trigger: Locator | undefined + let mode: "trow" | "tool" | undefined + + await expect + .poll( + async () => { + if (await trowSummary.isVisible().catch(() => false)) { + mode = "trow" + return "ready" + } + trigger = await visibleExpandableToolTrigger(page) + trigger ??= await visibleToolTrigger(page) + if (trigger) { + mode = "tool" + return "ready" + } + return "loading" + }, + { timeout: 30_000 }, + ) + .toBe("ready") + + if (mode === "trow") { + await resetPerfProbe(page) + if ((await trowDetails.getAttribute("open").catch(() => null)) !== null) { + await trowSummary.click() + await expect(trowDetails).not.toHaveAttribute("open", "") + } + await trowSummary.click() + await expect(trowDetails).toHaveAttribute("open", "") + await trowSummary.click() + await expect(trowDetails).not.toHaveAttribute("open", "") + await trowSummary.click() + await expect(trowDetails).toHaveAttribute("open", "") + return + } + + if (!trigger) throw new Error("No expandable tool trigger found") + await resetPerfProbe(page) + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", "true") +} + function skipUnlessScenario(name: PerfScenarioName) { test.skip(!shouldRunScenario(PERF_PROFILE, name), `${PERF_PROFILE} profile does not run ${name}`) } @@ -756,20 +843,10 @@ test.describe("PR0.1 perf probe baseline", () => { if (!created?.directory) throw new Error("Failed to create worktree for perf probe") project.trackDirectory(created.directory) await llm.tool("enter-worktree", { path: created.directory }) + await llm.tool("read", { filePath: "package.json" }) await llm.text(`tool call baseline ${run + 1}`) - await project.prompt(`Create todos for perf probe run ${run + 1}.`) - const trigger = page - .locator('[data-slot="collapsible-trigger"]') - .filter({ has: page.locator('[data-component="tool-trigger"]') }) - .first() - await expect(trigger).toBeVisible({ timeout: 30_000 }) - await resetPerfProbe(page) - await trigger.click() - await expect(trigger).toHaveAttribute("aria-expanded", "true") - await trigger.click() - await expect(trigger).toHaveAttribute("aria-expanded", "false") - await trigger.click() - await expect(trigger).toHaveAttribute("aria-expanded", "true") + await project.prompt(`Read package.json for perf probe run ${run + 1}.`) + await exerciseToolExpandCycle(page) await settleFrames(page, 4) runs.push(await snapshotPerfProbe(page)) if (run < 2) await cooldownAfterRun(page) @@ -792,11 +869,12 @@ test.describe("PR0.1 perf probe baseline", () => { const session = await seedHeavyBashSession({ project, llm, run }) try { await page.goto(sessionPath(project.directory, session.id)) - const trigger = page - .locator('[data-slot="collapsible-trigger"]') - .filter({ has: page.locator('[data-component="tool-trigger"]') }) - .first() - await expect(trigger).toBeVisible({ timeout: 30_000 }) + await revealTrowBodyIfPresent(page) + await expect + .poll(async () => Boolean(await visibleExpandableToolTrigger(page)), { timeout: 30_000 }) + .toBe(true) + const trigger = await visibleExpandableToolTrigger(page) + if (!trigger) throw new Error("No expandable tool trigger found") await expect(trigger).toHaveAttribute("aria-expanded", "true") await settleFrames(page, 2) runs.push(await snapshotPerfProbe(page)) diff --git a/packages/app/e2e/snap/fixtures/trow-snap-fixture-data.ts b/packages/app/e2e/snap/fixtures/trow-snap-fixture-data.ts new file mode 100644 index 00000000..bed952e6 --- /dev/null +++ b/packages/app/e2e/snap/fixtures/trow-snap-fixture-data.ts @@ -0,0 +1,285 @@ +import type { AssistantMessage, ToolPart, ToolState } from "@opencode-ai/sdk/v2" +import type { UiI18nKey, UiI18nParams } from "@opencode-ai/ui/context" +import { dict as zh } from "@opencode-ai/ui/i18n/zh" +import { contextTrowSummaryText } from "@opencode-ai/ui/message-part" + +function resolveTemplate(text: string, params?: UiI18nParams) { + if (!params) return text + return text.replace(/{{\s*([^}]+?)\s*}}/g, (_, rawKey) => { + const value = params[String(rawKey)] + return value === undefined ? "" : String(value) + }) +} + +export const zhI18n = { + locale: () => "zh", + t: (key: UiI18nKey, params?: UiI18nParams) => { + const value = (zh as Record)[key] ?? String(key) + return resolveTemplate(value, params) + }, +} + +export const labels = { + summaryRunning: (count: number) => `正在处理 ${count} 个工具调用`, + summaryCompleted: (parts: readonly ToolPart[], failed: number) => contextTrowSummaryText(parts, failed, zhI18n), +} + +export const fixtureData = { + session: [], + session_status: {}, + turn_change_aggregate: {}, + message: {}, + part: {}, +} + +export const snapAssistantMessage = { + id: "snap-message", + sessionID: "snap-session", + role: "assistant", + time: { created: 0, completed: 1 }, + parentID: "snap-user", + modelID: "snap-model", + providerID: "snap-provider", + mode: "build", + agent: "code", + path: { cwd: "/Users/yuhan/PawWork", root: "/Users/yuhan/PawWork" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, +} as AssistantMessage + +export function tool( + id: string, + description: string, + command: string, + status: ToolState["status"] = "completed", + output?: string, + toolName = "bash", +): ToolPart { + const input = { command, description } + let state: ToolState + switch (status) { + case "pending": + state = { status: "pending", input, raw: "" } + break + case "running": + state = { status: "running", input, time: { start: 0 } } + break + case "error": + state = { status: "error", input, error: "Command failed", time: { start: 0, end: 1 } } + break + case "completed": + default: + state = { + status: "completed", + input, + output: output ?? (command.includes("one") ? "one\n" : command.includes("two") ? "two\n" : "three\n"), + title: description, + metadata: {}, + time: { start: 0, end: 1 }, + } + } + + return { + id, + sessionID: "snap-session", + messageID: "snap-message", + type: "tool", + callID: `call-${id}`, + tool: toolName, + state, + } +} + +function realTool( + id: string, + toolName: string, + input: Record, + output = "", + metadata: Record = {}, +): ToolPart { + return { + id, + sessionID: "snap-session", + messageID: "snap-message", + type: "tool", + callID: `call-${id}`, + tool: toolName, + state: { + status: "completed", + input, + output, + title: toolName, + metadata, + time: { start: 0, end: 1 }, + }, + } +} + +export const completedParts = [ + tool("first", "first command", "echo one"), + tool("second", "second command", "echo two"), + tool("third", "third command", "echo three"), +] + +export const activitySummaryParts = [ + realTool("summary-read", "read", { filePath: "/Users/yuhan/PawWork/titlebar.tsx" }), + tool("summary-bash", "Find titlebar CSS rules", "rg titlebar"), + realTool("summary-grep", "grep", { path: "/Users/yuhan/PawWork", pattern: "titlebar" }), + realTool("summary-websearch", "websearch", { query: "PawWork titlebar icon" }), + realTool("summary-webfetch", "webfetch", { url: "https://example.com/" }), + realTool("summary-edit", "edit", { filePath: "/Users/yuhan/PawWork/titlebar.tsx" }), + realTool("summary-skill", "skill", { name: "debug" }), +] + +export const failedParts = [ + tool("failed-command", "Failing command", "exit 1", "error"), + realTool("failed-read", "read", { filePath: "/Users/yuhan/PawWork/titlebar.tsx" }), +] + +export const runningParts = [ + tool("first-running", "first command", "echo one"), + tool("second-running", "second command", "echo two"), + tool("third-running", "third command", "echo three", "running"), +] + +export const singleQuietParts = [tool("single-quiet", "quiet command", "sleep 0", "completed", "")] +export const singleResultParts = [tool("single-result", "prints one line", "echo one")] +export const singleErrorParts = [tool("single-error", "Command blocked", "rm -rf /", "error")] +export const singleRunningParts = [tool("single-running", "long command", "sleep 30", "running")] + +export const toolOutputParts = [ + realTool( + "glob-output", + "glob", + { path: "/Users/yuhan/PawWork", pattern: "*.md" }, + "/Users/yuhan/PawWork/a.md\n/Users/yuhan/PawWork/b.md\n", + ), + realTool( + "grep-output", + "grep", + { path: "/Users/yuhan/PawWork", pattern: "test", include: "*.md" }, + "Found 1 matches\n/Users/yuhan/PawWork/a.md:\n Line 3: test\n", + ), +] + +export const mixedRealToolParts = [ + realTool("websearch-real", "websearch", { query: "PawWork desktop app AI agent 2026" }, "https://example.com/"), + realTool("webfetch-real", "webfetch", { url: "https://example.com/" }), + realTool( + "enter-worktree-real", + "enter-worktree", + { name: "session-trow-revival" }, + "", + { + ownerDirectory: "/Users/yuhan/workspace/dev/pawwork", + activeDirectory: "/Users/yuhan/workspace/dev/pawwork/.worktrees/session-trow-revival", + }, + ), + realTool( + "exit-worktree-real", + "exit-worktree", + {}, + "", + { activeDirectory: "/Users/yuhan/workspace/dev/pawwork", previousBranch: "session-trow-revival" }, + ), + realTool("skill-real", "skill", { name: "learn-code" }), + realTool( + "question-real", + "question", + { + questions: [ + { + header: "Follow up", + question: "你想继续深入测试某个工具吗?", + options: [{ label: "够了" }], + }, + ], + }, + "", + { answers: [["够了"]] }, + ), +] + +export const questionDetailParts = [ + realTool("question-detail-skill", "skill", { name: "learn-code" }), + realTool( + "question-detail-real", + "question", + { + questions: [ + { + header: "Follow up", + question: "你想继续深入测试某个工具吗?", + options: [{ label: "够了" }], + }, + ], + }, + "", + { answers: [["够了"]] }, + ), +] + +export const dismissedQuestionParts = [ + realTool( + "dismissed-question-real", + "question", + { + questions: [ + { + header: "Follow up", + question: "要继续吗?", + options: [{ label: "继续" }], + }, + ], + }, + "", + { dismissed: true }, + ), + realTool("dismissed-question-skill", "skill", { name: "debug" }), +] + +export const metadataDetailParts = [ + realTool( + "metadata-detail-question", + "question", + { + questions: [ + { + header: "Follow up", + question: "这组工具详情还能看到吗?", + options: [{ label: "可以" }], + }, + ], + }, + "", + { answers: [["可以"]] }, + ), + realTool("metadata-detail-edit", "edit", { + filePath: "/Users/yuhan/PawWork/temp/tool-test-output.md", + oldString: "before", + newString: "after", + }), + realTool("metadata-detail-write", "write", { + filePath: "/Users/yuhan/PawWork/temp/new-file.md", + content: "# New file\n\nhello", + }), + realTool( + "metadata-detail-patch", + "apply_patch", + {}, + "", + { + files: [ + { + filePath: "/Users/yuhan/PawWork/temp/patched-file.md", + relativePath: "temp/patched-file.md", + type: "add", + before: "", + after: "# Patched file\n", + additions: 1, + deletions: 0, + }, + ], + }, + ), +] diff --git a/packages/app/e2e/snap/fixtures/trow-snap-fixture.tsx b/packages/app/e2e/snap/fixtures/trow-snap-fixture.tsx new file mode 100644 index 00000000..e8f46994 --- /dev/null +++ b/packages/app/e2e/snap/fixtures/trow-snap-fixture.tsx @@ -0,0 +1,304 @@ +import { Dynamic, render } from "solid-js/web" +import type { ToolPart } from "@opencode-ai/sdk/v2" +import { BasicTool } from "@opencode-ai/ui/basic-tool" +import { DataProvider, I18nProvider } from "@opencode-ai/ui/context" +import { FileComponentProvider } from "@opencode-ai/ui/context/file" +import { MarkedProvider } from "@opencode-ai/ui/context/marked" +import { AssistantParts, ToolRegistry } from "@opencode-ai/ui/message-part" +import { TrowBlock } from "@opencode-ai/ui/session-turn-trow-block" +import { + activitySummaryParts, + completedParts, + dismissedQuestionParts, + failedParts, + fixtureData, + labels, + metadataDetailParts, + mixedRealToolParts, + questionDetailParts, + runningParts, + singleErrorParts, + singleQuietParts, + singleResultParts, + singleRunningParts, + snapAssistantMessage, + tool, + toolOutputParts, + zhI18n, +} from "./trow-snap-fixture-data" + +function FileStub() { + return
File viewer stub
+} + +function AssistantPartsCase(props: { + parts: ToolPart[] + shellToolDefaultOpen?: boolean + editToolDefaultOpen?: boolean +}) { + return ( + + + + ) +} + +function describeTool(part: ToolPart) { + const description = part.state.input?.description + return `执行命令${typeof description === "string" && description ? ` ${description}` : ""}` +} + +function renderTool(prefix: string, openTool?: string) { + return (part: ToolPart) => { + const input = part.state.input ?? {} + const command = typeof input.command === "string" ? input.command : "" + const description = typeof input.description === "string" ? input.description : undefined + const output = part.state.status === "completed" ? part.state.output : "" + return ( +
+ + + +
+ ) + } +} + +function BashOutput(props: { command: string; output?: string }) { + return ( +
+
+
+          {`$ ${props.command}${props.output ? `\n\n${props.output.trim()}` : ""}`}
+        
+
+
+ ) +} + +function renderRegisteredTool(prefix: string, openTool?: string | readonly string[]) { + return (part: ToolPart) => { + const component = ToolRegistry.render(part.tool) + const state = part.state + const input = state.input ?? {} + const output = state.status === "completed" ? state.output : undefined + const metadata = state.status === "completed" ? (state.metadata ?? {}) : {} + const open = + Array.isArray(openTool) ? openTool.includes(part.id) : part.id === (openTool ?? "websearch-real") + return ( +
+ +
+ ) + } +} + +function TrowSnapFixture() { + return ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
工具完成后的下一段回复
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ) +} + +export function mountTrowSnapFixture(root: HTMLElement) { + root.innerHTML = "" + render( + () => ( + + + + + + + + + + ), + root, + ) +} diff --git a/packages/app/e2e/snap/session-trow.snap.ts b/packages/app/e2e/snap/session-trow.snap.ts new file mode 100644 index 00000000..41dc4b75 --- /dev/null +++ b/packages/app/e2e/snap/session-trow.snap.ts @@ -0,0 +1,302 @@ +import { expect, type Locator } from "@playwright/test" +import { fileURLToPath } from "node:url" +import { test } from "../fixtures" +import { composeGrid, snapOutputPath, type Shot } from "./_compose" + +test.use({ viewport: { width: 900, height: 560 }, deviceScaleFactor: 2 }) + +const LANGUAGE_KEY = "pawwork.global.dat:language" +const fixturePath = fileURLToPath(new URL("./fixtures/trow-snap-fixture.tsx", import.meta.url)) +async function captureBlock(name: string, block: Locator): Promise { + await expect(block).toBeVisible({ timeout: 30_000 }) + return { name, buf: await block.screenshot() } +} + +async function waitForThemeBoot(page: import("@playwright/test").Page): Promise { + await page.waitForFunction( + () => getComputedStyle(document.documentElement).getPropertyValue("--bg-base").trim().length > 0, + null, + { timeout: 30_000 }, + ) +} + +test("session-trow", async ({ page }) => { + test.setTimeout(180_000) + + await page.addInitScript((key) => { + localStorage.setItem(key, JSON.stringify({ locale: "zh" })) + }, LANGUAGE_KEY) + + await page.goto("/") + await waitForThemeBoot(page) + await page.evaluate(async (path) => { + const mod = await import(path) + mod.mountTrowSnapFixture(document.body) + }, `/@fs/${fixturePath}`) + + const shots: Shot[] = [] + const running = page.locator('[data-snap="running-current"]') + await expect(running).toContainText("执行命令 third command", { timeout: 30_000 }) + shots.push(await captureBlock("running-current", running)) + + const activitySummary = page.locator('[data-snap="activity-summary-collapsed"]') + await expect(activitySummary).toContainText("读取 1 个文件,运行 1 条命令,搜索文件 1 次", { timeout: 30_000 }) + await expect(activitySummary).toContainText("使用 1 个工具", { timeout: 30_000 }) + shots.push(await captureBlock("activity-summary-collapsed", activitySummary)) + + const failedSummary = page.locator('[data-snap="failed-summary-collapsed"]') + await expect(failedSummary).toContainText("运行 1 条命令,读取 1 个文件,1 个失败", { timeout: 30_000 }) + shots.push(await captureBlock("failed-summary-collapsed", failedSummary)) + + shots.push(await captureBlock("mixed-collapsed", page.locator('[data-snap="mixed-collapsed"]'))) + + const collapsedFollowedByText = page.locator('[data-snap="collapsed-followed-by-text"]') + const collapsedTextGap = await collapsedFollowedByText.evaluate((root) => { + const trow = root.querySelector('[data-component="session-turn-trow-block"]') + const text = root.querySelector('[data-component="text-part"]') + if (!trow || !text) return Number.NaN + return text.getBoundingClientRect().top - trow.getBoundingClientRect().bottom + }) + expect(collapsedTextGap).toBeGreaterThanOrEqual(0) + expect(collapsedTextGap).toBeLessThanOrEqual(12) + shots.push(await captureBlock("collapsed-followed-by-text", collapsedFollowedByText)) + + shots.push(await captureBlock("mixed-expanded", page.locator('[data-snap="mixed-expanded"]'))) + await expect(page.locator('[data-snap="inner-bash-expanded"] [data-component="bash-output"]')).toBeVisible({ + timeout: 30_000, + }) + const innerBashMetrics = await page.locator('[data-snap="inner-bash-expanded"]').evaluate((root) => { + const summary = root.querySelector('[data-slot="trow-summary"]') + const body = root.querySelector('[data-slot="trow-body"]') + const pre = root.querySelector('[data-component="bash-output"] [data-slot="bash-pre"]') + const code = pre?.querySelector("code") + const summaryText = root.querySelector('[data-slot="trow-summary-text"]') + const openTool = pre?.closest('[data-slot="trow-result-body"]') + const trigger = openTool?.querySelector('[data-slot="collapsible-trigger"]') + const triggerContent = openTool?.querySelector('[data-slot="basic-tool-tool-trigger-content"]') + const arrow = openTool?.querySelector('[data-slot="collapsible-arrow"]') + const content = openTool?.querySelector('[data-slot="collapsible-content"]') + if (!summary || !body || !pre || !code || !summaryText || !trigger || !triggerContent || !arrow || !content) { + return { + bodyTopGap: Number.NaN, + rowGap: "", + prePadding: "", + codeFontSize: "", + codeLineHeight: "", + summaryWhiteSpace: "", + triggerCursor: "", + arrowGap: Number.NaN, + contentGap: Number.NaN, + contentTransitionProperty: "", + } + } + return { + bodyTopGap: Math.round(body.getBoundingClientRect().top - summary.getBoundingClientRect().bottom), + rowGap: getComputedStyle(body).rowGap, + prePadding: getComputedStyle(pre).padding, + codeFontSize: getComputedStyle(code).fontSize, + codeLineHeight: getComputedStyle(code).lineHeight, + summaryWhiteSpace: getComputedStyle(summaryText).whiteSpace, + triggerCursor: getComputedStyle(trigger).cursor, + arrowGap: Math.round(arrow.getBoundingClientRect().left - triggerContent.getBoundingClientRect().right), + contentGap: Math.round(content.getBoundingClientRect().top - trigger.getBoundingClientRect().bottom), + contentTransitionProperty: getComputedStyle(content).transitionProperty, + } + }) + expect(innerBashMetrics.bodyTopGap).toBeGreaterThanOrEqual(0) + expect(innerBashMetrics.bodyTopGap).toBeLessThanOrEqual(5) + expect(innerBashMetrics.rowGap).toBe("4px") + expect(innerBashMetrics.prePadding).toBe("8px 10px") + expect(innerBashMetrics.codeFontSize).toBe("12px") + expect(innerBashMetrics.codeLineHeight).toBe("18px") + expect(innerBashMetrics.summaryWhiteSpace).toBe("nowrap") + expect(innerBashMetrics.triggerCursor).toBe("text") + expect(innerBashMetrics.arrowGap).toBe(8) + expect(innerBashMetrics.contentGap).toBe(4) + expect(innerBashMetrics.contentTransitionProperty).toContain("height") + expect(innerBashMetrics.contentTransitionProperty).toContain("content-visibility") + shots.push(await captureBlock("inner-bash-expanded", page.locator('[data-snap="inner-bash-expanded"]'))) + + const toolOutputSpacing = page.locator('[data-snap="tool-output-spacing"]') + await expect(toolOutputSpacing.locator('[data-component="tool-output"]')).toBeVisible({ timeout: 30_000 }) + const toolOutputMetrics = await toolOutputSpacing.evaluate((root) => { + const output = root.querySelector('[data-component="tool-output"]') + const lastContent = + output?.querySelector('[data-component="markdown"] > :last-child') ?? + output?.querySelector("pre") ?? + output + const triggers = root.querySelectorAll('[data-slot="collapsible-trigger"]') + const firstTrigger = triggers[0] + const nextTrigger = triggers[1] + if (!output || !lastContent || !firstTrigger || !nextTrigger) { + return { outputGap: Number.NaN, contentGap: Number.NaN, triggerOutputGap: Number.NaN } + } + return { + outputGap: nextTrigger.getBoundingClientRect().top - output.getBoundingClientRect().bottom, + contentGap: nextTrigger.getBoundingClientRect().top - lastContent.getBoundingClientRect().bottom, + triggerOutputGap: output.getBoundingClientRect().top - firstTrigger.getBoundingClientRect().bottom, + } + }) + expect(toolOutputMetrics.outputGap).toBeGreaterThanOrEqual(0) + expect(toolOutputMetrics.outputGap).toBeLessThanOrEqual(8) + expect(toolOutputMetrics.contentGap).toBeGreaterThanOrEqual(0) + expect(toolOutputMetrics.contentGap).toBeLessThanOrEqual(8) + expect(Math.round(toolOutputMetrics.triggerOutputGap)).toBe(4) + const toolOutputUserSelect = await toolOutputSpacing.evaluate((root) => { + const summary = root.querySelector('[data-slot="trow-summary"]') + const trigger = root.querySelector('[data-slot="collapsible-trigger"]') + const output = root.querySelector('[data-component="tool-output"]') + return { + summary: summary ? getComputedStyle(summary).userSelect : "", + trigger: trigger ? getComputedStyle(trigger).userSelect : "", + output: output ? getComputedStyle(output).userSelect : "", + } + }) + expect(toolOutputUserSelect.summary).toBe("text") + expect(toolOutputUserSelect.trigger).toBe("text") + expect(toolOutputUserSelect.output).toBe("text") + shots.push(await captureBlock("tool-output-spacing", toolOutputSpacing)) + + const registeredToolRows = page.locator('[data-snap="registered-tool-rows"]') + await expect(registeredToolRows).toContainText("网络搜索", { timeout: 30_000 }) + await expect(registeredToolRows).toContainText("进入工作树", { timeout: 30_000 }) + await expect(registeredToolRows).toContainText("使用技能", { timeout: 30_000 }) + await expect(registeredToolRows).toContainText("learn-code", { timeout: 30_000 }) + await expect(registeredToolRows).toContainText("提出问题", { timeout: 30_000 }) + await expect(registeredToolRows).toContainText("1 已回答", { timeout: 30_000 }) + await expect(registeredToolRows).not.toContainText("你想继续深入测试某个工具吗?", { timeout: 30_000 }) + await expect(registeredToolRows).not.toContainText("够了", { timeout: 30_000 }) + const registeredMetrics = await registeredToolRows.evaluate((root) => { + const titleSelectors = ['[data-slot="basic-tool-tool-title"]', '[data-component="task-tool-title"]'] + const titles = titleSelectors.flatMap((selector) => + Array.from(root.querySelectorAll(selector), (el) => ({ + text: el.textContent?.trim() ?? "", + left: el.getBoundingClientRect().left, + })), + ) + const output = root.querySelector('[data-component="exa-tool-output"]') + const triggers = Array.from(root.querySelectorAll('[data-slot="collapsible-trigger"]')) + const nextTrigger = triggers[1] + return { + titleLefts: titles.filter((item) => item.text).map((item) => item.left), + webSearchOutputGap: + output && nextTrigger ? nextTrigger.getBoundingClientRect().top - output.getBoundingClientRect().bottom : Number.NaN, + } + }) + expect(registeredMetrics.titleLefts.length).toBeGreaterThanOrEqual(5) + expect(Math.max(...registeredMetrics.titleLefts) - Math.min(...registeredMetrics.titleLefts)).toBeLessThanOrEqual(1) + expect(registeredMetrics.webSearchOutputGap).toBeGreaterThanOrEqual(0) + expect(registeredMetrics.webSearchOutputGap).toBeLessThanOrEqual(8) + shots.push(await captureBlock("registered-tool-rows", registeredToolRows)) + + const questionExpanded = page.locator('[data-snap="question-expanded"]') + await expect(questionExpanded).toContainText("提出问题", { timeout: 30_000 }) + await expect(questionExpanded).toContainText("你想继续深入测试某个工具吗?", { timeout: 30_000 }) + await expect(questionExpanded).toContainText("够了", { timeout: 30_000 }) + const questionMetrics = await questionExpanded.evaluate((root) => { + const answers = root.querySelector('[data-component="question-answers"]') + const item = root.querySelector('[data-slot="question-answer-item"]') + const question = root.querySelector('[data-slot="question-text"]') + if (!answers || !item || !question) { + return { listGap: "", itemGap: "", fontSize: "", lineHeight: "" } + } + return { + listGap: getComputedStyle(answers).rowGap, + itemGap: getComputedStyle(item).rowGap, + fontSize: getComputedStyle(question).fontSize, + lineHeight: getComputedStyle(question).lineHeight, + } + }) + expect(questionMetrics.listGap).toBe("4px") + expect(questionMetrics.itemGap).toBe("0px") + expect(questionMetrics.fontSize).toBe("12px") + expect(questionMetrics.lineHeight).toBe("18px") + shots.push(await captureBlock("question-expanded", questionExpanded)) + + const dismissedQuestion = page.locator('[data-snap="dismissed-question-collapsed"]') + await expect(dismissedQuestion.locator('[data-slot="trow-summary-chev"]')).toBeVisible({ timeout: 30_000 }) + await expect(dismissedQuestion.getByText("问题已忽略")).toBeHidden({ timeout: 30_000 }) + shots.push(await captureBlock("dismissed-question-collapsed", dismissedQuestion)) + await dismissedQuestion.locator('[data-slot="trow-summary"]').click() + await expect(dismissedQuestion.getByText("问题已忽略")).toBeVisible({ timeout: 30_000 }) + shots.push(await captureBlock("dismissed-question-expanded", dismissedQuestion)) + + const metadataDetailCollapsed = page.locator('[data-snap="metadata-detail-collapsed"]') + await expect(metadataDetailCollapsed.locator('[data-slot="trow-summary-chev"]')).toBeVisible({ timeout: 30_000 }) + await expect(metadataDetailCollapsed).not.toContainText("这组工具详情还能看到吗?", { timeout: 30_000 }) + shots.push(await captureBlock("metadata-detail-collapsed", metadataDetailCollapsed)) + + const metadataDetailExpanded = page.locator('[data-snap="metadata-detail-expanded"]') + await expect(metadataDetailExpanded.locator('[data-component="question-answers"]')).toBeVisible({ timeout: 30_000 }) + await expect(metadataDetailExpanded).toContainText("这组工具详情还能看到吗?", { timeout: 30_000 }) + await expect(metadataDetailExpanded).toContainText("可以", { timeout: 30_000 }) + await expect(metadataDetailExpanded.locator('[data-component="edit-content"]')).toBeVisible({ timeout: 30_000 }) + await expect(metadataDetailExpanded.locator('[data-component="write-content"]')).toBeVisible({ timeout: 30_000 }) + await expect(metadataDetailExpanded.locator('[data-component="apply-patch-file-diff"]')).toBeVisible({ + timeout: 30_000, + }) + shots.push(await captureBlock("metadata-detail-expanded", metadataDetailExpanded)) + + const singleDirect = page.locator('[data-snap="single-command-direct"]') + await expect(singleDirect.locator('[data-component="session-turn-trow-block"][data-single]')).toBeVisible({ + timeout: 30_000, + }) + await expect(singleDirect.locator('[data-slot="trow-summary-icon"]')).toBeVisible({ timeout: 30_000 }) + await expect(singleDirect.locator('[data-component="bash-output"]')).toBeVisible({ timeout: 30_000 }) + shots.push(await captureBlock("single-command-direct", singleDirect)) + + const singleExpanded = page.locator('[data-snap="single-command-expanded"]') + await expect(singleExpanded.locator('[data-component="session-turn-trow-block"][data-single]')).toBeVisible({ + timeout: 30_000, + }) + await expect(singleExpanded.locator('[data-slot="trow-summary-icon"]')).toBeVisible({ timeout: 30_000 }) + await expect(singleExpanded.locator('[data-component="bash-output"]')).toBeVisible({ + timeout: 30_000, + }) + shots.push(await captureBlock("single-command-expanded", singleExpanded)) + + const singleError = page.locator('[data-snap="single-command-error"]') + await expect(singleError.locator('[data-component="session-turn-trow-block"][data-single]')).toBeVisible({ + timeout: 30_000, + }) + await expect(singleError.locator('[data-kind="tool-error-card"]')).toBeVisible({ timeout: 30_000 }) + const singleErrorMetrics = await singleError.evaluate((root) => { + const trigger = root.querySelector('[data-slot="collapsible-trigger"]') + const blockedIcon = root.querySelector('[data-component="tool-error-card-icon"]') + const title = root.querySelector('[data-slot="basic-tool-tool-title"]') + if (!trigger || !blockedIcon || !title) { + return { triggerHeight: 0, iconTitleTopDelta: Number.POSITIVE_INFINITY } + } + return { + triggerHeight: trigger.getBoundingClientRect().height, + iconTitleTopDelta: Math.abs(blockedIcon.getBoundingClientRect().top - title.getBoundingClientRect().top), + } + }) + expect(singleErrorMetrics.triggerHeight).toBeGreaterThan(0) + expect(singleErrorMetrics.iconTitleTopDelta).toBeLessThanOrEqual(3) + shots.push(await captureBlock("single-command-error", singleError)) + + const singleShellSettingCollapsed = page.locator('[data-snap="single-shell-setting-collapsed"]') + await expect(singleShellSettingCollapsed).toContainText("执行命令", { timeout: 30_000 }) + await expect(singleShellSettingCollapsed).toContainText("respects shell setting", { timeout: 30_000 }) + await expect(singleShellSettingCollapsed.locator('[data-component="bash-output"]')).toBeHidden({ timeout: 30_000 }) + shots.push(await captureBlock("single-shell-setting-collapsed", singleShellSettingCollapsed)) + + const singleShellSettingExpanded = page.locator('[data-snap="single-shell-setting-expanded"]') + await expect(singleShellSettingExpanded).toContainText("执行命令", { timeout: 30_000 }) + await expect(singleShellSettingExpanded).toContainText("respects shell setting", { timeout: 30_000 }) + await expect(singleShellSettingExpanded.locator('[data-component="bash-output"]')).toBeVisible({ timeout: 30_000 }) + shots.push(await captureBlock("single-shell-setting-expanded", singleShellSettingExpanded)) + + const singleRunning = page.locator('[data-snap="single-command-running"]') + await expect(singleRunning.locator('[data-component="session-turn-trow-block"][data-single]')).toBeVisible({ + timeout: 30_000, + }) + await expect(singleRunning.locator('[data-slot="trow-summary-icon"]')).toBeVisible({ timeout: 30_000 }) + await expect(singleRunning).toContainText("执行命令", { timeout: 30_000 }) + shots.push(await captureBlock("single-command-running", singleRunning)) + + const out = snapOutputPath("session-trow") + await composeGrid(shots, out, { cols: 2 }) + process.stdout.write(`\n[snap] session-trow grid -> ${out}\n\n`) +}) diff --git a/packages/ui/src/components/message-part-registry.test.ts b/packages/ui/src/components/message-part-registry.test.ts index 4593d2ab..6ca8fe0e 100644 --- a/packages/ui/src/components/message-part-registry.test.ts +++ b/packages/ui/src/components/message-part-registry.test.ts @@ -117,7 +117,7 @@ test("split keeps hidden tools and deferred heavy tool bodies explicit", () => { expect(source).toContain("export const HIDDEN_TOOLS = new Set(HIDDEN_TOOL_NAMES)") expect(source).toContain('if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit') - expect(source).toContain("defaultOpen={completed()}") + expect(source).toContain("defaultOpen={props.defaultOpen ?? completed()}") const deferredHeavyTools = { "bash.tsx": 1, diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 2fa8f4ec..0bbbbb7d 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -20,6 +20,7 @@ export { } from "./message-part/registry" export { AssistantParts } from "./message-part/assistant-parts" export { AssistantMessageDisplay } from "./message-part/assistant-message-display" +export { contextTrowSummaryText } from "./message-part/context-tool-helpers" export { Message, Part } from "./message-part/message-router" export { UserMessageDisplay } from "./message-part/user-message" export { MessageDivider } from "./message-part/parts/compaction-and-divider" diff --git a/packages/ui/src/components/message-part/assistant-message-display.tsx b/packages/ui/src/components/message-part/assistant-message-display.tsx index 70a8aa21..2f316a14 100644 --- a/packages/ui/src/components/message-part/assistant-message-display.tsx +++ b/packages/ui/src/components/message-part/assistant-message-display.tsx @@ -1,7 +1,9 @@ import { createMemo, Index, Match, Show, Switch } from "solid-js" import type { AssistantMessage, Part as PartType, ToolPart } from "@opencode-ai/sdk/v2" -import { ContextToolGroup } from "./context-tool-group" -import { groupParts, isContextGroupTool, renderable, sameGroups, type PartGroup } from "./grouping" +import { useI18n } from "../../context/i18n" +import { TrowBlock } from "../session-turn-trow-block" +import { groupParts, partDefaultOpen, renderable, sameGroups, type PartGroup } from "./grouping" +import { contextToolSummaryText, contextTrowSummaryText } from "./context-tool-helpers" import { index, latestDefined, same } from "./shared-utils" import { Part } from "./message-router" @@ -10,6 +12,7 @@ export function AssistantMessageDisplay(props: { parts: PartType[] showReasoningSummaries?: boolean }) { + const i18n = useI18n() const emptyTools: ToolPart[] = [] const part = createMemo(() => index(props.parts)) const grouped = createMemo( @@ -33,23 +36,42 @@ export function AssistantMessageDisplay(props: { return ( - + {(() => { const parts = createMemo( () => { const entry = entryAccessor() - if (entry.type !== "context") return emptyTools + if (entry.type !== "trow") return emptyTools return entry.refs .map((ref) => part().get(ref.partID)) - .filter((part): part is ToolPart => !!part && isContextGroupTool(part)) + .filter((part): part is ToolPart => !!part && part.type === "tool") }, emptyTools, { equals: same }, ) + const singleTool = createMemo(() => parts().length === 1) + const defaultOpenForTool = (tool: ToolPart) => partDefaultOpen(tool) ?? singleTool() return ( 0}> - + i18n.t("ui.sessionTurn.trow.summary.running", { count }), + summaryCompleted: (parts, failed) => contextTrowSummaryText(parts, failed, i18n), + }} + describeTool={(tool) => contextToolSummaryText(tool, i18n)} + renderTool={(tool) => ( +
+ +
+ )} + />
) })()} diff --git a/packages/ui/src/components/message-part/assistant-parts.tsx b/packages/ui/src/components/message-part/assistant-parts.tsx index f9fa27e0..50fa3ae5 100644 --- a/packages/ui/src/components/message-part/assistant-parts.tsx +++ b/packages/ui/src/components/message-part/assistant-parts.tsx @@ -1,8 +1,10 @@ import { createMemo, Index, Match, Show, Switch } from "solid-js" import type { AssistantMessage, Part as PartType, ToolPart } from "@opencode-ai/sdk/v2" import { useData } from "../../context" -import { ContextToolGroup } from "./context-tool-group" -import { groupParts, isContextGroupTool, partDefaultOpen, renderable, sameGroups, type PartGroup } from "./grouping" +import { useI18n } from "../../context/i18n" +import { TrowBlock } from "../session-turn-trow-block" +import { activeWorkingTrowKey, groupParts, partDefaultOpen, renderable, sameGroups, type PartGroup } from "./grouping" +import { contextToolSummaryText, contextTrowSummaryText } from "./context-tool-helpers" import { index, latestDefined, list, same } from "./shared-utils" import { Part } from "./message-router" @@ -14,6 +16,7 @@ export function AssistantParts(props: { editToolDefaultOpen?: boolean }) { const data = useData() + const i18n = useI18n() const emptyParts: PartType[] = [] const emptyTools: ToolPart[] = [] const msgs = createMemo(() => index(props.messages)) @@ -39,8 +42,7 @@ export function AssistantParts(props: { [] as PartGroup[], { equals: sameGroups }, ) - - const last = createMemo(() => grouped().at(-1)?.key) + const workingTrowKey = createMemo(() => activeWorkingTrowKey(grouped(), props.working)) return ( @@ -49,24 +51,49 @@ export function AssistantParts(props: { return ( - + {(() => { const parts = createMemo( () => { const entry = entryAccessor() - if (entry.type !== "context") return emptyTools + if (entry.type !== "trow") return emptyTools return entry.refs .map((ref) => part().get(ref.messageID)?.get(ref.partID)) - .filter((part): part is ToolPart => !!part && isContextGroupTool(part)) + .filter((part): part is ToolPart => !!part && part.type === "tool") }, emptyTools, { equals: same }, ) - const busy = createMemo(() => props.working && last() === entryAccessor().key) + const singleTool = createMemo(() => parts().length === 1) + const defaultOpenForTool = (tool: ToolPart) => + partDefaultOpen(tool, props.shellToolDefaultOpen, props.editToolDefaultOpen) ?? singleTool() + const renderTool = (tool: ToolPart) => { + const message = msgs().get(tool.messageID) + if (!message) return null + return ( +
+ +
+ ) + } return ( 0}> - + i18n.t("ui.sessionTurn.trow.summary.running", { count }), + summaryCompleted: (parts, failed) => contextTrowSummaryText(parts, failed, i18n), + }} + describeTool={(tool) => contextToolSummaryText(tool, i18n)} + renderTool={renderTool} + /> ) })()} diff --git a/packages/ui/src/components/message-part/context-tool-helpers.test.ts b/packages/ui/src/components/message-part/context-tool-helpers.test.ts new file mode 100644 index 00000000..d599793f --- /dev/null +++ b/packages/ui/src/components/message-part/context-tool-helpers.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test" +import type { ToolPart, ToolState } from "@opencode-ai/sdk/v2" +import type { UiI18n, UiI18nKey, UiI18nParams } from "../../context/i18n" +import { dict as en } from "../../i18n/en" +import { dict as zh } from "../../i18n/zh" +import { contextTrowSummaryText } from "./context-tool-helpers" + +function resolveTemplate(text: string, params?: UiI18nParams) { + if (!params) return text + return text.replace(/{{\s*([^}]+?)\s*}}/g, (_, rawKey) => { + const value = params[String(rawKey)] + return value === undefined ? "" : String(value) + }) +} + +function i18n(locale: "en" | "zh"): UiI18n { + return { + locale: () => locale, + t: (key: UiI18nKey, params?: UiI18nParams) => { + const source = locale === "zh" ? (zh as Record) : en + return resolveTemplate(source[key] ?? en[key] ?? String(key), params) + }, + } +} + +function tool( + id: string, + name: string, + status: "completed" | "error" = "completed", + metadata: Record = {}, +): ToolPart { + const state: ToolState = + status === "error" + ? { status, input: {}, error: "boom", time: { start: 0, end: 1 } } + : { status, input: {}, output: "", title: "", metadata, time: { start: 0, end: 1 } } + + return { + id, + sessionID: "s", + messageID: "m", + type: "tool", + callID: `call-${id}`, + tool: name, + state, + } +} + +describe("contextTrowSummaryText", () => { + test("aggregates completed tool activity in first-seen category order", () => { + const parts = [ + tool("read", "read"), + tool("list", "list"), + tool("bash", "bash"), + tool("grep", "grep"), + tool("websearch", "websearch"), + tool("webfetch", "webfetch"), + tool("edit", "edit"), + tool("patch", "apply_patch", "completed", { files: [{}, {}] }), + tool("skill", "skill"), + tool("unknown", "linear_create_issue"), + ] + + expect(contextTrowSummaryText(parts, 0, i18n("zh"))).toBe( + "读取 2 个文件,运行 1 条命令,搜索文件 1 次,搜索网页 1 次,读取网页 1 个,修改 3 个文件,使用 2 个工具", + ) + }) + + test("keeps failures as a trailing summary item", () => { + expect(contextTrowSummaryText([tool("bash", "bash", "error")], 1, i18n("zh"))).toBe( + "运行 1 条命令,1 个失败", + ) + }) + + test("uses English singular labels when the count is one", () => { + const parts = [tool("read", "read"), tool("bash", "bash"), tool("skill", "skill")] + + expect(contextTrowSummaryText(parts, 0, i18n("en"))).toBe("Read 1 file, Ran 1 command, Used 1 tool") + }) +}) diff --git a/packages/ui/src/components/message-part/context-tool-helpers.ts b/packages/ui/src/components/message-part/context-tool-helpers.ts index 63c9fe32..a76b6a72 100644 --- a/packages/ui/src/components/message-part/context-tool-helpers.ts +++ b/packages/ui/src/components/message-part/context-tool-helpers.ts @@ -1,10 +1,10 @@ import type { ToolPart } from "@opencode-ai/sdk/v2" import { getFilename } from "@opencode-ai/core/util/path" -import type { useI18n } from "../../context/i18n" +import type { UiI18n, UiI18nKey } from "../../context/i18n" import { getDirectory } from "./markdown-render" import { toolInfoForInput } from "../tool-info" -export function contextToolDetail(part: ToolPart, i18n: ReturnType): string | undefined { +export function contextToolDetail(part: ToolPart, i18n: UiI18n): string | undefined { const info = toolInfoForInput(part.tool, part.state.input ?? {}, toolStateMetadata(part.state), i18n) if (info.subtitle) return info.subtitle if (part.state.status === "error") return toolStateError(part.state) @@ -15,7 +15,7 @@ export function contextToolDetail(part: ToolPart, i18n: ReturnType) { +export function contextToolTrigger(part: ToolPart, i18n: UiI18n) { const input = (part.state.input ?? {}) as Record const path = typeof input.path === "string" ? input.path : "/" const filePath = typeof input.filePath === "string" ? input.filePath : undefined @@ -67,6 +67,11 @@ export function contextToolTrigger(part: ToolPart, i18n: ReturnType { if (!state || !("metadata" in state)) return {} const metadata = state.metadata @@ -92,3 +97,63 @@ export function contextToolSummary(parts: ToolPart[]) { const list = parts.filter((part) => part.tool === "list").length return { read, search, list } } + +type TrowActivityKind = "read" | "search" | "websearch" | "webfetch" | "edit" | "command" | "tool" + +function trowActivityKind(tool: string): TrowActivityKind { + switch (tool) { + case "read": + case "list": + return "read" + case "glob": + case "grep": + return "search" + case "websearch": + return "websearch" + case "webfetch": + return "webfetch" + case "edit": + case "write": + case "apply_patch": + return "edit" + case "bash": + return "command" + default: + return "tool" + } +} + +function trowActivityCount(part: ToolPart): number { + if (part.tool !== "apply_patch") return 1 + const files = toolStateMetadata(part.state).files + return Array.isArray(files) && files.length > 0 ? files.length : 1 +} + +function trowSummaryKey(kind: TrowActivityKind, count: number) { + return `ui.sessionTurn.trow.summary.${kind}.${count === 1 ? "one" : "other"}` as UiI18nKey +} + +function trowFailedKey(count: number) { + return `ui.sessionTurn.trow.summary.failed.${count === 1 ? "one" : "other"}` as UiI18nKey +} + +function trowSummarySeparator(i18n: UiI18n) { + return i18n.locale().startsWith("zh") ? "," : ", " +} + +export function contextTrowSummaryText(parts: readonly ToolPart[], failedCount: number, i18n: UiI18n) { + const order: TrowActivityKind[] = [] + const counts = new Map() + for (const part of parts) { + const kind = trowActivityKind(part.tool) + if (!counts.has(kind)) order.push(kind) + counts.set(kind, (counts.get(kind) ?? 0) + trowActivityCount(part)) + } + + const items = order.map((kind) => { + const count = counts.get(kind) ?? 0 + return i18n.t(trowSummaryKey(kind, count), { count }) + }) + if (failedCount > 0) items.push(i18n.t(trowFailedKey(failedCount), { count: failedCount })) + return items.join(trowSummarySeparator(i18n)) +} diff --git a/packages/ui/src/components/message-part/grouping.test.ts b/packages/ui/src/components/message-part/grouping.test.ts new file mode 100644 index 00000000..80a977b2 --- /dev/null +++ b/packages/ui/src/components/message-part/grouping.test.ts @@ -0,0 +1,177 @@ +import { expect, test, describe } from "bun:test" +import type { Part, TextPart, ToolPart } from "@opencode-ai/sdk/v2" +import { activeWorkingTrowKey, groupParts, partDefaultOpen, renderable } from "./grouping" + +function textPart(id: string, text: string): TextPart { + return { + id, + sessionID: "s", + messageID: "m", + type: "text", + text, + } +} + +function toolPart(id: string, tool: string, status: "pending" | "running" | "completed" | "error" = "completed"): ToolPart { + if (status === "pending") { + return { + id, + sessionID: "s", + messageID: "m", + type: "tool", + callID: `call-${id}`, + tool, + state: { status: "pending", input: {}, raw: "" }, + } + } + if (status === "running") { + return { + id, + sessionID: "s", + messageID: "m", + type: "tool", + callID: `call-${id}`, + tool, + state: { status: "running", input: {}, time: { start: 0 } }, + } + } + if (status === "error") { + return { + id, + sessionID: "s", + messageID: "m", + type: "tool", + callID: `call-${id}`, + tool, + state: { status: "error", input: {}, error: "fail", time: { start: 0, end: 1 } }, + } + } + return { + id, + sessionID: "s", + messageID: "m", + type: "tool", + callID: `call-${id}`, + tool, + state: { status: "completed", input: {}, output: "", title: "", metadata: {}, time: { start: 0, end: 1 } }, + } +} + +function groupRenderable(parts: Part[]) { + return groupParts( + parts + .filter((part) => renderable(part)) + .map((part) => ({ + messageID: part.messageID, + part, + })), + ) +} + +describe("message-part groupParts", () => { + test("a single renderable tool emits one direct trow group", () => { + const result = groupRenderable([toolPart("a", "bash")]) + + expect(result).toEqual([ + { + key: "trow:a", + type: "trow", + refs: [{ messageID: "m", partID: "a" }], + }, + ]) + }) + + test("tool-only input emits one trow group", () => { + const result = groupRenderable([toolPart("a", "bash"), toolPart("b", "bash"), toolPart("c", "edit")]) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + key: "trow:a", + type: "trow", + refs: [ + { messageID: "m", partID: "a" }, + { messageID: "m", partID: "b" }, + { messageID: "m", partID: "c" }, + ], + }) + }) + + test("text between tool runs flushes into separate trows", () => { + const result = groupRenderable([ + toolPart("t1", "bash"), + toolPart("t2", "bash"), + textPart("p1", "intermediate prose"), + toolPart("t3", "edit"), + ]) + + expect(result.map((group) => group.type)).toEqual(["trow", "part", "trow"]) + expect(result[1]).toEqual({ + key: "part:m:p1", + type: "part", + ref: { messageID: "m", partID: "p1" }, + }) + expect(result[2]).toEqual({ + key: "trow:t3", + type: "trow", + refs: [{ messageID: "m", partID: "t3" }], + }) + }) + + test("hidden tools are filtered before grouping and do not split a trow", () => { + const result = groupRenderable([toolPart("t1", "bash"), toolPart("h1", "todowrite"), toolPart("t2", "bash")]) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + key: "trow:t1", + type: "trow", + refs: [ + { messageID: "m", partID: "t1" }, + { messageID: "m", partID: "t2" }, + ], + }) + }) + + test("pending question tools are filtered before grouping", () => { + const result = groupRenderable([toolPart("t1", "bash"), toolPart("q1", "question", "pending"), toolPart("t2", "bash")]) + + expect(result).toHaveLength(1) + expect(result[0].type).toBe("trow") + }) +}) + +describe("message-part activeWorkingTrowKey", () => { + test("keeps the last visible trow active while the turn is working", () => { + const result = groupRenderable([textPart("p1", "first"), toolPart("t1", "bash"), toolPart("t2", "grep")]) + + expect(activeWorkingTrowKey(result, true)).toBe("trow:t1") + }) + + test("does not keep an earlier trow active after following text appears", () => { + const result = groupRenderable([toolPart("t1", "bash"), toolPart("t2", "grep"), textPart("p1", "next prose")]) + + expect(activeWorkingTrowKey(result, true)).toBeUndefined() + }) + + test("does not mark any trow active when the turn is idle", () => { + const result = groupRenderable([toolPart("t1", "bash"), toolPart("t2", "grep")]) + + expect(activeWorkingTrowKey(result, false)).toBeUndefined() + }) +}) + +describe("message-part partDefaultOpen", () => { + test("respects shell and edit default-open settings for tool rows", () => { + expect(partDefaultOpen(toolPart("bash-closed", "bash"), false, true)).toBe(false) + expect(partDefaultOpen(toolPart("bash-open", "bash"), true, false)).toBe(true) + + expect(partDefaultOpen(toolPart("edit-closed", "edit"), true, false)).toBe(false) + expect(partDefaultOpen(toolPart("edit-open", "edit"), false, true)).toBe(true) + expect(partDefaultOpen(toolPart("write-open", "write"), false, true)).toBe(true) + expect(partDefaultOpen(toolPart("patch-open", "apply_patch"), false, true)).toBe(true) + }) + + test("leaves non-configured tools to the caller fallback", () => { + expect(partDefaultOpen(toolPart("read", "read"), true, true)).toBeUndefined() + expect(partDefaultOpen(textPart("text", "done"), true, true)).toBeUndefined() + }) +}) diff --git a/packages/ui/src/components/message-part/grouping.ts b/packages/ui/src/components/message-part/grouping.ts index 200828d6..cc2a4727 100644 --- a/packages/ui/src/components/message-part/grouping.ts +++ b/packages/ui/src/components/message-part/grouping.ts @@ -1,6 +1,6 @@ -import type { Part as PartType, ToolPart } from "@opencode-ai/sdk/v2" +import type { Part as PartType } from "@opencode-ai/sdk/v2" import { PART_MAPPING } from "./registry" -import { CONTEXT_GROUP_TOOLS, HIDDEN_TOOLS } from "./shared-utils" +import { HIDDEN_TOOLS } from "./shared-utils" import { TOOL_QUESTION } from "../tool-contract" export type PartRef = { @@ -16,7 +16,7 @@ export type PartGroup = } | { key: string - type: "context" + type: "trow" refs: PartRef[] } @@ -32,7 +32,7 @@ function sameGroup(a: PartGroup, b: PartGroup) { if (b.type !== "part") return false return sameRef(a.ref, b.ref) } - if (b.type !== "context") return false + if (b.type !== "trow") return false if (a.refs.length !== b.refs.length) return false return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!)) } @@ -44,6 +44,13 @@ export function sameGroups(a: readonly PartGroup[] | undefined, b: readonly Part return a.every((item, i) => sameGroup(item, b[i]!)) } +export function activeWorkingTrowKey(groups: readonly PartGroup[], working?: boolean) { + if (!working) return + const last = groups[groups.length - 1] + if (last?.type !== "trow") return + return last.key +} + export function groupParts(parts: { messageID: string; part: PartType }[]) { const result: PartGroup[] = [] let start = -1 @@ -51,24 +58,24 @@ export function groupParts(parts: { messageID: string; part: PartType }[]) { const flush = (end: number) => { if (start < 0) return const first = parts[start] - const last = parts[end] - if (!first || !last) { + if (!first) { start = -1 return } + const refs = parts.slice(start, end + 1).map((item) => ({ + messageID: item.messageID, + partID: item.part.id, + })) result.push({ - key: `context:${first.part.id}`, - type: "context", - refs: parts.slice(start, end + 1).map((item) => ({ - messageID: item.messageID, - partID: item.part.id, - })), + key: `trow:${first.part.id}`, + type: "trow", + refs, }) start = -1 } parts.forEach((item, index) => { - if (isContextGroupTool(item.part)) { + if (item.part.type === "tool") { if (start < 0) start = index return } @@ -108,7 +115,3 @@ export function partDefaultOpen(part: PartType, shell = false, edit = false) { if (part.type !== "tool") return return toolDefaultOpen(part.tool, shell, edit) } - -export function isContextGroupTool(part: PartType): part is ToolPart { - return part.type === "tool" && CONTEXT_GROUP_TOOLS.has(part.tool) -} diff --git a/packages/ui/src/components/message-part/tools/question.tsx b/packages/ui/src/components/message-part/tools/question.tsx index 6ccd6686..477ed698 100644 --- a/packages/ui/src/components/message-part/tools/question.tsx +++ b/packages/ui/src/components/message-part/tools/question.tsx @@ -34,7 +34,7 @@ ToolRegistry.register({ return ( props.input.name || i18n.t("ui.tool.skill")) - const running = createMemo(() => props.status === "pending" || props.status === "running") + const skillName = createMemo(() => { + const value = props.input.name + return typeof value === "string" && value ? value : undefined + }) - const titleContent = () => - - const trigger = () => ( -
-
- - {titleContent()} - -
-
+ return ( + ) - - return }, }) diff --git a/packages/ui/src/components/session-turn-trow-block.css b/packages/ui/src/components/session-turn-trow-block.css new file mode 100644 index 00000000..73e4ac4e --- /dev/null +++ b/packages/ui/src/components/session-turn-trow-block.css @@ -0,0 +1,466 @@ +/* + * Slice 11b.1 trow-block. + * + * Native `
` element drives expand / collapse — W1 lock. The + * height + opacity transition under `prefers-reduced-motion: + * no-preference` is owned by the `::details-content` rule below and + * mirrors `docs/design/preview/message-flow.html` L13-23. The header + * comment used to claim session-turn.css owned this rule, but it was + * never actually wired there; AstroHan's fourth W1 retest + * (msg=ac13481a) flagged the missing animation and slice 11b.1 + * landed the rule here. The `interpolate-size: allow-keywords` global + * (utilities.css L2) is what lets `height: 0` ↔ `height: auto` + * interpolate; `transition-behavior: allow-discrete` covers the + * discrete `content-visibility` step. + */ + +[data-component="session-turn-trow-block"] { + display: block; + width: 100%; + --trow-collapse-transition: + height var(--duration-slow) cubic-bezier(0.16, 1, 0.3, 1), + opacity var(--duration-fast) ease-out, + content-visibility var(--duration-slow); +} + +[data-component="session-turn-trow-block"] + [data-component="text-part"] { + margin-top: 0; +} + +[data-component="session-turn-trow-block"] details { + width: 100%; +} + +/* Trow expand / collapse animation — mirror of preview + * message-flow.html L13-23. Gated by `prefers-reduced-motion: no- + * preference` so reduced-motion users see an instant toggle. The + * `:root { interpolate-size: allow-keywords }` declaration in + * utilities.css is what lets `height: auto` participate in the + * transition; without it the rule silently no-ops to a snap. */ +@media (prefers-reduced-motion: no-preference) { + [data-component="session-turn-trow-block"] details::details-content { + height: 0; + opacity: 0; + overflow: hidden; + transition: var(--trow-collapse-transition); + transition-behavior: allow-discrete; + } + [data-component="session-turn-trow-block"] details[open]::details-content { + height: auto; + opacity: 1; + } + + [data-component="session-turn-trow-block"] [data-slot="trow-summary-text"] { + animation: trow-summary-text-in var(--duration-fast) ease-out; + } +} + +@keyframes trow-summary-text-in { + from { + opacity: 0.72; + transform: translateY(1px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Summary row — leading icon (16px) + text + optional chev. The chev is + * absent for groups whose tools produced no expandable body. */ +[data-component="session-turn-trow-block"] [data-slot="trow-summary"] { + display: inline-flex; + align-items: center; + gap: 8px; + max-width: 100%; + cursor: default; + list-style: none; + color: var(--fg-base); + font-family: var(--font-family-sans); + font-size: var(--font-size-body); + font-weight: var(--font-weight-body); + line-height: var(--line-height-body); +} + +[data-component="session-turn-trow-block"] [data-slot="trow-summary"]::-webkit-details-marker, +[data-component="session-turn-trow-block"] [data-slot="trow-summary"]::marker { + display: none; + content: ""; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-summary-icon"] { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 16px; + height: 16px; + color: var(--icon-base); +} + +[data-component="session-turn-trow-block"] [data-slot="trow-summary-icon"] [data-icon] { + width: 16px; + height: 16px; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-summary-text"] { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-family-sans); + font-size: var(--font-size-body); + font-weight: var(--font-weight-body); + color: var(--fg-base); + line-height: 1.4; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-summary-chev"] { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 12px; + height: 12px; + color: var(--icon-weak); + transition: transform var(--duration-base) ease-out; + /* Collapsed = chevron points right ("click to expand sideways into + * detail"). AstroHan's third W1 retest (msg=362e9b72) re-defined the + * affordance: 折叠朝右、展开朝下. The second retest's "open = up" + * rule was wrong — file-tree and Collapsible's own chev all use + * down-when-open, so trow should match. */ + transform: rotate(-90deg); +} + +[data-component="session-turn-trow-block"] [data-slot="trow-summary-chev"] [data-icon] { + width: 12px; + height: 12px; +} + +[data-component="session-turn-trow-block"] details[open] [data-slot="trow-summary-chev"] { + /* Open = chevron points down (natural `chevron-down` orientation). */ + transform: rotate(0deg); +} + +/* Running state — summary shimmer hook. The actual pw-shimmer animation + * is wired by the caller (session-turn.css owns the `.pw-shimmer` class + * application against `[data-running]` after the Phase 2a rewrite); this + * file leaves the data attribute exposed so the shimmer can attach. */ +[data-component="session-turn-trow-block"][data-running] [data-slot="trow-summary-text"] { + /* Hook for shimmer; intentionally no animation rule here. */ +} + +/* Body — the outer wrapper that holds the per-tool sub-rows. AstroHan + * flagged in the second W1 retest that having a frame here AND a frame + * on each per-tool body produced a double-bordered "nested box" look; + * DESIGN.md L417 actually scopes the transparent + 1px --border-weaker + * + radius-sm frame to the per-tool result body, not the trow group + * wrapper. So the wrapper stays borderless and only owns the vertical + * rhythm + caption typography between sub-rows. */ +[data-component="session-turn-trow-block"] [data-slot="trow-body"] { + margin-top: 0; + padding: 0; + background: transparent; + font-family: var(--font-family-mono); + font-size: var(--font-size-mono-small); + font-weight: var(--font-weight-mono-small); + line-height: var(--line-height-mono-small); + color: var(--fg-weak); + display: flex; + flex-direction: column; + gap: 4px; +} + +[data-component="session-turn-trow-block"][data-single] [data-slot="trow-body"] { + margin-top: 0; + flex: 1 1 auto; + min-width: 0; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-single-row"] { + display: flex; + align-items: flex-start; + gap: 8px; + width: 100%; + min-width: 0; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-single-row"] [data-slot="trow-summary-icon"] { + margin-top: 1px; +} + +[data-component="session-turn-trow-block"][data-single] [data-slot="trow-result-body"] [data-slot="basic-tool-tool-title"], +[data-component="session-turn-trow-block"][data-single] [data-slot="trow-result-body"] [data-slot="basic-tool-tool-subtitle"], +[data-component="session-turn-trow-block"][data-single] [data-slot="trow-result-body"] [data-slot="basic-tool-tool-arg"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-body); + font-weight: var(--font-weight-body); + line-height: 1.4; + color: var(--fg-base); +} + +[data-component="session-turn-trow-block"][data-single] [data-slot="trow-result-body"] [data-kind="tool-error-card"] { + --card-line-pad: 0px; + padding-top: 0; + padding-bottom: 0; +} + +[data-component="session-turn-trow-block"][data-single] [data-slot="trow-result-body"] [data-kind="tool-error-card"] + [data-slot="collapsible-trigger"], +[data-component="session-turn-trow-block"][data-single] [data-slot="trow-result-body"] [data-kind="tool-error-card"] + [data-component="tool-trigger"] { + min-height: 18px; + height: auto; +} + +/* Fallback row (default `renderTool` path) — used when the caller does + * not provide a per-tool renderer. */ +[data-component="session-turn-trow-block"] [data-slot="trow-item"] { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-item-name"] { + color: var(--fg-base); +} + +[data-component="session-turn-trow-block"] [data-slot="trow-item-status"] { + color: var(--fg-weak); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-tool"] { + width: 100%; + min-width: 0; +} + +/* Per-tool result body — a pure CSS scope marker, no visual chrome. + * + * The earlier W1 design lock (DESIGN.md L417) called for a transparent + * + 1px --border-weaker + radius-sm frame around each tool's rich body. + * Slice 11b.1 #5 B+ shipped that frame and AstroHan flagged in the + * third W1 retest (msg=362e9b72) that it produced a "box per tool" + * look (and stacked on top of bash-output's own border when expanded). + * The W1 intent is a flat stacked feed where tools 叠在一起 inside the + * trow body — per-tool framing breaks that. + * + * The wrapper stays because it is the scope boundary that lets the + * resets below flatten inner Part chrome's hard-coded sans / base / + * large typography to the W1 caption family without touching the tool + * registry. The visual chrome (border / radius / padding) is what's + * removed. */ +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] { + width: 100%; + min-width: 0; + font-family: var(--font-family-mono); + font-size: var(--font-size-mono-small); + font-weight: var(--font-weight-mono-small); + line-height: var(--line-height-mono-small); + color: var(--fg-weak); + -webkit-user-select: text; + user-select: text; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-component="tool-output"] { + margin-bottom: 0; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-component="bash-output"] [data-slot="bash-pre"] { + padding: 8px 10px; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-component="bash-output"] [data-slot="bash-pre"] code { + font-size: var(--font-size-mono-small); + line-height: var(--line-height-mono-small); + color: var(--fg-weak); +} + +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-component="collapsible"].tool-collapsible { + --tool-content-gap: 4px; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-component="tool-trigger"] { + gap: 8px; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-slot="basic-tool-tool-trigger-content"] { + max-width: 100%; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-slot="collapsible-trigger"], +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-slot="basic-tool-tool-info"] { + text-align: left; + cursor: text; + -webkit-user-select: text; + user-select: text; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-slot="basic-tool-tool-title"], +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-slot="basic-tool-tool-subtitle"], +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-slot="basic-tool-tool-arg"], +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-component="tool-output"], +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-component="bash-output"], +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-component="exa-tool-output"] { + cursor: text; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-slot="collapsible-arrow"] { + cursor: default; + -webkit-user-select: none; + user-select: none; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-summary"] { + -webkit-user-select: text; + user-select: text; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-component="tool-output"] [data-component="markdown"] { + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + color: inherit; + white-space: normal; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-component="tool-output"] [data-component="markdown"] p { + margin: 0; + white-space: pre-wrap; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-component="question-answers"] { + gap: 4px; + padding: 0; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + color: inherit; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-component="question-answers"] [data-slot="question-answer-item"] { + gap: 0; + font-size: inherit; + line-height: inherit; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-component="question-answers"] [data-slot="question-text"], +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-component="question-answers"] [data-slot="answer-text"] { + color: inherit; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-component="exa-tool-output"] { + font-family: var(--font-family-mono); + font-size: var(--font-size-mono-small); + font-weight: var(--font-weight-mono-small); + line-height: var(--line-height-mono-small); + color: var(--fg-weak); +} + +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-slot="exa-tool-link"] { + color: inherit; + text-decoration: none; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-component="task-tool-card"] { + padding: 0; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-component="task-tool-action"] { + display: none; +} + +/* Scoped reset for inner Part chrome hard-coded typography. Inside the + * trow result body the W1 caption family wins; outside (e.g. an + * agent-prose Part), the original sans/base/large header still + * applies — these selectors only fire as descendants of + * `[data-slot="trow-result-body"]`. */ +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-slot="basic-tool-tool-title"], +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-slot="basic-tool-tool-subtitle"], +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-slot="basic-tool-tool-arg"], +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-slot="message-part-title"], +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-slot="message-part-title-text"], +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-slot="message-part-title-filename"] { + font-family: var(--font-family-mono); + font-size: var(--font-size-mono-small); + font-weight: var(--font-weight-mono-small); + line-height: var(--line-height-mono-small); + color: var(--fg-weak); +} + +/* Scoped reset for inner Collapsible.Arrow chev. AstroHan's third W1 + * retest flagged that the chev inside each tool trigger ("执行命令 + * XXXX" row) rendered as a "大 chevron" — Collapsible defaults to the + * 16px icon, but inside a trow caption surface it needs to match the + * trow summary chev's 12px. DESIGN.md L412 caps the chev at 12px. The + * reset only fires inside `[data-slot="trow-result-body"]` so the + * Collapsible default chev size elsewhere (file-tree, settings, etc.) + * is unaffected. */ +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-slot="collapsible-arrow-icon"] [data-component="icon"] { + width: 12px; + height: 12px; +} + +/* Slice 11b.1 P0 #4 retest 5 (AstroHan msg=34822d84): the previous + * `gap: 8 → 6` on `trow-body` was masked by Solid Collapsible's + * hardcoded 30px trigger row + 26px arrow wrapper (collapsible.css + * L19, L72-77). Each per-tool row was effectively `max(30, content)` + * px tall regardless of the caption typography — stride was 30 + 6 = + * 36px between rows. Preview `.mf-tlist .ti` + * (message-flow.html L200-208) uses natural content height (~18px + * from mono-small 12px/150%) with a 12px hover-only chev. Mirror that + * inside the trow scope so the rendered rows match the screenshot + * spec — the resets are scoped to `[data-slot="trow-result-body"]` + * so Collapsible elsewhere (file-tree, settings, every other + * trigger) keeps the 30px default. */ +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-slot="collapsible-trigger"] { + height: auto; + min-height: 0; +} + +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-slot="collapsible-arrow"] { + width: 12px; + height: 12px; +} + +/* Inner Collapsible.Content expand / collapse animation — Solid's + * Collapsible primitive ships with the keyframe rules commented out + * (`collapsible.css` L92-101), so without this scoped override the + * inner per-tool body would snap open. AstroHan's fourth W1 retest + * (msg=ac13481a) flagged the missing animation. The rule below + * mirrors the trow `::details-content` transition and is scoped to + * the trow result body so Collapsible elsewhere (file-tree, settings) + * stays on its default behaviour. The `:root { interpolate-size: + * allow-keywords }` global is what lets `height: auto` participate + * in the transition. */ +@media (prefers-reduced-motion: no-preference) { + [data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-slot="collapsible-content"] { + height: 0; + opacity: 0; + overflow: hidden; + transition: var(--trow-collapse-transition); + transition-behavior: allow-discrete; + } + [data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-slot="collapsible-content"][data-expanded] { + height: auto; + opacity: 1; + overflow: visible; + } +} + +/* WebFetch's renderer (`message-part-tools-basic.tsx` L142) is the + * only tool that ships a right-aligned `[data-component="tool-action"]` + * arrow inside its trigger row — the other tools (read / list / glob / + * grep / websearch) use the plain `trigger={{ title, subtitle }}` + * object form. AstroHan's fourth W1 retest (msg=ac13481a) flagged that + * the right arrow makes WebFetch look like an "old-style" interactive + * card instead of a left-aligned trow caption row. Suppress the action + * icon only inside the trow result body — standalone WebFetch outside + * the trow keeps the affordance. */ +[data-component="session-turn-trow-block"] [data-slot="trow-result-body"] [data-component="tool-action"] { + display: none; +} diff --git a/packages/ui/src/components/session-turn-trow-block.reducer.test.ts b/packages/ui/src/components/session-turn-trow-block.reducer.test.ts new file mode 100644 index 00000000..31c17cb1 --- /dev/null +++ b/packages/ui/src/components/session-turn-trow-block.reducer.test.ts @@ -0,0 +1,235 @@ +import { expect, test, describe } from "bun:test" +import type { ToolPart, ToolState } from "@opencode-ai/sdk/v2" +import { + activeTrowTool, + reduceTrowBlock, + trowPartHasExpandableBody, + trowBlockAnchor, + toolFamilyIcon, +} from "./session-turn-trow-block" + +function tool( + id: string, + name: string, + status: ToolState["status"] = "completed", + options: { input?: Record; output?: string; metadata?: Record } = {}, +): ToolPart { + const input = options.input ?? {} + const metadata = options.metadata ?? {} + let state: ToolState + switch (status) { + case "pending": + state = { status: "pending", input, raw: "" } + break + case "running": + state = { status: "running", input, time: { start: 0 } } + break + case "error": + state = { status: "error", input, error: "boom", time: { start: 0, end: 1 } } + break + case "completed": + default: + state = { status: "completed", input, output: options.output ?? "", title: "", metadata, time: { start: 0, end: 1 } } + } + return { + id, + sessionID: "s", + messageID: "m", + type: "tool", + callID: `call-${id}`, + tool: name, + state, + } +} + +describe("toolFamilyIcon", () => { + test("maps the well-known tool families to their getToolInfo icons", () => { + // Pin the contract for every tool family `getToolInfo` knows. Updating + // `getToolInfo` without updating `toolFamilyIcon` causes the trow-block + // leading icon to drift from the trow body's tool-info icon. + expect(toolFamilyIcon("read")).toBe("glasses") + expect(toolFamilyIcon("list")).toBe("bullet-list") + expect(toolFamilyIcon("glob")).toBe("magnifying-glass-menu") + expect(toolFamilyIcon("grep")).toBe("magnifying-glass-menu") + expect(toolFamilyIcon("webfetch")).toBe("window-cursor") + expect(toolFamilyIcon("websearch")).toBe("window-cursor") + expect(toolFamilyIcon("enter-worktree")).toBe("worktree") + expect(toolFamilyIcon("exit-worktree")).toBe("worktree") + expect(toolFamilyIcon("task")).toBe("agent") + expect(toolFamilyIcon("agent")).toBe("agent") + expect(toolFamilyIcon("bash")).toBe("console") + expect(toolFamilyIcon("edit")).toBe("code-lines") + expect(toolFamilyIcon("write")).toBe("code-lines") + expect(toolFamilyIcon("apply_patch")).toBe("code-lines") + expect(toolFamilyIcon("todowrite")).toBe("checklist") + expect(toolFamilyIcon("question")).toBe("bubble-5") + expect(toolFamilyIcon("skill")).toBe("brain") + }) + + test("unknown tool name falls back to the generic mcp icon", () => { + expect(toolFamilyIcon("definitely-not-a-tool")).toBe("mcp") + expect(toolFamilyIcon("")).toBe("mcp") + }) +}) + +describe("reduceTrowBlock", () => { + test("empty parts list yields a safe default (count 0, mcp icon)", () => { + const summary = reduceTrowBlock([]) + expect(summary).toEqual({ count: 0, running: false, failedCount: 0, leadingIcon: "mcp" }) + }) + + test("count reflects the number of tools in the block", () => { + const summary = reduceTrowBlock([tool("a", "bash"), tool("b", "bash"), tool("c", "edit")]) + expect(summary.count).toBe(3) + }) + + test("running flag is true when any part is still running", () => { + const summary = reduceTrowBlock([ + tool("a", "bash", "completed"), + tool("b", "bash", "running"), + tool("c", "bash", "completed"), + ]) + expect(summary.running).toBe(true) + }) + + test("pending tools count as live state", () => { + const summary = reduceTrowBlock([ + tool("a", "bash", "completed"), + tool("b", "bash", "pending"), + ]) + expect(summary.running).toBe(true) + }) + + test("running flag is false once every part has completed or errored", () => { + const summary = reduceTrowBlock([ + tool("a", "bash", "completed"), + tool("b", "bash", "error"), + tool("c", "bash", "completed"), + ]) + expect(summary.running).toBe(false) + }) + + test("failedCount counts error-status parts", () => { + const summary = reduceTrowBlock([ + tool("a", "bash", "completed"), + tool("b", "bash", "error"), + tool("c", "bash", "error"), + ]) + expect(summary.failedCount).toBe(2) + }) + + test("leadingIcon is resolved from the first tool's family", () => { + expect(reduceTrowBlock([tool("a", "bash"), tool("b", "edit")]).leadingIcon).toBe("console") + expect(reduceTrowBlock([tool("a", "edit"), tool("b", "bash")]).leadingIcon).toBe("code-lines") + expect(reduceTrowBlock([tool("a", "read")]).leadingIcon).toBe("glasses") + }) +}) + +describe("activeTrowTool", () => { + test("returns the last live tool, not the first one", () => { + const parts = [ + tool("a", "read", "completed"), + tool("b", "bash", "running"), + tool("c", "glob", "pending"), + ] + + expect(activeTrowTool(parts)?.id).toBe("c") + }) + + test("keeps the last tool visible while the assistant round is still working", () => { + const parts = [ + tool("a", "read", "completed"), + tool("b", "bash", "completed"), + ] + + expect(activeTrowTool(parts, true)?.id).toBe("b") + expect(activeTrowTool(parts, false)).toBeUndefined() + }) +}) + +describe("trowPartHasExpandableBody", () => { + test("keeps the chevron for completed output and errors", () => { + expect(trowPartHasExpandableBody(tool("output", "bash", "completed", { output: "done" }))).toBe(true) + expect(trowPartHasExpandableBody(tool("error", "bash", "error"))).toBe(true) + }) + + test("keeps the chevron for completed question answers without output", () => { + expect( + trowPartHasExpandableBody( + tool( + "question", + "question", + "completed", + { + input: { questions: [{ question: "Continue?" }] }, + metadata: { answers: [["Yes"]] }, + }, + ), + ), + ).toBe(true) + }) + + test("keeps the chevron for completed dismissed questions without output", () => { + expect( + trowPartHasExpandableBody( + tool("question", "question", "completed", { + metadata: { dismissed: true }, + }), + ), + ).toBe(true) + }) + + test("keeps the chevron for completed edit details without output", () => { + expect( + trowPartHasExpandableBody( + tool("edit", "edit", "completed", { + input: { filePath: "/tmp/example.txt", oldString: "before", newString: "after" }, + }), + ), + ).toBe(true) + }) + + test("keeps the chevron for completed write content without output", () => { + expect( + trowPartHasExpandableBody( + tool("write", "write", "completed", { + input: { filePath: "/tmp/example.txt", content: "hello" }, + }), + ), + ).toBe(true) + }) + + test("keeps the chevron for completed apply_patch files without output", () => { + expect( + trowPartHasExpandableBody( + tool("patch", "apply_patch", "completed", { + metadata: { + files: [ + { + filePath: "/tmp/example.txt", + relativePath: "example.txt", + type: "add", + before: "", + after: "hello", + additions: 1, + deletions: 0, + }, + ], + }, + }), + ), + ).toBe(true) + }) + + test("does not add a chevron for completed tools with no visible body", () => { + expect(trowPartHasExpandableBody(tool("empty", "skill", "completed"))).toBe(false) + }) +}) + +describe("trowBlockAnchor", () => { + test("keeps the same anchor while a single tool grows into a trow group", () => { + const first = tool("a", "bash") + + expect(trowBlockAnchor([first])).toBe(trowBlockAnchor([first, tool("b", "grep")])) + }) +}) diff --git a/packages/ui/src/components/session-turn-trow-block.tsx b/packages/ui/src/components/session-turn-trow-block.tsx new file mode 100644 index 00000000..c09c23e6 --- /dev/null +++ b/packages/ui/src/components/session-turn-trow-block.tsx @@ -0,0 +1,282 @@ +import { For, Show, createMemo, createSignal, type JSX } from "solid-js" +import type { ToolPart } from "@opencode-ai/sdk/v2" +import { patchFiles } from "./apply-patch-file" +import { Icon, type IconName } from "./icon" +import "./session-turn-trow-block.css" + +// ============================================================================ +// Pure reducer + tool-family icon map +// ============================================================================ +// +// Kept at the top of this file as named exports so the reducer can be unit +// tested as a pure function (see session-turn-trow-block.reducer.test.ts). +// The Solid component below is a thin presentational shell that consumes +// these helpers — the testable logic lives here. +// +// The tool-family icon map is intentionally a subset of message-part.tsx's +// `getToolInfo()` switch — the trow-block leading icon only needs the icon +// name (no i18n title / subtitle), so we duplicate the mapping rather than +// pulling the whole i18n-coupled helper. When `getToolInfo()` adds a new +// tool kind, `toolFamilyIcon()` should be updated in lock-step; the unit +// test below pins the contract for the well-known tool families. + +/** + * Resolves a tool's family icon for the trow-block summary row. + * Returns `mcp` (the generic MCP icon) for any unknown tool name — + * matches `getToolInfo()`'s default branch. + */ +export function toolFamilyIcon(tool: string): IconName { + switch (tool) { + case "read": + return "glasses" + case "list": + return "bullet-list" + case "glob": + case "grep": + return "magnifying-glass-menu" + case "webfetch": + case "websearch": + return "window-cursor" + case "enter-worktree": + case "exit-worktree": + return "worktree" + case "task": + case "agent": + return "agent" + case "bash": + return "console" + case "edit": + case "write": + case "apply_patch": + return "code-lines" + case "todowrite": + return "checklist" + case "question": + return "bubble-5" + case "skill": + return "brain" + default: + return "mcp" + } +} + +/** + * Pure-derived state for a trow-block, computed from the immutable list of + * `ToolPart`s that the {@link groupParts} grouping produced. + */ +export type TrowBlockSummary = { + count: number + running: boolean + failedCount: number + leadingIcon: IconName +} + +export function reduceTrowBlock(parts: readonly ToolPart[]): TrowBlockSummary { + if (parts.length === 0) { + return { count: 0, running: false, failedCount: 0, leadingIcon: "mcp" } + } + let running = false + let failedCount = 0 + for (const part of parts) { + if (part.state.status === "running" || part.state.status === "pending") running = true + if (part.state.status === "error") failedCount += 1 + } + return { + count: parts.length, + running, + failedCount, + leadingIcon: toolFamilyIcon(parts[0]!.tool), + } +} + +export function activeTrowTool(parts: readonly ToolPart[], working = false): ToolPart | undefined { + for (let i = parts.length - 1; i >= 0; i--) { + const part = parts[i]! + if (part.state.status === "running" || part.state.status === "pending") return part + } + if (!working || parts.length === 0) return undefined + return parts[parts.length - 1] +} + +export function trowPartHasExpandableBody(part: ToolPart): boolean { + const state = part.state + if (state.status === "error") return true + if (state.status !== "completed") return false + if (state.output) return true + + const input = state.input ?? {} + const metadata = state.metadata ?? {} + + switch (part.tool) { + case "question": + return ( + metadata.dismissed === true || + Array.isArray(input.questions) && + input.questions.length > 0 && + Array.isArray(metadata.answers) && + metadata.answers.length > 0 + ) + case "edit": + return ( + !!metadata.filediff || + (typeof input.filePath === "string" && (input.oldString != null || input.newString != null)) + ) + case "write": + return typeof input.filePath === "string" && input.content != null + case "apply_patch": + return patchFiles(metadata.files).length > 0 + default: + return false + } +} + +export function trowBlockAnchor(parts: readonly ToolPart[]): string { + return `trow:${parts[0]?.id ?? "empty"}` +} + +// ============================================================================ +// Component +// ============================================================================ + +export interface TrowBlockLabels { + /** Fallback running summary used only when no active tool label is available. */ + summaryRunning: (count: number) => string + /** Caller-resolved completed summary, including any failure tail. */ + summaryCompleted: (parts: readonly ToolPart[], failedCount: number) => string +} + +export interface TrowBlockProps { + parts: readonly ToolPart[] + /** Default open state — DESIGN.md L468 locks default-collapsed (false). */ + defaultOpen?: boolean + /** Caller-resolved summary labels. */ + labels: TrowBlockLabels + /** + * Caller-provided per-tool renderer. The shell wires this to the existing + * `` / `` / `` paths from message-part.tsx so + * each individual tool keeps its current rich body. When `renderTool` is + * omitted, the block falls back to a minimal "name + status" row. + */ + renderTool?: (part: ToolPart) => JSX.Element + working?: boolean + describeTool?: (part: ToolPart) => string | undefined +} + +/** + * Slice 11b.1 trow-block — one row that summarises a group of consecutive + * tool calls produced by `groupParts()`, with a native `
` body + * that lists each tool. DESIGN.md L412-L417 / L471, design doc §3.1 / §3.6. + * + * Default-collapsed (DESIGN.md L468). The active row shows the current tool; + * once the row is no longer active, the caller supplies the compact completed + * summary. The summary shimmer (slot exposes a `data-running` attribute the + * CSS can target) signals live state without an extra spinner. + * + * Per-tool rich rendering (file accordion, raw output, copy button on + * hover) is intentionally delegated to a caller-provided slot — the + * SessionTurn shell wires the existing message-part renderers in. This + * keeps slice 11b.1 from reimplementing 11a's tool body logic and keeps + * the component context-free for unit testing. + */ +export function TrowBlock(props: TrowBlockProps) { + const summary = createMemo(() => reduceTrowBlock(props.parts)) + const activeTool = createMemo(() => activeTrowTool(props.parts, props.working)) + const single = createMemo(() => props.parts.length === 1) + const [open, setOpen] = createSignal(props.defaultOpen ?? false) + + const summaryText = createMemo(() => { + const active = activeTool() + const activeLabel = active ? props.describeTool?.(active) : undefined + if (activeLabel) return activeLabel + const s = summary() + if (s.running) return props.labels.summaryRunning(s.count) + return props.labels.summaryCompleted(props.parts, s.failedCount) + }) + const leadingIcon = createMemo(() => { + const active = activeTool() + return active ? toolFamilyIcon(active.tool) : summary().leadingIcon + }) + + // Suppress the chev when no tool in the group has a visible expanded body. + // Some renderers show details from input/metadata instead of state.output. + const hasExpandableBody = createMemo(() => props.parts.some(trowPartHasExpandableBody)) + const renderToolItem = (part: ToolPart) => ( + +
{props.renderTool?.(part)}
+
+ ) + + return ( +
0 || undefined} + data-single={single() || undefined} + > + { + const el = event.currentTarget as HTMLDetailsElement + setOpen(el.open) + }} + > + + + + + + {(text) => {text}} + + + + + +
+ {/* + * `` is the outer reactive primitive so the body stays in + * sync with `props.parts` while the round is streaming. Earlier + * iterations of this file wrapped the fallback path in a + * `` form, + * which captured the parts array at creation time and would + * not pick up new tool calls landing mid-stream. + */} + {renderToolItem} +
+
+ } + > +
+ +
+ {renderToolItem} +
+
+ + + ) +} + +/** + * Fallback per-tool renderer used when the caller does not provide a + * richer `renderTool` slot. Surfaces just the tool's name + status — + * enough for unit / story tests but not the production body (the shell + * wires the rich renderer). + */ +function renderDefaultToolItem(part: ToolPart): JSX.Element { + return ( +
+ {part.tool} + {part.state.status} +
+ ) +} diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index e8f72eba..fe4c44f9 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -89,6 +89,23 @@ export const dict: Record = { "ui.sessionTurn.status.thinkingWithTopic": "Thinking - {{topic}}", "ui.sessionTurn.status.gatheringThoughts": "Gathering thoughts", "ui.sessionTurn.status.consideringNextSteps": "Considering next steps", + "ui.sessionTurn.trow.summary.running": "Running {{count}} tool calls", + "ui.sessionTurn.trow.summary.read.one": "Read {{count}} file", + "ui.sessionTurn.trow.summary.read.other": "Read {{count}} files", + "ui.sessionTurn.trow.summary.search.one": "Searched files {{count}} time", + "ui.sessionTurn.trow.summary.search.other": "Searched files {{count}} times", + "ui.sessionTurn.trow.summary.websearch.one": "Searched web {{count}} time", + "ui.sessionTurn.trow.summary.websearch.other": "Searched web {{count}} times", + "ui.sessionTurn.trow.summary.webfetch.one": "Read {{count}} web page", + "ui.sessionTurn.trow.summary.webfetch.other": "Read {{count}} web pages", + "ui.sessionTurn.trow.summary.edit.one": "Modified {{count}} file", + "ui.sessionTurn.trow.summary.edit.other": "Modified {{count}} files", + "ui.sessionTurn.trow.summary.command.one": "Ran {{count}} command", + "ui.sessionTurn.trow.summary.command.other": "Ran {{count}} commands", + "ui.sessionTurn.trow.summary.tool.one": "Used {{count}} tool", + "ui.sessionTurn.trow.summary.tool.other": "Used {{count}} tools", + "ui.sessionTurn.trow.summary.failed.one": "{{count}} failed", + "ui.sessionTurn.trow.summary.failed.other": "{{count}} failed", "ui.messagePart.diagnostic.error": "Error", "ui.messagePart.title.edit": "Edit", @@ -176,7 +193,7 @@ export const dict: Record = { "ui.tool.questions": "Questions", "ui.tool.agent": "{{type}} Agent", "ui.tool.agent.default": "Agent", - "ui.tool.skill": "Skill", + "ui.tool.skill": "Used Skill", "ui.basicTool.called": "Called `{{tool}}`", "ui.toolErrorCard.failed": "Failed", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index a3b61958..9af9d769 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -92,6 +92,23 @@ export const dict = { "ui.sessionTurn.status.thinkingWithTopic": "思考:{{topic}}", "ui.sessionTurn.status.gatheringThoughts": "正在整理思路", "ui.sessionTurn.status.consideringNextSteps": "正在考虑下一步", + "ui.sessionTurn.trow.summary.running": "正在处理 {{count}} 个工具调用", + "ui.sessionTurn.trow.summary.read.one": "读取 {{count}} 个文件", + "ui.sessionTurn.trow.summary.read.other": "读取 {{count}} 个文件", + "ui.sessionTurn.trow.summary.search.one": "搜索文件 {{count}} 次", + "ui.sessionTurn.trow.summary.search.other": "搜索文件 {{count}} 次", + "ui.sessionTurn.trow.summary.websearch.one": "搜索网页 {{count}} 次", + "ui.sessionTurn.trow.summary.websearch.other": "搜索网页 {{count}} 次", + "ui.sessionTurn.trow.summary.webfetch.one": "读取网页 {{count}} 个", + "ui.sessionTurn.trow.summary.webfetch.other": "读取网页 {{count}} 个", + "ui.sessionTurn.trow.summary.edit.one": "修改 {{count}} 个文件", + "ui.sessionTurn.trow.summary.edit.other": "修改 {{count}} 个文件", + "ui.sessionTurn.trow.summary.command.one": "运行 {{count}} 条命令", + "ui.sessionTurn.trow.summary.command.other": "运行 {{count}} 条命令", + "ui.sessionTurn.trow.summary.tool.one": "使用 {{count}} 个工具", + "ui.sessionTurn.trow.summary.tool.other": "使用 {{count}} 个工具", + "ui.sessionTurn.trow.summary.failed.one": "{{count}} 个失败", + "ui.sessionTurn.trow.summary.failed.other": "{{count}} 个失败", "ui.messagePart.questions.dismissed": "问题已忽略", "ui.messagePart.questions.interrupted": "这个问题已取消,尚未收到回答。如需继续,请在下方重新说明。", @@ -215,7 +232,7 @@ export const dict = { "ui.fileSearch.previousMatch": "上一个", "ui.fileSearch.nextMatch": "下一个", "ui.fileSearch.close": "关闭搜索", - "ui.tool.skill": "技能", + "ui.tool.skill": "使用技能", "ui.basicTool.called": "调用了 `{{tool}}`", "ui.toolErrorCard.failed": "失败", "ui.toolErrorCard.copyError": "复制错误",