Merge remote-tracking branch 'origin/master' into worktree/open-settings-config-file

# Conflicts:
#	packages/client/connection/README.i18n.yaml
#	packages/client/connection/README.md
#	packages/client/connection/README.zh.md
This commit is contained in:
Yichen Jiang
2026-08-05 11:05:52 +08:00
487 changed files with 10605 additions and 1736 deletions

View File

@@ -8,6 +8,9 @@
"./dist/*": "./dist/*",
"./package.json": "./package.json"
},
"files": [
"dist"
],
"scripts": {
"build": "vite build",
"dev": "vite",

View File

@@ -56,7 +56,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', ()
const row = page.locator('[data-sample="bash"]').first()
const call = row.locator('xpath=..')
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')
await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(1)
await expect.poll(() => call.getByText('Error: tool call aborted', { exact: true }).count()).toBe(1)
await row.click()
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true')
@@ -64,7 +64,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', ()
await call.getByText('OUT', { exact: true }).waitFor()
await call.getByText('Wait until cancellation', { exact: false }).waitFor()
await call.getByText('setInterval(() => {}, 1000)', { exact: false }).waitFor()
await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(2)
await expect.poll(() => call.getByText('Error: tool call aborted', { exact: true }).count()).toBe(2)
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
// The borrowed fixture's UTC date is still the previous day in PDT;

View File

@@ -7,7 +7,7 @@
// content from the keyless FixtureApiClient transport.
//
// Component behavior remains owned by per-package suites (SlotTestRuntime
// benches over src). This smoke additionally pins the resident approval
// benches over src). This smoke additionally pins the resident interaction
// fixture's cross-plugin projection because only the built connection/runtime/
// workspace graph can prove that transport-to-row path end to end.
import { readFileSync } from 'node:fs'
@@ -105,14 +105,15 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
await within(tree).findByText('4 sessions')
// The resident approval fixture proves the assembled workspace plugin
// distinguishes a blocked running session from an ordinarily busy one.
// The resident fixture has both a question and an approval; composer routing
// exposes the question first, and the assembled workspace plugin mirrors that
// actionable wait instead of the underlying running state.
const waitingTitle = await within(tree).findByText('Fixture 历史会话')
const waitingRow = waitingTitle.closest<HTMLElement>('[role="treeitem"]')
if (waitingRow === null) throw new Error('fixture Session title must belong to a tree row')
expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull()
expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull()
within(waitingRow).getByText('Waiting for approval')
within(waitingRow).getByText('Waiting for answer')
// Opening a session reaches chat content through the fixture transport.
fireEvent.click(waitingTitle)

View File

@@ -608,6 +608,9 @@ describe('web e2e: long Chat scroll contract', () => {
await world.page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
await world.page.setViewportSize({ width: 700, height: 900 })
// The narrow breakpoint auto-collapses the sidebar. Re-open it because
// this scenario switches sessions while pinning the narrow Chat scroll owner.
await world.page.getByRole('button', { name: 'Open sidebar', exact: true }).click()
await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
await nextPaint(world.page)
await expectSameFlowTop(world.page, sessionAnchor)

View File

@@ -58,7 +58,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await dialog.getByText('填入各提供方的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 })
// The dormant pi-ai adapter contributes its whole installed catalog; no
// provider is configured yet, so the page is one add button.
const add = dialog.getByRole('button', { name: '+ 添加提供方' })
const add = dialog.getByRole('button', { name: '添加提供方' })
await add.waitFor({ timeout: 10_000 })
// The button enables once the dormant catalog lands in the join.
await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)

View File

@@ -25,6 +25,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
// The waiting golden owns the decision card; the approved golden owns the
// transcript the approval leaves behind — the state the card cannot see.
const REVIEW_EXPECTED = join(SNAPSHOT_DIR, 'review.expected.md')
const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
const APPROVED_EXPECTED = join(SNAPSHOT_DIR, 'approved.expected.md')
const MODE = webSnapshotMode()
@@ -82,9 +83,15 @@ describe('web e2e: plan review takeover round trip', () => {
expect(await page.locator('[data-question-key]').count()).toBe(0)
await expect.poll(() => card.getByText('Plan review').count(), { timeout: 10_000 }).toBeGreaterThan(0)
const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]')
await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => selectedRow.getByText('Plan awaiting review', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
if (MODE !== 'record') {
const snapshot = await captureStableAria(page, '[data-plan-review-key]', scaffold.workspaceCwd)
await compareOrRefreshGolden(REVIEW_EXPECTED, snapshot, MODE)
const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
}
await card.getByRole('button', { name: 'Approve' }).click()
@@ -100,6 +107,7 @@ describe('web e2e: plan review takeover round trip', () => {
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Card gone; regular input restored.
expect(await page.locator('[data-plan-review-key]').count()).toBe(0)
expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0)
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(APPROVED_EXPECTED, snapshot, MODE)
@@ -108,6 +116,8 @@ describe('web e2e: plan review takeover round trip', () => {
}, 200_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'review.expected.md', 'approved.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl', 'review.expected.md', 'sidebar.expected.md', 'approved.expected.md',
])
})
})

View File

@@ -23,6 +23,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md')
// Final golden: the answered transcript — the question resolved into its tool
// round trip and the final reply, the state the composer goldens cannot see.
@@ -76,11 +77,17 @@ describe('web e2e: resident question composer round trip', () => {
await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
await expect.poll(() => composer.getByText('Which color do you prefer?').count(), { timeout: 10_000 }).toBeGreaterThan(0)
const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]')
await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => selectedRow.getByText('Waiting for answer', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
if (MODE !== 'record') {
// This golden owns the stable question surface; the answered-state
// golden below owns the resulting transcript.
const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
}
// Squeezed card: the option rows are the capped card's scroll content, so
@@ -155,6 +162,7 @@ describe('web e2e: resident question composer round trip', () => {
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Composer gone; regular input restored.
expect(await page.locator('[data-question-key]').count()).toBe(0)
expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0)
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
// Golden of the answered transcript: the ask_user_question round trip
// rendered as history (question tool row + DONE), composer takeover gone.
@@ -168,6 +176,7 @@ describe('web e2e: resident question composer round trip', () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl',
'ui.expected.md',
'sidebar.expected.md',
'composed.expected.md',
'answered.expected.md',
])

View File

@@ -2,8 +2,8 @@
// .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
// Boots the REAL web composition — the shipped base plus web overlay through
// the vendored Loader (the same include boot AppCLIEntry drives), patched the
// snapshot way — so a real chromium exercises the real HTTP/SSE wire, the
// api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
// snapshot way — so a real chromium exercises the real HTTP uplink/WebSocket
// downlink, api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
// replay (default, keyless: normally disables the llm-deepseek row and
// inserts dsh-llm-replay in providers mode), record (real adapter + key,
// harvests fixtures from live session memory), refresh (keyless replay that

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

@@ -14,10 +14,10 @@
- img
- img
- text: Context injection
- 'button "Failed Bash Error: command aborted" [expanded]':
- 'button "Failed Bash Error: tool call aborted" [expanded]':
- img
- text: "Failed Bash Error: command aborted"
- text: "IN { \"command\": \"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: command aborted"
- text: "Failed Bash Error: tool call aborted"
- text: "IN { \"command\": \"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: tool call aborted"
- button "Inspect"
- 'button "Failed Bash Error: tool call aborted before dispatch"':
- img

View File

@@ -28,6 +28,7 @@
- textbox "Describe what you want to build"
- button "Commands":
- img
- tooltip "Commands"
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash

View File

@@ -18,4 +18,6 @@
- text: minimax-cn
- button "编辑"
- button "删除"
- button "+ 添加提供方"
- button "添加提供方":
- img
- text: 添加提供方

View File

@@ -0,0 +1 @@
- 'treeitem "Plan awaiting review Plan a small change: add now" [selected]'

View File

@@ -0,0 +1 @@
- treeitem "Waiting for answer Use the ask_user_question tool to now" [selected]

View File

@@ -30,6 +30,7 @@
- textbox "Edit queued message": Edited queue item
- button "Save queued message":
- img
- tooltip "Save queued message"
- button "Cancel editing":
- img
- textbox "Message the agent"

View File

@@ -23,7 +23,7 @@
- paragraph: partial
- status: Deep diving...
- region "To-dos":
- button "To-dos 1/2 tasks · 1 in progress"
- button "To-dos 1 completed · 1 in progress"
- img
- text: Ongoing Goal Keep the composer context panels aligned
- button "Pause goal":

View File

@@ -21,6 +21,7 @@
- text: Edited queue item
- button "Edit queued message":
- img
- tooltip "Edit queued message"
- button "Remove queued message":
- img
- button "Steer queued message":

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(84, 85, 87)
- --dsh-scrollbar-thumb-hover, pointer over the list: rgb(101, 103, 107)
- list overflows: true
- reserved band: 8px
- scrollbar inset from the sidebar edge: 2px