Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	apps/web/tests/snapshots/queue-actions/preserved.expected.md
#	packages/client/runtime/src/client/sessions/session.ts
This commit is contained in:
_Kerman
2026-08-04 16:56:44 +08:00
66 changed files with 917 additions and 84 deletions

View File

@@ -44,6 +44,12 @@
// overlap, and `timeCoveredBy` measures it at 7. Each was mutation-checked with
// the other assertions in its test silenced.
//
// The thumb is a pointer affordance (ui-sidebar rebinds the indirection pair
// to `transparent` while the pointer is outside the column), so every
// measurement below states which pointer position it was taken at: the
// scenario parks the pointer over the sidebar before asserting a colour, and
// the quiet state and its linger get their own test.
//
// 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
@@ -225,6 +231,34 @@ function measureRowInset(page: Page): Promise<Pick<ListMetrics, 'overflows' | 'r
})
}
/** One palette's readings, taken at both pointer positions. */
interface PaletteMetrics {
/** Everything measured with the pointer over the list, which is when a thumb exists. */
hovered: ListMetrics
/** `--dsh-scrollbar-thumb` with the pointer parked outside the column. */
quietThumb: string
}
/**
* Read one palette at both pointer positions, ending with the pointer back
* over the list so a caller measuring further leaves it revealed.
* @param page - the page under test.
* @returns the palette's quiet thumb and its hovered metrics.
*/
async function measurePalette(page: Page): Promise<PaletteMetrics> {
await pointAt(page, 'away')
// Poll rather than sleep the linger out: the wait is the column's, and a
// fixed sleep would either race it or pad every palette.
await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(NO_THUMB)
const quietThumb = await resolveThumb(page)
await pointAt(page, 'list')
// Poll the reveal too: the reading below is a colour, and taking it in the
// same tick as the pointer move would race React's flush and land a
// transparent thumb in the golden.
await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).not.toBe(NO_THUMB)
return { hovered: await measureList(page), quietThumb }
}
/**
* Render the golden body: the resolved scrollbar style of the list in each
* palette, plus the geometric relations the fix establishes.
@@ -240,18 +274,19 @@ function measureRowInset(page: Page): Promise<Pick<ListMetrics, 'overflows' | 'r
* @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[] => [
function renderGeometry(light: PaletteMetrics, dark: PaletteMetrics): string {
const palette = (name: string, { hovered: metrics, quietThumb }: PaletteMetrics): string[] => [
`## ${name}`,
'',
`- --dsh-scrollbar-thumb, pointer outside the sidebar: ${quietThumb}`,
`- 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}`,
`- --dsh-scrollbar-thumb, pointer over the list: ${metrics.token}`,
`- --dsh-scrollbar-thumb-hover, pointer over the list: ${metrics.hoverToken}`,
`- list overflows: ${String(metrics.overflows)}`,
`- reserved band: ${String(metrics.band)}px`,
`- scrollbar inset from the sidebar edge: ${String(metrics.scrollbarEdgeOffset)}px`,
@@ -269,6 +304,48 @@ function renderGeometry(light: ListMetrics, dark: ListMetrics): string {
].join('\n').trimEnd()
}
/**
* Resolve `--dsh-scrollbar-thumb` as the list sees it, without the rest of the
* geometry. Own probe element for the same reason {@link measureList} uses
* one: `getComputedStyle` returns a live declaration.
* @param page - the page under test.
* @returns the resolved thumb colour, serialized as `rgb`/`rgba`.
*/
function resolveThumb(page: Page): Promise<string> {
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 probe = document.createElement('span')
probe.style.color = 'var(--dsh-scrollbar-thumb)'
list.append(probe)
const value = getComputedStyle(probe).color
probe.remove()
return value
})
}
/** Fully transparent, which is how the quiet column spells "no thumb". */
const NO_THUMB = 'rgba(0, 0, 0, 0)'
/**
* Park the pointer over the session list or outside the sidebar entirely. The
* column reveals its scrollbars from real pointer movement, so a scenario that
* never moves the mouse measures the quiet state whatever it intended to.
* @param page - the page under test.
* @param where - `list` to point at the session list, `away` for the far side
* of the viewport (the conversation column).
*/
async function pointAt(page: Page, where: 'list' | 'away'): Promise<void> {
const box = await page.locator('[role="tree"][aria-label="Sessions"]').boundingBox()
if (box === null) throw new Error('sidebar session list has no layout box')
const viewport = page.viewportSize()
if (viewport === null) throw new Error('page has no viewport')
const target = where === 'list'
? { x: box.x + box.width / 2, y: box.y + box.height / 2 }
: { x: viewport.width - 5, y: box.y + box.height / 2 }
await page.mouse.move(target.x, target.y)
}
/**
* Reveal the seeded rows: every seeded session is unattached, so they all sit
* in the collapsed Ungrouped bucket. Converges on expanded rather than
@@ -314,6 +391,10 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await expandSeededSessions(page)
// Every assertion about a thumb colour needs a drawn thumb, and the column
// only draws one under the pointer; the quiet state is asserted where it is
// the subject rather than left as an ambient condition of the whole file.
await pointAt(page, 'list')
}, 180_000)
afterAll(async () => {
@@ -353,6 +434,34 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('draws no thumb until the pointer is over the column, and lingers on the way out', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-pointer'))
const revealed = await resolveThumb(page)
expect(revealed).not.toBe(NO_THUMB)
await pointAt(page, 'away')
// The linger, measured as a state rather than a duration: the thumb is
// still drawn on the leave itself, and gone once the window has passed. A
// tighter timing assertion would pin the wall clock of a CI machine.
expect(await resolveThumb(page)).toBe(revealed)
await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(NO_THUMB)
// The reservation is unconditional, so nothing moved while the bar was
// hidden — this is what buys `transparent` over hiding the bar itself.
const quiet = await measureList(page)
expect(quiet.gutter).toBe('stable')
expect(quiet.band).toBeGreaterThan(0)
expect(quiet.timeCoveredBy).toBe(0)
// Scrolling without a pointer — what a keyboard or a touch drag does —
// leaves the column quiet. This is the change's one deliberate loss, and
// it is pinned here rather than only described, so making a scroll
// re-reveal the bar has to be a decision rather than a side effect.
await page.locator('[role="tree"][aria-label="Sessions"]').evaluate((el) => { el.scrollTop += 200 })
await page.waitForTimeout(500)
expect(await resolveThumb(page)).toBe(NO_THUMB)
await pointAt(page, 'list')
await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(revealed)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps the row background inset when overflow disappears', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-stable-inset'))
expect(await measureRowInset(page)).toEqual({ overflows: true, rowEdgeInset: 12 })
@@ -404,9 +513,9 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
it('matches the committed scrollbar geometry golden in both palettes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-golden'))
const light = await measureList(page)
const light = await measurePalette(page)
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
const dark = await measureList(page)
const dark = await measurePalette(page)
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(light, dark), MODE)
expect(tripwire.pageErrors).toEqual([])

View File

@@ -36,7 +36,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -51,7 +51,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -31,7 +31,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -23,7 +23,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -20,7 +20,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -25,7 +25,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -19,7 +19,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -20,7 +20,7 @@
- img
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn 7/25 {{clock}}
- text: Available only on the last message of a completed turn 7/25 {{clock}}Ran for {{duration}}
- button "Read a.txt":
- img
- img
@@ -46,7 +46,7 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}}
- text: 7/25 {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -36,7 +36,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -31,7 +31,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -20,7 +20,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- button "2 queued messages" [expanded]
- list:
- listitem:

View File

@@ -33,7 +33,7 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}}
- text: 7/25 {{clock}}Ran for {{duration}}
- button "Context compacted View compaction summary":
- img
- text: Context compacted View compaction summary

View File

@@ -33,7 +33,7 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}}
- text: 7/25 {{clock}}Ran for {{duration}}
- button "Context compacted View compaction summary":
- img
- text: Context compacted View compaction summary

View File

@@ -2,14 +2,15 @@
## Light palette
- --dsh-scrollbar-thumb, pointer outside the sidebar: rgba(0, 0, 0, 0)
- 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)
- --dsh-scrollbar-thumb, pointer over the list: rgb(229, 229, 229)
- --dsh-scrollbar-thumb-hover, pointer over the list: rgb(212, 212, 212)
- list overflows: true
- reserved band: 8px
- scrollbar inset from the sidebar edge: 2px
@@ -20,14 +21,15 @@
## Dark palette
- --dsh-scrollbar-thumb, pointer outside the sidebar: rgba(0, 0, 0, 0)
- 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)
- --dsh-scrollbar-thumb, pointer over the list: rgb(60, 60, 61)
- --dsh-scrollbar-thumb-hover, pointer over the list: rgb(84, 85, 87)
- list overflows: true
- reserved band: 8px
- scrollbar inset from the sidebar edge: 2px

View File

@@ -37,7 +37,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -28,7 +28,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Now give the same explanation to a human reader. {{clock}}
- text: {{clock}}Ran for {{duration}} Now give the same explanation to a human reader. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:
@@ -43,7 +43,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -23,7 +23,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img