diff options
Diffstat (limited to 'makima/frontend/src/components')
| -rw-r--r-- | makima/frontend/src/components/QuickSwitcher.tsx | 314 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/DocumentTaskStream.tsx | 248 |
2 files changed, 527 insertions, 35 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; +} diff --git a/makima/frontend/src/components/directives/DocumentTaskStream.tsx b/makima/frontend/src/components/directives/DocumentTaskStream.tsx index 62c1a52..b718ae4 100644 --- a/makima/frontend/src/components/directives/DocumentTaskStream.tsx +++ b/makima/frontend/src/components/directives/DocumentTaskStream.tsx @@ -5,39 +5,88 @@ * Key differences from TaskOutput: * - Document typography (serif-ish paragraphs, not monospace logs). * - Interleaved with subtle marginalia for tool calls and results. - * - "Comment" footer interrupts the running task via sendTaskMessage — - * same backend wire as the existing input bar, just framed as a comment. + * - Sticky comment composer at the bottom that's always in view. + * - Header strip with explicit Stop / Send / Open-in-task-page buttons so + * primary task controls don't require a right-click discovery step. + * - Module-level cache of historical entries per taskId so re-selecting a + * task you've already viewed renders instantly while a fresh fetch + * refreshes in the background. */ import { useCallback, useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router"; import { SimpleMarkdown } from "../SimpleMarkdown"; import { useTaskSubscription, type TaskOutputEvent, } from "../../hooks/useTaskSubscription"; -import { getTaskOutput, sendTaskMessage } from "../../lib/api"; +import { getTaskOutput, sendTaskMessage, stopTask } from "../../lib/api"; interface DocumentTaskStreamProps { taskId: string; /** Human label used as the document header (e.g. "orchestrator", step name) */ label: string; + /** + * When this task is ephemeral (spawned via the directive's "+ New task" + * action) AND has reached a terminal state, surface a "Merge to base" + * affordance that navigates the user to the standalone task page where + * the existing merge UI handles the actual merge / conflict flow. + * + * Step-spawned tasks have their own merge path (the directive's PR), so + * this affordance is intentionally off by default. + */ + ephemeral?: boolean; + /** Current status of the task; drives whether merge button is enabled. */ + status?: string; } -export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) { - const [entries, setEntries] = useState<TaskOutputEvent[]>([]); - const [loading, setLoading] = useState(true); +// ============================================================================= +// Module-level cache for historical task entries. +// +// Switching between tasks you've already viewed used to re-fire +// getTaskOutput and show "Loading transcript…" for the duration of the +// network round-trip. We now keep the entries cached per taskId; on +// re-selection we render the cache immediately and refetch in the +// background. The WS subscription continues to handle live deltas. +// ============================================================================= +const entriesCache = new Map<string, TaskOutputEvent[]>(); + +export function DocumentTaskStream({ + taskId, + label, + ephemeral, + status, +}: DocumentTaskStreamProps) { + const navigate = useNavigate(); + const [entries, setEntries] = useState<TaskOutputEvent[]>( + () => entriesCache.get(taskId) ?? [], + ); + const [loading, setLoading] = useState(!entriesCache.has(taskId)); const [isStreaming, setIsStreaming] = useState(false); const [comment, setComment] = useState(""); const [sending, setSending] = useState(false); const [sendError, setSendError] = useState<string | null>(null); + const [stopping, setStopping] = useState(false); const containerRef = useRef<HTMLDivElement>(null); - const [autoScroll, setAutoScroll] = useState(true); + const composerRef = useRef<HTMLDivElement>(null); + // autoScroll lives in a ref so the scroll handler reads the latest value + // synchronously without re-creating the effect. + const autoScrollRef = useRef(true); + const [showResumeScroll, setShowResumeScroll] = useState(false); - // Load historical output when the selected task changes. + // Load historical output when the selected task changes. Render the cache + // immediately if we have it; refetch in the background regardless. useEffect(() => { let cancelled = false; - setLoading(true); - setEntries([]); + const cached = entriesCache.get(taskId); + if (cached) { + setEntries(cached); + setLoading(false); + } else { + setEntries([]); + setLoading(true); + } setIsStreaming(false); + getTaskOutput(taskId) .then((res) => { if (cancelled) return; @@ -52,6 +101,7 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) { durationMs: e.durationMs, isPartial: false, })); + entriesCache.set(taskId, mapped); setEntries(mapped); }) .catch((err) => { @@ -67,17 +117,27 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) { }; }, [taskId]); - const handleOutput = useCallback((event: TaskOutputEvent) => { - if (event.isPartial) return; - setEntries((prev) => [...prev, event]); - setIsStreaming(true); - }, []); + const handleOutput = useCallback( + (event: TaskOutputEvent) => { + if (event.isPartial) return; + setEntries((prev) => { + const next = [...prev, event]; + entriesCache.set(taskId, next); + return next; + }); + setIsStreaming(true); + }, + [taskId], + ); const handleUpdate = useCallback((event: { status: string }) => { if ( event.status === "completed" || event.status === "failed" || - event.status === "cancelled" + event.status === "cancelled" || + event.status === "interrupted" || + event.status === "merged" || + event.status === "done" ) { setIsStreaming(false); } else if (event.status === "running") { @@ -92,18 +152,32 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) { onUpdate: handleUpdate, }); - // Auto-scroll while at bottom. + // Auto-scroll while at bottom. The previous version only flipped autoScroll + // off and never resumed; now a scroll back into the bottom 80px reactivates + // it so a brief read-up doesn't permanently freeze the stream at the top. + useEffect(() => { + if (autoScrollRef.current && containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }, [entries]); + + // After loading the initial transcript, snap to the bottom unconditionally + // so users see the latest output, not the start. useEffect(() => { - if (autoScroll && containerRef.current) { + if (!loading && containerRef.current) { containerRef.current.scrollTop = containerRef.current.scrollHeight; + autoScrollRef.current = true; + setShowResumeScroll(false); } - }, [entries, autoScroll]); + }, [loading, taskId]); const handleScroll = useCallback(() => { if (!containerRef.current) return; const { scrollTop, scrollHeight, clientHeight } = containerRef.current; - const atBottom = scrollHeight - scrollTop - clientHeight < 80; - setAutoScroll(atBottom); + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + const atBottom = distanceFromBottom < 80; + autoScrollRef.current = atBottom; + setShowResumeScroll(!atBottom); }, []); const submitComment = useCallback( @@ -114,15 +188,19 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) { setSending(true); setSendError(null); // Show the comment immediately as a user-input entry. - setEntries((prev) => [ - ...prev, - { - taskId, - messageType: "user_input", - content: trimmed, - isPartial: false, - }, - ]); + setEntries((prev) => { + const next: TaskOutputEvent[] = [ + ...prev, + { + taskId, + messageType: "user_input", + content: trimmed, + isPartial: false, + }, + ]; + entriesCache.set(taskId, next); + return next; + }); try { await sendTaskMessage(taskId, trimmed); setComment(""); @@ -138,15 +216,94 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) { [comment, sending, taskId], ); + const handleStop = useCallback(async () => { + if (stopping || !isStreaming) return; + if (!window.confirm("Stop this task? It will be marked failed.")) return; + setStopping(true); + try { + await stopTask(taskId); + } catch (err) { + // eslint-disable-next-line no-console + console.error("Failed to stop task", err); + } finally { + setStopping(false); + } + }, [taskId, stopping, isStreaming]); + + const focusComposer = useCallback(() => { + const input = composerRef.current?.querySelector("textarea"); + input?.focus(); + }, []); + + const resumeScroll = useCallback(() => { + if (!containerRef.current) return; + containerRef.current.scrollTop = containerRef.current.scrollHeight; + autoScrollRef.current = true; + setShowResumeScroll(false); + }, []); + return ( - <div className="flex-1 flex flex-col h-full overflow-hidden bg-[#0a1628]"> + <div className="flex-1 flex flex-col h-full overflow-hidden bg-[#0a1628] relative"> + {/* Action header strip — explicit Stop / Send / Open-in-task-page so + users don't have to right-click to discover task controls. */} + <div className="shrink-0 flex items-center gap-2 px-6 py-2 border-b border-dashed border-[rgba(117,170,252,0.2)] bg-[#091428]"> + <span className="text-[10px] font-mono text-[#556677] uppercase tracking-wide"> + Task actions + </span> + <button + type="button" + onClick={focusComposer} + className="ml-auto px-2 py-1 font-mono text-[10px] uppercase tracking-wide text-emerald-300 border border-emerald-700/60 hover:border-emerald-400" + > + Send (⌘↵) + </button> + <button + type="button" + onClick={handleStop} + disabled={!isStreaming || stopping} + className="px-2 py-1 font-mono text-[10px] uppercase tracking-wide text-amber-300 border border-amber-600/60 hover:border-amber-400 disabled:opacity-40 disabled:cursor-not-allowed" + > + {stopping ? "Stopping…" : "Stop"} + </button> + + {/* Manual merge affordance — visible only on ephemeral tasks that + have reached a terminal state. Navigates to the standalone task + page where the existing mesh_merge UI drives the real merge / + conflict resolution flow. The user explicitly asked for this to + be a manual button press for safety. */} + {ephemeral && isTerminalStatus(status) && ( + <button + type="button" + onClick={() => { + const ok = window.confirm( + "Merge this ephemeral task into the base branch? You'll be taken to the task page where the merge runs and any conflicts are resolved.", + ); + if (!ok) return; + navigate(`/exec/${taskId}#merge`); + }} + title="Manual merge — opens the merge UI on the task page" + className="px-2 py-1 font-mono text-[10px] uppercase tracking-wide text-emerald-300 border border-emerald-700/60 hover:border-emerald-400" + > + Merge to base ↗ + </button> + )} + + <button + type="button" + onClick={() => navigate(`/exec/${taskId}`)} + className="px-2 py-1 font-mono text-[10px] uppercase tracking-wide text-[#9bc3ff] border border-[rgba(117,170,252,0.35)] hover:border-[#75aafc]" + > + Open in task page + </button> + </div> + {/* Document body */} <div ref={containerRef} onScroll={handleScroll} className="flex-1 overflow-y-auto" > - <div className="max-w-3xl mx-auto px-8 py-10 text-[#dbe7ff]"> + <div className="max-w-3xl mx-auto px-8 py-10 pb-32 text-[#dbe7ff]"> <div className="flex items-center gap-3 mb-1"> <h1 className="text-[24px] font-medium text-white tracking-tight"> {label} @@ -183,8 +340,23 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) { </div> </div> - {/* Comment / interrupt footer */} - <div className="shrink-0 border-t border-dashed border-[rgba(117,170,252,0.25)] bg-[#091428]"> + {/* "Resume auto-scroll" floating chip when the user has scrolled up. */} + {showResumeScroll && ( + <button + type="button" + onClick={resumeScroll} + className="absolute bottom-32 right-6 z-10 px-3 py-1.5 font-mono text-[10px] uppercase tracking-wide text-[#9bc3ff] bg-[#091428] border border-[rgba(117,170,252,0.4)] hover:border-[#75aafc] shadow-lg" + > + ↓ Jump to latest + </button> + )} + + {/* Sticky comment composer — always pinned to the viewport bottom so + users can interact with the task no matter where they've scrolled. */} + <div + ref={composerRef} + className="absolute bottom-0 left-0 right-0 border-t border-dashed border-[rgba(117,170,252,0.25)] bg-[#091428]/95 backdrop-blur" + > {sendError && ( <div className="px-6 py-1 bg-red-900/20 text-red-400 text-xs font-mono"> {sendError} @@ -192,7 +364,7 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) { )} <form onSubmit={submitComment} - className="max-w-3xl mx-auto px-8 py-4 flex items-start gap-3" + className="max-w-3xl mx-auto px-8 py-3 flex items-start gap-3" > <span className="text-[10px] font-mono text-[#556677] uppercase tracking-wide pt-2 shrink-0"> Comment @@ -325,6 +497,12 @@ function DocumentEntry({ entry }: { entry: TaskOutputEvent }) { } } +/** Terminal task statuses where the merge button is meaningful. */ +function isTerminalStatus(status?: string): boolean { + if (!status) return false; + return ["done", "completed", "merged"].includes(status); +} + function firstLineOfInput(input?: Record<string, unknown>): string { if (!input) return ""; // Common shapes — show the most informative single value. |
