summaryrefslogtreecommitdiff
path: root/makima/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src')
-rw-r--r--makima/frontend/src/components/Masthead.tsx10
-rw-r--r--makima/frontend/src/components/NavStrip.tsx33
-rw-r--r--makima/frontend/src/components/ProtectedRoute.tsx26
-rw-r--r--makima/frontend/src/components/files/BodyRenderer.tsx121
-rw-r--r--makima/frontend/src/components/files/CliInput.tsx58
-rw-r--r--makima/frontend/src/components/files/ElementContextMenu.tsx292
-rw-r--r--makima/frontend/src/components/files/FileDetail.tsx59
-rw-r--r--makima/frontend/src/components/files/FileList.tsx207
-rw-r--r--makima/frontend/src/components/mesh/DirectoryInput.tsx220
-rw-r--r--makima/frontend/src/components/mesh/InlineSubtaskEditor.tsx262
-rw-r--r--makima/frontend/src/components/mesh/MergeConflictResolver.tsx504
-rw-r--r--makima/frontend/src/components/mesh/OverlayDiffViewer.tsx476
-rw-r--r--makima/frontend/src/components/mesh/PRPreview.tsx314
-rw-r--r--makima/frontend/src/components/mesh/SubtaskTree.tsx297
-rw-r--r--makima/frontend/src/components/mesh/TaskDetail.tsx886
-rw-r--r--makima/frontend/src/components/mesh/TaskList.tsx164
-rw-r--r--makima/frontend/src/components/mesh/TaskOutput.tsx281
-rw-r--r--makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx536
-rw-r--r--makima/frontend/src/contexts/AuthContext.tsx160
-rw-r--r--makima/frontend/src/hooks/useMeshChatHistory.ts133
-rw-r--r--makima/frontend/src/hooks/useTaskSubscription.ts333
-rw-r--r--makima/frontend/src/hooks/useTasks.ts130
-rw-r--r--makima/frontend/src/lib/api.ts921
-rw-r--r--makima/frontend/src/lib/supabase.ts26
-rw-r--r--makima/frontend/src/main.tsx71
-rw-r--r--makima/frontend/src/routes/_index.tsx12
-rw-r--r--makima/frontend/src/routes/files.tsx350
-rw-r--r--makima/frontend/src/routes/login.tsx150
-rw-r--r--makima/frontend/src/routes/mesh.tsx634
-rw-r--r--makima/frontend/src/routes/settings.tsx724
30 files changed, 8336 insertions, 54 deletions
diff --git a/makima/frontend/src/components/Masthead.tsx b/makima/frontend/src/components/Masthead.tsx
index 803e45a..afe385e 100644
--- a/makima/frontend/src/components/Masthead.tsx
+++ b/makima/frontend/src/components/Masthead.tsx
@@ -18,7 +18,7 @@ export function Masthead({ showTicker = false, showNav = true }: MastheadProps)
makima.jp
</h1>
<small className="block text-[#dbe7ff] text-xs tracking-wide">
- Listening System
+ Control System
</small>
</div>
</Link>
@@ -29,10 +29,10 @@ export function Masthead({ showTicker = false, showNav = true }: MastheadProps)
<div className="absolute inset-y-0 left-0 w-3 bg-gradient-to-b from-[rgba(231,237,247,0.5)] to-transparent" />
<div className="absolute inset-y-0 right-0 w-3 bg-gradient-to-b from-[rgba(231,237,247,0.5)] to-transparent rotate-180" />
<span className="ticker-content">
- /// MAKIMA LISTENING SYSTEM // MESH LATTICE FOR CONTESTED DOMAINS ///
- TRANSPORT: WEBSOCKET /// ENCODING: PCM32F /// STATUS: ONLINE ///
- MAKIMA.JP /// MAKIMA LISTENING SYSTEM // MESH LATTICE FOR CONTESTED DOMAINS
- /// TRANSPORT: WEBSOCKET /// ENCODING: PCM32F /// STATUS: ONLINE ///
+ /// MAKIMA CONTROL SYSTEM // MESH ORCHESTRATION PLATFORM ///
+ TRANSPORT: WEBSOCKET /// DAEMONS: ACTIVE /// STATUS: ONLINE ///
+ MAKIMA.JP /// MAKIMA CONTROL SYSTEM // MESH ORCHESTRATION PLATFORM
+ /// TRANSPORT: WEBSOCKET /// DAEMONS: ACTIVE /// STATUS: ONLINE ///
MAKIMA.JP ///
</span>
</div>
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx
index 4e90d4d..806f0c5 100644
--- a/makima/frontend/src/components/NavStrip.tsx
+++ b/makima/frontend/src/components/NavStrip.tsx
@@ -1,3 +1,4 @@
+import { useAuth } from "../contexts/AuthContext";
import { RewriteLink } from "./RewriteLink";
interface NavLink {
@@ -10,12 +11,17 @@ interface NavLink {
const NAV_LINKS: NavLink[] = [
{ label: "Listen", href: "/listen" },
{ label: "Files", href: "/files" },
- { label: "Mesh", href: "/mesh", disabled: true },
- { label: "Register", href: "/register", disabled: true },
- { label: "Login", href: "/login", disabled: true },
+ { label: "Mesh", href: "/mesh" },
];
export function NavStrip() {
+ const { isAuthenticated, isAuthConfigured, signOut, user } = useAuth();
+
+ const handleSignOut = async () => {
+ await signOut();
+ window.location.href = "/login";
+ };
+
return (
<nav
className="flex items-center gap-2.5 px-3 py-2.5 border-t border-b border-dashed border-[rgba(117,170,252,0.35)] bg-[#0c1729] font-mono uppercase tracking-wide text-[11px]"
@@ -24,7 +30,7 @@ export function NavStrip() {
<span className="text-[#9bc3ff] pr-2.5 border-r border-[rgba(117,170,252,0.35)]">
NAV//
</span>
- <div className="flex flex-wrap gap-2 items-center">
+ <div className="flex flex-wrap gap-2 items-center flex-1">
{NAV_LINKS.map((link) => (
<RewriteLink
key={link.label}
@@ -36,6 +42,25 @@ export function NavStrip() {
</RewriteLink>
))}
</div>
+ <div className="flex items-center gap-2 pl-2.5 border-l border-[rgba(117,170,252,0.35)]">
+ {isAuthenticated && isAuthConfigured ? (
+ <>
+ {user?.email && (
+ <span className="text-[#9bc3ff] opacity-60">{user.email}</span>
+ )}
+ <RewriteLink to="/settings">Settings</RewriteLink>
+ <button
+ type="button"
+ onClick={handleSignOut}
+ className="bg-transparent border-none text-[#9bc3ff] hover:text-white transition-colors cursor-pointer uppercase text-[11px] font-mono tracking-wide p-0"
+ >
+ Logout
+ </button>
+ </>
+ ) : (
+ <RewriteLink to="/login">Login</RewriteLink>
+ )}
+ </div>
</nav>
);
}
diff --git a/makima/frontend/src/components/ProtectedRoute.tsx b/makima/frontend/src/components/ProtectedRoute.tsx
new file mode 100644
index 0000000..32ac592
--- /dev/null
+++ b/makima/frontend/src/components/ProtectedRoute.tsx
@@ -0,0 +1,26 @@
+import { Navigate } from "react-router";
+import { useAuth } from "../contexts/AuthContext";
+
+interface ProtectedRouteProps {
+ children: React.ReactNode;
+}
+
+export function ProtectedRoute({ children }: ProtectedRouteProps) {
+ const { isAuthenticated, isLoading, isAuthConfigured } = useAuth();
+
+ // Show loading state while checking auth
+ if (isLoading) {
+ return (
+ <div className="min-h-screen bg-black text-white flex items-center justify-center">
+ <div className="text-zinc-400">Loading...</div>
+ </div>
+ );
+ }
+
+ // If auth is configured but user is not authenticated, redirect to login
+ if (isAuthConfigured && !isAuthenticated) {
+ return <Navigate to="/login" replace />;
+ }
+
+ return <>{children}</>;
+}
diff --git a/makima/frontend/src/components/files/BodyRenderer.tsx b/makima/frontend/src/components/files/BodyRenderer.tsx
index 867fc4c..cf99fde 100644
--- a/makima/frontend/src/components/files/BodyRenderer.tsx
+++ b/makima/frontend/src/components/files/BodyRenderer.tsx
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from "react";
import type { BodyElement } from "../../lib/api";
import { ChartRenderer } from "../charts/ChartRenderer";
+import { ElementContextMenu } from "./ElementContextMenu";
interface BodyRendererProps {
elements: BodyElement[];
@@ -10,11 +11,54 @@ interface BodyRendererProps {
onEditingChange?: (isEditing: boolean) => void;
hasPendingRemoteUpdate?: boolean;
onOverwrite?: () => void;
+ onFocusElement?: (index: number) => void;
+ onDeleteElement?: (index: number) => void;
+ onDuplicateElement?: (index: number) => void;
+ onConvertElement?: (index: number, toType: string) => void;
+ onGenerateFromElement?: (index: number, action: string) => void;
+ onCreateTaskFromElement?: (index: number, selectedText?: string) => void;
}
-export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder, onEditingChange, hasPendingRemoteUpdate, onOverwrite }: BodyRendererProps) {
+export function BodyRenderer({
+ elements,
+ isEditing = false,
+ onUpdate,
+ onReorder,
+ onEditingChange,
+ hasPendingRemoteUpdate,
+ onOverwrite,
+ onFocusElement,
+ onDeleteElement,
+ onDuplicateElement,
+ onConvertElement,
+ onGenerateFromElement,
+ onCreateTaskFromElement,
+}: BodyRendererProps) {
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
+ const [contextMenu, setContextMenu] = useState<{
+ x: number;
+ y: number;
+ elementIndex: number;
+ selectedText?: string;
+ } | null>(null);
+
+ const handleContextMenu = (index: number) => (e: React.MouseEvent) => {
+ e.preventDefault();
+ // Get any selected text
+ const selection = window.getSelection();
+ const selectedText = selection?.toString().trim() || undefined;
+ setContextMenu({
+ x: e.clientX,
+ y: e.clientY,
+ elementIndex: index,
+ selectedText,
+ });
+ };
+
+ const closeContextMenu = () => {
+ setContextMenu(null);
+ };
if (elements.length === 0) {
return (
@@ -73,6 +117,7 @@ export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder,
onDragOver={handleDragOver(index)}
onDragLeave={handleDragLeave}
onDrop={handleDrop(index)}
+ onContextMenu={handleContextMenu(index)}
>
{/* Drag handle - only show in edit mode */}
{isEditing && onReorder && (
@@ -109,6 +154,24 @@ export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder,
</div>
</div>
))}
+
+ {/* Context Menu */}
+ {contextMenu && (
+ <ElementContextMenu
+ x={contextMenu.x}
+ y={contextMenu.y}
+ element={elements[contextMenu.elementIndex]}
+ elementIndex={contextMenu.elementIndex}
+ selectedText={contextMenu.selectedText}
+ onClose={closeContextMenu}
+ onFocus={(index) => onFocusElement?.(index)}
+ onDelete={(index) => onDeleteElement?.(index)}
+ onDuplicate={(index) => onDuplicateElement?.(index)}
+ onConvert={(index, toType) => onConvertElement?.(index, toType)}
+ onGenerate={(index, action) => onGenerateFromElement?.(index, action)}
+ onCreateTask={(index, selectedText) => onCreateTaskFromElement?.(index, selectedText)}
+ />
+ )}
</div>
);
}
@@ -156,6 +219,20 @@ function BodyElementRenderer({
onOverwrite={onOverwrite}
/>
);
+ case "code":
+ return (
+ <CodeElement
+ language={element.language}
+ content={element.content}
+ />
+ );
+ case "list":
+ return (
+ <ListElement
+ ordered={element.ordered}
+ items={element.items}
+ />
+ );
case "chart":
return (
<ChartElement
@@ -502,3 +579,45 @@ function ImageElement({
</figure>
);
}
+
+function CodeElement({
+ language,
+ content,
+}: {
+ language?: string;
+ content: string;
+}) {
+ return (
+ <div className="relative">
+ {language && (
+ <div className="absolute top-0 right-0 px-2 py-0.5 font-mono text-[10px] text-[#555] bg-[#1a1a1a] border-b border-l border-[#333]">
+ {language}
+ </div>
+ )}
+ <pre className="bg-[#0d0d0d] border border-[#333] p-4 overflow-x-auto">
+ <code className="font-mono text-sm text-[#9bc3ff] whitespace-pre">
+ {content}
+ </code>
+ </pre>
+ </div>
+ );
+}
+
+function ListElement({
+ ordered,
+ items,
+}: {
+ ordered: boolean;
+ items: string[];
+}) {
+ const ListTag = ordered ? "ol" : "ul";
+ return (
+ <ListTag className={`font-mono text-sm text-white/80 leading-relaxed pl-6 space-y-1 ${ordered ? "list-decimal" : "list-disc"}`}>
+ {items.map((item, index) => (
+ <li key={index} className="pl-1">
+ {item}
+ </li>
+ ))}
+ </ListTag>
+ );
+}
diff --git a/makima/frontend/src/components/files/CliInput.tsx b/makima/frontend/src/components/files/CliInput.tsx
index ff2b0a4..47e7616 100644
--- a/makima/frontend/src/components/files/CliInput.tsx
+++ b/makima/frontend/src/components/files/CliInput.tsx
@@ -8,10 +8,15 @@ import {
type UserAnswer,
} from "../../lib/api";
import { SimpleMarkdown } from "../SimpleMarkdown";
+import type { FocusedElement } from "./FileDetail";
interface CliInputProps {
fileId: string;
onUpdate: (body: BodyElement[], summary: string | null) => void;
+ focusedElement?: FocusedElement | null;
+ onClearFocus?: () => void;
+ suggestedPrompt?: string | null;
+ onClearSuggestedPrompt?: () => void;
}
interface Message {
@@ -28,7 +33,7 @@ const MODEL_OPTIONS: { value: LlmModel; label: string }[] = [
{ value: "groq", label: "Groq Kimi" },
];
-export function CliInput({ fileId, onUpdate }: CliInputProps) {
+export function CliInput({ fileId, onUpdate, focusedElement, onClearFocus, suggestedPrompt, onClearSuggestedPrompt }: CliInputProps) {
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
@@ -53,6 +58,21 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) {
}
}, [messages]);
+ // Auto-focus input when an element is focused
+ useEffect(() => {
+ if (focusedElement && inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, [focusedElement]);
+
+ // Handle suggested prompt from generate actions
+ useEffect(() => {
+ if (suggestedPrompt) {
+ setInput(suggestedPrompt);
+ onClearSuggestedPrompt?.();
+ }
+ }, [suggestedPrompt, onClearSuggestedPrompt]);
+
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
@@ -73,7 +93,13 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) {
try {
// Send request with conversation history for context
- const response = await chatWithFile(fileId, userMessage, model, conversationHistory);
+ const response = await chatWithFile(
+ fileId,
+ userMessage,
+ model,
+ conversationHistory,
+ focusedElement?.index
+ );
// Add assistant response
const assistantMsgId = (Date.now() + 1).toString();
@@ -128,7 +154,7 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) {
inputRef.current?.focus();
}
},
- [input, loading, fileId, model, onUpdate, conversationHistory]
+ [input, loading, fileId, model, onUpdate, conversationHistory, focusedElement]
);
// Handle option selection for a question
@@ -206,7 +232,13 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) {
try {
// Send answers as the next message
- const response = await chatWithFile(fileId, answerText, model, conversationHistory);
+ const response = await chatWithFile(
+ fileId,
+ answerText,
+ model,
+ conversationHistory,
+ focusedElement?.index
+ );
// Add assistant response
const assistantMsgId = (Date.now() + 1).toString();
@@ -258,7 +290,7 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) {
} finally {
setLoading(false);
}
- }, [pendingQuestions, userAnswers, customInputs, loading, fileId, model, conversationHistory, onUpdate]);
+ }, [pendingQuestions, userAnswers, customInputs, loading, fileId, model, conversationHistory, onUpdate, focusedElement]);
// Cancel answering questions
const handleCancelQuestions = useCallback(() => {
@@ -397,6 +429,22 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) {
</option>
))}
</select>
+
+ {/* Focus Badge */}
+ {focusedElement && (
+ <button
+ type="button"
+ onClick={onClearFocus}
+ className="flex items-center gap-1 px-2 py-0.5 font-mono text-[10px] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.3)] text-[#9bc3ff] hover:border-[#75aafc] transition-colors group"
+ title="Click to clear focus"
+ >
+ <span className="text-[#75aafc]">{focusedElement.type}</span>
+ <span className="text-[#555]">:</span>
+ <span>{focusedElement.index}</span>
+ <span className="text-[#555] group-hover:text-red-400 ml-1">&times;</span>
+ </button>
+ )}
+
<span className="text-[#9bc3ff] font-mono text-sm">&gt;</span>
<input
ref={inputRef}
diff --git a/makima/frontend/src/components/files/ElementContextMenu.tsx b/makima/frontend/src/components/files/ElementContextMenu.tsx
new file mode 100644
index 0000000..dcb430c
--- /dev/null
+++ b/makima/frontend/src/components/files/ElementContextMenu.tsx
@@ -0,0 +1,292 @@
+import { useEffect, useRef, useState } from "react";
+import type { BodyElement } from "../../lib/api";
+
+interface ElementContextMenuProps {
+ x: number;
+ y: number;
+ element: BodyElement;
+ elementIndex: number;
+ selectedText?: string;
+ onClose: () => void;
+ onFocus: (index: number) => void;
+ onDelete: (index: number) => void;
+ onDuplicate: (index: number) => void;
+ onConvert: (index: number, toType: string) => void;
+ onGenerate: (index: number, action: string) => void;
+ onCreateTask: (index: number, selectedText?: string) => void;
+}
+
+export function ElementContextMenu({
+ x,
+ y,
+ element,
+ elementIndex,
+ selectedText,
+ onClose,
+ onFocus,
+ onDelete,
+ onDuplicate,
+ onConvert,
+ onGenerate,
+ onCreateTask,
+}: ElementContextMenuProps) {
+ const menuRef = useRef<HTMLDivElement>(null);
+ const [activeSubmenu, setActiveSubmenu] = useState<"generate" | "convert" | null>(null);
+
+ // Close on click outside
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
+ onClose();
+ }
+ };
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ onClose();
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ document.addEventListener("keydown", handleKeyDown);
+
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [onClose]);
+
+ // Adjust position if menu would overflow viewport
+ useEffect(() => {
+ if (menuRef.current) {
+ const rect = menuRef.current.getBoundingClientRect();
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+
+ if (rect.right > viewportWidth) {
+ menuRef.current.style.left = `${x - rect.width}px`;
+ }
+ if (rect.bottom > viewportHeight) {
+ menuRef.current.style.top = `${y - rect.height}px`;
+ }
+ }
+ }, [x, y]);
+
+ const getElementTypeLabel = () => {
+ switch (element.type) {
+ case "heading":
+ return `Heading ${element.level}`;
+ case "paragraph":
+ return "Paragraph";
+ case "code":
+ return element.language ? `Code (${element.language})` : "Code";
+ case "list":
+ return element.ordered ? "Ordered List" : "Bullet List";
+ case "chart":
+ return `Chart (${element.chartType})`;
+ case "image":
+ return "Image";
+ default:
+ return "Element";
+ }
+ };
+
+ const menuItemClass =
+ "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center gap-2";
+ const submenuTriggerClass =
+ "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center justify-between";
+ const dividerClass = "border-t border-[rgba(117,170,252,0.2)] my-1";
+
+ return (
+ <div
+ ref={menuRef}
+ className="fixed z-50 bg-[#0a0a0a] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[180px]"
+ style={{ left: x, top: y }}
+ >
+ {/* Header showing element type */}
+ <div className="px-3 py-1.5 text-[10px] font-mono text-[#555] uppercase border-b border-[rgba(117,170,252,0.2)]">
+ {getElementTypeLabel()} [{elementIndex}]
+ </div>
+
+ {/* Focus action */}
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onFocus(elementIndex);
+ onClose();
+ }}
+ >
+ <span className="text-[#75aafc]">&gt;</span>
+ Focus on this element
+ </button>
+
+ {/* Create task action */}
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onCreateTask(elementIndex, selectedText);
+ onClose();
+ }}
+ >
+ <span className="text-[#75aafc]">@</span>
+ {selectedText ? "Create task from selection" : "Create task from this"}
+ </button>
+
+ <div className={dividerClass} />
+
+ {/* Generate submenu */}
+ <div
+ className="relative"
+ onMouseEnter={() => setActiveSubmenu("generate")}
+ onMouseLeave={() => setActiveSubmenu(null)}
+ >
+ <button className={submenuTriggerClass}>
+ <span className="flex items-center gap-2">
+ <span className="text-[#75aafc]">+</span>
+ Generate from this
+ </span>
+ <span className="text-[#555]">&rsaquo;</span>
+ </button>
+
+ {activeSubmenu === "generate" && (
+ <div className="absolute left-full top-0 bg-[#0a0a0a] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[160px]">
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onGenerate(elementIndex, "elaborate");
+ onClose();
+ }}
+ >
+ Elaborate/Expand
+ </button>
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onGenerate(elementIndex, "summarize");
+ onClose();
+ }}
+ >
+ Summarize
+ </button>
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onGenerate(elementIndex, "extract_actions");
+ onClose();
+ }}
+ >
+ Extract action items
+ </button>
+ </div>
+ )}
+ </div>
+
+ {/* Convert submenu */}
+ <div
+ className="relative"
+ onMouseEnter={() => setActiveSubmenu("convert")}
+ onMouseLeave={() => setActiveSubmenu(null)}
+ >
+ <button className={submenuTriggerClass}>
+ <span className="flex items-center gap-2">
+ <span className="text-[#75aafc]">~</span>
+ Convert to...
+ </span>
+ <span className="text-[#555]">&rsaquo;</span>
+ </button>
+
+ {activeSubmenu === "convert" && (
+ <div className="absolute left-full top-0 bg-[#0a0a0a] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[140px]">
+ {element.type !== "paragraph" && (
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onConvert(elementIndex, "paragraph");
+ onClose();
+ }}
+ >
+ Paragraph
+ </button>
+ )}
+ {element.type !== "list" && (
+ <>
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onConvert(elementIndex, "list_unordered");
+ onClose();
+ }}
+ >
+ Bullet List
+ </button>
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onConvert(elementIndex, "list_ordered");
+ onClose();
+ }}
+ >
+ Numbered List
+ </button>
+ </>
+ )}
+ {element.type !== "code" && (
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onConvert(elementIndex, "code");
+ onClose();
+ }}
+ >
+ Code Block
+ </button>
+ )}
+ {element.type !== "heading" && (
+ <>
+ <div className={dividerClass} />
+ {[1, 2, 3, 4, 5, 6].map((level) => (
+ <button
+ key={level}
+ className={menuItemClass}
+ onClick={() => {
+ onConvert(elementIndex, `heading_${level}`);
+ onClose();
+ }}
+ >
+ Heading {level}
+ </button>
+ ))}
+ </>
+ )}
+ </div>
+ )}
+ </div>
+
+ <div className={dividerClass} />
+
+ {/* Duplicate */}
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onDuplicate(elementIndex);
+ onClose();
+ }}
+ >
+ <span className="text-[#75aafc]">++</span>
+ Duplicate
+ </button>
+
+ {/* Delete */}
+ <button
+ className={`${menuItemClass} text-red-400 hover:bg-red-400/10`}
+ onClick={() => {
+ onDelete(elementIndex);
+ onClose();
+ }}
+ >
+ <span className="text-red-400">x</span>
+ Delete
+ </button>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/files/FileDetail.tsx b/makima/frontend/src/components/files/FileDetail.tsx
index c7b716a..60458e9 100644
--- a/makima/frontend/src/components/files/FileDetail.tsx
+++ b/makima/frontend/src/components/files/FileDetail.tsx
@@ -3,6 +3,12 @@ import type { FileDetail as FileDetailType, FileVersionSummary, FileVersion } fr
import { BodyRenderer } from "./BodyRenderer";
import { VersionHistoryDropdown } from "./VersionHistoryDropdown";
+export interface FocusedElement {
+ index: number;
+ type: string;
+ preview: string;
+}
+
interface FileDetailProps {
file: FileDetailType;
loading: boolean;
@@ -11,9 +17,17 @@ interface FileDetailProps {
onDelete: (id: string) => void;
onBodyElementUpdate?: (index: number, element: import("../../lib/api").BodyElement) => void;
onBodyReorder?: (fromIndex: number, toIndex: number) => void;
+ onBodyElementDelete?: (index: number) => void;
+ onBodyElementDuplicate?: (index: number) => void;
onEditingChange?: (isEditing: boolean) => void;
hasPendingRemoteUpdate?: boolean;
onOverwrite?: () => void;
+ // Focus element props
+ focusedElement?: FocusedElement | null;
+ onFocusElement?: (element: FocusedElement | null) => void;
+ onGenerateFromElement?: (index: number, action: string) => void;
+ onConvertElement?: (index: number, toType: string) => void;
+ onCreateTaskFromElement?: (index: number, selectedText?: string) => void;
// Version history props
versions?: FileVersionSummary[];
versionsLoading?: boolean;
@@ -33,9 +47,16 @@ export function FileDetail({
onDelete,
onBodyElementUpdate,
onBodyReorder,
+ onBodyElementDelete,
+ onBodyElementDuplicate,
onEditingChange,
hasPendingRemoteUpdate,
onOverwrite,
+ focusedElement: _focusedElement,
+ onFocusElement,
+ onGenerateFromElement,
+ onConvertElement,
+ onCreateTaskFromElement,
versions = [],
versionsLoading = false,
selectedVersion = null,
@@ -50,6 +71,38 @@ export function FileDetail({
const [description, setDescription] = useState(file.description || "");
const [transcriptExpanded, setTranscriptExpanded] = useState(false);
+ // Helper to get element preview text
+ const getElementPreview = (index: number): string => {
+ const element = file.body[index];
+ if (!element) return "";
+ switch (element.type) {
+ case "heading":
+ case "paragraph":
+ return element.text.slice(0, 50) + (element.text.length > 50 ? "..." : "");
+ case "code":
+ return element.content.slice(0, 50) + (element.content.length > 50 ? "..." : "");
+ case "list":
+ return element.items[0]?.slice(0, 40) + (element.items.length > 1 ? ` (+${element.items.length - 1} more)` : "");
+ case "chart":
+ return element.title || `${element.chartType} chart`;
+ case "image":
+ return element.alt || element.caption || "Image";
+ default:
+ return "Element";
+ }
+ };
+
+ // Handler for focus action from context menu
+ const handleFocusElement = (index: number) => {
+ const element = file.body[index];
+ if (!element || !onFocusElement) return;
+ onFocusElement({
+ index,
+ type: element.type,
+ preview: getElementPreview(index),
+ });
+ };
+
// Update local state when file changes
useEffect(() => {
setName(file.name);
@@ -192,6 +245,12 @@ export function FileDetail({
onEditingChange={onEditingChange}
hasPendingRemoteUpdate={hasPendingRemoteUpdate}
onOverwrite={onOverwrite}
+ onFocusElement={handleFocusElement}
+ onDeleteElement={onBodyElementDelete}
+ onDuplicateElement={onBodyElementDuplicate}
+ onConvertElement={onConvertElement}
+ onGenerateFromElement={onGenerateFromElement}
+ onCreateTaskFromElement={onCreateTaskFromElement}
/>
</div>
diff --git a/makima/frontend/src/components/files/FileList.tsx b/makima/frontend/src/components/files/FileList.tsx
index a859aa1..c537846 100644
--- a/makima/frontend/src/components/files/FileList.tsx
+++ b/makima/frontend/src/components/files/FileList.tsx
@@ -1,4 +1,5 @@
-import type { FileSummary } from "../../lib/api";
+import { useRef } from "react";
+import type { FileSummary, BodyElement } from "../../lib/api";
interface FileListProps {
files: FileSummary[];
@@ -6,6 +7,154 @@ interface FileListProps {
onSelect: (id: string) => void;
onDelete: (id: string) => void;
onCreate: () => void;
+ onUploadMarkdown?: (name: string, body: BodyElement[]) => void;
+}
+
+/**
+ * Parse markdown text into BodyElements.
+ * Converts image embeds to links instead of images.
+ */
+function parseMarkdown(markdown: string): BodyElement[] {
+ const elements: BodyElement[] = [];
+ const lines = markdown.split('\n');
+ let currentParagraph: string[] = [];
+ let inCodeBlock = false;
+ let codeBlockLanguage: string | undefined;
+ let codeBlockContent: string[] = [];
+ let currentList: { ordered: boolean; items: string[] } | null = null;
+
+ const flushParagraph = () => {
+ if (currentParagraph.length > 0) {
+ const text = currentParagraph.join('\n').trim();
+ if (text) {
+ elements.push({ type: "paragraph", text });
+ }
+ currentParagraph = [];
+ }
+ };
+
+ const flushCodeBlock = () => {
+ if (codeBlockContent.length > 0 || inCodeBlock) {
+ elements.push({
+ type: "code",
+ language: codeBlockLanguage || undefined,
+ content: codeBlockContent.join('\n'),
+ });
+ codeBlockContent = [];
+ codeBlockLanguage = undefined;
+ }
+ };
+
+ const flushList = () => {
+ if (currentList && currentList.items.length > 0) {
+ elements.push({
+ type: "list",
+ ordered: currentList.ordered,
+ items: currentList.items,
+ });
+ currentList = null;
+ }
+ };
+
+ // Convert image syntax ![alt](url) to link syntax [alt](url) or [image](url)
+ const convertImagesToLinks = (text: string): string => {
+ return text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => {
+ const linkText = alt || 'image';
+ return `[${linkText}](${url})`;
+ });
+ };
+
+ for (const rawLine of lines) {
+ // Check for code block fence (``` or ~~~)
+ const codeFenceMatch = rawLine.match(/^(`{3,}|~{3,})(\w*)?$/);
+ if (codeFenceMatch) {
+ if (!inCodeBlock) {
+ // Starting a code block
+ flushParagraph();
+ flushList();
+ inCodeBlock = true;
+ codeBlockLanguage = codeFenceMatch[2] || undefined;
+ codeBlockContent = [];
+ } else {
+ // Ending a code block
+ flushCodeBlock();
+ inCodeBlock = false;
+ }
+ continue;
+ }
+
+ // If inside a code block, add line as-is
+ if (inCodeBlock) {
+ codeBlockContent.push(rawLine);
+ continue;
+ }
+
+ // Convert images to links in all lines
+ const line = convertImagesToLinks(rawLine);
+
+ // Check for headings
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
+ if (headingMatch) {
+ flushParagraph();
+ flushList();
+ const level = headingMatch[1].length;
+ const text = headingMatch[2].trim();
+ elements.push({ type: "heading", level, text });
+ continue;
+ }
+
+ // Check for unordered list items (-, *, +)
+ const unorderedMatch = line.match(/^[\s]*[-*+]\s+(.+)$/);
+ if (unorderedMatch) {
+ flushParagraph();
+ const itemText = unorderedMatch[1].trim();
+ if (currentList && currentList.ordered) {
+ // Switch from ordered to unordered
+ flushList();
+ }
+ if (!currentList) {
+ currentList = { ordered: false, items: [] };
+ }
+ currentList.items.push(itemText);
+ continue;
+ }
+
+ // Check for ordered list items (1. 2. etc)
+ const orderedMatch = line.match(/^[\s]*\d+\.\s+(.+)$/);
+ if (orderedMatch) {
+ flushParagraph();
+ const itemText = orderedMatch[1].trim();
+ if (currentList && !currentList.ordered) {
+ // Switch from unordered to ordered
+ flushList();
+ }
+ if (!currentList) {
+ currentList = { ordered: true, items: [] };
+ }
+ currentList.items.push(itemText);
+ continue;
+ }
+
+ // Empty line - flush everything
+ if (line.trim() === '') {
+ flushParagraph();
+ flushList();
+ continue;
+ }
+
+ // Regular text - flush list first, then add to paragraph
+ flushList();
+ currentParagraph.push(line);
+ }
+
+ // Flush any remaining content
+ if (inCodeBlock) {
+ flushCodeBlock();
+ }
+ flushParagraph();
+ flushList();
+
+ return elements;
}
function formatDuration(seconds: number | null): string {
@@ -32,7 +181,32 @@ export function FileList({
onSelect,
onDelete,
onCreate,
+ onUploadMarkdown,
}: FileListProps) {
+ const fileInputRef = useRef<HTMLInputElement>(null);
+
+ const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const file = event.target.files?.[0];
+ if (!file || !onUploadMarkdown) return;
+
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ const content = e.target?.result as string;
+ if (content) {
+ const body = parseMarkdown(content);
+ // Use filename without extension as the name
+ const name = file.name.replace(/\.md$/i, '') || 'Imported Document';
+ onUploadMarkdown(name, body);
+ }
+ };
+ reader.readAsText(file);
+
+ // Reset input so the same file can be uploaded again
+ if (fileInputRef.current) {
+ fileInputRef.current.value = '';
+ }
+ };
+
if (loading) {
return (
<div className="panel h-full flex items-center justify-center">
@@ -47,12 +221,31 @@ export function FileList({
<div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
FILES//
</div>
- <button
- onClick={onCreate}
- className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors uppercase"
- >
- + New
- </button>
+ <div className="flex items-center gap-2">
+ {onUploadMarkdown && (
+ <>
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".md,.markdown,text/markdown"
+ onChange={handleFileUpload}
+ className="hidden"
+ />
+ <button
+ onClick={() => fileInputRef.current?.click()}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors uppercase"
+ >
+ Upload .md
+ </button>
+ </>
+ )}
+ <button
+ onClick={onCreate}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors uppercase"
+ >
+ + New
+ </button>
+ </div>
</div>
<div className="flex-1 overflow-y-auto">
diff --git a/makima/frontend/src/components/mesh/DirectoryInput.tsx b/makima/frontend/src/components/mesh/DirectoryInput.tsx
new file mode 100644
index 0000000..e2e331e
--- /dev/null
+++ b/makima/frontend/src/components/mesh/DirectoryInput.tsx
@@ -0,0 +1,220 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+import type { DaemonDirectory } from "../../lib/api";
+
+interface DirectoryInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ suggestions: DaemonDirectory[];
+ placeholder?: string;
+ /** Repository URL to extract repo name for home directory suggestions */
+ repoUrl?: string | null;
+ className?: string;
+ disabled?: boolean;
+}
+
+/** Extract repository name from URL */
+function extractRepoName(url: string | null | undefined): string | null {
+ if (!url) return null;
+
+ // Handle various URL formats:
+ // https://github.com/user/repo.git -> repo
+ // https://github.com/user/repo -> repo
+ // git@github.com:user/repo.git -> repo
+ // /path/to/local/repo -> repo
+
+ let name = url;
+
+ // Remove trailing .git
+ if (name.endsWith(".git")) {
+ name = name.slice(0, -4);
+ }
+
+ // Remove trailing slash
+ if (name.endsWith("/")) {
+ name = name.slice(0, -1);
+ }
+
+ // Get the last path segment
+ const lastSlash = name.lastIndexOf("/");
+ if (lastSlash !== -1) {
+ name = name.slice(lastSlash + 1);
+ }
+
+ // Handle git@host:user/repo format
+ const colonIndex = name.lastIndexOf(":");
+ if (colonIndex !== -1) {
+ const afterColon = name.slice(colonIndex + 1);
+ const slashIndex = afterColon.lastIndexOf("/");
+ if (slashIndex !== -1) {
+ name = afterColon.slice(slashIndex + 1);
+ } else {
+ name = afterColon;
+ }
+ }
+
+ return name || null;
+}
+
+export function DirectoryInput({
+ value,
+ onChange,
+ suggestions,
+ placeholder = "/path/to/directory",
+ repoUrl,
+ className = "",
+ disabled = false,
+}: DirectoryInputProps) {
+ const [showSuggestions, setShowSuggestions] = useState(false);
+ const [highlightedIndex, setHighlightedIndex] = useState(-1);
+ const inputRef = useRef<HTMLInputElement>(null);
+ const dropdownRef = useRef<HTMLDivElement>(null);
+
+ // Extract repo name for home directory suggestions
+ const repoName = extractRepoName(repoUrl);
+
+ // Process suggestions to add repo name to home directory
+ const processedSuggestions = suggestions.map((dir) => {
+ if (dir.directoryType === "home" && repoName) {
+ return {
+ ...dir,
+ path: `${dir.path}/${repoName}`,
+ label: `${dir.label} (${repoName})`,
+ };
+ }
+ return dir;
+ });
+
+ // Filter suggestions based on current input
+ const filteredSuggestions = processedSuggestions.filter((dir) => {
+ if (!value) return true;
+ const lowerValue = value.toLowerCase();
+ return (
+ dir.path.toLowerCase().includes(lowerValue) ||
+ dir.label.toLowerCase().includes(lowerValue)
+ );
+ });
+
+ // Handle clicking outside to close dropdown
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(e.target as Node) &&
+ inputRef.current &&
+ !inputRef.current.contains(e.target as Node)
+ ) {
+ setShowSuggestions(false);
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, []);
+
+ const handleFocus = useCallback(() => {
+ setShowSuggestions(true);
+ setHighlightedIndex(-1);
+ }, []);
+
+ const handleBlur = useCallback(() => {
+ // Delay hiding to allow click on suggestion
+ setTimeout(() => {
+ setShowSuggestions(false);
+ }, 150);
+ }, []);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (!showSuggestions || filteredSuggestions.length === 0) return;
+
+ switch (e.key) {
+ case "ArrowDown":
+ e.preventDefault();
+ setHighlightedIndex((prev) =>
+ prev < filteredSuggestions.length - 1 ? prev + 1 : 0
+ );
+ break;
+ case "ArrowUp":
+ e.preventDefault();
+ setHighlightedIndex((prev) =>
+ prev > 0 ? prev - 1 : filteredSuggestions.length - 1
+ );
+ break;
+ case "Enter":
+ e.preventDefault();
+ if (highlightedIndex >= 0 && highlightedIndex < filteredSuggestions.length) {
+ onChange(filteredSuggestions[highlightedIndex].path);
+ setShowSuggestions(false);
+ }
+ break;
+ case "Escape":
+ setShowSuggestions(false);
+ break;
+ }
+ },
+ [showSuggestions, filteredSuggestions, highlightedIndex, onChange]
+ );
+
+ const handleSelectSuggestion = useCallback(
+ (path: string) => {
+ onChange(path);
+ setShowSuggestions(false);
+ inputRef.current?.focus();
+ },
+ [onChange]
+ );
+
+ return (
+ <div className={`relative ${className}`}>
+ <input
+ ref={inputRef}
+ type="text"
+ value={value}
+ onChange={(e) => onChange(e.target.value)}
+ onFocus={handleFocus}
+ onBlur={handleBlur}
+ onKeyDown={handleKeyDown}
+ placeholder={placeholder}
+ disabled={disabled}
+ className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3] disabled:opacity-50"
+ />
+
+ {/* Suggestions dropdown */}
+ {showSuggestions && filteredSuggestions.length > 0 && (
+ <div
+ ref={dropdownRef}
+ className="absolute z-50 left-0 right-0 top-full mt-1 bg-[#0a1525] border border-[rgba(117,170,252,0.35)] shadow-lg max-h-48 overflow-auto"
+ >
+ {filteredSuggestions.map((dir, index) => (
+ <button
+ key={`${dir.directoryType}-${index}`}
+ type="button"
+ onClick={() => handleSelectSuggestion(dir.path)}
+ onMouseEnter={() => setHighlightedIndex(index)}
+ className={`w-full text-left px-3 py-2 font-mono text-xs transition-colors ${
+ index === highlightedIndex
+ ? "bg-[rgba(117,170,252,0.2)] text-[#dbe7ff]"
+ : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)]"
+ }`}
+ >
+ <div className="flex items-center justify-between gap-2">
+ <div className="flex items-center gap-2">
+ <span className="text-[#75aafc]">{dir.label}</span>
+ {dir.exists === true && (
+ <span className="text-[#f0ad4e] text-[10px]" title="Directory already exists">
+ (exists)
+ </span>
+ )}
+ </div>
+ {dir.hostname && (
+ <span className="text-[#555] text-[10px]">({dir.hostname})</span>
+ )}
+ </div>
+ <div className="text-[10px] text-[#555] truncate">{dir.path}</div>
+ </button>
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/InlineSubtaskEditor.tsx b/makima/frontend/src/components/mesh/InlineSubtaskEditor.tsx
new file mode 100644
index 0000000..3621b08
--- /dev/null
+++ b/makima/frontend/src/components/mesh/InlineSubtaskEditor.tsx
@@ -0,0 +1,262 @@
+import { useState, useCallback, useEffect } from "react";
+import type { TaskWithSubtasks, TaskStatus } from "../../lib/api";
+import { getTask, updateTask } from "../../lib/api";
+
+interface InlineSubtaskEditorProps {
+ subtaskId: string;
+ onClose: () => void;
+ onUpdated: () => void;
+ onNavigate?: (taskId: string) => void;
+}
+
+function getStatusColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "text-[#9bc3ff]";
+ case "running":
+ return "text-green-400";
+ case "paused":
+ return "text-yellow-400";
+ case "blocked":
+ return "text-orange-400";
+ case "done":
+ return "text-emerald-400";
+ case "failed":
+ return "text-red-400";
+ case "merged":
+ return "text-purple-400";
+ default:
+ return "text-[#9bc3ff]";
+ }
+}
+
+function getStatusBgColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "bg-[rgba(117,170,252,0.1)]";
+ case "running":
+ return "bg-green-400/10";
+ case "paused":
+ return "bg-yellow-400/10";
+ case "blocked":
+ return "bg-orange-400/10";
+ case "done":
+ return "bg-emerald-400/10";
+ case "failed":
+ return "bg-red-400/10";
+ case "merged":
+ return "bg-purple-400/10";
+ default:
+ return "bg-[rgba(117,170,252,0.1)]";
+ }
+}
+
+export function InlineSubtaskEditor({
+ subtaskId,
+ onClose,
+ onUpdated,
+ onNavigate,
+}: InlineSubtaskEditorProps) {
+ const [subtask, setSubtask] = useState<TaskWithSubtasks | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [isEditing, setIsEditing] = useState(false);
+ const [editName, setEditName] = useState("");
+ const [editDescription, setEditDescription] = useState("");
+ const [editPlan, setEditPlan] = useState("");
+
+ // Load subtask details
+ useEffect(() => {
+ setLoading(true);
+ getTask(subtaskId)
+ .then((task) => {
+ setSubtask(task);
+ setEditName(task.name);
+ setEditDescription(task.description || "");
+ setEditPlan(task.plan);
+ })
+ .catch((err) => {
+ console.error("Failed to load subtask:", err);
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ }, [subtaskId]);
+
+ const handleSave = useCallback(async () => {
+ if (!subtask || saving) return;
+ setSaving(true);
+ try {
+ await updateTask(subtaskId, {
+ name: editName,
+ description: editDescription || undefined,
+ plan: editPlan,
+ version: subtask.version,
+ });
+ // Refresh subtask
+ const updated = await getTask(subtaskId);
+ setSubtask(updated);
+ setIsEditing(false);
+ onUpdated();
+ } catch (err) {
+ console.error("Failed to save subtask:", err);
+ } finally {
+ setSaving(false);
+ }
+ }, [subtask, subtaskId, editName, editDescription, editPlan, saving, onUpdated]);
+
+ const handleCancel = useCallback(() => {
+ if (subtask) {
+ setEditName(subtask.name);
+ setEditDescription(subtask.description || "");
+ setEditPlan(subtask.plan);
+ }
+ setIsEditing(false);
+ }, [subtask]);
+
+ if (loading) {
+ return (
+ <div className="p-4 bg-[rgba(0,0,0,0.2)] border-l-2 border-[#3f6fb3]">
+ <div className="font-mono text-xs text-[#75aafc]">Loading subtask...</div>
+ </div>
+ );
+ }
+
+ if (!subtask) {
+ return (
+ <div className="p-4 bg-[rgba(0,0,0,0.2)] border-l-2 border-red-400">
+ <div className="font-mono text-xs text-red-400">Failed to load subtask</div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="bg-[rgba(0,0,0,0.2)] border-l-2 border-[#3f6fb3]">
+ {/* Header */}
+ <div className="flex items-center justify-between p-3 border-b border-[rgba(117,170,252,0.15)]">
+ <div className="flex items-center gap-2">
+ <button
+ onClick={onClose}
+ className="font-mono text-[10px] text-[#555] hover:text-[#75aafc]"
+ >
+ [close]
+ </button>
+ <span
+ className={`px-1.5 py-0.5 font-mono text-[9px] uppercase ${getStatusColor(
+ subtask.status as TaskStatus
+ )} ${getStatusBgColor(subtask.status as TaskStatus)} border border-current/20`}
+ >
+ {subtask.status}
+ </span>
+ {onNavigate && (
+ <button
+ onClick={() => onNavigate(subtaskId)}
+ className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff]"
+ >
+ [open full view]
+ </button>
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ {isEditing ? (
+ <>
+ <button
+ onClick={handleCancel}
+ disabled={saving}
+ className="px-2 py-0.5 font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] disabled:opacity-50"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleSave}
+ disabled={saving}
+ className="px-2 py-0.5 font-mono text-[10px] text-green-400 border border-green-400/30 hover:border-green-400/50 disabled:opacity-50"
+ >
+ {saving ? "..." : "Save"}
+ </button>
+ </>
+ ) : (
+ <button
+ onClick={() => setIsEditing(true)}
+ className="px-2 py-0.5 font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff]"
+ >
+ Edit
+ </button>
+ )}
+ </div>
+ </div>
+
+ {/* Content */}
+ <div className="p-3 space-y-3">
+ {/* Name */}
+ {isEditing ? (
+ <input
+ type="text"
+ value={editName}
+ onChange={(e) => setEditName(e.target.value)}
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-2 py-1 outline-none focus:border-[#3f6fb3]"
+ placeholder="Subtask name"
+ />
+ ) : (
+ <div className="font-mono text-sm text-[#dbe7ff]">{subtask.name}</div>
+ )}
+
+ {/* Description */}
+ {isEditing ? (
+ <textarea
+ value={editDescription}
+ onChange={(e) => setEditDescription(e.target.value)}
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs px-2 py-1 outline-none focus:border-[#3f6fb3] min-h-[40px] resize-y"
+ placeholder="Description (optional)"
+ />
+ ) : subtask.description ? (
+ <div className="font-mono text-xs text-[#75aafc]">{subtask.description}</div>
+ ) : null}
+
+ {/* Plan */}
+ <div className="space-y-1">
+ <div className="font-mono text-[10px] text-[#555] uppercase">Plan</div>
+ {isEditing ? (
+ <textarea
+ value={editPlan}
+ onChange={(e) => setEditPlan(e.target.value)}
+ className="w-full bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-xs px-2 py-1 outline-none focus:border-[#3f6fb3] min-h-[100px] resize-y"
+ placeholder="Plan/instructions..."
+ />
+ ) : (
+ <pre className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-2 font-mono text-xs text-[#9bc3ff] whitespace-pre-wrap max-h-[150px] overflow-y-auto">
+ {subtask.plan}
+ </pre>
+ )}
+ </div>
+
+ {/* Progress/Error */}
+ {subtask.progressSummary && (
+ <div className="font-mono text-[10px] text-[#75aafc]">
+ <span className="text-[#555]">Progress:</span> {subtask.progressSummary}
+ </div>
+ )}
+ {subtask.errorMessage && (
+ <div className="font-mono text-[10px] text-red-400">
+ <span className="text-red-400/50">Error:</span> {subtask.errorMessage}
+ </div>
+ )}
+
+ {/* Nested subtasks indicator */}
+ {subtask.subtasks.length > 0 && (
+ <div className="font-mono text-[10px] text-[#555]">
+ Has {subtask.subtasks.length} subtask{subtask.subtasks.length > 1 ? "s" : ""}
+ {onNavigate && (
+ <button
+ onClick={() => onNavigate(subtaskId)}
+ className="ml-2 text-[#75aafc] hover:text-[#9bc3ff]"
+ >
+ [view all]
+ </button>
+ )}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/MergeConflictResolver.tsx b/makima/frontend/src/components/mesh/MergeConflictResolver.tsx
new file mode 100644
index 0000000..4479705
--- /dev/null
+++ b/makima/frontend/src/components/mesh/MergeConflictResolver.tsx
@@ -0,0 +1,504 @@
+import { useState, useMemo, useCallback } from "react";
+
+interface ConflictHunk {
+ id: string;
+ filePath: string;
+ startLine: number;
+ endLine: number;
+ ours: string[]; // Changes from current branch
+ theirs: string[]; // Changes from incoming branch
+ base?: string[]; // Original content (if 3-way merge)
+ resolved?: "ours" | "theirs" | "both" | "custom";
+ customResolution?: string[];
+}
+
+interface ConflictFile {
+ path: string;
+ hunks: ConflictHunk[];
+ resolved: boolean;
+}
+
+interface MergeConflictResolverProps {
+ conflicts: ConflictFile[];
+ sourceBranch: string;
+ targetBranch: string;
+ loading?: boolean;
+ onResolve: (resolutions: Map<string, ConflictHunk[]>) => Promise<void>;
+ onAbort: () => void;
+ onAskLLM?: (hunk: ConflictHunk) => Promise<string[]>;
+}
+
+type ResolutionChoice = "ours" | "theirs" | "both" | "custom";
+
+function ConflictHunkView({
+ hunk,
+ sourceBranch,
+ targetBranch,
+ onResolve,
+ onAskLLM,
+}: {
+ hunk: ConflictHunk;
+ sourceBranch: string;
+ targetBranch: string;
+ onResolve: (resolution: ResolutionChoice, customLines?: string[]) => void;
+ onAskLLM?: () => Promise<void>;
+}) {
+ const [showCustomEditor, setShowCustomEditor] = useState(false);
+ const [customText, setCustomText] = useState(
+ hunk.customResolution?.join("\n") || [...hunk.ours, ...hunk.theirs].join("\n")
+ );
+ const [askingLLM, setAskingLLM] = useState(false);
+
+ const handleAskLLM = async () => {
+ if (!onAskLLM || askingLLM) return;
+ setAskingLLM(true);
+ try {
+ await onAskLLM();
+ } finally {
+ setAskingLLM(false);
+ }
+ };
+
+ const handleCustomSave = () => {
+ const lines = customText.split("\n");
+ onResolve("custom", lines);
+ setShowCustomEditor(false);
+ };
+
+ const isResolved = hunk.resolved !== undefined;
+
+ return (
+ <div
+ className={`border ${
+ isResolved
+ ? "border-green-400/30 bg-green-400/5"
+ : "border-yellow-400/30 bg-yellow-400/5"
+ } mb-3`}
+ >
+ {/* Hunk header */}
+ <div className="flex items-center justify-between px-3 py-2 border-b border-[rgba(117,170,252,0.2)]">
+ <div className="font-mono text-xs text-[#75aafc]">
+ Lines {hunk.startLine}-{hunk.endLine}
+ {isResolved && (
+ <span className="ml-2 text-green-400">
+ (Resolved: {hunk.resolved})
+ </span>
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ {onAskLLM && (
+ <button
+ onClick={handleAskLLM}
+ disabled={askingLLM}
+ className="px-2 py-1 font-mono text-[10px] text-purple-400 border border-purple-400/30 hover:border-purple-400/50 disabled:opacity-50 transition-colors"
+ >
+ {askingLLM ? "..." : "Ask LLM"}
+ </button>
+ )}
+ <button
+ onClick={() => setShowCustomEditor(!showCustomEditor)}
+ className="px-2 py-1 font-mono text-[10px] text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ Edit
+ </button>
+ </div>
+ </div>
+
+ {/* Conflict content */}
+ {!showCustomEditor ? (
+ <div className="grid grid-cols-2 divide-x divide-[rgba(117,170,252,0.2)]">
+ {/* Ours (current branch) */}
+ <div className="p-2">
+ <div className="flex items-center justify-between mb-2">
+ <span className="font-mono text-[10px] text-[#9bc3ff] uppercase">
+ {targetBranch} (ours)
+ </span>
+ <button
+ onClick={() => onResolve("ours")}
+ className={`px-2 py-0.5 font-mono text-[9px] border transition-colors ${
+ hunk.resolved === "ours"
+ ? "text-green-400 border-green-400/50 bg-green-400/10"
+ : "text-[#75aafc] border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3]"
+ }`}
+ >
+ Use This
+ </button>
+ </div>
+ <pre className="font-mono text-xs text-red-400 bg-red-400/5 p-2 overflow-x-auto">
+ {hunk.ours.map((line, i) => (
+ <div key={i}>
+ <span className="text-[#555] select-none mr-2">-</span>
+ {line}
+ </div>
+ ))}
+ </pre>
+ </div>
+
+ {/* Theirs (incoming branch) */}
+ <div className="p-2">
+ <div className="flex items-center justify-between mb-2">
+ <span className="font-mono text-[10px] text-[#9bc3ff] uppercase">
+ {sourceBranch} (theirs)
+ </span>
+ <button
+ onClick={() => onResolve("theirs")}
+ className={`px-2 py-0.5 font-mono text-[9px] border transition-colors ${
+ hunk.resolved === "theirs"
+ ? "text-green-400 border-green-400/50 bg-green-400/10"
+ : "text-[#75aafc] border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3]"
+ }`}
+ >
+ Use This
+ </button>
+ </div>
+ <pre className="font-mono text-xs text-green-400 bg-green-400/5 p-2 overflow-x-auto">
+ {hunk.theirs.map((line, i) => (
+ <div key={i}>
+ <span className="text-[#555] select-none mr-2">+</span>
+ {line}
+ </div>
+ ))}
+ </pre>
+ </div>
+ </div>
+ ) : (
+ /* Custom editor */
+ <div className="p-2">
+ <div className="flex items-center justify-between mb-2">
+ <span className="font-mono text-[10px] text-[#9bc3ff] uppercase">
+ Custom Resolution
+ </span>
+ <div className="flex items-center gap-2">
+ <button
+ onClick={() => setShowCustomEditor(false)}
+ className="px-2 py-0.5 font-mono text-[9px] text-[#555] hover:text-[#9bc3ff]"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleCustomSave}
+ className="px-2 py-0.5 font-mono text-[9px] text-green-400 border border-green-400/30 hover:border-green-400/50"
+ >
+ Apply
+ </button>
+ </div>
+ </div>
+ <textarea
+ value={customText}
+ onChange={(e) => setCustomText(e.target.value)}
+ className="w-full bg-[rgba(0,0,0,0.3)] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-xs p-2 outline-none focus:border-[#3f6fb3] min-h-[100px] resize-y"
+ />
+ </div>
+ )}
+
+ {/* Both option */}
+ <div className="px-3 py-2 border-t border-[rgba(117,170,252,0.1)] flex justify-center">
+ <button
+ onClick={() => onResolve("both")}
+ className={`px-3 py-1 font-mono text-[10px] border transition-colors ${
+ hunk.resolved === "both"
+ ? "text-green-400 border-green-400/50 bg-green-400/10"
+ : "text-[#75aafc] border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3]"
+ }`}
+ >
+ Keep Both
+ </button>
+ </div>
+ </div>
+ );
+}
+
+function ConflictFileView({
+ file,
+ sourceBranch,
+ targetBranch,
+ onResolveHunk,
+ onAskLLM,
+}: {
+ file: ConflictFile;
+ sourceBranch: string;
+ targetBranch: string;
+ onResolveHunk: (hunkId: string, resolution: ResolutionChoice, customLines?: string[]) => void;
+ onAskLLM?: (hunk: ConflictHunk) => Promise<string[]>;
+}) {
+ const [expanded, setExpanded] = useState(true);
+ const resolvedCount = file.hunks.filter((h) => h.resolved !== undefined).length;
+
+ return (
+ <div className="border border-[rgba(117,170,252,0.2)] mb-3">
+ {/* File header */}
+ <button
+ onClick={() => setExpanded(!expanded)}
+ className="w-full flex items-center gap-2 px-3 py-2 bg-[rgba(0,0,0,0.2)] hover:bg-[rgba(0,0,0,0.3)] text-left"
+ >
+ <span className="font-mono text-[10px] text-[#555]">
+ {expanded ? "▼" : "▶"}
+ </span>
+ <span
+ className={`px-1.5 py-0.5 font-mono text-[9px] ${
+ file.resolved
+ ? "text-green-400 bg-green-400/10"
+ : "text-yellow-400 bg-yellow-400/10"
+ }`}
+ >
+ {file.resolved ? "RESOLVED" : "CONFLICT"}
+ </span>
+ <span className="font-mono text-sm text-[#dbe7ff] flex-1 truncate">
+ {file.path}
+ </span>
+ <span className="font-mono text-[10px] text-[#555]">
+ {resolvedCount}/{file.hunks.length} hunks
+ </span>
+ </button>
+
+ {/* Hunks */}
+ {expanded && (
+ <div className="p-3">
+ {file.hunks.map((hunk) => (
+ <ConflictHunkView
+ key={hunk.id}
+ hunk={hunk}
+ sourceBranch={sourceBranch}
+ targetBranch={targetBranch}
+ onResolve={(resolution, customLines) =>
+ onResolveHunk(hunk.id, resolution, customLines)
+ }
+ onAskLLM={
+ onAskLLM
+ ? async () => {
+ const resolution = await onAskLLM(hunk);
+ onResolveHunk(hunk.id, "custom", resolution);
+ }
+ : undefined
+ }
+ />
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
+
+export function MergeConflictResolver({
+ conflicts: initialConflicts,
+ sourceBranch,
+ targetBranch,
+ loading = false,
+ onResolve,
+ onAbort,
+ onAskLLM,
+}: MergeConflictResolverProps) {
+ const [conflicts, setConflicts] = useState<ConflictFile[]>(initialConflicts);
+ const [resolving, setResolving] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ // Calculate resolution stats
+ const stats = useMemo(() => {
+ const totalHunks = conflicts.reduce((sum, f) => sum + f.hunks.length, 0);
+ const resolvedHunks = conflicts.reduce(
+ (sum, f) => sum + f.hunks.filter((h) => h.resolved !== undefined).length,
+ 0
+ );
+ const resolvedFiles = conflicts.filter((f) => f.resolved).length;
+ return {
+ totalFiles: conflicts.length,
+ resolvedFiles,
+ totalHunks,
+ resolvedHunks,
+ allResolved: resolvedHunks === totalHunks,
+ };
+ }, [conflicts]);
+
+ // Handle resolving a single hunk
+ const handleResolveHunk = useCallback(
+ (filePath: string, hunkId: string, resolution: ResolutionChoice, customLines?: string[]) => {
+ setConflicts((prev) =>
+ prev.map((file) => {
+ if (file.path !== filePath) return file;
+
+ const updatedHunks = file.hunks.map((hunk) => {
+ if (hunk.id !== hunkId) return hunk;
+ return {
+ ...hunk,
+ resolved: resolution,
+ customResolution: customLines,
+ };
+ });
+
+ const allHunksResolved = updatedHunks.every((h) => h.resolved !== undefined);
+
+ return {
+ ...file,
+ hunks: updatedHunks,
+ resolved: allHunksResolved,
+ };
+ })
+ );
+ },
+ []
+ );
+
+ // Resolve all hunks in a file with same choice
+ const handleResolveFileAll = useCallback(
+ (filePath: string, resolution: ResolutionChoice) => {
+ setConflicts((prev) =>
+ prev.map((file) => {
+ if (file.path !== filePath) return file;
+
+ const updatedHunks = file.hunks.map((hunk) => ({
+ ...hunk,
+ resolved: resolution,
+ customResolution:
+ resolution === "both"
+ ? [...hunk.ours, ...hunk.theirs]
+ : resolution === "ours"
+ ? hunk.ours
+ : resolution === "theirs"
+ ? hunk.theirs
+ : undefined,
+ }));
+
+ return {
+ ...file,
+ hunks: updatedHunks,
+ resolved: true,
+ };
+ })
+ );
+ },
+ []
+ );
+
+ // Apply all resolutions
+ const handleApplyResolutions = async () => {
+ if (!stats.allResolved || resolving) return;
+
+ setResolving(true);
+ setError(null);
+
+ try {
+ const resolutionMap = new Map<string, ConflictHunk[]>();
+ conflicts.forEach((file) => {
+ resolutionMap.set(file.path, file.hunks);
+ });
+ await onResolve(resolutionMap);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to apply resolutions");
+ } finally {
+ setResolving(false);
+ }
+ };
+
+ if (loading) {
+ return (
+ <div className="panel p-4">
+ <div className="flex items-center justify-center h-32">
+ <div className="font-mono text-sm text-[#75aafc]">
+ Analyzing conflicts...
+ </div>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="panel flex flex-col max-h-[80vh] overflow-hidden">
+ {/* Header */}
+ <div className="flex items-center justify-between p-4 border-b border-[rgba(117,170,252,0.2)] shrink-0">
+ <div className="flex items-center gap-3">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Merge Conflicts
+ </div>
+ <span className="px-2 py-0.5 font-mono text-[10px] text-yellow-400 bg-yellow-400/10 border border-yellow-400/20">
+ {sourceBranch} → {targetBranch}
+ </span>
+ </div>
+ <button
+ onClick={onAbort}
+ className="font-mono text-xs text-red-400 hover:text-red-300"
+ >
+ Abort Merge
+ </button>
+ </div>
+
+ {/* Progress */}
+ <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)] shrink-0">
+ <div className="flex items-center justify-between mb-2">
+ <span className="font-mono text-[10px] text-[#75aafc]">
+ {stats.resolvedFiles}/{stats.totalFiles} files resolved
+ </span>
+ <span className="font-mono text-[10px] text-[#75aafc]">
+ {stats.resolvedHunks}/{stats.totalHunks} conflicts resolved
+ </span>
+ </div>
+ <div className="h-1.5 bg-[rgba(117,170,252,0.1)] rounded-full overflow-hidden">
+ <div
+ className="h-full bg-green-400 transition-all"
+ style={{
+ width: `${(stats.resolvedHunks / stats.totalHunks) * 100}%`,
+ }}
+ />
+ </div>
+ </div>
+
+ {/* Error */}
+ {error && (
+ <div className="mx-4 mt-3 bg-red-400/10 border border-red-400/30 p-3 font-mono text-xs text-red-400 shrink-0">
+ {error}
+ </div>
+ )}
+
+ {/* Conflict files */}
+ <div className="flex-1 overflow-y-auto p-4">
+ {conflicts.map((file) => (
+ <ConflictFileView
+ key={file.path}
+ file={file}
+ sourceBranch={sourceBranch}
+ targetBranch={targetBranch}
+ onResolveHunk={(hunkId, resolution, customLines) =>
+ handleResolveHunk(file.path, hunkId, resolution, customLines)
+ }
+ onAskLLM={onAskLLM}
+ />
+ ))}
+ </div>
+
+ {/* Footer actions */}
+ <div className="flex items-center justify-between p-4 border-t border-[rgba(117,170,252,0.2)] shrink-0">
+ <div className="flex items-center gap-2">
+ <button
+ onClick={() =>
+ conflicts.forEach((f) => handleResolveFileAll(f.path, "ours"))
+ }
+ className="px-3 py-1.5 font-mono text-[10px] text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ Accept All Ours
+ </button>
+ <button
+ onClick={() =>
+ conflicts.forEach((f) => handleResolveFileAll(f.path, "theirs"))
+ }
+ className="px-3 py-1.5 font-mono text-[10px] text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ Accept All Theirs
+ </button>
+ </div>
+ <button
+ onClick={handleApplyResolutions}
+ disabled={!stats.allResolved || resolving}
+ className="px-4 py-2 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {resolving
+ ? "Applying..."
+ : stats.allResolved
+ ? "Complete Merge"
+ : `Resolve ${stats.totalHunks - stats.resolvedHunks} Conflicts`}
+ </button>
+ </div>
+ </div>
+ );
+}
+
+// Export types for use in other components
+export type { ConflictHunk, ConflictFile, ResolutionChoice };
diff --git a/makima/frontend/src/components/mesh/OverlayDiffViewer.tsx b/makima/frontend/src/components/mesh/OverlayDiffViewer.tsx
new file mode 100644
index 0000000..74059a0
--- /dev/null
+++ b/makima/frontend/src/components/mesh/OverlayDiffViewer.tsx
@@ -0,0 +1,476 @@
+import { useState, useMemo } from "react";
+
+interface DiffLine {
+ type: "add" | "remove" | "context" | "header" | "hunk";
+ content: string;
+ oldLineNumber?: number;
+ newLineNumber?: number;
+}
+
+interface DiffFile {
+ path: string;
+ status: "added" | "modified" | "deleted" | "renamed";
+ oldPath?: string; // For renames
+ additions: number;
+ deletions: number;
+ lines: DiffLine[];
+}
+
+interface OverlayDiffViewerProps {
+ diff: string;
+ changedFiles?: string[];
+ loading?: boolean;
+ error?: string;
+ onClose?: () => void;
+ title?: string;
+}
+
+function parseDiff(diffText: string): DiffFile[] {
+ if (!diffText.trim()) return [];
+
+ const files: DiffFile[] = [];
+ const lines = diffText.split("\n");
+
+ let currentFile: DiffFile | null = null;
+ let oldLineNum = 0;
+ let newLineNum = 0;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+
+ // File header (diff --git a/path b/path)
+ if (line.startsWith("diff --git")) {
+ if (currentFile) {
+ files.push(currentFile);
+ }
+
+ // Extract paths
+ const match = line.match(/diff --git a\/(.+) b\/(.+)/);
+ const oldPath = match?.[1] || "";
+ const newPath = match?.[2] || oldPath;
+
+ currentFile = {
+ path: newPath,
+ oldPath: oldPath !== newPath ? oldPath : undefined,
+ status: "modified",
+ additions: 0,
+ deletions: 0,
+ lines: [],
+ };
+ continue;
+ }
+
+ if (!currentFile) continue;
+
+ // New file indicator
+ if (line.startsWith("new file mode")) {
+ currentFile.status = "added";
+ continue;
+ }
+
+ // Deleted file indicator
+ if (line.startsWith("deleted file mode")) {
+ currentFile.status = "deleted";
+ continue;
+ }
+
+ // Rename indicator
+ if (line.startsWith("rename from") || line.startsWith("rename to")) {
+ currentFile.status = "renamed";
+ continue;
+ }
+
+ // Hunk header (@@ -1,3 +1,4 @@)
+ if (line.startsWith("@@")) {
+ const hunkMatch = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@/);
+ if (hunkMatch) {
+ oldLineNum = parseInt(hunkMatch[1], 10);
+ newLineNum = parseInt(hunkMatch[2], 10);
+ }
+ currentFile.lines.push({
+ type: "hunk",
+ content: line,
+ });
+ continue;
+ }
+
+ // Skip other headers (---, +++, index, etc.)
+ if (
+ line.startsWith("---") ||
+ line.startsWith("+++") ||
+ line.startsWith("index ") ||
+ line.startsWith("Binary files")
+ ) {
+ currentFile.lines.push({
+ type: "header",
+ content: line,
+ });
+ continue;
+ }
+
+ // Diff content
+ if (line.startsWith("+")) {
+ currentFile.additions++;
+ currentFile.lines.push({
+ type: "add",
+ content: line.substring(1),
+ newLineNumber: newLineNum++,
+ });
+ } else if (line.startsWith("-")) {
+ currentFile.deletions++;
+ currentFile.lines.push({
+ type: "remove",
+ content: line.substring(1),
+ oldLineNumber: oldLineNum++,
+ });
+ } else if (line.startsWith(" ") || line === "") {
+ currentFile.lines.push({
+ type: "context",
+ content: line.substring(1) || "",
+ oldLineNumber: oldLineNum++,
+ newLineNumber: newLineNum++,
+ });
+ }
+ }
+
+ if (currentFile) {
+ files.push(currentFile);
+ }
+
+ return files;
+}
+
+function DiffFileView({ file, collapsed, onToggle }: { file: DiffFile; collapsed: boolean; onToggle: () => void }) {
+ const statusColors: Record<DiffFile["status"], string> = {
+ added: "text-green-400 bg-green-400/10",
+ modified: "text-yellow-400 bg-yellow-400/10",
+ deleted: "text-red-400 bg-red-400/10",
+ renamed: "text-purple-400 bg-purple-400/10",
+ };
+
+ const statusLabels: Record<DiffFile["status"], string> = {
+ added: "A",
+ modified: "M",
+ deleted: "D",
+ renamed: "R",
+ };
+
+ return (
+ <div className="border border-[rgba(117,170,252,0.2)] mb-2">
+ {/* File header */}
+ <button
+ onClick={onToggle}
+ className="w-full flex items-center gap-2 px-3 py-2 bg-[rgba(0,0,0,0.2)] hover:bg-[rgba(0,0,0,0.3)] transition-colors text-left"
+ >
+ <span className="font-mono text-[10px] text-[#555]">
+ {collapsed ? "▶" : "▼"}
+ </span>
+ <span
+ className={`px-1.5 py-0.5 font-mono text-[9px] font-bold ${statusColors[file.status]}`}
+ >
+ {statusLabels[file.status]}
+ </span>
+ <span className="font-mono text-sm text-[#dbe7ff] flex-1 truncate">
+ {file.oldPath ? (
+ <>
+ <span className="text-[#555]">{file.oldPath}</span>
+ <span className="text-[#75aafc] mx-1">→</span>
+ {file.path}
+ </>
+ ) : (
+ file.path
+ )}
+ </span>
+ <span className="font-mono text-[10px]">
+ {file.additions > 0 && (
+ <span className="text-green-400 mr-2">+{file.additions}</span>
+ )}
+ {file.deletions > 0 && (
+ <span className="text-red-400">-{file.deletions}</span>
+ )}
+ </span>
+ </button>
+
+ {/* File content */}
+ {!collapsed && (
+ <div className="overflow-x-auto">
+ <table className="w-full font-mono text-xs">
+ <tbody>
+ {file.lines.map((line, i) => {
+ if (line.type === "header" || line.type === "hunk") {
+ return (
+ <tr
+ key={i}
+ className="bg-[rgba(117,170,252,0.05)]"
+ >
+ <td
+ colSpan={3}
+ className="px-2 py-0.5 text-[#75aafc] select-none"
+ >
+ {line.content}
+ </td>
+ </tr>
+ );
+ }
+
+ const bgColor =
+ line.type === "add"
+ ? "bg-green-400/10"
+ : line.type === "remove"
+ ? "bg-red-400/10"
+ : "";
+
+ const textColor =
+ line.type === "add"
+ ? "text-green-400"
+ : line.type === "remove"
+ ? "text-red-400"
+ : "text-[#9bc3ff]";
+
+ const prefix =
+ line.type === "add"
+ ? "+"
+ : line.type === "remove"
+ ? "-"
+ : " ";
+
+ return (
+ <tr key={i} className={bgColor}>
+ {/* Old line number */}
+ <td className="w-10 px-2 py-0 text-right text-[#555] select-none border-r border-[rgba(117,170,252,0.1)]">
+ {line.type !== "add" ? line.oldLineNumber : ""}
+ </td>
+ {/* New line number */}
+ <td className="w-10 px-2 py-0 text-right text-[#555] select-none border-r border-[rgba(117,170,252,0.1)]">
+ {line.type !== "remove" ? line.newLineNumber : ""}
+ </td>
+ {/* Content */}
+ <td className={`px-2 py-0 whitespace-pre ${textColor}`}>
+ <span className="select-none mr-1 text-[#555]">
+ {prefix}
+ </span>
+ {line.content}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ )}
+ </div>
+ );
+}
+
+export function OverlayDiffViewer({
+ diff,
+ changedFiles,
+ loading,
+ error,
+ onClose,
+ title = "Overlay Changes",
+}: OverlayDiffViewerProps) {
+ const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
+ const [showFullDiff, setShowFullDiff] = useState(true);
+
+ const parsedFiles = useMemo(() => parseDiff(diff), [diff]);
+
+ const toggleFile = (path: string) => {
+ setCollapsedFiles((prev) => {
+ const next = new Set(prev);
+ if (next.has(path)) {
+ next.delete(path);
+ } else {
+ next.add(path);
+ }
+ return next;
+ });
+ };
+
+ const expandAll = () => setCollapsedFiles(new Set());
+ const collapseAll = () => setCollapsedFiles(new Set(parsedFiles.map((f) => f.path)));
+
+ // Calculate totals
+ const totals = useMemo(() => {
+ return parsedFiles.reduce(
+ (acc, file) => ({
+ additions: acc.additions + file.additions,
+ deletions: acc.deletions + file.deletions,
+ files: acc.files + 1,
+ }),
+ { additions: 0, deletions: 0, files: 0 }
+ );
+ }, [parsedFiles]);
+
+ if (loading) {
+ return (
+ <div className="panel p-4">
+ <div className="flex items-center justify-center h-32">
+ <div className="font-mono text-sm text-[#75aafc]">
+ Loading diff...
+ </div>
+ </div>
+ </div>
+ );
+ }
+
+ if (error) {
+ return (
+ <div className="panel p-4">
+ <div className="flex items-center justify-between mb-3">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ {title}
+ </div>
+ {onClose && (
+ <button
+ onClick={onClose}
+ className="font-mono text-xs text-[#555] hover:text-[#9bc3ff]"
+ >
+ Close
+ </button>
+ )}
+ </div>
+ <div className="bg-red-400/10 border border-red-400/30 p-3 font-mono text-sm text-red-400">
+ {error}
+ </div>
+ </div>
+ );
+ }
+
+ if (!diff.trim() && (!changedFiles || changedFiles.length === 0)) {
+ return (
+ <div className="panel p-4">
+ <div className="flex items-center justify-between mb-3">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ {title}
+ </div>
+ {onClose && (
+ <button
+ onClick={onClose}
+ className="font-mono text-xs text-[#555] hover:text-[#9bc3ff]"
+ >
+ Close
+ </button>
+ )}
+ </div>
+ <div className="text-center py-8 font-mono text-sm text-[#555]">
+ No changes detected in overlay
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="panel flex flex-col max-h-[600px]">
+ {/* Header */}
+ <div className="flex items-center justify-between p-3 border-b border-[rgba(117,170,252,0.2)] shrink-0">
+ <div className="flex items-center gap-4">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ {title}
+ </div>
+ <div className="font-mono text-[10px] text-[#75aafc]">
+ {totals.files} file{totals.files !== 1 ? "s" : ""} changed
+ {totals.additions > 0 && (
+ <span className="text-green-400 ml-2">+{totals.additions}</span>
+ )}
+ {totals.deletions > 0 && (
+ <span className="text-red-400 ml-2">-{totals.deletions}</span>
+ )}
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ <button
+ onClick={expandAll}
+ className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff]"
+ >
+ Expand All
+ </button>
+ <button
+ onClick={collapseAll}
+ className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff]"
+ >
+ Collapse All
+ </button>
+ <button
+ onClick={() => setShowFullDiff(!showFullDiff)}
+ className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff]"
+ >
+ {showFullDiff ? "File List" : "Full Diff"}
+ </button>
+ {onClose && (
+ <button
+ onClick={onClose}
+ className="font-mono text-xs text-[#555] hover:text-[#9bc3ff] ml-2"
+ >
+ Close
+ </button>
+ )}
+ </div>
+ </div>
+
+ {/* Content */}
+ <div className="flex-1 overflow-y-auto p-3">
+ {showFullDiff ? (
+ // Full diff view
+ parsedFiles.length > 0 ? (
+ parsedFiles.map((file) => (
+ <DiffFileView
+ key={file.path}
+ file={file}
+ collapsed={collapsedFiles.has(file.path)}
+ onToggle={() => toggleFile(file.path)}
+ />
+ ))
+ ) : (
+ // Fallback to raw diff
+ <pre className="font-mono text-xs text-[#9bc3ff] whitespace-pre-wrap">
+ {diff}
+ </pre>
+ )
+ ) : (
+ // File list view
+ <div className="space-y-1">
+ {(changedFiles || parsedFiles.map((f) => f.path)).map((path) => {
+ const file = parsedFiles.find((f) => f.path === path);
+ return (
+ <div
+ key={path}
+ className="flex items-center gap-2 px-2 py-1 hover:bg-[rgba(117,170,252,0.05)]"
+ >
+ {file && (
+ <span
+ className={`px-1 py-0.5 font-mono text-[9px] ${
+ file.status === "added"
+ ? "text-green-400 bg-green-400/10"
+ : file.status === "deleted"
+ ? "text-red-400 bg-red-400/10"
+ : file.status === "renamed"
+ ? "text-purple-400 bg-purple-400/10"
+ : "text-yellow-400 bg-yellow-400/10"
+ }`}
+ >
+ {file.status.charAt(0).toUpperCase()}
+ </span>
+ )}
+ <span className="font-mono text-sm text-[#dbe7ff] flex-1 truncate">
+ {path}
+ </span>
+ {file && (
+ <span className="font-mono text-[10px]">
+ {file.additions > 0 && (
+ <span className="text-green-400 mr-1">+{file.additions}</span>
+ )}
+ {file.deletions > 0 && (
+ <span className="text-red-400">-{file.deletions}</span>
+ )}
+ </span>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/PRPreview.tsx b/makima/frontend/src/components/mesh/PRPreview.tsx
new file mode 100644
index 0000000..fc202b0
--- /dev/null
+++ b/makima/frontend/src/components/mesh/PRPreview.tsx
@@ -0,0 +1,314 @@
+import { useState, useMemo } from "react";
+import type { TaskWithSubtasks, TaskSummary } from "../../lib/api";
+import { OverlayDiffViewer } from "./OverlayDiffViewer";
+
+interface PRPreviewProps {
+ task: TaskWithSubtasks;
+ diff?: string;
+ changedFiles?: string[];
+ loading?: boolean;
+ onCreatePR?: (title: string, body: string, draft: boolean) => Promise<void>;
+ onAutoMerge?: () => Promise<void>;
+ onClose: () => void;
+}
+
+interface PRFormData {
+ title: string;
+ body: string;
+ isDraft: boolean;
+}
+
+function generatePRTitle(task: TaskWithSubtasks): string {
+ // Generate a PR title based on the task name
+ const prefix = task.parentTaskId ? "feat" : "feat";
+ return `${prefix}: ${task.name}`;
+}
+
+function generatePRBody(task: TaskWithSubtasks, changedFiles?: string[]): string {
+ const sections: string[] = [];
+
+ // Summary
+ sections.push("## Summary\n");
+ if (task.description) {
+ sections.push(task.description + "\n");
+ } else {
+ sections.push("_Add a brief description of the changes..._\n");
+ }
+
+ // Plan/Implementation details
+ sections.push("\n## Implementation\n");
+ if (task.plan) {
+ // Truncate if too long
+ const planPreview = task.plan.length > 500
+ ? task.plan.substring(0, 500) + "..."
+ : task.plan;
+ sections.push("```\n" + planPreview + "\n```\n");
+ }
+
+ // Subtasks summary
+ if (task.subtasks.length > 0) {
+ sections.push("\n## Subtasks\n");
+ task.subtasks.forEach((subtask: TaskSummary) => {
+ const emoji = subtask.status === "done" || subtask.status === "merged" ? "+" :
+ subtask.status === "running" ? "~" : "-";
+ sections.push(`- [${emoji === "+" ? "x" : " "}] ${subtask.name} (${subtask.status})\n`);
+ });
+ }
+
+ // Changed files
+ if (changedFiles && changedFiles.length > 0) {
+ sections.push("\n## Changed Files\n");
+ changedFiles.slice(0, 20).forEach((file) => {
+ sections.push(`- \`${file}\`\n`);
+ });
+ if (changedFiles.length > 20) {
+ sections.push(`\n_...and ${changedFiles.length - 20} more files_\n`);
+ }
+ }
+
+ // Test plan
+ sections.push("\n## Test Plan\n");
+ sections.push("- [ ] Manual testing completed\n");
+ sections.push("- [ ] Unit tests added/updated\n");
+ sections.push("- [ ] Integration tests passing\n");
+
+ // Footer
+ sections.push("\n---\n");
+ sections.push("_Generated by makima mesh orchestrator_\n");
+
+ return sections.join("");
+}
+
+export function PRPreview({
+ task,
+ diff = "",
+ changedFiles = [],
+ loading = false,
+ onCreatePR,
+ onAutoMerge,
+ onClose,
+}: PRPreviewProps) {
+ const [showDiff, setShowDiff] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const [formData, setFormData] = useState<PRFormData>(() => ({
+ title: generatePRTitle(task),
+ body: generatePRBody(task, changedFiles),
+ isDraft: false,
+ }));
+
+ const handleCreatePR = async () => {
+ if (!onCreatePR || creating) return;
+
+ setCreating(true);
+ setError(null);
+
+ try {
+ await onCreatePR(formData.title, formData.body, formData.isDraft);
+ onClose();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to create PR");
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ const handleAutoMerge = async () => {
+ if (!onAutoMerge || creating) return;
+
+ if (!confirm("Are you sure you want to auto-merge this task directly to the target branch?")) {
+ return;
+ }
+
+ setCreating(true);
+ setError(null);
+
+ try {
+ await onAutoMerge();
+ onClose();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to auto-merge");
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ // Calculate stats
+ const stats = useMemo(() => {
+ const completedSubtasks = task.subtasks.filter(
+ (s) => s.status === "done" || s.status === "merged"
+ ).length;
+ return {
+ filesChanged: changedFiles.length,
+ subtasksCompleted: completedSubtasks,
+ subtasksTotal: task.subtasks.length,
+ isReady: completedSubtasks === task.subtasks.length || task.subtasks.length === 0,
+ };
+ }, [task.subtasks, changedFiles]);
+
+ return (
+ <div className="panel flex flex-col max-h-[80vh] overflow-hidden">
+ {/* Header */}
+ <div className="flex items-center justify-between p-4 border-b border-[rgba(117,170,252,0.2)] shrink-0">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Create Pull Request
+ </div>
+ <button
+ onClick={onClose}
+ className="font-mono text-xs text-[#555] hover:text-[#9bc3ff]"
+ >
+ Cancel
+ </button>
+ </div>
+
+ {/* Content */}
+ <div className="flex-1 overflow-y-auto p-4 space-y-4">
+ {/* Status badges */}
+ <div className="flex flex-wrap gap-2">
+ <span className="px-2 py-0.5 font-mono text-[10px] text-[#75aafc] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.2)]">
+ {task.baseBranch || "main"} → {task.targetBranch || task.baseBranch || "main"}
+ </span>
+ <span className="px-2 py-0.5 font-mono text-[10px] text-[#9bc3ff] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.2)]">
+ {stats.filesChanged} files changed
+ </span>
+ {task.subtasks.length > 0 && (
+ <span
+ className={`px-2 py-0.5 font-mono text-[10px] border ${
+ stats.isReady
+ ? "text-green-400 bg-green-400/10 border-green-400/20"
+ : "text-yellow-400 bg-yellow-400/10 border-yellow-400/20"
+ }`}
+ >
+ {stats.subtasksCompleted}/{stats.subtasksTotal} subtasks complete
+ </span>
+ )}
+ </div>
+
+ {/* Warning if subtasks not complete */}
+ {!stats.isReady && (
+ <div className="bg-yellow-400/10 border border-yellow-400/30 p-3 font-mono text-xs text-yellow-400">
+ Some subtasks are not yet complete. Consider waiting before creating the PR.
+ </div>
+ )}
+
+ {/* Error message */}
+ {error && (
+ <div className="bg-red-400/10 border border-red-400/30 p-3 font-mono text-xs text-red-400">
+ {error}
+ </div>
+ )}
+
+ {/* PR Title */}
+ <div className="space-y-2">
+ <label className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Title
+ </label>
+ <input
+ type="text"
+ value={formData.title}
+ onChange={(e) => setFormData({ ...formData, title: e.target.value })}
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]"
+ placeholder="PR title"
+ disabled={creating}
+ />
+ </div>
+
+ {/* PR Body */}
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <label className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Description
+ </label>
+ <button
+ onClick={() => setFormData({
+ ...formData,
+ body: generatePRBody(task, changedFiles),
+ })}
+ className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff]"
+ >
+ Regenerate
+ </button>
+ </div>
+ <textarea
+ value={formData.body}
+ onChange={(e) => setFormData({ ...formData, body: e.target.value })}
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-xs px-3 py-2 outline-none focus:border-[#3f6fb3] min-h-[200px] resize-y"
+ placeholder="PR description (markdown)"
+ disabled={creating}
+ />
+ </div>
+
+ {/* Options */}
+ <div className="flex items-center gap-4">
+ <label className="flex items-center gap-2 cursor-pointer">
+ <input
+ type="checkbox"
+ checked={formData.isDraft}
+ onChange={(e) => setFormData({ ...formData, isDraft: e.target.checked })}
+ className="w-4 h-4 accent-[#75aafc]"
+ disabled={creating}
+ />
+ <span className="font-mono text-xs text-[#9bc3ff]">Create as draft</span>
+ </label>
+ </div>
+
+ {/* Diff preview toggle */}
+ <div className="border-t border-[rgba(117,170,252,0.2)] pt-4">
+ <button
+ onClick={() => setShowDiff(!showDiff)}
+ className="flex items-center gap-2 font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff]"
+ >
+ <span>{showDiff ? "▼" : "▶"}</span>
+ <span>
+ {showDiff ? "Hide" : "Show"} diff preview ({stats.filesChanged} files)
+ </span>
+ </button>
+ </div>
+
+ {/* Inline diff viewer */}
+ {showDiff && (
+ <div className="border border-[rgba(117,170,252,0.2)]">
+ <OverlayDiffViewer
+ diff={diff}
+ changedFiles={changedFiles}
+ loading={loading}
+ title="Changes to be merged"
+ />
+ </div>
+ )}
+ </div>
+
+ {/* Footer actions */}
+ <div className="flex items-center justify-between p-4 border-t border-[rgba(117,170,252,0.2)] shrink-0">
+ <div className="font-mono text-[10px] text-[#555]">
+ {task.repositoryUrl && (
+ <span className="truncate max-w-[200px] inline-block align-middle">
+ {task.repositoryUrl}
+ </span>
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ {task.mergeMode === "auto" && onAutoMerge && (
+ <button
+ onClick={handleAutoMerge}
+ disabled={creating || !stats.isReady}
+ className="px-4 py-2 font-mono text-xs text-yellow-400 border border-yellow-400/30 hover:border-yellow-400/50 hover:bg-yellow-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {creating ? "..." : "Auto-Merge"}
+ </button>
+ )}
+ {onCreatePR && (
+ <button
+ onClick={handleCreatePR}
+ disabled={creating || !formData.title.trim()}
+ className="px-4 py-2 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {creating ? "Creating..." : formData.isDraft ? "Create Draft PR" : "Create PR"}
+ </button>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/SubtaskTree.tsx b/makima/frontend/src/components/mesh/SubtaskTree.tsx
new file mode 100644
index 0000000..176b7a7
--- /dev/null
+++ b/makima/frontend/src/components/mesh/SubtaskTree.tsx
@@ -0,0 +1,297 @@
+import { useState, useCallback } from "react";
+import type { TaskSummary, TaskStatus } from "../../lib/api";
+
+interface SubtaskTreeProps {
+ subtasks: TaskSummary[];
+ onSelect: (taskId: string) => void;
+ depth?: number;
+ loading?: boolean;
+ fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>;
+}
+
+interface TreeNodeProps {
+ task: TaskSummary;
+ onSelect: (taskId: string) => void;
+ depth: number;
+ fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>;
+}
+
+function getStatusColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "text-[#9bc3ff]";
+ case "running":
+ return "text-green-400";
+ case "paused":
+ return "text-yellow-400";
+ case "blocked":
+ return "text-orange-400";
+ case "done":
+ return "text-emerald-400";
+ case "failed":
+ return "text-red-400";
+ case "merged":
+ return "text-purple-400";
+ default:
+ return "text-[#9bc3ff]";
+ }
+}
+
+function getStatusIcon(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "○";
+ case "running":
+ return "◉";
+ case "paused":
+ return "◎";
+ case "blocked":
+ return "◈";
+ case "done":
+ return "●";
+ case "failed":
+ return "✕";
+ case "merged":
+ return "◆";
+ default:
+ return "○";
+ }
+}
+
+function TreeNode({ task, onSelect, depth, fetchSubtasks }: TreeNodeProps) {
+ const [expanded, setExpanded] = useState(false);
+ const [children, setChildren] = useState<TaskSummary[] | null>(null);
+ const [loadingChildren, setLoadingChildren] = useState(false);
+
+ const hasSubtasks = task.subtaskCount > 0;
+
+ const handleToggle = useCallback(async () => {
+ if (!hasSubtasks) return;
+
+ if (expanded) {
+ setExpanded(false);
+ } else {
+ if (!children && fetchSubtasks) {
+ setLoadingChildren(true);
+ try {
+ const subtasks = await fetchSubtasks(task.id);
+ setChildren(subtasks);
+ } catch (err) {
+ console.error("Failed to fetch subtasks:", err);
+ } finally {
+ setLoadingChildren(false);
+ }
+ }
+ setExpanded(true);
+ }
+ }, [expanded, children, hasSubtasks, task.id, fetchSubtasks]);
+
+ const indent = depth * 16;
+
+ return (
+ <div className="select-none">
+ <div
+ className="flex items-center gap-2 py-1.5 px-2 hover:bg-[rgba(117,170,252,0.05)] transition-colors cursor-pointer group"
+ style={{ paddingLeft: `${indent + 8}px` }}
+ >
+ {/* Expand/Collapse button */}
+ <button
+ onClick={handleToggle}
+ className={`w-4 h-4 flex items-center justify-center font-mono text-[10px] ${
+ hasSubtasks
+ ? "text-[#75aafc] hover:text-[#9bc3ff]"
+ : "text-transparent cursor-default"
+ }`}
+ disabled={!hasSubtasks}
+ >
+ {loadingChildren ? (
+ <span className="animate-spin">⌛</span>
+ ) : hasSubtasks ? (
+ expanded ? "▼" : "▶"
+ ) : (
+ ""
+ )}
+ </button>
+
+ {/* Status icon */}
+ <span
+ className={`font-mono text-xs ${getStatusColor(task.status)}`}
+ title={task.status}
+ >
+ {getStatusIcon(task.status)}
+ </span>
+
+ {/* Task name - clickable */}
+ <button
+ onClick={() => onSelect(task.id)}
+ className="flex-1 text-left font-mono text-sm text-[#dbe7ff] hover:text-white transition-colors truncate"
+ >
+ {task.name}
+ </button>
+
+ {/* Subtask count badge */}
+ {hasSubtasks && (
+ <span className="font-mono text-[9px] text-[#555] group-hover:text-[#75aafc]">
+ {task.subtaskCount} sub
+ </span>
+ )}
+
+ {/* Priority indicator */}
+ {task.priority > 0 && (
+ <span className="font-mono text-[9px] text-orange-400">
+ P{task.priority}
+ </span>
+ )}
+ </div>
+
+ {/* Children */}
+ {expanded && children && children.length > 0 && (
+ <div className="border-l border-[rgba(117,170,252,0.15)]" style={{ marginLeft: `${indent + 16}px` }}>
+ {children.map((child) => (
+ <TreeNode
+ key={child.id}
+ task={child}
+ onSelect={onSelect}
+ depth={depth + 1}
+ fetchSubtasks={fetchSubtasks}
+ />
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
+
+export function SubtaskTree({
+ subtasks,
+ onSelect,
+ depth = 0,
+ loading = false,
+ fetchSubtasks,
+}: SubtaskTreeProps) {
+ if (loading) {
+ return (
+ <div className="p-4 text-center font-mono text-xs text-[#555]">
+ Loading subtasks...
+ </div>
+ );
+ }
+
+ if (subtasks.length === 0) {
+ return (
+ <div className="p-4 text-center font-mono text-xs text-[#555]">
+ No subtasks
+ </div>
+ );
+ }
+
+ return (
+ <div className="divide-y divide-[rgba(117,170,252,0.1)]">
+ {subtasks.map((task) => (
+ <TreeNode
+ key={task.id}
+ task={task}
+ onSelect={onSelect}
+ depth={depth}
+ fetchSubtasks={fetchSubtasks}
+ />
+ ))}
+ </div>
+ );
+}
+
+// Aggregated status summary for a task tree
+export interface TaskTreeStats {
+ total: number;
+ pending: number;
+ running: number;
+ paused: number;
+ blocked: number;
+ done: number;
+ failed: number;
+ merged: number;
+}
+
+export function calculateTreeStats(subtasks: TaskSummary[]): TaskTreeStats {
+ const stats: TaskTreeStats = {
+ total: subtasks.length,
+ pending: 0,
+ running: 0,
+ paused: 0,
+ blocked: 0,
+ done: 0,
+ failed: 0,
+ merged: 0,
+ };
+
+ for (const task of subtasks) {
+ switch (task.status) {
+ case "pending":
+ stats.pending++;
+ break;
+ case "running":
+ stats.running++;
+ break;
+ case "paused":
+ stats.paused++;
+ break;
+ case "blocked":
+ stats.blocked++;
+ break;
+ case "done":
+ stats.done++;
+ break;
+ case "failed":
+ stats.failed++;
+ break;
+ case "merged":
+ stats.merged++;
+ break;
+ }
+ }
+
+ return stats;
+}
+
+// Visual summary bar
+export function SubtaskProgressBar({ stats }: { stats: TaskTreeStats }) {
+ if (stats.total === 0) return null;
+
+ const segments = [
+ { count: stats.merged, color: "bg-purple-400", label: "Merged" },
+ { count: stats.done, color: "bg-emerald-400", label: "Done" },
+ { count: stats.running, color: "bg-green-400", label: "Running" },
+ { count: stats.paused, color: "bg-yellow-400", label: "Paused" },
+ { count: stats.blocked, color: "bg-orange-400", label: "Blocked" },
+ { count: stats.pending, color: "bg-[#75aafc]", label: "Pending" },
+ { count: stats.failed, color: "bg-red-400", label: "Failed" },
+ ].filter((s) => s.count > 0);
+
+ return (
+ <div className="space-y-1">
+ {/* Progress bar */}
+ <div className="h-2 flex overflow-hidden rounded-sm">
+ {segments.map((segment, i) => (
+ <div
+ key={i}
+ className={`${segment.color} transition-all`}
+ style={{ width: `${(segment.count / stats.total) * 100}%` }}
+ title={`${segment.label}: ${segment.count}`}
+ />
+ ))}
+ </div>
+
+ {/* Legend */}
+ <div className="flex flex-wrap gap-3 font-mono text-[9px]">
+ {segments.map((segment, i) => (
+ <div key={i} className="flex items-center gap-1">
+ <div className={`w-2 h-2 ${segment.color} rounded-sm`} />
+ <span className="text-[#75aafc]">
+ {segment.label}: {segment.count}
+ </span>
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/TaskDetail.tsx b/makima/frontend/src/components/mesh/TaskDetail.tsx
new file mode 100644
index 0000000..be4fb80
--- /dev/null
+++ b/makima/frontend/src/components/mesh/TaskDetail.tsx
@@ -0,0 +1,886 @@
+import { useState, useCallback, useMemo, useEffect } from "react";
+import type { TaskWithSubtasks, TaskStatus, TaskSummary, CompletionAction, DaemonDirectory } from "../../lib/api";
+import { retryCompletionAction, getDaemonDirectories, cloneWorktree } from "../../lib/api";
+import { SubtaskTree, SubtaskProgressBar, calculateTreeStats } from "./SubtaskTree";
+import { OverlayDiffViewer } from "./OverlayDiffViewer";
+import { PRPreview } from "./PRPreview";
+import { InlineSubtaskEditor } from "./InlineSubtaskEditor";
+import { DirectoryInput } from "./DirectoryInput";
+
+interface TaskDetailProps {
+ task: TaskWithSubtasks;
+ loading: boolean;
+ onBack: () => void;
+ onSave: (taskId: string, name: string, description: string, plan: string, targetRepoPath?: string, completionAction?: CompletionAction) => void;
+ onDelete: (taskId: string) => void;
+ onStart: (taskId: string) => void;
+ onStop: (taskId: string) => void;
+ onRestart: (taskId: string) => void;
+ onContinue: (taskId: string) => void;
+ onSelectSubtask: (taskId: string) => void;
+ onCreateSubtask: () => void;
+ /** Toggle viewing a subtask's output (for running subtasks) */
+ onToggleSubtaskOutput?: (subtaskId: string, subtaskName: string) => void;
+ /** Which subtask's output is currently being viewed */
+ viewingSubtaskId?: string | null;
+ // Optional advanced features
+ overlayDiff?: string;
+ changedFiles?: string[];
+ onRequestDiff?: () => void;
+ onCreatePR?: (title: string, body: string, draft: boolean) => Promise<void>;
+ onAutoMerge?: () => Promise<void>;
+ fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>;
+}
+
+function formatDate(dateStr: string): string {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+function getStatusColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "text-[#9bc3ff]";
+ case "initializing":
+ case "starting":
+ return "text-cyan-400";
+ case "running":
+ return "text-green-400";
+ case "paused":
+ return "text-yellow-400";
+ case "blocked":
+ return "text-orange-400";
+ case "done":
+ return "text-emerald-400";
+ case "failed":
+ return "text-red-400";
+ case "merged":
+ return "text-purple-400";
+ default:
+ return "text-[#9bc3ff]";
+ }
+}
+
+function getStatusBgColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "bg-[rgba(117,170,252,0.1)]";
+ case "initializing":
+ case "starting":
+ return "bg-cyan-400/10";
+ case "running":
+ return "bg-green-400/10";
+ case "paused":
+ return "bg-yellow-400/10";
+ case "blocked":
+ return "bg-orange-400/10";
+ case "done":
+ return "bg-emerald-400/10";
+ case "failed":
+ return "bg-red-400/10";
+ case "merged":
+ return "bg-purple-400/10";
+ default:
+ return "bg-[rgba(117,170,252,0.1)]";
+ }
+}
+
+export function TaskDetail({
+ task,
+ loading,
+ onBack,
+ onSave,
+ onDelete,
+ onStart,
+ onStop,
+ onRestart,
+ onContinue,
+ onSelectSubtask,
+ onCreateSubtask,
+ onToggleSubtaskOutput,
+ viewingSubtaskId,
+ overlayDiff,
+ changedFiles,
+ onRequestDiff,
+ onCreatePR,
+ onAutoMerge,
+ fetchSubtasks,
+}: TaskDetailProps) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [editName, setEditName] = useState(task.name);
+ const [editDescription, setEditDescription] = useState(task.description || "");
+ const [editPlan, setEditPlan] = useState(task.plan);
+ const [editTargetRepoPath, setEditTargetRepoPath] = useState(task.targetRepoPath || "");
+ const [editCompletionAction, setEditCompletionAction] = useState<CompletionAction>(
+ (task.completionAction as CompletionAction) || "none"
+ );
+ const [showDiff, setShowDiff] = useState(false);
+ const [showPRPreview, setShowPRPreview] = useState(false);
+ const [useTreeView, setUseTreeView] = useState(false);
+ // Track which subtask is expanded for inline editing
+ const [expandedSubtaskId, setExpandedSubtaskId] = useState<string | null>(null);
+ // Track interrupt dropdown state
+ const [showInterruptMenu, setShowInterruptMenu] = useState(false);
+ // Track retry completion action state
+ const [isRetryingCompletion, setIsRetryingCompletion] = useState(false);
+ const [retryError, setRetryError] = useState<string | null>(null);
+ // Suggested directories from daemon
+ const [suggestedDirectories, setSuggestedDirectories] = useState<DaemonDirectory[]>([]);
+ // Track clone worktree state
+ const [isCloning, setIsCloning] = useState(false);
+ const [cloneError, setCloneError] = useState<string | null>(null);
+ const [cloneTargetDir, setCloneTargetDir] = useState("");
+
+ // Check if task is running
+ const isTaskRunning = task.status === "running" || task.status === "initializing" || task.status === "starting";
+ // Check if task is in a terminal state (can be continued/reopened)
+ const isTaskTerminal = task.status === "done" || task.status === "failed" || task.status === "merged";
+
+ // Calculate subtask statistics
+ const subtaskStats = useMemo(
+ () => calculateTreeStats(task.subtasks),
+ [task.subtasks]
+ );
+
+ // Check if task can create PR
+ const canCreatePR = useMemo(() => {
+ return (
+ (task.status === "done" || task.status === "merged") &&
+ task.repositoryUrl &&
+ (onCreatePR || onAutoMerge)
+ );
+ }, [task.status, task.repositoryUrl, onCreatePR, onAutoMerge]);
+
+ // Check if task can retry completion action
+ const canRetryCompletion = useMemo(() => {
+ return (
+ (task.status === "done" || task.status === "failed" || task.status === "merged") &&
+ task.completionAction &&
+ task.completionAction !== "none" &&
+ task.targetRepoPath
+ // Note: overlayPath may be null in server DB even if worktree exists on daemon
+ // The daemon will scan for the worktree by task ID
+ );
+ }, [task.status, task.completionAction, task.targetRepoPath]);
+
+ // Handler for retrying completion action
+ const handleRetryCompletion = useCallback(async () => {
+ setIsRetryingCompletion(true);
+ setRetryError(null);
+ try {
+ await retryCompletionAction(task.id);
+ // Success - the result will be shown in task output
+ } catch (e) {
+ setRetryError(e instanceof Error ? e.message : "Failed to retry completion action");
+ } finally {
+ setIsRetryingCompletion(false);
+ }
+ }, [task.id]);
+
+ // Check if task can clone worktree
+ const canCloneWorktree = useMemo(() => {
+ return (
+ (task.status === "done" || task.status === "failed" || task.status === "merged")
+ );
+ }, [task.status]);
+
+ // Handler for cloning worktree
+ const handleCloneWorktree = useCallback(async () => {
+ if (!cloneTargetDir.trim()) {
+ setCloneError("Please enter a target directory");
+ return;
+ }
+ setIsCloning(true);
+ setCloneError(null);
+ try {
+ await cloneWorktree(task.id, cloneTargetDir);
+ // Success - the result will be shown in task output
+ setCloneTargetDir(""); // Clear input on success
+ } catch (e) {
+ setCloneError(e instanceof Error ? e.message : "Failed to clone worktree");
+ } finally {
+ setIsCloning(false);
+ }
+ }, [task.id, cloneTargetDir]);
+
+ // Fetch suggested directories when entering edit mode or when clone section is visible
+ useEffect(() => {
+ if (isEditing || canCloneWorktree) {
+ getDaemonDirectories()
+ .then((res) => setSuggestedDirectories(res.directories))
+ .catch(() => setSuggestedDirectories([]));
+ }
+ }, [isEditing, canCloneWorktree]);
+
+ const handleSave = useCallback(() => {
+ onSave(
+ task.id,
+ editName,
+ editDescription,
+ editPlan,
+ editTargetRepoPath || undefined,
+ editCompletionAction
+ );
+ setIsEditing(false);
+ }, [task.id, editName, editDescription, editPlan, editTargetRepoPath, editCompletionAction, onSave]);
+
+ const handleCancel = useCallback(() => {
+ setEditName(task.name);
+ setEditDescription(task.description || "");
+ setEditPlan(task.plan);
+ setEditTargetRepoPath(task.targetRepoPath || "");
+ setEditCompletionAction((task.completionAction as CompletionAction) || "none");
+ setIsEditing(false);
+ }, [task]);
+
+ // Toggle subtask expansion for inline editing
+ const handleSubtaskToggle = useCallback((subtaskId: string) => {
+ setExpandedSubtaskId((prev) => (prev === subtaskId ? null : subtaskId));
+ }, []);
+
+ // Handle subtask click - toggle output view for any task status
+ const handleSubtaskClick = useCallback(
+ (subtask: TaskSummary) => {
+ if (onToggleSubtaskOutput) {
+ // Toggle viewing this subtask's output (works for any status)
+ onToggleSubtaskOutput(subtask.id, subtask.name);
+ } else {
+ // Fallback to expand/collapse if output viewing not available
+ handleSubtaskToggle(subtask.id);
+ }
+ },
+ [onToggleSubtaskOutput, handleSubtaskToggle]
+ );
+
+ // Called when inline subtask editor saves changes
+ const handleSubtaskUpdated = useCallback(() => {
+ // Re-fetch the parent task to refresh subtask list
+ // This will trigger from the parent component when task updates
+ }, []);
+
+ if (loading) {
+ return (
+ <div className="panel h-full flex items-center justify-center">
+ <div className="font-mono text-[#9bc3ff] text-sm">Loading task...</div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="panel h-full flex flex-col overflow-hidden">
+ {/* Header */}
+ <div className="flex items-center justify-between p-4 pb-2 border-b border-dashed border-[rgba(117,170,252,0.35)] shrink-0">
+ <div className="flex items-center gap-3">
+ <button
+ onClick={onBack}
+ className="font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
+ >
+ &lt; Back
+ </button>
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ TASK//
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ {isEditing ? (
+ <>
+ <button
+ onClick={handleCancel}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleSave}
+ className="px-3 py-1 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 transition-colors uppercase"
+ >
+ Save
+ </button>
+ </>
+ ) : (
+ <>
+ {(task.status === "pending" || task.status === "failed") && (
+ <button
+ onClick={() => onStart(task.id)}
+ className="px-3 py-1 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 transition-colors uppercase"
+ >
+ Start
+ </button>
+ )}
+ {isTaskRunning && (
+ <div className="relative">
+ <button
+ onClick={() => setShowInterruptMenu(!showInterruptMenu)}
+ className="px-3 py-1 font-mono text-xs text-orange-400 border border-orange-400/30 hover:border-orange-400/50 hover:bg-orange-400/10 transition-colors uppercase flex items-center gap-1"
+ >
+ <span className="w-1.5 h-1.5 bg-orange-400 rounded-full animate-pulse" />
+ Interrupt
+ </button>
+ {showInterruptMenu && (
+ <>
+ {/* Backdrop to close menu on click outside */}
+ <div
+ className="fixed inset-0 z-40"
+ onClick={() => setShowInterruptMenu(false)}
+ />
+ <div className="absolute right-0 top-full mt-1 z-50 bg-[#0a1525] border border-[rgba(117,170,252,0.35)] shadow-lg">
+ <button
+ onClick={() => {
+ onRestart(task.id);
+ setShowInterruptMenu(false);
+ }}
+ className="block w-full px-4 py-2 font-mono text-xs text-left text-yellow-400 hover:bg-yellow-400/10 transition-colors whitespace-nowrap"
+ >
+ Restart Task
+ </button>
+ <button
+ onClick={() => {
+ onStop(task.id);
+ setShowInterruptMenu(false);
+ }}
+ className="block w-full px-4 py-2 font-mono text-xs text-left text-red-400 hover:bg-red-400/10 transition-colors whitespace-nowrap"
+ >
+ Cancel Task
+ </button>
+ </div>
+ </>
+ )}
+ </div>
+ )}
+ {isTaskTerminal && (
+ <button
+ onClick={() => onContinue(task.id)}
+ className="px-3 py-1 font-mono text-xs text-cyan-400 border border-cyan-400/30 hover:border-cyan-400/50 hover:bg-cyan-400/10 transition-colors uppercase flex items-center gap-1"
+ >
+ <span className="w-1.5 h-1.5 bg-cyan-400 rounded-full" />
+ Continue
+ </button>
+ )}
+ <button
+ onClick={() => setIsEditing(true)}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ Edit
+ </button>
+ <button
+ onClick={() => onDelete(task.id)}
+ className="px-3 py-1 font-mono text-xs text-red-400 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase"
+ >
+ Delete
+ </button>
+ </>
+ )}
+ </div>
+ </div>
+
+ {/* Content */}
+ <div className="flex-1 overflow-y-auto p-4 space-y-4">
+ {/* Task Info */}
+ <div className="space-y-3">
+ {isEditing ? (
+ <>
+ <input
+ type="text"
+ value={editName}
+ onChange={(e) => setEditName(e.target.value)}
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-lg px-3 py-2 outline-none focus:border-[#3f6fb3]"
+ placeholder="Task name"
+ />
+ <textarea
+ value={editDescription}
+ onChange={(e) => setEditDescription(e.target.value)}
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3] min-h-[60px] resize-y"
+ placeholder="Description (optional)"
+ />
+ </>
+ ) : (
+ <>
+ <h2 className="font-mono text-lg text-[#dbe7ff]">{task.name}</h2>
+ {task.description && (
+ <p className="font-mono text-sm text-[#9bc3ff]">{task.description}</p>
+ )}
+ </>
+ )}
+
+ {/* Status badges */}
+ <div className="flex flex-wrap gap-2">
+ <span
+ className={`px-2 py-0.5 font-mono text-xs uppercase ${getStatusColor(
+ task.status as TaskStatus
+ )} ${getStatusBgColor(task.status as TaskStatus)} border border-current/20`}
+ >
+ {task.status}
+ </span>
+ {/* Orchestrator badge for depth 0 tasks with subtasks */}
+ {task.depth === 0 && task.subtasks.length > 0 && (
+ <span className="px-2 py-0.5 font-mono text-xs text-purple-400 bg-purple-400/10 border border-purple-400/20">
+ Orchestrator
+ </span>
+ )}
+ {/* Depth indicator for subtasks */}
+ {task.depth > 0 && (
+ <span className="px-2 py-0.5 font-mono text-xs text-cyan-400 bg-cyan-400/10 border border-cyan-400/20">
+ Depth: {task.depth}
+ </span>
+ )}
+ {task.priority > 0 && (
+ <span className="px-2 py-0.5 font-mono text-xs text-orange-400 bg-orange-400/10 border border-orange-400/20">
+ Priority: {task.priority}
+ </span>
+ )}
+ {task.mergeMode && (
+ <span className="px-2 py-0.5 font-mono text-xs text-purple-400 bg-purple-400/10 border border-purple-400/20">
+ Merge: {task.mergeMode}
+ </span>
+ )}
+ </div>
+
+ {/* Metadata */}
+ <div className="flex flex-wrap gap-4 font-mono text-[10px] text-[#75aafc]">
+ <span>Created: {formatDate(task.createdAt)}</span>
+ {task.startedAt && <span>Started: {formatDate(task.startedAt)}</span>}
+ {task.completedAt && <span>Completed: {formatDate(task.completedAt)}</span>}
+ <span>Version: {task.version}</span>
+ </div>
+ </div>
+
+ {/* Plan */}
+ <div className="space-y-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Plan
+ </div>
+ {isEditing ? (
+ <textarea
+ value={editPlan}
+ onChange={(e) => setEditPlan(e.target.value)}
+ className="w-full bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3] min-h-[200px] resize-y"
+ placeholder="Enter the plan/instructions for this task..."
+ />
+ ) : (
+ <pre className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 font-mono text-sm text-[#dbe7ff] whitespace-pre-wrap overflow-x-auto">
+ {task.plan}
+ </pre>
+ )}
+ </div>
+
+ {/* Progress Summary */}
+ {task.progressSummary && (
+ <div className="space-y-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Progress
+ </div>
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 font-mono text-sm text-[#9bc3ff]">
+ {task.progressSummary}
+ </div>
+ </div>
+ )}
+
+ {/* Last Output */}
+ {task.lastOutput && (
+ <div className="space-y-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Last Output
+ </div>
+ <pre className="bg-[rgba(0,0,0,0.3)] border border-[rgba(117,170,252,0.15)] p-3 font-mono text-xs text-[#75aafc] whitespace-pre-wrap overflow-x-auto max-h-[200px] overflow-y-auto">
+ {task.lastOutput}
+ </pre>
+ </div>
+ )}
+
+ {/* Error Message */}
+ {task.errorMessage && (
+ <div className="space-y-2">
+ <div className="font-mono text-xs text-red-400 tracking-wide uppercase">
+ Error
+ </div>
+ <div className="bg-red-400/5 border border-red-400/30 p-3 font-mono text-sm text-red-400">
+ {task.errorMessage}
+ </div>
+ </div>
+ )}
+
+ {/* Repository Info */}
+ {(task.repositoryUrl || task.baseBranch || task.targetBranch) && (
+ <div className="space-y-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Repository
+ </div>
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-1">
+ {task.repositoryUrl && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">URL:</span> {task.repositoryUrl}
+ </div>
+ )}
+ {task.baseBranch && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Base:</span> {task.baseBranch}
+ </div>
+ )}
+ {task.targetBranch && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Target:</span> {task.targetBranch}
+ </div>
+ )}
+ {task.prUrl && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">PR:</span>{" "}
+ <a
+ href={task.prUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="text-[#9bc3ff] hover:underline"
+ >
+ {task.prUrl}
+ </a>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+
+ {/* Completion Action Settings */}
+ <div className="space-y-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Completion Actions
+ </div>
+ {isEditing ? (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-3">
+ <div className="space-y-1">
+ <label className="font-mono text-xs text-[#555]">Action on Completion</label>
+ <select
+ value={editCompletionAction}
+ onChange={(e) => setEditCompletionAction(e.target.value as CompletionAction)}
+ className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]"
+ >
+ <option value="none">None (keep in worktree)</option>
+ <option value="branch">Create branch in target repo</option>
+ <option value="merge">Auto-merge to target branch</option>
+ <option value="pr">Create Pull Request</option>
+ </select>
+ </div>
+ {editCompletionAction !== "none" && (
+ <div className="space-y-1">
+ <label className="font-mono text-xs text-[#555]">Target Repository Path</label>
+ <DirectoryInput
+ value={editTargetRepoPath}
+ onChange={setEditTargetRepoPath}
+ suggestions={suggestedDirectories}
+ placeholder="/path/to/your/local/repo"
+ repoUrl={task.repositoryUrl}
+ />
+ <p className="font-mono text-[10px] text-[#555]">
+ Path to your local repository where the branch will be pushed/merged.
+ </p>
+ </div>
+ )}
+ </div>
+ ) : (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-1">
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Action:</span>{" "}
+ {task.completionAction === "none" || !task.completionAction
+ ? "None (keep in worktree)"
+ : task.completionAction === "branch"
+ ? "Create branch in target repo"
+ : task.completionAction === "merge"
+ ? "Auto-merge to target branch"
+ : task.completionAction === "pr"
+ ? "Create Pull Request"
+ : task.completionAction}
+ </div>
+ {task.targetRepoPath && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Target Repo:</span> {task.targetRepoPath}
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+
+ {/* Metadata Info */}
+ {(task.daemonId || task.containerId || task.overlayPath) && (
+ <div className="space-y-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Metadata
+ </div>
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-1">
+ {task.daemonId && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Daemon:</span> {task.daemonId}
+ </div>
+ )}
+ {task.containerId && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Container:</span> {task.containerId}
+ </div>
+ )}
+ {task.overlayPath && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Overlay:</span> {task.overlayPath}
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+
+ {/* Subtasks */}
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-3">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Subtasks ({task.subtasks.length})
+ </div>
+ {task.subtasks.length > 0 && (
+ <button
+ onClick={() => setUseTreeView(!useTreeView)}
+ className="font-mono text-[9px] text-[#555] hover:text-[#75aafc]"
+ >
+ {useTreeView ? "List" : "Tree"}
+ </button>
+ )}
+ </div>
+ {/* Disable adding subtasks at max depth (2 = sub-subtask, cannot have children) */}
+ {task.depth < 2 ? (
+ <button
+ onClick={onCreateSubtask}
+ className="px-2 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ + Add Subtask
+ </button>
+ ) : (
+ <span className="px-2 py-1 font-mono text-[10px] text-[#555] border border-[#333]" title="Maximum depth reached">
+ Max depth
+ </span>
+ )}
+ </div>
+
+ {/* Progress bar for subtasks */}
+ {task.subtasks.length > 0 && (
+ <SubtaskProgressBar stats={subtaskStats} />
+ )}
+
+ {task.subtasks.length === 0 ? (
+ <div className="text-[#555] font-mono text-xs py-4 text-center">
+ No subtasks yet
+ </div>
+ ) : useTreeView ? (
+ <div className="border border-[rgba(117,170,252,0.15)]">
+ <SubtaskTree
+ subtasks={task.subtasks}
+ onSelect={onSelectSubtask}
+ fetchSubtasks={fetchSubtasks}
+ />
+ </div>
+ ) : (
+ <div className="divide-y divide-[rgba(117,170,252,0.15)] border border-[rgba(117,170,252,0.15)]">
+ {task.subtasks.map((subtask: TaskSummary) => {
+ const isRunning = subtask.status === "running" || subtask.status === "initializing" || subtask.status === "starting";
+ const isViewingOutput = viewingSubtaskId === subtask.id;
+ const isExpanded = expandedSubtaskId === subtask.id;
+
+ // Different highlight colors: green for running, subtle blue for others
+ const outputHighlightBg = isRunning ? "bg-green-400/10" : "bg-[rgba(117,170,252,0.08)]";
+ const outputHighlightBorder = isRunning ? "border-l-green-400" : "border-l-[#75aafc]";
+ const outputLabelColor = isRunning ? "text-green-400" : "text-[#75aafc]";
+
+ return (
+ <div key={subtask.id}>
+ {/* Subtask header - clickable to view output */}
+ <div
+ className={`w-full p-3 text-left hover:bg-[rgba(117,170,252,0.05)] transition-colors cursor-pointer ${
+ isExpanded && !isViewingOutput ? "bg-[rgba(117,170,252,0.08)]" : ""
+ } ${isViewingOutput ? `${outputHighlightBg} border-l-2 ${outputHighlightBorder}` : ""}`}
+ onClick={() => handleSubtaskClick(subtask)}
+ >
+ <div className="flex items-center gap-2 mb-1">
+ <span className="text-[#555] text-xs">
+ {isViewingOutput ? "[*]" : (isExpanded ? "[-]" : "[+]")}
+ </span>
+ <span className="font-mono text-sm text-[#dbe7ff]">
+ {subtask.name}
+ </span>
+ <span
+ className={`px-1.5 py-0.5 font-mono text-[9px] uppercase ${getStatusColor(
+ subtask.status
+ )} ${getStatusBgColor(subtask.status)} border border-current/20`}
+ >
+ {subtask.status}
+ </span>
+ {subtask.subtaskCount > 0 && (
+ <span className="font-mono text-[9px] text-[#555]">
+ +{subtask.subtaskCount}
+ </span>
+ )}
+ {isViewingOutput && (
+ <span className={`font-mono text-[9px] ${outputLabelColor} ml-auto`}>
+ {isRunning ? "viewing live output" : "viewing output"}
+ </span>
+ )}
+ {/* Expand/edit button - always available */}
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
+ handleSubtaskToggle(subtask.id);
+ }}
+ className={`ml-auto px-1.5 py-0.5 font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors ${
+ isViewingOutput ? "ml-2" : ""
+ }`}
+ title="Expand details"
+ >
+ {isExpanded ? "-" : "+"}
+ </button>
+ </div>
+ {subtask.progressSummary && !isExpanded && !isViewingOutput && (
+ <p className="font-mono text-xs text-[#75aafc] line-clamp-1 pl-6">
+ {subtask.progressSummary}
+ </p>
+ )}
+ </div>
+ {/* Inline subtask editor */}
+ {isExpanded && (
+ <InlineSubtaskEditor
+ subtaskId={subtask.id}
+ onClose={() => setExpandedSubtaskId(null)}
+ onUpdated={handleSubtaskUpdated}
+ onNavigate={onSelectSubtask}
+ />
+ )}
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+
+ {/* Action buttons for completed tasks */}
+ {(task.status === "done" || task.status === "merged" || task.status === "failed") && (
+ <div className="space-y-2 pt-4 border-t border-[rgba(117,170,252,0.2)]">
+ <div className="flex flex-wrap gap-2">
+ {onRequestDiff && (
+ <button
+ onClick={() => {
+ onRequestDiff();
+ setShowDiff(true);
+ }}
+ className="px-3 py-1.5 font-mono text-xs text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ View Diff
+ </button>
+ )}
+ {canCreatePR && (
+ <button
+ onClick={() => setShowPRPreview(true)}
+ className="px-3 py-1.5 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 transition-colors"
+ >
+ Create PR
+ </button>
+ )}
+ {/* Retry completion action button */}
+ {canRetryCompletion && (
+ <button
+ onClick={handleRetryCompletion}
+ disabled={isRetryingCompletion}
+ className="px-3 py-1.5 font-mono text-xs text-cyan-400 border border-cyan-400/30 hover:border-cyan-400/50 hover:bg-cyan-400/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {isRetryingCompletion
+ ? "Retrying..."
+ : task.completionAction === "branch"
+ ? "Push Branch"
+ : task.completionAction === "merge"
+ ? "Merge to Target"
+ : task.completionAction === "pr"
+ ? "Create PR"
+ : "Run Completion Action"}
+ </button>
+ )}
+ {/* Show hint if completion action needs configuration */}
+ {!canRetryCompletion && (
+ <span className="px-3 py-1.5 font-mono text-xs text-[#555] italic">
+ {!task.completionAction || task.completionAction === "none"
+ ? "Set completion action to enable"
+ : !task.targetRepoPath
+ ? "Set target repo path to enable"
+ : ""}
+ </span>
+ )}
+ </div>
+ {/* Retry error message */}
+ {retryError && (
+ <div className="font-mono text-xs text-red-400 bg-red-400/10 px-3 py-2 border border-red-400/30">
+ {retryError}
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* Clone Worktree Section */}
+ {canCloneWorktree && (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-2">
+ <div className="font-mono text-xs text-[#555]">Clone Worktree to Directory</div>
+ <div className="flex gap-2 items-start">
+ <DirectoryInput
+ value={cloneTargetDir}
+ onChange={setCloneTargetDir}
+ suggestions={suggestedDirectories}
+ placeholder="/path/to/clone"
+ repoUrl={task.repositoryUrl}
+ className="flex-1"
+ />
+ <button
+ onClick={handleCloneWorktree}
+ disabled={isCloning || !cloneTargetDir.trim()}
+ className="px-3 py-2 font-mono text-xs text-purple-400 border border-purple-400/30 hover:border-purple-400/50 hover:bg-purple-400/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
+ >
+ {isCloning ? "Cloning..." : "Clone"}
+ </button>
+ </div>
+ <p className="font-mono text-[10px] text-[#555]">
+ Clone the worktree (git repo) to a new directory. Useful for moving completed work outside ~/.makima.
+ </p>
+ {cloneError && (
+ <div className="font-mono text-xs text-red-400 bg-red-400/10 px-3 py-2 border border-red-400/30">
+ {cloneError}
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+
+ {/* Overlay Diff Modal */}
+ {showDiff && overlayDiff !== undefined && (
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
+ <div className="max-w-4xl w-full max-h-[80vh]">
+ <OverlayDiffViewer
+ diff={overlayDiff}
+ changedFiles={changedFiles}
+ onClose={() => setShowDiff(false)}
+ title={`Changes in ${task.name}`}
+ />
+ </div>
+ </div>
+ )}
+
+ {/* PR Preview Modal */}
+ {showPRPreview && (
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
+ <div className="max-w-3xl w-full">
+ <PRPreview
+ task={task}
+ diff={overlayDiff}
+ changedFiles={changedFiles}
+ onCreatePR={onCreatePR}
+ onAutoMerge={task.mergeMode === "auto" ? onAutoMerge : undefined}
+ onClose={() => setShowPRPreview(false)}
+ />
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/TaskList.tsx b/makima/frontend/src/components/mesh/TaskList.tsx
new file mode 100644
index 0000000..a37e564
--- /dev/null
+++ b/makima/frontend/src/components/mesh/TaskList.tsx
@@ -0,0 +1,164 @@
+import type { TaskSummary, TaskStatus } from "../../lib/api";
+
+interface TaskListProps {
+ tasks: TaskSummary[];
+ loading: boolean;
+ onSelect: (id: string) => void;
+ onDelete: (id: string) => void;
+ onCreate: () => void;
+}
+
+function formatDate(dateStr: string): string {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+function getStatusColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "text-[#9bc3ff]";
+ case "running":
+ return "text-green-400";
+ case "paused":
+ return "text-yellow-400";
+ case "blocked":
+ return "text-orange-400";
+ case "done":
+ return "text-emerald-400";
+ case "failed":
+ return "text-red-400";
+ case "merged":
+ return "text-purple-400";
+ default:
+ return "text-[#9bc3ff]";
+ }
+}
+
+function getStatusBgColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "bg-[rgba(117,170,252,0.1)]";
+ case "running":
+ return "bg-green-400/10";
+ case "paused":
+ return "bg-yellow-400/10";
+ case "blocked":
+ return "bg-orange-400/10";
+ case "done":
+ return "bg-emerald-400/10";
+ case "failed":
+ return "bg-red-400/10";
+ case "merged":
+ return "bg-purple-400/10";
+ default:
+ return "bg-[rgba(117,170,252,0.1)]";
+ }
+}
+
+export function TaskList({
+ tasks,
+ loading,
+ onSelect,
+ onDelete,
+ onCreate,
+}: TaskListProps) {
+ if (loading) {
+ return (
+ <div className="panel h-full flex items-center justify-center">
+ <div className="font-mono text-[#9bc3ff] text-sm">Loading tasks...</div>
+ </div>
+ );
+ }
+
+ // Separate root tasks (no parent) from subtasks
+ const rootTasks = tasks.filter((t) => !t.parentTaskId);
+
+ return (
+ <div className="panel h-full flex flex-col">
+ <div className="flex items-center justify-between p-4 pb-2 border-b border-dashed border-[rgba(117,170,252,0.35)]">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ MESH//TASKS
+ </div>
+ <button
+ onClick={onCreate}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors uppercase"
+ >
+ + New Task
+ </button>
+ </div>
+
+ <div className="flex-1 overflow-y-auto">
+ {rootTasks.length === 0 ? (
+ <div className="text-center text-[#9bc3ff] text-sm font-mono opacity-60 py-8">
+ No tasks yet. Create one to start orchestrating Claude Code instances.
+ </div>
+ ) : (
+ <div className="divide-y divide-[rgba(117,170,252,0.15)]">
+ {rootTasks.map((task) => (
+ <div
+ key={task.id}
+ className="p-4 hover:bg-[rgba(117,170,252,0.05)] transition-colors"
+ >
+ <div className="flex items-start justify-between gap-4">
+ <button
+ onClick={() => onSelect(task.id)}
+ className="flex-1 text-left"
+ >
+ <div className="flex items-center gap-2 mb-1">
+ <h3 className="font-mono text-sm text-[#dbe7ff]">
+ {task.name}
+ </h3>
+ <span
+ className={`px-2 py-0.5 font-mono text-[10px] uppercase ${getStatusColor(
+ task.status
+ )} ${getStatusBgColor(task.status)} border border-current/20`}
+ >
+ {task.status}
+ </span>
+ {task.depth === 0 && task.subtaskCount > 0 && (
+ <span className="px-2 py-0.5 font-mono text-[10px] text-purple-400 bg-purple-400/10 border border-purple-400/20">
+ Orchestrator
+ </span>
+ )}
+ {task.priority > 0 && (
+ <span className="px-2 py-0.5 font-mono text-[10px] text-orange-400 bg-orange-400/10 border border-orange-400/20">
+ P{task.priority}
+ </span>
+ )}
+ </div>
+ {task.progressSummary && (
+ <p className="font-mono text-xs text-[#9bc3ff] mb-2 line-clamp-2">
+ {task.progressSummary}
+ </p>
+ )}
+ <div className="flex gap-4 font-mono text-[10px] text-[#75aafc]">
+ {task.subtaskCount > 0 && (
+ <span>{task.subtaskCount} subtasks</span>
+ )}
+ <span>{formatDate(task.createdAt)}</span>
+ </div>
+ </button>
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
+ onDelete(task.id);
+ }}
+ className="px-2 py-1 font-mono text-[10px] text-red-400 hover:bg-red-400/10 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase"
+ >
+ Delete
+ </button>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/TaskOutput.tsx b/makima/frontend/src/components/mesh/TaskOutput.tsx
new file mode 100644
index 0000000..10de225
--- /dev/null
+++ b/makima/frontend/src/components/mesh/TaskOutput.tsx
@@ -0,0 +1,281 @@
+import { useRef, useEffect, useState, useCallback } from "react";
+import { SimpleMarkdown } from "../SimpleMarkdown";
+import type { TaskOutputEvent } from "../../hooks/useTaskSubscription";
+import { sendTaskMessage } from "../../lib/api";
+
+interface TaskOutputProps {
+ /** Array of parsed output events from the backend */
+ entries: TaskOutputEvent[];
+ isStreaming: boolean;
+ /** Name of subtask whose output is being viewed (null = parent task) */
+ viewingSubtaskName?: string | null;
+ /** Callback to return to parent task output */
+ onClearSubtaskView?: () => void;
+ onClear?: () => void;
+ /** Task ID for sending input (if provided, shows input bar when streaming) */
+ taskId?: string | null;
+ /** Callback when user sends input (to show it immediately in output) */
+ onUserInput?: (message: string) => void;
+}
+
+export function TaskOutput({ entries, isStreaming, viewingSubtaskName, onClearSubtaskView, onClear, taskId, onUserInput }: TaskOutputProps) {
+ const containerRef = useRef<HTMLDivElement>(null);
+ const [autoScroll, setAutoScroll] = useState(true);
+ const [inputValue, setInputValue] = useState("");
+ const [sendingInput, setSendingInput] = useState(false);
+ const [inputError, setInputError] = useState<string | null>(null);
+ const inputRef = useRef<HTMLInputElement>(null);
+
+ // Handle scroll to check if user has scrolled up
+ const handleScroll = useCallback(() => {
+ if (!containerRef.current) return;
+ const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
+ const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
+ setAutoScroll(isAtBottom);
+ }, []);
+
+ // Auto-scroll when entries change
+ useEffect(() => {
+ if (autoScroll && containerRef.current) {
+ containerRef.current.scrollTop = containerRef.current.scrollHeight;
+ }
+ }, [entries, autoScroll]);
+
+ // Handle sending input to the task
+ const handleSendInput = useCallback(async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!taskId || !inputValue.trim() || sendingInput) return;
+
+ const message = inputValue.trim();
+ setSendingInput(true);
+ setInputError(null);
+
+ // Show user input immediately in the output window
+ onUserInput?.(message);
+
+ try {
+ await sendTaskMessage(taskId, message);
+ setInputValue("");
+ inputRef.current?.focus();
+ } catch (err) {
+ setInputError(err instanceof Error ? err.message : "Failed to send input");
+ } finally {
+ setSendingInput(false);
+ }
+ }, [taskId, inputValue, sendingInput, onUserInput]);
+
+ // Show input bar when task is running and has a valid taskId
+ const showInputBar = isStreaming && taskId;
+
+ return (
+ <div className="flex flex-col h-full">
+ {/* Header */}
+ <div className="flex items-center justify-between px-3 py-2 border-b border-dashed border-[rgba(117,170,252,0.35)] shrink-0">
+ <div className="flex items-center gap-2">
+ {viewingSubtaskName ? (
+ <>
+ <button
+ onClick={onClearSubtaskView}
+ className="font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
+ >
+ &lt;
+ </button>
+ <span className="font-mono text-xs text-green-400 tracking-wide uppercase">
+ Subtask: {viewingSubtaskName}
+ </span>
+ </>
+ ) : (
+ <span className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Output
+ </span>
+ )}
+ {isStreaming && (
+ <span className="flex items-center gap-1.5 px-2 py-0.5 bg-green-400/10 border border-green-400/30 text-green-400 font-mono text-[10px] uppercase">
+ <span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" />
+ Live
+ </span>
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ {!autoScroll && (
+ <button
+ onClick={() => {
+ setAutoScroll(true);
+ if (containerRef.current) {
+ containerRef.current.scrollTop =
+ containerRef.current.scrollHeight;
+ }
+ }}
+ className="px-2 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ Resume Scroll
+ </button>
+ )}
+ {onClear && entries.length > 0 && (
+ <button
+ onClick={onClear}
+ className="px-2 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ Clear
+ </button>
+ )}
+ </div>
+ </div>
+
+ {/* Output area */}
+ <div
+ ref={containerRef}
+ onScroll={handleScroll}
+ className="flex-1 overflow-auto bg-[#0a0f18] p-3 font-mono text-xs min-h-0"
+ >
+ {entries.length === 0 ? (
+ <div className="text-[#555] italic">
+ {isStreaming ? "Waiting for output..." : "No output yet"}
+ </div>
+ ) : (
+ <div className="space-y-3">
+ {entries.map((entry, idx) => (
+ <OutputEntryRenderer key={idx} entry={entry} />
+ ))}
+ {isStreaming && (
+ <span className="inline-block w-2 h-4 bg-[#9bc3ff] animate-pulse" />
+ )}
+ </div>
+ )}
+ </div>
+
+ {/* Input bar for sending messages to running tasks */}
+ {showInputBar && (
+ <div className="shrink-0 border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]">
+ {inputError && (
+ <div className="px-3 py-1 bg-red-900/20 text-red-400 text-xs font-mono">
+ {inputError}
+ </div>
+ )}
+ <form onSubmit={handleSendInput} className="flex items-center gap-2 px-3 py-2">
+ <span className="text-green-400 font-mono text-sm">&gt;</span>
+ <input
+ ref={inputRef}
+ type="text"
+ value={inputValue}
+ onChange={(e) => setInputValue(e.target.value)}
+ placeholder={sendingInput ? "Sending..." : "Send input to Claude..."}
+ disabled={sendingInput}
+ className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]"
+ />
+ <button
+ type="submit"
+ disabled={sendingInput || !inputValue.trim()}
+ className="px-3 py-1 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {sendingInput ? "..." : "Send"}
+ </button>
+ </form>
+ </div>
+ )}
+ </div>
+ );
+}
+
+function OutputEntryRenderer({ entry }: { entry: TaskOutputEvent }) {
+ const [expanded, setExpanded] = useState(false);
+
+ switch (entry.messageType) {
+ case "user_input":
+ return (
+ <div className="pl-2 border-l-2 border-cyan-400/50">
+ <div className="flex items-center gap-2">
+ <span className="text-cyan-400 text-[10px] uppercase tracking-wide">You:</span>
+ </div>
+ <div className="text-cyan-300 mt-1">{entry.content}</div>
+ </div>
+ );
+
+ case "system":
+ return (
+ <div className="text-[#555] text-[10px] uppercase tracking-wide">
+ {entry.content}
+ </div>
+ );
+
+ case "assistant":
+ return (
+ <div className="pl-2 border-l-2 border-[#3f6fb3]">
+ <SimpleMarkdown content={entry.content} className="text-[#9bc3ff]" />
+ </div>
+ );
+
+ case "tool_use":
+ return (
+ <div className="space-y-1">
+ <div className="flex items-center gap-2">
+ <span className="text-yellow-500">*</span>
+ <span className="text-[#75aafc]">{entry.toolName || "unknown"}</span>
+ {entry.toolInput && Object.keys(entry.toolInput).length > 0 && (
+ <button
+ onClick={() => setExpanded(!expanded)}
+ className="text-[#555] hover:text-[#9bc3ff] text-[10px]"
+ >
+ {expanded ? "[-]" : "[+]"}
+ </button>
+ )}
+ </div>
+ {expanded && entry.toolInput && (
+ <pre className="ml-4 text-[10px] text-[#555] bg-[#0a1525] p-2 overflow-x-auto">
+ {JSON.stringify(entry.toolInput, null, 2)}
+ </pre>
+ )}
+ </div>
+ );
+
+ case "tool_result":
+ if (!entry.content) return null;
+ return (
+ <div className="ml-4 text-[10px]">
+ <span className={entry.isError ? "text-red-400" : "text-green-500"}>
+ {entry.isError ? "x" : "+"}
+ </span>{" "}
+ <span className="text-[#555]">
+ {entry.content.split("\n")[0]}
+ {entry.content.includes("\n") && "..."}
+ </span>
+ </div>
+ );
+
+ case "result":
+ return (
+ <div className="border-t border-[rgba(117,170,252,0.2)] pt-2 mt-2">
+ <div className="text-green-500 font-semibold mb-1">Result:</div>
+ <SimpleMarkdown content={entry.content} className="text-[#9bc3ff]" />
+ {(entry.costUsd !== undefined || entry.durationMs !== undefined) && (
+ <div className="text-[10px] text-[#555] mt-2">
+ {entry.durationMs !== undefined && (
+ <span>Duration: {(entry.durationMs / 1000).toFixed(1)}s</span>
+ )}
+ {entry.costUsd !== undefined && entry.durationMs !== undefined && " | "}
+ {entry.costUsd !== undefined && (
+ <span>Cost: ${entry.costUsd.toFixed(4)}</span>
+ )}
+ </div>
+ )}
+ </div>
+ );
+
+ case "error":
+ return (
+ <div className="text-red-400 pl-2 border-l-2 border-red-400/50">
+ {entry.content}
+ </div>
+ );
+
+ case "raw":
+ return (
+ <div className="text-[#555] text-[10px]">
+ {entry.content}
+ </div>
+ );
+
+ default:
+ return null;
+ }
+}
diff --git a/makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx b/makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx
new file mode 100644
index 0000000..5caa3c4
--- /dev/null
+++ b/makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx
@@ -0,0 +1,536 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+import {
+ type LlmModel,
+ type UserQuestion,
+ type UserAnswer,
+ type MeshChatContext,
+} from "../../lib/api";
+import { useMeshChatHistory } from "../../hooks/useMeshChatHistory";
+import { SimpleMarkdown } from "../SimpleMarkdown";
+
+interface UnifiedMeshChatInputProps {
+ context: MeshChatContext;
+ onUpdate?: () => void;
+}
+
+const MODEL_OPTIONS: { value: LlmModel; label: string }[] = [
+ { value: "claude-opus", label: "Claude Opus" },
+ { value: "claude-sonnet", label: "Claude Sonnet" },
+ { value: "groq", label: "Groq Kimi" },
+];
+
+const DEFAULT_MODEL: LlmModel = "claude-opus";
+
+// LocalStorage keys
+const STORAGE_KEY_MODEL = "makima-mesh-chat-model";
+const STORAGE_KEY_CMD_HISTORY = "makima-mesh-chat-cmd-history";
+const MAX_CMD_HISTORY = 100;
+
+function loadModel(): LlmModel {
+ try {
+ const modelStr = localStorage.getItem(STORAGE_KEY_MODEL);
+ return (modelStr as LlmModel) || DEFAULT_MODEL;
+ } catch {
+ return DEFAULT_MODEL;
+ }
+}
+
+function saveModel(model: LlmModel): void {
+ try {
+ localStorage.setItem(STORAGE_KEY_MODEL, model);
+ } catch {
+ // Ignore storage errors
+ }
+}
+
+function loadCommandHistory(): string[] {
+ try {
+ const historyJson = localStorage.getItem(STORAGE_KEY_CMD_HISTORY);
+ return historyJson ? JSON.parse(historyJson) : [];
+ } catch {
+ return [];
+ }
+}
+
+function saveCommandHistory(history: string[]): void {
+ try {
+ localStorage.setItem(
+ STORAGE_KEY_CMD_HISTORY,
+ JSON.stringify(history.slice(-MAX_CMD_HISTORY))
+ );
+ } catch {
+ // Ignore storage errors
+ }
+}
+
+function getPlaceholder(context: MeshChatContext): string {
+ switch (context.type) {
+ case "mesh":
+ return "Create task, list tasks, check status...";
+ case "task":
+ return "Create subtask, run task, check status...";
+ case "subtask":
+ return "Update plan, check siblings, merge...";
+ default:
+ return "Ask anything...";
+ }
+}
+
+function getContextLabel(context: MeshChatContext): string {
+ switch (context.type) {
+ case "mesh":
+ return "mesh";
+ case "task":
+ return `task:${context.taskId?.slice(0, 8)}`;
+ case "subtask":
+ return `subtask:${context.taskId?.slice(0, 8)}`;
+ default:
+ return "chat";
+ }
+}
+
+export function UnifiedMeshChatInput({
+ context,
+ onUpdate,
+}: UnifiedMeshChatInputProps) {
+ const {
+ messages,
+ loading: historyLoading,
+ error: historyError,
+ sending,
+ clearHistory,
+ sendMessage,
+ } = useMeshChatHistory();
+
+ const [input, setInput] = useState("");
+ const [expanded, setExpanded] = useState(false);
+ const [model, setModel] = useState<LlmModel>(DEFAULT_MODEL);
+
+ // Pending questions state
+ const [pendingQuestions, setPendingQuestions] = useState<
+ UserQuestion[] | null
+ >(null);
+ const [userAnswers, setUserAnswers] = useState<Map<string, string[]>>(
+ new Map()
+ );
+ const [customInputs, setCustomInputs] = useState<Map<string, string>>(
+ new Map()
+ );
+
+ // Command history for arrow key navigation
+ const [commandHistory, setCommandHistory] = useState<string[]>([]);
+ const [historyIndex, setHistoryIndex] = useState(-1);
+ const [savedInput, setSavedInput] = useState("");
+
+ const inputRef = useRef<HTMLInputElement>(null);
+ const messagesRef = useRef<HTMLDivElement>(null);
+
+ // Load model preference on mount
+ useEffect(() => {
+ setModel(loadModel());
+ setCommandHistory(loadCommandHistory());
+ }, []);
+
+ // Expand when messages exist
+ useEffect(() => {
+ if (messages.length > 0) {
+ setExpanded(true);
+ }
+ }, [messages.length]);
+
+ // Auto-scroll to bottom when messages change
+ useEffect(() => {
+ if (messagesRef.current) {
+ messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
+ }
+ }, [messages]);
+
+ // Handle model change
+ const handleModelChange = useCallback((newModel: LlmModel) => {
+ setModel(newModel);
+ saveModel(newModel);
+ }, []);
+
+ // Handle keyboard navigation for command history
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
+ if (e.key === "ArrowUp") {
+ e.preventDefault();
+ if (commandHistory.length === 0) return;
+
+ if (historyIndex === -1) {
+ setSavedInput(input);
+ setHistoryIndex(commandHistory.length - 1);
+ setInput(commandHistory[commandHistory.length - 1]);
+ } else if (historyIndex > 0) {
+ setHistoryIndex(historyIndex - 1);
+ setInput(commandHistory[historyIndex - 1]);
+ }
+ } else if (e.key === "ArrowDown") {
+ e.preventDefault();
+ if (historyIndex === -1) return;
+
+ if (historyIndex < commandHistory.length - 1) {
+ setHistoryIndex(historyIndex + 1);
+ setInput(commandHistory[historyIndex + 1]);
+ } else {
+ setHistoryIndex(-1);
+ setInput(savedInput);
+ }
+ }
+ },
+ [commandHistory, historyIndex, input, savedInput]
+ );
+
+ const handleSubmit = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!input.trim() || sending) return;
+
+ const userMessage = input.trim();
+
+ // Update command history
+ const newHistory =
+ commandHistory[commandHistory.length - 1] !== userMessage
+ ? [...commandHistory, userMessage]
+ : commandHistory;
+ setCommandHistory(newHistory);
+ saveCommandHistory(newHistory);
+
+ // Reset navigation state
+ setHistoryIndex(-1);
+ setSavedInput("");
+
+ setInput("");
+ setExpanded(true);
+
+ // Send message via hook (uses DB-persisted history)
+ const response = await sendMessage(userMessage, context, model);
+
+ if (response) {
+ // Handle pending questions
+ if (response.pendingQuestions?.length) {
+ setPendingQuestions(response.pendingQuestions);
+ const initialAnswers = new Map<string, string[]>();
+ response.pendingQuestions.forEach((q) => {
+ initialAnswers.set(q.id, []);
+ });
+ setUserAnswers(initialAnswers);
+ setCustomInputs(new Map());
+ }
+
+ // Notify parent that something may have been updated
+ // Always refresh when tool calls were made (state may have changed)
+ if (response.toolCalls && response.toolCalls.length > 0) {
+ onUpdate?.();
+ }
+ }
+
+ inputRef.current?.focus();
+ },
+ [input, sending, context, model, sendMessage, onUpdate, commandHistory]
+ );
+
+ // Handle option selection for a question
+ const handleOptionToggle = useCallback(
+ (questionId: string, option: string, allowMultiple: boolean) => {
+ setUserAnswers((prev) => {
+ const newMap = new Map(prev);
+ const currentAnswers = newMap.get(questionId) || [];
+
+ if (allowMultiple) {
+ if (currentAnswers.includes(option)) {
+ newMap.set(
+ questionId,
+ currentAnswers.filter((a) => a !== option)
+ );
+ } else {
+ newMap.set(questionId, [...currentAnswers, option]);
+ }
+ } else {
+ newMap.set(questionId, [option]);
+ }
+
+ return newMap;
+ });
+ },
+ []
+ );
+
+ // Handle custom input change
+ const handleCustomInputChange = useCallback(
+ (questionId: string, value: string) => {
+ setCustomInputs((prev) => {
+ const newMap = new Map(prev);
+ newMap.set(questionId, value);
+ return newMap;
+ });
+ },
+ []
+ );
+
+ // Submit answers to questions
+ const handleSubmitAnswers = useCallback(async () => {
+ if (!pendingQuestions || sending) return;
+
+ // Build answers array
+ const answers: UserAnswer[] = pendingQuestions.map((q) => {
+ const selectedOptions = userAnswers.get(q.id) || [];
+ const customInput = customInputs.get(q.id)?.trim();
+ const finalAnswers = customInput
+ ? [...selectedOptions, customInput]
+ : selectedOptions;
+
+ return {
+ id: q.id,
+ answers: finalAnswers,
+ };
+ });
+
+ // Format answers as a message
+ const answerText = answers
+ .map((a) => {
+ const question = pendingQuestions.find((q) => q.id === a.id);
+ return `${question?.question || a.id}: ${a.answers.join(", ")}`;
+ })
+ .join("\n");
+
+ // Clear pending questions
+ setPendingQuestions(null);
+ setUserAnswers(new Map());
+ setCustomInputs(new Map());
+
+ // Send answers as the next message
+ const response = await sendMessage(answerText, context, model);
+
+ if (response) {
+ // Handle more pending questions
+ if (response.pendingQuestions?.length) {
+ setPendingQuestions(response.pendingQuestions);
+ const initialAnswers = new Map<string, string[]>();
+ response.pendingQuestions.forEach((q) => {
+ initialAnswers.set(q.id, []);
+ });
+ setUserAnswers(initialAnswers);
+ setCustomInputs(new Map());
+ }
+
+ // Notify parent that something may have been updated
+ if (response.toolCalls && response.toolCalls.length > 0) {
+ onUpdate?.();
+ }
+ }
+ }, [
+ pendingQuestions,
+ userAnswers,
+ customInputs,
+ sending,
+ context,
+ model,
+ sendMessage,
+ onUpdate,
+ ]);
+
+ // Cancel answering questions
+ const handleCancelQuestions = useCallback(() => {
+ setPendingQuestions(null);
+ setUserAnswers(new Map());
+ setCustomInputs(new Map());
+ }, []);
+
+ const handleClearHistory = useCallback(async () => {
+ await clearHistory();
+ setPendingQuestions(null);
+ setUserAnswers(new Map());
+ setCustomInputs(new Map());
+ }, [clearHistory]);
+
+ const loading = sending || historyLoading;
+
+ return (
+ <div className="border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]">
+ {/* Error Display */}
+ {historyError && (
+ <div className="px-3 py-2 bg-red-900/20 text-red-400 text-xs font-mono">
+ {historyError}
+ </div>
+ )}
+
+ {/* Messages Panel (expandable) */}
+ {expanded && messages.length > 0 && (
+ <div
+ ref={messagesRef}
+ className="max-h-48 overflow-y-auto p-3 space-y-2 border-b border-[rgba(117,170,252,0.2)]"
+ >
+ {messages.map((msg) => (
+ <div key={msg.id} className="font-mono text-xs">
+ {msg.role === "user" && (
+ <div className="flex gap-2">
+ <span className="text-[#9bc3ff]">&gt;</span>
+ <span className="text-white/80 whitespace-pre-wrap">
+ {msg.content}
+ </span>
+ {msg.contextType !== "mesh" && (
+ <span className="text-[#555] text-[10px]">
+ [{msg.contextType}]
+ </span>
+ )}
+ </div>
+ )}
+ {msg.role === "assistant" && (
+ <div className="pl-4 space-y-1">
+ <SimpleMarkdown
+ content={msg.content}
+ className="text-[#75aafc]"
+ />
+ {msg.toolCalls && msg.toolCalls.length > 0 && (
+ <div className="text-[#555] text-[10px] space-y-0.5">
+ {msg.toolCalls.map((tc, i) => (
+ <div key={i}>
+ <span
+ className={
+ tc.result.success
+ ? "text-green-500"
+ : "text-red-400"
+ }
+ >
+ {tc.result.success ? "+" : "x"}
+ </span>{" "}
+ {tc.name}: {tc.result.message}
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+ {msg.role === "error" && (
+ <div className="pl-4 text-red-400">{msg.content}</div>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+
+ {/* Pending Questions UI */}
+ {pendingQuestions && pendingQuestions.length > 0 && (
+ <div className="p-3 border-b border-[rgba(117,170,252,0.2)] space-y-3">
+ <div className="text-[#9bc3ff] font-mono text-xs uppercase tracking-wide">
+ Questions from AI
+ </div>
+ {pendingQuestions.map((q) => (
+ <div key={q.id} className="space-y-2">
+ <div className="text-white/90 font-mono text-sm">
+ {q.question}
+ </div>
+ <div className="flex flex-wrap gap-2">
+ {q.options.map((option) => {
+ const isSelected = (userAnswers.get(q.id) || []).includes(
+ option
+ );
+ return (
+ <button
+ key={option}
+ type="button"
+ onClick={() =>
+ handleOptionToggle(q.id, option, q.allowMultiple)
+ }
+ className={`px-2 py-1 font-mono text-xs border transition-colors ${
+ isSelected
+ ? "bg-[#3f6fb3] border-[#75aafc] text-white"
+ : "bg-transparent border-[rgba(117,170,252,0.25)] text-[#9bc3ff] hover:border-[#3f6fb3]"
+ }`}
+ >
+ {q.allowMultiple && (
+ <span className="mr-1">{isSelected ? "+" : "-"}</span>
+ )}
+ {option}
+ </button>
+ );
+ })}
+ </div>
+ {q.allowCustom && (
+ <input
+ type="text"
+ value={customInputs.get(q.id) || ""}
+ onChange={(e) => handleCustomInputChange(q.id, e.target.value)}
+ placeholder="Or type a custom answer..."
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-xs px-2 py-1 outline-none focus:border-[#3f6fb3] placeholder-[#555]"
+ />
+ )}
+ </div>
+ ))}
+ <div className="flex gap-2 pt-2">
+ <button
+ type="button"
+ onClick={handleSubmitAnswers}
+ disabled={loading}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {loading ? "..." : "Submit Answers"}
+ </button>
+ <button
+ type="button"
+ onClick={handleCancelQuestions}
+ disabled={loading}
+ className="px-3 py-1 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors"
+ >
+ Cancel
+ </button>
+ </div>
+ </div>
+ )}
+
+ {/* Input Bar */}
+ <form onSubmit={handleSubmit} className="flex items-center gap-2 p-3">
+ <select
+ value={model}
+ onChange={(e) => handleModelChange(e.target.value as LlmModel)}
+ disabled={loading || !!pendingQuestions}
+ className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs px-2 py-1 rounded-none outline-none focus:border-[#3f6fb3] disabled:opacity-50"
+ >
+ {MODEL_OPTIONS.map((opt) => (
+ <option key={opt.value} value={opt.value}>
+ {opt.label}
+ </option>
+ ))}
+ </select>
+ <span className="text-[#555] font-mono text-[10px]">
+ [{getContextLabel(context)}]
+ </span>
+ <span className="text-[#9bc3ff] font-mono text-sm">&gt;</span>
+ <input
+ ref={inputRef}
+ type="text"
+ value={input}
+ onChange={(e) => setInput(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={
+ loading
+ ? "Processing..."
+ : pendingQuestions
+ ? "Answer questions above first..."
+ : getPlaceholder(context)
+ }
+ disabled={loading || !!pendingQuestions}
+ className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]"
+ />
+ {messages.length > 0 && (
+ <button
+ type="button"
+ onClick={handleClearHistory}
+ className="text-[#555] hover:text-[#9bc3ff] font-mono text-xs transition-colors"
+ >
+ clear
+ </button>
+ )}
+ <button
+ type="submit"
+ disabled={loading || !input.trim() || !!pendingQuestions}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {loading ? "..." : "Send"}
+ </button>
+ </form>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/contexts/AuthContext.tsx b/makima/frontend/src/contexts/AuthContext.tsx
new file mode 100644
index 0000000..ce2724b
--- /dev/null
+++ b/makima/frontend/src/contexts/AuthContext.tsx
@@ -0,0 +1,160 @@
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useState,
+ useCallback,
+ type ReactNode,
+} from "react";
+import { supabase, isAuthConfigured, type Session, type User } from "../lib/supabase";
+
+interface AuthState {
+ user: User | null;
+ session: Session | null;
+ isLoading: boolean;
+ isAuthenticated: boolean;
+ isAuthConfigured: boolean;
+}
+
+interface AuthContextValue extends AuthState {
+ /** Get the current access token for API calls */
+ getAccessToken: () => string | null;
+ /** Sign in with email and password */
+ signIn: (email: string, password: string) => Promise<{ error: Error | null }>;
+ /** Sign up with email and password */
+ signUp: (email: string, password: string) => Promise<{ error: Error | null }>;
+ /** Sign out */
+ signOut: () => Promise<void>;
+ /** Sign in with OAuth provider */
+ signInWithOAuth: (provider: "github" | "google") => Promise<{ error: Error | null }>;
+}
+
+const AuthContext = createContext<AuthContextValue | null>(null);
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [state, setState] = useState<AuthState>({
+ user: null,
+ session: null,
+ isLoading: true,
+ isAuthenticated: false,
+ isAuthConfigured: isAuthConfigured(),
+ });
+
+ // Initialize auth state
+ useEffect(() => {
+ if (!supabase) {
+ // Auth not configured - allow unauthenticated access
+ setState((prev) => ({
+ ...prev,
+ isLoading: false,
+ isAuthenticated: true, // Allow access when auth is not configured
+ }));
+ return;
+ }
+
+ // Get initial session
+ supabase.auth.getSession().then(({ data: { session } }) => {
+ setState({
+ user: session?.user ?? null,
+ session,
+ isLoading: false,
+ isAuthenticated: !!session,
+ isAuthConfigured: true,
+ });
+ });
+
+ // Listen for auth changes
+ const {
+ data: { subscription },
+ } = supabase.auth.onAuthStateChange((_event, session) => {
+ setState((prev) => ({
+ ...prev,
+ user: session?.user ?? null,
+ session,
+ isAuthenticated: !!session,
+ }));
+ });
+
+ return () => subscription.unsubscribe();
+ }, []);
+
+ const getAccessToken = useCallback((): string | null => {
+ return state.session?.access_token ?? null;
+ }, [state.session]);
+
+ const signIn = useCallback(
+ async (email: string, password: string): Promise<{ error: Error | null }> => {
+ if (!supabase) {
+ return { error: new Error("Auth not configured") };
+ }
+ const { error } = await supabase.auth.signInWithPassword({ email, password });
+ return { error: error ? new Error(error.message) : null };
+ },
+ []
+ );
+
+ const signUp = useCallback(
+ async (email: string, password: string): Promise<{ error: Error | null }> => {
+ if (!supabase) {
+ return { error: new Error("Auth not configured") };
+ }
+ const { error } = await supabase.auth.signUp({ email, password });
+ return { error: error ? new Error(error.message) : null };
+ },
+ []
+ );
+
+ const signOut = useCallback(async () => {
+ // Always clear local state first
+ setState((prev) => ({
+ ...prev,
+ user: null,
+ session: null,
+ isAuthenticated: false,
+ }));
+
+ // Clear Supabase storage directly in case signOut API fails
+ const storageKey = `sb-${import.meta.env.VITE_SUPABASE_URL?.split('//')[1]?.split('.')[0]}-auth-token`;
+ localStorage.removeItem(storageKey);
+
+ // Try to call signOut API (may fail if token is invalid, that's OK)
+ if (supabase) {
+ await supabase.auth.signOut({ scope: 'local' }).catch(() => {});
+ }
+ }, []);
+
+ const signInWithOAuth = useCallback(
+ async (provider: "github" | "google"): Promise<{ error: Error | null }> => {
+ if (!supabase) {
+ return { error: new Error("Auth not configured") };
+ }
+ const { error } = await supabase.auth.signInWithOAuth({
+ provider,
+ options: {
+ redirectTo: window.location.origin,
+ },
+ });
+ return { error: error ? new Error(error.message) : null };
+ },
+ []
+ );
+
+ const value: AuthContextValue = {
+ ...state,
+ getAccessToken,
+ signIn,
+ signUp,
+ signOut,
+ signInWithOAuth,
+ };
+
+ return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+}
+
+export function useAuth(): AuthContextValue {
+ const context = useContext(AuthContext);
+ if (!context) {
+ throw new Error("useAuth must be used within an AuthProvider");
+ }
+ return context;
+}
diff --git a/makima/frontend/src/hooks/useMeshChatHistory.ts b/makima/frontend/src/hooks/useMeshChatHistory.ts
new file mode 100644
index 0000000..82c576d
--- /dev/null
+++ b/makima/frontend/src/hooks/useMeshChatHistory.ts
@@ -0,0 +1,133 @@
+import { useState, useCallback, useEffect } from "react";
+import {
+ getMeshChatHistory,
+ clearMeshChatHistory,
+ chatWithMeshContext,
+ type MeshChatMessageRecord,
+ type MeshChatContext,
+ type MeshChatResponse,
+ type LlmModel,
+} from "../lib/api";
+
+export interface MeshChatState {
+ conversationId: string | null;
+ messages: MeshChatMessageRecord[];
+ loading: boolean;
+ error: string | null;
+ sending: boolean;
+}
+
+export function useMeshChatHistory() {
+ const [state, setState] = useState<MeshChatState>({
+ conversationId: null,
+ messages: [],
+ loading: true,
+ error: null,
+ sending: false,
+ });
+
+ const fetchHistory = useCallback(async () => {
+ setState((prev) => ({ ...prev, loading: true, error: null }));
+ try {
+ const response = await getMeshChatHistory();
+ setState((prev) => ({
+ ...prev,
+ conversationId: response.conversationId,
+ messages: response.messages,
+ loading: false,
+ }));
+ } catch (e) {
+ setState((prev) => ({
+ ...prev,
+ error: e instanceof Error ? e.message : "Failed to fetch chat history",
+ loading: false,
+ }));
+ }
+ }, []);
+
+ const clearHistory = useCallback(async (): Promise<boolean> => {
+ setState((prev) => ({ ...prev, loading: true, error: null }));
+ try {
+ const response = await clearMeshChatHistory();
+ setState({
+ conversationId: response.conversationId,
+ messages: [],
+ loading: false,
+ error: null,
+ sending: false,
+ });
+ return true;
+ } catch (e) {
+ setState((prev) => ({
+ ...prev,
+ error: e instanceof Error ? e.message : "Failed to clear chat history",
+ loading: false,
+ }));
+ return false;
+ }
+ }, []);
+
+ const sendMessage = useCallback(
+ async (
+ message: string,
+ context: MeshChatContext,
+ model?: LlmModel
+ ): Promise<MeshChatResponse | null> => {
+ setState((prev) => ({ ...prev, sending: true, error: null }));
+
+ // Optimistically add user message (will be refetched after response)
+ const tempUserMessage: MeshChatMessageRecord = {
+ id: `temp-${Date.now()}`,
+ conversationId: state.conversationId || "",
+ role: "user",
+ content: message,
+ contextType: context.type,
+ contextTaskId: context.taskId || null,
+ toolCalls: null,
+ pendingQuestions: null,
+ createdAt: new Date().toISOString(),
+ };
+
+ setState((prev) => ({
+ ...prev,
+ messages: [...prev.messages, tempUserMessage],
+ }));
+
+ try {
+ const response = await chatWithMeshContext(message, context, model);
+
+ // Refetch to get the actual saved messages (with proper IDs)
+ await fetchHistory();
+
+ setState((prev) => ({ ...prev, sending: false }));
+ return response;
+ } catch (e) {
+ // Remove optimistic message on error
+ setState((prev) => ({
+ ...prev,
+ messages: prev.messages.filter((m) => m.id !== tempUserMessage.id),
+ error: e instanceof Error ? e.message : "Failed to send message",
+ sending: false,
+ }));
+ return null;
+ }
+ },
+ [state.conversationId, fetchHistory]
+ );
+
+ // Initial fetch on mount
+ useEffect(() => {
+ fetchHistory();
+ }, [fetchHistory]);
+
+ return {
+ conversationId: state.conversationId,
+ messages: state.messages,
+ loading: state.loading,
+ error: state.error,
+ sending: state.sending,
+ fetchHistory,
+ clearHistory,
+ sendMessage,
+ };
+}
diff --git a/makima/frontend/src/hooks/useTaskSubscription.ts b/makima/frontend/src/hooks/useTaskSubscription.ts
new file mode 100644
index 0000000..9316c3a
--- /dev/null
+++ b/makima/frontend/src/hooks/useTaskSubscription.ts
@@ -0,0 +1,333 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+import { TASK_SUBSCRIBE_ENDPOINT } from "../lib/api";
+
+export interface TaskUpdateEvent {
+ taskId: string;
+ version: number;
+ status: string;
+ updatedFields: string[];
+ updatedBy: "user" | "daemon" | "system";
+}
+
+export interface TaskOutputEvent {
+ taskId: string;
+ /** Message type: "assistant", "tool_use", "tool_result", "result", "system", "error", "raw" */
+ messageType: string;
+ /** Main text content */
+ content: string;
+ /** Tool name if tool_use message */
+ toolName?: string;
+ /** Tool input JSON if tool_use message */
+ toolInput?: Record<string, unknown>;
+ /** Whether tool result was an error */
+ isError?: boolean;
+ /** Cost in USD if result message */
+ costUsd?: number;
+ /** Duration in ms if result message */
+ durationMs?: number;
+ isPartial: boolean;
+}
+
+interface UseTaskSubscriptionOptions {
+ taskId: string | null;
+ subscribeAll?: boolean;
+ subscribeOutput?: boolean;
+ /** Task ID to subscribe output for (defaults to taskId if not specified) */
+ outputTaskId?: string;
+ onUpdate?: (event: TaskUpdateEvent) => void;
+ onOutput?: (event: TaskOutputEvent) => void;
+ onError?: (error: string) => void;
+}
+
+export function useTaskSubscription(options: UseTaskSubscriptionOptions) {
+ const {
+ taskId,
+ subscribeAll = false,
+ subscribeOutput = false,
+ outputTaskId,
+ onUpdate,
+ onOutput,
+ onError,
+ } = options;
+
+ // The task ID to use for output subscription (defaults to taskId)
+ const effectiveOutputTaskId = outputTaskId || taskId;
+
+ const [connected, setConnected] = useState(false);
+ const wsRef = useRef<WebSocket | null>(null);
+ const reconnectTimeoutRef = useRef<number | null>(null);
+ const subscribedTaskRef = useRef<string | null>(null);
+ const subscribedAllRef = useRef(false);
+ const subscribedOutputRef = useRef<string | null>(null);
+
+ // Store callbacks in refs to avoid re-connecting when callbacks change
+ const callbacksRef = useRef({ onUpdate, onOutput, onError });
+ useEffect(() => {
+ callbacksRef.current = { onUpdate, onOutput, onError };
+ }, [onUpdate, onOutput, onError]);
+
+ const connect = useCallback(() => {
+ // Prevent multiple connections - check for OPEN or CONNECTING states
+ const currentState = wsRef.current?.readyState;
+ if (currentState === WebSocket.OPEN || currentState === WebSocket.CONNECTING) {
+ return;
+ }
+
+ // Close any existing connection that's in CLOSING state
+ if (wsRef.current && currentState === WebSocket.CLOSING) {
+ wsRef.current = null;
+ }
+
+ try {
+ const ws = new WebSocket(TASK_SUBSCRIBE_ENDPOINT);
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ setConnected(true);
+ // Re-subscribe if we had subscriptions
+ if (subscribedAllRef.current) {
+ ws.send(JSON.stringify({ type: "subscribeAll" }));
+ }
+ if (subscribedTaskRef.current) {
+ ws.send(
+ JSON.stringify({
+ type: "subscribe",
+ taskId: subscribedTaskRef.current,
+ })
+ );
+ }
+ if (subscribedOutputRef.current) {
+ ws.send(
+ JSON.stringify({
+ type: "subscribeOutput",
+ taskId: subscribedOutputRef.current,
+ })
+ );
+ }
+ };
+
+ ws.onmessage = (event) => {
+ try {
+ const message = JSON.parse(event.data);
+
+ switch (message.type) {
+ case "taskUpdated":
+ callbacksRef.current.onUpdate?.({
+ taskId: message.taskId,
+ version: message.version,
+ status: message.status,
+ updatedFields: message.updatedFields,
+ updatedBy: message.updatedBy,
+ });
+ break;
+ case "taskOutput":
+ callbacksRef.current.onOutput?.({
+ taskId: message.taskId,
+ messageType: message.messageType,
+ content: message.content,
+ toolName: message.toolName,
+ toolInput: message.toolInput,
+ isError: message.isError,
+ costUsd: message.costUsd,
+ durationMs: message.durationMs,
+ isPartial: message.isPartial,
+ });
+ break;
+ case "error":
+ callbacksRef.current.onError?.(message.message);
+ break;
+ // Acknowledgement messages - could add callbacks if needed
+ case "subscribed":
+ case "unsubscribed":
+ case "subscribedAll":
+ case "unsubscribedAll":
+ case "outputSubscribed":
+ case "outputUnsubscribed":
+ break;
+ }
+ } catch (e) {
+ console.error("Failed to parse task subscription message:", e);
+ }
+ };
+
+ ws.onerror = () => {
+ callbacksRef.current.onError?.("WebSocket connection error");
+ };
+
+ ws.onclose = () => {
+ setConnected(false);
+ wsRef.current = null;
+
+ // Attempt reconnection after 3 seconds if we still have a subscription
+ if (
+ subscribedTaskRef.current ||
+ subscribedAllRef.current ||
+ subscribedOutputRef.current
+ ) {
+ reconnectTimeoutRef.current = window.setTimeout(() => {
+ connect();
+ }, 3000);
+ }
+ };
+ } catch (e) {
+ callbacksRef.current.onError?.(
+ e instanceof Error ? e.message : "Failed to connect"
+ );
+ }
+ }, []);
+
+ const subscribeToTask = useCallback(
+ (id: string) => {
+ subscribedTaskRef.current = id;
+
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(
+ JSON.stringify({
+ type: "subscribe",
+ taskId: id,
+ })
+ );
+ } else {
+ connect();
+ }
+ },
+ [connect]
+ );
+
+ const unsubscribeFromTask = useCallback(() => {
+ if (
+ subscribedTaskRef.current &&
+ wsRef.current?.readyState === WebSocket.OPEN
+ ) {
+ wsRef.current.send(
+ JSON.stringify({
+ type: "unsubscribe",
+ taskId: subscribedTaskRef.current,
+ })
+ );
+ }
+ subscribedTaskRef.current = null;
+ }, []);
+
+ const subscribeToAll = useCallback(() => {
+ subscribedAllRef.current = true;
+
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(JSON.stringify({ type: "subscribeAll" }));
+ } else {
+ connect();
+ }
+ }, [connect]);
+
+ const unsubscribeFromAll = useCallback(() => {
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(JSON.stringify({ type: "unsubscribeAll" }));
+ }
+ subscribedAllRef.current = false;
+ }, []);
+
+ const subscribeToOutput = useCallback(
+ (id: string) => {
+ // First unsubscribe from any previous output subscription
+ if (subscribedOutputRef.current && subscribedOutputRef.current !== id) {
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(
+ JSON.stringify({
+ type: "unsubscribeOutput",
+ taskId: subscribedOutputRef.current,
+ })
+ );
+ }
+ }
+
+ subscribedOutputRef.current = id;
+
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(
+ JSON.stringify({
+ type: "subscribeOutput",
+ taskId: id,
+ })
+ );
+ } else {
+ connect();
+ }
+ },
+ [connect]
+ );
+
+ const unsubscribeFromOutput = useCallback(() => {
+ if (
+ subscribedOutputRef.current &&
+ wsRef.current?.readyState === WebSocket.OPEN
+ ) {
+ wsRef.current.send(
+ JSON.stringify({
+ type: "unsubscribeOutput",
+ taskId: subscribedOutputRef.current,
+ })
+ );
+ }
+ subscribedOutputRef.current = null;
+ }, []);
+
+ // Auto-subscribe based on options
+ useEffect(() => {
+ if (subscribeAll) {
+ subscribeToAll();
+ } else if (taskId) {
+ subscribeToTask(taskId);
+ } else {
+ unsubscribeFromTask();
+ unsubscribeFromAll();
+ }
+
+ return () => {
+ unsubscribeFromTask();
+ unsubscribeFromAll();
+ };
+ }, [
+ taskId,
+ subscribeAll,
+ subscribeToTask,
+ unsubscribeFromTask,
+ subscribeToAll,
+ unsubscribeFromAll,
+ ]);
+
+ // Handle output subscription separately
+ // Uses effectiveOutputTaskId which may be different from taskId when viewing subtask output
+ useEffect(() => {
+ if (subscribeOutput && effectiveOutputTaskId) {
+ subscribeToOutput(effectiveOutputTaskId);
+ } else {
+ unsubscribeFromOutput();
+ }
+
+ return () => {
+ unsubscribeFromOutput();
+ };
+ }, [effectiveOutputTaskId, subscribeOutput, subscribeToOutput, unsubscribeFromOutput]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current);
+ }
+ if (wsRef.current) {
+ wsRef.current.close();
+ }
+ };
+ }, []);
+
+ return {
+ connected,
+ subscribeToTask,
+ unsubscribeFromTask,
+ subscribeToAll,
+ unsubscribeFromAll,
+ subscribeToOutput,
+ unsubscribeFromOutput,
+ };
+}
diff --git a/makima/frontend/src/hooks/useTasks.ts b/makima/frontend/src/hooks/useTasks.ts
new file mode 100644
index 0000000..6e6c992
--- /dev/null
+++ b/makima/frontend/src/hooks/useTasks.ts
@@ -0,0 +1,130 @@
+import { useState, useCallback, useEffect } from "react";
+import {
+ listTasks,
+ getTask,
+ createTask,
+ updateTask,
+ deleteTask,
+ VersionConflictError,
+ type TaskSummary,
+ type TaskWithSubtasks,
+ type CreateTaskRequest,
+ type UpdateTaskRequest,
+} from "../lib/api";
+
+export interface ConflictState {
+ hasConflict: boolean;
+ expectedVersion: number;
+ actualVersion: number;
+}
+
+export function useTasks() {
+ const [tasks, setTasks] = useState<TaskSummary[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [conflict, setConflict] = useState<ConflictState | null>(null);
+
+ const fetchTasks = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await listTasks();
+ setTasks(response.tasks);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to fetch tasks");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const fetchTask = useCallback(
+ async (id: string): Promise<TaskWithSubtasks | null> => {
+ setError(null);
+ try {
+ return await getTask(id);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to fetch task");
+ return null;
+ }
+ },
+ []
+ );
+
+ const saveTask = useCallback(
+ async (data: CreateTaskRequest): Promise<TaskWithSubtasks | null> => {
+ setError(null);
+ try {
+ const task = await createTask(data);
+ await fetchTasks(); // Refresh list
+ // Return as TaskWithSubtasks
+ return { ...task, subtasks: [] };
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to save task");
+ return null;
+ }
+ },
+ [fetchTasks]
+ );
+
+ const editTask = useCallback(
+ async (id: string, data: UpdateTaskRequest): Promise<TaskWithSubtasks | null> => {
+ setError(null);
+ setConflict(null);
+ try {
+ await updateTask(id, data);
+ await fetchTasks(); // Refresh list
+ // Re-fetch to get subtasks
+ return await getTask(id);
+ } catch (e) {
+ if (e instanceof VersionConflictError) {
+ setConflict({
+ hasConflict: true,
+ expectedVersion: e.expectedVersion,
+ actualVersion: e.actualVersion,
+ });
+ return null;
+ }
+ setError(e instanceof Error ? e.message : "Failed to update task");
+ return null;
+ }
+ },
+ [fetchTasks]
+ );
+
+ const clearConflict = useCallback(() => {
+ setConflict(null);
+ }, []);
+
+ const removeTask = useCallback(
+ async (id: string): Promise<boolean> => {
+ setError(null);
+ try {
+ await deleteTask(id);
+ await fetchTasks(); // Refresh list
+ return true;
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to delete task");
+ return false;
+ }
+ },
+ [fetchTasks]
+ );
+
+ // Initial fetch
+ useEffect(() => {
+ fetchTasks();
+ }, [fetchTasks]);
+
+ return {
+ tasks,
+ loading,
+ error,
+ conflict,
+ clearConflict,
+ fetchTasks,
+ fetchTask,
+ saveTask,
+ editTask,
+ removeTask,
+ };
+}
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index 2657a95..a11f15e 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -1,3 +1,5 @@
+import { supabase } from "./supabase";
+
const API_CONFIG = {
local: {
http: "http://localhost:8080",
@@ -33,8 +35,72 @@ const env = detectEnvironment();
export const API_BASE = API_CONFIG[env].http;
export const WS_BASE = API_CONFIG[env].ws;
+
+// =============================================================================
+// Authentication helpers
+// =============================================================================
+
+/** Storage key for API key */
+const API_KEY_STORAGE_KEY = "makima_api_key";
+
+/** Get stored API key from localStorage */
+export function getStoredApiKey(): string | null {
+ if (typeof window === "undefined") return null;
+ return localStorage.getItem(API_KEY_STORAGE_KEY);
+}
+
+/** Store API key in localStorage */
+export function setStoredApiKey(key: string): void {
+ if (typeof window === "undefined") return;
+ localStorage.setItem(API_KEY_STORAGE_KEY, key);
+}
+
+/** Remove stored API key */
+export function clearStoredApiKey(): void {
+ if (typeof window === "undefined") return;
+ localStorage.removeItem(API_KEY_STORAGE_KEY);
+}
+
+/** Get auth headers for API requests */
+async function getAuthHeaders(): Promise<HeadersInit> {
+ const headers: HeadersInit = {
+ "Content-Type": "application/json",
+ };
+
+ // Try Supabase session first
+ if (supabase) {
+ const { data: { session } } = await supabase.auth.getSession();
+ if (session?.access_token) {
+ headers["Authorization"] = `Bearer ${session.access_token}`;
+ return headers;
+ }
+ }
+
+ // Fall back to API key if available
+ const apiKey = getStoredApiKey();
+ if (apiKey) {
+ headers["X-Makima-API-Key"] = apiKey;
+ }
+
+ return headers;
+}
+
+/** Fetch with authentication headers */
+async function authFetch(url: string, options: RequestInit = {}): Promise<Response> {
+ const authHeaders = await getAuthHeaders();
+ const mergedHeaders = {
+ ...authHeaders,
+ ...options.headers,
+ };
+
+ return fetch(url, {
+ ...options,
+ headers: mergedHeaders,
+ });
+}
export const LISTEN_ENDPOINT = `${WS_BASE}/api/v1/listen`;
export const FILE_SUBSCRIBE_ENDPOINT = `${WS_BASE}/api/v1/files/subscribe`;
+export const TASK_SUBSCRIBE_ENDPOINT = `${WS_BASE}/api/v1/mesh/tasks/subscribe`;
export function getEnvironment(): Environment {
return env;
@@ -57,6 +123,8 @@ export type ChartType = "line" | "bar" | "pie" | "area";
export type BodyElement =
| { type: "heading"; level: number; text: string }
| { type: "paragraph"; text: string }
+ | { type: "code"; language?: string; content: string }
+ | { type: "list"; ordered: boolean; items: string[] }
| {
type: "chart";
chartType: ChartType;
@@ -145,6 +213,7 @@ export interface ChatRequest {
message: string;
model?: LlmModel;
history?: ChatMessage[];
+ focusedElementIndex?: number;
}
export interface ToolCallInfo {
@@ -179,7 +248,7 @@ export interface ChatResponse {
// File API functions
export async function listFiles(): Promise<FileListResponse> {
- const res = await fetch(`${API_BASE}/api/v1/files`);
+ const res = await authFetch(`${API_BASE}/api/v1/files`);
if (!res.ok) {
throw new Error(`Failed to list files: ${res.statusText}`);
}
@@ -187,7 +256,7 @@ export async function listFiles(): Promise<FileListResponse> {
}
export async function getFile(id: string): Promise<FileDetail> {
- const res = await fetch(`${API_BASE}/api/v1/files/${id}`);
+ const res = await authFetch(`${API_BASE}/api/v1/files/${id}`);
if (!res.ok) {
throw new Error(`Failed to get file: ${res.statusText}`);
}
@@ -195,9 +264,8 @@ export async function getFile(id: string): Promise<FileDetail> {
}
export async function createFile(data: CreateFileRequest): Promise<FileDetail> {
- const res = await fetch(`${API_BASE}/api/v1/files`, {
+ const res = await authFetch(`${API_BASE}/api/v1/files`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
@@ -210,9 +278,8 @@ export async function updateFile(
id: string,
data: UpdateFileRequest
): Promise<FileDetail> {
- const res = await fetch(`${API_BASE}/api/v1/files/${id}`, {
+ const res = await authFetch(`${API_BASE}/api/v1/files/${id}`, {
method: "PUT",
- headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
@@ -228,7 +295,7 @@ export async function updateFile(
}
export async function deleteFile(id: string): Promise<void> {
- const res = await fetch(`${API_BASE}/api/v1/files/${id}`, {
+ const res = await authFetch(`${API_BASE}/api/v1/files/${id}`, {
method: "DELETE",
});
if (!res.ok) {
@@ -241,7 +308,8 @@ export async function chatWithFile(
id: string,
message: string,
model?: LlmModel,
- history?: ChatMessage[]
+ history?: ChatMessage[],
+ focusedElementIndex?: number
): Promise<ChatResponse> {
const body: ChatRequest = { message };
if (model) {
@@ -250,9 +318,11 @@ export async function chatWithFile(
if (history && history.length > 0) {
body.history = history;
}
- const res = await fetch(`${API_BASE}/api/v1/files/${id}/chat`, {
+ if (focusedElementIndex !== undefined) {
+ body.focusedElementIndex = focusedElementIndex;
+ }
+ const res = await authFetch(`${API_BASE}/api/v1/files/${id}/chat`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
@@ -294,7 +364,7 @@ export interface RestoreVersionRequest {
// Version history API functions
export async function listFileVersions(fileId: string): Promise<FileVersionListResponse> {
- const res = await fetch(`${API_BASE}/api/v1/files/${fileId}/versions`);
+ const res = await authFetch(`${API_BASE}/api/v1/files/${fileId}/versions`);
if (!res.ok) {
throw new Error(`Failed to list versions: ${res.statusText}`);
}
@@ -302,7 +372,7 @@ export async function listFileVersions(fileId: string): Promise<FileVersionListR
}
export async function getFileVersion(fileId: string, version: number): Promise<FileVersion> {
- const res = await fetch(`${API_BASE}/api/v1/files/${fileId}/versions/${version}`);
+ const res = await authFetch(`${API_BASE}/api/v1/files/${fileId}/versions/${version}`);
if (!res.ok) {
throw new Error(`Failed to get version: ${res.statusText}`);
}
@@ -314,9 +384,8 @@ export async function restoreFileVersion(
targetVersion: number,
currentVersion: number
): Promise<FileDetail> {
- const res = await fetch(`${API_BASE}/api/v1/files/${fileId}/versions/restore`, {
+ const res = await authFetch(`${API_BASE}/api/v1/files/${fileId}/versions/restore`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetVersion, currentVersion }),
});
@@ -396,3 +465,827 @@ export type LlmVersionToolResult =
| { name: "read_version"; result: ReadVersionToolOutput }
| { name: "list_versions"; result: ListVersionsToolOutput }
| { name: "restore_version"; result: RestoreVersionToolOutput };
+
+// =============================================================================
+// Mesh/Task Types for Claude Code Orchestration
+// =============================================================================
+
+export type TaskStatus =
+ | "pending"
+ | "initializing"
+ | "starting"
+ | "running"
+ | "paused"
+ | "blocked"
+ | "done"
+ | "failed"
+ | "merged";
+
+export type MergeMode = "pr" | "auto" | "manual";
+
+/** Action to perform when a task completes successfully */
+export type CompletionAction = "none" | "branch" | "merge" | "pr";
+
+export type DaemonStatus = "connected" | "disconnected" | "unhealthy";
+
+export interface TaskSummary {
+ id: string;
+ parentTaskId: string | null;
+ depth: number;
+ name: string;
+ status: TaskStatus;
+ priority: number;
+ progressSummary: string | null;
+ subtaskCount: number;
+ version: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface Task {
+ id: string;
+ ownerId: string;
+ parentTaskId: string | null;
+ depth: number;
+ name: string;
+ description: string | null;
+ status: TaskStatus;
+ priority: number;
+ plan: string;
+
+ // Daemon/container info
+ daemonId: string | null;
+ containerId: string | null;
+ overlayPath: string | null;
+
+ // Repository info
+ repositoryUrl: string | null;
+ baseBranch: string | null;
+ targetBranch: string | null;
+
+ // Merge settings
+ mergeMode: MergeMode | null;
+ prUrl: string | null;
+
+ // Completion action settings
+ /** Path to user's local repository for completion actions */
+ targetRepoPath: string | null;
+ /** Action on completion: "none", "branch", "merge", "pr" */
+ completionAction: CompletionAction | null;
+
+ // Progress tracking
+ progressSummary: string | null;
+ lastOutput: string | null;
+ errorMessage: string | null;
+
+ // Timestamps
+ startedAt: string | null;
+ completedAt: string | null;
+ version: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface TaskWithSubtasks extends Task {
+ subtasks: TaskSummary[];
+}
+
+export interface TaskListResponse {
+ tasks: TaskSummary[];
+ total: number;
+}
+
+export interface CreateTaskRequest {
+ name: string;
+ description?: string;
+ plan: string;
+ parentTaskId?: string;
+ priority?: number;
+ repositoryUrl?: string;
+ baseBranch?: string;
+ targetBranch?: string;
+ mergeMode?: MergeMode;
+ /** Path to user's local repository for completion actions */
+ targetRepoPath?: string;
+ /** Action on completion: "none", "branch", "merge", "pr" */
+ completionAction?: CompletionAction;
+}
+
+export interface UpdateTaskRequest {
+ name?: string;
+ description?: string;
+ plan?: string;
+ status?: TaskStatus;
+ priority?: number;
+ progressSummary?: string;
+ lastOutput?: string;
+ errorMessage?: string;
+ mergeMode?: MergeMode;
+ prUrl?: string;
+ /** Path to user's local repository for completion actions */
+ targetRepoPath?: string;
+ /** Action on completion: "none", "branch", "merge", "pr" */
+ completionAction?: CompletionAction;
+ version?: number;
+}
+
+export interface TaskEvent {
+ id: string;
+ taskId: string;
+ eventType: string;
+ previousStatus: string | null;
+ newStatus: string | null;
+ eventData: Record<string, unknown> | null;
+ createdAt: string;
+}
+
+export interface TaskEventListResponse {
+ events: TaskEvent[];
+ total: number;
+}
+
+export interface Daemon {
+ id: string;
+ ownerId: string;
+ connectionId: string;
+ hostname: string | null;
+ machineId: string | null;
+ maxConcurrentTasks: number;
+ currentTaskCount: number;
+ status: DaemonStatus;
+ lastHeartbeatAt: string;
+ connectedAt: string;
+ disconnectedAt: string | null;
+}
+
+export interface DaemonListResponse {
+ daemons: Daemon[];
+ total: number;
+}
+
+// Mesh API functions
+export async function listTasks(): Promise<TaskListResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks`);
+ if (!res.ok) {
+ throw new Error(`Failed to list tasks: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function getTask(id: string): Promise<TaskWithSubtasks> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${id}`);
+ if (!res.ok) {
+ throw new Error(`Failed to get task: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function createTask(data: CreateTaskRequest): Promise<Task> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks`, {
+ method: "POST",
+ body: JSON.stringify(data),
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to create task: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function updateTask(
+ id: string,
+ data: UpdateTaskRequest
+): Promise<Task> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${id}`, {
+ method: "PUT",
+ body: JSON.stringify(data),
+ });
+
+ if (res.status === 409) {
+ const conflict = (await res.json()) as ConflictErrorResponse;
+ throw new VersionConflictError(conflict);
+ }
+
+ if (!res.ok) {
+ throw new Error(`Failed to update task: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function deleteTask(id: string): Promise<void> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${id}`, {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to delete task: ${res.statusText}`);
+ }
+}
+
+export async function startTask(id: string): Promise<Task> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${id}/start`, {
+ method: "POST",
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to start task: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function stopTask(id: string): Promise<Task> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${id}/stop`, {
+ method: "POST",
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to stop task: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+export interface SendMessageResponse {
+ success: boolean;
+ taskId: string;
+ messageLength: number;
+}
+
+/**
+ * Send a message to a running task's stdin.
+ * This can be used to provide input to Claude Code when it's waiting for user input,
+ * or to inject context/instructions into a running task.
+ */
+export async function sendTaskMessage(
+ taskId: string,
+ message: string
+): Promise<SendMessageResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/message`, {
+ method: "POST",
+ body: JSON.stringify({ message }),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to send message: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+export interface RetryCompletionResponse {
+ success: boolean;
+ taskId: string;
+ action: string;
+ targetRepoPath: string;
+ message: string;
+}
+
+/**
+ * Retry completion action for a completed task.
+ * This allows retrying a completion action (push branch, merge, create PR)
+ * after filling in the target_repo_path if it wasn't set when the task completed.
+ */
+export async function retryCompletionAction(
+ taskId: string
+): Promise<RetryCompletionResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/retry-completion`, {
+ method: "POST",
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to retry completion action: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+/** A suggested directory from a connected daemon */
+export interface DaemonDirectory {
+ /** Path to the directory */
+ path: string;
+ /** Display label for the directory */
+ label: string;
+ /** Type of directory: "working", "makima", "worktrees" */
+ directoryType: string;
+ /** Daemon hostname this directory is from */
+ hostname: string | null;
+ /** Whether the directory already exists (for validation) */
+ exists?: boolean;
+}
+
+export interface DaemonDirectoriesResponse {
+ directories: DaemonDirectory[];
+}
+
+/**
+ * Get suggested directories from connected daemons.
+ * These can be used as target_repo_path suggestions for completion actions.
+ */
+export async function getDaemonDirectories(): Promise<DaemonDirectoriesResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/daemons/directories`);
+ if (!res.ok) {
+ throw new Error(`Failed to get daemon directories: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/** Request to clone a worktree */
+export interface CloneWorktreeRequest {
+ targetDir: string;
+}
+
+/** Response from clone worktree */
+export interface CloneWorktreeResponse {
+ status: string;
+ taskId: string;
+ targetDir: string;
+}
+
+/**
+ * Clone a task's worktree to a target directory.
+ */
+export async function cloneWorktree(
+ taskId: string,
+ targetDir: string
+): Promise<CloneWorktreeResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/clone`, {
+ method: "POST",
+ body: JSON.stringify({ targetDir }),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to clone worktree: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+/** Request to check if target exists */
+export interface CheckTargetExistsRequest {
+ targetDir: string;
+}
+
+/** Response from check target exists */
+export interface CheckTargetExistsResponse {
+ status: string;
+ taskId: string;
+ targetDir: string;
+}
+
+/**
+ * Check if a target directory exists.
+ */
+export async function checkTargetExists(
+ taskId: string,
+ targetDir: string
+): Promise<CheckTargetExistsResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/check-target`, {
+ method: "POST",
+ body: JSON.stringify({ targetDir }),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to check target: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function listSubtasks(taskId: string): Promise<TaskListResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/subtasks`);
+ if (!res.ok) {
+ throw new Error(`Failed to list subtasks: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function listTaskEvents(
+ taskId: string
+): Promise<TaskEventListResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/events`);
+ if (!res.ok) {
+ throw new Error(`Failed to list task events: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/** A single output entry from a Claude Code task */
+export interface TaskOutputEntry {
+ id: string;
+ taskId: string;
+ /** Message type: "assistant", "tool_use", "tool_result", "result", "system", "error", "raw" */
+ messageType: string;
+ /** Main text content */
+ content: string;
+ /** Tool name if tool_use message */
+ toolName?: string;
+ /** Tool input JSON if tool_use message */
+ toolInput?: Record<string, unknown>;
+ /** Whether tool result was an error */
+ isError?: boolean;
+ /** Cost in USD if result message */
+ costUsd?: number;
+ /** Duration in ms if result message */
+ durationMs?: number;
+ /** Timestamp when this output was recorded */
+ createdAt: string;
+}
+
+/** Response from the task output endpoint */
+export interface TaskOutputResponse {
+ entries: TaskOutputEntry[];
+ total: number;
+ taskId: string;
+}
+
+/**
+ * Get task output history.
+ * Retrieves all recorded output from a task's Claude Code process.
+ * Use this to fetch missed output when subscribing late or reconnecting.
+ */
+export async function getTaskOutput(
+ taskId: string
+): Promise<TaskOutputResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/output`);
+ if (!res.ok) {
+ throw new Error(`Failed to get task output: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function listDaemons(): Promise<DaemonListResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/daemons`);
+ if (!res.ok) {
+ throw new Error(`Failed to list daemons: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function getDaemon(id: string): Promise<Daemon> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/daemons/${id}`);
+ if (!res.ok) {
+ throw new Error(`Failed to get daemon: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+// =============================================================================
+// Mesh Chat Types for Task Orchestration
+// =============================================================================
+
+export interface MeshChatMessage {
+ role: "user" | "assistant";
+ content: string;
+}
+
+export interface MeshChatRequest {
+ message: string;
+ model?: LlmModel;
+ history?: MeshChatMessage[];
+}
+
+export interface MeshToolCallInfo {
+ name: string;
+ result: {
+ success: boolean;
+ message: string;
+ };
+}
+
+export interface MeshChatResponse {
+ response: string;
+ toolCalls: MeshToolCallInfo[];
+ pendingQuestions?: UserQuestion[];
+}
+
+// Mesh Chat API functions
+
+// Top-level mesh chat (no specific task context)
+export async function chatWithMesh(
+ message: string,
+ model?: LlmModel,
+ history?: MeshChatMessage[]
+): Promise<MeshChatResponse> {
+ const body: MeshChatRequest = { message };
+ if (model) {
+ body.model = model;
+ }
+ if (history && history.length > 0) {
+ body.history = history;
+ }
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/chat`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Mesh chat failed: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+// Task-scoped mesh chat
+export async function chatWithTask(
+ taskId: string,
+ message: string,
+ model?: LlmModel,
+ history?: MeshChatMessage[]
+): Promise<MeshChatResponse> {
+ const body: MeshChatRequest = { message };
+ if (model) {
+ body.model = model;
+ }
+ if (history && history.length > 0) {
+ body.history = history;
+ }
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/chat`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Mesh chat failed: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+// =============================================================================
+// Mesh Chat History Types
+// =============================================================================
+
+export type MeshChatContextType = "mesh" | "task" | "subtask";
+
+export interface MeshChatContext {
+ type: MeshChatContextType;
+ taskId?: string;
+ parentTaskId?: string;
+}
+
+export interface MeshChatMessageRecord {
+ id: string;
+ conversationId: string;
+ role: "user" | "assistant" | "error";
+ content: string;
+ contextType: MeshChatContextType;
+ contextTaskId: string | null;
+ toolCalls: MeshToolCallInfo[] | null;
+ pendingQuestions: UserQuestion[] | null;
+ createdAt: string;
+}
+
+export interface MeshChatHistoryResponse {
+ conversationId: string;
+ messages: MeshChatMessageRecord[];
+}
+
+export interface MeshChatWithContextRequest {
+ message: string;
+ model?: LlmModel;
+ contextType?: MeshChatContextType;
+ contextTaskId?: string;
+}
+
+// =============================================================================
+// Mesh Chat History API Functions
+// =============================================================================
+
+/**
+ * Get the current chat history from the database
+ */
+export async function getMeshChatHistory(): Promise<MeshChatHistoryResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/chat/history`);
+ if (!res.ok) {
+ throw new Error(`Failed to get chat history: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Clear chat history (archives current conversation, starts new one)
+ */
+export async function clearMeshChatHistory(): Promise<{ success: boolean; conversationId: string }> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/chat/history`, {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to clear chat history: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Chat with mesh using context (new approach with DB history)
+ */
+export async function chatWithMeshContext(
+ message: string,
+ context: MeshChatContext,
+ model?: LlmModel
+): Promise<MeshChatResponse> {
+ const body: MeshChatWithContextRequest = {
+ message,
+ contextType: context.type,
+ };
+
+ if (model) {
+ body.model = model;
+ }
+
+ // Set contextTaskId based on context type
+ if (context.type === "task" && context.taskId) {
+ body.contextTaskId = context.taskId;
+ } else if (context.type === "subtask" && context.taskId) {
+ body.contextTaskId = context.taskId;
+ }
+
+ // Use top-level endpoint (it now loads history from DB)
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/chat`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Mesh chat failed: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+// =============================================================================
+// API Key Management
+// =============================================================================
+
+export interface ApiKeyInfo {
+ id: string;
+ prefix: string;
+ name: string | null;
+ lastUsedAt: string | null;
+ createdAt: string;
+}
+
+export interface CreateApiKeyResponse {
+ id: string;
+ key: string;
+ prefix: string;
+ name: string | null;
+ createdAt: string;
+}
+
+export interface RefreshApiKeyResponse {
+ id: string;
+ key: string;
+ prefix: string;
+ name: string | null;
+ createdAt: string;
+ previousKeyRevoked: boolean;
+}
+
+export interface RevokeApiKeyResponse {
+ message: string;
+ revokedKeyPrefix: string;
+}
+
+/**
+ * Get information about the current active API key.
+ */
+export async function getApiKey(): Promise<ApiKeyInfo | null> {
+ const res = await authFetch(`${API_BASE}/api/v1/auth/api-keys`);
+ if (res.status === 404) {
+ return null;
+ }
+ if (!res.ok) {
+ throw new Error(`Failed to get API key: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Create a new API key.
+ */
+export async function createApiKey(name?: string): Promise<CreateApiKeyResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/auth/api-keys`, {
+ method: "POST",
+ body: JSON.stringify({ name }),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to create API key: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Refresh (rotate) the current API key.
+ */
+export async function refreshApiKey(name?: string): Promise<RefreshApiKeyResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/auth/api-keys/refresh`, {
+ method: "POST",
+ body: JSON.stringify({ name }),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to refresh API key: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Revoke the current API key.
+ */
+export async function revokeApiKey(): Promise<RevokeApiKeyResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/auth/api-keys`, {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to revoke API key: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+// =============================================================================
+// User Account Management
+// =============================================================================
+
+export interface ChangePasswordRequest {
+ currentPassword: string;
+ newPassword: string;
+}
+
+export interface ChangePasswordResponse {
+ success: boolean;
+ message: string;
+}
+
+export interface ChangeEmailRequest {
+ password: string;
+ newEmail: string;
+}
+
+export interface ChangeEmailResponse {
+ success: boolean;
+ message: string;
+ verificationSent: boolean;
+}
+
+export interface DeleteAccountRequest {
+ password: string;
+ confirmation: string;
+}
+
+export interface DeleteAccountResponse {
+ success: boolean;
+ message: string;
+}
+
+/**
+ * Change the current user's password.
+ * Requires current password verification.
+ */
+export async function changePassword(
+ currentPassword: string,
+ newPassword: string
+): Promise<ChangePasswordResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/users/me/password`, {
+ method: "PUT",
+ body: JSON.stringify({ currentPassword, newPassword }),
+ });
+ if (!res.ok) {
+ const errorData = await res.json().catch(() => null);
+ const errorMessage = errorData?.message || res.statusText;
+ throw new Error(errorMessage);
+ }
+ return res.json();
+}
+
+/**
+ * Change the current user's email address.
+ * Requires password verification.
+ */
+export async function changeEmail(
+ password: string,
+ newEmail: string
+): Promise<ChangeEmailResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/users/me/email`, {
+ method: "PUT",
+ body: JSON.stringify({ password, newEmail }),
+ });
+ if (!res.ok) {
+ const errorData = await res.json().catch(() => null);
+ const errorMessage = errorData?.message || res.statusText;
+ throw new Error(errorMessage);
+ }
+ return res.json();
+}
+
+/**
+ * Delete the current user's account.
+ * Requires password verification and email confirmation.
+ */
+export async function deleteAccount(
+ password: string,
+ confirmation: string
+): Promise<DeleteAccountResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/users/me`, {
+ method: "DELETE",
+ body: JSON.stringify({ password, confirmation }),
+ });
+ if (!res.ok) {
+ const errorData = await res.json().catch(() => null);
+ const errorMessage = errorData?.message || res.statusText;
+ throw new Error(errorMessage);
+ }
+ return res.json();
+}
diff --git a/makima/frontend/src/lib/supabase.ts b/makima/frontend/src/lib/supabase.ts
new file mode 100644
index 0000000..eedff10
--- /dev/null
+++ b/makima/frontend/src/lib/supabase.ts
@@ -0,0 +1,26 @@
+import { createClient, SupabaseClient, Session, User } from "@supabase/supabase-js";
+
+// Supabase configuration from environment variables
+const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string | undefined;
+const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY as string | undefined;
+
+// Only create client if configuration is available
+let supabaseClient: SupabaseClient | null = null;
+
+if (SUPABASE_URL && SUPABASE_ANON_KEY) {
+ supabaseClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
+ auth: {
+ autoRefreshToken: true,
+ persistSession: true,
+ detectSessionInUrl: true,
+ },
+ });
+}
+
+export const supabase = supabaseClient;
+
+export function isAuthConfigured(): boolean {
+ return supabaseClient !== null;
+}
+
+export type { Session, User };
diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx
index 874ab1a..d4ca13a 100644
--- a/makima/frontend/src/main.tsx
+++ b/makima/frontend/src/main.tsx
@@ -2,21 +2,74 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter, Routes, Route } from "react-router";
import "./index.css";
+import { AuthProvider } from "./contexts/AuthContext";
import { GridOverlay } from "./components/GridOverlay";
+import { ProtectedRoute } from "./components/ProtectedRoute";
import HomePage from "./routes/_index";
import ListenPage from "./routes/listen";
import FilesPage from "./routes/files";
+import MeshPage from "./routes/mesh";
+import LoginPage from "./routes/login";
+import SettingsPage from "./routes/settings";
createRoot(document.getElementById("root")!).render(
<StrictMode>
- <BrowserRouter>
- <GridOverlay />
- <Routes>
- <Route path="/" element={<HomePage />} />
- <Route path="/listen" element={<ListenPage />} />
- <Route path="/files" element={<FilesPage />} />
- <Route path="/files/:id" element={<FilesPage />} />
- </Routes>
- </BrowserRouter>
+ <AuthProvider>
+ <BrowserRouter>
+ <GridOverlay />
+ <Routes>
+ <Route path="/" element={<HomePage />} />
+ <Route path="/login" element={<LoginPage />} />
+ <Route
+ path="/listen"
+ element={
+ <ProtectedRoute>
+ <ListenPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
+ path="/files"
+ element={
+ <ProtectedRoute>
+ <FilesPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
+ path="/files/:id"
+ element={
+ <ProtectedRoute>
+ <FilesPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
+ path="/mesh"
+ element={
+ <ProtectedRoute>
+ <MeshPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
+ path="/mesh/:id"
+ element={
+ <ProtectedRoute>
+ <MeshPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
+ path="/settings"
+ element={
+ <ProtectedRoute>
+ <SettingsPage />
+ </ProtectedRoute>
+ }
+ />
+ </Routes>
+ </BrowserRouter>
+ </AuthProvider>
</StrictMode>
);
diff --git a/makima/frontend/src/routes/_index.tsx b/makima/frontend/src/routes/_index.tsx
index 4c3c2c0..7084c2e 100644
--- a/makima/frontend/src/routes/_index.tsx
+++ b/makima/frontend/src/routes/_index.tsx
@@ -13,18 +13,18 @@ export default function HomePage() {
</div>
<span className="inline-block px-2 py-1 border border-[#3f6fb3] bg-[#0f1c2f] text-[#9bc3ff] font-mono text-xs tracking-wide uppercase mb-3">
- Listening System
+ Control System
</span>
<h2 className="m-0 mb-3 text-xl text-[#f0f5ff] tracking-wide">
- Mesh Listening Lattice
+ Mesh Orchestration Platform
</h2>
<p className="my-2 text-[#e4edff]">
- Makima is a mesh listening lattice for contested domains, delivering
- live audio surveillance, detection, and analysis in one persistent layer.
+ Makima is a control system for orchestrating distributed daemon meshes,
+ coordinating concurrent execution across distinct domains.
</p>
<p className="my-2 text-[#e4edff]">
- Dynamic telemetry for detection, orchestration, and mission-critical
- decisions. Real-time transcription with speaker diarization.
+ Unified command interface for spawning, monitoring, and directing
+ worker daemons. Real-time task coordination with overlay management.
</p>
</section>
</main>
diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx
index 0d870f7..0645b85 100644
--- a/makima/frontend/src/routes/files.tsx
+++ b/makima/frontend/src/routes/files.tsx
@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useRef } from "react";
import { useParams, useNavigate } from "react-router";
import { Masthead } from "../components/Masthead";
import { FileList } from "../components/files/FileList";
-import { FileDetail } from "../components/files/FileDetail";
+import { FileDetail, type FocusedElement } from "../components/files/FileDetail";
import { CliInput } from "../components/files/CliInput";
import { ConflictNotification } from "../components/files/ConflictNotification";
import { UpdateNotification } from "../components/files/UpdateNotification";
@@ -12,7 +12,8 @@ import {
useFileSubscription,
type FileUpdateEvent,
} from "../hooks/useFileSubscription";
-import type { FileDetail as FileDetailType, BodyElement } from "../lib/api";
+import type { FileDetail as FileDetailType, BodyElement, Task } from "../lib/api";
+import { createTask } from "../lib/api";
export default function FilesPage() {
const { id } = useParams<{ id: string }>();
@@ -23,6 +24,9 @@ export default function FilesPage() {
const [creating, setCreating] = useState(false);
const [remoteUpdate, setRemoteUpdate] = useState<FileUpdateEvent | null>(null);
const [remoteFileData, setRemoteFileData] = useState<FileDetailType | null>(null);
+ const [focusedElement, setFocusedElement] = useState<FocusedElement | null>(null);
+ const [suggestedPrompt, setSuggestedPrompt] = useState<string | null>(null);
+ const [createdTask, setCreatedTask] = useState<Task | null>(null);
const pendingUpdateRef = useRef(false);
// Track the last version we sent to detect our own updates
const lastSentVersionRef = useRef<number | null>(null);
@@ -85,6 +89,7 @@ export default function FilesPage() {
currentVersionRef.current = null;
setRemoteUpdate(null);
setRemoteFileData(null);
+ setFocusedElement(null);
fetchFile(id).then((detail) => {
if (detail) {
currentVersionRef.current = detail.version;
@@ -285,6 +290,276 @@ export default function FilesPage() {
[fileDetail, id, editFile, updateHasLocalChanges]
);
+ // Element action handlers for context menu
+ const handleBodyElementDelete = useCallback(
+ async (index: number) => {
+ if (fileDetail && id) {
+ const newBody = fileDetail.body.filter((_, i) => i !== index);
+
+ // Update local state immediately
+ setFileDetail({
+ ...fileDetail,
+ body: newBody,
+ });
+ updateHasLocalChanges(true);
+
+ // Clear focus if deleting focused element
+ if (focusedElement?.index === index) {
+ setFocusedElement(null);
+ } else if (focusedElement && focusedElement.index > index) {
+ // Adjust focus index if deleting an element before it
+ setFocusedElement({
+ ...focusedElement,
+ index: focusedElement.index - 1,
+ });
+ }
+
+ // Save to backend
+ pendingUpdateRef.current = true;
+ lastSentVersionRef.current = fileDetail.version;
+ try {
+ const result = await editFile(id, { body: newBody, version: fileDetail.version });
+ if (result) {
+ lastSavedVersionRef.current = result.version;
+ currentVersionRef.current = result.version;
+ setFileDetail(result);
+ updateHasLocalChanges(false);
+ }
+ } finally {
+ pendingUpdateRef.current = false;
+ lastSentVersionRef.current = null;
+ }
+ }
+ },
+ [fileDetail, id, editFile, updateHasLocalChanges, focusedElement]
+ );
+
+ const handleBodyElementDuplicate = useCallback(
+ async (index: number) => {
+ if (fileDetail && id) {
+ const elementToDuplicate = fileDetail.body[index];
+ if (!elementToDuplicate) return;
+
+ const newBody = [...fileDetail.body];
+ // Insert duplicate after the original
+ newBody.splice(index + 1, 0, { ...elementToDuplicate });
+
+ // Update local state immediately
+ setFileDetail({
+ ...fileDetail,
+ body: newBody,
+ });
+ updateHasLocalChanges(true);
+
+ // Adjust focus index if duplicating before focused element
+ if (focusedElement && focusedElement.index > index) {
+ setFocusedElement({
+ ...focusedElement,
+ index: focusedElement.index + 1,
+ });
+ }
+
+ // Save to backend
+ pendingUpdateRef.current = true;
+ lastSentVersionRef.current = fileDetail.version;
+ try {
+ const result = await editFile(id, { body: newBody, version: fileDetail.version });
+ if (result) {
+ lastSavedVersionRef.current = result.version;
+ currentVersionRef.current = result.version;
+ setFileDetail(result);
+ updateHasLocalChanges(false);
+ }
+ } finally {
+ pendingUpdateRef.current = false;
+ lastSentVersionRef.current = null;
+ }
+ }
+ },
+ [fileDetail, id, editFile, updateHasLocalChanges, focusedElement]
+ );
+
+ const handleFocusElement = useCallback((element: FocusedElement | null) => {
+ setFocusedElement(element);
+ }, []);
+
+ const handleClearFocus = useCallback(() => {
+ setFocusedElement(null);
+ }, []);
+
+ // Convert element to a different type
+ const handleConvertElement = useCallback(
+ async (index: number, toType: string) => {
+ if (!fileDetail || !id) return;
+
+ const element = fileDetail.body[index];
+ if (!element) return;
+
+ // Extract text content from current element
+ let textContent = "";
+ switch (element.type) {
+ case "heading":
+ case "paragraph":
+ textContent = element.text;
+ break;
+ case "code":
+ textContent = element.content;
+ break;
+ case "list":
+ textContent = element.items.join("\n");
+ break;
+ default:
+ return; // Can't convert charts/images
+ }
+
+ // Create new element based on target type
+ let newElement: BodyElement;
+ if (toType === "paragraph") {
+ newElement = { type: "paragraph", text: textContent };
+ } else if (toType === "list_unordered") {
+ const items = textContent.split("\n").filter(line => line.trim());
+ newElement = { type: "list", ordered: false, items };
+ } else if (toType === "list_ordered") {
+ const items = textContent.split("\n").filter(line => line.trim());
+ newElement = { type: "list", ordered: true, items };
+ } else if (toType === "code") {
+ newElement = { type: "code", content: textContent };
+ } else if (toType.startsWith("heading_")) {
+ const level = parseInt(toType.replace("heading_", ""), 10) as 1 | 2 | 3 | 4 | 5 | 6;
+ newElement = { type: "heading", level, text: textContent };
+ } else {
+ return; // Unknown type
+ }
+
+ const newBody = [...fileDetail.body];
+ newBody[index] = newElement;
+
+ // Update local state
+ setFileDetail({ ...fileDetail, body: newBody });
+ updateHasLocalChanges(true);
+
+ // Update focus if this element was focused
+ if (focusedElement?.index === index) {
+ setFocusedElement({
+ index,
+ type: newElement.type,
+ preview: textContent.slice(0, 50) + (textContent.length > 50 ? "..." : ""),
+ });
+ }
+
+ // Save to backend
+ pendingUpdateRef.current = true;
+ lastSentVersionRef.current = fileDetail.version;
+ try {
+ const result = await editFile(id, { body: newBody, version: fileDetail.version });
+ if (result) {
+ lastSavedVersionRef.current = result.version;
+ currentVersionRef.current = result.version;
+ setFileDetail(result);
+ updateHasLocalChanges(false);
+ }
+ } finally {
+ pendingUpdateRef.current = false;
+ lastSentVersionRef.current = null;
+ }
+ },
+ [fileDetail, id, editFile, updateHasLocalChanges, focusedElement]
+ );
+
+ // Generate from element - focus on it and pre-fill a prompt
+ const handleGenerateFromElement = useCallback(
+ (index: number, action: string) => {
+ if (!fileDetail) return;
+
+ const element = fileDetail.body[index];
+ if (!element) return;
+
+ // Get preview text
+ let preview = "";
+ switch (element.type) {
+ case "heading":
+ case "paragraph":
+ preview = element.text.slice(0, 50);
+ break;
+ case "code":
+ preview = element.content.slice(0, 50);
+ break;
+ case "list":
+ preview = element.items[0]?.slice(0, 40) || "";
+ break;
+ default:
+ preview = "Element";
+ }
+
+ // Focus on the element
+ setFocusedElement({
+ index,
+ type: element.type,
+ preview: preview + (preview.length >= 50 ? "..." : ""),
+ });
+
+ // Set suggested prompt based on action
+ let prompt = "";
+ switch (action) {
+ case "elaborate":
+ prompt = "Elaborate and expand on this content";
+ break;
+ case "summarize":
+ prompt = "Summarize this content";
+ break;
+ case "extract_actions":
+ prompt = "Extract action items from this content";
+ break;
+ }
+ setSuggestedPrompt(prompt);
+ },
+ [fileDetail]
+ );
+
+ // Create a mesh task from an element
+ const handleCreateTaskFromElement = useCallback(
+ async (index: number, selectedText?: string) => {
+ if (!fileDetail) return;
+
+ const element = fileDetail.body[index];
+ if (!element) return;
+
+ // Get the content to use as task plan
+ let content = selectedText || "";
+ if (!content) {
+ switch (element.type) {
+ case "heading":
+ case "paragraph":
+ content = element.text;
+ break;
+ case "code":
+ content = element.content;
+ break;
+ case "list":
+ content = element.items.join("\n");
+ break;
+ default:
+ content = "Task from file element";
+ }
+ }
+
+ // Create a task name from the content
+ const name = content.slice(0, 60) + (content.length > 60 ? "..." : "");
+
+ try {
+ const task = await createTask({
+ name,
+ plan: content,
+ description: `Created from ${fileDetail.name}`,
+ });
+ setCreatedTask(task);
+ } catch (err) {
+ console.error("Failed to create task:", err);
+ }
+ },
+ [fileDetail]
+ );
+
const handleCreate = useCallback(async () => {
if (creating) return;
setCreating(true);
@@ -301,6 +576,28 @@ export default function FilesPage() {
}
}, [creating, saveFile, navigate]);
+ const handleUploadMarkdown = useCallback(async (name: string, body: BodyElement[]) => {
+ if (creating) return;
+ setCreating(true);
+ try {
+ const newFile = await saveFile({
+ name,
+ transcript: [],
+ });
+ if (newFile) {
+ // Update with the parsed body
+ const updated = await editFile(newFile.id, { body, version: newFile.version });
+ if (updated) {
+ navigate(`/files/${updated.id}`);
+ } else {
+ navigate(`/files/${newFile.id}`);
+ }
+ }
+ } finally {
+ setCreating(false);
+ }
+ }, [creating, saveFile, editFile, navigate]);
+
// Conflict resolution handlers
const handleConflictReload = useCallback(async () => {
if (id) {
@@ -381,9 +678,16 @@ export default function FilesPage() {
onDelete={handleDelete}
onBodyElementUpdate={handleBodyElementUpdate}
onBodyReorder={handleBodyReorder}
+ onBodyElementDelete={handleBodyElementDelete}
+ onBodyElementDuplicate={handleBodyElementDuplicate}
+ onConvertElement={handleConvertElement}
+ onGenerateFromElement={handleGenerateFromElement}
+ onCreateTaskFromElement={handleCreateTaskFromElement}
onEditingChange={updateIsActivelyEditing}
hasPendingRemoteUpdate={!!remoteUpdate}
onOverwrite={handleRemoteUpdateDismiss}
+ focusedElement={focusedElement}
+ onFocusElement={handleFocusElement}
versions={versions}
versionsLoading={versionsLoading}
selectedVersion={selectedVersion}
@@ -395,7 +699,14 @@ export default function FilesPage() {
/>
</div>
<div className="shrink-0">
- <CliInput fileId={id} onUpdate={handleBodyUpdate} />
+ <CliInput
+ fileId={id}
+ onUpdate={handleBodyUpdate}
+ focusedElement={focusedElement}
+ onClearFocus={handleClearFocus}
+ suggestedPrompt={suggestedPrompt}
+ onClearSuggestedPrompt={() => setSuggestedPrompt(null)}
+ />
</div>
</div>
) : id && detailLoading ? (
@@ -409,6 +720,7 @@ export default function FilesPage() {
onSelect={handleSelectFile}
onDelete={handleDelete}
onCreate={handleCreate}
+ onUploadMarkdown={handleUploadMarkdown}
/>
)}
</main>
@@ -432,6 +744,38 @@ export default function FilesPage() {
onDismiss={handleRemoteUpdateDismiss}
/>
)}
+
+ {/* Task created notification */}
+ {createdTask && (
+ <div className="fixed bottom-4 right-4 z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] p-4 shadow-lg max-w-sm">
+ <div className="flex items-start gap-3">
+ <span className="text-[#75aafc] text-lg">@</span>
+ <div className="flex-1">
+ <p className="font-mono text-xs text-[#9bc3ff] mb-1">Task created</p>
+ <p className="font-mono text-sm text-white truncate mb-3">
+ {createdTask.name}
+ </p>
+ <div className="flex gap-2">
+ <button
+ onClick={() => {
+ navigate(`/mesh/${createdTask.id}`);
+ setCreatedTask(null);
+ }}
+ className="px-3 py-1 font-mono text-xs text-[#0a1628] bg-[#75aafc] hover:bg-[#9bc3ff] transition-colors"
+ >
+ Go to task
+ </button>
+ <button
+ onClick={() => setCreatedTask(null)}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.3)] hover:border-[#75aafc] transition-colors"
+ >
+ Dismiss
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
</div>
);
}
diff --git a/makima/frontend/src/routes/login.tsx b/makima/frontend/src/routes/login.tsx
new file mode 100644
index 0000000..63b3af3
--- /dev/null
+++ b/makima/frontend/src/routes/login.tsx
@@ -0,0 +1,150 @@
+import { useState, type FormEvent } from "react";
+import { useNavigate } from "react-router";
+import { useAuth } from "../contexts/AuthContext";
+import { Masthead } from "../components/Masthead";
+
+type AuthMode = "signin" | "signup";
+
+export default function LoginPage() {
+ const navigate = useNavigate();
+ const { signIn, signUp, isAuthConfigured, isAuthenticated } = useAuth();
+
+ const [mode, setMode] = useState<AuthMode>("signin");
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState<string | null>(null);
+ const [loading, setLoading] = useState(false);
+ const [message, setMessage] = useState<string | null>(null);
+
+ // Redirect if already authenticated
+ if (isAuthenticated && isAuthConfigured) {
+ navigate("/mesh");
+ return null;
+ }
+
+ const handleEmailAuth = async (e: FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ setMessage(null);
+ setLoading(true);
+
+ try {
+ if (mode === "signin") {
+ const { error } = await signIn(email, password);
+ if (error) {
+ setError(error.message);
+ } else {
+ navigate("/mesh");
+ }
+ } else if (mode === "signup") {
+ const { error } = await signUp(email, password);
+ if (error) {
+ setError(error.message);
+ } else {
+ setMessage("Check your email for a confirmation link.");
+ }
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // If auth is not configured, show a message
+ if (!isAuthConfigured) {
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col">
+ <Masthead />
+ <main className="flex-1 flex items-center justify-center p-4">
+ <div className="w-full max-w-md text-center">
+ <h1 className="text-2xl font-bold mb-4">Authentication Required</h1>
+ <p className="text-zinc-400 mb-4">
+ Authentication is not configured. Please configure Supabase authentication to use this application.
+ </p>
+ <p className="text-zinc-500 text-sm">
+ For API access, use an API key in request headers instead.
+ </p>
+ </div>
+ </main>
+ </div>
+ );
+ }
+
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col">
+ <Masthead />
+ <main className="flex-1 flex items-center justify-center p-4">
+ <div className="w-full max-w-md">
+ <div className="text-center mb-8">
+ <h1 className="text-2xl font-bold mb-2">Sign In</h1>
+ <p className="text-zinc-400">
+ {mode === "signin" && "Sign in to your account"}
+ {mode === "signup" && "Create a new account"}
+ </p>
+ </div>
+
+ {/* Mode switcher */}
+ <div className="flex border-b border-zinc-800 mb-6">
+ <button
+ onClick={() => setMode("signin")}
+ className={`flex-1 py-2 text-sm transition-colors ${
+ mode === "signin"
+ ? "text-white border-b-2 border-white"
+ : "text-zinc-500 hover:text-zinc-300"
+ }`}
+ >
+ Sign In
+ </button>
+ <button
+ onClick={() => setMode("signup")}
+ className={`flex-1 py-2 text-sm transition-colors ${
+ mode === "signup"
+ ? "text-white border-b-2 border-white"
+ : "text-zinc-500 hover:text-zinc-300"
+ }`}
+ >
+ Sign Up
+ </button>
+ </div>
+
+ {/* Email/password form */}
+ <form onSubmit={handleEmailAuth} className="space-y-4">
+ <div>
+ <label className="block text-sm text-zinc-400 mb-1">Email</label>
+ <input
+ type="email"
+ value={email}
+ onChange={(e) => setEmail(e.target.value)}
+ placeholder="you@example.com"
+ className="w-full px-3 py-2 bg-zinc-900 border border-zinc-800 rounded text-white placeholder-zinc-600 focus:outline-none focus:border-zinc-600"
+ required
+ />
+ </div>
+ <div>
+ <label className="block text-sm text-zinc-400 mb-1">Password</label>
+ <input
+ type="password"
+ value={password}
+ onChange={(e) => setPassword(e.target.value)}
+ placeholder="********"
+ className="w-full px-3 py-2 bg-zinc-900 border border-zinc-800 rounded text-white placeholder-zinc-600 focus:outline-none focus:border-zinc-600"
+ required
+ minLength={6}
+ />
+ </div>
+
+ {error && <div className="text-red-400 text-sm">{error}</div>}
+ {message && <div className="text-green-400 text-sm">{message}</div>}
+
+ <button
+ type="submit"
+ disabled={loading}
+ className="w-full py-2 bg-white text-black rounded font-medium hover:bg-zinc-200 transition-colors disabled:opacity-50"
+ >
+ {loading ? "Loading..." : mode === "signin" ? "Sign In" : "Sign Up"}
+ </button>
+ </form>
+ </div>
+ </main>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx
new file mode 100644
index 0000000..852ce58
--- /dev/null
+++ b/makima/frontend/src/routes/mesh.tsx
@@ -0,0 +1,634 @@
+import { useState, useCallback, useEffect, useRef, useMemo, type MouseEvent } from "react";
+import { useParams, useNavigate } from "react-router";
+import { Masthead } from "../components/Masthead";
+import { TaskList } from "../components/mesh/TaskList";
+import { TaskDetail } from "../components/mesh/TaskDetail";
+import { TaskOutput } from "../components/mesh/TaskOutput";
+import { UnifiedMeshChatInput } from "../components/mesh/UnifiedMeshChatInput";
+import { useTasks } from "../hooks/useTasks";
+import { useTaskSubscription, type TaskUpdateEvent, type TaskOutputEvent } from "../hooks/useTaskSubscription";
+import type { TaskWithSubtasks, MeshChatContext } from "../lib/api";
+import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput } from "../lib/api";
+
+// View modes for the task detail page
+type ViewMode = "split" | "task" | "output";
+
+// Minimum panel widths (in pixels)
+const MIN_TASK_WIDTH = 300;
+const MIN_OUTPUT_WIDTH = 200;
+
+// TODO: Store task output in database for resuming from any device.
+// Currently only persisted in localStorage which is device-specific.
+
+// LocalStorage key prefix for task output
+const STORAGE_KEY_PREFIX_OUTPUT = "makima-task-output-";
+
+// Load persisted output from localStorage with deduplication
+function loadPersistedOutput(taskId: string): TaskOutputEvent[] {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY_PREFIX_OUTPUT + taskId);
+ if (!stored) return [];
+ const entries = JSON.parse(stored) as TaskOutputEvent[];
+
+ // Deduplicate consecutive identical entries (cleanup from previous bug)
+ const deduplicated: TaskOutputEvent[] = [];
+ for (const entry of entries) {
+ const last = deduplicated[deduplicated.length - 1];
+ if (
+ !last ||
+ last.messageType !== entry.messageType ||
+ last.content !== entry.content ||
+ last.toolName !== entry.toolName
+ ) {
+ deduplicated.push(entry);
+ }
+ }
+
+ // Save cleaned up version if we removed duplicates
+ if (deduplicated.length !== entries.length) {
+ savePersistedOutput(taskId, deduplicated);
+ }
+
+ return deduplicated;
+ } catch {
+ return [];
+ }
+}
+
+// Save output to localStorage
+function savePersistedOutput(taskId: string, entries: TaskOutputEvent[]): void {
+ try {
+ localStorage.setItem(STORAGE_KEY_PREFIX_OUTPUT + taskId, JSON.stringify(entries));
+ } catch {
+ // Ignore storage errors
+ }
+}
+
+// Clear output from localStorage
+function clearPersistedOutput(taskId: string): void {
+ try {
+ localStorage.removeItem(STORAGE_KEY_PREFIX_OUTPUT + taskId);
+ } catch {
+ // Ignore storage errors
+ }
+}
+
+export default function MeshPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const { tasks, loading, error, conflict, clearConflict, fetchTask, fetchTasks, editTask, removeTask, saveTask } = useTasks();
+ const [taskDetail, setTaskDetail] = useState<TaskWithSubtasks | null>(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [taskOutputEntries, setTaskOutputEntries] = useState<TaskOutputEvent[]>([]);
+ const [isStreaming, setIsStreaming] = useState(false);
+ // Track which subtask's output we're viewing (null = parent task)
+ const [viewingSubtaskId, setViewingSubtaskId] = useState<string | null>(null);
+ const [viewingSubtaskName, setViewingSubtaskName] = useState<string | null>(null);
+ // View mode for the split panel layout
+ const [viewMode, setViewMode] = useState<ViewMode>("split");
+ // Width of the task panel as a percentage (0-100)
+ const [taskPanelPercent, setTaskPanelPercent] = useState(66.67);
+ // Track resizing state
+ const [isResizing, setIsResizing] = useState(false);
+ const containerRef = useRef<HTMLDivElement>(null);
+ // Track which task we've loaded output for to avoid stale saves
+ const loadedTaskIdRef = useRef<string | null>(null);
+
+ // Handle task update events from WebSocket
+ const handleTaskUpdate = useCallback(async (event: TaskUpdateEvent) => {
+ // Refresh task list if we're viewing the list
+ if (!id) {
+ fetchTasks();
+ return;
+ }
+
+ // Check if this update is for the current task or one of its subtasks
+ const isCurrentTask = event.taskId === id;
+ const isSubtask = taskDetail?.subtasks.some((st) => st.id === event.taskId);
+
+ // Refresh task detail if the update is for current task or any subtask
+ // This ensures subtask status changes (e.g., when orchestrator starts them) are reflected
+ if (isCurrentTask || isSubtask) {
+ const updated = await fetchTask(id);
+ if (updated) {
+ setTaskDetail(updated);
+ }
+ }
+
+ // Update streaming state based on status for current task
+ if (isCurrentTask) {
+ setIsStreaming(event.status === "running");
+ }
+ }, [id, fetchTask, fetchTasks, taskDetail?.subtasks]);
+
+ // The task ID whose output we're currently viewing
+ const activeOutputTaskId = viewingSubtaskId || id;
+
+ // Handle task output events from WebSocket
+ const handleTaskOutput = useCallback((event: TaskOutputEvent) => {
+ // Only process output for the task we're currently viewing
+ if (event.taskId === activeOutputTaskId) {
+ setTaskOutputEntries((prev) => {
+ // Deduplicate by checking if last entry is identical
+ // This prevents duplicates from React StrictMode or WebSocket reconnects
+ const lastEntry = prev[prev.length - 1];
+ if (
+ lastEntry &&
+ lastEntry.messageType === event.messageType &&
+ lastEntry.content === event.content &&
+ lastEntry.toolName === event.toolName
+ ) {
+ return prev; // Skip duplicate
+ }
+ const newEntries = [...prev, event];
+ // Persist to localStorage
+ savePersistedOutput(event.taskId, newEntries);
+ return newEntries;
+ });
+ }
+ }, [activeOutputTaskId]);
+
+ // Handle user input sent to task - show immediately in output
+ const handleUserInput = useCallback((message: string) => {
+ if (!activeOutputTaskId) return;
+ const userEntry: TaskOutputEvent = {
+ taskId: activeOutputTaskId,
+ messageType: "user_input",
+ content: message,
+ isPartial: false,
+ };
+ setTaskOutputEntries((prev) => {
+ const newEntries = [...prev, userEntry];
+ savePersistedOutput(activeOutputTaskId, newEntries);
+ return newEntries;
+ });
+ }, [activeOutputTaskId]);
+
+ // Subscribe to task updates and output
+ // When viewing a subtask's output, subscribe to that instead of the parent
+ // Always subscribe to all updates so we see subtask status changes
+ const { connected } = useTaskSubscription({
+ taskId: id || null,
+ subscribeAll: true, // Always subscribe to all - needed to see subtask updates
+ subscribeOutput: !!activeOutputTaskId, // Subscribe to output when viewing a task
+ outputTaskId: activeOutputTaskId || undefined, // Which task's output to subscribe to
+ onUpdate: handleTaskUpdate,
+ onOutput: handleTaskOutput,
+ });
+
+ // Load persisted output when task or viewed subtask changes
+ useEffect(() => {
+ if (activeOutputTaskId) {
+ // First load from localStorage (instant, for local cache)
+ const persisted = loadPersistedOutput(activeOutputTaskId);
+ setTaskOutputEntries(persisted);
+ loadedTaskIdRef.current = activeOutputTaskId;
+
+ // Then fetch from API to get any output we missed
+ // (e.g., subtask was running before we started viewing it)
+ getTaskOutput(activeOutputTaskId)
+ .then((response) => {
+ if (response.entries.length > 0) {
+ setTaskOutputEntries((prev) => {
+ // API returns all historical entries in chronological order
+ const apiEntries = response.entries.map(entry => ({
+ taskId: entry.taskId,
+ messageType: entry.messageType,
+ content: entry.content,
+ toolName: entry.toolName,
+ toolInput: entry.toolInput,
+ isError: entry.isError,
+ costUsd: entry.costUsd,
+ durationMs: entry.durationMs,
+ isPartial: false,
+ }));
+
+ // If localStorage is empty, just use API data
+ if (prev.length === 0) {
+ savePersistedOutput(activeOutputTaskId, apiEntries);
+ return apiEntries;
+ }
+
+ // localStorage has user_input entries in correct positions - trust its order
+ // Only append API entries that we don't already have locally
+ const localKeys = new Set(prev.map(e => `${e.messageType}:${e.content}`));
+ const newFromApi = apiEntries.filter(e => !localKeys.has(`${e.messageType}:${e.content}`));
+
+ // Keep local order (has user_input in correct spots), append new API data
+ const merged = [...prev, ...newFromApi];
+ savePersistedOutput(activeOutputTaskId, merged);
+ return merged;
+ });
+ }
+ })
+ .catch((err) => {
+ console.error("Failed to fetch task output:", err);
+ });
+ } else {
+ setTaskOutputEntries([]);
+ loadedTaskIdRef.current = null;
+ }
+ setIsStreaming(false);
+ }, [activeOutputTaskId]);
+
+ // Reset subtask view when navigating to a different parent task
+ useEffect(() => {
+ setViewingSubtaskId(null);
+ setViewingSubtaskName(null);
+ }, [id]);
+
+ // Toggle viewing a subtask's output (for running subtasks)
+ const handleToggleSubtaskOutput = useCallback(
+ (subtaskId: string, subtaskName: string) => {
+ if (viewingSubtaskId === subtaskId) {
+ // Already viewing this subtask, switch back to parent
+ setViewingSubtaskId(null);
+ setViewingSubtaskName(null);
+ } else {
+ // Switch to viewing this subtask's output
+ setViewingSubtaskId(subtaskId);
+ setViewingSubtaskName(subtaskName);
+ }
+ },
+ [viewingSubtaskId]
+ );
+
+ // Load task detail when URL has an id
+ useEffect(() => {
+ if (id) {
+ setDetailLoading(true);
+ fetchTask(id).then((detail) => {
+ setTaskDetail(detail);
+ setDetailLoading(false);
+ });
+ } else {
+ setTaskDetail(null);
+ }
+ }, [id, fetchTask]);
+
+ const handleSelectTask = useCallback(
+ (taskId: string) => {
+ navigate(`/mesh/${taskId}`);
+ },
+ [navigate]
+ );
+
+ const handleBack = useCallback(() => {
+ // If viewing a subtask, go back to parent
+ if (taskDetail?.parentTaskId) {
+ navigate(`/mesh/${taskDetail.parentTaskId}`);
+ } else {
+ navigate("/mesh");
+ }
+ }, [navigate, taskDetail]);
+
+ const handleDelete = useCallback(
+ async (taskId: string) => {
+ if (confirm("Are you sure you want to delete this task?")) {
+ const success = await removeTask(taskId);
+ if (success && id === taskId) {
+ // If deleting current task, go back
+ if (taskDetail?.parentTaskId) {
+ navigate(`/mesh/${taskDetail.parentTaskId}`);
+ } else {
+ navigate("/mesh");
+ }
+ }
+ }
+ },
+ [removeTask, id, taskDetail, navigate]
+ );
+
+ const handleStart = useCallback(
+ async (taskId: string) => {
+ try {
+ const updated = await startTaskApi(taskId);
+ setTaskDetail((prev) => prev ? { ...prev, ...updated } : prev);
+ } catch (e) {
+ console.error("Failed to start task:", e);
+ alert(e instanceof Error ? e.message : "Failed to start task");
+ }
+ },
+ []
+ );
+
+ const handleStop = useCallback(
+ async (taskId: string) => {
+ try {
+ const updated = await stopTaskApi(taskId);
+ setTaskDetail((prev) => prev ? { ...prev, ...updated } : prev);
+ } catch (e) {
+ console.error("Failed to stop task:", e);
+ alert(e instanceof Error ? e.message : "Failed to stop task");
+ }
+ },
+ []
+ );
+
+ const handleRestart = useCallback(
+ async (taskId: string) => {
+ try {
+ // First stop the task
+ await stopTaskApi(taskId);
+ // Then start it again
+ const updated = await startTaskApi(taskId);
+ setTaskDetail((prev) => prev ? { ...prev, ...updated } : prev);
+ } catch (e) {
+ console.error("Failed to restart task:", e);
+ alert(e instanceof Error ? e.message : "Failed to restart task");
+ }
+ },
+ []
+ );
+
+ const handleContinue = useCallback(
+ async (taskId: string) => {
+ try {
+ // Start the task again from terminal state
+ const updated = await startTaskApi(taskId);
+ setTaskDetail((prev) => prev ? { ...prev, ...updated } : prev);
+ } catch (e) {
+ console.error("Failed to continue task:", e);
+ alert(e instanceof Error ? e.message : "Failed to continue task");
+ }
+ },
+ []
+ );
+
+ const handleSave = useCallback(
+ async (taskId: string, name: string, description: string, plan: string, targetRepoPath?: string, completionAction?: string) => {
+ if (!taskDetail) return;
+ const result = await editTask(taskId, {
+ name,
+ description: description || undefined,
+ plan,
+ targetRepoPath: targetRepoPath || undefined,
+ completionAction: completionAction as import("../lib/api").CompletionAction | undefined,
+ version: taskDetail.version,
+ });
+ if (result) {
+ setTaskDetail(result);
+ }
+ },
+ [editTask, taskDetail]
+ );
+
+ const handleCreate = useCallback(async () => {
+ if (creating) return;
+ setCreating(true);
+ try {
+ const newTask = await saveTask({
+ name: `Task ${new Date().toLocaleDateString()}`,
+ plan: "# Plan\n\nDescribe what this task should accomplish...",
+ });
+ if (newTask) {
+ navigate(`/mesh/${newTask.id}`);
+ }
+ } finally {
+ setCreating(false);
+ }
+ }, [creating, saveTask, navigate]);
+
+ const handleCreateSubtask = useCallback(async () => {
+ if (!taskDetail || creating) return;
+ setCreating(true);
+ try {
+ const newTask = await saveTask({
+ name: `Subtask of ${taskDetail.name}`,
+ plan: "# Plan\n\nDescribe what this subtask should accomplish...",
+ parentTaskId: taskDetail.id,
+ });
+ if (newTask) {
+ // Refresh current task to show new subtask
+ const refreshed = await fetchTask(taskDetail.id);
+ if (refreshed) {
+ setTaskDetail(refreshed);
+ }
+ }
+ } finally {
+ setCreating(false);
+ }
+ }, [creating, saveTask, taskDetail, fetchTask]);
+
+ // Callback when task is updated via CLI
+ const handleTaskUpdatedFromCli = useCallback(async () => {
+ if (id) {
+ const updated = await fetchTask(id);
+ if (updated) {
+ setTaskDetail(updated);
+ }
+ }
+ // Also refresh the task list
+ fetchTasks();
+ }, [id, fetchTask, fetchTasks]);
+
+ // Calculate chat context based on current view
+ const chatContext: MeshChatContext = useMemo(() => {
+ if (!id) {
+ return { type: "mesh" };
+ }
+ if (taskDetail?.parentTaskId) {
+ return { type: "subtask", taskId: id, parentTaskId: taskDetail.parentTaskId };
+ }
+ return { type: "task", taskId: id };
+ }, [id, taskDetail?.parentTaskId]);
+
+ // Handle resizing of the split panel
+ const handleResizeStart = useCallback((e: MouseEvent) => {
+ e.preventDefault();
+ setIsResizing(true);
+ }, []);
+
+ useEffect(() => {
+ if (!isResizing) return;
+
+ const handleMouseMove = (e: globalThis.MouseEvent) => {
+ if (!containerRef.current) return;
+ const containerRect = containerRef.current.getBoundingClientRect();
+ const containerWidth = containerRect.width;
+ const mouseX = e.clientX - containerRect.left;
+
+ // Calculate percentage, respecting minimum widths
+ const minTaskPercent = (MIN_TASK_WIDTH / containerWidth) * 100;
+ const maxTaskPercent = ((containerWidth - MIN_OUTPUT_WIDTH) / containerWidth) * 100;
+ const newPercent = Math.max(minTaskPercent, Math.min(maxTaskPercent, (mouseX / containerWidth) * 100));
+
+ setTaskPanelPercent(newPercent);
+ };
+
+ const handleMouseUp = () => {
+ setIsResizing(false);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [isResizing]);
+
+ // Cycle through view modes
+ const cycleViewMode = useCallback(() => {
+ setViewMode((current) => {
+ if (current === "split") return "task";
+ if (current === "task") return "output";
+ return "split";
+ });
+ }, []);
+
+ // Get label for current view mode
+ const getViewModeLabel = (mode: ViewMode): string => {
+ switch (mode) {
+ case "split": return "Split";
+ case "task": return "Task";
+ case "output": return "Output";
+ }
+ };
+
+ return (
+ <div className="relative z-10 h-screen flex flex-col overflow-hidden">
+ <Masthead showTicker={false} showNav />
+
+ <main className="flex-1 p-4 md:p-6 min-h-0 overflow-hidden flex flex-col">
+ {error && (
+ <div className="mb-4 p-3 border border-red-400/50 bg-red-400/10 text-red-400 font-mono text-sm shrink-0">
+ {error}
+ </div>
+ )}
+
+ {conflict?.hasConflict && (
+ <div className="mb-4 p-3 border border-yellow-400/50 bg-yellow-400/10 text-yellow-400 font-mono text-sm shrink-0">
+ <p>Version conflict detected. Please reload and try again.</p>
+ <button
+ onClick={clearConflict}
+ className="mt-2 px-3 py-1 border border-yellow-400/30 hover:border-yellow-400/50 text-xs uppercase"
+ >
+ Dismiss
+ </button>
+ </div>
+ )}
+
+ {/* Main content area - conditional based on route */}
+ <div className="flex-1 flex flex-col min-h-0 overflow-hidden gap-4">
+ {id && taskDetail ? (
+ <>
+ {/* Header with connection status and view toggle */}
+ <div className="flex items-center justify-between shrink-0">
+ <div className="flex items-center gap-2">
+ <span
+ className={`w-2 h-2 rounded-full ${
+ connected ? "bg-green-400" : "bg-yellow-400 animate-pulse"
+ }`}
+ />
+ <span className="font-mono text-[10px] text-[#75aafc] uppercase">
+ {connected ? "Connected" : "Connecting..."}
+ </span>
+ </div>
+ {/* View mode toggle */}
+ <button
+ onClick={cycleViewMode}
+ className="px-3 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ View: {getViewModeLabel(viewMode)}
+ </button>
+ </div>
+
+ {/* Split panel layout */}
+ <div
+ ref={containerRef}
+ className={`flex-1 flex min-h-0 overflow-hidden ${isResizing ? "select-none" : ""}`}
+ >
+ {/* Task detail panel */}
+ {(viewMode === "split" || viewMode === "task") && (
+ <div
+ className="min-h-0 overflow-hidden"
+ style={{
+ width: viewMode === "split" ? `${taskPanelPercent}%` : "100%",
+ flexShrink: 0,
+ }}
+ >
+ <TaskDetail
+ task={taskDetail}
+ loading={detailLoading}
+ onBack={handleBack}
+ onSave={handleSave}
+ onDelete={handleDelete}
+ onStart={handleStart}
+ onStop={handleStop}
+ onRestart={handleRestart}
+ onContinue={handleContinue}
+ onSelectSubtask={handleSelectTask}
+ onCreateSubtask={handleCreateSubtask}
+ onToggleSubtaskOutput={handleToggleSubtaskOutput}
+ viewingSubtaskId={viewingSubtaskId}
+ />
+ </div>
+ )}
+
+ {/* Resizable divider */}
+ {viewMode === "split" && (
+ <div
+ className="w-1 shrink-0 cursor-col-resize bg-[rgba(117,170,252,0.15)] hover:bg-[rgba(117,170,252,0.35)] transition-colors group flex items-center justify-center"
+ onMouseDown={handleResizeStart}
+ >
+ <div className="w-0.5 h-8 bg-[rgba(117,170,252,0.3)] group-hover:bg-[rgba(117,170,252,0.5)] rounded-full" />
+ </div>
+ )}
+
+ {/* Output panel */}
+ {(viewMode === "split" || viewMode === "output") && (
+ <div
+ className="panel min-h-0 overflow-hidden flex-1"
+ >
+ <TaskOutput
+ entries={taskOutputEntries}
+ isStreaming={isStreaming || taskDetail.status === "running"}
+ viewingSubtaskName={viewingSubtaskName}
+ onClearSubtaskView={viewingSubtaskId ? () => {
+ setViewingSubtaskId(null);
+ setViewingSubtaskName(null);
+ } : undefined}
+ onClear={() => {
+ setTaskOutputEntries([]);
+ if (activeOutputTaskId) {
+ clearPersistedOutput(activeOutputTaskId);
+ }
+ }}
+ taskId={activeOutputTaskId}
+ onUserInput={handleUserInput}
+ />
+ </div>
+ )}
+ </div>
+ </>
+ ) : id && detailLoading ? (
+ <div className="panel flex-1 flex items-center justify-center">
+ <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div>
+ </div>
+ ) : (
+ <div className="flex-1 min-h-0 overflow-hidden">
+ <TaskList
+ tasks={tasks}
+ loading={loading || creating}
+ onSelect={handleSelectTask}
+ onDelete={handleDelete}
+ onCreate={handleCreate}
+ />
+ </div>
+ )}
+
+ {/* Mesh Chat Input - always rendered to persist state across navigation */}
+ <div className="shrink-0">
+ <UnifiedMeshChatInput
+ context={chatContext}
+ onUpdate={id ? handleTaskUpdatedFromCli : fetchTasks}
+ />
+ </div>
+ </div>
+ </main>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/routes/settings.tsx b/makima/frontend/src/routes/settings.tsx
new file mode 100644
index 0000000..6d56e67
--- /dev/null
+++ b/makima/frontend/src/routes/settings.tsx
@@ -0,0 +1,724 @@
+import { useState, useEffect, type FormEvent } from "react";
+import { useAuth } from "../contexts/AuthContext";
+import { useNavigate } from "react-router";
+import { Masthead } from "../components/Masthead";
+import {
+ getApiKey,
+ createApiKey,
+ refreshApiKey,
+ revokeApiKey,
+ changePassword,
+ changeEmail,
+ deleteAccount,
+ type ApiKeyInfo,
+ type CreateApiKeyResponse,
+} from "../lib/api";
+
+// =============================================================================
+// Password Strength Indicator
+// =============================================================================
+
+interface PasswordStrength {
+ score: number;
+ label: string;
+ color: string;
+ requirements: { met: boolean; text: string }[];
+}
+
+function getPasswordStrength(password: string): PasswordStrength {
+ const requirements = [
+ { met: password.length >= 6, text: "At least 6 characters" },
+ ];
+
+ const score = requirements.filter((r) => r.met).length;
+
+ const label = score === 1 ? "Valid" : "Too short";
+ const color = score === 1 ? "bg-green-500" : "bg-red-500";
+
+ return { score, label, color, requirements };
+}
+
+// =============================================================================
+// Confirmation Dialog Component
+// =============================================================================
+
+interface ConfirmDialogProps {
+ isOpen: boolean;
+ title: string;
+ message: string;
+ confirmText: string;
+ confirmButtonClass?: string;
+ requireInput?: string;
+ inputPlaceholder?: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+}
+
+function ConfirmDialog({
+ isOpen,
+ title,
+ message,
+ confirmText,
+ confirmButtonClass = "bg-red-900/50 border-red-700 hover:bg-red-800/50",
+ requireInput,
+ inputPlaceholder,
+ onConfirm,
+ onCancel,
+}: ConfirmDialogProps) {
+ const [inputValue, setInputValue] = useState("");
+
+ useEffect(() => {
+ if (!isOpen) {
+ setInputValue("");
+ }
+ }, [isOpen]);
+
+ if (!isOpen) return null;
+
+ const canConfirm = !requireInput || inputValue === requireInput;
+
+ return (
+ <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
+ <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.35)] p-6 max-w-md w-full mx-4">
+ <h3 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff] mb-3">{title}</h3>
+ <p className="text-[#75aafc] text-xs font-mono mb-4">{message}</p>
+ {requireInput && (
+ <div className="mb-4">
+ <label className="block text-xs font-mono text-[#8899aa] mb-2">
+ Type <span className="text-[#9bc3ff]">{requireInput}</span> to confirm:
+ </label>
+ <input
+ type="text"
+ value={inputValue}
+ onChange={(e) => setInputValue(e.target.value)}
+ placeholder={inputPlaceholder}
+ className="w-full px-3 py-2 bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-sm placeholder-[#556677] focus:outline-none focus:border-[#3f6fb3]"
+ />
+ </div>
+ )}
+ <div className="flex gap-3 justify-end">
+ <button
+ onClick={onCancel}
+ className="px-4 py-2 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={onConfirm}
+ disabled={!canConfirm}
+ className={`px-4 py-2 border font-mono text-xs uppercase tracking-wide transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${confirmButtonClass}`}
+ >
+ {confirmText}
+ </button>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+// =============================================================================
+// Section Header Component
+// =============================================================================
+
+function SectionHeader({ children }: { children: React.ReactNode }) {
+ return (
+ <h2 className="text-[11px] font-mono uppercase tracking-wide text-[#8899aa] mb-3 pb-2 border-b border-[rgba(117,170,252,0.15)]">
+ {children}
+ </h2>
+ );
+}
+
+// =============================================================================
+// Form Input Component
+// =============================================================================
+
+function FormInput({
+ label,
+ type = "text",
+ value,
+ onChange,
+ placeholder,
+ required,
+ disabled,
+}: {
+ label: string;
+ type?: string;
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ required?: boolean;
+ disabled?: boolean;
+}) {
+ return (
+ <div>
+ <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa] mb-1">
+ {label}
+ </label>
+ <input
+ type={type}
+ value={value}
+ onChange={(e) => onChange(e.target.value)}
+ placeholder={placeholder}
+ required={required}
+ disabled={disabled}
+ className="w-full px-3 py-2 bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-sm placeholder-[#556677] focus:outline-none focus:border-[#3f6fb3] disabled:opacity-50"
+ />
+ </div>
+ );
+}
+
+// =============================================================================
+// Alert Components
+// =============================================================================
+
+function ErrorAlert({ children }: { children: React.ReactNode }) {
+ return (
+ <div className="border border-red-700/50 bg-red-900/20 text-red-400 px-3 py-2 mb-4 font-mono text-xs">
+ {children}
+ </div>
+ );
+}
+
+function SuccessAlert({ children }: { children: React.ReactNode }) {
+ return (
+ <div className="border border-green-700/50 bg-green-900/20 text-green-400 px-3 py-2 mb-4 font-mono text-xs">
+ {children}
+ </div>
+ );
+}
+
+// =============================================================================
+// Button Components
+// =============================================================================
+
+function PrimaryButton({
+ children,
+ onClick,
+ disabled,
+ type = "button",
+}: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ disabled?: boolean;
+ type?: "button" | "submit";
+}) {
+ return (
+ <button
+ type={type}
+ onClick={onClick}
+ disabled={disabled}
+ className="px-4 py-2 bg-[#3f6fb3] border border-[#75aafc] text-white font-mono text-xs uppercase tracking-wide hover:bg-[#4a7fc3] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {children}
+ </button>
+ );
+}
+
+function SecondaryButton({
+ children,
+ onClick,
+ disabled,
+}: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ disabled?: boolean;
+}) {
+ return (
+ <button
+ type="button"
+ onClick={onClick}
+ disabled={disabled}
+ className="px-4 py-2 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {children}
+ </button>
+ );
+}
+
+function DangerButton({
+ children,
+ onClick,
+ disabled,
+}: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ disabled?: boolean;
+}) {
+ return (
+ <button
+ type="button"
+ onClick={onClick}
+ disabled={disabled}
+ className="px-4 py-2 bg-red-900/30 border border-red-700/50 text-red-400 font-mono text-xs uppercase tracking-wide hover:bg-red-800/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {children}
+ </button>
+ );
+}
+
+// =============================================================================
+// Main Settings Page
+// =============================================================================
+
+export default function SettingsPage() {
+ const { user, isAuthConfigured, signOut } = useAuth();
+ const navigate = useNavigate();
+
+ // API Key state
+ const [apiKeyInfo, setApiKeyInfo] = useState<ApiKeyInfo | null>(null);
+ const [newKey, setNewKey] = useState<string | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [actionLoading, setActionLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [copied, setCopied] = useState(false);
+
+ // Password change state
+ const [passwordForm, setPasswordForm] = useState({
+ currentPassword: "",
+ newPassword: "",
+ confirmPassword: "",
+ });
+ const [passwordLoading, setPasswordLoading] = useState(false);
+ const [passwordError, setPasswordError] = useState<string | null>(null);
+ const [passwordSuccess, setPasswordSuccess] = useState<string | null>(null);
+
+ // Email change state
+ const [emailForm, setEmailForm] = useState({
+ password: "",
+ newEmail: "",
+ });
+ const [emailLoading, setEmailLoading] = useState(false);
+ const [emailError, setEmailError] = useState<string | null>(null);
+ const [emailSuccess, setEmailSuccess] = useState<string | null>(null);
+
+ // Account deletion state
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [deletePassword, setDeletePassword] = useState("");
+ const [deleteLoading, setDeleteLoading] = useState(false);
+ const [deleteError, setDeleteError] = useState<string | null>(null);
+
+ useEffect(() => {
+ loadApiKey();
+ }, []);
+
+ const loadApiKey = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const key = await getApiKey();
+ setApiKeyInfo(key);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load API key");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleCreate = async () => {
+ try {
+ setActionLoading(true);
+ setError(null);
+ setNewKey(null);
+ const response: CreateApiKeyResponse = await createApiKey("Web UI");
+ setNewKey(response.key);
+ setApiKeyInfo({
+ id: response.id,
+ prefix: response.prefix,
+ name: response.name,
+ lastUsedAt: null,
+ createdAt: response.createdAt,
+ });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to create API key");
+ } finally {
+ setActionLoading(false);
+ }
+ };
+
+ const handleRefresh = async () => {
+ try {
+ setActionLoading(true);
+ setError(null);
+ setNewKey(null);
+ const response = await refreshApiKey("Web UI (Refreshed)");
+ setNewKey(response.key);
+ setApiKeyInfo({
+ id: response.id,
+ prefix: response.prefix,
+ name: response.name,
+ lastUsedAt: null,
+ createdAt: response.createdAt,
+ });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to refresh API key");
+ } finally {
+ setActionLoading(false);
+ }
+ };
+
+ const handleRevoke = async () => {
+ if (!confirm("Are you sure you want to revoke this API key? Any applications using it will stop working.")) {
+ return;
+ }
+ try {
+ setActionLoading(true);
+ setError(null);
+ setNewKey(null);
+ await revokeApiKey();
+ setApiKeyInfo(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to revoke API key");
+ } finally {
+ setActionLoading(false);
+ }
+ };
+
+ const copyToClipboard = async () => {
+ if (!newKey) return;
+ try {
+ await navigator.clipboard.writeText(newKey);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ console.error("Failed to copy:", err);
+ }
+ };
+
+ // Password change handlers
+ const handlePasswordChange = async (e: FormEvent) => {
+ e.preventDefault();
+ setPasswordError(null);
+ setPasswordSuccess(null);
+
+ if (passwordForm.newPassword !== passwordForm.confirmPassword) {
+ setPasswordError("New passwords do not match");
+ return;
+ }
+
+ const strength = getPasswordStrength(passwordForm.newPassword);
+ if (strength.score < 1) {
+ setPasswordError("Password must be at least 6 characters");
+ return;
+ }
+
+ try {
+ setPasswordLoading(true);
+ await changePassword(passwordForm.currentPassword, passwordForm.newPassword);
+ setPasswordSuccess("Password changed successfully. Please sign in with your new password.");
+ setPasswordForm({ currentPassword: "", newPassword: "", confirmPassword: "" });
+ setTimeout(async () => {
+ await signOut();
+ navigate("/login");
+ }, 1500);
+ } catch (err) {
+ setPasswordError(err instanceof Error ? err.message : "Failed to change password");
+ } finally {
+ setPasswordLoading(false);
+ }
+ };
+
+ // Email change handlers
+ const handleEmailChange = async (e: FormEvent) => {
+ e.preventDefault();
+ setEmailError(null);
+ setEmailSuccess(null);
+
+ if (!emailForm.newEmail.includes("@")) {
+ setEmailError("Please enter a valid email address");
+ return;
+ }
+
+ try {
+ setEmailLoading(true);
+ await changeEmail(emailForm.password, emailForm.newEmail);
+ setEmailSuccess("Email changed successfully");
+ setEmailForm({ password: "", newEmail: "" });
+ } catch (err) {
+ setEmailError(err instanceof Error ? err.message : "Failed to change email");
+ } finally {
+ setEmailLoading(false);
+ }
+ };
+
+ // Account deletion handlers
+ const DELETE_CONFIRMATION = "DELETE MY ACCOUNT";
+
+ const handleDeleteAccount = async () => {
+ try {
+ setDeleteLoading(true);
+ setDeleteError(null);
+ await deleteAccount(deletePassword, DELETE_CONFIRMATION);
+ await signOut();
+ navigate("/login");
+ } catch (err) {
+ setDeleteError(err instanceof Error ? err.message : "Failed to delete account");
+ setDeleteLoading(false);
+ }
+ };
+
+ const passwordStrength = getPasswordStrength(passwordForm.newPassword);
+
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+
+ <main className="flex-1 max-w-4xl mx-auto p-6 w-full">
+ {/* Page Header */}
+ <div className="mb-8">
+ <h1 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff]">Settings</h1>
+ <div className="h-px bg-[rgba(117,170,252,0.35)] mt-2" />
+ </div>
+
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+ {/* Left Column */}
+ <div className="space-y-6">
+ {/* Account Info */}
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <SectionHeader>Account</SectionHeader>
+ {isAuthConfigured && user ? (
+ <div className="space-y-2 font-mono text-xs">
+ <div className="flex justify-between">
+ <span className="text-[#7788aa]">Email</span>
+ <span className="text-[#9bc3ff]">{user.email}</span>
+ </div>
+ <div className="flex justify-between">
+ <span className="text-[#7788aa]">User ID</span>
+ <span className="text-[#75aafc] text-[10px]">{user.id}</span>
+ </div>
+ </div>
+ ) : (
+ <p className="text-[#7788aa] font-mono text-xs">
+ {isAuthConfigured
+ ? "Not signed in"
+ : "Authentication not configured (API key mode)"}
+ </p>
+ )}
+ </section>
+
+ {/* API Key Section */}
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <SectionHeader>API Key</SectionHeader>
+ <p className="text-[#7788aa] font-mono text-[10px] mb-4">
+ Authenticate daemon and CLI tools. One active key at a time.
+ </p>
+
+ {error && <ErrorAlert>{error}</ErrorAlert>}
+
+ {newKey && (
+ <div className="border border-green-700/50 bg-green-900/20 p-3 mb-4">
+ <p className="text-green-400 font-mono text-[10px] mb-2">
+ Key created. Copy now - won't be shown again.
+ </p>
+ <div className="flex items-center gap-2">
+ <code className="flex-1 bg-black/50 px-2 py-1 text-[10px] font-mono text-green-400 break-all">
+ {newKey}
+ </code>
+ <button
+ onClick={copyToClipboard}
+ className="px-2 py-1 bg-green-900/50 border border-green-700/50 text-green-400 font-mono text-[10px] uppercase hover:bg-green-800/50 transition-colors"
+ >
+ {copied ? "Copied" : "Copy"}
+ </button>
+ </div>
+ </div>
+ )}
+
+ {loading ? (
+ <p className="text-[#7788aa] font-mono text-xs">Loading...</p>
+ ) : apiKeyInfo ? (
+ <div className="space-y-3">
+ <div className="font-mono text-xs">
+ <div className="flex justify-between mb-1">
+ <span className="text-[#7788aa]">Prefix</span>
+ <code className="text-[#75aafc]">{apiKeyInfo.prefix}...</code>
+ </div>
+ <div className="flex justify-between mb-1">
+ <span className="text-[#7788aa]">Created</span>
+ <span className="text-[#9bc3ff]">
+ {new Date(apiKeyInfo.createdAt).toLocaleDateString()}
+ </span>
+ </div>
+ {apiKeyInfo.lastUsedAt && (
+ <div className="flex justify-between">
+ <span className="text-[#7788aa]">Last used</span>
+ <span className="text-[#9bc3ff]">
+ {new Date(apiKeyInfo.lastUsedAt).toLocaleDateString()}
+ </span>
+ </div>
+ )}
+ </div>
+ <div className="flex gap-2 pt-2">
+ <SecondaryButton onClick={handleRefresh} disabled={actionLoading}>
+ {actionLoading ? "..." : "Rotate"}
+ </SecondaryButton>
+ <DangerButton onClick={handleRevoke} disabled={actionLoading}>
+ {actionLoading ? "..." : "Revoke"}
+ </DangerButton>
+ </div>
+ </div>
+ ) : (
+ <div>
+ <p className="text-[#7788aa] font-mono text-xs mb-3">No API key configured.</p>
+ <PrimaryButton onClick={handleCreate} disabled={actionLoading}>
+ {actionLoading ? "Creating..." : "Create API Key"}
+ </PrimaryButton>
+ </div>
+ )}
+ </section>
+
+ {/* Daemon Setup */}
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <SectionHeader>Daemon Setup</SectionHeader>
+ <p className="text-[#7788aa] font-mono text-[10px] mb-3">
+ Set your API key as an environment variable:
+ </p>
+ <code className="block bg-black/50 px-3 py-2 text-[11px] font-mono text-green-400 mb-3">
+ export MAKIMA_API_KEY="your-key"
+ </code>
+ <p className="text-[#7788aa] font-mono text-[10px]">
+ Then run: <code className="text-green-400">makima-daemon</code>
+ </p>
+ </section>
+ </div>
+
+ {/* Right Column */}
+ <div className="space-y-6">
+ {/* Password Change */}
+ {isAuthConfigured && user && (
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <SectionHeader>Change Password</SectionHeader>
+ {passwordError && <ErrorAlert>{passwordError}</ErrorAlert>}
+ {passwordSuccess && <SuccessAlert>{passwordSuccess}</SuccessAlert>}
+ <form onSubmit={handlePasswordChange} className="space-y-3">
+ <FormInput
+ label="Current Password"
+ type="password"
+ value={passwordForm.currentPassword}
+ onChange={(v) => setPasswordForm({ ...passwordForm, currentPassword: v })}
+ required
+ />
+ <FormInput
+ label="New Password"
+ type="password"
+ value={passwordForm.newPassword}
+ onChange={(v) => setPasswordForm({ ...passwordForm, newPassword: v })}
+ required
+ />
+ {passwordForm.newPassword && (
+ <div className="space-y-1">
+ <div className="flex items-center gap-2">
+ <div className="flex-1 h-1 bg-[#1a2a3a]">
+ <div
+ className={`h-full transition-all ${passwordStrength.color}`}
+ style={{ width: `${passwordStrength.score * 100}%` }}
+ />
+ </div>
+ <span className="text-[10px] font-mono text-[#9bc3ff]">
+ {passwordStrength.label}
+ </span>
+ </div>
+ </div>
+ )}
+ <FormInput
+ label="Confirm Password"
+ type="password"
+ value={passwordForm.confirmPassword}
+ onChange={(v) => setPasswordForm({ ...passwordForm, confirmPassword: v })}
+ required
+ />
+ {passwordForm.confirmPassword &&
+ passwordForm.newPassword !== passwordForm.confirmPassword && (
+ <p className="text-red-400 font-mono text-[10px]">Passwords do not match</p>
+ )}
+ <div className="pt-2">
+ <PrimaryButton
+ type="submit"
+ disabled={passwordLoading || passwordStrength.score < 1}
+ >
+ {passwordLoading ? "Changing..." : "Change Password"}
+ </PrimaryButton>
+ </div>
+ </form>
+ </section>
+ )}
+
+ {/* Email Change */}
+ {isAuthConfigured && user && (
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <SectionHeader>Change Email</SectionHeader>
+ {emailError && <ErrorAlert>{emailError}</ErrorAlert>}
+ {emailSuccess && <SuccessAlert>{emailSuccess}</SuccessAlert>}
+ <form onSubmit={handleEmailChange} className="space-y-3">
+ <FormInput
+ label="New Email"
+ type="email"
+ value={emailForm.newEmail}
+ onChange={(v) => setEmailForm({ ...emailForm, newEmail: v })}
+ placeholder="new@example.com"
+ required
+ />
+ <FormInput
+ label="Password (to confirm)"
+ type="password"
+ value={emailForm.password}
+ onChange={(v) => setEmailForm({ ...emailForm, password: v })}
+ required
+ />
+ <div className="pt-2">
+ <PrimaryButton type="submit" disabled={emailLoading}>
+ {emailLoading ? "Changing..." : "Change Email"}
+ </PrimaryButton>
+ </div>
+ </form>
+ </section>
+ )}
+
+ {/* Danger Zone */}
+ {isAuthConfigured && user && (
+ <section className="border border-red-900/50 bg-[#0d1b2d] p-4">
+ <h2 className="text-[11px] font-mono uppercase tracking-wide text-red-400 mb-3 pb-2 border-b border-red-900/30">
+ Danger Zone
+ </h2>
+ <p className="text-[#7788aa] font-mono text-[10px] mb-3">
+ Permanently delete your account and all data. This cannot be undone.
+ </p>
+ {deleteError && <ErrorAlert>{deleteError}</ErrorAlert>}
+ <div className="space-y-3">
+ <FormInput
+ label="Password"
+ type="password"
+ value={deletePassword}
+ onChange={setDeletePassword}
+ placeholder="Enter password to continue"
+ />
+ <DangerButton
+ onClick={() => setDeleteDialogOpen(true)}
+ disabled={!deletePassword || deleteLoading}
+ >
+ {deleteLoading ? "Deleting..." : "Delete Account"}
+ </DangerButton>
+ </div>
+ </section>
+ )}
+ </div>
+ </div>
+ </main>
+
+ {/* Delete Confirmation Dialog */}
+ <ConfirmDialog
+ isOpen={deleteDialogOpen}
+ title="Delete Account"
+ message="This will permanently delete your account and all your data. This action cannot be undone."
+ confirmText="Delete"
+ confirmButtonClass="bg-red-900/50 border border-red-700 text-red-400 hover:bg-red-800/50"
+ requireInput={DELETE_CONFIRMATION}
+ inputPlaceholder={`Type "${DELETE_CONFIRMATION}" to confirm`}
+ onConfirm={() => {
+ setDeleteDialogOpen(false);
+ handleDeleteAccount();
+ }}
+ onCancel={() => setDeleteDialogOpen(false)}
+ />
+ </div>
+ );
+}