Skip to content

feat(web-shell): Scope voice to composer workspace - #7754

Merged
doudouOUC merged 4 commits into
QwenLM:mainfrom
doudouOUC:codex/issue-6972-voice-design
Jul 27, 2026
Merged

feat(web-shell): Scope voice to composer workspace#7754
doudouOUC merged 4 commits into
QwenLM:mainfrom
doudouOUC:codex/issue-6972-voice-design

Conversation

@doudouOUC

Copy link
Copy Markdown
Collaborator

What this PR does

Routes the existing Web Shell Voice experience through the workspace that owns each composer. Main, locked, and split-view composers now resolve a fail-closed Voice target; trusted secondary workspaces use workspace-qualified status, provider, settings, model, and streaming routes, while primary and older single-workspace daemons retain the legacy routes. Each capture generation snapshots its owner and cleans up microphone, audio, timers, sockets, and stale callbacks when ownership changes. The development proxy now forwards only qualified Voice stream upgrades.

Why it's needed

Web Shell sessions can run in secondary workspaces, but Voice previously continued to use the primary workspace daemon. This could expose primary-workspace Voice configuration in a secondary composer and insert a transcript produced by the wrong runtime. Scoping Voice to the composer owner preserves workspace isolation and makes split-view behavior predictable.

Reviewer Test Plan

How to verify

  1. Start Web Shell with a primary workspace and a trusted secondary workspace, then confirm that Voice in the primary composer uses the legacy routes while Voice in the secondary composer uses routes qualified with that workspace's exact canonical path.
  2. Open both workspaces in split view and confirm each composer independently reports Voice availability, loads its own provider and model settings, and streams to its owning runtime.
  3. Start capture and then switch, remove, untrust, or drain the owning workspace. Confirm the capture is stopped, resources are released, and no late transcript or error from the old owner is inserted into the current composer.
  4. Confirm user-scoped Voice settings remain shared while workspace-scoped settings and model discovery are loaded and saved only through the resolved workspace.
  5. Confirm an older single-workspace daemon still supports Voice through the legacy endpoints, while an unknown, ambiguous, untrusted, bootstrapping, draining, or removed secondary workspace fails closed instead of falling back to primary.

Expected result: every Voice request and callback stays bound to the composer workspace that initiated it, with legacy compatibility limited to the primary single-workspace path.

Evidence (Before & After)

Before: Voice controls in a secondary-workspace composer queried and streamed through the primary runtime, so availability, settings, models, and transcripts could belong to the wrong workspace.

After: each composer resolves one exact Voice owner; trusted secondary workspaces use qualified routes, primary keeps legacy compatibility, and unresolved or unsafe secondary states disable Voice without primary fallback.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

macOS with the repository's Node.js 22+ toolchain. Verified with 359 focused Vitest tests, the Web Shell production and library builds, Web Shell typecheck, repository lint, and whitespace validation. Physical microphone browser E2E was not run because Playwright Chromium is not installed in the local environment.

Risk & Scope

  • Main risk or tradeoff: Voice target lifetime and stale asynchronous results are more explicit; capture generations and exact canonical workspace identity are used to prevent callbacks from crossing owners.
  • Not validated / out of scope: Physical microphone behavior in a real browser, provider credentials, Windows, and Linux were not validated locally. This change does not alter daemon or SDK route schemas.
  • Breaking changes / migration notes: None. Primary and older single-workspace daemons retain the existing Voice routes.

Linked Issues

Closes #6972

中文说明

本 PR 做了什么

将现有 Web Shell Voice 体验路由到拥有各个输入框的工作区。主工作区、锁定工作区和分屏输入框现在都会以失败关闭方式解析 Voice 目标;可信的次工作区使用带工作区限定的状态、提供商、设置、模型和流式路由,而主工作区及旧版单工作区 daemon 继续使用兼容的旧路由。每一代录音都会快照其所有者,并在所有权变化时清理麦克风、音频、定时器、套接字和过期回调。开发代理现在只转发带工作区限定的 Voice 流式升级请求。

为什么需要

Web Shell 会话可以运行在次工作区中,但 Voice 之前仍然使用主工作区 daemon。这可能在次工作区输入框中暴露主工作区的 Voice 配置,并插入由错误运行时生成的转录。将 Voice 限定到输入框所有者可以保持工作区隔离,并使分屏行为可预测。

Reviewer 测试计划

如何验证

  1. 使用一个主工作区和一个可信次工作区启动 Web Shell,确认主工作区输入框中的 Voice 使用旧路由,而次工作区输入框中的 Voice 使用包含该工作区精确规范路径的限定路由。
  2. 在分屏中打开两个工作区,确认每个输入框都独立报告 Voice 可用性、加载各自的提供商和模型设置,并流式连接到其所属运行时。
  3. 启动录音后切换、移除、不信任或排空所属工作区。确认录音停止、资源释放,并且旧所有者的延迟转录或错误不会插入当前输入框。
  4. 确认用户级 Voice 设置继续共享,而工作区级设置和模型发现只通过已解析的工作区加载和保存。
  5. 确认旧版单工作区 daemon 仍可通过旧端点支持 Voice;未知、歧义、不可信、启动中、排空中或已移除的次工作区应失败关闭,而不是回退到主工作区。

预期结果:每个 Voice 请求和回调都始终绑定到发起操作的输入框工作区,旧版兼容性仅限于主工作区的单工作区路径。

证据(之前与之后)

之前:次工作区输入框中的 Voice 控件通过主运行时查询和流式通信,因此可用性、设置、模型和转录可能属于错误的工作区。

之后:每个输入框解析到唯一且精确的 Voice 所有者;可信次工作区使用限定路由,主工作区保留旧版兼容性,无法解析或不安全的次工作区状态会禁用 Voice,且不会回退到主工作区。

测试平台

操作系统 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

macOS,使用仓库要求的 Node.js 22+ 工具链。已通过 359 个定向 Vitest 测试、Web Shell 生产构建和库构建、Web Shell 类型检查、仓库 lint 以及空白检查。由于本地环境未安装 Playwright Chromium,未执行真实麦克风浏览器 E2E。

风险与范围

  • 主要风险或权衡:Voice 目标生命周期和过期异步结果变得更明确;实现通过录音代次和精确的规范工作区身份防止回调跨越所有者。
  • 未验证或范围外:未在本地验证真实浏览器中的物理麦克风行为、提供商凭据、Windows 和 Linux。本变更不修改 daemon 或 SDK 路由 schema。
  • 破坏性变更或迁移说明:无。主工作区和旧版单工作区 daemon 保留现有 Voice 路由。

关联 Issue

Closes #6972

@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Jul 26, 2026
@doudouOUC doudouOUC self-assigned this Jul 26, 2026
@doudouOUC
doudouOUC requested a review from wenshao July 26, 2026 15:17
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head 6286dc5. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

ℹ️ No screenshot changed against the PR base — but this PR edits 5 render-shaping files:

  • packages/web-shell/client/App.tsx
  • packages/web-shell/client/components/ChatEditor.tsx
  • packages/web-shell/client/components/ChatPane.tsx
  • packages/web-shell/client/components/SplitView.tsx
  • packages/web-shell/client/voice/VoiceButton.tsx

Either the change has no visual effect (logic, plumbing, a state the scenarios never reach), or no scenario renders this UI — in which case the preview cannot see it, and an empty result is a coverage gap rather than a clean bill of health. To make it visible, add a scenario to packages/web-shell/client/e2e/visuals/screenshots.spec.ts that seeds whatever state the UI is gated on; it then appears here as a head-only (NEW) capture.

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this is a solid, well-tested change — the ownership/trust/generation/invalidation model is careful and coverage is thorough. Two follow-ups after digging into the code:

#1loadVoiceSettings cwd validation: no change needed ✅

I initially flagged that loadVoiceSettings doesn't echo-validate workspaceCwd like loadVoiceStatus/loadVoiceProviders do. Retracting that: DaemonWorkspaceSettingsStatus (types.ts:2340) has no workspaceCwd field — only v / warnings / settings — so there's nothing to validate against. Staleness is already handled correctly by the ownerKey + requestRef generation guards in useVoiceWorkspaceSettings, and the request itself is workspace-qualified via workspaceById/ByCwd. All good here.

