Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
acabc10
feat(ui): group tool calls into trow summaries
Astro-Han May 23, 2026
2471b03
fix(ui): stabilize trow tool summaries
Astro-Han May 23, 2026
381110a
test(ui): expand trow snap coverage
Astro-Han May 23, 2026
39c23db
fix(ui): render single tools in trow style
Astro-Han May 23, 2026
51b4752
fix(ui): tighten trow tool output spacing
Astro-Han May 23, 2026
d12fc2c
fix(ui): tighten collapsed trow text spacing
Astro-Han May 23, 2026
7519b8d
fix(ui): align registered trow tools
Astro-Han May 23, 2026
f6721ec
fix(ui): make trow tool text selectable
Astro-Han May 23, 2026
310fe38
fix(ui): label skill tool rows
Astro-Han May 23, 2026
2cced2e
fix(ui): address trow review feedback
Astro-Han May 23, 2026
7697582
fix(app): update trow perf expand probe
Astro-Han May 23, 2026
e7f2e6a
fix(app): make trow perf probe base compatible
Astro-Han May 23, 2026
0a700f5
fix(app): preserve legacy perf trigger fallback
Astro-Han May 23, 2026
66ba767
fix(app): make trow perf toggles idempotent
Astro-Han May 23, 2026
04ad409
fix(ui): tighten trow visual rhythm
Astro-Han May 24, 2026
76ca7b6
fix(ui): align nested trow expand affordance
Astro-Han May 24, 2026
e6c9f93
fix(ui): tighten expanded trow grouping
Astro-Han May 24, 2026
3055b5c
fix(ui): scope active trow status
Astro-Han May 24, 2026
317f88a
fix(ui): align question tool in trow groups
Astro-Han May 24, 2026
518784b
fix(ui): respect tool default-open settings in trows
Astro-Han May 24, 2026
6deef10
feat(ui): summarize trow tool activity
Astro-Han May 24, 2026
fed6d07
Merge remote-tracking branch 'origin/dev' into codex/session-trow-rev…
Astro-Han May 24, 2026
b56efcb
fix(ui): polish trow single-tool rows
Astro-Han May 24, 2026
3710829
fix(ui): align single trow typography
Astro-Han May 24, 2026
3f53427
fix(ui): align trow error rows
Astro-Han May 24, 2026
f9e6874
fix(ui): expose expandable trow details
Astro-Han May 24, 2026
437f7a3
fix(ui): reveal dismissed question details
Astro-Han May 24, 2026
27b112f
refactor(test): split trow snap fixture
Astro-Han May 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions packages/app/e2e/snap/session-trow.snap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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("../../src/testing/trow-snap-fixture.tsx", import.meta.url))
async function captureBlock(name: string, block: Locator): Promise<Shot> {
await expect(block).toBeVisible({ timeout: 30_000 })
return { name, buf: await block.screenshot() }
}

async function waitForThemeBoot(page: import("@playwright/test").Page): Promise<void> {
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))

