feat(web-shell): show git state hints beside branch picker actions - #10397
Conversation
The branch picker's Update Project / Commit / Push rows gave no context before the click: the user could not tell whether there was anything to pull, how many files a commit would sweep up, or whether a push would create a remote branch. The daemon already exposes all of this — ahead/behind/upstream on the branch listing fetched when the picker opens, and dirty counts / in-progress operation on the polled workspace status held by every trigger chip — but neither reached the popover. Add a `status` prop and derive a hint per action from the two sources: - Hard blockers (merge/rebase/cherry-pick in progress, conflicts, detached HEAD, pull without an upstream) disable the row and name the reason, since the daemon would reject the request anyway. - Soft states (up to date, nothing to push, clean tree) only dim the row. - Informational hints show `↓n · upstream`, `↑n`, and the file count a commit would include (calling out untracked files, which `git add -A` sweeps in). - Warnings flag pulling behind with uncommitted changes and pushing while also behind. The branch listing wins over the polled status for ahead/behind because it is fetched on open; the popover also asks the caller to refresh the status when it opens so the dirty counts are not a stale poll. The refresh callback is held in a ref so an inline handler cannot re-arm the open effect on every render. Wire the prop from the sidebar workspace header, the composer chip, and the Environment panel, and add en/zh-CN copy for the new hints.
|
Re-running the gate at @wenshao's request — same head as the first pass (
Moving on to code review. 🔍 中文说明应 @wenshao 的请求重新跑门禁 —— 与首轮相同的 head(
进入代码审查。🔍 — Qwen Code · qwen3.8-max Reviewed at |
Code reviewNo blockers. The implementation matches my independent proposal and holds up on close reading:
One nit, non-blocking: Test evidence — the PR's own CI on
|
| Check | Conclusion |
|---|---|
| Qwen Code CI — Test (ubuntu-latest, Node 22.x) | ✅ success |
| Qwen Code CI — Desktop Shell (ubuntu-22.04) | ✅ success |
| Qwen Code CI — Desktop Shell (windows-2022) | ✅ success |
| Qwen Code CI — Integration Tests (no-AK, No Sandbox) | ✅ success |
| Qwen Code CI — web-shell E2E Smoke (ubuntu-latest, Node 22.x) | ✅ success |
| Qwen Live Host CI — Live Host (macos-latest) | ✅ success |
| Security Checks — Secret scan (TruffleHog) | ✅ success |
| Security Checks — Dependency CVE audit | ✅ success |
| Web-shell Visuals — Capture web-shell visuals (ubuntu-latest, Node 22.x) | ✅ success |
The decision table means the suite pins the change — delete the disable logic and the deriveActionHints tests fail. The visuals job additionally renders the real UI against a mock daemon (before/after screenshots are in this thread). What neither covers is the one remaining hop: a live daemon's status flowing through the on-open fetch (wait=1 semantics, computedAt precedence over the chip's last poll). Sandboxed verification would settle that: @qwen-code /verify — the jsdom suite mocks the workspace client, so the real serve→popover wiring is the only claim currently resting on code reading rather than an exercised run.
中文说明
代码审查
无阻塞问题。实现与我独立设想的方案一致,细读之下站得住:
- 提示推导是纯函数决策表(
deriveActionHints):13 个用例覆盖 同步/落后/落后且工作区脏/无上游/上游已删除/领先/领先且落后/变更计数/进行中操作/冲突/detached HEAD,以及"列表优先于状态"的取值顺序和 v1 状态。禁用与 daemon git 路由实际拒绝的情形一致(workspace-git-branches.ts中的detached_head409、no_upstream400),不会出现"后端会执行的操作被置灰"或"后端拒绝的操作盲点击"。 - 竞态处理正确:两个拉取都有 request-id 防护;列表重取按状态
computedAt至多一次,持续不一致不会成环;onStatusRefreshed放在 ref 里,父组件的内联回调不会反复触发打开副作用;切换工作区时丢弃旧状态。 - 复用优先:与
GitBranchIndicator共享deriveStatus/hasComputedTreeSummary;v1 状态(无树摘要)不显示提交提示而非误报"无更改",且有测试。 - 唯一的 core 改动足够小:
parseBranchLines读取 git 自身的gone跟踪态。已端到端核实——路由原样透传result.local,类型只增可选字段,无其他消费者。
一个小问题(不阻塞):listingFetchedAt(浏览器时钟)与 daemon 的 computedAt 比较。qwen serve 与浏览器同机时没有问题;若未来有远端 daemon,时钟偏差最多让列表重取延后或跳过(最坏是行显示过期,不会错)。
测试证据 —— 该 SHA 上 PR 自身的 CI(此处未重跑;CI 路径从不执行 PR 代码)
该 SHA 的全部 pull_request 工作流运行均为绿色。原始检查列表的背景:提交上可见的 cancelled 检查属于被重跑取代的首次尝试;成功运行中 Test (macos/windows) 与 Integration Tests (CLI) 被工作流门禁跳过(非失败);仍在运行的 review-pr 是机器人编排任务(pull_request_target),不属于 PR CI。
决策表意味着测试钉住了改动——删掉禁用逻辑,deriveActionHints 的测试就会失败。visuals 任务还在 mock daemon 下渲染了真实 UI(前后对比截图见本评论区)。两者都未覆盖的只剩最后一跳:真实 daemon 的状态经由打开时的拉取流入(wait=1 语义、computedAt 优先于 chip 上次轮询值)。沙箱验证可以补足:@qwen-code /verify —— jsdom 套件 mock 了 workspace 客户端,真实 serve→弹窗链路是目前唯一只靠代码阅读支撑的断言。
— Qwen Code · qwen3.8-max
Reviewed at 1f960ddb36b1904e0da442e1342c6014e98f2e8e · re-run with @qwen-code /triage
|
Confidence: 4/5 — clean across every stage; the single reservation is the live serve→popover hop that no jsdom test can reach, and it's a thin one (an identity JSON passthrough plus one Stepping back: this is the kind of PR the gate should wave through. The problem is observable in the product today — the chip renders the state, the popover hides it — and the fix is the minimal version of itself: one pure derivation function, one genuinely-needed core bit ( CI is fully green on 中文说明置信度:4/5 —— 各阶段均干净;唯一的保留是 jsdom 测试够不到的"真实 serve→弹窗"链路,而它很薄(一个原样 JSON 透传加一次 退一步看:这正是门禁应当放行的那种 PR。问题在今天的产品里就能观察到——chip 渲染了状态、弹窗却藏着——而修复是它自身的最小形态:一个纯推导函数、一个确实需要的 core 增量(
— Qwen Code · qwen3.8-max Reviewed at |
🖼️ web-shell visual previewRendered against a mock daemon (no real backend): the PR base vs this PR head Screenshots · before / afterFull-resolution recordings (.webm) are attached to the workflow run. — Qwen Code · web-shell visuals |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship — CI landed green after the review. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
Test Plan (not a blocker): client/components/BranchPickerPopover.test.tsx — no such file or directory; client/components/sidebar/WorkspaceSection.test.tsx — no such file or directory; client/components/panels/EnvironmentPanel.test.tsx — no such file or directory.
中文说明
已审查——无阻断问题。 建议见行内评论。
Test Plan(非阻断):client/components/BranchPickerPopover.test.tsx — no such file or directory; client/components/sidebar/WorkspaceSection.test.tsx — no such file or directory; client/components/panels/EnvironmentPanel.test.tsx — no such file or directory。
— qwen3-max via Qwen Code /review (v0.22.2)
Follow-ups from review of the action hints: - Push is disabled only on a detached HEAD. A push does not consult the index, so an in-progress merge or conflicted entries are shown as a warning on an enabled row instead of blocking it; pull keeps blocking in those states because `git pull` refuses them. The doc comment now says what git actually rejects rather than claiming the daemon would. - A deleted-and-pruned upstream (`[gone]`) is surfaced end to end: core's `parseBranchLines` reads the `gone` tracking state into a new `upstreamGone` flag on the branch listing, and the picker treats it as no upstream — pull is disabled with "Upstream gone" (plain `git pull` always fails there) and push says it will set the upstream (which is what `--set-upstream` does). - The commit hint counts "changes", not "files": the status counters are entry-granular (a partially staged file counts twice, an untracked directory once), so the old copy overstated or understated the file count. - The push hint without an upstream says "Sets upstream on push" instead of promising a remote branch, since the daemon cannot know whether the push remote resolves or HEAD has commits. - The popover fetches its own working-tree status on open, so all three entry points (sidebar chip, composer chip, Environment panel) see fresh counters instead of their caller's last poll; the sidebar receives it back through `onStatusRefreshed` to keep its chip in step. The newer of the caller's status and the fetched one wins. - While open, a status the daemon computed after the listing that disagrees with it on upstream/detached/ahead/behind triggers one listing re-fetch, so the rows follow a terminal-side `--unset-upstream` or checkout instead of the snapshot from open time. - The counter normalisation and the "enriched fields were computed" predicate are now exported from GitBranchIndicator and shared with the picker rather than re-implemented. Tests cover the decision-table changes, the dimmed (muted) row styling, the "changes" wording for a partially staged file, the on-open status fetch and reconciliation re-fetch, the gone-upstream listing in core, and the status pass-through at the ChatEditor and EnvironmentPanel call sites.
|
@qwen-code /takeover |
|
🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. This is a fork PR, so the first round comes from the next scheduled scan (usually within minutes). Remove the 中文说明🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。移除 |
yiliang114
left a comment
There was a problem hiding this comment.
Approving. Reviewed at head 1f960dd: all 9 suggestion threads resolved with author replies, and the follow-up fix commit genuinely implements their substance — disabling is now reserved for what git itself refuses (pull during merge/rebase/cherry-pick, unmerged entries, detached HEAD, unusable upstream; push --set-upstream only detached) while conflicts/in-progress ops are warnings on enabled rows; upstreamGone parsing covers the deleted-upstream [gone] state (git-branches.ts + tests); freshness now keys on computedAt instead of an unconditional listing override; deriveStatus/hasComputedTreeSummary are reused instead of re-normalized. Heavy test coverage (+600 in BranchPickerPopover.test.tsx plus call-site wiring tests) and web-shell E2E Smoke green on the exact head.
|
@qwen /triage |
|
@qwen-code /triage |
|
Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 278 passed · 0 failed · 278 total Flakiness gate: ✅ 5 changed test file(s) x 5 identical rounds, no divergence 中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:278 通过 · 0 失败 · 278 总计 抖动门:✅ 5 changed test file(s) x 5 identical rounds, no divergence Verification reportPR 10397 verification reportVerdict: 中文摘要
Central claim and A/BCentral claim: the branch picker's Update/Commit/Push rows show git-state hints whose disabling is faithful to what git itself refuses (pull: no usable upstream / gone upstream / detached / in-progress operation / conflicts; push: detached HEAD only). A/B proof of the load-bearing data path (harness 3,
14/14 checks pass; base cannot even see the gone state, head can — the core change is load-bearing. Behavioral oracle (harness 2,
Reviewer Test Plan walkthrough
Extra gate: Mutation matrix (vacuity + pinning)
Unmutated control runs were green before and after every mutation; every killed mutant failed in the file that was mutated, with the intended assertion. FindingsF1 (Suggestion) — Push stays enabled in states where the daemon's own
|
| state | push row | real git push |
|---|---|---|
behind-only (behind-clean, behind-dirty) |
enabled, dimmed "Nothing to push" | ! [rejected] main -> main (non-fast-forward) |
diverged (ahead-behind, cherry-pick-conflict) |
enabled, warning "↑1 ↓1 · update first" | ! [rejected] … (non-fast-forward) |
A push that moves the remote backward (behind-only) or divergently is refused unconditionally — unlike the divergent-pull refusal, which is config-dependent (fatal: Need to specify how to reconcile divergent branches only when pull.rebase/pull.ff are unset) and therefore defensibly left enabled with an info hint. The push refusal needs no config and the PR's own rule would disable the row; as shipped, the click fails in the status bar (loud, no data loss, and the hint text does tell the user to update first — hence Suggestion, not blocking).
Minimal suggested fix (measured)
const pushDisabled =
detached || (!s.operation && hasUpstream === true && behind > 0);Measured in a scratch copy: PR suite 170/170 green (the !s.operation carve-out preserves the pinned "only warns on push" operation case, which the naive variant without it breaks 1/170). The behind/diverged push cells of the fidelity matrix flip from REFUSED✗row-on to disabled. Note the behind-only row would then read dimmed "Nothing to push" and disabled, which is consistent; if the author prefers the current warn-and-let-the-error-surface behaviour, the doc comment should say so (the stated rule as written does not match the implementation).
F2 (Nice to have) — newerStatus equal-computedAt tie-break is unpinned
M5 (>= → >) survived 170/170: nothing asserts which status wins when the caller's and the popover's own fetch carry identical stamps. Current behaviour (fetched wins ties) is reasonable; this is completeness reporting, not a merge condition.
Not covered
- Per-commit attribution: the checkout is depth 2; only
1f960ddb36is locally reachable while the snapshot lists two commits. All evidence is for the aggregateHEAD^1..HEADdiff. - Daemon HTTP transport: harnesses drove the real core functions directly; the branches route serializes
result.localverbatim (readworkspace-git-branches.ts), soupstreamGonepasses through, but no liveqwen serveround was made. - Composer chip / Environment panel entry points: prop pass-through asserted by their unit tests (gates); not driven end-to-end (same component/props as the sidebar path, per the PR's own scoping).
- Playwright manual plan, Windows, zh screenshots: not re-run; i18n asserted at key level instead.
- Repo-wide typecheck/lint: not run here (PR's CI covers); the base-tree core rebuild emitted pre-existing type noise unrelated to this PR.
- revert/bisect operations: not in the fidelity matrix; their i18n keys resolve and they flow through the same
s.operationblocker branch. - The working tree carried a pre-existing deletion (
.qwen/e2e-tests/2026-08-28-webshell-branch-picker-action-hints.md) at session start; left untouched.
Methodology
Environment: CI verify container (node:22, git 2.39.5), merge-ref checkout. Harness 1 (harness/harness-1-gitstates.mjs) built 11 scratch repos with real bare remotes and drove the real core dist (head, and base rebuilt in tmp/base-tree, removed after) for listings, statuses, and pull/push verdicts; harness 2 (vitest, run inside packages/web-shell for CSS-module handling, removed after) ran the real deriveActionHints/listingContradictsStatus/getTranslator over the captured states; harness 3 compared arms. Captures via scripts/verify-capture.mjs. Raw logs: logs/states-head.json, logs/states-base.json, logs/harness2-results.json, logs/ab-results.json. Assertion counts: 19 (harness 2) + 14 (harness 3) + 6 (mutation rows M1–M6') + 239 (gates: 170+58+11) = 278.
Flakiness gate log
rounds=5 files=5 skipped=0
file packages/core/src/utils/git-branches.test.ts: (cd packages/core) npx --no-install vitest run ./src/utils/git-branches.test.ts
file packages/web-shell/client/components/BranchPickerPopover.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/components/BranchPickerPopover.test.tsx
file packages/web-shell/client/components/ChatEditor.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/components/ChatEditor.test.tsx
file packages/web-shell/client/components/panels/EnvironmentPanel.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/components/panels/EnvironmentPanel.test.tsx
file packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/components/sidebar/WorkspaceSection.test.tsx
per-file results (P=pass F=fail I=infra-exit, one letter per run):
packages/core/src/utils/git-branches.test.ts: PPPPP
packages/web-shell/client/components/BranchPickerPopover.test.tsx: PPPPP
packages/web-shell/client/components/ChatEditor.test.tsx: PPPPP
packages/web-shell/client/components/panels/EnvironmentPanel.test.tsx: PPPPP
packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx: PPPPP
verdict: pass
summary: 5 changed test file(s) x 5 identical rounds, no divergence
--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/core/src/utils/git-branches.test.ts: P (exit 0)
round 1 · packages/web-shell/client/components/BranchPickerPopover.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/components/ChatEditor.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/components/panels/EnvironmentPanel.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx: P (exit 0)
round 2 · packages/core/src/utils/git-branches.test.ts: P (exit 0)
round 2 · packages/web-shell/client/components/BranchPickerPopover.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/components/ChatEditor.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/components/panels/EnvironmentPanel.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx: P (exit 0)
round 3 · packages/core/src/utils/git-branches.test.ts: P (exit 0)
round 3 · packages/web-shell/client/components/BranchPickerPopover.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/components/ChatEditor.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/components/panels/EnvironmentPanel.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx: P (exit 0)
round 4 · packages/core/src/utils/git-branches.test.ts: P (exit 0)
round 4 · packages/web-shell/client/components/BranchPickerPopover.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/components/ChatEditor.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/components/panels/EnvironmentPanel.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx: P (exit 0)
round 5 · packages/core/src/utils/git-branches.test.ts: P (exit 0)
round 5 · packages/web-shell/client/components/BranchPickerPopover.test.tsx: P (exit 0)
round 5 · packages/web-shell/client/components/ChatEditor.test.tsx: P (exit 0)
round 5 · packages/web-shell/client/components/panels/EnvironmentPanel.test.tsx: P (exit 0)
round 5 · packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx: P (exit 0)
Evidence images
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅




What this PR does
The branch picker popover (opened from the workspace folder's git chip in the sidebar, the composer chip, or the Environment panel) now shows a short state hint at the right edge of its Update Project, Commit, and Push rows, so the user can judge before clicking:
↓3 · origin/mainwhen behind, "Up to date" (dimmed) when in sync, and a warning-toned↓3 · uncommitted changeswhen behind with a dirty tree. It is disabled with "No upstream" when the branch has no tracking branch, and with "Upstream gone" when the tracked remote branch was deleted and pruned.5 changes (2 untracked)), or "No changes" (dimmed) on a clean tree. "Changes" rather than "files" because the daemon's counters are entry-granular (a partially staged file counts twice, an untracked directory once).↑2when ahead, "Nothing to push" (dimmed) when in sync, "Sets upstream on push" when there is no usable upstream, and a warning-toned↑2 ↓1 · update firstwhen the branch is both ahead and behind.Rows that are merely uninteresting (up to date, nothing to push, no changes) are only dimmed, never disabled, since the action is still harmless.
The popover gets a new optional
statusprop (the sameDaemonWorkspaceGitStatusthe trigger chips already hold) and fetches its own working-tree status when it opens, so all three entry points (sidebar chip, composer chip, Environment panel) see fresh counters rather than their caller's last poll; the newer of the two bycomputedAtwins, and the sidebar receives the fetched status back throughonStatusRefreshedto keep its chip in step. Ahead/behind/upstream come from the branch listing the popover already fetches on open; if a status the daemon computed later disagrees with that listing (upstream unset, HEAD detached, new commits from a terminal), the listing is re-fetched once so the rows follow the repo rather than the snapshot. The counter normalisation is shared withGitBranchIndicatorrather than re-implemented.One small core change:
parseBranchLinesnow reads git'sgonetracking state into an optionalupstreamGoneon the branch listing (GitBranchInfo/DaemonGitBranchInfo), since neither the listing nor the status could previously distinguish a deleted upstream from a healthy in-sync one.Why it's needed
The three actions have consequences that are invisible until after the click:
git pullwithout an upstream fails, a pull onto a dirty tree may be refused with a 409,Commitrunsgit add -Aand therefore sweeps in untracked files, andPushwith no upstream creates a remote branch. The daemon already computes everything needed to say so up front — the chip that opens the popover even renders↑n ↓nand a dirty dot — but none of it reached the popover, so users clicked blind and read the outcome from the status line afterwards.Related: #10390 changes what happens after clicking Update Project on a dirty tree (stash / discard resolution panel). This PR is about what the user sees before clicking and is independent of it; the two touch adjacent code in
BranchPickerPopover.tsxandi18n.tsx, so whichever merges second will need a small conflict resolution. The dirty-tree warning stays accurate under #10390 — it just leads to the resolution panel instead of an error.Reviewer Test Plan
How to verify
Unit coverage is the primary check.
deriveActionHintsis exported and covered as a decision table (13 cases: in-sync/clean, behind, behind+dirty, no upstream, ahead, ahead+behind, changed/untracked counts, in-progress operation, conflicts, detached HEAD, listing-vs-status precedence, v1 status without tree summary), plus rendered assertions that the rows carry the expecteddisabledstate anddata-tone, and that opening the popover asks the caller to refresh status exactly once even when the parent passes a new inline callback on re-render.For a manual check in
qwen serve+ Web Shell, open a trusted git workspace and click the branch chip on the sidebar folder header after putting the repo in each state: in sync and clean → all three rows dimmed with "Up to date" / "No changes" / "Nothing to push";git reset --hard HEAD~3→↓3 · origin/main; also edit a tracked file and add an untracked one → warning-toned↓3 · uncommitted changesand2 changes (1 untracked);git checkout -b xwith one local commit → Update Project disabled "No upstream", Push "Sets upstream on push"; an in-progress conflicting rebase → Update Project and Push disabled with "Rebasing";git checkout --detach→ both disabled with "Detached HEAD"; a conflicting merge on the branch → Update Project disabled "Merging", Push enabled with a warning "Merging"; push a branch with-u, delete it on the remote,git fetch --prune→ Update Project disabled "Upstream gone", Push "Sets upstream on push". Switch the UI language to 中文 to see the localized copy. The full manual plan is in.qwen/e2e-tests/2026-08-28-webshell-branch-picker-action-hints.md.Evidence (Before & After)
Round 2 (
1f960ddb36, after review) re-captured the After column with the revised copy and rules and added two states; the Before column is from round 1 and is unchanged by the review. Captured from the real stack:npm run bundle→node dist/cli.js serve --workspace <fixture>(isolatedQWEN_HOME, fake OpenAI env, no model calls) → Playwright clicks the sidebar git chip and screenshots the popover. Both columns were taken against the same daemon workspace in the same git state, seconds apart: "Before" is the same bundle with the web-shell UI rebuilt fromorigin/main's client files, "After" is this branch. Per-rowdisabled/ hint text /data-tonewere also read back from the DOM into a ledger (dom-ledger.jsonlnext to the images); every Before row has no hint and stays enabled, including the states where the click would fail.zh-CN, behind 3 with uncommitted changes:
Assets: round 1
wenshao/qwen-code@2d7c61f, round 2wenshao/qwen-code@447a954(branchpr-assets/10397-verify).Tested on
Environment (optional)
macOS; unit tests (vitest + jsdom) plus the bundled
qwen serve+ headless Chromium (Playwright) run above.Risk & Scope
computedAtwith the browser clock, so on a remote daemon with a skewed clock the re-fetch may not fire (the hints then degrade to open-time behaviour, never to a wrong enable).statusstill get the popover's own on-open fetch.upstreamGoneis an additive optional field on the branch listing.Linked Issues
Related to #10390 (dirty-working-tree pull resolution; independent, adjacent code).
中文说明
这个 PR 做了什么
分支选择弹窗(从侧栏工作区文件夹的 git 芯片、composer 芯片或环境面板打开)现在会在 更新项目、提交、推送 三行右侧显示一个简短的状态提示,让用户在点击前就能判断:
↓3 · origin/main;同步时显示"已是最新"(变灰);落后且工作区有未提交更改时显示警告色的↓3 · 有未提交更改;分支没有上游时禁用并显示"无上游分支",所跟踪的远程分支已被删除并 prune 时禁用并显示"上游分支已不存在"。5 处更改(2 未跟踪));工作区干净时显示"无更改"(变灰)。用"更改"而非"文件",是因为 daemon 的计数按条目计(部分暂存的文件算两条,未跟踪目录算一条)。↑2;同步时显示"无待推送"(变灰);没有可用上游时显示"推送时设置上游";既领先又落后时显示警告色的↑2 ↓1 · 请先更新。只是"没什么可做"的行(已是最新、无待推送、无更改)只变灰、不禁用,因为操作本身无害。
弹窗新增可选的
statusprop(就是触发芯片已经持有的DaemonWorkspaceGitStatus),并在打开时自行拉取一次工作区状态,因此三个入口(侧栏芯片、composer 芯片、环境面板)看到的都是新鲜计数而不是各自调用方上次轮询的结果;两者按computedAt取较新者,侧栏通过onStatusRefreshed拿回这份状态让芯片同步。ahead/behind/upstream 取自弹窗打开时本来就会拉取的分支列表;若之后到达的、daemon 计算时间更晚的状态与该列表不一致(上游被取消、HEAD 游离、终端里新提交),会重新拉取一次列表,让各行跟随仓库而不是打开时的快照。计数归一化与GitBranchIndicator共用,不再各写一份。core 有一处小改动:
parseBranchLines现在把 git 的gone跟踪状态读入分支列表的可选字段upstreamGone(GitBranchInfo/DaemonGitBranchInfo),此前无论列表还是状态都无法区分"上游已删除"和"健康且同步"。为什么需要
这三个操作的后果在点击之前是看不见的:没有上游时
git pull会失败;脏工作区上 pull 可能被 409 拒绝;"提交"会执行git add -A,因此会把未跟踪文件一并带入;没有上游时"推送"会创建远程分支。daemon 已经算出了提前说明所需的全部信息——打开弹窗的那个芯片甚至已经渲染了↑n ↓n和脏点——但这些都没有传到弹窗里,用户只能盲点,然后事后从状态行读结果。相关:#10390 改的是在脏工作区上点击"更新项目"之后发生的事(stash / 放弃 的解决面板)。本 PR 关注的是点击之前用户看到什么,与之相互独立;两者在
BranchPickerPopover.tsx和i18n.tsx中改到相邻代码,后合入的一方需要做一次小的冲突解决。脏工作区警告在 #10390 合入后依然准确——只是点下去会进入解决面板而不是报错。审阅测试计划
如何验证
主要依靠单元测试。
deriveActionHints已导出并以决策表方式覆盖(13 个用例:同步且干净、落后、落后且脏、无上游、领先、既领先又落后、变更/未跟踪计数、进行中操作、冲突、游离 HEAD、分支列表优先于状态、无树摘要的 v1 状态),另有渲染层断言确认各行携带预期的disabled状态与data-tone,以及打开弹窗时恰好请求一次状态刷新——即使父组件在重渲染时传入新的内联回调。在
qwen serve+ Web Shell 中手动检查:打开一个受信任的 git 工作区,把仓库置于以下各状态后点击侧栏文件夹头部的分支芯片:同步且干净 → 三行都变灰,显示"已是最新"/"无更改"/"无待推送";git reset --hard HEAD~3→↓3 · origin/main;再改一个已跟踪文件并新增一个未跟踪文件 → 警告色的↓3 · 有未提交更改与2 处更改(1 未跟踪);git checkout -b x并本地提交一次 → 更新项目禁用并显示"无上游分支",推送显示"推送时设置上游";进行中的冲突 rebase → 更新项目与推送禁用并显示"变基中";git checkout --detach→ 两者禁用并显示"游离 HEAD";分支上进行中的冲突 merge → 更新项目禁用显示"合并中",推送可点并带警告"合并中";用-u推送分支后在远端删除并git fetch --prune→ 更新项目禁用显示"上游分支已不存在",推送显示"推送时设置上游"。切换界面语言到中文查看本地化文案。完整手测计划见.qwen/e2e-tests/2026-08-28-webshell-branch-picker-action-hints.md。证据(前后对比)
第二轮(
1f960ddb36,评审后)用修订后的文案与规则重截了 After 列并新增两个状态;Before 列来自第一轮,评审未改变它。取自真实栈:npm run bundle→node dist/cli.js serve --workspace <夹具仓库>(隔离QWEN_HOME、伪造 OpenAI 环境变量,不调用模型)→ Playwright 点击侧栏 git 芯片并截取弹窗。两列都是对着同一个 daemon 工作区、同一 git 状态、相隔几秒截取的:"Before" 是同一份 bundle 但 web-shell 前端用origin/main的客户端文件重建,"After" 是本分支。每行的disabled/ 提示文本 /data-tone也从 DOM 读回记入台账(与图片同目录的dom-ledger.jsonl);Before 的每一行都没有提示且始终可点,包括点击必然失败的那些状态。中文界面,落后 3 且有未提交更改:
资源:第一轮
wenshao/qwen-code@2d7c61f,第二轮wenshao/qwen-code@447a954(分支pr-assets/10397-verify)。测试平台
环境(可选)
macOS;单元测试(vitest + jsdom)加上面的 bundle 版
qwen serve+ 无头 Chromium(Playwright)实跑。风险与范围
computedAt与浏览器时钟,远程 daemon 时钟偏差时重拉可能不触发(此时退化为打开时的行为,绝不会错误地放开)。status的调用方也有弹窗自身的打开时拉取。upstreamGone是分支列表上新增的可选字段。关联 Issue
与 #10390 相关(脏工作区 pull 解决面板;相互独立、代码相邻)。