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
16 changes: 8 additions & 8 deletions packages/web-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,14 +150,14 @@ export function App() {

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] The integration example persists only sessionId, and the prop documentation omits the new workspace identity callback value. Consumers following this README cannot round-trip secondary sessions across reloads. Document and demonstrate persisting both session and workspace IDs.

— Codex $qreview via Qwen Code /review

### WebShell

| 属性 | 类型 | 说明 |
| ------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------- |
| `onSessionIdChange` | `(sessionId: string \| undefined) => void` | 当前 session id 变化或清空时触发 |
| `onSessionCreated` | `(sessionId: string) => Promise<void> \| void` | 新 session 创建后触发;完成前会阻塞 session 初始化和 prompt 提交,最长等待 30 秒 |
| `theme` | `'dark' \| 'light'` | UI 主题,默认 `dark` |
| `onThemeChange` | `(theme: WebShellTheme) => void` | `/theme` 命令切换主题后触发 |
| `language` | `'en' \| 'zh-CN' \| 'zh' \| 'zh-cn'` | UI 语言 |
| `onLanguageChange` | `(language: WebShellLanguage) => void` | `/language ui` 切换 UI 语言后触发 |
| 属性 | 类型 | 说明 |
| ------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `onSessionIdChange` | `(sessionId: string \| undefined, workspaceId?: string) => void` | 当前 session id 或 workspace id 变化或清空时触发 |
| `onSessionCreated` | `(sessionId: string) => Promise<void> \| void` | 新 session 创建后触发;完成前会阻塞 session 初始化和 prompt 提交,最长等待 30 秒 |
| `theme` | `'dark' \| 'light'` | UI 主题,默认 `dark` |
| `onThemeChange` | `(theme: WebShellTheme) => void` | `/theme` 命令切换主题后触发 |
| `language` | `'en' \| 'zh-CN' \| 'zh' \| 'zh-cn'` | UI 语言 |
| `onLanguageChange` | `(language: WebShellLanguage) => void` | `/language ui` 切换 UI 语言后触发 |

## 可选图表 Renderer

Expand Down
37 changes: 0 additions & 37 deletions packages/web-shell/client/App.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -169,39 +169,6 @@
display: none;
}

/* Hosts can embed WebShell in a narrow side pane while the browser viewport
* remains wide. The public drawer API must still show a usable overlay in
* that container instead of relying exclusively on the viewport media query. */
.mobileDrawerForced {
display: block;
/* `.app` is the narrow host Webview's positioning context. Absolute keeps
* the drawer inside it; fixed would target the IDE's larger page instead. */
position: absolute;
inset: 0;
z-index: 50;
pointer-events: auto;
visibility: visible;
padding-top: env(safe-area-inset-top);
padding-right: env(safe-area-inset-right);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
}

.mobileDrawerForced .mobileBackdrop {
display: block;
position: absolute;
inset: 0;
z-index: 49;
background: rgba(0, 0, 0, 0.5);
opacity: 1;
pointer-events: auto;
}

.mobileDrawerForced > aside {
position: relative;
z-index: 50;
}

.hamburgerButton {
display: none;
align-items: center;
Expand Down Expand Up @@ -307,10 +274,6 @@
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}

:where(.app button) {
padding: 0;
}

