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: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"arcjet": "^1.3.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"convex": "^1.31.7",
"geist": "^1.7.0",
"highlight.js": "^11.11.1",
Expand Down
52 changes: 52 additions & 0 deletions apps/web/src/components/command-palette/command-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Command as Cmdk } from "cmdk";
import type { Command } from "../../lib/command-palette/types";
import { cn } from "../../lib/utils";
import { CommandKbd } from "./command-kbd";

interface CommandItemProps {
command: Command;
onRun: (command: Command) => void;
}

export function CommandItem({ command, onRun }: CommandItemProps) {
const Icon = command.icon;
return (
<Cmdk.Item
value={`${command.id} ${command.title} ${(command.keywords ?? []).join(" ")}`}
keywords={command.keywords}
onSelect={() => onRun(command)}
className={cn(
"group/cmd-item relative flex h-9 cursor-pointer select-none items-center gap-2.5 rounded-sm px-2 text-sm",
"text-foreground/85 outline-none transition-colors",
"aria-selected:bg-muted aria-selected:text-foreground",
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-40",
)}
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-muted-foreground group-aria-selected/cmd-item:text-foreground">
{command.colorDot ? (
<span
className="inline-block h-2 w-2 rounded-full ring-1 ring-border/60"
style={{ backgroundColor: command.colorDot }}
aria-hidden="true"
/>
) : Icon ? (
<Icon className="h-4 w-4" />
) : null}
</span>
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="truncate">{command.title}</span>
{command.subtitle && (
<span className="truncate text-xs text-muted-foreground">
{command.subtitle}
</span>
)}
</span>
{command.shortcut && (
<CommandKbd
shortcut={command.shortcut}
className="shrink-0 opacity-0 transition-opacity group-aria-selected/cmd-item:opacity-100"
/>
)}
</Cmdk.Item>
);
}
36 changes: 36 additions & 0 deletions apps/web/src/components/command-palette/command-kbd.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { cn } from "../../lib/utils";

/** Split a shortcut label like "⌘⌥1" or "Ctrl+Alt+1" into individual key tokens. */
function splitKeys(shortcut: string): string[] {
if (shortcut.includes("+")) return shortcut.split("+").filter(Boolean);
return Array.from(shortcut);
}

export function CommandKbd({
shortcut,
className,
}: {
shortcut: string;
className?: string;
}) {
const keys = splitKeys(shortcut);
return (
<kbd
className={cn(
"pointer-events-none inline-flex items-center gap-1 font-mono text-[10px] text-muted-foreground",
className,
)}
aria-label={shortcut}
>
{keys.map((key, i) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: shortcut keys form a fixed ordered set
key={i}
className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-sm border border-border/70 bg-muted/60 px-1 leading-none text-foreground/80"
>
{key}
</span>
))}
</kbd>
);
}
219 changes: 219 additions & 0 deletions apps/web/src/components/command-palette/command-palette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { Command as Cmdk } from "cmdk";
import { Search } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCommandPaletteHotkey } from "../../hooks/use-command-palette-hotkey";
import { useCommandPalette } from "../../lib/command-palette/context";
import {
getRecentCommandIds,
pushRecentCommand,
} from "../../lib/command-palette/recent";
import {
COMMAND_GROUP_LABELS,
COMMAND_GROUP_ORDER,
type Command,
type CommandGroupId,
} from "../../lib/command-palette/types";
import { cn } from "../../lib/utils";
import { CommandItem } from "./command-item";

