diff options
| author | soryu <soryu@soryu.co> | 2026-04-30 09:57:07 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-30 09:57:07 +0100 |
| commit | 2b695485753d55f956746b73c31c2deba0ed0a29 (patch) | |
| tree | 3965b49e1e5e14d7396844275133878997a45ebf | |
| parent | 8d864f83a1d8c2ad47cf194b70d802e366a146b0 (diff) | |
| download | soryu-2b695485753d55f956746b73c31c2deba0ed0a29.tar.gz soryu-2b695485753d55f956746b73c31c2deba0ed0a29.zip | |
feat(document-mode): longer goal save countdown, per-directive folders, live task stream (#103)
Three connected UX changes for the document-mode directive UI.
## Goal save UX (DocumentEditor.tsx)
- Replace the old 3-second countdown with 60s when no orchestrator is running
and 10s when one is, so editing a goal mid-flight does not feel rushed.
- The countdown bar is hidden until the last 10 seconds; users see "Saved" /
"Unsaved changes" indicators rather than a constantly-ticking clock.
- Continuously persist work-in-progress to localStorage on every keystroke
(debounced 250ms). On mount, if a draft for the directive exists in
localStorage and differs from the persisted goal, restore it and put the
editor in dirty/pending state — leaving the page no longer loses work.
- localStorage-backed "Live start" toggle in the bar. When off, the editor
stays in "dirty" instead of auto-firing; user clicks "Save now" to commit.
- Discard button reverts the editor to the persisted goal and clears the draft.
## Sidebar restructure (document-directives.tsx)
- Drop the active/idle/archived top-level grouping; show one folder per
directive instead. Folders sort by lifecycle (active, paused, idle, draft,
archived) then alphabetically.
- Each folder header shows a colored status dot on BOTH sides (left as the
primary status icon, right as a mirror plus a pulse when the orchestrator
is live), replacing the previous "/active", "/idle" text labels.
- Inside each open folder: the directive's document is pinned at the top
(with a small star icon), then the orchestrator task (if running), then
the completion task, then any step tasks that have started.
- The currently-selected directive's folder is auto-opened so deep links
always land somewhere visible.
## Live document task stream (DocumentTaskStream.tsx, new)
- Selecting a task in a folder navigates to ?task=<id> and replaces the
Lexical editor with a document-styled live transcript: assistant prose as
flowing paragraphs, tool calls as marginalia, results as a closing block.
No log/code box.
- Comment textarea at the bottom calls sendTaskMessage on submit, the same
wire the existing TaskOutput input bar uses for interrupts. ⌘/Ctrl-Enter
submits.
- Header breadcrumb gains a "back to document" affordance to return to the
pinned doc view without reopening the sidebar.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3 files changed, 939 insertions, 185 deletions
diff --git a/makima/frontend/src/components/directives/DocumentEditor.tsx b/makima/frontend/src/components/directives/DocumentEditor.tsx index 40fccf1..d953d45 100644 --- a/makima/frontend/src/components/directives/DocumentEditor.tsx +++ b/makima/frontend/src/components/directives/DocumentEditor.tsx @@ -49,8 +49,34 @@ import { // Constants // ============================================================================= -const SAVE_COUNTDOWN_MS = 3000; +/** + * Time between the user's last keystroke and the goal being persisted (which + * triggers the orchestrator to (re)plan). Longer when the directive is fresh + * so the user can think; shorter when the orchestrator is already running and + * we want changes to flow through quickly. + */ +const COUNTDOWN_FRESH_MS = 60_000; +const COUNTDOWN_RUNNING_MS = 10_000; +/** The countdown bar only appears once we're inside this many ms from firing. */ +const BAR_VISIBLE_MS = 10_000; const SAVED_TOAST_MS = 1200; +/** Debounce for writing the in-progress draft to localStorage (no backend hit). */ +const DRAFT_PERSIST_DEBOUNCE_MS = 250; +const DRAFT_KEY = (directiveId: string) => `makima:directive-goal-draft:${directiveId}`; +const LIVE_START_KEY = "makima:liveStartEnabled"; + +function isLiveStartEnabled(): boolean { + if (typeof window === "undefined") return true; + const raw = window.localStorage.getItem(LIVE_START_KEY); + // Default: live start ON — preserves existing behaviour for users who never + // touch the toggle. + return raw === null ? true : raw === "true"; +} + +function setLiveStartEnabled(value: boolean) { + if (typeof window === "undefined") return; + window.localStorage.setItem(LIVE_START_KEY, value ? "true" : "false"); +} // ============================================================================= // Editor theme — minimal, just enough so the rich-text plugin has something to @@ -85,8 +111,10 @@ const editorTheme = { */ function SeedContentPlugin({ directive, + onDraftRestored, }: { directive: DirectiveWithSteps; + onDraftRestored: (draft: string) => void; }) { const [editor] = useLexicalComposerContext(); const seededIdRef = useRef<string | null>(null); @@ -95,6 +123,22 @@ function SeedContentPlugin({ if (seededIdRef.current === directive.id) return; seededIdRef.current = directive.id; + // If a localStorage draft exists for this directive, prefer it over the + // persisted goal so the user does not lose unsaved work after navigating + // away. The parent is told about the restored draft so its state machine + // can transition to "dirty" or "pending". + let initialGoal = directive.goal; + let restoredDraft: string | null = null; + try { + const stored = window.localStorage.getItem(DRAFT_KEY(directive.id)); + if (stored !== null && stored !== directive.goal) { + initialGoal = stored; + restoredDraft = stored; + } + } catch { + /* localStorage may be unavailable; fall back to persisted goal */ + } + editor.update( () => { const root = $getRoot(); @@ -107,8 +151,8 @@ function SeedContentPlugin({ // Paragraph: goal (editable). const goalPara = $createParagraphNode(); - if (directive.goal.length > 0) { - goalPara.append($createTextNode(directive.goal)); + if (initialGoal.length > 0) { + goalPara.append($createTextNode(initialGoal)); } root.append(goalPara); @@ -121,7 +165,13 @@ function SeedContentPlugin({ }, { tag: "history-merge" }, ); - }, [editor, directive.id, directive.title, directive.goal]); + + if (restoredDraft !== null) { + // Defer so the parent's state update lands AFTER the editor's seeded + // content (avoids the GoalChangePlugin firing first and double-tracking). + queueMicrotask(() => onDraftRestored(restoredDraft!)); + } + }, [editor, directive.id, directive.title, directive.goal, onDraftRestored]); return null; } @@ -340,19 +390,36 @@ function EditorContextMenu({ // ============================================================================= interface SaveCountdownBarProps { - state: "idle" | "pending" | "saving" | "saved" | "error"; + state: "idle" | "dirty" | "pending" | "saving" | "saved" | "error"; remainingMs: number; - totalMs: number; + liveStart: boolean; + orchestratorRunning: boolean; + onSaveNow: () => void; onCancel: () => void; + onToggleLiveStart: (next: boolean) => void; } function SaveCountdownBar({ state, remainingMs, - totalMs, + liveStart, + orchestratorRunning, + onSaveNow, onCancel, + onToggleLiveStart, }: SaveCountdownBarProps) { - if (state === "idle") return null; + // Visibility rules: + // - Always show when actually saving / saved / error (transient feedback). + // - Show when "dirty" if live-start is OFF (user must trigger save). + // - Show when "pending" only inside the last BAR_VISIBLE_MS so the user + // does not feel rushed during the long fresh countdown. + const visible = + state === "saving" || + state === "saved" || + state === "error" || + (state === "dirty" && !liveStart) || + (state === "pending" && remainingMs <= BAR_VISIBLE_MS); + if (!visible) return null; let label: string; let progressPct = 0; @@ -360,8 +427,18 @@ function SaveCountdownBar({ if (state === "pending") { const seconds = Math.max(0, Math.ceil(remainingMs / 1000)); - label = `Saving goal in ${seconds}s — press Esc or Undo to cancel.`; - progressPct = Math.max(0, Math.min(100, (1 - remainingMs / totalMs) * 100)); + label = orchestratorRunning + ? `Replanning in ${seconds}s — Esc/Undo cancels.` + : `Saving goal in ${seconds}s — Esc/Undo cancels.`; + progressPct = Math.max( + 0, + Math.min(100, (1 - remainingMs / BAR_VISIBLE_MS) * 100), + ); + } else if (state === "dirty") { + label = orchestratorRunning + ? "Unsaved changes — saving will replan the directive." + : "Unsaved changes."; + progressPct = 0; } else if (state === "saving") { label = "Saving…"; progressPct = 100; @@ -387,15 +464,36 @@ function SaveCountdownBar({ style={{ width: `${progressPct}%` }} /> </div> - <div className="flex items-center justify-between px-4 py-1.5"> - <span className="text-[10px] font-mono">{label}</span> - {state === "pending" && ( + <div className="flex items-center gap-3 px-4 py-1.5"> + <span className="text-[10px] font-mono flex-1 truncate">{label}</span> + + {/* Live-start toggle is always shown so users can flip it from the bar. */} + <label className="flex items-center gap-1.5 text-[10px] font-mono text-[#7788aa] cursor-pointer select-none shrink-0"> + <input + type="checkbox" + checked={liveStart} + onChange={(e) => onToggleLiveStart(e.target.checked)} + className="accent-[#75aafc]" + /> + <span>Live start</span> + </label> + + {(state === "dirty" || state === "pending") && ( + <button + type="button" + onClick={onSaveNow} + className="text-[10px] font-mono text-emerald-300 hover:text-white border border-emerald-700/60 rounded px-2 py-0.5 shrink-0" + > + Save now + </button> + )} + {(state === "dirty" || state === "pending") && ( <button type="button" onClick={onCancel} - className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5" + className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5 shrink-0" > - Cancel + Discard </button> )} </div> @@ -415,7 +513,7 @@ export interface DocumentEditorProps { onPickUpOrders: () => Promise<unknown> | unknown; } -type SaveState = "idle" | "pending" | "saving" | "saved" | "error"; +type SaveState = "idle" | "dirty" | "pending" | "saving" | "saved" | "error"; export function DocumentEditor({ directive, @@ -442,45 +540,66 @@ export function DocumentEditor({ [directive.id], ); + // ---- Live-start setting (localStorage-backed) ------------------------- + const [liveStart, setLiveStartState] = useState<boolean>(isLiveStartEnabled); + const toggleLiveStart = useCallback((next: boolean) => { + setLiveStartEnabled(next); + setLiveStartState(next); + }, []); + // ---- Goal auto-save state machine -------------------------------------- + const orchestratorRunning = + !!directive.orchestratorTaskId || !!directive.completionTaskId; + // Pick the right countdown based on whether we'd be restarting work. + const countdownMs = orchestratorRunning ? COUNTDOWN_RUNNING_MS : COUNTDOWN_FRESH_MS; + const [saveState, setSaveState] = useState<SaveState>("idle"); - const [remainingMs, setRemainingMs] = useState(SAVE_COUNTDOWN_MS); + const [remainingMs, setRemainingMs] = useState(countdownMs); const pendingGoalRef = useRef<string>(directive.goal); const timerRef = useRef<number | null>(null); const tickRef = useRef<number | null>(null); const deadlineRef = useRef<number>(0); + const draftDebounceRef = useRef<number | null>(null); const editorRef = useRef<LexicalEditor | null>(null); + function cancelTimers() { + if (timerRef.current != null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + if (tickRef.current != null) { + window.clearInterval(tickRef.current); + tickRef.current = null; + } + } + // Reset state when switching directives. useEffect(() => { pendingGoalRef.current = directive.goal; cancelTimers(); setSaveState("idle"); - setRemainingMs(SAVE_COUNTDOWN_MS); + setRemainingMs(countdownMs); // eslint-disable-next-line react-hooks/exhaustive-deps }, [directive.id]); // If the persisted goal updated externally and matches the pending goal, // settle the bar. useEffect(() => { - if (saveState === "pending" && pendingGoalRef.current === directive.goal) { + if ( + (saveState === "pending" || saveState === "dirty") && + pendingGoalRef.current === directive.goal + ) { cancelTimers(); setSaveState("idle"); + try { + window.localStorage.removeItem(DRAFT_KEY(directive.id)); + } catch { + /* localStorage may be unavailable; ignore */ + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [directive.goal]); - function cancelTimers() { - if (timerRef.current != null) { - window.clearTimeout(timerRef.current); - timerRef.current = null; - } - if (tickRef.current != null) { - window.clearInterval(tickRef.current); - tickRef.current = null; - } - } - const fireSave = useCallback(async () => { const next = pendingGoalRef.current; cancelTimers(); @@ -488,6 +607,11 @@ export function DocumentEditor({ try { await onUpdateGoal(next); setSaveState("saved"); + try { + window.localStorage.removeItem(DRAFT_KEY(directive.id)); + } catch { + /* ignore */ + } window.setTimeout(() => { // Only fade if no new edit has reopened a pending state in the meantime. setSaveState((s) => (s === "saved" ? "idle" : s)); @@ -500,13 +624,13 @@ export function DocumentEditor({ setSaveState((s) => (s === "error" ? "idle" : s)); }, 2500); } - }, [onUpdateGoal]); + }, [onUpdateGoal, directive.id]); const startOrExtendCountdown = useCallback(() => { cancelTimers(); - deadlineRef.current = Date.now() + SAVE_COUNTDOWN_MS; + deadlineRef.current = Date.now() + countdownMs; setSaveState("pending"); - setRemainingMs(SAVE_COUNTDOWN_MS); + setRemainingMs(countdownMs); tickRef.current = window.setInterval(() => { const remaining = Math.max(0, deadlineRef.current - Date.now()); setRemainingMs(remaining); @@ -514,18 +638,23 @@ export function DocumentEditor({ window.clearInterval(tickRef.current); tickRef.current = null; } - }, 100); + }, 200); timerRef.current = window.setTimeout(() => { void fireSave(); - }, SAVE_COUNTDOWN_MS); - }, [fireSave]); + }, countdownMs); + }, [fireSave, countdownMs]); const cancelCountdown = useCallback(() => { - if (saveState !== "pending") return; + if (saveState !== "pending" && saveState !== "dirty") return; cancelTimers(); pendingGoalRef.current = directive.goal; // reset pending edit setSaveState("idle"); - setRemainingMs(SAVE_COUNTDOWN_MS); + setRemainingMs(countdownMs); + try { + window.localStorage.removeItem(DRAFT_KEY(directive.id)); + } catch { + /* ignore */ + } // Also revert the editor's goal paragraph back to the persisted value, so // the user sees the rollback. const editor = editorRef.current; @@ -543,27 +672,61 @@ export function DocumentEditor({ { tag: "history-merge" }, ); } - }, [directive.goal, saveState]); + }, [directive.goal, directive.id, saveState, countdownMs]); // Cleanup on unmount. useEffect(() => { - return cancelTimers; + return () => { + cancelTimers(); + if (draftDebounceRef.current != null) { + window.clearTimeout(draftDebounceRef.current); + draftDebounceRef.current = null; + } + }; }, []); const handleGoalChange = useCallback( (goal: string) => { pendingGoalRef.current = goal; + + // 1. Always persist work-in-progress to localStorage (debounced) so + // leaving the page does not lose typing. This is independent of + // whether we will trigger a backend save. + if (draftDebounceRef.current != null) { + window.clearTimeout(draftDebounceRef.current); + } + draftDebounceRef.current = window.setTimeout(() => { + try { + if (goal === directive.goal) { + window.localStorage.removeItem(DRAFT_KEY(directive.id)); + } else { + window.localStorage.setItem(DRAFT_KEY(directive.id), goal); + } + } catch { + /* localStorage may be unavailable / full; ignore */ + } + draftDebounceRef.current = null; + }, DRAFT_PERSIST_DEBOUNCE_MS); + + // 2. State-machine. if (goal === directive.goal) { // Edit reverted — cancel the countdown (if any). - if (saveState === "pending") { + if (saveState === "pending" || saveState === "dirty") { cancelTimers(); setSaveState("idle"); } return; } - startOrExtendCountdown(); + + if (liveStart) { + startOrExtendCountdown(); + } else { + // Manual mode: stay "dirty" until the user clicks Save now. + cancelTimers(); + setSaveState("dirty"); + } }, - [directive.goal, saveState, startOrExtendCountdown], + [directive.goal, directive.id, liveStart, saveState, startOrExtendCountdown], ); // ---- Right-click context menu ----------------------------------------- @@ -581,7 +744,17 @@ export function DocumentEditor({ <LexicalComposer key={directive.id} initialConfig={initialConfig}> {/* Capture the editor ref via a tiny inline plugin */} <EditorRefCapture editorRef={editorRef} /> - <SeedContentPlugin directive={directive} /> + <SeedContentPlugin + directive={directive} + onDraftRestored={(draft) => { + pendingGoalRef.current = draft; + if (liveStart) { + startOrExtendCountdown(); + } else { + setSaveState("dirty"); + } + }} + /> <ReadOnlyTitlePlugin title={directive.title} /> <HistoryPlugin /> <GoalChangePlugin onGoalChange={handleGoalChange} /> @@ -614,8 +787,11 @@ export function DocumentEditor({ <SaveCountdownBar state={saveState} remainingMs={remainingMs} - totalMs={SAVE_COUNTDOWN_MS} + liveStart={liveStart} + orchestratorRunning={orchestratorRunning} + onSaveNow={() => void fireSave()} onCancel={cancelCountdown} + onToggleLiveStart={toggleLiveStart} /> {menu && ( diff --git a/makima/frontend/src/components/directives/DocumentTaskStream.tsx b/makima/frontend/src/components/directives/DocumentTaskStream.tsx new file mode 100644 index 0000000..62c1a52 --- /dev/null +++ b/makima/frontend/src/components/directives/DocumentTaskStream.tsx @@ -0,0 +1,338 @@ +/** + * DocumentTaskStream — renders a running task's output as a flowing document + * (assistant prose, tool blocks) instead of the boxy log style of TaskOutput. + * + * 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. + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { SimpleMarkdown } from "../SimpleMarkdown"; +import { + useTaskSubscription, + type TaskOutputEvent, +} from "../../hooks/useTaskSubscription"; +import { getTaskOutput, sendTaskMessage } from "../../lib/api"; + +interface DocumentTaskStreamProps { + taskId: string; + /** Human label used as the document header (e.g. "orchestrator", step name) */ + label: string; +} + +export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) { + const [entries, setEntries] = useState<TaskOutputEvent[]>([]); + const [loading, setLoading] = useState(true); + const [isStreaming, setIsStreaming] = useState(false); + const [comment, setComment] = useState(""); + const [sending, setSending] = useState(false); + const [sendError, setSendError] = useState<string | null>(null); + const containerRef = useRef<HTMLDivElement>(null); + const [autoScroll, setAutoScroll] = useState(true); + + // Load historical output when the selected task changes. + useEffect(() => { + let cancelled = false; + setLoading(true); + setEntries([]); + setIsStreaming(false); + getTaskOutput(taskId) + .then((res) => { + if (cancelled) return; + const mapped: TaskOutputEvent[] = res.entries.map((e) => ({ + taskId: e.taskId, + messageType: e.messageType, + content: e.content, + toolName: e.toolName, + toolInput: e.toolInput, + isError: e.isError, + costUsd: e.costUsd, + durationMs: e.durationMs, + isPartial: false, + })); + setEntries(mapped); + }) + .catch((err) => { + if (cancelled) return; + // eslint-disable-next-line no-console + console.error("Failed to load task output history:", err); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [taskId]); + + const handleOutput = useCallback((event: TaskOutputEvent) => { + if (event.isPartial) return; + setEntries((prev) => [...prev, event]); + setIsStreaming(true); + }, []); + + const handleUpdate = useCallback((event: { status: string }) => { + if ( + event.status === "completed" || + event.status === "failed" || + event.status === "cancelled" + ) { + setIsStreaming(false); + } else if (event.status === "running") { + setIsStreaming(true); + } + }, []); + + useTaskSubscription({ + taskId, + subscribeOutput: true, + onOutput: handleOutput, + onUpdate: handleUpdate, + }); + + // Auto-scroll while at bottom. + useEffect(() => { + if (autoScroll && containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }, [entries, autoScroll]); + + const handleScroll = useCallback(() => { + if (!containerRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = containerRef.current; + const atBottom = scrollHeight - scrollTop - clientHeight < 80; + setAutoScroll(atBottom); + }, []); + + const submitComment = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = comment.trim(); + if (!trimmed || sending) return; + setSending(true); + setSendError(null); + // Show the comment immediately as a user-input entry. + setEntries((prev) => [ + ...prev, + { + taskId, + messageType: "user_input", + content: trimmed, + isPartial: false, + }, + ]); + try { + await sendTaskMessage(taskId, trimmed); + setComment(""); + } catch (err) { + setSendError( + err instanceof Error ? err.message : "Failed to send comment", + ); + window.setTimeout(() => setSendError(null), 5000); + } finally { + setSending(false); + } + }, + [comment, sending, taskId], + ); + + return ( + <div className="flex-1 flex flex-col h-full overflow-hidden bg-[#0a1628]"> + {/* 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="flex items-center gap-3 mb-1"> + <h1 className="text-[24px] font-medium text-white tracking-tight"> + {label} + </h1> + {isStreaming && ( + <span className="flex items-center gap-1.5 px-2 py-0.5 bg-green-400/10 border border-green-400/30 text-green-400 font-mono text-[10px] uppercase"> + <span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" /> + Live + </span> + )} + </div> + <p className="text-[10px] font-mono text-[#556677] uppercase tracking-wide mb-8"> + Live transcript — comments below are sent to the task as input. + </p> + + {loading && entries.length === 0 ? ( + <p className="text-[#556677] font-mono text-xs italic"> + Loading transcript… + </p> + ) : entries.length === 0 ? ( + <p className="text-[#556677] font-mono text-xs italic"> + {isStreaming ? "Waiting for output…" : "No output yet."} + </p> + ) : ( + <div className="space-y-4"> + {entries.map((entry, idx) => ( + <DocumentEntry key={idx} entry={entry} /> + ))} + {isStreaming && ( + <span className="inline-block w-2 h-4 bg-[#9bc3ff] animate-pulse align-baseline" /> + )} + </div> + )} + </div> + </div> + + {/* Comment / interrupt footer */} + <div className="shrink-0 border-t border-dashed border-[rgba(117,170,252,0.25)] bg-[#091428]"> + {sendError && ( + <div className="px-6 py-1 bg-red-900/20 text-red-400 text-xs font-mono"> + {sendError} + </div> + )} + <form + onSubmit={submitComment} + className="max-w-3xl mx-auto px-8 py-4 flex items-start gap-3" + > + <span className="text-[10px] font-mono text-[#556677] uppercase tracking-wide pt-2 shrink-0"> + Comment + </span> + <textarea + value={comment} + onChange={(e) => setComment(e.target.value)} + onKeyDown={(e) => { + // ⌘/Ctrl-Enter submits. + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + void submitComment(e as unknown as React.FormEvent); + } + }} + placeholder={ + isStreaming + ? "Add a comment to interrupt and redirect…" + : "Task is not streaming — comments will queue if accepted." + } + rows={2} + disabled={sending} + className="flex-1 bg-transparent border border-[rgba(117,170,252,0.2)] focus:border-[#75aafc] outline-none px-3 py-2 text-[13px] text-[#dbe7ff] placeholder-[#445566] resize-none" + /> + <button + type="submit" + disabled={sending || !comment.trim()} + className="px-3 py-1.5 font-mono text-[10px] uppercase tracking-wide text-emerald-300 border border-emerald-700/60 hover:border-emerald-400 disabled:opacity-40 disabled:cursor-not-allowed shrink-0" + > + {sending ? "Sending…" : "Send"} + </button> + </form> + </div> + </div> + ); +} + +// --------------------------------------------------------------------------- +// Entry rendering — document-style, not log-style. +// --------------------------------------------------------------------------- + +function DocumentEntry({ entry }: { entry: TaskOutputEvent }) { + switch (entry.messageType) { + case "user_input": + return ( + <blockquote className="border-l-2 border-cyan-400/60 pl-4 py-1 italic text-cyan-200"> + <span className="not-italic text-[10px] font-mono text-cyan-400 uppercase tracking-wide block mb-1"> + You + </span> + {entry.content} + </blockquote> + ); + + case "assistant": + return ( + <div className="leading-relaxed text-[14px]"> + <SimpleMarkdown content={entry.content} className="text-[#e0eaf8]" /> + </div> + ); + + case "system": + return ( + <p className="text-[10px] font-mono text-[#556677] uppercase tracking-wide"> + {entry.content} + </p> + ); + + case "tool_use": + return ( + <p className="text-[11px] font-mono text-[#7788aa] flex items-center gap-2"> + <span className="text-yellow-500">·</span> + <span className="text-[#75aafc]">{entry.toolName || "tool"}</span> + {firstLineOfInput(entry.toolInput) && ( + <span className="text-[#445566] truncate"> + {firstLineOfInput(entry.toolInput)} + </span> + )} + </p> + ); + + case "tool_result": + if (!entry.content) return null; + return ( + <p className="text-[11px] font-mono pl-4"> + <span className={entry.isError ? "text-red-400" : "text-emerald-400"}> + {entry.isError ? "✗" : "→"} + </span>{" "} + <span className="text-[#7788aa]"> + {entry.content.split("\n")[0]} + {entry.content.includes("\n") && "…"} + </span> + </p> + ); + + case "result": + return ( + <div className="border-t border-[rgba(117,170,252,0.15)] pt-3 mt-6"> + <p className="text-[10px] font-mono text-emerald-400 uppercase tracking-wide mb-2"> + Result + </p> + <div className="leading-relaxed text-[13px]"> + <SimpleMarkdown content={entry.content} className="text-[#e0eaf8]" /> + </div> + {(entry.costUsd !== undefined || entry.durationMs !== undefined) && ( + <p className="text-[10px] font-mono text-[#556677] mt-2"> + {entry.durationMs !== undefined && + `Duration: ${(entry.durationMs / 1000).toFixed(1)}s`} + {entry.costUsd !== undefined && entry.durationMs !== undefined && " · "} + {entry.costUsd !== undefined && + `Cost: $${entry.costUsd.toFixed(4)}`} + </p> + )} + </div> + ); + + case "error": + return ( + <p className="border-l-2 border-red-400/60 pl-4 py-1 text-red-300 text-[13px]"> + {entry.content} + </p> + ); + + default: + // Fall back to a quiet rendering for unknown message types so users + // still see the data, just inconspicuously. + if (!entry.content) return null; + return ( + <p className="text-[11px] font-mono text-[#556677]"> + {entry.content} + </p> + ); + } +} + +function firstLineOfInput(input?: Record<string, unknown>): string { + if (!input) return ""; + // Common shapes — show the most informative single value. + for (const key of ["command", "file_path", "path", "url", "pattern", "query"]) { + const v = input[key]; + if (typeof v === "string" && v.length > 0) { + return v.split("\n")[0].slice(0, 96); + } + } + return ""; +} diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx index 42e6a69..687d86f 100644 --- a/makima/frontend/src/routes/document-directives.tsx +++ b/makima/frontend/src/routes/document-directives.tsx @@ -1,10 +1,15 @@ -import { useEffect, useMemo, useState } from "react"; -import { useNavigate, useParams } from "react-router"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate, useParams, useSearchParams } from "react-router"; import { Masthead } from "../components/Masthead"; import { useDirective, useDirectives } from "../hooks/useDirectives"; import { useAuth } from "../contexts/AuthContext"; import { DocumentEditor } from "../components/directives/DocumentEditor"; -import type { DirectiveSummary, DirectiveStatus } from "../lib/api"; +import { DocumentTaskStream } from "../components/directives/DocumentTaskStream"; +import type { + DirectiveStatus, + DirectiveSummary, + DirectiveWithSteps, +} from "../lib/api"; // Status dot color, matching the existing tabular UI's badge palette so the // document mode feels like a sibling of the existing list, not a foreign UI. @@ -16,27 +21,17 @@ const STATUS_DOT: Record<DirectiveStatus, string> = { archived: "bg-[#3a4a6a]", }; -// ============================================================================= -// Sidebar grouping — group directives by lifecycle stage so the file tree -// reads like a folder per status. We collapse the noisy ones (Archived) by -// default and keep Active / Idle expanded. -// ============================================================================= - -type SidebarGroup = "active" | "idle" | "archived"; - -const GROUP_LABEL: Record<SidebarGroup, string> = { - active: "active", - idle: "idle", - archived: "archived", +// Per-task dot color for the sidebar entries inside a directive folder. +// Matches the StepsBlockNode palette. +const STEP_STATUS_DOT: Record<string, string> = { + pending: "bg-[#556677]", + ready: "bg-[#9bc3ff]", + running: "bg-yellow-400", + done: "bg-green-400", + failed: "bg-red-400", + skipped: "bg-[#3a4a6a]", }; -function bucketOf(status: DirectiveStatus): SidebarGroup { - if (status === "active" || status === "paused") return "active"; - if (status === "archived") return "archived"; - // draft + idle land in the idle bucket (i.e. "not currently running"). - return "idle"; -} - // ============================================================================= // Sidebar icons (inline SVG, no new deps) // ============================================================================= @@ -92,6 +87,24 @@ function FileIcon() { ); } +function PinIcon() { + return ( + <svg + viewBox="0 0 16 16" + width={10} + height={10} + className="shrink-0" + aria-hidden + > + <path + d="M8 1.5l1.6 3.6 3.9.4-2.95 2.7.85 3.9L8 10.2 4.6 12.1l.85-3.9L2.5 5.5l3.9-.4z" + fill="#75aafc" + opacity="0.7" + /> + </svg> + ); +} + function Caret({ open }: { open: boolean }) { return ( <svg @@ -110,42 +123,266 @@ function Caret({ open }: { open: boolean }) { // Sidebar // ============================================================================= +function slugify(title: string, fallback: string): string { + const slug = title + .trim() + .replace(/\s+/g, "-") + .replace(/[^a-zA-Z0-9._-]/g, "") + .toLowerCase(); + return slug.length > 0 ? slug : fallback; +} + +interface SidebarSelection { + directiveId: string; + /** null = the directive's document; otherwise a task id (orchestrator/step). */ + taskId: string | null; +} + interface SidebarProps { directives: DirectiveSummary[]; loading: boolean; - selectedId: string | null; - onSelect: (id: string) => void; + selection: SidebarSelection | null; + onSelect: (sel: SidebarSelection) => void; } -function DocumentSidebar({ directives, loading, selectedId, onSelect }: SidebarProps) { - const groups: Record<SidebarGroup, DirectiveSummary[]> = useMemo(() => { - const out: Record<SidebarGroup, DirectiveSummary[]> = { - active: [], - idle: [], - archived: [], - }; - for (const d of directives) { - out[bucketOf(d.status)].push(d); +/** + * Per-directive folder. Renders the directive as a collapsible folder whose + * children are the pinned document entry (always first) and the live task list + * — orchestrator, completion, and any step tasks. We fetch the directive's + * full step list lazily, only when the folder is expanded, to avoid a thundering + * herd of GETs at page load. + */ +function DirectiveFolder({ + directive, + open, + onToggle, + selection, + onSelect, +}: { + directive: DirectiveSummary; + open: boolean; + onToggle: () => void; + selection: SidebarSelection | null; + onSelect: (sel: SidebarSelection) => void; +}) { + const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft; + const fileName = `${slugify(directive.title, directive.id.slice(0, 8))}.md`; + + // Lazy fetch full directive (with steps) only when folder is open. + const { directive: detailed } = useDirective(open ? directive.id : undefined); + + const docSelected = + selection?.directiveId === directive.id && selection.taskId === null; + + // Collect the tasks to surface in the folder body. + const tasks = useMemo(() => collectTasks(detailed, directive), [detailed, directive]); + + return ( + <div className="select-none"> + <button + type="button" + onClick={onToggle} + title={directive.title} + className="w-full flex items-center gap-1.5 pl-3 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]" + > + <Caret open={open} /> + {/* Color icon LEFT — the user explicitly asked for an icon, not a /status text label. */} + <span + className={`inline-block w-2 h-2 rounded-full shrink-0 ${dotColor}`} + aria-label={`status: ${directive.status}`} + title={`status: ${directive.status}`} + /> + <FolderIcon open={open} /> + <span className="truncate flex-1 text-left">{directive.title}</span> + {/* And RIGHT — same dot, plus a pulsing one if the orchestrator is live. */} + {!!directive.orchestratorTaskId && ( + <span + className="inline-block w-1.5 h-1.5 rounded-full shrink-0 bg-yellow-400 animate-pulse" + title="Orchestrator running" + aria-label="Orchestrator running" + /> + )} + <span + className={`inline-block w-2 h-2 rounded-full shrink-0 ${dotColor}`} + aria-hidden + /> + </button> + + {open && ( + <ul className="py-0.5"> + {/* Pinned document entry — always at the top of the folder. */} + <li> + <button + type="button" + onClick={() => + onSelect({ directiveId: directive.id, taskId: null }) + } + className={`w-full text-left flex items-center gap-1.5 pl-8 pr-3 py-1 font-mono text-[11px] transition-colors ${ + docSelected + ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]" + : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent" + }`} + > + <PinIcon /> + <FileIcon /> + <span className="truncate flex-1">{fileName}</span> + </button> + </li> + + {tasks.length === 0 ? ( + <li className="pl-10 pr-3 py-1 font-mono text-[10px] text-[#556677]"> + No tasks yet + </li> + ) : ( + tasks.map((t) => { + const isSelected = + selection?.directiveId === directive.id && + selection?.taskId === t.taskId; + const tdot = STEP_STATUS_DOT[t.status] ?? STEP_STATUS_DOT.pending; + const live = + t.status === "running" || t.kind === "orchestrator-active"; + return ( + <li key={t.taskId}> + <button + type="button" + onClick={() => + onSelect({ + directiveId: directive.id, + taskId: t.taskId, + }) + } + title={t.label} + className={`w-full text-left flex items-center gap-1.5 pl-10 pr-3 py-1 font-mono text-[11px] transition-colors ${ + isSelected + ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]" + : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent" + }`} + > + <span + className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${tdot}`} + aria-hidden + /> + <span className="truncate flex-1">{t.label}</span> + {live && ( + <span + className="inline-block w-1.5 h-1.5 rounded-full shrink-0 bg-yellow-400 animate-pulse" + aria-hidden + /> + )} + </button> + </li> + ); + }) + )} + </ul> + )} + </div> + ); +} + +interface FolderTaskRow { + taskId: string; + label: string; + status: string; + kind: "orchestrator-active" | "completion" | "step"; +} + +function collectTasks( + detailed: DirectiveWithSteps | null, + summary: DirectiveSummary, +): FolderTaskRow[] { + const rows: FolderTaskRow[] = []; + + // Orchestrator (planner) — surfaces only while it's actively running so + // the folder is not flooded with stale orchestrator entries. + const orchestratorId = + detailed?.orchestratorTaskId ?? summary.orchestratorTaskId ?? null; + if (orchestratorId) { + rows.push({ + taskId: orchestratorId, + label: "orchestrator", + status: "running", + kind: "orchestrator-active", + }); + } + + // Completion (PR creation) task. + const completionId = + detailed?.completionTaskId ?? summary.completionTaskId ?? null; + if (completionId) { + rows.push({ + taskId: completionId, + label: "completion", + status: "running", + kind: "completion", + }); + } + + // Step tasks — only steps that have actually been started have a taskId. + if (detailed) { + for (const step of detailed.steps) { + if (!step.taskId) continue; + rows.push({ + taskId: step.taskId, + label: step.name, + status: step.status, + kind: "step", + }); } - // Sort each group alphabetically so it feels like a stable file tree. - (Object.keys(out) as SidebarGroup[]).forEach((k) => { - out[k].sort((a, b) => - a.title.localeCompare(b.title, undefined, { sensitivity: "base" }), - ); + } + + return rows; +} + +interface SidebarProps { + directives: DirectiveSummary[]; + loading: boolean; + selection: SidebarSelection | null; + onSelect: (sel: SidebarSelection) => void; +} + +function DocumentSidebar({ directives, loading, selection, onSelect }: SidebarProps) { + // Sort active first, then idle, then paused, then archived. + const sorted = useMemo(() => { + const order: Record<DirectiveStatus, number> = { + active: 0, + paused: 1, + idle: 2, + draft: 3, + archived: 4, + }; + return [...directives].sort((a, b) => { + const oa = order[a.status] ?? 99; + const ob = order[b.status] ?? 99; + if (oa !== ob) return oa - ob; + return a.title.localeCompare(b.title, undefined, { sensitivity: "base" }); }); - return out; }, [directives]); - // Default-collapsed state per folder. Archived is collapsed by default - // (it's history); the other two are open so users see their work. - const [openGroups, setOpenGroups] = useState<Record<SidebarGroup, boolean>>({ - active: true, - idle: true, - archived: false, - }); - - const toggleGroup = (g: SidebarGroup) => - setOpenGroups((prev) => ({ ...prev, [g]: !prev[g] })); + // Track which directive folders are open. The currently selected directive + // is forced open so deep links land on something visible. + const [openIds, setOpenIds] = useState<Set<string>>(new Set()); + const lastSelectedRef = useRef<string | null>(null); + useEffect(() => { + if (selection && selection.directiveId !== lastSelectedRef.current) { + lastSelectedRef.current = selection.directiveId; + setOpenIds((prev) => { + if (prev.has(selection.directiveId)) return prev; + const next = new Set(prev); + next.add(selection.directiveId); + return next; + }); + } + }, [selection]); + + const toggleOpen = useCallback((id: string) => { + setOpenIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); return ( <div className="flex flex-col h-full"> @@ -159,7 +396,7 @@ function DocumentSidebar({ directives, loading, selectedId, onSelect }: SidebarP </span> </div> - {/* Top-level "directives/" folder */} + {/* Top-level "directives/" folder header (informational, non-interactive). */} <div className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-mono text-[#9bc3ff]"> <FolderIcon open /> <span>directives/</span> @@ -176,74 +413,16 @@ function DocumentSidebar({ directives, loading, selectedId, onSelect }: SidebarP No directives yet </div> ) : ( - (Object.keys(groups) as SidebarGroup[]).map((group) => { - const list = groups[group]; - if (list.length === 0) return null; - const open = openGroups[group]; - return ( - <div key={group} className="select-none"> - {/* Group header (sub-folder) */} - <button - type="button" - onClick={() => toggleGroup(group)} - className="w-full flex items-center gap-1.5 pl-4 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]" - > - <Caret open={open} /> - <FolderIcon open={open} /> - <span>{GROUP_LABEL[group]}/</span> - <span className="ml-auto text-[10px] text-[#556677]"> - {list.length} - </span> - </button> - - {/* Files inside the group */} - {open && ( - <ul className="py-0.5"> - {list.map((d) => { - const isSelected = d.id === selectedId; - const dot = STATUS_DOT[d.status] ?? STATUS_DOT.draft; - const slug = d.title - .trim() - .replace(/\s+/g, "-") - .replace(/[^a-zA-Z0-9._-]/g, "") - .toLowerCase(); - const fileName = - slug.length > 0 ? `${slug}.md` : `${d.id.slice(0, 8)}.md`; - const orchestratorRunning = !!d.orchestratorTaskId; - return ( - <li key={d.id}> - <button - type="button" - onClick={() => onSelect(d.id)} - title={d.title} - className={`w-full text-left flex items-center gap-1.5 pl-9 pr-3 py-1 font-mono text-[11px] transition-colors ${ - isSelected - ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]" - : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent" - }`} - > - <FileIcon /> - <span - className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dot}`} - aria-hidden - /> - <span className="truncate flex-1">{fileName}</span> - {orchestratorRunning && ( - <span - className="inline-block w-1.5 h-1.5 rounded-full shrink-0 bg-yellow-400 animate-pulse" - title="Orchestrator running" - aria-label="Orchestrator running" - /> - )} - </button> - </li> - ); - })} - </ul> - )} - </div> - ); - }) + sorted.map((d) => ( + <DirectiveFolder + key={d.id} + directive={d} + open={openIds.has(d.id)} + onToggle={() => toggleOpen(d.id)} + selection={selection} + onSelect={onSelect} + /> + )) )} </div> </div> @@ -257,11 +436,19 @@ function DocumentSidebar({ directives, loading, selectedId, onSelect }: SidebarP interface EditorShellProps { selectedId: string | undefined; + selectedTaskId: string | null; hasDirectives: boolean; listLoading: boolean; + onClearTask: () => void; } -function EditorShell({ selectedId, hasDirectives, listLoading }: EditorShellProps) { +function EditorShell({ + selectedId, + selectedTaskId, + hasDirectives, + listLoading, + onClearTask, +}: EditorShellProps) { const { directive, loading, @@ -301,6 +488,16 @@ function EditorShell({ selectedId, hasDirectives, listLoading }: EditorShellProp ); } + // Resolve the label for the breadcrumb when a task is selected. + const taskLabel = selectedTaskId + ? selectedTaskId === directive.orchestratorTaskId + ? "orchestrator" + : selectedTaskId === directive.completionTaskId + ? "completion" + : directive.steps.find((s) => s.taskId === selectedTaskId)?.name ?? + selectedTaskId.slice(0, 8) + : null; + return ( <div className="flex-1 flex flex-col h-full overflow-hidden"> {/* Document header — breadcrumb-like, mirrors a code editor's tab bar */} @@ -309,7 +506,20 @@ function EditorShell({ selectedId, hasDirectives, listLoading }: EditorShellProp <FileIcon /> <span>directives /</span> <span className="text-[#9bc3ff]">{directive.id.slice(0, 8)}</span> - {!!directive.orchestratorTaskId && ( + {selectedTaskId && ( + <> + <span>/</span> + <span className="text-[#9bc3ff]">{taskLabel}</span> + <button + type="button" + onClick={onClearTask} + className="ml-2 px-1.5 py-0.5 text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded normal-case" + > + back to document + </button> + </> + )} + {!selectedTaskId && !!directive.orchestratorTaskId && ( <span className="ml-2 inline-flex items-center gap-1 text-yellow-400"> <span className="inline-block w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse" /> orchestrator running @@ -318,22 +528,28 @@ function EditorShell({ selectedId, hasDirectives, listLoading }: EditorShellProp </div> </div> - {/* Lexical editor body */} - <DocumentEditor - directive={directive} - onUpdateGoal={async (goal) => { - await updateGoal(goal); - }} - onCleanup={async () => { - await cleanup(); - }} - onCreatePR={async () => { - await createPR(); - }} - onPickUpOrders={async () => { - await pickUpOrders(); - }} - /> + {selectedTaskId ? ( + <DocumentTaskStream + taskId={selectedTaskId} + label={taskLabel ?? selectedTaskId.slice(0, 8)} + /> + ) : ( + <DocumentEditor + directive={directive} + onUpdateGoal={async (goal) => { + await updateGoal(goal); + }} + onCleanup={async () => { + await cleanup(); + }} + onCreatePR={async () => { + await createPR(); + }} + onPickUpOrders={async () => { + await pickUpOrders(); + }} + /> + )} </div> ); } @@ -346,6 +562,8 @@ export default function DocumentDirectivesPage() { const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); const navigate = useNavigate(); const { id: selectedId } = useParams<{ id: string }>(); + const [searchParams, setSearchParams] = useSearchParams(); + const selectedTaskId = searchParams.get("task"); const { directives, loading: listLoading } = useDirectives(); useEffect(() => { @@ -354,6 +572,22 @@ export default function DocumentDirectivesPage() { } }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); + const onSelect = useCallback( + (sel: SidebarSelection) => { + const next = `/directives/${sel.directiveId}${ + sel.taskId ? `?task=${sel.taskId}` : "" + }`; + navigate(next); + }, + [navigate], + ); + + const onClearTask = useCallback(() => { + const next = new URLSearchParams(searchParams); + next.delete("task"); + setSearchParams(next, { replace: true }); + }, [searchParams, setSearchParams]); + if (authLoading) { return ( <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> @@ -365,6 +599,10 @@ export default function DocumentDirectivesPage() { ); } + const selection: SidebarSelection | null = selectedId + ? { directiveId: selectedId, taskId: selectedTaskId } + : null; + return ( <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> <Masthead showNav /> @@ -373,20 +611,22 @@ export default function DocumentDirectivesPage() { style={{ height: "calc(100vh - 80px)" }} > {/* Left: file-tree sidebar */} - <div className="w-[240px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col bg-[#091428]"> + <div className="w-[260px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col bg-[#091428]"> <DocumentSidebar directives={directives} loading={listLoading} - selectedId={selectedId ?? null} - onSelect={(id) => navigate(`/directives/${id}`)} + selection={selection} + onSelect={onSelect} /> </div> - {/* Right: Lexical editor */} + {/* Right: Lexical editor / task stream */} <EditorShell selectedId={selectedId} + selectedTaskId={selectedTaskId} hasDirectives={directives.length > 0} listLoading={listLoading} + onClearTask={onClearTask} /> </main> </div> |