#2activeWorkspaceCwd fallback is redundant for Voice and broadens shared git state

App.tsx adds ?? workspace.capabilities?.workspaceCwd to activeWorkspaceCwd. But resolveVoiceWorkspaceTarget already performs this exact fallback internally (primaryCwd = registered?.find(primary)?.cwd ?? capabilities.workspaceCwd), so an old single-workspace daemon resolves the correct legacy-primary target with or without this line.

The only observable effect of the App-level change is on non-Voice shared state: activeWorkspaceCwd also drives the git-status effect (App.tsx:1488/1496), the Changes dialog (3240), and gitModeEligible (3243). For a legacy single-workspace daemon it now activates git-status fetching + a 30s poll before a session exists, which previously stayed off. That's benign (correct repo), but it contradicts the PR's "Voice-specific and small" framing and isn't covered by any test.

I verified locally on this branch:

  • Baseline: App + ChatPane + voice-workspace-target → 261 passed.
  • With the fallback reverted: same suites + VoiceButton278 passed, zero failures.

So the revert is test-safe and Voice-neutral. Suggested change:

       : (lockedWorkspaceCwd ??
          selectedWorkspaceCwd ??
-         workspaces.find((entry) => entry.primary)?.cwd ??
-         workspace.capabilities?.workspaceCwd),
+         workspaces.find((entry) => entry.primary)?.cwd),
     [
       connection.sessionId,
       connection.workspaceCwd,
       lockedWorkspaceCwd,
       selectedWorkspaceCwd,
-      workspace.capabilities?.workspaceCwd,
       workspaces,
     ],

If the pre-session git-status behavior for legacy daemons is intentional, please keep it but call it out in the PR description and add a test — right now nothing exercises it (which is why reverting stays green).

中文

整体是高质量且测试充分的改动 —— 所有权/信任/世代/失效模型设计严谨,覆盖全面。深入代码后有两点后续:

#1 —— loadVoiceSettings 的 cwd 校验:无需改动 ✅
我最初提出它没像 loadVoiceStatus/loadVoiceProviders 那样回显校验 workspaceCwd。现撤回:DaemonWorkspaceSettingsStatus(types.ts:2340)根本没有 workspaceCwd 字段(只有 v / warnings / settings),无从校验。过期防护已由 useVoiceWorkspaceSettings 中的 ownerKey + requestRef 世代守卫正确处理,且请求本身经 workspaceById/ByCwd 定址。此处没问题。

#2 —— activeWorkspaceCwd 的 fallback 对 Voice 冗余,且扩大了共享的 git 状态
App.tsxactiveWorkspaceCwd 加了 ?? workspace.capabilities?.workspaceCwd。但 resolveVoiceWorkspaceTarget 内部已有完全相同的 fallback(primaryCwd = registered?.find(primary)?.cwd ?? capabilities.workspaceCwd),所以旧单 workspace daemon 无论加不加这行都能解析到正确的 legacy-primary target。

这行唯一可观察的影响落在非 Voice 的共享状态上:activeWorkspaceCwd 还驱动 git-status effect(App.tsx:1488/1496)、Changes 对话框(3240)、gitModeEligible(3243)。旧单 workspace daemon 现在会在 session 建立之前就启动 git-status 请求 + 30s 轮询,而这之前是关闭的。功能上良性(仓库正确),但与 PR "Voice-specific and small" 的表述冲突,且无测试覆盖。

本地在该分支已验证:

  • Baseline: App + ChatPane + voice-workspace-target → 261 passed。
  • 回退该 fallback 后: 上述 + VoiceButton278 passed,零失败。

即回退安全且对 Voice 中性,建议改动同上。若旧 daemon 的 pre-session git-status 行为是有意为之,请保留但在 PR 描述中说明并补测试 —— 目前完全没有测试触及它(所以回退才会全绿)。

@doudouOUC
doudouOUC force-pushed the codex/issue-6972-voice-design branch from d50a84b to 5846e58 Compare July 26, 2026 17:21
@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff at 5846e58 and ran the suite locally: client/voice + vite-config + ChatPane + SplitView (153 passed), App.test.tsx (186 passed), and eslint over every changed file (clean). Typecheck was inconclusive in my checkout — the errors it reports are all in files this PR does not touch (GitHubPrsDialog, GitLogDialog, WebShellSidebar), from a stale SDK build, so I did not count it either way.

The architecture choice is right: a Voice-specific resolver returning an immutable descriptor, rather than retargeting DaemonWorkspaceProvider. The capture-generation/snapshot rework is the substantive win — snapshotting onFinal at start() instead of following a latest-ref, plus the three-way snapshotIsCurrent check (mounted / generation / snapshotRef identity), is exactly what closes the "transcript from the wrong runtime lands in this composer" hole. The resolver is pure and fails closed, and the e2e test asserting the legacy route is not called is the most valuable assertion in the PR.

One blocker below, plus a set of smaller items. Everything else reads clean.

中文说明

已完整审查 5846e58 的 diff,并在本地跑了测试:client/voice + vite-config + ChatPane + SplitView(153 通过)、App.test.tsx(186 通过),以及对所有改动文件的 eslint(干净)。typecheck 在我的工作区结论不可用——它报的错全在本 PR 未改动的文件里(GitHubPrsDialogGitLogDialogWebShellSidebar),来自过期的 SDK 构建产物,因此没有采信。

架构选择是对的:用 Voice 专属 resolver 返回不可变 descriptor,而不是去改 DaemonWorkspaceProvider。capture generation / snapshot 的改造是本 PR 的实质价值——在 start() 时快照 onFinal 而非沿用 latest-ref,加上 snapshotIsCurrent 的三重校验(mounted / generation / snapshotRef 同一性),正好堵死了「错误 runtime 的转录插进当前输入框」这个缺陷。resolver 是纯函数且失败关闭,而 e2e 里断言 legacy 路由没有被调用,是这个 PR 中最有价值的断言。

下面有一个阻塞项,以及若干较小的问题。其余部分读下来是干净的。

Comment thread packages/web-shell/client/App.tsx Outdated
Comment thread packages/web-shell/client/voice/VoiceButton.tsx
Comment thread packages/web-shell/client/App.tsx Outdated
Comment thread packages/web-shell/client/App.tsx
Comment thread packages/web-shell/client/voice/use-voice-workspace-settings.ts Outdated
Comment thread packages/web-shell/client/voice/VoiceButton.tsx
Comment thread packages/web-shell/client/App.tsx Outdated
Comment thread packages/web-shell/client/voice/useVoiceCapture.ts Outdated
Comment thread packages/web-shell/vite.config.ts
Comment thread docs/design/web-shell-secondary-workspace-voice.md Outdated
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Addressed review feedback in d3b3ee8610.

Feedback Decision Result
Preserve active capture through dialogs/approvals Agree Fixed for main and split composers; hidden composers still abort
Recover after failed 1012 capability refresh Agree Fixed with same-owner revalidation and stale-owner guards
Memoize Voice status revisions Agree Fixed for main and split composers
Settings row consistency/order/disabled UX Partially agree Split behavior made route-consistent; disabled-row and synthetic ordering deferred to avoid scope expansion
Avoid Settings-row flicker Agree Fixed by retaining same-owner descriptor during refresh
Log status-probe failures Agree Added fail-closed diagnostic warning
Avoid render-phase ref writes Agree Moved affected refs to a commit-phase layout effect
Restore Voice capture constraints/comments Agree Restored protocol constraint, daemon pointer, PCM naming, and CSP rationale
Accept future query strings in the dev proxy Disagree Not changed; the current protocol is intentionally exact and query-free
Clarify #6972 prerequisite Agree Updated #6972 tracking and the design statement

Validation: 300 focused Web Shell tests passed; Web Shell typecheck and production/library build passed; changed-file lint/format checks passed. Full-repository build remains unavailable in this checkout because the already-declared @octokit/rest dependency is absent from local node_modules; the failure is in packages/channels/github, outside this PR diff.

@doudouOUC
doudouOUC force-pushed the codex/issue-6972-voice-design branch from d3b3ee8 to c831fed Compare July 27, 2026 06:48
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Rebased onto origin/main at 1f9a1a90a3dfe166e355a8c847611627eaff1105 and resolved the Voice lifecycle conflicts by preserving both workspace-scoped capture ownership and main's mobile capture/startup behavior.