.app *::-webkit-scrollbar {
width: 8px;
height: 8px;
Expand Down
155 changes: 18 additions & 137 deletions packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type ChatEditorTestProps = {
const {
mockConnection,
mockSessionActions,
mockWorkspace,
mockWorkspaceActions,
mockStore,
mockFollowup,
Expand All @@ -67,6 +68,11 @@ const {
loadingTranscript: false,
catchingUp: false,
};
const workspaceClient = {
workspaceByCwd: vi.fn(() => ({
workspaceGit: vi.fn().mockResolvedValue({ branch: 'main' }),
})),
};
return {
mockConnection: connection,
mockSessionActions: {
Expand All @@ -88,6 +94,12 @@ const {
loadArtifacts: vi.fn().mockResolvedValue({ artifacts: [] }),
loadSession: vi.fn().mockResolvedValue(undefined),
},
mockWorkspace: {
capabilities: {
workspaces: [{ id: 'primary', cwd: '/workspace', primary: true }],
},
client: workspaceClient,
},
mockWorkspaceActions: {
loadSkillsStatus: vi.fn().mockResolvedValue({ skills: [] }),
loadProviders: vi.fn().mockResolvedValue({ current: null }),
Expand Down Expand Up @@ -153,6 +165,7 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
useStreamingState: () => testState.streamingState,
useTranscriptBlocks: () => testState.blocks,
useTranscriptStore: () => mockStore,
useWorkspace: () => mockWorkspace,
useWorkspaceActions: () => mockWorkspaceActions,
useWorkspaceEventSignals: () => ({
artifactsVersion: 0,
Expand Down Expand Up @@ -401,6 +414,8 @@ mockComponent('./components/WelcomeHeader', 'WelcomeHeader');
mockComponent('./components/dialogs/ApprovalModeDialog', 'ApprovalModeDialog');
mockComponent('./components/dialogs/ResumeDialog', 'ResumeDialog');
mockComponent('./components/dialogs/ToolsDialog', 'ToolsDialog');
mockComponent('./components/tools/ToolsManagerPage', 'ToolsManagerPage');

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] These mocks for ./components/tools/ToolsManagerPage and ./components/skills/SkillsManagerPage reference components that don't exist anywhere in the repo. No imports reference them. These are dead mocks — either remove them or add the corresponding components.

— qwen3.7-max via Qwen Code /review

mockComponent('./components/skills/SkillsManagerPage', 'SkillsManagerPage');
mockComponent('./components/dialogs/DaemonStatusDialog', 'DaemonStatusDialog');
mockComponent('./components/SessionOverviewPanel', 'SessionOverviewPanel');
vi.doMock('./components/SplitView', async () => {
Expand Down Expand Up @@ -1722,142 +1737,6 @@ describe('App session callbacks', () => {
expect(panel?.getAttribute('aria-label')).toBe('Session Overview');
});

it('forces the compact session drawer from the external shell ref', async () => {
const shellRef = createRef<WebShellApi>();
const { container } = renderApp({ sidebar: true, shellRef });
await flush();

await act(async () => {
shellRef.current?.openSessionDrawer();
await Promise.resolve();
});

const drawer = container.querySelector(
'[data-sidebar-shell][role="dialog"]',
);
expect(drawer).not.toBeNull();
expect(drawer?.className).toContain('mobileDrawerForced');
});

it('returns a forced compact drawer to viewport control when the user dismisses it', async () => {
const shellRef = createRef<WebShellApi>();
const { container } = renderApp({ sidebar: true, shellRef });
await flush();

await act(async () => {
shellRef.current?.openSessionDrawer();
await Promise.resolve();
});
expect(
container.querySelector('[data-sidebar-shell]')?.className,
).toContain('mobileDrawerForced');

await act(async () => {
container
.querySelector<HTMLElement>(
'[data-sidebar-shell] > div[aria-hidden="true"]',
)
?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-sidebar-shell]')?.className,
).not.toContain('mobileDrawerForced');
expect(
container.querySelector('[data-sidebar-shell][role="dialog"]'),
).toBeNull();
});

it('returns to chat and clears the current page when the external shell opens the compact drawer', async () => {
const shellRef = createRef<WebShellApi>();
const { container } = renderApp({ sidebar: true, shellRef });
await flush();

await act(async () => {
shellRef.current?.openSessionOverview();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();

await act(async () => {
shellRef.current?.openSessionDrawer();
await Promise.resolve();
});
expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull();

await act(async () => {
shellRef.current?.openSplitView();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();

await act(async () => {
shellRef.current?.openSessionDrawer();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
expect(
container.querySelector('[data-sidebar-shell][role="dialog"]'),
).not.toBeNull();
});

it('clears a forced compact drawer after crossing to a wide viewport', async () => {
let mobileChangeHandler:
| ((event: { matches: boolean }) => void)
| undefined;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query.includes('min-width'),
media: query,
addEventListener: (
_type: string,
handler: (event: { matches: boolean }) => void,
) => {
if (query.includes('max-width')) mobileChangeHandler = handler;
},
removeEventListener: vi.fn(),
})),
});
const shellRef = createRef<WebShellApi>();
const { container } = renderApp({ sidebar: true, shellRef });
await flush();

await act(async () => {
shellRef.current?.openSessionDrawer();
await Promise.resolve();
});
expect(
container.querySelector('[data-sidebar-shell]')?.className,
).toContain('mobileDrawerForced');

await act(async () => {
mobileChangeHandler?.({ matches: false });
await Promise.resolve();
});
expect(
container.querySelector('[data-sidebar-shell]')?.className,
).not.toContain('mobileDrawerForced');
expect(
container.querySelector('[data-sidebar-shell][role="dialog"]'),
).toBeNull();
});

it('lets a host hide the built-in compact sidebar toggle', async () => {
const { container } = renderApp({
sidebar: { enabled: true, showCompactToggle: false },
});
await flush();

expect(container.querySelector('[aria-label="Toggle menu"]')).toBeNull();
});

it('returns to the Session Overview when leaving the split view', async () => {
const { container } = renderApp();
await flush();
Expand Down Expand Up @@ -2892,7 +2771,9 @@ describe('App manual-run orchestration (scheduled tasks)', () => {
await act(async () => {
await expect(run('do the thing', 'session-1')).resolves.toBeUndefined();
});
expect(mockSessionActions.loadSession).toHaveBeenCalledWith('session-1');
expect(mockSessionActions.loadSession).toHaveBeenCalledWith('session-1', {
workspaceCwd: undefined,
});
});

it('supersedes an older pending bound run with a newer one', async () => {
Expand Down
Loading
Loading