Merge remote-tracking branch 'origin/master' into feat/web-terminal-card

# Conflicts:
#	apps/web/tests/navigation-panes.e2e.ts
#	apps/web/tests/snapshots/navigation-panes/details-open.expected.md
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.md
#	packages/client/ui-conversation/README.zh.md
#	packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
#	packages/client/ui-conversation/src/client/chat/ToolRow.tsx
#	packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx
#	packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
This commit is contained in:
Chinesezjc
2026-07-28 19:03:03 +08:00
262 changed files with 6195 additions and 1048 deletions

View File

@@ -5,7 +5,7 @@
// the code-variant parent row titled by the model-authored description, its
// three always-visible nested sub-rows (bash through the sample registration,
// read through GenericToolCard, the failing read wearing the error state),
// the expanded program body, details-panel resolution of a sub-callId, and
// the expanded program body, inert bash / file-link sub-row gestures, and
// the trajectory/waterfall tabs' sub-call cells and timing lanes.
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
@@ -152,7 +152,7 @@ it('renders the fixture run_code turn: code parent row, nested sub-rows, error s
`)
})
it('expands the code row into the program body and resolves a sub-row through the details panel', async () => {
it('expands the code row into the program body; sub-row clicks do not open details', async () => {
boot()
await openFixtureSession()
@@ -171,26 +171,28 @@ it('expands the code row into the program body and resolves a sub-row through th
}
})
// Sub-row click → details panel resolves the sub-callId with FULL output.
// Tool rows no longer drive the details panel: bash is inert, file paths
// are host-open links (fixture openPath is a no-op success).
const nest = document.querySelector('[data-subcalls]')
if (nest === null) throw new Error('sub-call nest missing')
const bashRow = nest.querySelector('[data-sample="bash-global"]')
if (bashRow === null) throw new Error('bash sample sub-row missing')
const fileLink = nest.querySelector('button')
if (fileLink === null) throw new Error('file-path summary link missing on a read sub-row')
const frame = document.querySelector('[data-details-collapsed]')
if (frame === null) throw new Error('app frame missing')
expect(frame.getAttribute('data-details-collapsed')).toBe('true')
fireEvent.click(bashRow)
const details = await screen.findByText('Input')
const panel = details.closest('[class*="root"]')
if (panel === null) throw new Error('details panel missing')
expect(frame.getAttribute('data-details-collapsed')).toBe('true')
fireEvent.click(fileLink)
expect(frame.getAttribute('data-details-collapsed')).toBe('true')
expect({
title: visibleText(within(panel as HTMLElement).getByText('bash')),
inputEchoesArgs: visibleText(panel).includes('ls notes'),
outputComplete: visibleText(panel).includes('demo.txt new-demo.txt')
|| visibleText(panel).includes('demo.txt\nnew-demo.txt')
|| (panel.textContent ?? '').includes('demo.txt\nnew-demo.txt'),
fileLink: visibleText(fileLink),
detailsCollapsed: frame.getAttribute('data-details-collapsed'),
}).toMatchInlineSnapshot(`
{
"inputEchoesArgs": true,
"outputComplete": true,
"title": "bash",
"detailsCollapsed": "true",
"fileLink": "notes/demo.txt",
}
`)
})

View File

@@ -118,19 +118,14 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1)
}, 60_000)
it.skipIf(MODE === 'record')('a sub-row click opens the details panel on the sub-call material', async () => {
it.skipIf(MODE === 'record')('a bash sub-row click leaves the details panel collapsed', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-details'))
const nest = page.locator('[data-subcalls]').first()
const frame = page.locator('[data-details-collapsed], [class*="frame"]').first()
expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull()
await nest.locator('[data-sample="bash-global"]').first().click()
// The details column opens (width > 0) and shows the sub-call's complete
// output — the full-content log contract, no truncation marker anywhere.
await page.waitForFunction(() => {
const frame = document.querySelector('[class*="frame"]')
if (frame === null) return false
return Number(getComputedStyle(frame).gridTemplateColumns.split(' ').pop()!.replace('px', '')) > 0
}, undefined, { timeout: 10_000 })
await expect.poll(() => page.getByText('CODE_ROUND_OK', { exact: false }).count(), { timeout: 5_000 })
.toBeGreaterThanOrEqual(1)
// Tool rows no longer open details; the column stays width 0.
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
})
it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => {

View File

@@ -1,12 +1,11 @@
// Web e2e scenarios: navigation & panes — the view tabs (Trajectory /
// Waterfall), the details column, and sidebar search, all over ONE rich
// two-turn seeded fixture rendered purely from the log (the seeded-history
// pattern: zero model calls in replay, so every surface here is the client
// fold + host history RPC, not replay binding). The seed is recorded live
// under the standard discipline: turn 1 produces a bash call plus two
// parallel reads in one assistant message (tool-call density for the
// trajectory/waterfall lanes and a details-capable bash row), turn 2 a
// markdown-rich reply (a second turn so the waterfall has two lanes).
// Waterfall) and sidebar search, all over ONE rich two-turn seeded fixture
// rendered purely from the log (the seeded-history pattern: zero model calls
// in replay, so every surface here is the client fold + host history RPC,
// not replay binding). The seed is recorded live under the standard
// discipline: turn 1 produces a bash call plus two parallel reads in one
// assistant message (tool-call density for the trajectory/waterfall lanes),
// turn 2 a markdown-rich reply (a second turn so the waterfall has two lanes).
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -25,7 +24,6 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', impor
const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
const WATERFALL_EXPECTED = join(SNAPSHOT_DIR, 'waterfall.expected.md')
const DETAILS_EXPECTED = join(SNAPSHOT_DIR, 'details-open.expected.md')
const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
const MODE = webSnapshotMode()
const SEED_ID = 'navigation-panes-web-e2e'
@@ -156,31 +154,24 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await compareOrRefreshGolden(WATERFALL_EXPECTED, snapshot, MODE)
}, 60_000)
it.skipIf(MODE === 'record')('opens the details column from the bash row and closes it', async () => {
it.skipIf(MODE === 'record')('bash and file-path rows leave the details column collapsed', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
await page.getByRole('tab', { name: 'Chat' }).click()
// The bash toolview row routes its click to openDetails (read rows are
// expand-in-place instead — the seeded-history scenario owns that fold).
const bashRow = page.locator('[data-sample="bash-global"]').first()
await bashRow.waitFor({ timeout: 15_000 })
// Open/closed is the frame's collapsed attribute: the column collapses to
// width 0 but its subtree deliberately never unmounts (hidden, not
// absent), so element presence/visibility cannot express the state.
const frame = page.locator('[data-details-collapsed], [class*="frame"]').first()
expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull()
await bashRow.click()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).toBeNull()
// The open panel shows the selected call's name, arguments, and durable
// result. The chat row carries its own terminal card, so NAVIGATION_OK
// appears there too — in the prompt line and in the output.
await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
// Golden of the open panel: tool name header, Input args, and the Output
// section's terminal card (prompt line + captured output).
const snapshot = (await captureStableAria(page, '[class*="detailsCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(DETAILS_EXPECTED, snapshot, MODE)
await page.getByRole('button', { name: '关闭详情' }).click()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).not.toBeNull()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
// The card's own controls are outside the summary row and must not open
// details either — the terminal card is read in place.
await page.locator('[data-sample="bash-global"] ~ [data-terminal] [class*="_copyButton_"]').first().click()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
// Read summaries are host-open file links; they also must not open details.
const fileLink = page.locator('[data-variant="read"] button').first()
await fileLink.waitFor({ timeout: 10_000 })
await fileLink.click()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
}, 60_000)
it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {
@@ -260,8 +251,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [
'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', 'details-open.expected.md',
'terminal-card.expected.md',
'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', 'terminal-card.expected.md',
])
})
})

View File

@@ -71,6 +71,35 @@ describe('web e2e: seeded history renders through cold resume', () => {
await recordFixture(scaffold, sessionId, SEED)
}, 200_000)
it.skipIf(MODE === 'record')('serves the projections baseline on the real composition tail page', async () => {
// Composition regression tripwire: the projection registry must be a row
// in the SHIPPED cordis.yml — with it absent every domain unit's optional
// injection stays silent and this block disappears (no titles/todos on
// the web), while fixture-level suites stay green. Assert through the
// real HTTP wire against the booted real host.
const response = await fetch(`${scaffold.baseUrl}/api/session.history`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request', rpcId: 'seeded-projections', method: 'session.history',
payload: { sessionId: SEED_ID },
}),
})
expect(response.ok).toBe(true)
const body = await response.json() as {
result: { ok: boolean; value?: { projections?: { asOfSeq: number; values: Record<string, unknown> } } }
}
expect(body.result.ok).toBe(true)
const projections = body.result.value?.projections
expect(projections).toBeDefined()
expect(projections?.asOfSeq).toBeGreaterThanOrEqual(0)
// The seed carries a session/title event: the title unit must serve it.
expect(typeof projections?.values.title).toBe('string')
// tool-todo is composed but the seed has no todo/write: whole-value null,
// key PRESENT (absence would mean the unit never registered).
expect(projections?.values).toHaveProperty('todos', null)
})
it.skipIf(MODE === 'record')('lists the seeded session cold and renders its history from the log', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-history'))
// The sidebar tree collapses workspace groups by default: click the group
@@ -103,21 +132,19 @@ describe('web e2e: seeded history renders through cold resume', () => {
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
})
it.skipIf(MODE === 'record')('expands and collapses a tool row rebuilt from the cold log', async () => {
it.skipIf(MODE === 'record')('file-path tool rows rebuilt from the cold log stay details-inert', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-toolrow'))
// Interaction over cold-resumed history: read rows are expand-in-place
// rows (rowExpands routes the click to toggleExpand, not openDetails), so
// the gesture under test is the inline fold over log-rebuilt content.
// Runs after the golden capture; still zero model calls.
const row = page.locator('[data-variant] [data-clickable][role="button"]').first()
await row.waitFor({ timeout: 10_000 })
expect(await row.getAttribute('aria-expanded')).toBe('false')
await row.click()
await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
// The expanded body renders the recorded tool result (a.txt's contents).
await expect.poll(() => page.getByText('alpha', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
await row.click()
await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
// Interaction over cold-resumed history: read summaries are host-open
// file links (not expand-in-place / not details). Runs after the golden
// capture; still zero model calls.
const fileLink = page.locator('[data-variant="read"] button').first()
await fileLink.waitFor({ timeout: 10_000 })
const frame = page.locator('[data-details-collapsed], [class*="frame"]').first()
expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull()
await fileLink.click()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
// Path label survives from the recorded args (a.txt).
await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
})
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {

View File

@@ -0,0 +1,375 @@
// Web e2e scenario: the sidebar session list's scrollbar as the browser
// actually lays it out — the observable half of the themed-scrollbar change
// (packages/client/ui-theme/src/styles/scrollbar.css plus the
// `scrollbar-gutter: stable` reservation on WorkspaceBrowser's `.list`). The
// ui-theme/ui-workspace unit specs read the CSS text; only a real engine
// reports the reserved gutter width and the substituted `scrollbar-color`, so
// those two facts live here.
//
// Zero model calls: the list only has to overflow, so the scenario seeds many
// cold sessions from another spec's committed fixture (seeded-history's
// seed.jsonl, reused read-only — this spec needs row count, not new recorded
// content) and never launches a replay row. A stray stream would fail loud
// with NO_ADAPTER.
//
// Headless-chromium caveats, load-bearing for what is asserted below.
//
// Headless chromium defaults to an OVERLAY scrollbar: one drawn on top of the
// content, consuming no layout width unless something reserves space. That is
// the mode in which the reported symptom exists at all, so this environment
// reproduces it rather than merely approximating it — measured against clean
// master, where the list's band is 0 and the bar covers 7px of the relative
// time. (Under a classic space-consuming bar, `clientWidth` already excludes
// the bar and nothing can be covered; a headed run under xvfb behaves that way
// and cannot show the symptom.)
//
// The consequence for assertions: comparing the time element's right edge
// against the list's CLIENT-area right edge holds in both states and proves
// nothing, because with an overlay bar the client edge is the border edge. The
// two signals that do separate the states are the reserved band width and
// `timeCoveredBy`, which measures the overlap against the bar's own width.
//
// Both the `scrollbar-gutter: stable` reservation and the sheet's
// `::-webkit-scrollbar` width are needed for that band, and neither suffices:
// measured on the running app, deleting either one takes the band from 8 to 0
// while the other stays in force. The gutter states that space be reserved; the
// pseudo-element width is what makes chromium treat the bar as occupying layout
// space in the first place.
//
// That conjunction is why `band` and `timeCoveredBy` are both asserted and
// neither replaces the other. Removing only the gutter leaves `timeCoveredBy` at
// 0, because the bar is then 8px wide and the row's right padding is also 8px,
// so it abuts the timestamp without covering it; `band` catches that case.
// Removing both — the actual master state — is what produces the reported
// overlap, and `timeCoveredBy` measures it at 7. Each was mutation-checked with
// the other assertions in its test silenced.
//
// Chromium also takes the `::-webkit-scrollbar*` path, not the standard
// properties: scrollbar.css gates `scrollbar-width`/`scrollbar-color` behind
// `@supports not selector(::-webkit-scrollbar)`, which is false here. The
// resolved standard properties therefore read `auto`, and that reading is
// asserted — a concrete value would mean the gate leaked and silenced the
// pseudo-element rules. What the theme test measures instead is the pair the
// pseudo-element rules read: the indirection variables as they resolve ON the
// list, plus the `::-webkit-scrollbar-thumb:hover` declaration as it stands in
// the cascade. The hover thumb colour is not observable any other way —
// chromium folds the `:hover` rule into `getComputedStyle(el,
// '::-webkit-scrollbar-thumb')`, so that query reports the hover colour at
// rest and cannot pin either state (measured by deleting the hover rule live:
// the same query flipped from the hover colour to the resting one).
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/sidebar-scrollbar', import.meta.url))
/**
* Committed golden of the resolved scrollbar style and geometry, in both
* palettes. The aria goldens the other scenarios commit cannot carry this
* change: it alters no DOM and no accessible name, so their normalized trees are
* byte-identical with and without it. This one records the values instead, which
* makes an unintended shift in thumb colour, band width, or rendering path a
* reviewable diff rather than an assertion someone has to think about.
*/
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
/** Enough rows that the list overflows the 800px-tall viewport's sidebar; the scenario asserts the overflow rather than trusting it. */
const SEED_COUNT = 24
/** Geometry and resolved scrollbar style of one scroll container, measured in the page. */
interface ListMetrics {
/** Resolved `scrollbar-gutter`. */
gutter: string
/** Resolved `::-webkit-scrollbar` width: the pseudo-element path's own sizing. */
width: string
/** Resolved `::-webkit-scrollbar-track` background. */
track: string
/** Resolved `scrollbar-width`, expected `auto` because the gate excludes chromium. */
standardWidth: string
/** Resolved `scrollbar-color`, expected `auto` for the same reason. */
standardColor: string
/** `::-webkit-scrollbar-thumb:hover` background declarations found in the cascade, in sheet order. */
hoverRules: string[]
/** `--dsh-scrollbar-thumb` resolved on the list, serialized as a colour. */
token: string
/** `--dsh-scrollbar-thumb-hover` resolved on the list, serialized the same way. */
hoverToken: string
/** True when the list actually scrolls. */
overflows: boolean
/** Border-box width minus client width: the space the scrollbar takes out of the content area. */
band: number
/** Client-area right edge in viewport coordinates (`clientWidth` excludes the scrollbar band). */
clientRight: number
/** Border-box right edge in viewport coordinates. */
borderRight: number
/** Right edge of the first row's relative-time element, the content the unreserved bar covered. */
timeRight: number
/**
* Pixels of the relative time the scrollbar paints over: how far its right
* edge reaches into the band the bar occupies, `[borderRight - barWidth,
* borderRight]`. This is the reported symptom as a number, and it is the one
* geometric signal that separates the two states in this environment — see
* the file header on why `clientWidth` comparisons cannot.
*/
timeCoveredBy: number
}
/**
* Measure the sidebar list in the page.
* @param page - the page under test.
* @returns the list's resolved scrollbar style and the geometry the fix changes.
*/
function measureList(page: Page): Promise<ListMetrics> {
return page.evaluate(() => {
const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
if (list === null) throw new Error('sidebar session list not in the DOM')
const time = list.querySelector<HTMLElement>('[class*="time"]')
if (time === null) throw new Error('no row relative-time element in the sidebar list')
// Each indirection variable is resolved through its own throwaway probe
// appended to the list: `var()` substitution then happens where the list
// sits in the cascade, which is the claim, and `color` normalizes whatever
// notation the palette sheet chose into one comparable serialization. A
// REUSED probe would report only the last value read — `getComputedStyle`
// returns a live declaration, so reassigning `style.color` retroactively
// changes every earlier read.
const resolve = (name: string): string => {
const probe = document.createElement('span')
probe.style.color = `var(${name})`
list.append(probe)
const value = getComputedStyle(probe).color
probe.remove()
return value
}
// The hover colour is read out of the cascade rather than computed:
// chromium reports the `:hover` background for the resting pseudo-element
// too (see the file header), so no computed query separates the states.
// Cross-origin sheets throw on `cssRules`; none is expected, and skipping
// them cannot mask the rule under test, which ships in the app's own CSS.
const hoverRules = [...document.styleSheets]
.flatMap((sheet) => {
try {
return [...sheet.cssRules]
} catch {
return []
}
})
.filter((rule): rule is CSSStyleRule => rule instanceof CSSStyleRule)
.filter(rule => rule.selectorText === '::-webkit-scrollbar-thumb:hover')
.map(rule => rule.style.getPropertyValue('background'))
const style = getComputedStyle(list)
const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width
const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth)
return {
gutter: style.scrollbarGutter,
width: pseudoWidth,
track: getComputedStyle(list, '::-webkit-scrollbar-track').backgroundColor,
standardWidth: style.scrollbarWidth,
standardColor: style.scrollbarColor,
hoverRules,
token: resolve('--dsh-scrollbar-thumb'),
hoverToken: resolve('--dsh-scrollbar-thumb-hover'),
overflows: list.scrollHeight > list.clientHeight,
band: list.getBoundingClientRect().width - list.clientWidth,
clientRight: list.getBoundingClientRect().left + list.clientWidth,
borderRight: list.getBoundingClientRect().right,
timeRight: time.getBoundingClientRect().right,
// The bar is drawn in the rightmost `barWidth` of the border box, whether
// or not that space was reserved. Its width comes from the sheet where the
// sheet applies, and from the UA's own overlay bar otherwise — 15px is
// what this chromium paints, measured against master where the rule is
// absent. Taking the UA width as the fallback is what keeps the assertion
// honest: assuming 0 there would report no occlusion precisely in the
// state that has it.
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (list.getBoundingClientRect().right - barWidth)),
}
})
}
/**
* Render the golden body: the resolved scrollbar style of the list in each
* palette, plus the geometric relations the fix establishes.
*
* Absolute coordinates are deliberately absent. `timeRight`, `clientRight`, and
* `borderRight` depend on the sidebar's laid-out width and on font metrics, so
* committing them would make the golden fail on a machine whose fonts measure
* differently — a fixture that has to be re-recorded per platform documents the
* platform, not the change. What is recorded instead is the band, the overlap,
* and the two orderings, each of which is a difference or a comparison and so
* survives any layout that keeps the reservation.
* @param light - metrics measured under the light palette.
* @param dark - metrics measured under the dark palette.
* @returns the golden body, without a trailing newline.
*/
function renderGeometry(light: ListMetrics, dark: ListMetrics): string {
const palette = (name: string, metrics: ListMetrics): string[] => [
`## ${name}`,
'',
`- scrollbar-gutter: ${metrics.gutter}`,
`- ::-webkit-scrollbar width: ${metrics.width}`,
`- ::-webkit-scrollbar-track background: ${metrics.track}`,
`- scrollbar-width: ${metrics.standardWidth}`,
`- scrollbar-color: ${metrics.standardColor}`,
`- ::-webkit-scrollbar-thumb:hover declarations: ${metrics.hoverRules.join(' | ')}`,
`- --dsh-scrollbar-thumb: ${metrics.token}`,
`- --dsh-scrollbar-thumb-hover: ${metrics.hoverToken}`,
`- list overflows: ${String(metrics.overflows)}`,
`- reserved band: ${String(metrics.band)}px`,
`- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`,
`- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`,
`- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`,
'',
]
return [
'# Sidebar session list scrollbar',
'',
...palette('Light palette', light),
...palette('Dark palette', dark),
].join('\n').trimEnd()
}
/**
* Reveal the seeded rows: every seeded session is unattached, so they all sit
* in the collapsed Ungrouped bucket. Converges on expanded rather than
* clicking once — startup auto-selection can expand the bucket first, and a
* second click would collapse it again. Hand-rolled polling because
* `expect.poll` is test-scoped and this runs in `beforeAll`.
* @param page - the page under test.
*/
async function expandSeededSessions(page: Page): Promise<void> {
const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
await bucket.waitFor({ timeout: 15_000 })
const rows = page.locator('[role="tree"][aria-label="Sessions"] [role="treeitem"]')
const deadline = Date.now() + 30_000
for (;;) {
if (await bucket.getAttribute('aria-expanded') !== 'true') {
await page.getByText('Ungrouped', { exact: true }).click()
}
if (await bucket.getAttribute('aria-expanded') === 'true' && await rows.count() > SEED_COUNT / 2) return
if (Date.now() > deadline) {
throw new Error(`Ungrouped bucket never revealed more than ${SEED_COUNT / 2} rows`)
}
await page.waitForTimeout(200)
}
}
describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thumb)', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
const fixture = await readFile(SEED, 'utf8')
for (let index = 0; index < SEED_COUNT; index += 1) {
await seedSession(scaffold, fixture, `sidebar-scrollbar-web-e2e-${String(index).padStart(2, '0')}`)
}
browser = await chromium.launch()
// Shorter than the other scenarios' 1000px so SEED_COUNT rows overflow
// the list with room to spare.
page = await browser.newPage({ viewport: { width: 1680, height: 800 } })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await expandSeededSessions(page)
}, 180_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('reserves a scrollbar gutter on the overflowing session list', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-gutter'))
// Vacuity guard: with a non-overflowing list `stable` still reserves, but
// the scenario would no longer be reproducing the reported situation.
await expect.poll(async () => (await measureList(page)).overflows, { timeout: 10_000 }).toBe(true)
const metrics = await measureList(page)
expect(metrics.gutter).toBe('stable')
// The control. `band > 0` is the whole observable effect of the
// reservation: the scrollbar is taken out of the content area instead of
// drawn over it. Removing the declaration makes it exactly 0. The value
// itself is not pinned — it tracks `scrollbar-width` and the platform.
expect(metrics.band).toBeGreaterThan(0)
// The reported symptom, stated directly: no part of the row's relative time
// lies under the bar. Measures 7 on clean master — the `h` of `1h` is the
// covered part. Unlike the client-edge comparison below it does not go
// vacuous under an overlay scrollbar, because it measures against the bar's
// own width rather than against a content edge the overlay bar does not
// move. It is not a replacement for the band assertion above; see the file
// header for which regression each one catches.
expect(metrics.timeCoveredBy).toBe(0)
// Corollaries of the reservation, kept because they pin where the band sits
// rather than only that it exists: the time ends inside the content area,
// and the content area ends before the border box. Each holds in both
// states on its own (see the file header) and is meaningful only alongside
// the two assertions above.
expect(metrics.timeRight).toBeLessThanOrEqual(metrics.clientRight)
expect(metrics.clientRight).toBeLessThan(metrics.borderRight)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('renders the themed thumb through the WebKit path in both palettes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-theme'))
const light = await measureList(page)
// The gate's signature on this engine, and the reason it exists: chromium
// implements `::-webkit-scrollbar`, so the standard properties stay at
// their initial `auto`. A concrete value here would mean the gate leaked,
// which is exactly what makes chromium discard the pseudo-element rules —
// the hover token included.
expect(light.standardWidth).toBe('auto')
expect(light.standardColor).toBe('auto')
// The pseudo-element path is the one in force: the sheet's own 8px sizing
// and transparent track reached a container it never names.
expect(light.width).toBe('8px')
expect(light.track).toBe('rgba(0, 0, 0, 0)')
// The resting and the hover rule each read the rebindable indirection, and
// the two resolve to DIFFERENT colours on this list: the l1 pair arrived
// here intact rather than collapsing to one value or falling back.
expect(light.hoverRules).toEqual(['var(--dsh-scrollbar-thumb-hover)'])
expect(light.token).toMatch(/^rgba?\(/)
expect(light.hoverToken).not.toBe(light.token)
// The dark palette declares different scrollbar tokens; driving the body
// attribute pins the cascade the way lifecycle-chrome does (the Settings
// gesture that sets it is owned there).
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
const dark = await measureList(page)
expect(dark.token).not.toBe(light.token)
expect(dark.hoverToken).not.toBe(dark.token)
expect(dark.hoverToken).not.toBe(light.hoverToken)
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
const restored = await measureList(page)
expect(restored.token).toBe(light.token)
expect(restored.hoverToken).toBe(light.hoverToken)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('matches the committed scrollbar geometry golden in both palettes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-golden'))
const light = await measureList(page)
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
const dark = await measureList(page)
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(light, dark), MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('commits exactly the fixtures it reads', async () => {
// The scenario borrows seeded-history's seed.jsonl rather than committing a
// second copy, so this directory holds the golden alone.
await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
})
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
})
})

