summaryrefslogblamecommitdiff
path: root/makima/frontend/src/components/QuickSwitcher.tsx
blob: 8fe1d4a1ed64b2cca128eafbf1a649aeb9cc9d47 (plain) (tree)

























































































































































































































































































































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