summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/directives/DirectiveDetail.tsx
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-02-12 02:29:45 +0000
committerGitHub <noreply@github.com>2026-02-12 02:29:45 +0000
commit355f10964c4dbec24a244a00caba5c17ed23fc65 (patch)
tree6fdc998e6b95948e80a87a962acd58acf79d5b98 /makima/frontend/src/components/directives/DirectiveDetail.tsx
parent9bd6eacaa9ebe860842b5d5cfbf2b7d2d0293ab1 (diff)
downloadsoryu-355f10964c4dbec24a244a00caba5c17ed23fc65.tar.gz
soryu-355f10964c4dbec24a244a00caba5c17ed23fc65.zip
makima: Add an optional memory system for directives (#59)
* feat: makima: Add an optional memory system for directives: Add directive_memories database table and migration * feat: makima: Add an optional memory system for directives: Update directive skill documentation with memory commands * feat: makima: Add an optional memory system for directives: Add repository functions for directive memory CRUD * feat: makima: Add an optional memory system for directives: Add frontend API functions and types for directive memory * feat: makima: Add an optional memory system for directives: Add Rust models for directive memory * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * feat: makima: Add an optional memory system for directives: Add memory panel to frontend DirectiveDetail component * Merge remote-tracking branch 'origin/makima/makima--add-an-optional-memory-system-for-directiv-5de1e06d' into combined branch * Merge remote-tracking branch 'origin/makima/makima--add-an-optional-memory-system-for-directiv-c8298c6c' into combined branch * feat: makima: Add an optional memory system for directives: Create useMultiTaskSubscription hook for multi-output WebSocket streaming * feat: makima: Add an optional memory system for directives: Create DirectiveLogStream component for stern-like multi-task output viewing * feat: makima: Add an optional memory system for directives: Integrate log stream panel into directive detail page
Diffstat (limited to 'makima/frontend/src/components/directives/DirectiveDetail.tsx')
-rw-r--r--makima/frontend/src/components/directives/DirectiveDetail.tsx318
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>
);
}