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/components | |
| parent | f79c416c58557d2f946aa5332989afdfa8c021cd (diff) | |
| download | soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.tar.gz soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.zip | |
Initial Control system
Diffstat (limited to 'makima/frontend/src/components')
18 files changed, 4724 insertions, 22 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> + ); +} |
