Skip to content
Draft
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
135 changes: 89 additions & 46 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
useCallback,
useDeferredValue,
useEffect,
useEffectEvent,
useLayoutEffect,
useMemo,
useRef,
Expand Down Expand Up @@ -327,11 +328,20 @@ function errorMessage(error: unknown): string {
}

export function CommandPalette({ children }: { children: ReactNode }) {
const open = useCommandPaletteStore((store) => store.open);
const setOpen = useCommandPaletteStore((store) => store.setOpen);
const composerHandleRef = useRef<ChatComposerHandle | null>(null);

return (
<ComposerHandleContext value={composerHandleRef}>
<CommandPaletteKeyboardShortcutController />
{children}
<CommandPaletteDialogRoot />
</ComposerHandleContext>
);
}

function CommandPaletteKeyboardShortcutController() {
const toggleOpen = useCommandPaletteStore((store) => store.toggleOpen);
const keybindings = useServerKeybindings();
const composerHandleRef = useRef<ChatComposerHandle | null>(null);
const routeTarget = useParams({
strict: false,
select: (params) => resolveThreadRouteTarget(params),
Expand All @@ -343,51 +353,89 @@ export function CommandPalette({ children }: { children: ReactNode }) {
: false,
);

useEffect(() => {
const onKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.defaultPrevented) return;
const command = resolveShortcutCommand(event, keybindings, {
context: {
terminalFocus: isTerminalFocused(),
terminalOpen,
},
});
if (command !== "commandPalette.toggle") {
return;
}
event.preventDefault();
event.stopPropagation();
toggleOpen();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [keybindings, terminalOpen, toggleOpen]);
useCommandPaletteKeyboardShortcut({
keybindings,
terminalOpen,
toggleOpen,
});

return (
<ComposerHandleContext value={composerHandleRef}>
<CommandDialog open={open} onOpenChange={setOpen}>
{children}
<CommandPaletteDialog />
</CommandDialog>
</ComposerHandleContext>
);
return null;
}

function CommandPaletteDialog() {
function useCommandPaletteKeyboardShortcut({
keybindings,
terminalOpen,
toggleOpen,
}: {
keybindings: ReturnType<typeof useServerKeybindings>;
terminalOpen: boolean;
toggleOpen: () => void;
}) {
const onKeyDown = useEffectEvent((event: globalThis.KeyboardEvent) => {
if (event.defaultPrevented) return;
const command = resolveShortcutCommand(event, keybindings, {
context: {
terminalFocus: isTerminalFocused(),
terminalOpen,
},
});
if (command !== "commandPalette.toggle") {
return;
}
event.preventDefault();
event.stopPropagation();
toggleOpen();
});

useEffect(() => {
const listener = (event: globalThis.KeyboardEvent) => onKeyDown(event);
window.addEventListener("keydown", listener);
return () => window.removeEventListener("keydown", listener);
}, [onKeyDown]);
}

function CommandPaletteDialogRoot() {
const open = useCommandPaletteStore((store) => store.open);
const setOpen = useCommandPaletteStore((store) => store.setOpen);
useCloseCommandPaletteOnUnmount(setOpen);

return (
<CommandDialog open={open} onOpenChange={setOpen}>
{open ? <OpenCommandPaletteDialog /> : null}
</CommandDialog>
);
}

function useCloseCommandPaletteOnUnmount(setOpen: (open: boolean) => void) {
useEffect(() => {
return () => {
setOpen(false);
};
}, [setOpen]);
}

if (!open) {
return null;
}
function usePrefetchBrowseParent({
filteredBrowseEntriesLength,
isBrowsing,
prefetchBrowsePath,
query,
}: {
filteredBrowseEntriesLength: number;
isBrowsing: boolean;
prefetchBrowsePath: (partialPath: string) => void;
query: string;
}) {
// Prefetch only the parent (for back-navigation). Prefetching the
// highlighted child on every arrow-key press triggers a macOS TCC prompt
// whenever the highlighted entry is a permission-gated home dir (Music,
// Documents, Downloads, Desktop, etc.), so we wait for explicit navigation.
useEffect(() => {
if (!isBrowsing || filteredBrowseEntriesLength === 0) return;

return <OpenCommandPaletteDialog />;
if (canNavigateUp(query)) {
prefetchBrowsePath(getBrowseParentPath(query)!);
}
}, [filteredBrowseEntriesLength, isBrowsing, prefetchBrowsePath, query]);
}

function OpenCommandPaletteDialog() {
Expand Down Expand Up @@ -583,17 +631,12 @@ function OpenCommandPaletteDialog() {
[browseEnvironmentId, currentProjectCwdForBrowse, fetchBrowseResult, queryClient],
);

// Prefetch only the parent (for back-navigation). Prefetching the
// highlighted child on every arrow-key press triggers a macOS TCC prompt
// whenever the highlighted entry is a permission-gated home dir (Music,
// Documents, Downloads, Desktop, etc.), so we wait for explicit navigation.
useEffect(() => {
if (!isBrowsing || filteredBrowseEntries.length === 0) return;

if (canNavigateUp(query)) {
prefetchBrowsePath(getBrowseParentPath(query)!);
}
}, [filteredBrowseEntries.length, isBrowsing, prefetchBrowsePath, query]);
usePrefetchBrowseParent({
filteredBrowseEntriesLength: filteredBrowseEntries.length,
isBrowsing,
prefetchBrowsePath,
query,
});

const openProjectFromSearch = useMemo(
() => async (project: (typeof projects)[number]) => {
Expand Down
24 changes: 14 additions & 10 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,6 @@ import {
import { sortThreads } from "../lib/threadSort";
import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { CommandDialogTrigger } from "./ui/command";
import { readEnvironmentApi } from "../environmentApi";
import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
import { useServerKeybindings } from "../rpc/serverState";
Expand Down Expand Up @@ -2551,6 +2550,7 @@ interface SidebarProjectsContentProps {
projectGroupingMode: SidebarProjectGroupingMode;
threadPreviewCount: SidebarThreadPreviewCount;
updateSettings: ReturnType<typeof useUpdateSettings>["updateSettings"];
openCommandPalette: () => void;
openAddProject: () => void;
isManualProjectSorting: boolean;
projectDnDSensors: ReturnType<typeof useSensors>;
Expand Down Expand Up @@ -2592,6 +2592,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
projectGroupingMode,
threadPreviewCount,
updateSettings,
openCommandPalette,
openAddProject,
isManualProjectSorting,
projectDnDSensors,
Expand Down Expand Up @@ -2649,14 +2650,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
<SidebarGroup className="px-2 pt-2 pb-1">
<SidebarMenu>
<SidebarMenuItem>
<CommandDialogTrigger
render={
<SidebarMenuButton
size="sm"
className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground focus-visible:ring-0"
data-testid="command-palette-trigger"
/>
}
<SidebarMenuButton
size="sm"
className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground focus-visible:ring-0"
data-testid="command-palette-trigger"
aria-haspopup="dialog"
onClick={openCommandPalette}
>
<SearchIcon className="size-3.5 text-muted-foreground/70" />
<span className="flex-1 truncate text-left text-xs">Search</span>
Expand All @@ -2665,7 +2664,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
{commandPaletteShortcutLabel}
</Kbd>
) : null}
</CommandDialogTrigger>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
Expand Down Expand Up @@ -2834,7 +2833,11 @@ export default function Sidebar() {
});
const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;
const keybindings = useServerKeybindings();
const setCommandPaletteOpen = useCommandPaletteStore((store) => store.setOpen);
const openAddProjectCommandPalette = useCommandPaletteStore((store) => store.openAddProject);
const openCommandPalette = useCallback(() => {
setCommandPaletteOpen(true);
}, [setCommandPaletteOpen]);
const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
ReadonlySet<string>
>(() => new Set());
Expand Down Expand Up @@ -3455,6 +3458,7 @@ export default function Sidebar() {
projectGroupingMode={sidebarProjectGroupingMode}
threadPreviewCount={sidebarThreadPreviewCount}
updateSettings={updateSettings}
openCommandPalette={openCommandPalette}
openAddProject={openAddProjectCommandPalette}
isManualProjectSorting={isManualProjectSorting}
projectDnDSensors={projectDnDSensors}
Expand Down
Loading