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 /makima/frontend/src/components/directives/DocumentTaskStream.tsx | |
| 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>
Diffstat (limited to 'makima/frontend/src/components/directives/DocumentTaskStream.tsx')
| -rw-r--r-- | makima/frontend/src/components/directives/DocumentTaskStream.tsx | 338 |
1 files changed, 338 insertions, 0 deletions
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 ""; +} |
