fix(web-shell): gate todo spinner on live work - #11269
Conversation
|
Thanks for the PR! Template looks good ✓ — every required heading is present, and the Tested-on table is honestly marked "not run" instead of being left blank or optimistically ticked. Problem: real, and reachable from the code rather than theoretical. Direction: aligned. The reference product has fixed this exact class repeatedly — claude-code 2.1.261: "Fixed Remote Control sessions showing as still working (stuck spinner and Stop button) after stopping a turn from a connected phone or browser", and the same changelog carries a stuck-spinner fix for remote clients and an idle session wrongly reported as "a turn is in progress" while a background task ran. A browser-side panel animating work nobody is doing is the same bug in a different hat. Size: Stage 0 not triggered — no core paths ( Approach: close to what I'd have written independently — one optional prop, gated at the single call site, reusing the live-state contract #11267 already establishes instead of adding a timer or a task tracker. I checked the scope is complete rather than partial: One question, not a blocker: the hooks module now exports two functions where it had one — Risk: no Stage 1e high-risk path match. Two structural risks worth stating up front, neither about the code itself:
Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ —— 所有必需标题都在,Tested-on 表格也如实标注"未运行",没有留空或乐观打勾。 问题: 真实存在,而且是代码上可推导出来的,不是理论性加固。 方向: 对齐。参考产品反复修过同一类问题 —— claude-code 2.1.261:"Fixed Remote Control sessions showing as still working (stuck spinner and Stop button) after stopping a turn from a connected phone or browser";同一份 changelog 里还有 remote client 卡住 spinner 的修复,以及"空闲 Session 因后台任务被误报 a turn is in progress"的修复。浏览器侧面板给没人执行的工作播放动画,是同一个 bug 换了件外套。 规模: 未触发 Stage 0 —— 没有核心路径( 方案: 和我独立想到的做法基本一致 —— 一个可选 prop,在唯一调用点收口,复用 #11267 已经建立的 live-state 契约,而不是新增 timer 或 task tracker。我确认了范围是完整的而非局部: 一个疑问(非阻塞):hooks 模块现在从一个导出变成两个 —— 风险: Stage 1e 高风险路径未命中。有两个结构性风险需要先讲清楚,都与代码本身无关:
进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
Code reviewNo Critical findings and no AGENTS.md violations. The change is small enough that I traced every read site rather than sampling, which matters here because nothing has compiled this commit (see Testing below). What I verified: The field is live, not a dead switch. Every consumer of the changed hook is named. At the base ref only Scope is valid. The hook refactor preserves semantics. I walked all four combinations of Existing tests survive the rename. The fallback glyph is meaningful. Two things I'd note without blocking:
TestingBeing plain about what this section carries: static review only. No build, no typecheck, no unit test ran on this commit — not by me, not by CI, not by the author. That is structural, not a timing artifact.
There is no failing check to excerpt, and no green suite to lean on either — the absence of a red check here is not evidence of anything. I did not execute any PR-derived code; per the triage rules the review is static, and I read the base-ref files through the API rather than checking the branch out. Not verified: that the stack compiles as a whole, that the web-shell suites still pass after the mock rename, and that the spinner actually stops when live work settles. The author's own report ("no local test, build, typecheck, or CI command was run") is their claim, and I'm not presenting it as evidence. Sandboxed verification would settle the behavioural half of this: 中文说明代码审查没有 Critical,也没有违反 AGENTS.md。改动足够小,所以我把每个读取点都追了一遍而不是抽样 —— 在这个 PR 上这点尤其重要,因为没有任何机器编译过这个 commit(见下方"测试")。已核实的内容: 这个字段是活的,不是死开关。 被改动 hook 的每个消费方都已点名。 在 base ref 上只有 作用域有效。 hook 重构保持了语义。 我把 现有测试能扛过这次改名。 回退图标是有意义的。 两点提醒,都不阻塞:
测试这一节到底带了什么证据,说清楚:只有静态审查。这个 commit 上没有跑过任何 build、typecheck 或 unit test —— 我没跑,CI 没跑,作者也没跑。 这是结构性的,不是时序问题。 没有失败的检查可以摘录,但也没有绿色的套件可以依靠 —— 这里"没有红叉"并不能证明任何事。我没有执行任何来自 PR 的代码;按 triage 规则审查是静态的,base ref 的文件我通过 API 读取,而不是把分支 checkout 出来。 未验证:整个堆叠能否编译通过、mock 改名后 web-shell 套件是否仍然全绿、以及 live work 结束后 spinner 是否真的停下。作者自己的说明("未运行本地测试、build、typecheck 或 CI 命令")属于作者的声明,我不把它当作证据呈现。 沙箱验证可以解决其中行为层面的部分: — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Confidence: 4/5 — the diff is right and I could trace every read site; the missing fifth is that no machine has compiled or tested it, so this approval attests to a static read and nothing more. Stepping back: my independent proposal before opening the diff was "gate the animation on a liveness fact Am I approving because I ran out of reasons to say no? I don't think so. I went in expecting to find the classic version of this bug — a gate wired to a signal that never arrives — and checked for it specifically: What gives me pause is the evidence, not the code. Nothing has compiled this commit: the base isn't
One honest observation on cadence, not a criticism of the diff: this is the third PR in one chain, and one of forty you have open, several created within the last hour. I reviewed it on its own contents and not on volume — but a chain this deep is worth a moment's thought about whether #11265 and #11267 could land first and let this one be reviewed against Mechanically, for the record: the fork- 中文说明Confidence: 4/5 —— diff 是对的,每个读取点我都追到了;差的那一分在于没有任何机器编译或测试过它,所以这次 approve 只为一次静态阅读背书,仅此而已。 退一步看:我在打开 diff 之前的独立设想是"用 我是不是因为找不到理由拒绝才 approve?我认为不是。我进来时就预期会看到这类 bug 的经典版本 —— 闸门接到一个永远不会到达的信号 —— 并专门查了这一点: 让我犹豫的是证据,不是代码。没有任何东西编译过这个 commit:base 不是
关于节奏的一点坦白观察,不是对 diff 的批评:这是同一条链上的第三个 PR,也是你 40 个 open PR 之一,其中好几个是最近一小时内创建的。我是按它自身的内容审查的,不是按数量 —— 但这么深的链值得想一下:能否先让 #11265 和 #11267 落地,使这一个可以对着 流程上留个记录:fork- — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
…to codex/issue-11119-todo-liveness
6ff45b4
into
codex/issue-11119-live-state
Local verification — real browser + real daemonI built a real verification environment for this PR locally and ran it as a two-arm A/B. Everything below is measured, not read. Arms — Worth knowing before reading the rest: 1. Behaviour matrix — real Chromium, real Web Shell clientI drove the real client (real Every scenario starts from the same persisted state: a settled transcript whose last
8/8 as intended on Two details that mattered while building this:
2. Real
|
| arm | icon timeline |
|---|---|
| base | 0ms = spinner — one state, never changes; still spinning 8s after the daemon reported the turn settled |
| head | 0ms = spinner → 29948ms = static — exactly one transition, landing with the settle |
No flicker, no intermediate drop-out. That also answers the risk I was most worried about going in: the icon does not blink off and back on while the client's streamingState and the daemon's answer disagree during a turn.
One thing worth knowing for sizing the impact: todo_write is opt-in (tools.todoWrite.enabled, default false), so both the bug and this fix only reach users who turned it on. My first real-daemon run silently produced no plan at all until I enabled it.
3. Mutation controls
To show the matrix is not a tautology, I mutated the fix on head and re-ran:
| Mutation | Expected to break | Actually broke |
|---|---|---|
Drop && hasLiveActivity from TodoPanel |
D, E, F, G | exactly D, E, F, G |
Make the bridge return activeWorkState: undefined |
C, H | exactly C, H |
Both new lines in session-catalog-hooks.ts are load-bearing — the live path (C) and the catalog-fallback path (H) each have exactly one scenario that dies without them.
4. Gates run locally
| Gate | Result |
|---|---|
vitest run (web-shell, head) |
853 passed / 853 |
vitest run (web-shell, base) |
853 passed / 853 |
tsc -p tsconfig.json --noEmit |
clean |
eslint packages/web-shell --max-warnings 0 |
clean |
prettier --experimental-cli --check packages/web-shell |
1 file — pre-existing on the base, see N3 |
Findings — none blocking
N1 · The fix has no test coverage. Both arms run 853 tests: this PR adds none. App.test.tsx hard-codes activeWorkState: undefined in the mock, so sessionActiveWorkState === 'active' is never exercised, and TodoPanel.test.tsx never passes hasLiveActivity. A repo-wide grep finds activeWorkState in exactly one test file — that one mock line — so the field has no behavioural coverage anywhere in the stack (#11265 → #11267 → this).
Three cases in TodoPanel.test.tsx would close it for this PR. I ran them: they pass on the PR and the middle one fails when && hasLiveActivity is removed.
const todos: TodoItem[] = [
{ id: '1', status: 'in_progress', content: 'Apply the fix' },
];
const iconOf = (c: HTMLElement) =>
c.querySelector('[role="tooltip"] > div')?.querySelector('span');
it('animates an in_progress item while work is live', () => {
const icon = iconOf(render(<TodoPanel todos={todos} hasLiveActivity />));
expect(icon?.firstElementChild).not.toBeNull();
});
it('keeps the static glyph when no live work is reported', () => {
const icon = iconOf(render(<TodoPanel todos={todos} hasLiveActivity={false} />));
expect(icon?.firstElementChild).toBeNull();
expect(icon?.textContent).toBe('◐');
});
it('defaults to animating so existing callers are unaffected', () => {
const icon = iconOf(render(<TodoPanel todos={todos} />));
expect(icon?.firstElementChild).not.toBeNull();
});N2 · unknown renders identically to idle. WebShellSidebar gives activeWorkState: 'unknown' its own affordance (sessionStatusUnknown, sidebar.activityUnknown), but the Todo panel collapses unknown into the static glyph, so a plan on a daemon that cannot answer looks settled. That is consistent with the PR's own principle — better than pretending execution is known — but the design doc paragraph added here says "confirms that execution is live" without saying what unknown does. One sentence there would close the gap.
N3 · The base carries a prettier violation that will fail the stack. packages/web-shell/client/components/sidebar/WebShellSidebar.tsx fails format:check. It is clean at origin/main and fails at 3dd860aafb, so it came in with #11267, not here — but format:check will go red as soon as the stack reaches a main-targeted PR. Worth fixing on #11267 while it is still open.
N4 · The E2E mock daemon is unfaithful on this field. client/e2e/utils/mockDaemon.ts builds its live-state response by hand and omits activeWorkState, which the real route forwards. Any future E2E written against that mock silently cannot see the field. Four lines, matching the real route's conditional spread.
N5 · The gate inherits the bridge's workspace-scoping blind spot. activePromptWorkspaceCwd is undefined when connection.sessionContext?.kind === 'live' and the number of trusted live workspaces is not exactly 1 (App.tsx:3261). In that configuration the bridge returns hasActivePrompt: false, activeWorkState: undefined, so the spinner falls back to streamingState alone — and the comment right above that hook says streamingState is exactly what drops to idle through silent tool gaps. This is pre-existing scoping that already governs the conversation indicator, so the Todo spinner is now merely consistent with it. My real-daemon run measured zero spurious transitions in the ordinary workspace configuration, and I did not build the multi-live-workspace configuration, so this is a code-path observation rather than a reproduction — not a reason to hold this PR. Flagging it because this PR is what makes it visible in a second surface.
Verdict
The change does exactly what it says, on every combination of the three signals, and each line of it is covered by a mutation that kills only the scenarios it should. From my side this is merge-ready; N1 is the one I would like to see land with it, since nothing in CI will notice if this regresses.
Screenshots, the raw per-scenario captures, the JSON result matrices and the whole harness (Playwright spec, the mock-daemon patch, the suggested test, and the real-daemon rig) are on assets-pr11269.
中文版
本地验证 —— 真实浏览器 + 真实 daemon
我在本地为这个 PR 搭了一套真实验证环境,按两臂 A/B 跑了一遍。下面每一条都是实测结果,不是静态阅读。
两臂 —— base = 3dd860aafb(本 PR 的 merge base,即 #11267 head)· head = c926de9df9(本 PR)。两臂之间只差本 PR 的 5 个文件。
先说一个前提:Qwen Code CI 只在 base 分支是 main / release/** 的 pull_request 上触发(.github/workflows/ci.yml)。本 PR 的 base 是 codex/issue-11119-live-state,所以没有任何 CI job 编译、lint 或测试过这个 commit —— PR 上那些 check 全是 triage/路由类。下面这些是第一次真跑。
1. 行为矩阵 —— 真 Chromium,真 Web Shell 客户端
我在无头 Chromium 149 里驱动真实客户端(真 App、真 session-catalog-store、真 hooks、真 TodoPanel.module.css),对接一个线级 daemon。每个场景的判据直接读自浏览器:TodoPanel 是否渲染了 spinner 元素,再加 Element.getAnimations() 确认 todoPanelSpin 关键帧确实在 running —— 这样"渲染了但不动"的 spinner 不会被当成在转。
所有场景的初始状态相同:一段已经 settle 的 transcript,最后一次 todo_write 把 Apply the fix 留在 in_progress。
| # | 场景 | live-state row | base | head |
|---|---|---|---|---|
| A | 本地 turn 正在流式输出 | hasActivePrompt:false, activeWorkState:idle |
旋转 | 旋转 |
| B | 静默前台 turn | hasActivePrompt:true |
旋转 | 旋转 |
| C | 前台已结束,后台 hold | activeWorkState:"active" |
旋转 | 旋转 |
| D | 全部已结束 | activeWorkState:"idle" |
旋转 | 静态 |
| E | 旧 daemon,字段缺失 | 不带 activeWorkState |
旋转 | 静态 |
| F | bridge 无法回答 | activeWorkState:"unknown" |
旋转 | 静态 |
| G | 无 live-state 能力,走 catalog row | activeWorkState:"idle" |
旋转 | 静态 |
| H | 无 live-state 能力,走 catalog row | activeWorkState:"active" |
旋转 | 旋转 |
head 8/8 符合预期;base 8/8 全部旋转 —— 这就是那个 bug,包括 D/E/F/G 这四个根本没有 live work 的情况。
搭这套东西时有两个细节值得记一笔:
- 场景 A 不能用一个残留的 running tool block 伪造。只要 daemon 的
promptStatus是 idle,selectDaemonStreamingState就返回idle,所以这一臂是从 composer 真提交一个 prompt,plan 在 turn 中途通过 SSE 到达。 - E2E mock daemon 的
live-state路由会丢掉activeWorkState,而真实路由(packages/cli/src/serve/routes/session.ts)是转发的。我给 mock 打了补丁把它透传出来(两臂完全一致),让客户端看到真实的 payload 形状。这 4 行值得合回仓库 —— 见 N4。
2. 真实 qwen serve daemon,真实 turn
上面那个矩阵是脚本化 daemon 的回答。为了排除"系统级死开关",我又把整条栈真跑了一遍:真的 qwen serve daemon(从 merge base 构建 —— 本 PR 不碰 packages/cli,所以两臂的 daemon 逐字节相同)、真的 session,加一个脚本化的 OpenAI 兼容模型:先写一份带 in_progress 项的 Todo 计划,工作 30 秒,然后作答。两臂先后跑在同一个 daemon 上,各自用全新的 session。
daemon 自己的 live-state 响应,每秒采样一次:
turn 之前 hasActivePrompt:false activeWorkState:"unknown"
t=0..29s hasActivePrompt:true activeWorkState:"active"
t=30s.. hasActivePrompt:false activeWorkState:"idle"
也就是说这个字段确实是端到端产出的 —— entryActiveWorkState → serve → store → hook → prop,不是死开关。
我在整个 turn 期间每 300ms 采一次图标状态(每臂约 125 个采样),这样"闪烁"就藏不到两张静态截图之间:
| 臂 | 图标时间线 |
|---|---|
| base | 0ms = spinner —— 只有一个状态,从不改变;daemon 报告 turn 结束 8 秒后仍在转 |
| head | 0ms = spinner → 29948ms = static —— 恰好一次跳变,与 settle 同步落下 |
没有闪烁,也没有中途掉出。这同时回答了我事先最担心的风险:在 turn 期间客户端 streamingState 与 daemon 的回答不一致时,图标不会先灭再亮。
还有一点对判断影响面有用:todo_write 是 opt-in 的(tools.todoWrite.enabled,默认 false),所以这个 bug 和这个修复都只影响开了它的用户。我第一次真 daemon 跑的时候,没开这个开关,计划面板根本不出现,而且没有任何提示。
3. 变异对照
为了证明上面的矩阵不是自证,我在 head 上把修复变异掉再跑:
| 变异 | 预期挂掉 | 实际挂掉 |
|---|---|---|
从 TodoPanel 删掉 && hasLiveActivity |
D、E、F、G | 恰好 D、E、F、G |
让 bridge 返回 activeWorkState: undefined |
C、H | 恰好 C、H |
session-catalog-hooks.ts 里新增的两条线都是承重的 —— live 路径(C)和 catalog-fallback 路径(H)各自都有且只有一个场景在缺了它之后会死。
4. 本地跑过的门禁
| 门禁 | 结果 |
|---|---|
vitest run(web-shell,head) |
853 通过 / 853 |
vitest run(web-shell,base) |
853 通过 / 853 |
tsc -p tsconfig.json --noEmit |
干净 |
eslint packages/web-shell --max-warnings 0 |
干净 |
prettier --experimental-cli --check packages/web-shell |
1 个文件 —— base 上就有,见 N3 |
结论性意见 —— 没有阻塞项
N1 · 这个修复没有任何测试覆盖。 两臂都跑 853 个测试:本 PR 一个都没加。App.test.tsx 在 mock 里把 activeWorkState 写死成 undefined,所以 sessionActiveWorkState === 'active' 这条分支从未被执行;TodoPanel.test.tsx 也从没传过 hasLiveActivity。全仓 grep 下来,activeWorkState 只出现在一个测试文件里,就是那行 mock —— 也就是说整条栈(#11265 → #11267 → 本 PR)里这个字段没有任何行为级覆盖。
在 TodoPanel.test.tsx 里加三个 case 就能补上本 PR 这一段。我跑过:在本 PR 上三个全过,把 && hasLiveActivity 删掉之后中间那个会挂。
const todos: TodoItem[] = [
{ id: '1', status: 'in_progress', content: 'Apply the fix' },
];
const iconOf = (c: HTMLElement) =>
c.querySelector('[role="tooltip"] > div')?.querySelector('span');
it('animates an in_progress item while work is live', () => {
const icon = iconOf(render(<TodoPanel todos={todos} hasLiveActivity />));
expect(icon?.firstElementChild).not.toBeNull();
});
it('keeps the static glyph when no live work is reported', () => {
const icon = iconOf(render(<TodoPanel todos={todos} hasLiveActivity={false} />));
expect(icon?.firstElementChild).toBeNull();
expect(icon?.textContent).toBe('◐');
});
it('defaults to animating so existing callers are unaffected', () => {
const icon = iconOf(render(<TodoPanel todos={todos} />));
expect(icon?.firstElementChild).not.toBeNull();
});N2 · unknown 与 idle 渲染完全一样。 WebShellSidebar 给 activeWorkState: 'unknown' 单独留了表达(sessionStatusUnknown、sidebar.activityUnknown),但 Todo 面板把 unknown 折叠成了静态图标 —— 于是在一个答不上来的 daemon 上,计划看起来像已经结束了。这跟本 PR 自己的原则是一致的(总好过假装"已知在执行"),但这里新增的设计文档段落只写了 "confirms that execution is live",没说 unknown 会怎样。补一句就够了。
N3 · base 上带着一个会让整条栈变红的 prettier 违规。 packages/web-shell/client/components/sidebar/WebShellSidebar.tsx 过不了 format:check。它在 origin/main 上是干净的,在 3dd860aafb 上挂 —— 所以是 #11267 带进来的,不是这里。但只要这条栈走到一个以 main 为 base 的 PR,format:check 就会红。趁 #11267 还开着,建议在那边修掉。
N4 · E2E mock daemon 在这个字段上不忠实。 client/e2e/utils/mockDaemon.ts 手工拼 live-state 响应时漏了 activeWorkState,而真实路由是转发的。今后任何基于这个 mock 写的 E2E 都会静默地看不见这个字段。补 4 行,跟真实路由的条件展开保持一致即可。
N5 · 这个门禁继承了 bridge 的 workspace 作用域盲区。 当 connection.sessionContext?.kind === 'live' 且 trusted live workspace 的数量不是恰好 1 个时,activePromptWorkspaceCwd 是 undefined(App.tsx:3261)。这种配置下 bridge 返回 hasActivePrompt: false, activeWorkState: undefined,spinner 就只能靠 streamingState —— 而那个 hook 上面的注释恰好写着:streamingState 正是会在静默工具间隙掉到 idle 的那个量。这是既有的作用域问题,本来就管着 conversation indicator,所以 Todo spinner 现在只是跟它保持了一致。真 daemon 那一轮在普通 workspace 配置下测到的多余跳变是 0 次,而多 live workspace 的配置我没有搭,所以这是一条代码路径上的观察、不是复现 —— 不构成拦本 PR 的理由。之所以提,是因为本 PR 让这个盲区多了一个可见的表面。
结论
这个改动在三路信号的每一种组合上都做到了它声称的事情,而且它的每一行都有一个变异能且只能打死它该负责的场景。就我这边看,可以合。N1 我希望能跟着一起进来 —— 因为一旦回归,CI 里没有任何东西会发现。
截图、每个场景的原始截屏、JSON 结果矩阵,以及整套 harness(Playwright spec、mock-daemon 补丁、建议的测试、真 daemon 装置)都在 assets-pr11269。
Verified locally with Claude Code (Claude Opus 5, 1M context) — Node v22.22.2, vitest 3.2.4, Playwright 1.61.1 / Chrome for Testing 149.0.7827.55, Linux x86_64.



What this PR does
Makes the floating Todo panel animate an
in_progressitem only while the current Session has observable live execution: a locally streaming turn, a daemon-reported foreground prompt, or per-sessionactiveWorkState: "active". When the persisted Todo still saysin_progressbut no live source confirms execution, the panel keeps the existing static status glyph instead of showing an endless spinner.The existing prompt bridge now returns the active-work state from the same live-session snapshot it already consumes. Its original boolean wrapper remains unchanged for prompt-only consumers, and the App test harness is only updated to preserve that existing mock boundary.
Why it's needed
A Todo item is persisted plan state, not a liveness signal. Today
in_progressunconditionally renders a spinner, so a settled or disconnected Session can look as if it is still executing forever. The live-state contract introduced by #11267 supplies the missing execution fact; this PR consumes it without inventing another timer or task tracker.Reviewer Test Plan
How to verify
in_progressTodo keeps spinning.hasActivePrompt: true, confirm the spinner remains active.activeWorkState: "active".in_progress; confirm its icon becomes static.Evidence (Before & After)
Before: persisted
in_progressalways animated, even with no live execution. After: animation is gated by the existing local/daemon liveness facts. No local screenshot was captured.Tested on
Environment (optional)
No local test, build, typecheck, or CI command was run. Verification was limited to two clean static flow reviews and
git diff --check.Risk & Scope
Linked Issues
Part of #11119
Depends on #11267
中文说明
本 PR 做了什么
让浮动 Todo 面板只在当前 Session 存在可观测的 live execution 时,为
in_progress项目显示旋转动画:本地正在流式输出、daemon 报告前台 prompt 正在运行,或者 per-sessionactiveWorkState: "active"。如果持久化 Todo 仍为in_progress,但没有任何 live 来源确认仍在执行,则保留现有静态状态图标,不再无限旋转。现有 prompt bridge 现在会从它已经消费的同一份 live-session snapshot 返回 active-work 状态。给只关心 prompt 的调用方保留原有 boolean wrapper;App 测试夹具仅做必要的 mock 边界适配。
为什么需要
Todo 项目是持久化计划状态,不是 liveness 信号。目前
in_progress无条件渲染 spinner,所以已经 settle 或断开的 Session 也可能永久显示成仍在执行。#11267 引入的 live-state contract 提供了缺失的执行事实;本 PR 直接消费它,不新增 timer 或 task tracker。Reviewer Test Plan
如何验证
in_progressTodo 继续旋转。hasActivePrompt: true的静默前台 turn 中,确认 spinner 保持活动。activeWorkState: "active"会维持 spinner。in_progress时,确认图标变为静态。证据(Before & After)
Before:只要持久化状态是
in_progress就一定旋转,即使没有 live execution。After:动画由现有本地/daemon liveness 事实控制。未采集本地截图。已测试平台
环境(可选)
未运行本地测试、build、typecheck 或 CI 命令。验证仅包含两轮干净的静态链路审核与
git diff --check。风险与范围
关联 Issue
Part of #11119
Depends on #11267