/** * TaskPage — the right-pane view for a selected task in document mode. * * Layout (replaces the old single-column DocumentTaskStream): * * ┌───────────────────────────────────────────────────────────┐ * │ Header: title · status · branch · Stop │ * ├────────────────────────┬──────────────────────────────────┤ * │ Changed files (~30%) │ Transcript feed (scrollable) │ * │ src/foo.rs │ [user] do thing │ * │ src/bar.rs │ [tool] Read foo.rs │ * │ (selected file diff) │ │ * │ + added │ │ * │ - removed │ │ * │ ├──────────────────────────────────┤ * │ │ Composer (sticky bottom) │ * └────────────────────────┴──────────────────────────────────┘ * * Diff data comes from getTaskDiff(); we parse it with parseDiff (reused * from OverlayDiffViewer) so we don't duplicate the parser. The file * list on the left is the parsed file paths; selecting one filters the * diff render to that single file. Refresh button re-fetches on demand; * by default the diff loads once on mount + after each task status * change so the user sees fresh changes as soon as the daemon commits. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { SimpleMarkdown } from "../SimpleMarkdown"; import { useTaskSubscription, type TaskOutputEvent, } from "../../hooks/useTaskSubscription"; import { getTaskOutput, getTaskDiff, sendTaskMessage, stopTask } from "../../lib/api"; import { parseDiff, DiffFileView, type DiffFile } from "../mesh/OverlayDiffViewer"; interface TaskPageProps { taskId: string; /** Human label for the task header (e.g. "orchestrator", step name). */ label: string; /** True for tasks spawned via the directive's `+ New ephemeral task` * action (no backing step). Drives the optional "Merge to base" * affordance — step-spawned tasks merge via the directive's PR. */ ephemeral?: boolean; /** Current task status — drives whether merge button is enabled. */ status?: string; } // Module-level caches so navigation between tasks is instant on re-visit. const entriesCache = new Map(); const diffCache = new Map(); export function TaskPage({ taskId, label, ephemeral, status }: TaskPageProps) { // ---- Transcript state (mirrors the old DocumentTaskStream) ---- 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 transcriptRef = useRef(null); const composerRef = useRef(null); const autoScrollRef = useRef(true); const [showResumeScroll, setShowResumeScroll] = useState(false); // ---- Diff state ---- const [diffText, setDiffText] = useState(() => diffCache.get(taskId) ?? ""); const [diffLoading, setDiffLoading] = useState(false); const [diffError, setDiffError] = useState(null); const [selectedFilePath, setSelectedFilePath] = useState(null); const [collapsedFiles, setCollapsedFiles] = useState>(new Set()); // Reset both panes when the task id changes. useEffect(() => { setSelectedFilePath(null); setCollapsedFiles(new Set()); setDiffText(diffCache.get(taskId) ?? ""); }, [taskId]); // ---- Load historical transcript on task change. ---- 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]); // ---- Load diff on task change + after each task status update. ---- const refreshDiff = useCallback(async () => { setDiffLoading(true); setDiffError(null); try { const res = await getTaskDiff(taskId); if (res.success && res.diff !== null) { setDiffText(res.diff); diffCache.set(taskId, res.diff); } else if (res.error) { setDiffError(res.error); } else { setDiffText(""); diffCache.set(taskId, ""); } } catch (e) { setDiffError(e instanceof Error ? e.message : "Failed to load diff"); } finally { setDiffLoading(false); } }, [taskId]); useEffect(() => { void refreshDiff(); }, [refreshDiff]); // ---- Live subscription. ---- 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 }) => { const terminal = [ "completed", "failed", "cancelled", "interrupted", "merged", "done", ]; if (terminal.includes(event.status)) { setIsStreaming(false); // Daemon may have written final commits — refresh the diff. void refreshDiff(); } else if (event.status === "running") { setIsStreaming(true); } }, [refreshDiff], ); useTaskSubscription({ taskId, subscribeOutput: true, onOutput: handleOutput, onUpdate: handleUpdate, }); // ---- Auto-scroll (transcript pane). ---- useEffect(() => { if (autoScrollRef.current && transcriptRef.current) { transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight; } }, [entries]); useEffect(() => { if (!loading && transcriptRef.current) { transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight; autoScrollRef.current = true; setShowResumeScroll(false); } }, [loading, taskId]); const handleScroll = useCallback(() => { if (!transcriptRef.current) return; const { scrollTop, scrollHeight, clientHeight } = transcriptRef.current; const distanceFromBottom = scrollHeight - scrollTop - clientHeight; const atBottom = distanceFromBottom < 80; autoScrollRef.current = atBottom; setShowResumeScroll(!atBottom); }, []); const resumeScroll = useCallback(() => { if (!transcriptRef.current) return; transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight; autoScrollRef.current = true; setShowResumeScroll(false); }, []); // ---- Composer + stop. ---- const submitComment = useCallback( async (e: React.FormEvent) => { e.preventDefault(); const trimmed = comment.trim(); if (!trimmed || sending) return; setSending(true); setSendError(null); 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(); }, []); // ---- Parse diff once, derive file list. ---- const parsedFiles = useMemo(() => parseDiff(diffText), [diffText]); // Default selection: first file in the parsed list. useEffect(() => { if (!selectedFilePath && parsedFiles.length > 0) { setSelectedFilePath(parsedFiles[0].path); } }, [parsedFiles, selectedFilePath]); const visibleFiles: DiffFile[] = useMemo(() => { if (!selectedFilePath) return parsedFiles; const match = parsedFiles.find((f) => f.path === selectedFilePath); return match ? [match] : parsedFiles; }, [parsedFiles, selectedFilePath]); const toggleFile = (path: string) => { setCollapsedFiles((prev) => { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); return next; }); }; return (
{/* Header strip — title + live indicator + actions. */}

{label}

{isStreaming && ( Live )} {status && ( {status} )}
{ephemeral && isTerminalStatus(status) && ( )}
{/* Two-column body. */}
{/* Left: changed files + diff. */}
Changed files {parsedFiles.length}
{/* File list (top half of left pane). */}
{parsedFiles.length === 0 ? (
{diffLoading ? "Loading diff…" : diffError ? diffError : "No changes yet"}
) : ( parsedFiles.map((f) => ( )) )}
{/* Diff content (bottom of left pane). */}
{visibleFiles.length === 0 ? (
Select a file to view its diff.
) : ( visibleFiles.map((file) => ( toggleFile(file.path)} /> )) )}
{/* Right: transcript + sticky composer. */}
{loading && entries.length === 0 ? (

Loading transcript…

) : entries.length === 0 ? (

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

) : (
{entries.map((entry, idx) => ( ))} {isStreaming && ( )}
)}
{showResumeScroll && ( )}
{sendError && (
{sendError}
)}
Comment