Validation:

  • 368 impacted Web Shell tests passed
  • Web Shell typecheck passed
  • Web Shell lint passed
  • Web Shell build passed
  • Changed-file Prettier check passed
  • Two consecutive self-audit passes found no further actionable issues

Review threads resolved: 10/10; remaining unresolved: 0.

Rebased head: c831feda1ee0335985e3b5313af7a3ac6dc9837e.

@doudouOUC
doudouOUC marked this pull request as ready for review July 27, 2026 08:20
@doudouOUC
doudouOUC enabled auto-merge July 27, 2026 08:20
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: This is a real, observed architectural gap — not theoretical hardening. The daemon and SDK have supported workspace-qualified Voice since #6839, but the Web Shell client still hardcodes the primary workspace's legacy Voice routes. A secondary-workspace composer's microphone control, status, model picker, and settings all bind to the wrong runtime. The linked issue #6972 documents the gap with concrete acceptance criteria and implementation tracking.

Direction: Aligned. The CHANGELOG records #6839 ("Voice input is now qualified per workspace for better multi-workspace support") and #7345 ("respect voice enabled setting") as prior steps. This PR is the natural client-side completion of that work. No new protocol, SDK, or UX surface — it routes existing Voice behavior through the correct workspace owner.

Size: No core module paths touched — all changes are in packages/web-shell/ and docs/. Production logic is ~1,446 lines (App.tsx 403, useVoiceCapture.ts 406, voice-workspace-target.ts 274, VoiceButton.tsx 148, use-voice-workspace-settings.ts 101, ChatPane.tsx 51, others ~63). Tests account for ~2,070 lines and the design doc for 402. The 1,000+ production-line advisory applies, but the scope is driven by the number of Voice surfaces that need workspace awareness (status, capture, model picker, settings, split-view propagation) — splitting would fragment the ownership model.

Approach: The architecture is sound. A pure resolver (voice-workspace-target.ts) keeps target resolution testable and side-effect-free. The capture snapshot pattern in useVoiceCapture correctly prevents stale callbacks from crossing workspace owners. The design doc explicitly rejects simpler alternatives (retargeting the global provider, resolving inside VoiceButton) with good reasoning. The diff is focused on the stated goal — no drive-by refactors or unrelated changes.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题: 这是一个真实的架构缺口,而非理论性加固。daemon 和 SDK 自 #6839 起已支持工作区限定的 Voice,但 Web Shell 客户端仍然硬编码使用主工作区的旧 Voice 路由。次工作区输入框的麦克风控制、状态、模型选择器和设置都绑定到错误的运行时。关联的 issue #6972 记录了具体的验收标准和实现跟踪。

方向: 对齐。CHANGELOG 记录了 #6839("Voice 输入按工作区限定")和 #7345("尊重 Voice 启用设置")作为前置步骤。本 PR 是该工作的自然客户端完成。不引入新协议、SDK 或 UX 界面。

规模: 未触及核心模块路径——所有改动在 packages/web-shell/docs/ 中。生产逻辑约 1,446 行,测试约 2,070 行,设计文档 402 行。超过 1,000 行生产代码建议阈值,但范围由需要工作区感知的 Voice 面(状态、录音、模型选择器、设置、分屏传播)驱动。

方案: 架构合理。纯函数解析器保持目标解析可测试且无副作用。录音快照模式正确防止过期回调跨越工作区所有者。diff 聚焦于既定目标,无无关改动。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at c831feda1ee0335985e3b5313af7a3ac6dc9837e · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review

Independent proposal: Given "scope Voice to the composer workspace", I would add a pure resolver mapping (capabilities, intendedCwd, sessionId, workspaces) to an immutable target descriptor with route kind and stream path; pass it from App through ChatEditor to VoiceButton; snapshot the target at capture start and reject stale callbacks via generation identity; resolve per-pane targets in ChatPane for split view; and add a narrow Vite proxy regex for qualified WebSocket upgrades.

Comparison: The PR matches this approach and goes further in several areas: picker lifecycle management with pending-intent invalidation, revision-based status invalidation split by user vs workspace scope, structured close-code handling (1012 capability refresh, 1013 capacity preservation, 1006 revalidation), and layout-effect timing for owner changes to prevent races between React commit and browser events. The design doc's rejected alternatives (retargeting the global provider, resolving inside VoiceButton) are well-reasoned.

No critical blockers found. No security issues — bearer subprotocol authentication is preserved, tokens never appear in URLs, and qualified failures never fall back to primary. The code follows project conventions (ESM, no any, collocated tests, kebab-case .ts, PascalCase .tsx).

Two observations, neither blocking:

  1. The disabled prop addition to the main ChatEditor (interactionBlocked || approvalOverlayActive) and split ChatPane (approvalActive) is a behavioral change beyond Voice routing, but it is a prerequisite — without it the Voice button stays active during approval overlays. Reasonable inclusion.

  2. writeVoiceModelForTarget bumps the revision and reloads settings in both the success and error paths. This is intentional (the design doc calls for explicit reload after writes), and the error path re-throws, so the caller still sees the failure.

sequenceDiagram
    participant P1 as App or ChatPane
    participant P2 as voice-workspace-target
    participant P3 as VoiceButton
    participant P4 as useVoiceCapture
    participant P5 as Daemon

    P1->>P2: resolveVoiceWorkspaceTarget(caps, cwd, session)
    P2-->>P1: target (legacy-primary or workspace-qualified)
    P1->>P3: target prop
    P3->>P5: loadVoiceStatus(client, target)
    P5-->>P3: enabled, workspaceCwd
    Note over P3: gate open if enabled and cwd matches
    P3->>P4: start() snapshots ownerKey and streamPath
    P4->>P5: WebSocket to streamPath (qualified or legacy)
    P5-->>P4: interim and final frames
    Note over P4: deliver final only if snapshot is current
    P4-->>P3: onFinal(text)
Loading
Files changed (20 of 20 shown)
File What changed
docs/design/web-shell-secondary-workspace-voice.md New design doc covering ownership model, target resolution, capability gates, and error behavior
packages/web-shell/client/voice/voice-workspace-target.ts New pure resolver, capability helpers, and route-aware SDK adapters for status, providers, settings
packages/web-shell/client/voice/voice-workspace-target.test.ts 321 lines of resolver tests: primary, secondary, split, locked, old daemon, encoding, capability gates
packages/web-shell/client/voice/useVoiceCapture.ts Capture snapshot pattern replacing generation-only staleness; target-aware WebSocket URL; unexpected-close callback
packages/web-shell/client/voice/useVoiceCapture.test.tsx 221 lines of new capture tests: target identity, stale snapshot rejection, close-code handling
packages/web-shell/client/voice/VoiceButton.tsx Target-aware status probe with generation guard; revision-keyed gate; 1012 capability refresh; owner-change layout effect
packages/web-shell/client/voice/VoiceButton.test.tsx 333 lines of new button tests: qualified status, stale rejection, revision invalidation, close recovery
packages/web-shell/client/voice/use-voice-workspace-settings.ts New hook overlaying the voiceModel descriptor from qualified workspace settings
packages/web-shell/client/voice/use-voice-workspace-settings.test.tsx 185 lines of settings hook tests: owner-keyed reload, stale rejection, capability gate
packages/web-shell/client/App.tsx Main target resolution, revision state, targeted settings overlay, voice picker lifecycle, writeVoiceModelForTarget
packages/web-shell/client/App.test.tsx 746 lines of new App tests: target propagation, revision bumping, picker invalidation, settings overlay
packages/web-shell/client/components/ChatEditor.tsx Pass-through of voiceTarget and voiceStatusRevision props to VoiceButton
packages/web-shell/client/components/ChatPane.tsx Per-pane Voice target resolution from connection state; hidden-pane Voice suppression
packages/web-shell/client/components/ChatPane.test.tsx 128 lines of split-pane Voice tests: secondary binding, legacy fallback, mismatch fail-closed
packages/web-shell/client/components/SplitView.tsx Forwards Voice revision state and merged workspace list to each pane
packages/web-shell/client/components/SplitView.test.tsx 30 lines testing revision and workspace list forwarding
packages/web-shell/client/e2e/utils/mockDaemon.ts Qualified Voice, providers, and settings route handling in the mock daemon
packages/web-shell/client/e2e/web-shell.smoke.spec.ts New E2E smoke test: secondary workspace Voice status uses qualified route, never legacy
packages/web-shell/client/vite-config.test.ts 36 lines verifying the qualified Voice proxy regex and ws flag
packages/web-shell/vite.config.ts Narrow regex proxy rule for qualified Voice WebSocket upgrades

Testing

This is an unattended CI run — PR code is not executed locally. Evidence comes from the PR's own CI checks on the reviewed commit.

The web-shell E2E smoke test (web-shell E2E Smoke (ubuntu-latest, Node 22.x)) passed, including the new secondary-workspace Voice status test that verifies the qualified route is used and the legacy route is never called. The full unit suite (Test (ubuntu-latest, Node 22.x)) passed. Windows and macOS test jobs were skipped (fork PR).

The sandboxed real-scenario lanes (@qwen-code /tmux, @qwen-code /verify) are unavailable for this PR because the author is an external contributor without write access. A maintainer can verify the behavioral claims by checking out the PR in a disposable container and driving Voice in a multi-workspace Web Shell session.

Not verified: physical microphone browser E2E (author noted Playwright Chromium is not installed locally), Windows and Linux behavior, provider credentials.

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
Test (windows-latest, Node 22.x) ⏭️ skipped
Test (macos-latest, Node 22.x) ⏭️ skipped
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
Capture web-shell visuals (ubuntu-latest, Node 22.x) ✅ success
precheck-pr / precheck ✅ success
Classify PR ✅ success
中文说明

代码审查

独立方案: 给定"将 Voice 限定到输入框工作区"的目标,我会添加一个纯函数解析器,将(capabilities、intendedCwd、sessionId、workspaces)映射为不可变的目标描述符;从 App 经 ChatEditor 传递到 VoiceButton;在录音启动时快照目标并通过代次身份拒绝过期回调;在 ChatPane 中为分屏解析每个面板的目标;并添加窄范围的 Vite 代理正则用于限定的 WebSocket 升级。

对比: PR 与此方案一致,并在多个方面更深入:选择器生命周期管理、按用户/工作区范围拆分的修订版本状态失效、结构化关闭码处理(1012 能力刷新、1013 容量保留、1006 重验证),以及布局效果时序防止 React 提交与浏览器事件之间的竞态。

未发现关键阻塞问题。无安全问题——bearer 子协议认证保留,令牌不出现在 URL 中,限定失败不回退到主工作区。代码遵循项目规范。

两个非阻塞观察:

  1. ChatEditor 和 ChatPane 的 disabled 属性添加是 Voice 路由的前提条件,合理包含。
  2. writeVoiceModelForTarget 在成功和错误路径都刷新修订版本和设置,这是有意为之。

测试

这是无人值守的 CI 运行——不在本地执行 PR 代码。证据来自 PR 自身在审查提交上的 CI 检查。

web-shell E2E 冒烟测试通过,包括新的次工作区 Voice 状态测试。完整单元测试套件(Ubuntu)通过。Windows 和 macOS 测试被跳过(fork PR)。

沙箱真实场景测试通道不可用(作者为外部贡献者,无写入权限)。维护者可在一次性容器中检出 PR 并驱动多工作区 Web Shell 会话来验证行为声明。

未验证:物理麦克风浏览器 E2E、Windows 和 Linux 行为、提供商凭据。

Qwen Code · qwen3.8-max-preview

Reviewed at c831feda1ee0335985e3b5313af7a3ac6dc9837e · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — solid implementation that completes a well-scoped integration; only non-blocking nit is a minor 1012 refresh-failure behavior deviation from the design doc.

This PR does one thing and does it well: it routes every existing Voice surface through the workspace that owns the composer. The pure resolver is clean and independently testable, the capture snapshot pattern correctly prevents stale callbacks from crossing owners, and the revision-based invalidation avoids unnecessary aborts across unrelated workspaces. The design doc is thorough without being bloated, and the rejected alternatives are well-reasoned.

My independent proposal for this problem was essentially the same architecture — pure resolver, target propagation, capture snapshots, per-pane resolution. The PR exceeds it in edge case handling: picker lifecycle invalidation, structured close-code responses, and layout-effect timing for owner changes. I did not find a simpler path it missed.

The scope is large (~1,446 production lines) but driven by the number of Voice surfaces that need workspace awareness. Every change in the diff serves the stated goal. The test coverage is comprehensive (~2,070 lines), and the E2E smoke test verifies the core routing claim.

Non-blocking nit: the onUnexpectedClose handler for code 1012 calls settleRefresh on both success and failure of refreshCapabilities, clearing capabilityRefreshFailed either way. The design doc says "If refresh fails, keep the local gate invalid instead of exposing Retry." In practice the code's behavior is arguably better — the subsequent status re-probe is itself a validity check — but it deviates from the stated design. Worth a brief note for the maintainer, not a blocker.

All CI checks passed on the reviewed commit. Approving. ✅

中文说明

置信度:4/5 —— 实现扎实,完成了一个范围明确的集成;唯一的非阻塞问题是 1012 刷新失败行为与设计文档的轻微偏差。

本 PR 只做一件事并做好:将每个现有 Voice 面路由到拥有输入框的工作区。纯函数解析器干净且可独立测试,录音快照模式正确防止过期回调跨越所有者,基于修订版本的失效避免了不相关工作区之间的不必要中断。

我的独立方案与此架构基本一致。PR 在边缘情况处理上超越了它。未发现更简路径。

范围较大(约 1,446 行生产代码),但由需要工作区感知的 Voice 面数量驱动。diff 中每项改动都服务于既定目标。测试覆盖全面(约 2,070 行),E2E 冒烟测试验证了核心路由声明。

非阻塞问题:1012 关闭码的 onUnexpectedClose 处理器在 refreshCapabilities 成功和失败时都调用 settleRefresh,与设计文档"刷新失败时保持本地门控无效"的描述有偏差。实际行为可能更好(后续状态重探本身就是有效性检查),但值得维护者注意。

所有 CI 检查在审查提交上通过。批准。✅

Qwen Code · qwen3.8-max-preview

Reviewed at c831feda1ee0335985e3b5313af7a3ac6dc9837e · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Thanks — deferring this non-blocking documentation correction. The implementation intentionally clears the refresh latch on either outcome and then performs a same-owner status probe, which remains fail-closed; keeping the latch set permanently caused the recovery regression covered by the current tests. Since this PR has already exceeded roughly five review rounds, the stale design sentence is not worth another code-review cycle.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no blockers. Suggestions are inline. 2 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.

中文说明

已审查——无阻断问题。 建议见行内评论。 2 条建议级发现无法锚定到改动行,已丢弃;此处无需进一步处理。

— qwen3.7-max via Qwen Code /review

Comment thread packages/web-shell/client/App.test.tsx
@wenshao

wenshao commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Review — feat(web-shell): Scope voice to composer workspace

Overview

The change adds a pure resolveVoiceWorkspaceTarget() resolver plus route/capability adapters under client/voice/, threads the resulting immutable descriptor from App and ChatPane through ChatEditor into VoiceButton, makes useVoiceCapture snapshot {generation, ownerKey, streamPath, callbacks} at start(), overlays only the voiceModel setting descriptor for a qualified target, and adds a narrow qualified Voice WebSocket rule to the Vite dev proxy.

The architecture is right. Ownership resolution is separated from per-operation capability gates, the capture-generation snapshot is the correct pattern for stale-callback rejection, workspaceKey includes the runtime id so a same-cwd re-registration invalidates the target, and no failure path retries a qualified request on a legacy route. The E2E test is a genuine oracle — it asserts GET /workspaces/secondary/voice fired and that /workspace/voice never did.

Verification performed

Checked out c831feda1 in an isolated worktree.

  • client/voice/ + App.test.tsx + ChatPane + SplitView + vite-config: 346 tests pass.
  • eslint on all changed source files: clean. prettier --check on all changed files: clean.
  • tsc --noEmit: only two pre-existing paginationError errors unrelated to this PR (stale symlinked SDK dist in my worktree).
  • 21-mutant matrix over the new gates. 15 killed, 6 survived — the survivors are below.

Findings