shots.push(await captureBlock("mixed-collapsed", page.locator('[data-snap="mixed-collapsed"]')))
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,
})
shots.push(await captureBlock("inner-bash-expanded", page.locator('[data-snap="inner-bash-expanded"]')))
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-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-component="bash-output"]')).toBeVisible({
timeout: 30_000,
})
shots.push(await captureBlock("single-command-expanded", singleExpanded))

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).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`)
})
177 changes: 177 additions & 0 deletions packages/app/src/testing/trow-snap-fixture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { render } from "solid-js/web"
import type { ToolPart, ToolState } from "@opencode-ai/sdk/v2"
import { BasicTool } from "@opencode-ai/ui/basic-tool"
import { TrowBlock } from "@opencode-ai/ui/session-turn-trow-block"

const labels = {
summaryRunning: (count: number) => `正在运行 ${count} 条命令`,
summaryCompleted: (count: number) => `已运行 ${count} 条命令`,
summaryWithFailed: (count: number, failed: number) => `已运行 ${count} 条命令,${failed} 条失败`,
}

function tool(
id: string,
description: string,
command: string,
status: ToolState["status"] = "completed",
output?: string,
): ToolPart {
const input = { command, description }
const state: ToolState =
status === "running"
? { status: "running", input, time: { start: 0 } }
: {
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: "bash",
state,
}
}

const completedParts = [
tool("first", "first command", "echo one"),
tool("second", "second command", "echo two"),
tool("third", "third command", "echo three"),
]

const runningParts = [
tool("first-running", "first command", "echo one"),
tool("second-running", "second command", "echo two"),
tool("third-running", "third command", "echo three", "running"),
]

const singleQuietParts = [tool("single-quiet", "quiet command", "sleep 0", "completed", "")]
const singleResultParts = [tool("single-result", "prints one line", "echo one")]
const singleRunningParts = [tool("single-running", "long command", "sleep 30", "running")]

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 (
<div data-slot="trow-result-body" data-timeline-anchor={`tool:${part.id}`}>
<BasicTool
icon="console"
status={part.state.status}
defaultOpen={part.id === openTool}
stateKey={`${prefix}:${part.id}`}
trigger={{ title: "执行命令", subtitle: description }}
>
<BashOutput command={command} output={output} />
</BasicTool>
</div>
)
}
}

function BashOutput(props: { command: string; output?: string }) {
return (
<div data-component="bash-output">
<div data-slot="bash-scroll" data-scrollable>
<pre data-slot="bash-pre">
<code>{`$ ${props.command}${props.output ? `\n\n${props.output.trim()}` : ""}`}</code>
</pre>
</div>
</div>
)
}

function TrowSnapFixture() {
return (
<div
style={{
display: "grid",
gap: "18px",
padding: "24px",
background: "var(--bg-base)",
color: "var(--fg-base)",
width: "760px",
}}
>
<div data-snap="running-current">
<TrowBlock
parts={runningParts}
working
labels={labels}
describeTool={describeTool}
renderTool={renderTool("running")}
/>
</div>
<div data-snap="mixed-collapsed">
<TrowBlock
parts={completedParts}
labels={labels}
describeTool={describeTool}
renderTool={renderTool("collapsed")}
/>
</div>
<div data-snap="mixed-expanded">
<TrowBlock
parts={completedParts}
defaultOpen
labels={labels}
describeTool={describeTool}
renderTool={renderTool("expanded")}
/>
</div>
<div data-snap="inner-bash-expanded">
<TrowBlock
parts={completedParts}
defaultOpen
labels={labels}
describeTool={describeTool}
renderTool={renderTool("inner", "third")}
/>
</div>
<div data-snap="single-command-direct">
<TrowBlock
parts={singleQuietParts}
labels={labels}
describeTool={describeTool}
renderTool={renderTool("single-direct", "single-quiet")}
/>
</div>
<div data-snap="single-command-expanded">
<TrowBlock
parts={singleResultParts}
labels={labels}
describeTool={describeTool}
renderTool={renderTool("single-expanded", "single-result")}
/>
</div>
<div data-snap="single-command-running">
<TrowBlock
parts={singleRunningParts}
working
labels={labels}
describeTool={describeTool}
renderTool={renderTool("single-running", "single-running")}
/>
</div>
</div>
)
}

export function mountTrowSnapFixture(root: HTMLElement) {
root.innerHTML = ""
render(() => <TrowSnapFixture />, root)
}
Original file line number Diff line number Diff line change
@@ -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, renderable, sameGroups, type PartGroup } from "./grouping"
import { contextToolSummaryText } from "./context-tool-helpers"
import { index, latestDefined, same } from "./shared-utils"
import { Part } from "./message-router"

Expand All @@ -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(
Expand All @@ -33,23 +36,43 @@ export function AssistantMessageDisplay(props: {

return (
<Switch>
<Match when={entryType() === "context"}>
<Match when={entryType() === "trow"}>
{(() => {
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)

return (
<Show when={parts().length > 0}>
<ContextToolGroup parts={parts()} />
<TrowBlock
parts={parts()}
labels={{
summaryRunning: (count) => i18n.t("ui.sessionTurn.trow.summary.running", { count }),
summaryCompleted: (count) => i18n.t("ui.sessionTurn.trow.summary.completed", { count }),
summaryWithFailed: (count, failed) =>
i18n.t("ui.sessionTurn.trow.summary.withFailed", { count, failed }),
}}
describeTool={(tool) => contextToolSummaryText(tool, i18n)}
renderTool={(tool) => (
<div data-slot="trow-result-body" data-timeline-anchor={`tool:${tool.id}`}>
<Part
part={tool}
message={props.message}
defaultOpen={singleTool() ? true : undefined}
stateKey={`tool:${tool.id}`}
/>
</div>
)}
/>
</Show>
)
})()}
Expand Down
Loading
Loading