summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/mesh
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-06 04:08:11 +0000
committersoryu <soryu@soryu.co>2026-01-11 03:01:13 +0000
commit8b17a175c3e7e27b789812eba4e3cd760beadb10 (patch)
tree7864dcaa2fa9db47fdfd4e8bfdb0b1dde832aa33 /makima/frontend/src/components/mesh
parentf79c416c58557d2f946aa5332989afdfa8c021cd (diff)
downloadsoryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.tar.gz
soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.zip
Initial Control system
Diffstat (limited to 'makima/frontend/src/components/mesh')
-rw-r--r--makima/frontend/src/components/mesh/DirectoryInput.tsx220
-rw-r--r--makima/frontend/src/components/mesh/InlineSubtaskEditor.tsx262
-rw-r--r--makima/frontend/src/components/mesh/MergeConflictResolver.tsx504
-rw-r--r--makima/frontend/src/components/mesh/OverlayDiffViewer.tsx476
-rw-r--r--makima/frontend/src/components/mesh/PRPreview.tsx314
-rw-r--r--makima/frontend/src/components/mesh/SubtaskTree.tsx297
-rw-r--r--makima/frontend/src/components/mesh/TaskDetail.tsx886
-rw-r--r--makima/frontend/src/components/mesh/TaskList.tsx164
-rw-r--r--makima/frontend/src/components/mesh/TaskOutput.tsx281
-rw-r--r--makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx536
10 files changed, 3940 insertions, 0 deletions
diff --git a/makima/frontend/src/components/mesh/DirectoryInput.tsx b/makima/frontend/src/components/mesh/DirectoryInput.tsx
new file mode 100644
index 0000000..e2e331e
--- /dev/null
+++ b/makima/frontend/src/components/mesh/DirectoryInput.tsx
@@ -0,0 +1,220 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+import type { DaemonDirectory } from "../../lib/api";
+
+interface DirectoryInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ suggestions: DaemonDirectory[];
+ placeholder?: string;
+ /** Repository URL to extract repo name for home directory suggestions */
+ repoUrl?: string | null;
+ className?: string;
+ disabled?: boolean;
+}
+
+/** Extract repository name from URL */
+function extractRepoName(url: string | null | undefined): string | null {
+ if (!url) return null;
+
+ // Handle various URL formats:
+ // https://github.com/user/repo.git -> repo
+ // https://github.com/user/repo -> repo
+ // git@github.com:user/repo.git -> repo
+ // /path/to/local/repo -> repo
+
+ let name = url;
+
+ // Remove trailing .git
+ if (name.endsWith(".git")) {
+ name = name.slice(0, -4);
+ }
+
+ // Remove trailing slash
+ if (name.endsWith("/")) {
+ name = name.slice(0, -1);
+ }
+
+ // Get the last path segment
+ const lastSlash = name.lastIndexOf("/");
+ if (lastSlash !== -1) {
+ name = name.slice(lastSlash + 1);
+ }
+
+ // Handle git@host:user/repo format
+ const colonIndex = name.lastIndexOf(":");
+ if (colonIndex !== -1) {
+ const afterColon = name.slice(colonIndex + 1);
+ const slashIndex = afterColon.lastIndexOf("/");
+ if (slashIndex !== -1) {
+ name = afterColon.slice(slashIndex + 1);
+ } else {
+ name = afterColon;
+ }
+ }
+
+ return name || null;
+}
+
+export function DirectoryInput({
+ value,
+ onChange,
+ suggestions,
+ placeholder = "/path/to/directory",
+ repoUrl,
+ className = "",
+ disabled = false,
+}: DirectoryInputProps) {
+ const [showSuggestions, setShowSuggestions] = useState(false);
+ const [highlightedIndex, setHighlightedIndex] = useState(-1);
+ const inputRef = useRef<HTMLInputElement>(null);
+ const dropdownRef = useRef<HTMLDivElement>(null);
+
+ // Extract repo name for home directory suggestions
+ const repoName = extractRepoName(repoUrl);
+
+ // Process suggestions to add repo name to home directory
+ const processedSuggestions = suggestions.map((dir) => {
+ if (dir.directoryType === "home" && repoName) {
+ return {
+ ...dir,
+ path: `${dir.path}/${repoName}`,
+ label: `${dir.label} (${repoName})`,
+ };
+ }
+ return dir;
+ });
+
+ // Filter suggestions based on current input
+ const filteredSuggestions = processedSuggestions.filter((dir) => {
+ if (!value) return true;
+ const lowerValue = value.toLowerCase();
+ return (
+ dir.path.toLowerCase().includes(lowerValue) ||
+ dir.label.toLowerCase().includes(lowerValue)
+ );
+ });
+
+ // Handle clicking outside to close dropdown
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(e.target as Node) &&
+ inputRef.current &&
+ !inputRef.current.contains(e.target as Node)
+ ) {
+ setShowSuggestions(false);
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, []);
+
+ const handleFocus = useCallback(() => {
+ setShowSuggestions(true);
+ setHighlightedIndex(-1);
+ }, []);
+
+ const handleBlur = useCallback(() => {
+ // Delay hiding to allow click on suggestion
+ setTimeout(() => {
+ setShowSuggestions(false);
+ }, 150);
+ }, []);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (!showSuggestions || filteredSuggestions.length === 0) return;
+
+ switch (e.key) {
+ case "ArrowDown":
+ e.preventDefault();
+ setHighlightedIndex((prev) =>
+ prev < filteredSuggestions.length - 1 ? prev + 1 : 0
+ );
+ break;
+ case "ArrowUp":
+ e.preventDefault();
+ setHighlightedIndex((prev) =>
+ prev > 0 ? prev - 1 : filteredSuggestions.length - 1
+ );
+ break;
+ case "Enter":
+ e.preventDefault();
+ if (highlightedIndex >= 0 && highlightedIndex < filteredSuggestions.length) {
+ onChange(filteredSuggestions[highlightedIndex].path);
+ setShowSuggestions(false);
+ }
+ break;
+ case "Escape":
+ setShowSuggestions(false);
+ break;
+ }
+ },
+ [showSuggestions, filteredSuggestions, highlightedIndex, onChange]
+ );
+
+ const handleSelectSuggestion = useCallback(
+ (path: string) => {
+ onChange(path);
+ setShowSuggestions(false);
+ inputRef.current?.focus();
+ },
+ [onChange]
+ );
+
+ return (
+ <div className={`relative ${className}`}>
+ <input
+ ref={inputRef}
+ type="text"
+ value={value}
+ onChange={(e) => onChange(e.target.value)}
+ onFocus={handleFocus}
+ onBlur={handleBlur}
+ onKeyDown={handleKeyDown}
+ placeholder={placeholder}
+ disabled={disabled}
+ className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3] disabled:opacity-50"
+ />
+
+ {/* Suggestions dropdown */}
+ {showSuggestions && filteredSuggestions.length > 0 && (
+ <div
+ ref={dropdownRef}
+ className="absolute z-50 left-0 right-0 top-full mt-1 bg-[#0a1525] border border-[rgba(117,170,252,0.35)] shadow-lg max-h-48 overflow-auto"
+ >
+ {filteredSuggestions.map((dir, index) => (
+ <button
+ key={`${dir.directoryType}-${index}`}
+ type="button"
+ onClick={() => handleSelectSuggestion(dir.path)}
+ onMouseEnter={() => setHighlightedIndex(index)}
+ className={`w-full text-left px-3 py-2 font-mono text-xs transition-colors ${
+ index === highlightedIndex
+ ? "bg-[rgba(117,170,252,0.2)] text-[#dbe7ff]"
+ : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)]"
+ }`}
+ >
+ <div className="flex items-center justify-between gap-2">
+ <div className="flex items-center gap-2">
+ <span className="text-[#75aafc]">{dir.label}</span>
+ {dir.exists === true && (
+ <span className="text-[#f0ad4e] text-[10px]" title="Directory already exists">
+ (exists)
+ </span>
+ )}
+ </div>
+ {dir.hostname && (
+ <span className="text-[#555] text-[10px]">({dir.hostname})</span>
+ )}
+ </div>
+ <div className="text-[10px] text-[#555] truncate">{dir.path}</div>
+ </button>
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/InlineSubtaskEditor.tsx b/makima/frontend/src/components/mesh/InlineSubtaskEditor.tsx
new file mode 100644
index 0000000..3621b08
--- /dev/null
+++ b/makima/frontend/src/components/mesh/InlineSubtaskEditor.tsx
@@ -0,0 +1,262 @@
+import { useState, useCallback, useEffect } from "react";
+import type { TaskWithSubtasks, TaskStatus } from "../../lib/api";
+import { getTask, updateTask } from "../../lib/api";
+
+interface InlineSubtaskEditorProps {
+ subtaskId: string;
+ onClose: () => void;
+ onUpdated: () => void;
+ onNavigate?: (taskId: string) => void;
+}
+
+function getStatusColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "text-[#9bc3ff]";
+ case "running":
+ return "text-green-400";
+ case "paused":
+ return "text-yellow-400";
+ case "blocked":
+ return "text-orange-400";
+ case "done":
+ return "text-emerald-400";
+ case "failed":
+ return "text-red-400";
+ case "merged":
+ return "text-purple-400";
+ default:
+ return "text-[#9bc3ff]";
+ }
+}
+
+function getStatusBgColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "bg-[rgba(117,170,252,0.1)]";
+ case "running":
+ return "bg-green-400/10";
+ case "paused":
+ return "bg-yellow-400/10";
+ case "blocked":
+ return "bg-orange-400/10";
+ case "done":
+ return "bg-emerald-400/10";
+ case "failed":
+ return "bg-red-400/10";
+ case "merged":
+ return "bg-purple-400/10";
+ default:
+ return "bg-[rgba(117,170,252,0.1)]";
+ }
+}
+
+export function InlineSubtaskEditor({
+ subtaskId,
+ onClose,
+ onUpdated,
+ onNavigate,
+}: InlineSubtaskEditorProps) {
+ const [subtask, setSubtask] = useState<TaskWithSubtasks | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [isEditing, setIsEditing] = useState(false);
+ const [editName, setEditName] = useState("");
+ const [editDescription, setEditDescription] = useState("");
+ const [editPlan, setEditPlan] = useState("");
+
+ // Load subtask details
+ useEffect(() => {
+ setLoading(true);
+ getTask(subtaskId)
+ .then((task) => {
+ setSubtask(task);
+ setEditName(task.name);
+ setEditDescription(task.description || "");
+ setEditPlan(task.plan);
+ })
+ .catch((err) => {
+ console.error("Failed to load subtask:", err);
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ }, [subtaskId]);
+
+ const handleSave = useCallback(async () => {
+ if (!subtask || saving) return;
+ setSaving(true);
+ try {
+ await updateTask(subtaskId, {
+ name: editName,
+ description: editDescription || undefined,
+ plan: editPlan,
+ version: subtask.version,
+ });
+ // Refresh subtask
+ const updated = await getTask(subtaskId);
+ setSubtask(updated);
+ setIsEditing(false);
+ onUpdated();
+ } catch (err) {
+ console.error("Failed to save subtask:", err);
+ } finally {
+ setSaving(false);
+ }
+ }, [subtask, subtaskId, editName, editDescription, editPlan, saving, onUpdated]);
+
+ const handleCancel = useCallback(() => {
+ if (subtask) {
+ setEditName(subtask.name);
+ setEditDescription(subtask.description || "");
+ setEditPlan(subtask.plan);
+ }
+ setIsEditing(false);
+ }, [subtask]);
+
+ if (loading) {
+ return (
+ <div className="p-4 bg-[rgba(0,0,0,0.2)] border-l-2 border-[#3f6fb3]">
+ <div className="font-mono text-xs text-[#75aafc]">Loading subtask...</div>
+ </div>
+ );
+ }
+
+ if (!subtask) {
+ return (
+ <div className="p-4 bg-[rgba(0,0,0,0.2)] border-l-2 border-red-400">
+ <div className="font-mono text-xs text-red-400">Failed to load subtask</div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="bg-[rgba(0,0,0,0.2)] border-l-2 border-[#3f6fb3]">
+ {/* Header */}
+ <div className="flex items-center justify-between p-3 border-b border-[rgba(117,170,252,0.15)]">
+ <div className="flex items-center gap-2">
+ <button
+ onClick={onClose}
+ className="font-mono text-[10px] text-[#555] hover:text-[#75aafc]"
+ >
+ [close]
+ </button>
+ <span
+ className={`px-1.5 py-0.5 font-mono text-[9px] uppercase ${getStatusColor(
+ subtask.status as TaskStatus
+ )} ${getStatusBgColor(subtask.status as TaskStatus)} border border-current/20`}
+ >
+ {subtask.status}
+ </span>
+ {onNavigate && (
+ <button
+ onClick={() => onNavigate(subtaskId)}
+ className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff]"
+ >
+ [open full view]
+ </button>
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ {isEditing ? (
+ <>
+ <button
+ onClick={handleCancel}
+ disabled={saving}
+ className="px-2 py-0.5 font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] disabled:opacity-50"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleSave}
+ disabled={saving}
+ className="px-2 py-0.5 font-mono text-[10px] text-green-400 border border-green-400/30 hover:border-green-400/50 disabled:opacity-50"
+ >
+ {saving ? "..." : "Save"}
+ </button>
+ </>
+ ) : (
+ <button
+ onClick={() => setIsEditing(true)}
+ className="px-2 py-0.5 font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff]"
+ >
+ Edit
+ </button>
+ )}
+ </div>
+ </div>
+
+ {/* Content */}
+ <div className="p-3 space-y-3">
+ {/* Name */}
+ {isEditing ? (
+ <input
+ type="text"
+ value={editName}
+ onChange={(e) => setEditName(e.target.value)}
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-2 py-1 outline-none focus:border-[#3f6fb3]"
+ placeholder="Subtask name"
+ />
+ ) : (
+ <div className="font-mono text-sm text-[#dbe7ff]">{subtask.name}</div>
+ )}
+
+ {/* Description */}
+ {isEditing ? (
+ <textarea
+ value={editDescription}
+ onChange={(e) => setEditDescription(e.target.value)}
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs px-2 py-1 outline-none focus:border-[#3f6fb3] min-h-[40px] resize-y"
+ placeholder="Description (optional)"
+ />
+ ) : subtask.description ? (
+ <div className="font-mono text-xs text-[#75aafc]">{subtask.description}</div>
+ ) : null}
+
+ {/* Plan */}
+ <div className="space-y-1">
+ <div className="font-mono text-[10px] text-[#555] uppercase">Plan</div>
+ {isEditing ? (
+ <textarea
+ value={editPlan}
+ onChange={(e) => setEditPlan(e.target.value)}
+ className="w-full bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-xs px-2 py-1 outline-none focus:border-[#3f6fb3] min-h-[100px] resize-y"
+ placeholder="Plan/instructions..."
+ />
+ ) : (
+ <pre className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-2 font-mono text-xs text-[#9bc3ff] whitespace-pre-wrap max-h-[150px] overflow-y-auto">
+ {subtask.plan}
+ </pre>
+ )}
+ </div>
+
+ {/* Progress/Error */}
+ {subtask.progressSummary && (
+ <div className="font-mono text-[10px] text-[#75aafc]">
+ <span className="text-[#555]">Progress:</span> {subtask.progressSummary}
+ </div>
+ )}
+ {subtask.errorMessage && (
+ <div className="font-mono text-[10px] text-red-400">
+ <span className="text-red-400/50">Error:</span> {subtask.errorMessage}
+ </div>
+ )}
+
+ {/* Nested subtasks indicator */}
+ {subtask.subtasks.length > 0 && (
+ <div className="font-mono text-[10px] text-[#555]">
+ Has {subtask.subtasks.length} subtask{subtask.subtasks.length > 1 ? "s" : ""}
+ {onNavigate && (
+ <button
+ onClick={() => onNavigate(subtaskId)}
+ className="ml-2 text-[#75aafc] hover:text-[#9bc3ff]"
+ >
+ [view all]
+ </button>
+ )}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/MergeConflictResolver.tsx b/makima/frontend/src/components/mesh/MergeConflictResolver.tsx
new file mode 100644
index 0000000..4479705
--- /dev/null
+++ b/makima/frontend/src/components/mesh/MergeConflictResolver.tsx
@@ -0,0 +1,504 @@
+import { useState, useMemo, useCallback } from "react";
+
+interface ConflictHunk {
+ id: string;
+ filePath: string;
+ startLine: number;
+ endLine: number;
+ ours: string[]; // Changes from current branch
+ theirs: string[]; // Changes from incoming branch
+ base?: string[]; // Original content (if 3-way merge)
+ resolved?: "ours" | "theirs" | "both" | "custom";
+ customResolution?: string[];
+}
+
+interface ConflictFile {
+ path: string;
+ hunks: ConflictHunk[];
+ resolved: boolean;
+}
+
+interface MergeConflictResolverProps {
+ conflicts: ConflictFile[];
+ sourceBranch: string;
+ targetBranch: string;
+ loading?: boolean;
+ onResolve: (resolutions: Map<string, ConflictHunk[]>) => Promise<void>;
+ onAbort: () => void;
+ onAskLLM?: (hunk: ConflictHunk) => Promise<string[]>;
+}
+
+type ResolutionChoice = "ours" | "theirs" | "both" | "custom";
+
+function ConflictHunkView({
+ hunk,
+ sourceBranch,
+ targetBranch,
+ onResolve,
+ onAskLLM,
+}: {
+ hunk: ConflictHunk;
+ sourceBranch: string;
+ targetBranch: string;
+ onResolve: (resolution: ResolutionChoice, customLines?: string[]) => void;
+ onAskLLM?: () => Promise<void>;
+}) {
+ const [showCustomEditor, setShowCustomEditor] = useState(false);
+ const [customText, setCustomText] = useState(
+ hunk.customResolution?.join("\n") || [...hunk.ours, ...hunk.theirs].join("\n")
+ );
+ const [askingLLM, setAskingLLM] = useState(false);
+
+ const handleAskLLM = async () => {
+ if (!onAskLLM || askingLLM) return;
+ setAskingLLM(true);
+ try {
+ await onAskLLM();
+ } finally {
+ setAskingLLM(false);
+ }
+ };
+
+ const handleCustomSave = () => {
+ const lines = customText.split("\n");
+ onResolve("custom", lines);
+ setShowCustomEditor(false);
+ };
+
+ const isResolved = hunk.resolved !== undefined;
+
+ return (
+ <div
+ className={`border ${
+ isResolved
+ ? "border-green-400/30 bg-green-400/5"
+ : "border-yellow-400/30 bg-yellow-400/5"
+ } mb-3`}
+ >
+ {/* Hunk header */}
+ <div className="flex items-center justify-between px-3 py-2 border-b border-[rgba(117,170,252,0.2)]">
+ <div className="font-mono text-xs text-[#75aafc]">
+ Lines {hunk.startLine}-{hunk.endLine}
+ {isResolved && (
+ <span className="ml-2 text-green-400">
+ (Resolved: {hunk.resolved})
+ </span>
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ {onAskLLM && (
+ <button
+ onClick={handleAskLLM}
+ disabled={askingLLM}
+ className="px-2 py-1 font-mono text-[10px] text-purple-400 border border-purple-400/30 hover:border-purple-400/50 disabled:opacity-50 transition-colors"
+ >
+ {askingLLM ? "..." : "Ask LLM"}
+ </button>
+ )}
+ <button
+ onClick={() => setShowCustomEditor(!showCustomEditor)}
+ className="px-2 py-1 font-mono text-[10px] text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ Edit
+ </button>
+ </div>
+ </div>
+
+ {/* Conflict content */}
+ {!showCustomEditor ? (
+ <div className="grid grid-cols-2 divide-x divide-[rgba(117,170,252,0.2)]">
+ {/* Ours (current branch) */}
+ <div className="p-2">
+ <div className="flex items-center justify-between mb-2">
+ <span className="font-mono text-[10px] text-[#9bc3ff] uppercase">
+ {targetBranch} (ours)
+ </span>
+ <button
+ onClick={() => onResolve("ours")}
+ className={`px-2 py-0.5 font-mono text-[9px] border transition-colors ${
+ hunk.resolved === "ours"
+ ? "text-green-400 border-green-400/50 bg-green-400/10"
+ : "text-[#75aafc] border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3]"
+ }`}
+ >
+ Use This
+ </button>
+ </div>
+ <pre className="font-mono text-xs text-red-400 bg-red-400/5 p-2 overflow-x-auto">
+ {hunk.ours.map((line, i) => (
+ <div key={i}>
+ <span className="text-[#555] select-none mr-2">-</span>
+ {line}
+ </div>
+ ))}
+ </pre>
+ </div>
+
+ {/* Theirs (incoming branch) */}
+ <div className="p-2">
+ <div className="flex items-center justify-between mb-2">
+ <span className="font-mono text-[10px] text-[#9bc3ff] uppercase">
+ {sourceBranch} (theirs)
+ </span>
+ <button
+ onClick={() => onResolve("theirs")}
+ className={`px-2 py-0.5 font-mono text-[9px] border transition-colors ${
+ hunk.resolved === "theirs"
+ ? "text-green-400 border-green-400/50 bg-green-400/10"
+ : "text-[#75aafc] border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3]"
+ }`}
+ >
+ Use This
+ </button>
+ </div>
+ <pre className="font-mono text-xs text-green-400 bg-green-400/5 p-2 overflow-x-auto">
+ {hunk.theirs.map((line, i) => (
+ <div key={i}>
+ <span className="text-[#555] select-none mr-2">+</span>
+ {line}
+ </div>
+ ))}
+ </pre>
+ </div>
+ </div>
+ ) : (
+ /* Custom editor */
+ <div className="p-2">
+ <div className="flex items-center justify-between mb-2">
+ <span className="font-mono text-[10px] text-[#9bc3ff] uppercase">
+ Custom Resolution
+ </span>
+ <div className="flex items-center gap-2">
+ <button
+ onClick={() => setShowCustomEditor(false)}
+ className="px-2 py-0.5 font-mono text-[9px] text-[#555] hover:text-[#9bc3ff]"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleCustomSave}
+ className="px-2 py-0.5 font-mono text-[9px] text-green-400 border border-green-400/30 hover:border-green-400/50"
+ >
+ Apply
+ </button>
+ </div>
+ </div>
+ <textarea
+ value={customText}
+ onChange={(e) => setCustomText(e.target.value)}
+ className="w-full bg-[rgba(0,0,0,0.3)] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-xs p-2 outline-none focus:border-[#3f6fb3] min-h-[100px] resize-y"
+ />
+ </div>
+ )}
+
+ {/* Both option */}
+ <div className="px-3 py-2 border-t border-[rgba(117,170,252,0.1)] flex justify-center">
+ <button
+ onClick={() => onResolve("both")}
+ className={`px-3 py-1 font-mono text-[10px] border transition-colors ${
+ hunk.resolved === "both"
+ ? "text-green-400 border-green-400/50 bg-green-400/10"
+ : "text-[#75aafc] border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3]"
+ }`}
+ >
+ Keep Both
+ </button>
+ </div>
+ </div>
+ );
+}
+
+function ConflictFileView({
+ file,
+ sourceBranch,
+ targetBranch,
+ onResolveHunk,
+ onAskLLM,
+}: {
+ file: ConflictFile;
+ sourceBranch: string;
+ targetBranch: string;
+ onResolveHunk: (hunkId: string, resolution: ResolutionChoice, customLines?: string[]) => void;
+ onAskLLM?: (hunk: ConflictHunk) => Promise<string[]>;
+}) {
+ const [expanded, setExpanded] = useState(true);
+ const resolvedCount = file.hunks.filter((h) => h.resolved !== undefined).length;
+
+ return (
+ <div className="border border-[rgba(117,170,252,0.2)] mb-3">
+ {/* File header */}
+ <button
+ onClick={() => setExpanded(!expanded)}
+ className="w-full flex items-center gap-2 px-3 py-2 bg-[rgba(0,0,0,0.2)] hover:bg-[rgba(0,0,0,0.3)] text-left"
+ >
+ <span className="font-mono text-[10px] text-[#555]">
+ {expanded ? "▼" : "▶"}
+ </span>
+ <span
+ className={`px-1.5 py-0.5 font-mono text-[9px] ${
+ file.resolved
+ ? "text-green-400 bg-green-400/10"
+ : "text-yellow-400 bg-yellow-400/10"
+ }`}
+ >
+ {file.resolved ? "RESOLVED" : "CONFLICT"}
+ </span>
+ <span className="font-mono text-sm text-[#dbe7ff] flex-1 truncate">
+ {file.path}
+ </span>
+ <span className="font-mono text-[10px] text-[#555]">
+ {resolvedCount}/{file.hunks.length} hunks
+ </span>
+ </button>
+
+ {/* Hunks */}
+ {expanded && (
+ <div className="p-3">
+ {file.hunks.map((hunk) => (
+ <ConflictHunkView
+ key={hunk.id}
+ hunk={hunk}
+ sourceBranch={sourceBranch}
+ targetBranch={targetBranch}
+ onResolve={(resolution, customLines) =>
+ onResolveHunk(hunk.id, resolution, customLines)
+ }
+ onAskLLM={
+ onAskLLM
+ ? async () => {
+ const resolution = await onAskLLM(hunk);
+ onResolveHunk(hunk.id, "custom", resolution);
+ }
+ : undefined
+ }
+ />
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
+
+export function MergeConflictResolver({
+ conflicts: initialConflicts,
+ sourceBranch,
+ targetBranch,
+ loading = false,
+ onResolve,
+ onAbort,
+ onAskLLM,
+}: MergeConflictResolverProps) {
+ const [conflicts, setConflicts] = useState<ConflictFile[]>(initialConflicts);
+ const [resolving, setResolving] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ // Calculate resolution stats
+ const stats = useMemo(() => {
+ const totalHunks = conflicts.reduce((sum, f) => sum + f.hunks.length, 0);
+ const resolvedHunks = conflicts.reduce(
+ (sum, f) => sum + f.hunks.filter((h) => h.resolved !== undefined).length,
+ 0
+ );
+ const resolvedFiles = conflicts.filter((f) => f.resolved).length;
+ return {
+ totalFiles: conflicts.length,
+ resolvedFiles,
+ totalHunks,
+ resolvedHunks,
+ allResolved: resolvedHunks === totalHunks,
+ };
+ }, [conflicts]);
+
+ // Handle resolving a single hunk
+ const handleResolveHunk = useCallback(
+ (filePath: string, hunkId: string, resolution: ResolutionChoice, customLines?: string[]) => {
+ setConflicts((prev) =>
+ prev.map((file) => {
+ if (file.path !== filePath) return file;
+
+ const updatedHunks = file.hunks.map((hunk) => {
+ if (hunk.id !== hunkId) return hunk;
+ return {
+ ...hunk,
+ resolved: resolution,
+ customResolution: customLines,
+ };
+ });
+
+ const allHunksResolved = updatedHunks.every((h) => h.resolved !== undefined);
+
+ return {
+ ...file,
+ hunks: updatedHunks,
+ resolved: allHunksResolved,
+ };
+ })
+ );
+ },
+ []
+ );
+
+ // Resolve all hunks in a file with same choice
+ const handleResolveFileAll = useCallback(
+ (filePath: string, resolution: ResolutionChoice) => {
+ setConflicts((prev) =>
+ prev.map((file) => {
+ if (file.path !== filePath) return file;
+
+ const updatedHunks = file.hunks.map((hunk) => ({
+ ...hunk,
+ resolved: resolution,
+ customResolution:
+ resolution === "both"
+ ? [...hunk.ours, ...hunk.theirs]
+ : resolution === "ours"
+ ? hunk.ours
+ : resolution === "theirs"
+ ? hunk.theirs
+ : undefined,
+ }));
+
+ return {
+ ...file,
+ hunks: updatedHunks,
+ resolved: true,
+ };
+ })
+ );
+ },
+ []
+ );
+
+ // Apply all resolutions
+ const handleApplyResolutions = async () => {
+ if (!stats.allResolved || resolving) return;
+
+ setResolving(true);
+ setError(null);
+
+ try {
+ const resolutionMap = new Map<string, ConflictHunk[]>();
+ conflicts.forEach((file) => {
+ resolutionMap.set(file.path, file.hunks);
+ });
+ await onResolve(resolutionMap);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to apply resolutions");
+ } finally {
+ setResolving(false);
+ }
+ };
+
+ if (loading) {
+ return (
+ <div className="panel p-4">
+ <div className="flex items-center justify-center h-32">
+ <div className="font-mono text-sm text-[#75aafc]">
+ Analyzing conflicts...
+ </div>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="panel flex flex-col max-h-[80vh] overflow-hidden">
+ {/* Header */}
+ <div className="flex items-center justify-between p-4 border-b border-[rgba(117,170,252,0.2)] shrink-0">
+ <div className="flex items-center gap-3">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Merge Conflicts
+ </div>
+ <span className="px-2 py-0.5 font-mono text-[10px] text-yellow-400 bg-yellow-400/10 border border-yellow-400/20">
+ {sourceBranch} → {targetBranch}
+ </span>
+ </div>
+ <button
+ onClick={onAbort}
+ className="font-mono text-xs text-red-400 hover:text-red-300"
+ >
+ Abort Merge
+ </button>
+ </div>
+
+ {/* Progress */}
+ <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)] shrink-0">
+ <div className="flex items-center justify-between mb-2">
+ <span className="font-mono text-[10px] text-[#75aafc]">
+ {stats.resolvedFiles}/{stats.totalFiles} files resolved
+ </span>
+ <span className="font-mono text-[10px] text-[#75aafc]">
+ {stats.resolvedHunks}/{stats.totalHunks} conflicts resolved
+ </span>
+ </div>
+ <div className="h-1.5 bg-[rgba(117,170,252,0.1)] rounded-full overflow-hidden">
+ <div
+ className="h-full bg-green-400 transition-all"
+ style={{
+ width: `${(stats.resolvedHunks / stats.totalHunks) * 100}%`,
+ }}
+ />
+ </div>
+ </div>
+
+ {/* Error */}
+ {error && (
+ <div className="mx-4 mt-3 bg-red-400/10 border border-red-400/30 p-3 font-mono text-xs text-red-400 shrink-0">
+ {error}
+ </div>
+ )}
+
+ {/* Conflict files */}
+ <div className="flex-1 overflow-y-auto p-4">
+ {conflicts.map((file) => (
+ <ConflictFileView
+ key={file.path}
+ file={file}
+ sourceBranch={sourceBranch}
+ targetBranch={targetBranch}
+ onResolveHunk={(hunkId, resolution, customLines) =>
+ handleResolveHunk(file.path, hunkId, resolution, customLines)
+ }
+ onAskLLM={onAskLLM}
+ />
+ ))}
+ </div>
+
+ {/* Footer actions */}
+ <div className="flex items-center justify-between p-4 border-t border-[rgba(117,170,252,0.2)] shrink-0">
+ <div className="flex items-center gap-2">
+ <button
+ onClick={() =>
+ conflicts.forEach((f) => handleResolveFileAll(f.path, "ours"))
+ }
+ className="px-3 py-1.5 font-mono text-[10px] text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ Accept All Ours
+ </button>
+ <button
+ onClick={() =>
+ conflicts.forEach((f) => handleResolveFileAll(f.path, "theirs"))
+ }
+ className="px-3 py-1.5 font-mono text-[10px] text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ Accept All Theirs
+ </button>
+ </div>
+ <button
+ onClick={handleApplyResolutions}
+ disabled={!stats.allResolved || resolving}
+ className="px-4 py-2 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {resolving
+ ? "Applying..."
+ : stats.allResolved
+ ? "Complete Merge"
+ : `Resolve ${stats.totalHunks - stats.resolvedHunks} Conflicts`}
+ </button>
+ </div>
+ </div>
+ );
+}
+
+// Export types for use in other components
+export type { ConflictHunk, ConflictFile, ResolutionChoice };
diff --git a/makima/frontend/src/components/mesh/OverlayDiffViewer.tsx b/makima/frontend/src/components/mesh/OverlayDiffViewer.tsx
new file mode 100644
index 0000000..74059a0
--- /dev/null
+++ b/makima/frontend/src/components/mesh/OverlayDiffViewer.tsx
@@ -0,0 +1,476 @@
+import { useState, useMemo } from "react";
+
+interface DiffLine {
+ type: "add" | "remove" | "context" | "header" | "hunk";
+ content: string;
+ oldLineNumber?: number;
+ newLineNumber?: number;
+}
+
+interface DiffFile {
+ path: string;
+ status: "added" | "modified" | "deleted" | "renamed";
+ oldPath?: string; // For renames
+ additions: number;
+ deletions: number;
+ lines: DiffLine[];
+}
+
+interface OverlayDiffViewerProps {
+ diff: string;
+ changedFiles?: string[];
+ loading?: boolean;
+ error?: string;
+ onClose?: () => void;
+ title?: string;
+}
+
+function parseDiff(diffText: string): DiffFile[] {
+ if (!diffText.trim()) return [];
+
+ const files: DiffFile[] = [];
+ const lines = diffText.split("\n");
+
+ let currentFile: DiffFile | null = null;
+ let oldLineNum = 0;
+ let newLineNum = 0;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+
+ // File header (diff --git a/path b/path)
+ if (line.startsWith("diff --git")) {
+ if (currentFile) {
+ files.push(currentFile);
+ }
+
+ // Extract paths
+ const match = line.match(/diff --git a\/(.+) b\/(.+)/);
+ const oldPath = match?.[1] || "";
+ const newPath = match?.[2] || oldPath;
+
+ currentFile = {
+ path: newPath,
+ oldPath: oldPath !== newPath ? oldPath : undefined,
+ status: "modified",
+ additions: 0,
+ deletions: 0,
+ lines: [],
+ };
+ continue;
+ }
+
+ if (!currentFile) continue;
+
+ // New file indicator
+ if (line.startsWith("new file mode")) {
+ currentFile.status = "added";
+ continue;
+ }
+
+ // Deleted file indicator
+ if (line.startsWith("deleted file mode")) {
+ currentFile.status = "deleted";
+ continue;
+ }
+
+ // Rename indicator
+ if (line.startsWith("rename from") || line.startsWith("rename to")) {
+ currentFile.status = "renamed";
+ continue;
+ }
+
+ // Hunk header (@@ -1,3 +1,4 @@)
+ if (line.startsWith("@@")) {
+ const hunkMatch = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@/);
+ if (hunkMatch) {
+ oldLineNum = parseInt(hunkMatch[1], 10);
+ newLineNum = parseInt(hunkMatch[2], 10);
+ }
+ currentFile.lines.push({
+ type: "hunk",
+ content: line,
+ });
+ continue;
+ }
+
+ // Skip other headers (---, +++, index, etc.)
+ if (
+ line.startsWith("---") ||
+ line.startsWith("+++") ||
+ line.startsWith("index ") ||
+ line.startsWith("Binary files")
+ ) {
+ currentFile.lines.push({
+ type: "header",
+ content: line,
+ });
+ continue;
+ }
+
+ // Diff content
+ if (line.startsWith("+")) {
+ currentFile.additions++;
+ currentFile.lines.push({
+ type: "add",
+ content: line.substring(1),
+ newLineNumber: newLineNum++,
+ });
+ } else if (line.startsWith("-")) {
+ currentFile.deletions++;
+ currentFile.lines.push({
+ type: "remove",
+ content: line.substring(1),
+ oldLineNumber: oldLineNum++,
+ });
+ } else if (line.startsWith(" ") || line === "") {
+ currentFile.lines.push({
+ type: "context",
+ content: line.substring(1) || "",
+ oldLineNumber: oldLineNum++,
+ newLineNumber: newLineNum++,
+ });
+ }
+ }
+
+ if (currentFile) {
+ files.push(currentFile);
+ }
+
+ return files;
+}
+
+function DiffFileView({ file, collapsed, onToggle }: { file: DiffFile; collapsed: boolean; onToggle: () => void }) {
+ const statusColors: Record<DiffFile["status"], string> = {
+ added: "text-green-400 bg-green-400/10",
+ modified: "text-yellow-400 bg-yellow-400/10",
+ deleted: "text-red-400 bg-red-400/10",
+ renamed: "text-purple-400 bg-purple-400/10",
+ };
+
+ const statusLabels: Record<DiffFile["status"], string> = {
+ added: "A",
+ modified: "M",
+ deleted: "D",
+ renamed: "R",
+ };
+
+ return (
+ <div className="border border-[rgba(117,170,252,0.2)] mb-2">
+ {/* File header */}
+ <button
+ onClick={onToggle}
+ className="w-full flex items-center gap-2 px-3 py-2 bg-[rgba(0,0,0,0.2)] hover:bg-[rgba(0,0,0,0.3)] transition-colors text-left"
+ >
+ <span className="font-mono text-[10px] text-[#555]">
+ {collapsed ? "▶" : "▼"}
+ </span>
+ <span
+ className={`px-1.5 py-0.5 font-mono text-[9px] font-bold ${statusColors[file.status]}`}
+ >
+ {statusLabels[file.status]}
+ </span>
+ <span className="font-mono text-sm text-[#dbe7ff] flex-1 truncate">
+ {file.oldPath ? (
+ <>
+ <span className="text-[#555]">{file.oldPath}</span>
+ <span className="text-[#75aafc] mx-1">→</span>
+ {file.path}
+ </>
+ ) : (
+ file.path
+ )}
+ </span>
+ <span className="font-mono text-[10px]">
+ {file.additions > 0 && (
+ <span className="text-green-400 mr-2">+{file.additions}</span>
+ )}
+ {file.deletions > 0 && (
+ <span className="text-red-400">-{file.deletions}</span>
+ )}
+ </span>
+ </button>
+
+ {/* File content */}
+ {!collapsed && (
+ <div className="overflow-x-auto">
+ <table className="w-full font-mono text-xs">
+ <tbody>
+ {file.lines.map((line, i) => {
+ if (line.type === "header" || line.type === "hunk") {
+ return (
+ <tr
+ key={i}
+ className="bg-[rgba(117,170,252,0.05)]"
+ >
+ <td
+ colSpan={3}
+ className="px-2 py-0.5 text-[#75aafc] select-none"
+ >
+ {line.content}
+ </td>
+ </tr>
+ );
+ }
+
+ const bgColor =
+ line.type === "add"
+ ? "bg-green-400/10"
+ : line.type === "remove"
+ ? "bg-red-400/10"
+ : "";
+
+ const textColor =
+ line.type === "add"
+ ? "text-green-400"
+ : line.type === "remove"
+ ? "text-red-400"
+ : "text-[#9bc3ff]";
+
+ const prefix =
+ line.type === "add"
+ ? "+"
+ : line.type === "remove"
+ ? "-"
+ : " ";
+
+ return (
+ <tr key={i} className={bgColor}>
+ {/* Old line number */}
+ <td className="w-10 px-2 py-0 text-right text-[#555] select-none border-r border-[rgba(117,170,252,0.1)]">
+ {line.type !== "add" ? line.oldLineNumber : ""}
+ </td>
+ {/* New line number */}
+ <td className="w-10 px-2 py-0 text-right text-[#555] select-none border-r border-[rgba(117,170,252,0.1)]">
+ {line.type !== "remove" ? line.newLineNumber : ""}
+ </td>
+ {/* Content */}
+ <td className={`px-2 py-0 whitespace-pre ${textColor}`}>
+ <span className="select-none mr-1 text-[#555]">
+ {prefix}
+ </span>
+ {line.content}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ )}
+ </div>
+ );
+}
+
+export function OverlayDiffViewer({
+ diff,
+ changedFiles,
+ loading,
+ error,
+ onClose,
+ title = "Overlay Changes",
+}: OverlayDiffViewerProps) {
+ const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
+ const [showFullDiff, setShowFullDiff] = useState(true);
+
+ const parsedFiles = useMemo(() => parseDiff(diff), [diff]);
+
+ const toggleFile = (path: string) => {
+ setCollapsedFiles((prev) => {
+ const next = new Set(prev);
+ if (next.has(path)) {
+ next.delete(path);
+ } else {
+ next.add(path);
+ }
+ return next;
+ });
+ };
+
+ const expandAll = () => setCollapsedFiles(new Set());
+ const collapseAll = () => setCollapsedFiles(new Set(parsedFiles.map((f) => f.path)));
+
+ // Calculate totals
+ const totals = useMemo(() => {
+ return parsedFiles.reduce(
+ (acc, file) => ({
+ additions: acc.additions + file.additions,
+ deletions: acc.deletions + file.deletions,
+ files: acc.files + 1,
+ }),
+ { additions: 0, deletions: 0, files: 0 }
+ );
+ }, [parsedFiles]);
+
+ if (loading) {
+ return (
+ <div className="panel p-4">
+ <div className="flex items-center justify-center h-32">
+ <div className="font-mono text-sm text-[#75aafc]">
+ Loading diff...
+ </div>
+ </div>
+ </div>
+ );
+ }
+
+ if (error) {
+ return (
+ <div className="panel p-4">
+ <div className="flex items-center justify-between mb-3">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ {title}
+ </div>
+ {onClose && (
+ <button
+ onClick={onClose}
+ className="font-mono text-xs text-[#555] hover:text-[#9bc3ff]"
+ >
+ Close
+ </button>
+ )}
+ </div>
+ <div className="bg-red-400/10 border border-red-400/30 p-3 font-mono text-sm text-red-400">
+ {error}
+ </div>
+ </div>
+ );
+ }
+
+ if (!diff.trim() && (!changedFiles || changedFiles.length === 0)) {
+ return (
+ <div className="panel p-4">
+ <div className="flex items-center justify-between mb-3">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ {title}
+ </div>
+ {onClose && (
+ <button
+ onClick={onClose}
+ className="font-mono text-xs text-[#555] hover:text-[#9bc3ff]"
+ >
+ Close
+ </button>
+ )}
+ </div>
+ <div className="text-center py-8 font-mono text-sm text-[#555]">
+ No changes detected in overlay
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="panel flex flex-col max-h-[600px]">
+ {/* Header */}
+ <div className="flex items-center justify-between p-3 border-b border-[rgba(117,170,252,0.2)] shrink-0">
+ <div className="flex items-center gap-4">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ {title}
+ </div>
+ <div className="font-mono text-[10px] text-[#75aafc]">
+ {totals.files} file{totals.files !== 1 ? "s" : ""} changed
+ {totals.additions > 0 && (
+ <span className="text-green-400 ml-2">+{totals.additions}</span>
+ )}
+ {totals.deletions > 0 && (
+ <span className="text-red-400 ml-2">-{totals.deletions}</span>
+ )}
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ <button
+ onClick={expandAll}
+ className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff]"
+ >
+ Expand All
+ </button>
+ <button
+ onClick={collapseAll}
+ className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff]"
+ >
+ Collapse All
+ </button>
+ <button
+ onClick={() => setShowFullDiff(!showFullDiff)}
+ className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff]"
+ >
+ {showFullDiff ? "File List" : "Full Diff"}
+ </button>
+ {onClose && (
+ <button
+ onClick={onClose}
+ className="font-mono text-xs text-[#555] hover:text-[#9bc3ff] ml-2"
+ >
+ Close
+ </button>
+ )}
+ </div>
+ </div>
+
+ {/* Content */}
+ <div className="flex-1 overflow-y-auto p-3">
+ {showFullDiff ? (
+ // Full diff view
+ parsedFiles.length > 0 ? (
+ parsedFiles.map((file) => (
+ <DiffFileView
+ key={file.path}
+ file={file}
+ collapsed={collapsedFiles.has(file.path)}
+ onToggle={() => toggleFile(file.path)}
+ />
+ ))
+ ) : (
+ // Fallback to raw diff
+ <pre className="font-mono text-xs text-[#9bc3ff] whitespace-pre-wrap">
+ {diff}
+ </pre>
+ )
+ ) : (
+ // File list view
+ <div className="space-y-1">
+ {(changedFiles || parsedFiles.map((f) => f.path)).map((path) => {
+ const file = parsedFiles.find((f) => f.path === path);
+ return (
+ <div
+ key={path}
+ className="flex items-center gap-2 px-2 py-1 hover:bg-[rgba(117,170,252,0.05)]"
+ >
+ {file && (
+ <span
+ className={`px-1 py-0.5 font-mono text-[9px] ${
+ file.status === "added"
+ ? "text-green-400 bg-green-400/10"
+ : file.status === "deleted"
+ ? "text-red-400 bg-red-400/10"
+ : file.status === "renamed"
+ ? "text-purple-400 bg-purple-400/10"
+ : "text-yellow-400 bg-yellow-400/10"
+ }`}
+ >
+ {file.status.charAt(0).toUpperCase()}
+ </span>
+ )}
+ <span className="font-mono text-sm text-[#dbe7ff] flex-1 truncate">
+ {path}
+ </span>
+ {file && (
+ <span className="font-mono text-[10px]">
+ {file.additions > 0 && (
+ <span className="text-green-400 mr-1">+{file.additions}</span>
+ )}
+ {file.deletions > 0 && (
+ <span className="text-red-400">-{file.deletions}</span>
+ )}
+ </span>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/PRPreview.tsx b/makima/frontend/src/components/mesh/PRPreview.tsx
new file mode 100644
index 0000000..fc202b0
--- /dev/null
+++ b/makima/frontend/src/components/mesh/PRPreview.tsx
@@ -0,0 +1,314 @@
+import { useState, useMemo } from "react";
+import type { TaskWithSubtasks, TaskSummary } from "../../lib/api";
+import { OverlayDiffViewer } from "./OverlayDiffViewer";
+
+interface PRPreviewProps {
+ task: TaskWithSubtasks;
+ diff?: string;
+ changedFiles?: string[];
+ loading?: boolean;
+ onCreatePR?: (title: string, body: string, draft: boolean) => Promise<void>;
+ onAutoMerge?: () => Promise<void>;
+ onClose: () => void;
+}
+
+interface PRFormData {
+ title: string;
+ body: string;
+ isDraft: boolean;
+}
+
+function generatePRTitle(task: TaskWithSubtasks): string {
+ // Generate a PR title based on the task name
+ const prefix = task.parentTaskId ? "feat" : "feat";
+ return `${prefix}: ${task.name}`;
+}
+
+function generatePRBody(task: TaskWithSubtasks, changedFiles?: string[]): string {
+ const sections: string[] = [];
+
+ // Summary
+ sections.push("## Summary\n");
+ if (task.description) {
+ sections.push(task.description + "\n");
+ } else {
+ sections.push("_Add a brief description of the changes..._\n");
+ }
+
+ // Plan/Implementation details
+ sections.push("\n## Implementation\n");
+ if (task.plan) {
+ // Truncate if too long
+ const planPreview = task.plan.length > 500
+ ? task.plan.substring(0, 500) + "..."
+ : task.plan;
+ sections.push("```\n" + planPreview + "\n```\n");
+ }
+
+ // Subtasks summary
+ if (task.subtasks.length > 0) {
+ sections.push("\n## Subtasks\n");
+ task.subtasks.forEach((subtask: TaskSummary) => {
+ const emoji = subtask.status === "done" || subtask.status === "merged" ? "+" :
+ subtask.status === "running" ? "~" : "-";
+ sections.push(`- [${emoji === "+" ? "x" : " "}] ${subtask.name} (${subtask.status})\n`);
+ });
+ }
+
+ // Changed files
+ if (changedFiles && changedFiles.length > 0) {
+ sections.push("\n## Changed Files\n");
+ changedFiles.slice(0, 20).forEach((file) => {
+ sections.push(`- \`${file}\`\n`);
+ });
+ if (changedFiles.length > 20) {
+ sections.push(`\n_...and ${changedFiles.length - 20} more files_\n`);
+ }
+ }
+
+ // Test plan
+ sections.push("\n## Test Plan\n");
+ sections.push("- [ ] Manual testing completed\n");
+ sections.push("- [ ] Unit tests added/updated\n");
+ sections.push("- [ ] Integration tests passing\n");
+
+ // Footer
+ sections.push("\n---\n");
+ sections.push("_Generated by makima mesh orchestrator_\n");
+
+ return sections.join("");
+}
+
+export function PRPreview({
+ task,
+ diff = "",
+ changedFiles = [],
+ loading = false,
+ onCreatePR,
+ onAutoMerge,
+ onClose,
+}: PRPreviewProps) {
+ const [showDiff, setShowDiff] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const [formData, setFormData] = useState<PRFormData>(() => ({
+ title: generatePRTitle(task),
+ body: generatePRBody(task, changedFiles),
+ isDraft: false,
+ }));
+
+ const handleCreatePR = async () => {
+ if (!onCreatePR || creating) return;
+
+ setCreating(true);
+ setError(null);
+
+ try {
+ await onCreatePR(formData.title, formData.body, formData.isDraft);
+ onClose();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to create PR");
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ const handleAutoMerge = async () => {
+ if (!onAutoMerge || creating) return;
+
+ if (!confirm("Are you sure you want to auto-merge this task directly to the target branch?")) {
+ return;
+ }
+
+ setCreating(true);
+ setError(null);
+
+ try {
+ await onAutoMerge();
+ onClose();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to auto-merge");
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ // Calculate stats
+ const stats = useMemo(() => {
+ const completedSubtasks = task.subtasks.filter(
+ (s) => s.status === "done" || s.status === "merged"
+ ).length;
+ return {
+ filesChanged: changedFiles.length,
+ subtasksCompleted: completedSubtasks,
+ subtasksTotal: task.subtasks.length,
+ isReady: completedSubtasks === task.subtasks.length || task.subtasks.length === 0,
+ };
+ }, [task.subtasks, changedFiles]);
+
+ return (
+ <div className="panel flex flex-col max-h-[80vh] overflow-hidden">
+ {/* Header */}
+ <div className="flex items-center justify-between p-4 border-b border-[rgba(117,170,252,0.2)] shrink-0">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Create Pull Request
+ </div>
+ <button
+ onClick={onClose}
+ className="font-mono text-xs text-[#555] hover:text-[#9bc3ff]"
+ >
+ Cancel
+ </button>
+ </div>
+
+ {/* Content */}
+ <div className="flex-1 overflow-y-auto p-4 space-y-4">
+ {/* Status badges */}
+ <div className="flex flex-wrap gap-2">
+ <span className="px-2 py-0.5 font-mono text-[10px] text-[#75aafc] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.2)]">
+ {task.baseBranch || "main"} → {task.targetBranch || task.baseBranch || "main"}
+ </span>
+ <span className="px-2 py-0.5 font-mono text-[10px] text-[#9bc3ff] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.2)]">
+ {stats.filesChanged} files changed
+ </span>
+ {task.subtasks.length > 0 && (
+ <span
+ className={`px-2 py-0.5 font-mono text-[10px] border ${
+ stats.isReady
+ ? "text-green-400 bg-green-400/10 border-green-400/20"
+ : "text-yellow-400 bg-yellow-400/10 border-yellow-400/20"
+ }`}
+ >
+ {stats.subtasksCompleted}/{stats.subtasksTotal} subtasks complete
+ </span>
+ )}
+ </div>
+
+ {/* Warning if subtasks not complete */}
+ {!stats.isReady && (
+ <div className="bg-yellow-400/10 border border-yellow-400/30 p-3 font-mono text-xs text-yellow-400">
+ Some subtasks are not yet complete. Consider waiting before creating the PR.
+ </div>
+ )}
+
+ {/* Error message */}
+ {error && (
+ <div className="bg-red-400/10 border border-red-400/30 p-3 font-mono text-xs text-red-400">
+ {error}
+ </div>
+ )}
+
+ {/* PR Title */}
+ <div className="space-y-2">
+ <label className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Title
+ </label>
+ <input
+ type="text"
+ value={formData.title}
+ onChange={(e) => setFormData({ ...formData, title: e.target.value })}
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]"
+ placeholder="PR title"
+ disabled={creating}
+ />
+ </div>
+
+ {/* PR Body */}
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <label className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Description
+ </label>
+ <button
+ onClick={() => setFormData({
+ ...formData,
+ body: generatePRBody(task, changedFiles),
+ })}
+ className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff]"
+ >
+ Regenerate
+ </button>
+ </div>
+ <textarea
+ value={formData.body}
+ onChange={(e) => setFormData({ ...formData, body: e.target.value })}
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-xs px-3 py-2 outline-none focus:border-[#3f6fb3] min-h-[200px] resize-y"
+ placeholder="PR description (markdown)"
+ disabled={creating}
+ />
+ </div>
+
+ {/* Options */}
+ <div className="flex items-center gap-4">
+ <label className="flex items-center gap-2 cursor-pointer">
+ <input
+ type="checkbox"
+ checked={formData.isDraft}
+ onChange={(e) => setFormData({ ...formData, isDraft: e.target.checked })}
+ className="w-4 h-4 accent-[#75aafc]"
+ disabled={creating}
+ />
+ <span className="font-mono text-xs text-[#9bc3ff]">Create as draft</span>
+ </label>
+ </div>
+
+ {/* Diff preview toggle */}
+ <div className="border-t border-[rgba(117,170,252,0.2)] pt-4">
+ <button
+ onClick={() => setShowDiff(!showDiff)}
+ className="flex items-center gap-2 font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff]"
+ >
+ <span>{showDiff ? "▼" : "▶"}</span>
+ <span>
+ {showDiff ? "Hide" : "Show"} diff preview ({stats.filesChanged} files)
+ </span>
+ </button>
+ </div>
+
+ {/* Inline diff viewer */}
+ {showDiff && (
+ <div className="border border-[rgba(117,170,252,0.2)]">
+ <OverlayDiffViewer
+ diff={diff}
+ changedFiles={changedFiles}
+ loading={loading}
+ title="Changes to be merged"
+ />
+ </div>
+ )}
+ </div>
+
+ {/* Footer actions */}
+ <div className="flex items-center justify-between p-4 border-t border-[rgba(117,170,252,0.2)] shrink-0">
+ <div className="font-mono text-[10px] text-[#555]">
+ {task.repositoryUrl && (
+ <span className="truncate max-w-[200px] inline-block align-middle">
+ {task.repositoryUrl}
+ </span>
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ {task.mergeMode === "auto" && onAutoMerge && (
+ <button
+ onClick={handleAutoMerge}
+ disabled={creating || !stats.isReady}
+ className="px-4 py-2 font-mono text-xs text-yellow-400 border border-yellow-400/30 hover:border-yellow-400/50 hover:bg-yellow-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {creating ? "..." : "Auto-Merge"}
+ </button>
+ )}
+ {onCreatePR && (
+ <button
+ onClick={handleCreatePR}
+ disabled={creating || !formData.title.trim()}
+ className="px-4 py-2 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {creating ? "Creating..." : formData.isDraft ? "Create Draft PR" : "Create PR"}
+ </button>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/SubtaskTree.tsx b/makima/frontend/src/components/mesh/SubtaskTree.tsx
new file mode 100644
index 0000000..176b7a7
--- /dev/null
+++ b/makima/frontend/src/components/mesh/SubtaskTree.tsx
@@ -0,0 +1,297 @@
+import { useState, useCallback } from "react";
+import type { TaskSummary, TaskStatus } from "../../lib/api";
+
+interface SubtaskTreeProps {
+ subtasks: TaskSummary[];
+ onSelect: (taskId: string) => void;
+ depth?: number;
+ loading?: boolean;
+ fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>;
+}
+
+interface TreeNodeProps {
+ task: TaskSummary;
+ onSelect: (taskId: string) => void;
+ depth: number;
+ fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>;
+}
+
+function getStatusColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "text-[#9bc3ff]";
+ case "running":
+ return "text-green-400";
+ case "paused":
+ return "text-yellow-400";
+ case "blocked":
+ return "text-orange-400";
+ case "done":
+ return "text-emerald-400";
+ case "failed":
+ return "text-red-400";
+ case "merged":
+ return "text-purple-400";
+ default:
+ return "text-[#9bc3ff]";
+ }
+}
+
+function getStatusIcon(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "○";
+ case "running":
+ return "◉";
+ case "paused":
+ return "◎";
+ case "blocked":
+ return "◈";
+ case "done":
+ return "●";
+ case "failed":
+ return "✕";
+ case "merged":
+ return "◆";
+ default:
+ return "○";
+ }
+}
+
+function TreeNode({ task, onSelect, depth, fetchSubtasks }: TreeNodeProps) {
+ const [expanded, setExpanded] = useState(false);
+ const [children, setChildren] = useState<TaskSummary[] | null>(null);
+ const [loadingChildren, setLoadingChildren] = useState(false);
+
+ const hasSubtasks = task.subtaskCount > 0;
+
+ const handleToggle = useCallback(async () => {
+ if (!hasSubtasks) return;
+
+ if (expanded) {
+ setExpanded(false);
+ } else {
+ if (!children && fetchSubtasks) {
+ setLoadingChildren(true);
+ try {
+ const subtasks = await fetchSubtasks(task.id);
+ setChildren(subtasks);
+ } catch (err) {
+ console.error("Failed to fetch subtasks:", err);
+ } finally {
+ setLoadingChildren(false);
+ }
+ }
+ setExpanded(true);
+ }
+ }, [expanded, children, hasSubtasks, task.id, fetchSubtasks]);
+
+ const indent = depth * 16;
+
+ return (
+ <div className="select-none">
+ <div
+ className="flex items-center gap-2 py-1.5 px-2 hover:bg-[rgba(117,170,252,0.05)] transition-colors cursor-pointer group"
+ style={{ paddingLeft: `${indent + 8}px` }}
+ >
+ {/* Expand/Collapse button */}
+ <button
+ onClick={handleToggle}
+ className={`w-4 h-4 flex items-center justify-center font-mono text-[10px] ${
+ hasSubtasks
+ ? "text-[#75aafc] hover:text-[#9bc3ff]"
+ : "text-transparent cursor-default"
+ }`}
+ disabled={!hasSubtasks}
+ >
+ {loadingChildren ? (
+ <span className="animate-spin">⌛</span>
+ ) : hasSubtasks ? (
+ expanded ? "▼" : "▶"
+ ) : (
+ ""
+ )}
+ </button>
+
+ {/* Status icon */}
+ <span
+ className={`font-mono text-xs ${getStatusColor(task.status)}`}
+ title={task.status}
+ >
+ {getStatusIcon(task.status)}
+ </span>
+
+ {/* Task name - clickable */}
+ <button
+ onClick={() => onSelect(task.id)}
+ className="flex-1 text-left font-mono text-sm text-[#dbe7ff] hover:text-white transition-colors truncate"
+ >
+ {task.name}
+ </button>
+
+ {/* Subtask count badge */}
+ {hasSubtasks && (
+ <span className="font-mono text-[9px] text-[#555] group-hover:text-[#75aafc]">
+ {task.subtaskCount} sub
+ </span>
+ )}
+
+ {/* Priority indicator */}
+ {task.priority > 0 && (
+ <span className="font-mono text-[9px] text-orange-400">
+ P{task.priority}
+ </span>
+ )}
+ </div>
+
+ {/* Children */}
+ {expanded && children && children.length > 0 && (
+ <div className="border-l border-[rgba(117,170,252,0.15)]" style={{ marginLeft: `${indent + 16}px` }}>
+ {children.map((child) => (
+ <TreeNode
+ key={child.id}
+ task={child}
+ onSelect={onSelect}
+ depth={depth + 1}
+ fetchSubtasks={fetchSubtasks}
+ />
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
+
+export function SubtaskTree({
+ subtasks,
+ onSelect,
+ depth = 0,
+ loading = false,
+ fetchSubtasks,
+}: SubtaskTreeProps) {
+ if (loading) {
+ return (
+ <div className="p-4 text-center font-mono text-xs text-[#555]">
+ Loading subtasks...
+ </div>
+ );
+ }
+
+ if (subtasks.length === 0) {
+ return (
+ <div className="p-4 text-center font-mono text-xs text-[#555]">
+ No subtasks
+ </div>
+ );
+ }
+
+ return (
+ <div className="divide-y divide-[rgba(117,170,252,0.1)]">
+ {subtasks.map((task) => (
+ <TreeNode
+ key={task.id}
+ task={task}
+ onSelect={onSelect}
+ depth={depth}
+ fetchSubtasks={fetchSubtasks}
+ />
+ ))}
+ </div>
+ );
+}
+
+// Aggregated status summary for a task tree
+export interface TaskTreeStats {
+ total: number;
+ pending: number;
+ running: number;
+ paused: number;
+ blocked: number;
+ done: number;
+ failed: number;
+ merged: number;
+}
+
+export function calculateTreeStats(subtasks: TaskSummary[]): TaskTreeStats {
+ const stats: TaskTreeStats = {
+ total: subtasks.length,
+ pending: 0,
+ running: 0,
+ paused: 0,
+ blocked: 0,
+ done: 0,
+ failed: 0,
+ merged: 0,
+ };
+
+ for (const task of subtasks) {
+ switch (task.status) {
+ case "pending":
+ stats.pending++;
+ break;
+ case "running":
+ stats.running++;
+ break;
+ case "paused":
+ stats.paused++;
+ break;
+ case "blocked":
+ stats.blocked++;
+ break;
+ case "done":
+ stats.done++;
+ break;
+ case "failed":
+ stats.failed++;
+ break;
+ case "merged":
+ stats.merged++;
+ break;
+ }
+ }
+
+ return stats;
+}
+
+// Visual summary bar
+export function SubtaskProgressBar({ stats }: { stats: TaskTreeStats }) {
+ if (stats.total === 0) return null;
+
+ const segments = [
+ { count: stats.merged, color: "bg-purple-400", label: "Merged" },
+ { count: stats.done, color: "bg-emerald-400", label: "Done" },
+ { count: stats.running, color: "bg-green-400", label: "Running" },
+ { count: stats.paused, color: "bg-yellow-400", label: "Paused" },
+ { count: stats.blocked, color: "bg-orange-400", label: "Blocked" },
+ { count: stats.pending, color: "bg-[#75aafc]", label: "Pending" },
+ { count: stats.failed, color: "bg-red-400", label: "Failed" },
+ ].filter((s) => s.count > 0);
+
+ return (
+ <div className="space-y-1">
+ {/* Progress bar */}
+ <div className="h-2 flex overflow-hidden rounded-sm">
+ {segments.map((segment, i) => (
+ <div
+ key={i}
+ className={`${segment.color} transition-all`}
+ style={{ width: `${(segment.count / stats.total) * 100}%` }}
+ title={`${segment.label}: ${segment.count}`}
+ />
+ ))}
+ </div>
+
+ {/* Legend */}
+ <div className="flex flex-wrap gap-3 font-mono text-[9px]">
+ {segments.map((segment, i) => (
+ <div key={i} className="flex items-center gap-1">
+ <div className={`w-2 h-2 ${segment.color} rounded-sm`} />
+ <span className="text-[#75aafc]">
+ {segment.label}: {segment.count}
+ </span>
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/TaskDetail.tsx b/makima/frontend/src/components/mesh/TaskDetail.tsx
new file mode 100644
index 0000000..be4fb80
--- /dev/null
+++ b/makima/frontend/src/components/mesh/TaskDetail.tsx
@@ -0,0 +1,886 @@
+import { useState, useCallback, useMemo, useEffect } from "react";
+import type { TaskWithSubtasks, TaskStatus, TaskSummary, CompletionAction, DaemonDirectory } from "../../lib/api";
+import { retryCompletionAction, getDaemonDirectories, cloneWorktree } from "../../lib/api";
+import { SubtaskTree, SubtaskProgressBar, calculateTreeStats } from "./SubtaskTree";
+import { OverlayDiffViewer } from "./OverlayDiffViewer";
+import { PRPreview } from "./PRPreview";
+import { InlineSubtaskEditor } from "./InlineSubtaskEditor";
+import { DirectoryInput } from "./DirectoryInput";
+
+interface TaskDetailProps {
+ task: TaskWithSubtasks;
+ loading: boolean;
+ onBack: () => void;
+ onSave: (taskId: string, name: string, description: string, plan: string, targetRepoPath?: string, completionAction?: CompletionAction) => void;
+ onDelete: (taskId: string) => void;
+ onStart: (taskId: string) => void;
+ onStop: (taskId: string) => void;
+ onRestart: (taskId: string) => void;
+ onContinue: (taskId: string) => void;
+ onSelectSubtask: (taskId: string) => void;
+ onCreateSubtask: () => void;
+ /** Toggle viewing a subtask's output (for running subtasks) */
+ onToggleSubtaskOutput?: (subtaskId: string, subtaskName: string) => void;
+ /** Which subtask's output is currently being viewed */
+ viewingSubtaskId?: string | null;
+ // Optional advanced features
+ overlayDiff?: string;
+ changedFiles?: string[];
+ onRequestDiff?: () => void;
+ onCreatePR?: (title: string, body: string, draft: boolean) => Promise<void>;
+ onAutoMerge?: () => Promise<void>;
+ fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>;
+}
+
+function formatDate(dateStr: string): string {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+function getStatusColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "text-[#9bc3ff]";
+ case "initializing":
+ case "starting":
+ return "text-cyan-400";
+ case "running":
+ return "text-green-400";
+ case "paused":
+ return "text-yellow-400";
+ case "blocked":
+ return "text-orange-400";
+ case "done":
+ return "text-emerald-400";
+ case "failed":
+ return "text-red-400";
+ case "merged":
+ return "text-purple-400";
+ default:
+ return "text-[#9bc3ff]";
+ }
+}
+
+function getStatusBgColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "bg-[rgba(117,170,252,0.1)]";
+ case "initializing":
+ case "starting":
+ return "bg-cyan-400/10";
+ case "running":
+ return "bg-green-400/10";
+ case "paused":
+ return "bg-yellow-400/10";
+ case "blocked":
+ return "bg-orange-400/10";
+ case "done":
+ return "bg-emerald-400/10";
+ case "failed":
+ return "bg-red-400/10";
+ case "merged":
+ return "bg-purple-400/10";
+ default:
+ return "bg-[rgba(117,170,252,0.1)]";
+ }
+}
+
+export function TaskDetail({
+ task,
+ loading,
+ onBack,
+ onSave,
+ onDelete,
+ onStart,
+ onStop,
+ onRestart,
+ onContinue,
+ onSelectSubtask,
+ onCreateSubtask,
+ onToggleSubtaskOutput,
+ viewingSubtaskId,
+ overlayDiff,
+ changedFiles,
+ onRequestDiff,
+ onCreatePR,
+ onAutoMerge,
+ fetchSubtasks,
+}: TaskDetailProps) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [editName, setEditName] = useState(task.name);
+ const [editDescription, setEditDescription] = useState(task.description || "");
+ const [editPlan, setEditPlan] = useState(task.plan);
+ const [editTargetRepoPath, setEditTargetRepoPath] = useState(task.targetRepoPath || "");
+ const [editCompletionAction, setEditCompletionAction] = useState<CompletionAction>(
+ (task.completionAction as CompletionAction) || "none"
+ );
+ const [showDiff, setShowDiff] = useState(false);
+ const [showPRPreview, setShowPRPreview] = useState(false);
+ const [useTreeView, setUseTreeView] = useState(false);
+ // Track which subtask is expanded for inline editing
+ const [expandedSubtaskId, setExpandedSubtaskId] = useState<string | null>(null);
+ // Track interrupt dropdown state
+ const [showInterruptMenu, setShowInterruptMenu] = useState(false);
+ // Track retry completion action state
+ const [isRetryingCompletion, setIsRetryingCompletion] = useState(false);
+ const [retryError, setRetryError] = useState<string | null>(null);
+ // Suggested directories from daemon
+ const [suggestedDirectories, setSuggestedDirectories] = useState<DaemonDirectory[]>([]);
+ // Track clone worktree state
+ const [isCloning, setIsCloning] = useState(false);
+ const [cloneError, setCloneError] = useState<string | null>(null);
+ const [cloneTargetDir, setCloneTargetDir] = useState("");
+
+ // Check if task is running
+ const isTaskRunning = task.status === "running" || task.status === "initializing" || task.status === "starting";
+ // Check if task is in a terminal state (can be continued/reopened)
+ const isTaskTerminal = task.status === "done" || task.status === "failed" || task.status === "merged";
+
+ // Calculate subtask statistics
+ const subtaskStats = useMemo(
+ () => calculateTreeStats(task.subtasks),
+ [task.subtasks]
+ );
+
+ // Check if task can create PR
+ const canCreatePR = useMemo(() => {
+ return (
+ (task.status === "done" || task.status === "merged") &&
+ task.repositoryUrl &&
+ (onCreatePR || onAutoMerge)
+ );
+ }, [task.status, task.repositoryUrl, onCreatePR, onAutoMerge]);
+
+ // Check if task can retry completion action
+ const canRetryCompletion = useMemo(() => {
+ return (
+ (task.status === "done" || task.status === "failed" || task.status === "merged") &&
+ task.completionAction &&
+ task.completionAction !== "none" &&
+ task.targetRepoPath
+ // Note: overlayPath may be null in server DB even if worktree exists on daemon
+ // The daemon will scan for the worktree by task ID
+ );
+ }, [task.status, task.completionAction, task.targetRepoPath]);
+
+ // Handler for retrying completion action
+ const handleRetryCompletion = useCallback(async () => {
+ setIsRetryingCompletion(true);
+ setRetryError(null);
+ try {
+ await retryCompletionAction(task.id);
+ // Success - the result will be shown in task output
+ } catch (e) {
+ setRetryError(e instanceof Error ? e.message : "Failed to retry completion action");
+ } finally {
+ setIsRetryingCompletion(false);
+ }
+ }, [task.id]);
+
+ // Check if task can clone worktree
+ const canCloneWorktree = useMemo(() => {
+ return (
+ (task.status === "done" || task.status === "failed" || task.status === "merged")
+ );
+ }, [task.status]);
+
+ // Handler for cloning worktree
+ const handleCloneWorktree = useCallback(async () => {
+ if (!cloneTargetDir.trim()) {
+ setCloneError("Please enter a target directory");
+ return;
+ }
+ setIsCloning(true);
+ setCloneError(null);
+ try {
+ await cloneWorktree(task.id, cloneTargetDir);
+ // Success - the result will be shown in task output
+ setCloneTargetDir(""); // Clear input on success
+ } catch (e) {
+ setCloneError(e instanceof Error ? e.message : "Failed to clone worktree");
+ } finally {
+ setIsCloning(false);
+ }
+ }, [task.id, cloneTargetDir]);
+
+ // Fetch suggested directories when entering edit mode or when clone section is visible
+ useEffect(() => {
+ if (isEditing || canCloneWorktree) {
+ getDaemonDirectories()
+ .then((res) => setSuggestedDirectories(res.directories))
+ .catch(() => setSuggestedDirectories([]));
+ }
+ }, [isEditing, canCloneWorktree]);
+
+ const handleSave = useCallback(() => {
+ onSave(
+ task.id,
+ editName,
+ editDescription,
+ editPlan,
+ editTargetRepoPath || undefined,
+ editCompletionAction
+ );
+ setIsEditing(false);
+ }, [task.id, editName, editDescription, editPlan, editTargetRepoPath, editCompletionAction, onSave]);
+
+ const handleCancel = useCallback(() => {
+ setEditName(task.name);
+ setEditDescription(task.description || "");
+ setEditPlan(task.plan);
+ setEditTargetRepoPath(task.targetRepoPath || "");
+ setEditCompletionAction((task.completionAction as CompletionAction) || "none");
+ setIsEditing(false);
+ }, [task]);
+
+ // Toggle subtask expansion for inline editing
+ const handleSubtaskToggle = useCallback((subtaskId: string) => {
+ setExpandedSubtaskId((prev) => (prev === subtaskId ? null : subtaskId));
+ }, []);
+
+ // Handle subtask click - toggle output view for any task status
+ const handleSubtaskClick = useCallback(
+ (subtask: TaskSummary) => {
+ if (onToggleSubtaskOutput) {
+ // Toggle viewing this subtask's output (works for any status)
+ onToggleSubtaskOutput(subtask.id, subtask.name);
+ } else {
+ // Fallback to expand/collapse if output viewing not available
+ handleSubtaskToggle(subtask.id);
+ }
+ },
+ [onToggleSubtaskOutput, handleSubtaskToggle]
+ );
+
+ // Called when inline subtask editor saves changes
+ const handleSubtaskUpdated = useCallback(() => {
+ // Re-fetch the parent task to refresh subtask list
+ // This will trigger from the parent component when task updates
+ }, []);
+
+ if (loading) {
+ return (
+ <div className="panel h-full flex items-center justify-center">
+ <div className="font-mono text-[#9bc3ff] text-sm">Loading task...</div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="panel h-full flex flex-col overflow-hidden">
+ {/* Header */}
+ <div className="flex items-center justify-between p-4 pb-2 border-b border-dashed border-[rgba(117,170,252,0.35)] shrink-0">
+ <div className="flex items-center gap-3">
+ <button
+ onClick={onBack}
+ className="font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
+ >
+ &lt; Back
+ </button>
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ TASK//
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ {isEditing ? (
+ <>
+ <button
+ onClick={handleCancel}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleSave}
+ className="px-3 py-1 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 transition-colors uppercase"
+ >
+ Save
+ </button>
+ </>
+ ) : (
+ <>
+ {(task.status === "pending" || task.status === "failed") && (
+ <button
+ onClick={() => onStart(task.id)}
+ className="px-3 py-1 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 transition-colors uppercase"
+ >
+ Start
+ </button>
+ )}
+ {isTaskRunning && (
+ <div className="relative">
+ <button
+ onClick={() => setShowInterruptMenu(!showInterruptMenu)}
+ className="px-3 py-1 font-mono text-xs text-orange-400 border border-orange-400/30 hover:border-orange-400/50 hover:bg-orange-400/10 transition-colors uppercase flex items-center gap-1"
+ >
+ <span className="w-1.5 h-1.5 bg-orange-400 rounded-full animate-pulse" />
+ Interrupt
+ </button>
+ {showInterruptMenu && (
+ <>
+ {/* Backdrop to close menu on click outside */}
+ <div
+ className="fixed inset-0 z-40"
+ onClick={() => setShowInterruptMenu(false)}
+ />
+ <div className="absolute right-0 top-full mt-1 z-50 bg-[#0a1525] border border-[rgba(117,170,252,0.35)] shadow-lg">
+ <button
+ onClick={() => {
+ onRestart(task.id);
+ setShowInterruptMenu(false);
+ }}
+ className="block w-full px-4 py-2 font-mono text-xs text-left text-yellow-400 hover:bg-yellow-400/10 transition-colors whitespace-nowrap"
+ >
+ Restart Task
+ </button>
+ <button
+ onClick={() => {
+ onStop(task.id);
+ setShowInterruptMenu(false);
+ }}
+ className="block w-full px-4 py-2 font-mono text-xs text-left text-red-400 hover:bg-red-400/10 transition-colors whitespace-nowrap"
+ >
+ Cancel Task
+ </button>
+ </div>
+ </>
+ )}
+ </div>
+ )}
+ {isTaskTerminal && (
+ <button
+ onClick={() => onContinue(task.id)}
+ className="px-3 py-1 font-mono text-xs text-cyan-400 border border-cyan-400/30 hover:border-cyan-400/50 hover:bg-cyan-400/10 transition-colors uppercase flex items-center gap-1"
+ >
+ <span className="w-1.5 h-1.5 bg-cyan-400 rounded-full" />
+ Continue
+ </button>
+ )}
+ <button
+ onClick={() => setIsEditing(true)}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ Edit
+ </button>
+ <button
+ onClick={() => onDelete(task.id)}
+ className="px-3 py-1 font-mono text-xs text-red-400 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase"
+ >
+ Delete
+ </button>
+ </>
+ )}
+ </div>
+ </div>
+
+ {/* Content */}
+ <div className="flex-1 overflow-y-auto p-4 space-y-4">
+ {/* Task Info */}
+ <div className="space-y-3">
+ {isEditing ? (
+ <>
+ <input
+ type="text"
+ value={editName}
+ onChange={(e) => setEditName(e.target.value)}
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-lg px-3 py-2 outline-none focus:border-[#3f6fb3]"
+ placeholder="Task name"
+ />
+ <textarea
+ value={editDescription}
+ onChange={(e) => setEditDescription(e.target.value)}
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3] min-h-[60px] resize-y"
+ placeholder="Description (optional)"
+ />
+ </>
+ ) : (
+ <>
+ <h2 className="font-mono text-lg text-[#dbe7ff]">{task.name}</h2>
+ {task.description && (
+ <p className="font-mono text-sm text-[#9bc3ff]">{task.description}</p>
+ )}
+ </>
+ )}
+
+ {/* Status badges */}
+ <div className="flex flex-wrap gap-2">
+ <span
+ className={`px-2 py-0.5 font-mono text-xs uppercase ${getStatusColor(
+ task.status as TaskStatus
+ )} ${getStatusBgColor(task.status as TaskStatus)} border border-current/20`}
+ >
+ {task.status}
+ </span>
+ {/* Orchestrator badge for depth 0 tasks with subtasks */}
+ {task.depth === 0 && task.subtasks.length > 0 && (
+ <span className="px-2 py-0.5 font-mono text-xs text-purple-400 bg-purple-400/10 border border-purple-400/20">
+ Orchestrator
+ </span>
+ )}
+ {/* Depth indicator for subtasks */}
+ {task.depth > 0 && (
+ <span className="px-2 py-0.5 font-mono text-xs text-cyan-400 bg-cyan-400/10 border border-cyan-400/20">
+ Depth: {task.depth}
+ </span>
+ )}
+ {task.priority > 0 && (
+ <span className="px-2 py-0.5 font-mono text-xs text-orange-400 bg-orange-400/10 border border-orange-400/20">
+ Priority: {task.priority}
+ </span>
+ )}
+ {task.mergeMode && (
+ <span className="px-2 py-0.5 font-mono text-xs text-purple-400 bg-purple-400/10 border border-purple-400/20">
+ Merge: {task.mergeMode}
+ </span>
+ )}
+ </div>
+
+ {/* Metadata */}
+ <div className="flex flex-wrap gap-4 font-mono text-[10px] text-[#75aafc]">
+ <span>Created: {formatDate(task.createdAt)}</span>
+ {task.startedAt && <span>Started: {formatDate(task.startedAt)}</span>}
+ {task.completedAt && <span>Completed: {formatDate(task.completedAt)}</span>}
+ <span>Version: {task.version}</span>
+ </div>
+ </div>
+
+ {/* Plan */}
+ <div className="space-y-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Plan
+ </div>
+ {isEditing ? (
+ <textarea
+ value={editPlan}
+ onChange={(e) => setEditPlan(e.target.value)}
+ className="w-full bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3] min-h-[200px] resize-y"
+ placeholder="Enter the plan/instructions for this task..."
+ />
+ ) : (
+ <pre className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 font-mono text-sm text-[#dbe7ff] whitespace-pre-wrap overflow-x-auto">
+ {task.plan}
+ </pre>
+ )}
+ </div>
+
+ {/* Progress Summary */}
+ {task.progressSummary && (
+ <div className="space-y-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Progress
+ </div>
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 font-mono text-sm text-[#9bc3ff]">
+ {task.progressSummary}
+ </div>
+ </div>
+ )}
+
+ {/* Last Output */}
+ {task.lastOutput && (
+ <div className="space-y-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Last Output
+ </div>
+ <pre className="bg-[rgba(0,0,0,0.3)] border border-[rgba(117,170,252,0.15)] p-3 font-mono text-xs text-[#75aafc] whitespace-pre-wrap overflow-x-auto max-h-[200px] overflow-y-auto">
+ {task.lastOutput}
+ </pre>
+ </div>
+ )}
+
+ {/* Error Message */}
+ {task.errorMessage && (
+ <div className="space-y-2">
+ <div className="font-mono text-xs text-red-400 tracking-wide uppercase">
+ Error
+ </div>
+ <div className="bg-red-400/5 border border-red-400/30 p-3 font-mono text-sm text-red-400">
+ {task.errorMessage}
+ </div>
+ </div>
+ )}
+
+ {/* Repository Info */}
+ {(task.repositoryUrl || task.baseBranch || task.targetBranch) && (
+ <div className="space-y-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Repository
+ </div>
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-1">
+ {task.repositoryUrl && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">URL:</span> {task.repositoryUrl}
+ </div>
+ )}
+ {task.baseBranch && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Base:</span> {task.baseBranch}
+ </div>
+ )}
+ {task.targetBranch && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Target:</span> {task.targetBranch}
+ </div>
+ )}
+ {task.prUrl && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">PR:</span>{" "}
+ <a
+ href={task.prUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="text-[#9bc3ff] hover:underline"
+ >
+ {task.prUrl}
+ </a>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+
+ {/* Completion Action Settings */}
+ <div className="space-y-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Completion Actions
+ </div>
+ {isEditing ? (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-3">
+ <div className="space-y-1">
+ <label className="font-mono text-xs text-[#555]">Action on Completion</label>
+ <select
+ value={editCompletionAction}
+ onChange={(e) => setEditCompletionAction(e.target.value as CompletionAction)}
+ className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]"
+ >
+ <option value="none">None (keep in worktree)</option>
+ <option value="branch">Create branch in target repo</option>
+ <option value="merge">Auto-merge to target branch</option>
+ <option value="pr">Create Pull Request</option>
+ </select>
+ </div>
+ {editCompletionAction !== "none" && (
+ <div className="space-y-1">
+ <label className="font-mono text-xs text-[#555]">Target Repository Path</label>
+ <DirectoryInput
+ value={editTargetRepoPath}
+ onChange={setEditTargetRepoPath}
+ suggestions={suggestedDirectories}
+ placeholder="/path/to/your/local/repo"
+ repoUrl={task.repositoryUrl}
+ />
+ <p className="font-mono text-[10px] text-[#555]">
+ Path to your local repository where the branch will be pushed/merged.
+ </p>
+ </div>
+ )}
+ </div>
+ ) : (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-1">
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Action:</span>{" "}
+ {task.completionAction === "none" || !task.completionAction
+ ? "None (keep in worktree)"
+ : task.completionAction === "branch"
+ ? "Create branch in target repo"
+ : task.completionAction === "merge"
+ ? "Auto-merge to target branch"
+ : task.completionAction === "pr"
+ ? "Create Pull Request"
+ : task.completionAction}
+ </div>
+ {task.targetRepoPath && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Target Repo:</span> {task.targetRepoPath}
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+
+ {/* Metadata Info */}
+ {(task.daemonId || task.containerId || task.overlayPath) && (
+ <div className="space-y-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Metadata
+ </div>
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-1">
+ {task.daemonId && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Daemon:</span> {task.daemonId}
+ </div>
+ )}
+ {task.containerId && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Container:</span> {task.containerId}
+ </div>
+ )}
+ {task.overlayPath && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Overlay:</span> {task.overlayPath}
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+
+ {/* Subtasks */}
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-3">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Subtasks ({task.subtasks.length})
+ </div>
+ {task.subtasks.length > 0 && (
+ <button
+ onClick={() => setUseTreeView(!useTreeView)}
+ className="font-mono text-[9px] text-[#555] hover:text-[#75aafc]"
+ >
+ {useTreeView ? "List" : "Tree"}
+ </button>
+ )}
+ </div>
+ {/* Disable adding subtasks at max depth (2 = sub-subtask, cannot have children) */}
+ {task.depth < 2 ? (
+ <button
+ onClick={onCreateSubtask}
+ className="px-2 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ + Add Subtask
+ </button>
+ ) : (
+ <span className="px-2 py-1 font-mono text-[10px] text-[#555] border border-[#333]" title="Maximum depth reached">
+ Max depth
+ </span>
+ )}
+ </div>
+
+ {/* Progress bar for subtasks */}
+ {task.subtasks.length > 0 && (
+ <SubtaskProgressBar stats={subtaskStats} />
+ )}
+
+ {task.subtasks.length === 0 ? (
+ <div className="text-[#555] font-mono text-xs py-4 text-center">
+ No subtasks yet
+ </div>
+ ) : useTreeView ? (
+ <div className="border border-[rgba(117,170,252,0.15)]">
+ <SubtaskTree
+ subtasks={task.subtasks}
+ onSelect={onSelectSubtask}
+ fetchSubtasks={fetchSubtasks}
+ />
+ </div>
+ ) : (
+ <div className="divide-y divide-[rgba(117,170,252,0.15)] border border-[rgba(117,170,252,0.15)]">
+ {task.subtasks.map((subtask: TaskSummary) => {
+ const isRunning = subtask.status === "running" || subtask.status === "initializing" || subtask.status === "starting";
+ const isViewingOutput = viewingSubtaskId === subtask.id;
+ const isExpanded = expandedSubtaskId === subtask.id;
+
+ // Different highlight colors: green for running, subtle blue for others
+ const outputHighlightBg = isRunning ? "bg-green-400/10" : "bg-[rgba(117,170,252,0.08)]";
+ const outputHighlightBorder = isRunning ? "border-l-green-400" : "border-l-[#75aafc]";
+ const outputLabelColor = isRunning ? "text-green-400" : "text-[#75aafc]";
+
+ return (
+ <div key={subtask.id}>
+ {/* Subtask header - clickable to view output */}
+ <div
+ className={`w-full p-3 text-left hover:bg-[rgba(117,170,252,0.05)] transition-colors cursor-pointer ${
+ isExpanded && !isViewingOutput ? "bg-[rgba(117,170,252,0.08)]" : ""
+ } ${isViewingOutput ? `${outputHighlightBg} border-l-2 ${outputHighlightBorder}` : ""}`}
+ onClick={() => handleSubtaskClick(subtask)}
+ >
+ <div className="flex items-center gap-2 mb-1">
+ <span className="text-[#555] text-xs">
+ {isViewingOutput ? "[*]" : (isExpanded ? "[-]" : "[+]")}
+ </span>
+ <span className="font-mono text-sm text-[#dbe7ff]">
+ {subtask.name}
+ </span>
+ <span
+ className={`px-1.5 py-0.5 font-mono text-[9px] uppercase ${getStatusColor(
+ subtask.status
+ )} ${getStatusBgColor(subtask.status)} border border-current/20`}
+ >
+ {subtask.status}
+ </span>
+ {subtask.subtaskCount > 0 && (
+ <span className="font-mono text-[9px] text-[#555]">
+ +{subtask.subtaskCount}
+ </span>
+ )}
+ {isViewingOutput && (
+ <span className={`font-mono text-[9px] ${outputLabelColor} ml-auto`}>
+ {isRunning ? "viewing live output" : "viewing output"}
+ </span>
+ )}
+ {/* Expand/edit button - always available */}
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
+ handleSubtaskToggle(subtask.id);
+ }}
+ className={`ml-auto px-1.5 py-0.5 font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors ${
+ isViewingOutput ? "ml-2" : ""
+ }`}
+ title="Expand details"
+ >
+ {isExpanded ? "-" : "+"}
+ </button>
+ </div>
+ {subtask.progressSummary && !isExpanded && !isViewingOutput && (
+ <p className="font-mono text-xs text-[#75aafc] line-clamp-1 pl-6">
+ {subtask.progressSummary}
+ </p>
+ )}
+ </div>
+ {/* Inline subtask editor */}
+ {isExpanded && (
+ <InlineSubtaskEditor
+ subtaskId={subtask.id}
+ onClose={() => setExpandedSubtaskId(null)}
+ onUpdated={handleSubtaskUpdated}
+ onNavigate={onSelectSubtask}
+ />
+ )}
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+
+ {/* Action buttons for completed tasks */}
+ {(task.status === "done" || task.status === "merged" || task.status === "failed") && (
+ <div className="space-y-2 pt-4 border-t border-[rgba(117,170,252,0.2)]">
+ <div className="flex flex-wrap gap-2">
+ {onRequestDiff && (
+ <button
+ onClick={() => {
+ onRequestDiff();
+ setShowDiff(true);
+ }}
+ className="px-3 py-1.5 font-mono text-xs text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ View Diff
+ </button>
+ )}
+ {canCreatePR && (
+ <button
+ onClick={() => setShowPRPreview(true)}
+ className="px-3 py-1.5 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 transition-colors"
+ >
+ Create PR
+ </button>
+ )}
+ {/* Retry completion action button */}
+ {canRetryCompletion && (
+ <button
+ onClick={handleRetryCompletion}
+ disabled={isRetryingCompletion}
+ className="px-3 py-1.5 font-mono text-xs text-cyan-400 border border-cyan-400/30 hover:border-cyan-400/50 hover:bg-cyan-400/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {isRetryingCompletion
+ ? "Retrying..."
+ : task.completionAction === "branch"
+ ? "Push Branch"
+ : task.completionAction === "merge"
+ ? "Merge to Target"
+ : task.completionAction === "pr"
+ ? "Create PR"
+ : "Run Completion Action"}
+ </button>
+ )}
+ {/* Show hint if completion action needs configuration */}
+ {!canRetryCompletion && (
+ <span className="px-3 py-1.5 font-mono text-xs text-[#555] italic">
+ {!task.completionAction || task.completionAction === "none"
+ ? "Set completion action to enable"
+ : !task.targetRepoPath
+ ? "Set target repo path to enable"
+ : ""}
+ </span>
+ )}
+ </div>
+ {/* Retry error message */}
+ {retryError && (
+ <div className="font-mono text-xs text-red-400 bg-red-400/10 px-3 py-2 border border-red-400/30">
+ {retryError}
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* Clone Worktree Section */}
+ {canCloneWorktree && (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-2">
+ <div className="font-mono text-xs text-[#555]">Clone Worktree to Directory</div>
+ <div className="flex gap-2 items-start">
+ <DirectoryInput
+ value={cloneTargetDir}
+ onChange={setCloneTargetDir}
+ suggestions={suggestedDirectories}
+ placeholder="/path/to/clone"
+ repoUrl={task.repositoryUrl}
+ className="flex-1"
+ />
+ <button
+ onClick={handleCloneWorktree}
+ disabled={isCloning || !cloneTargetDir.trim()}
+ className="px-3 py-2 font-mono text-xs text-purple-400 border border-purple-400/30 hover:border-purple-400/50 hover:bg-purple-400/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
+ >
+ {isCloning ? "Cloning..." : "Clone"}
+ </button>
+ </div>
+ <p className="font-mono text-[10px] text-[#555]">
+ Clone the worktree (git repo) to a new directory. Useful for moving completed work outside ~/.makima.
+ </p>
+ {cloneError && (
+ <div className="font-mono text-xs text-red-400 bg-red-400/10 px-3 py-2 border border-red-400/30">
+ {cloneError}
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+
+ {/* Overlay Diff Modal */}
+ {showDiff && overlayDiff !== undefined && (
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
+ <div className="max-w-4xl w-full max-h-[80vh]">
+ <OverlayDiffViewer
+ diff={overlayDiff}
+ changedFiles={changedFiles}
+ onClose={() => setShowDiff(false)}
+ title={`Changes in ${task.name}`}
+ />
+ </div>
+ </div>
+ )}
+
+ {/* PR Preview Modal */}
+ {showPRPreview && (
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
+ <div className="max-w-3xl w-full">
+ <PRPreview
+ task={task}
+ diff={overlayDiff}
+ changedFiles={changedFiles}
+ onCreatePR={onCreatePR}
+ onAutoMerge={task.mergeMode === "auto" ? onAutoMerge : undefined}
+ onClose={() => setShowPRPreview(false)}
+ />
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/TaskList.tsx b/makima/frontend/src/components/mesh/TaskList.tsx
new file mode 100644
index 0000000..a37e564
--- /dev/null
+++ b/makima/frontend/src/components/mesh/TaskList.tsx
@@ -0,0 +1,164 @@
+import type { TaskSummary, TaskStatus } from "../../lib/api";
+
+interface TaskListProps {
+ tasks: TaskSummary[];
+ loading: boolean;
+ onSelect: (id: string) => void;
+ onDelete: (id: string) => void;
+ onCreate: () => void;
+}
+
+function formatDate(dateStr: string): string {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+function getStatusColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "text-[#9bc3ff]";
+ case "running":
+ return "text-green-400";
+ case "paused":
+ return "text-yellow-400";
+ case "blocked":
+ return "text-orange-400";
+ case "done":
+ return "text-emerald-400";
+ case "failed":
+ return "text-red-400";
+ case "merged":
+ return "text-purple-400";
+ default:
+ return "text-[#9bc3ff]";
+ }
+}
+
+function getStatusBgColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "bg-[rgba(117,170,252,0.1)]";
+ case "running":
+ return "bg-green-400/10";
+ case "paused":
+ return "bg-yellow-400/10";
+ case "blocked":
+ return "bg-orange-400/10";
+ case "done":
+ return "bg-emerald-400/10";
+ case "failed":
+ return "bg-red-400/10";
+ case "merged":
+ return "bg-purple-400/10";
+ default:
+ return "bg-[rgba(117,170,252,0.1)]";
+ }
+}
+
+export function TaskList({
+ tasks,
+ loading,
+ onSelect,
+ onDelete,
+ onCreate,
+}: TaskListProps) {
+ if (loading) {
+ return (
+ <div className="panel h-full flex items-center justify-center">
+ <div className="font-mono text-[#9bc3ff] text-sm">Loading tasks...</div>
+ </div>
+ );
+ }
+
+ // Separate root tasks (no parent) from subtasks
+ const rootTasks = tasks.filter((t) => !t.parentTaskId);
+
+ return (
+ <div className="panel h-full flex flex-col">
+ <div className="flex items-center justify-between p-4 pb-2 border-b border-dashed border-[rgba(117,170,252,0.35)]">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ MESH//TASKS
+ </div>
+ <button
+ onClick={onCreate}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors uppercase"
+ >
+ + New Task
+ </button>
+ </div>
+
+ <div className="flex-1 overflow-y-auto">
+ {rootTasks.length === 0 ? (
+ <div className="text-center text-[#9bc3ff] text-sm font-mono opacity-60 py-8">
+ No tasks yet. Create one to start orchestrating Claude Code instances.
+ </div>
+ ) : (
+ <div className="divide-y divide-[rgba(117,170,252,0.15)]">
+ {rootTasks.map((task) => (
+ <div
+ key={task.id}
+ className="p-4 hover:bg-[rgba(117,170,252,0.05)] transition-colors"
+ >
+ <div className="flex items-start justify-between gap-4">
+ <button
+ onClick={() => onSelect(task.id)}
+ className="flex-1 text-left"
+ >
+ <div className="flex items-center gap-2 mb-1">
+ <h3 className="font-mono text-sm text-[#dbe7ff]">
+ {task.name}
+ </h3>
+ <span
+ className={`px-2 py-0.5 font-mono text-[10px] uppercase ${getStatusColor(
+ task.status
+ )} ${getStatusBgColor(task.status)} border border-current/20`}
+ >
+ {task.status}
+ </span>
+ {task.depth === 0 && task.subtaskCount > 0 && (
+ <span className="px-2 py-0.5 font-mono text-[10px] text-purple-400 bg-purple-400/10 border border-purple-400/20">
+ Orchestrator
+ </span>
+ )}
+ {task.priority > 0 && (
+ <span className="px-2 py-0.5 font-mono text-[10px] text-orange-400 bg-orange-400/10 border border-orange-400/20">
+ P{task.priority}
+ </span>
+ )}
+ </div>
+ {task.progressSummary && (
+ <p className="font-mono text-xs text-[#9bc3ff] mb-2 line-clamp-2">
+ {task.progressSummary}
+ </p>
+ )}
+ <div className="flex gap-4 font-mono text-[10px] text-[#75aafc]">
+ {task.subtaskCount > 0 && (
+ <span>{task.subtaskCount} subtasks</span>
+ )}
+ <span>{formatDate(task.createdAt)}</span>
+ </div>
+ </button>
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
+ onDelete(task.id);
+ }}
+ className="px-2 py-1 font-mono text-[10px] text-red-400 hover:bg-red-400/10 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase"
+ >
+ Delete
+ </button>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/TaskOutput.tsx b/makima/frontend/src/components/mesh/TaskOutput.tsx
new file mode 100644
index 0000000..10de225
--- /dev/null
+++ b/makima/frontend/src/components/mesh/TaskOutput.tsx
@@ -0,0 +1,281 @@
+import { useRef, useEffect, useState, useCallback } from "react";
+import { SimpleMarkdown } from "../SimpleMarkdown";
+import type { TaskOutputEvent } from "../../hooks/useTaskSubscription";
+import { sendTaskMessage } from "../../lib/api";
+
+interface TaskOutputProps {
+ /** Array of parsed output events from the backend */
+ entries: TaskOutputEvent[];
+ isStreaming: boolean;
+ /** Name of subtask whose output is being viewed (null = parent task) */
+ viewingSubtaskName?: string | null;
+ /** Callback to return to parent task output */
+ onClearSubtaskView?: () => void;
+ onClear?: () => void;
+ /** Task ID for sending input (if provided, shows input bar when streaming) */
+ taskId?: string | null;
+ /** Callback when user sends input (to show it immediately in output) */
+ onUserInput?: (message: string) => void;
+}
+
+export function TaskOutput({ entries, isStreaming, viewingSubtaskName, onClearSubtaskView, onClear, taskId, onUserInput }: TaskOutputProps) {
+ const containerRef = useRef<HTMLDivElement>(null);
+ const [autoScroll, setAutoScroll] = useState(true);
+ const [inputValue, setInputValue] = useState("");
+ const [sendingInput, setSendingInput] = useState(false);
+ const [inputError, setInputError] = useState<string | null>(null);
+ const inputRef = useRef<HTMLInputElement>(null);
+
+ // Handle scroll to check if user has scrolled up
+ const handleScroll = useCallback(() => {
+ if (!containerRef.current) return;
+ const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
+ const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
+ setAutoScroll(isAtBottom);
+ }, []);
+
+ // Auto-scroll when entries change
+ useEffect(() => {
+ if (autoScroll && containerRef.current) {
+ containerRef.current.scrollTop = containerRef.current.scrollHeight;
+ }
+ }, [entries, autoScroll]);
+
+ // Handle sending input to the task
+ const handleSendInput = useCallback(async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!taskId || !inputValue.trim() || sendingInput) return;
+
+ const message = inputValue.trim();
+ setSendingInput(true);
+ setInputError(null);
+
+ // Show user input immediately in the output window
+ onUserInput?.(message);
+
+ try {
+ await sendTaskMessage(taskId, message);
+ setInputValue("");
+ inputRef.current?.focus();
+ } catch (err) {
+ setInputError(err instanceof Error ? err.message : "Failed to send input");
+ } finally {
+ setSendingInput(false);
+ }
+ }, [taskId, inputValue, sendingInput, onUserInput]);
+
+ // Show input bar when task is running and has a valid taskId
+ const showInputBar = isStreaming && taskId;
+
+ return (
+ <div className="flex flex-col h-full">
+ {/* Header */}
+ <div className="flex items-center justify-between px-3 py-2 border-b border-dashed border-[rgba(117,170,252,0.35)] shrink-0">
+ <div className="flex items-center gap-2">
+ {viewingSubtaskName ? (
+ <>
+ <button
+ onClick={onClearSubtaskView}
+ className="font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
+ >
+ &lt;
+ </button>
+ <span className="font-mono text-xs text-green-400 tracking-wide uppercase">
+ Subtask: {viewingSubtaskName}
+ </span>
+ </>
+ ) : (
+ <span className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Output
+ </span>
+ )}
+ {isStreaming && (
+ <span className="flex items-center gap-1.5 px-2 py-0.5 bg-green-400/10 border border-green-400/30 text-green-400 font-mono text-[10px] uppercase">
+ <span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" />
+ Live
+ </span>
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ {!autoScroll && (
+ <button
+ onClick={() => {
+ setAutoScroll(true);
+ if (containerRef.current) {
+ containerRef.current.scrollTop =
+ containerRef.current.scrollHeight;
+ }
+ }}
+ className="px-2 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ Resume Scroll
+ </button>
+ )}
+ {onClear && entries.length > 0 && (
+ <button
+ onClick={onClear}
+ className="px-2 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ Clear
+ </button>
+ )}
+ </div>
+ </div>
+
+ {/* Output area */}
+ <div
+ ref={containerRef}
+ onScroll={handleScroll}
+ className="flex-1 overflow-auto bg-[#0a0f18] p-3 font-mono text-xs min-h-0"
+ >
+ {entries.length === 0 ? (
+ <div className="text-[#555] italic">
+ {isStreaming ? "Waiting for output..." : "No output yet"}
+ </div>
+ ) : (
+ <div className="space-y-3">
+ {entries.map((entry, idx) => (
+ <OutputEntryRenderer key={idx} entry={entry} />
+ ))}
+ {isStreaming && (
+ <span className="inline-block w-2 h-4 bg-[#9bc3ff] animate-pulse" />
+ )}
+ </div>
+ )}
+ </div>
+
+ {/* Input bar for sending messages to running tasks */}
+ {showInputBar && (
+ <div className="shrink-0 border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]">
+ {inputError && (
+ <div className="px-3 py-1 bg-red-900/20 text-red-400 text-xs font-mono">
+ {inputError}
+ </div>
+ )}
+ <form onSubmit={handleSendInput} className="flex items-center gap-2 px-3 py-2">
+ <span className="text-green-400 font-mono text-sm">&gt;</span>
+ <input
+ ref={inputRef}
+ type="text"
+ value={inputValue}
+ onChange={(e) => setInputValue(e.target.value)}
+ placeholder={sendingInput ? "Sending..." : "Send input to Claude..."}
+ disabled={sendingInput}
+ className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]"
+ />
+ <button
+ type="submit"
+ disabled={sendingInput || !inputValue.trim()}
+ className="px-3 py-1 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {sendingInput ? "..." : "Send"}
+ </button>
+ </form>
+ </div>
+ )}
+ </div>
+ );
+}
+
+function OutputEntryRenderer({ entry }: { entry: TaskOutputEvent }) {
+ const [expanded, setExpanded] = useState(false);
+
+ switch (entry.messageType) {
+ case "user_input":
+ return (
+ <div className="pl-2 border-l-2 border-cyan-400/50">
+ <div className="flex items-center gap-2">
+ <span className="text-cyan-400 text-[10px] uppercase tracking-wide">You:</span>
+ </div>
+ <div className="text-cyan-300 mt-1">{entry.content}</div>
+ </div>
+ );
+
+ case "system":
+ return (
+ <div className="text-[#555] text-[10px] uppercase tracking-wide">
+ {entry.content}
+ </div>
+ );
+
+ case "assistant":
+ return (
+ <div className="pl-2 border-l-2 border-[#3f6fb3]">
+ <SimpleMarkdown content={entry.content} className="text-[#9bc3ff]" />
+ </div>
+ );
+
+ case "tool_use":
+ return (
+ <div className="space-y-1">
+ <div className="flex items-center gap-2">
+ <span className="text-yellow-500">*</span>
+ <span className="text-[#75aafc]">{entry.toolName || "unknown"}</span>
+ {entry.toolInput && Object.keys(entry.toolInput).length > 0 && (
+ <button
+ onClick={() => setExpanded(!expanded)}
+ className="text-[#555] hover:text-[#9bc3ff] text-[10px]"
+ >
+ {expanded ? "[-]" : "[+]"}
+ </button>
+ )}
+ </div>
+ {expanded && entry.toolInput && (
+ <pre className="ml-4 text-[10px] text-[#555] bg-[#0a1525] p-2 overflow-x-auto">
+ {JSON.stringify(entry.toolInput, null, 2)}
+ </pre>
+ )}
+ </div>
+ );
+
+ case "tool_result":
+ if (!entry.content) return null;
+ return (
+ <div className="ml-4 text-[10px]">
+ <span className={entry.isError ? "text-red-400" : "text-green-500"}>
+ {entry.isError ? "x" : "+"}
+ </span>{" "}
+ <span className="text-[#555]">
+ {entry.content.split("\n")[0]}
+ {entry.content.includes("\n") && "..."}
+ </span>
+ </div>
+ );
+
+ case "result":
+ return (
+ <div className="border-t border-[rgba(117,170,252,0.2)] pt-2 mt-2">
+ <div className="text-green-500 font-semibold mb-1">Result:</div>
+ <SimpleMarkdown content={entry.content} className="text-[#9bc3ff]" />
+ {(entry.costUsd !== undefined || entry.durationMs !== undefined) && (
+ <div className="text-[10px] text-[#555] mt-2">
+ {entry.durationMs !== undefined && (
+ <span>Duration: {(entry.durationMs / 1000).toFixed(1)}s</span>
+ )}
+ {entry.costUsd !== undefined && entry.durationMs !== undefined && " | "}
+ {entry.costUsd !== undefined && (
+ <span>Cost: ${entry.costUsd.toFixed(4)}</span>
+ )}
+ </div>
+ )}
+ </div>
+ );
+
+ case "error":
+ return (
+ <div className="text-red-400 pl-2 border-l-2 border-red-400/50">
+ {entry.content}
+ </div>
+ );
+
+ case "raw":
+ return (
+ <div className="text-[#555] text-[10px]">
+ {entry.content}
+ </div>
+ );
+
+ default:
+ return null;
+ }
+}
diff --git a/makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx b/makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx
new file mode 100644
index 0000000..5caa3c4
--- /dev/null
+++ b/makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx
@@ -0,0 +1,536 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+import {
+ type LlmModel,
+ type UserQuestion,
+ type UserAnswer,
+ type MeshChatContext,
+} from "../../lib/api";
+import { useMeshChatHistory } from "../../hooks/useMeshChatHistory";
+import { SimpleMarkdown } from "../SimpleMarkdown";
+
+interface UnifiedMeshChatInputProps {
+ context: MeshChatContext;
+ onUpdate?: () => void;
+}
+
+const MODEL_OPTIONS: { value: LlmModel; label: string }[] = [
+ { value: "claude-opus", label: "Claude Opus" },
+ { value: "claude-sonnet", label: "Claude Sonnet" },
+ { value: "groq", label: "Groq Kimi" },
+];
+
+const DEFAULT_MODEL: LlmModel = "claude-opus";
+
+// LocalStorage keys
+const STORAGE_KEY_MODEL = "makima-mesh-chat-model";
+const STORAGE_KEY_CMD_HISTORY = "makima-mesh-chat-cmd-history";
+const MAX_CMD_HISTORY = 100;
+
+function loadModel(): LlmModel {
+ try {
+ const modelStr = localStorage.getItem(STORAGE_KEY_MODEL);
+ return (modelStr as LlmModel) || DEFAULT_MODEL;
+ } catch {
+ return DEFAULT_MODEL;
+ }
+}
+
+function saveModel(model: LlmModel): void {
+ try {
+ localStorage.setItem(STORAGE_KEY_MODEL, model);
+ } catch {
+ // Ignore storage errors
+ }
+}
+
+function loadCommandHistory(): string[] {
+ try {
+ const historyJson = localStorage.getItem(STORAGE_KEY_CMD_HISTORY);
+ return historyJson ? JSON.parse(historyJson) : [];
+ } catch {
+ return [];
+ }
+}
+
+function saveCommandHistory(history: string[]): void {
+ try {
+ localStorage.setItem(
+ STORAGE_KEY_CMD_HISTORY,
+ JSON.stringify(history.slice(-MAX_CMD_HISTORY))
+ );
+ } catch {
+ // Ignore storage errors
+ }
+}
+
+function getPlaceholder(context: MeshChatContext): string {
+ switch (context.type) {
+ case "mesh":
+ return "Create task, list tasks, check status...";
+ case "task":
+ return "Create subtask, run task, check status...";
+ case "subtask":
+ return "Update plan, check siblings, merge...";
+ default:
+ return "Ask anything...";
+ }
+}
+
+function getContextLabel(context: MeshChatContext): string {
+ switch (context.type) {
+ case "mesh":
+ return "mesh";
+ case "task":
+ return `task:${context.taskId?.slice(0, 8)}`;
+ case "subtask":
+ return `subtask:${context.taskId?.slice(0, 8)}`;
+ default:
+ return "chat";
+ }
+}
+
+export function UnifiedMeshChatInput({
+ context,
+ onUpdate,
+}: UnifiedMeshChatInputProps) {
+ const {
+ messages,
+ loading: historyLoading,
+ error: historyError,
+ sending,
+ clearHistory,
+ sendMessage,
+ } = useMeshChatHistory();
+
+ const [input, setInput] = useState("");
+ const [expanded, setExpanded] = useState(false);
+ const [model, setModel] = useState<LlmModel>(DEFAULT_MODEL);
+
+ // Pending questions state
+ const [pendingQuestions, setPendingQuestions] = useState<
+ UserQuestion[] | null
+ >(null);
+ const [userAnswers, setUserAnswers] = useState<Map<string, string[]>>(
+ new Map()
+ );
+ const [customInputs, setCustomInputs] = useState<Map<string, string>>(
+ new Map()
+ );
+
+ // Command history for arrow key navigation
+ const [commandHistory, setCommandHistory] = useState<string[]>([]);
+ const [historyIndex, setHistoryIndex] = useState(-1);
+ const [savedInput, setSavedInput] = useState("");
+
+ const inputRef = useRef<HTMLInputElement>(null);
+ const messagesRef = useRef<HTMLDivElement>(null);
+
+ // Load model preference on mount
+ useEffect(() => {
+ setModel(loadModel());
+ setCommandHistory(loadCommandHistory());
+ }, []);
+
+ // Expand when messages exist
+ useEffect(() => {
+ if (messages.length > 0) {
+ setExpanded(true);
+ }
+ }, [messages.length]);
+
+ // Auto-scroll to bottom when messages change
+ useEffect(() => {
+ if (messagesRef.current) {
+ messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
+ }
+ }, [messages]);
+
+ // Handle model change
+ const handleModelChange = useCallback((newModel: LlmModel) => {
+ setModel(newModel);
+ saveModel(newModel);
+ }, []);
+
+ // Handle keyboard navigation for command history
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
+ if (e.key === "ArrowUp") {
+ e.preventDefault();
+ if (commandHistory.length === 0) return;
+
+ if (historyIndex === -1) {
+ setSavedInput(input);
+ setHistoryIndex(commandHistory.length - 1);
+ setInput(commandHistory[commandHistory.length - 1]);
+ } else if (historyIndex > 0) {
+ setHistoryIndex(historyIndex - 1);
+ setInput(commandHistory[historyIndex - 1]);
+ }
+ } else if (e.key === "ArrowDown") {
+ e.preventDefault();
+ if (historyIndex === -1) return;
+
+ if (historyIndex < commandHistory.length - 1) {
+ setHistoryIndex(historyIndex + 1);
+ setInput(commandHistory[historyIndex + 1]);
+ } else {
+ setHistoryIndex(-1);
+ setInput(savedInput);
+ }
+ }
+ },
+ [commandHistory, historyIndex, input, savedInput]
+ );
+
+ const handleSubmit = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!input.trim() || sending) return;
+
+ const userMessage = input.trim();
+
+ // Update command history
+ const newHistory =
+ commandHistory[commandHistory.length - 1] !== userMessage
+ ? [...commandHistory, userMessage]
+ : commandHistory;
+ setCommandHistory(newHistory);
+ saveCommandHistory(newHistory);
+
+ // Reset navigation state
+ setHistoryIndex(-1);
+ setSavedInput("");
+
+ setInput("");
+ setExpanded(true);
+
+ // Send message via hook (uses DB-persisted history)
+ const response = await sendMessage(userMessage, context, model);
+
+ if (response) {
+ // Handle pending questions
+ if (response.pendingQuestions?.length) {
+ setPendingQuestions(response.pendingQuestions);
+ const initialAnswers = new Map<string, string[]>();
+ response.pendingQuestions.forEach((q) => {
+ initialAnswers.set(q.id, []);
+ });
+ setUserAnswers(initialAnswers);
+ setCustomInputs(new Map());
+ }
+
+ // Notify parent that something may have been updated
+ // Always refresh when tool calls were made (state may have changed)
+ if (response.toolCalls && response.toolCalls.length > 0) {
+ onUpdate?.();
+ }
+ }
+
+ inputRef.current?.focus();
+ },
+ [input, sending, context, model, sendMessage, onUpdate, commandHistory]
+ );
+
+ // Handle option selection for a question
+ const handleOptionToggle = useCallback(
+ (questionId: string, option: string, allowMultiple: boolean) => {
+ setUserAnswers((prev) => {
+ const newMap = new Map(prev);
+ const currentAnswers = newMap.get(questionId) || [];
+
+ if (allowMultiple) {
+ if (currentAnswers.includes(option)) {
+ newMap.set(
+ questionId,
+ currentAnswers.filter((a) => a !== option)
+ );
+ } else {
+ newMap.set(questionId, [...currentAnswers, option]);
+ }
+ } else {
+ newMap.set(questionId, [option]);
+ }
+
+ return newMap;
+ });
+ },
+ []
+ );
+
+ // Handle custom input change
+ const handleCustomInputChange = useCallback(
+ (questionId: string, value: string) => {
+ setCustomInputs((prev) => {
+ const newMap = new Map(prev);
+ newMap.set(questionId, value);
+ return newMap;
+ });
+ },
+ []
+ );
+
+ // Submit answers to questions
+ const handleSubmitAnswers = useCallback(async () => {
+ if (!pendingQuestions || sending) return;
+
+ // Build answers array
+ const answers: UserAnswer[] = pendingQuestions.map((q) => {
+ const selectedOptions = userAnswers.get(q.id) || [];
+ const customInput = customInputs.get(q.id)?.trim();
+ const finalAnswers = customInput
+ ? [...selectedOptions, customInput]
+ : selectedOptions;
+
+ return {
+ id: q.id,
+ answers: finalAnswers,
+ };
+ });
+
+ // Format answers as a message
+ const answerText = answers
+ .map((a) => {
+ const question = pendingQuestions.find((q) => q.id === a.id);
+ return `${question?.question || a.id}: ${a.answers.join(", ")}`;
+ })
+ .join("\n");
+
+ // Clear pending questions
+ setPendingQuestions(null);
+ setUserAnswers(new Map());
+ setCustomInputs(new Map());
+
+ // Send answers as the next message
+ const response = await sendMessage(answerText, context, model);
+
+ if (response) {
+ // Handle more pending questions
+ if (response.pendingQuestions?.length) {
+ setPendingQuestions(response.pendingQuestions);
+ const initialAnswers = new Map<string, string[]>();
+ response.pendingQuestions.forEach((q) => {
+ initialAnswers.set(q.id, []);
+ });
+ setUserAnswers(initialAnswers);
+ setCustomInputs(new Map());
+ }
+
+ // Notify parent that something may have been updated
+ if (response.toolCalls && response.toolCalls.length > 0) {
+ onUpdate?.();
+ }
+ }
+ }, [
+ pendingQuestions,
+ userAnswers,
+ customInputs,
+ sending,
+ context,
+ model,
+ sendMessage,
+ onUpdate,
+ ]);
+
+ // Cancel answering questions
+ const handleCancelQuestions = useCallback(() => {
+ setPendingQuestions(null);
+ setUserAnswers(new Map());
+ setCustomInputs(new Map());
+ }, []);
+
+ const handleClearHistory = useCallback(async () => {
+ await clearHistory();
+ setPendingQuestions(null);
+ setUserAnswers(new Map());
+ setCustomInputs(new Map());
+ }, [clearHistory]);
+
+ const loading = sending || historyLoading;
+
+ return (
+ <div className="border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]">
+ {/* Error Display */}
+ {historyError && (
+ <div className="px-3 py-2 bg-red-900/20 text-red-400 text-xs font-mono">
+ {historyError}
+ </div>
+ )}
+
+ {/* Messages Panel (expandable) */}
+ {expanded && messages.length > 0 && (
+ <div
+ ref={messagesRef}
+ className="max-h-48 overflow-y-auto p-3 space-y-2 border-b border-[rgba(117,170,252,0.2)]"
+ >
+ {messages.map((msg) => (
+ <div key={msg.id} className="font-mono text-xs">
+ {msg.role === "user" && (
+ <div className="flex gap-2">
+ <span className="text-[#9bc3ff]">&gt;</span>
+ <span className="text-white/80 whitespace-pre-wrap">
+ {msg.content}
+ </span>
+ {msg.contextType !== "mesh" && (
+ <span className="text-[#555] text-[10px]">
+ [{msg.contextType}]
+ </span>
+ )}
+ </div>
+ )}
+ {msg.role === "assistant" && (
+ <div className="pl-4 space-y-1">
+ <SimpleMarkdown
+ content={msg.content}
+ className="text-[#75aafc]"
+ />
+ {msg.toolCalls && msg.toolCalls.length > 0 && (
+ <div className="text-[#555] text-[10px] space-y-0.5">
+ {msg.toolCalls.map((tc, i) => (
+ <div key={i}>
+ <span
+ className={
+ tc.result.success
+ ? "text-green-500"
+ : "text-red-400"
+ }
+ >
+ {tc.result.success ? "+" : "x"}
+ </span>{" "}
+ {tc.name}: {tc.result.message}
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+ {msg.role === "error" && (
+ <div className="pl-4 text-red-400">{msg.content}</div>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+
+ {/* Pending Questions UI */}
+ {pendingQuestions && pendingQuestions.length > 0 && (
+ <div className="p-3 border-b border-[rgba(117,170,252,0.2)] space-y-3">
+ <div className="text-[#9bc3ff] font-mono text-xs uppercase tracking-wide">
+ Questions from AI
+ </div>
+ {pendingQuestions.map((q) => (
+ <div key={q.id} className="space-y-2">
+ <div className="text-white/90 font-mono text-sm">
+ {q.question}
+ </div>
+ <div className="flex flex-wrap gap-2">
+ {q.options.map((option) => {
+ const isSelected = (userAnswers.get(q.id) || []).includes(
+ option
+ );
+ return (
+ <button
+ key={option}
+ type="button"
+ onClick={() =>
+ handleOptionToggle(q.id, option, q.allowMultiple)
+ }
+ className={`px-2 py-1 font-mono text-xs border transition-colors ${
+ isSelected
+ ? "bg-[#3f6fb3] border-[#75aafc] text-white"
+ : "bg-transparent border-[rgba(117,170,252,0.25)] text-[#9bc3ff] hover:border-[#3f6fb3]"
+ }`}
+ >
+ {q.allowMultiple && (
+ <span className="mr-1">{isSelected ? "+" : "-"}</span>
+ )}
+ {option}
+ </button>
+ );
+ })}
+ </div>
+ {q.allowCustom && (
+ <input
+ type="text"
+ value={customInputs.get(q.id) || ""}
+ onChange={(e) => handleCustomInputChange(q.id, e.target.value)}
+ placeholder="Or type a custom answer..."
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-xs px-2 py-1 outline-none focus:border-[#3f6fb3] placeholder-[#555]"
+ />
+ )}
+ </div>
+ ))}
+ <div className="flex gap-2 pt-2">
+ <button
+ type="button"
+ onClick={handleSubmitAnswers}
+ disabled={loading}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {loading ? "..." : "Submit Answers"}
+ </button>
+ <button
+ type="button"
+ onClick={handleCancelQuestions}
+ disabled={loading}
+ className="px-3 py-1 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors"
+ >
+ Cancel
+ </button>
+ </div>
+ </div>
+ )}
+
+ {/* Input Bar */}
+ <form onSubmit={handleSubmit} className="flex items-center gap-2 p-3">
+ <select
+ value={model}
+ onChange={(e) => handleModelChange(e.target.value as LlmModel)}
+ disabled={loading || !!pendingQuestions}
+ className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs px-2 py-1 rounded-none outline-none focus:border-[#3f6fb3] disabled:opacity-50"
+ >
+ {MODEL_OPTIONS.map((opt) => (
+ <option key={opt.value} value={opt.value}>
+ {opt.label}
+ </option>
+ ))}
+ </select>
+ <span className="text-[#555] font-mono text-[10px]">
+ [{getContextLabel(context)}]
+ </span>
+ <span className="text-[#9bc3ff] font-mono text-sm">&gt;</span>
+ <input
+ ref={inputRef}
+ type="text"
+ value={input}
+ onChange={(e) => setInput(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={
+ loading
+ ? "Processing..."
+ : pendingQuestions
+ ? "Answer questions above first..."
+ : getPlaceholder(context)
+ }
+ disabled={loading || !!pendingQuestions}
+ className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]"
+ />
+ {messages.length > 0 && (
+ <button
+ type="button"
+ onClick={handleClearHistory}
+ className="text-[#555] hover:text-[#9bc3ff] font-mono text-xs transition-colors"
+ >
+ clear
+ </button>
+ )}
+ <button
+ type="submit"
+ disabled={loading || !input.trim() || !!pendingQuestions}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {loading ? "..." : "Send"}
+ </button>
+ </form>
+ </div>
+ );
+}