Skip to content

Commit aa0e3c7

Browse files
authored
Command palette (Cmd+K) (#73)
* EditAgentDialog: self-load messages for title generation Remove messages prop from EditAgentDialog and AgentHeader usage. Dialog now fetches its own messages via createResource, keyed by agentId, so Generate Title works without callers needing to pass message arrays. Generate button is disabled while messages are loading. Add tinykeys dependency (global keyboard listener utility, zero deps). Add tests covering message loading, generate state, and save flow. * Add command palette (Cmd+K) with global shortcut and agent context New files: - CommandPalette.tsx: Corvu Dialog-based palette with Navigation and Agent sections, substring filtering, arrow key navigation, Enter to run. Lazy-fetches agent metadata on open. Handles Edit Title, Edit Notes, Archive, Export for the topmost agent (modal stack aware). - command-palette-state.ts: Module-level signal for palette open/close, importable without pulling in component code. - CommandPalette.test.tsx: Tests for filterActions (empty, whitespace, substring, case-insensitive, order). Modified files: - preferences.ts: Add commandPaletteShortcut preference (default $mod+k) with localStorage persistence. - WorkspaceLayout.tsx: Register tinykeys global Cmd+K listener via createEffect; re-registers on shortcut preference change. - LiveApp.tsx: Mount <CommandPalette /> alongside SkillLibraryDialog and AgentSearchDialog. - Header.tsx: Add KeyBindingInput component and Command Palette Shortcut section in Settings popover. KeyBindingInput captures key combos from onKeyDown events and stores them as tinykeys binding strings. - EditAgentDialog.test.tsx: Format fix for Biome. * CommandPalette: add Unarchive action for archived agents Show 'Unarchive Agent' when the active agent is archived (archivedAt is set) and 'Archive Agent' when it is not. Wire UnarchiveAgentDialog as a sub-dialog rendered outside the palette shell, matching the Archive pattern. * AgentSearchDialog: add keyboard navigation for results list Arrow up/down moves through grouped results (wraps); Enter opens active result in an agent modal; Cmd/Ctrl+Enter navigates directly to the agent page and closes the search dialog. Active result highlighted via aria-current and bg-accent/5 background on the card. Active index resets to -1 when results change (new search query), so the highlight is always fresh. Index starts at -1 so mouse-only usage is unaffected. Handler placed on Dialog.Content (not the input) to avoid double-firing via event bubbling through the dialog wrapper. Add 8 tests covering ArrowDown/Up, Enter, Cmd+Enter, Ctrl+Enter, wrap, no-selection guard, and reset-on-new-search. * Header: logo tap opens command palette Replace the two separate logo divs (desktop icon+text, mobile icon-only) with a single <button> that calls setIsCommandPaletteOpen(true) on click. The button wraps the existing BirdhouseIcon and the conditionally-visible 'Birdhouse' text (hidden sm:block), preserving the exact same visual layout. A single gradientId is used since both breakpoints now share the same element. Affordance: hover:opacity-70 / active:opacity-50 gives subtle feedback without adding visual noise. The logo was not a link previously, so no navigation behaviour is removed. * CommandPalette: drop 'Open' prefix from navigation action labels * CommandPalette: reorder Navigation actions (Agent Search, Skills, New Agent, Workspace Settings) * KeyBindingInput: extend denylist and fix Alt+key on Mac - Add Escape, Tab, Enter, Backspace to the eventToBinding denylist so they can't be accidentally captured as command palette shortcuts - Use e.code (physical key) instead of e.key when Alt is held on Mac, where e.key gives the composed character (e.g. Alt+k → '°' instead of 'k'); strip the 'Key' prefix from e.code to produce the correct binding segment * CommandPalette: show archive action disabled while agent metadata loads Adds disabled?: boolean to PaletteAction. While agentData() is undefined (fetch in-flight), Archive Agent is shown greyed out and unclickable instead of showing the wrong action or hiding entirely. Once the fetch resolves, the correct Archive/Unarchive action becomes active. Keyboard Enter and pointer handlers are both guarded against disabled items. * Revert "Header: logo tap opens command palette" This reverts commit 1a00853. * Header: add Command Palette icon button between Skills and Settings Icon-only button using the Command icon from lucide-solid, styled identically to the Skills button (p-2, hover:bg-surface-overlay, text-text-secondary). Title attribute shows two lines: 'Command Palette' and the formatted shortcut (e.g. '⌘K'), using the existing shortcutToDisplay helper so it reflects any user-configured binding.
1 parent 5e33f2f commit aa0e3c7

14 files changed

Lines changed: 1063 additions & 20 deletions

projects/birdhouse/frontend/bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

projects/birdhouse/frontend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"shiki": "^3.20.0",
6464
"solid-floating-ui": "^0.3.1",
6565
"solid-js": "^1.9.9",
66-
"solid-transition-group": "^0.3.0"
66+
"solid-transition-group": "^0.3.0",
67+
"tinykeys": "^3.0.0"
6768
}
6869
}

projects/birdhouse/frontend/src/LiveApp.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import AgentListHeader from "./components/AgentListHeader";
1111
import AgentModal from "./components/AgentModal";
1212
import AgentSearchDialog from "./components/AgentSearchDialog";
1313
import AgentTreeItem from "./components/AgentTreeItem";
14+
import CommandPalette from "./components/CommandPalette";
1415
import ConnectionStatusBanner from "./components/ConnectionStatusBanner";
1516
import LiveMessages from "./components/LiveMessages";
1617
import MobileNavDrawer from "./components/MobileNavDrawer";
@@ -604,6 +605,9 @@ const LiveApp: Component<LiveAppProps> = (props) => {
604605

605606
{/* Agent Search Dialog */}
606607
<AgentSearchDialog />
608+
609+
{/* Command Palette */}
610+
<CommandPalette />
607611
</div>
608612
);
609613
};

projects/birdhouse/frontend/src/components/AgentHeader.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -482,7 +482,6 @@ export const AgentHeader: Component<AgentHeaderProps> = (props) => {
482482
<EditAgentDialog
483483
agentId={props.agentId}
484484
currentTitle={currentTitle()}
485-
messages={props.messages}
486485
open={isEditDialogOpen()}
487486
onOpenChange={setIsEditDialogOpen}
488487
onSuccess={handleEditSuccess}

projects/birdhouse/frontend/src/components/AgentSearchDialog.test.tsx

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// ABOUTME: Tests for AgentSearchDialog component
2-
// ABOUTME: Verifies open/close, idle state, debounce API call, and result rendering
2+
// ABOUTME: Verifies open/close, idle state, debounce API call, result rendering, and keyboard nav
33

44
import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library";
55
import type { JSX } from "solid-js";
@@ -20,6 +20,7 @@ vi.mock("../services/agents-api", () => ({
2020
let mockIsOpen = true;
2121
const mockCloseModal = vi.fn();
2222
const mockOpenModal = vi.fn();
23+
const mockNavigate = vi.fn();
2324

2425
vi.mock("../lib/routing", () => ({
2526
useModalRoute: () => ({
@@ -28,6 +29,11 @@ vi.mock("../lib/routing", () => ({
2829
removeModalByType: vi.fn(),
2930
openModal: mockOpenModal,
3031
}),
32+
useWorkspaceId: () => () => "test-workspace",
33+
}));
34+
35+
vi.mock("@solidjs/router", () => ({
36+
useNavigate: () => mockNavigate,
3137
}));
3238

3339
vi.mock("corvu/dialog", () => {
@@ -36,8 +42,11 @@ vi.mock("corvu/dialog", () => {
3642
);
3743
Dialog.Portal = (props: { children: JSX.Element }) => <>{props.children}</>;
3844
Dialog.Overlay = () => null;
39-
Dialog.Content = (props: { children: JSX.Element; class?: string }) => (
40-
<div class={props.class}>{props.children}</div>
45+
Dialog.Content = (props: { children: JSX.Element; class?: string; onKeyDown?: (e: KeyboardEvent) => void }) => (
46+
// biome-ignore lint/a11y/noStaticElementInteractions: test mock — not a real interactive element
47+
<div role="presentation" class={props.class} onKeyDown={props.onKeyDown}>
48+
{props.children}
49+
</div>
4150
);
4251
Dialog.Close = (props: { children: JSX.Element; class?: string; onClick?: () => void }) => (
4352
<button type="button" class={props.class} onClick={props.onClick}>
@@ -118,4 +127,110 @@ describe("AgentSearchDialog", () => {
118127
{ timeout: 1000 },
119128
);
120129
});
130+
131+
// ---------------------------------------------------------------------------
132+
// Keyboard navigation tests
133+
// ---------------------------------------------------------------------------
134+
135+
describe("keyboard navigation", () => {
136+
const twoResults = [
137+
makeResult({ agentId: "agent-1", title: "Alpha Agent" }),
138+
makeResult({ agentId: "agent-2", sessionId: "ses-2", title: "Beta Agent" }),
139+
];
140+
141+
async function renderWithResults() {
142+
mockSearchAgentMessages.mockResolvedValue(makeResponse(twoResults));
143+
renderDialog();
144+
const input = screen.getByLabelText("Search agent messages") as HTMLInputElement;
145+
fireEvent.input(input, { target: { value: "agent" } });
146+
// Wait for debounce + results
147+
await waitFor(() => expect(screen.getByText("Alpha Agent")).toBeInTheDocument(), { timeout: 1000 });
148+
}
149+
150+
it("ArrowDown moves active index from -1 to 0, highlighting the first result", async () => {
151+
await renderWithResults();
152+
const input = screen.getByLabelText("Search agent messages");
153+
fireEvent.keyDown(input, { key: "ArrowDown" });
154+
// First result link should become active (aria-current or highlighted)
155+
await waitFor(() => {
156+
const firstLink = screen.getByText("Alpha Agent").closest("a");
157+
expect(firstLink).toHaveAttribute("aria-current", "true");
158+
});
159+
});
160+
161+
it("Enter with an active result opens agent in modal", async () => {
162+
await renderWithResults();
163+
const input = screen.getByLabelText("Search agent messages");
164+
fireEvent.keyDown(input, { key: "ArrowDown" });
165+
fireEvent.keyDown(input, { key: "Enter" });
166+
await waitFor(() => expect(mockOpenModal).toHaveBeenCalledWith("agent", "agent-1"));
167+
});
168+
169+
it("Cmd+Enter with an active result navigates directly to the agent", async () => {
170+
await renderWithResults();
171+
const input = screen.getByLabelText("Search agent messages");
172+
fireEvent.keyDown(input, { key: "ArrowDown" });
173+
fireEvent.keyDown(input, { key: "Enter", metaKey: true });
174+
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith("/workspace/test-workspace/agent/agent-1"));
175+
});
176+
177+
it("Ctrl+Enter with an active result navigates directly to the agent", async () => {
178+
await renderWithResults();
179+
const input = screen.getByLabelText("Search agent messages");
180+
fireEvent.keyDown(input, { key: "ArrowDown" });
181+
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true });
182+
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith("/workspace/test-workspace/agent/agent-1"));
183+
});
184+
185+
it("ArrowDown wraps from last result to first", async () => {
186+
await renderWithResults();
187+
const input = screen.getByLabelText("Search agent messages");
188+
// Move to index 0, then 1, then wrap back to 0
189+
fireEvent.keyDown(input, { key: "ArrowDown" });
190+
fireEvent.keyDown(input, { key: "ArrowDown" });
191+
fireEvent.keyDown(input, { key: "ArrowDown" });
192+
await waitFor(() => {
193+
const firstLink = screen.getByText("Alpha Agent").closest("a");
194+
expect(firstLink).toHaveAttribute("aria-current", "true");
195+
});
196+
});
197+
198+
it("ArrowUp from no selection moves to the last result", async () => {
199+
await renderWithResults();
200+
const input = screen.getByLabelText("Search agent messages");
201+
fireEvent.keyDown(input, { key: "ArrowUp" });
202+
await waitFor(() => {
203+
const lastLink = screen.getByText("Beta Agent").closest("a");
204+
expect(lastLink).toHaveAttribute("aria-current", "true");
205+
});
206+
});
207+
208+
it("Enter with no active result (index -1) does nothing", async () => {
209+
await renderWithResults();
210+
const input = screen.getByLabelText("Search agent messages");
211+
// No ArrowDown pressed — index stays at -1
212+
fireEvent.keyDown(input, { key: "Enter" });
213+
expect(mockOpenModal).not.toHaveBeenCalled();
214+
});
215+
216+
it("active index resets to -1 when a new search is triggered", async () => {
217+
await renderWithResults();
218+
const input = screen.getByLabelText("Search agent messages");
219+
fireEvent.keyDown(input, { key: "ArrowDown" });
220+
// Confirm first result is highlighted
221+
await waitFor(() => {
222+
const firstLink = screen.getByText("Alpha Agent").closest("a");
223+
expect(firstLink).toHaveAttribute("aria-current", "true");
224+
});
225+
// Change the query
226+
mockSearchAgentMessages.mockResolvedValue(
227+
makeResponse([makeResult({ agentId: "agent-3", title: "Gamma Agent" })]),
228+
);
229+
fireEvent.input(input, { target: { value: "gamma" } });
230+
await waitFor(() => screen.getByText("Gamma Agent"));
231+
// No result should be highlighted (aria-current should be absent or false)
232+
const gammaLink = screen.getByText("Gamma Agent").closest("a");
233+
expect(gammaLink).not.toHaveAttribute("aria-current", "true");
234+
});
235+
});
121236
});

projects/birdhouse/frontend/src/components/AgentSearchDialog.tsx

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
// ABOUTME: Modal dialog for searching agent messages by content
22
// ABOUTME: Shows results grouped by agent with a match-count popover for each
33

4+
import { useNavigate } from "@solidjs/router";
45
import Dialog from "corvu/dialog";
56
import Popover from "corvu/popover";
67
import { Search, X } from "lucide-solid";
78
import { type Component, createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";
89
import { useWorkspace } from "../contexts/WorkspaceContext";
910
import { useZIndex } from "../contexts/ZIndexContext";
10-
import { useModalRoute } from "../lib/routing";
11+
import { useModalRoute, useWorkspaceId } from "../lib/routing";
1112
import type { AgentMessageSearchResult, MessagePart } from "../services/agents-api";
1213
import { searchAgentMessages } from "../services/agents-api";
1314
import { cardSurfaceFlat } from "../styles/containerStyles";
@@ -160,6 +161,8 @@ const DEBOUNCE_MS = 300;
160161

161162
const AgentSearchDialog: Component = () => {
162163
const { workspaceId } = useWorkspace();
164+
const routeWorkspaceId = useWorkspaceId();
165+
const navigate = useNavigate();
163166
const { modalStack, removeModalByType, openModal } = useModalRoute();
164167

165168
const isOpen = createMemo(() => modalStack().some((m) => m.type === MODAL_TYPE_AGENT_SEARCH));
@@ -171,6 +174,8 @@ const AgentSearchDialog: Component = () => {
171174
const [isSearching, setIsSearching] = createSignal(false);
172175
const [searchError, setSearchError] = createSignal<string | null>(null);
173176
const [hasSearched, setHasSearched] = createSignal(false);
177+
// -1 means no result is keyboard-selected
178+
const [activeIndex, setActiveIndex] = createSignal(-1);
174179
let requestId = 0;
175180

176181
let inputRef: HTMLInputElement | undefined;
@@ -249,6 +254,19 @@ const AgentSearchDialog: Component = () => {
249254
return order.map((key) => map.get(key) as GroupedResult);
250255
});
251256

257+
// Reset keyboard selection whenever results change
258+
createEffect(() => {
259+
groupedResults();
260+
setActiveIndex(-1);
261+
});
262+
263+
// Also reset when dialog closes
264+
createEffect(() => {
265+
if (!isOpen()) {
266+
setActiveIndex(-1);
267+
}
268+
});
269+
252270
const handleAgentClick = (agentId: string | null, e: MouseEvent) => {
253271
if (!agentId) return;
254272
// Let Cmd/Ctrl+click fall through to the browser for new-tab behavior
@@ -262,6 +280,34 @@ const AgentSearchDialog: Component = () => {
262280
return `/workspace/${workspaceId}/agent/${agentId}`;
263281
};
264282

283+
// Keyboard navigation: arrow up/down moves through grouped results;
284+
// Enter opens the active result in a modal; Cmd/Ctrl+Enter navigates directly.
285+
const handleKeyDown = (e: KeyboardEvent) => {
286+
const groups = groupedResults();
287+
if (groups.length === 0) return;
288+
289+
if (e.key === "ArrowDown") {
290+
e.preventDefault();
291+
setActiveIndex((i) => (i + 1) % groups.length);
292+
} else if (e.key === "ArrowUp") {
293+
e.preventDefault();
294+
setActiveIndex((i) => (i <= 0 ? groups.length - 1 : i - 1));
295+
} else if (e.key === "Enter") {
296+
const idx = activeIndex();
297+
if (idx < 0) return;
298+
const group = groups[idx];
299+
if (!group?.agentId) return;
300+
e.preventDefault();
301+
if (e.metaKey || e.ctrlKey) {
302+
// Direct navigation — close search and navigate to the agent's main view
303+
closeSearch();
304+
navigate(`/workspace/${routeWorkspaceId()}/agent/${group.agentId}`);
305+
} else {
306+
openModal("agent", group.agentId);
307+
}
308+
}
309+
};
310+
265311
return (
266312
<Dialog
267313
open={isOpen()}
@@ -285,6 +331,7 @@ const AgentSearchDialog: Component = () => {
285331
w-[95vw] max-w-2xl max-h-[85dvh]
286332
left-1/2 top-[8%] -translate-x-1/2
287333
flex flex-col overflow-hidden z-[40]`}
334+
onKeyDown={handleKeyDown}
288335
>
289336
{/* Search input header */}
290337
<div class="flex items-center gap-2 px-4 py-3 border-b border-border flex-shrink-0">
@@ -348,11 +395,12 @@ const AgentSearchDialog: Component = () => {
348395
<Show when={groupedResults().length > 0}>
349396
<div class="divide-y divide-border">
350397
<For each={groupedResults()}>
351-
{(group) => (
398+
{(group, index) => (
352399
<SearchResultCard
353400
group={group}
354401
onAgentClick={(e) => handleAgentClick(group.agentId, e)}
355402
agentHref={agentHref(group.agentId)}
403+
isActive={activeIndex() === index()}
356404
/>
357405
)}
358406
</For>
@@ -369,6 +417,7 @@ interface SearchResultCardProps {
369417
group: GroupedResult;
370418
onAgentClick: (e: MouseEvent) => void;
371419
agentHref: string | undefined;
420+
isActive: boolean;
372421
}
373422

374423
const SearchResultCard: Component<SearchResultCardProps> = (props) => {
@@ -377,13 +426,14 @@ const SearchResultCard: Component<SearchResultCardProps> = (props) => {
377426
const matchCount = () => props.group.matches.length;
378427

379428
return (
380-
<div class="px-4 py-4 space-y-2">
429+
<div class="px-4 py-4 space-y-2" classList={{ "bg-accent/5": props.isActive }}>
381430
{/* Agent title + session date range */}
382431
<div class="flex flex-col gap-0.5">
383432
<a
384433
href={props.agentHref}
385434
onClick={(e) => props.onAgentClick(e)}
386435
class="text-sm font-medium text-accent hover:underline leading-snug"
436+
aria-current={props.isActive ? "true" : undefined}
387437
data-agent-id={props.group.agentId}
388438
>
389439
{props.group.title ?? props.group.sessionId}

0 commit comments

Comments
 (0)