From ee7d5c633f3b6acb9153554297ee4795f9773aef Mon Sep 17 00:00:00 2001 From: soryu Date: Thu, 30 Apr 2026 01:48:11 +0100 Subject: feat(document-mode): longer goal save countdown, per-directive folders, live task stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= 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) --- .../src/components/directives/DocumentEditor.tsx | 266 +++++++++-- .../components/directives/DocumentTaskStream.tsx | 338 ++++++++++++++ makima/frontend/src/routes/document-directives.tsx | 520 +++++++++++++++------ 3 files changed, 939 insertions(+), 185 deletions(-) create mode 100644 makima/frontend/src/components/directives/DocumentTaskStream.tsx (limited to 'makima/frontend') 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(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}%` }} /> -
- {label} - {state === "pending" && ( +
+ {label} + + {/* Live-start toggle is always shown so users can flip it from the bar. */} + + + {(state === "dirty" || state === "pending") && ( + + )} + {(state === "dirty" || state === "pending") && ( )}
@@ -415,7 +513,7 @@ export interface DocumentEditorProps { onPickUpOrders: () => Promise | 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(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("idle"); - const [remainingMs, setRemainingMs] = useState(SAVE_COUNTDOWN_MS); + const [remainingMs, setRemainingMs] = useState(countdownMs); const pendingGoalRef = useRef(directive.goal); const timerRef = useRef(null); const tickRef = useRef(null); const deadlineRef = useRef(0); + const draftDebounceRef = useRef(null); const editorRef = useRef(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({ {/* Capture the editor ref via a tiny inline plugin */} - + { + pendingGoalRef.current = draft; + if (liveStart) { + startOrExtendCountdown(); + } else { + setSaveState("dirty"); + } + }} + /> @@ -614,8 +787,11 @@ export function DocumentEditor({ 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([]); + const [loading, setLoading] = useState(true); + const [isStreaming, setIsStreaming] = useState(false); + const [comment, setComment] = useState(""); + const [sending, setSending] = useState(false); + const [sendError, setSendError] = useState(null); + const containerRef = useRef(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 ( +
+ {/* Document body */} +
+
+
+

+ {label} +

+ {isStreaming && ( + + + Live + + )} +
+

+ Live transcript — comments below are sent to the task as input. +

+ + {loading && entries.length === 0 ? ( +

+ Loading transcript… +

+ ) : entries.length === 0 ? ( +

+ {isStreaming ? "Waiting for output…" : "No output yet."} +

+ ) : ( +
+ {entries.map((entry, idx) => ( + + ))} + {isStreaming && ( + + )} +
+ )} +
+
+ + {/* Comment / interrupt footer */} +
+ {sendError && ( +
+ {sendError} +
+ )} +
+ + Comment + +