diff options
Diffstat (limited to 'makima/frontend/src/components/directives/DirectiveDetail.tsx')
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveDetail.tsx | 318 |
1 files changed, 316 insertions, 2 deletions
diff --git a/makima/frontend/src/components/directives/DirectiveDetail.tsx b/makima/frontend/src/components/directives/DirectiveDetail.tsx index 616c5d2..ab6ddbb 100644 --- a/makima/frontend/src/components/directives/DirectiveDetail.tsx +++ b/makima/frontend/src/components/directives/DirectiveDetail.tsx @@ -1,6 +1,9 @@ -import { useState } from "react"; -import type { DirectiveWithSteps, DirectiveStatus } from "../../lib/api"; +import { useState, useMemo, useEffect, useRef } from "react"; +import type { DirectiveWithSteps, DirectiveStatus, MemoryCategory } from "../../lib/api"; import { DirectiveDAG } from "./DirectiveDAG"; +import { DirectiveLogStream } from "./DirectiveLogStream"; +import { useDirectiveMemories } from "../../hooks/useDirectiveMemories"; +import { useMultiTaskSubscription } from "../../hooks/useMultiTaskSubscription"; const STATUS_BADGE: Record<DirectiveStatus, { color: string; label: string }> = { draft: { color: "text-[#7788aa] border-[#2a3a5a]", label: "DRAFT" }, @@ -10,6 +13,16 @@ const STATUS_BADGE: Record<DirectiveStatus, { color: string; label: string }> = archived: { color: "text-[#556677] border-[#2a3a5a]", label: "ARCHIVED" }, }; +const CATEGORY_COLORS: Record<MemoryCategory, { text: string; border: string; bg: string; label: string }> = { + decision: { text: "text-amber-400", border: "border-amber-800", bg: "bg-amber-900/20", label: "Decision" }, + context: { text: "text-cyan-400", border: "border-cyan-800", bg: "bg-cyan-900/20", label: "Context" }, + preference: { text: "text-violet-400", border: "border-violet-800", bg: "bg-violet-900/20", label: "Preference" }, + learning: { text: "text-emerald-400", border: "border-emerald-800", bg: "bg-emerald-900/20", label: "Learning" }, + other: { text: "text-[#7788aa]", border: "border-[#2a3a5a]", bg: "bg-[#1a2540]", label: "Other" }, +}; + +const ALL_CATEGORIES: MemoryCategory[] = ["decision", "context", "preference", "learning", "other"]; + interface DirectiveDetailProps { directive: DirectiveWithSteps; onStart: () => void; @@ -37,12 +50,70 @@ export function DirectiveDetail({ }: DirectiveDetailProps) { const [editingGoal, setEditingGoal] = useState(false); const [goalText, setGoalText] = useState(directive.goal); + const [visibleTaskIds, setVisibleTaskIds] = useState<Set<string> | null>(null); + 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; + // Memory panel state + const [memoryOpen, setMemoryOpen] = useState(false); + const [addingMemory, setAddingMemory] = useState(false); + const [newCategory, setNewCategory] = useState<MemoryCategory>("context"); + const [newContent, setNewContent] = useState(""); + const [newSource, setNewSource] = useState(""); + const [confirmClear, setConfirmClear] = useState(false); + + const { + grouped, + config: memoryConfig, + loading: memoryLoading, + error: memoryError, + toggleEnabled, + add: addMemory, + remove: removeMemory, + clearAll: clearMemories, + refresh: refreshMemories, + } = useDirectiveMemories(directive.id); + + const memoryEnabled = memoryConfig?.enabled ?? false; + const totalMemories = Object.values(grouped).reduce((sum, arr) => sum + arr.length, 0); + + // Build task map from directive steps and orchestrator + 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; + }, [directive.orchestratorTaskId, directive.steps]); + + // 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 handleGoalSave = () => { if (goalText.trim() && goalText !== directive.goal) { onUpdateGoal(goalText.trim()); @@ -50,6 +121,23 @@ export function DirectiveDetail({ setEditingGoal(false); }; + const handleAddMemory = async () => { + if (!newContent.trim()) return; + await addMemory({ + category: newCategory, + content: newContent.trim(), + source: newSource.trim() || undefined, + }); + setNewContent(""); + setNewSource(""); + setAddingMemory(false); + }; + + const handleClearAll = async () => { + await clearMemories(); + setConfirmClear(false); + }; + return ( <div className="flex flex-col h-full overflow-y-auto"> {/* Header */} @@ -249,6 +337,214 @@ export function DirectiveDetail({ )} </div> + {/* Memory Panel */} + <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]"> + {/* Memory header — always visible */} + <div className="flex items-center justify-between"> + <button + type="button" + onClick={() => setMemoryOpen((v) => !v)} + className="flex items-center gap-1.5 group" + > + <span className="text-[10px] font-mono text-[#556677] group-hover:text-[#9bc3ff] transition-colors"> + {memoryOpen ? "\u25BC" : "\u25B6"} + </span> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Memory + </span> + {totalMemories > 0 && ( + <span className="text-[9px] font-mono text-[#556677] ml-1"> + ({totalMemories}) + </span> + )} + </button> + <div className="flex items-center gap-2"> + {/* Enable/disable toggle */} + <button + type="button" + onClick={() => toggleEnabled(!memoryEnabled)} + className={`text-[9px] font-mono border rounded px-1.5 py-0.5 transition-colors ${ + memoryEnabled + ? "text-emerald-400 border-emerald-800 hover:text-emerald-300" + : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]" + }`} + title={memoryEnabled ? "Disable memory" : "Enable memory"} + > + {memoryEnabled ? "ON" : "OFF"} + </button> + </div> + </div> + + {/* Collapsible content */} + {memoryOpen && ( + <div className="mt-2"> + {memoryError && ( + <div className="text-[10px] font-mono text-red-400 mb-2 px-2 py-1 bg-red-900/10 border border-red-800/30 rounded"> + {memoryError} + </div> + )} + + {memoryLoading ? ( + <div className="text-[10px] font-mono text-[#556677] py-2">Loading...</div> + ) : totalMemories === 0 ? ( + <div className="text-[10px] font-mono text-[#556677] py-2"> + No memory entries yet. + {!memoryEnabled && " Enable memory to start capturing entries."} + </div> + ) : ( + /* Grouped entries */ + <div className="flex flex-col gap-2"> + {ALL_CATEGORIES.map((cat) => { + const entries = grouped[cat]; + if (entries.length === 0) return null; + const style = CATEGORY_COLORS[cat]; + return ( + <div key={cat}> + <div className="flex items-center gap-1.5 mb-1"> + <span className={`text-[9px] font-mono ${style.text} uppercase tracking-wider`}> + {style.label} + </span> + <span className="text-[9px] font-mono text-[#556677]"> + ({entries.length}) + </span> + </div> + <div className="flex flex-col gap-1"> + {entries.map((entry) => ( + <div + key={entry.id} + className={`flex items-start gap-2 px-2 py-1.5 rounded border ${style.border} ${style.bg}`} + > + <div className="flex-1 min-w-0"> + <p className="text-[10px] font-mono text-[#c0d0e0] whitespace-pre-wrap break-words"> + {entry.content} + </p> + {entry.source && ( + <span className="text-[9px] font-mono text-[#556677] mt-0.5 block"> + src: {entry.source} + </span> + )} + </div> + <button + type="button" + onClick={() => removeMemory(entry.id)} + className="text-[9px] font-mono text-[#556677] hover:text-red-400 shrink-0 mt-0.5" + title="Delete entry" + > + x + </button> + </div> + ))} + </div> + </div> + ); + })} + </div> + )} + + {/* Action bar: Add + Clear */} + <div className="flex items-center gap-2 mt-2 pt-2 border-t border-[rgba(117,170,252,0.1)]"> + <button + type="button" + onClick={() => setAddingMemory((v) => !v)} + className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.2)] rounded px-2 py-0.5" + > + {addingMemory ? "Cancel" : "+ Add"} + </button> + {totalMemories > 0 && ( + <> + {confirmClear ? ( + <div className="flex items-center gap-1.5 ml-auto"> + <span className="text-[9px] font-mono text-red-400">Clear all?</span> + <button + type="button" + onClick={handleClearAll} + className="text-[9px] font-mono text-red-400 hover:text-red-300 border border-red-800 rounded px-1.5 py-0.5" + > + Yes + </button> + <button + type="button" + onClick={() => setConfirmClear(false)} + className="text-[9px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-1.5 py-0.5" + > + No + </button> + </div> + ) : ( + <button + type="button" + onClick={() => setConfirmClear(true)} + className="text-[10px] font-mono text-[#556677] hover:text-red-400 ml-auto" + > + Clear all + </button> + )} + </> + )} + <button + type="button" + onClick={refreshMemories} + className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]" + title="Refresh memories" + > + [refresh] + </button> + </div> + + {/* Add form */} + {addingMemory && ( + <div className="mt-2 p-2 bg-[#0a1628] border border-[rgba(117,170,252,0.15)] rounded flex flex-col gap-2"> + <div className="flex items-center gap-2"> + <label className="text-[9px] font-mono text-[#7788aa] shrink-0">Category</label> + <select + value={newCategory} + onChange={(e) => setNewCategory(e.target.value as MemoryCategory)} + className="bg-[#1a2540] border border-[rgba(117,170,252,0.2)] rounded px-1.5 py-0.5 text-[10px] font-mono text-white flex-1" + > + {ALL_CATEGORIES.map((c) => ( + <option key={c} value={c}> + {CATEGORY_COLORS[c].label} + </option> + ))} + </select> + </div> + <textarea + value={newContent} + onChange={(e) => setNewContent(e.target.value)} + placeholder="Memory content..." + className="w-full bg-[#1a2540] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[10px] font-mono text-white resize-y min-h-[40px] placeholder:text-[#556677]" + rows={2} + /> + <input + type="text" + value={newSource} + onChange={(e) => setNewSource(e.target.value)} + placeholder="Source (optional)" + className="w-full bg-[#1a2540] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1 text-[10px] font-mono text-white placeholder:text-[#556677]" + /> + <div className="flex gap-1.5"> + <button + type="button" + onClick={handleAddMemory} + disabled={!newContent.trim()} + className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-0.5 disabled:opacity-40 disabled:cursor-not-allowed" + > + Save + </button> + <button + type="button" + onClick={() => { setAddingMemory(false); setNewContent(""); setNewSource(""); }} + className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5" + > + Cancel + </button> + </div> + </div> + )} + </div> + )} + </div> + {/* DAG */} <div className="px-4 py-3 flex-1"> <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-2"> @@ -261,6 +557,24 @@ export function DirectiveDetail({ onSkip={onSkipStep} /> </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> ); } |
