qwen-code/packages/web-shell/client/components/WorkspaceSelector.test.tsx
Heyang Wang 6f5d6dfd65
feat(web-shell): add workspace selector button with add/switch dropdown in composer toolbar (#7390)
* feat(web-shell): add managed workspace selector

Let Web Shell create and select daemon-managed workspaces without
changing ownership of existing sessions.

- Add capability-gated existing and scratch workspace registration
- Validate scratch roots, trust provenance, capacity, and shutdown races
- Serialize workspace mutations, session switching, and refresh results
- Add SDK/WebUI wiring and focused cross-package regression coverage

# Conflicts:
#	packages/web-shell/client/App.tsx
#	packages/web-shell/client/components/sidebar/WebShellSidebar.tsx

# Conflicts:
#	packages/cli/src/serve/capabilities.ts
#	packages/cli/src/serve/routes/workspace-management.ts
#	packages/cli/src/serve/server.test.ts
#	packages/sdk-typescript/src/daemon/DaemonClient.ts
#	packages/web-shell/client/App.tsx
#	packages/web-shell/client/components/dialogs/AddWorkspaceDialog.tsx
#	packages/web-shell/client/components/sidebar/WebShellSidebar.tsx

* fix(web-shell): revalidate workspace before session creation

Prevent a stale workspace selection from bypassing the latest trusted
capability snapshot during lazy session creation.

- Validate the selected workspace before passing it to the daemon
- Fall back to the primary workspace when trust has been revoked
- Add a regression test for the pre-effect race window
- Remove stale branch state and clarify add-workspace ownership

* fix(web-shell): improve workspace removal feedback

Keep workspace removal controls legible and make blocked force removals
visibly inactive.

- Size the action menu independently from its narrow icon trigger
- Add a disabled affordance and suppress destructive hover styling
- Cover the removal menu width override with a regression test

* fix(web-shell): centralize existing workspace registration

Route sidebar and composer entry points through the App-owned dialog so
capability gating and workspace reconciliation remain consistent.

- Forward display names only when the daemon advertises support
- Hide and suppress persistence when registration is runtime-only
- Mark directory registrations with existing-workspace provenance
- Cover both entry points and capability combinations with tests

* fix(web-shell): address review feedback on workspace dialogs and capability docs (#7390)

- Document dynamic_workspace_registration and scratch_workspace_registration
  in the conditional serve-features table so the capabilities-docs-contract
  test passes.
- Gate DialogShell backdrop-click and Escape dismissal on the dismissible
  prop so non-dismissible dialogs ignore both gestures.
- Surface an inline error when an added folder registers but the capability
  refresh fails, mirroring the scratch recovery path.
- Add coverage for the active-session workspace switch and the add-folder
  refresh-failure paths.

* fix(web-shell): address review feedback on workspace dialogs and capability docs (#7390)

---------

Co-authored-by: heyang.why <heyang.why@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
2026-07-22 01:10:18 +00:00

101 lines
2.9 KiB
TypeScript

// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { I18nProvider } from '../i18n';
import { WorkspaceSelector } from './WorkspaceSelector';
let root: Root | undefined;
let container: HTMLDivElement | undefined;
afterEach(async () => {
await act(async () => root?.unmount());
container?.remove();
});
function renderSelector(
overrides: Partial<React.ComponentProps<typeof WorkspaceSelector>> = {},
) {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<I18nProvider language="en">
<WorkspaceSelector
workspaces={[
{
id: 'primary',
cwd: '/primary',
label: 'primary',
primary: true,
trusted: true,
},
{
id: 'locked',
cwd: '/locked',
label: 'locked',
primary: false,
trusted: false,
},
]}
scratchSupported
existingFolderSupported
onSelectWorkspace={vi.fn()}
onCreateScratch={vi.fn()}
onOpenExistingFolder={vi.fn()}
{...overrides}
/>
</I18nProvider>,
);
});
return container;
}
describe('WorkspaceSelector', () => {
it('hides for a single workspace without creation capabilities', () => {
const element = renderSelector({
workspaces: [
{
id: 'primary',
cwd: '/primary',
label: 'primary',
primary: true,
trusted: true,
},
],
scratchSupported: false,
existingFolderSupported: false,
});
expect(element.querySelector('button')).toBeNull();
});
it('gates creation actions and disables untrusted workspaces', async () => {
const onCreateScratch = vi.fn();
const element = renderSelector({ onCreateScratch });
const trigger = element.querySelector('button')!;
await act(async () => {
trigger.dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true, button: 0 }),
);
});
expect(document.body.textContent).toContain('New workspace');
expect(document.body.textContent).toContain('untrusted');
const newWorkspace = document.querySelector(
'[data-slot="dropdown-menu-sub-trigger"]',
)!;
await act(async () => {
newWorkspace.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
);
});
expect(document.body.textContent).toContain('Start from scratch');
expect(document.body.textContent).toContain('Use an existing folder');
const locked = [
...document.querySelectorAll('[role="menuitemradio"]'),
].find((entry) => entry.textContent?.includes('locked'));
expect(locked?.getAttribute('data-disabled')).not.toBeNull();
});
});