From c794ffcd1c86b7aac710c73db3165e10398deb96 Mon Sep 17 00:00:00 2001 From: mattheliu Date: Mon, 31 Aug 2026 01:05:19 +0800 Subject: [PATCH 01/38] fix(dev): make alpha2 web validation portable --- ...2026-07-28-web-gui-feedback-loop.i18n.yaml | 4 +-- .../2026-07-28-web-gui-feedback-loop.md | 2 +- .../2026-07-28-web-gui-feedback-loop.zh.md | 2 +- THIRD_PARTY_NOTICES.md | 1 + apps/web/tests/hmr-live.e2e.ts | 31 ++++++++++++++----- apps/web/tests/remote-welcome.e2e.ts | 5 ++- apps/web/tests/scaffold.ts | 17 +++++++--- package.json | 1 + packages/client/tsdown.client.ts | 6 ++-- pnpm-lock.yaml | 22 +++++++++++-- scripts/dev-web.ts | 5 +++ scripts/verify-md-wrap.ts | 6 ++-- 12 files changed, 79 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.i18n.yaml index f2d57581bb00..c1e0d8960fdf 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md -2026-07-28-web-gui-feedback-loop.md: dad85c37dac19c04c15ecb34c210c5376299d50f -2026-07-28-web-gui-feedback-loop.zh.md: 196912b8ba1b2f7737c73967d22c9e176566bd88 +2026-07-28-web-gui-feedback-loop.md: e21969e46beff5cf93807155332ec2e49d357e00 +2026-07-28-web-gui-feedback-loop.zh.md: e17264e7babb500683b9798b5890b89039eb2d37 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md index dad85c37dac1..e21969e46bef 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md @@ -22,7 +22,7 @@ No server restart or replacement is required merely because static artifacts cha ## Verification -The keyless fresh-round-trip browser scenario boots the shipped Web composition, drives a real replayed session, snapshots the URL-bearing system-prompt prefix, and invokes the assembled bash tool to prove `$DSH_WEB_URL` matches the actual bound runtime. The real CLI smoke launches `dsh web` and captures the provider request, pinning the complete two-command development contract. The `dev:web` watcher test rebuilds an isolated client bundle after a source change; the browser HMR scenario launches `dsh web`, changes an initial roster bundle, and observes the new DOM under the same page identity. A real Vite subprocess test requires serve mode to exit naturally with the full-host correction and instruments `Server.listen()` to prove it was never called. The real-Loader webserver test rewrites a static asset after the process binds and proves the same port returns the new bytes. These assertions inspect prompt state, process exit, shell output, DOM identity, and HTTP bytes rather than an agent's success statement. +The keyless fresh-round-trip browser scenario boots the shipped Web composition, drives a real replayed session, snapshots the URL-bearing system-prompt prefix, and invokes the assembled bash tool to prove `$DSH_WEB_URL` matches the actual bound runtime. The real CLI smoke launches `dsh web` and captures the provider request, pinning the complete two-command development contract. The `dev:web` watcher test rebuilds an isolated client bundle after a source change; its programmatic tsdown call pins the `unrun` config loader so every supported Node line avoids the pre-24.11.1 native no-cache hook defect while recursively transforming workspace child configs. The shared client preset resolves repository-owned manifests from the workspace root rather than its module URL, because a compiling config loader evaluates that preset from a cache directory. The browser HMR scenario launches `dsh web`, changes an initial roster bundle, and observes the new DOM under the same page identity; after stopping the watcher it restores the complete dynamic-bundle and shell-dist artifact set, then revalidates the recorded artifact digest so the mutation cannot contaminate later browser files or a second replay. A real Vite subprocess test requires serve mode to exit naturally with the full-host correction and instruments `Server.listen()` to prove it was never called. The real-Loader webserver test rewrites a static asset after the process binds and proves the same port returns the new bytes. These assertions inspect prompt state, process exit, shell output, DOM identity, artifact digest, and HTTP bytes rather than an agent's success statement. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md index 196912b8ba1b..e17264e7babb 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md @@ -22,7 +22,7 @@ Web agent(智能体)既无法识别承载当前会话的 GUI,也不知道 ## 验证 -无密钥的 fresh-round-trip 浏览器场景会启动已交付的 Web 组合,驱动真实的回放会话,对包含 URL 的系统提示词前缀生成快照,并调用组装后的 bash 工具,证明 `$DSH_WEB_URL` 与实际绑定的运行时一致。真实 CLI 冒烟测试会启动 `dsh web` 并捕获模型提供方请求,从而固定完整的双命令开发约定。`dev:web` watcher 测试会在源码发生变化后重新构建隔离的客户端 bundle;浏览器 HMR 场景会启动 `dsh web`,修改初始 roster 中的 bundle,并在页面 identity 不变的情况下观察新 DOM。真实 Vite 子进程测试要求服务模式在给出改用完整宿主的纠正信息后自然退出,并通过插桩 `Server.listen()` 证明它从未被调用。真实 loader Web 服务器测试会在进程完成绑定后改写静态资源,并证明同一端口返回新的字节。这些断言检查提示词状态、进程退出状态、shell 输出、DOM identity 和 HTTP 字节,而不是 agent 的成功声明。 +无密钥的 fresh-round-trip 浏览器场景会启动已交付的 Web 组合,驱动真实的回放会话,对包含 URL 的系统提示词前缀生成快照,并调用组装后的 bash 工具,证明 `$DSH_WEB_URL` 与实际绑定的运行时一致。真实 CLI 冒烟测试会启动 `dsh web` 并捕获模型提供方请求,从而固定完整的双命令开发约定。`dev:web` watcher 测试会在源码发生变化后重新构建隔离的客户端 bundle;其程序化 tsdown 调用固定使用 `unrun` config loader,使每条受支持的 Node 版本线都能避开 24.11.1 之前的原生 no-cache hook 缺陷,同时递归转换 Workspace 子配置。共享客户端 preset 从 Workspace 根目录而非自身 module URL 解析仓库持有的 manifest,因为编译型 config loader 会先把该 preset 计算到缓存目录,再执行它。浏览器 HMR 场景会启动 `dsh web`,修改初始 roster 中的 bundle,并在页面 identity 不变的情况下观察新 DOM;停止 watcher 后,它会恢复完整的动态 bundle 与 shell dist 产物集,再次校验已记录的产物 digest,使该变更无法污染后续浏览器文件或第二次 replay。真实 Vite 子进程测试要求服务模式在给出改用完整宿主的纠正信息后自然退出,并通过插桩 `Server.listen()` 证明它从未被调用。真实 loader Web 服务器测试会在进程完成绑定后改写静态资源,并证明同一端口返回新的字节。这些断言检查提示词状态、进程退出状态、shell 输出、DOM identity、产物 digest 和 HTTP 字节,而不是 agent 的成功声明。 ## 考虑过的替代方案 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 659018f35d47..b3815ea1caa7 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -185,6 +185,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`spdx-expression-parse`](https://github.com/jslicense/spdx-expression-parse.js) | MIT | | [`tsdown`](https://github.com/rolldown/tsdown) | MIT | | [`typescript-language-server`](https://github.com/typescript-language-server/typescript-language-server) | Apache-2.0 | +| [`unrun`](https://github.com/Gugustinette/unrun) | MIT | | [`vite`](https://github.com/vitejs/vite) | MIT | | [`vite-tsconfig-paths`](https://github.com/aleclarson/vite-tsconfig-paths) | MIT | | [`vitepress`](https://github.com/vuejs/vitepress) | MIT | diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index 97fe5d5833ef..63a7d3d19b9a 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -1,9 +1,9 @@ /** Published dsh web + pnpm dev:web → browser HMR, with no page reload. */ -import { existsSync, globSync } from 'node:fs' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { existsSync, globSync, statSync } from 'node:fs' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { chromium } from 'playwright' import { expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -74,9 +74,15 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a const binPath = join(REPO_ROOT, 'apps/cli/lib/bin.js') if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first') const clientBuildEnvironment = readClientBuildRecord(REPO_ROOT).environment - const clientBundlePaths = globSync('packages/*/*/lib/client.js{,.map}', { cwd: REPO_ROOT }) - .map(path => join(REPO_ROOT, path)) - const originalClientBundles = await Promise.all(clientBundlePaths.map(async path => [path, await readFile(path)] as const)) + const clientArtifactPaths = globSync([ + 'apps/web/dist/**/*', + 'packages/*/*/lib/client.js{,.map}', + ], { cwd: REPO_ROOT }) + .filter(path => statSync(join(REPO_ROOT, path)).isFile()) + const originalClientArtifacts = await Promise.all(clientArtifactPaths.map(async path => [ + path, + await readFile(join(REPO_ROOT, path)), + ] as const)) const originalSource = await readFile(sourcePath) const oldText = 'Into the Unknown' const sourceNeedle = "'hero.headline': 'Into the Unknown'" @@ -131,13 +137,22 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a } finally { await writeFile(sourcePath, originalSource).catch((error: unknown) => failures.push(error)) if (watcher !== undefined) await stopTree(watcher).catch((error: unknown) => failures.push(error)) - await Promise.all(originalClientBundles.map(async ([path, content]) => { - await writeFile(path, content).catch((error: unknown) => failures.push(error)) + await rm(join(REPO_ROOT, 'apps/web/dist'), { recursive: true, force: true }) + .catch((error: unknown) => failures.push(error)) + await Promise.all(originalClientArtifacts.map(async ([path, content]) => { + const destination = join(REPO_ROOT, path) + await mkdir(dirname(destination), { recursive: true }).catch((error: unknown) => failures.push(error)) + await writeFile(destination, content).catch((error: unknown) => failures.push(error)) })) if (host !== undefined) await stopTree(host).catch((error: unknown) => failures.push(error)) await browser?.close().catch((error: unknown) => failures.push(error)) await subprocessFiber?.dispose().catch((error: unknown) => failures.push(error)) await rm(world, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + try { + readClientBuildRecord(REPO_ROOT, clientBuildEnvironment) + } catch (error) { + failures.push(error) + } } if (failures.length > 0) throw new AggregateError(failures, 'HMR browser test or cleanup failed') }, 120_000) diff --git a/apps/web/tests/remote-welcome.e2e.ts b/apps/web/tests/remote-welcome.e2e.ts index b7b013dc2dd2..ad318e808a0b 100644 --- a/apps/web/tests/remote-welcome.e2e.ts +++ b/apps/web/tests/remote-welcome.e2e.ts @@ -23,7 +23,10 @@ describe.skipIf(MODE === 'record')('web e2e: remote welcome notice', () => { remoteAuthority: 'remote.localhost', welcomeNoticePending: true, }) - browser = await chromium.launch() + // Chromium's wildcard .localhost behavior differs by host resolver. Map + // the test authority explicitly while retaining it in the page URL and + // HTTP Host header, which are the product semantics under test. + browser = await chromium.launch({ args: ['--host-resolver-rules=MAP remote.localhost 127.0.0.1'] }) page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE, diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 1991ce2e0b82..be37f543c23e 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -337,9 +337,9 @@ export interface LaunchOptions { /** Uploading mode for the mounted telemetry row. Defaults to `FULL`. */ telemetryMode?: 'FULL' | 'FEEDBACK_ONLY' /** - * Browse through a trusted non-loopback hostname that the browser resolves - * to loopback (for example `*.localhost`). The test server stays bound to - * 127.0.0.1; a non-resolving authority fails before Host trust is exercised. + * Browse through a trusted non-loopback hostname that the browser maps to + * loopback (for example `*.localhost`). The test server and scaffold's token + * exchange stay on 127.0.0.1 while preserving this HTTP Host authority. */ remoteAuthority?: string /** Reuse an existing harness home so a second Host can verify user settings across origins. */ @@ -703,7 +703,16 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise= 0.8'} + unrun@0.3.1: + resolution: {integrity: sha512-onIck/oNnCaytwths1ZVp1LK2Gq2hPoyFhiHebObuUXqR3S0uHuLLaBK8K6mRRgV7Ptip8AnNvaUsgzwWwBZuA==} + engines: {node: ^22.13.0 || >=24.0.0} + hasBin: true + peerDependencies: + synckit: ^0.11.11 + peerDependenciesMeta: + synckit: + optional: true + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -21956,7 +21969,7 @@ snapshots: optionalDependencies: typescript: 6.0.3 - tsdown@0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3): + tsdown@0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3)(unrun@0.3.1): dependencies: ansis: 4.3.1 cac: 7.0.0 @@ -21977,6 +21990,7 @@ snapshots: publint: 0.3.21 tsx: 4.22.4 typescript: 6.0.3 + unrun: 0.3.1 transitivePeerDependencies: - '@ts-macro/tsc' - '@typescript/native-preview' @@ -22065,6 +22079,10 @@ snapshots: unpipe@1.0.0: {} + unrun@0.3.1: + dependencies: + rolldown: 1.1.1 + update-browserslist-db@1.2.3(browserslist@4.28.6): dependencies: browserslist: 4.28.6 diff --git a/scripts/dev-web.ts b/scripts/dev-web.ts index 35500eca1309..e6c41b37c5ef 100644 --- a/scripts/dev-web.ts +++ b/scripts/dev-web.ts @@ -131,6 +131,11 @@ export async function watchClientPlugins( const bundles = await build({ cwd: root, workspace: [...pluginDirs], + // tsdown's native no-cache hook needs Node >=24.11.1 for config graphs + // containing CommonJS dependencies. The supported Node 24.3 line can + // return a sourceless load before that fix, while tsx does not transform + // workspace child configs. unrun handles the complete config graph. + configLoader: 'unrun', watch: true, hooks: { 'build:done': ({ options }) => { diff --git a/scripts/verify-md-wrap.ts b/scripts/verify-md-wrap.ts index 528911c2bfbd..13d4945784df 100644 --- a/scripts/verify-md-wrap.ts +++ b/scripts/verify-md-wrap.ts @@ -22,8 +22,10 @@ const PATTERNS = [ 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', - 'snapshots/**/system-prompt.expected.md', - 'packages/**/system-prompt.expected.md', + // Node 24.3 fs.globSync can re-descend a literal basename after `**` as + // `file.md/file.md`; exact extglob `@(s)` avoids that literal traversal. + 'snapshots/**/@(s)ystem-prompt.expected.md', + 'packages/**/@(s)ystem-prompt.expected.md', 'AGENTS.md', 'packages/AGENTS.md', 'snapshots/AGENTS.md', From 409564d45edb2bbc653548eefb0006e6715414ee Mon Sep 17 00:00:00 2001 From: mattheliu Date: Mon, 31 Aug 2026 01:05:35 +0800 Subject: [PATCH 02/38] fix(a11y): restore alpha2 modal focus ownership --- ...ha2-accessibility-core-migration.i18n.yaml | 6 + ...-31-alpha2-accessibility-core-migration.md | 41 ++++ ...-alpha2-accessibility-core-migration.zh.md | 41 ++++ apps/web/tests/workspace-management.e2e.ts | 37 +++- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 + packages/client/ui-primitives/README.zh.md | 2 + packages/client/ui-primitives/src/Button.tsx | 14 +- packages/client/ui-primitives/src/Modal.tsx | 160 +++++++++++++- .../ui-primitives/tests/atoms.client.spec.tsx | 209 +++++++++++++++++- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 + packages/client/ui-workspace/README.zh.md | 2 + .../src/client/WorkspacePicker.tsx | 32 ++- .../tests/workspace-picker.client.spec.tsx | 12 +- 15 files changed, 531 insertions(+), 37 deletions(-) create mode 100644 .agents/notes/proposed/feature/2026-08-31-alpha2-accessibility-core-migration.i18n.yaml create mode 100644 .agents/notes/proposed/feature/2026-08-31-alpha2-accessibility-core-migration.md create mode 100644 .agents/notes/proposed/feature/2026-08-31-alpha2-accessibility-core-migration.zh.md diff --git a/.agents/notes/proposed/feature/2026-08-31-alpha2-accessibility-core-migration.i18n.yaml b/.agents/notes/proposed/feature/2026-08-31-alpha2-accessibility-core-migration.i18n.yaml new file mode 100644 index 000000000000..69116bd15f16 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-31-alpha2-accessibility-core-migration.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-31-alpha2-accessibility-core-migration.md +2026-08-31-alpha2-accessibility-core-migration.md: 2dced15c6c671066843ef0f7c64b4d80c85d7523 +2026-08-31-alpha2-accessibility-core-migration.zh.md: 20ed25a4901fe4dd50f3f9478da9ebe01a050ffb diff --git a/.agents/notes/proposed/feature/2026-08-31-alpha2-accessibility-core-migration.md b/.agents/notes/proposed/feature/2026-08-31-alpha2-accessibility-core-migration.md new file mode 100644 index 000000000000..2dced15c6c67 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-31-alpha2-accessibility-core-migration.md @@ -0,0 +1,41 @@ +# Agent Note: Alpha.2 accessibility core migration + +Status: proposed + +English | [中文](2026-08-31-alpha2-accessibility-core-migration.zh.md) + +## Problem + +The verified accessibility candidate is bound to the `dsh-v0.1.2-alpha.1` product shape, while `dsh-v0.1.2-alpha.2` changes 1,604 paths from that official tag. The candidate changes 302 paths from its development base, and 128 of those paths overlap the official alpha.2 delta. A direct merge produces conflicts across interaction owners, tests, generated browser expectations, coverage infrastructure, and files removed by alpha.2. Passing alpha.1 evidence therefore cannot be transferred to alpha.2, and mechanically retaining either side would hide regressions or discard current product behavior. + +Alpha.2 also retains shared controls that expose accessibility semantics without owning the corresponding interaction. In particular, the shared `Modal` declares a modal dialog but does not make the application inert, contain focus, restore focus, or arbitrate nested dialogs. A companion plugin cannot reconstruct those lifecycle guarantees after React renders the control. + +## Proposal + +Build the alpha.2 accessibility candidate from the exact `dsh-v0.1.2-alpha.2` tag and migrate behavior by interaction owner. For each alpha.1 candidate responsibility, compare the official alpha.1 base, the verified alpha.1 candidate, and alpha.2; classify it as already equivalent, additive, redesigned, obsolete, test/process-only, or regenerated evidence. Reimplement redesigned responsibilities against alpha.2 contracts instead of cherry-picking the old branch. + +Core components continue to own required names, states, keyboard operation, focus, live announcements, contrast behavior, reflow, and reduced-motion behavior. Optional accessibility plugins add diagnostics, preferences, authoring assistance, and evidence collection through declared extension points; they do not repair core semantics with DOM observation. + +The first vertical slice restores the shared dialog contract and the Workspace adoption-error path: `Button` exposes its native focus owner; `Modal` manages an open-dialog stack, application-root inertness, initial and contained focus, topmost dismissal, descriptions, and connected focus restoration; the Workspace picker associates its alert, initially focuses Cancel, and restores the durable picker trigger. Later slices cover the remaining alpha.2 interaction owners, including menus, Workspace and Session navigation, shell landmarks and separators, conversation views, structured tool and trajectory navigation, question and review flows, and announcements. + +Expected browser output is regenerated from alpha.2 only after the owning behavior passes focused source tests. Evidence records the exact product commit, browser and operating-system capability, assistive technology and version, task, result, limitation, and reviewer. Automated DOM, accessibility-tree, browser, contrast, reflow, motion, and packaging checks remain necessary but never substitute for task-level assistive-technology sessions and disabled-developer evidence. + +## Alternatives considered + +**Merge the complete alpha.1 candidate into alpha.2.** Rejected because the histories share only the official alpha.1 release base and the semantic overlap crosses redesigned source, generated expectations, and deleted files. A text merge cannot decide which interaction contract remains valid. + +**Ship a companion plugin that rewrites inaccessible DOM.** Rejected because post-render observation cannot authoritatively control component state, nested dismissal, focus restoration, virtualized navigation, or async error recovery. The plugin remains useful for diagnostics and authoring support. + +**Keep alpha.1 as the accessibility release until upstream stabilizes.** Rejected because users would lose alpha.2 product and security changes, and stale evidence would accumulate behind the current package line. + +## Acceptance criteria + +- Every migrated responsibility is reviewed against alpha.2 architecture and has focused unit or component evidence plus a built, assembled browser path when user-visible. +- Keyboard-only, VoiceOver on macOS, NVDA on Windows, and at least one additional platform screen-reader path complete versioned core-task protocols; braille and other input evidence is recorded as contributors become available. +- Disabled developers validate that the supported core tasks can be completed independently, effectively, and safely, with failures and workarounds retained in the public ledger. +- Release metadata identifies the exact DSH compatibility range and evidence status; no tag, package, or documentation claims complete accessibility while required task or assistive-technology evidence is missing. +- Generated snapshots, coverage ownership, and CI workflow changes are derived from alpha.2 and pass without erasing failed run history or rerunning failures into invisibility. + +## Risks + +Alpha.2 may continue changing while the migration is in progress, so evidence can become version-bound before every task is covered. Broad shared primitives can also change focus order for many consumers; each slice needs both primitive tests and assembled consumer checks. Automated browser semantics may pass while spoken output or real input workflows remain confusing, which is why public evidence must distinguish automated conformance from assistive-technology and disabled-user validation. diff --git a/.agents/notes/proposed/feature/2026-08-31-alpha2-accessibility-core-migration.zh.md b/.agents/notes/proposed/feature/2026-08-31-alpha2-accessibility-core-migration.zh.md new file mode 100644 index 000000000000..20ed25a4901f --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-31-alpha2-accessibility-core-migration.zh.md @@ -0,0 +1,41 @@ +# Agent Note: Alpha.2 无障碍核心迁移 + +Status: proposed + +[English](2026-08-31-alpha2-accessibility-core-migration.md) | 中文 + +## Problem + +经过验证的无障碍候选版本绑定于 `dsh-v0.1.2-alpha.1` 的产品形态,而 `dsh-v0.1.2-alpha.2` 相比该官方 tag 改动了 1,604 个路径。候选版本相对其开发基线改动了 302 个路径,其中 128 个与官方 alpha.2 增量重叠。直接合并会在交互 owner、测试、生成的浏览器预期、覆盖率基础设施以及被 alpha.2 删除的文件中产生冲突。因此,alpha.1 的通过证据不能转移到 alpha.2;机械保留任一侧都会隐藏回归或丢弃当前产品行为。 + +Alpha.2 还保留了一些虽然暴露无障碍语义、却没有持有相应交互的共享控件。尤其是,共享 `Modal` 声明了模态对话框,却没有让应用进入 inert 状态,也没有约束和恢复焦点或协调嵌套对话框。React 渲染控件后,companion 插件无法重建这些生命周期保证。 + +## Proposal + +从精确的 `dsh-v0.1.2-alpha.2` tag 构建 alpha.2 无障碍候选版本,并按交互 owner 迁移行为。对于 alpha.1 候选版本中的每项职责,对比官方 alpha.1 基线、经过验证的 alpha.1 候选版本和 alpha.2,将其归类为已经等效、可增量迁移、需要重新设计、已经过时、仅涉及测试/流程或需要重新生成的证据。需要重新设计的职责应按照 alpha.2 契约重新实现,而不是 cherry-pick 旧分支。 + +核心组件继续持有必要的名称、状态、键盘操作、焦点、实时播报、对比度行为、回流与减少动态效果。可选无障碍插件通过已声明的扩展点增加诊断、偏好、内容创作辅助和证据采集能力;它们不通过观察 DOM 修补核心语义。 + +首个垂直切片恢复共享对话框契约与 Workspace 接纳错误路径:`Button` 暴露原生焦点 owner;`Modal` 管理打开对话框栈、应用根节点 inert 状态、初始和受约束焦点、最上层关闭、描述关联以及仍连接目标的焦点恢复;Workspace 选择器关联其警报,把初始焦点放在“取消”上,并恢复到持久的选择器触发控件。后续切片覆盖 alpha.2 中剩余的交互 owner,包括菜单、Workspace 与 Session 导航、外壳地标与分隔条、对话视图、结构化工具与 trajectory 导航、问题与评审流程以及播报。 + +只有 owner 行为通过聚焦源码测试后,才基于 alpha.2 重新生成预期浏览器输出。证据记录精确产品 commit、浏览器和操作系统能力、辅助技术及版本、任务、结果、限制与评审者。自动化 DOM、无障碍树、浏览器、对比度、回流、动态效果和打包检查仍然必不可少,但绝不替代任务级辅助技术会话和残障开发者证据。 + +## Alternatives considered + +**把完整的 alpha.1 候选版本合并到 alpha.2。** 拒绝,因为两条历史只共享官方 alpha.1 发布基线,语义重叠横跨重新设计的源码、生成的预期与已删除文件。文本合并无法决定哪一份交互契约仍然有效。 + +**发布一个重写不可访问 DOM 的 companion 插件。** 拒绝,因为渲染后观察无法权威控制组件状态、嵌套关闭、焦点恢复、虚拟化导航或异步错误恢复。该插件仍可用于诊断与内容创作支持。 + +**在上游稳定之前继续把 alpha.1 作为无障碍发布版本。** 拒绝,因为用户会失去 alpha.2 的产品与安全变更,陈旧证据也会在当前包版本之后持续累积。 + +## Acceptance criteria + +- 每项迁移职责都按照 alpha.2 架构进行评审,并具备聚焦单元或组件证据;如果属于用户可见行为,还必须具备构建后组装浏览器路径证据。 +- 纯键盘、macOS VoiceOver、Windows NVDA 与至少一种其他平台读屏路径完成版本化核心任务协议;随着贡献者加入,持续记录盲文与其他输入方式的证据。 +- 残障开发者验证受支持核心任务可以独立、有效且安全地完成;失败与规避方式保留在公开台账中。 +- 发布元数据标明精确的 DSH 兼容范围与证据状态;只要仍缺少必需任务或辅助技术证据,任何 tag、包或文档都不得宣称完整无障碍。 +- 生成的快照、覆盖率职责与 CI 工作流变更均以 alpha.2 为基线,并且通过检查时不会擦除失败运行历史,也不会通过重跑让失败不可见。 + +## Risks + +迁移期间 alpha.2 可能继续变化,因此在覆盖全部任务之前,证据就可能仅适用于特定版本。影响范围广的共享原语还可能改变许多使用方的焦点顺序,因此每个切片都需要基础组件测试和组装使用方检查。自动化浏览器语义可能已经通过,但真实朗读或输入工作流仍然令人困惑;因此公开证据必须区分自动化符合性、辅助技术验证与残障用户验证。 diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index a8345c45d30b..75aa82c83afa 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -11,7 +11,7 @@ // are host RPCs with no model involvement, and the one session row the // flat/hover/menu/archive scenarios need comes from a seeded fixture (the // seeded-history seed reused verbatim — no new recording). -import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join, sep } from 'node:path' import type { Browser, Locator, Page } from 'playwright' @@ -149,6 +149,41 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff expect(tripwire.pageErrors).toEqual([]) }, 90_000) + it('describes an adoption failure and restores the durable Add workspace trigger', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-adoption-error')) + const workspaceIdsBefore = scaffold.ctx.workspaceRegistry.list().map(workspace => workspace.id) + const disappearing = join(scaffold.workspaceCwd, 'disappearing-before-adoption') + await mkdir(disappearing, { recursive: true }) + const picker = await browseTo(disappearing) + // The browse flow has accepted and rendered the directory, but the Host + // must still canonicalize it when Workspace adoption starts. Removing this + // task-owned fixture creates a real wire refusal without mocking the flow. + await rm(disappearing, { recursive: true, force: true }) + await picker.getByRole('button', { name: 'Open', exact: true }).click() + + const errorDialog = page.getByRole('dialog', { name: 'Couldn’t open folder' }) + await errorDialog.waitFor({ timeout: 10_000 }) + const alert = errorDialog.getByRole('alert') + const alertId = await alert.getAttribute('id') + expect(alertId).not.toBeNull() + expect(await errorDialog.getAttribute('aria-describedby')).toBe(alertId) + const cancel = errorDialog.getByRole('button', { name: 'Cancel' }) + const activeName = await page.locator(':focus').evaluate(element => + element.getAttribute('aria-label') ?? element.textContent?.trim() ?? '') + expect(activeName).toBe('Cancel') + + await cancel.click() + await expect.poll(() => page.evaluate(() => { + const active = document.activeElement + return active?.getAttribute('aria-label') ?? active?.textContent?.trim() ?? '' + }), { timeout: 5_000 }).toBe('Add workspace') + // A missing path makes resolveByPath reject during its documented + // canonicalization step, so compare the durable registry identity set + // instead of trying to canonicalize this intentionally absent fixture. + expect(scaffold.ctx.workspaceRegistry.list().map(workspace => workspace.id)).toEqual(workspaceIdsBefore) + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + it('renames a workspace over the wire with a duplicate-name pre-check', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-rename')) const alphaRow = page.locator('[role="treeitem"]').filter({ hasText: 'alpha-ws' }).first() diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index d48604ea73fc..03b832a56055 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 42c1110e8735dd2191c8b7a2e4dcc1f2b9b938bc -README.zh.md: 9f3c06cacebb7a596204dbe1c8e00a549ce1fcae +README.md: ace9cdf0b66e3f49ab1fa9c5857cc662e20e3056 +README.zh.md: 5b0eb9a3c9305535291b58e72aa5808e68e856ae diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 42c1110e8735..ace9cdf0b66e 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -31,6 +31,8 @@ Compose feature UI from these atoms whenever the web client needs a standard con `Button`, `Pill`, `Input`, `Menu`, `Modal`, `Tooltip`, `DisclosureRow`, `StateDot`, `HoverCard`, `Toast`, `ConnectionIndicator`, `RiskConfirmation`, and the `OnboardingSurface` first-run takeover cover the common interaction shapes. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. +`Button` forwards its native element ref for interaction owners. `Modal` makes the application root inert while any shared dialog is open, keeps only the top dialog interactive, chooses and contains focus, limits Escape and mask dismissal to that top dialog, and restores focus to an explicit connected target or the connected opening control. + ### Rendering agent output `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. While a reply streams, it freezes completed blocks and highlights a growing fence from saved Shiki grammar state; the final render uses the same span tree ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)). `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `MessageText` remains the literal-text primitive for user-authored content. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 9f3c06cacebb..5b0eb9a3c930 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -31,6 +31,8 @@ kind: "package-library" `Button`、`Pill`、`Input`、`Menu`、`Modal`、`Tooltip`、`DisclosureRow`、`StateDot`、`HoverCard`、`Toast`、`ConnectionIndicator`、`RiskConfirmation` 与首次运行接管层 `OnboardingSurface` 覆盖常见的交互形态。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 +`Button` 会转发原生元素 ref,供交互 owner 使用。只要有任一共享对话框处于打开状态,`Modal` 就会让应用根节点进入 inert 状态,并且只让最上层对话框保持可交互;它还负责选择和约束焦点,只允许最上层对话框响应 Escape 与遮罩关闭,并把焦点恢复到显式指定且仍连接的目标,或仍连接的打开控件。 + ### 渲染 agent 输出 `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。回复流式输出时,它冻结已完成的块,并从保存的 Shiki grammar state 为不断增长的 fence 增量高亮;最终渲染使用相同的 span 树([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`MessageText` 仍是用户创作内容的字面文本原语。 diff --git a/packages/client/ui-primitives/src/Button.tsx b/packages/client/ui-primitives/src/Button.tsx index d2e39dbf2386..a7612cd4b30b 100644 --- a/packages/client/ui-primitives/src/Button.tsx +++ b/packages/client/ui-primitives/src/Button.tsx @@ -1,6 +1,7 @@ // Button: token-styled button atom. Variants map to the --dsw-alias-button-* // fill families; no framework imports, all behavior via props. +import { forwardRef } from 'react' import type { ButtonHTMLAttributes, ReactNode } from 'react' import clsx from 'clsx' import css from './Button.module.css' @@ -15,17 +16,22 @@ export type ButtonVariant = 'primary' | 'ghost' | 'outline' | 'toolbar' * @param props.icon - optional leading 16px icon node. * @returns the button element; native button attributes pass through. */ -export function Button({ variant = 'ghost', size = 'md', icon, className, children, ...rest }: { +export type ButtonProps = { variant?: ButtonVariant size?: 'md' | 'sm' icon?: ReactNode className?: string | undefined children?: ReactNode -} & ButtonHTMLAttributes) { +} & ButtonHTMLAttributes + +/** Token-styled native button with its DOM focus owner exposed to consumers. */ +export const Button = forwardRef(function Button({ + variant = 'ghost', size = 'md', icon, className, children, ...rest +}, ref) { return ( - ) -} +}) diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index fd33a8724cd6..3d4dc23bcfa9 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -1,14 +1,76 @@ -import { useEffect } from 'react' -import type { ReactNode } from 'react' +import { useEffect, useId, useRef } from 'react' +import type { ReactNode, RefObject } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCloseOutline16 } from './icons/index.tsx' import css from './Modal.module.css' +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'area[href]', + 'button:not([disabled])', + 'input:not([disabled]):not([type="hidden"])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[contenteditable="true"]', + '[tabindex]:not([tabindex="-1"])', +].join(',') + +interface ActiveDialog { + element: HTMLElement + previousInert: boolean +} + +const dialogStack: ActiveDialog[] = [] +let inertRoot: { element: HTMLElement; previous: boolean } | null = null + +function focusableElements(dialog: HTMLElement): HTMLElement[] { + return [...dialog.querySelectorAll(FOCUSABLE_SELECTOR)].filter(element => + !element.hidden + && element.getAttribute('aria-hidden') !== 'true' + && element.closest('[inert]') === null) +} + +function topDialog(): HTMLElement | undefined { + return dialogStack.at(-1)?.element +} + +function syncDialogInertness(): void { + const top = dialogStack.at(-1) + for (const entry of dialogStack) entry.element.inert = entry === top ? entry.previousInert : true +} + +function activateDialog(dialog: HTMLElement): () => void { + if (dialogStack.length === 0) { + const appRoot = document.getElementById('root') + if (appRoot !== null) { + inertRoot = { element: appRoot, previous: appRoot.inert } + appRoot.inert = true + } + } + const entry = { element: dialog, previousInert: dialog.inert } + dialogStack.push(entry) + syncDialogInertness() + return () => { + const index = dialogStack.lastIndexOf(entry) + /* v8 ignore else -- every cleanup closes the dialog registered by this activation. */ + if (index >= 0) dialogStack.splice(index, 1) + dialog.inert = entry.previousInert + syncDialogInertness() + if (dialogStack.length !== 0 || inertRoot === null) return + inertRoot.element.inert = inertRoot.previous + inertRoot = null + } +} + interface ModalBaseProps { open: boolean onClose: () => void title: string + labelledBy?: string + describedBy?: string + initialFocusRef?: RefObject | undefined + restoreFocusRef?: RefObject | undefined description?: string children?: ReactNode footer?: ReactNode @@ -26,6 +88,12 @@ type ModalProps = ModalBaseProps & ( * @param props.open - whether the dialog is showing. * @param props.onClose - Escape or mask click. * @param props.title - dialog heading (aria-label in every mode). + * @param props.labelledBy - optional id of a visible heading that replaces the aria-label. + * @param props.describedBy - optional id of visible supporting content; the + * built-in description receives a generated id when this is omitted. + * @param props.initialFocusRef - optional contained target focused when the dialog opens. + * @param props.restoreFocusRef - optional durable target focused when the dialog closes; + * falls back to the connected opening control when absent or disconnected. * @param props.closeLabel - localized accessible close-button label. * @param props.description - optional supporting sentence under the title. * @param props.children - body (inputs, etc.). @@ -36,27 +104,101 @@ type ModalProps = ModalBaseProps & ( * @returns null when closed; otherwise the overlay tree. */ export function Modal({ - open, onClose, title, closeLabel, description, children, footer, className, contentClassName, headless = false, + open, onClose, title, labelledBy, describedBy, initialFocusRef, restoreFocusRef, closeLabel, description, children, + footer, className, contentClassName, headless = false, }: ModalProps) { + const generatedDescriptionId = useId() + const descriptionId = describedBy + ?? (description !== undefined && description !== '' ? generatedDescriptionId : undefined) + const dialogRef = useRef(null) + const onCloseRef = useRef(onClose) + onCloseRef.current = onClose + const openingInvokerRef = useRef(null) + if (!open) openingInvokerRef.current = null + else if (dialogRef.current === null && openingInvokerRef.current === null + && typeof document !== 'undefined' && document.activeElement instanceof HTMLElement) { + openingInvokerRef.current = document.activeElement + } + useEffect(() => { if (!open) return + const dialog = dialogRef.current + /* v8 ignore next -- open always renders and attaches the dialog before effects run. */ + if (dialog === null) return + const opener = openingInvokerRef.current + const deactivate = activateDialog(dialog) + const requestedInitial = initialFocusRef?.current ?? null + const explicitInitial = requestedInitial !== null && dialog.contains(requestedInitial) + ? requestedInitial + : null + const current = document.activeElement instanceof HTMLElement && dialog.contains(document.activeElement) + ? document.activeElement + : null + const initial = explicitInitial + ?? current + ?? dialog.querySelector('[autofocus]') + ?? focusableElements(dialog)[0] + ?? dialog + initial.focus() const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() + if (topDialog() !== dialog) return + if (e.key === 'Escape') { + e.preventDefault() + onCloseRef.current() + return + } + if (e.key !== 'Tab') return + const items = focusableElements(dialog) + const first = items[0] + const last = items.at(-1) + if (first === undefined || last === undefined) { + e.preventDefault() + dialog.focus() + return + } + const active = document.activeElement + const activeIndex = items.findIndex(item => item === active) + if (activeIndex < 0) { + e.preventDefault() + ;(e.shiftKey ? last : first).focus() + return + } + if (e.shiftKey ? activeIndex === 0 : activeIndex === items.length - 1) { + e.preventDefault() + ;(e.shiftKey ? last : first).focus() + } } document.addEventListener('keydown', onKeyDown) - return () => { document.removeEventListener('keydown', onKeyDown) } - }, [open, onClose]) + return () => { + document.removeEventListener('keydown', onKeyDown) + deactivate() + const explicitRestore = restoreFocusRef?.current ?? null + if (explicitRestore?.isConnected === true) explicitRestore.focus() + else if (opener?.isConnected === true) opener.focus() + } + }, [initialFocusRef, open, restoreFocusRef]) if (!open) return null return createPortal((
-