export function CommandPalette() {
useCommandPaletteHotkey();

const { open, setOpen, snapshot } = useCommandPalette();
const [search, setSearch] = useState("");
const [recentIds, setRecentIds] = useState<string[]>([]);
const [snapshotCommands, setSnapshotCommands] = useState<Command[]>([]);
const wasOpenRef = useRef(false);

useEffect(() => {
if (!open) {
wasOpenRef.current = false;
return;
}
const justOpened = !wasOpenRef.current;
wasOpenRef.current = true;
if (justOpened) {
setSearch("");
setRecentIds(getRecentCommandIds());
}
setSnapshotCommands(snapshot().filter((c) => !c.when || c.when()));
}, [open, snapshot]);

const { groups, recentCommands } = useMemo(() => {
const byGroup = new Map<CommandGroupId, Command[]>();
for (const cmd of snapshotCommands) {
const list = byGroup.get(cmd.group) ?? [];
list.push(cmd);
byGroup.set(cmd.group, list);
}

const byId = new Map(snapshotCommands.map((c) => [c.id, c]));
const recent: Command[] = [];
if (search.trim().length === 0) {
for (const id of recentIds) {
const cmd = byId.get(id);
if (cmd) recent.push(cmd);
if (recent.length >= 5) break;
}
}

const ordered: Array<{ id: CommandGroupId; commands: Command[] }> = [];
for (const groupId of COMMAND_GROUP_ORDER) {
if (groupId === "recent") continue;
const commands = byGroup.get(groupId);
if (commands && commands.length > 0) {
ordered.push({ id: groupId, commands });
}
}
return { groups: ordered, recentCommands: recent };
}, [snapshotCommands, recentIds, search]);

const runCommand = (command: Command) => {
setOpen(false);
pushRecentCommand(command.id);
// Defer to let the dialog close animation start before the handler fires
// (e.g., navigation). Keeps the UI feeling snappy and avoids focus fights.
queueMicrotask(() => {
try {
command.perform();
} catch (err) {
console.error("[command-palette] command failed", command.id, err);
}
});
};

return (
<Cmdk.Dialog
open={open}
onOpenChange={setOpen}
label="Command Palette"
loop
overlayClassName={cn(
"fixed inset-0 z-[60] bg-black/40 backdrop-blur-[2px]",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
)}
contentClassName={cn(
"fixed left-1/2 top-[18vh] z-[61] w-[min(640px,calc(100vw-2rem))] -translate-x-1/2",
"overflow-hidden rounded-lg border border-border bg-background shadow-2xl shadow-black/30",
"outline-none",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
"data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
"data-[state=open]:slide-in-from-top-2 data-[state=closed]:slide-out-to-top-2",
"duration-150",
)}
>
<div className="flex items-center gap-2 border-b border-border/80 px-3">
<Search
className="h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<Cmdk.Input
value={search}
onValueChange={setSearch}
autoFocus
placeholder="Type a command or search…"
className={cn(
"flex h-12 w-full bg-transparent text-sm text-foreground outline-none",
"placeholder:text-muted-foreground/70 disabled:cursor-not-allowed disabled:opacity-50",
)}
/>
<kbd className="ml-auto hidden shrink-0 items-center rounded-sm border border-border/70 bg-muted/60 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground sm:inline-flex">
esc
</kbd>
</div>

<Cmdk.List className="max-h-[min(420px,60vh)] overflow-y-auto overscroll-contain p-1.5">
<Cmdk.Empty className="flex h-24 flex-col items-center justify-center gap-1 text-center">
<span className="text-sm text-muted-foreground">
No results found
</span>
<span className="text-xs text-muted-foreground/70">
Try a different keyword
</span>
</Cmdk.Empty>

{recentCommands.length > 0 && (
<Cmdk.Group
heading={<GroupHeading>Recently used</GroupHeading>}
className="mb-1"
>
{recentCommands.map((command) => (
<CommandItem
key={`recent-${command.id}`}
command={command}
onRun={runCommand}
/>
))}
</Cmdk.Group>
)}

{groups.map(({ id, commands }) => (
<Cmdk.Group
key={id}
heading={<GroupHeading>{COMMAND_GROUP_LABELS[id]}</GroupHeading>}
className="mb-1"
>
{commands.map((command) => (
<CommandItem
key={command.id}
command={command}
onRun={runCommand}
/>
))}
</Cmdk.Group>
))}
</Cmdk.List>

<div className="flex items-center justify-between border-t border-border/80 px-3 py-1.5 text-[11px] text-muted-foreground">
<div className="flex items-center gap-3">
<FooterHint label="navigate">
<FooterKey>↑</FooterKey>
<FooterKey>↓</FooterKey>
</FooterHint>
<FooterHint label="select">
<FooterKey>↵</FooterKey>
</FooterHint>
<FooterHint label="close">
<FooterKey>esc</FooterKey>
</FooterHint>
</div>
<span className="hidden font-mono text-[10px] opacity-70 sm:inline">
Harness
</span>
</div>
</Cmdk.Dialog>
);
}

function GroupHeading({ children }: { children: React.ReactNode }) {
return (
<div className="px-2 pt-2 pb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">
{children}
</div>
);
}

function FooterHint({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<span className="inline-flex items-center gap-1">
<span className="inline-flex items-center gap-0.5">{children}</span>
<span>{label}</span>
</span>
);
}

function FooterKey({ children }: { children: React.ReactNode }) {
return (
<kbd className="inline-flex h-4 min-w-[16px] items-center justify-center rounded-[3px] border border-border/70 bg-muted/60 px-1 font-mono text-[10px] leading-none text-foreground/80">
{children}
</kbd>
);
}
75 changes: 75 additions & 0 deletions apps/web/src/components/command-palette/commands/chat-commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {
MessageSquarePlus,
PanelLeftClose,
PanelLeftOpen,
Square,
} from "lucide-react";
import { useMemo } from "react";
import { useRegisterCommands } from "../../../hooks/use-register-commands";
import type { Command } from "../../../lib/command-palette/types";

interface ChatPaletteCommandsInput {
isStreaming: boolean;
canStartNewConversation: boolean;
sidebarOpen: boolean;
onNewConversation: () => void;
onCancelStream: () => void;
onToggleSidebar: () => void;
}

/**
* Registers the chat-scoped commands: new conversation, cancel streaming,
* and sidebar toggle. Call from whichever route hosts the chat surface
* (both `/chat` and `/workspaces` use this — they're mutually exclusive
* at runtime, so the command IDs don't collide).
*/
export function useChatPaletteCommands({
isStreaming,
canStartNewConversation,
sidebarOpen,
onNewConversation,
onCancelStream,
onToggleSidebar,
}: ChatPaletteCommandsInput): void {
const commands = useMemo<Command[]>(() => {
const list: Command[] = [
{
id: "chat:new-conversation",
title: "New conversation",
group: "chat",
icon: MessageSquarePlus,
keywords: ["new", "chat", "convo", "message"],
when: () => canStartNewConversation,
perform: onNewConversation,
},
{
id: "chat:toggle-sidebar",
title: sidebarOpen ? "Hide sidebar" : "Show sidebar",
group: "chat",
icon: sidebarOpen ? PanelLeftClose : PanelLeftOpen,
keywords: ["sidebar", "panel", "toggle"],
perform: onToggleSidebar,
},
];
if (isStreaming) {
list.push({
id: "chat:cancel-stream",
title: "Cancel streaming response",
group: "chat",
icon: Square,
keywords: ["stop", "interrupt", "abort"],
perform: onCancelStream,
});
}
return list;
}, [
isStreaming,
canStartNewConversation,
sidebarOpen,
onNewConversation,
onCancelStream,
onToggleSidebar,
]);

useRegisterCommands(commands);
}
Loading