summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/QuickSwitcher.tsx
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-05-01 18:06:38 +0100
committerGitHub <noreply@github.com>2026-05-01 18:06:38 +0100
commit80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef (patch)
tree5802a9923eab572a98a6d8b21be2fdc56fdf7118 /makima/frontend/src/components/QuickSwitcher.tsx
parent6d922307223d12f436b229d4c4b29b8835b93b6c (diff)
downloadsoryu-80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef.tar.gz
soryu-80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef.zip
feat(doc-mode): unified surface — ephemeral tasks, tmp/, /exec redirect, palette, SWR (#117)
Phases 1-3 of the unified-surface plan, bundled per the user's request. The directive document/folder UI is now the canonical place to interact with makima; legacy /exec is subsumed by routing redirects, /contracts is already hidden in nav, and orphan tasks surface under a top-level tmp/ pseudo-folder. Phase 4 (porting contract-only features) was confirmed out of scope; Phase 5 (deleting the contracts code) is a follow-up. ## Backend - New migration `20260501000000_archive_existing_contracts.sql` flips every legacy contract to `archived` so /contracts is read-only history. - New endpoint `POST /api/v1/directives/{id}/tasks` creates an ephemeral task — `directive_id` set, `directive_step_id` NULL, repo/branch inherited from the directive. Reuses `create_task_for_owner`. - New endpoint `GET /api/v1/directives/{id}/tasks` lists ephemeral tasks attached to a directive (drives the per-folder ephemeral group). - `GET /api/v1/mesh/tasks?orphan=true` returns top-level tasks with no `directive_id` AND no `parent_task_id` — backs the sidebar's tmp/. - New repo helpers `list_ephemeral_directive_tasks_for_owner` and `list_orphan_tasks_for_owner`. - The existing `mesh_merge` endpoints are reused as-is for ephemeral task merge (no new merge logic needed). ## Frontend ### Sticky composer + auto-scroll fix (`DocumentTaskStream.tsx`) - Sticky comment composer pinned to viewport bottom; padding compensates so the last entry isn't hidden behind it. - `autoScroll` now resumes when the user scrolls back within 80px of the bottom (previously stuck off forever after a single scroll-up). - Floating "↓ Jump to latest" chip when the user has scrolled away. - Action header strip: explicit Stop / Send / Open-in-task-page + conditional "Merge to base ↗" button on ephemeral terminal tasks. - Module-level cache of historical entries by taskId so re-selecting a task you've viewed renders instantly while a fresh fetch runs. ### Sidebar (`document-directives.tsx`) - Top-level `tmp/` folder: orphan tasks, polled every 5s. - Per-directive `tasks/` subfolder now also surfaces ephemeral tasks (lazily fetched on folder open) with a distinct asterisk-on-terminal icon (`EphemeralTaskIcon`). - Inline hover-action chips on each directive folder header: Start / Pause / PR / +New task. Right-click menu still works as a power-user fallback. - "Now executing" amber strip in the editor pane: surfaces the live orchestrator/completion/running-step task with a one-click jump. - Inline `+ New task` modal (name + plan); on submit calls `createDirectiveTask` and navigates into the freshly-spawned task. - New `EphemeralAwareTaskStream` wrapper passes `ephemeral` and `status` to `DocumentTaskStream` so the merge button only shows when the selected task is genuinely an ephemeral spinoff in a terminal state. Step-spawned tasks merge via the directive's PR completion. ### SWR cache (`useDirectives.ts`) - Module-level `listCache` and per-id `detailCache` (mirrors the pattern in `useUserSettings.ts`). Mounting the hook renders the cache value immediately if present and kicks a background refresh; subscribers see the new value when it lands. Cuts perceived navigation latency to near-zero on warm cache hits. ### QuickSwitcher (`QuickSwitcher.tsx`, new) - IntelliJ-style double-Shift command palette mounted at app root. - Listens at the document level for two `Shift` keydowns within 300ms with no other key in between; ignores while focus is in an input/textarea so capitalising letters doesn't pop the palette. - Searches across directives + their tasks (orchestrator/completion/ steps/ephemerals) + orphan tmp tasks. Fuzzy matches on title. - Eagerly loads task details for the first 20 directives on open so searches don't block on per-directive fetches. ### Routing (`main.tsx` + `exec-redirect.tsx` + `tmp.tsx`) - New `ExecRedirect` wrapper at `/exec/:id`: when documentMode is on AND the task has a `directiveId`, replaces the URL with `/directives/<directiveId>?task=<taskId>`. Otherwise renders the legacy `MeshPage` as before. - New `/tmp/:taskId` route renders `DocumentTaskStream` standalone for orphan tasks, with the masthead and a `tmp / <slug>` breadcrumb. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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;
+}