diff options
Diffstat (limited to 'makima/frontend/src/components/directives/TaskSlideOutPanel.tsx')
| -rw-r--r-- | makima/frontend/src/components/directives/TaskSlideOutPanel.tsx | 179 |
1 files changed, 179 insertions, 0 deletions
diff --git a/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx b/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx new file mode 100644 index 0000000..29fce23 --- /dev/null +++ b/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx @@ -0,0 +1,179 @@ +import { useState, useEffect, useCallback } from "react"; +import { useTaskSubscription } from "../../hooks/useTaskSubscription"; +import type { TaskOutputEvent } from "../../hooks/useTaskSubscription"; +import { TaskOutput } from "../mesh/TaskOutput"; +import { WorktreeFilesPanel } from "../mesh/WorktreeFilesPanel"; +import { getTaskOutput } from "../../lib/api"; + +interface TaskSlideOutPanelProps { + taskId: string; + taskName?: string; + isOpen: boolean; + onClose: () => void; +} + +export function TaskSlideOutPanel({ + taskId, + taskName, + isOpen, + onClose, +}: TaskSlideOutPanelProps) { + const [entries, setEntries] = useState<TaskOutputEvent[]>([]); + const [isStreaming, setIsStreaming] = useState(false); + const [loadingHistory, setLoadingHistory] = useState(false); + + // Escape key handler + useEffect(() => { + const handleEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + if (isOpen) document.addEventListener("keydown", handleEsc); + return () => document.removeEventListener("keydown", handleEsc); + }, [isOpen, onClose]); + + // Load historical output when panel opens with a taskId + useEffect(() => { + if (!isOpen || !taskId) { + setEntries([]); + setIsStreaming(false); + return; + } + + let cancelled = false; + setLoadingHistory(true); + + getTaskOutput(taskId) + .then((res) => { + if (cancelled) return; + // Map TaskOutputEntry to TaskOutputEvent + 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; + console.error("Failed to load task output history:", err); + }) + .finally(() => { + if (!cancelled) setLoadingHistory(false); + }); + + return () => { + cancelled = true; + }; + }, [isOpen, taskId]); + + // Handle live output events + const handleOutput = useCallback( + (event: TaskOutputEvent) => { + if (event.isPartial) return; + setEntries((prev) => [...prev, event]); + setIsStreaming(true); + }, + [] + ); + + // Handle task updates (to detect completion) + 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); + } + }, + [] + ); + + // Subscribe to live output + useTaskSubscription({ + taskId: isOpen ? taskId : null, + subscribeOutput: isOpen && !!taskId, + onOutput: handleOutput, + onUpdate: handleUpdate, + }); + + return ( + <> + {/* Backdrop overlay */} + <div + className={`fixed inset-0 bg-black/30 z-50 transition-opacity duration-300 ${ + isOpen ? "opacity-100" : "opacity-0 pointer-events-none" + }`} + onClick={onClose} + /> + + {/* Slide-out panel */} + <div + className={`fixed top-0 right-0 h-full w-[550px] max-w-[90vw] z-50 bg-[#0d1117] border-l border-[rgba(117,170,252,0.2)] shadow-xl shadow-black/50 flex flex-col transition-transform duration-300 ease-in-out ${ + isOpen ? "translate-x-0" : "translate-x-full" + }`} + > + {/* Header */} + <div className="flex items-center justify-between px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.25)] shrink-0"> + <div className="flex items-center gap-2 min-w-0 flex-1"> + <span className="text-[10px] font-mono text-[#75aafc] uppercase tracking-wide shrink-0"> + Task + </span> + <span className="text-[12px] font-mono text-white truncate"> + {taskName || taskId} + </span> + {isStreaming && ( + <span className="flex items-center gap-1 px-1.5 py-0.5 bg-green-400/10 border border-green-400/30 shrink-0"> + <span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" /> + <span className="text-green-400 font-mono text-[9px] uppercase"> + Live + </span> + </span> + )} + </div> + <button + type="button" + onClick={onClose} + className="text-[#7788aa] hover:text-white font-mono text-sm transition-colors ml-2 shrink-0 w-6 h-6 flex items-center justify-center" + > + ✕ + </button> + </div> + + {/* Content */} + <div className="flex-1 flex flex-col min-h-0 overflow-hidden"> + {/* Task Output section (~60% height) */} + <div className="flex-[3] min-h-0 flex flex-col border-b border-[rgba(117,170,252,0.15)]"> + {loadingHistory ? ( + <div className="flex-1 flex items-center justify-center"> + <span className="font-mono text-xs text-[#555] animate-pulse"> + Loading output... + </span> + </div> + ) : ( + <TaskOutput + entries={entries} + isStreaming={isStreaming} + taskId={taskId} + /> + )} + </div> + + {/* Worktree Changes section (~40% height) */} + <div className="flex-[2] min-h-0 overflow-y-auto"> + {taskId && <WorktreeFilesPanel taskId={taskId} />} + </div> + </div> + </div> + </> + ); +} |
