Repository navigation
fix(web-shell): keep live Generated UI cards attached - #15787
Conversation
A card produced during a live turn showed Reopen interactive view with its fallback text as soon as it rendered; it only attached after the page was reloaded from history. The owner source compared the session recovery generation with the value seen at render. Sending a prompt advances that generation without changing the capability receipt, so every attach during the turn was refused. The owner snapshot already rejects a recovery that started after it was captured, so the render-time comparison is removed. A refused first attach was also never retried, because the slot only attached again when its key, resource or token changed. A card that has never attached now retries after each render; a card whose instance was released still needs an explicit reopen.
yiliang114
left a comment
There was a problem hiding this comment.
Review — APPROVE ✅
Scope: 3 files, +33/−9. Fix for Generated UI cards being detached during live turns.
Root cause: During a live turn, the sessionRecoveryGenerationRef advances when a prompt is sent, but the owner capability receipt does not change. The generatedUiRecoveryGeneration captured at render time would never match the ref's current value during the turn, causing every isCurrent() to fail and every card attach to be rejected.
Fix analysis:
-
DaemonSessionProvider.tsx: Removes thegeneratedUiRecoveryGenerationcapture and comparison. The comment correctly notes thatowner.isCurrent()already handles the "recovery started after capture" race; the generation check was redundant and over-blocking. -
GeneratedUiRuntimeProvider.tsx: Adds a retry path — when a card has never been successfully attached (!store.getRecord(slot.key)) and is not already active (!ordinaryActive), it callsstore.attach()instead ofstore.update(). This recovers from a stale-first-attach without re-attaching cards that were intentionally released. -
Test:
attaches a live card once a refused first attach becomes current— directly exercises the stale→current transition, verifying no iframe on first render and successful attach on re-render.
Verdict: No P0 or P1 findings. The fix is minimal, correct, and well-tested. CI is green on all relevant lanes.
qwen-code-dev-bot
left a comment
There was a problem hiding this comment.
APPROVE — 审查版本 head c3135c75(提交前后两次读取一致,单 commit)。
- 历史 Review:唯一一条正式 Review(yiliang114,APPROVE)无 Critical/必要 Suggestion,无未解决的 review thread;结论与本次独立检查一致。
- CI:head 上唯一 required check
Lint & Static (ubuntu-latest, Node 22.x)success,Test (ubuntu-latest, Node 22.x)、web-shell E2E Smoke、Integration Tests (no-AK)、TUI parity、OpenTUI no-flicker、Managed Agent gate均 success。非 required 的Capture web-shell visuals仍在运行、precheck-pr / precheck自 09:17Z 起 pending(自动化上下文,非代码 lane),不构成门禁阻塞;若 visuals lane 后续变红请再触发一次 review。 - 独立检查未发现新 Critical:
- 去掉 render 期 recovery generation 比较后,失效保护没有变弱:
captureOwner()内的ownerGuardValue.capture({ includeRecovery: true })在消费方捕获时记录 generation,任何此后的advanceSessionRecoveryGeneration(prompt 开始、turn_complete/turn_error、rewind、policy 变更)都会让isCurrent()变假;session 对象、clientId、eventEpoch、scope、connection、generatedUireceipt 身份仍逐项实时校验。 - 重试路径不会把「已释放」的卡片自动拉起:
attach()被拒绝且原本无 record 时不写入 record(getRecord仍为 undefined,故可重试);而release/dispose(含 128 条 record 上限、MAX_PARKED_INSTANCES驱逐、deleted/reconnected/replay_complete/cancelled/stream_ended等 invalidation)都保留 record(entry 置空),此时 effect 走update()分支且因entry?.token !== slot.token直接 no-op,仍需显式 reopen。 - 唯二会删除 record 的路径是
scope-changedinvalidation(scope 变更后generatedUiScopeRef.current === generatedUiScope使isCurrent()为假)与 provider 卸载时的store.clear(),都不会造成误重挂。 - 无渲染风暴:
attach成功才changed(),重试被拒时不触发 store 版本变更;update()不changed()。
- 去掉 render 期 recovery generation 比较后,失效保护没有变弱:
|
Released in v0.26.3. |
What this PR does
A Generated UI card produced during a live turn now attaches and shows its interactive view as soon as the tool result arrives, instead of rendering as a released card with Reopen interactive view.
Two things kept the card detached. The Web Shell owner source compared the session recovery generation with the value it saw when it last rendered. Sending a prompt advances that generation without changing the capability receipt, so every attach during the turn was refused; the owner snapshot already rejects a recovery that started after it was captured, so the render-time comparison is removed. A refused first attach was also never retried, because the slot attached again only when its key, resource or token changed. A card that has never attached now retries after each render, while a card whose instance was released still requires an explicit reopen.
Why it's needed
Since the retention work in #15611, every card generated in a live Web Shell turn appears released right away: the transcript shows the saved fallback text, the reopen notice and a Reopen interactive view button, and the card only becomes interactive after a page reload, when it is rendered from history. This affects every user who enables
experimental.generatedUi, and blocks making the feature default-on (#14019).Reviewer Test Plan
How to verify
experimental.generatedUi, startqwen serveand open Web Shell.请使用 generated-ui 技能和 generated_ui_show 工具,在对话里给我一个交互卡片:聚餐分账,菜品总价 924 元,6 个人平分,可以调整人数和服务费比例。Unit coverage: a new
GeneratedUiRuntimeProvidercase renders a slot whose owner is not current, then makes it current and expects the card to attach without a reopen button; it fails without this change.Evidence (Before & After)
Same daemon build, model (qwen3.8-max) and prompt; a Playwright probe sampled the transcript once per second:
9ef52e1536)Instrumenting the owner check on the baseline showed the only failing term during the turn was the render-time recovery generation (210 refusals in one turn).
Tests:
packages/web-shellclient/generated-ui,DaemonSessionProviderandMcpAppsuites, 617 passed;generated-ui.retention.spec.tsandgenerated-ui.continue.spec.tsin Chromium passed, with three timing-sensitive cases that also fail intermittently on main under full-suite load passing when rerun.Tested on
Environment (optional)
Local bundle of
mainwith and without this change,qwen servewith an isolatedHOME, Chromium via Playwright.Risk & Scope
Linked Issues
Follow-up to #15611. Part of #14019.
中文说明
这个 PR 做了什么
在实时回合中生成的 Generated UI 卡片,现在会在工具结果到达时直接挂载并显示交互视图,不再一出现就变成带 重新打开交互视图 按钮的"已释放"卡片。
导致卡片挂不上的原因有两个。Web Shell 的 owner source 用"上次渲染时看到的"会话恢复代数做比对;发送消息会推进这个代数,但能力回执并没有变化,于是整个回合内的每次 attach 都被拒绝。owner 快照本身已经会拒绝在捕获之后才开始的恢复,因此去掉了这项按渲染时取值的比对。另外,首次 attach 被拒后不会重试,因为卡片槽位只在 key、resource 或 token 变化时才重新 attach。现在从未挂上过的卡片会在每次渲染后重试;已经被释放的卡片仍需用户显式重新打开。
为什么需要
自 #15611 引入保留机制以来,实时 Web Shell 回合中生成的每张卡片都会立刻显示为已释放:transcript 中显示保存的回退文本、重新打开说明和 重新打开交互视图 按钮,只有刷新页面、从历史记录渲染时卡片才能交互。所有开启
experimental.generatedUi的用户都会遇到,也阻碍了把该功能默认开启(#14019)。审阅者测试计划
如何验证
experimental.generatedUi,启动qwen serve并打开 Web Shell。请使用 generated-ui 技能和 generated_ui_show 工具,在对话里给我一个交互卡片:聚餐分账,菜品总价 924 元,6 个人平分,可以调整人数和服务费比例。单元测试:新增一个
GeneratedUiRuntimeProvider用例,先以 owner 非当前状态渲染槽位,再让它变为当前,预期卡片自动挂载且没有重新打开按钮;没有本改动时该用例失败。证据(修改前后)
相同的 daemon 构建、模型(qwen3.8-max)和提示词;Playwright 探针每秒采样一次 transcript:
9ef52e1536)在基线上对 owner 检查加日志后发现,回合期间唯一不成立的就是按渲染时取值的恢复代数(一个回合内被拒 210 次)。
测试:
packages/web-shell中client/generated-ui、DaemonSessionProvider和McpApp测试共 617 个通过;Chromium 下generated-ui.retention.spec.ts与generated-ui.continue.spec.ts通过,其中三个对时序敏感、在 main 上整套运行时同样会偶发失败的用例,重跑后通过。测试平台
环境(可选)
main在有无本改动两种情况下的本地 bundle,隔离HOME的qwen serve,通过 Playwright 驱动 Chromium。风险与范围
关联 Issue
#15611 的后续修复。属于 #14019 的一部分。