import { useState, useMemo, useEffect, useRef } from "react";
import type { DirectiveWithSteps, DirectiveStatus, UpdateDirectiveRequest, DirectiveOrderGroup, CreateDOGRequest, UpdateDOGRequest } from "../../lib/api";
import { DirectiveDAG } from "./DirectiveDAG";
import type { SpecializedStep } from "./DirectiveDAG";
import { DirectiveLogStream } from "./DirectiveLogStream";
import { TaskSlideOutPanel } from "./TaskSlideOutPanel";
import { DOGList } from "./DOGList";
import { useMultiTaskSubscription } from "../../hooks/useMultiTaskSubscription";
import { useSupervisorQuestions } from "../../contexts/SupervisorQuestionsContext";
const STATUS_BADGE: Record<DirectiveStatus, { color: string; label: string }> = {
draft: { color: "text-[#7788aa] border-[#2a3a5a]", label: "DRAFT" },
active: { color: "text-green-400 border-green-800", label: "ACTIVE" },
idle: { color: "text-yellow-400 border-yellow-800", label: "IDLE" },
paused: { color: "text-orange-400 border-orange-800", label: "PAUSED" },
inactive: { color: "text-[#9bc3ff] border-[#3f6fb3]", label: "INACTIVE" },
archived: { color: "text-[#556677] border-[#2a3a5a]", label: "ARCHIVED" },
};
interface DirectiveDetailProps {
directive: DirectiveWithSteps;
onStart: () => void;
onPause: () => void;
onAdvance: () => void;
onCompleteStep: (stepId: string) => void;
onFailStep: (stepId: string) => void;
onSkipStep: (stepId: string) => void;
onUpdateGoal: (goal: string) => void;
onUpdate: (req: UpdateDirectiveRequest) => void;
onDelete: () => void;
onRefresh: () => void;
onCleanup: () => void;
onPickUpOrders: () => Promise<{ message: string; orderCount: number; taskId: string | null } | null>;
onCreatePR: () => Promise<void>;
dogs: DirectiveOrderGroup[];
dogsLoading: boolean;
onCreateDog: (req: CreateDOGRequest) => Promise<DirectiveOrderGroup | null>;
onUpdateDog: (dogId: string, req: UpdateDOGRequest) => Promise<void>;
onDeleteDog: (dogId: string) => Promise<void>;
onPickUpDogOrders: (dogId: string) => Promise<any>;
}
export function DirectiveDetail({
directive,
onStart,
onPause,
onAdvance,
onCompleteStep,
onFailStep,
onSkipStep,
onUpdateGoal,
onUpdate,
onDelete,
onRefresh,
onCleanup,
onPickUpOrders,
onCreatePR,
dogs,
dogsLoading,
onCreateDog,
onUpdateDog,
onDeleteDog,
onPickUpDogOrders,
}: DirectiveDetailProps) {
const [activeTab, setActiveTab] = useState<"steps" | "dogs">("steps");
const [editingGoal, setEditingGoal] = useState(false);
const [goalText, setGoalText] = useState(directive.goal);
const [visibleTaskIds, setVisibleTaskIds] = useState<Set<string> | null>(null);
const [pickingUpOrders, setPickingUpOrders] = useState(false);
const [pickUpResult, setPickUpResult] = useState<string | null>(null);
const [creatingPR, setCreatingPR] = useState(false);
const [slideOutTaskId, setSlideOutTaskId] = useState<string | null>(null);
const handleViewTask = (taskId: string) => {
setSlideOutTaskId(taskId);
};
// Sync goalText and reset editing state when directive changes
useEffect(() => {
setGoalText(directive.goal);
setEditingGoal(false);
}, [directive.id, directive.goal]);
const [searchQuery, setSearchQuery] = useState("");
const [isLogCollapsed, setIsLogCollapsed] = useState(true);
const prevHadRunningRef = useRef(false);
const badge = STATUS_BADGE[directive.status] || STATUS_BADGE.draft;
const completedSteps = directive.steps.filter((s) => s.status === "completed").length;
const totalSteps = directive.steps.length;
const progress = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
// Get pending questions for this directive's tasks
const { pendingQuestions, submitAnswer } = useSupervisorQuestions();
const directiveTaskIds = useMemo(() => {
const ids = new Set<string>();
if (directive.orchestratorTaskId) ids.add(directive.orchestratorTaskId);
for (const step of directive.steps) {
if (step.taskId) ids.add(step.taskId);
}
return ids;
}, [directive.orchestratorTaskId, directive.steps]);
const directiveQuestions = useMemo(
() => pendingQuestions.filter((q) =>
q.directiveId === directive.id || directiveTaskIds.has(q.taskId)
),
[pendingQuestions, directive.id, directiveTaskIds]
);
// Build task map from directive steps and orchestrator
// Derive a stable key from the actual task IDs to avoid recreating the map on every poll
const taskMapKey = useMemo(() => {
const parts: string[] = [];
if (directive.orchestratorTaskId) parts.push(`o:${directive.orchestratorTaskId}`);
for (const step of directive.steps) {
if (step.taskId) parts.push(`${step.id}:${step.taskId}`);
}
return parts.join(",");
}, [directive.orchestratorTaskId, directive.steps]);
const taskMap = useMemo(() => {
const map = new Map<string, string>();
if (directive.orchestratorTaskId) {
map.set(directive.orchestratorTaskId, "Orchestrator");
}
for (const step of directive.steps) {
if (step.taskId) {
map.set(step.taskId, step.name);
}
}
return map;
}, [taskMapKey]); // eslint-disable-line react-hooks/exhaustive-deps
// Build specialized steps for DAG visualization
const specializedSteps = useMemo(() => {
const steps: SpecializedStep[] = [];
if (directive.orchestratorTaskId) {
steps.push({
id: `orchestrator-${directive.orchestratorTaskId}`,
name: taskMap.get(directive.orchestratorTaskId) || "Planning",
type: "orchestrator",
taskId: directive.orchestratorTaskId,
status: "running",
});
}
if (directive.completionTaskId) {
steps.push({
id: `completion-${directive.completionTaskId}`,
name: directive.prUrl ? "Updating PR" : "Creating PR",
type: "completion",
taskId: directive.completionTaskId,
status: "running",
});
}
return steps;
}, [directive.orchestratorTaskId, directive.completionTaskId, directive.prUrl, taskMap]);
// Subscribe to all task outputs
const { connected, entries, clearEntries } = useMultiTaskSubscription({
taskMap,
enabled: taskMap.size > 0,
});
// Auto-expand log panel when tasks start running
const hasRunningTasks = directive.steps.some((s) => s.status === "running") ||
!!directive.orchestratorTaskId;
useEffect(() => {
if (hasRunningTasks && !prevHadRunningRef.current) {
setIsLogCollapsed(false);
}
prevHadRunningRef.current = hasRunningTasks;
}, [hasRunningTasks]);
const handlePickUpOrders = async () => {
setPickingUpOrders(true);
setPickUpResult(null);
try {
const result = await onPickUpOrders();
if (result) {
setPickUpResult(result.message);
setTimeout(() => setPickUpResult(null), 5000);
}
} catch (e) {
setPickUpResult(e instanceof Error ? e.message : "Failed to pick up orders");
setTimeout(() => setPickUpResult(null), 5000);
} finally {
setPickingUpOrders(false);
}
};
const handleGoalSave = () => {
if (goalText.trim() && goalText !== directive.goal) {
onUpdateGoal(goalText.trim());
}
setEditingGoal(false);
};
// Find the task name for the slide-out panel
const slideOutTaskName = slideOutTaskId
? (directive.steps.find((s) => s.taskId === slideOutTaskId)?.name ??
taskMap.get(slideOutTaskId) ??
undefined)
: undefined;
return (
<>
<div className="flex flex-col h-full overflow-y-auto">
{/* Header */}
<div className="px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]">
<div className="flex items-center justify-between mb-2">
<h2 className="text-[14px] font-mono text-white font-medium truncate pr-2">
{directive.title}
</h2>
<div className="flex items-center gap-2 shrink-0">
<span
className={`text-[10px] font-mono ${badge.color} border rounded px-2 py-0.5`}
>
{badge.label}
</span>
<button
type="button"
onClick={onRefresh}
className="text-[10px] font-mono text-[#7788aa] hover:text-white"
title="Refresh"
>
[refresh]
</button>
</div>
</div>
{/* Progress bar */}
{totalSteps > 0 && (
<div className="flex items-center gap-2 mb-2">
<div className="flex-1 h-1.5 bg-[#1a2540] rounded overflow-hidden">
<div
className="h-full bg-emerald-600 rounded transition-all"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-[10px] font-mono text-[#7788aa] shrink-0">
{completedSteps}/{totalSteps} steps
</span>
</div>
)}
{/* Repo info */}
{(directive.repositoryUrl || directive.localPath) && (
<div className="text-[10px] font-mono text-[#556677] mb-2 truncate">
{directive.repositoryUrl || directive.localPath}
{directive.baseBranch && ` @ ${directive.baseBranch}`}
</div>
)}
{/* Reconcile mode toggle */}
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center border border-[#2a3a5a] rounded overflow-hidden">
{(["auto", "semi-auto", "manual"] as const).map((mode) => {
const isActive = directive.reconcileMode === mode;
const modeStyles: Record<string, string> = {
auto: isActive ? "text-[#9bc3ff] bg-[#1a2540]" : "text-[#445566] hover:text-[#7788aa]",
"semi-auto": isActive ? "text-amber-400 bg-amber-900/20" : "text-[#445566] hover:text-[#7788aa]",
manual: isActive ? "text-orange-400 bg-orange-900/20" : "text-[#445566] hover:text-[#7788aa]",
};
const labels: Record<string, string> = { auto: "Auto", "semi-auto": "Semi", manual: "Manual" };
return (
<button
key={mode}
type="button"
onClick={() => onUpdate({ reconcileMode: mode })}
className={`text-[10px] font-mono px-2 py-0.5 transition-colors border-r border-[#2a3a5a] last:border-r-0 ${modeStyles[mode]}`}
>
{labels[mode]}
</button>
);
})}
</div>
<span className="text-[9px] font-mono text-[#445566]">
{directive.reconcileMode === "auto" && "Questions timeout after 30s"}
{directive.reconcileMode === "semi-auto" && "Questions pause execution"}
{directive.reconcileMode === "manual" && "Tasks ask clarifying questions"}
</span>
</div>
{/* PR link */}
{directive.prUrl && (
<div className="flex items-center gap-2 mb-2 px-2 py-1.5 bg-[#0a1a10] border border-emerald-900 rounded">
<span className="inline-block w-2 h-2 rounded-full bg-emerald-400" />
<span className="text-[10px] font-mono text-emerald-400">
PR created
</span>
<a
href={directive.prUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 underline ml-auto truncate max-w-[200px]"
>
{directive.prUrl}
</a>
</div>
)}
{/* Pending Questions */}
{directiveQuestions.length > 0 && (
<div className="mb-2 space-y-2">
{directiveQuestions.map((q) => (
<DirectiveQuestionCard
key={q.questionId}
question={q}
taskName={taskMap.get(q.taskId) || "Task"}
onAnswer={(response) => submitAnswer(q.questionId, response)}
/>
))}
</div>
)}
{/* Controls */}
<div className="flex flex-wrap gap-2">
{(directive.status === "draft" || directive.status === "paused") && (
<button
type="button"
onClick={onStart}
className="text-[10px] font-mono text-green-400 hover:text-green-300 border border-green-800 rounded px-2 py-1"
>
Start
</button>
)}
{directive.status === "active" && (
<>
<button
type="button"
onClick={onPause}
className="text-[10px] font-mono text-orange-400 hover:text-orange-300 border border-orange-800 rounded px-2 py-1"
>
Pause
</button>
<button
type="button"
onClick={onAdvance}
className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.3)] rounded px-2 py-1"
>
Advance
</button>
</>
)}
{directive.status === "idle" && (
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-yellow-400">
All steps done. Update goal to add new work.
</span>
<button
type="button"
onClick={() => { setGoalText(directive.goal); setEditingGoal(true); }}
className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.3)] rounded px-2 py-1"
>
Update Goal
</button>
<button
type="button"
onClick={onCleanup}
className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-1"
>
Clean up
</button>
</div>
)}
{completedSteps > 0 && !directive.completionTaskId && (
<button
type="button"
onClick={async () => {
setCreatingPR(true);
try { await onCreatePR(); } catch (e) { console.error("Failed to create PR:", e); } finally { setCreatingPR(false); }
}}
disabled={creatingPR}
className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-1 disabled:opacity-50"
>
{creatingPR ? "Creating..." : directive.prUrl ? "Update PR" : "Create PR"}
</button>
)}
<button
type="button"
onClick={handlePickUpOrders}
disabled={pickingUpOrders}
className="text-[10px] font-mono text-[#c084fc] hover:text-[#d8b4fe] border border-[rgba(192,132,252,0.3)] rounded px-2 py-1 disabled:opacity-50"
>
{pickingUpOrders ? "Planning..." : "Plan Orders"}
</button>
<button
type="button"
onClick={onDelete}
className="text-[10px] font-mono text-red-400 hover:text-red-300 border border-red-800 rounded px-2 py-1 ml-auto"
>
Delete
</button>
</div>
{pickUpResult && (
<div className="mt-2 px-2 py-1.5 bg-[#1a1030] border border-[rgba(192,132,252,0.2)] rounded">
<span className="text-[10px] font-mono text-[#c084fc]">{pickUpResult}</span>
</div>
)}
</div>
{/* Goal */}
<div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide">
Goal
</span>
{!editingGoal && (
<button
type="button"
onClick={() => { setGoalText(directive.goal); setEditingGoal(true); }}
className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]"
>
[edit]
</button>
)}
</div>
{editingGoal ? (
<div className="flex flex-col gap-1.5">
<textarea
value={goalText}
onChange={(e) => setGoalText(e.target.value)}
className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[11px] font-mono text-white resize-y min-h-[60px]"
rows={3}
/>
<div className="flex gap-1.5">
<button
type="button"
onClick={handleGoalSave}
className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-0.5"
>
Save
</button>
<button
type="button"
onClick={() => setEditingGoal(false)}
className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5"
>
Cancel
</button>
</div>
</div>
) : (
<p className="text-[11px] font-mono text-[#c0d0e0] whitespace-pre-wrap">
{directive.goal}
</p>
)}
</div>
{/* Tab bar */}
<div className="flex items-center gap-0 border-b border-[rgba(117,170,252,0.1)] px-4">
<button
type="button"
onClick={() => setActiveTab("steps")}
className={`px-3 py-2 text-[10px] font-mono uppercase tracking-wide transition-colors
${activeTab === "steps" ? "text-[#dbe7ff] border-b-2 border-[#75aafc]" : "text-[#556677] hover:text-[#9bc3ff]"}
`}
>
Steps ({totalSteps})
</button>
<button
type="button"
onClick={() => setActiveTab("dogs")}
className={`px-3 py-2 text-[10px] font-mono uppercase tracking-wide transition-colors
${activeTab === "dogs" ? "text-[#dbe7ff] border-b-2 border-[#75aafc]" : "text-[#556677] hover:text-[#9bc3ff]"}
`}
>
DOGs ({dogs.length})
</button>
</div>
{/* Tab content */}
{activeTab === "steps" ? (
<div className="px-4 py-3 flex-1">
<DirectiveDAG
steps={directive.steps}
specializedSteps={specializedSteps}
onComplete={onCompleteStep}
onFail={onFailStep}
onSkip={onSkipStep}
onViewTask={handleViewTask}
/>
</div>
) : (
<div className="px-4 py-3 flex-1">
<DOGList
dogs={dogs}
loading={dogsLoading}
onCreateDog={onCreateDog}
onUpdateDog={onUpdateDog}
onDeleteDog={onDeleteDog}
onPickUpOrders={onPickUpDogOrders}
/>
</div>
)}
{/* Log Stream */}
{taskMap.size > 0 && (
<div className="px-4 py-3 border-t border-[rgba(117,170,252,0.1)]">
<DirectiveLogStream
entries={entries}
taskMap={taskMap}
connected={connected}
visibleTaskIds={visibleTaskIds}
searchQuery={searchQuery}
isCollapsed={isLogCollapsed}
onToggleCollapse={() => setIsLogCollapsed((prev) => !prev)}
onSetVisibleTaskIds={setVisibleTaskIds}
onSetSearchQuery={setSearchQuery}
onClear={clearEntries}
/>
</div>
)}
</div>
<TaskSlideOutPanel
taskId={slideOutTaskId || ""}
taskName={slideOutTaskName}
isOpen={!!slideOutTaskId}
onClose={() => setSlideOutTaskId(null)}
/>
</>
);
}
/** Inline question card for directive pending questions */
function DirectiveQuestionCard({
question,
taskName,
onAnswer,
}: {
question: { questionId: string; question: string; choices: string[]; context: string | null };
taskName: string;
onAnswer: (response: string) => void;
}) {
const [customResponse, setCustomResponse] = useState("");
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (response: string) => {
setSubmitting(true);
await onAnswer(response);
setSubmitting(false);
};
return (
<div className="px-2 py-2 bg-[#1a1020] border border-purple-900/50 rounded">
<div className="flex items-center gap-1.5 mb-1">
<span className="inline-block w-2 h-2 rounded-full bg-purple-400 animate-pulse" />
<span className="text-[9px] font-mono text-purple-400 uppercase">
Question from {taskName}
</span>
</div>
<p className="text-[11px] font-mono text-white mb-1.5">{question.question}</p>
{question.context && (
<p className="text-[9px] font-mono text-[#556677] mb-1.5">{question.context}</p>
)}
{question.choices.length > 0 ? (
<div className="flex flex-wrap gap-1">
{question.choices.map((choice) => (
<button
key={choice}
type="button"
disabled={submitting}
onClick={() => handleSubmit(choice)}
className="text-[10px] font-mono text-purple-300 hover:text-white border border-purple-800 hover:border-purple-600 rounded px-2 py-0.5 disabled:opacity-50"
>
{choice}
</button>
))}
</div>
) : (
<div className="flex gap-1">
<input
type="text"
value={customResponse}
onChange={(e) => setCustomResponse(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && customResponse.trim()) {
handleSubmit(customResponse.trim());
}
}}
placeholder="Type your answer..."
className="flex-1 bg-[#0a0618] border border-purple-900/50 rounded px-2 py-0.5 text-[10px] font-mono text-white placeholder:text-[#445566]"
disabled={submitting}
/>
<button
type="button"
disabled={submitting || !customResponse.trim()}
onClick={() => handleSubmit(customResponse.trim())}
className="text-[10px] font-mono text-purple-300 hover:text-white border border-purple-800 rounded px-2 py-0.5 disabled:opacity-50"
>
Send
</button>
</div>
)}
</div>
);
}