/**
* 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;
}