1. The untrusted-secondary fail-closed assertion is vacuousclient/voice/voice-workspace-target.test.ts:133

it('fails closed for unknown, ambiguous, and untrusted secondary targets') builds both fixtures on the same cwd:

const untrusted = { ...secondary, trusted: false };
const duplicate = { ...secondary, id: 'other' };   // same cwd as `secondary`

so matches.length === 2 and the resolver returns at the ambiguity gate (voice-workspace-target.ts:145) — the trust gate at line 148 is never reached. Deleting if (!workspace.trusted) return undefined; leaves all 346 tests green:

$ npx vitest run client/voice/voice-workspace-target.test.ts -t "fails closed"   # trust gate deleted
 ✓ voice/voice-workspace-target.test.ts (22 tests | 21 skipped)
      Tests  1 passed | 21 skipped (22)

The reverse also holds: relaxing matches.length !== 1matches.length < 1 also survives, because matches[0] is then the untrusted entry and the trust gate absorbs it. One test names two gates and pins neither. "Voice access for untrusted workspaces" is an explicit non-goal, so this is the highest-value gap here.

Fix — split into two independent cases:

// unique cwd, untrusted → no target (exercises line 148)
resolveVoiceWorkspaceTarget({ capabilities: capabilities({ workspaces: [primary, { ...secondary, trusted: false }] }), intendedCwd: secondary.cwd })
// two *trusted* entries on one cwd → no target (exercises line 145)
resolveVoiceWorkspaceTarget({ capabilities: capabilities({ workspaces: [primary, secondary, { ...secondary, id: 'other' }] }), intendedCwd: secondary.cwd })

2. The provider-response cwd echo is unpinnedclient/voice/voice-workspace-target.ts:243-248

it('rejects a response for the wrong workspace') covers loadVoiceStatus only. Neutralising the identical guard in loadVoiceProviders leaves the suite green. The design's own test strategy says "Qualified status and provider responses with a mismatched workspaceCwd are rejected" — the provider half is missing. This is the guard that stops a cross-runtime provider list from populating the model picker; it's a two-line mirror of the existing test.

3. The main composer's panel/view Voice gate is unpinnedclient/App.tsx:8200-8204

voiceTarget={activePanel !== null || mainView !== 'chat' ? undefined : mainVoiceTarget}

Replacing this with plain mainVoiceTarget leaves the suite green. I probed the mount state and the composer is not unmounted when the Settings panel opens (its props object is rebuilt on re-render, voiceTargetundefined, disabledtrue) — so this ternary is the only thing that aborts a live capture when a full-surface panel takes over, and it prevents a transcript landing in a composer the user can no longer see. App.test.tsx already records latestChatEditorProps.voiceTarget, so the test is ~4 lines.

4. VoiceButton's settings-version owner guard is unpinnedclient/voice/VoiceButton.tsx:82-87

Collapsing the guard to const settingsVersion = signals?.settingsVersion; leaves the suite green. That value feeds queryKey → the effect resets the gate to enabled: false → the layout effect at line 276 aborts an in-flight capture. For a draft/pre-session composer bound to a selected secondary workspace, this guard is what keeps an unrelated primary-scope settings write from killing dictation mid-sentence. The design's test strategy lists this case verbatim ("A settings-version signal from a different workspace does not revalidate or abort the current target").

5. The picker-selection owner mismatch branch is unreachable in testsclient/App.tsx:6661

if (!target || target.ownerKey !== mainVoiceTargetRef.current?.ownerKey) survives removal. It is a race backstop behind the close-on-owner-change effect, so severity is low, but the "picker writes to workspace B" mitigation named in the design's Risks section currently has no test of its own.

Notes (not blocking)

  • Composer disabled widening is an out-of-band UX change. App.tsx:8188-8193 adds interactionBlocked || approvalOverlayActive and ChatPane.tsx:726 adds disabled={approvalActive}. disabled reaches EditorView.editable.of(!disabled) in useComposerCore.ts:2723, so it makes the editor read-only, not just Voice-inert. For the main view this is mostly redundant (isDisabled already covers pendingApproval, and the dialog/panel cases cover the composer anyway), but for split panes it is new: a pane composer was always editable before and is now read-only while that pane has a pending approval. That matches main-view behaviour so it reads as intentional, but it is a composer behaviour change and the PR body's "does not alter the existing Voice UX" line doesn't mention it — worth a sentence under Risk & Scope. If you'd rather keep the blast radius Voice-local, ChatEditor already has dialogOpen in scope at the VoiceButton call site (line 2337), so disabled={disabled || dialogOpen} there would gate starting a capture without touching editability.
  • loadVoiceSettings (line 251) is the only adapter without the workspaceCwd echo check that loadVoiceStatus/loadVoiceProviders have. The design table only requires it for status/providers, so this is consistent with the doc — just flagging the asymmetry in case it was unintentional.
  • Two new user-facing toast strings are hard-coded English (App.tsx:3916 and 6664). There is precedent in this file (5047, 5102), so this is a nit, not a convention violation.
  • useVoiceWorkspaceSettings is enabled whenever a qualified target supports settings, not only while the Settings panel is open, so a qualified GET /workspaces/:ws/settings fires on every matching settingsVersion bump. It feeds currentVoiceModel too, so this looks deliberate; noting it as a cost.
  • The dev-proxy rule is placed before /workspace and its regex is asserted against both /workspaces/id/voice/stream and /voice/voiceModels.ts — good. encodeURIComponent on a cwd selector yields no literal /, so [^/]+ still matches the cwd-fallback path.

Verdict

Sound design, careful lifecycle handling, and the shipped E2E is a real no-fallback oracle. The gaps are all test coverage of fail-closed gates, and finding 1 is the one I'd want fixed before merge because the assertion that reads as "untrusted workspaces get no Voice" currently proves nothing. Findings 2-4 are each a handful of lines against harnesses that already exist in these files.

中文说明

概述

本 PR 新增了纯函数 resolveVoiceWorkspaceTarget() 解析器以及路由/能力适配器,把不可变的目标描述符从 AppChatPaneChatEditor 传给 VoiceButtonuseVoiceCapturestart() 时快照 {代次, ownerKey, streamPath, 回调};对限定目标只覆盖 voiceModel 这一项设置描述符;并为 Vite 开发代理增加了一条窄范围的限定 Voice WebSocket 规则。

架构方向正确:所有权解析与逐操作能力门分离;录音代次快照是拒绝过期回调的正确做法;workspaceKey 含运行时 id,因此同一 cwd 重新注册会使旧目标失效;任何失败路径都不会把限定请求回退到旧路由。E2E 是真正的判据——它既断言发出了 GET /workspaces/secondary/voice,也断言从未请求 /workspace/voice

已完成的验证

在隔离 worktree 中检出 c831feda1

  • client/voice/ + App.test.tsx + ChatPane + SplitView + vite-config346 个测试通过
  • 改动文件的 eslintprettier --check:均干净。
  • tsc --noEmit:仅有两处与本 PR 无关的既有 paginationError 报错(我的 worktree 中 SDK dist 为软链旧产物)。
  • 针对新增各道门做了 21 个变异体矩阵,杀死 15 个,存活 6 个,即下列问题。

问题

1. “不可信次工作区失败关闭”的断言是空转的voice-workspace-target.test.ts:133

it('fails closed for unknown, ambiguous, and untrusted secondary targets') 里的两个 fixture 用了同一个 cwd,因此 matches.length === 2,解析器在歧义门voice-workspace-target.ts:145)就返回了,第 148 行的信任门根本走不到。删掉 if (!workspace.trusted) return undefined; 后 346 个测试全绿。反向也成立:把 matches.length !== 1 放宽成 < 1 同样存活,因为此时 matches[0] 是不可信条目,被信任门吸收。一个测试声称覆盖两道门,实际一道都没钉住。“不为不可信工作区提供 Voice”是明确的非目标,所以这是本次最值得修的缺口。建议拆成两个独立用例:唯一 cwd 的不可信次工作区 → 无目标;两个可信条目共用同一 cwd → 无目标。

2. provider 响应的 cwd 回显校验未被钉住voice-workspace-target.ts:243-248

