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 { OverlayDiffViewer } from "../mesh/OverlayDiffViewer";
import { getTaskOutput, getTaskDiff } 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);
const [showDiff, setShowDiff] = useState(false);
const [diffContent, setDiffContent] = useState<string>("");
const [diffLoading, setDiffLoading] = useState(false);
// Escape key handler
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
// If diff is showing, close diff first; otherwise close panel
if (e.key === "Escape") {
if (selectedFileDiff !== null || diffLoading) {
setSelectedFileDiff(null);
setSelectedFilePath(null);
setDiffLoading(false);
} else {
onClose();
}
}
};
if (isOpen) document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}, [isOpen, onClose, selectedFileDiff, diffLoading]);
// Load historical output when panel opens with a taskId
useEffect(() => {
if (!isOpen || !taskId) {
setEntries([]);
setIsStreaming(false);
// Reset diff state when panel closes
setSelectedFileDiff(null);
setSelectedFilePath(null);
setDiffLoading(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);
}
},
[]
);
// Handle file click to show diff
const handleFileClick = useCallback(async (_filePath: string) => {
if (!taskId) return;
setDiffLoading(true);
try {
const result = await getTaskDiff(taskId);
if (result.success && result.diff) {
setDiffContent(result.diff);
setShowDiff(true);
}
} catch (e) {
console.error("Failed to get diff:", e);
} finally {
setDiffLoading(false);
}
}, [taskId]);
// Subscribe to live output
useTaskSubscription({
taskId: isOpen ? taskId : null,
subscribeOutput: isOpen && !!taskId,
onOutput: handleOutput,
onUpdate: handleUpdate,
});
const showingDiff = selectedFileDiff !== null || diffLoading;
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">
{showingDiff && (
<button
type="button"
onClick={() => {
setSelectedFileDiff(null);
setSelectedFilePath(null);
setDiffLoading(false);
}}
className="text-[#75aafc] hover:text-white font-mono text-xs transition-colors shrink-0 mr-1"
title="Back to worktree view"
>
←
</button>
)}
<span className="text-[10px] font-mono text-[#75aafc] uppercase tracking-wide shrink-0">
{showingDiff ? "Diff" : "Task"}
</span>
<span className="text-[12px] font-mono text-white truncate">
{showingDiff ? (selectedFilePath || "Loading...") : (taskName || taskId)}
</span>
{!showingDiff && 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
onClick={async () => {
if (!taskId) return;
setDiffLoading(true);
try {
const result = await getTaskDiff(taskId);
if (result.success && result.diff) {
setDiffContent(result.diff);
setShowDiff(true);
}
} catch (e) {
console.error("Failed to get diff:", e);
} finally {
setDiffLoading(false);
}
}}
className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff] transition-colors px-1.5 py-0.5 border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] shrink-0"
>
{diffLoading ? "Loading..." : "View Diff"}
</button>
<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">
{showingDiff ? (
/* Diff view replaces the worktree panel content */
<div className="flex-1 min-h-0 overflow-y-auto">
<OverlayDiffViewer
diff={selectedFileDiff || ""}
loading={diffLoading}
onClose={() => {
setSelectedFileDiff(null);
setSelectedFilePath(null);
setDiffLoading(false);
}}
title={selectedFilePath ? `Diff: ${selectedFilePath}` : "File Diff"}
/>
</div>
) : (
<>
{/* 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} onFileClick={handleFileClick} />}
</div>
</div>
</div>
{/* Diff modal */}
{showDiff && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4">
<div className="max-w-4xl w-full max-h-[80vh]">
<OverlayDiffViewer
diff={diffContent}
onClose={() => setShowDiff(false)}
title={`Changes in ${taskName || taskId}`}
/>
</div>
</div>
)}
</>
);
}