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 ( + + + Command palette + {/* shouldFilter=false: the server filters sessions and we filter actions + (see file header). vimBindings=false: keep Ctrl+K/J from doubling as + list-nav on Win/Linux, where Ctrl+K is also the opener. */} + + + + + {isFetching && debouncedQuery ? "Searching…" : "No results found"} + + {filteredActions.length > 0 && ( + + {filteredActions.map((a) => { + const Icon = a.icon; + return ( + runAction(a)}> + + {a.label} + + ); + })} + + )} + {sessions.length > 0 && ( + + {sessions.map((s) => ( + goToSession(s.id)}> + {s.label} + {s.agent} + + ))} + + )} + + + + + ); +} 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) => ({