it('rejects a response for the wrong workspace') 只覆盖了 loadVoiceStatus。使 loadVoiceProviders 中同样的校验失效后测试仍全绿。设计文档的测试策略明确写了“限定 status 和 provider 响应中 workspaceCwd 不匹配时应被拒绝”,provider 这一半缺失。这道门阻止跨运行时的 provider 列表填充模型选择器,补测只需照抄现有用例。

3. 主输入框的面板/视图 Voice 门未被钉住App.tsx:8200-8204

把该三元表达式换成裸的 mainVoiceTarget 后测试全绿。我实测确认:打开 Settings 面板时输入框并未卸载(props 对象在重渲染中被重建,voiceTarget 变为 undefineddisabled 变为 true),因此这个三元式是全屏面板接管时中止录音的唯一保障,也避免转录落进用户已看不到的输入框。App.test.tsx 已经记录了 latestChatEditorProps.voiceTarget,补测约 4 行。

4. VoiceButton 的 settingsVersion 归属守卫未被钉住VoiceButton.tsx:82-87

把守卫收缩成 const settingsVersion = signals?.settingsVersion; 后测试全绿。该值进入 queryKey → 效果把门重置为 enabled: false → 第 276 行的 layout effect 中止进行中的录音。对于绑定到所选次工作区的草稿(未建会话)输入框,这道守卫正是防止无关的主工作区级设置写入把用户说到一半的录音打断。设计文档测试策略中原样列出了该用例。

5. 选择器归属不匹配分支在测试中不可达App.tsx:6661

if (!target || target.ownerKey !== mainVoiceTargetRef.current?.ownerKey) 被移除后仍存活。它是“归属变化即关闭选择器”效果之后的竞态兜底,严重度低,但设计文档风险章节点名的这条缓解措施目前没有自己的测试。

说明(非阻塞)

  • 输入框 disabled 的扩大是范围之外的 UX 变更。 App.tsx:8188-8193 加入了 interactionBlocked || approvalOverlayActiveChatPane.tsx:726 加入了 disabled={approvalActive}disabled 会传到 useComposerCore.ts:2723EditorView.editable.of(!disabled),即让编辑器变为只读,而不只是禁用 Voice。主视图基本是冗余的(isDisabled 已覆盖 pendingApproval,弹窗/面板场景下输入框本来也够不到),但分屏是新行为:pane 输入框此前始终可编辑,现在在该 pane 有待批准工具调用时变为只读。这与主视图行为一致、看起来是有意为之,但毕竟是输入框行为变更,而 PR 描述中“不改变既有 Voice UX”一句未提及,建议在“风险与范围”里补一句。若希望把影响面限制在 Voice 内,ChatEditorVoiceButton 调用点(2337 行)已有 dialogOpen,改成 disabled={disabled || dialogOpen} 即可在不影响可编辑性的前提下阻止开始录音。
  • loadVoiceSettings(251 行)是唯一没有 workspaceCwd 回显校验的适配器。设计表格只对 status/providers 提出该要求,因此与文档一致——仅提示这处不对称,以免是无意遗漏。
  • 两条新的面向用户的 toast 文案是硬编码英文(App.tsx:39166664)。该文件已有先例(50475102),属小瑕疵而非违反约定。
  • useVoiceWorkspaceSettings 只要限定目标支持设置就会启用,并非仅在 Settings 面板打开时,因此每次匹配的 settingsVersion 变化都会触发一次限定 GET /workspaces/:ws/settings。它同时供给 currentVoiceModel,看起来是有意设计;仅记录其成本。
  • 开发代理规则放在了 /workspace 之前,且其正则同时对 /workspaces/id/voice/stream/voice/voiceModels.ts 做了断言——很好。cwd 选择器经 encodeURIComponent 后不含字面 /,因此 [^/]+ 对 cwd 回退路径同样匹配。

结论

设计扎实,生命周期处理细致,随 PR 提交的 E2E 是真正的“无回退”判据。所有缺口都集中在失败关闭各道门的测试覆盖上;其中第 1 条我建议合并前修复,因为那条读起来像“不可信工作区拿不到 Voice”的断言目前什么都没证明。第 2–4 条各自只需在这些文件已有的测试脚手架上补几行。

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Follow-up to the mutation audit in #7754 (comment):

Item Judgment Action
1. Vacuous trust/ambiguity fail-closed test Agree Fixed in 4d3bfa55b5. The unique untrusted target and the two-trusted-target ambiguity now have independent tests, so either guard regressing fails its own case.
2. Provider-response cwd echo test Agree that this is a coverage gap Deferred. The production guard is present and no behavioral defect was identified; after roughly five review rounds, additional non-critical coverage is being kept out of this PR.
3. Main composer panel/view Voice gate test Agree that this is a coverage gap Deferred for the same scope-control reason; the production gate remains intact.
4. settingsVersion owner guard test Agree that this is a coverage gap Deferred for the same scope-control reason; the production guard remains intact.
5. Picker owner-mismatch race test Agree that this is a low-severity coverage gap Deferred for the same scope-control reason; the race backstop remains intact.
Non-blocking notes Acknowledged No further code expansion in this review round. The split-pane approval behavior is intentional and consistent with the main composer; the remaining notes do not demonstrate a current correctness, security, data-loss, or regression defect.

Validation for the accepted fix: the focused Voice target suite passes (23/23), and the changed test file passes Prettier and ESLint.

Resolved 0/0 review threads for this update: the audit was posted as a top-level comment rather than an inline review thread.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unresolved, please confirm: [Critical] App.tsx voiceTarget clearing on inline panels (activePanel !== null) — blocker comment 3654175742: approvals fixed but inline panels still clear; author's reply addressed only dialogs/approvals, intent for inline panels cannot be determined from the code

Not reviewed: reverse audit round 2 — auditors ran with hand-written prompts instead of CLI-built prompts.

Not reviewed: reverse audit — an auditor ran and opened its brief, but no agent was launched with the prompt the CLI built — the launch was written by hand, and what the agent was actually asked is not what this skill certifies.

中文说明

未决,请确认:[Critical] App.tsx voiceTarget clearing on inline panels (activePanel !== null) — blocker comment 3654175742: approvals fixed but inline panels still clear; author's reply addressed only dialogs/approvals, intent for inline panels cannot be determined from the code

未审查:reverse audit round 2 — auditors ran with hand-written prompts instead of CLI-built prompts。

未审查:反向审计——有审计 agent 运行并打开了自己的 brief,但没有 agent 是用 CLI 构建的 prompt 启动的——启动 prompt 是手写的,agent 实际被要求做的并不是本 skill 所认证的内容。

— qwen3.7-max via Qwen Code /review

