mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-03 21:34:40 +00:00
* 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>
262 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|