qwen-code/packages/web-shell/client/components/dialogs/DialogShell.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

262 lines
8.2 KiB
TypeScript

import {
createContext,
useEffect,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from 'react';
import { Maximize2Icon, Minimize2Icon, XIcon } from 'lucide-react';
import { useI18n } from '../../i18n';
import { useTheme, WebShellThemeId } from '../../themeContext';
import { Button } from '../ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
import styles from './DialogShell.module.css';
type DialogSize = 'sm' | 'md' | 'lg' | 'xl';
interface DialogShellProps {
title: string;
subtitle?: string;
size?: DialogSize;
allowFullscreen?: boolean;
dismissible?: boolean;
onClose: () => void;
children: ReactNode;
}
const sizeClass: Record<DialogSize, string> = {
sm: 'sm:max-w-[420px]',
md: 'sm:max-w-[560px]',
lg: 'sm:max-w-[720px]',
xl: 'sm:max-w-[900px]',
};
const FOCUSABLE_SELECTOR = [
'a[href]:not([hidden])',
'button:not([disabled]):not([hidden])',
'input:not([disabled]):not([hidden])',
'select:not([disabled]):not([hidden])',
'textarea:not([disabled]):not([hidden])',
'[tabindex]:not([tabindex="-1"]):not([hidden])',
].join(',');
function getFocusable(container: HTMLElement | null): HTMLElement[] {
if (!container) return [];
return Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
);
}
const shellStack: object[] = [];
export const DialogShellIdContext = createContext<object | null>(null);
export function isTopDialogShellId(shellId: object | null): boolean {
if (shellId === null) return true;
return shellStack[shellStack.length - 1] === shellId;
}
export function DialogShell({
title,
subtitle,
size = 'md',
allowFullscreen = false,
dismissible = true,
onClose,
children,
}: DialogShellProps) {
const { t } = useI18n();
const theme = useTheme();
const [fullscreen, setFullscreen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const [previouslyFocused] = useState<HTMLElement | null>(() =>
typeof document !== 'undefined'
? (document.activeElement as HTMLElement | null)
: null,
);
const backdropPressStartedRef = useRef(false);
const backdropPressEndedRef = useRef(false);
const shellIdRef = useRef<object | null>(null);
if (shellIdRef.current === null) shellIdRef.current = {};
useEffect(() => {
const shellId = shellIdRef.current!;
shellStack.push(shellId);
const preserveImeEscape = (event: KeyboardEvent) => {
if (
event.key !== 'Escape' ||
(!event.isComposing && event.keyCode !== 229) ||
!isTopDialogShellId(shellId)
) {
return;
}
// Radix handles Escape on document capture and otherwise prevents the
// native IME cancellation. Mask it only for Radix, then restore it before
// the event continues to the focused input.
Object.defineProperty(event, 'key', {
configurable: true,
value: 'Process',
});
document.addEventListener(
'keydown',
(currentEvent) => {
if (currentEvent === event) Reflect.deleteProperty(event, 'key');
},
{ capture: true, once: true },
);
};
window.addEventListener('keydown', preserveImeEscape, { capture: true });
return () => {
window.removeEventListener('keydown', preserveImeEscape, {
capture: true,
});
const index = shellStack.indexOf(shellId);
if (index >= 0) shellStack.splice(index, 1);
if (shellStack.length === 0) {
previouslyFocused?.focus?.();
return;
}
const scopes = Array.from(
document.querySelectorAll<HTMLElement>('[data-keyboard-scope]'),
);
const topPanel = scopes[scopes.length - 1];
const preferred = getFocusable(topPanel).find(
(element) => !element.hasAttribute('data-dialog-close'),
);
(preferred ?? topPanel)?.focus();
};
}, [previouslyFocused]);
const handleBackdropMouseDown = (event: ReactMouseEvent<HTMLDivElement>) => {
backdropPressStartedRef.current = event.target === event.currentTarget;
backdropPressEndedRef.current = false;
};
const handleBackdropMouseUp = (event: ReactMouseEvent<HTMLDivElement>) => {
backdropPressEndedRef.current = event.target === event.currentTarget;
};
const handleBackdropClick = (event: ReactMouseEvent<HTMLDivElement>) => {
const shouldClose =
backdropPressStartedRef.current &&
backdropPressEndedRef.current &&
event.target === event.currentTarget;
backdropPressStartedRef.current = false;
backdropPressEndedRef.current = false;
if (shouldClose && dismissible) onClose();
};
const themeClass =
theme === WebShellThemeId.Light ? styles.themeLight : styles.themeDark;
return (
<Dialog
open
onOpenChange={(open) => {
if (!open && dismissible) onClose();
}}
>
<DialogShellIdContext.Provider value={shellIdRef.current}>
<DialogContent
ref={panelRef}
showCloseButton={false}
overlayProps={{
onMouseDown: handleBackdropMouseDown,
onMouseUp: handleBackdropMouseUp,
onClick: handleBackdropClick,
}}
className={`${themeClass} ${
theme === WebShellThemeId.Dark ? 'dark' : ''
} flex max-h-[min(80vh,calc(100vh-48px))] flex-col gap-0 overflow-hidden p-0 font-mono text-sm ${
fullscreen
? 'h-[calc(100vh-32px)] max-h-[calc(100vh-32px)] max-w-[calc(100vw-32px)] sm:max-w-[calc(100vw-32px)]'
: sizeClass[size]
}`}
aria-label={title}
data-keyboard-scope
data-web-shell-dialog
data-web-shell-dialog-title={title}
onPointerDownOutside={(event) => event.preventDefault()}
onEscapeKeyDown={(event) => {
if (event.defaultPrevented) return;
if (event.isComposing || event.keyCode === 229) {
return;
}
if (!isTopDialogShellId(shellIdRef.current)) {
return;
}
event.preventDefault();
if (dismissible) onCloseRef.current();
}}
onOpenAutoFocus={(event) => {
event.preventDefault();
const preferred = getFocusable(panelRef.current).find(
(element) => !element.hasAttribute('data-dialog-close'),
);
(preferred ?? panelRef.current)?.focus();
}}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader className="flex-row items-center gap-2 border-b px-4 py-2.5 text-left">
<div className="min-w-0 flex-1">
<DialogTitle>{title}</DialogTitle>
{subtitle && (
<DialogDescription className="mt-0.5 text-xs">
{subtitle}
</DialogDescription>
)}
</div>
{allowFullscreen && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setFullscreen((value) => !value)}
aria-label={t(
fullscreen ? 'common.exitFullscreen' : 'common.fullscreen',
)}
aria-pressed={fullscreen}
title={t(
fullscreen ? 'common.exitFullscreen' : 'common.fullscreen',
)}
>
{fullscreen ? <Minimize2Icon /> : <Maximize2Icon />}
</Button>
)}
{dismissible && (
<DialogClose asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t('common.close')}
title={t('common.close')}
data-dialog-close
>
<XIcon />
</Button>
</DialogClose>
)}
</DialogHeader>
<div
className="flex min-h-0 flex-1 flex-col overflow-y-auto p-4"
data-dialog-fullscreen={fullscreen ? '' : undefined}
>
{children}
</div>
</DialogContent>
</DialogShellIdContext.Provider>
</Dialog>
);
}