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
4 changes: 4 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { UpdateBanner } from "@/components/UpdateBanner";
import { DesktopCapabilitiesProvider } from "@/components/DesktopCapabilities";
import { RoutinesPage } from "@/components/RoutinesPage";
import { NoEngines } from "@/components/NoEngines";
import { CommandPalette } from "@/components/CommandPalette";

function Shell() {
const { state, dispatch } = useStore();
Expand Down Expand Up @@ -129,6 +130,9 @@ function Shell() {
{state.computerOpen && bot && <ComputerPanel bot={bot} />}
{state.appSettingsOpen && <SettingsModal />}
{state.pluginsOpen && <PluginsPanel />}
{/* mounted after the modals: same z-50 tier, so DOM order keeps the
palette on top when one of them is open underneath */}
<CommandPalette />
</div>
</div>
);
Expand Down
250 changes: 250 additions & 0 deletions src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
// ⌘K switcher: bots and rooms from local state, transcript hits from
// /api/search. Self-contained — owns its open state and its global chord,
// so App.tsx only mounts it.
import { useEffect, useRef, useState } from "react";
import { Bot as BotIcon, MessageSquare, Search, Users } from "lucide-react";
import { api, useStore, type Bot, type Group } from "@/state/store";
import { rankByName } from "@/lib/palette-rank";
import { cn } from "@/lib/cn";

/** One /api/search result: a text message somewhere in a transcript. */
interface MessageHit {
threadId: string;
messageId: string;
at: number;
role: string;
snippet: string;
name: string;
botId?: string;
groupId?: string;
task?: string;
}

type PaletteEntry =
| { kind: "bot"; bot: Bot }
| { kind: "room"; group: Group }
| { kind: "message"; hit: MessageHit };

export function CommandPalette() {
const { state, dispatch } = useStore();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [messageHits, setMessageHits] = useState<MessageHit[]>([]);
const [cursor, setCursor] = useState(0);
const selectedRef = useRef<HTMLButtonElement>(null);

// The chord fires from anywhere — Shell's app-wide shortcuts (⌘N, ⌘1–9)
// set the precedent of not guarding against focused inputs, and a
// modifier chord never collides with typing.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k" && !e.shiftKey && !e.altKey) {
e.preventDefault();
setOpen((o) => !o);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);

// fresh palette every open; stale queries from last time would flash
useEffect(() => {
if (!open) return;
setQuery("");
setMessageHits([]);
setCursor(0);
}, [open]);

const q = query.trim().toLowerCase();

// Same debounce pattern as the sidebar search: names answer instantly
// from local state, transcript hits arrive a beat later, and a stale
// response for an outdated query is dropped.
useEffect(() => {
if (!open || !q) {
setMessageHits([]);
return;
}
let alive = true;
const timer = setTimeout(() => {
api(`/api/search?q=${encodeURIComponent(q)}&limit=12`)
.then((result: { hits?: MessageHit[] }) => alive && setMessageHits(result.hits ?? []))
.catch(() => alive && setMessageHits([]));
}, 150);
return () => {
alive = false;
clearTimeout(timer);
};
}, [open, q]);

useEffect(() => setCursor(0), [q]);

useEffect(() => {
selectedRef.current?.scrollIntoView({ block: "nearest" });
}, [cursor, messageHits]);

if (!open) return null;

const bots = rankByName(state.bots.filter((b) => !b.hidden), q);
const rooms = rankByName(state.groups, q);
const entries: PaletteEntry[] = [
...bots.map((bot): PaletteEntry => ({ kind: "bot", bot })),
...rooms.map((group): PaletteEntry => ({ kind: "room", group })),
// message hits only make sense for a typed query; empty = switcher mode
...(q ? messageHits.map((hit): PaletteEntry => ({ kind: "message", hit })) : []),
];
// hits arriving or rows filtering away can strand the cursor past the end
const selected = entries.length ? Math.min(cursor, entries.length - 1) : 0;

const activate = (entry: PaletteEntry) => {
if (entry.kind === "message") {
const hit = entry.hit;
const id = hit.botId ?? hit.groupId;
if (!id) return;
dispatch({ type: "select", id });
// a hit on a non-active task opens THAT task, not whichever one the
// bot happens to have in front (same rule as the sidebar search)
const targetBot = hit.botId ? state.bots.find((b) => b.id === hit.botId) : undefined;
if (targetBot && targetBot.threadId !== hit.threadId) {
dispatch({ type: "switchTask", botId: targetBot.id, threadId: hit.threadId });
}
} else {
dispatch({ type: "select", id: entry.kind === "bot" ? entry.bot.id : entry.group.id });
}
setOpen(false);
};

const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
// shield the window listeners other modals hang their own Esc on
e.stopPropagation();
setOpen(false);
} else if (e.key === "ArrowDown") {
e.preventDefault();
setCursor(entries.length ? (selected + 1) % entries.length : 0);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setCursor(entries.length ? (selected - 1 + entries.length) % entries.length : 0);
} else if (e.key === "Enter") {
e.preventDefault();
const entry = entries[selected];
if (entry) activate(entry);
}
};

// flat cursor across sections; each row needs its absolute index
const roomOffset = bots.length;
const messageOffset = bots.length + rooms.length;

const row = (key: string, index: number, onPick: () => void, children: React.ReactNode, twoLine = false) => (
<button
key={key}
ref={index === selected ? selectedRef : undefined}
onClick={onPick}
// mousemove, not mouseenter: rows shifting under a resting pointer
// (hits arriving) must not steal the keyboard selection
onMouseMove={() => setCursor(index)}
className={cn(
"flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left",
twoLine && "flex-col items-stretch gap-0.5",
index === selected ? "bg-raised" : "hover:bg-raised/50",
)}
>
{children}
</button>
);

return (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-6 pt-[14vh]"
onMouseDown={(e) => e.target === e.currentTarget && setOpen(false)}
onKeyDown={onKeyDown}
>
<div
role="dialog"
aria-modal="true"
aria-label="Command palette"
className="flex max-h-[min(480px,70vh)] w-full max-w-[560px] flex-col overflow-hidden rounded-xl border border-hairline/50 bg-card shadow-2xl shadow-black/60"
>
<div className="flex items-center gap-3 border-b border-hairline/40 px-4 py-3">
<Search size={16} className="shrink-0 text-ink-secondary" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search bots, rooms, messages…"
className="w-full bg-transparent text-[14px] text-ink placeholder:text-ink-secondary focus:outline-none"
/>
<kbd className="shrink-0 rounded-md border border-hairline/40 px-1.5 py-0.5 text-[11px] text-ink-secondary">
esc
</kbd>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{entries.length === 0 && (
<div className="px-3 py-6 text-center text-[13px] text-ink-secondary">
{q ? `Nothing matches “${query}”` : "Nothing to switch to yet"}
</div>
)}
{bots.length > 0 && (
<div className="px-3 pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-[0.08em] text-ink-secondary">
Bots
</div>
)}
{bots.map((bot, i) =>
row(
`bot:${bot.id}`,
i,
() => activate({ kind: "bot", bot }),
<>
<BotIcon size={16} className="shrink-0 text-ink-secondary" />
<span className="truncate text-[14px] text-ink">{bot.name}</span>
{bot.title && (
<span className="min-w-0 truncate text-[12.5px] text-ink-secondary">{bot.title}</span>
)}
</>,
),
)}
{rooms.length > 0 && (
<div className="px-3 pb-1 pt-2 text-[11px] font-medium uppercase tracking-[0.08em] text-ink-secondary">
Rooms
</div>
)}
{rooms.map((group, i) =>
row(
`room:${group.id}`,
roomOffset + i,
() => activate({ kind: "room", group }),
<>
<Users size={16} className="shrink-0 text-ink-secondary" />
<span className="truncate text-[14px] text-ink">{group.name}</span>
</>,
),
)}
{q && messageHits.length > 0 && (
<div className="px-3 pb-1 pt-2 text-[11px] font-medium uppercase tracking-[0.08em] text-ink-secondary">
Messages
</div>
)}
{q &&
messageHits.map((hit, i) =>
row(
`msg:${hit.threadId}:${hit.messageId}`,
messageOffset + i,
() => activate({ kind: "message", hit }),
<>
<span className="flex items-center gap-2 truncate text-[13px] font-medium text-ink">
<MessageSquare size={13} className="shrink-0 text-ink-secondary" />
{hit.name}
{hit.task ? <span className="font-normal text-ink-secondary"> · {hit.task}</span> : null}
</span>
<span className="line-clamp-2 text-[12.5px] text-ink-secondary">{hit.snippet}</span>
</>,
true,
),
)}
</div>
</div>
</div>
);
}
34 changes: 34 additions & 0 deletions src/lib/palette-rank.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";

import { rankByName } from "./palette-rank";

const names = (items: Array<{ name: string }>) => items.map((i) => i.name);

describe("rankByName", () => {
it("puts prefix matches above substring matches", () => {
const items = [{ name: "Big Momo" }, { name: "Momo" }, { name: "Lemon Momo" }];
expect(names(rankByName(items, "mo"))).toEqual(["Momo", "Big Momo", "Lemon Momo"]);
});

it("matches case-insensitively on both sides", () => {
const items = [{ name: "MOSS" }, { name: "quiet moss" }];
expect(names(rankByName(items, "Moss"))).toEqual(["MOSS", "quiet moss"]);
});

it("keeps input order within a tier", () => {
const items = [{ name: "Miso One" }, { name: "Miso Two" }, { name: "A Miso" }, { name: "B Miso" }];
expect(names(rankByName(items, "miso"))).toEqual(["Miso One", "Miso Two", "A Miso", "B Miso"]);
});

it("filters out non-matches", () => {
const items = [{ name: "Momo" }, { name: "Moss" }];
expect(rankByName(items, "zebra")).toEqual([]);
expect(names(rankByName(items, "oss"))).toEqual(["Moss"]);
});

it("returns everything in input order for an empty or whitespace query", () => {
const items = [{ name: "Momo" }, { name: "Moss" }];
expect(names(rankByName(items, ""))).toEqual(["Momo", "Moss"]);
expect(names(rankByName(items, " "))).toEqual(["Momo", "Moss"]);
});
});
19 changes: 19 additions & 0 deletions src/lib/palette-rank.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/** Ranking for the command palette's name lists (bots, rooms).
*
* A prefix match is almost always what a few typed letters mean, so those
* rows outrank plain substring matches. Within each tier the input order is
* preserved — the caller's list already encodes a meaningful order (pinned
* first, recency, …) and re-sorting it here would fight that. */
export function rankByName<T extends { name: string }>(items: readonly T[], query: string): T[] {
const q = query.trim().toLowerCase();
// empty query = switcher mode: everything, untouched
if (!q) return [...items];
const prefix: T[] = [];
const substring: T[] = [];
for (const item of items) {
const name = item.name.toLowerCase();
if (name.startsWith(q)) prefix.push(item);
else if (name.includes(q)) substring.push(item);
}
return [...prefix, ...substring];
}
Loading