Repository navigation
fix(web-shell): make mobile access opt-in and simplify split composer - #12537
Conversation
E2E and regression test report / E2E 与回归测试报告
|
chiga0
left a comment
There was a problem hiding this comment.
Review — fix(web-shell): make mobile access opt-in and simplify split composer
Tier: Standard. Scope: packages/web-shell only — no persisted format, wire protocol, or auth changes.
Scope exclusions: Windows/Linux execution not run (no host) — no environment-dependent behaviour changed.
Findings
No blockers.
Minor — documentation gap (packages/web-shell/README.md:422)
showMobileAccess is added as a free-form paragraph after the options table. A host scanning the header row for sub-options will not find the default-off opt-in. Suggest moving this into a note under the header row or adding a WebShellChatHeaderOptions sub-table, so the breaking-change default is co-located with the prop it belongs to.
What was checked
Class 1 (contract asymmetry): All three call-sites that expose onOpenLocalControlSettings are gated on showMobileAccess && workspaceContextActive — defaultPaneHeaderActions (App.tsx:9274), the renderChatHeader spread (App.tsx:19186), and the built-in ChatHeader prop (App.tsx:19237). No fourth site found at this head.
Class 2 (API compatibility): showMobileAccess defaults to false, changing existing embedded-consumer behaviour. The PR explicitly acknowledges this and provides migration notes ("embedded hosts that want the existing entry should set header.showMobileAccess: true"). main.tsx opts in with showMobileAccess: true, preserving the standalone app's behaviour.
Class 5 (test validity): The new tests in App.test.tsx would fail if the showMobileAccess gating were reverted — the [aria-label="Mobile access"] query would find the button, and onOpenLocalControlSettings would not be undefined. The split-header toggle covers off→on→off. The ChatPane test distinguishes non-embedded (header label, no toolbar chip) from embedded (no header, toolbar chip), matching SideTaskPanel.tsx:344 which passes embedded to ChatPane.
Class 10 (stated intent): Verified: workspaceLabel (ChatPane.tsx:1502) derives from showWorkspaceChip; the header at :1537 renders it when !embedded. The toolbar chip at :1483 now requires embedded && showWorkspaceChip, so non-embedded split panes lose the chip while keeping the header label. Side-task panes (embedded=true via SideTaskPanel:344) retain the chip. Matches the PR's stated intent.
No blocking findings. Approval blockers: none.
Reviewed with AI assistance.
| | `onAssistantTurnSettled` | `(event: WebShellAssistantTurnSettledEvent) => void` | daemon 权威终态提交后触发;多个 provider 可能重复上报,宿主按 `(sessionId, promptId)` 去重 | | ||
| | `settings` | `WebShellSettingsOptions` | 可选。控制原生 `/settings` 页面的呈现;见 [原生设置呈现](#原生设置呈现)。 | | ||
|
|
||
| 移动访问二维码入口由 `header.showMobileAccess?: boolean` 控制,默认隐藏,适用于主聊天和分屏页头。独立入口 `main.tsx` 显式设为 `true`,保留本地 Qwen Code 用户的入口。 |
There was a problem hiding this comment.
Minor — documentation gap: showMobileAccess is added as a free-form paragraph after the options table. A host scanning the header row for sub-options will not find the default-off opt-in. Suggest moving the content into a note under the header row, or adding a WebShellChatHeaderOptions sub-table, so the breaking-change default is co-located with the prop it belongs to.
yiliang114
left a comment
There was a problem hiding this comment.
Verified the blast radius of the default flip, since that's the only thing that could make this unsafe:
- The default-off cannot silently remove anything in-repo: the only embedded consumer of
@qwen-code/web-shellin this repo isvscode-ide-companion, and its QR entry is unreachable today — it passesheader={{ items: [] }}(sochatHeaderEnabledis false and neitheronOpenLocalControlSettingspath renders) and has no split view (sodefaultPaneHeaderActionsnever mounts). The only host whose behavior changes is an out-of-tree one, and the PR body declares the breaking change plus the one-line migration (header.showMobileAccess: true).main.tsxopts in explicitly, preserving the local app's entry in both single-chat and split headers. - The option lives on the right type:
WebShellChatHeaderOptions.showMobileAccessis where a host scanningheaderfor sub-options will look — which also covers the substance of the open README thread (co-locating the note under theheaderrow would finish it, but that's documentation polish, not a blocker). - All three render points gate on the same flag (pane actions + both chat-header paths), with tests for default-off, explicit on, explicit off again, and the standalone restriction preserved.
- The split-composer simplification keeps the right information in the right place: the workspace chip now shows only for embedded headerless panes (where it's the only workspace indicator), while split panes keep the workspace in their header labels. The updated test asserts both directions of that split/embedded distinction.
CI green on this head (review-pr lane still running — bot infrastructure, not a gate); web-shell E2E Smoke completed/success. chiga0 has already approved this head.
Executed verification at
|
| file | bytes | blob sha |
|---|---|---|
client/App.tsx |
806,092 | 687e8dc278712395 |
client/components/ChatPane.tsx |
70,026 | 36529b64dcacaf31 |
client/customization.tsx |
23,664 | 2bd49d8f230ccef7 |
client/main.tsx |
19,922 | a1cdc6f796b71964 |
client/App.test.tsx |
1,337,711 | c600cb6f5fdca533 |
client/components/ChatPane.test.tsx |
120,694 | 37bfc41e1d747719 |
App.test.tsx at 1.34 MB is above the REST contents route's inline cap, where the API answers HTTP 200 with encoding:"none" and an empty content — a void body that still reports a truthful size and sha. It came through git show instead, which is byte-faithful at any size. The four base-arm production blobs came through contents?ref=<base> (all under the cap), each asserted SIZE_MATCH=BLOBSHA_MATCH=True VOID=False NUL=0, and each byte delta closes against its own patch: App.tsx −397 for +13/−7, ChatPane.tsx −549 for +8/−14, customization.tsx +107 for +2/−0, main.tsx +42 for +1/−0.
Four cells
Runner invoked as node <tree>/node_modules/vitest/vitest.mjs run — never npx/npm run, which injects an npm_config_* family that can shadow a test's own uppercase env. npm-ish env keys = [].
| cell | source | tests | ChatPane.test.tsx |
App.test.tsx -t "Mobile access" |
|---|---|---|---|---|
| A | head | head | 158 passed (158) rc=0 | 2 passed | 1028 skipped (1030) rc=0 |
| B | base production | head | 1 failed | 157 passed (158) rc=1 | 2 failed | 1028 skipped (1030) rc=1 |
| C | head + one-token mutation | head | 158 passed (158) rc=0 | 2 failed | 1028 skipped (1030) rc=1 |
| D | restored | head | 158 passed (158) rc=0 | 2 passed | 1028 skipped (1030) rc=0 |
Failures across the four cells: 0 → 3 → 2 → 0.
Cell B is what makes A mean anything. Reverting only the four production files to base makes the head tests fail, and all three failures are AssertionError, not resolution failures (is not a function = 0, Cannot find module = 0, is not exported = 0, counted over both streams). The failing titles are exactly the ones this PR adds or rewrites:
hides Mobile access by default in built-in and custom chat headers—expected <button type="button" …></button> to be null, i.e. at base the QR button is rendered where the new test expects null.hides the split header Mobile access entry by default and when disabled— same assertion.keeps the workspace in the split header and only shows the toolbar chip when embedded—expected [ 'addMenu', 'approvalMode', …(4) ] to not include 'workspace'.
Cell C is scoped, which is the strongest form of the reachability witness. Mutating one token — const showMobileAccess = header?.showMobileAccess ?? false; → ?? true (anchor uniqueness asserted before = 1, and after: original = 0, mutated = 1) — fails exactly the two tests that own the changed default and leaves all 158 ChatPane tests green. A mutation that failed everything would be ambiguous; one that fails only its own subject proves the harness reaches that subject and nothing else.
Cell D restores both suites to green, with files differing from the head blobs = [] and a mutation-token count of 0.
Non-vacuity: -t "Mobile access" reports 1030 total with 1028 skipped, so exactly 2 tests ran — the filter selected the two new titles and did not silently select nothing.
The one open question from the prior review, now settled
An earlier read of this PR left one axis explicitly unmeasured: the composer-toolbar workspace chip is now gated on embedded && alone, while its rewritten comment says "only embedded panes without a header need the composer chip" — so if a non-embedded pane could exist on a multi-workspace daemon without a header, that pane would lose its only workspace indicator.
It cannot. ChatPane.tsx renders its header block under {!embedded && ( and the workspace label inside it under {workspaceLabel && (, and the PR does not touch either line (its hunks are at 187, 1449, 1484 and 1499). So embedded and "has a header" are mutually exclusive by construction, and the two indicators are complementary: a non-embedded pane keeps its header label and drops the chip; an embedded pane has no header and keeps the chip. workspaceLabel is still computed from showWorkspaceChip alone, so the header path is unchanged at both arms.
The PR's own new test pins the same invariant from the other side — rerender({ …, embedded: true }) then expect(container.querySelector('header')).toBeNull(). Two disjoint routes agreeing.
Also checked
- No dead switch.
showMobileAccessis declared once (in the publicWebShellChatHeaderOptions), read at three sites inApp.tsx(theLocalControlQrButtongate plus bothonOpenLocalControlSettingsternaries), added to theuseCallbackdependency array — the omission that would have made it a stale closure — and set by a real caller (main.tsxinStandaloneApp). So the standalone entry keeps its QR button and only embedded hosts lose it by default, which is the stated intent. - Docs match the code. The new README sentence claims the entry is controlled by
header.showMobileAccess?: boolean, defaults to hidden, applies to both the main chat and split headers, and thatmain.tsxsets it explicitly — all four hold (?? false, both gates,showMobileAccess: true). - CI at this head. Every executed product lane is green and fresh against
headDate 2026-09-23T09:14:29Z, includingweb-shell E2E SmokeandCapture web-shell visuals, which are the two lanes that own this surface.action_required = 0 of 30, so this is not a fork-gated phantom green.check-runs 130/130over 2 pages,staleA = staleB = 0.
Scope of this verdict
The claim is that the green is bound to the diff and that no merge-blocking defect was found in the four production files, read in full at both arms. It is not a claim that the whole repository builds: node_modules and dist are hardlinked from a checkout at a different commit, so the A/B is sound only on the six overlaid paths. README.md was read at patch level, which is not byte-faithful for control-character escapes — no claim here turns on a literal character.
qqqys
left a comment
There was a problem hiding this comment.
Approval after executed verification at 7402e5baaaa9beb65a2b8dc7e2f3bab53d10d2bd
We previously posted an executed verification of this head in #issuecomment-5797379259 (created and last updated 2026-09-23T15:10:03Z, never edited since), whose verdict was mergeable, no Critical found. The head has not moved since, so that verification still describes the code as it stands. Approving on the strength of it.
What was re-read live before approving (never carried from the earlier round)
| Leg | Reading |
|---|---|
| PR state | open, merged=false, mergeable=true, mergeable_state=clean |
| Head | 7402e5baaaa9beb65a2b8dc7e2f3bab53d10d2bd, committed 2026-09-23T09:14:29Z, parents=1 — unmoved |
| GraphQL agreement | headRefOid == REST head.sha, reviewDecision=APPROVED, mergeStateStatus=CLEAN, statusCheckRollup=SUCCESS, autoMergeRequest=None, isDraft=false |
| Approvals at head | 2, both r309-fresh (submittedAt >= headCommittedDate): chiga0 09:45:53Z, yiliang114 09:59:56Z; the newest undismissed verdict row is yiliang114 APPROVED |
| Open Criticals at head | 0 inline [Critical] rows — and the count is the same whether keyed on original_commit_id or on the re-anchored display pin commit_id, so the two do not disagree |
| Prior approval by us at this head | 0 (no duplicate) |
CI at this head
check-runs 130/130, MATCH=true, shape=list-of-pages over 2 pages, capped=false — so the census is complete and not a lower bound. Lanes classify {BOT: 117, PRODUCT: 13} summing to 130, with zero lanes left unclassified.
Of the 13 product runs (11 distinct names — build-cli spawns three times, all skipped): 7 green, 0 failing, 6 skipped, 0 in flight, 0 cancelled. Every green postdates the head commit, so stale greens = 0 on both the run-level and the latest-per-name leg. check-suites 30/30 with action_required = 0 of 30, which means the tests were genuinely permitted to execute — this is not a fork-gated phantom green.
The two lanes that own a packages/web-shell/client delta are green here: web-shell E2E Smoke (ubuntu-latest, Node 22.x) and Test (ubuntu-latest, Node 22.x), the latter being the runner that executes the App.test.tsx and ChatPane.test.tsx cases this PR ships.
Scope of this approval, stated rather than implied
The earlier verification bound the green to the diff with a four-cell A/B on the PR's own head tree: head-against-head green, base-source-against-head-tests red on exactly the new cases, a mutation cell that flipped the changed default and failed only the two titles owning it while leaving all 158 sibling tests green, and a restore cell returning to green with zero files differing from the head blobs. That is evidence about the 6 overlaid paths, not a claim that the whole PR builds — the dependency tree in that harness was hardlinked from a checkout at a different commit.
One deviation worth naming: the prescribed arm for this task is a tmux-driven TUI report, and the tmux server on our machine is down — every invocation dies with lost server, leaving a lock file and no socket beside it, which localises the fault to the server fork. It worked on this host on 2026-09-16, so the onset is somewhere in that window and this is a state, not a permanent property. The earlier round drove the delta through its own owning runner instead — and for this PR that is vitest, because it ships App.test.tsx and ChatPane.test.tsx and no e2e/ spec reaches the changed code. A browser or TUI drive here would have painted green without executing a changed line.
This approval is additive. main's ruleset requires one approving review plus code-owner review, and both are already satisfied — the PR reads APPROVED/CLEAN without our row, so nothing about mergeability is claimed to change because of it.
What this PR does
Makes the mobile-access QR entry opt-in through
header.showMobileAccess, defaulting tofalsefor embedded Web Shell consumers. The local Qwen Code browser application explicitly enables it, preserving its existing single-chat and split-header entry points. Split panes omit the duplicate workspace folder from the composer while retaining their workspace header labels; headerless side-task panes keep their composer workspace indicator.Why it's needed
Embedding hosts should decide whether to expose mobile access. Split panes already identify their workspace in the header, so the repeated composer folder consumes space without adding context.
Reviewer Test Plan
How to verify
header.showMobileAccessand confirm the entry appears; turn it off again and confirm it disappears.Evidence (Before & After)
Before: embedded headers exposed mobile access without opting in, and multiple-workspace split panes repeated the workspace in the composer. Three focused regression checks reproduced these behaviors before the fix.
After: the component checks cover default-off, opt-in, toggle-off, standalone restrictions, and embedded side-task preservation. The three relevant test files passed 1,202 tests; the final standalone test correction passed all six selected related cases. Build, repository typecheck, and changed-file lint passed.
The following screenshots were recaptured from the final UI source using Chromium and the existing mock daemon, then visually inspected. They demonstrate local-app QR preservation and removal of duplicate split-composer folders. Default-off embedding behavior is verified by component tests; these screenshots do not verify real daemon or Git backend operations. Images live on a separate fork asset branch and use immutable commit URLs.
Split view — workspace labels remain in the headers; composer folders are absent:
Single chat — local-app mobile access remains available:
Tested on
Environment (optional)
Local Node.js workspace, Vitest, and Vite with headless Chromium and mocked daemon responses.
Risk & Scope
header.showMobileAccess: true. No daemon protocol changes.Linked Issues
None; user-reported UI fixes.
中文说明
此 PR 的改动
通过
header.showMobileAccess控制移动访问二维码入口,嵌入式 Web Shell 默认设为false。本地 Qwen Code 浏览器应用显式开启,保留单聊天和分屏页头的现有入口。分屏输入框移除重复的工作区文件夹,保留页头工作区名称;没有页头的侧边任务聊天继续保留输入框工作区标识。修改原因
嵌入宿主应能决定是否显示移动访问入口。分屏页头已经标识工作区,输入框中的重复文件夹占用空间,没有提供额外信息。
审阅者测试计划
如何验证
header.showMobileAccess后入口应显示,再关闭后应消失。证据(修改前后)
修改前:嵌入式页头未显式开启就显示移动访问,多工作区分屏在输入框中重复显示工作区。修复前的三项定向回归检查均复现了这些行为。
修改后:组件检查覆盖默认关闭、显式开启、动态关闭、standalone 限制以及侧边任务行为保留。三个相关测试文件的 1,202 项测试通过;最后修正 standalone 测试后,六项相关定向测试全部通过。构建、全仓类型检查及修改文件的 lint 均通过。
以下截图使用 Chromium 和现有模拟 daemon,从最终 UI 源码重新捕获并目视检查。截图展示本地应用二维码入口保留以及分屏输入框重复文件夹移除。嵌入式默认关闭行为由组件测试验证;截图不代表真实 daemon 或 Git 后端操作验证。图片位于 fork 的独立素材分支,使用固定 commit 链接。
分屏:页头保留工作区名称,输入框不再显示文件夹:
单聊天:本地应用继续显示移动访问入口:
测试平台
环境(可选)
本地 Node.js 工作区、Vitest,以及使用模拟 daemon 响应的 Vite 和无头 Chromium。
风险与范围
header.showMobileAccess: true。未修改 daemon 协议。关联 Issue
无;来自用户反馈的 UI 修复。