Skip to content

fix(web-shell): make mobile access opt-in and simplify split composer - #12537

Merged
ytahdn merged 1 commit into
QwenLM:mainfrom
ytahdn:codex/web-shell-header-visibility
Sep 24, 2026
Merged

ytahdn merged 1 commit into
QwenLM:mainfrom
ytahdn:codex/web-shell-header-visibility

Conversation

@ytahdn

@ytahdn ytahdn commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Makes the mobile-access QR entry opt-in through header.showMobileAccess, defaulting to false for 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

  1. Open an embedded Web Shell without the new option: the mobile-access entry should be absent in single-chat and split headers. Enable header.showMobileAccess and confirm the entry appears; turn it off again and confirm it disappears.
  2. Open the local Qwen Code browser application: both single-chat and split headers should retain mobile access and its settings navigation.
  3. Open two split chats on a daemon with multiple workspaces: each header should retain its workspace label, while neither composer repeats the folder. Headerless side-task chats should retain their workspace indicator.
  4. With mobile access explicitly enabled, a standalone session's custom header should still receive no workspace-settings shortcut.

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:

Split view after the fix

Single chat — local-app mobile access remains available:

Single chat after the fix

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

Environment (optional)

Local Node.js workspace, Vitest, and Vite with headless Chromium and mocked daemon responses.

Risk & Scope

  • Main risk or tradeoff: embedded consumers now need to opt in to mobile access; the local browser application preserves its existing behavior.
  • Not validated / out of scope: Windows/Linux UI execution, real mobile pairing, and real daemon/Git backend operations.
  • Breaking changes / migration notes: embedded hosts that want the existing entry should set header.showMobileAccess: true. No daemon protocol changes.

Linked Issues

None; user-reported UI fixes.

中文说明

此 PR 的改动

通过 header.showMobileAccess 控制移动访问二维码入口,嵌入式 Web Shell 默认设为 false。本地 Qwen Code 浏览器应用显式开启,保留单聊天和分屏页头的现有入口。分屏输入框移除重复的工作区文件夹,保留页头工作区名称;没有页头的侧边任务聊天继续保留输入框工作区标识。

修改原因

嵌入宿主应能决定是否显示移动访问入口。分屏页头已经标识工作区,输入框中的重复文件夹占用空间,没有提供额外信息。

审阅者测试计划

如何验证

  1. 在嵌入式 Web Shell 中不设置新选项:单聊天和分屏页头均不应出现移动访问入口。开启 header.showMobileAccess 后入口应显示,再关闭后应消失。
  2. 打开本地 Qwen Code 浏览器应用:单聊天和分屏页头应继续提供移动访问入口及其设置跳转。
  3. 在包含多个工作区的 daemon 中打开两个分屏聊天:各页头应保留工作区名称,两个输入框均不应重复显示文件夹。无页头的侧边任务聊天应继续显示工作区标识。
  4. 显式开启移动访问时,standalone 会话的自定义页头仍不应收到工作区设置快捷入口。

证据(修改前后)

修改前:嵌入式页头未显式开启就显示移动访问,多工作区分屏在输入框中重复显示工作区。修复前的三项定向回归检查均复现了这些行为。

修改后:组件检查覆盖默认关闭、显式开启、动态关闭、standalone 限制以及侧边任务行为保留。三个相关测试文件的 1,202 项测试通过;最后修正 standalone 测试后,六项相关定向测试全部通过。构建、全仓类型检查及修改文件的 lint 均通过。

以下截图使用 Chromium 和现有模拟 daemon,从最终 UI 源码重新捕获并目视检查。截图展示本地应用二维码入口保留以及分屏输入框重复文件夹移除。嵌入式默认关闭行为由组件测试验证;截图不代表真实 daemon 或 Git 后端操作验证。图片位于 fork 的独立素材分支,使用固定 commit 链接。

分屏:页头保留工作区名称,输入框不再显示文件夹:

修改后的分屏

单聊天:本地应用继续显示移动访问入口:

修改后的单聊天

测试平台

操作系统 状态
🍏 macOS ✅ 已测试
🪟 Windows ⚠️ 未测试
🐧 Linux ⚠️ 未测试

环境(可选)

本地 Node.js 工作区、Vitest,以及使用模拟 daemon 响应的 Vite 和无头 Chromium。

风险与范围

  • 主要风险或取舍:嵌入式消费者现在需要显式开启移动访问;本地浏览器应用保留原有行为。
  • 未验证或范围之外:Windows/Linux UI 运行、真实移动配对以及真实 daemon/Git 后端操作。
  • 兼容性变化与迁移说明:需要保留入口的嵌入宿主应设置 header.showMobileAccess: true。未修改 daemon 协议。

关联 Issue

无;来自用户反馈的 UI 修复。

@ytahdn

ytahdn commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator Author

