/** * 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(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 >(new Map()); const [orphanTasks, setOrphanTasks] = useState([]); 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 (
e.stopPropagation()} > 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" />
    {filtered.length === 0 ? (
  • No matches.
  • ) : ( filtered.map((entry, idx) => (
  • 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)]" }`} > {entry.label} {entry.hint}
  • )) )}
↑↓ navigate ↵ open Esc close Shift Shift to reopen
); } function KindBadge({ kind }: { kind: PaletteEntry["kind"] }) { const map: Record = { 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 ( {m.label} ); } /** * 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; }