From 2b695485753d55f956746b73c31c2deba0ed0a29 Mon Sep 17 00:00:00 2001 From: soryu Date: Thu, 30 Apr 2026 09:57:07 +0100 Subject: feat(document-mode): longer goal save countdown, per-directive folders, live task stream (#103) 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) --- .../components/directives/DocumentTaskStream.tsx | 338 +++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 makima/frontend/src/components/directives/DocumentTaskStream.tsx (limited to 'makima/frontend/src/components/directives/DocumentTaskStream.tsx') 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 + +