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
127 changes: 90 additions & 37 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ import type { ChatComposerHandle } from "./chat/ChatComposer";

const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = [];
const BROWSE_STALE_TIME_MS = 30_000;
type CommandPaletteOpenIntent = ReturnType<typeof useCommandPaletteStore.getState>["openIntent"];
type BrowseEntry = FilesystemBrowseResult["entries"][number];

function getLocalFileManagerName(platform: string): string {
if (isMacPlatform(platform)) {
Expand Down Expand Up @@ -327,11 +329,31 @@ function errorMessage(error: unknown): string {
}

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

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

function CommandPaletteController() {
const open = useCommandPaletteStore((store) => store.open);
const setOpen = useCommandPaletteStore((store) => store.setOpen);
useCommandPaletteShortcut();

return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandPaletteDialog />
</CommandDialog>
);
}

function useCommandPaletteShortcut() {
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 Down Expand Up @@ -362,32 +384,82 @@ export function CommandPalette({ children }: { children: ReactNode }) {
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [keybindings, terminalOpen, toggleOpen]);

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

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

if (!open) {
return null;
}

return <OpenCommandPaletteDialog />;
}

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

if (!open) {
return null;
}
function usePrefetchBrowsePaths({
exactBrowseEntry,
filteredBrowseEntryCount,
highlightedBrowseEntry,
isBrowsing,
prefetchBrowsePath,
query,
}: {
readonly exactBrowseEntry: BrowseEntry | null;
readonly filteredBrowseEntryCount: number;
readonly highlightedBrowseEntry: BrowseEntry | null;
readonly isBrowsing: boolean;
readonly prefetchBrowsePath: (partialPath: string) => void;
readonly query: string;
}) {
// Prefetch the parent and the most likely next child so browse navigation
// stays warm without scanning every child directory in large trees.
useEffect(() => {
if (!isBrowsing || filteredBrowseEntryCount === 0) return;

return <OpenCommandPaletteDialog />;
if (canNavigateUp(query)) {
prefetchBrowsePath(getBrowseParentPath(query)!);
}

const nextChild = highlightedBrowseEntry ?? exactBrowseEntry;
if (nextChild) {
prefetchBrowsePath(appendBrowsePathSegment(query, nextChild.name));
}
}, [
exactBrowseEntry,
filteredBrowseEntryCount,
highlightedBrowseEntry,
isBrowsing,
prefetchBrowsePath,
query,
]);
}

function useOpenAddProjectIntent({
clearOpenIntent,
openAddProjectFlow,
openIntent,
}: {
readonly clearOpenIntent: () => void;
readonly openAddProjectFlow: () => void;
readonly openIntent: CommandPaletteOpenIntent;
}) {
useLayoutEffect(() => {
if (openIntent?.kind !== "add-project") {
return;
}
clearOpenIntent();
openAddProjectFlow();
}, [clearOpenIntent, openAddProjectFlow, openIntent]);
}

function OpenCommandPaletteDialog() {
Expand Down Expand Up @@ -587,27 +659,14 @@ function OpenCommandPaletteDialog() {
[browseEnvironmentId, currentProjectCwdForBrowse, fetchBrowseResult, queryClient],
);

// Prefetch the parent and the most likely next child so browse navigation
// stays warm without scanning every child directory in large trees.
useEffect(() => {
if (!isBrowsing || filteredBrowseEntries.length === 0) return;

if (canNavigateUp(query)) {
prefetchBrowsePath(getBrowseParentPath(query)!);
}

const nextChild = highlightedBrowseEntry ?? exactBrowseEntry;
if (nextChild) {
prefetchBrowsePath(appendBrowsePathSegment(query, nextChild.name));
}
}, [
usePrefetchBrowsePaths({
exactBrowseEntry,
filteredBrowseEntries.length,
filteredBrowseEntryCount: filteredBrowseEntries.length,
highlightedBrowseEntry,
isBrowsing,
prefetchBrowsePath,
query,
]);
});

const openProjectFromSearch = useMemo(
() => async (project: (typeof projects)[number]) => {
Expand Down Expand Up @@ -971,13 +1030,7 @@ function OpenCommandPaletteDialog() {
startAddProjectSourceSelection,
]);

useLayoutEffect(() => {
if (openIntent?.kind !== "add-project") {
return;
}
clearOpenIntent();
openAddProjectFlow();
}, [clearOpenIntent, openAddProjectFlow, openIntent]);
useOpenAddProjectIntent({ clearOpenIntent, openAddProjectFlow, openIntent });

const actionItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = [];

Expand Down
21 changes: 11 additions & 10 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,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 @@ -2619,20 +2618,22 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
},
[updateSettings],
);
const setCommandPaletteOpen = useCommandPaletteStore((store) => store.setOpen);
const handleCommandPaletteClick = useCallback(() => {
setCommandPaletteOpen(true);
}, [setCommandPaletteOpen]);

return (
<SidebarContent className="gap-0">
<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={handleCommandPaletteClick}
>
<SearchIcon className="size-3.5" />
<span className="flex-1 truncate text-left text-xs">Search</span>
Expand All @@ -2641,7 +2642,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
{commandPaletteShortcutLabel}
</Kbd>
) : null}
</CommandDialogTrigger>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
Expand Down
Binary file added react-scan-recordings/after-command-palette.webm
Binary file not shown.
Binary file added react-scan-recordings/after-frame.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added react-scan-recordings/before-command-palette.webm
Binary file not shown.
Binary file added react-scan-recordings/before-frame.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading