summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/QuickSwitcher.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components/QuickSwitcher.tsx')
-rw-r--r--makima/frontend/src/components/QuickSwitcher.tsx314
1 files changed, 314 insertions, 0 deletions
diff --git a/makima/frontend/src/components/QuickSwitcher.tsx b/makima/frontend/src/components/QuickSwitcher.tsx
new file mode 100644
index 0000000..8fe1d4a
--- /dev/null
+++ b/makima/frontend/src/components/QuickSwitcher.tsx
@@ -0,0 +1,314 @@
+/**
+ * QuickSwitcher — IntelliJ-style "double Shift" command palette.
+ *
+ * Listens at the document level for two `Shift` keydowns within ~300ms with
+ * no other key in between. Single-Shift presses (capitalising letters etc.)
+ * are pass-through. The palette pulls directives + their tasks (orchestrator,
+ * completion, started steps, ephemerals) + orphan tasks, fuzzy-matches on
+ * the typed query, arrows navigate, Enter selects, Esc dismisses.
+ *
+ * The directive list comes straight from `useDirectives()` (now backed by
+ * the SWR cache) so the palette opens instantly when the cache is warm.
+ * Tasks per-directive are fetched eagerly into the cache when the palette
+ * opens so subsequent searches don't block on a network round-trip.
+ */
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useNavigate } from "react-router";
+import { useDirectives } from "../hooks/useDirectives";
+import {
+ getDirective,
+ listDirectiveEphemeralTasks,
+ listOrphanTasks,
+ type DirectiveWithSteps,
+ type TaskSummary,
+} from "../lib/api";
+
+interface PaletteEntry {
+ id: string;
+ label: string;
+ /** Subtitle shown to the right (status, parent context). */
+ hint: string;
+ /** Where to navigate on selection. */
+ href: string;
+ kind: "directive" | "task" | "orphan";
+}
+
+const DOUBLE_SHIFT_WINDOW_MS = 300;
+
+export function QuickSwitcher() {
+ const navigate = useNavigate();
+ const { directives } = useDirectives();
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState("");
+ const [activeIdx, setActiveIdx] = useState(0);
+ const inputRef = useRef<HTMLInputElement>(null);
+
+ // ---- Double-shift detection -----------------------------------------------
+ useEffect(() => {
+ let lastShift = 0;
+ let dirty = false;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Shift") {
+ const now = Date.now();
+ if (!dirty && now - lastShift < DOUBLE_SHIFT_WINDOW_MS) {
+ // Don't open if we're typing in an input — otherwise typing a
+ // capital letter via Shift triggers the palette unexpectedly.
+ const target = e.target as HTMLElement | null;
+ const tag = target?.tagName?.toUpperCase();
+ const editable = target?.isContentEditable;
+ if (
+ tag !== "INPUT" &&
+ tag !== "TEXTAREA" &&
+ tag !== "SELECT" &&
+ !editable
+ ) {
+ setOpen(true);
+ setQuery("");
+ setActiveIdx(0);
+ }
+ lastShift = 0;
+ } else {
+ lastShift = now;
+ dirty = false;
+ }
+ } else {
+ // Any other keydown invalidates the in-flight shift sequence.
+ dirty = true;
+ }
+ };
+ document.addEventListener("keydown", onKey);
+ return () => document.removeEventListener("keydown", onKey);
+ }, []);
+
+ // ---- Eager-load tasks per directive on open ------------------------------
+ const [directiveTasks, setDirectiveTasks] = useState<
+ Map<string, { detail: DirectiveWithSteps | null; ephemeral: TaskSummary[] }>
+ >(new Map());
+ const [orphanTasks, setOrphanTasks] = useState<TaskSummary[]>([]);
+
+ useEffect(() => {
+ if (!open) return;
+ let cancelled = false;
+ // Orphan tasks
+ listOrphanTasks()
+ .then((res) => {
+ if (!cancelled) setOrphanTasks(res.tasks);
+ })
+ .catch(() => {
+ /* swallow */
+ });
+ // Per-directive details + ephemeral tasks. We only fetch the first
+ // ~20 directives to keep the open-time bounded; the rest still appear
+ // in the palette (just without their tasks expanded).
+ const slice = directives.slice(0, 20);
+ Promise.all(
+ slice.map(async (d): Promise<[string, { detail: DirectiveWithSteps | null; ephemeral: TaskSummary[] }]> => {
+ try {
+ const [detail, ephRes] = await Promise.all([
+ getDirective(d.id).catch(() => null),
+ listDirectiveEphemeralTasks(d.id).catch(() => ({ tasks: [] as TaskSummary[], total: 0 })),
+ ]);
+ return [d.id, { detail, ephemeral: ephRes.tasks }];
+ } catch {
+ return [d.id, { detail: null, ephemeral: [] as TaskSummary[] }];
+ }
+ }),
+ ).then((entries) => {
+ if (!cancelled) setDirectiveTasks(new Map(entries));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [open, directives]);
+
+ // ---- Build the searchable entry list -------------------------------------
+ const allEntries: PaletteEntry[] = useMemo(() => {
+ const out: PaletteEntry[] = [];
+ for (const d of directives) {
+ out.push({
+ id: `dir:${d.id}`,
+ label: d.title,
+ hint: `contract · ${d.status}`,
+ href: `/directives/${d.id}`,
+ kind: "directive",
+ });
+ const td = directiveTasks.get(d.id);
+ if (td) {
+ if (td.detail?.orchestratorTaskId) {
+ out.push({
+ id: `task:${td.detail.orchestratorTaskId}`,
+ label: `${d.title} › orchestrator`,
+ hint: "task · running",
+ href: `/directives/${d.id}?task=${td.detail.orchestratorTaskId}`,
+ kind: "task",
+ });
+ }
+ if (td.detail?.completionTaskId) {
+ out.push({
+ id: `task:${td.detail.completionTaskId}`,
+ label: `${d.title} › completion`,
+ hint: "task · running",
+ href: `/directives/${d.id}?task=${td.detail.completionTaskId}`,
+ kind: "task",
+ });
+ }
+ if (td.detail) {
+ for (const step of td.detail.steps) {
+ if (!step.taskId) continue;
+ out.push({
+ id: `task:${step.taskId}`,
+ label: `${d.title} › ${step.name}`,
+ hint: `step · ${step.status}`,
+ href: `/directives/${d.id}?task=${step.taskId}`,
+ kind: "task",
+ });
+ }
+ }
+ for (const t of td.ephemeral) {
+ out.push({
+ id: `task:${t.id}`,
+ label: `${d.title} › ${t.name}`,
+ hint: `ephemeral · ${t.status}`,
+ href: `/directives/${d.id}?task=${t.id}`,
+ kind: "task",
+ });
+ }
+ }
+ }
+ for (const t of orphanTasks) {
+ out.push({
+ id: `orphan:${t.id}`,
+ label: t.name,
+ hint: `tmp · ${t.status}`,
+ href: `/tmp/${t.id}`,
+ kind: "orphan",
+ });
+ }
+ return out;
+ }, [directives, directiveTasks, orphanTasks]);
+
+ // ---- Fuzzy filter --------------------------------------------------------
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q) return allEntries.slice(0, 50);
+ return allEntries
+ .filter((e) => fuzzyContains(e.label.toLowerCase(), q))
+ .slice(0, 50);
+ }, [allEntries, query]);
+
+ useEffect(() => {
+ setActiveIdx(0);
+ }, [query]);
+
+ useEffect(() => {
+ if (open) inputRef.current?.focus();
+ }, [open]);
+
+ const close = useCallback(() => setOpen(false), []);
+ const select = useCallback(
+ (entry: PaletteEntry) => {
+ navigate(entry.href);
+ setOpen(false);
+ },
+ [navigate],
+ );
+
+ if (!open) return null;
+
+ return (
+ <div
+ className="fixed inset-0 z-50 flex items-start justify-center pt-[12vh] bg-black/60"
+ onClick={close}
+ >
+ <div
+ className="w-[600px] max-w-[90vw] bg-[#0a1628] border border-[rgba(117,170,252,0.4)] shadow-2xl flex flex-col"
+ onClick={(e) => e.stopPropagation()}
+ >
+ <input
+ ref={inputRef}
+ value={query}
+ onChange={(e) => setQuery(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") {
+ close();
+ } else if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setActiveIdx((i) => Math.min(i + 1, filtered.length - 1));
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setActiveIdx((i) => Math.max(i - 1, 0));
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ const entry = filtered[activeIdx];
+ if (entry) select(entry);
+ }
+ }}
+ placeholder="Jump to a contract or task…"
+ className="bg-transparent border-b border-dashed border-[rgba(117,170,252,0.3)] px-4 py-3 text-[14px] text-white placeholder-[#445566] outline-none font-mono"
+ />
+ <ul className="flex-1 overflow-y-auto max-h-[50vh]">
+ {filtered.length === 0 ? (
+ <li className="px-4 py-3 text-[#556677] font-mono text-xs italic">
+ No matches.
+ </li>
+ ) : (
+ filtered.map((entry, idx) => (
+ <li
+ key={entry.id}
+ onMouseEnter={() => setActiveIdx(idx)}
+ onClick={() => select(entry)}
+ className={`px-4 py-2 font-mono text-[12px] cursor-pointer flex items-center gap-3 ${
+ idx === activeIdx
+ ? "bg-[rgba(117,170,252,0.15)] text-white"
+ : "text-[#dbe7ff] hover:bg-[rgba(117,170,252,0.06)]"
+ }`}
+ >
+ <KindBadge kind={entry.kind} />
+ <span className="flex-1 truncate">{entry.label}</span>
+ <span className="text-[10px] text-[#556677] uppercase tracking-wide shrink-0">
+ {entry.hint}
+ </span>
+ </li>
+ ))
+ )}
+ </ul>
+ <div className="px-4 py-2 border-t border-dashed border-[rgba(117,170,252,0.2)] text-[10px] font-mono text-[#556677] flex items-center gap-3">
+ <span>↑↓ navigate</span>
+ <span>↵ open</span>
+ <span>Esc close</span>
+ <span className="ml-auto">Shift Shift to reopen</span>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function KindBadge({ kind }: { kind: PaletteEntry["kind"] }) {
+ const map: Record<PaletteEntry["kind"], { label: string; tone: string }> = {
+ directive: { label: "DOC", tone: "text-[#75aafc] border-[#3f6fb3]" },
+ task: { label: "TASK", tone: "text-emerald-300 border-emerald-700/60" },
+ orphan: { label: "TMP", tone: "text-[#7788aa] border-[#2a3a5a]" },
+ };
+ const m = map[kind];
+ return (
+ <span
+ className={`text-[9px] font-mono uppercase border rounded px-1 py-0.5 shrink-0 ${m.tone}`}
+ >
+ {m.label}
+ </span>
+ );
+}
+
+/**
+ * Crude fuzzy match: every char of `q` appears in `s` in order. Good enough
+ * for the typical "fold-by-substring" feel of an IntelliJ palette without
+ * pulling in a fuzzy library.
+ */
+function fuzzyContains(s: string, q: string): boolean {
+ let i = 0;
+ for (const c of s) {
+ if (c === q[i]) i++;
+ if (i === q.length) return true;
+ }
+ return i === q.length;
+}