Skip to content

feat(web-shell): slide the active pill between tabs - #11844

Merged
callmeYe merged 7 commits into
mainfrom
web-shell/tabs-sliding-pill
Sep 15, 2026
Merged

callmeYe merged 7 commits into
mainfrom
web-shell/tabs-sliding-pill

Conversation

@callmeYe

@callmeYe callmeYe commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Tab lists in the Web Shell (the sidebar's Tasks/Channels session-source switch, plus the MCP/Plugins/Agents/Extensions managers, the skill install dialog, and the settings message) now move a single pill-shaped highlight to the newly activated tab with a 200ms slide, instead of recoloring each tab in place. The pill measures the active tab's fractional rect and glides between positions; it snaps without animating on first render, tracks sidebar drags 1:1 (resize-driven re-measures skip the transition), dims when the active tab is disabled, works for vertical lists, and disables the motion under prefers-reduced-motion. The underline-style line variant is untouched, and the tab list now forwards refs like the other interactive primitives. The sidebar's two triggers opt into shrink-to-equal halves with truncating labels so the switch stays symmetric without leaking that policy into other tab lists.

Why it's needed

The session-source switch is one of the most frequent interactions in the sidebar, and the old in-place recolor made it hard to perceive which tab became active. A moving indicator gives immediate spatial feedback about the direction and target of the switch, matching what users expect from modern segmented controls.

Reviewer Test Plan

How to verify

Run the Web Shell, open a workspace session, and click between the Tasks and Channels tabs in the sidebar: the highlight should visibly slide across (about 0.2s) rather than fade in place. Drag the sidebar width — the pill tracks 1:1 with no easing lag. Other tabbed surfaces (Plugins, MCP, Agents managers) get the same slide while keeping their content-sized labels; the workflow-runs page keeps its underline style. Automated coverage: client/e2e/web-shell.tabs-indicator.spec.ts (tagged @smoke) asserts the pill exactly overlays the active tab before and after a click, records intermediate positions during the transition to prove it slides, and asserts a position jump with no intermediate frames under reduced motion; components/ui/tabs.test.tsx covers indicator presence/hide, ref forwarding, and the resize-vs-switch transition split.

Evidence (Before & After)

Before: the active tab's background/shadow transitioned in place per trigger; no element moved. After: a sampled trace of the pill's left edge during a Tasks → Channels click shows a continuous ease-out glide (67.8 → 74.9 → 81.8 → … → 123.9 → 124px), landing exactly on the Channels tab's bounds; stills at rest are pixel-identical to the old active state since the pill carries the same background, border, and shadow tokens as the former active trigger. A maintainer re-ran this on a real daemon with transition pausing and confirmed the slide, reduced-motion snap, keyboard activation, and vertical orientation.

Tested on

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

Environment (optional)

npm run dev (vite) with the Playwright mock daemon and with a real qwen serve; Chromium.

Risk & Scope

  • Main risk or tradeoff: every default-variant tab list gains the moving pill, not only the sidebar switch — intentional for consistency, and each list measures its own active tab, so no per-page wiring is needed. Overflow policy stays per-surface: the primitive keeps its content-width default, and only the sidebar's two triggers opt into equal shrinkable halves.
  • Not validated / out of scope: the pre-existing React 18 ref drop on TabsTrigger (PluginManagerPage passes a focus-restore ref to a plain function component) is unchanged by this PR and worth a follow-up; nothing else was touched.
  • Breaking changes / migration notes: none. TabsList no longer advertises asChild (the two-child render it would receive cannot be slotted); no caller used it.

Linked Issues

N/A

中文说明

本 PR 做了什么

Web Shell 中的标签列表(侧栏的 Tasks/Channels 会话来源切换,以及 MCP/Plugins/Agents/Extensions 管理页、技能安装对话框、设置消息)现在会把一个药丸形高亮块以约 200ms 的滑动动画移动到新激活的标签上,而不是在原地为每个标签换色。滑块按激活标签的小数像素矩形测量并在位置间平滑移动;首次渲染时直接到位不播放动画;拖动侧栏宽度时 1:1 跟随(resize 驱动的重测不走过渡);激活标签被禁用时滑块同步变淡;纵向标签列表同样适用;在 prefers-reduced-motion 下完全禁用动画。下划线样式的 line 变体不受影响;标签列表现在也和其他交互基础组件一样转发 ref。侧栏的两个标签单独选择"可收缩等分 + 文字截断"策略,保证切换开关对称,而不影响其他标签列表。

为什么需要

会话来源切换是侧栏里最高频的交互之一,原来的原地换色让人难以感知哪个标签被激活了。移动的指示块能对切换的方向和目标给出即时的空间反馈,符合用户对现代分段控件的预期。

评审验证

运行 Web Shell,打开一个工作区会话,在侧栏 Tasks 和 Channels 标签间点击:高亮块应可见地滑动过去(约 0.2s)而不是原地渐变。拖动侧栏宽度,滑块 1:1 跟随无迟滞。其他带标签的页面(Plugins、MCP、Agents 管理页)获得同样的滑动,同时保持内容宽度的标签;workflow-runs 页面保持下划线样式。自动化覆盖:client/e2e/web-shell.tabs-indicator.spec.ts(已打 @smoke)断言点击前后滑块与激活标签精确重合、记录过渡期间的中间位置以证明确实在滑动,并验证 reduced-motion 下无中间帧的跳变;components/ui/tabs.test.tsx 覆盖滑块渲染/隐藏、ref 转发、resize 与切换的过渡差异。

证据(前后对比)

改动前:激活标签的背景/阴影在原地渐变,没有位移。改动后:Tasks → Channels 点击期间对滑块左缘采样得到连续 ease-out 轨迹(67.8 → 74.9 → 81.8 → … → 123.9 → 124px),终点与 Channels 标签边界精确重合;静止态与旧激活态逐像素一致,因为滑块沿用了原激活标签的背景、边框与阴影 token。维护者已在真实 daemon 上用暂停过渡的方式复核了滑动、reduced-motion 跳变、键盘激活与纵向排列。

测试平台

macOS ✅;Windows / Linux 未本地验证(由 CI 覆盖)。

风险与范围

  • 主要风险/取舍:所有 default 变体的标签列表都会获得滑动效果,而非仅侧栏开关——这是一致性的有意为之;每个列表各自测量自己的激活标签,不需要各页面额外接线。溢出策略仍按各界面归属:基础组件保持内容宽度默认,只有侧栏两个标签选择等分可收缩。
  • 未验证/范围外:TabsTrigger 在 React 18 下既有的 ref 丢失问题(PluginManagerPage 给普通函数组件传了 focus 恢复用的 ref)与本 PR 无关、未做改动,值得后续跟进。
  • 破坏性变更/迁移说明:无。TabsList 不再声明 asChild(两个子节点的渲染无法被 Slot 承接);没有任何调用方使用过它。

The tab list used to recolor each trigger in place, so switching tabs
(e.g. Tasks/Channels in the sidebar) read as a flat cross-fade. Render a
single pill in the list that measures the active trigger and glides to it,
arming transitions only after first paint and honoring reduced motion.
@callmeYe

Copy link
Copy Markdown
Collaborator Author

E2E test report (2026-09-14, local Chromium, vite dev server + mock daemon):

  • web-shell.tabs-indicator.spec.ts (new, 2 tests): PASS — pill bounding box matches the Tasks tab on load (left 23px, width 101px) and the Channels tab after clicking (left 124px, width 101px); a 400ms rAF trace captured 20+ intermediate left positions with an ease-out curve (67.8 → … → 124px), confirming a slide rather than a cross-fade; under prefers-reduced-motion: reduce the computed transition-property is none.
  • web-shell.channels.spec.ts (existing, 2 tests): PASS — no regression in channel catalog switching.
  • Unit: components/ui/tabs.test.tsx (4 tests) and react18-ref-compat.test.tsx (31 tests, now including TabsList) PASS. npm run build, web-shell typecheck, and ESLint on changed files are clean.
  • Note: this machine has a pre-existing jsdom localStorage breakage (localStorage.setItem is not a function) that fails many unrelated web-shell suites identically with the change stashed, so it is environmental and unrelated to this PR.

Evidence plan and frame stills (resting Tasks state, mid-slide frame, resting Channels state) are in .qwen/e2e-tests/tabs-sliding-pill/ on the working branch checkout (git-ignored).

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 14, 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 ✓ — every required heading is filled in, including the Risk & Scope bullets and a complete Chinese translation.

Problem: this is a feat, so there is no bug to reproduce — the claim is a perceptual one ("the in-place recolor made it hard to see which tab became active"). You backed it with a sampled left-edge trace rather than just asserting it, which is more than most UI PRs offer. What is missing is the why now: there is no linked issue and no design doc behind it, so the direction call is yours rather than a response to a reported user problem. Worth saying out loud, since it changes what a reviewer is being asked to judge.

Direction: aligned. Web Shell UI polish is squarely in scope, and the two house rules this could have tripped are both respected — the change lands in the shared ui/tabs primitive instead of duplicating a per-page widget, and prefers-reduced-motion is already an established, tested convention here (client/hooks/usePrefersReducedMotion.ts, and visual-capture-contracts.test.ts pins reduced-motion rendering), so motion-reduce:transition-none matches precedent rather than inventing a parallel mechanism. claude-code's CHANGELOG has no direct reference — it is a TUI, different surface — but the area is relevant. No escalation trigger: nothing here touches auth, sandbox, model selection, telemetry, release, or a public contract.

Size: not applicable — no core paths. All four files sit under packages/web-shell/client/, a single package, so neither the two-tier core gate nor the large-PR advisory applies. For transparency: 124 production lines (tabs.tsx, 108 added / 16 removed) and 216 test lines across the three test files.

Approach: scope feels right and the diff is focused — no drive-by refactors and no unrelated churn. The import type * as React → named-import switch does rewrite three signatures that the feature did not need, but it is forced by adding a real forwardRef value import under verbatimModuleSyntax, and it matches what the sibling primitives and tests already do.

One correction to the description, since you enumerated the affected surfaces: it is nine default-variant TabsList instances across eight files, not seven — AgentCreatePage.tsx:528 also gets the pill and is not in your list. I agree the primitive is the right layer (per-page wiring would be worse, and AGENTS.md asks for exactly this), but that makes it an app-wide visual change, so each surface deserves a look rather than just the sidebar. Two of them are worth naming specifically because their layout differs from the sidebar's: SkillInstallDialog and the second ExtensionsManagerPage list use grid w-full grid-cols-N, and three use overflow-x-auto. I checked both shapes against the implementation — details in the code review, neither is broken.

Risk: no elevated risk signals — none of the revert-correlated paths are touched.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ —— 所有必需标题都已填写,包括 Risk & Scope 各项和完整的中文翻译。

问题: 这是一个 feat,没有可复现的 bug —— 论点属于感知层面("原地换色让人难以看出哪个标签被激活了")。你用采样的左边缘位移轨迹来支撑它,而不是空口断言,这比多数 UI PR 做得更多。缺的是为什么是现在:背后没有关联 issue,也没有设计文档,所以方向上的判断出自你本人,而非回应某个已上报的用户问题。这一点值得明说,因为它改变了评审者被要求判断的对象。

方向: 对齐。Web Shell 的 UI 打磨完全在范围内,而且这个改动可能踩到的两条项目规矩都遵守了 —— 改动落在共享的 ui/tabs 基础组件里,而不是在各页面重复实现一个小部件;prefers-reduced-motion 在本仓库已是既有的、有测试覆盖的约定(client/hooks/usePrefersReducedMotion.ts,且 visual-capture-contracts.test.ts 固化了 reduced-motion 下的渲染),所以 motion-reduce:transition-none 是沿用先例,而不是另造一套并行机制。claude-code 的 CHANGELOG 没有直接参照 —— 它是 TUI,界面形态不同 —— 但这个领域是相关的。无需上报维护者:这里没有触及 auth、sandbox、模型选择、telemetry、release 或对外契约。

规模: 不适用 —— 未触及核心路径。四个文件全部位于 packages/web-shell/client/ 之下,属于单个 package,因此核心两级门禁和大 PR 提示都不适用。供参考:生产代码 124 行(tabs.tsx,新增 108 / 删除 16),三个测试文件共 216 行测试代码。

方案: 范围合理,diff 聚焦 —— 没有顺手重构,也没有无关改动。import type * as React 改为具名导入确实重写了三个本功能并不需要的签名,但在 verbatimModuleSyntax 下新增 forwardRef 这个值导入就必然如此,而且这与同级基础组件和测试文件的写法一致。

有一处需要更正你的描述,因为你逐条列出了受影响的界面:实际是 8 个文件里的 9 处 default 变体 TabsList,不是 7 处 —— AgentCreatePage.tsx:528 同样会获得滑块,但没有出现在你的清单里。我同意基础组件是正确的层级(逐页接线会更糟,AGENTS.md 要求的正是这种做法),但这确实使它成为一次全局视觉改动,因此每个界面都值得看一眼,而不只是侧栏。其中有两处因其布局与侧栏不同而值得点名:SkillInstallDialogExtensionsManagerPage 的第二个列表使用 grid w-full grid-cols-N,另有三处使用 overflow-x-auto。我已对照实现检查了这两种形态 —— 细节见代码审查,两者都没有问题。

风险: 无升级风险信号 —— 未触及任何与回滚相关的路径。

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Code review

My independent proposal before reading the diff: make TabsList position: relative, render an aria-hidden absolutely-positioned span, measure the active trigger's offsets in useLayoutEffect, re-measure on activation and on resize, suppress the transition for the first placement, honour reduced motion, and move the active-state background/border/shadow off the trigger onto the pill. That is essentially what this does, so I have no simpler alternative to offer. There is also nothing to reuse: no animation library is a dependency anywhere in the repo, and adding one for a 200ms pill would be a worse trade than the ~60 lines here. Driving re-measurement from a MutationObserver on data-state rather than from the Radix value looked odd at first, but the comment is right — TabsList does not own the Root's context, so uncontrolled roots (defaultValue) and keyboard navigation would otherwise be missed, and the alternative is context plumbing through a leaf primitive. Reasonable call.

No correctness blockers found. Two things to fix before this merges, neither in the product code:

1. The new browser spec never runs as a PR gate. CI's only pre-merge web-shell browser job is web-shell E2E Smoke, which runs npm run test:e2e:smoke = playwright test --grep @smoke. web-shell.tabs-indicator.spec.ts carries no @smoke tag, so it is not selected. The job that would run it — web-shell Browser Regression in e2e.yml, which runs the full test:e2e — triggers on push-to-main, the nightly schedule, and manual dispatch, so it executes after merge, not before. Net effect: the sliding trace and the reduced-motion assertion, which are the only real-browser evidence for this PR's central claim, are never machine-checked pre-merge. Seventeen sibling specs are @smoke-tagged, so tagging is the established way to make a spec gate. Honest caveat: it is not universal — web-shell.channels.spec.ts, which covers this same sidebar surface, is untagged too — but for a PR whose headline claim is behavioural, one word in the test.describe title fixes it.

2. Lint & Static is red, and the reason matters more than the colour. It failed at the Check lint gate freshness step, not in ESLint. Run ESLint, Run Prettier, the i18n check, the schema checks and the helper tests all sit after that step in the same job, so none of them executed: this diff has had no lint, format, or static analysis at all. The gate's own message names the cause — the branch predates a ci.yml change on main:

The lint gate changed on 'main' after this branch last incorporated it:

  - .github/workflows/ci.yml: c666ec1a0ab8 fix(ci): restore Windows test baseline (#11787) (2026-09-14)

This lane checks out the branch head alone, so its green proves the branch
passes the gate AS THE BRANCH DEFINES IT — with the files above, that gate
is stale. Merge or rebase 'main' into this branch and push to
re-validate under the current gate.
##[error]Process completed with exit code 1.

So this is PR-caused and PR-fixable, not infra noise: merge or rebase main and push. I am not claiming a specific violation — I spot-checked the most likely candidate, the wrapped expect(ref.current).toBe(...) assertion in tabs.test.tsx, and it is Prettier-correct as written: collapsed to one line it is 81 characters against this repo's printWidth: 80, so the wrap and the trailing comma are exactly what Prettier would produce. The point is narrower and still worth stating — nobody has confirmed that for the other three files, because the job that would confirm it never got there.

Things I checked that are not broken — recording them so the next reader does not have to re-derive them:

  • grid w-full grid-cols-N consumers (SkillInstallDialog, the second ExtensionsManagerPage list): the indicator is a child of the list, so it looks like it would eat a grid cell and shove the triggers over. It does not — an absolutely-positioned child of a grid container is out of flow and is not a grid item, so the triggers lay out as if it were absent, and its containing block is the same padding box offsetLeft is measured against.
  • overflow-x-auto consumers (Agents manager, Agent create, Extensions manager): the list becomes a scroll container, but offsetLeft is a layout offset independent of scroll position, and an abspos descendant scrolls with the container's content, so pill and trigger shift together. Scrolling does not resize the list, so the ResizeObserver correctly stays quiet.
  • left: active.offsetLeft under p-[3px]: both offsetLeft and an abspos left resolve against the offsetParent's padding box, so the 3px padding introduces no skew. The spec's getBoundingClientRect comparison would catch it either way.
  • Paint and hit-testing: the pill is rendered before {children}; both it and the triggers are positioned with z-index: auto, so DOM order puts the triggers on top. Labels and focus-visible rings stay visible, and clicks still reach the triggers — the missing pointer-events-none is harmless, not a latent click-swallow.
  • Deleting the trigger's active-state classes: with data-active:bg-background and data-active:shadow-sm gone from the trigger, the line variant's bg-transparent overrides became dead code, so removing them is correct rather than a regression. The pill carries the same bg-background / dark:bg-input/30 / dark:border-input / shadow-sm tokens the active trigger used to, so the rest state is genuinely equivalent, and the line variant's after: underline is untouched.
  • ResizeObserver in jsdom: tabs.test.tsx installs no stub, which would throw if jsdom were bare — but client/test/setup.ts polyfills it globally, so the suite runs. Worth knowing what that suite can and cannot prove: jsdom computes no layout, so every offsetLeft/offsetWidth is 0 there. The unit tests meaningfully pin indicator presence, the opacity-0 no-active case, line-variant absence, and ref forwarding — and cannot pin positioning at all. That split is right, and it is exactly why finding 1 matters.
  • Ref forwarding uses React.forwardRef and passes the ref to the Radix primitive, with a new entry in react18-ref-compat.test.tsx — precisely what the Web Shell ref rule asks for. Reduced motion via Tailwind's motion-reduce: variant rather than the existing usePrefersReducedMotion hook is also the better choice here: pure CSS, no subscription, no re-render.

Not verified: the actual rendered result. No pre-merge CI lane renders these pixels (the visuals suite is capture-only — no toHaveScreenshot, no committed baselines), jsdom computes no layout, and the browser spec that would check it is not gated. Also unexercised anywhere: no consumer passes orientation="vertical" (the only aria-orientation="vertical" hits are unrelated menus), so the top/height half of the measurement has no caller. It is two extra properties rather than speculative machinery, but "works for vertical tab lists" is currently an untested claim.

CI test evidence

The PR's own CI, read via the API at the reviewed commit — nothing was built or executed locally. Bot orchestration checks (triage, review-pr, assign, label, authorize, and the skipped publish/resolve/precheck jobs) are omitted as noise; Test (macos) and Test (windows) were skipped by the workflow's own matrix conditions, not by this PR.

Final CI results for 3b9baf2 (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Lint & Static (ubuntu-latest, Node 22.x) ❌ failure
web-shell E2E Smoke (ubuntu-latest, Node 22.x) 🚫 cancelled
Capture web-shell visuals (ubuntu-latest, Node 22.x) ✅ success
Classify PR ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

Test (ubuntu-latest, Node 22.x) is the job that runs the packages/web-shell vitest suite, so the new tabs.test.tsx and the react18-ref-compat.test.tsx addition were still in progress at the time of writing, not green — CI has not yet proven them. The author reports both passing locally along with typecheck, lint and a full build; that is the author's claim, not evidence I re-ran, and it sits awkwardly next to a red Lint & Static that never reached ESLint.

Sandboxed verification would settle this: @qwen-code /verify — that the pill actually lands on the active trigger's bounds and slides rather than cross-fading is not observable from the diff, and after finding 1 no pre-merge lane observes it either. Two honest caveats on lane fit. @qwen-code /tmux is not the right tool here: it drives the TUI and cannot see a Web Shell browser surface. And /verify is stronger on wire and CLI behaviour than on CSS animation. The higher-value action is the one-word @smoke tag from finding 1 — it makes the existing CI job execute the spec the author already wrote, on this PR and every future one, which beats any one-off run. The author has write access, so no sponsored-run screening applies.

中文说明

代码审查

在读 diff 之前我自己的方案是:让 TabsList 变成 position: relative,渲染一个 aria-hidden 的绝对定位 span,在 useLayoutEffect 里测量激活标签的偏移量,在激活变化和尺寸变化时重新测量,首次定位时不播放过渡,遵循 reduced motion,并把激活态的背景/边框/阴影从标签移到滑块上。这个 PR 做的基本就是这些,所以我没有更简单的替代方案可提。也没有可复用的东西:仓库里任何地方都没有动画库依赖,为一个 200ms 的滑块引入一个,代价比这里的约 60 行更差。用监听 data-stateMutationObserver 而不是 Radix 的 value 来驱动重测,一开始看着有点怪,但注释说得对 —— TabsList 并不持有 Root 的 context,否则非受控 root(defaultValue)和键盘导航都会漏掉,而替代方案是要在一个叶子基础组件里穿透 context。这个取舍是合理的。

未发现正确性阻塞问题。 合并前有两件事要处理,都不在产品代码里:

1. 新增的浏览器测试不会作为 PR 门禁运行。 CI 中唯一在合并前跑的 web-shell 浏览器任务是 web-shell E2E Smoke,它执行 npm run test:e2e:smoke,即 playwright test --grep @smokeweb-shell.tabs-indicator.spec.ts 没有 @smoke 标记,因此不会被选中。真正会跑它的是 e2e.yml 里的 web-shell Browser Regression(执行完整的 test:e2e),而它的触发条件是 push 到 main、每夜定时和手动 dispatch —— 也就是在合并之后执行,而不是之前。结果是:滑动轨迹采样和 reduced-motion 断言是这个 PR 核心论点唯一的真实浏览器证据,但它们在合并前从不会被机器验证过。有 17 个同级 spec 带了 @smoke,所以打标记是让 spec 成为门禁的既有做法。需要如实说明:这并不是普遍约定 —— 覆盖同一个侧栏界面的 web-shell.channels.spec.ts 同样没有标记 —— 但对一个核心论点是行为性的 PR 来说,在 test.describe 标题里加一个词就能解决。

2. Lint & Static 是红的,而原因比红色本身更重要。 它失败在 Check lint gate freshness 这一步,不是 ESLint。Run ESLintRun Prettier、i18n 检查、schema 检查和 helper 测试在同一个 job 里都排在这一步之后,所以一个都没执行:这个 diff 完全没有经过 lint、格式化或静态分析。 门禁自己的输出说明了原因 —— 分支落后于 main 上的一次 ci.yml 改动(日志摘录见上方英文部分)。所以这是 PR 自身造成、也可以由 PR 自身修复的,不是基础设施噪音:合并或 rebase main 后推送即可。我并没有指认某条具体的违规 —— 我抽查了最可能的候选,即 tabs.test.tsx 里那个折行的 expect(ref.current).toBe(...) 断言,而它按现状是符合 Prettier 的:折叠成一行是 81 个字符,而本仓库的 printWidth 是 80,所以这个折行和尾随逗号正是 Prettier 会产出的形式。要点更窄,但仍然值得说明 —— 另外三个文件没有人确认过,因为本该确认它们的 job 没有跑到那一步。

我检查过、确认没有问题的点 —— 记录下来,免得下一位读者重新推导:

  • grid w-full grid-cols-N 的使用方SkillInstallDialogExtensionsManagerPage 的第二个列表):滑块是列表的子元素,看起来会占掉一个 grid 单元格、把标签挤开。实际不会 —— grid 容器的绝对定位子元素脱离文档流,不是 grid item,所以标签的布局就像它不存在一样;而它的包含块与 offsetLeft 的测量基准是同一个 padding box。
  • overflow-x-auto 的使用方(Agents 管理页、Agent 创建页、Extensions 管理页):列表变成滚动容器,但 offsetLeft 是与滚动位置无关的布局偏移量,且绝对定位的后代会随容器内容一起滚动,所以滑块和标签同步移动。滚动不会改变列表自身尺寸,因此 ResizeObserver 保持静默是正确的。
  • p-[3px] 下的 left: active.offsetLeftoffsetLeft 和绝对定位的 left 都以 offsetParent 的 padding box 为基准,所以 3px 内边距不会造成错位。无论如何 spec 里的 getBoundingClientRect 比较也能抓到。
  • 绘制顺序与命中测试:滑块在 {children} 之前渲染;它和标签都是 z-index: auto 的定位元素,因此 DOM 顺序让标签绘制在滑块之上。文字和 focus-visible 焦点环仍然可见,点击也仍然落在标签上 —— 缺少 pointer-events-none 是无害的,不是潜在的点击吞噬。
  • 删除标签上的激活态类名data-active:bg-backgrounddata-active:shadow-sm 从标签上移除后,line 变体的 bg-transparent 覆盖就成了死代码,所以删掉它们是正确的,而不是引入回归。滑块携带的 bg-background / dark:bg-input/30 / dark:border-input / shadow-sm 与激活标签原先使用的 token 完全一致,因此静止态确实等价,line 变体的 after: 下划线也未受影响。
  • jsdom 里的 ResizeObservertabs.test.tsx 没有安装 stub,如果 jsdom 是裸的就会抛错 —— 但 client/test/setup.ts 做了全局 polyfill,所以测试能跑。需要清楚这套测试能与不能证明什么:jsdom 不计算布局,所以那里所有 offsetLeft/offsetWidth 都是 0。单元测试有意义地固化了滑块是否渲染、无激活项时 opacity 为 0、line 变体不渲染滑块、以及 ref 转发 —— 而完全无法固化定位。这个分工是对的,而这恰恰是第 1 条为什么重要。
  • ref 转发使用 React.forwardRef 并把 ref 传给 Radix 基础组件,同时在 react18-ref-compat.test.tsx 里新增了一条 —— 正是 Web Shell ref 规则所要求的。用 Tailwind 的 motion-reduce: 变体而非既有的 usePrefersReducedMotion hook 来处理 reduced motion,在这里也是更好的选择:纯 CSS,不需要订阅,也不会触发重渲染。

未验证: 实际渲染结果。合并前没有任何 CI 通道会渲染这些像素(visuals 套件只做截图捕获 —— 没有 toHaveScreenshot,也没有提交的基线图),jsdom 不计算布局,而本该检查它的浏览器 spec 又不在门禁里。另外有一处完全无人调用:没有任何使用方传 orientation="vertical"(唯一命中的 aria-orientation="vertical" 属于无关的菜单),所以测量里的 top/height 那一半没有调用者。它只是两个多余的属性,算不上投机性的复杂设计,但"纵向标签列表同样适用"目前是一个未经测试的说法。

CI 测试证据

以上是通过 API 在受审 commit 上读取的、该 PR 自身的 CI —— 本地没有构建或执行任何东西。机器人编排类检查(triage、review-pr、assign、label、authorize,以及被跳过的 publish/resolve/precheck 各 job)作为噪音省略;Test (macos)Test (windows) 是被工作流自身的 matrix 条件跳过的,与本 PR 无关。

CI 表格见上方英文部分(该表格由后续流程原地更新,此处不重复)。Test (ubuntu-latest, Node 22.x) 是运行 packages/web-shell vitest 套件的 job,因此截至撰写时,新增的 tabs.test.tsxreact18-ref-compat.test.tsx 的改动仍在进行中,不是绿色 —— CI 还没有证明它们。作者报告本地这两项连同 typecheck、lint 和完整构建均通过;那是作者的说法,不是我重跑得到的证据,而且它与一个从未跑到 ESLint 的红色 Lint & Static 并列时显得矛盾。

沙箱验证可以定论:@qwen-code /verify —— 滑块是否真的落在激活标签的边界上、是滑动而不是原地渐变,从 diff 看不出来,而在第 1 条之后,合并前也没有任何通道会去观察它。关于通道适配性有两点需要如实说明:@qwen-code /tmux 在这里不是合适的工具,它驱动的是 TUI,看不到 Web Shell 的浏览器界面;而 /verify 在协议与 CLI 行为上比在 CSS 动画上更有力。价值更高的动作是第 1 条里那个一个词的 @smoke 标记 —— 它让现有的 CI job 去执行作者已经写好的 spec,在这个 PR 和以后每个 PR 上都执行,这胜过任何一次性的验证运行。作者有写权限,因此不涉及 sponsored run 的筛查。

Qwen Code · qwen3.8-max-2026-09-02

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 14, 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 c555bc4. 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 2 render-shaping files:

  • packages/web-shell/client/components/sidebar/WebShellSidebar.tsx
  • packages/web-shell/client/components/ui/tabs.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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — the code is right as far as I can tell, but nothing has actually executed it: static analysis never ran, and the one real-browser check for the headline claim is not wired into any pre-merge gate.

Stepping back. My independent proposal for this converged on the same design, which is usually a good sign — I am not looking at an approach I would have done more simply. The "not broken" list in my review is long because there were several plausible traps here and none of them fired: an absolutely-positioned child inside two grid-cols-N tab lists, three overflow-x-auto scroll containers, a p-[3px] padding box as the positioning origin, and paint order against the triggers' focus-visible rings. Each of those could have been a real bug. That the implementation survives all four is evidence the author thought about the primitive rather than about the sidebar.

Context that raises my prior on the direction call: this author wrote #11262, the unified session sources feature that created the Tasks/Channels switch in the first place. So this is the owner of that surface improving its feedback, not a stranger decorating it. I would normally push harder on an app-wide animation with no linked issue behind it — nobody reported that tab switching was hard to follow — but a maintainer with write access polishing a control they built, in the shared primitive where AGENTS.md says it belongs, is a reasonable way for that change to arrive. The blast radius is still under-described in the PR body (nine default-variant lists across eight files, not seven; AgentCreatePage is missing from the enumeration), and I would like each of the nine eyeballed rather than just the sidebar before this merges.

Why 3/5 and not 4/5. I cannot describe this as "solid, only non-blocking nits" when the diff has had zero lint, format, or static analysis — Lint & Static died at the freshness gate before reaching ESLint — and when the sliding trace and the reduced-motion assertion, which are the entire behavioural case for the PR, are not selected by any job that runs before merge. Both gaps are cheap to close and neither is a code defect. But right now "it works" rests on the author's local run, and the unit suite that CI will run cannot see layout at all, because jsdom reports every offset as 0. A green unit run here proves the indicator renders and hides; it does not prove it lands on the right pixel.

Why not lower. Nothing in the diff is wrong, and I am not going to file a formal request-changes over a stale branch and a missing test tag. That would be theatre.

One forward-looking fragility, non-blocking but worth knowing before someone maintains this in six months: active.offsetLeft resolves against the trigger's offsetParent, and the whole scheme assumes that is the list. Nothing enforces it. If a future change wraps a TabsTrigger in any position: relative element, the pill silently measures against the wrapper and lands in the wrong place, with no test to catch it — the unit suite cannot see offsets, and the browser spec is not gated. A one-line comment on the measurement stating the assumption would cost nothing.

What would move me to approve: merge or rebase main so Lint & Static runs ESLint and Prettier for real, and add @smoke to the new spec's test.describe so web-shell E2E Smoke actually executes it. After that the visual claim is machine-checked on this PR and on every future touch of the tabs primitive, which is worth more than any one-off verification run I could ask for now.

So: not approving this run, and not requesting changes. @callmeYe — the two items above are yours and both are small; ping for a re-run once they land and CI is green on the new head.

中文说明

Confidence: 3/5 —— 就我能判断的而言代码是对的,但实际上没有任何东西真正执行过它:静态分析从未运行,而针对核心论点的唯一真实浏览器检查也没有接入任何合并前门禁。

退一步看。我为这个问题独立构想的方案与它收敛到了同一个设计,这通常是个好信号 —— 我面对的不是一个我本可以做得更简单的方案。审查里那份"没有问题"的清单之所以长,是因为这里有几个看起来很可能会踩的坑,而一个都没踩中:两个 grid-cols-N 标签列表里的绝对定位子元素、三个 overflow-x-auto 滚动容器、作为定位原点的 p-[3px] padding box,以及与标签 focus-visible 焦点环之间的绘制顺序。这里面每一个都可能成为真实的 bug。实现能同时避开这四处,说明作者是在为这个基础组件本身考虑,而不只是为侧栏考虑。

有一点提升了我对方向判断的先验:这位作者写了 #11262,也就是最初创建 Tasks/Channels 切换的统一会话来源功能。所以这是该界面的负责人在改进它自己的反馈,而不是一个外人在给它做装饰。对于一个背后没有关联 issue 的全局动画,我通常会追问得更狠一些 —— 并没有人上报过标签切换难以辨认 —— 但一位有写权限的维护者去打磨自己构建的控件,并且改在 AGENTS.md 指定应该改的共享基础组件里,这是这类改动合理的来路。不过影响范围在 PR 描述里仍然被低估了(8 个文件里的 9 处 default 变体列表,不是 7 处;枚举里漏掉了 AgentCreatePage),我希望合并前这 9 处都被逐一看过,而不只是侧栏。

为什么是 3/5 而不是 4/5。当这个 diff 完全没有经过 lint、格式化或静态分析时 —— Lint & Static 在到达 ESLint 之前就死在了 freshness 门禁上 —— 我没法把它描述成"扎实,只有非阻塞的小问题";同样,当滑动轨迹采样和 reduced-motion 断言(也就是这个 PR 全部的行为性论据)不被任何合并前运行的 job 选中时,我也没法这么描述。这两个缺口都很容易补,也都不是代码缺陷。但此刻"它能工作"依赖的是作者本地的运行,而 CI 跑的那套单元测试完全看不到布局,因为 jsdom 把所有偏移量都报为 0。在这里一次绿色的单元测试只能证明滑块会渲染和隐藏,不能证明它落在正确的像素上。

为什么不更低。diff 里没有任何东西是错的,而我也不打算为一个落后的分支和一个缺失的测试标记提交正式的 request-changes。那只是做戏。

有一处面向未来的脆弱点,非阻塞,但在六个月后有人维护它之前值得知道:active.offsetLeft 是相对标签的 offsetParent 解析的,而整套方案假定那就是列表本身。没有任何东西强制这一点。如果将来某个改动把 TabsTrigger 包进任何 position: relative 的元素里,滑块就会静默地以那个包装元素为基准测量,落到错误的位置,而且没有测试能抓到 —— 单元测试看不到偏移量,浏览器 spec 又不在门禁里。在测量处加一行注释说明这个假定,成本是零。

能让我转向批准的条件:合并或 rebase main,让 Lint & Static 真正跑到 ESLint 和 Prettier;并在新 spec 的 test.describe 上加 @smoke,让 web-shell E2E Smoke 真正执行它。之后这个视觉论点就会在本 PR 以及将来每一次改动 tabs 基础组件时被机器验证,这比我此刻能要求的任何一次性验证运行都更有价值。

所以:本轮不批准,也不提交 request-changes。 @callmeYe —— 上面两项是你的,而且都很小;处理完、CI 在新的 head 上变绿之后,回复一声重跑即可。

Qwen Code · qwen3.8-max-2026-09-02

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

The tab trigger is a plain button with no cursor rule, so it kept the
UA default arrow; give it the pointer the rest of the sidebar uses.
Flex triggers defaulted to min-width:auto, so on narrow lists their
content could not shrink, justify-center split the overflow to both
sides, and the active trigger (and the pill mirroring it) poked past
the list's padding edge; unequal labels also produced unequal tab
widths despite flex-1. min-w-0 lets the triggers shrink to equal
shares, which keeps the indicator inside the frame.
Once triggers can shrink (min-w-0), a label wider than its slot
overflows symmetrically from the centered content and paints over the
sliding pill's edge. Clip content to the trigger box in the default
variant; the line variant keeps visible overflow because its underline
sits outside the box.
Equal halves can undercut a label in narrow sidebars or wider font
environments, and the trigger's overflow guard then cut the text
mid-glyph. Truncating spans turn that into a clean ellipsis while
leaving the common case untouched.

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

Not explored to full depth (tool budget reached): "agent reverse-audit (round 1)": the two new Playwright specs were not executed in a real browser (they need the npm run dev vite server plus a Chromium install), so the pill's measured geome….

Test Plan (not a blocker): components/ui/tabs.test.tsxno such file or directory.

中文说明

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 1)"the two new Playwright specs were not executed in a real browser (they need the npm run dev vite server plus a Chromium install), so the pill's measured geome…

Test Plan(非阻断):components/ui/tabs.test.tsxno such file or directory

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment thread packages/web-shell/client/components/ui/tabs.tsx Outdated
Comment thread packages/web-shell/client/components/ui/tabs.test.tsx Outdated
Comment thread packages/web-shell/client/components/ui/tabs.tsx Outdated
Comment thread packages/web-shell/client/components/ui/tabs.tsx
Comment thread packages/web-shell/client/components/ui/tabs.tsx Outdated
Comment thread packages/web-shell/client/components/ui/tabs.tsx Outdated
Comment thread packages/web-shell/client/components/ui/tabs.tsx
Comment thread packages/web-shell/client/components/ui/tabs.tsx
Comment thread packages/web-shell/client/components/ui/tabs.tsx
@wenshao

wenshao commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Local verification on a real stack (maintainer)

I built 5c6f1d5 locally and drove it in Chromium against a real qwen serve daemon — no mock daemon — side by side with its merge base c666ec1, then A/B'd individual classes to attribute every difference.

Short version: the sliding pill itself does exactly what the description says, and I'd like it in. But the three follow-up commits pushed after the triage review (min-w-0, overflow-hidden) regress tab labels on shipped surfaces, and the PR's own new spec fails deterministically on this machine.

Setup

Trees head 5c6f1d5 and merge base c666ec1, separate worktrees, each its own npm ci
Stack one real daemon (node scripts/dev.js serve, isolated QWEN_HOME/QWEN_RUNTIME_DIR, fixture workspace) + two vite dev servers (head :5911, base :5912) pointed at the same daemon, so the only difference is the tree
Driver Playwright 1.61.1 / Chromium 149, 1440×900 @ DPR 2 (also 1100 and 1920), en + zh-CN, dark + light
Machine macOS 26.6.2 (Darwin 25.6.0), Node 24.18.1

What holds up

1 · It really slides, and the fill really moved onto the pill. Frames captured by pausing the live CSS transition at fixed currentTime values: pill left = 23 → 61.4 → 84.8 → 112.8 → 130 px at t = 0/25/50/100/200 ms, and both triggers report background-color: rgba(0,0,0,0) at every sample. On base nothing moves and the two triggers cross-fade in place (alpha 0.045 → 0 and 0 → 0.045). A live rAF trace agrees (23 … 91.0 @116 ms … 130 @230 ms).

pill slides

2 · Reduced motion — computed transition-property: none, and the pill's only two distinct x positions through a switch are [23, 130]: it jumps, no intermediate frame.

3 · KeyboardArrowLeft/ArrowRight move the pill too (Radix automatic activation), dx = 0.

4 · Vertical orientation works. No consumer passes orientation="vertical", so this had never been executed anywhere; rendered from both trees, the pill tracks y 56 → 108 with dx = dy = 0. The top/height half of the measurement is correct.

5 · Rest-state fill is unchanged. Inside the pill, 0–6 of 6732 sampled pixels differ (text antialiasing, dark and light, both tabs). See finding 2 for the edge.

6 · Everything green here. packages/web-shell vitest: head 313 files / 8011 tests passed, base 312 / 8006 — the delta is exactly this PR's new file and cases. tabs.test.tsx 4 ✓, react18-ref-compat.test.tsx 31 ✓, typecheck ✓, eslint ✓, prettier --check ✓. Note for the PR description: the "pre-existing jsdom localStorage breakage" does not reproduce here — on a clean npm ci both trees are fully green, so that caveat looks local to your machine rather than to the repo.

7 · The unit tests have teeth, but only for presence. Mutating the component to never render the indicator fails 3 of 4 tests; pinning the pill at a wrong fixed box (left:0; top:0; width:4; height:4) still passes 4/4 — jsdom computes no layout, so only the browser spec can pin positioning. Which brings us to finding 2.


Findings

1 · Blocking — tab labels are now hard-clipped mid-word, with no ellipsis, on shipped surfaces

TabsTrigger is flex-1 (flex: 1 1 0%). Base relied on the flex default min-width: auto to hold each trigger at its content width. 7fa81bb adds min-w-0 and 57a62ff adds group-data-[variant=default]/tabs-list:overflow-hiddeneither one alone drops the automatic minimum size to 0 — so every default tab list now splits its width into equal shares and cuts the longest label. Only the sidebar got a truncate span (5c6f1d5); everywhere else the text is simply sliced, on both ends, because it is centred.

Measured at 1440×900 against the same daemon and the same data:

Surface PR base this PR
Plugins header (Extensions / MCP / Skills / Agents) 84 / 43 / 47 / 58 px — full labels 58 px each; Extensions overflows 13 px → renders xtension
Agent detail (Basic Information / System Prompt / Tools / MCP / Hooks) 127 / 113 / 47 / 43 / 54 px — full 77 px each; overflow 25 px and 18 px → sic Informat / ystem Promp
Plugins header, zh-CN 40 / 43 / 40 / 54 px 44 px each; 智能体 clipped 5 px
max-w-full overflow-x-auto list (Agents manager, Extensions manager, Agent create) content widths, real scroll container (scrollLeft moves) 7 equal 36 px cells, 5 of 7 labels clipped, scrollLeft stays 0 — the list no longer scrolls
Sidebar at its 220 px minimum — this PR's own surface Tasks 74 / Channels 95 — both full 85 / 85 → Chan…

Viewport-independent: identical at 1100, 1440 and 1920 px.

label clipping

scroll and sidebar

A/B in the head tree: removing min-w-0 alone is not enough (the overflow-hidden keeps the minimum at 0). Removing both — keeping the sidebar's truncate span — restores base widths on every surface (Plugins 84/43/47/58, agent detail 0 px overflow, sidebar 74/95) and the pill keeps working, because it measures whatever the trigger ends up being.

Suggested shape:

  • drop min-w-0 and group-data-[variant=default]/tabs-list:overflow-hidden from the primitive;
  • if the sidebar wants equal halves, put min-w-0 on those two TabsTriggers (they already carry the truncating span) rather than on every tab in the app;
  • if a global safety net is still wanted, make it truncate (ellipsis) instead of a hard cut.

2 · The new Playwright spec fails here — deterministically, 3/3 — and CI still never runs it

web-shell.tabs-indicator.spec.ts:36 › pill overlays the active trigger and slides on switch
  Expected: 106.5   <- tab getBoundingClientRect().width
  Received: 107     <- pill width, set from active.offsetWidth (an integer)
  Expected difference: < 0.5 ; Received difference: 0.5
  > 55 |  expect(pillBox.width).toBeCloseTo(tabBox.width, 0);

offsetLeft/offsetWidth are rounded integers while the flex layout lands on half-pixels, so the pill sits 0.5 px low and 0.5 px wide, and toBeCloseTo(…, 0) requires < 0.5. The same rounding is why "stills at rest are pixel-identical" holds for the fill but not for the border: the interior is identical (above), while the border ring moves by one device pixel — 2.5 % (dark) to 4.1 % (light) of the strip, max channel delta 46. Measuring with getBoundingClientRect() fixes the spec and the alignment at once.

The spec is also still untagged, so npm run test:e2e:smoke (--grep @smoke) does not select it — the triage review's finding 1 is unaddressed at this head, which is precisely why nobody saw the failure above.

spec failure and rounding

3 · Minor — the pill trails the tab during a sidebar resize drag

The 200 ms transition also applies to ResizeObserver-driven re-measurements, so during a drag the pill lags: max |Δleft| = 10.7 px over a normal drag (Channels active), settling ~200 ms after release (a single 140 px jump shows 69.5 px of offset at t = 40 ms, 0.1 px at 200 ms). "Stays glued" is true at rest, not during the drag. Suppressing the transition when the measurement came from the ResizeObserver would fix it.

4 · Minor / latent — a list that first mounts hidden flies in from the origin

ready arms on the first requestAnimationFrame after mount regardless of whether the list had a measurable box. A default TabsList that first renders inside display: none and is revealed later animates in: x 29 → 83, width 2 → 53 over ~200 ms. No shipped consumer hits it today (Radix unmounts inactive TabsContent), so it's latent — arming ready on the first non-zero measurement would close it.

5 · Doc nit — the line variant is not untouched

min-w-0 sits on the trigger for every variant, so the Workflows tab bar changes too: Saved/Running/History 106.15 / 119.91 / 113.6 px → 113.22 px each, which shifts the tab positions and the underline width. Benign, but it is what the visuals preview flagged on this PR.

vertical, line variant, resize


Coverage note

I ran the repo's own visuals capture on both trees: 51 of 71 screens identical, 20 differ — 12 of them only by the sub-pixel pill edge (max channel delta ≤ 9) and 8 workflow pages (text moved, finding 5). None of the surfaces in finding 1 are captured by any suite, and the browser spec that would cover the pill is not gated — so no lane in this repo would have caught the clipping.

Verdict

Core change: good, and the two claims that were unverifiable from the diff (it slides; it honours reduced motion) are now confirmed on a real stack, plus vertical, which had never run anywhere. Before merge I'd like:

  1. min-w-0 + overflow-hidden dropped from the primitive or scoped to the sidebar (finding 1);
  2. the pill measured with getBoundingClientRect() so the pill and the spec agree (finding 2);
  3. the spec tagged @smoke so CI actually runs it.

Ping me after the push and I'll re-run this rig against the new head.

中文说明

本地真实环境验证(维护者)

我在本地构建了 5c6f1d5,用 Chromium 驱动它连接真实的 qwen serve daemon(不是 mock daemon),与其 merge base c666ec1 并排对照,并通过逐个类名的 A/B 回退来归因每一处差异。

结论先说:滑块本身完全符合描述,我希望它能合入。但在 triage 评审之后追加的三个修复提交(min-w-0overflow-hidden)在已发布的界面上造成了标签被裁切的回归,而且这个 PR 自己新增的 spec 在本机是稳定失败的。

环境

代码树 head 5c6f1d5 与 merge base c666ec1,各自独立 worktree、各自 npm ci
一个真实 daemon(node scripts/dev.js serve,隔离的 QWEN_HOME/QWEN_RUNTIME_DIR,fixture 工作区)+ 两个 vite dev(head :5911、base :5912)指向同一个 daemon,因此唯一变量就是代码树
驱动 Playwright 1.61.1 / Chromium 149,1440×900 @ DPR 2(另测 1100 与 1920),en + zh-CN,深色 + 浅色
机器 macOS 26.6.2(Darwin 25.6.0),Node 24.18.1

站得住的部分

  1. 确实在滑动,且背景确实转移到了滑块上。 用固定 currentTime 暂停真实 CSS 过渡抓帧:滑块 left 在 t = 0/25/50/100/200 ms 分别为 23 → 61.4 → 84.8 → 112.8 → 130 px,同时两个标签的 background-color 在每个采样点都是 rgba(0,0,0,0)。base 上没有任何元素移动,两个标签在原地交叉渐变(alpha 0.045 → 0、0 → 0.045)。实时 rAF 采样也一致(23 … 91.0 @116 ms … 130 @230 ms)。
  2. Reduced motion —— 计算值 transition-property: none,整个切换过程中滑块只有 [23, 130] 两个位置:直接跳过去,没有中间帧。
  3. 键盘 —— ArrowLeft/ArrowRight 同样带动滑块(Radix 自动激活),dx = 0
  4. 纵向排列可用。 仓库里没有任何使用方传 orientation="vertical",所以这条路径此前从未被执行过;两棵树分别渲染后,滑块沿 y 从 56 移到 108,dx = dy = 0,测量中 top/height 的那一半是正确的。
  5. 静止态填充没有变化。 滑块内部 6732 个采样像素中只有 0–6 个不同(文字抗锯齿,深浅色、两个标签都如此)。边框部分见发现 2。
  6. 本机全绿。 packages/web-shell vitest:head 313 个文件 / 8011 条通过,base 312 / 8006,差值正好是本 PR 新增的文件与用例。tabs.test.tsx 4 条 ✓、react18-ref-compat.test.tsx 31 条 ✓、typecheck ✓、eslint ✓、prettier --check ✓。给 PR 描述的一条更正:所谓**「既有的 jsdom localStorage 环境问题」在这里复现不出来** —— 干净 npm ci 之后两棵树都完全通过,所以那应该是你本机的问题,而不是仓库的问题。
  7. 单测有辨别力,但只针对「是否渲染」。 把组件改成永不渲染滑块,4 条中有 3 条失败;而把滑块钉死在错误的固定位置(left:0; top:0; width:4; height:4),4 条仍然全过 —— jsdom 不计算布局,所以只有浏览器 spec 能固化定位。这正好引出发现 2。

发现

1 · 阻塞项 —— 标签现在会被硬裁切(无省略号),且发生在已发布的界面上。

TabsTriggerflex-1flex: 1 1 0%)。base 依赖 flex 默认的 min-width: auto 把每个标签保持在内容宽度。7fa81bb 加了 min-w-057a62ff 加了 group-data-[variant=default]/tabs-list:overflow-hidden —— 两者任意一个单独存在都会把自动最小尺寸降为 0 —— 于是所有 default 标签列表现在都把宽度平均分配,并把最长的标签切掉。只有侧栏拿到了 truncate5c6f1d5),其它地方是直接切断,而且因为文字居中,是两端都被切。

