/** * 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. * - 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, 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; } // ============================================================================= // 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(); export function DocumentTaskStream({ taskId, label, ephemeral, status, }: DocumentTaskStreamProps) { const navigate = useNavigate(); const [entries, setEntries] = useState( () => 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(null); const [stopping, setStopping] = useState(false); const containerRef = useRef(null); const composerRef = useRef(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. Render the cache // immediately if we have it; refetch in the background regardless. useEffect(() => { let cancelled = false; const cached = entriesCache.get(taskId); if (cached) { setEntries(cached); setLoading(false); } else { setEntries([]); setLoading(true); } 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, })); entriesCache.set(taskId, mapped); 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) => { 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 === "interrupted" || event.status === "merged" || event.status === "done" ) { setIsStreaming(false); } else if (event.status === "running") { setIsStreaming(true); } }, []); useTaskSubscription({ taskId, subscribeOutput: true, onOutput: handleOutput, onUpdate: handleUpdate, }); // 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 (!loading && containerRef.current) { containerRef.current.scrollTop = containerRef.current.scrollHeight; autoScrollRef.current = true; setShowResumeScroll(false); } }, [loading, taskId]); const handleScroll = useCallback(() => { if (!containerRef.current) return; const { scrollTop, scrollHeight, clientHeight } = containerRef.current; const distanceFromBottom = scrollHeight - scrollTop - clientHeight; const atBottom = distanceFromBottom < 80; autoScrollRef.current = atBottom; setShowResumeScroll(!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) => { const next: TaskOutputEvent[] = [ ...prev, { taskId, messageType: "user_input", content: trimmed, isPartial: false, }, ]; entriesCache.set(taskId, next); return next; }); 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], ); 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 (
{/* Action header strip — explicit Stop / Send / Open-in-task-page so users don't have to right-click to discover task controls. */}
Task actions {/* 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) && ( )}
{/* 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 && ( )}
)}
{/* "Resume auto-scroll" floating chip when the user has scrolled up. */} {showResumeScroll && ( )} {/* Sticky comment composer — always pinned to the viewport bottom so users can interact with the task no matter where they've scrolled. */}
{sendError && (
{sendError}
)}
Comment