summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components/directives/TaskSlideOutPanel.tsx')
-rw-r--r--makima/frontend/src/components/directives/TaskSlideOutPanel.tsx179
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>
+ </>
+ );
+}