diff options
| author | soryu <soryu@soryu.co> | 2026-01-06 04:08:11 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-11 03:01:13 +0000 |
| commit | 8b17a175c3e7e27b789812eba4e3cd760beadb10 (patch) | |
| tree | 7864dcaa2fa9db47fdfd4e8bfdb0b1dde832aa33 /makima/frontend/src | |
| parent | f79c416c58557d2f946aa5332989afdfa8c021cd (diff) | |
| download | soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.tar.gz soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.zip | |
Initial Control system
Diffstat (limited to 'makima/frontend/src')
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">×</span> + </button> + )} + <span className="text-[#9bc3ff] font-mono text-sm">></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]">></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]">›</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]">›</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  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" + > + < 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" + > + < + </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">></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]">></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">></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> + ); +} |
