feat(web-shell): add host mount entries and align managed empty-state layout (#15928)

* feat(web-shell): 增加 managed-host 构建入口与托管会话页

为产品侧集成提供独立的 managed-host 产物(--mode managed-host)与托管会话页:
不挂 daemon workspace/session context,托管会话由 provider 直接渲染列表、会话与输入框。

包含:vite lib 构建模式、managed-host 渲染入口及测试、ManagedSessionsPage 托管分支与
测试、provider 类型调整、README 与中英文设计说明。本次不涉及宿主注入能力。

* feat(web-shell): 托管宿主可注入侧栏导航 props

ManagedAgentWebShell 增加可选导航 props 并原样透传给 ManagedSessionsPage:
navigationTarget / archivedNavigationTarget / onArchivedSessionsStatusChange /
navigationActive / navigationSearch。

宿主据此决定是否把官方会话列表 portal 到自己的侧栏(navigationTarget 为 null 时
仍走 home 布局但不 portal),并同步搜索词与归档计数。类型复用 ManagedSessionsPage
现有 props,不新增重复定义;新增 2 个用例覆盖原样透传与未传时保持 undefined。

* feat(web-shell): add full-page app-host entry and align managed empty-state layout

- Add app-host mount entry (renderWebShellApp) so a non-React host can render the
  complete WebShell (sidebar + welcome + composer) by injecting baseUrl/provider;
  no shell is hand-drawn and no backend address is hardcoded.
- Center the managed empty state like the standalone chat (content shrinks so the
  chatPane centers it) instead of pinning the welcome to the top and composer to
  the bottom; gate the welcome header off when a failed session is selected.
- Surface session failure and unconfirmed-submission notices together above the
  composer, dedupe the same message across channels, and style them as callouts.
- A pending prompt owned by another session now offers a redirect to the owning
  conversation instead of retry/discard from a foreign session; reconcile tests.
- Build app-host with global (unscoped) CSS so root-level state classes match.

* test(web-shell): align managed status smoke assertions

Co-authored-by: Qwen-Coder <[email protected]>

* test(web-shell): reach the owning conversation before retrying a foreign pending

A pending prompt owned by another conversation no longer offers retry in the
foreign session: it renders "Open the original conversation" instead. The
spec still clicked "Retry the same request" straight from the foreign session,
so the two foreign variants timed out waiting for a button that is deliberately
absent from this head while the owner variant passed.

Follow the redirect first, then retry. Owner variant unchanged.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvwo8rnm58

* docs(web-shell): document the app-host entry and drop ignored e2e links

The review on head 932a61d8dc read `app-host` as undocumented: no README
section, no mention in either design doc. Document it the same way
`managed-host` is documented -- entry API, build mode, served directory --
and record the unscoped-CSS behaviour as a deliberate decision narrowed to
that bundle, naming the entries that keep scoped CSS. Also state that neither
host mode is part of `npm run build`, which is what the wiring actually does.

Both design docs linked `.qwen/e2e-tests/managed-host-webshell.md`, which
`.gitignore` excludes (`.qwen/*`), so the links are dropped and the
statement they supported stays.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvxdyp0v69

* fix(web-shell): revert breaking changes to existing consumers

- Revert `isChatEmptyState` to `!managedMode &&` (was: `!connection.sessionId && !(managedAgentProvider !== undefined && managedSessionId)`), restoring the original behavior where managed mode is never empty state. The head condition broke existing managed consumers by making them hit empty-state layout when no session was selected.

- Revert `sidebarRailEnabled` to drop `managedAgentProvider === undefined &&`, restoring rail for existing managed consumers. The condition was app-host-specific and should not affect hosts that already pass a provider.

- Revert `ManagedSessionsPage` inline `flex: 0 0 auto` (was unconditional in empty state), restoring `flex: 1 1 auto` for the managed entry. The shrink was only correct when paired with `.appChatEmpty .chatPane { justify-content: center }`, which only exists in the full-page `renderWebShellApp` entry. In `renderManagedAgentWebShell` the class is absent, so the shrink alone produced a 136px content block pinned to the top with 550px dead space below.

- Remove `skipScope` from `vite.lib.config.ts`: the justification ("scoped selectors use [data-web-shell-root] as an ancestor anchor") is factually incorrect. `COMPONENT_SCOPE` includes `:where([data-web-shell-root][data-web-shell-shadcn], ...)` which matches the element itself, so scoped bundles already apply state classes on the root element. Keep scoping for all modes.

- Update README and both design docs to reflect the corrected scoping behavior.

Co-authored-by: Qwen-Coder <[email protected]>

* chore(web-shell): remove app-host entry

The app-host entry (renderWebShellApp) had no build wiring (absent from
build script, exports, files, and no CI job invoked --mode app-host), no
test coverage, and hardcoded a specific host's sidebar/footer configuration.
Remove it from this PR; it can be reintroduced in a follow-up with proper
packaging and tests once a named consumer exists.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): drop unused mode parameter from injectCssModules

The mode parameter became unused after the skipScope removal. Remove it to
fix the ESLint no-unused-vars error.

Co-authored-by: Qwen-Coder <[email protected]>

* docs(web-shell): scope the managed-host entry and correct stale layout notes

- README: say plainly that `managed-host` is source-only (no `build`,
  `exports` or `files` wiring, so it is not in the npm package), and record
  that one shell per shadow root is the contract.
- package.json: keep the orphan `dist/types/managed-host.d.ts` out of the
  published tree so the package does not ship a type with no implementation.
- ManagedSessionsPage: point the `summary.failure` dedupe comment at the
  current failure block above the composer, and record why a session switch
  resets failure and discard notices.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvy1jgjo76

* docs(web-shell): state the real consequence of the session-switch reset

The comment claimed the list error is re-derived on a session switch. It is
not: the list effect's deps exclude sessionId, so a catalog-load error is
hidden until provider/listRevision changes. Say what actually happens.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvy1jgjo76

* fix(web-shell): keep managed sessions visible when workspaces collapse

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): address doudouOUC review feedback

- Remove shadow-DOM style adoption from renderManagedAgentWebShell: the
  shared CSSStyleSheet cache caused cross-contamination when two shells
  shared one shadow root. Hosts using shadow DOM should adopt styles
  themselves or use a plain container.
- Scope the error/discard reset effect to hostSessionOnly only: the
  unconditional clear on [provider, sessionId] hid the catalog-load error
  for existing managed consumers on every session switch without retrying.
- Update the managed-host test to match the removed shadow-DOM behavior.
- Drop the stale shadow-root note from README.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): preserve styles for managed hosts in shared shadow roots

Restore the documented managed-host ShadowRoot style lifecycle and keep the shared package sheet until its last owner unmounts. Cover both fallback and constructable sheet behavior.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): center the managed host empty conversation as one group

The host mount entry has no .appChatEmpty ancestor, so the managed empty conversation kept its full-height content box and pinned the composer to the bottom. Give host-mounted empty conversations the same shrink-to-content centering, mount the entry through the managedHome fill wrapper, and drop the empty trailing status row below the composer.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): let a host-mode session clear through without an echo channel

hostSessionOnly alone marks the shell controlled, so the page's clear
signal after deleting or archiving the current session was dropped when
the host passed no onSessionChange (the README-documented mount). The
deleted conversation stayed on screen and sendable.

Allow the internal write for a clear (undefined) when no echo channel
exists; new selections stay host-owned, preserving the existing
'unrequested selection is suppressed' contract. The bot's suggested
controlled-expression rewrite would have broken that contract, so the
gate lives in the handler instead.

Witness: the new no-echo test fails at 5683eed0 without this change
(sessionId stays 's1') and passes with it; the echo-channel test pins
that the fix does not leak internal writes into echo-owned hosts.

---------

Co-authored-by: Shaojin Wen <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-11-03 16:50:24 +08:00
1 parent 1f09497f45
commit bc19325983
21 files changed
+1129 -123

No files matched your search

@@ -0,0 +1,70 @@
# Managed Web Shell host integration
[English](2026-10-29-managed-host-webshell.md) | [简体中文](2026-10-29-managed-host-webshell.zh-CN.md)
## Problem and scope
A non-React host owns an existing managed Session and exposes a limited BFF. It
needs the official conversation and composer without mounting daemon contexts or
using the Java Session catalog. This change stays inside Web Shell; the host
implements transport, authentication, authorization and actual browser acceptance.
## Contract and decisions
`ManagedAgentWebShell` accepts `managedAgentProvider?: ManagedAgentProvider` and
`hostSessionOnly?: boolean` (default `false`). An injected provider is used
without constructing the Java provider; omission preserves Java behavior.
Host-only mode opens only the explicit `sessionId`. Missing IDs render an empty
conversation region, without listing, choosing or creating Sessions. The internal
catalog, New button and catalog refresh are hidden. Existing Session loading uses
`getSession`, `getTranscript` and `subscribeEvents`; sending uses `submitPrompt`.
The official message list and composer retain the provider's capabilities, so
`canSend: false` disables submission and `canCancel: false` exposes no cancel
operation or Escape hint, including when an old cancellation intent is stored.
Without model-change capability the host view hides the model selector and fixed
model hint; Agent identity is never presented as a model. Unsupported optional
APIs must be omitted by the host provider. A
four-API provider supplies rejecting stubs for required catalog/create/cancel
methods; the host path never invokes these stubs. Generic `submitPrompt` admission
requires `sessionId` and `turnId`; `operationId` is optional because a host may
expose event streaming without a durable-operation API. Java still validates its
real operation identity; cancel and cancellation recovery retain their strict
operation identity contract.
The provider's `storageKey` must include tenant and actor identity. Replacing that
scope clears selection, pending requests, conversation and subscriptions; a
carried-over ID is dropped until the host supplies a new explicit selection.
Pending host sends are additionally isolated by Session ID. Short-lived token
refresh should keep provider identity stable. Brand, theme, language, portal
container and scoped CSS retain their existing implementation.
`renderManagedAgentWebShell(container, props)` creates a React root and returns
`{ update(nextProps), unmount() }`. Updates replace the complete props object.
The host owns the container and calls `unmount()` on disposal. No daemon provider,
service or alternate conversation page is introduced. Containers inside a ShadowRoot
reuse existing Web Shell style adoption and release adopted styles on unmount.
## Build and constraints
Run `npx vite build --config vite.lib.config.ts --mode managed-host` from
`packages/web-shell`. Output is `dist/managed-host/index.js` with relative chunks.
This separate mode bundles React, React DOM and runtime dependencies and reuses
the existing CSS scoping and injection plugin. Serve the complete output directory
with JavaScript MIME types; no import map or separate CSS import is required.
The normal default and transcript builds keep their previous entry/external rules.
The full-page host entry (`--mode app-host`) was removed from this PR: it had
no build wiring, no test coverage, and hardcoded host-specific sidebar/footer
configuration. It can be reintroduced in a follow-up PR with proper packaging
and tests once a named consumer exists.
## Validation and acceptance
React integration tests cover existing-ID loading and sending, no catalog/create/
daemon access, no-ID waiting and removal, read-only capabilities, and scope reset.
Existing Java tests remain the compatibility regression. A regression that restores
the unconditional catalog effect fails the host-only request assertion; restoring
creation without an ID fails the no-ID composer assertion. Package typecheck and
both default and managed-host builds validate integration. Real browser acceptance
is delegated to the host agent; no browser or cloud operation is performed here.
@@ -0,0 +1,58 @@
# Managed Web Shell 宿主接入
[English](2026-10-29-managed-host-webshell.md) | [简体中文](2026-10-29-managed-host-webshell.zh-CN.md)
## 问题与范围
非 React 宿主管理已有托管 Session,并仅提供有限 BFF。宿主需要官方对话与输入框,
且不能挂载 daemon 上下文或使用 Java 会话列表。本次改动限定在 Web Shell;传输、
鉴权、授权与真实浏览器验收由宿主实现。
## 契约与决策
`ManagedAgentWebShell` 接受 `managedAgentProvider?: ManagedAgentProvider` 和
`hostSessionOnly?: boolean`(默认 `false`)。注入 provider 时直接使用,不构造 Java
provider;省略时保留 Java 行为。
host-only 仅打开显式的 `sessionId`。没有 ID 时渲染空对话区域,不查询、自动选择或
创建会话。隐藏内部列表、New 与列表刷新。已有会话只通过 `getSession`、
`getTranscript`、`subscribeEvents` 加载,发送使用 `submitPrompt`。官方消息列表和
输入框保留 provider 能力,`canSend: false` 禁用发送,`canCancel: false` 不提供取消
操作或 Escape 提示,即使存储中存在旧取消请求也不会重试。没有模型切换能力时,
宿主视图隐藏模型选择器和固定模型提示,不把 Agent 身份当作模型。宿主 provider
必须省略不支持的可选 API。四 API provider 可为类型要求的列表、
创建、取消方法提供拒绝 stub,宿主路径不调用它们。通用 `submitPrompt` 回执要求
`sessionId` 与 `turnId`,`operationId` 可省略:宿主可能只公开事件流而没有持久化操作
查询 API。Java 仍校验自身真实 operation 身份;取消和取消恢复保持严格 operation
身份契约。
provider 的 `storageKey` 必须包含租户与账号身份。更换 scope 会清理选择、待确认请求、
对话和订阅;沿用旧 ID 时先丢弃该选择,等待宿主显式传入新选择。宿主待发送请求另按
Session ID 隔离。短期 token 刷新应保持 provider 身份稳定。品牌、主题、语言、portal
容器与 scoped CSS 均沿用已有实现。
`renderManagedAgentWebShell(container, props)` 创建 React root,返回
`{ update(nextProps), unmount() }`。更新替换完整 props 对象。宿主拥有容器并在销毁时
调用 `unmount()`。不引入 daemon provider、服务或替代对话页面。容器位于 ShadowRoot 时复用
已有 Web Shell 样式 adoption,unmount 时释放注入样式。
## 构建与约束
在 `packages/web-shell` 运行
`npx vite build --config vite.lib.config.ts --mode managed-host`。产物为
`dist/managed-host/index.js` 及相对 chunks。独立模式 bundle React、React DOM 与运行时
依赖,复用已有 CSS 隔离和注入插件。宿主需按 JavaScript MIME 类型提供完整产物目录,
无需 import map 或单独 CSS import。普通 default 与 transcript 构建保持既有入口和 external
规则。
整页宿主入口(`--mode app-host`)已从本 PR 移除:它没有构建接线、没有测试覆盖、
硬编码了特定宿主的侧栏/footer 配置。等有明确消费者后,可以在后续 PR 中补上
打包和测试再重新引入。
## 验证与验收
React 集成测试覆盖已有 ID 的加载和发送、不访问列表/创建/daemon、无 ID 等待与清除、
只读能力和 scope 重置。既有 Java 测试作为兼容回归。恢复无条件列表 effect 会触发
host-only 请求断言失败;恢复无 ID 创建会触发无 ID 输入框断言失败。包内 typecheck 与
普通、managed-host 构建验证集成。真实浏览器验收由宿主 agent 执行;此处不进行浏览器或
云端操作。
+32
View File
@@ -3,6 +3,38 @@
Qwen Code Web Shell 是面向浏览器的 daemon 会话终端 UI,可以作为 React
组件嵌入到其他项目中。
## 宿主已有 Managed Session
`ManagedAgentWebShell` 支持注入 `managedAgentProvider`,省略时仍创建 Java provider。
宿主传 `hostSessionOnly: true` 时只打开显式给定的 `sessionId`,隐藏内部会话列表、
New 与列表刷新;没有 ID 时等待宿主选择,不自动选择或创建。官方消息列表和 composer
沿用 provider 的真实 capabilities;四 API 宿主应设置 `canCancel: false`、省略不支持的
可选 API,并按单轮/运行状态提供 `capabilities.canSend`。
非 React 宿主可使用本地 ESM 入口:
```ts
import { renderManagedAgentWebShell } from './managed-host/index.js';
const shell = renderManagedAgentWebShell(container, {
managedAgentProvider: provider,
hostSessionOnly: true,
sessionId: existingSessionId,
language: 'zh-CN',
theme: 'dark',
});
// update 接受完整的新 props;宿主销毁时调用 shell.unmount()。
```
在包内执行 `npx vite build --config vite.lib.config.ts --mode managed-host`,输出
`dist/managed-host/index.js` 及相对 chunks,已 bundle React/React DOM 和运行时依赖,
复用原有 scoped CSS 注入,无需单独 CSS 或 import map。需提供整个输出目录。
该入口仅随源码提供:没有接入 `build`、`exports` 和 `files`,不随 npm 包发布(
`dist/types/managed-host.d.ts` 也在发布产物之外),需要时按上述命令自行构建。
自定义 provider 的 `storageKey` 必须包含租户/账号身份;切换身份时更换 provider 与
storageKey,旧 scope 沿用的 ID 会先丢弃,再由宿主显式给出新选择。详见
[宿主接入设计](../../docs/design/2026-10-29-managed-host-webshell.zh-CN.md)。
## Managed 工作区与模型
完整 Web Shell 在配置 `managedAgentProvider` 后,在同一个工作区列表中同时显示本地和托管工作区,不再使用本地/托管 tab。名称旁的标志显示所属服务,工作区的新建会话使用统一的书写图标;顶部“新建会话”继承当前会话或草稿的服务。新会话的工作区菜单可双向选择两类服务已有的工作区,保留原身份和独立草稿,提交前不创建工作区或会话。添加工作区时可明确选择本地(daemon)或托管(Managed Agent),切换表单或取消不会改变当前会话。托管模式复用标准欢迎界面、输入框、对话和工具卡片、审批、停止按钮及侧栏,后台任务继续运行。未配置 provider 时保持原首页。Java Vite 演示入口 `?managedProvider=java` 默认托管,`managed=0` 可明确选择本地,`managed=1&managedSession=...` 深链继续有效;直接打开本地会话链接时保持本地。完整行为与边界见[统一工作区导航设计](../../docs/design/2026-10-21-unified-workspace-navigation.zh-CN.md)。
+14
View File
@@ -720,6 +720,20 @@
overflow: hidden;
}
/* Host-mounted managed home has no .appChatEmpty ancestor, so give its
empty conversation the same shrink-to-content behavior here. The
centered group must exclude the session header, which stays pinned at
the top. */
.chatPane[data-managed-home][data-managed-empty] .content {
flex: 0 0 auto;
overflow: visible;
}
.chatPane[data-managed-home][data-managed-empty] {
justify-content: center;
overflow-y: auto;
}
/* The chat view (message list + composer) stays mounted while a panel is shown,
just hidden — so the composer keeps its unsent draft and attachments instead
of losing them on unmount. */
@@ -60,6 +60,135 @@ describe('ManagedAgentWebShell', () => {
container.remove();
});
it('uses the injected provider and resets host selection when its identity scope changes', async () => {
const fetchImpl = vi.fn();
const first = { storageKey: 'tenant-a:actor-a' } as ManagedAgentProvider;
const second = { storageKey: 'tenant-b:actor-b' } as ManagedAgentProvider;
const render = async (
provider: ManagedAgentProvider,
sessionId?: string,
) => {
await act(async () =>
root.render(
<ManagedAgentWebShell
managedAgentProvider={provider}
hostSessionOnly
sessionId={sessionId}
fetch={fetchImpl}
/>,
),
);
};
await render(first, 'old');
expect(captured.props).toMatchObject({
managedAgentProvider: first,
hostSessionOnly: true,
sessionId: 'old',
});
await render(second, 'old');
expect(captured.props).toMatchObject({
managedAgentProvider: second,
sessionId: undefined,
});
await render(second, 'new');
expect(captured.props).toMatchObject({ sessionId: 'new' });
await act(async () => {
(
captured.props as { onSelectSession: (id?: string) => void }
).onSelectSession('unrequested');
});
expect(captured.props).toMatchObject({ sessionId: 'new' });
await render(second);
expect(captured.props).toMatchObject({ sessionId: undefined });
expect(fetchImpl).not.toHaveBeenCalled();
});
it('discards internal selection when a host takes ownership without an ID', async () => {
const provider = { storageKey: 'host-mode-switch' } as ManagedAgentProvider;
await act(async () =>
root.render(<ManagedAgentWebShell managedAgentProvider={provider} />),
);
await act(async () => {
(
captured.props as { onSelectSession: (id?: string) => void }
).onSelectSession('internal-session');
});
expect(captured.props).toMatchObject({ sessionId: 'internal-session' });
await act(async () =>
root.render(
<ManagedAgentWebShell
managedAgentProvider={provider}
hostSessionOnly
/>,
),
);
expect(captured.props).toMatchObject({
sessionId: undefined,
hostSessionOnly: true,
});
});
it('lets a delete/archive clear through when the host has no echo channel', async () => {
const provider = { storageKey: 'host-no-echo' } as ManagedAgentProvider;
await act(async () =>
root.render(
<ManagedAgentWebShell
managedAgentProvider={provider}
hostSessionOnly
sessionId="s1"
/>,
),
);
expect(captured.props).toMatchObject({ sessionId: 's1' });
// New selections stay host-owned even without an echo channel…
await act(async () => {
(
captured.props as { onSelectSession: (id?: string) => void }
).onSelectSession('unrequested');
});
expect(captured.props).toMatchObject({ sessionId: 's1' });
// …but clearing the deleted/archived session must reach the page.
await act(async () => {
(
captured.props as { onSelectSession: (id?: string) => void }
).onSelectSession(undefined);
});
expect(captured.props).toMatchObject({ sessionId: undefined });
});
it('routes the clear through the echo channel when the host provides one', async () => {
const provider = { storageKey: 'host-echo' } as ManagedAgentProvider;
const onSessionChange = vi.fn();
await act(async () =>
root.render(
<ManagedAgentWebShell
managedAgentProvider={provider}
hostSessionOnly
sessionId="s1"
onSessionChange={onSessionChange}
/>,
),
);
await act(async () => {
(
captured.props as { onSelectSession: (id?: string) => void }
).onSelectSession(undefined);
});
expect(onSessionChange).toHaveBeenCalledWith(undefined);
// The host owns the selection: no internal write before the echo.
expect(captured.props).toMatchObject({ sessionId: 's1' });
await act(async () =>
root.render(
<ManagedAgentWebShell
managedAgentProvider={provider}
hostSessionOnly
onSessionChange={onSessionChange}
/>,
),
);
expect(captured.props).toMatchObject({ sessionId: undefined });
});
it('constructs a Java provider without daemon workspace props', async () => {
await act(async () => {
root.render(
@@ -412,4 +541,46 @@ describe('ManagedAgentWebShell', () => {
newScopeRenders.every((render) => render.sessionId === undefined),
).toBe(true);
});
it('forwards the navigation slot props unchanged to the sessions page', async () => {
const provider = { storageKey: 'navigation-props' } as ManagedAgentProvider;
const navigationTarget = document.createElement('nav');
const archivedNavigationTarget = document.createElement('section');
const onArchivedSessionsStatusChange = vi.fn();
await act(async () => {
root.render(
<ManagedAgentWebShell
managedAgentProvider={provider}
navigationTarget={navigationTarget}
archivedNavigationTarget={archivedNavigationTarget}
onArchivedSessionsStatusChange={onArchivedSessionsStatusChange}
navigationActive={false}
navigationSearch="release"
/>,
);
});
expect(captured.props).toMatchObject({
navigationTarget,
archivedNavigationTarget,
onArchivedSessionsStatusChange,
navigationActive: false,
navigationSearch: 'release',
});
});
it('leaves the navigation defaults to the sessions page when unset', async () => {
const provider = {
storageKey: 'navigation-defaults',
} as ManagedAgentProvider;
await act(async () => {
root.render(<ManagedAgentWebShell managedAgentProvider={provider} />);
});
expect(captured.props).toMatchObject({
navigationTarget: undefined,
archivedNavigationTarget: undefined,
onArchivedSessionsStatusChange: undefined,
navigationActive: undefined,
navigationSearch: undefined,
});
});
});
@@ -5,6 +5,7 @@ import {
useMemo,
useRef,
useState,
type ComponentProps,
type CSSProperties,
} from 'react';
import { BrandProvider, type WebShellBrand } from './brandContext';
@@ -14,6 +15,7 @@ import {
createJavaManagedAgentProvider,
type JavaManagedAgentProviderOptions,
} from './components/managed/java-managed-agent-provider';
import type { ManagedAgentProvider } from './components/managed/managed-agent-provider';
import { RootErrorFallback } from './components/RootErrorFallback';
import { WebShellCustomizationProvider } from './customization';
import { I18nProvider, normalizeLanguage, type WebShellLanguage } from './i18n';
@@ -25,9 +27,26 @@ import {
} from './themeContext';
import { CompactModeContext, TodoContextsProvider } from './WebShellContexts';
/**
* 会话列表 portal 到宿主导航槽位所需的 props,直接从 ManagedSessionsPage 的
* props 推导,避免在入口层维护第二份类型。
*/
type ManagedSessionsNavigationProps = Pick<
ComponentProps<typeof ManagedSessionsPage>,
| 'navigationTarget'
| 'archivedNavigationTarget'
| 'onArchivedSessionsStatusChange'
| 'navigationActive'
| 'navigationSearch'
>;
/** Change productScope with tenant/actor identity so saved selections and output caches are discarded. */
export interface ManagedAgentWebShellProps
extends JavaManagedAgentProviderOptions {
extends JavaManagedAgentProviderOptions,
ManagedSessionsNavigationProps {
managedAgentProvider?: ManagedAgentProvider;
/** Only open the existing Session explicitly selected by the host. */
hostSessionOnly?: boolean;
sessionId?: string;
onSessionChange?: (sessionId: string | undefined) => void;
language?: WebShellLanguage;
@@ -41,6 +60,8 @@ export interface ManagedAgentWebShellProps
export function ManagedAgentWebShell(props: ManagedAgentWebShellProps) {
const {
sessionId,
managedAgentProvider,
hostSessionOnly = false,
onSessionChange,
language,
theme = WebShellThemeId.Dark,
@@ -56,6 +77,11 @@ export function ManagedAgentWebShell(props: ManagedAgentWebShellProps) {
enableWorkspaceBinding,
modelPresets,
saveArtifact,
navigationTarget,
archivedNavigationTarget,
onArchivedSessionsStatusChange,
navigationActive,
navigationSearch,
} = props;
const resolvedLanguage = normalizeLanguage(language);
// Function props may be inline closures: route them through a ref so the
@@ -75,6 +101,7 @@ export function ManagedAgentWebShell(props: ManagedAgentWebShellProps) {
const hasSaveArtifact = saveArtifact !== undefined;
const provider = useMemo(
() =>
managedAgentProvider ??
createJavaManagedAgentProvider({
baseUrl,
credentials,
@@ -95,6 +122,7 @@ export function ManagedAgentWebShell(props: ManagedAgentWebShellProps) {
Promise.resolve(),
}),
[
managedAgentProvider,
baseUrl,
credentials,
agentId,
@@ -108,6 +136,7 @@ export function ManagedAgentWebShell(props: ManagedAgentWebShellProps) {
);
const [selection, setSelection] = useState(() => ({
storageKey: provider.storageKey,
hostSessionOnly,
externalSessionId: sessionId,
selectedSessionId: sessionId,
}));
@@ -115,10 +144,13 @@ export function ManagedAgentWebShell(props: ManagedAgentWebShellProps) {
// onSelectSession suppresses the internal write, but the echoed value
// still passes through the same state machine — otherwise a host can never
// return to "no selection" and a scope switch forwards the carried-over id.
const controlled = sessionId !== undefined && onSessionChange !== undefined;
const controlled =
hostSessionOnly ||
(sessionId !== undefined && onSessionChange !== undefined);
let selectedSessionId: string | undefined;
if (
selection.storageKey !== provider.storageKey ||
selection.hostSessionOnly !== hostSessionOnly ||
selection.externalSessionId !== sessionId
) {
// Adjust during render (React's adjusting-state-when-props-change
@@ -132,6 +164,7 @@ export function ManagedAgentWebShell(props: ManagedAgentWebShellProps) {
: sessionId;
setSelection({
storageKey: provider.storageKey,
hostSessionOnly,
externalSessionId: sessionId,
selectedSessionId,
});
@@ -162,7 +195,7 @@ export function ManagedAgentWebShell(props: ManagedAgentWebShellProps) {
<CompactModeContext.Provider value={false}>
<div
ref={setPortalRoot}
className={`${shellStyles.app} ${theme === WebShellThemeId.Dark ? `${shellStyles.themeDark} dark` : shellStyles.themeLight} ${className ?? ''}`}
className={`${shellStyles.app} ${shellStyles.managedHome} ${theme === WebShellThemeId.Dark ? `${shellStyles.themeDark} dark` : shellStyles.themeLight} ${className ?? ''}`}
style={style}
data-web-shell-root
data-web-shell-shadcn
@@ -172,14 +205,29 @@ export function ManagedAgentWebShell(props: ManagedAgentWebShellProps) {
key={provider.storageKey}
sessionId={selectedSessionId}
onSelectSession={(next) => {
if (!controlled)
// A host without an echo channel still owns new
// selections, but a clear (delete/archive of the
// current session) must not evaporate with it.
if (
!controlled ||
(next === undefined &&
onSessionChange === undefined)
)
setSelection((current) => ({
...current,
selectedSessionId: next,
}));
onSessionChange?.(next);
}}
hostSessionOnly={hostSessionOnly}
managedAgentProvider={provider}
navigationTarget={navigationTarget}
archivedNavigationTarget={archivedNavigationTarget}
onArchivedSessionsStatusChange={
onArchivedSessionsStatusChange
}
navigationActive={navigationActive}
navigationSearch={navigationSearch}
/>
</div>
</CompactModeContext.Provider>
@@ -315,6 +315,147 @@ describe('ManagedSessionsPage', () => {
});
}
it('opens only the host Session without catalog, creation, cancellation or daemon calls', async () => {
provider = { ...provider, canCancel: false };
mocks.client.listSessions.mockRejectedValue(new Error('catalog forbidden'));
mocks.client.createSession.mockRejectedValue(
new Error('creation forbidden'),
);
await render('s1', 'en', provider, undefined, true, {
hostSessionOnly: true,
});
expect(
container.querySelector('[data-testid="messages"]')?.textContent,
).toContain('Persisted answer');
expect(mocks.client.getSession).toHaveBeenCalledWith(
's1',
expect.any(Object),
);
expect(mocks.client.getTranscript).toHaveBeenCalledWith(
's1',
expect.any(Object),
);
expect(mocks.client.subscribeEvents).toHaveBeenCalledWith(
's1',
expect.any(Object),
);
expect(container.querySelector('nav')).toBeNull();
expect(container.textContent).not.toContain('New session');
expect(mocks.editorProps?.disabled).toBe(false);
await act(async () => {
mocks.editorProps!.onSubmit(
'host follow-up',
undefined,
undefined,
() => {},
);
await flush();
});
expect(mocks.client.submitPrompt).toHaveBeenCalledWith(
's1',
expect.objectContaining({ text: 'host follow-up' }),
expect.any(Object),
);
expect(mocks.client.listSessions).not.toHaveBeenCalled();
expect(mocks.client.createSession).not.toHaveBeenCalled();
expect(mocks.client.cancel).not.toHaveBeenCalled();
expect(mocks.useWorkspace).not.toHaveBeenCalled();
});
it('waits for an explicit host ID and clears the old conversation when it is removed', async () => {
const host = { hostSessionOnly: true };
await render(undefined, 'en', provider, undefined, true, host);
expect(
container.querySelector('[data-managed-host-waiting]'),
).not.toBeNull();
expect(container.querySelector('form')).toBeNull();
expect(mocks.client.getSession).not.toHaveBeenCalled();
expect(mocks.client.listSessions).not.toHaveBeenCalled();
await render('s1', 'en', provider, undefined, true, host);
const streamSignal = mocks.client.subscribeEvents.mock.calls[0][1]
.signal as AbortSignal;
expect(container.textContent).toContain('Persisted answer');
await render(undefined, 'en', provider, undefined, true, host);
expect(streamSignal.aborted).toBe(true);
expect(container.textContent).not.toContain('Persisted answer');
expect(container.querySelector('form')).toBeNull();
expect(onSelect).not.toHaveBeenCalled();
expect(mocks.client.createSession).not.toHaveBeenCalled();
expect(mocks.client.listSessions).not.toHaveBeenCalled();
});
it('tears down the old host scope and ignores its late transcript', async () => {
let resolveOld!: (value: ManagedAgentSessionTranscript) => void;
mocks.client.getTranscript.mockImplementationOnce(
() =>
new Promise<ManagedAgentSessionTranscript>((resolve) => {
resolveOld = resolve;
}),
);
await render('s1', 'en', provider, undefined, true, {
hostSessionOnly: true,
});
const oldSignal = mocks.client.getSession.mock.calls[0][1]
.signal as AbortSignal;
const next = { ...provider, storageKey: 'tenant-b:actor-b' };
await render('s2', 'en', next, undefined, true, { hostSessionOnly: true });
expect(oldSignal.aborted).toBe(true);
await act(async () => {
resolveOld({ events: [event(1, 'Private old scope')], lastEventId: 1 });
await flush();
});
expect(container.textContent).not.toContain('Private old scope');
expect(mocks.editorProps?.composerScopeKey).toContain(
'tenant-b:actor-b:s2',
);
expect(mocks.client.listSessions).not.toHaveBeenCalled();
expect(mocks.client.createSession).not.toHaveBeenCalled();
});
it('uses the host capability to keep a single-turn session read-only', async () => {
sessionStorage.setItem(
`qwen-managed-turn-cancel:${JSON.stringify([provider.storageKey, 's1'])}`,
JSON.stringify({
sessionId: 's1',
turnId: 'p1',
idempotencyKey: 'old-cancel',
}),
);
mocks.client.getSession.mockResolvedValue(
summary('s1', {
agentId: 'qwen-code',
phase: 'runtime_starting',
capabilities: { canSend: false, canCancel: true },
workspace: { workspaceId: 'host-workspace', cwdRelative: '.' },
runtimeReady: false,
runtimeState: 'unknown',
}),
);
await render(
's1',
'en',
{ ...provider, canCancel: false },
undefined,
true,
{ hostSessionOnly: true },
);
expect(container.textContent).toContain('Persisted answer');
expect(mocks.editorProps?.disabled).toBe(true);
expect(mocks.editorProps?.isRunning).toBe(false);
expect(mocks.editorProps?.onCancel).toBeUndefined();
expect(mocks.editorProps?.currentModel).toBe('');
expect(mocks.editorProps?.visibleToolbarActions).not.toContain('model');
expect(container.textContent).not.toContain('The model is fixed');
await act(async () => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
mocks.editorProps!.onSubmit('blocked', undefined, undefined, () => {});
await flush();
});
expect(mocks.client.submitPrompt).not.toHaveBeenCalled();
expect(mocks.client.cancel).not.toHaveBeenCalled();
expect(mocks.client.queryCancelOperation).not.toHaveBeenCalled();
});
it.each([false, true])(
'refreshes a preparing empty worktree session until ready (home: %s) and keeps attachments unavailable during preparation',
async (home) => {
@@ -3884,6 +4025,38 @@ describe('ManagedSessionsPage', () => {
},
);
it('marks the managed home conversation as empty only without messages or failure', async () => {
mocks.client.getTranscript.mockResolvedValue({
events: [],
lastEventId: 0,
});
await render('s1', 'en', provider, undefined, true, {
hostSessionOnly: true,
});
expect(
container
.querySelector('[data-managed-home]')
?.getAttribute('data-managed-empty'),
).toBe('true');
await act(async () => root.unmount());
container.remove();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
mocks.client.getSession.mockImplementation(async (id: string) =>
id === 's1' ? { ...summary(id), failure: 'runtime lost' } : summary(id),
);
await render('s1', 'en', provider, undefined, true, {
hostSessionOnly: true,
});
expect(
container
.querySelector('[data-managed-home]')
?.getAttribute('data-managed-empty'),
).toBeNull();
});
it('renders Chinese Managed labels and states without English fallback overriding them', async () => {
await render('s1', 'zh-CN');
expect(container.querySelector('nav')?.getAttribute('aria-label')).toBe(
@@ -3905,7 +4078,7 @@ describe('ManagedSessionsPage', () => {
await render();
await input('Do the work');
await click('Send');
expect(container.textContent).toContain('request outcome is unconfirmed');
expect(container.textContent).toContain('not confirmed as submitted');
expect(container.querySelector('textarea')?.disabled).toBe(true);
await click('Retry the same request');
const [first, retry] = mocks.client.createSession.mock.calls;
@@ -3918,6 +4091,33 @@ describe('ManagedSessionsPage', () => {
expect(onSelect).toHaveBeenLastCalledWith('created');
});
it('keeps an uncertain create with its original page when viewing another session', async () => {
mocks.client.createSession.mockRejectedValueOnce(
new TypeError('Network failed'),
);
await render();
await input('CHECK_CREATE');
await click('Send');
const original = mocks.client.createSession.mock.calls[0];
await render('s1');
expect(container.textContent).not.toContain('not confirmed as submitted');
expect(container.textContent).not.toContain('Retry the same request');
expect(container.textContent).toContain(
'Another conversation has an unconfirmed request',
);
expect(container.querySelector('textarea')?.disabled).toBe(true);
expect(mocks.client.createSession).toHaveBeenCalledTimes(1);
expect(mocks.client.submitPrompt).not.toHaveBeenCalled();
await click('Open the original conversation');
expect(onSelect).toHaveBeenLastCalledWith(undefined);
await render();
await click('Retry the same request');
expect(mocks.client.createSession.mock.calls[1]).toEqual(original);
expect(mocks.client.submitPrompt).not.toHaveBeenCalled();
});
it('discards a permanently failing pending prompt and restores the draft', async () => {
mocks.client.createSession.mockRejectedValue(
new TypeError('Network failed'),
@@ -3925,7 +4125,7 @@ describe('ManagedSessionsPage', () => {
await render();
await input('Do the work');
await click('Send');
expect(container.textContent).toContain('request outcome is unconfirmed');
expect(container.textContent).toContain('not confirmed as submitted');
const textarea = () => container.querySelector('textarea');
expect(textarea()?.disabled).toBe(true);
expect(container.querySelectorAll('p[role="alert"]')).toHaveLength(1);
@@ -3940,9 +4140,7 @@ describe('ManagedSessionsPage', () => {
discard!.click();
await flush();
});
expect(container.textContent).not.toContain(
'request outcome is unconfirmed',
);
expect(container.textContent).not.toContain('not confirmed as submitted');
expect(textarea()?.disabled).toBe(false);
expect(textarea()?.value).toBe('Do the work');
expect(mocks.client.createSession).toHaveBeenCalledTimes(1);
@@ -3959,9 +4157,7 @@ describe('ManagedSessionsPage', () => {
await act(async () => root.unmount());
root = createRoot(container);
await render();
expect(container.textContent).not.toContain(
'request outcome is unconfirmed',
);
expect(container.textContent).not.toContain('not confirmed as submitted');
});
it('sends the edited draft under a fresh idempotency key after a discard', async () => {
@@ -3971,7 +4167,7 @@ describe('ManagedSessionsPage', () => {
await render();
await input('Do the work');
await click('Send');
expect(container.textContent).toContain('request outcome is unconfirmed');
expect(container.textContent).toContain('not confirmed as submitted');
const originalKey =
mocks.client.createSession.mock.calls[0]?.[1].idempotencyKey;
@@ -3998,15 +4194,21 @@ describe('ManagedSessionsPage', () => {
await render('s1');
await input('For session A only');
await click('Send');
expect(container.textContent).toContain('request outcome is unconfirmed');
expect(container.textContent).toContain('not confirmed as submitted');
await click('Task s2Completed');
await render('s2');
// The composer is wedged on the other session's pending record: the
// explanation stays, but the destructive escape is offered only where
// the draft can be restored.
expect(container.querySelector('textarea')?.disabled).toBe(true);
expect(container.textContent).toContain('request outcome is unconfirmed');
// The pending belongs to s1: a foreign session only offers a redirect to
// the owning conversation, never the retry/discard escape (the draft that
// would be restored lives over there).
expect(container.textContent).toContain(
'Another conversation has an unconfirmed request',
);
expect(
[...container.querySelectorAll('button')].some(
(item) => item.textContent === 'Open the original conversation',
),
).toBe(true);
expect(
[...container.querySelectorAll('button')].some(
(item) => item.textContent === 'Discard this request',
@@ -4017,9 +4219,7 @@ describe('ManagedSessionsPage', () => {
await click('Task s1Completed');
await render('s1');
await click('Discard this request');
expect(container.textContent).not.toContain(
'request outcome is unconfirmed',
);
expect(container.textContent).not.toContain('not confirmed as submitted');
expect(container.querySelector('textarea')?.value).toBe(
'For session A only',
);
@@ -4038,7 +4238,7 @@ describe('ManagedSessionsPage', () => {
);
}
it('preserves another session draft when retrying a restored pending prompt', async () => {
it('preserves another session draft when opening the owner of a restored pending prompt', async () => {
seedPending({
idempotencyKey: 'restored-a',
sessionId: 's1',
@@ -4049,12 +4249,10 @@ describe('ManagedSessionsPage', () => {
expect(container.querySelector('textarea')?.value).toBe(
'Saved draft for session B',
);
await click('Retry the same request');
expect(mocks.client.submitPrompt).toHaveBeenCalledWith(
's1',
{ text: 'For session A' },
expect.objectContaining({ idempotencyKey: 'restored-a' }),
);
await click('Open the original conversation');
// Redirecting only navigates to the owning session; it does not resubmit
// from the foreign session, and the foreign draft stays intact.
expect(mocks.client.submitPrompt).not.toHaveBeenCalled();
expect(container.querySelector('textarea')?.value).toBe(
'Saved draft for session B',
);
@@ -4062,7 +4260,7 @@ describe('ManagedSessionsPage', () => {
});
it.each([' For session A ', 'A newer different draft'])(
'only clears matching accepted storage after restoring a foreign retry: %s',
'leaves both drafts untouched when opening the pending owner from a foreign session: %s',
async (storedDraft) => {
seedPending({
idempotencyKey: 'restored-a',
@@ -4083,22 +4281,18 @@ describe('ManagedSessionsPage', () => {
mocks.restoreEditorDraft.mockReturnValue('Saved draft for session B');
await render('s2');
await click('Retry the same request');
await click('Open the original conversation');
expect(localStorage.getItem(ownerKey)).toBe(
storedDraft === ' For session A ' ? null : storedDraft,
);
// A redirect is not a retry: nothing is submitted and neither draft is
// cleared; the owner's draft is only reconciled when it retries there.
expect(localStorage.getItem(ownerKey)).toBe(storedDraft);
expect(localStorage.getItem(foreignKey)).toBe(
'Saved draft for session B',
);
expect(container.querySelector('textarea')?.value).toBe(
'Saved draft for session B',
);
expect(mocks.client.submitPrompt).toHaveBeenCalledWith(
's1',
{ text: 'For session A' },
expect.objectContaining({ idempotencyKey: 'restored-a' }),
);
expect(mocks.client.submitPrompt).not.toHaveBeenCalled();
expect(onSelect).toHaveBeenLastCalledWith('s1');
},
);
@@ -4135,7 +4329,7 @@ describe('ManagedSessionsPage', () => {
expect(mocks.clearEditor).toHaveBeenCalledOnce();
});
it('preserves the visible foreign draft when retrying a live retained commit', async () => {
it('preserves the visible foreign draft when opening the owner of a live retained commit', async () => {
mocks.client.submitPrompt.mockRejectedValueOnce(
new TypeError('Lost response'),
);
@@ -4145,17 +4339,16 @@ describe('ManagedSessionsPage', () => {
mocks.restoreEditorDraft.mockReturnValue('Saved draft for session B');
await render('s2');
await click('Retry the same request');
await click('Open the original conversation');
expect(mocks.client.submitPrompt).toHaveBeenCalledTimes(2);
expect(mocks.client.submitPrompt.mock.calls[1]).toEqual(
mocks.client.submitPrompt.mock.calls[0],
);
// Only the original send was submitted; the redirect does not retry, and
// the foreign draft stays visible.
expect(mocks.client.submitPrompt).toHaveBeenCalledTimes(1);
expect(container.querySelector('textarea')?.value).toBe(
'Saved draft for session B',
);
expect(onSelect).toHaveBeenLastCalledWith('s1');
expect(mocks.commitAccepted).toHaveBeenCalledTimes(1);
expect(mocks.commitAccepted).not.toHaveBeenCalled();
});
it('clears the owning editor after a restored pending prompt is accepted', async () => {
@@ -4190,7 +4383,7 @@ describe('ManagedSessionsPage', () => {
await render();
await input('Keep this prompt');
await click('Send');
expect(container.textContent).toContain('request outcome is unconfirmed');
expect(container.textContent).toContain('not confirmed as submitted');
// A real remount empties the text state: the composer is fed from the
// persisted pending record alone.
@@ -4203,9 +4396,7 @@ describe('ManagedSessionsPage', () => {
// The restore is the only way the draft survives the discard now.
expect(container.querySelector('textarea')?.value).toBe('Keep this prompt');
expect(container.querySelector('textarea')?.disabled).toBe(false);
expect(container.textContent).not.toContain(
'request outcome is unconfirmed',
);
expect(container.textContent).not.toContain('not confirmed as submitted');
});
it('moves focus to the session list when a discard has no composer to focus', async () => {
@@ -4222,13 +4413,11 @@ describe('ManagedSessionsPage', () => {
await render(undefined, 'en', bindingProvider);
// The workspace-binding creator replaces the composer form entirely.
expect(container.querySelector('textarea')).toBeNull();
expect(container.textContent).toContain('request outcome is unconfirmed');
expect(container.textContent).toContain('not confirmed as submitted');
await focusAndClickDiscard();
expect(container.textContent).not.toContain(
'request outcome is unconfirmed',
);
expect(container.textContent).not.toContain('not confirmed as submitted');
expect(document.activeElement).not.toBe(document.body);
expect(document.activeElement).toBe(container.querySelector('nav'));
});
@@ -4244,7 +4433,7 @@ describe('ManagedSessionsPage', () => {
});
await render('s1');
expect(container.querySelector('textarea')?.disabled).toBe(true);
expect(container.textContent).toContain('request outcome is unconfirmed');
expect(container.textContent).toContain('not confirmed as submitted');
await focusAndClickDiscard();
@@ -4398,9 +4587,7 @@ describe('ManagedSessionsPage', () => {
// The unconfirmed row unmounted with the click; the persistent visually
// hidden status region still announces the outcome.
expect(container.textContent).not.toContain(
'request outcome is unconfirmed',
);
expect(container.textContent).not.toContain('not confirmed as submitted');
expect(
Array.from(
container.querySelectorAll('[role="status"], [aria-live]'),
@@ -190,6 +190,7 @@ export function ManagedSessionsPage({
onSelectWorkspace,
onSessionWorkspaceChange,
managedAgentProvider,
hostSessionOnly = false,
navigationTarget,
archivedNavigationTarget,
onArchivedSessionsStatusChange,
@@ -215,6 +216,7 @@ export function ManagedSessionsPage({
onSelectWorkspace?: (workspaceId: string) => void;
onSessionWorkspaceChange?: (workspaceId: string | null) => void;
managedAgentProvider?: ManagedAgentProvider;
hostSessionOnly?: boolean;
/** Null keeps home layout while its sidebar slot is temporarily unmounted. */
navigationTarget?: HTMLElement | null;
archivedNavigationTarget?: HTMLElement | null;
@@ -245,6 +247,7 @@ export function ManagedSessionsPage({
onSelectWorkspace={onSelectWorkspace}
onSessionWorkspaceChange={onSessionWorkspaceChange}
provider={managedAgentProvider}
hostSessionOnly={hostSessionOnly}
cancellationEnabled={managedAgentProvider.canCancel}
navigationTarget={navigationTarget}
archivedNavigationTarget={archivedNavigationTarget}
@@ -313,6 +316,7 @@ function ManagedSessionsContent({
onSelectWorkspace,
onSessionWorkspaceChange,
provider,
hostSessionOnly,
cancellationEnabled,
navigationTarget,
archivedNavigationTarget,
@@ -339,6 +343,7 @@ function ManagedSessionsContent({
onSelectWorkspace?: (workspaceId: string) => void;
onSessionWorkspaceChange?: (workspaceId: string | null) => void;
provider: ManagedAgentProvider;
hostSessionOnly: boolean;
cancellationEnabled: boolean;
navigationTarget?: HTMLElement | null;
archivedNavigationTarget?: HTMLElement | null;
@@ -352,7 +357,7 @@ function ManagedSessionsContent({
}) {
const { t, language } = useI18n();
const recoveryText = useManagedRecoveryText();
const home = navigationTarget !== undefined;
const home = hostSessionOnly || navigationTarget !== undefined;
const clientId = useMemo(
() => getManagedClientId(provider.storageKey),
[provider.storageKey],
@@ -372,9 +377,9 @@ function ManagedSessionsContent({
const turnCancel = useManagedTurnCancel(
provider,
clientId,
sessionId,
hostSessionOnly && !cancellationEnabled ? undefined : sessionId,
detail.summary,
interactive,
interactive && (!hostSessionOnly || cancellationEnabled),
detail.refreshSummary,
);
const [handoff, setHandoff] = useState<ManagedRecoveryHandoff>();
@@ -553,10 +558,13 @@ function ManagedSessionsContent({
},
[sessionId],
);
const pendingKey = `qwen-managed-pending:${provider.storageKey}:${clientId}`;
const pendingKey = `qwen-managed-pending:${provider.storageKey}:${clientId}${hostSessionOnly ? `:host:${sessionId ?? ''}` : ''}`;
const [pending, setPending] = useState<PendingPrompt | undefined>(() =>
readPending(pendingKey),
);
// 未确认记录仍按原 client key 保存;切会话只改变展示归属,不能覆盖或重放别人的请求。
const pendingHere = pending?.sessionId === sessionId ? pending : undefined;
const pendingElsewhere = pending !== undefined && pendingHere === undefined;
const pendingRef = useRef(pending);
const encoding = useRef(false);
const focusComposer = useRef<{ sessionId: string | undefined } | undefined>(
@@ -794,6 +802,16 @@ function ManagedSessionsContent({
return () => abort.abort();
}, [pendingKey]);
useEffect(() => {
if (!hostSessionOnly) return;
// host-session-only: failure and discard notices describe the conversation
// they came from, so a session switch drops them. The list error shares
// this slot — switching hides it without a retry because the list effect
// does not depend on sessionId.
setError(undefined);
setDiscarded(false);
}, [provider, sessionId, hostSessionOnly]);
// The Discard button unmounts itself, dropping focus to <body>: land it on
// the composer that just got the draft back. The move must wait for the
// clear to commit — the textarea is disabled while pending is set — and
@@ -839,6 +857,7 @@ function ManagedSessionsContent({
: '';
const navigationSessions = listTitleQuery ? sessions : navigationSummaries;
useEffect(() => {
if (hostSessionOnly) return;
const abort = new AbortController();
listLifetime.current = abort;
listBusy.current = false;
@@ -875,7 +894,14 @@ function ManagedSessionsContent({
if (!abort.signal.aborted) setListLoading(false);
});
return () => abort.abort();
}, [provider, clientId, listRevision, archiveState, listTitleQuery]);
}, [
provider,
clientId,
listRevision,
archiveState,
listTitleQuery,
hostSessionOnly,
]);
useEffect(() => {
const current = detail.summary;
@@ -1001,6 +1027,8 @@ function ManagedSessionsContent({
) {
const abort = lifetime.current;
if (
(hostSessionOnly && !sessionId) ||
(pendingRef.current && pendingRef.current.sessionId !== sessionId) ||
!abort ||
abort.signal.aborted ||
busy ||
@@ -1238,7 +1266,12 @@ function ManagedSessionsContent({
const modelContext = currentSummary?.modelContext;
const selectedModel = currentSummary?.modelSelection?.modelId;
const displayedModel =
selectedModel ?? modelContext?.modelId ?? currentSummary?.agentId ?? '';
hostSessionOnly && !modelChange.supported
? ''
: (selectedModel ??
modelContext?.modelId ??
(hostSessionOnly ? undefined : currentSummary?.agentId) ??
'');
const contextUsageKnown =
!compression.usageInvalidated &&
(!selectedModel || modelContext?.modelId === selectedModel) &&
@@ -1571,6 +1604,14 @@ function ManagedSessionsContent({
)}
</nav>
);
if (hostSessionOnly && !sessionId) {
return (
<section
aria-label={t('managed.conversation')}
data-managed-host-waiting
/>
);
}
return (
<div
ref={pageRef}
@@ -1581,7 +1622,8 @@ function ManagedSessionsContent({
<p role="status" className="sr-only">
{discarded ? t('managed.discarded') : ''}
</p>
{home &&
{!hostSessionOnly &&
home &&
provider.lifecycle &&
archivedNavigationTarget &&
createPortal(
@@ -1643,7 +1685,10 @@ function ManagedSessionsContent({
...new Set(
[detail.stoppedReason, detail.error, error].filter(
(message): message is string =>
Boolean(message) && (interactive || message !== error),
Boolean(message) &&
(interactive || message !== error) &&
// summary.failure 已在 composer 上方的失败区块渲染,告警通道不再重复。
message !== failureMessage,
),
),
].map((message) => (
@@ -1690,14 +1735,21 @@ function ManagedSessionsContent({
: undefined
}
>
{home
? navigationTarget
? createPortal(navigation, navigationTarget)
: null
: navigation}
{hostSessionOnly
? null
: home
? navigationTarget
? createPortal(navigation, navigationTarget)
: null
: navigation}
<section
className={`${shellStyles.chatPane} ${home ? '' : 'gap-3'}`}
data-managed-home={home || undefined}
data-managed-empty={
home && messages.length === 0 && !summary?.failure
? true
: undefined
}
aria-label={t('managed.conversation')}
>
{summary && home && (
@@ -1846,9 +1898,7 @@ function ManagedSessionsContent({
compact={home}
/>
)}
{summary?.failure && (
<p className="text-sm text-destructive">{failureMessage}</p>
)}
{recovery.value && (
<ManagedRecoveryPanel
recovery={recovery.value}
@@ -1946,7 +1996,7 @@ function ManagedSessionsContent({
</p>
)}
{!(!sessionId && provider.workspaceBinding) && (
<div className={shellStyles.content} style={{ flex: '1 1 auto' }}>
<div className={shellStyles.content}>
<ToolDetailsContext.Provider value={loadToolDetails}>
<MessageList
messages={messages}
@@ -1964,8 +2014,12 @@ function ManagedSessionsContent({
loadingOlderHistory={detail.loadingOlder}
onLoadOlderHistory={detail.loadOlder}
hideSessionTimeline
// 空转录仍显示欢迎头;但当会话已有失败态(如服务端已删除)时,
// 由 failure 横幅说明状态,不再叠欢迎文案。
welcomeHeader={
messages.length === 0 && !detail.loading ? (
messages.length === 0 &&
!detail.loading &&
!summary?.failure ? (
<WelcomeHeader
version=""
cwd=""
@@ -1978,11 +2032,40 @@ function ManagedSessionsContent({
</ToolDetailsContext.Provider>
</div>
)}
{pending && !busy && (
<div className="flex items-center gap-2" role="status">
<p className="text-sm text-muted-foreground">
{t('managed.uncertain')}
</p>
{/* 会话级状态统一在 composer 上方透出(与 renderComposerHeader 的位置一致),
失败与未决提交同区相邻,不再散落在会话信息区。 */}
{summary?.failure && (
<div
role="alert"
className="mx-auto mt-auto mb-2 flex w-full max-w-[var(--chat-shell-width)] items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-[10px] text-xs leading-relaxed text-destructive"
>
<span aria-hidden="true" className="mt-0.5 shrink-0">
⚠
</span>
<p className="min-w-0 flex-1">{failureMessage}</p>
</div>
)}
{pendingElsewhere && !busy && (
<div
role="status"
className="mx-auto mt-auto mb-2 flex w-full max-w-[var(--chat-shell-width)] items-center gap-3 text-sm text-muted-foreground"
>
<p>{t('managed.pendingElsewhere')}</p>
<Button
variant="outline"
size="sm"
onClick={() => onSelectSession(pending?.sessionId)}
>
{t('managed.openPendingOwner')}
</Button>
</div>
)}
{pendingHere && !busy && (
<div
role="status"
className="mx-auto mt-auto mb-2 flex w-full max-w-[var(--chat-shell-width)] items-start justify-between gap-3 rounded-lg border bg-muted/40 px-3 py-[10px] text-xs leading-relaxed text-muted-foreground"
>
<p className="min-w-0 flex-1">{t('managed.uncertain')}</p>
<Button
size="sm"
disabled={busy || compression.blocked}
@@ -1993,7 +2076,7 @@ function ManagedSessionsContent({
{/* Offered only where the handler can restore the draft:
destroying the only copy from a foreign session would lose
it for good. */}
{pending.sessionId === sessionId && (
{pendingHere.sessionId === sessionId && (
<Button
variant="ghost"
size="sm"
@@ -2006,12 +2089,12 @@ function ManagedSessionsContent({
// The pending record is client-scoped but the draft
// belongs to the session it was written for: restore it
// only there.
if (pending.sessionId === sessionId) {
setText(pending.text);
composerRef.current?.setText(pending.text);
if (pendingHere.sessionId === sessionId) {
setText(pendingHere.text);
composerRef.current?.setText(pendingHere.text);
restoreManagedAttachments(
composerRef.current,
pending.input,
pendingHere.input,
);
}
composerCommit.current = undefined;
@@ -2019,7 +2102,7 @@ function ManagedSessionsContent({
// A create that committed server-side before an
// unconfirmed failure is otherwise invisible until the
// user happens to press Refresh.
if (pending.sessionId === undefined)
if (pendingHere.sessionId === undefined)
setListRevision((current) => current + 1);
focusComposer.current = { sessionId };
setDiscarded(true);
@@ -2147,7 +2230,10 @@ function ManagedSessionsContent({
loading={detail.loading}
/>
</div>
{(!summary?.workspace || summary.capabilities.workspaceTurns) &&
{(!hostSessionOnly || !!sessionId) &&
(hostSessionOnly ||
!summary?.workspace ||
summary.capabilities.workspaceTurns) &&
(!provider.workspaceBinding || (sessionId && summary)) ? (
<div
ref={composerContainerRef}
@@ -2356,7 +2442,14 @@ function ManagedSessionsContent({
}
dialogOpen={!interactive}
isRunning={
home && Boolean(active || summary?.capabilities.canCancel)
home &&
Boolean(active || summary?.capabilities.canCancel) &&
(!hostSessionOnly ||
Boolean(
cancellationEnabled &&
summary?.capabilities.canCancel &&
summary.activeTurnId,
))
}
onCancel={
home &&
@@ -2608,17 +2701,24 @@ function ManagedSessionsContent({
{t('managed.approvalFixed')}
</p>
)}
<div className="flex items-center justify-end gap-2">
{sessionId &&
!active &&
summary &&
!summary.capabilities.canSend && (
<span className="mr-auto text-sm text-muted-foreground">
{t('managed.newRequired')}
</span>
)}
{(!home || Boolean(text.trim())) && cancelButton}
</div>
{((sessionId &&
!active &&
summary &&
!summary.capabilities.canSend) ||
(!home && cancelButton) ||
Boolean(text.trim())) && (
<div className="flex items-center justify-end gap-2">
{sessionId &&
!active &&
summary &&
!summary.capabilities.canSend && (
<span className="mr-auto text-sm text-muted-foreground">
{t('managed.newRequired')}
</span>
)}
{(!home || Boolean(text.trim())) && cancelButton}
</div>
)}
</div>
) : (
cancelButton && (
@@ -716,7 +716,7 @@ export interface ManagedAgentProvider {
sessionId: string,
request: { text: string; input?: ManagedInputBlock[] },
options: ManagedAgentCommandOptions,
): Promise<ManagedAgentTurnAdmission & { operationId: string }>;
): Promise<ManagedAgentTurnAdmission & { operationId?: string }>;
cancel(
sessionId: string,
turnId: string,
@@ -455,6 +455,25 @@ it('clears a previous standalone archive error before the next visit commits', a
});
describe('WebShellSidebar current-session delete (issue #12619)', () => {
it('keeps Managed navigation visible when local Workspaces are collapsed', async () => {
window.localStorage.setItem(
'qwen.web-shell.sidebar.workspace-expanded:projects',
'false',
);
renderSidebar({
managedWorkspaces: (
<nav data-testid="managed-navigation">Managed sessions</nav>
),
});
await flushSidebar();
const managedNavigation = container.querySelector(
'[data-testid="managed-navigation"]',
);
expect(managedNavigation).not.toBeNull();
expect(managedNavigation?.closest('[hidden]')).toBeNull();
});
it('renders Managed and Local archives only in the settings target', async () => {
connection.capabilities = {
qwenCodeVersion: '1.2.3',
@@ -7678,6 +7678,9 @@ export function WebShellSidebar({
/>
</div>
)}
{/* Managed 历史由外部 App Host 注入,不受本地 Workspaces
折叠状态控制;否则折叠分组会把仍有效的 Managed 会话一起隐藏。 */}
{!channelView && !liveView && managedWorkspaces}
<div
className="flex flex-col"
hidden={liveView || !projectsExpanded}
@@ -7699,7 +7702,6 @@ export function WebShellSidebar({
{!hideProjectHeader && <WorkspaceHostHeading />}
<div className={styles.workspacePicker}>
<div className={styles.workspaceList}>
{!channelView && managedWorkspaces}
{orderedProjectWorkspaces.map((ws) => (
<Fragment key={ws.id}>
<WorkspaceSection
@@ -105,7 +105,9 @@ for (const retryFrom of ['owner', 'foreign', 'restored-foreign']) {
await editor.fill(' Accepted A input ');
await editor.press('Enter');
await expect(
page.getByText('The request outcome is unconfirmed', { exact: false }),
page.getByText('The previous message was not confirmed as submitted', {
exact: false,
}),
).toBeVisible();
await choose('B');
await expect(editor).toContainText('Unrelated B draft');
@@ -117,6 +119,12 @@ for (const retryFrom of ['owner', 'foreign', 'restored-foreign']) {
if (retryFrom === 'owner') {
await choose('A');
await expect(editor).toContainText('Accepted A input');
} else {
// A foreign session owns no retry affordance: it redirects to the
// conversation holding the pending prompt instead.
await page
.getByRole('button', { name: 'Open the original conversation' })
.click();
}
await page.getByRole('button', { name: 'Retry the same request' }).click();
@@ -664,13 +664,17 @@ test('a permanently failing pending create exposes a discard path @smoke', async
.click();
await expect(
page.getByText('The request outcome is unconfirmed', { exact: false }),
page.getByText('The previous message was not confirmed as submitted', {
exact: false,
}),
).toBeVisible();
await expect(composer).toHaveAttribute('contenteditable', 'false');
await page.getByRole('button', { name: 'Discard this request' }).click();
await expect(
page.getByText('The request outcome is unconfirmed', { exact: false }),
page.getByText('The previous message was not confirmed as submitted', {
exact: false,
}),
).toBeHidden();
await expect(composer).toHaveAttribute('contenteditable', 'true');
await expect(composer).toHaveText('Fragile draft');
+9 -2
View File
@@ -237,7 +237,10 @@ const EN: Messages = {
'managed.retry': 'Retry the same request',
'managed.cancel': 'Cancel turn',
'managed.uncertain':
'The request outcome is unconfirmed. Retry to check or complete the same submission.',
'The previous message was not confirmed as submitted. Retrying reuses the same request and will not run twice.',
'managed.pendingElsewhere':
'Another conversation has an unconfirmed request. Return to it before sending a new message.',
'managed.openPendingOwner': 'Open the original conversation',
'managed.discard': 'Discard this request',
'managed.pendingRestoreFailed':
'The pending request cannot be restored and may already have been submitted. Check the session before discarding this pending record.',
@@ -4861,7 +4864,11 @@ const ZH: Messages = {
'managed.runningHint': '本轮执行中,暂时不能发送新消息。',
'managed.retry': '重试同一请求',
'managed.cancel': '取消本轮',
'managed.uncertain': '请求结果尚未确认。重试会确认或完成同一次提交。',
'managed.uncertain':
'上一条消息未确认是否提交成功。重试使用同一请求标识,不会重复执行。',
'managed.pendingElsewhere':
'另一个对话有未确认请求,请返回原对话处理后再发送新消息。',
'managed.openPendingOwner': '返回原对话',
'managed.discard': '丢弃本次请求',
'managed.pendingRestoreFailed':
'无法恢复待确认请求,此前的提交可能已成功。请先检查会话,再决定是否丢弃这条待确认记录。',
@@ -0,0 +1,108 @@
// @vitest-environment jsdom
import { EditorView } from '@codemirror/view';
import { act } from 'react';
import { expect, it, vi } from 'vitest';
import { renderManagedAgentWebShell } from './managed-host';
import type {
ManagedAgentProvider,
ManagedAgentSessionSummary,
} from './components/managed/managed-agent-provider';
it('accepts a real composer submission without a public durable operation ID', async () => {
const session: ManagedAgentSessionSummary = {
sessionId: 'host-session',
title: 'Host session',
createdAt: 1,
updatedAt: 1,
admittedAt: 1,
phase: 'created',
runtimeReady: true,
runtimeState: 'ready',
capabilities: { canSend: true, canCancel: false },
};
const unsupported = vi.fn(async () => {
throw new Error('Unsupported host API');
});
const submitPrompt = vi.fn<ManagedAgentProvider['submitPrompt']>(async () => {
session.phase = 'completed';
session.capabilities.canSend = false;
return { sessionId: session.sessionId, turnId: 'accepted-turn' };
});
const provider: ManagedAgentProvider = {
storageKey: 'host-composer:tenant:actor',
canCancel: false,
listSessions: unsupported,
createSession: unsupported,
cancel: unsupported,
queryCancelOperation: unsupported,
getSession: async () => ({ ...session }),
getTranscript: async () => ({ events: [], lastEventId: 0 }),
async *subscribeEvents(_id, options) {
await new Promise<void>((resolve) => {
if (options.signal?.aborted) resolve();
else
options.signal?.addEventListener('abort', () => resolve(), {
once: true,
});
});
yield* [];
},
submitPrompt,
};
const unexpectedFetch = vi.fn(async () => {
throw new Error('Unexpected network request');
});
vi.stubGlobal('fetch', unexpectedFetch);
localStorage.clear();
sessionStorage.clear();
const container = document.createElement('div');
document.body.appendChild(container);
let shell!: ReturnType<typeof renderManagedAgentWebShell>;
const flush = async () => {
for (let i = 0; i < 12; i++) await Promise.resolve();
};
try {
await act(async () => {
shell = renderManagedAgentWebShell(container, {
managedAgentProvider: provider,
hostSessionOnly: true,
sessionId: session.sessionId,
language: 'en',
});
await flush();
});
const editorNode = container.querySelector<HTMLElement>('.cm-editor');
expect(editorNode).not.toBeNull();
const editor = EditorView.findFromDOM(editorNode!)!;
await act(async () => {
editor.dispatch({
changes: { from: 0, insert: 'Run the existing host session' },
});
await flush();
});
const send = container.querySelector<HTMLButtonElement>(
'[data-web-shell-composer-submit]',
)!;
expect(send.disabled).toBe(false);
await act(async () => {
send.click();
await flush();
});
expect(submitPrompt).toHaveBeenCalledTimes(1);
expect(submitPrompt).toHaveBeenCalledWith(
session.sessionId,
expect.objectContaining({ text: 'Run the existing host session' }),
expect.objectContaining({ idempotencyKey: expect.any(String) }),
);
expect(editor.state.doc.toString()).toBe('');
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.textContent).not.toContain('unconfirmed');
expect(unsupported).not.toHaveBeenCalled();
expect(unexpectedFetch).not.toHaveBeenCalled();
} finally {
if (shell) await act(async () => shell.unmount());
container.remove();
vi.unstubAllGlobals();
}
});
@@ -0,0 +1,64 @@
// @vitest-environment jsdom
import { act } from 'react';
import { expect, it, vi } from 'vitest';
import { renderManagedAgentWebShell } from './managed-host';
vi.mock('./ManagedAgentWebShell', () => ({
ManagedAgentWebShell: ({ sessionId }: { sessionId?: string }) => (
<div data-web-shell-root>{sessionId}</div>
),
}));
it('mounts, replaces props and disposes a host root', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const container = document.createElement('div');
host.appendChild(container);
let shell!: ReturnType<typeof renderManagedAgentWebShell>;
try {
await act(async () => {
shell = renderManagedAgentWebShell(container, { sessionId: 'first' });
});
expect(container.textContent).toBe('first');
await act(async () => shell.update({ sessionId: 'second' }));
expect(container.textContent).toBe('second');
await act(async () => shell.unmount());
expect(container.childElementCount).toBe(0);
} finally {
host.remove();
}
});
it('keeps shadow-root styles until the last host root unmounts', async () => {
const packageStyle = document.createElement('style');
packageStyle.dataset.qwenWebShell = 'component';
packageStyle.textContent = '[data-web-shell-root] { color: red; }';
document.head.appendChild(packageStyle);
const host = document.createElement('div');
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const first = document.createElement('div');
const second = document.createElement('div');
shadow.append(first, second);
let left!: ReturnType<typeof renderManagedAgentWebShell>;
let right!: ReturnType<typeof renderManagedAgentWebShell>;
try {
await act(async () => {
left = renderManagedAgentWebShell(first, { sessionId: 'first' });
right = renderManagedAgentWebShell(second, { sessionId: 'second' });
});
const hasStyles = () =>
shadow.querySelectorAll('style').length > 0 ||
(shadow.adoptedStyleSheets?.length ?? 0) > 0;
expect(hasStyles()).toBe(true);
await act(async () => left.unmount());
expect(second.textContent).toBe('second');
expect(hasStyles()).toBe(true);
await act(async () => right.unmount());
expect(hasStyles()).toBe(false);
} finally {
packageStyle.remove();
host.remove();
}
});
@@ -0,0 +1,35 @@
import { createRoot } from 'react-dom/client';
import {
ManagedAgentWebShell,
type ManagedAgentWebShellProps,
} from './ManagedAgentWebShell';
import { installWebShellShadowStyles } from './shadowDom';
export type { ManagedAgentWebShellProps } from './ManagedAgentWebShell';
export type { ManagedAgentProvider } from './components/managed/managed-agent-provider';
export function renderManagedAgentWebShell(
container: Element,
props: ManagedAgentWebShellProps,
): {
update(nextProps: ManagedAgentWebShellProps): void;
unmount(): void;
} {
const containerRoot = container.getRootNode();
const removeStyles =
containerRoot instanceof ShadowRoot
? installWebShellShadowStyles(containerRoot)
: undefined;
const root = createRoot(container);
const update = (nextProps: ManagedAgentWebShellProps) => {
root.render(<ManagedAgentWebShell {...nextProps} />);
};
update(props);
return {
update,
unmount() {
root.unmount();
removeStyles?.();
},
};
}
+39 -1
View File
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import {
isPluginShadowPanel,
installWebShellShadowStyles,
@@ -79,4 +79,42 @@ describe('installWebShellShadowStyles', () => {
packageStyle.remove();
expect(shadowRoot.querySelector('style')).toBeNull();
});
it('does not release a shared constructable sheet while another shell uses it', () => {
const replaceSync = vi.fn();
vi.stubGlobal(
'CSSStyleSheet',
class {
replaceSync(css: string) {
replaceSync(css);
}
},
);
const packageStyle = document.createElement('style');
packageStyle.dataset.qwenWebShell = 'component';
packageStyle.textContent = '[data-web-shell-root] { color: green; }';
document.head.appendChild(packageStyle);
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
let sheets: CSSStyleSheet[] = [];
Object.defineProperty(shadow, 'adoptedStyleSheets', {
get: () => sheets,
set: (next: CSSStyleSheet[]) => {
sheets = next;
},
});
try {
const releaseFirst = installWebShellShadowStyles(shadow);
const releaseSecond = installWebShellShadowStyles(shadow);
expect(replaceSync).toHaveBeenCalledWith(packageStyle.textContent);
expect(sheets).toHaveLength(1);
releaseFirst();
expect(sheets).toHaveLength(1);
releaseSecond();
expect(sheets).toHaveLength(0);
} finally {
packageStyle.remove();
vi.unstubAllGlobals();
}
});
});
+33 -2
View File
@@ -45,6 +45,10 @@ const packageStyleSheetCache = new WeakMap<
Document,
{ css: string; sheet: CSSStyleSheet }
>();
const adoptedPackageSheets = new WeakMap<
ShadowRoot,
Map<CSSStyleSheet, { users: number; owned: boolean }>
>();
function getWebShellStyleText(document: Document): string {
// The lib build injects one tag per component entry (`index` and
@@ -119,13 +123,40 @@ export function installWebShellShadowStyles(
(sheet): sheet is CSSStyleSheet => Boolean(sheet),
);
if (sheets.length === styles.length && sheets.length > 0) {
const ownedPackageSheet =
packageSheet !== null &&
!shadowRoot.adoptedStyleSheets.includes(packageSheet);
shadowRoot.adoptedStyleSheets = [
...shadowRoot.adoptedStyleSheets,
...sheets,
...sheets.filter(
(sheet) => !shadowRoot.adoptedStyleSheets.includes(sheet),
),
];
if (packageSheet) {
let installed = adoptedPackageSheets.get(shadowRoot);
if (!installed) {
installed = new Map();
adoptedPackageSheets.set(shadowRoot, installed);
}
const existing = installed.get(packageSheet);
if (existing) existing.users += 1;
else
installed.set(packageSheet, {
users: 1,
owned: ownedPackageSheet,
});
}
return () => {
const installed = packageSheet
? adoptedPackageSheets.get(shadowRoot)
: undefined;
const entry = packageSheet ? installed?.get(packageSheet) : undefined;
if (entry && --entry.users === 0 && packageSheet)
installed?.delete(packageSheet);
shadowRoot.adoptedStyleSheets = shadowRoot.adoptedStyleSheets.filter(
(sheet) => !sheets.includes(sheet),
(sheet) =>
sheet !== additionalSheet &&
!(sheet === packageSheet && entry?.users === 0 && entry.owned),
);
};
}
+2 -1
View File
@@ -32,7 +32,8 @@
"NOTICES.txt",
"dist/*.js",
"dist/types",
"!dist/types/components/artifacts/pierre-*.d.ts"
"!dist/types/components/artifacts/pierre-*.d.ts",
"!dist/types/managed-host.d.ts"
],
"scripts": {
"dev": "vite",
+21 -12
View File
@@ -128,11 +128,13 @@ function injectCssModules(): Plugin {
// keep `data-qwen-web-shell="component"` so shadow-root style adoption
// (client/shadowDom.ts) still finds them; that reader concatenates
// every match rather than taking the first.
const entry = item.facadeModuleId?.endsWith('/client/transcript.ts')
? 'transcript'
: item.facadeModuleId?.endsWith('/client/index.tsx')
? 'index'
: undefined;
const entry = item.facadeModuleId?.endsWith('/client/managed-host.tsx')
? 'managed-host'
: item.facadeModuleId?.endsWith('/client/transcript.ts')
? 'transcript'
: item.facadeModuleId?.endsWith('/client/index.tsx')
? 'index'
: undefined;
if (!entry) {
continue;
}
@@ -280,6 +282,7 @@ export default defineConfig(({ mode }) => ({
},
build: {
emptyOutDir: false,
outDir: mode === 'managed-host' ? 'dist/managed-host' : 'dist',
// Same floor as the app build: the lib bundle minifies the same xterm
// (it is not external), and Vite 5's default target lowers its logical
// assignments into code that throws on the first mode query. Also covers
@@ -287,18 +290,21 @@ export default defineConfig(({ mode }) => ({
target: WEB_SHELL_BUILD_TARGET,
lib: {
entry:
mode === 'transcript'
? { transcript: 'client/transcript.ts' }
: {
index: 'client/index.tsx',
'daemon-react-sdk': 'client/daemon-react-sdk.ts',
'code-highlighter': 'client/code-highlighter.ts',
},
mode === 'managed-host'
? { index: 'client/managed-host.tsx' }
: mode === 'transcript'
? { transcript: 'client/transcript.ts' }
: {
index: 'client/index.tsx',
'daemon-react-sdk': 'client/daemon-react-sdk.ts',
'code-highlighter': 'client/code-highlighter.ts',
},
formats: ['es'],
fileName: (_format, entryName) => `${entryName}.js`,
},
rollupOptions: {
external: (id, importer) => {
if (mode === 'managed-host') return false;
// Pierre requires Shiki 3/4; keep its copy inside the lazy preview bundle.
if (id === 'shiki') return !importer?.includes('/@pierre/');
return [
@@ -342,6 +348,9 @@ export default defineConfig(({ mode }) => ({
},
},
define: {
...(mode === 'managed-host'
? { 'process.env.NODE_ENV': JSON.stringify('production') }
: {}),
__WEB_SHELL_VERSION__: JSON.stringify(pkg.version),
},
}));