在 1440×900、同一 daemon、同一数据下实测:

界面 PR base 本 PR
Plugins 头部(Extensions / MCP / Skills / Agents 84 / 43 / 47 / 58 px,标签完整 各 58 pxExtensions 溢出 13 px → 显示成 xtension
智能体详情(Basic Information / System Prompt / Tools / MCP / Hooks 127 / 113 / 47 / 43 / 54 px,完整 各 77 px;溢出 25 px 与 18 px → sic Informat / ystem Promp
Plugins 头部(zh-CN 40 / 43 / 40 / 54 px 各 44 px智能体 被切 5 px
max-w-full overflow-x-auto 列表(Agents 管理页、Extensions 管理页、Agent 创建页) 内容宽度,真正可横向滚动(scrollLeft 会变) 7 个等宽 36 px 格子,7 个标签里 5 个被裁scrollLeft 恒为 0 —— 列表不再滚动
侧栏拖到 220 px 最小宽度(本 PR 自己的界面) Tasks 74 / Channels 95,都完整 85 / 85 → Chan…

与视口无关:1100、1440、1920 px 下结果一致。

在 head 树上做的 A/B:只删 min-w-0 不够overflow-hidden 仍把最小尺寸压到 0)。两个都删、并保留侧栏的 truncate span 之后,每个界面都回到 base 的宽度(Plugins 84/43/47/58、智能体详情 0 px 溢出、侧栏 74/95),而滑块照常工作 —— 它测的就是标签最终的尺寸。

建议的形状:

  • 从基础组件里删掉 min-w-0group-data-[variant=default]/tabs-list:overflow-hidden
  • 如果侧栏想要左右等宽,就把 min-w-0 放到侧栏那两个 TabsTrigger 上(它们已经带了会截断的 span),而不是加在全应用所有标签上;
  • 如果仍然想要一个全局兜底,请用 truncate(省略号)而不是硬切。

2 · 新增的 Playwright spec 在本机稳定失败(3/3),而 CI 依然不会跑它。

web-shell.tabs-indicator.spec.ts:36 › pill overlays the active trigger and slides on switch
  Expected: 106.5   <- 标签的 getBoundingClientRect().width
  Received: 107     <- 滑块宽度,来自 active.offsetWidth(整数)
  Expected difference: < 0.5 ; Received difference: 0.5

offsetLeft/offsetWidth 是四舍五入后的整数,而 flex 布局落在半像素上,所以滑块低 0.5 px、宽 0.5 px,而 toBeCloseTo(…, 0) 要求小于 0.5。同一个原因也让「静止态逐像素一致」这个说法对填充成立、对边框不成立:内部完全一致,但边框环整体移动了一个设备像素 —— 占整条标签栏 2.5%(深色)到 4.1%(浅色),单通道最大差值 46。改用 getBoundingClientRect() 测量可以同时修好 spec 和对齐。

另外该 spec 仍未打标记,所以 npm run test:e2e:smoke--grep @smoke)不会选中它 —— triage 评审的第 1 条在当前 head 上仍未处理,这也正是上面这个失败此前没人发现的原因。

3 · 次要 —— 拖动侧栏改变宽度时滑块会拖尾。 200 ms 过渡同样作用于 ResizeObserver 触发的重新测量,因此拖动过程中滑块滞后:正常拖动下 |Δleft| 最大 10.7 px(Channels 激活),松手后约 200 ms 归位(单次 140 px 跳变时 t = 40 ms 偏差 69.5 px,t = 200 ms 为 0.1 px)。「始终贴合」在静止态成立,拖动过程中不成立。让 ResizeObserver 触发的测量不走过渡即可。

4 · 次要 / 潜在 —— 首次在隐藏容器中挂载的列表会从原点飞入。 ready 在挂载后的第一个 requestAnimationFrame 就置位,不管列表当时是否有可测量的盒子。一个 default TabsList 若首次渲染在 display: none 里、之后才显示,滑块会飞入:x 29 → 83、宽度 2 → 53,约 200 ms。目前没有使用方会触发(Radix 会卸载未激活的 TabsContent),所以只是潜在问题 —— 把 ready 改成在第一次测到非零尺寸后才置位即可。

5 · 描述上的小出入 —— line 变体并非未受影响。 min-w-0 加在所有变体的标签上,所以 Workflows 的标签栏也变了:Saved/Running/History 106.15 / 119.91 / 113.6 px → 各 113.22 px,标签位置与下划线宽度随之改变。影响无害,但这正是本 PR 的 visuals 预览标出 workflow 页面的原因。

覆盖面备注

我还在两棵树上跑了仓库自带的 visuals 截图套件:71 张里 51 张完全一致,20 张有差异 —— 其中 12 张只是滑块边缘的亚像素差(单通道最大差值 ≤ 9),8 张是 workflow 页面(文字位移,见发现 5)。发现 1 涉及的界面没有任何套件会截到,而能覆盖滑块的浏览器 spec 又不在门禁里 —— 所以本仓库没有任何一条通道能拦下这次裁切回归。

结论

核心改动是好的,而且从 diff 中无法验证的两条主张(确实在滑动、确实遵循 reduced motion)现在已经在真实栈上得到确认,另外还补上了此前从未被执行过的纵向排列。合并前希望处理:

  1. 从基础组件里去掉或收窄 min-w-0 + overflow-hidden(发现 1);
  2. getBoundingClientRect() 测量,让滑块与 spec 一致(发现 2);
  3. 给 spec 打 @smoke,让 CI 真的跑它。

推完之后叫我一声,我会用同一套装置对新的 head 复测。

Keep the overflow policy of every other tab list intact: min-w-0 and
the default-variant overflow guard move off the primitive; the sidebar
keeps equal halves by opting its own triggers into min-w-0, which its
truncating labels already support. Measure the pill with fractional
rects so it lands exactly on half-pixel flex boundaries, skip the
transition for resize-driven measurements so the pill tracks a sidebar
drag 1:1, and arm transitions only once a measurable box exists so a
list revealed from display:none never flies in from the origin. The
pill now also dims with a disabled active trigger, TabsList stops
advertising an asChild it can no longer honour, and the variant
fallback resolves once. Tests cover the hide path for real and the
resize-vs-switch animation split; the e2e spec joins the smoke gate.
@callmeYe

Copy link
Copy Markdown
Collaborator Author

@wenshao Thanks for the thorough real-stack run — the label-clipping table and the 0.5px boundary analysis were exactly right. New head c555bc4 addresses the three merge asks:

  1. Finding 1 (blocking, label clipping): min-w-0 and the default-variant overflow guard are off the primitive — the base trigger is back to flex-1 + flex's default min-width: auto, so content-width lists (Plugins header, agent detail, the overflow-x-auto strips) render and scroll as before. The sidebar keeps its symmetric halves by opting its own two triggers into min-w-0; they already carry truncating spans, so its worst case is Chan…, never a mid-glyph cut. Verified here: Plugins strip is 85.8/45.2/48.7/60.3px (content-sized, no clipping), sidebar stays 181/181.
  2. Finding 2 (spec failure / sub-pixel): the pill now measures with getBoundingClientRect() (plus scroll offsets for the overflow-x-auto strips), so pill and trigger agree on the same fractional box; the spec's overlay assertion no longer sits on the rounding boundary. Both spec cases are tagged @smoke and run in the gate.
  3. Finding 3 (resize lag): resize-driven measurements write with no transition — the pill tracks a sidebar drag 1:1 instead of easing toward stale targets; switch-driven (mutation) measurements keep the 200ms ease.

Also folded in from the inline review, same push: finding 4 — transitions arm on the first measurable box, so a list revealed from display:none snaps in instead of flying from the origin; R1-4 — the pill dims with a disabled active trigger (inline write, since inline style beats any class); R1-5TabsList drops the asChild prop it can no longer honor; R1-9 — the variant fallback resolves once via variant ?? 'default'; R1-7/R1-8 — the hide test now drives an actual visible→hidden transition, and a new unit test pins the resize-vs-switch transition split (both pass: 36/36).

Deliberately not in this PR: R1-6 (forwardRef on TabsTrigger for PluginManagerPage's focus-restore ref) — that ref drop predates this PR on React 18 and fixing it would widen the diff; noted in the PR description as a follow-up.

One correction to my earlier caveat, per your note: the jsdom localStorage failures I saw were local to my machine, not the repo's baseline — I've removed that claim from the description.

Local gates on c555bc4: typecheck ✓, eslint ✓, tabs.test.tsx + react18-ref-compat.test.tsx 36/36 ✓, web-shell.tabs-indicator.spec.ts + web-shell.channels.spec.ts 4/4 ✓. Ping — ready for your re-run.

wenshao added a commit to wenshao/qwen-code that referenced this pull request Sep 15, 2026
@wenshao

wenshao commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Re-run on c555bc4 (maintainer, same rig as last round)

Same setup as the previous report: head c555bc4 and merge base c666ec1 in separate worktrees, one real qwen serve daemon (isolated QWEN_HOME/QWEN_RUNTIME_DIR, fixture workspace with three project agents, one extension, one MCP entry), one vite dev per tree on the same daemon, Playwright 1.61.1 / Chromium 149 at 1440×900 @2x, en + zh-CN, dark + light, macOS 26.6.2 / Node 24.18.1. Two extra arms this time for attribution: head with only the measurement reverted to offsetLeft/offsetWidth, and head plus a one-hunk candidate fix (finding 1).

Short version: all three merge asks are closed and hold up on the real stack, and the resize lag, the display:none fly-in and the disabled dimming are fixed too. One new regression came in with the finding-2 fix: the two dialog-hosted tab lists (Extensions › Add, Skills › Upload) open with a pill that is 5 % too small and stays that way until the next tab switch. One-hunk fix validated below; with that in, I'd approve.

Last round's asks

# Ask On c555bc4
1 drop min-w-0 / overflow-hidden from the primitive Fixed. Every surface is back to base widths, same daemon, same data: Plugins header 85.8 / 45.2 / 48.7 / 60.3 px, zh-CN 42 / 45.2 / 42 / 56, agent detail 129.3 / 115.3 / 49.0 / 45.2 / 55.8, extension detail 75.7 / 101.5 / 61.3 / 70.6 / 110.3 / 109.7, Workflows (line) 106.2 / 119.9 / 113.6 — all identical to base, 0 clipped labels (scrollWidth − clientWidth) anywhere. The overflow-x-auto strips scroll again (probe: scrollLeft 223), the pill scrolls with the content and lands on a tab activated while scrolled (Δ = 0 on all four axes).
2 measure with getBoundingClientRect() Fixed. Pill vs active trigger Δleft/Δtop/Δwidth/Δheight = 0.000 at rest in every list I measured (sidebar dark + light, both tabs, Plugins, agent, extension, MCP). Pixel diff of the sidebar strip head vs base at rest: 1 of 28 032 pixels differs (max channel Δ 13) — last round the border ring moved on 2.5–4.1 % of the strip. The spec passes 6/6 here (3 repeats × 2 cases).
3 tag the spec @smoke Done. The smoke job on this head ran both cases ([56/80], [57/80], 80 passed) — run.

pill slides

Paused-transition frames on this head: pill left 23 → 61.25 → 84.5 → 112.38 → 125.69 → 129.5 px at 0 / 25 / 50 / 100 / 150 / 200 ms, both triggers rgba(0,0,0,0) at every sample; base still cross-fades in place (alpha 0.045 → 0 and 0 → 0.045). Live rAF trace: 5 intermediate positions, settles at Δ = 0. Reduced motion: transition-property: none, positions [23, 129.5]. Keyboard ArrowLeft/Right: Δ = 0.

Also fixed since 5c6f1d5

  • Resize drag (finding 3): during a 4 px/frame drag the pill is at most 2 px behind, for one frame, with no easing — last head trailed by 10.7 px with a 200 ms ease. A 140 px width jump is corrected on the next frame (2 sampled frames off, 0 at 40 / 100 / 200 ms). The transition class is absent during resize-driven writes and present again on the next switch.
  • Reveal from display:none (finding 4): after the reveal the pill takes exactly two positions, the hidden one and its final one (32 → 86.8 px), one sample apart, no transition class — it snaps.
  • Disabled active trigger (R1-4): pill opacity 0.5 while disabled, 1 again on re-enable, Δ = 0 throughout.
  • Vertical orientation still tracks y with Δ = 0; line variant renders no pill.

surfaces restored

Findings

1 · New in c555bc4 — dialog-hosted lists open with a 5 % short pill (fix-induced)

DialogContent and AlertDialogContent enter with zoom-in-95 (a 100 ms keyframe from scale(.95)). The first measure() runs in useLayoutEffect while that keyframe is on its first frame (computed transform matrix3d(0.953…) right after open), getBoundingClientRect() returns the transformed boxes, and the pill is written 5 % too small. Nothing re-measures afterwards: the ResizeObserver never fires (the layout box did not change) and there is no data-state mutation. Measured 700 ms after open, dialog already at transform: none:

Surface active tab pill Δw / Δh after one tab switch
Extensions › Add (AlertDialog, grid-cols-2) 269 × 25 px 255.55 × 23.75 −13.45 / −1.25 0 / 0
Skills › Upload (Dialog, grid-cols-3) 158 × 25 px 150.09 × 23.75 −7.91 / −1.25 0 / 0

Reproduces on every open, also under prefers-reduced-motion (the enter keyframe still runs). Δleft is only −0.15 px because the default tab sits 3 px into the list; the same 5 % on a third tab is a 12 px offset (probe: a list mounted under scale(.95), third tab active, ends at Δleft −11.95 / Δw −5.9 after the transform is removed).

dialog zoom

Attribution: the same head with only the measurement reverted to offsetLeft/offsetWidth gives Δw 0 in both dialogs (with the old Δtop 0.5 back), so this comes from the finding-2 fix, not from the pill itself.

Suggested shape — validated as a third arm in the rig — divide the bounding rects by the list's own bounding/layout ratio so the pill is written in the list's untransformed coordinate space:

const listStyle = getComputedStyle(list);
const layoutWidth = parseFloat(listStyle.width);
const layoutHeight = parseFloat(listStyle.height);
const scaleX = layoutWidth > 0 ? listRect.width / layoutWidth : 1;
const scaleY = layoutHeight > 0 ? listRect.height / layoutHeight : 1;
// …
left: (rect.left - listRect.left) / scaleX + list.scrollLeft,
top: (rect.top - listRect.top) / scaleY + list.scrollTop,
width: rect.width / scaleX,
height: rect.height / scaleY,

With that hunk: Extensions dialog Δw −0.016 / Δh 0, Skills dialog 0 / 0, the scaled probe −0.016 / 0 / 0 / 0, and every other number in this report is unchanged (paused frames identical, reduced motion, keyboard, resize, scroll strip, spec 6/6). An alternative is to re-measure when an ancestor animation finishes (document.getAnimations() filtered to effects whose target contains the list, then .finished), but the ratio is one hunk and needs no event plumbing. A jsdom case that stubs the list rect at 0.95× its computed width would pin it.

2 · Note, not a blocker — the sidebar at its 220 px minimum

Base fits both labels there (75.6 + 97.4 = 173 < 179 px); with its two triggers in min-w-0 this head splits 86.5 / 86.5 and shows Chan…. That is the documented worst case — noting it only because it is the one surface where a label still differs from base. If you would rather keep both labels whole at the minimum, basis-auto on those two triggers does that at the price of exactly equal halves (each gets its content width plus half the slack) — your call.

3 · Coverage note

Mutation matrix against tabs.test.tsx (5 cases): pin the pill at a wrong box, swap the resize/switch flags, never arm → each killed (1 failed). Drop + list.scrollLeft, ignore listRect.left, drop the disabled dimming, arm on a zero-width box → all survive (5/5). Those four are pinned only by the browser (my probe page), so the R1-4 and finding-4 fixes have no test guard — the same stubbed-rect technique the new case uses covers the disabled opacity and the zero-width arming cheaply. Not blocking.

probe

Gates on c555bc4 (this machine)

typecheck ✓ · eslint ✓ · prettier --check ✓ · packages/web-shell vitest 313 files / 8012 tests ✓ (last head 8011, base 8006 — the delta is the new resize-vs-switch case) · tabs.test.tsx 5 ✓ + react18-ref-compat.test.tsx 31 ✓ · web-shell.tabs-indicator.spec.ts 6/6 across 3 repeats ✓.

Verdict

Three asks closed and confirmed on the real stack, plus findings 3 / 4 and R1-4. One new one-hunk regression in the two dialog-hosted lists (finding 1) — fold in the scale ratio (or equivalent) and I'll re-run the dialog check; everything else is ready.

中文说明

c555bc4 上复测(维护者,同一套装置)

装置与上一轮相同:head c555bc4 与 merge base c666ec1 各自独立 worktree,一个真实 qwen serve daemon(隔离的 QWEN_HOME/QWEN_RUNTIME_DIR,fixture 工作区带 3 个项目级 agent、1 个扩展、1 条 MCP 配置),每棵树一个 vite dev 指向同一个 daemon;Playwright 1.61.1 / Chromium 149,1440×900 @2xen + zh-CN,深色 + 浅色;macOS 26.6.2 / Node 24.18.1。本轮为归因额外加了两臂:head 上把测量改回 offsetLeft/offsetWidth,以及 head + 一个单 hunk 的候选修复(发现 1)。

结论先说:上一轮的三条合并要求全部已处理,且在真实栈上站得住;resize 拖尾、display:none 飞入、禁用变淡也都修好了。但发现 2 的修复带来了一个新回归:两个挂在对话框里的标签列表(Extensions › Add、Skills › Upload)打开时滑块小了 5%,并一直保持到下一次切换标签。下面给出了已在装置里验证过的单 hunk 修法;补上之后我会批准。

上一轮的要求

# 要求 c555bc4
1 从基础组件去掉 min-w-0 / overflow-hidden 已修。 同一 daemon、同一数据下所有界面回到 base 宽度:Plugins 头部 85.8 / 45.2 / 48.7 / 60.3 px,zh-CN 42 / 45.2 / 42 / 56,智能体详情 129.3 / 115.3 / 49.0 / 45.2 / 55.8,扩展详情 75.7 / 101.5 / 61.3 / 70.6 / 110.3 / 109.7,Workflows(line)106.2 / 119.9 / 113.6 —— 与 base 完全一致,任何地方都没有被裁的标签(scrollWidth − clientWidth 全为 0)。overflow-x-auto 列表重新可以滚动(probe:scrollLeft 223),滑块随内容滚动,滚动状态下激活标签时也精确落位(四个轴 Δ = 0)。
2 getBoundingClientRect() 测量 已修。 我测的每个列表(侧栏深色 + 浅色、两个标签、Plugins、智能体、扩展、MCP)静止态滑块与激活标签的 Δleft/Δtop/Δwidth/Δheight 都是 0.000。侧栏条带静止态 head vs base 逐像素对比:28 032 个像素里只有 1 个不同(单通道最大差 13);上一轮边框环移动影响了条带的 2.5–4.1%。spec 本机 6/6(3 次重复 × 2 用例)。
3 给 spec 打 @smoke 已做。 这个 head 的 smoke job 跑了两个用例([56/80][57/80],80 通过)—— 运行

滑块滑动

本 head 上暂停过渡抓的帧:滑块 left 在 0 / 25 / 50 / 100 / 150 / 200 ms 分别为 23 → 61.25 → 84.5 → 112.38 → 125.69 → 129.5 px,每个采样点两个标签的背景都是 rgba(0,0,0,0);base 仍是原地交叉渐变(alpha 0.045 → 0、0 → 0.045)。实时 rAF 采样 5 个中间位置,终点 Δ = 0。Reduced motion:transition-property: none,位置只有 [23, 129.5]。键盘 ArrowLeft/Right:Δ = 0。

5c6f1d5 以来一并修好的

  • resize 拖动(发现 3): 以每帧 4 px 拖动时滑块最多落后 2 px、只持续一帧、没有缓动 —— 上一个 head 是 10.7 px 拖尾 + 200 ms 缓动。140 px 的宽度跳变下一帧就纠正(2 个采样帧偏离,40 / 100 / 200 ms 时为 0)。resize 驱动的写入没有过渡类名,下一次切换时又恢复。
  • display:none 显示(发现 4): 显示之后滑块只出现两个位置 —— 隐藏态和最终位置(32 → 86.8 px),相隔一个采样、没有过渡类名,直接到位。
  • 激活标签被禁用(R1-4): 禁用期间滑块 opacity 0.5,重新启用后回到 1,全程 Δ = 0。
  • 纵向排列仍然 Δ = 0 跟随 yline 变体不渲染滑块。

界面恢复

发现

1 · c555bc4 新引入 —— 对话框里的标签列表打开时滑块小 5%(修复引入)

DialogContentAlertDialogContent 进场动画是 zoom-in-95(100 ms 关键帧,从 scale(.95) 开始)。第一次 measure()useLayoutEffect 里执行时,这个关键帧正处于第一帧(刚打开时计算得到的 transform 是 matrix3d(0.953…)),getBoundingClientRect() 返回的是变换后的盒子,于是滑块被写成 5% 偏小。之后没有任何东西重新测量:ResizeObserver 不会触发(布局盒子没变),也没有 data-state 变化。打开 700 ms 后、对话框已经 transform: none 时实测:

界面 激活标签 滑块 Δw / Δh 切换一次标签后
Extensions › Add(AlertDialoggrid-cols-2 269 × 25 px 255.55 × 23.75 −13.45 / −1.25 0 / 0
Skills › Upload(Dialoggrid-cols-3 158 × 25 px 150.09 × 23.75 −7.91 / −1.25 0 / 0

每次打开都复现,prefers-reduced-motion 下也一样(进场关键帧照常运行)。Δleft 只有 −0.15 px 是因为默认标签离列表左边只有 3 px;同样的 5% 落到第三个标签上就是 12 px 的偏移(probe:列表挂载在 scale(.95) 下、第三个标签激活,去掉变换后 Δleft −11.95 / Δw −5.9)。

对话框缩放

归因:同一个 head 上把测量改回 offsetLeft/offsetWidth,两个对话框的 Δw 都是 0(同时老的 Δtop 0.5 回来了),所以问题来自发现 2 的修复,而不是滑块本身。

建议的形状 —— 已作为第三臂在装置里验证 —— 用列表自身的「包围盒 / 布局尺寸」比值把变换除掉,让滑块写在列表未变换的坐标系里:

const listStyle = getComputedStyle(list);
const layoutWidth = parseFloat(listStyle.width);
const layoutHeight = parseFloat(listStyle.height);
const scaleX = layoutWidth > 0 ? listRect.width / layoutWidth : 1;
const scaleY = layoutHeight > 0 ? listRect.height / layoutHeight : 1;
// …
left: (rect.left - listRect.left) / scaleX + list.scrollLeft,
top: (rect.top - listRect.top) / scaleY + list.scrollTop,
width: rect.width / scaleX,
height: rect.height / scaleY,

加上这个 hunk 之后:Extensions 对话框 Δw −0.016 / Δh 0,Skills 对话框 0 / 0,缩放 probe −0.016 / 0 / 0 / 0,本报告里其它所有数字不变(暂停帧完全一致、reduced motion、键盘、resize、滚动列表、spec 6/6)。另一种做法是等祖先动画结束后重测(document.getAnimations() 过滤出目标包含该列表的动画,再等 .finished),但比值法只有一个 hunk、不用接事件。用一个把列表 rect 桩成计算宽度 0.95 倍的 jsdom 用例就能固化。

2 · 备注,非阻塞 —— 侧栏 220 px 最小宽度

base 在那里放得下两个完整标签(75.6 + 97.4 = 173 < 179 px);这个 head 给侧栏两个标签加了 min-w-0,于是等分成 86.5 / 86.5 并显示 Chan…。这是描述里写明的最坏情况 —— 只是记一笔,因为这是唯一一个标签仍与 base 不同的界面。如果更想在最小宽度下两个标签都完整,给这两个标签加 basis-auto 即可,代价是不再严格等分(各自拿到内容宽度加一半富余)—— 由你定。

3 · 覆盖面备注

tabs.test.tsx(5 个用例)做的变异矩阵:把滑块钉在错误位置、交换 resize/切换的标志、永不 arm → 各被杀死(1 失败)。去掉 + list.scrollLeft、忽略 listRect.left、去掉禁用变淡、在零宽度时 arm → 全部存活(5/5 通过)。这四条目前只靠浏览器(我的 probe 页)钉住,所以 R1-4 与发现 4 的修复没有测试守护 —— 新用例用的桩 rect 技术同样能便宜地覆盖禁用 opacity 与零宽度 arm。不阻塞。

probe

c555bc4 上的门禁(本机)

typecheck ✓ · eslint ✓ · prettier --check ✓ · packages/web-shell vitest 313 个文件 / 8012 条 ✓(上个 head 8011、base 8006 —— 差值就是新增的 resize-vs-切换用例)· tabs.test.tsx 5 ✓ + react18-ref-compat.test.tsx 31 ✓ · web-shell.tabs-indicator.spec.ts 3 次重复 6/6 ✓。

结论

三条要求已关闭并在真实栈上确认,发现 3 / 4 与 R1-4 也一并修好。两个对话框里的标签列表有一个新的单 hunk 回归(发现 1)—— 补上比值(或等价做法)后我会复测对话框这一项;其它都已就绪。

@callmeYe
callmeYe dismissed qwen-code-ci-bot’s stale review September 15, 2026 04:59

Superseded: every finding in this round is addressed at head c555bc4 (see the per-thread replies and the summary comment) or explicitly deferred as pre-existing (R1-6). A fresh review is queued on the current head and will govern from here.

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

What this PR does / PR 主旨

Adds a sliding pill indicator to the Web Shell's default-variant tab lists: instead of recoloring each tab in place, a single absolutely-positioned pill measures the active trigger and glides to it over 200ms. It snaps (no transition) on first render and on container resizes so it tracks a sidebar drag 1:1, dims when the active trigger is disabled, supports vertical lists, honors prefers-reduced-motion, and leaves the line (underline) variant untouched. TabsList becomes a ref-forwarding component; the sidebar's two triggers opt into shrink-to-equal halves with truncating labels locally.

为 Web Shell 的 default 变体标签列表加入滑动药丸指示器:不再就地给每个标签重新着色,而是一个绝对定位的药丸测量当前激活触发器并以 200ms 滑过去。首帧与容器缩放时直接吸附(无过渡),从而 1:1 跟随侧栏拖拽;激活触发器被禁用时变暗;支持纵向列表;遵循 prefers-reduced-motionline(下划线)变体不受影响。TabsList 改为转发 ref 的组件;侧栏的两个触发器在本地选择「等分收缩 + 标签截断」。

Static / diff-only review; verified directly against head tree c555bc45.

仅静态 / diff 审查;直接在 head tree c555bc45 上核实。


🎉 Clean implementation, and the prior bot round is fully addressed / 实现干净,且上一轮 bot 意见已全部处理

I independently read the head-tree useSlidingIndicator and confirmed the design is sound:

我独立阅读了 head tree 的 useSlidingIndicator,确认设计正确:

  • Measurement uses getBoundingClientRect() (fractional) plus list.scrollLeft/scrollTop, so the pill shares the trigger's exact box (tabs.tsx:88-101).
    测量使用 getBoundingClientRect()(含小数)加上 list.scrollLeft/scrollTop,因此药丸与触发器共享精确的盒子(tabs.tsx:88-101)。
  • Animate vs snap is correctly split: MutationObserver (data-state/disabled/childList) drives animated re-measures, ResizeObserver drives non-animated ones, so a tab switch slides while a sidebar drag tracks 1:1 (:108-122). A tab switch doesn't resize the list (all triggers stay mounted), so the two observers don't fight.
    动画与吸附正确分离:MutationObserver(data-state/disabled/childList)驱动带动画的重测,ResizeObserver 驱动不带动画的重测,因此切换标签会滑动、而拖拽侧栏 1:1 跟随(:108-122)。切换标签不会改变列表尺寸(所有触发器始终挂载),所以两个 observer 不会互相打架。
  • Arm-on-first-measurable-box (arm() only when rect.width > 0, :104-106) prevents the pill flying in from the origin on mount or on a reveal from display:none.
    首个可测量盒子才武装arm() 仅在 rect.width > 0 时,:104-106)避免挂载时或从 display:none 显现时药丸从原点飞入。
  • Cleanup cancels the rAF and disconnects both observers; the effect deps [listRef, enabled] are stable, so no leak (:124-131).
    清理取消 rAF 并断开两个 observer;effect 依赖 [listRef, enabled] 稳定,无泄漏(:124-131)。
  • The active background moved out of TabsTrigger into the pill, and the disabled dimming is written inline (opacity: 0.5 when the measured trigger :disabled) so it isn't beaten by the inline geometry; asChild is omitted from TabsListProps (the two-child render can't be slotted); variant ?? 'default' is resolved once and read consistently.
    激活背景从 TabsTrigger 移到药丸,禁用变暗以内联写入(测得的触发器 :disabledopacity: 0.5),以免被内联几何样式覆盖;TabsListProps 省略了 asChild(双子节点渲染无法 slot);variant ?? 'default' 只解析一次并被一致读取。

The earlier bot round's one Critical (R1-1: min-w-0/overflow-hidden leaking onto the shared trigger base) and its Suggestions are all genuinely fixed at this head — the truncation policy now lives only on the sidebar caller (WebShellSidebar.tsx, local min-w-0 + truncate span), and the tests pin the real behaviors (indicator opacity 1→0 on active-disappear, no pill in line variant, ref forwarding, resize-snaps vs mutation-animates). R1-6 (the React-18 ref drop on the sibling TabsTrigger) is pre-existing and reasonably recorded as a follow-up rather than widened into this diff.

上一轮 bot 的唯一 Critical(R1-1:min-w-0/overflow-hidden 泄漏到共享触发器基类)及其各 Suggestion 在此 head 上均已真正修复——截断策略现在只存在于侧栏调用方(WebShellSidebar.tsx,本地 min-w-0 + truncate span),测试也钉住了真实行为(激活消失时药丸 opacity 1→0、line 变体无药丸、ref 转发、resize 吸附 vs mutation 动画)。R1-6(兄弟 TabsTrigger 上的 React-18 ref 丢失)是既有问题,合理地记为后续项而非塞进本 diff。

Verdict / 结论

APPROVE. No verified Critical or Important: the indicator hook is correct, observers are cleaned up, the animate/snap and disabled-dim semantics are right and tested, the prior Critical is fixed, and the change is well-scoped (the line variant and other tab lists are untouched). CI is green except the pending automated-review lane.

APPROVE(批准)。 无经核实的 Critical 或 Important:指示器 hook 正确、observer 有清理、动画/吸附与禁用变暗语义正确且有测试、此前的 Critical 已修复,改动范围收得好(line 变体与其它标签列表未受影响)。CI 除 pending 的自动审查 lane 外全绿。

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

LGTM — no blocking issues. Pill measurement runs in useLayoutEffect + observers (never during render), MutationObserver/ResizeObserver/rAF all cleaned up on unmount, active-tab-removal fades to opacity 0, and rapid switching collapses into one CSS transition via React 18 batching. Consistent with ytahdn's earlier approval; CI green on c555bc4.

@callmeYe
callmeYe added this pull request to the merge queue Sep 15, 2026
Merged via the queue into main with commit 4b8e02b Sep 15, 2026
323 of 332 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.

5 participants