View File

@@ -7,7 +7,7 @@
//
// Selector convention: CSS Modules hash as [hash]_[local], so class-substring
// selectors are unreliable — anchor on data-* attributes (data-variant /
// data-clickable / data-sample) or visible text. The one [class*=] use below
// data-sample) or visible text. The one [class*=] use below
// (frame/handle) rides local names that survive hashing as suffixes; prefer
// data-* for anything new.
//
@@ -448,7 +448,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
await screen(page, '07-back-to-chat')
})
it('5 bash differential rendering: tool row click opens the details column', async () => {
it('5 bash differential rendering: tool row click leaves the details column collapsed', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-tool-details'))
const input = page.locator('textarea').first()
await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果')
@@ -462,13 +462,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
await screen(page, '08-bash-round')
expect(await detailsTrack(page)).toBe(0)
await toolRow.click()
// Selection channel: click writes selection + layout.openDetails.
await page.waitForFunction(() => {
const frame = document.querySelector('[class*="frame"]')
if (frame === null) return false
return Number(getComputedStyle(frame).gridTemplateColumns.split(' ').pop()!.replace('px', '')) > 0
}, undefined, { timeout: 10_000 })
await screen(page, '09-details-open')
// Tool rows no longer drive layout.openDetails; the column stays closed.
expect(await detailsTrack(page)).toBe(0)
await screen(page, '09-details-closed')
}, 150_000)
it('6 sidebar drag widens the column and persists across reload', async () => {

View File

@@ -1,8 +0,0 @@
- text: bash
- button "关闭详情"
- text: Input json
- button "复制"
- code: "{ \"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\" }"
- text: Output 已完成 $ echo NAVIGATION_OK
- button "复制"
- text: NAVIGATION_OK

View File

@@ -0,0 +1,33 @@
# Sidebar session list scrollbar
## Light palette
- scrollbar-gutter: stable
- ::-webkit-scrollbar width: 8px
- ::-webkit-scrollbar-track background: rgba(0, 0, 0, 0)
- scrollbar-width: auto
- scrollbar-color: auto
- ::-webkit-scrollbar-thumb:hover declarations: var(--dsh-scrollbar-thumb-hover)
- --dsh-scrollbar-thumb: rgb(229, 229, 229)
- --dsh-scrollbar-thumb-hover: rgb(212, 212, 212)
- list overflows: true
- reserved band: 8px
- relative time covered by the bar: 0px
- relative time ends inside the content area: true
- content area ends before the border box: true
## Dark palette
- scrollbar-gutter: stable
- ::-webkit-scrollbar width: 8px
- ::-webkit-scrollbar-track background: rgba(0, 0, 0, 0)
- scrollbar-width: auto
- scrollbar-color: auto
- ::-webkit-scrollbar-thumb:hover declarations: var(--dsh-scrollbar-thumb-hover)
- --dsh-scrollbar-thumb: rgb(60, 60, 61)
- --dsh-scrollbar-thumb-hover: rgb(84, 85, 87)
- list overflows: true
- reserved band: 8px
- relative time covered by the bar: 0px
- relative time ends inside the content area: true
- content area ends before the border box: true

View File

@@ -4,10 +4,17 @@
// Opens the fixture history session and pins the `card: 'terminal'` render
// intent at both of its conversation render sites, for both chat-row shapes:
// turn 60's `fx-bash` on the render-site fallback row (expand-gated body) and
// turn 66's `bash` on the keyed BashRow registration (resident body), plus the
// details panel's Output section. Turn 66 carries what turn 60's three plain
// lines cannot — SGR runs resolved to --dsw-* tokens, output past the chat
// cap, a nested cwd, and a non-zero exit pill.
// turn 66's `bash` on the keyed BashRow registration (resident body). Turn 66
// carries what turn 60's two clean prompt rows cannot — SGR runs resolved to
// --dsw-* tokens, output past the chat cap, a nested cwd, and a non-zero exit
// pill; turn 60 carries the multi-line command's per-line prompt rows.
//
// The details panel's Output section is NOT covered here: tool rows stopped
// being details-panel click targets, and nothing else in the assembled
// application opens that panel, so the surface cannot be driven end to end.
// Its terminal rendering stays pinned in ui-conversation's
// tests/terminal-card.spec.tsx, which mounts DetailsPanel with a selection
// directly.
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
@@ -292,68 +299,3 @@ it('the chat card expands the collapsed middle in place, without opening the det
}
`)
})
it('the details panel Output section renders the same call at full height', async () => {
boot()
await openFixtureSession()
fireEvent.click(keyedBashRow())
const label = await screen.findByText('Output')
const section = label.closest('section')
if (section === null) throw new Error('Output section missing')
const card = section.querySelector('[data-terminal]')
if (card === null) throw new Error('details panel Output is not a terminal card')
const chatLines = keyedBashRow().parentElement?.querySelectorAll('[class*="_line_"]').length ?? 0
expect({
...readCard(card),
// The panel keeps the primitive's own allowance (16) against the chat
// row's 8, so it shows strictly more of the same output.
panelLines: card.querySelectorAll('[class*="_line_"]').length,
chatLines,
}).toMatchInlineSnapshot(`
{
"chatLines": 8,
"colors": [
"font-weight: 700;",
"color: var(--dsw-alias-state-success-primary);",
"color: var(--dsw-alias-state-error-primary);",
"color: var(--dsw-alias-label-tertiary);",
],
"copy": "复制",
"dotsPerPromptRow": [
1,
],
"expander": {
"expanded": "false",
"label": "展开其余 6 行输出",
"text": "… 其余 6 行",
},
"lines": [
"Running 4 checks",
"✓ typecheck 1.82s",
"✓ lint 0.94s",
"✓ duplication 2.10s",
"✗ unit 8.41s",
"",
"packages/client/ui-primitives/tests/terminal-block.spec.tsx",
" FAIL caps output at the configured line budget",
"CodeBlock.tsx 98.4% 96.2% 100% 41-43",
"highlight.ts 100% 100% 100% -",
"Pill.tsx 100% 100% 100% -",
"StateDot.tsx 100% 100% 100% -",
"markdown/Markdown.tsx 100% 100% 100% -",
"",
"1 of 4 checks failed",
"[exit code: 1]",
],
"panelLines": 16,
"prompt": [
"nested pnpm run check",
],
"runState": "error",
"runStateLabel": "失败",
"status": "退出码 1",
}
`)
})

View File

@@ -32,6 +32,7 @@
"tests/workspace-management.e2e.ts",
"tests/replay-round-trip.e2e.ts",
"tests/seeded-history.e2e.ts",
"tests/sidebar-scrollbar.e2e.ts",
"tests/code-mode-round.e2e.ts",
"tests/cordis-tool-round.e2e.ts"
],