Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
109 changes: 91 additions & 18 deletions packages/app/e2e/perf/perf-probe.spec.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -691,6 +692,87 @@ 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
await summary.click()
await expect(page.locator('[data-slot="trow-body"]').first()).toBeVisible()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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)
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}`)
}
Expand Down Expand Up @@ -831,20 +913,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)
Expand All @@ -867,11 +939,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))
Expand Down
150 changes: 150 additions & 0 deletions packages/app/e2e/snap/session-trow.snap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
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"]')))

const collapsedFollowedByText = page.locator('[data-snap="collapsed-followed-by-text"]')
const collapsedTextGap = await collapsedFollowedByText.evaluate((root) => {
const trow = root.querySelector<HTMLElement>('[data-component="session-turn-trow-block"]')
const text = root.querySelector<HTMLElement>('[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,
})
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<HTMLElement>('[data-component="tool-output"]')
const lastContent =
output?.querySelector<HTMLElement>('[data-component="markdown"] > :last-child') ??
output?.querySelector<HTMLElement>("pre") ??
output
const triggers = root.querySelectorAll<HTMLElement>('[data-slot="collapsible-trigger"]')
const nextTrigger = triggers[1]
if (!output || !lastContent || !nextTrigger) return { outputGap: Number.NaN, contentGap: Number.NaN }
return {
outputGap: nextTrigger.getBoundingClientRect().top - output.getBoundingClientRect().bottom,
contentGap: nextTrigger.getBoundingClientRect().top - lastContent.getBoundingClientRect().bottom,
}
})
expect(toolOutputMetrics.outputGap).toBeGreaterThanOrEqual(0)
expect(toolOutputMetrics.outputGap).toBeLessThanOrEqual(8)
expect(toolOutputMetrics.contentGap).toBeGreaterThanOrEqual(0)
expect(toolOutputMetrics.contentGap).toBeLessThanOrEqual(8)
const toolOutputUserSelect = await toolOutputSpacing.evaluate((root) => {
const summary = root.querySelector<HTMLElement>('[data-slot="trow-summary"]')
const trigger = root.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')
const output = root.querySelector<HTMLElement>('[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 })
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<HTMLElement>(selector), (el) => ({
text: el.textContent?.trim() ?? "",
left: el.getBoundingClientRect().left,
})),
)
const output = root.querySelector<HTMLElement>('[data-component="exa-tool-output"]')
const triggers = Array.from(root.querySelectorAll<HTMLElement>('[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 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`)
})
Loading
Loading