mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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 <[email protected]>
This commit is contained in:
1 parent
32f6713ce6
commit
c586e00cb7
36 files changed
+8151
-203
No files matched your search
@@ -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.
|
||||
@@ -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 仍受已有执行预算约束。
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -301,6 +301,10 @@ export class DaemonSessionClient {
|
||||
/** In-flight `reattach()` so concurrent prompts re-register only once. */
|
||||
private reattaching?: Promise<void>;
|
||||
private generatedUiReadGeneration = 0;
|
||||
private generatedUiRevocationRevision = 0;
|
||||
private readonly generatedUiRevocationListeners = new Set<
|
||||
(revision: number) => void
|
||||
>();
|
||||
private cancelling?: Promise<void>;
|
||||
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) {
|
||||
|
||||
@@ -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<Response>((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<Response>((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 () => {
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<GeneratedUiRuntimeProvider>
|
||||
{mcpAppMessages.review}
|
||||
{capacityRecovery && <CapacityRecoveryDialog intent={capacityRecovery} onClose={dismissCapacityRecovery} />}
|
||||
{!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). */}
|
||||
<WebShellCustomizationProvider value={customization}>
|
||||
<McpAppVisibilityContext.Provider value={!activePanel && !artifactPanelFullscreen}>
|
||||
<SplitView
|
||||
showResponseTokensPerSecond={showResponseTokensPerSecond}
|
||||
modelManagement={modelManagement}
|
||||
@@ -23459,6 +23466,7 @@ export function App({
|
||||
}
|
||||
sessionWorkflowEnabled={sessionWorkflowEnabled}
|
||||
/>
|
||||
</McpAppVisibilityContext.Provider>
|
||||
</WebShellCustomizationProvider>
|
||||
</div>
|
||||
)}
|
||||
@@ -23592,6 +23600,7 @@ export function App({
|
||||
}
|
||||
>
|
||||
<WebShellCustomizationProvider value={mainChatCustomization}>
|
||||
<McpAppVisibilityContext.Provider value={chatActive && !showMissingSessionState}>
|
||||
<TodoContextsProvider
|
||||
timeline={todoTimeline}
|
||||
details={todoDetails}
|
||||
@@ -24676,6 +24685,7 @@ export function App({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</McpAppVisibilityContext.Provider>
|
||||
</WebShellCustomizationProvider>
|
||||
</div>
|
||||
</div>
|
||||
@@ -24951,6 +24961,7 @@ export function App({
|
||||
artifactPanelSlotEl,
|
||||
)}
|
||||
</div>
|
||||
</GeneratedUiRuntimeProvider>
|
||||
</div>
|
||||
</CompactModeContext.Provider>
|
||||
</WebShellPortalRootContext.Provider>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof transcriptBlocksToDaemonMessages>,
|
||||
) =>
|
||||
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',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
>
|
||||
<WorkflowDetailsProvider tasks={sessionTasks}>
|
||||
<TranscriptViewport
|
||||
ref={transcriptViewportRef}
|
||||
messages={messages}
|
||||
sourceSessionId={connection.sessionId}
|
||||
pendingApproval={pendingToolApproval}
|
||||
loadingTranscript={connection.loadingTranscript}
|
||||
catchingUp={connection.catchingUp}
|
||||
hasOlderHistory={transcriptHistory.hasMore}
|
||||
loadingOlderHistory={transcriptHistory.loading}
|
||||
historyCapacityReached={transcriptHistory.capacityReached}
|
||||
historyPaginationError={transcriptHistory.paginationError}
|
||||
onLoadOlderHistory={transcriptHistory.loadMore}
|
||||
transcriptBlockCount={blocks.length}
|
||||
transcriptActivity={store}
|
||||
onReloadTranscript={
|
||||
transcriptReloadSupported ? reloadTranscript : undefined
|
||||
}
|
||||
isResponding={isResponding}
|
||||
workspaceCwd={connection.workspaceCwd || ''}
|
||||
hideSessionTimeline
|
||||
turnFileChanges={
|
||||
visibleTurnOutputKinds.has('file')
|
||||
? fileChangesByTurn
|
||||
: undefined
|
||||
}
|
||||
turnArtifacts={
|
||||
visibleTurnOutputKinds.has('artifact')
|
||||
? artifactsByTurn
|
||||
: undefined
|
||||
}
|
||||
turnScheduledTasks={
|
||||
visibleTurnOutputKinds.has('scheduled_task')
|
||||
? scheduledTasksByTurn
|
||||
: undefined
|
||||
}
|
||||
onTurnOutputOpen={handleRightPanelOpen}
|
||||
onImagePreview={handleImagePreview}
|
||||
onAttachmentPreview={handleAttachmentPreview}
|
||||
onError={reportError}
|
||||
generateContent={
|
||||
connection.capabilities?.features.includes(
|
||||
'session_generation',
|
||||
)
|
||||
? actions.generateSessionContent
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</WorkflowDetailsProvider>
|
||||
<McpAppVisibilityContext.Provider
|
||||
value={parentSurfaceVisible && !hidden}
|
||||
>
|
||||
<WorkflowDetailsProvider tasks={sessionTasks}>
|
||||
<TranscriptViewport
|
||||
ref={transcriptViewportRef}
|
||||
messages={messages}
|
||||
sourceSessionId={connection.sessionId}
|
||||
pendingApproval={pendingToolApproval}
|
||||
loadingTranscript={connection.loadingTranscript}
|
||||
catchingUp={connection.catchingUp}
|
||||
hasOlderHistory={transcriptHistory.hasMore}
|
||||
loadingOlderHistory={transcriptHistory.loading}
|
||||
historyCapacityReached={transcriptHistory.capacityReached}
|
||||
historyPaginationError={transcriptHistory.paginationError}
|
||||
onLoadOlderHistory={transcriptHistory.loadMore}
|
||||
transcriptBlockCount={blocks.length}
|
||||
transcriptActivity={store}
|
||||
onReloadTranscript={
|
||||
transcriptReloadSupported ? reloadTranscript : undefined
|
||||
}
|
||||
isResponding={isResponding}
|
||||
workspaceCwd={connection.workspaceCwd || ''}
|
||||
hideSessionTimeline
|
||||
turnFileChanges={
|
||||
visibleTurnOutputKinds.has('file')
|
||||
? fileChangesByTurn
|
||||
: undefined
|
||||
}
|
||||
turnArtifacts={
|
||||
visibleTurnOutputKinds.has('artifact')
|
||||
? artifactsByTurn
|
||||
: undefined
|
||||
}
|
||||
turnScheduledTasks={
|
||||
visibleTurnOutputKinds.has('scheduled_task')
|
||||
? scheduledTasksByTurn
|
||||
: undefined
|
||||
}
|
||||
onTurnOutputOpen={handleRightPanelOpen}
|
||||
onImagePreview={handleImagePreview}
|
||||
onAttachmentPreview={handleAttachmentPreview}
|
||||
onError={reportError}
|
||||
generateContent={
|
||||
connection.capabilities?.features.includes(
|
||||
'session_generation',
|
||||
)
|
||||
? actions.generateSessionContent
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</WorkflowDetailsProvider>
|
||||
</McpAppVisibilityContext.Provider>
|
||||
</SubagentDetailsProvider>
|
||||
</OptionalMonitorDetailsProvider>
|
||||
</div>
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider language="en">
|
||||
<SessionOverviewPanel onOpenSession={onOpenSession} {...props} />
|
||||
<McpAppGeneratedUiOwnerContext.Provider value={generatedUiOwner}>
|
||||
<SessionOverviewPanel onOpenSession={onOpenSession} {...props} />
|
||||
</McpAppGeneratedUiOwnerContext.Provider>
|
||||
</I18nProvider>,
|
||||
),
|
||||
);
|
||||
@@ -310,7 +318,9 @@ function rerender(
|
||||
act(() =>
|
||||
root!.render(
|
||||
<I18nProvider language="en">
|
||||
<SessionOverviewPanel onOpenSession={onOpenSession} {...props} />
|
||||
<McpAppGeneratedUiOwnerContext.Provider value={generatedUiOwner}>
|
||||
<SessionOverviewPanel onOpenSession={onOpenSession} {...props} />
|
||||
</McpAppGeneratedUiOwnerContext.Provider>
|
||||
</I18nProvider>,
|
||||
),
|
||||
);
|
||||
@@ -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<typeof result>((resolve) => {
|
||||
resolveDeletion = resolve;
|
||||
}),
|
||||
);
|
||||
render();
|
||||
act(() => click(rowActionButton(rows()[0]!, 'Delete')));
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
act(() =>
|
||||
click(
|
||||
document.querySelector<HTMLElement>(
|
||||
'[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<HTMLElement>(
|
||||
'[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<HTMLElement>(
|
||||
'[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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AppBridge['sendSandboxResourceReady']>[0];
|
||||
type AppToolResult = Parameters<AppBridge['sendToolResult']>[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 ? (
|
||||
<GeneratedUiSlot display={display} toolId={toolId} visible={visible} />
|
||||
) : (
|
||||
<GeneratedUiFallback display={display} />
|
||||
);
|
||||
return allowed ? (
|
||||
<McpAppRuntime display={display} />
|
||||
) : (
|
||||
@@ -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 }) {
|
||||
<div className={styles.header}>
|
||||
<span>MCP App</span>
|
||||
<span className={styles.server}>{display.serverName}</span>
|
||||
{retained && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title={t('mcpApp.generatedUi.reopenNotice')}
|
||||
onClick={retained.reset}
|
||||
>
|
||||
{t('mcpApp.generatedUi.reset')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{retained?.reopened && (
|
||||
<div className={styles.fallback}>
|
||||
{t('mcpApp.generatedUi.reopenNotice')}
|
||||
</div>
|
||||
)}
|
||||
{error ? (
|
||||
<div className={styles.fallback}>
|
||||
{display.fallbackText || 'MCP App could not initialize.'}
|
||||
|
||||
@@ -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 ? (
|
||||
<McpApp display={mcpApp} />
|
||||
<McpApp
|
||||
display={mcpApp}
|
||||
toolId={tool.generatedUiToolCallId}
|
||||
visible={detailsVisible}
|
||||
/>
|
||||
) : (
|
||||
<GeneratedUiFallback display={mcpApp} />
|
||||
))}
|
||||
|
||||
+223
-22
@@ -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<typeof WebShellSidebar>['workspaces'];
|
||||
} = {},
|
||||
): void {
|
||||
const onError = props.onError ?? (() => {});
|
||||
act(() => {
|
||||
root.render(
|
||||
<I18nProvider language="en">
|
||||
<WebShellSidebar
|
||||
collapsed={false}
|
||||
onCollapsedChange={() => {}}
|
||||
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'] }}
|
||||
/>
|
||||
<McpAppGeneratedUiOwnerContext.Provider value={generatedUiOwner}>
|
||||
<WebShellSidebar
|
||||
collapsed={false}
|
||||
onCollapsedChange={() => {}}
|
||||
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'] }}
|
||||
/>
|
||||
</McpAppGeneratedUiOwnerContext.Provider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
@@ -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<void> {
|
||||
await flushSidebar();
|
||||
const button = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>('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<typeof result>((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',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<GeneratedUiScope | undefined>,
|
||||
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<GeneratedUiLifecycle>(() => {
|
||||
const listeners = new Set<(event: McpAppGeneratedUiInvalidation) => void>();
|
||||
const scopes = new Set<GeneratedUiScope>();
|
||||
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<GeneratedUiScope>(
|
||||
() => ({
|
||||
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<DaemonSessionClient, string>(),
|
||||
);
|
||||
const generatedUiRevocations = useRef(
|
||||
new WeakMap<DaemonSessionClient, number>(),
|
||||
);
|
||||
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<McpAppGeneratedUiOwner>(
|
||||
() => ({
|
||||
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 (
|
||||
<TrajectoryLiveContext.Provider value={trajectoryLiveStore}>
|
||||
<McpAppGeneratedUiContext.Provider value={mcpAppGeneratedUi}>
|
||||
<McpAppToolsContext.Provider value={mcpAppTools}>
|
||||
<DaemonStoreContext.Provider value={store}>
|
||||
<DaemonTurnNavigationContext.Provider value={turnNavigationStore}>
|
||||
<DaemonConnectionContext.Provider value={connection}>
|
||||
<DaemonPromptStatusContext.Provider value={promptStatus}>
|
||||
<DaemonSessionNoticesContext.Provider value={noticesValue}>
|
||||
<DaemonWorkspaceEventSignalsContext.Provider
|
||||
value={workspaceEventSignals}
|
||||
>
|
||||
<DaemonActionsContext.Provider value={actions}>
|
||||
<DaemonSessionOwnerGuardContext.Provider
|
||||
value={ownerGuardValue}
|
||||
<GeneratedUiScopeContext.Provider value={generatedUiScopeContext}>
|
||||
<McpAppGeneratedUiOwnerContext.Provider value={generatedUiOwner}>
|
||||
<TrajectoryLiveContext.Provider value={trajectoryLiveStore}>
|
||||
<McpAppGeneratedUiContext.Provider value={mcpAppGeneratedUi}>
|
||||
<McpAppToolsContext.Provider value={mcpAppTools}>
|
||||
<DaemonStoreContext.Provider value={store}>
|
||||
<DaemonTurnNavigationContext.Provider
|
||||
value={turnNavigationStore}
|
||||
>
|
||||
<DaemonConnectionContext.Provider value={connection}>
|
||||
<DaemonPromptStatusContext.Provider value={promptStatus}>
|
||||
<DaemonSessionNoticesContext.Provider
|
||||
value={noticesValue}
|
||||
>
|
||||
<DaemonWorkspaceEventSignalsContext.Provider
|
||||
value={workspaceEventSignals}
|
||||
>
|
||||
<DaemonTranscriptHistoryContext.Provider
|
||||
value={transcriptHistoryValue}
|
||||
>
|
||||
<DaemonPromptSettlementContext.Provider
|
||||
value={subscribeToPromptSettlement}
|
||||
<DaemonActionsContext.Provider value={actions}>
|
||||
<DaemonSessionOwnerGuardContext.Provider
|
||||
value={ownerGuardValue}
|
||||
>
|
||||
{children}
|
||||
</DaemonPromptSettlementContext.Provider>
|
||||
</DaemonTranscriptHistoryContext.Provider>
|
||||
</DaemonSessionOwnerGuardContext.Provider>
|
||||
</DaemonActionsContext.Provider>
|
||||
</DaemonWorkspaceEventSignalsContext.Provider>
|
||||
</DaemonSessionNoticesContext.Provider>
|
||||
</DaemonPromptStatusContext.Provider>
|
||||
</DaemonConnectionContext.Provider>
|
||||
</DaemonTurnNavigationContext.Provider>
|
||||
</DaemonStoreContext.Provider>
|
||||
</McpAppToolsContext.Provider>
|
||||
</McpAppGeneratedUiContext.Provider>
|
||||
</TrajectoryLiveContext.Provider>
|
||||
<DaemonTranscriptHistoryContext.Provider
|
||||
value={transcriptHistoryValue}
|
||||
>
|
||||
<DaemonPromptSettlementContext.Provider
|
||||
value={subscribeToPromptSettlement}
|
||||
>
|
||||
{children}
|
||||
</DaemonPromptSettlementContext.Provider>
|
||||
</DaemonTranscriptHistoryContext.Provider>
|
||||
</DaemonSessionOwnerGuardContext.Provider>
|
||||
</DaemonActionsContext.Provider>
|
||||
</DaemonWorkspaceEventSignalsContext.Provider>
|
||||
</DaemonSessionNoticesContext.Provider>
|
||||
</DaemonPromptStatusContext.Provider>
|
||||
</DaemonConnectionContext.Provider>
|
||||
</DaemonTurnNavigationContext.Provider>
|
||||
</DaemonStoreContext.Provider>
|
||||
</McpAppToolsContext.Provider>
|
||||
</McpAppGeneratedUiContext.Provider>
|
||||
</TrajectoryLiveContext.Provider>
|
||||
</McpAppGeneratedUiOwnerContext.Provider>
|
||||
</GeneratedUiScopeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void>();
|
||||
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<unknown>();
|
||||
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<typeof vi.fn>;
|
||||
onContinuationAdmitted?: ReturnType<typeof vi.fn>;
|
||||
onPromptRemoved?: ReturnType<typeof vi.fn>;
|
||||
onGeneratedUiInvalidated?: ReturnType<typeof vi.fn>;
|
||||
passiveAssistantDoneTimerRef?: {
|
||||
current: ReturnType<typeof setTimeout> | 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);
|
||||
|
||||
@@ -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<SetStateAction<number>>;
|
||||
setNewSessionNonce: Dispatch<SetStateAction<number>>;
|
||||
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(
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
let sandbox: { url: string; close(): Promise<void> };
|
||||
let fixtureDirectory: string;
|
||||
let sandbox: Awaited<ReturnType<typeof startGeneratedUiSandbox>>;
|
||||
|
||||
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<string, unknown>;
|
||||
startSandbox(): Promise<typeof sandbox>;
|
||||
};
|
||||
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) {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<string, unknown>,
|
||||
): Record<string, unknown>;
|
||||
startSandbox(): Promise<{ url: string; close(): Promise<void> }>;
|
||||
};
|
||||
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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 (
|
||||
<InstanceStoreContext.Provider value={store}>
|
||||
{children}
|
||||
<div
|
||||
hidden
|
||||
aria-hidden="true"
|
||||
data-generated-ui-parking
|
||||
ref={setParking}
|
||||
/>
|
||||
{portalRoot &&
|
||||
createPortal(
|
||||
<div
|
||||
hidden
|
||||
aria-hidden="true"
|
||||
data-generated-ui-parking
|
||||
ref={setPortalParking}
|
||||
/>,
|
||||
portalRoot,
|
||||
)}
|
||||
{entries.map((entry) =>
|
||||
createPortal(
|
||||
<McpAppHostContext.Provider value={entry.baseUrl}>
|
||||
<McpAppSessionContext.Provider value={entry.sessionId}>
|
||||
<McpAppToolsContext.Provider
|
||||
value={entry.canCallTool ? entry.tools : undefined}
|
||||
>
|
||||
<McpAppMessagesContext.Provider
|
||||
value={entry.canMessage ? entry.messages : undefined}
|
||||
>
|
||||
<McpAppRuntime
|
||||
display={entry.display}
|
||||
retained={entry.runtime}
|
||||
/>
|
||||
</McpAppMessagesContext.Provider>
|
||||
</McpAppToolsContext.Provider>
|
||||
</McpAppSessionContext.Provider>
|
||||
</McpAppHostContext.Provider>,
|
||||
entry.container,
|
||||
String(entry.id),
|
||||
),
|
||||
)}
|
||||
</InstanceStoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useGeneratedUiRetention() {
|
||||
return useContext(InstanceStoreContext);
|
||||
}
|
||||
|
||||
export function GeneratedUiSlot({
|
||||
display,
|
||||
toolId,
|
||||
visible,
|
||||
}: {
|
||||
display: McpAppDisplay;
|
||||
toolId: string;
|
||||
visible: boolean;
|
||||
}) {
|
||||
const store = useContext(InstanceStoreContext)!;
|
||||
const { t } = useI18n();
|
||||
const source = useContext(McpAppGeneratedUiOwnerContext)?.source;
|
||||
const receipt = useContext(McpAppGeneratedUiContext);
|
||||
const sessionId = useContext(McpAppSessionContext);
|
||||
const baseUrl = useContext(McpAppHostContext);
|
||||
const tools = useContext(McpAppToolsContext);
|
||||
const messages = useContext(McpAppMessagesContext);
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
const token = useRef({}).current;
|
||||
const [ordinary, setOrdinary] = useState<{
|
||||
key: string;
|
||||
resourceKey: string;
|
||||
}>();
|
||||
useSyncExternalStore(store.subscribe, store.getVersion, store.getVersion);
|
||||
const key = useMemo(
|
||||
() =>
|
||||
source && source.sessionId === sessionId
|
||||
? instanceKey(source, toolId, display)
|
||||
: '',
|
||||
[source, sessionId, toolId, display],
|
||||
);
|
||||
const toolsMatch = !!sessionId && tools?.sessionId === sessionId;
|
||||
const messagesMatch = !!sessionId && messages?.sessionId === sessionId;
|
||||
const resource = useMemo(
|
||||
() =>
|
||||
resourceKey(display, source?.eventEpoch, {
|
||||
tools: toolsMatch,
|
||||
messages: messagesMatch,
|
||||
}),
|
||||
[display, source?.eventEpoch, toolsMatch, messagesMatch],
|
||||
);
|
||||
const ordinaryActive =
|
||||
visible && ordinary?.key === key && ordinary.resourceKey === resource;
|
||||
useLayoutEffect(() => {
|
||||
if (ordinary && !ordinaryActive) setOrdinary(undefined);
|
||||
}, [ordinary, ordinaryActive]);
|
||||
const input = useRef<InstanceSlot | undefined>(undefined);
|
||||
const open = () => {
|
||||
const slot = input.current;
|
||||
if (
|
||||
!slot ||
|
||||
!slot.element.isConnected ||
|
||||
!slot.source.captureOwner().isCurrent()
|
||||
)
|
||||
return;
|
||||
const previous = store.getRecord(slot.key)?.entry;
|
||||
if (previous?.token && previous.token !== slot.token) return;
|
||||
if (previous) store.dispose(slot.key, 'reset');
|
||||
if (!store.attach(slot, true))
|
||||
setOrdinary({ key: slot.key, resourceKey: slot.resourceKey });
|
||||
};
|
||||
input.current =
|
||||
source && receipt && baseUrl && key && receipt.sessionId === sessionId
|
||||
? {
|
||||
source,
|
||||
receipt,
|
||||
baseUrl,
|
||||
tools,
|
||||
messages,
|
||||
key,
|
||||
resourceKey: resource,
|
||||
display,
|
||||
element: element.current!,
|
||||
token,
|
||||
visible,
|
||||
reset: open,
|
||||
}
|
||||
: undefined;
|
||||
useLayoutEffect(() => {
|
||||
const slot = input.current;
|
||||
if (slot && element.current) {
|
||||
slot.element = element.current;
|
||||
if (!ordinaryActive) store.attach(slot);
|
||||
}
|
||||
return () => store.release(key, token);
|
||||
}, [store, key, resource, ordinaryActive, token]);
|
||||
useLayoutEffect(() => {
|
||||
if (input.current && element.current) {
|
||||
input.current.element = element.current;
|
||||
store.update(input.current);
|
||||
}
|
||||
});
|
||||
const entry = store.getRecord(key)?.entry;
|
||||
const elsewhere = !!entry?.token && entry.token !== token;
|
||||
const attached = entry?.token === token;
|
||||
return (
|
||||
<div ref={element} data-generated-ui-slot>
|
||||
{ordinaryActive && key ? (
|
||||
<>
|
||||
<p className="p-3 text-sm text-muted-foreground">
|
||||
{t('mcpApp.generatedUi.reopenNotice')}
|
||||
</p>
|
||||
<McpAppRuntime display={display} />
|
||||
</>
|
||||
) : !attached ? (
|
||||
<div className="p-3 text-sm text-muted-foreground">
|
||||
<div className="whitespace-pre-wrap">{display.fallbackText}</div>
|
||||
<p>
|
||||
{t(
|
||||
elsewhere
|
||||
? 'mcpApp.generatedUi.openElsewhere'
|
||||
: 'mcpApp.generatedUi.reopenNotice',
|
||||
)}
|
||||
</p>
|
||||
{!elsewhere && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={open}
|
||||
disabled={!input.current}
|
||||
>
|
||||
{t('mcpApp.generatedUi.reopen')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { McpAppDisplay } from '../components/messages/McpApp';
|
||||
import type { McpAppGeneratedUiOwnerSource } from '../mcpAppHostContext';
|
||||
import {
|
||||
GeneratedUiInstanceStore,
|
||||
instanceKey,
|
||||
MAX_INSTANCE_RECORDS,
|
||||
MAX_PARKED_INSTANCES,
|
||||
PARKED_INSTANCE_TTL,
|
||||
resourceKey,
|
||||
type InstanceSlot,
|
||||
} from './instance-store';
|
||||
|
||||
const moveBeforeDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
'moveBefore',
|
||||
);
|
||||
const stores: GeneratedUiInstanceStore[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-10-26T00:00:00Z'));
|
||||
// This stub only tests the store's DOM operations. jsdom cannot establish
|
||||
// that a browser move preserves an iframe realm or Worker.
|
||||
Object.defineProperty(HTMLElement.prototype, 'moveBefore', {
|
||||
configurable: true,
|
||||
value(this: HTMLElement, node: Node, before: Node | null) {
|
||||
this.insertBefore(node, before);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const store of stores.splice(0)) store.clear();
|
||||
document.body.replaceChildren();
|
||||
if (moveBeforeDescriptor) {
|
||||
Object.defineProperty(
|
||||
HTMLElement.prototype,
|
||||
'moveBefore',
|
||||
moveBeforeDescriptor,
|
||||
);
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, 'moveBefore');
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function display(overrides: Partial<McpAppDisplay> = {}): McpAppDisplay {
|
||||
return {
|
||||
type: 'mcp_app',
|
||||
requiredFeature: 'generated-ui',
|
||||
serverName: 'qwen-code-generated-ui',
|
||||
resourceUri: 'ui://qwen-code-generated-ui/view-v1.html',
|
||||
html: '<main>Simulation</main>',
|
||||
toolResult: { content: [{ type: 'text', text: 'Ready' }] },
|
||||
toolArguments: { workers: 8 },
|
||||
fallbackText: 'Simulation',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function source(overrides: Partial<McpAppGeneratedUiOwnerSource> = {}) {
|
||||
let owner = {};
|
||||
const value: McpAppGeneratedUiOwnerSource = {
|
||||
scopeId: 'daemon-a',
|
||||
sessionId: 'session-a',
|
||||
clientId: 'client-a',
|
||||
workspaceCwd: '/workspace/a',
|
||||
eventEpoch: 'epoch-a',
|
||||
captureOwner: () => {
|
||||
const captured = owner;
|
||||
return { isCurrent: () => captured === owner };
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
value,
|
||||
recover: () => {
|
||||
owner = {};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function slot(toolId = 'call-a', owner = source().value, app = display()) {
|
||||
const element = document.createElement('div');
|
||||
document.body.appendChild(element);
|
||||
const callTool = vi
|
||||
.fn<NonNullable<InstanceSlot['tools']>['callTool']>()
|
||||
.mockResolvedValue({ content: [{ type: 'text', text: 'Calculated' }] });
|
||||
const requestMessage = vi
|
||||
.fn<NonNullable<InstanceSlot['messages']>['requestMessage']>()
|
||||
.mockResolvedValue({});
|
||||
const reset = vi.fn();
|
||||
const value: InstanceSlot = {
|
||||
key: instanceKey(owner, toolId, app),
|
||||
resourceKey: resourceKey(app, owner.eventEpoch),
|
||||
display: app,
|
||||
source: owner,
|
||||
receipt: {
|
||||
sessionId: owner.sessionId,
|
||||
status: { version: 1, enabled: true },
|
||||
},
|
||||
tools: { sessionId: owner.sessionId, callTool },
|
||||
messages: { sessionId: owner.sessionId, requestMessage },
|
||||
baseUrl: 'http://127.0.0.1:4170',
|
||||
token: {},
|
||||
element,
|
||||
visible: true,
|
||||
reset,
|
||||
};
|
||||
return { value, callTool, requestMessage, reset };
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const store = new GeneratedUiInstanceStore();
|
||||
const parking = document.createElement('div');
|
||||
parking.hidden = true;
|
||||
parking.setAttribute('inert', '');
|
||||
document.body.appendChild(parking);
|
||||
store.parking = parking;
|
||||
stores.push(store);
|
||||
return { store, parking };
|
||||
}
|
||||
|
||||
function shadowParking(store: GeneratedUiInstanceStore) {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = host.attachShadow({ mode: 'open' });
|
||||
const parking = document.createElement('div');
|
||||
root.appendChild(parking);
|
||||
return { root, parking, unregister: store.registerParking(parking) };
|
||||
}
|
||||
|
||||
function toolRequest() {
|
||||
return {
|
||||
serverName: 'qwen-code-generated-ui',
|
||||
resourceUri: 'ui://qwen-code-generated-ui/view-v1.html',
|
||||
name: 'calculate',
|
||||
arguments: { workers: 8 },
|
||||
};
|
||||
}
|
||||
|
||||
function messageRequest() {
|
||||
return {
|
||||
serverName: 'qwen-code-generated-ui',
|
||||
resourceUri: 'ui://qwen-code-generated-ui/view-v1.html',
|
||||
text: 'Continue with 8 workers',
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Generated UI instance identity', () => {
|
||||
it('keeps identical documents from different tool calls independent', () => {
|
||||
const { store } = setup();
|
||||
const owner = source().value;
|
||||
const app = display();
|
||||
const first = slot('call-a', owner, app);
|
||||
const second = slot('call-b', owner, app);
|
||||
const firstEntry = store.attach(first.value)!;
|
||||
const secondEntry = store.attach(second.value)!;
|
||||
|
||||
expect(first.value.resourceKey).toBe(second.value.resourceKey);
|
||||
expect(first.value.key).not.toBe(second.value.key);
|
||||
expect(firstEntry.id).not.toBe(secondEntry.id);
|
||||
expect(firstEntry.container).not.toBe(secondEntry.container);
|
||||
const input = document.createElement('input');
|
||||
input.value = 'Unapplied draft';
|
||||
firstEntry.container.appendChild(input);
|
||||
expect(secondEntry.container.querySelector('input')).toBeNull();
|
||||
expect(store.getEntries()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ sessionId: 'session-b' },
|
||||
{ workspaceCwd: '/workspace/b' },
|
||||
{ scopeId: 'daemon-b' },
|
||||
])('separates the same tool ID by owner scope %j', (difference) => {
|
||||
const original = source().value;
|
||||
const other = source(difference).value;
|
||||
expect(instanceKey(original, 'call-a', display())).not.toBe(
|
||||
instanceKey(other, 'call-a', display()),
|
||||
);
|
||||
});
|
||||
|
||||
it('compares nested resource objects by value instead of insertion order', () => {
|
||||
const first = display({
|
||||
toolArguments: { workers: 8, limits: { low: 1, high: 20 } },
|
||||
});
|
||||
const same = display({
|
||||
toolArguments: { limits: { high: 20, low: 1 }, workers: 8 },
|
||||
});
|
||||
expect(resourceKey(first, 'epoch-a')).toBe(resourceKey(same, 'epoch-a'));
|
||||
expect(resourceKey(first, 'epoch-a')).not.toBe(
|
||||
resourceKey(first, 'epoch-b'),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['html', 'epoch'] as const)(
|
||||
'requires explicit reopening when %s changes under the same logical key',
|
||||
(change) => {
|
||||
const { store } = setup();
|
||||
const first = slot();
|
||||
const entry = store.attach(first.value)!;
|
||||
const oldSignal = entry.runtime.getSignal();
|
||||
store.release(first.value.key, first.value.token);
|
||||
const next = slot(
|
||||
'call-a',
|
||||
source({ eventEpoch: change === 'epoch' ? 'epoch-b' : 'epoch-a' })
|
||||
.value,
|
||||
display(change === 'html' ? { html: '<main>Changed</main>' } : {}),
|
||||
);
|
||||
|
||||
expect(next.value.key).toBe(first.value.key);
|
||||
expect(store.attach(next.value)).toBeUndefined();
|
||||
expect(oldSignal.aborted).toBe(true);
|
||||
expect(entry.container.isConnected).toBe(false);
|
||||
const replacement = store.attach(next.value, true)!;
|
||||
expect(replacement.id).not.toBe(entry.id);
|
||||
expect(replacement.container.parentElement).toBe(next.value.element);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Generated UI active leases', () => {
|
||||
it('preserves an in-flight request across same-slot rerenders', async () => {
|
||||
const { store } = setup();
|
||||
const first = slot();
|
||||
const pending = deferred<{ isError?: boolean }>();
|
||||
first.requestMessage.mockReturnValueOnce(pending.promise);
|
||||
const entry = store.attach(first.value)!;
|
||||
const signal = entry.runtime.getSignal();
|
||||
const request = entry.messages.requestMessage(messageRequest());
|
||||
const freshHost = slot('call-a', first.value.source);
|
||||
const rerender = {
|
||||
...freshHost.value,
|
||||
token: first.value.token,
|
||||
element: first.value.element,
|
||||
};
|
||||
|
||||
store.update(rerender);
|
||||
expect(store.attach(rerender)).toBe(entry);
|
||||
expect(entry.runtime.getSignal()).toBe(signal);
|
||||
expect(first.requestMessage.mock.calls[0][0].signal.aborted).toBe(false);
|
||||
pending.resolve({});
|
||||
expect(await request).toEqual({});
|
||||
expect(first.requestMessage).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ text: 'Continue with 8 workers' }),
|
||||
);
|
||||
await entry.messages.requestMessage(messageRequest());
|
||||
expect(freshHost.requestMessage).toHaveBeenCalledTimes(1);
|
||||
entry.runtime.reset();
|
||||
expect(freshHost.reset).toHaveBeenCalledTimes(1);
|
||||
expect(first.reset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts synchronously on park and permits no hidden host actions', async () => {
|
||||
const { store, parking } = setup();
|
||||
const host = slot();
|
||||
const entry = store.attach(host.value)!;
|
||||
await expect(
|
||||
entry.tools.callTool(toolRequest(), new AbortController().signal),
|
||||
).resolves.toEqual({ content: [{ type: 'text', text: 'Calculated' }] });
|
||||
await expect(
|
||||
entry.messages.requestMessage(messageRequest()),
|
||||
).resolves.toEqual({});
|
||||
const signal = entry.runtime.getSignal();
|
||||
|
||||
store.release(host.value.key, host.value.token);
|
||||
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(entry.runtime.isVisible()).toBe(false);
|
||||
expect(entry.runtime.getSignal().aborted).toBe(true);
|
||||
expect(entry.container.parentElement).toBe(parking);
|
||||
await expect(
|
||||
entry.tools.callTool(toolRequest(), new AbortController().signal),
|
||||
).rejects.toThrow('inactive');
|
||||
await expect(
|
||||
entry.messages.requestMessage(messageRequest()),
|
||||
).resolves.toEqual({ isError: true });
|
||||
entry.runtime.reset();
|
||||
expect(host.callTool).toHaveBeenCalledTimes(1);
|
||||
expect(host.requestMessage).toHaveBeenCalledTimes(1);
|
||||
expect(host.reset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects old completions without revoking a newly attached lease', async () => {
|
||||
const { store } = setup();
|
||||
const first = slot();
|
||||
const pendingMessage = deferred<{ isError?: boolean }>();
|
||||
const pendingTool = deferred<{ content: [] }>();
|
||||
first.requestMessage.mockReturnValue(pendingMessage.promise);
|
||||
first.callTool.mockReturnValue(pendingTool.promise);
|
||||
const entry = store.attach(first.value)!;
|
||||
const oldMessage = entry.messages.requestMessage(messageRequest());
|
||||
const oldTool = entry.tools.callTool(
|
||||
toolRequest(),
|
||||
new AbortController().signal,
|
||||
);
|
||||
const oldToolFailure = oldTool.catch((error: unknown) => error);
|
||||
|
||||
store.release(first.value.key, first.value.token);
|
||||
expect(first.requestMessage.mock.calls[0][0].signal.aborted).toBe(true);
|
||||
expect(first.callTool.mock.calls[0][1].aborted).toBe(true);
|
||||
const second = slot('call-a', first.value.source);
|
||||
expect(store.attach(second.value)).toBe(entry);
|
||||
const newSignal = entry.runtime.getSignal();
|
||||
await expect(
|
||||
entry.messages.requestMessage(messageRequest()),
|
||||
).resolves.toEqual({});
|
||||
await entry.tools.callTool(toolRequest(), new AbortController().signal);
|
||||
pendingMessage.resolve({});
|
||||
pendingTool.resolve({ content: [] });
|
||||
|
||||
expect(await oldMessage).toEqual({ isError: true });
|
||||
expect(await oldToolFailure).toEqual(
|
||||
expect.objectContaining({ message: 'Generated UI owner changed' }),
|
||||
);
|
||||
expect(newSignal.aborted).toBe(false);
|
||||
expect(entry.runtime.getSignal()).toBe(newSignal);
|
||||
expect(second.callTool).toHaveBeenCalledTimes(1);
|
||||
expect(second.requestMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refreshes the owner after recovery and revokes the former signal', async () => {
|
||||
const { store } = setup();
|
||||
const owner = source();
|
||||
const host = slot('call-a', owner.value);
|
||||
const entry = store.attach(host.value)!;
|
||||
await entry.messages.requestMessage(messageRequest());
|
||||
const oldSignal = entry.runtime.getSignal();
|
||||
|
||||
owner.recover();
|
||||
expect(entry.runtime.isVisible()).toBe(false);
|
||||
expect(await entry.messages.requestMessage(messageRequest())).toEqual({
|
||||
isError: true,
|
||||
});
|
||||
expect(host.requestMessage).toHaveBeenCalledTimes(1);
|
||||
store.update(host.value);
|
||||
|
||||
expect(oldSignal.aborted).toBe(true);
|
||||
expect(entry.runtime.getSignal()).not.toBe(oldSignal);
|
||||
expect(entry.runtime.isVisible()).toBe(true);
|
||||
await entry.messages.requestMessage(messageRequest());
|
||||
expect(host.requestMessage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects a duplicate slot and ignores its cleanup and the former slot cleanup', () => {
|
||||
const { store } = setup();
|
||||
const first = slot();
|
||||
const duplicate = slot('call-a', first.value.source);
|
||||
const entry = store.attach(first.value)!;
|
||||
const originalSignal = entry.runtime.getSignal();
|
||||
|
||||
expect(store.attach(duplicate.value)).toBeUndefined();
|
||||
store.release(duplicate.value.key, duplicate.value.token);
|
||||
expect(entry.container.parentElement).toBe(first.value.element);
|
||||
expect(originalSignal.aborted).toBe(false);
|
||||
store.release(first.value.key, first.value.token);
|
||||
expect(store.attach(duplicate.value)).toBe(entry);
|
||||
const activeSignal = entry.runtime.getSignal();
|
||||
store.release(first.value.key, first.value.token);
|
||||
expect(entry.container.parentElement).toBe(duplicate.value.element);
|
||||
expect(activeSignal.aborted).toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('revokes folded slots and resumes them without replacing the instance', async () => {
|
||||
const { store } = setup();
|
||||
const host = slot();
|
||||
const entry = store.attach(host.value)!;
|
||||
const signal = entry.runtime.getSignal();
|
||||
store.update({ ...host.value, visible: false });
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(await entry.messages.requestMessage(messageRequest())).toEqual({
|
||||
isError: true,
|
||||
});
|
||||
expect(host.requestMessage).not.toHaveBeenCalled();
|
||||
store.update(host.value);
|
||||
expect(store.getEntries()).toEqual([entry]);
|
||||
expect(entry.runtime.isVisible()).toBe(true);
|
||||
await entry.messages.requestMessage(messageRequest());
|
||||
expect(host.requestMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['missing-epoch', 'stale-owner', 'disabled', 'foreign-receipt'])(
|
||||
'denies admission for %s',
|
||||
(reason) => {
|
||||
const { store } = setup();
|
||||
const host = slot();
|
||||
if (reason === 'missing-epoch') host.value.source.eventEpoch = undefined;
|
||||
if (reason === 'stale-owner') {
|
||||
host.value.source.captureOwner = () => ({ isCurrent: () => false });
|
||||
}
|
||||
if (reason === 'disabled') host.value.receipt.status.enabled = false;
|
||||
if (reason === 'foreign-receipt') host.value.receipt.sessionId = 'other';
|
||||
expect(store.attach(host.value)).toBeUndefined();
|
||||
expect(store.getEntries()).toEqual([]);
|
||||
expect(host.callTool).not.toHaveBeenCalled();
|
||||
expect(host.requestMessage).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['stale-owner', 'disabled', 'foreign-receipt'])(
|
||||
'releases an existing activation when admission becomes %s',
|
||||
(reason) => {
|
||||
const { store } = setup();
|
||||
const host = slot();
|
||||
const entry = store.attach(host.value)!;
|
||||
const signal = entry.runtime.getSignal();
|
||||
if (reason === 'stale-owner')
|
||||
host.value.source.captureOwner = () => ({ isCurrent: () => false });
|
||||
if (reason === 'disabled') host.value.receipt.status.enabled = false;
|
||||
if (reason === 'foreign-receipt') host.value.receipt.sessionId = 'other';
|
||||
|
||||
expect(store.attach(host.value)).toBeUndefined();
|
||||
expect(store.getRecord(host.value.key)?.entry).toBeUndefined();
|
||||
expect(store.getRecord(host.value.key)?.reason).toBe('unavailable');
|
||||
expect(entry.container.isConnected).toBe(false);
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(store.getEntries()).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Generated UI shadow parking', () => {
|
||||
it('parks a populated shadow instance and reattaches the same entry within its root', () => {
|
||||
const { store, parking } = setup();
|
||||
const shadow = shadowParking(store);
|
||||
const host = slot();
|
||||
shadow.root.appendChild(host.value.element);
|
||||
const entry = store.attach(host.value)!;
|
||||
const frame = document.createElement('iframe');
|
||||
entry.container.appendChild(frame);
|
||||
const signal = entry.runtime.getSignal();
|
||||
const nativeMove = vi.spyOn(
|
||||
HTMLElement.prototype as HTMLElement & {
|
||||
moveBefore(node: Node, before: Node | null): void;
|
||||
},
|
||||
'moveBefore',
|
||||
);
|
||||
|
||||
store.release(host.value.key, host.value.token);
|
||||
|
||||
expect(entry.container.parentElement).toBe(shadow.parking);
|
||||
expect(entry.container.getRootNode()).toBe(shadow.root);
|
||||
expect(entry.parkedAt).toBe(Date.now());
|
||||
expect(frame.isConnected).toBe(true);
|
||||
expect(parking.childElementCount).toBe(0);
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(store.getRecord(host.value.key)?.entry).toBe(entry);
|
||||
expect(store.attach(host.value)).toBe(entry);
|
||||
expect(entry.container.parentElement).toBe(host.value.element);
|
||||
expect(entry.container.firstChild).toBe(frame);
|
||||
expect(entry.runtime.getSignal().aborted).toBe(false);
|
||||
expect(nativeMove.mock.contexts).toEqual([
|
||||
shadow.parking,
|
||||
host.value.element,
|
||||
]);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('shares one four-card budget and expiry timers across main and shadow parking', () => {
|
||||
const { store, parking } = setup();
|
||||
const shadow = shadowParking(store);
|
||||
const cards = Array.from(
|
||||
{ length: MAX_PARKED_INSTANCES + 1 },
|
||||
(_, index) => {
|
||||
const host = slot(`mixed-${index}`);
|
||||
if (index % 2 === 0) shadow.root.appendChild(host.value.element);
|
||||
const entry = store.attach(host.value)!;
|
||||
entry.container.appendChild(document.createElement('iframe'));
|
||||
store.release(host.value.key, host.value.token);
|
||||
vi.advanceTimersByTime(1);
|
||||
return { host, entry };
|
||||
},
|
||||
);
|
||||
|
||||
expect(store.getRecord(cards[0].host.value.key)?.reason).toBe('evicted');
|
||||
expect(cards[0].entry.container.isConnected).toBe(false);
|
||||
expect(store.getEntries()).toEqual(
|
||||
cards.slice(1).map(({ entry }) => entry),
|
||||
);
|
||||
expect(parking.childElementCount).toBe(2);
|
||||
expect(shadow.parking.childElementCount).toBe(2);
|
||||
expect(vi.getTimerCount()).toBe(MAX_PARKED_INSTANCES);
|
||||
const restored = cards[2];
|
||||
expect(store.attach(restored.host.value)).toBe(restored.entry);
|
||||
|
||||
vi.advanceTimersByTime(PARKED_INSTANCE_TTL);
|
||||
|
||||
expect(store.getEntries()).toEqual([restored.entry]);
|
||||
expect(restored.entry.container.parentElement).toBe(
|
||||
restored.host.value.element,
|
||||
);
|
||||
expect(restored.entry.runtime.isVisible()).toBe(true);
|
||||
for (const index of [1, 3, 4]) {
|
||||
expect(store.getRecord(cards[index].host.value.key)?.reason).toBe(
|
||||
'expired',
|
||||
);
|
||||
expect(cards[index].entry.container.isConnected).toBe(false);
|
||||
}
|
||||
expect(parking.childElementCount).toBe(0);
|
||||
expect(shadow.parking.childElementCount).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('unregisters only its parked shadow entries and refuses later moves without a matching host', () => {
|
||||
const { store, parking } = setup();
|
||||
const shadow = shadowParking(store);
|
||||
const main = slot('main');
|
||||
const parked = slot('shadow-parked');
|
||||
const attached = slot('shadow-attached');
|
||||
shadow.root.append(parked.value.element, attached.value.element);
|
||||
const mainEntry = store.attach(main.value)!;
|
||||
const parkedEntry = store.attach(parked.value)!;
|
||||
const attachedEntry = store.attach(attached.value)!;
|
||||
for (const entry of [mainEntry, parkedEntry, attachedEntry])
|
||||
entry.container.appendChild(document.createElement('iframe'));
|
||||
store.release(main.value.key, main.value.token);
|
||||
store.release(parked.value.key, parked.value.token);
|
||||
|
||||
shadow.unregister();
|
||||
|
||||
expect(store.getRecord(parked.value.key)?.reason).toBe('parking-removed');
|
||||
expect(parkedEntry.container.isConnected).toBe(false);
|
||||
expect(store.getEntries()).toEqual([mainEntry, attachedEntry]);
|
||||
expect(mainEntry.container.parentElement).toBe(parking);
|
||||
expect(attachedEntry.runtime.isVisible()).toBe(true);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
store.release(attached.value.key, attached.value.token);
|
||||
expect(store.getRecord(attached.value.key)?.reason).toBe('move-failed');
|
||||
expect(attachedEntry.container.isConnected).toBe(false);
|
||||
expect(store.getEntries()).toEqual([mainEntry]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Generated UI retention budgets', () => {
|
||||
it('evicts only the least recently parked runtime when a fifth is parked', () => {
|
||||
expect(MAX_PARKED_INSTANCES).toBe(4);
|
||||
const { store } = setup();
|
||||
const attached = slot('visible');
|
||||
const attachedEntry = store.attach(attached.value)!;
|
||||
const parked = Array.from({ length: MAX_PARKED_INSTANCES }, (_, index) => {
|
||||
const host = slot(`parked-${index}`);
|
||||
const entry = store.attach(host.value)!;
|
||||
store.release(host.value.key, host.value.token);
|
||||
vi.advanceTimersByTime(1);
|
||||
return { host, entry };
|
||||
});
|
||||
expect(store.attach(parked[0].host.value)).toBe(parked[0].entry);
|
||||
store.release(parked[0].host.value.key, parked[0].host.value.token);
|
||||
vi.advanceTimersByTime(1);
|
||||
const fifth = slot('parked-fifth');
|
||||
store.attach(fifth.value);
|
||||
store.release(fifth.value.key, fifth.value.token);
|
||||
|
||||
expect(store.getRecord(parked[1].host.value.key)?.reason).toBe('evicted');
|
||||
expect(parked[1].entry.container.isConnected).toBe(false);
|
||||
expect(store.getRecord(parked[0].host.value.key)?.entry).toBe(
|
||||
parked[0].entry,
|
||||
);
|
||||
expect(store.getRecord(attached.value.key)?.entry).toBe(attachedEntry);
|
||||
expect(attachedEntry.runtime.isVisible()).toBe(true);
|
||||
expect(store.getEntries()).toHaveLength(MAX_PARKED_INSTANCES + 1);
|
||||
expect(vi.getTimerCount()).toBe(MAX_PARKED_INSTANCES);
|
||||
});
|
||||
|
||||
it('expires at 15 minutes, while remount cancels the old expiry timer', () => {
|
||||
expect(PARKED_INSTANCE_TTL).toBe(15 * 60_000);
|
||||
const { store } = setup();
|
||||
const host = slot();
|
||||
const entry = store.attach(host.value)!;
|
||||
store.release(host.value.key, host.value.token);
|
||||
vi.advanceTimersByTime(PARKED_INSTANCE_TTL - 1);
|
||||
expect(store.getRecord(host.value.key)?.entry).toBe(entry);
|
||||
expect(store.attach(host.value)).toBe(entry);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
vi.advanceTimersByTime(PARKED_INSTANCE_TTL);
|
||||
expect(store.getRecord(host.value.key)?.entry).toBe(entry);
|
||||
|
||||
store.release(host.value.key, host.value.token);
|
||||
vi.advanceTimersByTime(PARKED_INSTANCE_TTL - 1);
|
||||
expect(store.getRecord(host.value.key)?.entry).toBe(entry);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(store.getRecord(host.value.key)?.entry).toBeUndefined();
|
||||
expect(store.getRecord(host.value.key)?.reason).toBe('expired');
|
||||
expect(entry.container.isConnected).toBe(false);
|
||||
expect(store.attach(host.value)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses absolute parked age when background expiry timers have not run', () => {
|
||||
const { store } = setup();
|
||||
const host = slot();
|
||||
const entry = store.attach(host.value)!;
|
||||
const parkedAt = Date.now();
|
||||
store.release(host.value.key, host.value.token);
|
||||
vi.setSystemTime(parkedAt + PARKED_INSTANCE_TTL);
|
||||
expect(store.getRecord(host.value.key)?.entry).toBe(entry);
|
||||
|
||||
expect(store.attach(host.value)).toBeUndefined();
|
||||
expect(store.getRecord(host.value.key)?.reason).toBe('expired-or-changed');
|
||||
expect(entry.container.isConnected).toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
const replacement = store.attach(host.value, true);
|
||||
expect(replacement).toBeDefined();
|
||||
expect(replacement!.id).not.toBe(entry.id);
|
||||
});
|
||||
|
||||
it('bounds released metadata and leaves saturated unknown keys for outer fallback', () => {
|
||||
expect(MAX_INSTANCE_RECORDS).toBe(128);
|
||||
const { store } = setup();
|
||||
const retained = slot('retained');
|
||||
const retainedEntry = store.attach(retained.value)!;
|
||||
const releasedKeys: string[] = [];
|
||||
for (let index = 1; index < MAX_INSTANCE_RECORDS; index++) {
|
||||
const host = slot(`released-${index}`);
|
||||
store.attach(host.value);
|
||||
store.dispose(host.value.key, 'reset');
|
||||
releasedKeys.push(host.value.key);
|
||||
}
|
||||
const overflow = slot('overflow');
|
||||
|
||||
expect(store.attach(overflow.value)).toBeUndefined();
|
||||
expect(store.attach(overflow.value, true)).toBeUndefined();
|
||||
expect(store.getRecord(overflow.value.key)).toBeUndefined();
|
||||
expect(store.getEntries()).toEqual([retainedEntry]);
|
||||
expect(retainedEntry.runtime.isVisible()).toBe(true);
|
||||
for (const key of releasedKeys) {
|
||||
expect(store.getRecord(key)).toEqual({
|
||||
scopeId: 'daemon-a',
|
||||
sessionId: 'session-a',
|
||||
entry: undefined,
|
||||
reason: 'reset',
|
||||
});
|
||||
}
|
||||
store.invalidate({ scopeId: 'daemon-a', reason: 'scope-changed' });
|
||||
expect(store.getRecord(retained.value.key)).toBeUndefined();
|
||||
expect(store.attach(overflow.value)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Generated UI invalidation and cleanup', () => {
|
||||
it('parks cleared attachments without losing parked or released records', async () => {
|
||||
const { store, parking } = setup();
|
||||
const attached = slot('attached');
|
||||
const parked = slot('parked');
|
||||
const released = slot('released');
|
||||
const attachedEntry = store.attach(attached.value)!;
|
||||
attachedEntry.container.appendChild(document.createElement('iframe'));
|
||||
const parkedEntry = store.attach(parked.value)!;
|
||||
store.release(parked.value.key, parked.value.token);
|
||||
const parkedAt = parkedEntry.parkedAt;
|
||||
store.attach(released.value);
|
||||
store.dispose(released.value.key, 'reset');
|
||||
const signal = attachedEntry.runtime.getSignal();
|
||||
|
||||
store.invalidate({
|
||||
scopeId: 'daemon-a',
|
||||
sessionId: 'session-a',
|
||||
reason: 'cleared',
|
||||
});
|
||||
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(attachedEntry.container.parentElement).toBe(parking);
|
||||
expect(store.getRecord(attached.value.key)?.entry).toBe(attachedEntry);
|
||||
expect(parkedEntry.parkedAt).toBe(parkedAt);
|
||||
expect(store.getRecord(released.value.key)?.reason).toBe('reset');
|
||||
expect(
|
||||
await attachedEntry.messages.requestMessage(messageRequest()),
|
||||
).toEqual({ isError: true });
|
||||
expect(store.attach(attached.value)).toBe(attachedEntry);
|
||||
expect(attachedEntry.runtime.getSignal().aborted).toBe(false);
|
||||
expect(store.attach(released.value)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps explicit-reopen records after scope-wide authentication failure', () => {
|
||||
const { store } = setup();
|
||||
const host = slot();
|
||||
const stopped = slot('stopped');
|
||||
const entry = store.attach(host.value)!;
|
||||
store.attach(stopped.value);
|
||||
store.release(stopped.value.key, stopped.value.token);
|
||||
|
||||
store.invalidate({ scopeId: 'daemon-a', reason: 'auth-failed' });
|
||||
|
||||
expect(entry.container.isConnected).toBe(false);
|
||||
expect(store.getEntries()).toEqual([]);
|
||||
for (const card of [host, stopped]) {
|
||||
expect(store.getRecord(card.value.key)?.reason).toBe('auth-failed');
|
||||
expect(store.attach(card.value)).toBeUndefined();
|
||||
expect(store.attach(card.value, true)?.runtime.reopened).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('invalidates only the requested owner and drops released payload references', async () => {
|
||||
const { store } = setup();
|
||||
const target = slot();
|
||||
const sameScopeOtherSession = slot(
|
||||
'call-a',
|
||||
source({ sessionId: 'b' }).value,
|
||||
);
|
||||
const otherScope = slot('call-a', source({ scopeId: 'daemon-b' }).value);
|
||||
const targetEntry = store.attach(target.value)!;
|
||||
const otherSessionEntry = store.attach(sameScopeOtherSession.value)!;
|
||||
const otherScopeEntry = store.attach(otherScope.value)!;
|
||||
await targetEntry.messages.requestMessage(messageRequest());
|
||||
const targetSignal = targetEntry.runtime.getSignal();
|
||||
|
||||
store.invalidate({
|
||||
scopeId: 'daemon-a',
|
||||
sessionId: 'session-a',
|
||||
reason: 'deleted',
|
||||
});
|
||||
|
||||
expect(targetSignal.aborted).toBe(true);
|
||||
expect(targetEntry.container.isConnected).toBe(false);
|
||||
expect(store.getRecord(target.value.key)).toEqual({
|
||||
scopeId: 'daemon-a',
|
||||
sessionId: 'session-a',
|
||||
entry: undefined,
|
||||
reason: 'deleted',
|
||||
});
|
||||
expect(await targetEntry.messages.requestMessage(messageRequest())).toEqual(
|
||||
{
|
||||
isError: true,
|
||||
},
|
||||
);
|
||||
expect(target.requestMessage).toHaveBeenCalledTimes(1);
|
||||
expect(store.getEntries()).toEqual([otherSessionEntry, otherScopeEntry]);
|
||||
expect(store.attach(target.value)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears attached and parked entries, timers and subsequent host access', async () => {
|
||||
const { store, parking } = setup();
|
||||
const attached = slot('attached');
|
||||
const parked = slot('parked');
|
||||
const attachedEntry = store.attach(attached.value)!;
|
||||
const parkedEntry = store.attach(parked.value)!;
|
||||
const attachedSignal = attachedEntry.runtime.getSignal();
|
||||
store.release(parked.value.key, parked.value.token);
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = store.subscribe(listener);
|
||||
store.clear();
|
||||
|
||||
expect(attachedSignal.aborted).toBe(true);
|
||||
expect(attachedEntry.container.isConnected).toBe(false);
|
||||
expect(parkedEntry.container.isConnected).toBe(false);
|
||||
expect(parking.children).toHaveLength(0);
|
||||
expect(store.getEntries()).toEqual([]);
|
||||
expect(store.getRecord(attached.value.key)).toBeUndefined();
|
||||
expect(store.getRecord(parked.value.key)).toBeUndefined();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(
|
||||
await attachedEntry.messages.requestMessage(messageRequest()),
|
||||
).toEqual({
|
||||
isError: true,
|
||||
});
|
||||
expect(attached.requestMessage).not.toHaveBeenCalled();
|
||||
expect(listener).toHaveBeenCalled();
|
||||
unsubscribe();
|
||||
listener.mockClear();
|
||||
store.clear();
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['missing', 'throwing', 'detached', 'different-root'])(
|
||||
'disposes instead of claiming retention when a DOM move is %s',
|
||||
(failure) => {
|
||||
const { store, parking } = setup();
|
||||
const host = slot();
|
||||
const entry = store.attach(host.value)!;
|
||||
entry.container.appendChild(document.createElement('iframe'));
|
||||
const signal = entry.runtime.getSignal();
|
||||
if (failure === 'missing') {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, 'moveBefore');
|
||||
} else if (failure === 'throwing') {
|
||||
Object.defineProperty(parking, 'moveBefore', {
|
||||
value: () => {
|
||||
throw new DOMException('Cannot move', 'HierarchyRequestError');
|
||||
},
|
||||
});
|
||||
} else if (failure === 'detached') {
|
||||
host.value.element.remove();
|
||||
} else {
|
||||
const shadowHost = document.createElement('div');
|
||||
document.body.appendChild(shadowHost);
|
||||
shadowHost.attachShadow({ mode: 'open' }).appendChild(parking);
|
||||
}
|
||||
|
||||
store.release(host.value.key, host.value.token);
|
||||
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(entry.container.isConnected).toBe(false);
|
||||
expect(store.getRecord(host.value.key)?.reason).toBe('move-failed');
|
||||
expect(store.getRecord(host.value.key)?.entry).toBeUndefined();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(store.attach(host.value)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,424 @@
|
||||
import type { ContextType } from 'react';
|
||||
import type { McpAppDisplay } from '../components/messages/McpApp';
|
||||
import type {
|
||||
McpAppGeneratedUi,
|
||||
McpAppGeneratedUiInvalidation,
|
||||
McpAppGeneratedUiOwnerSource,
|
||||
McpAppMessages,
|
||||
McpAppToolsContext,
|
||||
} from '../mcpAppHostContext';
|
||||
|
||||
export const MAX_PARKED_INSTANCES = 4;
|
||||
export const PARKED_INSTANCE_TTL = 15 * 60_000;
|
||||
export const MAX_INSTANCE_RECORDS = 128;
|
||||
|
||||
type ToolHost = NonNullable<ContextType<typeof McpAppToolsContext>>;
|
||||
|
||||
export interface InstanceHost {
|
||||
source: McpAppGeneratedUiOwnerSource;
|
||||
receipt: McpAppGeneratedUi;
|
||||
tools?: ToolHost;
|
||||
messages?: McpAppMessages;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export interface InstanceSlot extends InstanceHost {
|
||||
key: string;
|
||||
resourceKey: string;
|
||||
display: McpAppDisplay;
|
||||
element: HTMLElement;
|
||||
token: object;
|
||||
visible: boolean;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
interface Lease {
|
||||
generation: number;
|
||||
controller: AbortController;
|
||||
owner: ReturnType<McpAppGeneratedUiOwnerSource['captureOwner']>;
|
||||
host: InstanceHost;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export interface GeneratedUiInstance {
|
||||
id: number;
|
||||
key: string;
|
||||
scopeId: string;
|
||||
sessionId: string;
|
||||
resourceKey: string;
|
||||
display: McpAppDisplay;
|
||||
baseUrl: string;
|
||||
container: HTMLDivElement;
|
||||
canCallTool: boolean;
|
||||
canMessage: boolean;
|
||||
token?: object;
|
||||
lease?: Lease;
|
||||
leaseGeneration?: number;
|
||||
parkedAt?: number;
|
||||
timer?: ReturnType<typeof setTimeout>;
|
||||
tools: ToolHost;
|
||||
messages: McpAppMessages;
|
||||
runtime: {
|
||||
reopened: boolean;
|
||||
getSignal(): AbortSignal;
|
||||
isVisible(): boolean;
|
||||
reset(): void;
|
||||
};
|
||||
}
|
||||
|
||||
interface InstanceRecord {
|
||||
scopeId: string;
|
||||
sessionId: string;
|
||||
entry?: GeneratedUiInstance;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function instanceKey(
|
||||
source: McpAppGeneratedUiOwnerSource,
|
||||
toolId: string,
|
||||
display: McpAppDisplay,
|
||||
) {
|
||||
return JSON.stringify([
|
||||
source.scopeId,
|
||||
source.workspaceCwd,
|
||||
source.sessionId,
|
||||
toolId,
|
||||
display.serverName,
|
||||
display.resourceUri,
|
||||
]);
|
||||
}
|
||||
|
||||
export function resourceKey(
|
||||
display: McpAppDisplay,
|
||||
eventEpoch?: string,
|
||||
capabilities?: { tools: boolean; messages: boolean },
|
||||
) {
|
||||
return JSON.stringify(
|
||||
[
|
||||
display.requiredFeature,
|
||||
display.serverName,
|
||||
display.resourceUri,
|
||||
display.html,
|
||||
display.csp,
|
||||
display.permissions,
|
||||
display.toolArguments,
|
||||
display.toolResult,
|
||||
eventEpoch,
|
||||
capabilities,
|
||||
],
|
||||
(_key, value: unknown) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).sort(([a], [b]) => a.localeCompare(b)),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function move(container: HTMLElement, target: HTMLElement) {
|
||||
const parent = target as HTMLElement & {
|
||||
moveBefore?: (node: Node, before: Node | null) => void;
|
||||
};
|
||||
if (
|
||||
!container.isConnected ||
|
||||
!target.isConnected ||
|
||||
container.getRootNode() !== target.getRootNode()
|
||||
)
|
||||
return false;
|
||||
// StrictMode can release a new slot before its portal first renders. Moving
|
||||
// that empty container cannot reset a frame; live Runtime DOM always needs
|
||||
// the native state-preserving operation below.
|
||||
if (!container.hasChildNodes()) {
|
||||
target.appendChild(container);
|
||||
return true;
|
||||
}
|
||||
if (!parent.moveBefore) return false;
|
||||
try {
|
||||
parent.moveBefore(container, null);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class GeneratedUiInstanceStore {
|
||||
private records = new Map<string, InstanceRecord>();
|
||||
private listeners = new Set<() => void>();
|
||||
private version = 0;
|
||||
private generation = 0;
|
||||
private snapshot: GeneratedUiInstance[] = [];
|
||||
private revoked = AbortSignal.abort();
|
||||
private parkingHosts = new Set<HTMLElement>();
|
||||
parking?: HTMLElement;
|
||||
|
||||
registerParking(element: HTMLElement) {
|
||||
this.parkingHosts.add(element);
|
||||
return () => {
|
||||
this.parkingHosts.delete(element);
|
||||
for (const [key, { entry }] of this.records) {
|
||||
if (
|
||||
entry?.parkedAt !== undefined &&
|
||||
entry.container.parentElement === element
|
||||
)
|
||||
this.dispose(key, 'parking-removed');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
subscribe = (listener: () => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
getVersion = () => this.version;
|
||||
getEntries = () => this.snapshot;
|
||||
getRecord = (key: string) => this.records.get(key);
|
||||
|
||||
private changed() {
|
||||
this.snapshot = [...this.records.values()].flatMap(({ entry }) =>
|
||||
entry ? [entry] : [],
|
||||
);
|
||||
this.version++;
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
|
||||
private current(
|
||||
entry: GeneratedUiInstance,
|
||||
lease = entry.lease,
|
||||
): lease is Lease {
|
||||
return (
|
||||
!!lease &&
|
||||
entry.lease === lease &&
|
||||
entry.leaseGeneration === lease.generation &&
|
||||
this.records.get(entry.key)?.entry === entry &&
|
||||
!!entry.token &&
|
||||
!lease.controller.signal.aborted &&
|
||||
lease.owner.isCurrent() &&
|
||||
lease.host.receipt.sessionId === entry.sessionId &&
|
||||
lease.host.receipt.status.version === 1 &&
|
||||
lease.host.receipt.status.enabled
|
||||
);
|
||||
}
|
||||
|
||||
private revoke(entry: GeneratedUiInstance) {
|
||||
const lease = entry.lease;
|
||||
entry.lease = undefined;
|
||||
entry.leaseGeneration = ++this.generation;
|
||||
lease?.controller.abort();
|
||||
}
|
||||
|
||||
private grant(entry: GeneratedUiInstance, slot: InstanceSlot) {
|
||||
if (!slot.visible) {
|
||||
this.revoke(entry);
|
||||
return;
|
||||
}
|
||||
const existing = entry.lease;
|
||||
if (
|
||||
this.current(entry, existing) &&
|
||||
existing.host.source.clientId === slot.source.clientId
|
||||
) {
|
||||
existing.host = slot;
|
||||
existing.reset = slot.reset;
|
||||
return;
|
||||
}
|
||||
this.revoke(entry);
|
||||
const owner = slot.source.captureOwner();
|
||||
if (!owner.isCurrent()) return;
|
||||
entry.lease = {
|
||||
generation: ++this.generation,
|
||||
controller: new AbortController(),
|
||||
owner,
|
||||
host: slot,
|
||||
reset: slot.reset,
|
||||
};
|
||||
entry.leaseGeneration = entry.lease.generation;
|
||||
}
|
||||
|
||||
attach(
|
||||
slot: InstanceSlot,
|
||||
explicit = false,
|
||||
): GeneratedUiInstance | undefined {
|
||||
let record = this.records.get(slot.key);
|
||||
let entry = record?.entry;
|
||||
if (entry?.token && entry.token !== slot.token) return undefined;
|
||||
if (
|
||||
entry &&
|
||||
(entry.resourceKey !== slot.resourceKey ||
|
||||
(entry.parkedAt !== undefined &&
|
||||
Date.now() - entry.parkedAt >= PARKED_INSTANCE_TTL))
|
||||
) {
|
||||
this.dispose(slot.key, 'expired-or-changed');
|
||||
entry = undefined;
|
||||
}
|
||||
if (
|
||||
!slot.element.isConnected ||
|
||||
!slot.source.eventEpoch ||
|
||||
!slot.source.captureOwner().isCurrent() ||
|
||||
slot.receipt.sessionId !== slot.source.sessionId ||
|
||||
slot.receipt.status.version !== 1 ||
|
||||
!slot.receipt.status.enabled
|
||||
) {
|
||||
if (entry) this.dispose(slot.key, 'unavailable');
|
||||
return undefined;
|
||||
}
|
||||
if (!entry) {
|
||||
if (
|
||||
(!explicit && record) ||
|
||||
(!record && this.records.size >= MAX_INSTANCE_RECORDS)
|
||||
)
|
||||
return undefined;
|
||||
const container = document.createElement('div');
|
||||
slot.element.appendChild(container);
|
||||
entry = {
|
||||
id: ++this.generation,
|
||||
key: slot.key,
|
||||
scopeId: slot.source.scopeId,
|
||||
sessionId: slot.source.sessionId,
|
||||
resourceKey: slot.resourceKey,
|
||||
display: slot.display,
|
||||
baseUrl: slot.baseUrl,
|
||||
container,
|
||||
canCallTool: slot.tools?.sessionId === slot.source.sessionId,
|
||||
canMessage: slot.messages?.sessionId === slot.source.sessionId,
|
||||
tools: undefined!,
|
||||
messages: undefined!,
|
||||
runtime: undefined!,
|
||||
};
|
||||
const instance = entry;
|
||||
entry.tools = {
|
||||
sessionId: entry.sessionId,
|
||||
callTool: async (request, signal) => {
|
||||
const lease = instance.lease;
|
||||
if (
|
||||
!this.current(instance, lease) ||
|
||||
!lease.host.tools ||
|
||||
lease.host.tools.sessionId !== instance.sessionId
|
||||
)
|
||||
throw new Error('Generated UI is inactive');
|
||||
const result = await lease.host.tools.callTool(
|
||||
request,
|
||||
AbortSignal.any([signal, lease.controller.signal]),
|
||||
);
|
||||
if (!this.current(instance, lease))
|
||||
throw new Error('Generated UI owner changed');
|
||||
return result;
|
||||
},
|
||||
};
|
||||
entry.messages = {
|
||||
sessionId: entry.sessionId,
|
||||
requestMessage: async (request) => {
|
||||
const lease = instance.lease;
|
||||
if (
|
||||
!this.current(instance, lease) ||
|
||||
!lease.host.messages ||
|
||||
lease.host.messages.sessionId !== instance.sessionId
|
||||
)
|
||||
return { isError: true };
|
||||
const result = await lease.host.messages.requestMessage({
|
||||
...request,
|
||||
signal: AbortSignal.any([request.signal, lease.controller.signal]),
|
||||
});
|
||||
return this.current(instance, lease) ? result : { isError: true };
|
||||
},
|
||||
};
|
||||
entry.runtime = {
|
||||
reopened: explicit,
|
||||
getSignal: () =>
|
||||
this.current(instance)
|
||||
? instance.lease!.controller.signal
|
||||
: this.revoked,
|
||||
isVisible: () => this.current(instance),
|
||||
reset: () => {
|
||||
if (this.current(instance)) instance.lease!.reset();
|
||||
},
|
||||
};
|
||||
record = { scopeId: entry.scopeId, sessionId: entry.sessionId, entry };
|
||||
this.records.set(slot.key, record);
|
||||
} else if (!entry.token && !move(entry.container, slot.element)) {
|
||||
this.dispose(slot.key, 'move-failed');
|
||||
return undefined;
|
||||
}
|
||||
const wasParked = entry.parkedAt !== undefined;
|
||||
clearTimeout(entry.timer);
|
||||
entry.timer = undefined;
|
||||
entry.parkedAt = undefined;
|
||||
entry.token = slot.token;
|
||||
this.grant(entry, slot);
|
||||
if (wasParked || !this.snapshot.includes(entry)) this.changed();
|
||||
return entry;
|
||||
}
|
||||
|
||||
update(slot: InstanceSlot) {
|
||||
const entry = this.records.get(slot.key)?.entry;
|
||||
if (entry?.token === slot.token) this.grant(entry, slot);
|
||||
}
|
||||
|
||||
release(key: string, token: object) {
|
||||
const entry = this.records.get(key)?.entry;
|
||||
if (!entry || entry.token !== token) return;
|
||||
this.revoke(entry);
|
||||
entry.token = undefined;
|
||||
const focused = entry.container.ownerDocument.activeElement;
|
||||
if (focused && entry.container.contains(focused))
|
||||
(focused as HTMLElement).blur();
|
||||
const root = entry.container.getRootNode();
|
||||
const parking = [this.parking, ...this.parkingHosts].find(
|
||||
(element) => element?.isConnected && element.getRootNode() === root,
|
||||
);
|
||||
if (!parking || !move(entry.container, parking)) {
|
||||
this.dispose(key, 'move-failed');
|
||||
return;
|
||||
}
|
||||
entry.parkedAt = Date.now();
|
||||
entry.timer = setTimeout(() => {
|
||||
if (this.records.get(key)?.entry === entry && !entry.token)
|
||||
this.dispose(key, 'expired');
|
||||
}, PARKED_INSTANCE_TTL);
|
||||
const parked = [...this.records.values()]
|
||||
.flatMap(({ entry }) => (entry?.parkedAt !== undefined ? [entry] : []))
|
||||
.sort((a, b) => a.parkedAt! - b.parkedAt!);
|
||||
while (parked.length > MAX_PARKED_INSTANCES)
|
||||
this.dispose(parked.shift()!.key, 'evicted');
|
||||
this.changed();
|
||||
}
|
||||
|
||||
dispose(key: string, reason: string) {
|
||||
const record = this.records.get(key);
|
||||
if (!record) return;
|
||||
const entry = record.entry;
|
||||
record.entry = undefined;
|
||||
record.reason = reason;
|
||||
if (entry) {
|
||||
this.revoke(entry);
|
||||
clearTimeout(entry.timer);
|
||||
entry.token = undefined;
|
||||
entry.container.remove();
|
||||
}
|
||||
this.changed();
|
||||
}
|
||||
|
||||
invalidate = (event: McpAppGeneratedUiInvalidation) => {
|
||||
for (const [key, record] of this.records) {
|
||||
if (
|
||||
record.scopeId === event.scopeId &&
|
||||
(!event.sessionId || event.sessionId === record.sessionId)
|
||||
) {
|
||||
if (event.reason === 'cleared') {
|
||||
if (record.entry?.token) this.release(key, record.entry.token);
|
||||
continue;
|
||||
}
|
||||
this.dispose(key, event.reason);
|
||||
if (event.reason === 'scope-changed') this.records.delete(key);
|
||||
}
|
||||
}
|
||||
this.changed();
|
||||
};
|
||||
|
||||
clear() {
|
||||
for (const key of this.records.keys()) this.dispose(key, 'closed');
|
||||
this.records.clear();
|
||||
this.changed();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createDaemonTranscriptStore,
|
||||
DaemonHttpError,
|
||||
type DaemonTranscriptStore,
|
||||
} from '@qwen-code/sdk/daemon';
|
||||
import type { Message } from '../adapters/types';
|
||||
import { McpAppGeneratedUiOwnerContext } from '../mcpAppHostContext';
|
||||
import { useMessageDeletion } from './useMessageDeletion';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
store: undefined as unknown as DaemonTranscriptStore,
|
||||
client: {
|
||||
getMessageGroups: vi.fn(),
|
||||
deleteMessageGroups: vi.fn(),
|
||||
},
|
||||
actions: { reloadSession: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
|
||||
useConnection: () => ({
|
||||
status: 'connected',
|
||||
sessionId: 'original-session',
|
||||
clientId: 'client-1',
|
||||
workspaceCwd: '/workspace',
|
||||
capabilities: { features: ['session_message_deletion'] },
|
||||
}),
|
||||
useActions: () => mocks.actions,
|
||||
useTranscriptStore: () => mocks.store,
|
||||
useWorkspace: () => ({ client: mocks.client }),
|
||||
}));
|
||||
vi.mock('../i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock('../interactionBlockContext', () => ({
|
||||
useInteractionBlocker: () => () => () => undefined,
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
const message: Message = {
|
||||
id: 'message-1',
|
||||
role: 'user',
|
||||
content: 'create a view',
|
||||
promptId: 'prompt-1',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
mocks.store = createDaemonTranscriptStore();
|
||||
mocks.client.getMessageGroups.mockReset().mockResolvedValue({
|
||||
snapshot: 'snapshot-1',
|
||||
groups: [{ id: 'group-1', promptId: 'prompt-1', recordIds: [] }],
|
||||
});
|
||||
mocks.client.deleteMessageGroups.mockReset().mockResolvedValue({});
|
||||
mocks.actions.reloadSession.mockReset().mockResolvedValue(undefined);
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
describe('Generated UI message deletion invalidation', () => {
|
||||
it.each([
|
||||
'success',
|
||||
'persisted-context-failure',
|
||||
'not-persisted-context-failure',
|
||||
'refused',
|
||||
] as const)(
|
||||
'uses the real deletion result before invalidating: %s',
|
||||
async (outcome) => {
|
||||
if (outcome !== 'success') {
|
||||
mocks.client.deleteMessageGroups.mockRejectedValue(
|
||||
new DaemonHttpError(
|
||||
409,
|
||||
{
|
||||
code: outcome.endsWith('context-failure')
|
||||
? 'message_deletion_context_unavailable'
|
||||
: 'session_busy',
|
||||
persisted: outcome === 'persisted-context-failure',
|
||||
},
|
||||
'deletion failed',
|
||||
),
|
||||
);
|
||||
}
|
||||
const invalidate = vi.fn();
|
||||
let controls: ReturnType<typeof useMessageDeletion>;
|
||||
function Harness() {
|
||||
controls = useMessageDeletion([message], false);
|
||||
return null;
|
||||
}
|
||||
act(() =>
|
||||
root.render(
|
||||
<McpAppGeneratedUiOwnerContext.Provider
|
||||
value={{
|
||||
scopeId: 'original-scope',
|
||||
workspaceCwd: '/workspace',
|
||||
subscribeInvalidation: () => () => undefined,
|
||||
captureSessionDeletion: () => () => undefined,
|
||||
invalidate,
|
||||
}}
|
||||
>
|
||||
<Harness />
|
||||
</McpAppGeneratedUiOwnerContext.Provider>,
|
||||
),
|
||||
);
|
||||
await act(async () => {
|
||||
await controls!.start(message);
|
||||
});
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
await controls!.submit();
|
||||
});
|
||||
expect(mocks.client.deleteMessageGroups).toHaveBeenCalledOnce();
|
||||
if (
|
||||
outcome === 'refused' ||
|
||||
outcome === 'not-persisted-context-failure'
|
||||
) {
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
expect(mocks.actions.reloadSession).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
scopeId: 'original-scope',
|
||||
sessionId: 'original-session',
|
||||
reason: 'deleted',
|
||||
});
|
||||
expect(mocks.actions.reloadSession).toHaveBeenCalledOnce();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('deletes only local command output without invalidating Generated UI', async () => {
|
||||
mocks.client.getMessageGroups.mockResolvedValue({
|
||||
snapshot: '',
|
||||
groups: [],
|
||||
});
|
||||
mocks.store = createDaemonTranscriptStore({
|
||||
blocks: [
|
||||
{
|
||||
id: 'local-output',
|
||||
kind: 'user',
|
||||
text: '/stats',
|
||||
promptId: 'local-command:stats',
|
||||
meta: { localCommandId: 'local-1' },
|
||||
clientReceivedAt: 0,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
{
|
||||
id: 'persisted-output',
|
||||
kind: 'user',
|
||||
text: 'Keep this message',
|
||||
promptId: 'persisted-prompt',
|
||||
clientReceivedAt: 0,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
const localMessage: Message = {
|
||||
id: 'local-message',
|
||||
role: 'user',
|
||||
content: '/stats',
|
||||
localCommandId: 'local-1',
|
||||
sourceBlockIds: ['local-output'],
|
||||
};
|
||||
const invalidate = vi.fn();
|
||||
let controls: ReturnType<typeof useMessageDeletion>;
|
||||
function Harness() {
|
||||
controls = useMessageDeletion([localMessage], false);
|
||||
return null;
|
||||
}
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<McpAppGeneratedUiOwnerContext.Provider
|
||||
value={{
|
||||
scopeId: 'original-scope',
|
||||
workspaceCwd: '/workspace',
|
||||
subscribeInvalidation: () => () => undefined,
|
||||
captureSessionDeletion: () => () => undefined,
|
||||
invalidate,
|
||||
}}
|
||||
>
|
||||
<Harness />
|
||||
</McpAppGeneratedUiOwnerContext.Provider>,
|
||||
),
|
||||
);
|
||||
await act(async () => {
|
||||
await controls!.start(localMessage);
|
||||
});
|
||||
expect(controls!.active).toBe(true);
|
||||
expect(controls!.selected.size).toBe(1);
|
||||
await act(async () => {
|
||||
await controls!.submit();
|
||||
});
|
||||
expect(mocks.store.getSnapshot().blocks.map((block) => block.id)).toEqual([
|
||||
'persisted-output',
|
||||
]);
|
||||
expect(controls!.active).toBe(false);
|
||||
expect(controls!.error).toBeUndefined();
|
||||
expect(mocks.client.deleteMessageGroups).not.toHaveBeenCalled();
|
||||
expect(mocks.actions.reloadSession).not.toHaveBeenCalled();
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { DaemonHttpError } from '@qwen-code/sdk/daemon';
|
||||
import {
|
||||
useActions,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import type { Message } from '../adapters/types';
|
||||
import { useInteractionBlocker } from '../interactionBlockContext';
|
||||
import { useI18n } from '../i18n';
|
||||
import { McpAppGeneratedUiOwnerContext } from '../mcpAppHostContext';
|
||||
import {
|
||||
localMessageDeletionGroups,
|
||||
hasPendingLocalCommands,
|
||||
@@ -40,6 +41,7 @@ export function useMessageDeletion(
|
||||
sessionBlocked: boolean,
|
||||
historical = false,
|
||||
) {
|
||||
const generatedUiOwner = useContext(McpAppGeneratedUiOwnerContext);
|
||||
const connection = useConnection();
|
||||
const actions = useActions();
|
||||
const store = useTranscriptStore();
|
||||
@@ -216,6 +218,13 @@ export function useMessageDeletion(
|
||||
}
|
||||
}
|
||||
deleted = true;
|
||||
if (generatedUiOwner && connection.sessionId && persisted.length > 0) {
|
||||
generatedUiOwner.invalidate({
|
||||
scopeId: generatedUiOwner.scopeId,
|
||||
sessionId: connection.sessionId,
|
||||
reason: 'deleted',
|
||||
});
|
||||
}
|
||||
if (currentKey.current !== key || token !== operation.current) return;
|
||||
if (localIds.size > 0) {
|
||||
const snapshot = store.getSnapshot();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { DaemonSessionSummary } from '@qwen-code/sdk/daemon';
|
||||
import { McpAppGeneratedUiOwnerContext } from '../mcpAppHostContext';
|
||||
|
||||
const primarySessions = [
|
||||
{ sessionId: 'primary', workspaceCwd: '/primary' },
|
||||
@@ -90,6 +91,52 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('useScopedSessions', () => {
|
||||
it.each(['/retained-deletion', '/another-workspace'])(
|
||||
'captures confirmed deletions in the requested workspace from owner %s',
|
||||
async (ownerWorkspace) => {
|
||||
listWorkspaceSessionsPage.mockResolvedValue({ sessions: [] });
|
||||
deleteSessionsData.mockResolvedValue({
|
||||
removed: ['secondary'],
|
||||
notFound: ['missing'],
|
||||
errors: [{ sessionId: 'failed', error: 'busy' }],
|
||||
});
|
||||
const invalidateDeleted = vi.fn();
|
||||
const captureSessionDeletion = vi.fn(() => invalidateDeleted);
|
||||
let sessions: ReturnType<typeof useScopedSessions> | undefined;
|
||||
function Harness() {
|
||||
sessions = useScopedSessions('/retained-deletion');
|
||||
return null;
|
||||
}
|
||||
act(() => {
|
||||
root!.render(
|
||||
<McpAppGeneratedUiOwnerContext.Provider
|
||||
value={{
|
||||
scopeId: 'retained-scope',
|
||||
workspaceCwd: ownerWorkspace,
|
||||
invalidate: vi.fn(),
|
||||
captureSessionDeletion,
|
||||
subscribeInvalidation: () => () => undefined,
|
||||
}}
|
||||
>
|
||||
<Harness />
|
||||
</McpAppGeneratedUiOwnerContext.Provider>,
|
||||
);
|
||||
});
|
||||
expect(invalidateDeleted).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
await sessions!.deleteSessions(['secondary', 'missing', 'failed']);
|
||||
});
|
||||
expect(captureSessionDeletion).toHaveBeenCalledWith('/retained-deletion');
|
||||
expect(captureSessionDeletion.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
deleteSessionsData.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
expect(invalidateDeleted).toHaveBeenCalledExactlyOnceWith([
|
||||
'secondary',
|
||||
'missing',
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it('loads and mutates sessions through the requested workspace', async () => {
|
||||
listWorkspaceSessionsPage.mockResolvedValue({
|
||||
sessions: [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { useWorkspace } from '@qwen-code/web-shell/daemon-react-sdk';
|
||||
import type { DaemonSessionArchiveState } from '@qwen-code/sdk/daemon';
|
||||
import { WEB_SHELL_SESSION_SOURCE_TYPE } from '../constants/sessions';
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useWebShellSessions,
|
||||
} from '../session-catalog/session-catalog-hooks';
|
||||
import type { SessionCatalogQuery } from '../session-catalog/session-catalog-store';
|
||||
import { McpAppGeneratedUiOwnerContext } from '../mcpAppHostContext';
|
||||
|
||||
interface ScopedSessionsOptions {
|
||||
autoLoad?: boolean;
|
||||
@@ -24,6 +25,7 @@ export function useScopedSessions(
|
||||
workspaceCwd: string | undefined,
|
||||
options: ScopedSessionsOptions = {},
|
||||
) {
|
||||
const generatedUiOwner = useContext(McpAppGeneratedUiOwnerContext);
|
||||
const {
|
||||
autoLoad = false,
|
||||
enabled = true,
|
||||
@@ -82,14 +84,24 @@ export function useScopedSessions(
|
||||
async (sessionIds: string[]) => {
|
||||
if (!workspaceCwd) return primaryDeleteSessions(sessionIds);
|
||||
try {
|
||||
return await workspace.client
|
||||
const invalidateDeleted =
|
||||
generatedUiOwner?.captureSessionDeletion(workspaceCwd);
|
||||
const result = await workspace.client
|
||||
.workspaceByCwd(workspaceCwd)
|
||||
.deleteSessionsData(sessionIds);
|
||||
invalidateDeleted?.([...result.removed, ...result.notFound]);
|
||||
return result;
|
||||
} finally {
|
||||
controller.invalidateWorkspace(workspaceCwd);
|
||||
}
|
||||
},
|
||||
[controller, primaryDeleteSessions, workspace.client, workspaceCwd],
|
||||
[
|
||||
controller,
|
||||
generatedUiOwner,
|
||||
primaryDeleteSessions,
|
||||
workspace.client,
|
||||
workspaceCwd,
|
||||
],
|
||||
);
|
||||
const deleteSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
*/
|
||||
|
||||
export const MCP_APP_MESSAGES_EN: Record<string, string> = {
|
||||
'mcpApp.generatedUi.reset': 'Reopen and reset',
|
||||
'mcpApp.generatedUi.reopen': 'Reopen interactive view',
|
||||
'mcpApp.generatedUi.openElsewhere': 'Already open elsewhere.',
|
||||
'mcpApp.generatedUi.reopenNotice':
|
||||
'Reopening starts from the original content and discards previous interactions. Check the original session first: analysis already sent is not revoked or automatically resent.',
|
||||
'mcpApp.generatedUi.unavailable':
|
||||
'Generated UI is unavailable for this session.',
|
||||
'mcpApp.message.title': 'Review App message',
|
||||
@@ -34,6 +39,11 @@ export const MCP_APP_MESSAGES_EN: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const MCP_APP_MESSAGES_ZH: Record<string, string> = {
|
||||
'mcpApp.generatedUi.reset': '重新打开并重置',
|
||||
'mcpApp.generatedUi.reopen': '重新打开交互视图',
|
||||
'mcpApp.generatedUi.openElsewhere': '已在另一处打开。',
|
||||
'mcpApp.generatedUi.reopenNotice':
|
||||
'重新打开将从原始内容开始,此前交互不会恢复。请先检查原会话:已发送的分析不会撤销,也不会自动重发。',
|
||||
'mcpApp.generatedUi.unavailable': '此会话未启用内置 Generated UI。',
|
||||
'mcpApp.message.title': '审阅 App 消息',
|
||||
'mcpApp.message.description':
|
||||
|
||||
@@ -49,15 +49,64 @@ export const McpAppGeneratedUiContext = createContext<
|
||||
McpAppGeneratedUi | undefined
|
||||
>(undefined);
|
||||
|
||||
export function useMcpAppAdmission(display?: {
|
||||
export interface McpAppGeneratedUiOwnerSource {
|
||||
scopeId: string;
|
||||
sessionId: string;
|
||||
clientId: string;
|
||||
workspaceCwd: string;
|
||||
eventEpoch?: string;
|
||||
captureOwner(): { isCurrent(): boolean };
|
||||
}
|
||||
|
||||
export interface McpAppGeneratedUiInvalidation {
|
||||
scopeId: string;
|
||||
sessionId?: string;
|
||||
reason:
|
||||
| 'policy'
|
||||
| 'epoch'
|
||||
| 'runtime-stopped'
|
||||
| 'cleared'
|
||||
| 'deleted'
|
||||
| 'rewound'
|
||||
| 'auth-failed'
|
||||
| 'scope-changed';
|
||||
}
|
||||
|
||||
export interface McpAppGeneratedUiOwner {
|
||||
scopeId: string;
|
||||
workspaceCwd?: string;
|
||||
source?: McpAppGeneratedUiOwnerSource;
|
||||
subscribeInvalidation(
|
||||
listener: (event: McpAppGeneratedUiInvalidation) => void,
|
||||
): () => void;
|
||||
invalidate(event: McpAppGeneratedUiInvalidation): void;
|
||||
captureSessionDeletion(
|
||||
workspaceCwd: string | undefined,
|
||||
): (sessionIds: readonly string[]) => void;
|
||||
}
|
||||
|
||||
export const McpAppGeneratedUiOwnerContext = createContext<
|
||||
McpAppGeneratedUiOwner | undefined
|
||||
>(undefined);
|
||||
|
||||
export const McpAppVisibilityContext = createContext(true);
|
||||
|
||||
export function isBuiltInGeneratedUi(display?: {
|
||||
resourceUri: string;
|
||||
requiredFeature?: 'generated-ui';
|
||||
}): boolean {
|
||||
return (
|
||||
display?.requiredFeature === 'generated-ui' ||
|
||||
display?.resourceUri === 'ui://qwen-code-generated-ui/view-v1.html'
|
||||
);
|
||||
}
|
||||
|
||||
export function useMcpAppAdmission(
|
||||
display?: Parameters<typeof isBuiltInGeneratedUi>[0],
|
||||
): boolean {
|
||||
const sessionId = useContext(McpAppSessionContext);
|
||||
const capability = useContext(McpAppGeneratedUiContext);
|
||||
const builtIn =
|
||||
display?.requiredFeature === 'generated-ui' ||
|
||||
display?.resourceUri === 'ui://qwen-code-generated-ui/view-v1.html';
|
||||
const builtIn = isBuiltInGeneratedUi(display);
|
||||
return (
|
||||
!builtIn ||
|
||||
((import.meta as ImportMeta & { env?: { MODE?: string } }).env?.MODE !==
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
DaemonSessionListPage,
|
||||
} from '@qwen-code/sdk/daemon';
|
||||
import type { SessionCatalogQuery } from './session-catalog-store';
|
||||
import { McpAppGeneratedUiOwnerContext } from '../mcpAppHostContext';
|
||||
import {
|
||||
SESSION_CATALOG_ERROR_RETRY_MS,
|
||||
SESSION_CATALOG_TRAILING_REFRESH_MS,
|
||||
@@ -186,6 +187,8 @@ describe('session catalog hooks', () => {
|
||||
legacy.mockResolvedValue({ sessions: [{ sessionId: 'primary' }] });
|
||||
mocks.workspace.actions.deleteSession.mockResolvedValue(true);
|
||||
let facade: ReturnType<typeof useWebShellSessions> | undefined;
|
||||
const invalidateDeleted = vi.fn();
|
||||
const captureSessionDeletion = vi.fn(() => invalidateDeleted);
|
||||
|
||||
function FacadeProbe() {
|
||||
facade = useWebShellSessions({ autoLoad: true });
|
||||
@@ -193,11 +196,24 @@ describe('session catalog hooks', () => {
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(<FacadeProbe />);
|
||||
root.render(
|
||||
<McpAppGeneratedUiOwnerContext.Provider
|
||||
value={{
|
||||
scopeId: 'primary-scope',
|
||||
workspaceCwd: '/another-workspace',
|
||||
invalidate: vi.fn(),
|
||||
captureSessionDeletion,
|
||||
subscribeInvalidation: () => () => undefined,
|
||||
}}
|
||||
>
|
||||
<FacadeProbe />
|
||||
</McpAppGeneratedUiOwnerContext.Provider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(legacy).toHaveBeenCalledTimes(1);
|
||||
expect(invalidateDeleted).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
await facade!.deleteSession('primary');
|
||||
@@ -210,6 +226,42 @@ describe('session catalog hooks', () => {
|
||||
mocks.useSessions.mock.results.at(-1)?.value.deleteSession,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(legacy).toHaveBeenCalledTimes(2);
|
||||
expect(captureSessionDeletion).toHaveBeenCalledWith('/primary');
|
||||
expect(captureSessionDeletion.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.workspace.actions.deleteSession.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
expect(invalidateDeleted).toHaveBeenCalledExactlyOnceWith(['primary']);
|
||||
|
||||
invalidateDeleted.mockClear();
|
||||
mocks.workspace.actions.deleteSession.mockResolvedValue(false);
|
||||
await act(async () => {
|
||||
await facade!.deleteSession('failed');
|
||||
});
|
||||
expect(invalidateDeleted).not.toHaveBeenCalled();
|
||||
|
||||
mocks.workspace.actions.deleteSessions.mockResolvedValue({
|
||||
removed: ['removed'],
|
||||
notFound: ['missing'],
|
||||
errors: [{ sessionId: 'failed', error: 'busy' }],
|
||||
});
|
||||
await act(async () => {
|
||||
await facade!.deleteSessions(['removed', 'missing', 'failed']);
|
||||
});
|
||||
expect(invalidateDeleted).toHaveBeenCalledExactlyOnceWith([
|
||||
'removed',
|
||||
'missing',
|
||||
]);
|
||||
|
||||
invalidateDeleted.mockClear();
|
||||
mocks.workspace.actions.deleteSessions.mockRejectedValue(
|
||||
new Error('offline'),
|
||||
);
|
||||
await act(async () => {
|
||||
await expect(facade!.deleteSessions(['failed'])).rejects.toThrow(
|
||||
'offline',
|
||||
);
|
||||
});
|
||||
expect(invalidateDeleted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the legacy reload contract when a list request fails', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
type SessionCatalogQuery,
|
||||
type SessionCatalogSnapshot,
|
||||
} from './session-catalog-store';
|
||||
import { McpAppGeneratedUiOwnerContext } from '../mcpAppHostContext';
|
||||
|
||||
const EMPTY_SNAPSHOTS: readonly SessionCatalogSnapshot[] = [];
|
||||
const EMPTY_QUERIES: readonly SessionCatalogQuery[] = [];
|
||||
@@ -498,6 +500,7 @@ export function useSessionCatalogController(client: DaemonClient) {
|
||||
}
|
||||
|
||||
export function useWebShellSessions(options: WebShellSessionsOptions = {}) {
|
||||
const generatedUiOwner = useContext(McpAppGeneratedUiOwnerContext);
|
||||
const {
|
||||
autoLoad = false,
|
||||
enabled = true,
|
||||
@@ -582,22 +585,30 @@ export function useWebShellSessions(options: WebShellSessionsOptions = {}) {
|
||||
const deleteSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
try {
|
||||
return await workspace.actions.deleteSession(sessionId);
|
||||
const invalidateDeleted =
|
||||
generatedUiOwner?.captureSessionDeletion(workspaceCwd);
|
||||
const removed = await workspace.actions.deleteSession(sessionId);
|
||||
if (removed) invalidateDeleted?.([sessionId]);
|
||||
return removed;
|
||||
} finally {
|
||||
invalidate();
|
||||
}
|
||||
},
|
||||
[invalidate, workspace.actions],
|
||||
[generatedUiOwner, invalidate, workspace.actions, workspaceCwd],
|
||||
);
|
||||
const deleteSessions = useCallback(
|
||||
async (sessionIds: string[]) => {
|
||||
try {
|
||||
return await workspace.actions.deleteSessions(sessionIds);
|
||||
const invalidateDeleted =
|
||||
generatedUiOwner?.captureSessionDeletion(workspaceCwd);
|
||||
const result = await workspace.actions.deleteSessions(sessionIds);
|
||||
invalidateDeleted?.([...result.removed, ...result.notFound]);
|
||||
return result;
|
||||
} finally {
|
||||
invalidate();
|
||||
}
|
||||
},
|
||||
[invalidate, workspace.actions],
|
||||
[generatedUiOwner, invalidate, workspace.actions, workspaceCwd],
|
||||
);
|
||||
const archiveSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
|
||||
Reference in new issue
Block a user