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
1 change: 0 additions & 1 deletion packages/client/workbench/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
"foxact": "^0.3.10",
"foxts": "^5.9.1",
"lucide-react": "catalog:",
"motion": "^12.42.2",
"pathe": "^2.0.3",
"posthog-js": "^1.406.2",
"react-hook-form": "^7.82.0",
Expand Down
13 changes: 2 additions & 11 deletions packages/client/workbench/src/palette/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
repositoryLabel,
useKeyboardShortcutLabels,
} from '@linkcode/ui';
import { AnimatePresence } from 'motion/react';
import { useState } from 'react';
import { useTranslations } from 'use-intl';
import { recentThreadJumpActionId } from '../surface/use-workbench-keyboard-shortcuts';
Expand All @@ -21,20 +20,12 @@ export interface WorkbenchCommandPaletteProps {
sessions: WorkbenchSessions;
}

/**
* The palette container: mounted permanently, but everything below exists only while open — the
* closed palette costs nothing and the query resets on close for free. `AnimatePresence` defers
* the unmount until the dialog's exit transition finishes.
*/
/** The palette subtree exists only while open, so closing also resets the query. */
export function WorkbenchCommandPalette({
sessions,
}: WorkbenchCommandPaletteProps): React.ReactNode {
const open = useCommandPaletteStore((state) => state.open);
return (
<AnimatePresence>
{open && <OpenCommandPalette key="palette" sessions={sessions} />}
</AnimatePresence>
);
return open ? <OpenCommandPalette sessions={sessions} /> : null;
}