Comment on lines +8200 to +8204
voiceTarget={
activePanel !== null || mainView !== 'chat'
? undefined
: mainVoiceTarget
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Inline panels (Settings, Tools, Memory, Sessions, etc.) still clear voiceTarget and abort an in-flight recording — Failure scenario: user presses mic → opens Settings panel → activePanel becomes non-null → voiceTarget becomes undefineduseVoiceCapture's targetIdentity layout effect calls abort() → recording is discarded with no user notification.

The approval-overlay fix correctly moved approvalOverlayActive to the disabled prop, but activePanel !== null remains in the voiceTarget condition. Modal dialogs (Shortcuts, Help, Theme) don't set activePanel, so they're also fine. But the inline panels listed in activePanel's type union ('settings' | 'status' | 'sessions' | 'extensions' | 'mcp' | 'skills' | 'plugins' | 'agents') still trigger the clearing. No test exercises opening an inline panel during active capture.

Note: the split-view ChatPane uses only hidden ? undefined : voiceTarget, which correctly keeps the target when the pane is merely covered (not hidden). The main composer's inline panels are analogous — the composer stays mounted and visible behind the panel.

中文说明

内联面板(设置、工具、记忆、会话等)仍然会清空 voiceTarget 并中止正在进行的录音。失败场景:用户点击麦克风 → 打开设置面板 → activePanel 变为非空 → voiceTarget 变为 undefineduseVoiceCapturetargetIdentity layout effect 调用 abort() → 录音被丢弃且无提示。

审批浮层的修复已正确将 approvalOverlayActive 移至 disabled prop,但 activePanel !== null 仍在 voiceTarget 条件中。模态对话框(快捷键、帮助、主题)不设置 activePanel,所以没问题。但 activePanel 类型联合中的内联面板仍会触发清空。分屏 ChatPane 仅使用 hidden ? undefined : voiceTarget,主输入框的内联面板场景类似——输入框仍然挂载且在面板后部可见。

Suggested change
voiceTarget={
activePanel !== null || mainView !== 'chat'
? undefined
: mainVoiceTarget
}
voiceTarget={
mainView !== 'chat'
? undefined
: mainVoiceTarget
}

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — won't take this one. activePanel replaces the chat surface: chatViewWrap receives chatViewHidden (display: none) plus aria-hidden, so the composer is mounted only to preserve state and is not visible or interactive behind the panel. Keeping capture alive would leave the microphone and transcript delivery running against an invisible composer. Clearing the target is the intentional fail-safe; normal dialogs and approvals preserve the target because they do not replace the owning chat surface. This also matches ChatPane, which clears the target when a pane is actually hidden.

Comment thread packages/web-shell/client/components/SplitView.tsx
Comment thread packages/web-shell/client/voice/useVoiceCapture.ts
Comment thread packages/web-shell/client/App.tsx
'/daemon/status': daemonProxy,
'/session': daemonProxy,
'/permission': daemonProxy,
[QUALIFIED_VOICE_STREAM_PROXY]: { ...daemonProxy, ws: true },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Vite dev proxy only covers the workspace-qualified WebSocket stream path — Concrete cost: the proxy entry [QUALIFIED_VOICE_STREAM_PROXY] matches only /workspaces/<id>/voice/stream. The workspace-qualified REST routes (GET /workspaces/<id>/voice, GET /workspaces/<id>/providers, GET/POST /workspaces/<id>/settings) have no proxy entry. When getDaemonBaseUrl() returns '' in dev (no ?daemon= param), these REST requests hit the vite dev server, get the SPA fallback index.html, and fail JSON parsing — silently disabling workspace-qualified Voice status, model picker, and settings in development. Primary workspaces are unaffected (they use the legacy /workspace proxy). The E2E smoke test doesn't catch this because the mock daemon intercepts at the network level.

中文说明

Vite 开发代理仅覆盖了工作区限定的 WebSocket 流路径。工作区限定的 REST 路由(voice、providers、settings)没有代理条目。当 getDaemonBaseUrl() 在开发环境中返回空字符串时,这些 REST 请求会命中 vite 开发服务器并获得 SPA 回退的 index.html,导致 JSON 解析失败——静默禁用工作区限定的 Voice 状态、模型选择器和设置。主工作区不受影响(使用旧的 /workspace 代理)。

Suggested change
[QUALIFIED_VOICE_STREAM_PROXY]: { ...daemonProxy, ws: true },
'^/workspaces/[^/]+/(voice|providers|settings)/?$': daemonProxy,
[QUALIFIED_VOICE_STREAM_PROXY]: { ...daemonProxy, ws: true },

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — won't take this one. Vite matches a non-regex proxy context with url.startsWith(context), so /workspaces/:id/voice, /providers, and /settings already match the existing /workspace entry. The dedicated qualified Voice rule is needed only for the WebSocket upgrade because the broad REST proxy does not enable ws: true; adding another REST rule would duplicate existing coverage.

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Review follow-up for 4d3bfa55b5:

Item Judgment Action
Inline panels clear voiceTarget Disagree Kept unchanged. Panels replace and hide the chat surface with display: none and aria-hidden; aborting capture prevents an invisible composer from retaining microphone/transcript ownership.
SplitView revision default identity Agree on the minor performance concern Deferred because the production caller supplies stable state and no correctness regression exists.
Raw 1013 close reason Partially agree as a malformed-peer UX improvement Deferred. The real daemon sends a friendly terminal error frame before its 1013 close, so users do not see the raw close reason on the supported path.
Failed Voice-model write reconciliation test Agree that this is a coverage gap Deferred; the production reconciliation is present and no current defect was identified.
Qualified REST dev proxy Disagree No change. Vite string proxy contexts use prefix matching, so /workspaces/... already matches /workspace; only the WebSocket stream needs the additional ws: true rule.

The PR is already beyond roughly five review rounds, so only demonstrated correctness, security, data-loss, or regression fixes are being added. No code change was warranted by this batch.

doudouOUC and others added 3 commits July 27, 2026 20:39
Route voice status, settings, model discovery, and streaming through the workspace that owns each main or split-view composer while preserving legacy primary behavior.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Prevent the Voice-only legacy workspace fallback from activating pre-session git polling, and cover both behaviors together.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC
doudouOUC force-pushed the codex/issue-6972-voice-design branch from 4d3bfa5 to 6286dc5 Compare July 27, 2026 12:46
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Rebased this PR onto origin/main at 8785216be5 and pushed the rewritten branch as 6286dc5085.

Action Result
Fixed Resolved the non-semantic App.test.tsx conflict by preserving both main's background-task test state and this PR's workspace Voice settings state.
Pushed back No disposition changes; the two previously rebutted review threads remain open for reviewer follow-up.
Deferred No new deferrals in this update.

Validation:

  • Focused Web Shell suite: 8 files, 382 tests passed.
  • Full repository build passed after syncing the dependencies added on main.
  • Full repository lint and typecheck passed.
  • Prettier check and git diff --check passed.
  • The four unchanged Web Shell test files that failed in the default local Node environment (48 tests, because localStorage was unavailable) passed 48/48 when rerun with an explicit Node local-storage file.
  • Two consecutive post-rebase audits found no new actionable issues.

Newly resolved review threads: 0. Existing pushed-back threads intentionally remain unresolved.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM! ✅

中文说明

未发现问题。LGTM!✅

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Round 2 — local verification of 6286dc5085

Follow-up to my round-1 review (head c831feda1). The head has since moved to 6286dc5085 — a rebase onto 8785216be5 plus test(web-shell): Pin Voice trust and ambiguity gates. Everything below was re-run against the new head in a fresh detached worktree with its own npm ci (no borrowed node_modules), and every carried-over finding was re-tested rather than repeated.

Round-1 findings → status at this head

# Round-1 finding Status at 6286dc5085 Evidence
1 Untrusted-secondary fail-closed assertion is vacuous (one it() named two gates, pinned neither) Fixed — each gate now dies on its own test A/B below
2 loadVoiceProviders cwd-echo guard unpinned Still unpinned (author deferred) — production guard present and correct mutant F2 survives the full suite
3 Main composer panel/view Voice gate unpinned Still unpinned (deferred) — gate present at App.tsx:8487 mutant F3 survives
4 VoiceButton settingsVersion owner guard unpinned Still unpinned (deferred) — guard present mutant F4 survives
5 Picker owner-mismatch backstop unpinned Still unpinned (deferred), low severity mutant F5 survives

Findings 2–5 are coverage gaps over correct code, not defects: I re-read each guard at this head and re-ran each mutation — the guards are all present and behave as round 1 established; only the tests that would pin them are missing. Nothing here blocks merge.

Rebase integrity — what actually changed since round 1

  • Every production file this PR touches is byte-identical (sha256) at c831feda1 and 6286dc5085, except App.tsx.
  • App.tsx's only delta is main's monitor-panel work: the diff between the two heads contains zero Voice lines, and the PR's own 136 added Voice lines in that file hash identically at both heads (43021b8c…).
  • The single authored change since round 1 is voice-workspace-target.test.ts (+16/−6).

So all round-1 correctness evidence carries over verbatim, and every measured delta below is attributable to the test change alone.

1. Finding 1 is genuinely fixed — same production file, test file swapped

I ran the two gate mutations over the unmodified production file against the round-1 test file and against this head's test file. Old: both mutants survive (the assertion proved nothing). New: each mutant is killed, by the test whose name states that gate.

A/B mutation of the trust and ambiguity gates

The failure detail is the useful part: with the trust gate removed, resolveVoiceWorkspaceTarget hands an untrusted workspace a live workspace-qualified target with a real streamPath. That is exactly the non-goal the test now pins.

2. Full-suite mutation matrix at this head (10 mutants × 2 325 tests)

Mutation matrix

  • Controls C1/C2 (guards that round 1 found already covered) are still killed — the test-only commit traded no coverage away.
  • Each new test is killed by its own mutant and no other; no accidental cross-kill.
  • N1 was newly probed this round and is an equivalent mutant, proven by running both variants: if (!cwd) return undefined; only changes behaviour for a capabilities payload whose workspace entry has no cwd, which the schema forbids. Not a gap — listing it so the matrix isn't read as five outstanding holes.

3. The pinned gate, exercised end-to-end in a browser

A reviewer-side spec driving the PR's own mockDaemon harness, flipping exactly one bit — the secondary workspace's trusted flag:

Trusted vs untrusted secondary composer

Trusted: mic renders, GET /workspaces/secondary/voice fires, /workspace/voice is never requested. Untrusted: same composer, no mic, and no /voice request of any kind — fail-closed in the running app, not just in the resolver unit test.

4. Gates re-run locally

Local gates

Also checked: the PR trial-merges onto current main (6f8376ee1) with 0 conflicts, and the full web-shell suite passes on that merge result too. The ESLint pass was confirmed live with a planted defect (exit 1), so the clean run isn't a silently broken linter. FWIW the four files you reported failing locally for a missing localStorage passed here with no special flags (Node 22.23.1, plain vitest run).

Reproduce
git worktree add --detach /tmp/wt-7754 6286dc5085
cd /tmp/wt-7754 && npm ci                       # prepare also builds sdk/webui dist, which web-shell vitest needs
cd packages/web-shell
../../node_modules/.bin/vitest run --config vitest.config.ts        # 141 files / 2325 tests
../../node_modules/.bin/tsc -p tsconfig.json --noEmit
../../node_modules/.bin/playwright test --config playwright.config.ts --project chromium --grep @smoke

# gate A/B: same production file, swap only the test file
git show c831feda1:packages/web-shell/client/voice/voice-workspace-target.test.ts > /tmp/old.test.ts
# then delete `if (!workspace.trusted) return undefined;` in voice-workspace-target.ts and run both variants

Verdict

The one item I asked to have fixed before merge is fixed, and fixed properly — the new tests are independently load-bearing, the previously covered gates stayed covered, and the rebase preserved the PR's Voice implementation byte-for-byte. Suite, typecheck, lint, format, build, and the E2E oracle are all green locally at this head, and it merges cleanly onto current main.

Ready to merge from my side. Findings 2–5 remain worthwhile as a small follow-up (four tests, all against harnesses that already exist in these files) — I'm happy for them to land after this PR rather than extending a sixth review round.

中文说明

第 2 轮 —— 对 6286dc5085 的本地验证

这是对我 第 1 轮评审(当时 head 为 c831feda1)的复验。此后 head 变为 6286dc5085:一次 rebase 到 8785216be5,外加 test(web-shell): Pin Voice trust and ambiguity gates。以下所有验证都在新 head 上、于全新的 detached worktree 中独立 npm ci 后重跑(未借用其它检出的 node_modules);每一条沿用的问题都重新实测,而非照搬结论。

第 1 轮问题 → 当前 head 状态

# 第 1 轮问题 6286dc5085 上的状态 证据
1 不可信次工作区的失败关闭断言是空转的(一个 it() 声称覆盖两道门,实际一道都没钉住) 已修复 —— 两道门各自被独立测试钉住 下方 A/B
2 loadVoiceProviders 的 cwd 回显校验未被钉住 仍未钉住(作者选择推迟)—— 生产代码中的校验存在且正确 变异体 F2 在全量套件下存活
3 主输入框面板/视图 Voice 门未被钉住 仍未钉住(推迟)—— 该门在 App.tsx:8487 存在 变异体 F3 存活
4 VoiceButtonsettingsVersion 归属守卫未被钉住 仍未钉住(推迟)—— 守卫存在 变异体 F4 存活
5 选择器归属不匹配兜底未被钉住 仍未钉住(推迟),严重度低 变异体 F5 存活

第 2–5 条都是针对正确代码的测试覆盖缺口,不是缺陷:我在当前 head 逐条重读了守卫并重跑了变异,生产行为均与设计文档一致。这些都不阻塞合并。

Rebase 完整性 —— 相比第 1 轮究竟变了什么

  • 本 PR 涉及的所有生产文件在 c831feda16286dc5085sha256 完全一致,仅 App.tsx 除外。
  • App.tsx 的差异全部来自 main 的 monitor 面板改动:两个 head 之间的 diff 中 没有任何一行 Voice 代码,且本 PR 在该文件中新增的 136 行 Voice 代码在两个 head 上哈希一致(43021b8c…)。
  • 第 1 轮以来唯一的作者改动是 voice-workspace-target.test.ts(+16/−6)。

因此第 1 轮的正确性证据可原样沿用,下面测得的所有差异都只能归因于这次测试改动。

1. 第 1 条确已修复 —— 生产文件不变,只换测试文件

我在未改动的生产文件上施加两个门的变异,分别用第 1 轮的测试文件与当前 head 的测试文件运行。旧版:两个变异体都存活(断言什么都没证明)。新版:各自被“名字正好说明该门”的测试杀死。

信任门与歧义门的 A/B 变异

最有价值的是失败详情:删掉信任门后,resolveVoiceWorkspaceTarget 会把一个真实可用的 workspace-qualified 目标(含真实 streamPath)交给不可信工作区——这正是新测试所钉住的非目标。

2. 当前 head 的全量变异矩阵(10 个变异体 × 2325 个测试)

变异矩阵

  • 对照组 C1/C2(第 1 轮已被覆盖的守卫)依然被杀死——这次纯测试提交没有以牺牲其它覆盖为代价。
  • 每个新测试只被自己对应的变异体杀死,没有误杀、没有交叉覆盖。
  • N1 是本轮新增探测,属于等价变异体,并已实跑证明:if (!cwd) return undefined; 只有在 capabilities 载荷中某个工作区条目缺少 cwd(schema 不允许)时才会产生行为差异。它不是缺口——列出来是为了避免把矩阵读成“还有五个洞”。

3. 被钉住的这道门在真实浏览器中的端到端表现

一份评审侧 spec,驱动本 PR 自带的 mockDaemon 脚手架,只翻转一个比特——次工作区的 trusted 标志:

可信 vs 不可信次工作区输入框

可信:麦克风渲染,发出 GET /workspaces/secondary/voice,从未请求 /workspace/voice。不可信:同一个输入框,无麦克风,且没有任何 /voice 请求——在运行中的应用里就是失败关闭,而不只是解析器单测里的行为。

4. 本地重跑的各道关卡

本地关卡

另外确认:本 PR 与当前 main6f8376ee1)试合并 0 冲突,且在合并结果上 web-shell 全量套件同样通过。ESLint 的“干净”结论用植入缺陷做了正向对照(退出码 1),确保不是 linter 静默失效。另外,你提到在本地默认 Node 环境下因缺少 localStorage 而失败的那 4 个文件,在这里无需任何额外参数即可通过(Node 22.23.1,直接 vitest run)。

结论

我唯一要求合并前修复的问题已经修复,而且修得扎实:新测试各自独立生效,原有已覆盖的门没有退化,rebase 逐字节保留了本 PR 的 Voice 实现。当前 head 上套件、类型检查、lint、格式化、构建与 E2E 判据在本地全绿,且能干净合并到当前 main

从我这边看可以合并。 第 2–5 条仍值得作为一个小的后续(四个测试,且都能复用这些文件里已有的脚手架)——我同意它们在本 PR 之后再补,而不是再开第六轮评审。

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough fresh-head verification. I independently rechecked the four remaining guards at 6286dc5085 and agree that findings 2–5 are non-blocking coverage gaps over correct production behavior. They will remain deferred to a focused follow-up rather than extending this PR into a sixth review round; no code change is needed here.

@doudouOUC
doudouOUC added this pull request to the merge queue Jul 27, 2026
Merged via the queue into QwenLM:main with commit 2210a18 Jul 27, 2026
41 checks passed
qwen-code-dev-bot pushed a commit that referenced this pull request Jul 27, 2026
Reconcile the workspace-scoped voice refactor (#7754) with the hold-mode
PCM buffering and deferred-stop work: port the buffer/overflow/finalize
logic onto the snapshot-based capture lifecycle, keep the required target
prop wired through the button tests, and assert the icon/pill button reuses
one DOM node so pointer capture survives the recording transition.
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.1.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(web-shell): Add secondary-workspace Voice controls

3 participants