Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/design/web-shell-plugin-shadow-surfaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Web Shell Plugin Shadow Surfaces

## Goal

Make `shadowDom={{ plugins: true }}` isolate every plugin-management page,
regardless of whether it was opened from the unified Plugins navigation or a
slash-command compatibility route.

## Surfaces

The plugin Shadow DOM boundary applies to these inline panel IDs:

- `plugins`
- `extensions`
- `mcp`
- `skills`
- `agents`

`agents` is included for compatibility with the `AgentsManagerPage` and
`AgentCreatePage` introduced by PR #7572. The create page is rendered inside
the agents manager, so both pages share the same boundary.

Settings, daemon status, and session overview remain in the Light DOM.
Portal-based UI remains controlled independently by `shadowDom.portals`.

## Implementation

Use one boundary around the inline panel body and enable it only for the
plugin-management panel IDs. This preserves a single rendering path and keeps
the legacy slash routes consistent with the unified Plugins page.
3 changes: 2 additions & 1 deletion packages/web-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ import customShadowStyles from './web-shell-shadow.css?inline';
/>;
```

- `plugins` 只隔离插件管理页面主体。
- `plugins` 隔离所有插件管理页面主体,包括统一的 Plugins 页面,以及
`/extensions`、`/mcp`、`/skills` 等兼容入口打开的页面。
- `portals` 统一隔离 Web Shell 的所有弹窗层,包括 Dialog、Drawer、Popover、
DropdownMenu、Select 和 Tooltip;插件页面发起的弹窗也由这个开关管理。
- `styles` 会追加到每个启用的 ShadowRoot,供 render props 等业务自定义内容继续
Expand Down
41 changes: 40 additions & 1 deletion packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3512,7 +3512,7 @@ describe('App session callbacks', () => {
).toBe('true');
});

it('shadow-isolates only the plugin manager body when plugins is enabled', async () => {
it('shadow-isolates the unified plugin manager body when plugins is enabled', async () => {
const { container } = renderApp({
shadowDom: {
plugins: true,
Expand Down Expand Up @@ -3546,6 +3546,45 @@ describe('App session callbacks', () => {
).not.toBeNull();
});

it.each([
['/extensions manage', 'Manage Extensions'],
['/mcp', 'MCP Servers'],
['/skills details', 'Skills'],
])(
'shadow-isolates the %s compatibility page when plugins is enabled',
async (command, panelLabel) => {
mockWorkspaceActions.loadMcpStatus.mockResolvedValue({
initialized: true,
discoveryState: 'completed',
servers: [],
});
const { container } = renderApp({
shadowDom: {
plugins: true,
portals: false,
},
});
await flush();

testState.prompt = command;
await clickSubmit(container);
await flush();

const panel = container.querySelector('[data-testid="inline-panel"]');
const host = panel?.querySelector<HTMLElement>(
'[data-web-shell-shadow-host="plugins"]',
);
expect(panel?.getAttribute('aria-label')).toBe(panelLabel);
expect(host?.shadowRoot).not.toBeNull();
expect(
host?.shadowRoot?.querySelector(
'[data-web-shell-shadow-root="plugins"]',
),
).not.toBeNull();
expect(panel?.querySelector('button')).toBeNull();
},
);

it('uses one shadow root for all portals without moving plugin content', async () => {
const { container } = renderApp({
shadowDom: {
Expand Down
72 changes: 41 additions & 31 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ import { RewindDialog } from './components/dialogs/RewindDialog';
import { AddWorkspaceDialog } from './components/dialogs/AddWorkspaceDialog';
import { Button } from './components/ui/button';
import {
isPluginShadowPanel,
installWebShellShadowStyles,
resolveWebShellShadowDom,
type WebShellShadowDom,
Expand Down Expand Up @@ -7189,7 +7190,32 @@ export function App({
</div>
)}
<div className={styles.panelBody} key={activePanel}>
{activePanel === 'settings' ? (
<ShadowDomBoundary
enabled={
shadowDomOptions.plugins &&
isPluginShadowPanel(activePanel)
}
language={selectedLanguage}
themeClassName={[
selectedTheme === WebShellThemeId.Light
? styles.themeLight
: styles.themeDark,
selectedTheme === WebShellThemeId.Dark
? 'dark'
: undefined,
]
.filter(Boolean)
.join(' ')}
styles={shadowDomOptions.styles}
initialFocusRef={

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] initialFocusRef only covers plugins and extensions; mcp/skills/agents fall through to undefined. If these panels manage focus internally, a brief comment (e.g. // mcp/skills/agents manage focus internally) would prevent future maintainers from thinking it's an oversight.

— qwen3.8-max-preview via Qwen Code /review

activePanel === 'plugins'
? pluginTabRef
: activePanel === 'extensions'
? panelHeadingRef
: undefined
}
>
{activePanel === 'settings' ? (
<SettingsMessage
settingsState={workspaceSettingsState}
embedded
Expand Down Expand Up @@ -7277,37 +7303,20 @@ export function App({
onUseSkill={handleUseSkill}
/>
) : activePanel === 'plugins' ? (
<ShadowDomBoundary
enabled={shadowDomOptions.plugins}
language={selectedLanguage}
themeClassName={[
selectedTheme === WebShellThemeId.Light
? styles.themeLight
: styles.themeDark,
selectedTheme === WebShellThemeId.Dark
? 'dark'
: undefined,
]
.filter(Boolean)
.join(' ')}
styles={shadowDomOptions.styles}
<PluginManagerPage
mcpMessage={mcpDialogMessage}
loadMcpMessage={async () => {
try {
await loadMcpManagerMessage();
} catch (error) {
reportError(error, 'Failed to load MCP status');
throw error;
}
}}
onClose={closePanel}
onUseSkill={handleUseSkill}
initialFocusRef={pluginTabRef}
>
<PluginManagerPage
mcpMessage={mcpDialogMessage}
loadMcpMessage={async () => {
try {
await loadMcpManagerMessage();
} catch (error) {
reportError(error, 'Failed to load MCP status');
throw error;
}
}}
onClose={closePanel}
onUseSkill={handleUseSkill}
initialFocusRef={pluginTabRef}
/>
</ShadowDomBoundary>
/>
) : (
<SessionOverviewPanel
onOpenSession={handleOpenSessionFromOverview}
Expand All @@ -7316,6 +7325,7 @@ export function App({
workspaceCwd={lockedWorkspaceCwd}
/>
)}
</ShadowDomBoundary>
</div>
</section>
)}
Expand Down
17 changes: 17 additions & 0 deletions packages/web-shell/client/shadowDom.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import {
isPluginShadowPanel,
installWebShellShadowStyles,
resolveWebShellShadowDom,
} from './shadowDom';

describe('isPluginShadowPanel', () => {
it.each(['plugins', 'extensions', 'mcp', 'skills', 'agents'])(
'includes the %s management surface',
(panel) => {
expect(isPluginShadowPanel(panel)).toBe(true);
},
);

it.each([null, 'settings', 'status', 'sessions'])(
'excludes the %s non-plugin surface',
(panel) => {
expect(isPluginShadowPanel(panel)).toBe(false);
},
);
});

describe('resolveWebShellShadowDom', () => {
it('keeps Shadow DOM disabled by default', () => {
expect(resolveWebShellShadowDom(undefined)).toEqual({
Expand Down
12 changes: 12 additions & 0 deletions packages/web-shell/client/shadowDom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ export interface ResolvedWebShellShadowDomOptions {
styles?: string;
}

const PLUGIN_SHADOW_PANELS = new Set([
'plugins',
'extensions',
'mcp',
'skills',
'agents',
]);

export function isPluginShadowPanel(panel: string | null): boolean {
return panel !== null && PLUGIN_SHADOW_PANELS.has(panel);
}

export function resolveWebShellShadowDom(
value: WebShellShadowDom | undefined,
): ResolvedWebShellShadowDomOptions {
Expand Down
Loading