function OpenCommandPalette({ sessions }: WorkbenchCommandPaletteProps): React.ReactNode {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// @vitest-environment jsdom

import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { setKeyboardShortcutPlatform, useKeyboardShortcutListener } from '../../keyboard';
import { CommandPalette } from '../command-palette';

const ANIMATION_CLASS_PATTERN = /animate|duration|transition/;

function translate(key: string): string {
return key;
}

vi.mock('use-intl', () => ({
useTranslations() {
return translate;
},
}));

function PaletteHarness({ onOpenChange }: { onOpenChange: (open: boolean) => void }) {
useKeyboardShortcutListener();
return (
<CommandPalette
onOpenChange={onOpenChange}
query=""
onQueryChange={vi.fn()}
threads={[]}
commands={[]}
onSelectThread={vi.fn()}
onRunCommand={vi.fn()}
/>
);
}

afterEach(cleanup);

describe('CommandPalette', () => {
it('renders immediately with a dark backdrop and no animation classes', () => {
const { container } = render(<PaletteHarness onOpenChange={vi.fn()} />);
const backdrop = container.ownerDocument.querySelector('[data-slot="command-dialog-backdrop"]');
const popup = container.ownerDocument.querySelector('[data-slot="command-dialog-popup"]');

expect(backdrop?.classList.contains('bg-black/32')).toBe(true);
expect(backdrop?.className).not.toMatch(ANIMATION_CLASS_PATTERN);
expect(popup?.className).not.toMatch(ANIMATION_CLASS_PATTERN);
Comment thread
lucas77778 marked this conversation as resolved.
});

it('closes on Escape', () => {
const onOpenChange = vi.fn();
render(<PaletteHarness onOpenChange={onOpenChange} />);

fireEvent.keyDown(document, { code: 'Escape', key: 'Escape' });

expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything());
});

it('closes when Command+K is pressed again', () => {
setKeyboardShortcutPlatform('mac');
const onOpenChange = vi.fn();
render(<PaletteHarness onOpenChange={onOpenChange} />);

fireEvent.keyDown(document, { code: 'KeyK', key: 'k', metaKey: true });

expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
71 changes: 19 additions & 52 deletions packages/presentation/ui/src/shell/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ import {
} from 'coss-ui/components/command';
import { Kbd, KbdGroup } from 'coss-ui/components/kbd';
import { ArrowDownIcon, ArrowUpIcon, CornerDownLeftIcon } from 'lucide-react';
import type { Transition } from 'motion/react';
import { motion, useReducedMotion } from 'motion/react';
import { Fragment, useRef, useState } from 'react';
import { Fragment, useRef } from 'react';
import { useTranslations } from 'use-intl';
import { AgentIcon } from '../chat/agent-icon';
import { useKeyboardShortcut } from '../keyboard';
Expand All @@ -48,7 +46,7 @@ export interface PaletteCommandViewModel {
}

export interface CommandPaletteProps {
/** Fires with `false` on Escape/backdrop dismissal; closing happens by unmounting (the caller's `AnimatePresence` plays the exit). */
/** Fires with `false` on Escape, Command+K, or backdrop dismissal. */
onOpenChange: (open: boolean) => void;
/** Controlled query — filtering/ranking happens upstream, never inside the dialog. */
query: string;
Expand Down Expand Up @@ -111,25 +109,11 @@ function RecentThreadJumpBinding({
return null;
}

/** Dialog chrome forked from coss-ui's `CommandDialogBackdrop`/`CommandDialogPopup`: motion owns
* enter/exit, so the `data-starting/ending-style` classes are dropped; `backdrop-blur-sm` is
* dropped because backdrop-filter can't blur the native vibrancy behind the translucent sidebar. */
/** Dialog chrome without coss-ui's transitions; backdrop blur cannot blur native vibrancy. */
const BACKDROP_CLASS = 'fixed inset-0 z-50 bg-black/32';
const POPUP_CLASS =
'relative flex max-h-105 min-h-0 w-full min-w-0 max-w-xl flex-col rounded-2xl border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 outline-none before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:bg-muted/72 before:shadow-[0_1px_--theme(--color-black/4%)] **:data-[slot=scroll-area-viewport]:data-has-overflow-y:pe-1 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]';

/** Intrinsic height of an element, observed so the list wrapper can animate to it. */
function useMeasuredHeight(): [React.RefCallback<HTMLElement>, number | null] {
const [height, setHeight] = useState<number | null>(null);
const measureRef = (element: HTMLElement | null): (() => void) | undefined => {
if (!element) return undefined;
const observer = new ResizeObserver(() => setHeight(element.offsetHeight));
observer.observe(element);
return () => observer.disconnect();
};
return [measureRef, height];
}

/** The ⌘K palette dialog. Items are pre-ranked by the caller (`mode="none"`); Base UI owns
* keyboard navigation and Enter-activation, so rows only need `onClick`. */
export function CommandPalette({
Expand All @@ -142,12 +126,20 @@ export function CommandPalette({
onRunCommand,
}: CommandPaletteProps): React.ReactNode {
const t = useTranslations('workbench.palette');
const reducedMotion = useReducedMotion();
const [listRef, listHeight] = useMeasuredHeight();
// Owner for the ⌘1–⌘9 jumps: while the palette is open the workbench root carrying the global
// bindings is `data-base-ui-inert`, so the popup must own these bindings itself.
// The workbench shortcut owner is inert while the dialog is open, so popup-local bindings own
// Command+K dismissal and the ⌘1–⌘9 jumps.
const popupRef = useRef<HTMLDivElement>(null);

useKeyboardShortcut({
actionId: 'workbench.command-palette',
Comment thread
lucas77778 marked this conversation as resolved.
shortcut: { code: 'KeyK', modifiers: ['primary'] },
owner: popupRef,
handler() {
onOpenChange(false);
return true;
},
});

const groups: PaletteGroup[] = [];
if (threads.length > 0) {
groups.push({
Expand All @@ -164,38 +156,18 @@ export function CommandPalette({
});
}

const dialogTransition: Transition = reducedMotion
? { duration: 0 }
: { duration: 0.2, ease: 'easeInOut' };

return (
<CommandDialog open onOpenChange={onOpenChange}>
<CommandDialogPortal>
<CommandDialogPrimitive.Backdrop
className={BACKDROP_CLASS}
data-slot="command-dialog-backdrop"
render={
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={dialogTransition}
/>
}
/>
<CommandDialogViewport>
<CommandDialogPrimitive.Popup
ref={popupRef}
className={POPUP_CLASS}
data-slot="command-dialog-popup"
render={
<motion.div
ref={popupRef}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={dialogTransition}
/>
}
>
{/* ⌘1–⌘9 select the nth Recent row while the palette is open. Only on the empty-query
Recent view — a filtered ranking no longer lines up with the digit hints. */}
Expand All @@ -219,13 +191,8 @@ export function CommandPalette({
<CommandInput placeholder={t('placeholder')} />
<CommandPanel className="flex flex-col">
<CommandEmpty>{t('empty')}</CommandEmpty>
<motion.div
className="min-h-0"
initial={false}
animate={listHeight === null ? undefined : { height: listHeight }}
transition={reducedMotion ? { duration: 0 } : { duration: 0.15, ease: 'easeOut' }}
>
<CommandList ref={listRef}>
<div className="min-h-0">
<CommandList>
{(group: PaletteGroup) => (
<Fragment key={group.value}>
<CommandGroup items={group.items}>
Expand All @@ -252,7 +219,7 @@ export function CommandPalette({
</Fragment>
)}
</CommandList>
</motion.div>
</div>
</CommandPanel>
<CommandFooter>
<div className="flex items-center gap-4">
Expand Down
3 changes: 0 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading