From c586e00cb7a963c041a28759c3749a5e01dd4e29 Mon Sep 17 00:00:00 2001 From: qqqys Date: Wed, 28 Oct 2026 00:54:35 +0800 Subject: [PATCH] feat(web-shell): retain Generated UI instances across same-page navigation (#15611) * feat(web-shell): retain Generated UI instances across same-page navigation * fix(web-shell): release retained UI after background session deletion * fix(web-shell): bind Generated UI owners before initial events * fix(web-shell): release retained UI after failed session restore * fix(web-shell): retain views in shadow portals and local deletion * fix(web-shell): keep retained views out of transcript exports Let transcript builds prune the unused retention branch without raising the document renderer budget. Verify built artifacts and align the retained-history fixture with the current navigation gutter requirement. * fix(web-shell): preserve retained view lifecycle boundaries * fix(web-shell): observe Generated UI revocations before render * fix(web-shell): defer retention until canonical ownership matches --------- Co-authored-by: qqqys <266654365+qqqys@users.noreply.github.com> --- docs/design/generated-ui-retention.md | 97 + docs/design/generated-ui-retention.zh-CN.md | 71 + integrations/generated-ui/README.md | 10 +- .../src/daemon/DaemonSessionClient.ts | 30 +- .../test/unit/DaemonSessionClient.test.ts | 105 +- packages/web-shell/client/App.tsx | 13 +- .../web-shell/client/adapters/messageTypes.ts | 2 + .../adapters/transcriptToMessages.test.ts | 31 + .../client/adapters/transcriptToMessages.ts | 3 + .../web-shell/client/build-artifact.test.ts | 11 + .../web-shell/client/components/ChatPane.tsx | 105 +- .../components/SessionOverviewPanel.test.tsx | 178 +- .../components/SessionOverviewPanel.tsx | 7 + .../client/components/messages/McpApp.tsx | 77 +- .../client/components/messages/ToolGroup.tsx | 8 +- ...ellSidebar.current-session-delete.test.tsx | 245 ++- .../components/sidebar/WebShellSidebar.tsx | 8 + .../session/DaemonSessionProvider.test.tsx | 1814 ++++++++++++++++ .../daemon/session/DaemonSessionProvider.tsx | 445 +++- .../client/daemon/session/actions.test.ts | 66 + .../client/daemon/session/actions.ts | 13 +- .../client/e2e/generated-ui.continue.spec.ts | 61 +- .../client/e2e/generated-ui.retention.spec.ts | 1842 +++++++++++++++++ .../client/e2e/utils/generatedUiSandbox.ts | 72 + .../GeneratedUiRuntimeProvider.dom.test.tsx | 1126 ++++++++++ .../GeneratedUiRuntimeProvider.tsx | 254 +++ .../generated-ui/instance-store.test.ts | 810 ++++++++ .../client/generated-ui/instance-store.ts | 424 ++++ .../client/hooks/useMessageDeletion.test.tsx | 210 ++ .../client/hooks/useMessageDeletion.ts | 11 +- .../client/hooks/useScopedSessions.test.tsx | 47 + .../client/hooks/useScopedSessions.ts | 18 +- packages/web-shell/client/mcp-app/messages.ts | 10 + .../web-shell/client/mcpAppHostContext.ts | 57 +- .../session-catalog-hooks.test.tsx | 54 +- .../session-catalog/session-catalog-hooks.ts | 19 +- 36 files changed, 8151 insertions(+), 203 deletions(-) create mode 100644 docs/design/generated-ui-retention.md create mode 100644 docs/design/generated-ui-retention.zh-CN.md create mode 100644 packages/web-shell/client/e2e/generated-ui.retention.spec.ts create mode 100644 packages/web-shell/client/e2e/utils/generatedUiSandbox.ts create mode 100644 packages/web-shell/client/generated-ui/GeneratedUiRuntimeProvider.dom.test.tsx create mode 100644 packages/web-shell/client/generated-ui/GeneratedUiRuntimeProvider.tsx create mode 100644 packages/web-shell/client/generated-ui/instance-store.test.ts create mode 100644 packages/web-shell/client/generated-ui/instance-store.ts create mode 100644 packages/web-shell/client/hooks/useMessageDeletion.test.tsx diff --git a/docs/design/generated-ui-retention.md b/docs/design/generated-ui-retention.md new file mode 100644 index 00000000000..86319a3ea4b --- /dev/null +++ b/docs/design/generated-ui-retention.md @@ -0,0 +1,97 @@ +# Generated UI retention in Web Shell + +[English](generated-ui-retention.md) | [简体中文](generated-ui-retention.zh-CN.md) + +## Problem and scope + +Remounting a transcript card recreates its iframe and Worker, losing applied +output, dirty inputs, dynamic controls, and guest state. Retain eligible bundled +Generated UI activations within one browser page across virtualization, history, +same-workspace navigation, New task, and main/split-view handoffs. + +Ordinary MCP Apps and manually installed extensions keep their existing +lifecycle. Refresh, page closure, export, and session forks do not preserve or +transfer live activations. No interaction snapshots are persisted, and there +are no new daemon routes or protocol fields. + +## Identity and ownership + +Each record is keyed by daemon/credential/workspace scope, session, producer +tool-call ID, server, and resource URI. Independent calls and copied sessions +never share an activation. A separate resource key covers bootstrap HTML, +arguments/results, permissions, CSP, daemon epoch, and host capabilities. +Changing that resource releases the activation and requires explicit reopening. +Memoization avoids serializing the full bootstrap on unrelated renders. + +The provider binds session clients to their original scope. The daemon's +confirmed canonical cwd remains authoritative during reconnect and detachment; +requested aliases do not create new scopes on transport failure. Actual host, +credential, or workspace changes retire the old scope. The binding guard must +never reassign an old client to a different scope. +An action-created client binds only when its scope matches the daemon's +canonical cwd. A connected render before attachment cannot bind it to a +provisional requested alias. + +## Parking and authorization + +The page owns a store and hidden, inert parking hosts in its document and portal +roots. A live container moves only between connected elements sharing one root, +using native `moveBefore`; moving an empty container may use `appendChild`. +Unsupported or failed populated moves release the activation instead of +claiming continuity. Cross-root handoffs cannot preserve the activation. + +Only one visible transcript slot owns an activation at a time. Hiding, folding, +unmounting, or clearing the session revokes its lease synchronously. New task +parks rather than deletes. Returning requires a current owner, matching enabled +receipt, and compatible resource. Every host call checks the lease before and +after awaiting and composes its abort signal with the lease signal. + +A pending message review is cancelled on hide. An already dispatched analysis +continues in its original session; navigation or reset never resends it or +claims to cancel it. Parked size notifications cannot change the restored height. + +## Bounds and invalidation + +At most four parked instances survive, evicting the oldest parked instance. +Each parking period lasts at most 15 minutes, checked both by timer and absolute +age. All roots share these bounds. At most 128 identities are tracked; further +cards require explicit opening without retention. + +Policy events and confirmed disabled receipts release the matching scope's +instances. The SDK reports each validated current denial synchronously and +replays its latest revocation revision to late subscribers. The workspace owner +subscribes before reading context, so a later enabled receipt or React batching +cannot erase a confirmed revocation. Missing/failed receipts revoke authority +without certifying policy revocation. Authentication failure releases instances +but keeps identity records, so recovery cannot silently restart them. Deletion, +rewind, epoch changes, eviction, expiry, and changed resources likewise preserve +released records. Only actual scope retirement forgets its records. + +Released eligible cards offer **Reopen interactive view**. **Reopen and reset** +also resets retained, stopped, or failed cards. Both create a fresh activation +from the saved original content and display the interaction-loss notice. + +## Validation and acceptance + +- New task, controlled empty-session navigation, virtualization, history, and + successful retry after a temporary load failure preserve the actual realm, + Worker, applied result, and dirty input when retention remains eligible. +- Canonical-cwd reconnection keeps scope and owner source stable. Actual scope + changes and foreign scopes remain isolated. + Creation may commit before attachment; the attached client must still have a + canonical owner and retain its activation after the first prompt. +- Disabled load/context receipts and attached-stream/heartbeat access failures + release runtimes. Recovery of a released card requires explicit reopening. + Disabled then enabled receipts before one React commit must still release the + old activation; stale or foreign receipts must not release another owner. +- Hidden main/split surfaces, including artifact fullscreen and missing-session + states, reject tool/message RPCs while retaining eligible runtime identity. +- Reopening resets only the affected activation; siblings and independent panes + keep their frames and dirty values. Ordinary apps close on removal. +- Unit/DOM tests pin leases, resource changes, reset, admission denial, and + bounds. Native Chromium tests establish frame/Worker continuity and host RPC + denial; DOM move shims alone do not establish browser continuity. + +Browser support for state-preserving moves is required. Firefox/WebKit, online +model quality, and long-running memory pressure remain outside this change's +validation scope. Retained Workers remain subject to existing execution budgets. diff --git a/docs/design/generated-ui-retention.zh-CN.md b/docs/design/generated-ui-retention.zh-CN.md new file mode 100644 index 00000000000..8e4c86f48fd --- /dev/null +++ b/docs/design/generated-ui-retention.zh-CN.md @@ -0,0 +1,71 @@ +# Web Shell 中的 Generated UI 保活 + +[English](generated-ui-retention.md) | [简体中文](generated-ui-retention.zh-CN.md) + +## 问题与范围 + +转录卡片重新挂载会重建 iframe 和 Worker,丢失已应用结果、未应用输入、动态控件和 +guest 状态。本设计在同一浏览器页面中为符合条件的内置 Generated UI 激活实例保活, +覆盖虚拟列表、历史导航、同工作区导航、新任务和主视图/分屏交接。 + +普通 MCP Apps 和手动安装的扩展保持原有生命周期。刷新、关闭页面、导出和派生会话 +不会保存或转移活实例。不持久化交互快照,不新增 daemon 路由或协议字段。 + +## 身份与归属 + +记录的键包含 daemon/凭据/工作区 scope、会话、生产者工具调用 ID、服务端和资源 URI。 +独立调用和复制的会话不会共享激活实例。单独的资源键包含 bootstrap HTML、参数/结果、 +权限、CSP、daemon epoch 和宿主能力。资源变化会释放实例并要求显式重开。 +通过 memoization 避免在无关渲染中重新序列化完整 bootstrap。 + +Provider 将 session client 绑定到原 scope。在重连和分离期间,daemon 已确认的规范 cwd +仍是身份依据;请求路径的别名不会因传输故障生成新 scope。真正切换宿主、凭据或工作区 +会退役原 scope。绑定守卫不得将旧 client 重新指派给不同的 scope。 +通过 action 创建的 client 仅在 scope 与 daemon 的规范 cwd 匹配后绑定。创建成功后、 +attachment 前的 connected 渲染不得将其绑定到请求别名的临时 scope。 + +## 停放与授权 + +页面拥有一个 store,以及位于主文档和 portal 根中的隐藏、inert 停放宿主。活容器只在 +同一根中、仍连接的元素间通过原生 `moveBefore` 移动;空容器可以使用 `appendChild`。 +浏览器不支持或无法移动已填充容器时,释放实例,不声称保持连续性。跨根交接不能保留实例。 + +每次只有一个可见的转录 slot 拥有实例。隐藏、折叠、卸载或清空会话会同步撤销 lease。 +新任务仅停放,不删除。返回时要求当前 owner、匹配的启用回执及兼容资源。每次宿主调用 +在 await 前后检查 lease,并将调用的 abort signal 与 lease signal 组合。 + +隐藏会取消待确认的消息审阅。已派发的分析继续在原会话中执行;导航和重置不会重发分析, +也不会声称将其取消。停放期间的尺寸通知不能改变返回后的高度。 + +## 上限与失效 + +最多保留四个停放实例,淘汰最早停放的实例。每次停放最多 15 分钟,同时通过定时器和 +绝对时长校验。所有根共享这些上限。最多追踪 128 个身份;之后的卡片只能显式打开,且不保活。 + +策略事件和明确禁用的回执会释放匹配 scope 的实例。SDK 同步报告每个已校验且仍有效的 +禁用回执,并向较晚订阅者重放最新的撤销版本。工作区 owner 在读取 context 前订阅, +因此后续启用回执或 React 批量提交不能抹去已确认的撤销。缺失/失败的回执撤销权限,但不代表 +已确认策略撤销。认证失败释放实例但保留身份记录,避免恢复后静默重启。删除、回退、epoch +变化、淘汰、过期和资源变化也保留释放记录。只有真正退役 scope 才遗忘其记录。 + +已释放且仍符合准入条件的卡片提供 **重新打开交互视图**。**重新打开并重置** 也可重置 +保活、已停止或失败的卡片。两者均从保存的原始内容创建新实例,并显示交互丢失提示。 + +## 验证与验收 + +- 在仍符合保活条件时,新任务、受控空会话导航、虚拟列表、历史和临时加载失败后的成功 + 重试,保持真实 realm、Worker、已应用结果和未应用输入。 +- 规范 cwd 重连保持 scope 和 owner source 稳定。真正的 scope 变化和外部 scope 保持隔离。 + 创建可以在 attachment 前提交;连接后的 client 仍须拥有规范 owner,并在首次 prompt + 后保留激活实例。 +- 禁用的 load/context 回执及已连接流/heartbeat 的访问失败会释放运行时。已释放卡片恢复 + 后需要显式重开。在同一次 React 提交前先收到禁用、再收到启用回执,仍须释放旧实例; + 过时或属于其他会话的回执不得释放另一 owner 的实例。 +- 隐藏的主视图/分屏(包括 artifact 全屏和会话缺失状态)拒绝工具/消息 RPC,同时保留 + 符合条件的运行时身份。 +- 重开只重置目标实例;其他卡片和独立分屏保留 frame 与未应用值。普通 app 移除即关闭。 +- 单元/DOM 测试覆盖 lease、资源变化、重置、准入拒绝及上限。原生 Chromium 测试验证 + frame/Worker 连续性及宿主 RPC 拒绝;单独的 DOM 移动 shim 无法证明浏览器连续性。 + +浏览器必须支持保状态移动。Firefox/WebKit、在线模型质量和长期内存压力仍不在本次变更的 +验证范围。保活的 Worker 仍受已有执行预算约束。 diff --git a/integrations/generated-ui/README.md b/integrations/generated-ui/README.md index f775573bfcc..884e75ddabb 100644 --- a/integrations/generated-ui/README.md +++ b/integrations/generated-ui/README.md @@ -71,7 +71,7 @@ Each initialization or event has a 250 ms execution budget, a 32 MiB guest heap The trusted Controller exposes a local `captureSnapshot()` interface for host integration. It returns a deeply frozen schema-version-1 parameter snapshot only when the current activation is ready; otherwise it reports not-ready, busy, dirty, stopped or error. Each snapshot carries its document identity, temporary activation identity, applied request sequence, dispatch parameter revision and post-apply parameter revision. The capture result also records the number of queue rejections observed at capture time. -Parameter revisions advance when the control ID set, value or checked state changes, including changes caused by patches. Same-value input/change notifications do not advance them; changing a value and changing it back does. Captures use browser-normalized values after the actual DOM update, remain unchanged by later edits, and retain the existing input bounds. Supported controls can include disabled or visually hidden values and values written by generated code; a snapshot is not user approval or a calculation correctness guarantee. It contains no derived output, HTML, source, guest closure state or persistence data. Generated code cannot access this interface. Restoring interaction state remains a separate follow-up feature. +Parameter revisions advance when the control ID set, value or checked state changes, including changes caused by patches. Same-value input/change notifications do not advance them; changing a value and changing it back does. Captures use browser-normalized values after the actual DOM update, remain unchanged by later edits, and retain the existing input bounds. Supported controls can include disabled or visually hidden values and values written by generated code; a snapshot is not user approval or a calculation correctness guarantee. It contains no derived output, HTML, source, guest closure state or persistence data. Generated code cannot access this interface. Snapshots do not restore interaction state. ### Continue analysis with current parameters @@ -119,7 +119,13 @@ Two fictional options are scored on responsiveness and operational simplicity, e The server never executes the generated source or writes a document cache. It returns a versioned snapshot with a SHA-256 identity. Public text and structured output contain the title, summary and identity; source travels in the App-only result metadata. Titles and summaries are normalized before hashing and shared unchanged by the App, readable fallback and structured output. Normalization removes control characters, Unicode formatting characters and line/paragraph separators, preserving newline, tab and the U+200C/U+200D joiners used in text and emoji. Each normalized title and summary must contain text beyond whitespace and those joiners; empty or blank-only fields are rejected during creation and replay. HTML, CSS and JavaScript source are unchanged. Previously saved snapshots with unsupported display characters or blank-only titles/summaries are rejected with a visible limitation; other clean snapshots retain their identities. Resource reads always return the fixed trusted bootstrap, independent of earlier show calls. The browser validates the complete snapshot and identity before execution. -Daemon/Web Shell history can replay the saved initial snapshot without fetching the generating MCP server again. Interaction state is local to one activation: refreshing or remounting starts from the saved initial values. Two cards remain independent, including two copies of the same document. A later show call does not update an earlier card. Stop remains terminal for the current activation, including if pressed before a result arrives; duplicate final notifications cannot restart it. +Daemon/Web Shell history can replay the saved initial snapshot without fetching the generating MCP server again. Two tool calls remain independent, including two copies of the same document. A later show call does not update an earlier card. Stop remains terminal for the current activation, including if pressed before a result arrives; duplicate final notifications cannot restart it. + +The bundled feature keeps a card's live frame and Worker while scrolling through the transcript or switching sessions within the same workspace and browser page, including leaving for New task, when the browser supports state-preserving DOM moves. Applied results, unapplied inputs and local runtime state remain in that activation. Canonical workspace identity survives transport reconnects. Hidden cards cannot call host tools or submit messages; returning requires fresh session authorization. A pending review is cancelled when its card leaves the active view, while an already dispatched or accepted request cannot be withdrawn and is never automatically resent. + +At most four detached cards are retained, each for up to 15 minutes. Eviction, changed resources or permissions, confirmed policy revocation (including a disabled capability receipt), authentication failure, session/message deletion, rewind, a daemon epoch change, changing workspace or host, and unsupported browser moves release the affected activation. A later enabled receipt cannot erase a confirmed revocation, even before React commits; reopening remains explicit. Missing or failed capability reads revoke authority without confirming policy revocation. Authentication recovery preserves released-card records and requires explicit reopening. Released cards show **Reopen interactive view**; **Reopen and reset** is also available on a retained card, including after Stop or an error. Both start from the original content and discard previous interactions. Check the original session before submitting again: resetting the view does not cancel analysis already sent. After the page has tracked 128 card identities, additional cards require explicit opening and are not retained. + +This retention is limited to eligible bundled cards with a known daemon producer and epoch. Ordinary MCP Apps and manually installed extensions keep their existing lifecycle. Refreshing or closing the page, exporting a transcript, or forking a session does not save or transfer a live activation. There is no persistent interaction-state storage. The existing host can degrade rich cards to their text fallback when history or delivery windows exceed their budgets. Terminal history does not retain interactive App payloads. A host without MCP Apps retains the readable summary. Missing Worker/WebAssembly/DecompressionStream support, invalid or dropped snapshot metadata, digest mismatch, invalid controls or exhausted runtime budgets produces a visible limitation instead of re-fetching or executing incomplete source. Unsupported markup is removed by the sanitizer. This extension does not promise streaming, saved interaction state or unlimited rich history. diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index 56d1a2df7b8..1c020c4cae4 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -301,6 +301,10 @@ export class DaemonSessionClient { /** In-flight `reattach()` so concurrent prompts re-register only once. */ private reattaching?: Promise; private generatedUiReadGeneration = 0; + private generatedUiRevocationRevision = 0; + private readonly generatedUiRevocationListeners = new Set< + (revision: number) => void + >(); private cancelling?: Promise; private readonly promptLimit: number; private readonly attachmentCache = new Map< @@ -328,6 +332,7 @@ export class DaemonSessionClient { constructor(opts: DaemonSessionClientOptions) { this.client = opts.client; this.session = { ...opts.session }; + this.updateGeneratedUi(this.session.generatedUi); const context = (opts.session as { context?: { kind?: unknown } }).context; this.restoreStrategy = opts.session.sourceType === 'standalone' && context?.kind === 'standalone' @@ -357,6 +362,24 @@ export class DaemonSessionClient { : normalizePendingPromptLimit(opts.maxPendingPromptsPerSession); } + /** Replays the latest confirmed denial so late subscribers cannot miss it. */ + subscribeGeneratedUiRevocation(listener: (revision: number) => void) { + this.generatedUiRevocationListeners.add(listener); + if (this.generatedUiRevocationRevision > 0) + listener(this.generatedUiRevocationRevision); + return () => { + this.generatedUiRevocationListeners.delete(listener); + }; + } + + private updateGeneratedUi(status: DaemonSession['generatedUi']) { + this.session.generatedUi = status; + if (status?.version !== 1 || status.enabled !== false) return; + const revision = ++this.generatedUiRevocationRevision; + for (const listener of this.generatedUiRevocationListeners) + listener(revision); + } + /** * Creates a new daemon session or attaches to an existing matching session. */ @@ -899,7 +922,7 @@ export class DaemonSessionClient { } // Refresh attachment identity and capabilities without moving the SSE cursor. if (generatedUiGeneration === this.generatedUiReadGeneration) { - this.session.generatedUi = session.generatedUi; + this.updateGeneratedUi(session.generatedUi); } this.session.clientId = session.clientId; }); @@ -1233,7 +1256,7 @@ export class DaemonSessionClient { ); if (generation === this.generatedUiReadGeneration) { const value = status.state._meta?.['generatedUi']; - this.session.generatedUi = undefined; + let generatedUi: DaemonSession['generatedUi']; if ( status.v === 1 && status.sessionId === this.sessionId && @@ -1245,7 +1268,7 @@ export class DaemonSessionClient { 'enabled' in value && typeof value.enabled === 'boolean' ) { - this.session.generatedUi = { + generatedUi = { version: 1, enabled: value.enabled, ...('reason' in value && typeof value.reason === 'string' @@ -1253,6 +1276,7 @@ export class DaemonSessionClient { : {}), }; } + this.updateGeneratedUi(generatedUi); } return status; } catch (error) { diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index 7707b520213..5a90f409477 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -3935,9 +3935,12 @@ describe('DaemonSessionClient clientId self-heal', () => { new DaemonClient({ baseUrl: 'http://daemon', fetch }), ); session.session.generatedUi = { version: 1, enabled: true }; + const revoked = vi.fn(); + session.subscribeGeneratedUiRevocation(revoked); await session.prompt({ prompt: [{ type: 'text', text: 'hello' }] }); expect(session.clientId).toBe('client-2'); expect(session.session.generatedUi).toEqual(generatedUi); + expect(revoked).toHaveBeenCalledTimes(enabled === false ? 1 : 0); }, ); @@ -3948,6 +3951,16 @@ describe('DaemonSessionClient clientId self-heal', () => { expected: { version: 1, enabled: false }, }, { sessionId: 's-1', generatedUi: undefined, expected: undefined }, + { + sessionId: 's-1', + generatedUi: { version: 1, enabled: true }, + expected: { version: 1, enabled: true }, + }, + { + sessionId: 'other', + generatedUi: { version: 1, enabled: false }, + expected: undefined, + }, { sessionId: 'other', generatedUi: { version: 1, enabled: true }, @@ -3978,8 +3991,13 @@ describe('DaemonSessionClient clientId self-heal', () => { new DaemonClient({ baseUrl: 'http://daemon', fetch }), ); session.session.generatedUi = { version: 1, enabled: true }; + const revoked = vi.fn(); + session.subscribeGeneratedUiRevocation(revoked); await session.context(); expect(session.session.generatedUi).toEqual(expected); + expect(revoked).toHaveBeenCalledTimes( + expected?.enabled === false ? 1 : 0, + ); }, ); @@ -3991,35 +4009,92 @@ describe('DaemonSessionClient clientId self-heal', () => { new DaemonClient({ baseUrl: 'http://daemon', fetch }), ); session.session.generatedUi = { version: 1, enabled: true }; + const revoked = vi.fn(); + session.subscribeGeneratedUiRevocation(revoked); await expect(session.context()).rejects.toThrow('unavailable'); expect(session.session.generatedUi).toBeUndefined(); + expect(revoked).not.toHaveBeenCalled(); }); - it('ignores an older Generated UI context response after a newer restriction', async () => { - let resolveOlder!: (response: Response) => void; - let calls = 0; - const older = new Promise((resolve) => { - resolveOlder = resolve; - }); - const reply = (enabled: boolean) => + it.each([true, false])( + 'ignores an older Generated UI context receipt: enabled=%s', + async (olderEnabled) => { + let resolveOlder!: (response: Response) => void; + let calls = 0; + const older = new Promise((resolve) => { + resolveOlder = resolve; + }); + const reply = (enabled: boolean) => + jsonResponse(200, { + v: 1, + sessionId: 's-1', + workspaceCwd: '/work/a', + state: { _meta: { generatedUi: { version: 1, enabled } } }, + }); + const { fetch } = recordingFetch(() => + ++calls === 1 ? older : reply(!olderEnabled), + ); + const session = newSession( + new DaemonClient({ baseUrl: 'http://daemon', fetch }), + ); + const revoked = vi.fn(); + session.subscribeGeneratedUiRevocation(revoked); + const first = session.context(); + await session.context(); + expect(session.session.generatedUi?.enabled).toBe(!olderEnabled); + resolveOlder(reply(olderEnabled)); + await first; + expect(session.session.generatedUi?.enabled).toBe(!olderEnabled); + expect(revoked).toHaveBeenCalledTimes(olderEnabled ? 1 : 0); + }, + ); + + it('replays confirmed Generated UI revocations after re-enabling and releases subscriptions', async () => { + let enabled = true; + const { fetch } = recordingFetch(() => jsonResponse(200, { v: 1, sessionId: 's-1', workspaceCwd: '/work/a', state: { _meta: { generatedUi: { version: 1, enabled } } }, - }); - const { fetch } = recordingFetch(() => - ++calls === 1 ? older : reply(false), + }), ); const session = newSession( new DaemonClient({ baseUrl: 'http://daemon', fetch }), ); - const first = session.context(); + const first = vi.fn(); + const unsubscribe = session.subscribeGeneratedUiRevocation(first); + expect(first).not.toHaveBeenCalled(); + enabled = false; await session.context(); - expect(session.session.generatedUi?.enabled).toBe(false); - resolveOlder(reply(true)); - await first; - expect(session.session.generatedUi?.enabled).toBe(false); + expect(first).toHaveBeenCalledTimes(1); + expect(first).toHaveBeenLastCalledWith(1); + enabled = true; + await session.context(); + const late = vi.fn(); + session.subscribeGeneratedUiRevocation(late); + expect(late).toHaveBeenCalledTimes(1); + expect(late).toHaveBeenLastCalledWith(1); + unsubscribe(); + enabled = false; + await session.context(); + expect(first).toHaveBeenCalledTimes(1); + expect(late).toHaveBeenLastCalledWith(2); + }); + + it('replays a disabled load receipt to the first Generated UI subscriber', () => { + const session = new DaemonSessionClient({ + client: new DaemonClient({ baseUrl: 'http://daemon' }), + session: { + sessionId: 's-1', + workspaceCwd: '/work/a', + generatedUi: { version: 1, enabled: false }, + }, + }); + const revoked = vi.fn(); + session.subscribeGeneratedUiRevocation(revoked); + expect(revoked).toHaveBeenCalledTimes(1); + expect(revoked).toHaveBeenLastCalledWith(1); }); it('does not replace a newer Generated UI restriction with an older reattach response', async () => { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 9acbce503b6..994030dd2df 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -330,7 +330,12 @@ import { ExtensionsManagerPage } from './components/extensions/ExtensionsManager import { PluginManagerPage } from './components/plugins/PluginManagerPage'; import { ChannelsManagerPage } from './components/channels/ChannelsManagerPage'; import { ShadowDomBoundary } from './components/ShadowDomBoundary'; -import { McpAppHostContext, McpAppMessagesContext } from './mcpAppHostContext'; +import { + McpAppHostContext, + McpAppMessagesContext, + McpAppVisibilityContext, +} from './mcpAppHostContext'; +import { GeneratedUiRuntimeProvider } from './generated-ui/GeneratedUiRuntimeProvider'; import { useMcpAppMessages, type McpAppPromptSubmission, @@ -21594,6 +21599,7 @@ export function App({ data-compact-sidebar={compactShell ? '' : undefined} lang={selectedLanguage} > + {mcpAppMessages.review} {capacityRecovery && } {!onToast && ( @@ -23410,6 +23416,7 @@ export function App({ session chat does (todo contexts stay chat-only — they belong to the outer session, not the panes). */} + + )} @@ -23592,6 +23600,7 @@ export function App({ } > + )} + @@ -24951,6 +24961,7 @@ export function App({ artifactPanelSlotEl, )} + diff --git a/packages/web-shell/client/adapters/messageTypes.ts b/packages/web-shell/client/adapters/messageTypes.ts index 3331522b617..338642bb0e4 100644 --- a/packages/web-shell/client/adapters/messageTypes.ts +++ b/packages/web-shell/client/adapters/messageTypes.ts @@ -65,6 +65,8 @@ export interface DaemonMessageToolCall { parentToolCallId?: string; /** The producer's own call ID when `callId` is keyed by something else. */ toolCallId?: string; + /** Stable producer identity from the daemon tool-block projection only. */ + generatedUiToolCallId?: string; title?: string; content?: readonly DaemonMessageToolCallContent[]; rawOutput?: unknown; diff --git a/packages/web-shell/client/adapters/transcriptToMessages.test.ts b/packages/web-shell/client/adapters/transcriptToMessages.test.ts index f3e86cd92e0..a0850827ea2 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.test.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.test.ts @@ -286,6 +286,35 @@ function toolBlock( } describe('transcriptBlocksToDaemonMessages', () => { + it('keeps producer identity across live/history projections and separates identical tool results', () => { + const output = { + resultDisplay: { type: 'mcp_app', documentId: 'same-content' }, + }; + const live = transcriptBlocksToDaemonMessages([ + toolBlock('live-block', 'producer-a', 'completed', 1, { + rawOutput: output, + }), + ]); + const history = transcriptBlocksToDaemonMessages([ + toolBlock('history-block', 'producer-a', 'completed', 1, { + rawOutput: output, + }), + toolBlock('history-block-2', 'producer-b', 'completed', 2, { + rawOutput: output, + }), + ]); + const ids = ( + messages: ReturnType, + ) => + messages.flatMap((message) => + message.role === 'tool_group' + ? message.tools.map((tool) => tool.generatedUiToolCallId) + : [], + ); + expect(ids(live)).toEqual(['producer-a']); + expect(ids(history)).toEqual(['producer-a', 'producer-b']); + }); + it('keeps shell input previews out of output while preserving actual content', () => { const block = toolBlock('shell-live', 'shell-1', 'in_progress', 1000, { toolName: 'run_shell_command', @@ -4015,6 +4044,7 @@ describe('transcriptBlocksToDaemonMessages', () => { status: 'in_progress', kind: 'fetch', }); + expect(pendingTool?.generatedUiToolCallId).toBeUndefined(); const messages = transcriptBlocksToDaemonMessages([ { @@ -4057,6 +4087,7 @@ describe('transcriptBlocksToDaemonMessages', () => { toolName: 'web_fetch', status: 'completed', rawOutput: 'Content processed successfully.', + generatedUiToolCallId: 'webfetch-1', }); }); diff --git a/packages/web-shell/client/adapters/transcriptToMessages.ts b/packages/web-shell/client/adapters/transcriptToMessages.ts index ddae8578e01..39cc90c3508 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -1630,6 +1630,8 @@ function mergeToolCall( target.status = source.status ?? target.status; target.title = source.title ?? target.title; target.toolName = source.toolName ?? target.toolName; + target.generatedUiToolCallId = + source.generatedUiToolCallId ?? target.generatedUiToolCallId; target.kind = source.kind ?? target.kind; target.sourceBlockIds = unionMessageIds( target.sourceBlockIds, @@ -1730,6 +1732,7 @@ export function daemonToolBlockToToolCall( return { callId: block.toolCallId, + generatedUiToolCallId: block.toolCallId, toolName, title: block.title === block.toolName ? toolName : block.title, status: diff --git a/packages/web-shell/client/build-artifact.test.ts b/packages/web-shell/client/build-artifact.test.ts index e495e8d31c6..91ff3330cad 100644 --- a/packages/web-shell/client/build-artifact.test.ts +++ b/packages/web-shell/client/build-artifact.test.ts @@ -44,6 +44,17 @@ function enclosingLayer(rule: Rule): string | undefined { } describe('build artifact — package boundary', () => { + it('keeps Generated UI retention out of the read-only transcript runtime', () => { + const withoutCss = (javascript: string) => + javascript.replace(/^const __qwenWebShellCss=.*\n/gm, ''); + expect(withoutCss(readTranscriptBundle())).not.toContain( + 'data-generated-ui-slot', + ); + expect(withoutCss(readPackageJavascript())).toContain( + 'data-generated-ui-slot', + ); + }); + it('keeps deletion-only copy out of the read-only transcript', () => { for (const copy of [ 'Delete permanently', diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index 4712d912bfb..256d675efa3 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -13,6 +13,7 @@ import { } from '../utils/message-deletion'; import { useCallback, + useContext, useEffect, useMemo, useRef, @@ -43,6 +44,7 @@ import { SubagentDetailsProvider } from '../subagentDetailsContext'; import { MonitorDetailsProvider } from '../monitorDetailsContext'; import { WorkflowDetailsProvider } from '../workflowDetailsContext'; import { useI18n } from '../i18n'; +import { McpAppVisibilityContext } from '../mcpAppHostContext'; import { getSubagentDetailsUnavailableReason } from './messages/toolFormatting'; import { useWebShellCustomization } from '../customization'; import { @@ -316,6 +318,7 @@ export function ChatPane({ planControlVisible = false, }: ChatPaneProps) { const { t } = useI18n(); + const parentSurfaceVisible = useContext(McpAppVisibilityContext); const { renderComposerFooter: CustomComposerFooter, askUserFreeTextLabel, @@ -1777,55 +1780,59 @@ export function ChatPane({ : undefined } > - - - + + + + + diff --git a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx index 7694039b656..854b3a9940f 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx @@ -15,6 +15,10 @@ import type { DaemonStatusReportSession, } from '@qwen-code/sdk/daemon'; import { I18nProvider } from '../i18n'; +import { + McpAppGeneratedUiOwnerContext, + type McpAppGeneratedUiOwner, +} from '../mcpAppHostContext'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); @@ -37,6 +41,7 @@ let connectionState: { workspaceCwd?: string; }; let workspaceCapabilities: DaemonCapabilities | undefined; +let generatedUiOwner: McpAppGeneratedUiOwner | undefined; let sessionsState: { sessions: DaemonSessionSummary[]; loading: boolean; @@ -194,6 +199,7 @@ beforeEach(() => { workspaceCwd: '/w', }; workspaceCapabilities = undefined; + generatedUiOwner = undefined; sessionsState = { sessions: [], loading: false }; statusState = { report: { full: { sessions: [] } } }; otherWorkspaceSessions = {}; @@ -290,7 +296,9 @@ function render( act(() => root!.render( - + + + , ), ); @@ -310,7 +318,9 @@ function rerender( act(() => root!.render( - + + + , ), ); @@ -394,6 +404,18 @@ function statusFilterButton(label: string): HTMLButtonElement { ).find((button) => button.textContent?.startsWith(label))!; } +function deletionOwner( + captureSessionDeletion: McpAppGeneratedUiOwner['captureSessionDeletion'], +): McpAppGeneratedUiOwner { + return { + scopeId: 'overview-owner', + workspaceCwd: '/w', + subscribeInvalidation: () => () => {}, + invalidate: vi.fn(), + captureSessionDeletion, + }; +} + describe('deriveSessionCards', () => { it('ranks needs-approval above user-input above running above idle, then by recency', () => { const sessions = [ @@ -2604,6 +2626,158 @@ describe('SessionOverviewPanel', () => { expect(workspaceClient.deleteSessionsData).toHaveBeenCalledWith(['s1']); }); + it.each(['removed', 'notFound'] as const)( + 'cleans up a %s background session using the owner captured before deletion', + async (outcome) => { + sessionsState.sessions = [session('background', { displayName: 'Old' })]; + const deleted = vi.fn(); + const capture = vi.fn(() => deleted); + generatedUiOwner = deletionOwner(capture); + const result = { + removed: outcome === 'removed' ? ['background'] : [], + notFound: outcome === 'notFound' ? ['background'] : [], + errors: [], + }; + let resolveDeletion!: (value: typeof result) => void; + workspaceClient.deleteSessionsData.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveDeletion = resolve; + }), + ); + render(); + act(() => click(rowActionButton(rows()[0]!, 'Delete'))); + expect(capture).not.toHaveBeenCalled(); + act(() => + click( + document.querySelector( + '[data-slot="alert-dialog-action"]', + )!, + ), + ); + expect(workspaceClient.deleteSessionsData).toHaveBeenCalledWith([ + 'background', + ]); + expect(capture).toHaveBeenCalledExactlyOnceWith('/w'); + expect(capture.mock.invocationCallOrder[0]).toBeLessThan( + workspaceClient.deleteSessionsData.mock.invocationCallOrder[0]!, + ); + expect(deleted).not.toHaveBeenCalled(); + + const nextDeleted = vi.fn(); + const nextCapture = vi.fn(() => nextDeleted); + generatedUiOwner = { + ...deletionOwner(nextCapture), + scopeId: 'replacement-owner', + workspaceCwd: '/replacement', + }; + rerender(); + await act(async () => resolveDeletion(result)); + await flushAsync(); + + expect(deleted).toHaveBeenCalledExactlyOnceWith(['background']); + expect(nextCapture).not.toHaveBeenCalled(); + expect(nextDeleted).not.toHaveBeenCalled(); + }, + ); + + it('cleans up only confirmed batch deletions in each owning workspace', async () => { + connectionState.capabilities = { + features: ['session_archive', 'workspace_qualified_rest_core'], + workspaceCwd: '/w', + workspaces: [ + { id: 'w0', cwd: '/w', primary: true, trusted: true }, + { id: 'w1', cwd: '/wsB', primary: false, trusted: true }, + ], + }; + sessionsState.sessions = [ + session('same', { displayName: 'Primary same' }), + session('missing', { displayName: 'Primary missing' }), + session('failed', { displayName: 'Primary failed' }), + ]; + otherWorkspaceSessions['/wsB'] = [ + session('same', { workspaceCwd: '/wsB', displayName: 'Secondary same' }), + session('other', { + workspaceCwd: '/wsB', + displayName: 'Secondary other', + }), + ]; + const primaryDeleted = vi.fn(); + const secondaryDeleted = vi.fn(); + const capture = vi.fn((cwd: string | undefined) => + cwd === '/w' ? primaryDeleted : secondaryDeleted, + ); + generatedUiOwner = deletionOwner(capture); + workspaceClient.deleteSessionsData.mockResolvedValueOnce({ + removed: ['same'], + notFound: ['missing'], + errors: [{ sessionId: 'failed', error: 'primary locked' }], + }); + const qualified = workspaceClient.workspaceByCwd('/wsB'); + qualified.deleteSessionsData.mockResolvedValueOnce({ + removed: ['other'], + notFound: [], + errors: [{ sessionId: 'same', error: 'secondary locked' }], + }); + workspaceClient.workspaceByCwd.mockReturnValue(qualified); + + render(); + await flushAsync(); + expect(rows()).toHaveLength(5); + act(() => click(selectAllCheckbox())); + act(() => click(footerButton('Delete')!)); + act(() => + click( + document.querySelector( + '[data-slot="alert-dialog-action"]', + )!, + ), + ); + await flushAsync(); + + expect(workspaceClient.deleteSessionsData).toHaveBeenCalledWith([ + 'same', + 'missing', + 'failed', + ]); + expect(qualified.deleteSessionsData).toHaveBeenCalledWith([ + 'same', + 'other', + ]); + expect(capture.mock.calls).toEqual([['/w'], ['/wsB']]); + expect(primaryDeleted).toHaveBeenCalledExactlyOnceWith(['same', 'missing']); + expect(secondaryDeleted).toHaveBeenCalledExactlyOnceWith(['other']); + expect(container!.textContent).toContain( + 'Failed to delete session: primary locked', + ); + }); + + it('keeps retained sessions when the overview delete request rejects', async () => { + sessionsState.sessions = [session('background', { displayName: 'Old' })]; + const deleted = vi.fn(); + const capture = vi.fn(() => deleted); + generatedUiOwner = deletionOwner(capture); + workspaceClient.deleteSessionsData.mockRejectedValueOnce( + new Error('daemon unavailable'), + ); + render(); + act(() => click(rowActionButton(rows()[0]!, 'Delete'))); + act(() => + click( + document.querySelector( + '[data-slot="alert-dialog-action"]', + )!, + ), + ); + await flushAsync(); + + expect(capture).toHaveBeenCalledExactlyOnceWith('/w'); + expect(deleted).not.toHaveBeenCalled(); + expect(container!.textContent).toContain( + 'Failed to delete session: daemon unavailable', + ); + }); + it('batch-archives the selected sessions', async () => { workspaceClient.archiveSessionsData.mockResolvedValueOnce({ archived: ['a'], diff --git a/packages/web-shell/client/components/SessionOverviewPanel.tsx b/packages/web-shell/client/components/SessionOverviewPanel.tsx index a73b84a7088..c9c337cf4f5 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.tsx @@ -6,6 +6,7 @@ import { useCallback, + useContext, useEffect, useId, useMemo, @@ -50,6 +51,7 @@ import { useReactTable, } from '@tanstack/react-table'; import { useI18n } from '../i18n'; +import { McpAppGeneratedUiOwnerContext } from '../mcpAppHostContext'; import { SessionPrBadge } from './SessionPrBadge'; import { formatRelativeTime } from '../utils/formatRelativeTime'; import { buildSplitUrl, MAX_SPLIT_PANES } from '../utils/splitUrl'; @@ -290,6 +292,7 @@ function SessionOverviewPanelInner({ manageLiveState: boolean; }) { const { t } = useI18n(); + const generatedUiOwner = useContext(McpAppGeneratedUiOwnerContext); const connection = useConnection(); const connectionRef = useRef(connection); connectionRef.current = connection; @@ -745,7 +748,10 @@ function SessionOverviewPanelInner({ : undefined; } } else { + const invalidateDeleted = + generatedUiOwner?.captureSessionDeletion(ownerCwd); const result = await client.deleteSessionsData(ids); + invalidateDeleted?.([...result.removed, ...result.notFound]); for (const id of [...result.removed, ...result.notFound]) { const card = cardsById.get(id); if (card) succeededIdentities.add(getSessionIdentity(card)); @@ -768,6 +774,7 @@ function SessionOverviewPanelInner({ [ canArchiveCard, canDeleteCard, + generatedUiOwner, primaryCwd, sessionCatalogController, t, diff --git a/packages/web-shell/client/components/messages/McpApp.tsx b/packages/web-shell/client/components/messages/McpApp.tsx index 02b5cb64d19..f7f1002353e 100644 --- a/packages/web-shell/client/components/messages/McpApp.tsx +++ b/packages/web-shell/client/components/messages/McpApp.tsx @@ -6,13 +6,22 @@ import { import { McpAppHostContext, useMcpAppAdmission, + isBuiltInGeneratedUi, McpAppMessagesContext, McpAppSessionContext, McpAppToolsContext, + McpAppGeneratedUiOwnerContext, + McpAppVisibilityContext, } from '../../mcpAppHostContext'; import { useI18n } from '../../i18n'; import { useTheme } from '../../themeContext'; import styles from './McpApp.module.css'; +import { + GeneratedUiSlot, + useGeneratedUiRetention, +} from '../../generated-ui/GeneratedUiRuntimeProvider'; +import { Button } from '../ui/button'; +import type { GeneratedUiInstance } from '../../generated-ui/instance-store'; type SandboxResource = Parameters[0]; type AppToolResult = Parameters[0]; @@ -172,8 +181,34 @@ export function GeneratedUiFallback({ display }: { display: McpAppDisplay }) { ); } -export function McpApp({ display }: { display: McpAppDisplay }) { +export function McpApp({ + display, + toolId, + visible = true, +}: { + display: McpAppDisplay; + toolId?: string; + visible?: boolean; +}) { const allowed = useMcpAppAdmission(display); + const retention = useGeneratedUiRetention(); + const owner = useContext(McpAppGeneratedUiOwnerContext)?.source; + const sessionId = useContext(McpAppSessionContext); + const surfaceVisible = useContext(McpAppVisibilityContext); + if ( + import.meta.env.MODE !== 'transcript' && + allowed && + retention && + toolId && + isBuiltInGeneratedUi(display) && + owner?.eventEpoch && + owner.sessionId === sessionId + ) + return surfaceVisible ? ( + + ) : ( + + ); return allowed ? ( ) : ( @@ -181,7 +216,14 @@ export function McpApp({ display }: { display: McpAppDisplay }) { ); } -function McpAppRuntime({ display }: { display: McpAppDisplay }) { +export function McpAppRuntime({ + display, + retained, +}: { + display: McpAppDisplay; + retained?: GeneratedUiInstance['runtime']; +}) { + const { t } = useI18n(); const daemonBaseUrl = useContext(McpAppHostContext); const sessionId = useContext(McpAppSessionContext); const tools = useContext(McpAppToolsContext); @@ -245,7 +287,11 @@ function McpAppRuntime({ display }: { display: McpAppDisplay }) { let messagePending = false; if (requestMessage) { bridge.onmessage = async (params, extra) => { - const signal = AbortSignal.any([extra.signal, appAbort.signal]); + const signal = AbortSignal.any([ + extra.signal, + appAbort.signal, + ...(retained ? [retained.getSignal()] : []), + ]); const content = params.content; if ( signal.aborted || @@ -276,7 +322,11 @@ function McpAppRuntime({ display }: { display: McpAppDisplay }) { } if (callTool) { bridge.oncalltool = async (params, extra) => { - const signal = AbortSignal.any([extra.signal, appAbort.signal]); + const signal = AbortSignal.any([ + extra.signal, + appAbort.signal, + ...(retained ? [retained.getSignal()] : []), + ]); let progress = 0; const progressToken = params._meta?.progressToken; // ext-apps omits the base MCP progress notification from its union. @@ -368,7 +418,8 @@ function McpAppRuntime({ display }: { display: McpAppDisplay }) { bridge.onsizechange = ({ height: requestedHeight }) => { if ( typeof requestedHeight === 'number' && - Number.isFinite(requestedHeight) + Number.isFinite(requestedHeight) && + (!retained || (retained.isVisible() && requestedHeight > 0)) ) { setHeight(Math.min(640, Math.max(120, Math.ceil(requestedHeight)))); } @@ -423,6 +474,7 @@ function McpAppRuntime({ display }: { display: McpAppDisplay }) { cspKey, toolArgumentsKey, toolResultKey, + retained, ]); useEffect(() => { @@ -438,7 +490,22 @@ function McpAppRuntime({ display }: { display: McpAppDisplay }) {
MCP App {display.serverName} + {retained && ( + + )}
+ {retained?.reopened && ( +
+ {t('mcpApp.generatedUi.reopenNotice')} +
+ )} {error ? (
{display.fallbackText || 'MCP App could not initialize.'} diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx index 244d3d521f0..fa73b1e8d2d 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.tsx @@ -1018,6 +1018,7 @@ function areToolLinePropsEqual( const b = next.tool; return ( a.callId === b.callId && + a.generatedUiToolCallId === b.generatedUiToolCallId && a.toolName === b.toolName && a.status === b.status && a.subagentSessionReady === b.subagentSessionReady && @@ -1048,6 +1049,7 @@ function areSubToolsEqual( const b = next[i]; if ( a.callId !== b.callId || + a.generatedUiToolCallId !== b.generatedUiToolCallId || a.toolName !== b.toolName || a.status !== b.status || a.subagentSessionReady !== b.subagentSessionReady || @@ -1540,7 +1542,11 @@ export const ToolLine = memo(function ToolLine({ {mcpApp && (!summaryOnly || expanded) && (mcpAppAllowed ? ( - + ) : ( ))} diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.current-session-delete.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.current-session-delete.test.tsx index edbbddcd24b..bdc48a800f6 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.current-session-delete.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.current-session-delete.test.tsx @@ -10,6 +10,10 @@ import * as React from 'react'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import type { DaemonSessionSummary } from '@qwen-code/sdk/daemon'; +import { + McpAppGeneratedUiOwnerContext, + type McpAppGeneratedUiOwner, +} from '../../mcpAppHostContext'; import { clickSidebarElement as click, flushSidebar, @@ -50,7 +54,7 @@ const { connection, workspace, workspaceActions, active, pinned, archived } = | undefined, client: { searchWorkspaceSessions: vi.fn().mockResolvedValue({ results: [] }), - workspaceByCwd: vi.fn(() => ({ + workspaceByCwd: vi.fn((_cwd: string) => ({ listWorkspaceSessions: vi.fn().mockResolvedValue([]), listSessionGroups: vi.fn().mockResolvedValue({ groups: [], @@ -168,6 +172,9 @@ installSidebarDomShims(); let root: Root; let container: HTMLDivElement; +let generatedUiOwner: McpAppGeneratedUiOwner | undefined; +const defaultWorkspaceByCwd = + workspace.client.workspaceByCwd.getMockImplementation()!; function renderSidebar( props: { @@ -179,33 +186,39 @@ function renderSidebar( typeof WebShellSidebar >['managedArchivedSessions']; onSearchQueryChange?: (query: string) => void; + lockedWorkspaceCwd?: string; + workspaces?: React.ComponentProps['workspaces']; } = {}, ): void { const onError = props.onError ?? (() => {}); act(() => { root.render( - {}} - onOpenSettings={() => {}} - onOpenDaemonStatus={() => {}} - onOpenScheduledTasks={() => {}} - onOpenGoals={() => {}} - onOpenSessions={() => {}} - onOpenSplitView={() => {}} - onNewSession={() => false} - onLoadSession={loadSession} - onError={onError} - onSessionsDeleted={props.onSessionsDeleted} - currentSessionActive={props.currentSessionActive} - managedWorkspaces={props.managedWorkspaces} - managedArchivedSessions={props.managedArchivedSessions} - onSearchQueryChange={props.onSearchQueryChange} - // Surface delete as an inline hover button so the row's disabled - // state is directly assertable without opening the dropdown. - sessionActions={{ inlineItems: ['delete'] }} - /> + + {}} + onOpenSettings={() => {}} + onOpenDaemonStatus={() => {}} + onOpenScheduledTasks={() => {}} + onOpenGoals={() => {}} + onOpenSessions={() => {}} + onOpenSplitView={() => {}} + onNewSession={() => false} + onLoadSession={loadSession} + onError={onError} + onSessionsDeleted={props.onSessionsDeleted} + currentSessionActive={props.currentSessionActive} + managedWorkspaces={props.managedWorkspaces} + managedArchivedSessions={props.managedArchivedSessions} + onSearchQueryChange={props.onSearchQueryChange} + lockedWorkspaceCwd={props.lockedWorkspaceCwd} + workspaces={props.workspaces} + // Surface delete as an inline hover button so the row's disabled + // state is directly assertable without opening the dropdown. + sessionActions={{ inlineItems: ['delete'] }} + /> + , ); }); @@ -237,6 +250,64 @@ function confirmDeleteButton(): HTMLButtonElement | undefined { ); } +function deletionOwner( + captureSessionDeletion: McpAppGeneratedUiOwner['captureSessionDeletion'], +): McpAppGeneratedUiOwner { + return { + scopeId: 'sidebar-owner', + workspaceCwd: '/tmp/project', + subscribeInvalidation: () => () => {}, + invalidate: vi.fn(), + captureSessionDeletion, + }; +} + +function secondaryDeletion(locked: boolean) { + connection.capabilities = { + qwenCodeVersion: '1.2.3', + features: ['multi_workspace_sessions', 'workspace_qualified_rest_core'], + }; + workspace.capabilities = connection.capabilities; + active.sessions = [makeSession('same', { displayName: 'Primary same ID' })]; + active.data = active.sessions; + const secondary = makeSession('same', { + workspaceCwd: '/tmp/other', + displayName: 'Secondary same ID', + }); + const deleteSessionsData = vi.fn().mockResolvedValue({ + removed: ['same'], + notFound: [], + errors: [], + }); + workspace.client.workspaceByCwd.mockImplementation((cwd: string) => ({ + ...defaultWorkspaceByCwd(cwd), + listWorkspaceSessions: vi.fn().mockResolvedValue([secondary]), + deleteSessionsData, + })); + return { + deleteSessionsData, + props: { + lockedWorkspaceCwd: locked ? '/tmp/other' : undefined, + workspaces: [ + { id: 'primary', cwd: '/tmp/project', primary: true, trusted: true }, + { id: 'secondary', cwd: '/tmp/other', primary: false, trusted: true }, + ], + }, + }; +} + +async function expandSecondaryWorkspace(): Promise { + await flushSidebar(); + const button = Array.from( + container.querySelectorAll('button[aria-expanded]'), + ).find((candidate) => candidate.textContent?.includes('other')); + expect(button).toBeDefined(); + if (button!.getAttribute('aria-expanded') !== 'true') { + act(() => click(button!)); + } + await flushSidebar(); +} + beforeEach(() => { window.localStorage.clear(); container = document.createElement('div'); @@ -246,6 +317,10 @@ beforeEach(() => { connection.workspaceCwd = '/tmp/project'; connection.capabilities = undefined; workspace.capabilities = undefined; + workspace.client.workspaceByCwd + .mockReset() + .mockImplementation(defaultWorkspaceByCwd); + generatedUiOwner = undefined; active.sessions = []; active.data = active.sessions; active.deleteSession.mockClear(); @@ -489,3 +564,129 @@ describe('WebShellSidebar current-session delete (issue #12619)', () => { }); }); }); + +describe('WebShellSidebar Generated UI deletion cleanup', () => { + it.each([ + { locked: false, outcome: 'removed' }, + { locked: false, outcome: 'notFound' }, + { locked: true, outcome: 'removed' }, + { locked: true, outcome: 'notFound' }, + ])( + 'cleans up a $outcome secondary session in its own workspace (locked: $locked)', + async ({ locked, outcome }) => { + const { deleteSessionsData, props } = secondaryDeletion(locked); + deleteSessionsData.mockResolvedValue({ + removed: outcome === 'removed' ? ['same'] : [], + notFound: outcome === 'notFound' ? ['same'] : [], + errors: [], + }); + const deleted = vi.fn(); + const capture = vi.fn(() => deleted); + generatedUiOwner = deletionOwner(capture); + const onSessionsDeleted = vi.fn(); + renderSidebar({ ...props, onSessionsDeleted }); + await expandSecondaryWorkspace(); + + act(() => click(findSessionDeleteButton('Secondary same ID'))); + await flushSidebar(); + expect(capture).not.toHaveBeenCalled(); + act(() => click(confirmDeleteButton()!)); + await flushSidebar(); + + expect(capture).toHaveBeenCalledExactlyOnceWith('/tmp/other'); + expect(capture.mock.invocationCallOrder[0]).toBeLessThan( + deleteSessionsData.mock.invocationCallOrder[0]!, + ); + expect(deleteSessionsData).toHaveBeenCalledExactlyOnceWith(['same']); + expect(deleted).toHaveBeenCalledExactlyOnceWith(['same']); + expect(active.deleteSession).not.toHaveBeenCalled(); + expect(onSessionsDeleted).toHaveBeenCalledWith(['same'], { + attachedSessionId: undefined, + }); + if (!locked) { + expect(container.textContent).toContain('Primary same ID'); + } + }, + ); + + it.each([false, true])( + 'uses the captured owner when a delayed secondary delete finishes (locked: %s)', + async (locked) => { + const { deleteSessionsData, props } = secondaryDeletion(locked); + const deleted = vi.fn(); + const capture = vi.fn(() => deleted); + generatedUiOwner = deletionOwner(capture); + const result = { removed: ['same'], notFound: [], errors: [] }; + let resolveDeletion!: (value: typeof result) => void; + deleteSessionsData.mockReturnValue( + new Promise((resolve) => { + resolveDeletion = resolve; + }), + ); + renderSidebar(props); + await expandSecondaryWorkspace(); + act(() => click(findSessionDeleteButton('Secondary same ID'))); + await flushSidebar(); + act(() => click(confirmDeleteButton()!)); + expect(capture).toHaveBeenCalledExactlyOnceWith('/tmp/other'); + expect(deleteSessionsData).toHaveBeenCalledExactlyOnceWith(['same']); + expect(deleted).not.toHaveBeenCalled(); + + const nextDeleted = vi.fn(); + const nextCapture = vi.fn(() => nextDeleted); + generatedUiOwner = { + ...deletionOwner(nextCapture), + scopeId: 'replacement-owner', + workspaceCwd: '/replacement', + }; + renderSidebar(props); + await act(async () => resolveDeletion(result)); + await flushSidebar(); + + expect(deleted).toHaveBeenCalledExactlyOnceWith(['same']); + expect(nextCapture).not.toHaveBeenCalled(); + expect(nextDeleted).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { locked: false, failure: 'item error' }, + { locked: false, failure: 'request rejection' }, + { locked: true, failure: 'item error' }, + { locked: true, failure: 'request rejection' }, + ])( + 'keeps retained secondary sessions after $failure (locked: $locked)', + async ({ locked, failure }) => { + const { deleteSessionsData, props } = secondaryDeletion(locked); + if (failure === 'item error') { + deleteSessionsData.mockResolvedValue({ + removed: [], + notFound: [], + errors: [{ sessionId: 'same', error: 'daemon denied delete' }], + }); + } else { + deleteSessionsData.mockRejectedValue(new Error('daemon denied delete')); + } + const deleted = vi.fn<(sessionIds: readonly string[]) => void>(); + const capture = vi.fn(() => deleted); + generatedUiOwner = deletionOwner(capture); + const onError = vi.fn(); + const onSessionsDeleted = vi.fn(); + renderSidebar({ ...props, onError, onSessionsDeleted }); + await expandSecondaryWorkspace(); + act(() => click(findSessionDeleteButton('Secondary same ID'))); + await flushSidebar(); + act(() => click(confirmDeleteButton()!)); + await flushSidebar(); + + expect(capture).toHaveBeenCalledExactlyOnceWith('/tmp/other'); + expect(deleted.mock.calls.flatMap(([ids]) => ids)).toEqual([]); + expect(active.deleteSession).not.toHaveBeenCalled(); + expect(onSessionsDeleted).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'daemon denied delete' }), + 'Failed to delete session', + ); + }, + ); +}); diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index 32f8d83d161..25e50285353 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -1,6 +1,7 @@ import { Fragment, useCallback, + useContext, useEffect, useMemo, useRef, @@ -74,6 +75,7 @@ import { import { WebShellThemeId, type WebShellTheme } from '../../themeContext'; import { useBrand, useBrandName } from '../../brandContext'; import { useI18n } from '../../i18n'; +import { McpAppGeneratedUiOwnerContext } from '../../mcpAppHostContext'; import { Input } from '../ui/input'; import { Button } from '../ui/button'; import { Empty, EmptyMedia, EmptyDescription } from '../ui/empty'; @@ -1217,6 +1219,7 @@ export function WebShellSidebar({ const actions = useActions(); const workspaceActions = useWorkspaceActions(); const workspace = useWorkspace(); + const generatedUiOwner = useContext(McpAppGeneratedUiOwnerContext); // Fixed per-origin rank for host groups (#13727): every connected host, // including the focused one, occupies its stable position regardless of // focus, so switching hosts never reshuffles the sidebar. @@ -3591,9 +3594,13 @@ export function WebShellSidebar({ const removeSession = scope.kind === 'locked' || scope.kind === 'restricted' ? async (id: string) => { + const invalidateDeleted = generatedUiOwner?.captureSessionDeletion( + scope.cwd, + ); const result = await workspace.client .workspaceByCwd(scope.cwd) .deleteSessionsData([id]); + invalidateDeleted?.([...result.removed, ...result.notFound]); const itemError = result.errors.find( (entry) => entry.sessionId === id, ); @@ -3628,6 +3635,7 @@ export function WebShellSidebar({ deleteArchivedSession, deleteCandidate, deleteSession, + generatedUiOwner, getIdentityForSession, isCurrentSession, onError, diff --git a/packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx b/packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx index c2905e0ae8f..2d9483f8c0d 100644 --- a/packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx @@ -21,10 +21,19 @@ import { import { McpAppToolsContext, McpAppGeneratedUiContext, + McpAppGeneratedUiOwnerContext, + type McpAppGeneratedUiInvalidation, } from '../../mcpAppHostContext.js'; import { BrowserTurnNotifications } from '../../browser-turn-notifications'; import { I18nProvider } from '../../i18n.js'; import { SessionRecoveryBanner } from '../../components/SessionRecoveryBanner.js'; +import type { McpAppDisplay } from '../../components/messages/McpApp.js'; +import { + GeneratedUiInstanceStore, + instanceKey, + resourceKey, + type InstanceSlot, +} from '../../generated-ui/instance-store.js'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { @@ -128,6 +137,9 @@ interface MockSession { state: Record; recovery?: NonNullable['recovery']; }>; + subscribeGeneratedUiRevocation?: ( + listener: (revision: number) => void, + ) => () => void; supportedCommands: () => Promise<{ v: 1; sessionId: string; @@ -18343,6 +18355,1807 @@ describe('DaemonSessionProvider', () => { } }); + describe('Generated UI retained owner lifecycle', () => { + describe('restore failures', () => { + const providerProps = { + baseUrl: 'http://127.0.0.1:4170', + token: 'token-a', + workspaceCwd: '/mock-workspace', + sessionId: 'session-a', + autoConnect: true, + autoReconnect: false, + }; + const display: McpAppDisplay = { + type: 'mcp_app', + requiredFeature: 'generated-ui', + serverName: 'qwen-code-generated-ui', + resourceUri: 'ui://qwen-code-generated-ui/view-v1.html', + html: '
Retained parameters
', + toolResult: { content: [{ type: 'text', text: 'Ready' }] }, + fallbackText: 'Retained parameters', + }; + let retained: GeneratedUiInstanceStore; + let parking: HTMLDivElement; + let slotElement: HTMLDivElement; + let probes: Record< + string, + { + owner: ContextType; + actions: DaemonSessionActions; + connection: DaemonConnectionState; + receipt: ContextType; + } + >; + + beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + retained = new GeneratedUiInstanceStore(); + parking = document.createElement('div'); + parking.hidden = true; + slotElement = document.createElement('div'); + document.body.append(parking, slotElement); + retained.parking = parking; + probes = {}; + const load = async ( + client: unknown, + sessionId: string, + request?: unknown, + ) => { + const session = createMockSession({ + sessionId, + workspaceCwd: (request as { workspaceCwd?: string } | undefined) + ?.workspaceCwd, + session: { generatedUi: { version: 1, enabled: true } }, + }); + session.client = client as MockClient; + return session; + }; + sdkMocks.MockDaemonSessionClient.load.mockImplementation(load); + sdkMocks.MockDaemonSessionClient.resume.mockImplementation(load); + }); + + afterEach(() => { + retained.clear(); + parking.remove(); + slotElement.remove(); + }); + + function Probe({ + name, + children, + }: { + name: string; + children?: ReactNode; + }) { + const owner = useContext(McpAppGeneratedUiOwnerContext); + probes[name] = { + owner, + actions: useDaemonActions(), + connection: useDaemonConnection(), + receipt: useContext(McpAppGeneratedUiContext), + }; + const subscribe = owner?.subscribeInvalidation; + useLayoutEffect( + () => + name === 'main' ? subscribe?.(retained.invalidate) : undefined, + [name, subscribe], + ); + return children ?? null; + } + + function retainedSlot(name: string): InstanceSlot { + const source = requireActions(probes[name]?.owner?.source); + return { + key: instanceKey(source, 'call-a', display), + resourceKey: resourceKey(display, source.eventEpoch), + display, + source, + receipt: requireActions(probes[name].receipt), + baseUrl: providerProps.baseUrl, + token: {}, + element: slotElement, + visible: true, + reset: vi.fn(), + }; + } + + function park(name: string) { + const slot = retainedSlot(name); + const entry = requireActions(retained.attach(slot)); + entry.container.dataset.dirtyInput = '9'; + retained.release(slot.key, slot.token); + expect(entry.container.parentElement).toBe(parking); + return entry; + } + + async function switchSession( + name: string, + sessionId: string, + mode: 'load' | 'resume' = 'load', + options?: Parameters[1], + ) { + let pending!: Promise; + act(() => { + const actions = requireActions(probes[name]).actions; + pending = ( + mode === 'resume' + ? actions.resumeSession(sessionId, options) + : actions.loadSession(sessionId, options) + ).catch((error: unknown) => error); + }); + let result: unknown; + await act(async () => { + result = await pending; + await flushPromises(); + }); + return result; + } + + async function useSdkContextReader( + sessionContext: MockSession['context'], + enabled = true, + ) { + const sdk = await vi.importActual< + typeof import('@qwen-code/sdk/daemon') + >('@qwen-code/sdk/daemon'); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + async (client, sessionId) => { + Object.assign(client as MockClient, { sessionContext }); + const reader = new sdk.DaemonSessionClient({ + client: client as InstanceType, + session: { + sessionId, + workspaceCwd: providerProps.workspaceCwd, + clientId: 'client-1', + generatedUi: { version: 1, enabled }, + }, + }); + const session = createMockSession({ + sessionId, + workspaceCwd: providerProps.workspaceCwd, + session: reader.session as unknown as Record, + context: vi.fn(() => reader.context()), + subscribeGeneratedUiRevocation: + reader.subscribeGeneratedUiRevocation?.bind(reader), + }); + session.client = client as MockClient; + return session; + }, + ); + } + + it.each(['action', 'controlled'] as const)( + 'retains a card across %s empty-session navigation', + async (mode) => { + await renderWithProvider(, providerProps); + const slot = retainedSlot('main'); + const entry = requireActions(retained.attach(slot)); + entry.container.dataset.dirtyInput = '9'; + const signal = entry.runtime.getSignal(); + if (mode === 'action') { + const detached = createDeferred(); + const session = + await sdkMocks.MockDaemonSessionClient.load.mock.results[0].value; + session.detach = vi.fn(() => detached.promise); + let clearing!: Promise; + act(() => { + clearing = probes.main.actions.clearSession(); + }); + expect(signal.aborted).toBe(true); + expect(entry.container.parentElement).toBe(parking); + detached.resolve(); + await act(async () => { + await clearing; + await flushPromises(); + }); + } else { + await act(async () => { + root!.render( + + + , + ); + await flushPromises(); + }); + } + expect(signal.aborted).toBe(true); + expect(probes.main.owner?.source).toBeUndefined(); + expect(retained.getEntries()).toEqual([entry]); + expect( + await entry.messages.requestMessage({ + serverName: display.serverName, + resourceUri: display.resourceUri, + text: 'Hidden request', + signal: new AbortController().signal, + }), + ).toEqual({ isError: true }); + if (mode === 'controlled') { + await act(async () => { + root!.render( + + + , + ); + await flushPromises(); + }); + } else { + expect(await switchSession('main', 'session-a')).toBeUndefined(); + } + expect(retained.attach(retainedSlot('main'))).toBe(entry); + expect(entry.container.dataset.dirtyInput).toBe('9'); + expect(entry.runtime.getSignal().aborted).toBe(false); + }, + ); + + it('releases a parked card on a disabled load receipt and requires reopening after re-enable', async () => { + await renderWithProvider(, providerProps); + const entry = park('main'); + await switchSession('main', 'session-b'); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + async (client) => { + const session = createMockSession({ + sessionId: 'session-a', + session: { generatedUi: { version: 1, enabled: false } }, + }); + session.client = client as MockClient; + return session; + }, + ); + await switchSession('main', 'session-a'); + expect(retained.getEntries()).toEqual([]); + expect(entry.container.isConnected).toBe(false); + expect(retained.getRecord(entry.key)?.reason).toBe('policy'); + await switchSession('main', 'session-b'); + await switchSession('main', 'session-a'); + const returned = retainedSlot('main'); + expect(retained.attach(returned)).toBeUndefined(); + expect(retained.attach(returned, true)?.runtime.reopened).toBe(true); + }); + + it('cannot regrant authority while clearing waits for persisted reasoning', async () => { + await renderWithProvider(, providerProps); + const session: MockSession = + await sdkMocks.MockDaemonSessionClient.load.mock.results[0].value; + const persisted = createDeferred<{ + configOptions: ReturnType; + persisted: boolean; + }>(); + session.setConfigOption = vi.fn(() => persisted.promise); + const slot = retainedSlot('main'); + const entry = requireActions(retained.attach(slot)); + const signal = entry.runtime.getSignal(); + const captured = slot.source.captureOwner(); + let reasoning!: Promise; + let clearing!: Promise; + act(() => { + reasoning = probes.main.actions.setReasoningEffort('medium', { + persist: true, + }); + clearing = probes.main.actions.clearSession(); + }); + expect(signal.aborted).toBe(true); + expect(entry.container.parentElement).toBe(parking); + expect(captured.isCurrent()).toBe(false); + expect(slot.source.captureOwner().isCurrent()).toBe(false); + await act(async () => { + persisted.resolve({ + configOptions: reasoningConfigOptions('medium'), + persisted: true, + }); + await reasoning; + await clearing; + await flushPromises(); + }); + expect(retained.getEntries()).toEqual([entry]); + await switchSession('main', 'session-a'); + expect(retained.attach(retainedSlot('main'))).toBe(entry); + }); + + it.each(['separate commits', 'one commit'])( + 'observes disabled SDK receipts followed by enabled receipts in %s', + async (commits) => { + let enabled: boolean | undefined = true; + const sessionContext = vi.fn(async () => ({ + v: 1 as const, + sessionId: 'session-a', + workspaceCwd: providerProps.workspaceCwd, + state: { + _meta: + enabled === undefined + ? {} + : { generatedUi: { version: 1, enabled } }, + }, + })); + await useSdkContextReader(sessionContext); + await renderWithProvider(, providerProps); + const entry = park('main'); + const scopeId = probes.main.owner!.scopeId; + const session: MockSession = + await sdkMocks.MockDaemonSessionClient.load.mock.results[0].value; + enabled = undefined; + await act(async () => { + await probes.main.actions.getContext(); + }); + expect(retained.getEntries()).toEqual([entry]); + sessionContext.mockRejectedValueOnce( + new DaemonHttpError(503, undefined, 'Context unavailable'), + ); + await act(async () => { + await expect(probes.main.actions.getContext()).rejects.toThrow( + 'Context unavailable', + ); + }); + expect(retained.getEntries()).toEqual([entry]); + enabled = false; + await act(async () => { + await probes.main.actions.getContext(); + expect(session.session!['generatedUi']).toMatchObject({ + enabled: false, + }); + if (commits === 'one commit') { + expect(retained.getEntries()).toEqual([]); + enabled = true; + await probes.main.actions.getContext(); + } + }); + expect(sessionContext).toHaveBeenCalled(); + expect(session.session!['generatedUi']).toMatchObject({ + enabled: commits === 'one commit', + }); + expect(retained.getEntries()).toEqual([]); + expect(entry.container.isConnected).toBe(false); + expect(retained.getRecord(entry.key)?.reason).toBe('policy'); + if (commits === 'separate commits') { + enabled = true; + await act(async () => { + await probes.main.actions.getContext(); + }); + } + expect(probes.main.owner!.scopeId).toBe(scopeId); + const returned = retainedSlot('main'); + expect(retained.attach(returned)).toBeUndefined(); + const reopened = retained.attach(returned, true); + expect(reopened?.runtime.reopened).toBe(true); + expect(reopened).not.toBe(entry); + }, + ); + + it('observes a disabled SDK load receipt before an enabled metadata read', async () => { + await renderWithProvider(, providerProps); + const entry = park('main'); + const scopeId = probes.main.owner!.scopeId; + await switchSession('main', 'session-b'); + await useSdkContextReader( + async () => ({ + v: 1, + sessionId: 'session-a', + workspaceCwd: providerProps.workspaceCwd, + state: { _meta: { generatedUi: { version: 1, enabled: true } } }, + }), + false, + ); + await switchSession('main', 'session-a'); + expect(probes.main.receipt?.status.enabled).toBe(true); + expect(probes.main.owner!.scopeId).toBe(scopeId); + expect(retained.getEntries()).toEqual([]); + expect(entry.container.isConnected).toBe(false); + expect(retained.getRecord(entry.key)?.reason).toBe('policy'); + const returned = retainedSlot('main'); + expect(retained.attach(returned)).toBeUndefined(); + expect(retained.attach(returned, true)?.runtime.reopened).toBe(true); + }); + + it.each(['/tmp', '/private/tmp'])( + 'keeps a canonical owner after action creation commits before attachment from %s', + async (requested) => { + const session = createMockSession({ + sessionId: 'created-session', + workspaceCwd: '/private/tmp', + session: { generatedUi: { version: 1, enabled: true } }, + }); + sdkMocks.MockDaemonSessionClient.createOrAttach.mockImplementationOnce( + async (client) => { + session.client = client as MockClient; + return session; + }, + ); + await renderWithProvider(, { + ...providerProps, + sessionId: undefined, + workspaceCwd: requested, + }); + await act(async () => { + await probes.main.actions.createSession({ + workspaceCwd: requested, + }); + await flushPromises(); + }); + expect(probes.main.connection).toMatchObject({ + status: 'connected', + sessionId: session.sessionId, + workspaceCwd: '/private/tmp', + }); + + let attaching!: Promise; + act(() => { + attaching = probes.main.actions.attachSession(); + }); + await act(async () => { + await attaching; + await flushPromises(); + }); + expect(probes.main.owner?.source).toBeDefined(); + expect(probes.main.owner?.workspaceCwd).toBe('/private/tmp'); + const entry = park('main'); + const scopeId = probes.main.owner!.scopeId; + await act(async () => { + await probes.main.actions.submitPrompt('first prompt'); + await flushPromises(); + }); + expect(session.submitPrompt).toHaveBeenCalledTimes(1); + expect(probes.main.owner?.scopeId).toBe(scopeId); + expect(probes.main.owner?.source?.captureOwner().isCurrent()).toBe( + true, + ); + expect(retained.attach(retainedSlot('main'))).toBe(entry); + expect(entry.container.dataset.dirtyInput).toBe('9'); + expect(entry.runtime.reopened).toBe(false); + expect( + sdkMocks.MockDaemonSessionClient.createOrAttach, + ).toHaveBeenCalledTimes(1); + expect(sdkMocks.MockDaemonSessionClient.load).not.toHaveBeenCalled(); + }, + ); + + it.each(['/tmp', '/private/tmp'])( + 'keeps canonical scope through reconnect and detachment when requesting %s', + async (requested) => { + const disconnected = createDeferred(); + let subscriptions = 0; + sdkMocks.MockDaemonSessionClient.load.mockImplementation( + async (client) => { + const session = createMockSession({ + sessionId: 'session-a', + workspaceCwd: '/private/tmp', + session: { generatedUi: { version: 1, enabled: true } }, + async *events(opts) { + subscriptions++; + if (subscriptions === 1) { + await disconnected.promise; + throw new Error('Transient SSE disconnect'); + } + yield* createIdleEvents()(opts); + }, + }); + session.client = client as MockClient; + return session; + }, + ); + await renderWithProvider(, { + ...providerProps, + workspaceCwd: requested, + autoReconnect: true, + reconnectDelayMs: 10, + maxReconnectDelayMs: 10, + }); + const entry = park('main'); + const scope = probes.main.owner!.scopeId; + await act(async () => { + disconnected.resolve(); + await flushPromises(); + }); + expect(probes.main.owner!.scopeId).toBe(scope); + await act(async () => { + await wait(30); + await flushPromises(); + }); + expect(subscriptions).toBe(2); + expect(probes.main.connection.status).toBe('connected'); + expect(probes.main.owner!.scopeId).toBe(scope); + expect(retained.attach(retainedSlot('main'))).toBe(entry); + await act(async () => { + await probes.main.actions.clearSession(); + await flushPromises(); + }); + expect(probes.main.owner!.scopeId).toBe(scope); + expect(await switchSession('main', 'session-a')).toBeUndefined(); + expect(retained.attach(retainedSlot('main'))).toBe(entry); + expect(entry.container.dataset.dirtyInput).toBe('9'); + }, + ); + + it('targets the canonical scope after a second requested alias is confirmed', async () => { + sdkMocks.MockDaemonSessionClient.load.mockImplementation( + async (client, sessionId) => { + const session = createMockSession({ + sessionId, + workspaceCwd: '/private/tmp', + session: { generatedUi: { version: 1, enabled: true } }, + }); + session.client = client as MockClient; + return session; + }, + ); + await renderWithProvider(, { + ...providerProps, + workspaceCwd: '/tmp', + }); + const first = park('main'); + await switchSession('main', 'session-b', 'load', { + workspaceCwd: '/tmp/.', + }); + const second = park('main'); + expect(second.scopeId).toBe(first.scopeId); + const error = new DaemonHttpError(401, undefined, 'Unauthorized'); + sdkMocks.MockDaemonSessionClient.load.mockRejectedValueOnce(error); + expect( + await switchSession('main', 'session-a', 'load', { + workspaceCwd: '/tmp/.', + }), + ).toBe(error); + expect(retained.getEntries()).toEqual([]); + for (const entry of [first, second]) { + expect(entry.container.isConnected).toBe(false); + expect(retained.getRecord(entry.key)?.reason).toBe('auth-failed'); + } + }); + + it('preserves a canonical scope when controlled props catch up to a confirmed alias', async () => { + sdkMocks.MockDaemonSessionClient.load.mockImplementation( + async (client, sessionId) => { + const session = createMockSession({ + sessionId, + workspaceCwd: '/private/tmp', + session: { generatedUi: { version: 1, enabled: true } }, + }); + session.client = client as MockClient; + return session; + }, + ); + await renderWithProvider(, { + ...providerProps, + workspaceCwd: '/tmp', + }); + await switchSession('main', 'session-b', 'load', { + workspaceCwd: '/tmp/.', + }); + const entry = park('main'); + await act(async () => { + root!.render( + + + , + ); + await flushPromises(); + }); + expect(probes.main.owner!.scopeId).toBe(entry.scopeId); + expect(retained.attach(retainedSlot('main'))).toBe(entry); + expect(entry.container.dataset.dirtyInput).toBe('9'); + }); + + it.each([ + { boundary: 'workspace', props: { workspaceCwd: '/workspace-b' } }, + { boundary: 'host', props: { baseUrl: 'http://127.0.0.1:4171' } }, + { boundary: 'credentials', props: { token: 'token-b' } }, + ])( + 'retires a canonical scope before an unconfirmed $boundary change can reuse it', + async ({ props }) => { + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + async (client) => { + const session = createMockSession({ + sessionId: 'session-a', + workspaceCwd: '/private/tmp', + session: { generatedUi: { version: 1, enabled: true } }, + }); + session.client = client as MockClient; + return session; + }, + ); + await renderWithProvider(, { + ...providerProps, + workspaceCwd: '/tmp', + }); + const entry = park('main'); + const pending = createDeferred(); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + () => pending.promise, + ); + await act(async () => { + root!.render( + + + , + ); + await flushPromises(); + }); + expect(probes.main.owner!.scopeId).not.toBe(entry.scopeId); + expect(entry.container.isConnected).toBe(false); + await act(async () => { + pending.reject(new DaemonHttpError(401, undefined, 'Unauthorized')); + await flushPromises(); + }); + expect(probes.main.connection.errorStatus).toBe(401); + expect(retained.getRecord(entry.key)).toBeUndefined(); + }, + ); + + it.each([ + { path: 'stream', status: 401 }, + { path: 'stream', status: 403 }, + { path: 'stream', status: 404 }, + { path: 'heartbeat', status: 401 }, + { path: 'heartbeat', status: 403 }, + { path: 'heartbeat', status: 404 }, + ] as const)( + 'releases attached workspace instances after $path returns $status', + async ({ path, status }) => { + const failure = createDeferred(); + const heartbeat = vi.fn(async () => { + await failure.promise; + throw new DaemonHttpError(status, undefined, 'Access failed'); + }); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_heartbeat'], + }); + const children = ( + + + + + + ); + await renderWithProvider(children, providerProps); + const sibling = park('main'); + const foreign = park('foreign'); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + async (client) => { + const session = createMockSession({ + sessionId: 'session-b', + session: { generatedUi: { version: 1, enabled: true } }, + heartbeat: path === 'heartbeat' ? heartbeat : undefined, + async *events(opts) { + if (path === 'stream') { + await failure.promise; + throw new DaemonHttpError( + status, + undefined, + 'Access failed', + ); + } + yield* createIdleEvents()(opts); + }, + }); + session.client = client as MockClient; + return session; + }, + ); + await switchSession('main', 'session-b'); + const target = park('main'); + const slot = retainedSlot('main'); + if (path === 'heartbeat') { + await act(async () => { + root!.render( + + {children} + , + ); + await flushPromises(); + }); + await act(async () => { + await vi.waitFor(() => expect(heartbeat).toHaveBeenCalled()); + }); + } + await act(async () => { + failure.resolve(); + await flushPromises(); + await wait(5); + }); + const auth = status !== 404; + expect(target.container.isConnected).toBe(false); + expect(retained.getRecord(target.key)?.reason).toBe( + auth ? 'auth-failed' : 'deleted', + ); + expect(retained.getEntries().includes(sibling)).toBe(!auth); + expect(sibling.container.isConnected).toBe(!auth); + if (auth) + expect(retained.getRecord(sibling.key)?.reason).toBe('auth-failed'); + expect(retained.getEntries()).toContain(foreign); + expect(foreign.container.isConnected).toBe(true); + expect(await switchSession('main', 'session-b')).toBeUndefined(); + const returned = retainedSlot('main'); + expect(returned.key).toBe(slot.key); + expect(retained.attach(returned)).toBeUndefined(); + expect(retained.attach(returned, true)?.runtime.reopened).toBe(true); + }, + ); + + it.each([ + { mode: 'load', status: 404 }, + { mode: 'load', status: 410 }, + { mode: 'load', status: 401 }, + { mode: 'load', status: 403 }, + { mode: 'load', status: 503 }, + { mode: 'resume', status: 404 }, + { mode: 'resume', status: 401 }, + ] as const)( + 'releases the correct retained instances after $mode returns $status', + async ({ mode, status }) => { + await renderWithProvider( + + + + + , + providerProps, + ); + const target = park('main'); + const foreign = park('foreign'); + expect(target.sessionId).toBe(foreign.sessionId); + expect(target.scopeId).not.toBe(foreign.scopeId); + expect(await switchSession('main', 'session-c')).toBeUndefined(); + const sibling = park('main'); + expect(sibling.scopeId).toBe(target.scopeId); + expect(await switchSession('main', 'session-b')).toBeUndefined(); + expect(retained.getEntries()).toHaveLength(3); + + const error = new DaemonHttpError( + status, + undefined, + 'Restore failed', + ); + sdkMocks.MockDaemonSessionClient[mode].mockRejectedValueOnce(error); + expect(await switchSession('main', 'session-a', mode)).toBe(error); + expect(probes.main?.connection.errorStatus).toBe(status); + expect(probes.main?.owner?.scopeId).toBe(target.scopeId); + const authFailure = status === 401 || status === 403; + const temporaryFailure = status === 503; + expect(retained.getEntries().includes(target)).toBe(temporaryFailure); + expect(target.container.isConnected).toBe(temporaryFailure); + expect(retained.getEntries().includes(sibling)).toBe(!authFailure); + expect(sibling.container.isConnected).toBe(!authFailure); + expect(retained.getEntries()).toContain(foreign); + expect(foreign.container.isConnected).toBe(true); + expect(foreign.container.dataset.dirtyInput).toBe('9'); + if (!temporaryFailure) { + expect(retained.getRecord(target.key)?.reason).toBe( + authFailure ? 'auth-failed' : 'deleted', + ); + expect(await switchSession('main', 'session-a')).toBeUndefined(); + const returned = retainedSlot('main'); + expect(returned.key).toBe(target.key); + expect(retained.attach(returned)).toBeUndefined(); + expect(retained.attach(returned, true)?.runtime.reopened).toBe( + true, + ); + } + }, + ); + + it.each([ + { + boundary: 'matching ancestor', + baseUrl: providerProps.baseUrl, + token: 'token-a', + workspaceCwd: '/ancestor', + releases: true, + }, + { + boundary: 'different workspace', + baseUrl: providerProps.baseUrl, + token: 'token-a', + workspaceCwd: '/other', + releases: false, + }, + { + boundary: 'different daemon', + baseUrl: 'http://127.0.0.1:4171', + token: 'token-a', + workspaceCwd: '/ancestor', + releases: false, + }, + { + boundary: 'different credentials', + baseUrl: providerProps.baseUrl, + token: 'token-b', + workspaceCwd: '/ancestor', + releases: false, + }, + ])( + 'isolates retained ancestor instances on failed restore: $boundary', + async ({ baseUrl, token, workspaceCwd, releases }) => { + await renderWithProvider( + + + + + , + { ...providerProps, workspaceCwd: '/ancestor' }, + ); + const ancestor = park('main'); + const child = park('child'); + expect(child.scopeId).not.toBe(ancestor.scopeId); + const error = new DaemonHttpError(401, undefined, 'Unauthorized'); + sdkMocks.MockDaemonSessionClient.load.mockRejectedValueOnce(error); + expect( + await switchSession('child', 'session-a', 'load', { workspaceCwd }), + ).toBe(error); + expect(probes.child?.connection.errorStatus).toBe(401); + expect(retained.getEntries().includes(ancestor)).toBe(!releases); + expect(ancestor.container.isConnected).toBe(!releases); + expect(retained.getEntries()).toContain(child); + expect(child.container.isConnected).toBe(true); + }, + ); + + it.each(['standalone', 'live'] as const)( + 'keeps workspace instances when a %s restore fails authentication', + async (kind) => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/primary', + features: ['standalone_sessions_v1', 'multi_workspace_sessions'], + workspaces: [ + { + id: 'primary', + cwd: '/primary', + primary: true, + trusted: true, + }, + { + id: 'live', + cwd: '/mock-workspace', + kind: 'live', + primary: false, + trusted: true, + }, + ], + }); + sdkMocks.MockDaemonSessionClient.loadStandalone.mockImplementation( + async (client, sessionId) => { + const session = createMockSession({ + sessionId, + session: { + generatedUi: { version: 1, enabled: true }, + sourceType: 'standalone', + context: { kind: 'standalone' }, + projectlessOutputDirectory: '/output/non-workspace', + workingDirectory: { state: 'ready' }, + }, + }); + session.client = client as MockClient; + return session; + }, + ); + await renderWithProvider( + + + + + , + providerProps, + ); + const workspace = park('main'); + expect(probes.child?.connection).toMatchObject({ + status: 'connected', + sessionContext: { kind }, + workspaceCwd: undefined, + }); + expect(probes.child?.owner?.source).toBeUndefined(); + const error = new DaemonHttpError(401, undefined, 'Unauthorized'); + (kind === 'standalone' + ? sdkMocks.MockDaemonSessionClient.loadStandalone + : sdkMocks.MockDaemonSessionClient.load + ).mockRejectedValueOnce(error); + expect( + await switchSession('child', 'non-workspace-next', 'load', { + sessionContext: { kind }, + }), + ).toBe(error); + expect(probes.child?.connection.errorStatus).toBe(401); + expect(retained.getEntries()).toContain(workspace); + expect(workspace.container.isConnected).toBe(true); + }, + ); + + it('ignores a late auth failure after another restore owns the effect', async () => { + await renderWithProvider(, providerProps); + const old = park('main'); + expect(await switchSession('main', 'session-b')).toBeUndefined(); + const delayed = createDeferred(); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + () => delayed.promise, + ); + let replaced!: Promise; + await act(async () => { + replaced = requireActions(probes.main) + .actions.loadSession('session-a') + .catch((error: unknown) => error); + await flushPromises(); + }); + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenLastCalledWith( + expect.anything(), + 'session-a', + expect.anything(), + expect.any(String), + ); + expect(await switchSession('main', 'session-c')).toBeUndefined(); + expect(await replaced).toMatchObject({ name: 'AbortError' }); + const current = park('main'); + expect(current.scopeId).toBe(old.scopeId); + await act(async () => { + delayed.reject( + new DaemonHttpError(401, undefined, 'Late auth failure'), + ); + await flushPromises(); + }); + expect(probes.main?.connection).toMatchObject({ + status: 'connected', + sessionId: 'session-c', + }); + expect(retained.getEntries()).toEqual( + expect.arrayContaining([old, current]), + ); + expect(old.container.isConnected).toBe(true); + expect(current.container.isConnected).toBe(true); + }); + }); + + it.each(['immediate', 'after-render'] as const)( + 'disposes a parked activation when the next session receives a policy event %s', + async (timing) => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + const retained = new GeneratedUiInstanceStore(); + const parking = document.createElement('div'); + parking.hidden = true; + document.body.appendChild(parking); + retained.parking = parking; + const slotElement = document.createElement('div'); + document.body.appendChild(slotElement); + const delayedPolicy = createDeferred(); + const invalidations: McpAppGeneratedUiInvalidation[] = []; + let owner: ContextType; + let actions: DaemonSessionActions | undefined; + function Harness() { + owner = useContext(McpAppGeneratedUiOwnerContext); + actions = useDaemonActions(); + const subscribe = owner?.subscribeInvalidation; + useLayoutEffect( + () => + subscribe?.((event) => { + invalidations.push(event); + retained.invalidate(event); + }), + [subscribe], + ); + return null; + } + try { + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'session-a', + session: { generatedUi: { version: 1, enabled: true } }, + }), + ); + await renderWithProvider(, { + autoConnect: true, + workspaceCwd: '/mock-workspace', + }); + const initialOwner = requireActions(owner?.source); + const display: McpAppDisplay = { + type: 'mcp_app', + requiredFeature: 'generated-ui', + serverName: 'qwen-code-generated-ui', + resourceUri: 'ui://qwen-code-generated-ui/view-v1.html', + html: '
retained parameters
', + toolResult: { content: [{ type: 'text', text: 'Ready' }] }, + toolArguments: { rate: 7 }, + fallbackText: 'Retained parameters', + }; + const initialSlot: InstanceSlot = { + key: instanceKey(initialOwner, 'call-a', display), + resourceKey: resourceKey(display, initialOwner.eventEpoch), + display, + source: initialOwner, + receipt: { + sessionId: 'session-a', + status: { version: 1, enabled: true }, + }, + baseUrl: 'http://127.0.0.1:4170', + token: {}, + element: slotElement, + visible: true, + reset: vi.fn(), + }; + const activation = requireActions(retained.attach(initialSlot)); + activation.container.dataset.dirtyInput = '9'; + retained.release(initialSlot.key, initialSlot.token); + expect(activation.container.parentElement).toBe(parking); + invalidations.length = 0; + + const policy: DaemonEvent = { + v: 1, + type: 'settings_reloaded', + data: {}, + }; + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'session-b', + session: { generatedUi: { version: 1, enabled: true } }, + async *events(opts) { + yield timing === 'immediate' + ? policy + : await delayedPolicy.promise; + yield* createIdleEvents()(opts); + }, + }), + ); + let switchToB!: Promise; + act(() => { + switchToB = requireActions(actions).loadSession('session-b'); + }); + await act(async () => { + await switchToB; + await flushPromises(); + }); + if (timing === 'after-render') { + expect(retained.getEntries()).toContain(activation); + await act(async () => { + delayedPolicy.resolve(policy); + await flushPromises(); + }); + } + expect(invalidations).toContainEqual({ + scopeId: initialOwner.scopeId, + reason: 'policy', + }); + expect(retained.getEntries()).not.toContain(activation); + expect(activation.container.isConnected).toBe(false); + + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'session-a', + session: { generatedUi: { version: 1, enabled: true } }, + }), + ); + let switchToA!: Promise; + act(() => { + switchToA = requireActions(actions).loadSession('session-a'); + }); + await act(async () => { + await switchToA; + await flushPromises(); + }); + const returnedOwner = requireActions(owner?.source); + expect(returnedOwner.scopeId).toBe(initialOwner.scopeId); + const returnedSlot: InstanceSlot = { + ...initialSlot, + source: returnedOwner, + token: {}, + key: instanceKey(returnedOwner, 'call-a', display), + resourceKey: resourceKey(display, returnedOwner.eventEpoch), + }; + expect(retained.attach(returnedSlot)).toBeUndefined(); + const reopened = requireActions(retained.attach(returnedSlot, true)); + expect(reopened).not.toBe(activation); + expect(reopened.runtime.reopened).toBe(true); + expect(reopened.container.dataset.dirtyInput).toBeUndefined(); + } finally { + retained.clear(); + parking.remove(); + slotElement.remove(); + } + }, + ); + + it.each([ + { + boundary: 'matching ancestor', + baseUrl: 'http://127.0.0.1:4170', + token: 'token-a', + workspaceCwd: '/ancestor', + inherits: true, + }, + { + boundary: 'different workspace', + baseUrl: 'http://127.0.0.1:4170', + token: 'token-a', + workspaceCwd: '/other', + inherits: false, + }, + { + boundary: 'different daemon', + baseUrl: 'http://127.0.0.1:4171', + token: 'token-a', + workspaceCwd: '/ancestor', + inherits: false, + }, + { + boundary: 'different credentials', + baseUrl: 'http://127.0.0.1:4170', + token: 'token-b', + workspaceCwd: '/ancestor', + inherits: false, + }, + ])( + 'binds the first policy event across a nested workspace switch: $boundary', + async ({ baseUrl, token, workspaceCwd, inherits }) => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + let outer: ContextType; + let inner: ContextType; + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + const invalidations: McpAppGeneratedUiInvalidation[] = []; + function Inner() { + inner = useContext(McpAppGeneratedUiOwnerContext); + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + function Outer() { + outer = useContext(McpAppGeneratedUiOwnerContext); + const subscribe = outer?.subscribeInvalidation; + useLayoutEffect( + () => subscribe?.((event) => invalidations.push(event)), + [subscribe], + ); + return ( + + + + ); + } + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'child-a', + workspaceCwd: '/child', + session: { generatedUi: { version: 1, enabled: true } }, + }), + ); + await renderWithProvider(, { + workspaceCwd: '/ancestor', + token: 'token-a', + }); + const ancestorScopeId = requireActions(outer).scopeId; + const childScopeId = requireActions(inner?.source).scopeId; + expect(childScopeId).not.toBe(ancestorScopeId); + invalidations.length = 0; + let receivedPolicy = false; + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'child-b', + workspaceCwd, + session: { generatedUi: { version: 1, enabled: true } }, + async *events(opts) { + receivedPolicy = true; + yield { v: 1, type: 'settings_reloaded', data: {} }; + yield* createIdleEvents()(opts); + }, + }), + ); + let switched!: Promise; + act(() => { + switched = requireActions(actions).loadSession('child-b', { + workspaceCwd, + }); + }); + await act(async () => { + await switched; + await flushPromises(); + }); + expect(receivedPolicy).toBe(true); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'child-b', + workspaceCwd, + }); + const connectedSource = requireActions(inner?.source); + expect(connectedSource.scopeId === ancestorScopeId).toBe(inherits); + expect(connectedSource.scopeId).not.toBe(childScopeId); + expect( + invalidations.filter( + (event) => + event.reason === 'policy' && event.scopeId === ancestorScopeId, + ), + ).toEqual( + inherits ? [{ scopeId: ancestorScopeId, reason: 'policy' }] : [], + ); + }, + ); + + it.each(['standalone', 'live'] as const)( + 'does not bind a first policy event in %s to an ancestor with the same SDK cwd', + async (kind) => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/primary', + features: ['standalone_sessions_v1', 'multi_workspace_sessions'], + workspaces: [ + { id: 'primary', cwd: '/primary', primary: true, trusted: true }, + { + id: 'live', + cwd: '/ancestor', + kind: 'live', + primary: false, + trusted: true, + }, + ], + }); + let inner: ContextType; + let connection: DaemonConnectionState | undefined; + let receivedPolicy = false; + const invalidations: McpAppGeneratedUiInvalidation[] = []; + function Inner() { + inner = useContext(McpAppGeneratedUiOwnerContext); + connection = useDaemonConnection(); + return null; + } + function Outer() { + const owner = useContext(McpAppGeneratedUiOwnerContext); + const subscribe = owner?.subscribeInvalidation; + useLayoutEffect( + () => subscribe?.((event) => invalidations.push(event)), + [subscribe], + ); + return ( + + + + ); + } + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'non-workspace', + workspaceCwd: '/ancestor', + session: { + generatedUi: { version: 1, enabled: true }, + ...(kind === 'standalone' + ? { + sourceType: 'standalone', + context: { kind: 'standalone' }, + projectlessOutputDirectory: '/output/non-workspace', + workingDirectory: { state: 'ready' }, + } + : {}), + }, + async *events(opts) { + receivedPolicy = true; + yield { v: 1, type: 'settings_reloaded', data: {} }; + yield* createIdleEvents()(opts); + }, + }), + ); + await renderWithProvider(, { + workspaceCwd: '/ancestor', + token: 'token-a', + }); + expect(receivedPolicy).toBe(true); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'non-workspace', + sessionContext: { kind }, + workspaceCwd: undefined, + }); + expect(inner?.source).toBeUndefined(); + expect( + invalidations.filter((event) => event.reason === 'policy'), + ).toEqual([]); + }, + ); + + it('captures deletion scopes by workspace, daemon and credentials before the response', async () => { + const owners: Record< + string, + ContextType + > = {}; + const invalidations: McpAppGeneratedUiInvalidation[] = []; + let targetKey = 'initial'; + let showInherited = true; + function Probe({ name }: { name: string }) { + owners[name] = useContext(McpAppGeneratedUiOwnerContext); + return null; + } + function Outer() { + owners.outer = useContext(McpAppGeneratedUiOwnerContext); + const subscribe = owners.outer?.subscribeInvalidation; + useLayoutEffect( + () => subscribe?.((event) => invalidations.push(event)), + [subscribe], + ); + return [ + ['target', '/target', 'http://127.0.0.1:4170', 'token-a'], + ['same-primary', '/primary', 'http://127.0.0.1:4170', 'token-a'], + ['other-workspace', '/other', 'http://127.0.0.1:4170', 'token-a'], + ['other-daemon', '/target', 'http://127.0.0.1:4171', 'token-a'], + ['other-credentials', '/target', 'http://127.0.0.1:4170', 'token-b'], + ] + .filter(([name]) => showInherited || name !== 'same-primary') + .map(([name, workspaceCwd, baseUrl, token]) => ( + + + + )); + } + await renderWithProvider(, { + workspaceCwd: '/primary', + token: 'token-a', + }); + const rerender = async () => { + await act(async () => { + root?.render( + + + , + ); + await flushPromises(); + }); + }; + const outer = requireActions(owners.outer); + const originalTarget = requireActions(owners.target).scopeId; + expect(owners['same-primary']?.scopeId).toBe(outer.scopeId); + const deleted = outer.captureSessionDeletion('/target'); + deleted(['removed', 'not-found']); + expect(invalidations).toEqual( + ['removed', 'not-found'].map((sessionId) => ({ + scopeId: originalTarget, + sessionId, + reason: 'deleted', + })), + ); + invalidations.length = 0; + outer.captureSessionDeletion('/primary')(['same-session-id']); + expect(invalidations).toEqual([ + { + scopeId: outer.scopeId, + sessionId: 'same-session-id', + reason: 'deleted', + }, + ]); + invalidations.length = 0; + outer.captureSessionDeletion(undefined)(['same-session-id']); + outer.captureSessionDeletion('/unknown')(['same-session-id']); + expect(invalidations).toEqual([]); + + targetKey = 'replacement'; + showInherited = false; + await rerender(); + const replacement = requireActions(owners.target).scopeId; + expect(replacement).not.toBe(originalTarget); + invalidations.length = 0; + deleted(['late-result']); + expect(invalidations).toEqual([ + { + scopeId: originalTarget, + sessionId: 'late-result', + reason: 'deleted', + }, + ]); + invalidations.length = 0; + requireActions(owners.outer).captureSessionDeletion('/target')(['fresh']); + expect(invalidations).toEqual([ + { scopeId: replacement, sessionId: 'fresh', reason: 'deleted' }, + ]); + invalidations.length = 0; + outer.captureSessionDeletion('/primary')(['after-inherited-unmount']); + expect(invalidations).toEqual([ + { + scopeId: outer.scopeId, + sessionId: 'after-inherited-unmount', + reason: 'deleted', + }, + ]); + }); + + it.each(['randomUUID', 'crypto'])( + 'creates distinct scopes without %s while Generated UI is disabled', + async (missing) => { + vi.stubGlobal( + 'crypto', + missing === 'crypto' + ? undefined + : { getRandomValues: webcrypto.getRandomValues.bind(webcrypto) }, + ); + let owner: ContextType; + let connection: DaemonConnectionState | undefined; + function Harness() { + owner = useContext(McpAppGeneratedUiOwnerContext); + connection = useDaemonConnection(); + return null; + } + await renderWithProvider(, { + clientId: 'http-client', + workspaceCwd: '/workspace-a', + }); + expect(connection?.status).toBe('idle'); + expect(owner?.source).toBeUndefined(); + const firstScope = requireActions(owner).scopeId; + for (const workspaceCwd of ['/workspace-a', '/workspace-b']) { + await act(async () => { + root?.render( + + + , + ); + }); + expect(requireActions(owner).scopeId === firstScope).toBe( + workspaceCwd === '/workspace-a', + ); + } + }, + ); + + it.each(['/workspace-a', '/workspace-b'])( + 'releases only an owned scope when a nested provider closes: %s', + async (workspaceCwd) => { + let outer: ContextType; + let inner: ContextType; + let show = true; + const invalidations: McpAppGeneratedUiInvalidation[] = []; + const makeSession = () => + createMockSession({ + sessionId: 'nested-session', + workspaceCwd, + session: { generatedUi: { version: 1, enabled: true } }, + }); + sdkMocks.sessions.push(makeSession()); + function Inner() { + inner = useContext(McpAppGeneratedUiOwnerContext); + return null; + } + function Outer() { + outer = useContext(McpAppGeneratedUiOwnerContext); + const subscribe = outer?.subscribeInvalidation; + useLayoutEffect( + () => subscribe?.((event) => invalidations.push(event)), + [subscribe], + ); + return show ? ( + + + + ) : null; + } + const tree = () => ( + + + + ); + await renderWithProvider(, { workspaceCwd: '/workspace-a' }); + const first = requireActions(inner?.source); + expect(first.captureOwner().isCurrent()).toBe(true); + const shared = first.scopeId === requireActions(outer).scopeId; + expect(shared).toBe(workspaceCwd === '/workspace-a'); + invalidations.length = 0; + show = false; + await act(async () => { + root?.render(tree()); + await flushPromises(); + }); + expect(first.captureOwner().isCurrent()).toBe(false); + expect(invalidations).toEqual( + shared ? [] : [{ scopeId: first.scopeId, reason: 'scope-changed' }], + ); + show = true; + sdkMocks.sessions.push(makeSession()); + await act(async () => { + root?.render(tree()); + await flushPromises(); + }); + const reopened = requireActions(inner?.source); + expect(reopened.scopeId === first.scopeId).toBe(shared); + expect(reopened.captureOwner().isCurrent()).toBe(true); + }, + ); + + it('shares a same-workspace nested scope and its invalidation channel', async () => { + let outer: ContextType; + let inner: ContextType; + const invalidations: McpAppGeneratedUiInvalidation[] = []; + function Inner() { + inner = useContext(McpAppGeneratedUiOwnerContext); + return null; + } + function Outer() { + outer = useContext(McpAppGeneratedUiOwnerContext); + const subscribe = outer?.subscribeInvalidation; + useLayoutEffect( + () => subscribe?.((event) => invalidations.push(event)), + [subscribe], + ); + return ( + + + + ); + } + await renderWithProvider(, { workspaceCwd: '/same-workspace' }); + expect(inner?.scopeId).toBe(outer?.scopeId); + const event = { + scopeId: requireActions(inner).scopeId, + sessionId: 'nested-session', + reason: 'deleted' as const, + }; + inner?.invalidate(event); + expect(invalidations).toContainEqual(event); + }); + + it('keeps the scope across session switches and revokes the previous owner', async () => { + sdkMocks.sessions.push( + createMockSession({ + session: { generatedUi: { version: 1, enabled: true } }, + }), + ); + let owner: ContextType; + let actions: DaemonSessionActions | undefined; + const invalidations: McpAppGeneratedUiInvalidation[] = []; + function Harness() { + owner = useContext(McpAppGeneratedUiOwnerContext); + actions = useDaemonActions(); + const subscribe = owner?.subscribeInvalidation; + useLayoutEffect( + () => subscribe?.((event) => invalidations.push(event)), + [subscribe], + ); + return null; + } + await renderWithProvider(, { autoConnect: true }); + const first = requireActions(owner?.source); + const captured = first.captureOwner(); + expect(first).toMatchObject({ + sessionId: 'session-1', + clientId: 'client-1', + workspaceCwd: '/mock-workspace', + eventEpoch: 'epoch-1', + }); + expect(captured.isCurrent()).toBe(true); + invalidations.length = 0; + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'session-2', + session: { generatedUi: { version: 1, enabled: true } }, + }), + ); + let switched!: Promise; + act(() => { + switched = requireActions(actions).loadSession('session-2'); + }); + await act(async () => { + await switched; + await flushPromises(); + }); + expect(owner?.source?.scopeId).toBe(first.scopeId); + expect(owner?.source?.sessionId).toBe('session-2'); + expect(captured.isCurrent()).toBe(false); + expect(invalidations).toEqual([]); + await act(async () => { + await requireActions(actions).clearSession(); + }); + expect(owner?.source).toBeUndefined(); + expect(invalidations).toEqual([ + { scopeId: first.scopeId, sessionId: 'session-2', reason: 'cleared' }, + ]); + }); + + it.each(['client', 'epoch', 'receipt'] as const)( + 'rejects a captured retained owner immediately after in-place %s changes', + async (change) => { + const session = createMockSession({ + session: { generatedUi: { version: 1, enabled: true } }, + }); + sdkMocks.sessions.push(session); + let owner: ContextType; + function Harness() { + owner = useContext(McpAppGeneratedUiOwnerContext); + return null; + } + await renderWithProvider(, { autoConnect: true }); + const captured = requireActions(owner?.source).captureOwner(); + expect(captured.isCurrent()).toBe(true); + if (change === 'client') session.clientId = 'reattached-client'; + if (change === 'epoch') session.eventEpoch = 'epoch-2'; + if (change === 'receipt') session.session!['generatedUi'] = undefined; + expect(captured.isCurrent()).toBe(false); + }, + ); + + it.each([ + ['policy', { v: 1, type: 'settings_reloaded', data: {} }, false], + [ + 'deleted', + { + v: 1, + type: 'session_messages_deleted', + originatorClientId: 'client-1', + data: { + deletedGroupIds: ['group-1'], + deletedPromptIds: ['prompt-1'], + }, + }, + true, + ], + ['rewound', { v: 1, type: 'session_rewound', data: {} }, true], + [ + 'epoch', + { + v: 1, + type: 'state_resync_required', + data: { reason: 'epoch_reset' }, + }, + false, + ], + [ + 'deleted', + { v: 1, type: 'session_closed', data: { reason: 'client_close' } }, + true, + ], + [ + 'runtime-stopped', + { + v: 1, + type: 'session_closed', + data: { reason: 'client_close', cause: 'workspace_runtime_stop' }, + }, + false, + ], + ] satisfies Array< + [McpAppGeneratedUiInvalidation['reason'], DaemonEvent, boolean] + >)( + 'publishes %s invalidation from the actual session event: %j', + async (reason, event, sessionOnly) => { + const change = createDeferred(); + sdkMocks.sessions.push( + createMockSession({ + session: { generatedUi: { version: 1, enabled: true } }, + async *events(opts) { + yield await change.promise; + yield* createIdleEvents()(opts); + }, + }), + ); + let owner: ContextType; + const invalidations: McpAppGeneratedUiInvalidation[] = []; + function Harness() { + owner = useContext(McpAppGeneratedUiOwnerContext); + const subscribe = owner?.subscribeInvalidation; + useLayoutEffect( + () => + subscribe?.((invalidation) => invalidations.push(invalidation)), + [subscribe], + ); + return null; + } + await renderWithProvider(, { autoConnect: true }); + const scopeId = requireActions(owner?.source).scopeId; + invalidations.length = 0; + await act(async () => { + change.resolve(event); + await flushPromises(); + }); + expect(invalidations).toContainEqual({ + scopeId, + ...(sessionOnly ? { sessionId: 'session-1' } : {}), + reason, + }); + }, + ); + + it.each(['baseUrl', 'token', 'workspaceCwd'] as const)( + 'invalidates the original scope when the confirmed %s changes', + async (field) => { + let owner: ContextType; + const invalidations: McpAppGeneratedUiInvalidation[] = []; + function Harness() { + owner = useContext(McpAppGeneratedUiOwnerContext); + const subscribe = owner?.subscribeInvalidation; + useLayoutEffect( + () => subscribe?.((event) => invalidations.push(event)), + [subscribe], + ); + return null; + } + const props = { + baseUrl: 'http://127.0.0.1:4170', + token: 'first-token', + workspaceCwd: '/first-workspace', + autoConnect: false, + }; + await renderWithProvider(, props); + const scopeId = requireActions(owner).scopeId; + act(() => { + root?.render( + + + , + ); + }); + expect(owner?.scopeId).not.toBe(scopeId); + expect(invalidations).toContainEqual({ + scopeId, + reason: 'scope-changed', + }); + }, + ); + }); + it('exposes Generated UI only from the attached session and clears it with the owner', async () => { const session = createMockSession({ session: { generatedUi: { version: 1, enabled: true } }, @@ -25049,6 +26862,7 @@ function createMockSession(opts: Partial = {}): MockSession { workspaceCwd: opts.workspaceCwd ?? '/mock-workspace', state: {}, })), + subscribeGeneratedUiRevocation: opts.subscribeGeneratedUiRevocation, supportedCommands: opts.supportedCommands ?? vi.fn(async () => ({ diff --git a/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx b/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx index f6ae773b0ea..cbfaf643383 100644 --- a/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx +++ b/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx @@ -14,6 +14,7 @@ import { useCallback, useContext, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -81,6 +82,10 @@ import { import { McpAppToolsContext, McpAppGeneratedUiContext, + McpAppGeneratedUiOwnerContext, + type McpAppGeneratedUiInvalidation, + type McpAppGeneratedUiOwner, + type McpAppGeneratedUiOwnerSource, } from '../../mcpAppHostContext.js'; import { createTrajectoryLiveStore } from '../../trajectory/trajectory-live-store.js'; import { TrajectoryLiveContext } from '../../trajectory/trajectory-live-context.js'; @@ -775,6 +780,46 @@ const DaemonWorkspaceEventSignalsContext = createContext< const DaemonSessionOwnerGuardContext = createContext< DaemonSessionOwnerGuard | undefined >(undefined); + +type GeneratedUiLifecycle = Pick< + McpAppGeneratedUiOwner, + 'subscribeInvalidation' | 'invalidate' +> & { + registerScope(scope: GeneratedUiScope): () => void; + captureSessionDeletion( + owner: GeneratedUiScope, + workspaceCwd: string | undefined, + ): (sessionIds: readonly string[]) => void; +}; + +interface GeneratedUiScope { + id: string; + baseUrl?: string; + token?: string; + workspaceCwd?: string; +} + +function findGeneratedUiScope( + candidates: Array, + baseUrl: string | undefined, + token: string | undefined, + workspaceCwd: string | undefined, +) { + return candidates.find( + (scope) => + scope && + scope.baseUrl === baseUrl && + scope.token === token && + scope.workspaceCwd === workspaceCwd, + ); +} + +let nextGeneratedUiScopeId = 0; + +const GeneratedUiScopeContext = createContext< + { scope: GeneratedUiScope; lifecycle: GeneratedUiLifecycle } | undefined +>(undefined); + /** * Subset of TERMINAL_SESSION_HTTP_STATUSES that represent **credential * failures** (vs session-not-found 404/410). Auth failures should NOT enter @@ -1340,6 +1385,150 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ); const connectionRef = useRef(connection); connectionRef.current = connection; + const inheritedGeneratedUi = useContext(GeneratedUiScopeContext); + const ownGeneratedUiLifecycle = useMemo(() => { + const listeners = new Set<(event: McpAppGeneratedUiInvalidation) => void>(); + const scopes = new Set(); + const invalidate = (event: McpAppGeneratedUiInvalidation) => { + for (const listener of listeners) listener(event); + }; + return { + subscribeInvalidation(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + invalidate, + registerScope(scope) { + scopes.add(scope); + return () => { + scopes.delete(scope); + }; + }, + captureSessionDeletion(owner, workspaceCwd) { + const scopeIds = [...scopes] + .filter( + (scope) => + workspaceCwd !== undefined && + scope.workspaceCwd === workspaceCwd && + scope.baseUrl === owner.baseUrl && + scope.token === owner.token, + ) + .map((scope) => scope.id); + return (sessionIds) => { + for (const scopeId of scopeIds) { + for (const sessionId of sessionIds) { + invalidate({ scopeId, sessionId, reason: 'deleted' }); + } + } + }; + }, + }; + }, []); + const generatedUiLifecycle = + inheritedGeneratedUi?.lifecycle ?? ownGeneratedUiLifecycle; + const generatedUiProvider = useMemo( + () => ({ resolvedBaseUrl, resolvedToken, resolvedWorkspaceCwd }), + [resolvedBaseUrl, resolvedToken, resolvedWorkspaceCwd], + ); + const generatedUiProviderRef = useRef(generatedUiProvider); + generatedUiProviderRef.current = generatedUiProvider; + const confirmedGeneratedUiWorkspace = useRef< + | { + provider: typeof generatedUiProvider; + requestedCwd?: string; + cwd: string; + } + | undefined + >(undefined); + const getConfirmedGeneratedUiWorkspace = useCallback(() => { + const confirmed = confirmedGeneratedUiWorkspace.current; + const provider = generatedUiProviderRef.current; + return confirmed && + confirmed.provider.resolvedBaseUrl === provider.resolvedBaseUrl && + confirmed.provider.resolvedToken === provider.resolvedToken && + [ + confirmed.provider.resolvedWorkspaceCwd, + confirmed.requestedCwd, + confirmed.cwd, + ].includes(provider.resolvedWorkspaceCwd) + ? confirmed + : undefined; + }, []); + const confirmedWorkspace = getConfirmedGeneratedUiWorkspace(); + const generatedUiWorkspaceCwd = + confirmedWorkspace?.cwd ?? resolvedWorkspaceCwd ?? connection.workspaceCwd; + const ownGeneratedUiScope = useMemo( + () => ({ + id: String(++nextGeneratedUiScopeId), + baseUrl: resolvedBaseUrl, + token: resolvedToken, + workspaceCwd: generatedUiWorkspaceCwd, + }), + [generatedUiWorkspaceCwd, resolvedBaseUrl, resolvedToken], + ); + const inheritedScope = inheritedGeneratedUi?.scope; + const inheritedGeneratedUiScopeRef = useRef(inheritedScope); + inheritedGeneratedUiScopeRef.current = inheritedScope; + const generatedUiScope = findGeneratedUiScope( + [inheritedScope, ownGeneratedUiScope], + resolvedBaseUrl, + resolvedToken, + generatedUiWorkspaceCwd, + )!; + const generatedUiScopeRef = useRef(generatedUiScope); + generatedUiScopeRef.current = generatedUiScope; + const resolveGeneratedUiScope = useCallback( + (cwd: string | undefined) => { + const confirmed = getConfirmedGeneratedUiWorkspace(); + const provider = generatedUiProviderRef.current; + return findGeneratedUiScope( + [inheritedGeneratedUiScopeRef.current, generatedUiScopeRef.current], + resolvedBaseUrl, + resolvedToken, + confirmed && + (confirmed.requestedCwd === cwd || + provider.resolvedWorkspaceCwd === cwd) + ? confirmed.cwd + : cwd, + ); + }, + [getConfirmedGeneratedUiWorkspace, resolvedBaseUrl, resolvedToken], + ); + const generatedUiSessionScopes = useRef( + new WeakMap(), + ); + const generatedUiRevocations = useRef( + new WeakMap(), + ); + useLayoutEffect(() => { + if (generatedUiScope !== ownGeneratedUiScope) return; + const unregister = generatedUiLifecycle.registerScope(generatedUiScope); + return () => { + unregister(); + generatedUiLifecycle.invalidate({ + scopeId: generatedUiScope.id, + reason: 'scope-changed', + }); + }; + }, [generatedUiLifecycle, generatedUiScope, ownGeneratedUiScope]); + const invalidateGeneratedUi = useCallback( + ( + owner: DaemonSessionClient, + reason: McpAppGeneratedUiInvalidation['reason'], + sessionId: string | null = owner.sessionId, + ) => { + const scopeId = generatedUiSessionScopes.current.get(owner); + if (!scopeId) return; + generatedUiLifecycle.invalidate({ + scopeId, + ...(sessionId !== null ? { sessionId } : {}), + reason, + }); + }, + [generatedUiLifecycle], + ); const initialClientIdDependencyRef = useRef(clientId); const knownCapabilities = workspace?.capabilities ?? @@ -1619,6 +1808,8 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { sessionRef.current === skipNextCleanupDetachSessionRef.current; const effectPendingSessionLoad = pendingSessionLoadRef.current; let runnerSession = sessionRef.current; + let generatedUiSubscriptionOwner: DaemonSessionClient | undefined; + let unsubscribeGeneratedUiRevocation: (() => void) | undefined; // ── Batched transcript dispatch ──────────────────────────────── // The live SSE loop dispatches transcript events through this batcher @@ -1822,6 +2013,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const skipMetadataRefreshThisIteration = skipMetadataRefresh; skipMetadataRefresh = false; let loadingRequestedSession = false; + let generatedUiRestoreTarget: + | { scopeId: string; sessionId: string } + | undefined; let eventStream: | { sessionId: string; @@ -2264,6 +2458,15 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { : {}), }; let nextSession: DaemonSessionClient; + if (targetSessionId && effectSessionContext?.kind === 'workspace') { + const scope = resolveGeneratedUiScope(effectWorkspaceCwd); + if (scope) { + generatedUiRestoreTarget = { + scopeId: scope.id, + sessionId: targetSessionId, + }; + } + } if (restoreSessionId) { if (effectSessionContext?.kind === 'standalone') { nextSession = shouldResumeRequestedSession @@ -2550,6 +2753,48 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { turnNotifications.activate(activeSession); const activeWorkspaceScoped = activeProductSessionContext.kind === 'workspace'; + if (activeWorkspaceScoped) { + confirmedGeneratedUiWorkspace.current = { + provider: generatedUiProviderRef.current, + requestedCwd: activeWorkspaceCwdRef.current, + cwd: activeSession.workspaceCwd, + }; + } + // Buffered SSE events can arrive before the connected render binds + // this session. Never assign a different workspace's current scope. + if ( + activeWorkspaceScoped && + !generatedUiSessionScopes.current.has(activeSession) + ) { + const scope = resolveGeneratedUiScope(activeSession.workspaceCwd); + if (scope) { + generatedUiSessionScopes.current.set(activeSession, scope.id); + } + } + if (generatedUiSubscriptionOwner !== activeSession) { + unsubscribeGeneratedUiRevocation?.(); + generatedUiSubscriptionOwner = activeSession; + unsubscribeGeneratedUiRevocation = + activeSession.subscribeGeneratedUiRevocation?.((revision) => { + const scope = resolveGeneratedUiScope( + activeSession.workspaceCwd, + ); + if ( + disposed || + abort.signal.aborted || + !activeWorkspaceScoped || + sessionRef.current !== activeSession || + !scope || + generatedUiSessionScopes.current.get(activeSession) !== + scope.id || + (generatedUiRevocations.current.get(activeSession) ?? 0) >= + revision + ) + return; + generatedUiRevocations.current.set(activeSession, revision); + invalidateGeneratedUi(activeSession, 'policy', null); + }); + } if (runnerSession !== activeSession) { setConnectionSynchronous((current) => ({ ...current, @@ -3517,6 +3762,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { let sawEvent = false; let resyncRequested = false; const requestEpochResetReload = () => { + invalidateGeneratedUi(activeSession, 'epoch', null); // An epoch reset means the daemon/EventBus timeline was rebuilt. // The current SSE cursor and any restored/local prompt activity may // describe the old epoch, so do a full /load and let @@ -3605,6 +3851,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { try { turnNavigationStore.handleSessionEvent(event.type); if (event.type === 'session_messages_deleted') { + invalidateGeneratedUi(activeSession, 'deleted'); if (event.originatorClientId !== activeSession.clientId) { setConnection((current) => ({ ...current, @@ -3638,6 +3885,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { } continue; } + if (event.type === 'session_rewound') { + invalidateGeneratedUi(activeSession, 'rewound'); + } const followupSuggestion = parseSidechannelFollowupSuggestion(event); @@ -3792,6 +4042,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ); }; if (generatedUiPolicyChanged) { + invalidateGeneratedUi(activeSession, 'policy', null); activeSession.session.generatedUi = undefined; publishContext(); } @@ -4136,6 +4387,11 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { stopPersistenceUnconfirmed = closedData.persistenceUnconfirmed === true; const closedSessionId = activeSession.sessionId; + invalidateGeneratedUi( + activeSession, + workspaceRuntimeStopped ? 'runtime-stopped' : 'deleted', + workspaceRuntimeStopped ? null : closedSessionId, + ); const active = activePromptsRef.current.get(closedSessionId); active?.controller.abort(); activePromptsRef.current.delete(closedSessionId); @@ -4496,6 +4752,21 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { return; } if (isAuthFailure || isTerminal) { + if (session) { + invalidateGeneratedUi( + session, + isAuthFailure ? 'auth-failed' : 'deleted', + isAuthFailure ? null : session.sessionId, + ); + } else if (generatedUiRestoreTarget) { + generatedUiLifecycle.invalidate({ + scopeId: generatedUiRestoreTarget.scopeId, + ...(isAuthFailure + ? {} + : { sessionId: generatedUiRestoreTarget.sessionId }), + reason: isAuthFailure ? 'auth-failed' : 'deleted', + }); + } // Auth failures (401/403) and terminal session errors (404/410) // must clear the session — the server-side state is gone or // inaccessible, so delta resume is impossible. @@ -4669,6 +4940,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const session = runnerSession; if (session) trajectoryLiveStore.invalidate(session, 'stream_closed'); disposed = true; + unsubscribeGeneratedUiRevocation?.(); abort.abort(); const ownsCurrentSession = session !== undefined && sessionRef.current === session; @@ -4743,11 +5015,14 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { }, [ autoConnect, autoReconnect, + generatedUiLifecycle, + invalidateGeneratedUi, prefetchGitBranch, prefetchSkills, turnNotifications, resolvedBaseUrl, resolvedToken, + resolveGeneratedUiScope, sessionEffectContext, sessionContextResolutionError, modelServiceId, @@ -4859,6 +5134,11 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { AUTH_FAILURE_HTTP_STATUSES.has(errorStatus); const missingSession = isMissingSessionHttpStatus(errorStatus); if (authFailure || missingSession) { + invalidateGeneratedUi( + session, + authFailure ? 'auth-failed' : 'deleted', + authFailure ? null : session.sessionId, + ); trajectoryLiveStore.invalidate( session, authFailure ? 'auth_failure' : 'session_missing', @@ -4931,6 +5211,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { connection.status, heartbeatFailureThreshold, heartbeatIntervalMs, + invalidateGeneratedUi, trajectoryLiveStore, ]); @@ -5095,6 +5376,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { liveJournalRepairRef.current?.controller?.abort(); liveJournalRepairRef.current = undefined; }, + onGeneratedUiInvalidated: invalidateGeneratedUi, onPromptAdmitted: (owner, admission) => { bindPrompt( locallyBoundPromptIdsRef.current, @@ -5150,6 +5432,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { }), [ addNotice, + invalidateGeneratedUi, clientId, resolvedBaseUrl, resolvedToken, @@ -5526,6 +5809,94 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const appSession = sessionRef.current; const generatedUi = appSession?.session?.generatedUi; + const generatedUiClientId = appSession?.clientId; + const generatedUiEventEpoch = appSession?.eventEpoch; + const generatedUiRecoveryGeneration = appSession + ? sessionRecoveryGenerationRef.current.get(appSession) + : undefined; + const generatedUiOwnerSource = useMemo< + McpAppGeneratedUiOwnerSource | undefined + >(() => { + if ( + !appSession || + !generatedUiClientId || + connection.status !== 'connected' || + connection.sessionId !== appSession.sessionId || + connection.workspaceCwd !== appSession.workspaceCwd || + generatedUiScope.workspaceCwd !== appSession.workspaceCwd + ) + return undefined; + const previousScope = generatedUiSessionScopes.current.get(appSession); + if (previousScope && previousScope !== generatedUiScope.id) + return undefined; + generatedUiSessionScopes.current.set(appSession, generatedUiScope.id); + return { + scopeId: generatedUiScope.id, + sessionId: appSession.sessionId, + clientId: generatedUiClientId, + workspaceCwd: appSession.workspaceCwd, + eventEpoch: generatedUiEventEpoch, + captureOwner() { + const owner = ownerGuardValue.capture({ includeRecovery: true }); + return { + isCurrent: () => + owner.isCurrent() && + !manualSessionClearRef.current && + sessionRecoveryGenerationRef.current.get(appSession) === + generatedUiRecoveryGeneration && + generatedUiScopeRef.current === generatedUiScope && + sessionRef.current === appSession && + appSession.clientId === generatedUiClientId && + appSession.eventEpoch === generatedUiEventEpoch && + connectionRef.current.status === 'connected' && + connectionRef.current.sessionId === appSession.sessionId && + appSession.session.generatedUi === generatedUi && + generatedUi?.version === 1 && + generatedUi.enabled === true, + }; + }, + }; + }, [ + appSession, + connection.sessionId, + connection.status, + connection.workspaceCwd, + generatedUi, + generatedUiClientId, + generatedUiEventEpoch, + generatedUiRecoveryGeneration, + generatedUiScope, + ownerGuardValue, + ]); + const generatedUiOwner = useMemo( + () => ({ + scopeId: generatedUiScope.id, + workspaceCwd: generatedUiScope.workspaceCwd, + source: generatedUiOwnerSource, + subscribeInvalidation: generatedUiLifecycle.subscribeInvalidation, + invalidate: generatedUiLifecycle.invalidate, + captureSessionDeletion: (workspaceCwd) => + generatedUiLifecycle.captureSessionDeletion( + generatedUiScope, + workspaceCwd, + ), + }), + [generatedUiLifecycle, generatedUiOwnerSource, generatedUiScope], + ); + useLayoutEffect(() => { + if ( + appSession && + generatedUiOwnerSource && + generatedUi?.version === 1 && + generatedUi.enabled === false + ) { + invalidateGeneratedUi(appSession, 'policy', null); + } + }, [appSession, generatedUi, generatedUiOwnerSource, invalidateGeneratedUi]); + const generatedUiScopeContext = useMemo( + () => ({ scope: generatedUiScope, lifecycle: generatedUiLifecycle }), + [generatedUiLifecycle, generatedUiScope], + ); const mcpAppGeneratedUi = useMemo(() => { if ( !appSession || @@ -5567,41 +5938,49 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { }, [appSession, connection.sessionId]); return ( - - - - - - - - - - - + + + + + + + + + + - - + - {children} - - - - - - - - - - - - - + + + {children} + + + + + + + + + + + + + + + ); } diff --git a/packages/web-shell/client/daemon/session/actions.test.ts b/packages/web-shell/client/daemon/session/actions.test.ts index 205ee7f3c09..f26d13fd8b0 100644 --- a/packages/web-shell/client/daemon/session/actions.test.ts +++ b/packages/web-shell/client/daemon/session/actions.test.ts @@ -32,6 +32,70 @@ import type { SettledPrompt, } from './types'; +describe('Generated UI action invalidation', () => { + it('revokes the cleared session before waiting for detach', async () => { + const session = createMockSession('original'); + const detach = createDeferred(); + session.detach.mockReturnValue(detach.promise); + const onGeneratedUiInvalidated = vi.fn(); + const h = createActionsHarness({ session, onGeneratedUiInvalidated }); + const clearing = h.actions.clearSession(); + expect(onGeneratedUiInvalidated).toHaveBeenCalledWith(session, 'cleared'); + detach.resolve(); + await clearing; + }); + + it.each(['release', 'rewind'] as const)( + 'invalidates the original owner after successful %s, even after navigation', + async (operation) => { + const session = createMockSession('original'); + const finished = createDeferred(); + session.client.closeSession.mockReturnValue(finished.promise); + session.rewind.mockReturnValue(finished.promise); + const onGeneratedUiInvalidated = vi.fn(); + const h = createActionsHarness({ session, onGeneratedUiInvalidated }); + const pending = + operation === 'release' + ? h.actions.releaseSession('background') + : h.actions.rewindSession('prompt'); + expect(onGeneratedUiInvalidated).not.toHaveBeenCalled(); + h.sessionRef.current = createMockSession( + 'replacement', + ) as unknown as DaemonSessionClient; + finished.resolve({}); + await pending; + if (operation === 'release') + expect(onGeneratedUiInvalidated).toHaveBeenCalledWith( + session, + 'deleted', + 'background', + ); + else + expect(onGeneratedUiInvalidated).toHaveBeenCalledWith( + session, + 'rewound', + ); + }, + ); + + it.each(['release', 'rewind'] as const)( + 'does not report an unsuccessful %s as a deletion', + async (operation) => { + const session = createMockSession('original'); + session.client.closeSession.mockRejectedValue(new Error('refused')); + session.rewind.mockRejectedValue(new Error('refused')); + const onGeneratedUiInvalidated = vi.fn(); + const h = createActionsHarness({ session, onGeneratedUiInvalidated }); + await expect( + operation === 'release' + ? h.actions.releaseSession('background') + : h.actions.rewindSession('prompt'), + ).rejects.toThrow('refused'); + expect(onGeneratedUiInvalidated).not.toHaveBeenCalled(); + }, + ); +}); + describe('context usage counter reconciliation', () => { function snapshot() { return { @@ -6993,6 +7057,7 @@ function createActionsHarness( onPromptAdmitted?: ReturnType; onContinuationAdmitted?: ReturnType; onPromptRemoved?: ReturnType; + onGeneratedUiInvalidated?: ReturnType; passiveAssistantDoneTimerRef?: { current: ReturnType | undefined; }; @@ -7088,6 +7153,7 @@ function createActionsHarness( onPromptAdmitted: opts.onPromptAdmitted, onContinuationAdmitted: opts.onContinuationAdmitted, onPromptRemoved: opts.onPromptRemoved, + onGeneratedUiInvalidated: opts.onGeneratedUiInvalidated, setConnection: (update) => { connection = typeof update === 'function' ? update(connection) : update; opts.onConnectionChange?.(connection); diff --git a/packages/web-shell/client/daemon/session/actions.ts b/packages/web-shell/client/daemon/session/actions.ts index a638f53586b..a610863561e 100644 --- a/packages/web-shell/client/daemon/session/actions.ts +++ b/packages/web-shell/client/daemon/session/actions.ts @@ -5,6 +5,7 @@ */ import type { Dispatch, SetStateAction } from 'react'; +import type { McpAppGeneratedUiInvalidation } from '../../mcpAppHostContext.js'; import type { DaemonApprovalMode, DaemonClient, @@ -283,6 +284,11 @@ export interface CreateDaemonSessionActionsArgs { setAttachSessionNonce: Dispatch>; setNewSessionNonce: Dispatch>; clearLiveJournalRepair?: () => void; + onGeneratedUiInvalidated?: ( + owner: DaemonSessionClient, + reason: McpAppGeneratedUiInvalidation['reason'], + sessionId?: string, + ) => void; onPromptAdmitted?: ( owner: DaemonSessionClient, admission: { @@ -479,6 +485,7 @@ export function createDaemonSessionActions({ setAttachSessionNonce, setNewSessionNonce, clearLiveJournalRepair = () => undefined, + onGeneratedUiInvalidated, onPromptAdmitted, onContinuationAdmitted, onPromptRemoved, @@ -2519,6 +2526,7 @@ export function createDaemonSessionActions({ async clearSession(options?: { dropSessionContext?: boolean }) { const session = sessionRef.current; manualSessionClearRef.current = true; + if (session) onGeneratedUiInvalidated?.(session, 'cleared'); if (pendingPersistedReasoningAction) { await pendingPersistedReasoningAction.catch(() => undefined); } @@ -2584,6 +2592,7 @@ export function createDaemonSessionActions({ session.client.closeSession(sessionId), 'Release session timed out', ); + onGeneratedUiInvalidated?.(session, 'deleted', sessionId); } catch (error) { throw dispatchActionError( addNotice, @@ -2857,10 +2866,12 @@ export function createDaemonSessionActions({ 'rewind_session', ); try { - return await withActionTimeout( + const result = await withActionTimeout( session.rewind(promptId, opts), 'Rewind session timed out', ); + onGeneratedUiInvalidated?.(session, 'rewound'); + return result; } catch (error) { if (opts?.silent) throw error; throw dispatchActionError( diff --git a/packages/web-shell/client/e2e/generated-ui.continue.spec.ts b/packages/web-shell/client/e2e/generated-ui.continue.spec.ts index f568df5dd36..6b97396b8b9 100644 --- a/packages/web-shell/client/e2e/generated-ui.continue.spec.ts +++ b/packages/web-shell/client/e2e/generated-ui.continue.spec.ts @@ -4,12 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { createRequire } from 'node:module'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { build } from 'esbuild'; +import { startGeneratedUiSandbox } from './utils/generatedUiSandbox'; import { expect, test, type Page, type TestInfo } from '@playwright/test'; import { createWebShellDaemonScenario, @@ -19,7 +14,6 @@ import { userTextEvent, } from './utils/mockDaemon'; -const root = new URL('../../../../', import.meta.url); const resourceUri = 'ui://qwen-code-generated-ui/view-v1.html'; const input = { title: 'Parameter continuation fixture', @@ -54,61 +48,16 @@ interface SnapshotMessage { let appHtml: string; let toolResult: Record; -let sandbox: { url: string; close(): Promise }; -let fixtureDirectory: string; +let sandbox: Awaited>; test.beforeAll(async () => { - appHtml = await readFile( - new URL('integrations/generated-ui/dist/app.html', root), - 'utf8', - ); - const require = createRequire(new URL('packages/cli/package.json', root)); - const fixture = await build({ - stdin: { - resolveDir: fileURLToPath(root), - contents: ` - import express from ${JSON.stringify(require.resolve('express'))}; - import { mountMcpAppSandbox } from './packages/cli/src/serve/mcp-app-sandbox.ts'; - export { createShowResult } from './integrations/generated-ui/src/contracts.ts'; - export async function startSandbox() { - const app = express(); - const dispose = mountMcpAppSandbox(app); - const server = await new Promise((resolve) => { - const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); - }); - return { - url: 'http://127.0.0.1:' + server.address().port, - async close() { - dispose(); - server.closeAllConnections(); - await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); - }, - }; - } - `, - }, - bundle: true, - platform: 'node', - format: 'esm', - write: false, - banner: { - js: `import { createRequire } from 'node:module'; const require = createRequire(${JSON.stringify(fileURLToPath(new URL('packages/cli/package.json', root)))});`, - }, - }); - fixtureDirectory = await mkdtemp(join(tmpdir(), 'generated-ui-e2e-')); - const fixturePath = join(fixtureDirectory, 'sandbox.mjs'); - await writeFile(fixturePath, fixture.outputFiles[0].text); - const helpers = (await import(pathToFileURL(fixturePath).href)) as { - createShowResult(document: typeof input): Record; - startSandbox(): Promise; - }; - toolResult = helpers.createShowResult(input); - sandbox = await helpers.startSandbox(); + sandbox = await startGeneratedUiSandbox(); + appHtml = sandbox.appHtml; + toolResult = sandbox.createShowResult(input); }); test.afterAll(async () => { await sandbox?.close(); - if (fixtureDirectory) await rm(fixtureDirectory, { recursive: true }); }); async function openCards(page: Page, testInfo: TestInfo, count = 1) { diff --git a/packages/web-shell/client/e2e/generated-ui.retention.spec.ts b/packages/web-shell/client/e2e/generated-ui.retention.spec.ts new file mode 100644 index 00000000000..3377bc94fc7 --- /dev/null +++ b/packages/web-shell/client/e2e/generated-ui.retention.spec.ts @@ -0,0 +1,1842 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { startGeneratedUiSandbox } from './utils/generatedUiSandbox'; +import { + expect, + test, + type Frame, + type FrameLocator, + type Page, + type TestInfo, +} from '@playwright/test'; +import { + assistantTextEvent, + createWebShellDaemonScenario, + installMockDaemon, + replayCompleteEvent, + toolCallEvent, + turnCompleteEvent, + userTextEvent, +} from './utils/mockDaemon'; + +const resourceUri = 'ui://qwen-code-generated-ui/view-v1.html'; +const frameSelector = 'iframe[title="generated-ui MCP App"]'; +const input = { + title: 'Retained parameter fixture', + summary: 'Original summary. Reopening starts from the original values.', + html: + '' + + '' + + '' + + '
', + css: 'button { margin: 4px; }', + javascript: + 'let rounds = 0;' + + 'ui.text("answer", "0:" + ui.input("rate").value);' + + 'ui.on("apply", "click", () => {' + + 'rounds += 1; ui.text("answer", String(rounds) + ":" + ui.input("rate").value); });' + + 'ui.on("insert", "click", () => ui.html("dynamic", ' + + JSON.stringify( + '' + + '' + + '', + ) + + '));' + + 'ui.on("fail", "click", () => { throw new Error("retention fixture failure"); });', +}; + +interface RealmProbe { + id: string; + inits: number; +} + +interface LifecycleEvent { + kind: 'init' | 'pagehide'; + realm: string; +} + +type ProbeWindow = Window & { + __pr03Realm: RealmProbe; + __pr03Record(event: LifecycleEvent): Promise; + __pr03QueueParking?: Promise<{ + waiting: number; + expired: boolean; + status: string | null | undefined; + notice: string | null | undefined; + }>; + __pr03ReplyGate: { + hold: boolean; + waiting: number; + expired: boolean; + holdFor(): void; + release(): void; + }; +}; + +let appHtml: string; +let toolResult: Record; +let sandbox: Awaited>; + +test.beforeAll(async () => { + sandbox = await startGeneratedUiSandbox(); + appHtml = sandbox.appHtml; + toolResult = sandbox.createShowResult(input); +}); + +test.afterAll(async () => { + await sandbox?.close(); +}); + +async function observeRuntime(page: Page, holdInit = false) { + const lifecycle: LifecycleEvent[] = []; + const workers: Array<{ nonce: Promise; closed: boolean }> = []; + await page.exposeFunction('__pr03Record', (event: LifecycleEvent) => { + lifecycle.push(event); + }); + await page.addInitScript( + ({ holdInit }) => { + const target = window as ProbeWindow; + target.__pr03Realm = { + id: Array.from(crypto.getRandomValues(new Uint32Array(4))).join('-'), + inits: 0, + }; + const held: Array<() => void> = []; + let holdMilliseconds = holdInit ? 3_500 : 750; + let timer: ReturnType | undefined; + target.__pr03ReplyGate = { + hold: holdInit, + waiting: 0, + expired: false, + holdFor() { + this.release(); + this.expired = false; + this.hold = true; + holdMilliseconds = 750; + }, + release() { + clearTimeout(timer); + timer = undefined; + this.hold = false; + this.waiting = 0; + for (const deliver of held.splice(0)) deliver(); + }, + }; + window.addEventListener('pagehide', () => { + void target + .__pr03Record({ kind: 'pagehide', realm: target.__pr03Realm.id }) + .catch(() => {}); + }); + if (typeof Worker === 'undefined') return; + const onmessage = Object.getOwnPropertyDescriptor( + Worker.prototype, + 'onmessage', + )!; + Object.defineProperty(Worker.prototype, 'onmessage', { + configurable: true, + get: onmessage.get, + set(this: Worker, listener: Worker['onmessage']) { + onmessage.set!.call( + this, + listener && + ((event: MessageEvent) => { + if (target.__pr03ReplyGate.hold) { + if (!held.length) + timer = setTimeout(() => { + target.__pr03ReplyGate.expired = true; + target.__pr03ReplyGate.release(); + }, holdMilliseconds); + held.push(() => listener.call(this, event)); + target.__pr03ReplyGate.waiting = held.length; + } else listener.call(this, event); + }), + ); + }, + }); + const postMessage = Worker.prototype.postMessage; + Worker.prototype.postMessage = function ( + this: Worker, + ...args: Parameters + ) { + const message = args[0] as { type?: unknown } | null; + if (message && typeof message === 'object' && message.type === 'init') { + target.__pr03Realm.inits += 1; + void target + .__pr03Record({ kind: 'init', realm: target.__pr03Realm.id }) + .catch(() => {}); + } + return Reflect.apply(postMessage, this, args); + }; + }, + { holdInit }, + ); + page.on('worker', (worker) => { + const record = { + nonce: worker + .evaluate(() => { + const target = globalThis as typeof globalThis & { + __pr03Worker?: string; + }; + target.__pr03Worker ??= Array.from( + crypto.getRandomValues(new Uint32Array(4)), + ).join('-'); + return target.__pr03Worker; + }) + .catch(() => { + record.closed = true; + return ''; + }), + closed: false, + }; + worker.on('close', () => { + record.closed = true; + }); + workers.push(record); + }); + const liveWorkerNonces = async () => { + const values = await Promise.all( + workers.map(async (record) => ({ record, nonce: await record.nonce })), + ); + return values + .filter(({ record, nonce }) => !record.closed && nonce !== '') + .map(({ nonce }) => nonce); + }; + return { + lifecycle, + workers, + liveWorkerNonces, + async snapshot(app: FrameLocator | Frame) { + const realm = await app.locator('body').evaluate(() => { + const target = window as ProbeWindow; + return target.__pr03Realm; + }); + return { + realm, + workerNonces: await liveWorkerNonces(), + workersCreated: workers.length, + workersClosed: workers.filter((worker) => worker.closed).length, + pagehides: lifecycle.filter((event) => event.kind === 'pagehide'), + }; + }, + }; +} + +async function openCards( + page: Page, + testInfo: TestInfo, + { + count = 1, + virtualized = false, + moveBefore = true, + dataMode = false, + holdInit = false, + history = false, + } = {}, +) { + await page.setViewportSize({ width: history ? 1600 : 1280, height: 900 }); + await page.route('**/workspace/models', (route) => + route.fulfill({ json: { models: [] } }), + ); + const probe = await observeRuntime(page, holdInit); + if (!moveBefore) { + await page.addInitScript(() => { + Object.defineProperty(Element.prototype, 'moveBefore', { + value: undefined, + configurable: true, + }); + }); + } + const earlier = virtualized + ? Array.from({ length: 120 }, (_, index) => [ + userTextEvent(`Earlier question ${index}`, { id: index * 2 + 1 }), + assistantTextEvent(`Earlier answer ${index}`, { id: index * 2 + 2 }), + ]).flat() + : []; + const firstId = earlier.length + 1; + const scenario = createWebShellDaemonScenario({ + sessionId: 'generated-ui-retention', + state: { _meta: { generatedUi: { version: 1, enabled: true } } }, + events: [ + ...earlier, + history + ? { + v: 1, + id: firstId, + type: 'session_update', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { + type: 'text', + text: 'Show a retained parameter view.', + }, + _meta: { + 'qwen.session.recordId': 'retention-live-record', + qwenTranscript: { + sourceRecordIds: ['retention-live-record'], + }, + }, + }, + }, + } + : userTextEvent('Show a retained parameter view.', { id: firstId }), + ...Array.from({ length: count }, (_, index) => + toolCallEvent( + `generated-retention-${index}`, + 'mcp__generated-ui__generated_ui_show', + input, + { + id: firstId + index + 1, + rawOutput: { + type: 'mcp_app', + requiredFeature: 'generated-ui', + serverName: 'generated-ui', + resourceUri, + html: appHtml, + toolResult, + toolArguments: input, + fallbackText: input.summary, + }, + }, + ), + ), + turnCompleteEvent('retained', { id: firstId + count + 1 }), + ], + }); + if (history) + scenario.capabilities.features.push( + 'session_turn_navigation', + 'session_transcript_pagination', + ); + const daemon = await installMockDaemon(page, scenario, { + baseURL: String(testInfo.project.use.baseURL), + }); + let eventEpoch = 'generated-ui-retention-epoch-1'; + let loadCount = 0; + // HTTP/SSE data are fixtures. React, transcript projection, the cross-origin + // sandbox, fixed App, bridge, and QuickJS Worker are production components. + await page.route('**/session/*/load', (route) => { + loadCount += 1; + return route.fulfill({ + json: { + sessionId: scenario.sessionId, + clientId: scenario.clientId, + workspaceCwd: scenario.workspaceCwd, + eventEpoch, + attached: true, + createdAt: '2026-10-26T00:00:00.000Z', + hasActivePrompt: false, + generatedUi: scenario.state._meta?.['generatedUi'], + state: scenario.state, + compactedReplay: scenario.events, + liveJournal: [], + lastEventId: firstId + count + 1, + ...(history + ? { + historyHasMore: true, + historyAnchorRecordId: 'retention-live-record', + } + : {}), + }, + }); + }); + if (history) { + await page.route('**/session/*/turn-index*', (route) => + route.fulfill({ + json: { + v: 1, + sessionId: scenario.sessionId, + snapshot: 'retention-history', + totalTurns: 4, + start: 0, + turns: Array.from({ length: 4 }, (_, ordinal) => ({ + ordinal, + turnId: + ordinal === 3 + ? 'retention-live-record' + : `retention-old-${ordinal}`, + kind: 'prompt', + label: `Retained history ${ordinal}`, + })), + }, + }), + ); + await page.route('**/session/*/transcript*', (route) => { + const at = new URL(route.request().url()).searchParams.get('atRecordId'); + // The initial underfill request must not prepend the navigation target + // into the live list; the anchored request below rebuilds MessageList. + if (!at) + return route.fulfill({ + json: { + v: 1, + sessionId: scenario.sessionId, + events: [], + hasMore: false, + }, + }); + return route.fulfill({ + json: { + v: 1, + sessionId: scenario.sessionId, + hasMore: false, + hasOlder: false, + targetRecordId: at, + events: [ + { + v: 1, + id: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { + type: 'text', + text: 'An earlier retained-history question.', + }, + _meta: { + 'qwen.session.recordId': at, + qwenTranscript: { sourceRecordIds: [at] }, + }, + }, + }, + }, + { + v: 1, + id: 2, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: 'An earlier retained-history answer.', + }, + _meta: { + 'qwen.session.recordId': `${at}-answer`, + qwenTranscript: { sourceRecordIds: [`${at}-answer`] }, + }, + }, + }, + }, + ], + }, + }); + }); + } + await page.route('**/mcp-app-sandbox?*', async (route) => { + const requested = new URL(route.request().url()); + if (dataMode) requested.searchParams.set('mode', 'data'); + const response = await fetch( + `${sandbox.url}${requested.pathname}${requested.search}`, + { redirect: 'manual' }, + ); + await route.fulfill({ + status: response.status, + headers: Object.fromEntries(response.headers), + body: await response.text(), + }); + }); + await page.goto(`/session/${scenario.sessionId}?language=en`); + await daemon.sse.waitForConnection(scenario.sessionId); + await daemon.sendEvent( + replayCompleteEvent({ + sessionId: scenario.sessionId, + replayedCount: scenario.events.length, + }), + ); + const cards = page.locator(frameSelector); + const scroll = page.locator('[data-web-shell-message-list]'); + const visibleCards = scroll.locator(frameSelector); + const app = (index = 0) => + cards.nth(index).contentFrame().frameLocator('iframe'); + await expect(cards).toHaveCount(count); + for (let index = 0; index < count; index++) { + await expect(app(index).locator('#status')).toHaveAttribute( + 'data-state', + holdInit ? 'starting' : 'ready', + ); + } + await expect.poll(() => probe.workers.length).toBe(count); + return { + daemon, + scenario, + probe, + cards, + visibleCards, + scroll, + app, + loadCount: () => loadCount, + changeEpoch: () => { + eventEpoch = 'generated-ui-retention-epoch-2'; + }, + async park() { + await scroll.hover({ position: { x: 10, y: 10 } }); + await page.mouse.wheel(0, -100_000); + await expect(visibleCards).toHaveCount(0); + }, + async returnToCard() { + await scroll.hover({ position: { x: 10, y: 10 } }); + await page.mouse.wheel(0, 100_000); + await expect(visibleCards).toHaveCount(count); + await visibleCards.last().scrollIntoViewIfNeeded(); + }, + }; +} + +async function reviewSnapshot(page: Page, app: FrameLocator) { + await app.locator('#continue-analysis').click(); + const review = page.getByRole('dialog', { name: 'Review App message' }); + await expect(review).toBeVisible(); + const text = (await review.locator('pre').textContent())!; + const payload = JSON.parse( + text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1), + ) as { + snapshot: { documentId: string; activationId: string }; + rejectedEvents: number; + }; + await review.getByRole('button', { name: /^cancel$/i }).click(); + await expect(review).not.toBeVisible(); + await expect(app.locator('#continue-analysis')).toBeEnabled(); + return { ...payload.snapshot, rejectedEvents: payload.rejectedEvents }; +} + +interface HostRpcReply { + result?: { + isError?: boolean; + content?: Array<{ type: string; text?: string }>; + }; + error?: { code: number; message: string }; + timedOut?: boolean; +} + +async function appRpc( + app: FrameLocator | Frame, + method: 'ui/message' | 'tools/call', + params: Record, +): Promise { + // This adversarial request tests the real host lease. It is sent from the + // actual fixed App through its sandbox proxy, not used to click hidden UI. + return app.locator('body').evaluate( + (_body, { method, params }) => + new Promise((resolve) => { + const id = `pr03-visibility-${Array.from( + crypto.getRandomValues(new Uint32Array(4)), + ).join('-')}`; + const listener = (event: MessageEvent) => { + if (event.source !== window.parent || event.data?.id !== id) return; + window.removeEventListener('message', listener); + clearTimeout(timer); + resolve(event.data as HostRpcReply); + }; + const timer = setTimeout(() => { + window.removeEventListener('message', listener); + resolve({ timedOut: true }); + }, 3000); + window.addEventListener('message', listener); + window.parent.postMessage({ jsonrpc: '2.0', id, method, params }, '*'); + }), + { method, params }, + ); +} + +async function openNavigationPage(page: Page, name: string) { + await page + .locator('[data-web-shell-navigation-rail]') + .getByRole('button', { name: 'More', exact: true }) + .click(); + await page + .locator('[data-web-shell-sidebar-more]') + .getByRole('button', { name, exact: true }) + .click(); +} + +const hiddenMessage = { + role: 'user', + content: [ + { + type: 'text', + text: 'Hidden Generated UI must not obtain host review or dispatch authority.', + }, + ], +}; + +test('retains the actual App and Worker across virtual row unmounts @smoke', async ({ + page, +}, testInfo) => { + const fixture = await openCards(page, testInfo, { virtualized: true }); + const { app, cards, probe, daemon } = fixture; + const view = app().frameLocator('#view'); + await cards.first().scrollIntoViewIfNeeded(); + expect( + await app() + .locator('body') + .evaluate(() => { + try { + return !!window.top?.document; + } catch { + return false; + } + }), + ).toBe(false); + await view.locator('#rate').fill('7'); + await view.getByRole('button', { name: 'Apply', exact: true }).click(); + await expect(view.locator('#answer')).toHaveText('1:7'); + await view.getByRole('button', { name: 'Insert controls' }).click(); + await view.locator('#two').check(); + await view.locator('#choice').selectOption('b'); + await view.locator('#rate').fill('9'); + await expect(app().locator('#status')).toHaveAttribute('data-state', 'dirty'); + const before = await probe.snapshot(app()); + expect(before.realm.inits).toBe(1); + expect(before.workerNonces).toHaveLength(1); + await fixture.park(); + await expect(cards).toHaveCount(1); + await expect(cards).toBeHidden(); + expect(daemon.promptRequests()).toHaveLength(0); + await fixture.returnToCard(); + expect(await probe.snapshot(app())).toEqual(before); + await expect(view.locator('#rate')).toHaveValue('9'); + await expect(view.locator('#two')).toBeChecked(); + await expect(view.locator('#one')).not.toBeChecked(); + await expect(view.locator('#choice')).toHaveValue('b'); + await expect(app().locator('#continue-analysis')).toBeDisabled(); + await view.getByRole('button', { name: 'Apply', exact: true }).click(); + await expect(view.locator('#answer')).toHaveText('2:9'); + await expect(app().locator('#continue-analysis')).toBeEnabled(); + await testInfo.attach('retained-runtime-identities', { + body: JSON.stringify( + { before, after: await probe.snapshot(app()) }, + null, + 2, + ), + contentType: 'application/json', + }); + expect(daemon.promptRequests()).toHaveLength(0); +}); + +for (const { status, stopped } of [ + { status: 404, stopped: false }, + { status: 401, stopped: false }, + { status: 401, stopped: true }, + { status: 503, stopped: false }, +]) { + test(`handles retained ${stopped ? 'stopped ' : ''}App and Worker lifetime after load returns ${status} @smoke`, async ({ + page, + }, testInfo) => { + const { app, cards, probe, daemon, scenario } = await openCards( + page, + testInfo, + ); + const view = app().frameLocator('#view'); + await view.locator('#rate').fill('7'); + await view.getByRole('button', { name: 'Apply', exact: true }).click(); + await expect(view.locator('#answer')).toHaveText('1:7'); + await view.locator('#rate').fill('9'); + await expect(app().locator('#status')).toHaveAttribute( + 'data-state', + 'dirty', + ); + if (stopped) { + await app().locator('#stop').click(); + await expect(app().locator('#status')).toHaveAttribute( + 'data-state', + 'stopped', + ); + await expect + .poll(() => probe.workers.filter(({ closed }) => closed).length) + .toBe(1); + } + const before = await probe.snapshot(app()); + const mainRealm = await page.evaluate( + () => (window as ProbeWindow).__pr03Realm.id, + ); + + await page.route('**/session/previous-session/load', (route) => + route.fulfill({ + json: { + sessionId: 'previous-session', + clientId: scenario.clientId, + workspaceCwd: scenario.workspaceCwd, + eventEpoch: 'generated-ui-retention-epoch-1', + attached: true, + hasActivePrompt: false, + state: scenario.state, + generatedUi: scenario.state._meta?.['generatedUi'], + compactedReplay: [], + liveJournal: [], + lastEventId: 0, + }, + }), + ); + const sidebar = page.locator('[data-sidebar-shell]'); + await sidebar.getByText('Previous Session', { exact: true }).click(); + await daemon.sse.waitForConnection('previous-session'); + await daemon.sendEvent( + replayCompleteEvent({ sessionId: 'previous-session' }), + ); + await expect(page).toHaveURL(/\/session\/previous-session/); + await expect(cards).toHaveCount(1); + await expect(cards).toBeHidden(); + expect(await probe.snapshot(app())).toEqual(before); + + const failurePath = `/session/${scenario.sessionId}/load`; + const errorMessage = `Retention load failure ${status}`; + await page.route(`**${failurePath}`, (route) => + route.fulfill({ status, json: { error: errorMessage } }), + ); + const failedLoad = page.waitForResponse( + (response) => + new URL(response.url()).pathname === failurePath && + response.request().method() === 'POST', + ); + await sidebar.getByText(scenario.displayName, { exact: true }).click(); + expect((await failedLoad).status()).toBe(status); + await expect(page.getByText(errorMessage).first()).toBeVisible(); + if (status === 503) { + await expect(cards).toHaveCount(1); + await expect(cards).toBeHidden(); + expect(await probe.snapshot(app())).toEqual(before); + await expect(view.locator('#rate')).toHaveValue('9'); + await expect(view.locator('#answer')).toHaveText('1:7'); + } else { + await expect(cards).toHaveCount(0); + await expect + .poll(() => probe.workers.every(({ closed }) => closed)) + .toBe(true); + } + await page.unroute(`**${failurePath}`); + await sidebar.getByText('Previous Session', { exact: true }).click(); + await daemon.sse.waitForConnection('previous-session'); + const priorConnections = await daemon.sse.connections(); + await sidebar.getByText(scenario.displayName, { exact: true }).click(); + await expect + .poll(async () => + (await daemon.sse.connections()).some( + (connection) => + connection.sessionId === scenario.sessionId && + !priorConnections.some( + (prior) => + prior.url === connection.url && + prior.connectedAt === connection.connectedAt, + ), + ), + ) + .toBe(true); + await daemon.sendEvent( + replayCompleteEvent({ + sessionId: scenario.sessionId, + replayedCount: scenario.events.length, + }), + ); + await expect( + page.locator('[data-web-shell-composer-surface] .cm-content'), + ).toBeEditable(); + if (status === 503) { + await expect(cards).toBeVisible(); + expect(await probe.snapshot(app())).toEqual(before); + await expect(view.locator('#rate')).toHaveValue('9'); + await expect(view.locator('#answer')).toHaveText('1:7'); + } else { + const reopen = page.getByRole('button', { + name: 'Reopen interactive view', + exact: true, + }); + await expect(reopen).toBeVisible(); + await expect(cards).toHaveCount(0); + await reopen.click(); + await expect(app().locator('#status')).toHaveAttribute( + 'data-state', + 'ready', + ); + await expect(app().frameLocator('#view').locator('#rate')).toHaveValue( + '2', + ); + await expect( + page.getByText(/Reopening starts from the original content/).first(), + ).toBeVisible(); + expect((await probe.snapshot(app())).realm.id).not.toBe(before.realm.id); + } + expect( + await page.evaluate(() => (window as ProbeWindow).__pr03Realm.id), + ).toBe(mainRealm); + expect(daemon.promptRequests()).toHaveLength(0); + }); +} + +test('folding preserves state and returns keyboard focus at narrow width @smoke', async ({ + page, +}, testInfo) => { + const { app, cards, probe, scroll, daemon } = await openCards(page, testInfo); + await page.setViewportSize({ width: 375, height: 900 }); + await cards.first().scrollIntoViewIfNeeded(); + await app().frameLocator('#view').locator('#rate').fill('6'); + const before = await probe.snapshot(app()); + const collapse = scroll.getByTitle('Collapse', { exact: true }).first(); + await collapse.click(); + await expect(cards).toBeHidden(); + await expect(cards).toHaveCount(1); + expect( + await cards.evaluate((frame) => frame.closest('[inert]') !== null), + ).toBe(true); + const expand = scroll.getByTitle('Expand', { exact: true }).first(); + await expect(expand).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(cards).toBeVisible(); + await expect(collapse).toBeFocused(); + expect(await probe.snapshot(app())).toEqual(before); + await expect(app().frameLocator('#view').locator('#rate')).toHaveValue('6'); + await expect(app().locator('#continue-analysis')).toBeDisabled(); + const bounds = await cards.boundingBox(); + expect(bounds).not.toBeNull(); + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(375); + const screenshot = testInfo.outputPath('retained-card-narrow.png'); + await page.screenshot({ path: screenshot, fullPage: true }); + await testInfo.attach('Retained card at 375px', { + path: screenshot, + contentType: 'image/png', + }); + expect(daemon.promptRequests()).toHaveLength(0); + await page.close(); + await expect + .poll(() => probe.workers.every(({ closed }) => closed)) + .toBe(true); +}); + +test('New task parks the same App and Worker and revokes host authority @smoke', async ({ + page, +}, testInfo) => { + const { app, cards, probe, daemon, scenario } = await openCards( + page, + testInfo, + ); + const view = app().frameLocator('#view'); + await view.locator('#rate').fill('7'); + await view.getByRole('button', { name: 'Apply', exact: true }).click(); + await expect(view.locator('#answer')).toHaveText('1:7'); + await view.locator('#rate').fill('9'); + const before = await probe.snapshot(app()); + await page + .getByRole('button', { name: 'New task', exact: true }) + .first() + .click(); + await expect(cards).toBeHidden(); + expect( + (await appRpc(app(), 'ui/message', hiddenMessage)).result?.isError, + ).toBe(true); + expect(await probe.snapshot(app())).toEqual(before); + await page + .locator('[data-sidebar-shell]') + .getByText(scenario.displayName, { exact: true }) + .click(); + await daemon.sse.waitForConnection(scenario.sessionId); + await expect(cards).toBeVisible(); + expect(await probe.snapshot(app())).toEqual(before); + await expect(view.locator('#rate')).toHaveValue('9'); + await expect(view.locator('#answer')).toHaveText('1:7'); + expect(daemon.promptRequests()).toHaveLength(0); +}); + +test('a missing session leaves the previous App parked without host authority @smoke', async ({ + page, +}, testInfo) => { + const { app, cards, probe, daemon } = await openCards(page, testInfo); + await app().frameLocator('#view').locator('#rate').fill('9'); + const before = await probe.snapshot(app()); + const calls: string[] = []; + await page.route('**/session/*/mcp-app/tools/call', (route) => { + calls.push(route.request().url()); + return route.fulfill({ json: { content: [] } }); + }); + await page.route('**/session/previous-session/load', (route) => + route.fulfill({ status: 404, json: { error: 'Session not found' } }), + ); + await page + .locator('[data-sidebar-shell]') + .getByText('Previous Session', { exact: true }) + .click(); + await expect(page.locator('[class*="missingSessionState"]')).toBeVisible(); + await expect(cards).toBeHidden(); + expect( + ( + await appRpc(app(), 'tools/call', { + name: 'generated_ui_show', + arguments: input, + }) + ).error, + ).toBeDefined(); + expect( + (await appRpc(app(), 'ui/message', hiddenMessage)).result?.isError, + ).toBe(true); + expect(calls).toEqual([]); + expect(daemon.promptRequests()).toHaveLength(0); + expect(await probe.snapshot(app())).toEqual(before); +}); + +test('a fullscreen artifact panel revokes split-card authority without rebuilding it @smoke', async ({ + page, +}, testInfo) => { + const { app, cards, probe, daemon, scenario } = await openCards( + page, + testInfo, + ); + const calls: string[] = []; + await page.route('**/session/*/mcp-app/tools/call', (route) => { + calls.push(route.request().url()); + return route.fulfill({ + json: { content: [{ type: 'text', text: 'visible tool' }] }, + }); + }); + await page + .getByRole('button', { name: 'Toggle right panel', exact: true }) + .click(); + await openNavigationPage(page, 'Split View'); + const pane = page + .getByTestId('split-view') + .locator(`[data-pane-session-id="${scenario.sessionId}"]`); + await expect(pane.locator(frameSelector)).toBeVisible(); + await app().frameLocator('#view').locator('#rate').fill('9'); + const before = await probe.snapshot(app()); + const tool = { name: 'generated_ui_show', arguments: input }; + expect( + (await appRpc(app(), 'tools/call', tool)).result?.content?.[0]?.text, + ).toBe('visible tool'); + await page.getByRole('button', { name: 'Fullscreen', exact: true }).click(); + await expect( + page.getByRole('button', { name: 'Exit fullscreen', exact: true }), + ).toBeVisible(); + await expect(cards).toBeHidden(); + expect((await appRpc(app(), 'tools/call', tool)).error).toBeDefined(); + expect( + (await appRpc(app(), 'ui/message', hiddenMessage)).result?.isError, + ).toBe(true); + expect(calls).toHaveLength(1); + expect(daemon.promptRequests()).toHaveLength(0); + expect(await probe.snapshot(app())).toEqual(before); + await page + .getByRole('button', { name: 'Exit fullscreen', exact: true }) + .click(); + await expect(pane.locator(frameSelector)).toBeVisible(); + expect(await probe.snapshot(app())).toEqual(before); + await expect(app().frameLocator('#view').locator('#rate')).toHaveValue('9'); +}); + +test('the reply gate automatically releases before the event watchdog', async ({ + page, +}, testInfo) => { + const { app, probe } = await openCards(page, testInfo); + const before = await probe.snapshot(app()); + await app() + .locator('body') + .evaluate(() => (window as ProbeWindow).__pr03ReplyGate.holdFor()); + await app() + .frameLocator('#view') + .getByRole('button', { name: 'Apply', exact: true }) + .click(); + await expect + .poll(() => + app() + .locator('body') + .evaluate(() => { + const gate = (window as ProbeWindow).__pr03ReplyGate; + return { + hold: gate.hold, + waiting: gate.waiting, + expired: gate.expired, + }; + }), + ) + .toEqual({ hold: false, waiting: 0, expired: true }); + await expect(app().locator('#status')).toHaveAttribute('data-state', 'ready'); + expect( + await app() + .locator('body') + .evaluate(() => (window as ProbeWindow).__pr03ReplyGate.expired), + ).toBe(true); + expect(await probe.snapshot(app())).toEqual(before); +}); + +test('history navigation rebuilds MessageList without rebuilding the live card @smoke', async ({ + page, +}, testInfo) => { + const fixture = await openCards(page, testInfo, { history: true }); + const { app, cards, probe, daemon } = fixture; + await cards.first().scrollIntoViewIfNeeded(); + await app().frameLocator('#view').locator('#rate').fill('23'); + const before = await probe.snapshot(app()); + const navigation = page.locator('[data-global-turn-navigation]'); + await expect(navigation).toBeVisible(); + await navigation.locator('[data-turn-ordinal="0"]').click(); + await expect( + page.locator('[data-history-viewport="historical"]'), + ).toContainText('An earlier retained-history answer.'); + await expect( + page.locator('[data-history-viewport="historical"]').locator(frameSelector), + ).toHaveCount(0); + await expect(cards).toHaveCount(1); + await expect(cards).toBeHidden(); + await page + .getByRole('button', { name: 'Scroll to bottom', exact: true }) + .click(); + await expect( + page.locator('[data-history-viewport="live"]').locator(frameSelector), + ).toHaveCount(1); + await cards.first().scrollIntoViewIfNeeded(); + expect(await probe.snapshot(app())).toEqual(before); + await expect(app().frameLocator('#view').locator('#rate')).toHaveValue('23'); + await expect(app().locator('#continue-analysis')).toBeDisabled(); + expect(daemon.promptRequests()).toHaveLength(0); +}); + +test('a real initialization reply can finish while its frame is parked', async ({ + page, +}, testInfo) => { + const fixture = await openCards(page, testInfo, { + virtualized: true, + holdInit: true, + }); + const { app, cards, probe, daemon } = fixture; + await expect + .poll(() => + app() + .locator('body') + .evaluate(() => (window as ProbeWindow).__pr03ReplyGate.waiting), + ) + .toBe(1); + const before = await probe.snapshot(app()); + expect(before.realm.inits).toBe(1); + await fixture.park(); + await expect(cards).toHaveCount(1); + const heldAtParking = await app() + .locator('body') + .evaluate(() => { + const gate = (window as ProbeWindow).__pr03ReplyGate; + const result = { waiting: gate.waiting, expired: gate.expired }; + gate.release(); + return result; + }); + expect( + heldAtParking.expired, + 'Initialization gate expired before parking', + ).toBe(false); + expect(heldAtParking.waiting).toBe(1); + await expect(app().locator('#status')).toHaveAttribute('data-state', 'ready'); + await fixture.returnToCard(); + expect(await probe.snapshot(app())).toEqual(before); + await app() + .frameLocator('#view') + .getByRole('button', { name: 'Apply', exact: true }) + .click(); + await expect(app().frameLocator('#view').locator('#answer')).toHaveText( + '1:2', + ); + expect(daemon.promptRequests()).toHaveLength(0); +}); + +test('parking with a full queue preserves admitted work and rejected-event count', async ({ + page, +}, testInfo) => { + const fixture = await openCards(page, testInfo, { virtualized: true }); + const { app, cards, probe, daemon, visibleCards } = fixture; + await cards.first().scrollIntoViewIfNeeded(); + const before = await probe.snapshot(app()); + await app() + .locator('body') + .evaluate(() => { + const target = window as Window & { __pr03ApplyClicks: number }; + target.__pr03ApplyClicks = 0; + // Register from the fixed App: #view deliberately disallows scripts. + const body = + document.querySelector('#view')!.contentDocument! + .body; + body.addEventListener('click', (event) => { + if ( + event.isTrusted && + (event.target as Element | null)?.closest('#apply') + ) { + target.__pr03ApplyClicks++; + if (target.__pr03ApplyClicks === 18) + window.top!.postMessage({ type: 'pr03-park-queue' }, '*'); + } + }); + }); + // Keep cross-origin parking and reply delivery within the browser's budget. + await page.evaluate((selector) => { + const frame = document.querySelector(selector)!; + const park = (event: MessageEvent) => { + if (event.data?.type !== 'pr03-park-queue') return; + window.removeEventListener('message', park); + const observer = new MutationObserver(() => { + if (frame.closest('[data-generated-ui-parking]')) { + observer.disconnect(); + (event.source as Window).postMessage( + { type: 'pr03-queue-parked' }, + '*', + ); + } + }); + observer.observe(document, { childList: true, subtree: true }); + document.querySelector( + '[data-web-shell-message-list]', + )!.scrollTop = 0; + }; + window.addEventListener('message', park); + }, frameSelector); + await app() + .locator('body') + .evaluate(() => { + const target = window as ProbeWindow; + target.__pr03QueueParking = new Promise((resolve) => { + const parked = (event: MessageEvent) => { + if ( + event.source !== window.top || + event.data?.type !== 'pr03-queue-parked' + ) + return; + window.removeEventListener('message', parked); + const gate = target.__pr03ReplyGate; + const result = { + waiting: gate.waiting, + expired: gate.expired, + status: document + .querySelector('#status') + ?.getAttribute('data-state'), + notice: document.querySelector('#notice')?.textContent, + }; + gate.release(); + resolve(result); + }; + window.addEventListener('message', parked); + }); + target.__pr03ReplyGate.holdFor(); + }); + // Delay delivery only: the real Worker computes its unchanged response. + // Native clicks fill the existing one-in-flight + 16 waiting queue. + await app() + .frameLocator('#view') + .getByRole('button', { name: 'Apply', exact: true }) + .click({ clickCount: 20 }); + const heldAtParking = await app() + .locator('body') + .evaluate(() => (window as ProbeWindow).__pr03QueueParking!); + expect(heldAtParking.status).toBe('running'); + expect(heldAtParking.notice).toContain('queue was full'); + expect( + heldAtParking.expired, + 'Reply gate expired before parking completed', + ).toBe(false); + expect(heldAtParking.waiting).toBeGreaterThan(0); + await expect(visibleCards).toHaveCount(0); + const deliveredClicks = await app() + .locator('body') + .evaluate(() => { + return (window as Window & { __pr03ApplyClicks: number }) + .__pr03ApplyClicks; + }); + await testInfo.attach('native-click-admission', { + body: JSON.stringify({ requestedClicks: 20, deliveredClicks }), + contentType: 'application/json', + }); + // The queue notice changes the frame's layout mid-burst. Count actual + // trusted clicks reaching Apply instead of assuming every mouse-up hits it. + expect(deliveredClicks).toBeGreaterThan(17); + await expect(app().locator('#status')).toHaveAttribute('data-state', 'ready'); + await fixture.returnToCard(); + expect(await probe.snapshot(app())).toEqual(before); + await expect(app().frameLocator('#view').locator('#answer')).toHaveText( + '17:2', + ); + await expect(app().locator('#notice')).toContainText('queue was full'); + const capture = await reviewSnapshot(page, app()); + expect(capture.rejectedEvents).toBe(deliveredClicks - 17); + await fixture.park(); + await fixture.returnToCard(); + expect(await probe.snapshot(app())).toEqual(before); + await expect(app().frameLocator('#view').locator('#answer')).toHaveText( + '17:2', + ); + const returnedCapture = await reviewSnapshot(page, app()); + expect(returnedCapture).toEqual(capture); + await testInfo.attach('retained-queue-captures', { + body: JSON.stringify({ deliveredClicks, capture, returnedCapture }), + contentType: 'application/json', + }); + expect(daemon.promptRequests()).toHaveLength(0); +}); + +test('retains the opaque data-mode App and its Worker across parking', async ({ + page, +}, testInfo) => { + const fixture = await openCards(page, testInfo, { + virtualized: true, + dataMode: true, + }); + const { app, cards, probe, daemon } = fixture; + await cards.first().scrollIntoViewIfNeeded(); + expect( + await app() + .locator('body') + .evaluate(() => window.origin), + ).toBe('null'); + await app().frameLocator('#view').locator('#rate').fill('17'); + const before = await probe.snapshot(app()); + expect(before.realm.inits).toBe(1); + await fixture.park(); + await fixture.returnToCard(); + expect(await probe.snapshot(app())).toEqual(before); + await expect(app().frameLocator('#view').locator('#rate')).toHaveValue('17'); + await expect(app().locator('#continue-analysis')).toBeDisabled(); + expect(daemon.promptRequests()).toHaveLength(0); +}); + +test('parking a fifth card evicts only the oldest runtime and requires reopening', async ({ + page, +}, testInfo) => { + const fixture = await openCards(page, testInfo, { + count: 5, + virtualized: true, + }); + const { app, cards, probe, daemon, scroll, visibleCards } = fixture; + const siblingRealms: RealmProbe[] = []; + for (let index = 0; index < 5; index++) { + await cards.nth(index).scrollIntoViewIfNeeded(); + await app(index) + .frameLocator('#view') + .locator('#rate') + .fill(String(index + 3)); + if (index > 0) siblingRealms.push((await probe.snapshot(app(index))).realm); + } + await fixture.park(); + await expect(cards).toHaveCount(4); + await expect + .poll(() => probe.workers.filter(({ closed }) => closed).length) + .toBe(1); + expect(probe.workers).toHaveLength(5); + await scroll.hover({ position: { x: 10, y: 10 } }); + await page.mouse.wheel(0, 100_000); + await expect(visibleCards).toHaveCount(4); + const reopen = page.getByRole('button', { name: /Reopen interactive view/i }); + await expect(reopen).toHaveCount(1); + for (let index = 0; index < 4; index++) { + await expect(app(index).frameLocator('#view').locator('#rate')).toHaveValue( + String(index + 4), + ); + } + await reopen.scrollIntoViewIfNeeded(); + await reopen.click(); + await expect(cards).toHaveCount(5); + await expect(app().locator('#status')).toHaveAttribute('data-state', 'ready'); + await expect(app().frameLocator('#view').locator('#rate')).toHaveValue('2'); + await expect.poll(() => probe.workers.length).toBe(6); + for (let index = 0; index < 4; index++) { + expect((await probe.snapshot(app(index + 1))).realm).toEqual( + siblingRealms[index], + ); + await expect( + app(index + 1) + .frameLocator('#view') + .locator('#rate'), + ).toHaveValue(String(index + 4)); + } + expect(probe.workers).toHaveLength(6); + expect(daemon.promptRequests()).toHaveLength(0); +}); + +test('parking cancels a pending review and never repeats an accepted request', async ({ + page, +}, testInfo) => { + const fixture = await openCards(page, testInfo, { virtualized: true }); + const { app, cards, scroll, daemon, scenario, visibleCards, probe } = fixture; + await cards.first().scrollIntoViewIfNeeded(); + const before = await probe.snapshot(app()); + await app().locator('#continue-analysis').click(); + const review = page.getByRole('dialog', { name: 'Review App message' }); + await expect(review).toBeVisible(); + // The modal deliberately blocks user scrolling. Change the actual viewport + // to exercise a row eviction while its asynchronous review is still open. + await scroll.evaluate((element) => { + element.scrollTop = 0; + }); + await expect(visibleCards).toHaveCount(0); + await expect(review).toContainText('Cancelled before sending.'); + expect(daemon.promptRequests()).toHaveLength(0); + await review.getByRole('button', { name: /^close$/i }).click(); + await fixture.returnToCard(); + expect(await probe.snapshot(app())).toEqual(before); + await expect(app().locator('#continue-analysis')).toBeEnabled(); + await app().frameLocator('#view').locator('#rate').fill('12'); + await app() + .frameLocator('#view') + .getByRole('button', { + name: 'Apply', + exact: true, + }) + .click(); + await app().locator('#continue-analysis').click(); + await expect(review.locator('pre')).toContainText('"12"'); + await review.getByRole('button', { name: 'Send to session' }).click(); + await expect.poll(() => daemon.promptRequests().length).toBe(1); + expect(daemon.promptRequests()[0].path).toBe( + `/session/${scenario.sessionId}/prompt`, + ); + await expect(app().locator('#continuation-status')).toHaveAttribute( + 'data-state', + 'accepted', + ); + await review.getByRole('button', { name: /^close$/i }).click(); + await fixture.park(); + expect(daemon.promptRequests()).toHaveLength(1); + await fixture.returnToCard(); + await expect(app().locator('#continuation-status')).toHaveAttribute( + 'data-state', + 'accepted', + ); + expect(await probe.snapshot(app())).toEqual(before); + expect(daemon.promptRequests()).toHaveLength(1); +}); + +test('identical content from different tool calls has separate activations and state @smoke', async ({ + page, +}, testInfo) => { + const { app, cards, probe, daemon } = await openCards(page, testInfo, { + count: 2, + }); + await cards.nth(0).scrollIntoViewIfNeeded(); + const first = await reviewSnapshot(page, app(0)); + const firstRealm = (await probe.snapshot(app(0))).realm; + await app(0).frameLocator('#view').locator('#rate').fill('11'); + await cards.nth(1).scrollIntoViewIfNeeded(); + const second = await reviewSnapshot(page, app(1)); + const secondRealm = (await probe.snapshot(app(1))).realm; + expect(first.documentId).toBe(second.documentId); + expect(first.activationId).not.toBe(second.activationId); + expect(firstRealm.id).not.toBe(secondRealm.id); + expect(new Set(await probe.liveWorkerNonces()).size).toBe(2); + await expect(app(1).frameLocator('#view').locator('#rate')).toHaveValue('2'); + await cards.nth(0).scrollIntoViewIfNeeded(); + await expect(app(0).frameLocator('#view').locator('#rate')).toHaveValue('11'); + expect(daemon.promptRequests()).toHaveLength(0); +}); + +for (const terminal of ['stopped', 'error'] as const) { + test(`${terminal} stays terminal across parking until an explicit host reset @smoke`, async ({ + page, + }, testInfo) => { + const fixture = await openCards(page, testInfo, { virtualized: true }); + const { app, cards, probe, daemon } = fixture; + await cards.first().scrollIntoViewIfNeeded(); + const oldSnapshot = await reviewSnapshot(page, app()); + const before = await probe.snapshot(app()); + if (terminal === 'stopped') await app().locator('#stop').click(); + else + await app() + .frameLocator('#view') + .getByRole('button', { + name: 'Fail runtime', + }) + .click(); + await expect(app().locator('#status')).toHaveAttribute( + 'data-state', + terminal, + ); + await expect + .poll(() => probe.workers.filter(({ closed }) => closed).length) + .toBe(1); + await fixture.park(); + await fixture.returnToCard(); + await expect(app().locator('#status')).toHaveAttribute( + 'data-state', + terminal, + ); + expect((await probe.snapshot(app())).realm).toEqual(before.realm); + expect(probe.workers).toHaveLength(1); + await page + .getByRole('button', { name: 'Reopen and reset', exact: true }) + .click(); + await expect(app().locator('#status')).toHaveAttribute( + 'data-state', + 'ready', + ); + await expect.poll(() => probe.workers.length).toBe(2); + await cards.first().scrollIntoViewIfNeeded(); + const reopened = await reviewSnapshot(page, app()); + expect(reopened.activationId).not.toBe(oldSnapshot.activationId); + expect((await probe.snapshot(app())).realm.id).not.toBe(before.realm.id); + await expect(app().frameLocator('#view').locator('#answer')).toHaveText( + '0:2', + ); + expect(daemon.promptRequests()).toHaveLength(0); + }); +} + +test('missing moveBefore offers an explicit cold start after a row unmount', async ({ + page, +}, testInfo) => { + const fixture = await openCards(page, testInfo, { + virtualized: true, + moveBefore: false, + }); + const { app, cards, probe, daemon, scroll } = fixture; + await cards.first().scrollIntoViewIfNeeded(); + const before = await probe.snapshot(app()); + await app().frameLocator('#view').locator('#rate').fill('15'); + await fixture.park(); + await expect(cards).toHaveCount(0); + await expect + .poll(() => probe.workers.filter(({ closed }) => closed).length) + .toBe(1); + await scroll.hover({ position: { x: 10, y: 10 } }); + await page.mouse.wheel(0, 100_000); + const reopen = page.getByRole('button', { name: /Reopen interactive view/i }); + await expect(reopen).toBeVisible(); + expect(probe.workers).toHaveLength(1); + await reopen.click(); + await expect(app().locator('#status')).toHaveAttribute('data-state', 'ready'); + expect((await probe.snapshot(app())).realm.id).not.toBe(before.realm.id); + await expect(app().frameLocator('#view').locator('#rate')).toHaveValue('2'); + expect(daemon.promptRequests()).toHaveLength(0); +}); + +test('observed policy revocation releases a parked runtime without dispatch', async ({ + page, +}, testInfo) => { + const fixture = await openCards(page, testInfo, { virtualized: true }); + const { daemon, scenario, cards, probe, scroll } = fixture; + await fixture.park(); + await expect(cards).toHaveCount(1); + scenario.state._meta = { generatedUi: { version: 1, enabled: false } }; + await daemon.sendEvent({ + v: 1, + type: 'settings_changed', + data: { key: 'mcp.allowed', scope: 'workspace', value: [] }, + }); + await expect(cards).toHaveCount(0); + await expect + .poll(() => probe.workers.filter(({ closed }) => closed).length) + .toBe(1); + await scroll.hover({ position: { x: 10, y: 10 } }); + await page.mouse.wheel(0, 100_000); + await expect(page.getByText(input.summary, { exact: true })).toBeVisible(); + expect(probe.workers).toHaveLength(1); + expect(daemon.promptRequests()).toHaveLength(0); +}); + +test('epoch reset never reattaches the previous runtime', async ({ + page, +}, testInfo) => { + const fixture = await openCards(page, testInfo, { virtualized: true }); + const { daemon, cards, probe, scroll } = fixture; + await cards.first().scrollIntoViewIfNeeded(); + await fixture.app().frameLocator('#view').locator('#rate').fill('19'); + const before = await probe.snapshot(fixture.app()); + await fixture.park(); + fixture.changeEpoch(); + const loads = fixture.loadCount(); + await daemon.sendEvent({ + v: 1, + type: 'state_resync_required', + data: { reason: 'epoch_reset' }, + }); + await expect.poll(fixture.loadCount).toBeGreaterThan(loads); + await expect + .poll(() => probe.workers.filter(({ closed }) => closed).length) + .toBe(1); + await scroll.hover({ position: { x: 10, y: 10 } }); + await page.mouse.wheel(0, 100_000); + const reopen = page.getByRole('button', { name: /Reopen interactive view/i }); + await expect(reopen).toBeVisible(); + expect(probe.workers).toHaveLength(1); + await reopen.click(); + await expect(fixture.app().locator('#status')).toHaveAttribute( + 'data-state', + 'ready', + ); + expect((await probe.snapshot(fixture.app())).realm.id).not.toBe( + before.realm.id, + ); + await expect( + fixture.app().frameLocator('#view').locator('#rate'), + ).toHaveValue('2'); + expect(daemon.promptRequests()).toHaveLength(0); +}); + +test('a main card hidden by Settings loses host authority and keeps its runtime @smoke', async ({ + page, +}, testInfo) => { + const { app, cards, probe, daemon } = await openCards(page, testInfo); + await cards.first().scrollIntoViewIfNeeded(); + await app().frameLocator('#view').locator('#rate').fill('29'); + const before = await probe.snapshot(app()); + await openNavigationPage(page, 'Settings'); + await expect(page.getByTestId('inline-panel')).toHaveAttribute( + 'aria-label', + 'Settings', + ); + await expect(cards).toHaveCount(1); + await expect(cards).toBeHidden(); + const response = await appRpc(app(), 'ui/message', hiddenMessage); + await testInfo.attach('hidden-main-card-rpc', { + body: JSON.stringify({ destination: 'Settings', response }), + contentType: 'application/json', + }); + expect(response.result?.isError).toBe(true); + await expect( + page.getByRole('dialog', { name: 'Review App message' }), + ).toHaveCount(0); + expect(daemon.promptRequests()).toHaveLength(0); + expect(await probe.snapshot(app())).toEqual(before); + await page + .locator('[data-web-shell-navigation-rail]') + .getByRole('button', { name: 'Home', exact: true }) + .click(); + await cards.first().scrollIntoViewIfNeeded(); + expect(await probe.snapshot(app())).toEqual(before); + await expect(app().frameLocator('#view').locator('#rate')).toHaveValue('29'); + await expect(app().locator('#continue-analysis')).toBeDisabled(); + await app() + .frameLocator('#view') + .getByRole('button', { name: 'Apply', exact: true }) + .click(); + await expect(app().frameLocator('#view').locator('#answer')).toHaveText( + '1:29', + ); + await reviewSnapshot(page, app()); + expect(daemon.promptRequests()).toHaveLength(0); +}); + +test('entering and leaving Split View hands the same card to the visible surface @smoke', async ({ + page, +}, testInfo) => { + const { app, cards, probe, daemon, scenario } = await openCards( + page, + testInfo, + ); + await cards.first().scrollIntoViewIfNeeded(); + await app().frameLocator('#view').locator('#rate').fill('29'); + const before = await probe.snapshot(app()); + await openNavigationPage(page, 'Split View'); + const split = page.getByTestId('split-view'); + await expect(split).toBeVisible(); + const pane = split.locator(`[data-pane-session-id="${scenario.sessionId}"]`); + await expect(pane.locator(frameSelector)).toBeVisible(); + await expect(cards).toHaveCount(1); + expect(await probe.snapshot(app())).toEqual(before); + await expect(app().frameLocator('#view').locator('#rate')).toHaveValue('29'); + await expect(app().locator('#continue-analysis')).toBeDisabled(); + await cards.first().scrollIntoViewIfNeeded(); + await app() + .frameLocator('#view') + .getByRole('button', { name: 'Apply', exact: true }) + .click(); + await expect(app().frameLocator('#view').locator('#answer')).toHaveText( + '1:29', + ); + await reviewSnapshot(page, app()); + await page + .locator('[data-web-shell-navigation-rail]') + .getByRole('button', { name: 'Home', exact: true }) + .click(); + await expect(split).toHaveCount(0); + await expect(cards).toHaveCount(1); + await cards.first().scrollIntoViewIfNeeded(); + expect(await probe.snapshot(app())).toEqual(before); + await expect(app().frameLocator('#view').locator('#rate')).toHaveValue('29'); + await expect(app().frameLocator('#view').locator('#answer')).toHaveText( + '1:29', + ); + await reviewSnapshot(page, app()); + expect(daemon.promptRequests()).toHaveLength(0); +}); + +test('maximizing another split pane revokes hidden tool RPCs without recreating the App', async ({ + page, +}, testInfo) => { + const { scenario, daemon, probe, cards } = await openCards(page, testInfo); + await page.setViewportSize({ width: 1600, height: 1000 }); + // Only the HTTP daemon is a fixture. Each real split provider receives its + // requested session/client and epoch, so its attached owner can be verified. + await page.route('**/session/*/load', (route) => { + const sessionId = new URL(route.request().url()).pathname.split('/')[2]; + return route.fulfill({ + json: { + sessionId, + clientId: + route.request().headers()['x-qwen-client-id'] ?? scenario.clientId, + workspaceCwd: scenario.workspaceCwd, + eventEpoch: 'generated-ui-retention-epoch-1', + attached: true, + createdAt: '2026-10-26T00:00:00.000Z', + hasActivePrompt: false, + generatedUi: scenario.state._meta?.['generatedUi'], + state: scenario.state, + compactedReplay: scenario.events, + liveJournal: [], + lastEventId: scenario.events.length, + }, + }); + }); + const calls: string[] = []; + await page.route('**/session/*/mcp-app/tools/call', (route) => { + calls.push(new URL(route.request().url()).pathname); + return route.fulfill({ + json: { + content: [{ type: 'text', text: 'visible tool reached daemon' }], + }, + }); + }); + await openNavigationPage(page, 'Split View'); + const split = page.getByTestId('split-view'); + await expect(split).toBeVisible(); + await split.getByRole('button', { name: 'Add session' }).click(); + await split + .getByRole('button', { name: 'Previous Session', exact: true }) + .click(); + await daemon.sse.waitForConnection('previous-session'); + await daemon.sendEvent( + replayCompleteEvent({ + sessionId: 'previous-session', + replayedCount: scenario.events.length, + }), + ); + const mainPane = split.locator( + `[data-pane-session-id="${scenario.sessionId}"]`, + ); + const otherPane = split.locator('[data-pane-session-id="previous-session"]'); + const card = otherPane.locator(frameSelector); + await expect(cards).toHaveCount(2); + await expect(mainPane.locator(frameSelector)).toBeVisible(); + await expect(card).toBeVisible(); + await expect( + card.contentFrame().frameLocator('iframe').locator('#status'), + ).toHaveAttribute('data-state', 'ready'); + // Keep the actual Frame handle while its slot disappears into parking. + const appElement = await card + .contentFrame() + .locator('iframe') + .elementHandle(); + const app = (await appElement!.contentFrame())!; + await card.scrollIntoViewIfNeeded(); + await app.frameLocator('#view').locator('#rate').fill('31'); + const before = await probe.snapshot(app); + const tool = { name: 'generated_ui_show', arguments: input }; + const visibleReply = await appRpc(app, 'tools/call', tool); + await testInfo.attach('visible-split-pane-rpc', { + body: JSON.stringify({ visibleReply, calls }), + contentType: 'application/json', + }); + expect(visibleReply.result?.content?.[0]?.text).toBe( + 'visible tool reached daemon', + ); + expect(calls).toEqual(['/session/previous-session/mcp-app/tools/call']); + await mainPane + .getByRole('button', { name: 'Maximize pane', exact: true }) + .click(); + await expect(otherPane).toBeHidden(); + await expect( + page.locator('[data-generated-ui-parking]').locator(frameSelector), + ).toHaveCount(1); + const hiddenReply = await appRpc(app, 'tools/call', tool); + expect(hiddenReply.timedOut).not.toBe(true); + expect(hiddenReply.error).toBeDefined(); + expect(calls).toHaveLength(1); + await expect( + page.getByRole('dialog', { name: 'Review App message' }), + ).toHaveCount(0); + expect(daemon.promptRequests()).toHaveLength(0); + expect(await probe.snapshot(app)).toEqual(before); + await mainPane + .getByRole('button', { name: 'Restore pane', exact: true }) + .click(); + await expect(card).toBeVisible(); + await expect(mainPane.locator(frameSelector)).toBeVisible(); + await expect(cards).toHaveCount(2); + expect(await probe.snapshot(app)).toEqual(before); + await expect(app.frameLocator('#view').locator('#rate')).toHaveValue('31'); + const restoredReply = await appRpc(app, 'tools/call', tool); + expect(restoredReply.result?.content?.[0]?.text).toBe( + 'visible tool reached daemon', + ); + expect(calls).toEqual([ + '/session/previous-session/mcp-app/tools/call', + '/session/previous-session/mcp-app/tools/call', + ]); + await testInfo.attach('hidden-split-pane-rpc', { + body: JSON.stringify({ + visibleReply, + hiddenReply, + restoredReply, + calls, + }), + contentType: 'application/json', + }); +}); + +for (const shadowPortals of [false, true]) { + test(`retains the subagent App and Worker after panel close with shadow portals ${shadowPortals} @smoke`, async ({ + page, + }, testInfo) => { + let injected = 0; + await page.route('**/main.tsx', async (route) => { + const response = await route.fetch(); + const body = await response.text(); + const marker = /webShellProps:\s*\{/g; + expect(body.match(marker)).toHaveLength(1); + injected++; + await route.fulfill({ + response, + body: body.replace( + marker, + `webShellProps: { shadowDom: { portals: ${shadowPortals} },`, + ), + }); + }); + const fixture = await openCards(page, testInfo); + expect(injected).toBe(1); + await page.setViewportSize({ width: 900, height: 900 }); + const childId = 'retained-child'; + let childLoads = 0; + const childReplay = [ + userTextEvent('Render the child view', { id: 1, sessionId: childId }), + toolCallEvent( + 'child-card', + 'mcp__generated-ui__generated_ui_show', + input, + { + id: 2, + rawOutput: { + type: 'mcp_app', + requiredFeature: 'generated-ui', + serverName: 'generated-ui', + resourceUri, + html: appHtml, + toolResult, + toolArguments: input, + fallbackText: input.summary, + }, + }, + ), + turnCompleteEvent('child-turn', { id: 3, sessionId: childId }), + ]; + await page.route('**/session/**/subagents/retained-agent', (route) => + route.fulfill({ json: { sessionId: childId, status: 'completed' } }), + ); + await page.route(`**/session/${childId}/load`, (route) => { + childLoads++; + return route.fulfill({ + json: { + sessionId: childId, + clientId: + route.request().headers()['x-qwen-client-id'] ?? 'child-client', + workspaceCwd: fixture.scenario.workspaceCwd, + eventEpoch: 'child-epoch-1', + attached: true, + createdAt: '2026-10-27T00:00:00.000Z', + hasActivePrompt: false, + state: fixture.scenario.state, + generatedUi: fixture.scenario.state._meta?.['generatedUi'], + compactedReplay: childReplay, + liveJournal: [], + lastEventId: 3, + }, + }); + }); + await fixture.daemon.sendEvent( + userTextEvent('Open child details', { id: 100 }), + ); + await fixture.daemon.sendEvent( + toolCallEvent( + 'retained-agent', + 'agent', + { + description: 'Retained child', + prompt: 'Render the child view', + subagent_type: 'general-purpose', + }, + { + id: 101, + rawOutput: { + task_id: 'retained-task', + subagentName: 'Retained child', + status: 'completed', + }, + }, + ), + ); + await fixture.daemon.sendEvent( + turnCompleteEvent('parent-agent-turn', { id: 102 }), + ); + const trigger = page + .getByRole('button', { name: /Retained child/ }) + .first(); + await trigger.click(); + const panel = page.locator('aside[aria-label="Right panel"]'); + await expect(panel).toBeVisible(); + await fixture.daemon.sse.waitForConnection(childId); + await fixture.daemon.sendEvent( + replayCompleteEvent({ + sessionId: childId, + replayedCount: childReplay.length, + }), + ); + const card = panel.locator(frameSelector); + await expect(card).toHaveCount(1); + expect( + await card.evaluate( + (element) => element.getRootNode() instanceof ShadowRoot, + ), + ).toBe(shadowPortals); + const app = card.contentFrame().frameLocator('iframe'); + await expect(app.locator('#status')).toHaveAttribute('data-state', 'ready'); + const view = app.frameLocator('#view'); + await view.locator('#rate').fill('7'); + await view.getByRole('button', { name: 'Apply', exact: true }).click(); + await expect(view.locator('#answer')).toHaveText('1:7'); + await view.locator('#rate').fill('9'); + await expect(app.locator('#status')).toHaveAttribute('data-state', 'dirty'); + const before = await fixture.probe.snapshot(app); + const frame = await card.elementHandle(); + const mainRealm = await page.evaluate( + () => (window as ProbeWindow).__pr03Realm.id, + ); + await panel + .getByRole('button', { name: 'Toggle right panel', exact: true }) + .click(); + await expect(panel).toHaveCount(0); + expect(await frame!.evaluate((element) => element.isConnected)).toBe(true); + expect( + await frame!.evaluate((element) => + element.closest('[data-generated-ui-parking]')?.hasAttribute('inert'), + ), + ).toBe(true); + expect(await fixture.probe.liveWorkerNonces()).toEqual(before.workerNonces); + const previousLoads = childLoads; + await trigger.click(); + await expect(panel).toBeVisible(); + await expect.poll(() => childLoads).toBeGreaterThan(previousLoads); + await fixture.daemon.sse.waitForConnection(childId); + await fixture.daemon.sendEvent( + replayCompleteEvent({ + sessionId: childId, + replayedCount: childReplay.length, + }), + ); + await expect(card).toHaveCount(1); + expect(await fixture.probe.snapshot(app)).toEqual(before); + await expect(view.locator('#rate')).toHaveValue('9'); + await expect(view.locator('#answer')).toHaveText('1:7'); + expect( + await page.evaluate(() => (window as ProbeWindow).__pr03Realm.id), + ).toBe(mainRealm); + expect(fixture.daemon.promptRequests()).toHaveLength(0); + await testInfo.attach('subagent-portal-retention', { + body: JSON.stringify({ + shadowPortals, + before, + after: await fixture.probe.snapshot(app), + }), + contentType: 'application/json', + }); + }); +} diff --git a/packages/web-shell/client/e2e/utils/generatedUiSandbox.ts b/packages/web-shell/client/e2e/utils/generatedUiSandbox.ts new file mode 100644 index 00000000000..dda326f8f79 --- /dev/null +++ b/packages/web-shell/client/e2e/utils/generatedUiSandbox.ts @@ -0,0 +1,72 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +// E2E fixtures use the repository's build dependency. +// eslint-disable-next-line import/no-extraneous-dependencies +import { build } from 'esbuild'; + +const root = new URL('../../../../../', import.meta.url); + +export async function startGeneratedUiSandbox() { + const appHtml = await readFile( + new URL('integrations/generated-ui/dist/app.html', root), + 'utf8', + ); + const require = createRequire(new URL('packages/cli/package.json', root)); + const fixture = await build({ + stdin: { + resolveDir: fileURLToPath(root), + contents: ` + import express from ${JSON.stringify(require.resolve('express'))}; + import { mountMcpAppSandbox } from './packages/cli/src/serve/mcp-app-sandbox.ts'; + export { createShowResult } from './integrations/generated-ui/src/contracts.ts'; + export async function startSandbox() { + const app = express(); + const dispose = mountMcpAppSandbox(app); + const server = await new Promise((resolve) => { + const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); + }); + return { + url: 'http://127.0.0.1:' + server.address().port, + async close() { + dispose(); + server.closeAllConnections(); + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + }, + }; + } + `, + }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + banner: { + js: `import { createRequire } from 'node:module'; const require = createRequire(${JSON.stringify(fileURLToPath(new URL('packages/cli/package.json', root)))});`, + }, + }); + const directory = await mkdtemp(join(tmpdir(), 'generated-ui-e2e-')); + const fixturePath = join(directory, 'sandbox.mjs'); + await writeFile(fixturePath, fixture.outputFiles[0].text); + const helpers = (await import(pathToFileURL(fixturePath).href)) as { + createShowResult( + document: Record, + ): Record; + startSandbox(): Promise<{ url: string; close(): Promise }>; + }; + const sandbox = await helpers.startSandbox(); + return { + appHtml, + createShowResult: helpers.createShowResult, + url: sandbox.url, + async close() { + try { + await sandbox.close(); + } finally { + await rm(directory, { recursive: true }); + } + }, + }; +} diff --git a/packages/web-shell/client/generated-ui/GeneratedUiRuntimeProvider.dom.test.tsx b/packages/web-shell/client/generated-ui/GeneratedUiRuntimeProvider.dom.test.tsx new file mode 100644 index 00000000000..4761f1e7a42 --- /dev/null +++ b/packages/web-shell/client/generated-ui/GeneratedUiRuntimeProvider.dom.test.tsx @@ -0,0 +1,1126 @@ +// @vitest-environment jsdom +import { act, StrictMode, type ContextType } from 'react'; +import { createPortal } from 'react-dom'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + McpAppGeneratedUi, + McpAppGeneratedUiInvalidation, + McpAppGeneratedUiOwner, + McpAppGeneratedUiOwnerSource, + McpAppMessages, +} from '../mcpAppHostContext'; +import { + McpAppGeneratedUiContext, + McpAppGeneratedUiOwnerContext, + McpAppHostContext, + McpAppMessagesContext, + McpAppSessionContext, + McpAppToolsContext, + McpAppVisibilityContext, +} from '../mcpAppHostContext'; +import { I18nProvider } from '../i18n'; +import { WebShellPortalRootContext } from '../portalRoot'; +import { McpApp, type McpAppDisplay } from '../components/messages/McpApp'; +import { + GeneratedUiRuntimeProvider, + useGeneratedUiRetention, +} from './GeneratedUiRuntimeProvider'; +import { instanceKey, resourceKey } from './instance-store'; + +interface BridgeMock { + capabilities?: unknown; + onsandboxready?: () => void; + oninitialized?: () => void; + onsizechange?: (size: { height: number }) => void; + onmessage?: ( + params: { role: string; content: Array<{ type: 'text'; text: string }> }, + extra: { signal: AbortSignal }, + ) => Promise; + oncalltool?: ( + params: { name: string; arguments?: Record }, + extra: { signal: AbortSignal }, + ) => Promise; + connect: ReturnType; + close: ReturnType; + sendSandboxResourceReady: ReturnType; + sendToolInput: ReturnType; + sendToolResult: ReturnType; + teardownResource: ReturnType; +} + +const bridges = vi.hoisted(() => ({ instances: [] as BridgeMock[] })); + +vi.mock('@modelcontextprotocol/ext-apps/app-bridge', () => ({ + PostMessageTransport: class PostMessageTransport {}, + AppBridge: class AppBridge implements BridgeMock { + connect = vi.fn().mockResolvedValue(undefined); + close = vi.fn().mockResolvedValue(undefined); + sendSandboxResourceReady = vi.fn().mockResolvedValue(undefined); + sendToolInput = vi.fn().mockResolvedValue(undefined); + sendToolResult = vi.fn().mockResolvedValue(undefined); + teardownResource = vi.fn().mockResolvedValue(undefined); + setHostContext = vi.fn(); + capabilities: unknown; + constructor(_app: unknown, _info: unknown, capabilities?: unknown) { + this.capabilities = capabilities; + bridges.instances.push(this); + } + }, +})); + +const moveBeforeDescriptor = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'moveBefore', +); +const closeHarnesses: Array<() => Promise> = []; + +beforeEach(() => { + vi.useFakeTimers(); + bridges.instances = []; + // This shim verifies React lifecycle and host routing only. It cannot prove + // that a browser preserves iframe realms or Workers during a native move. + Object.defineProperty(HTMLElement.prototype, 'moveBefore', { + configurable: true, + value(this: HTMLElement, node: Node, before: Node | null) { + this.insertBefore(node, before); + }, + }); +}); + +afterEach(async () => { + for (const close of closeHarnesses.splice(0)) await close(); + if (moveBeforeDescriptor) { + Object.defineProperty( + HTMLElement.prototype, + 'moveBefore', + moveBeforeDescriptor, + ); + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'moveBefore'); + } + vi.useRealTimers(); +}); + +function appDisplay(overrides: Partial = {}): McpAppDisplay { + return { + type: 'mcp_app', + requiredFeature: 'generated-ui', + serverName: 'qwen-code-generated-ui', + resourceUri: 'ui://qwen-code-generated-ui/view-v1.html', + html: '
Generated simulation
', + toolArguments: { workers: 8 }, + toolResult: { content: [{ type: 'text', text: 'Simulation ready' }] }, + fallbackText: 'Simulation summary', + ...overrides, + }; +} + +interface Card { + surface: string; + toolId: string; + display?: McpAppDisplay; + visible?: boolean; + surfaceVisible?: boolean; + portal?: boolean; +} + +interface HarnessProps { + cards?: Card[]; + source?: McpAppGeneratedUiOwnerSource | null; + receipt?: McpAppGeneratedUi | null; + messages?: McpAppMessages | null; + tools?: ContextType; + viewportKey?: string; + surfaceVisible?: boolean; + portalRoot?: HTMLElement | null; +} + +function setup(strict = false) { + let ownerIdentity = {}; + const source: McpAppGeneratedUiOwnerSource = { + scopeId: 'daemon-a', + sessionId: 'session-a', + clientId: 'client-a', + workspaceCwd: '/workspace/a', + eventEpoch: 'epoch-a', + captureOwner: () => { + const captured = ownerIdentity; + return { isCurrent: () => ownerIdentity === captured }; + }, + }; + const receipt: McpAppGeneratedUi = { + sessionId: source.sessionId, + status: { version: 1, enabled: true }, + }; + const listeners = new Set<(event: McpAppGeneratedUiInvalidation) => void>(); + const owner: McpAppGeneratedUiOwner = { + scopeId: source.scopeId, + workspaceCwd: source.workspaceCwd, + source, + captureSessionDeletion: () => () => undefined, + subscribeInvalidation: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + invalidate: (event) => { + for (const listener of listeners) listener(event); + }, + }; + const requestMessage = vi + .fn() + .mockResolvedValue({}); + const messages: McpAppMessages = { + sessionId: source.sessionId, + requestMessage, + }; + const display = appDisplay(); + const defaultCards: Card[] = [{ surface: 'main', toolId: 'call-a', display }]; + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + let closed = false; + let store: ReturnType; + function StoreObserver() { + store = useGeneratedUiRetention(); + return null; + } + const render = async (props: HarnessProps = {}) => { + const tree = ( + + + + + + + + + +
+ {(props.cards ?? defaultCards).map((card) => { + const content = ( + +
+ +
+
+ ); + return card.portal && props.portalRoot + ? createPortal( + content, + props.portalRoot, + card.surface, + ) + : content; + })} +
+
+
+
+
+
+
+
+
+ ); + const content = ( + + {tree} + + ); + await act(async () => + root.render(strict ? {content} : content), + ); + }; + const close = async () => { + if (closed) return; + closed = true; + await act(async () => root.unmount()); + container.remove(); + }; + closeHarnesses.push(close); + return { + source, + receipt, + display, + container, + requestMessage, + listeners, + render, + close, + store: () => { + if (!store) throw new Error('Provider has not rendered'); + return store; + }, + recover: () => { + ownerIdentity = {}; + }, + invalidate: async (reason: McpAppGeneratedUiInvalidation['reason']) => { + await act(async () => + owner.invalidate({ + scopeId: source.scopeId, + sessionId: source.sessionId, + reason, + }), + ); + }, + surface: (name = 'main') => + container.querySelector(`[data-surface="${name}"]`)!, + parking: () => + container.querySelector('[data-generated-ui-parking]')!, + }; +} + +function activeBridges() { + return bridges.instances.filter( + (bridge) => bridge.close.mock.calls.length === 0, + ); +} + +async function initialize(bridge: BridgeMock) { + await act(async () => { + bridge.onsandboxready?.(); + bridge.oninitialized?.(); + }); + expect(bridge.sendToolInput).toHaveBeenCalledTimes(1); + expect(bridge.sendToolResult).toHaveBeenCalledTimes(1); +} + +function message(bridge: BridgeMock) { + expect(bridge.onmessage).toBeTypeOf('function'); + return bridge.onmessage!( + { + role: 'user', + content: [{ type: 'text', text: 'Continue with 8 workers' }], + }, + { signal: new AbortController().signal }, + ); +} + +function createShadowPortal() { + const host = document.createElement('div'); + document.body.appendChild(host); + const shadow = host.attachShadow({ mode: 'open' }); + const portalRoot = document.createElement('div'); + shadow.appendChild(portalRoot); + closeHarnesses.push(async () => host.remove()); + return { shadow, portalRoot }; +} + +describe('GeneratedUiRuntimeProvider lifecycle', () => { + it('hands a hidden surface to a visible surface for the same producer', async () => { + const h = setup(); + const main = { surface: 'main', toolId: 'call-a' }; + const split = { surface: 'split', toolId: 'call-a' }; + await h.render({ cards: [main] }); + const bridge = activeBridges()[0]; + const frame = h.surface().querySelector('iframe'); + await initialize(bridge); + expect(await message(bridge)).toEqual({}); + const oldSignal = h.requestMessage.mock.calls[0][0].signal; + + await h.render({ cards: [{ ...main, surfaceVisible: false }, split] }); + + expect(oldSignal.aborted).toBe(true); + expect(h.surface().querySelector('iframe')).toBeNull(); + expect(h.surface('split').querySelector('iframe')).toBe(frame); + expect(activeBridges()).toEqual([bridge]); + expect(await message(bridge)).toEqual({}); + await h.render({ cards: [main] }); + expect(h.surface().querySelector('iframe')).toBe(frame); + expect(activeBridges()).toEqual([bridge]); + expect(bridge.sendToolInput).toHaveBeenCalledTimes(1); + expect(h.requestMessage).toHaveBeenCalledTimes(2); + }); + + it('initializes without native moves but releases a populated runtime on relocation', async () => { + Reflect.deleteProperty(HTMLElement.prototype, 'moveBefore'); + const h = setup(true); + await h.render({ cards: [] }); + expect(bridges.instances).toHaveLength(0); + + await h.render(); + + expect(h.surface().querySelectorAll('iframe')).toHaveLength(1); + expect(activeBridges()).toHaveLength(1); + const bridge = activeBridges()[0]; + await initialize(bridge); + expect(await message(bridge)).toEqual({}); + expect(h.requestMessage).toHaveBeenCalledTimes(1); + await h.render({ viewportKey: 'history-slice' }); + expect(h.surface().querySelector('iframe')).toBeNull(); + expect(h.surface().querySelector('button')?.textContent).toBe( + 'Reopen interactive view', + ); + expect(bridge.close).toHaveBeenCalledTimes(1); + expect(activeBridges()).toEqual([]); + }); + + it('leaves one live bridge after StrictMode replay and tears down on close', async () => { + const h = setup(true); + await h.render(); + expect(h.surface().querySelectorAll('iframe')).toHaveLength(1); + expect(activeBridges()).toHaveLength(1); + const bridge = activeBridges()[0]; + await initialize(bridge); + expect(await message(bridge)).toEqual({}); + expect(h.requestMessage).toHaveBeenCalledTimes(1); + expect(h.listeners.size).toBe(1); + + await h.close(); + + expect(activeBridges()).toHaveLength(0); + expect(bridge.teardownResource).toHaveBeenCalledExactlyOnceWith( + {}, + { timeout: 500 }, + ); + for (const created of bridges.instances) { + expect(created.close).toHaveBeenCalledTimes(1); + } + expect(h.listeners.size).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it('parks before ancestor removal and restores the same runtime on remount', async () => { + const h = setup(); + await h.render(); + const bridge = activeBridges()[0]; + const frame = h.surface().querySelector('iframe')!; + await initialize(bridge); + expect(await message(bridge)).toEqual({}); + + await h.render({ cards: [] }); + + expect(h.parking().contains(frame)).toBe(true); + expect(h.parking().hasAttribute('inert')).toBe(true); + expect(h.parking().getAttribute('aria-hidden')).toBe('true'); + expect(bridge.close).not.toHaveBeenCalled(); + expect(await message(bridge)).toEqual({ isError: true }); + expect(h.requestMessage).toHaveBeenCalledTimes(1); + await h.render({ viewportKey: 'history-slice' }); + + expect(h.surface().querySelector('iframe')).toBe(frame); + expect(activeBridges()).toEqual([bridge]); + expect(bridge.sendToolInput).toHaveBeenCalledTimes(1); + expect(bridge.sendToolResult).toHaveBeenCalledTimes(1); + expect(await message(bridge)).toEqual({}); + expect(h.requestMessage).toHaveBeenCalledTimes(2); + }); + + it('retains a populated shadow portal across parked rerenders and StrictMode remounts', async () => { + const h = setup(true); + const { shadow, portalRoot } = createShadowPortal(); + const card = { surface: 'shadow', toolId: 'call-a', portal: true }; + await h.render({ portalRoot, cards: [] }); + await h.render({ portalRoot, cards: [card] }); + const bridge = activeBridges()[0]; + const frame = portalRoot.querySelector('iframe')!; + const entry = h.store().getEntries()[0]; + await initialize(bridge); + expect(frame.getRootNode()).toBe(shadow); + expect(entry.container.contains(frame)).toBe(true); + expect(await message(bridge)).toEqual({}); + const previousSignal = h.requestMessage.mock.calls[0][0].signal; + + await h.render({ portalRoot, cards: [] }); + + const parking = portalRoot.querySelector( + '[data-generated-ui-parking]', + )!; + expect(parking.hidden).toBe(true); + expect(parking.hasAttribute('inert')).toBe(true); + expect(parking.getAttribute('aria-hidden')).toBe('true'); + expect(parking.contains(frame)).toBe(true); + expect(entry.parkedAt).toBeDefined(); + expect(h.parking().contains(frame)).toBe(false); + expect(previousSignal.aborted).toBe(true); + expect(await message(bridge)).toEqual({ isError: true }); + expect(h.requestMessage).toHaveBeenCalledTimes(1); + await h.render({ portalRoot, cards: [], viewportKey: 'history-slice' }); + expect(h.store().getEntries()).toEqual([entry]); + expect(parking.contains(frame)).toBe(true); + expect(bridge.close).not.toHaveBeenCalled(); + + await h.render({ portalRoot, cards: [card], viewportKey: 'returned' }); + + expect(portalRoot.querySelector('[data-surface="shadow"] iframe')).toBe( + frame, + ); + expect(h.store().getEntries()).toEqual([entry]); + expect(entry.parkedAt).toBeUndefined(); + expect(activeBridges()).toEqual([bridge]); + expect(await message(bridge)).toEqual({}); + expect(h.requestMessage.mock.calls[1][0].signal.aborted).toBe(false); + expect(bridge.sendToolInput).toHaveBeenCalledTimes(1); + expect(bridge.sendToolResult).toHaveBeenCalledTimes(1); + }); + + it('removes only the parked shadow runtime when its portal parking host disappears', async () => { + const h = setup(); + const { portalRoot } = createShadowPortal(); + const main = { surface: 'main', toolId: 'main' }; + const shadow = { surface: 'shadow', toolId: 'shadow', portal: true }; + await h.render({ portalRoot, cards: [main, shadow] }); + const [mainBridge, shadowBridge] = activeBridges(); + await initialize(mainBridge); + await initialize(shadowBridge); + const mainFrame = h.surface().querySelector('iframe'); + const shadowFrame = portalRoot.querySelector('iframe')!; + const [mainEntry, shadowEntry] = h.store().getEntries(); + await h.render({ portalRoot, cards: [] }); + + await h.render({ portalRoot: null, cards: [] }); + + expect(h.store().getRecord(shadowEntry.key)?.reason).toBe( + 'parking-removed', + ); + expect(shadowFrame.isConnected).toBe(false); + expect(shadowBridge.close).toHaveBeenCalledTimes(1); + expect(h.store().getEntries()).toEqual([mainEntry]); + expect(h.parking().contains(mainFrame)).toBe(true); + expect(mainBridge.close).not.toHaveBeenCalled(); + expect(portalRoot.querySelector('[data-generated-ui-parking]')).toBeNull(); + await h.render({ portalRoot, cards: [main, shadow] }); + expect(h.surface().querySelector('iframe')).toBe(mainFrame); + expect( + portalRoot.querySelector('[data-surface="shadow"] iframe'), + ).toBeNull(); + expect( + portalRoot.querySelector('[data-surface="shadow"] button'), + ).not.toBeNull(); + expect(activeBridges()).toEqual([mainBridge]); + }); + + it('keeps an active key in one surface and allows reopening after its owner leaves', async () => { + const h = setup(); + const main = { surface: 'main', toolId: 'call-a' }; + const duplicate = { surface: 'duplicate', toolId: 'call-a' }; + await h.render({ cards: [main, duplicate] }); + const bridge = activeBridges()[0]; + await initialize(bridge); + const parkedFrame = h.surface().querySelector('iframe'); + expect(h.surface().querySelectorAll('iframe')).toHaveLength(1); + expect(h.surface('duplicate').querySelector('iframe')).toBeNull(); + expect(h.surface('duplicate').querySelector('button')).toBeNull(); + expect(activeBridges()).toHaveLength(1); + + await h.render({ cards: [duplicate] }); + const reopen = h + .surface('duplicate') + .querySelector('button'); + expect(reopen).not.toBeNull(); + await act(async () => reopen!.click()); + + expect(h.surface('duplicate').querySelectorAll('iframe')).toHaveLength(1); + expect(bridge.close).toHaveBeenCalledTimes(1); + expect(h.surface('duplicate').querySelector('iframe')).not.toBe( + parkedFrame, + ); + expect(h.surface('duplicate').textContent).toContain( + 'Reopening starts from the original content', + ); + expect(activeBridges()).toHaveLength(1); + expect(await message(activeBridges()[0])).toEqual({}); + expect(h.requestMessage).toHaveBeenCalledTimes(1); + }); + + it('keeps separate tool keys and requires explicit reopen after a resource change', async () => { + const h = setup(); + await h.render(); + const firstBridge = activeBridges()[0]; + const firstFrame = h.surface().querySelector('iframe'); + await initialize(firstBridge); + await h.render({ cards: [{ surface: 'main', toolId: 'call-b' }] }); + const secondFrame = h.surface().querySelector('iframe'); + expect(secondFrame).not.toBe(firstFrame); + expect(h.parking().contains(firstFrame)).toBe(true); + expect(activeBridges()).toHaveLength(2); + await h.render(); + expect(h.surface().querySelector('iframe')).toBe(firstFrame); + expect(h.parking().contains(secondFrame)).toBe(true); + + const changed = appDisplay({ html: '
New simulation
' }); + await h.render({ + cards: [{ surface: 'main', toolId: 'call-a', display: changed }], + }); + expect(h.surface().querySelector('iframe')).toBeNull(); + expect(firstBridge.close).toHaveBeenCalledTimes(1); + const reopen = h.surface().querySelector('button')!; + await act(async () => reopen.click()); + const replacement = activeBridges().at(-1)!; + await initialize(replacement); + expect(h.surface().querySelectorAll('iframe')).toHaveLength(1); + expect(h.surface().querySelector('iframe')).not.toBe(firstFrame); + expect(replacement.sendSandboxResourceReady).toHaveBeenCalledWith( + expect.objectContaining({ html: changed.html }), + ); + }); + + it('wires owner invalidation to disposal without automatically reopening', async () => { + const h = setup(); + await h.render(); + const bridge = activeBridges()[0]; + await initialize(bridge); + expect(await message(bridge)).toEqual({}); + const signal = h.requestMessage.mock.calls[0][0].signal; + + await h.invalidate('policy'); + + expect(signal.aborted).toBe(true); + expect(h.surface().querySelector('iframe')).toBeNull(); + expect(bridge.close).toHaveBeenCalledTimes(1); + expect(bridge.onmessage).toBeUndefined(); + await h.render(); + expect(activeBridges()).toHaveLength(0); + expect(h.surface().querySelector('button')).not.toBeNull(); + expect(h.requestMessage).toHaveBeenCalledTimes(1); + }); + + it.each(['html', 'csp', 'permissions', 'arguments', 'result', 'epoch'])( + 'invalidates a memoized resource when %s changes', + async (change) => { + const h = setup(); + await h.render(); + const bridge = activeBridges()[0]; + const frame = h.surface().querySelector('iframe'); + await initialize(bridge); + const updates: Record> = { + html: { html: '
Changed
' }, + csp: { csp: { connectDomains: ['https://example.test'] } }, + permissions: { + permissions: { clipboardWrite: {} } as McpAppDisplay['permissions'], + }, + arguments: { toolArguments: { workers: 9 } }, + result: { + toolResult: { content: [{ type: 'text', text: 'Changed' }] }, + }, + }; + await h.render({ + source: + change === 'epoch' + ? { ...h.source, eventEpoch: 'epoch-b' } + : h.source, + cards: [ + { + surface: 'main', + toolId: 'call-a', + display: + change === 'epoch' ? h.display : appDisplay(updates[change]), + }, + ], + }); + expect(bridge.close).toHaveBeenCalledTimes(1); + expect(frame!.isConnected).toBe(false); + expect(h.surface().querySelector('iframe')).toBeNull(); + expect(h.surface().querySelector('button')?.textContent).toBe( + 'Reopen interactive view', + ); + }, + ); + + it('cleans both an attached and a parked runtime when the provider closes', async () => { + const h = setup(); + await h.render({ + cards: [ + { surface: 'main', toolId: 'call-a' }, + { surface: 'second', toolId: 'call-b' }, + ], + }); + const initial = [...activeBridges()]; + expect(initial).toHaveLength(2); + for (const bridge of initial) await initialize(bridge); + await h.render(); + expect(h.parking().querySelectorAll('iframe')).toHaveLength(1); + expect(vi.getTimerCount()).toBe(1); + + await h.close(); + + for (const bridge of initial) { + expect(bridge.teardownResource).toHaveBeenCalledTimes(1); + expect(bridge.close).toHaveBeenCalledTimes(1); + expect(bridge.onmessage).toBeUndefined(); + } + expect(activeBridges()).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe('GeneratedUiRuntimeProvider eligibility and authority', () => { + it('keeps an ordinary MCP App outside retention and closes it on removal', async () => { + const h = setup(); + await h.render({ + cards: [ + { + surface: 'main', + toolId: 'foreign-call', + display: appDisplay({ + requiredFeature: undefined, + serverName: 'foreign-server', + resourceUri: 'ui://foreign/view.html', + }), + }, + ], + }); + const bridge = activeBridges()[0]; + await initialize(bridge); + expect(h.surface().querySelector('iframe')).not.toBeNull(); + expect(h.surface().querySelector('[data-generated-ui-slot]')).toBeNull(); + expect(h.store().getEntries()).toEqual([]); + await h.render({ cards: [] }); + expect(bridge.close).toHaveBeenCalledTimes(1); + expect(h.parking().querySelector('iframe')).toBeNull(); + expect(activeBridges()).toEqual([]); + }); + + it('ignores size changes while parked and keeps the previous height on return', async () => { + const h = setup(); + await h.render(); + const bridge = activeBridges()[0]; + const frame = h.surface().querySelector('iframe')!; + await initialize(bridge); + await act(async () => bridge.onsizechange!({ height: 400 })); + expect(frame.style.height).toBe('400px'); + await h.render({ cards: [] }); + for (const height of [0, 500]) { + await act(async () => bridge.onsizechange!({ height })); + expect(frame.style.height).toBe('400px'); + } + await h.render(); + expect(h.surface().querySelector('iframe')).toBe(frame); + expect(frame.style.height).toBe('400px'); + }); + + it.each(['missing', 'foreign'] as const)( + 'does not advertise message or tool capabilities for a %s host', + async (hostKind) => { + const h = setup(); + const requestMessage = vi.fn().mockResolvedValue({}); + const callTool = vi.fn().mockResolvedValue({ content: [] }); + await h.render({ + messages: + hostKind === 'missing' + ? null + : { sessionId: 'other', requestMessage }, + tools: + hostKind === 'missing' ? undefined : { sessionId: 'other', callTool }, + }); + expect(activeBridges()).toHaveLength(1); + const bridge = activeBridges()[0]; + await initialize(bridge); + + expect(bridge.capabilities).not.toHaveProperty('message'); + expect(bridge.capabilities).not.toHaveProperty('serverTools'); + expect(bridge.onmessage).toBeUndefined(); + expect(bridge.oncalltool).toBeUndefined(); + expect(requestMessage).not.toHaveBeenCalled(); + expect(callTool).not.toHaveBeenCalled(); + }, + ); + + it('advertises and routes matching host capabilities', async () => { + const h = setup(); + const callTool = vi + .fn() + .mockResolvedValue({ content: [{ type: 'text', text: 'Calculated' }] }); + await h.render({ tools: { sessionId: h.source.sessionId, callTool } }); + const bridge = activeBridges()[0]; + await initialize(bridge); + + expect(bridge.capabilities).toMatchObject({ + message: { text: {} }, + serverTools: {}, + }); + expect(await message(bridge)).toEqual({}); + expect(h.requestMessage).toHaveBeenCalledTimes(1); + expect(bridge.oncalltool).toBeTypeOf('function'); + expect( + await bridge.oncalltool!( + { name: 'calculate', arguments: { workers: 8 } }, + { signal: new AbortController().signal }, + ), + ).toEqual({ content: [{ type: 'text', text: 'Calculated' }] }); + expect(callTool).toHaveBeenCalledExactlyOnceWith( + { + serverName: h.display.serverName, + resourceUri: h.display.resourceUri, + name: 'calculate', + arguments: { workers: 8 }, + }, + expect.any(AbortSignal), + ); + }); + + it.each(['message', 'serverTools'] as const)( + 'requires explicit reopening when a visible %s capability changes', + async (capability) => { + const h = setup(); + const tools = { + sessionId: h.source.sessionId, + callTool: vi.fn().mockResolvedValue({ content: [] }), + }; + await h.render({ tools }); + const first = activeBridges()[0]; + await initialize(first); + expect(first.capabilities).toHaveProperty(capability); + expect(await message(first)).toEqual({}); + const signal = h.requestMessage.mock.calls[0][0].signal; + + await h.render({ + tools: capability === 'serverTools' ? undefined : tools, + messages: capability === 'message' ? null : undefined, + }); + + expect(signal.aborted).toBe(true); + expect(first.close).toHaveBeenCalledTimes(1); + expect(h.surface().querySelector('iframe')).toBeNull(); + expect(activeBridges()).toHaveLength(0); + await act(async () => + h.surface().querySelector('button')!.click(), + ); + const limited = activeBridges()[0]; + await initialize(limited); + expect(limited.capabilities).not.toHaveProperty(capability); + expect( + capability === 'message' ? limited.onmessage : limited.oncalltool, + ).toBeUndefined(); + + await h.render({ tools }); + expect(limited.close).toHaveBeenCalledTimes(1); + expect(h.surface().querySelector('iframe')).toBeNull(); + expect(activeBridges()).toHaveLength(0); + await act(async () => + h.surface().querySelector('button')!.click(), + ); + const restored = activeBridges()[0]; + await initialize(restored); + expect(restored.capabilities).toHaveProperty(capability); + expect(await message(restored)).toEqual({}); + expect(h.requestMessage).toHaveBeenCalledTimes(2); + }, + ); + + it('parks a mounted card during an owner-session mismatch and restores it on return', async () => { + const h = setup(); + await h.render(); + const bridge = activeBridges()[0]; + const frame = h.surface().querySelector('iframe'); + await initialize(bridge); + expect(await message(bridge)).toEqual({}); + await h.render({ + source: { ...h.source, sessionId: 'session-b', clientId: 'client-b' }, + messages: null, + }); + + expect(h.parking().contains(frame)).toBe(true); + expect(bridge.close).not.toHaveBeenCalled(); + expect(await message(bridge)).toEqual({ isError: true }); + expect(activeBridges()).toHaveLength(2); + const ordinary = activeBridges().find((other) => other !== bridge)!; + expect(h.surface().querySelector('[data-generated-ui-slot]')).toBeNull(); + expect(h.surface().querySelector('iframe')).not.toBeNull(); + await h.render(); + expect(ordinary.close).toHaveBeenCalledTimes(1); + expect(h.surface().querySelector('iframe')).toBe(frame); + expect(activeBridges()).toEqual([bridge]); + expect(await message(bridge)).toEqual({}); + expect(h.requestMessage).toHaveBeenCalledTimes(2); + expect(bridge.sendToolInput).toHaveBeenCalledTimes(1); + }); + + it('requires a fresh explicit open for saturated fallback visibility, resource, epoch and mount changes', async () => { + const h = setup(); + await h.render({ cards: [] }); + const store = h.store(); + const filler = document.createElement('div'); + h.container.appendChild(filler); + await act(async () => { + for (let index = 0; index < 128; index++) { + const key = instanceKey(h.source, `released-${index}`, h.display); + expect( + store.attach({ + key, + resourceKey: resourceKey(h.display, h.source.eventEpoch), + display: h.display, + source: h.source, + receipt: h.receipt, + baseUrl: 'http://127.0.0.1:4170', + element: filler, + token: {}, + visible: true, + reset: () => {}, + }), + ).toBeDefined(); + store.dispose(key, 'evicted'); + } + }); + filler.remove(); + expect(bridges.instances).toHaveLength(0); + + await h.render(); + expect(h.surface().querySelector('iframe')).toBeNull(); + expect(activeBridges()).toHaveLength(0); + const openFallback = async () => { + const button = h.surface().querySelector('button'); + expect(button).not.toBeNull(); + await act(async () => button!.click()); + expect(h.surface().querySelectorAll('iframe')).toHaveLength(1); + expect(h.surface().querySelector('button')).toBeNull(); + expect(activeBridges()).toHaveLength(1); + const bridge = activeBridges()[0]; + await initialize(bridge); + expect(await message(bridge)).toEqual({}); + expect(store.getEntries()).toEqual([]); + return bridge; + }; + const first = await openFallback(); + expect( + store.getRecord(instanceKey(h.source, 'call-a', h.display)), + ).toBeUndefined(); + + const beforeFold = h.requestMessage.mock.calls[0][0].signal; + const beforeFoldMessage = first.onmessage!; + await h.render({ + cards: [{ surface: 'main', toolId: 'call-a', visible: false }], + }); + expect(beforeFold.aborted).toBe(true); + expect(first.close).toHaveBeenCalledTimes(1); + expect(h.surface().querySelector('iframe')).toBeNull(); + expect(first.onmessage).toBeUndefined(); + expect( + await beforeFoldMessage( + { role: 'user', content: [{ type: 'text', text: 'Stale message' }] }, + { signal: new AbortController().signal }, + ), + ).toEqual({ isError: true }); + expect(h.requestMessage).toHaveBeenCalledTimes(1); + await h.render(); + expect(h.surface().querySelector('iframe')).toBeNull(); + const afterFold = await openFallback(); + + const cards = [ + { + surface: 'main', + toolId: 'call-a', + display: appDisplay({ html: '
Changed resource
' }), + }, + ]; + await h.render({ cards }); + expect(afterFold.close).toHaveBeenCalledTimes(1); + expect(h.surface().querySelector('iframe')).toBeNull(); + expect(activeBridges()).toHaveLength(0); + await h.render(); + expect(h.surface().querySelector('iframe')).toBeNull(); + await h.render({ cards }); + const second = await openFallback(); + + const source = { ...h.source, eventEpoch: 'epoch-b' }; + await h.render({ cards, source }); + expect(second.close).toHaveBeenCalledTimes(1); + expect(h.surface().querySelector('iframe')).toBeNull(); + expect(activeBridges()).toHaveLength(0); + await h.render({ cards }); + expect(h.surface().querySelector('iframe')).toBeNull(); + await h.render({ cards, source }); + const third = await openFallback(); + + await h.render({ cards: [], source }); + expect(third.close).toHaveBeenCalledTimes(1); + expect(h.parking().querySelector('iframe')).toBeNull(); + await h.render({ cards, source }); + expect(h.surface().querySelector('iframe')).toBeNull(); + expect(activeBridges()).toHaveLength(0); + await openFallback(); + expect(h.requestMessage).toHaveBeenCalledTimes(5); + }); + + it('keeps a pending review during an equivalent owner and receipt rerender', async () => { + const h = setup(); + let resolveRequest!: (result: { isError?: boolean }) => void; + h.requestMessage.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRequest = resolve; + }), + ); + await h.render(); + const bridge = activeBridges()[0]; + await initialize(bridge); + const pending = message(bridge); + const signal = h.requestMessage.mock.calls[0][0].signal; + + await h.render({ + source: { ...h.source }, + receipt: { ...h.receipt, status: { ...h.receipt.status } }, + }); + + expect(signal.aborted).toBe(false); + expect(activeBridges()).toEqual([bridge]); + expect(bridge.sendToolInput).toHaveBeenCalledTimes(1); + resolveRequest({}); + expect(await pending).toEqual({}); + expect(h.requestMessage).toHaveBeenCalledTimes(1); + }); + + it('uses the ordinary McpApp lifecycle when the producer epoch is unavailable', async () => { + const h = setup(); + const unknown = { ...h.source, eventEpoch: undefined }; + await h.render({ source: unknown }); + const bridge = activeBridges()[0]; + await initialize(bridge); + expect(h.surface().querySelector('iframe')).not.toBeNull(); + expect(h.surface().querySelector('[data-generated-ui-slot]')).toBeNull(); + expect(await message(bridge)).toEqual({}); + + await h.render({ source: unknown, cards: [] }); + + expect(bridge.close).toHaveBeenCalledTimes(1); + expect(h.parking().querySelector('iframe')).toBeNull(); + await h.render({ source: unknown }); + expect(activeBridges()).toHaveLength(1); + expect(activeBridges()[0]).not.toBe(bridge); + expect(h.requestMessage).toHaveBeenCalledTimes(1); + }); + + it('revokes on a missing receipt and uses a fresh lease when the receipt returns', async () => { + const h = setup(); + await h.render(); + const bridge = activeBridges()[0]; + const frame = h.surface().querySelector('iframe'); + await initialize(bridge); + expect(await message(bridge)).toEqual({}); + const before = h.requestMessage.mock.calls[0][0].signal; + + await h.render({ receipt: null }); + + expect(before.aborted).toBe(true); + expect(h.surface().querySelector('iframe')).toBeNull(); + expect(await message(bridge)).toEqual({ isError: true }); + expect(h.requestMessage).toHaveBeenCalledTimes(1); + await h.render({ + receipt: { ...h.receipt, status: { ...h.receipt.status } }, + }); + expect(h.surface().querySelector('iframe')).toBe(frame); + expect(await message(bridge)).toEqual({}); + expect(h.requestMessage).toHaveBeenCalledTimes(2); + expect(h.requestMessage.mock.calls[1][0].signal.aborted).toBe(false); + expect(bridge.sendToolInput).toHaveBeenCalledTimes(1); + }); + + it('revokes a missing source and removes the temporary ordinary runtime on return', async () => { + const h = setup(); + await h.render(); + const retained = activeBridges()[0]; + const frame = h.surface().querySelector('iframe'); + await initialize(retained); + expect(await message(retained)).toEqual({}); + const oldSignal = h.requestMessage.mock.calls[0][0].signal; + + await h.render({ source: null }); + + expect(oldSignal.aborted).toBe(true); + expect(h.parking().contains(frame)).toBe(true); + expect(await message(retained)).toEqual({ isError: true }); + const ordinary = activeBridges().find((bridge) => bridge !== retained)!; + expect(ordinary).toBeDefined(); + await h.render(); + expect(ordinary.close).toHaveBeenCalledTimes(1); + expect(h.surface().querySelector('iframe')).toBe(frame); + expect(activeBridges()).toEqual([retained]); + expect(await message(retained)).toEqual({}); + expect(h.requestMessage).toHaveBeenCalledTimes(2); + }); + + it('updates recovered owners without keeping stale in-flight authority', async () => { + const h = setup(); + let resolveRequest!: (result: { isError?: boolean }) => void; + h.requestMessage.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRequest = resolve; + }), + ); + await h.render(); + const bridge = activeBridges()[0]; + await initialize(bridge); + const old = message(bridge); + const oldSignal = h.requestMessage.mock.calls[0][0].signal; + + h.recover(); + await h.render({ source: { ...h.source } }); + + expect(oldSignal.aborted).toBe(true); + resolveRequest({}); + expect(await old).toEqual({ isError: true }); + expect(await message(bridge)).toEqual({}); + expect(h.requestMessage).toHaveBeenCalledTimes(2); + expect(activeBridges()).toEqual([bridge]); + expect(bridge.sendToolResult).toHaveBeenCalledTimes(1); + }); + + it.each(['folded', 'hidden-surface'])( + 'revokes %s cards without remounting their runtime', + async (visibility) => { + const h = setup(); + await h.render(); + const bridge = activeBridges()[0]; + const frame = h.surface().querySelector('iframe'); + await initialize(bridge); + expect(await message(bridge)).toEqual({}); + const oldSignal = h.requestMessage.mock.calls[0][0].signal; + + await h.render({ + cards: [ + { + surface: 'main', + toolId: 'call-a', + visible: visibility !== 'folded', + }, + ], + surfaceVisible: visibility !== 'hidden-surface', + }); + + expect(oldSignal.aborted).toBe(true); + expect(await message(bridge)).toEqual({ isError: true }); + expect(h.requestMessage).toHaveBeenCalledTimes(1); + expect( + (visibility === 'folded' ? h.surface() : h.parking()).querySelector( + 'iframe', + ), + ).toBe(frame); + await h.render(); + expect(await message(bridge)).toEqual({}); + expect(h.requestMessage).toHaveBeenCalledTimes(2); + expect(activeBridges()).toEqual([bridge]); + expect(bridge.sendToolInput).toHaveBeenCalledTimes(1); + }, + ); +}); diff --git a/packages/web-shell/client/generated-ui/GeneratedUiRuntimeProvider.tsx b/packages/web-shell/client/generated-ui/GeneratedUiRuntimeProvider.tsx new file mode 100644 index 00000000000..248d0487763 --- /dev/null +++ b/packages/web-shell/client/generated-ui/GeneratedUiRuntimeProvider.tsx @@ -0,0 +1,254 @@ +import { + createContext, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, + type ReactNode, +} from 'react'; +import { createPortal } from 'react-dom'; +import { + McpAppGeneratedUiContext, + McpAppGeneratedUiOwnerContext, + McpAppHostContext, + McpAppMessagesContext, + McpAppSessionContext, + McpAppToolsContext, +} from '../mcpAppHostContext'; +import { + McpAppRuntime, + type McpAppDisplay, +} from '../components/messages/McpApp'; +import { Button } from '../components/ui/button'; +import { useI18n } from '../i18n'; +import { useWebShellPortalRoot } from '../portalRoot'; +import { + GeneratedUiInstanceStore, + instanceKey, + resourceKey, + type InstanceSlot, +} from './instance-store'; + +const InstanceStoreContext = createContext< + GeneratedUiInstanceStore | undefined +>(undefined); + +export function GeneratedUiRuntimeProvider({ + children, +}: { + children: ReactNode; +}) { + const [store] = useState(() => new GeneratedUiInstanceStore()); + const portalRoot = useWebShellPortalRoot(); + const portalParkingCleanup = useRef<(() => void) | undefined>(undefined); + const setParking = useCallback( + (element: HTMLDivElement | null) => { + element?.setAttribute('inert', ''); + store.parking = element ?? undefined; + }, + [store], + ); + const setPortalParking = useCallback( + (element: HTMLDivElement | null) => { + portalParkingCleanup.current?.(); + portalParkingCleanup.current = undefined; + if (element) { + element.setAttribute('inert', ''); + portalParkingCleanup.current = store.registerParking(element); + } + }, + [store], + ); + const owner = useContext(McpAppGeneratedUiOwnerContext); + const subscribeInvalidation = owner?.subscribeInvalidation; + const entries = useSyncExternalStore( + store.subscribe, + store.getEntries, + store.getEntries, + ); + useLayoutEffect( + () => subscribeInvalidation?.(store.invalidate), + [store, subscribeInvalidation], + ); + useLayoutEffect(() => () => store.clear(), [store]); + return ( + + {children} +