diff --git a/tests/e2e_ui/sessions/test_command_palette.py b/tests/e2e_ui/sessions/test_command_palette.py
new file mode 100644
index 0000000000..935fdf9154
--- /dev/null
+++ b/tests/e2e_ui/sessions/test_command_palette.py
@@ -0,0 +1,77 @@
+"""E2E: ⌘/Ctrl+K opens the command palette and jumps to a session.
+
+Covers the command palette added in ``ap-web/src/shell/CommandPalette.tsx`` and
+its global hotkey (``useCommandPaletteHotkey``, ⌘/Ctrl+K, bound in
+``AppShell``). The palette lists sessions from the same server-search source as
+the sidebar and navigates to the picked one.
+
+The flow: open the palette from a focused composer (proving the window-level
+hotkey fires regardless of focus, like the session-switch hotkey), then select
+the *other* seeded session from the palette's list and assert the route changes
+to it.
+
+No LLM turn is needed — this is pure client-side keyboard + routing — so it
+skips the nightly/real-agent markers the approval suites carry. Two runner-bound
+sessions come from the ``seeded_session_pair`` fixture; both are recent and
+non-archived, so both appear in the palette's default (empty-query) list.
+
+Server-side search-query *filtering* is left to the Vitest unit tests
+(``CommandPalette.test.tsx``): the server's search reindex is asynchronous (see
+``useConversations.ts``), which would make a "type then expect filtered" e2e
+assertion timing-dependent. Selecting from the listed sessions exercises the
+same open → select → navigate path deterministically.
+"""
+
+from __future__ import annotations
+
+import httpx
+from playwright.sync_api import Page, expect
+
+_COMPOSER = "Ask the agent anything…"
+
+
+def _set_title(base_url: str, session_id: str, title: str) -> None:
+ """Title a session via ``PATCH /v1/sessions/{id}`` so its row is legible."""
+ resp = httpx.patch(
+ f"{base_url}/v1/sessions/{session_id}",
+ json={"title": title},
+ timeout=10.0,
+ )
+ resp.raise_for_status()
+
+
+def test_command_palette_opens_and_switches_session(
+ page: Page,
+ seeded_session_pair: tuple[str, str, str],
+) -> None:
+ """⌘/Ctrl+K opens the palette; picking session B navigates to it."""
+ base_url, session_a, session_b = seeded_session_pair
+ _set_title(base_url, session_a, "e2e-palette-a")
+ _set_title(base_url, session_b, "e2e-palette-b")
+
+ page.goto(f"{base_url}/c/{session_a}")
+
+ # Both sessions must be loaded so the palette's session list holds them.
+ expect(page.locator(f'a[href="/c/{session_a}"]')).to_be_visible(timeout=30_000)
+ expect(page.locator(f'a[href="/c/{session_b}"]')).to_be_visible()
+
+ # Focus the composer first — the hotkey is window-level and must fire even
+ # from a focused text field (same contract as the session-switch hotkey).
+ composer = page.get_by_placeholder(_COMPOSER)
+ expect(composer).to_be_visible()
+ composer.click()
+
+ # Open the palette. CI runs Linux chromium → Control; the hook also accepts
+ # Cmd via metaKey on macOS.
+ page.keyboard.press("Control+k")
+
+ dialog = page.get_by_role("dialog")
+ expect(dialog).to_be_visible(timeout=10_000)
+ expect(page.get_by_test_id("command-palette-input")).to_be_focused()
+
+ # Pick the other session from inside the palette and assert we navigate to it.
+ dialog.get_by_text("e2e-palette-b").click()
+
+ expect(page).to_have_url(f"{base_url}/c/{session_b}", timeout=10_000)
+ # The palette closes on select.
+ expect(page.get_by_test_id("command-palette-input")).to_have_count(0)
diff --git a/web/src/components/KeyboardShortcutsDialog.test.tsx b/web/src/components/KeyboardShortcutsDialog.test.tsx
index 76f545fdc6..f541ff2a4d 100644
--- a/web/src/components/KeyboardShortcutsDialog.test.tsx
+++ b/web/src/components/KeyboardShortcutsDialog.test.tsx
@@ -32,6 +32,7 @@ describe("KeyboardShortcutsDialog", () => {
expect(screen.getByText("Keyboard shortcuts")).toBeTruthy();
// General / In chats / Navigation / View / Slash commands — one each.
+ expect(screen.getByText("Open command palette")).toBeTruthy();
expect(screen.getByText("Show keyboard shortcuts")).toBeTruthy();
expect(screen.getByText("Send message")).toBeTruthy();
expect(screen.getByText("Recall previous prompt")).toBeTruthy();
diff --git a/web/src/components/KeyboardShortcutsDialog.tsx b/web/src/components/KeyboardShortcutsDialog.tsx
index 7116d67d7d..fbe96788e4 100644
--- a/web/src/components/KeyboardShortcutsDialog.tsx
+++ b/web/src/components/KeyboardShortcutsDialog.tsx
@@ -65,7 +65,10 @@ interface ShortcutGroup {
const SHORTCUT_GROUPS: ShortcutGroup[] = [
{
title: "General",
- items: [{ label: "Show keyboard shortcuts", keys: [MOD_KEY, "/"] }],
+ items: [
+ { label: "Open command palette", keys: [MOD_KEY, "K"] },
+ { label: "Show keyboard shortcuts", keys: [MOD_KEY, "/"] },
+ ],
},
{
title: "In chats",
diff --git a/web/src/hooks/useCommandPaletteHotkey.test.tsx b/web/src/hooks/useCommandPaletteHotkey.test.tsx
new file mode 100644
index 0000000000..1c0bb13462
--- /dev/null
+++ b/web/src/hooks/useCommandPaletteHotkey.test.tsx
@@ -0,0 +1,108 @@
+import { cleanup, renderHook } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { isCommandPaletteHotkey, useCommandPaletteHotkey } from "./useCommandPaletteHotkey";
+
+afterEach(() => {
+ cleanup();
+ document.body.innerHTML = "";
+});
+
+function press(init: KeyboardEventInit): KeyboardEvent {
+ const e = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init });
+ window.dispatchEvent(e);
+ return e;
+}
+
+describe("isCommandPaletteHotkey", () => {
+ it("matches Cmd+K and Ctrl+K", () => {
+ expect(isCommandPaletteHotkey(new KeyboardEvent("keydown", { key: "k", metaKey: true }))).toBe(
+ true,
+ );
+ expect(isCommandPaletteHotkey(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }))).toBe(
+ true,
+ );
+ // Uppercase (some layouts report "K" with the modifier).
+ expect(isCommandPaletteHotkey(new KeyboardEvent("keydown", { key: "K", metaKey: true }))).toBe(
+ true,
+ );
+ });
+
+ it("rejects plain k, and k with Alt or Shift held", () => {
+ expect(isCommandPaletteHotkey(new KeyboardEvent("keydown", { key: "k" }))).toBe(false);
+ expect(
+ isCommandPaletteHotkey(
+ new KeyboardEvent("keydown", { key: "k", metaKey: true, altKey: true }),
+ ),
+ ).toBe(false);
+ expect(
+ isCommandPaletteHotkey(
+ new KeyboardEvent("keydown", { key: "k", ctrlKey: true, shiftKey: true }),
+ ),
+ ).toBe(false);
+ });
+
+ it("rejects other keys with the modifier", () => {
+ expect(isCommandPaletteHotkey(new KeyboardEvent("keydown", { key: "j", metaKey: true }))).toBe(
+ false,
+ );
+ });
+});
+
+describe("useCommandPaletteHotkey", () => {
+ it("toggles on Cmd+K and prevents the browser default", () => {
+ const onToggle = vi.fn();
+ renderHook(() => useCommandPaletteHotkey(onToggle));
+
+ const e = press({ key: "k", metaKey: true });
+
+ expect(onToggle).toHaveBeenCalledTimes(1);
+ expect(e.defaultPrevented).toBe(true);
+ });
+
+ it("ignores auto-repeat", () => {
+ const onToggle = vi.fn();
+ renderHook(() => useCommandPaletteHotkey(onToggle));
+
+ press({ key: "k", metaKey: true, repeat: true });
+
+ expect(onToggle).not.toHaveBeenCalled();
+ });
+
+ it("does nothing when disabled", () => {
+ const onToggle = vi.fn();
+ renderHook(() => useCommandPaletteHotkey(onToggle, false));
+
+ const e = press({ key: "k", metaKey: true });
+
+ expect(onToggle).not.toHaveBeenCalled();
+ expect(e.defaultPrevented).toBe(false);
+ });
+
+ it("bails when focus sits inside a terminal or code editor", () => {
+ const onToggle = vi.fn();
+ renderHook(() => useCommandPaletteHotkey(onToggle));
+
+ const term = document.createElement("div");
+ term.className = "xterm";
+ const input = document.createElement("input");
+ term.appendChild(input);
+ document.body.appendChild(term);
+ input.focus();
+ expect(document.activeElement).toBe(input);
+
+ press({ key: "k", metaKey: true });
+
+ expect(onToggle).not.toHaveBeenCalled();
+ });
+
+ it("unbinds on unmount", () => {
+ const onToggle = vi.fn();
+ const { unmount } = renderHook(() => useCommandPaletteHotkey(onToggle));
+ unmount();
+
+ press({ key: "k", metaKey: true });
+
+ expect(onToggle).not.toHaveBeenCalled();
+ });
+});
diff --git a/web/src/hooks/useCommandPaletteHotkey.ts b/web/src/hooks/useCommandPaletteHotkey.ts
new file mode 100644
index 0000000000..4a08415c4e
--- /dev/null
+++ b/web/src/hooks/useCommandPaletteHotkey.ts
@@ -0,0 +1,66 @@
+// ⌘K (Ctrl+K on Win/Linux) toggles the global command palette. Sibling to the
+// session-switch (⌘↑/↓) and sidebar-toggle (⌘⌥[ / ⌘⌥]) hotkeys; like them it's
+// bound ONCE at the app shell, where the palette's open-state lives.
+//
+// Why ⌘K: it's the de-facto command-palette key across developer tools, and
+// issue #1059 / PR #1064 deliberately reserved it for this (PR #1064 took ⌘⇧F
+// for sidebar search precisely to leave ⌘K free). The browser binds Ctrl+K to
+// the address bar, so we preventDefault to claim it.
+//
+// Two surfaces own ⌘K themselves and must keep it: xterm terminals (forward it
+// to the PTY) and the Monaco editor (⌘K is a chord prefix). When focus sits in
+// one of those, we bail and let the keystroke through.
+
+import { useEffect, useRef } from "react";
+
+/** Selector for surfaces that own ⌘K and must keep it (terminals, code editor). */
+const HOTKEY_OWNING_SURFACES = ".xterm, .monaco-editor";
+
+/** True when the event is the command-palette chord: Cmd/Ctrl+K, no Alt/Shift. */
+export function isCommandPaletteHotkey(e: globalThis.KeyboardEvent): boolean {
+ if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return false;
+ // AltGr reports as Ctrl+Alt on some layouts; the altKey check above already
+ // rejects it, but guard explicitly so intl typing never triggers the palette.
+ if (e.getModifierState("AltGraph")) return false;
+ // Match the letter, not a physical code — ⌘ doesn't remap "k" across layouts.
+ return e.key === "k" || e.key === "K";
+}
+
+/** Does focus sit inside a surface that owns ⌘K (xterm / Monaco)? */
+function focusOwnsHotkey(): boolean {
+ const el = document.activeElement;
+ return el instanceof Element && el.closest(HOTKEY_OWNING_SURFACES) !== null;
+}
+
+/**
+ * Bind ⌘/Ctrl+K to toggle the command palette. Bind ONCE.
+ *
+ * @param onToggle Flip the palette open/closed.
+ * @param enabled Pass `false` to disable the hotkey (e.g. embedded mode, where
+ * ⌘K belongs to the host page). Defaults to enabled.
+ */
+export function useCommandPaletteHotkey(onToggle: () => void, enabled: boolean = true): void {
+ // Held in a ref so the bound handler always calls the latest closure without
+ // re-registering on every render.
+ const latest = useRef(onToggle);
+ latest.current = onToggle;
+
+ useEffect(() => {
+ if (!enabled) return;
+ const handler = (e: globalThis.KeyboardEvent): void => {
+ // Ignore auto-repeat: holding the chord would flap the palette.
+ if (e.repeat) return;
+ if (!isCommandPaletteHotkey(e)) return;
+ // Leave ⌘K to terminals/editors that bind it themselves.
+ if (focusOwnsHotkey()) return;
+ // Claim the chord: preventDefault drops the browser default (Ctrl+K
+ // focuses the address bar). stopPropagation mirrors the sibling hotkey
+ // hooks; no other listener binds ⌘K, so it's belt-and-suspenders.
+ e.preventDefault();
+ e.stopPropagation();
+ latest.current();
+ };
+ window.addEventListener("keydown", handler);
+ return () => window.removeEventListener("keydown", handler);
+ }, [enabled]);
+}
diff --git a/web/src/shell/AppShell.tsx b/web/src/shell/AppShell.tsx
index 4dc310e160..37ef0be1e1 100644
--- a/web/src/shell/AppShell.tsx
+++ b/web/src/shell/AppShell.tsx
@@ -5,6 +5,8 @@ import { useConversations } from "@/hooks/useConversations";
import { useSessionAgent } from "@/hooks/useAgents";
import { useApproveHotkey } from "@/hooks/useApproveHotkey";
import { useSidebarToggleHotkeys } from "@/hooks/useSidebarToggleHotkeys";
+import { useCommandPaletteHotkey } from "@/hooks/useCommandPaletteHotkey";
+import { useIsEmbedded } from "@/lib/embedded";
import { AgentInfoContent, agentHasInfo } from "@/components/AgentInfo";
import { useIdleNotifications } from "@/hooks/useIdleNotifications";
import { useSeedReadState } from "@/hooks/useUnseenConversations";
@@ -70,6 +72,7 @@ import { TerminalsPanel } from "./TerminalsPanel";
import { TodoPanel } from "./TodoPanel";
import { PermissionsModal } from "@/components/PermissionsModal";
import { KeyboardShortcutsDialog } from "@/components/KeyboardShortcutsDialog";
+import { CommandPalette } from "./CommandPalette";
import { Toaster } from "@/components/ui/toast";
import { ForkSessionDialog } from "./ForkSessionDialog";
import { ForkDialogContextProvider, type ForkDialogContextValue } from "./ForkDialogContext";
@@ -777,6 +780,12 @@ export function AppShell() {
onToggleRight: toggleRightPanel,
});
+ // ⌘K (Ctrl+K) toggles the command palette. Disabled embedded, where ⌘K is the
+ // host page's. Bound here where the palette's open-state lives.
+ const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
+ const isEmbedded = useIsEmbedded();
+ useCommandPaletteHotkey(() => setCommandPaletteOpen((prev) => !prev), !isEmbedded);
+
// Mobile back button: close the open file and return to the files/changes
// list. On mobile the tab strip is hidden, so a "back" should fully drop the
// file (remove it from openFiles) rather than leaving an orphan tab the user
@@ -1334,6 +1343,16 @@ export function AppShell() {
{/* Keyboard-shortcuts reference. Self-contained (owns its open state +
⌘/Ctrl+/ opener); ungated so it works on every route. */}
+ {/* Global command palette (⌘K). Ungated so it works on every route;
+ the hotkey itself is disabled in embedded mode. */}
+ {!isEmbedded && (
+ setSidebarOpen((prev) => !prev)}
+ onToggleRightSidebar={toggleRightPanel}
+ />
+ )}
{/* Transient toasts (e.g. "session archived"). Mounted once here so
any surface can fire one via showToast(). */}
diff --git a/web/src/shell/CommandPalette.test.tsx b/web/src/shell/CommandPalette.test.tsx
new file mode 100644
index 0000000000..32e1b39576
--- /dev/null
+++ b/web/src/shell/CommandPalette.test.tsx
@@ -0,0 +1,163 @@
+import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { ComponentProps } from "react";
+
+import { CommandPalette } from "./CommandPalette";
+
+const navigate = vi.fn();
+vi.mock("@/lib/routing", () => ({
+ useNavigate: () => navigate,
+}));
+
+const useConversations = vi.fn();
+vi.mock("@/hooks/useConversations", () => ({
+ useConversations: (...args: unknown[]) => useConversations(...args),
+}));
+
+function conv(id: string, title: string | null, agent_name: string | null = null) {
+ return { id, title, agent_name, archived: false };
+}
+
+function setSessions(sessions: ReturnType[], isFetching = false) {
+ useConversations.mockReturnValue({ data: { pages: [{ data: sessions }] }, isFetching });
+}
+
+function renderPalette(overrides: Partial> = {}) {
+ const props = {
+ open: true,
+ onOpenChange: vi.fn(),
+ onToggleLeftSidebar: vi.fn(),
+ onToggleRightSidebar: vi.fn(),
+ ...overrides,
+ };
+ render();
+ return props;
+}
+
+beforeEach(() => {
+ navigate.mockClear();
+ useConversations.mockReset();
+ setSessions([]);
+});
+afterEach(cleanup);
+
+describe("CommandPalette — sessions", () => {
+ it("lists sessions by display label with their agent type", () => {
+ setSessions([conv("c1", "Fix the parser", "research-agent"), conv("c2", null)]);
+ renderPalette();
+
+ expect(screen.getByText("Fix the parser")).toBeTruthy();
+ expect(screen.getByText("research-agent")).toBeTruthy();
+ // Null title → conversationDisplayLabel's "New session" fallback.
+ expect(screen.getByText("New session")).toBeTruthy();
+ });
+
+ it("navigates to the session and closes when an item is selected", () => {
+ setSessions([conv("c1", "Fix the parser")]);
+ const onOpenChange = vi.fn();
+ renderPalette({ onOpenChange });
+
+ fireEvent.click(screen.getByText("Fix the parser"));
+
+ expect(navigate).toHaveBeenCalledWith("/c/c1");
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it("debounces the typed query into a server search (archived excluded)", () => {
+ vi.useFakeTimers();
+ try {
+ setSessions([conv("c1", "Fix the parser")]);
+ renderPalette();
+
+ // Empty query on mount → shares AppShell's `["conversations","",false]` entry.
+ expect(useConversations).toHaveBeenCalledWith("", false);
+
+ fireEvent.change(screen.getByTestId("command-palette-input"), {
+ target: { value: "deploy" },
+ });
+ // Before the debounce elapses the query has NOT yet reached the hook.
+ expect(useConversations).not.toHaveBeenCalledWith("deploy", false);
+
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+ // After the 300ms debounce, the typed query drives a server search with
+ // archived excluded — proving the palette searches the server, not a page.
+ expect(useConversations).toHaveBeenCalledWith("deploy", false);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("dedupes sessions that appear on overlapping pages", () => {
+ useConversations.mockReturnValue({
+ data: {
+ pages: [{ data: [conv("c1", "One")] }, { data: [conv("c1", "One"), conv("c2", "Two")] }],
+ },
+ isFetching: false,
+ });
+ renderPalette();
+
+ expect(screen.getAllByText("One")).toHaveLength(1);
+ expect(screen.getByText("Two")).toBeTruthy();
+ });
+});
+
+describe("CommandPalette — actions", () => {
+ it("lists the built-in action commands", () => {
+ renderPalette();
+
+ expect(screen.getByText("New chat")).toBeTruthy();
+ expect(screen.getByText("Go to Inbox")).toBeTruthy();
+ expect(screen.getByText("Go to Settings")).toBeTruthy();
+ expect(screen.getByText("Toggle conversations sidebar")).toBeTruthy();
+ expect(screen.getByText("Toggle workspace sidebar")).toBeTruthy();
+ });
+
+ it("runs a navigation action and closes the palette", () => {
+ const onOpenChange = vi.fn();
+ renderPalette({ onOpenChange });
+
+ fireEvent.click(screen.getByText("Go to Settings"));
+
+ expect(navigate).toHaveBeenCalledWith("/settings");
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it("invokes the sidebar-toggle callbacks", () => {
+ const onToggleLeftSidebar = vi.fn();
+ const onToggleRightSidebar = vi.fn();
+ renderPalette({ onToggleLeftSidebar, onToggleRightSidebar });
+
+ fireEvent.click(screen.getByText("Toggle conversations sidebar"));
+ expect(onToggleLeftSidebar).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(screen.getByText("Toggle workspace sidebar"));
+ expect(onToggleRightSidebar).toHaveBeenCalledTimes(1);
+ });
+
+ it("filters actions client-side against the query", () => {
+ renderPalette();
+
+ fireEvent.change(screen.getByTestId("command-palette-input"), {
+ target: { value: "settings" },
+ });
+
+ expect(screen.getByText("Go to Settings")).toBeTruthy();
+ expect(screen.queryByText("New chat")).toBeNull();
+ });
+});
+
+describe("CommandPalette — empty state", () => {
+ it("shows an empty state when nothing matches", () => {
+ setSessions([]);
+ renderPalette();
+
+ // A query that matches no action and no session.
+ fireEvent.change(screen.getByTestId("command-palette-input"), {
+ target: { value: "zzzznomatch" },
+ });
+
+ expect(screen.getByText("No results found")).toBeTruthy();
+ });
+});
diff --git a/web/src/shell/CommandPalette.tsx b/web/src/shell/CommandPalette.tsx
new file mode 100644
index 0000000000..c65f862d10
--- /dev/null
+++ b/web/src/shell/CommandPalette.tsx
@@ -0,0 +1,215 @@
+// Global command palette (⌘K). Two command groups:
+//
+// • Actions — static app commands (new chat, navigate, toggle panels).
+// Filtered client-side against the live query.
+// • Sessions — fuzzy session switching from the SAME server-search source the
+// sidebar uses (`useConversations(query)` → `GET /v1/sessions?search_query=`),
+// debounced. Not a static first page: a user with hundreds of sessions must
+// find any of them, which client-side filtering over one page cannot do.
+//
+// cmdk's own filtering is disabled (`shouldFilter={false}`): the server filters
+// sessions, and we filter the (tiny, static) action list ourselves so both
+// groups react to the same input.
+
+import { useEffect, useMemo, useState } from "react";
+import {
+ InboxIcon,
+ type LucideIcon,
+ PanelLeftIcon,
+ PanelRightIcon,
+ SettingsIcon,
+ SquarePenIcon,
+} from "lucide-react";
+import { useNavigate } from "@/lib/routing";
+import { useConversations } from "@/hooks/useConversations";
+import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import { conversationDisplayLabel, getConversationAgentType } from "./sidebarNav";
+
+export interface CommandPaletteProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** Flip the left (Conversations) sidebar — owned by AppShell. */
+ onToggleLeftSidebar: () => void;
+ /** Flip the right (Workspace) sidebar — owned by AppShell. */
+ onToggleRightSidebar: () => void;
+}
+
+interface ActionCommand {
+ id: string;
+ label: string;
+ /** Mirrors the icon on the equivalent button elsewhere in the UI. */
+ icon: LucideIcon;
+ /** Extra terms the client-side filter matches against (beyond the label). */
+ keywords: string[];
+ run: () => void;
+}
+
+/** Debounce matches the sidebar search (300ms) so keystrokes don't each fetch. */
+const SEARCH_DEBOUNCE_MS = 300;
+
+export function CommandPalette({
+ open,
+ onOpenChange,
+ onToggleLeftSidebar,
+ onToggleRightSidebar,
+}: CommandPaletteProps) {
+ const navigate = useNavigate();
+ const [query, setQuery] = useState("");
+ const [debouncedQuery, setDebouncedQuery] = useState("");
+
+ // Reset the query when the palette closes so it reopens clean.
+ useEffect(() => {
+ if (!open) {
+ setQuery("");
+ setDebouncedQuery("");
+ }
+ }, [open]);
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebouncedQuery(query), SEARCH_DEBOUNCE_MS);
+ return () => clearTimeout(timer);
+ }, [query]);
+
+ const close = (): void => onOpenChange(false);
+
+ const actions = useMemo(
+ () => [
+ {
+ id: "new-chat",
+ label: "New chat",
+ icon: SquarePenIcon,
+ keywords: ["compose", "start", "new session"],
+ run: () => navigate("/"),
+ },
+ {
+ id: "go-inbox",
+ label: "Go to Inbox",
+ icon: InboxIcon,
+ keywords: ["notifications", "comments", "needs response"],
+ run: () => navigate("/inbox"),
+ },
+ {
+ id: "go-settings",
+ label: "Go to Settings",
+ icon: SettingsIcon,
+ keywords: ["preferences", "configuration", "account"],
+ run: () => navigate("/settings"),
+ },
+ {
+ id: "toggle-left-sidebar",
+ label: "Toggle conversations sidebar",
+ icon: PanelLeftIcon,
+ keywords: ["panel", "left", "sessions list"],
+ run: onToggleLeftSidebar,
+ },
+ {
+ id: "toggle-right-sidebar",
+ label: "Toggle workspace sidebar",
+ icon: PanelRightIcon,
+ keywords: ["panel", "right", "files", "terminal"],
+ run: onToggleRightSidebar,
+ },
+ ],
+ [navigate, onToggleLeftSidebar, onToggleRightSidebar],
+ );
+
+ const filteredActions = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (q === "") return actions;
+ return actions.filter(
+ (a) =>
+ a.label.toLowerCase().includes(q) || a.keywords.some((k) => k.toLowerCase().includes(q)),
+ );
+ }, [actions, query]);
+
+ // Archived excluded (matches the sidebar default). With an empty query this
+ // shares AppShell's existing `useConversations()` cache entry, so an idle
+ // palette costs no extra fetch; a search keys its own entry.
+ const { data, isFetching } = useConversations(debouncedQuery, false);
+
+ const sessions = useMemo(() => {
+ const seen = new Set();
+ const out: { id: string; label: string; agent: string }[] = [];
+ for (const page of data?.pages ?? []) {
+ for (const c of page.data) {
+ if (seen.has(c.id)) continue;
+ seen.add(c.id);
+ out.push({
+ id: c.id,
+ label: conversationDisplayLabel(c),
+ agent: getConversationAgentType(c),
+ });
+ }
+ }
+ return out;
+ }, [data]);
+
+ const runAction = (action: ActionCommand): void => {
+ close();
+ action.run();
+ };
+
+ const goToSession = (id: string): void => {
+ close();
+ navigate(`/c/${id}`);
+ };
+
+ return (
+
+ );
+}
diff --git a/web/src/test-setup.ts b/web/src/test-setup.ts
index 7bf3eff0c9..c7c9b2cbc0 100644
--- a/web/src/test-setup.ts
+++ b/web/src/test-setup.ts
@@ -67,6 +67,17 @@ if (!("IntersectionObserver" in globalThis)) {
});
}
+// cmdk (the command-palette primitive) constructs a ResizeObserver on mount,
+// which jsdom doesn't implement. A no-op stub lets command-palette/selector
+// component tests render without throwing.
+if (typeof globalThis.ResizeObserver === "undefined") {
+ globalThis.ResizeObserver = class {
+ observe(): void {}
+ unobserve(): void {}
+ disconnect(): void {}
+ };
+}
+
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({