E2E and regression test report / E2E 与回归测试报告

  • Baseline: the globally installed CLI launched, but its older UI could not exercise the current embedding behavior. Source-level reproduction produced three expected failures before the fix. / 基线:全局 CLI 可启动,但旧版 UI 无法覆盖当前嵌入行为;源码定向检查在修复前三项均失败。
  • Component regression: npx vitest run client/App.test.tsx client/components/ChatPane.test.tsx client/components/ChatContextHeader.test.tsx --reporter=dot from packages/web-shell: 1,202 passed with the nested header.showMobileAccess API. / 组件回归:嵌套属性版本共 1,202 项通过。
  • Review follow-up: the standalone test now explicitly enables mobile access; the six selected QR/header tests passed. / Review 修正:standalone 测试显式开启入口后,六项相关定向测试通过。
  • Final-source Chromium run: passed. Two split QR entries, one single-chat QR entry, two split workspace header labels, editable split composers, and zero repeated split composer folder chips. / 最终源码 Chromium 验证通过:分屏两个二维码入口、单聊天一个入口、两个分屏工作区页头、可编辑输入框,且分屏输入框重复文件夹数量为零。
  • Screenshots in the PR were visually inspected and are stored outside the implementation branch. Mock daemon only; no claim of real pairing, daemon, or Git backend verification. / PR 截图已目视检查并存于独立素材分支。仅使用模拟 daemon,不代表真实配对、daemon 或 Git 后端验证。
  • Local validation: workspace build and bundle, Web Shell rebuild after API placement, full repository typecheck, changed-file lint, and commit hooks passed on macOS. / macOS 本地验证:工作区构建和打包、属性归位后的 Web Shell 重建、全仓类型检查、修改文件 lint 和提交钩子通过。

@chiga0 chiga0 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.

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 用户的入口。

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.

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 yiliang114 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.

Verified the blast radius of the default flip, since that's the only thing that could make this unsafe:

  1. The default-off cannot silently remove anything in-repo: the only embedded consumer of @qwen-code/web-shell in this repo is vscode-ide-companion, and its QR entry is unreachable today — it passes header={{ items: [] }} (so chatHeaderEnabled is false and neither onOpenLocalControlSettings path renders) and has no split view (so defaultPaneHeaderActions never 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.tsx opts in explicitly, preserving the local app's entry in both single-chat and split headers.
  2. The option lives on the right type: WebShellChatHeaderOptions.showMobileAccess is where a host scanning header for sub-options will look — which also covers the substance of the open README thread (co-locating the note under the header row would finish it, but that's documentation polish, not a blocker).
  3. 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.
  4. 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.

@qqqys

qqqys commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator

Executed verification at 7402e5baaaa9beb65a2b8dc7e2f3bab53d10d2bd

Verdict: mergeable. No Critical found.

I built the PR's own head tree and ran a four-cell A/B so the green is bound to the diff rather than merely observed. Every count below is read from vitest's own Test Files … / Tests … reporter line, never from an exit status taken across a pipe.

The arm, and why it is not the tmux drive the task names

Two substitutions, both measured rather than assumed:

  • tmux is unavailable on this host. A bare tmux new-session -d -s X "sleep 20" returns lost server (rc=1), and tmux ls returns error connecting to /tmp/tmux-0/default. A sleep 20 payload cannot fail on any property of the subject, so the fault is the server, not the command string. Fresh -L <socket> and TMUX_TMPDIR=<dir> both fail identically, and each leaves a default.lock with no socket beside it — the server child dies between fork and bind. Not disk (0.86 GB free), not load (0.23), not ulimit -u (256027), not the socket path.
  • For this surface a tmux TUI drive could not reach the code anyway. The delta is entirely under packages/web-shell/client/**, which the terminal TUI never mounts. I also checked the browser route before choosing: grep -rlE 'showMobileAccess|LocalControlQrButton' over packages/web-shell/client/e2e/ returns 0 hits, so no Playwright spec reaches the subject either — a browser run would have painted green without executing a changed line.

So the arm is the PR's own vitest suites, which do own the delta.

Tree and fidelity

git archive of the fetched PR head ref, tar -x, then hardlinked (cp -al, never symlinked) root node_modules, 26 of 26 package-level node_modules, 24 of 24 packages/*/dist, and packages/cli/src/generated. 9,230 files. Postconditions asserted both ways: symlinked package node_modules = 0, and readlink -f node_modules/@qwen-code/qwen-code-core resolves to /tmp/r408mn-arm/packages/core, inside the tree.

All 6 overlaid files are byte-faithful, each with a locally recomputed sha1('blob '+len+'\0'+bytes) equal to the API's own pulls/files[].sha, NUL_bytes=0:

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. showMobileAccess is declared once (in the public WebShellChatHeaderOptions), read at three sites in App.tsx (the LocalControlQrButton gate plus both onOpenLocalControlSettings ternaries), added to the useCallback dependency array — the omission that would have made it a stale closure — and set by a real caller (main.tsx in StandaloneApp). 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 that main.tsx sets 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, including web-shell E2E Smoke and Capture 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/130 over 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 qqqys 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.

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.

@ytahdn
ytahdn added this pull request to the merge queue Sep 24, 2026
Merged via the queue into QwenLM:main with commit 906418a Sep 24, 2026
149 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants