summaryrefslogtreecommitdiff
path: root/makima/frontend/src/routes
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/routes')
-rw-r--r--makima/frontend/src/routes/_index.tsx12
-rw-r--r--makima/frontend/src/routes/files.tsx350
-rw-r--r--makima/frontend/src/routes/login.tsx150
-rw-r--r--makima/frontend/src/routes/mesh.tsx634
-rw-r--r--makima/frontend/src/routes/settings.tsx724
5 files changed, 1861 insertions, 9 deletions
diff --git a/makima/frontend/src/routes/_index.tsx b/makima/frontend/src/routes/_index.tsx
index 4c3c2c0..7084c2e 100644
--- a/makima/frontend/src/routes/_index.tsx
+++ b/makima/frontend/src/routes/_index.tsx
@@ -13,18 +13,18 @@ export default function HomePage() {
</div>
<span className="inline-block px-2 py-1 border border-[#3f6fb3] bg-[#0f1c2f] text-[#9bc3ff] font-mono text-xs tracking-wide uppercase mb-3">
- Listening System
+ Control System
</span>
<h2 className="m-0 mb-3 text-xl text-[#f0f5ff] tracking-wide">
- Mesh Listening Lattice
+ Mesh Orchestration Platform
</h2>
<p className="my-2 text-[#e4edff]">
- Makima is a mesh listening lattice for contested domains, delivering
- live audio surveillance, detection, and analysis in one persistent layer.
+ Makima is a control system for orchestrating distributed daemon meshes,
+ coordinating concurrent execution across distinct domains.
</p>
<p className="my-2 text-[#e4edff]">
- Dynamic telemetry for detection, orchestration, and mission-critical
- decisions. Real-time transcription with speaker diarization.
+ Unified command interface for spawning, monitoring, and directing
+ worker daemons. Real-time task coordination with overlay management.
</p>
</section>
</main>
diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx
index 0d870f7..0645b85 100644
--- a/makima/frontend/src/routes/files.tsx
+++ b/makima/frontend/src/routes/files.tsx
@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useRef } from "react";
import { useParams, useNavigate } from "react-router";
import { Masthead } from "../components/Masthead";
import { FileList } from "../components/files/FileList";
-import { FileDetail } from "../components/files/FileDetail";
+import { FileDetail, type FocusedElement } from "../components/files/FileDetail";
import { CliInput } from "../components/files/CliInput";
import { ConflictNotification } from "../components/files/ConflictNotification";
import { UpdateNotification } from "../components/files/UpdateNotification";
@@ -12,7 +12,8 @@ import {
useFileSubscription,
type FileUpdateEvent,
} from "../hooks/useFileSubscription";
-import type { FileDetail as FileDetailType, BodyElement } from "../lib/api";
+import type { FileDetail as FileDetailType, BodyElement, Task } from "../lib/api";
+import { createTask } from "../lib/api";
export default function FilesPage() {
const { id } = useParams<{ id: string }>();
@@ -23,6 +24,9 @@ export default function FilesPage() {
const [creating, setCreating] = useState(false);
const [remoteUpdate, setRemoteUpdate] = useState<FileUpdateEvent | null>(null);
const [remoteFileData, setRemoteFileData] = useState<FileDetailType | null>(null);
+ const [focusedElement, setFocusedElement] = useState<FocusedElement | null>(null);
+ const [suggestedPrompt, setSuggestedPrompt] = useState<string | null>(null);
+ const [createdTask, setCreatedTask] = useState<Task | null>(null);
const pendingUpdateRef = useRef(false);
// Track the last version we sent to detect our own updates
const lastSentVersionRef = useRef<number | null>(null);
@@ -85,6 +89,7 @@ export default function FilesPage() {
currentVersionRef.current = null;
setRemoteUpdate(null);
setRemoteFileData(null);
+ setFocusedElement(null);
fetchFile(id).then((detail) => {
if (detail) {
currentVersionRef.current = detail.version;
@@ -285,6 +290,276 @@ export default function FilesPage() {
[fileDetail, id, editFile, updateHasLocalChanges]
);
+ // Element action handlers for context menu
+ const handleBodyElementDelete = useCallback(
+ async (index: number) => {
+ if (fileDetail && id) {
+ const newBody = fileDetail.body.filter((_, i) => i !== index);
+
+ // Update local state immediately
+ setFileDetail({
+ ...fileDetail,
+ body: newBody,
+ });
+ updateHasLocalChanges(true);
+
+ // Clear focus if deleting focused element
+ if (focusedElement?.index === index) {
+ setFocusedElement(null);
+ } else if (focusedElement && focusedElement.index > index) {
+ // Adjust focus index if deleting an element before it
+ setFocusedElement({
+ ...focusedElement,
+ index: focusedElement.index - 1,
+ });
+ }
+
+ // Save to backend
+ pendingUpdateRef.current = true;
+ lastSentVersionRef.current = fileDetail.version;
+ try {
+ const result = await editFile(id, { body: newBody, version: fileDetail.version });
+ if (result) {
+ lastSavedVersionRef.current = result.version;
+ currentVersionRef.current = result.version;
+ setFileDetail(result);
+ updateHasLocalChanges(false);
+ }
+ } finally {
+ pendingUpdateRef.current = false;
+ lastSentVersionRef.current = null;
+ }
+ }
+ },
+ [fileDetail, id, editFile, updateHasLocalChanges, focusedElement]
+ );
+
+ const handleBodyElementDuplicate = useCallback(
+ async (index: number) => {
+ if (fileDetail && id) {
+ const elementToDuplicate = fileDetail.body[index];
+ if (!elementToDuplicate) return;
+
+ const newBody = [...fileDetail.body];
+ // Insert duplicate after the original
+ newBody.splice(index + 1, 0, { ...elementToDuplicate });
+
+ // Update local state immediately
+ setFileDetail({
+ ...fileDetail,
+ body: newBody,
+ });
+ updateHasLocalChanges(true);
+
+ // Adjust focus index if duplicating before focused element
+ if (focusedElement && focusedElement.index > index) {
+ setFocusedElement({
+ ...focusedElement,
+ index: focusedElement.index + 1,
+ });
+ }
+
+ // Save to backend
+ pendingUpdateRef.current = true;
+ lastSentVersionRef.current = fileDetail.version;
+ try {
+ const result = await editFile(id, { body: newBody, version: fileDetail.version });
+ if (result) {
+ lastSavedVersionRef.current = result.version;
+ currentVersionRef.current = result.version;
+ setFileDetail(result);
+ updateHasLocalChanges(false);
+ }
+ } finally {
+ pendingUpdateRef.current = false;
+ lastSentVersionRef.current = null;
+ }
+ }
+ },
+ [fileDetail, id, editFile, updateHasLocalChanges, focusedElement]
+ );
+
+ const handleFocusElement = useCallback((element: FocusedElement | null) => {
+ setFocusedElement(element);
+ }, []);
+
+ const handleClearFocus = useCallback(() => {
+ setFocusedElement(null);
+ }, []);
+
+ // Convert element to a different type
+ const handleConvertElement = useCallback(
+ async (index: number, toType: string) => {
+ if (!fileDetail || !id) return;
+
+ const element = fileDetail.body[index];
+ if (!element) return;
+
+ // Extract text content from current element
+ let textContent = "";
+ switch (element.type) {
+ case "heading":
+ case "paragraph":
+ textContent = element.text;
+ break;
+ case "code":
+ textContent = element.content;
+ break;
+ case "list":
+ textContent = element.items.join("\n");
+ break;
+ default:
+ return; // Can't convert charts/images
+ }
+
+ // Create new element based on target type
+ let newElement: BodyElement;
+ if (toType === "paragraph") {
+ newElement = { type: "paragraph", text: textContent };
+ } else if (toType === "list_unordered") {
+ const items = textContent.split("\n").filter(line => line.trim());
+ newElement = { type: "list", ordered: false, items };
+ } else if (toType === "list_ordered") {
+ const items = textContent.split("\n").filter(line => line.trim());
+ newElement = { type: "list", ordered: true, items };
+ } else if (toType === "code") {
+ newElement = { type: "code", content: textContent };
+ } else if (toType.startsWith("heading_")) {
+ const level = parseInt(toType.replace("heading_", ""), 10) as 1 | 2 | 3 | 4 | 5 | 6;
+ newElement = { type: "heading", level, text: textContent };
+ } else {
+ return; // Unknown type
+ }
+
+ const newBody = [...fileDetail.body];
+ newBody[index] = newElement;
+
+ // Update local state
+ setFileDetail({ ...fileDetail, body: newBody });
+ updateHasLocalChanges(true);
+
+ // Update focus if this element was focused
+ if (focusedElement?.index === index) {
+ setFocusedElement({
+ index,
+ type: newElement.type,
+ preview: textContent.slice(0, 50) + (textContent.length > 50 ? "..." : ""),
+ });
+ }
+
+ // Save to backend
+ pendingUpdateRef.current = true;
+ lastSentVersionRef.current = fileDetail.version;
+ try {
+ const result = await editFile(id, { body: newBody, version: fileDetail.version });
+ if (result) {
+ lastSavedVersionRef.current = result.version;
+ currentVersionRef.current = result.version;
+ setFileDetail(result);
+ updateHasLocalChanges(false);
+ }
+ } finally {
+ pendingUpdateRef.current = false;
+ lastSentVersionRef.current = null;
+ }
+ },
+ [fileDetail, id, editFile, updateHasLocalChanges, focusedElement]
+ );
+
+ // Generate from element - focus on it and pre-fill a prompt
+ const handleGenerateFromElement = useCallback(
+ (index: number, action: string) => {
+ if (!fileDetail) return;
+
+ const element = fileDetail.body[index];
+ if (!element) return;
+
+ // Get preview text
+ let preview = "";
+ switch (element.type) {
+ case "heading":
+ case "paragraph":
+ preview = element.text.slice(0, 50);
+ break;
+ case "code":
+ preview = element.content.slice(0, 50);
+ break;
+ case "list":
+ preview = element.items[0]?.slice(0, 40) || "";
+ break;
+ default:
+ preview = "Element";
+ }
+
+ // Focus on the element
+ setFocusedElement({
+ index,
+ type: element.type,
+ preview: preview + (preview.length >= 50 ? "..." : ""),
+ });
+
+ // Set suggested prompt based on action
+ let prompt = "";
+ switch (action) {
+ case "elaborate":
+ prompt = "Elaborate and expand on this content";
+ break;
+ case "summarize":
+ prompt = "Summarize this content";
+ break;
+ case "extract_actions":
+ prompt = "Extract action items from this content";
+ break;
+ }
+ setSuggestedPrompt(prompt);
+ },
+ [fileDetail]
+ );
+
+ // Create a mesh task from an element
+ const handleCreateTaskFromElement = useCallback(
+ async (index: number, selectedText?: string) => {
+ if (!fileDetail) return;
+
+ const element = fileDetail.body[index];
+ if (!element) return;
+
+ // Get the content to use as task plan
+ let content = selectedText || "";
+ if (!content) {
+ switch (element.type) {
+ case "heading":
+ case "paragraph":
+ content = element.text;
+ break;
+ case "code":
+ content = element.content;
+ break;
+ case "list":
+ content = element.items.join("\n");
+ break;
+ default:
+ content = "Task from file element";
+ }
+ }
+
+ // Create a task name from the content
+ const name = content.slice(0, 60) + (content.length > 60 ? "..." : "");
+
+ try {
+ const task = await createTask({
+ name,
+ plan: content,
+ description: `Created from ${fileDetail.name}`,
+ });
+ setCreatedTask(task);
+ } catch (err) {
+ console.error("Failed to create task:", err);
+ }
+ },
+ [fileDetail]
+ );
+
const handleCreate = useCallback(async () => {
if (creating) return;
setCreating(true);
@@ -301,6 +576,28 @@ export default function FilesPage() {
}
}, [creating, saveFile, navigate]);
+ const handleUploadMarkdown = useCallback(async (name: string, body: BodyElement[]) => {
+ if (creating) return;
+ setCreating(true);
+ try {
+ const newFile = await saveFile({
+ name,
+ transcript: [],
+ });
+ if (newFile) {
+ // Update with the parsed body
+ const updated = await editFile(newFile.id, { body, version: newFile.version });
+ if (updated) {
+ navigate(`/files/${updated.id}`);
+ } else {
+ navigate(`/files/${newFile.id}`);
+ }
+ }
+ } finally {
+ setCreating(false);
+ }
+ }, [creating, saveFile, editFile, navigate]);
+
// Conflict resolution handlers
const handleConflictReload = useCallback(async () => {
if (id) {
@@ -381,9 +678,16 @@ export default function FilesPage() {
onDelete={handleDelete}
onBodyElementUpdate={handleBodyElementUpdate}
onBodyReorder={handleBodyReorder}
+ onBodyElementDelete={handleBodyElementDelete}
+ onBodyElementDuplicate={handleBodyElementDuplicate}
+ onConvertElement={handleConvertElement}
+ onGenerateFromElement={handleGenerateFromElement}
+ onCreateTaskFromElement={handleCreateTaskFromElement}
onEditingChange={updateIsActivelyEditing}
hasPendingRemoteUpdate={!!remoteUpdate}
onOverwrite={handleRemoteUpdateDismiss}
+ focusedElement={focusedElement}
+ onFocusElement={handleFocusElement}
versions={versions}
versionsLoading={versionsLoading}
selectedVersion={selectedVersion}
@@ -395,7 +699,14 @@ export default function FilesPage() {
/>
</div>
<div className="shrink-0">
- <CliInput fileId={id} onUpdate={handleBodyUpdate} />
+ <CliInput
+ fileId={id}
+ onUpdate={handleBodyUpdate}
+ focusedElement={focusedElement}
+ onClearFocus={handleClearFocus}
+ suggestedPrompt={suggestedPrompt}
+ onClearSuggestedPrompt={() => setSuggestedPrompt(null)}
+ />
</div>
</div>
) : id && detailLoading ? (
@@ -409,6 +720,7 @@ export default function FilesPage() {
onSelect={handleSelectFile}
onDelete={handleDelete}
onCreate={handleCreate}
+ onUploadMarkdown={handleUploadMarkdown}
/>
)}
</main>
@@ -432,6 +744,38 @@ export default function FilesPage() {
onDismiss={handleRemoteUpdateDismiss}
/>
)}
+
+ {/* Task created notification */}
+ {createdTask && (
+ <div className="fixed bottom-4 right-4 z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] p-4 shadow-lg max-w-sm">
+ <div className="flex items-start gap-3">
+ <span className="text-[#75aafc] text-lg">@</span>
+ <div className="flex-1">
+ <p className="font-mono text-xs text-[#9bc3ff] mb-1">Task created</p>
+ <p className="font-mono text-sm text-white truncate mb-3">
+ {createdTask.name}
+ </p>
+ <div className="flex gap-2">
+ <button
+ onClick={() => {
+ navigate(`/mesh/${createdTask.id}`);
+ setCreatedTask(null);
+ }}
+ className="px-3 py-1 font-mono text-xs text-[#0a1628] bg-[#75aafc] hover:bg-[#9bc3ff] transition-colors"
+ >
+ Go to task
+ </button>
+ <button
+ onClick={() => setCreatedTask(null)}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.3)] hover:border-[#75aafc] transition-colors"
+ >
+ Dismiss
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
</div>
);
}
diff --git a/makima/frontend/src/routes/login.tsx b/makima/frontend/src/routes/login.tsx
new file mode 100644
index 0000000..63b3af3
--- /dev/null
+++ b/makima/frontend/src/routes/login.tsx
@@ -0,0 +1,150 @@
+import { useState, type FormEvent } from "react";
+import { useNavigate } from "react-router";
+import { useAuth } from "../contexts/AuthContext";
+import { Masthead } from "../components/Masthead";
+
+type AuthMode = "signin" | "signup";
+
+export default function LoginPage() {
+ const navigate = useNavigate();
+ const { signIn, signUp, isAuthConfigured, isAuthenticated } = useAuth();
+
+ const [mode, setMode] = useState<AuthMode>("signin");
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState<string | null>(null);
+ const [loading, setLoading] = useState(false);
+ const [message, setMessage] = useState<string | null>(null);
+
+ // Redirect if already authenticated
+ if (isAuthenticated && isAuthConfigured) {
+ navigate("/mesh");
+ return null;
+ }
+
+ const handleEmailAuth = async (e: FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ setMessage(null);
+ setLoading(true);
+
+ try {
+ if (mode === "signin") {
+ const { error } = await signIn(email, password);
+ if (error) {
+ setError(error.message);
+ } else {
+ navigate("/mesh");
+ }
+ } else if (mode === "signup") {
+ const { error } = await signUp(email, password);
+ if (error) {
+ setError(error.message);
+ } else {
+ setMessage("Check your email for a confirmation link.");
+ }
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // If auth is not configured, show a message
+ if (!isAuthConfigured) {
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col">
+ <Masthead />
+ <main className="flex-1 flex items-center justify-center p-4">
+ <div className="w-full max-w-md text-center">
+ <h1 className="text-2xl font-bold mb-4">Authentication Required</h1>
+ <p className="text-zinc-400 mb-4">
+ Authentication is not configured. Please configure Supabase authentication to use this application.
+ </p>
+ <p className="text-zinc-500 text-sm">
+ For API access, use an API key in request headers instead.
+ </p>
+ </div>
+ </main>
+ </div>
+ );
+ }
+
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col">
+ <Masthead />
+ <main className="flex-1 flex items-center justify-center p-4">
+ <div className="w-full max-w-md">
+ <div className="text-center mb-8">
+ <h1 className="text-2xl font-bold mb-2">Sign In</h1>
+ <p className="text-zinc-400">
+ {mode === "signin" && "Sign in to your account"}
+ {mode === "signup" && "Create a new account"}
+ </p>
+ </div>
+
+ {/* Mode switcher */}
+ <div className="flex border-b border-zinc-800 mb-6">
+ <button
+ onClick={() => setMode("signin")}
+ className={`flex-1 py-2 text-sm transition-colors ${
+ mode === "signin"
+ ? "text-white border-b-2 border-white"
+ : "text-zinc-500 hover:text-zinc-300"
+ }`}
+ >
+ Sign In
+ </button>
+ <button
+ onClick={() => setMode("signup")}
+ className={`flex-1 py-2 text-sm transition-colors ${
+ mode === "signup"
+ ? "text-white border-b-2 border-white"
+ : "text-zinc-500 hover:text-zinc-300"
+ }`}
+ >
+ Sign Up
+ </button>
+ </div>
+
+ {/* Email/password form */}
+ <form onSubmit={handleEmailAuth} className="space-y-4">
+ <div>
+ <label className="block text-sm text-zinc-400 mb-1">Email</label>
+ <input
+ type="email"
+ value={email}
+ onChange={(e) => setEmail(e.target.value)}
+ placeholder="you@example.com"
+ className="w-full px-3 py-2 bg-zinc-900 border border-zinc-800 rounded text-white placeholder-zinc-600 focus:outline-none focus:border-zinc-600"
+ required
+ />
+ </div>
+ <div>
+ <label className="block text-sm text-zinc-400 mb-1">Password</label>
+ <input
+ type="password"
+ value={password}
+ onChange={(e) => setPassword(e.target.value)}
+ placeholder="********"
+ className="w-full px-3 py-2 bg-zinc-900 border border-zinc-800 rounded text-white placeholder-zinc-600 focus:outline-none focus:border-zinc-600"
+ required
+ minLength={6}
+ />
+ </div>
+
+ {error && <div className="text-red-400 text-sm">{error}</div>}
+ {message && <div className="text-green-400 text-sm">{message}</div>}
+
+ <button
+ type="submit"
+ disabled={loading}
+ className="w-full py-2 bg-white text-black rounded font-medium hover:bg-zinc-200 transition-colors disabled:opacity-50"
+ >
+ {loading ? "Loading..." : mode === "signin" ? "Sign In" : "Sign Up"}
+ </button>
+ </form>
+ </div>
+ </main>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx
new file mode 100644
index 0000000..852ce58
--- /dev/null
+++ b/makima/frontend/src/routes/mesh.tsx
@@ -0,0 +1,634 @@
+import { useState, useCallback, useEffect, useRef, useMemo, type MouseEvent } from "react";
+import { useParams, useNavigate } from "react-router";
+import { Masthead } from "../components/Masthead";
+import { TaskList } from "../components/mesh/TaskList";
+import { TaskDetail } from "../components/mesh/TaskDetail";
+import { TaskOutput } from "../components/mesh/TaskOutput";
+import { UnifiedMeshChatInput } from "../components/mesh/UnifiedMeshChatInput";
+import { useTasks } from "../hooks/useTasks";
+import { useTaskSubscription, type TaskUpdateEvent, type TaskOutputEvent } from "../hooks/useTaskSubscription";
+import type { TaskWithSubtasks, MeshChatContext } from "../lib/api";
+import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput } from "../lib/api";
+
+// View modes for the task detail page
+type ViewMode = "split" | "task" | "output";
+
+// Minimum panel widths (in pixels)
+const MIN_TASK_WIDTH = 300;
+const MIN_OUTPUT_WIDTH = 200;
+
+// TODO: Store task output in database for resuming from any device.
+// Currently only persisted in localStorage which is device-specific.
+
+// LocalStorage key prefix for task output
+const STORAGE_KEY_PREFIX_OUTPUT = "makima-task-output-";
+
+// Load persisted output from localStorage with deduplication
+function loadPersistedOutput(taskId: string): TaskOutputEvent[] {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY_PREFIX_OUTPUT + taskId);
+ if (!stored) return [];
+ const entries = JSON.parse(stored) as TaskOutputEvent[];
+
+ // Deduplicate consecutive identical entries (cleanup from previous bug)
+ const deduplicated: TaskOutputEvent[] = [];
+ for (const entry of entries) {
+ const last = deduplicated[deduplicated.length - 1];
+ if (
+ !last ||
+ last.messageType !== entry.messageType ||
+ last.content !== entry.content ||
+ last.toolName !== entry.toolName
+ ) {
+ deduplicated.push(entry);
+ }
+ }
+
+ // Save cleaned up version if we removed duplicates
+ if (deduplicated.length !== entries.length) {
+ savePersistedOutput(taskId, deduplicated);
+ }
+
+ return deduplicated;
+ } catch {
+ return [];
+ }
+}
+
+// Save output to localStorage
+function savePersistedOutput(taskId: string, entries: TaskOutputEvent[]): void {
+ try {
+ localStorage.setItem(STORAGE_KEY_PREFIX_OUTPUT + taskId, JSON.stringify(entries));
+ } catch {
+ // Ignore storage errors
+ }
+}
+
+// Clear output from localStorage
+function clearPersistedOutput(taskId: string): void {
+ try {
+ localStorage.removeItem(STORAGE_KEY_PREFIX_OUTPUT + taskId);
+ } catch {
+ // Ignore storage errors
+ }
+}
+
+export default function MeshPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const { tasks, loading, error, conflict, clearConflict, fetchTask, fetchTasks, editTask, removeTask, saveTask } = useTasks();
+ const [taskDetail, setTaskDetail] = useState<TaskWithSubtasks | null>(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [taskOutputEntries, setTaskOutputEntries] = useState<TaskOutputEvent[]>([]);
+ const [isStreaming, setIsStreaming] = useState(false);
+ // Track which subtask's output we're viewing (null = parent task)
+ const [viewingSubtaskId, setViewingSubtaskId] = useState<string | null>(null);
+ const [viewingSubtaskName, setViewingSubtaskName] = useState<string | null>(null);
+ // View mode for the split panel layout
+ const [viewMode, setViewMode] = useState<ViewMode>("split");
+ // Width of the task panel as a percentage (0-100)
+ const [taskPanelPercent, setTaskPanelPercent] = useState(66.67);
+ // Track resizing state
+ const [isResizing, setIsResizing] = useState(false);
+ const containerRef = useRef<HTMLDivElement>(null);
+ // Track which task we've loaded output for to avoid stale saves
+ const loadedTaskIdRef = useRef<string | null>(null);
+
+ // Handle task update events from WebSocket
+ const handleTaskUpdate = useCallback(async (event: TaskUpdateEvent) => {
+ // Refresh task list if we're viewing the list
+ if (!id) {
+ fetchTasks();
+ return;
+ }
+
+ // Check if this update is for the current task or one of its subtasks
+ const isCurrentTask = event.taskId === id;
+ const isSubtask = taskDetail?.subtasks.some((st) => st.id === event.taskId);
+
+ // Refresh task detail if the update is for current task or any subtask
+ // This ensures subtask status changes (e.g., when orchestrator starts them) are reflected
+ if (isCurrentTask || isSubtask) {
+ const updated = await fetchTask(id);
+ if (updated) {
+ setTaskDetail(updated);
+ }
+ }
+
+ // Update streaming state based on status for current task
+ if (isCurrentTask) {
+ setIsStreaming(event.status === "running");
+ }
+ }, [id, fetchTask, fetchTasks, taskDetail?.subtasks]);
+
+ // The task ID whose output we're currently viewing
+ const activeOutputTaskId = viewingSubtaskId || id;
+
+ // Handle task output events from WebSocket
+ const handleTaskOutput = useCallback((event: TaskOutputEvent) => {
+ // Only process output for the task we're currently viewing
+ if (event.taskId === activeOutputTaskId) {
+ setTaskOutputEntries((prev) => {
+ // Deduplicate by checking if last entry is identical
+ // This prevents duplicates from React StrictMode or WebSocket reconnects
+ const lastEntry = prev[prev.length - 1];
+ if (
+ lastEntry &&
+ lastEntry.messageType === event.messageType &&
+ lastEntry.content === event.content &&
+ lastEntry.toolName === event.toolName
+ ) {
+ return prev; // Skip duplicate
+ }
+ const newEntries = [...prev, event];
+ // Persist to localStorage
+ savePersistedOutput(event.taskId, newEntries);
+ return newEntries;
+ });
+ }
+ }, [activeOutputTaskId]);
+
+ // Handle user input sent to task - show immediately in output
+ const handleUserInput = useCallback((message: string) => {
+ if (!activeOutputTaskId) return;
+ const userEntry: TaskOutputEvent = {
+ taskId: activeOutputTaskId,
+ messageType: "user_input",
+ content: message,
+ isPartial: false,
+ };
+ setTaskOutputEntries((prev) => {
+ const newEntries = [...prev, userEntry];
+ savePersistedOutput(activeOutputTaskId, newEntries);
+ return newEntries;
+ });
+ }, [activeOutputTaskId]);
+
+ // Subscribe to task updates and output
+ // When viewing a subtask's output, subscribe to that instead of the parent
+ // Always subscribe to all updates so we see subtask status changes
+ const { connected } = useTaskSubscription({
+ taskId: id || null,
+ subscribeAll: true, // Always subscribe to all - needed to see subtask updates
+ subscribeOutput: !!activeOutputTaskId, // Subscribe to output when viewing a task
+ outputTaskId: activeOutputTaskId || undefined, // Which task's output to subscribe to
+ onUpdate: handleTaskUpdate,
+ onOutput: handleTaskOutput,
+ });
+
+ // Load persisted output when task or viewed subtask changes
+ useEffect(() => {
+ if (activeOutputTaskId) {
+ // First load from localStorage (instant, for local cache)
+ const persisted = loadPersistedOutput(activeOutputTaskId);
+ setTaskOutputEntries(persisted);
+ loadedTaskIdRef.current = activeOutputTaskId;
+
+ // Then fetch from API to get any output we missed
+ // (e.g., subtask was running before we started viewing it)
+ getTaskOutput(activeOutputTaskId)
+ .then((response) => {
+ if (response.entries.length > 0) {
+ setTaskOutputEntries((prev) => {
+ // API returns all historical entries in chronological order
+ const apiEntries = response.entries.map(entry => ({
+ taskId: entry.taskId,
+ messageType: entry.messageType,
+ content: entry.content,
+ toolName: entry.toolName,
+ toolInput: entry.toolInput,
+ isError: entry.isError,
+ costUsd: entry.costUsd,
+ durationMs: entry.durationMs,
+ isPartial: false,
+ }));
+
+ // If localStorage is empty, just use API data
+ if (prev.length === 0) {
+ savePersistedOutput(activeOutputTaskId, apiEntries);
+ return apiEntries;
+ }
+
+ // localStorage has user_input entries in correct positions - trust its order
+ // Only append API entries that we don't already have locally
+ const localKeys = new Set(prev.map(e => `${e.messageType}:${e.content}`));
+ const newFromApi = apiEntries.filter(e => !localKeys.has(`${e.messageType}:${e.content}`));
+
+ // Keep local order (has user_input in correct spots), append new API data
+ const merged = [...prev, ...newFromApi];
+ savePersistedOutput(activeOutputTaskId, merged);
+ return merged;
+ });
+ }
+ })
+ .catch((err) => {
+ console.error("Failed to fetch task output:", err);
+ });
+ } else {
+ setTaskOutputEntries([]);
+ loadedTaskIdRef.current = null;
+ }
+ setIsStreaming(false);
+ }, [activeOutputTaskId]);
+
+ // Reset subtask view when navigating to a different parent task
+ useEffect(() => {
+ setViewingSubtaskId(null);
+ setViewingSubtaskName(null);
+ }, [id]);
+
+ // Toggle viewing a subtask's output (for running subtasks)
+ const handleToggleSubtaskOutput = useCallback(
+ (subtaskId: string, subtaskName: string) => {
+ if (viewingSubtaskId === subtaskId) {
+ // Already viewing this subtask, switch back to parent
+ setViewingSubtaskId(null);
+ setViewingSubtaskName(null);
+ } else {
+ // Switch to viewing this subtask's output
+ setViewingSubtaskId(subtaskId);
+ setViewingSubtaskName(subtaskName);
+ }
+ },
+ [viewingSubtaskId]
+ );
+
+ // Load task detail when URL has an id
+ useEffect(() => {
+ if (id) {
+ setDetailLoading(true);
+ fetchTask(id).then((detail) => {
+ setTaskDetail(detail);
+ setDetailLoading(false);
+ });
+ } else {
+ setTaskDetail(null);
+ }
+ }, [id, fetchTask]);
+
+ const handleSelectTask = useCallback(
+ (taskId: string) => {
+ navigate(`/mesh/${taskId}`);
+ },
+ [navigate]
+ );
+
+ const handleBack = useCallback(() => {
+ // If viewing a subtask, go back to parent
+ if (taskDetail?.parentTaskId) {
+ navigate(`/mesh/${taskDetail.parentTaskId}`);
+ } else {
+ navigate("/mesh");
+ }
+ }, [navigate, taskDetail]);
+
+ const handleDelete = useCallback(
+ async (taskId: string) => {
+ if (confirm("Are you sure you want to delete this task?")) {
+ const success = await removeTask(taskId);
+ if (success && id === taskId) {
+ // If deleting current task, go back
+ if (taskDetail?.parentTaskId) {
+ navigate(`/mesh/${taskDetail.parentTaskId}`);
+ } else {
+ navigate("/mesh");
+ }
+ }
+ }
+ },
+ [removeTask, id, taskDetail, navigate]
+ );
+
+ const handleStart = useCallback(
+ async (taskId: string) => {
+ try {
+ const updated = await startTaskApi(taskId);
+ setTaskDetail((prev) => prev ? { ...prev, ...updated } : prev);
+ } catch (e) {
+ console.error("Failed to start task:", e);
+ alert(e instanceof Error ? e.message : "Failed to start task");
+ }
+ },
+ []
+ );
+
+ const handleStop = useCallback(
+ async (taskId: string) => {
+ try {
+ const updated = await stopTaskApi(taskId);
+ setTaskDetail((prev) => prev ? { ...prev, ...updated } : prev);
+ } catch (e) {
+ console.error("Failed to stop task:", e);
+ alert(e instanceof Error ? e.message : "Failed to stop task");
+ }
+ },
+ []
+ );
+
+ const handleRestart = useCallback(
+ async (taskId: string) => {
+ try {
+ // First stop the task
+ await stopTaskApi(taskId);
+ // Then start it again
+ const updated = await startTaskApi(taskId);
+ setTaskDetail((prev) => prev ? { ...prev, ...updated } : prev);
+ } catch (e) {
+ console.error("Failed to restart task:", e);
+ alert(e instanceof Error ? e.message : "Failed to restart task");
+ }
+ },
+ []
+ );
+
+ const handleContinue = useCallback(
+ async (taskId: string) => {
+ try {
+ // Start the task again from terminal state
+ const updated = await startTaskApi(taskId);
+ setTaskDetail((prev) => prev ? { ...prev, ...updated } : prev);
+ } catch (e) {
+ console.error("Failed to continue task:", e);
+ alert(e instanceof Error ? e.message : "Failed to continue task");
+ }
+ },
+ []
+ );
+
+ const handleSave = useCallback(
+ async (taskId: string, name: string, description: string, plan: string, targetRepoPath?: string, completionAction?: string) => {
+ if (!taskDetail) return;
+ const result = await editTask(taskId, {
+ name,
+ description: description || undefined,
+ plan,
+ targetRepoPath: targetRepoPath || undefined,
+ completionAction: completionAction as import("../lib/api").CompletionAction | undefined,
+ version: taskDetail.version,
+ });
+ if (result) {
+ setTaskDetail(result);
+ }
+ },
+ [editTask, taskDetail]
+ );
+
+ const handleCreate = useCallback(async () => {
+ if (creating) return;
+ setCreating(true);
+ try {
+ const newTask = await saveTask({
+ name: `Task ${new Date().toLocaleDateString()}`,
+ plan: "# Plan\n\nDescribe what this task should accomplish...",
+ });
+ if (newTask) {
+ navigate(`/mesh/${newTask.id}`);
+ }
+ } finally {
+ setCreating(false);
+ }
+ }, [creating, saveTask, navigate]);
+
+ const handleCreateSubtask = useCallback(async () => {
+ if (!taskDetail || creating) return;
+ setCreating(true);
+ try {
+ const newTask = await saveTask({
+ name: `Subtask of ${taskDetail.name}`,
+ plan: "# Plan\n\nDescribe what this subtask should accomplish...",
+ parentTaskId: taskDetail.id,
+ });
+ if (newTask) {
+ // Refresh current task to show new subtask
+ const refreshed = await fetchTask(taskDetail.id);
+ if (refreshed) {
+ setTaskDetail(refreshed);
+ }
+ }
+ } finally {
+ setCreating(false);
+ }
+ }, [creating, saveTask, taskDetail, fetchTask]);
+
+ // Callback when task is updated via CLI
+ const handleTaskUpdatedFromCli = useCallback(async () => {
+ if (id) {
+ const updated = await fetchTask(id);
+ if (updated) {
+ setTaskDetail(updated);
+ }
+ }
+ // Also refresh the task list
+ fetchTasks();
+ }, [id, fetchTask, fetchTasks]);
+
+ // Calculate chat context based on current view
+ const chatContext: MeshChatContext = useMemo(() => {
+ if (!id) {
+ return { type: "mesh" };
+ }
+ if (taskDetail?.parentTaskId) {
+ return { type: "subtask", taskId: id, parentTaskId: taskDetail.parentTaskId };
+ }
+ return { type: "task", taskId: id };
+ }, [id, taskDetail?.parentTaskId]);
+
+ // Handle resizing of the split panel
+ const handleResizeStart = useCallback((e: MouseEvent) => {
+ e.preventDefault();
+ setIsResizing(true);
+ }, []);
+
+ useEffect(() => {
+ if (!isResizing) return;
+
+ const handleMouseMove = (e: globalThis.MouseEvent) => {
+ if (!containerRef.current) return;
+ const containerRect = containerRef.current.getBoundingClientRect();
+ const containerWidth = containerRect.width;
+ const mouseX = e.clientX - containerRect.left;
+
+ // Calculate percentage, respecting minimum widths
+ const minTaskPercent = (MIN_TASK_WIDTH / containerWidth) * 100;
+ const maxTaskPercent = ((containerWidth - MIN_OUTPUT_WIDTH) / containerWidth) * 100;
+ const newPercent = Math.max(minTaskPercent, Math.min(maxTaskPercent, (mouseX / containerWidth) * 100));
+
+ setTaskPanelPercent(newPercent);
+ };
+
+ const handleMouseUp = () => {
+ setIsResizing(false);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [isResizing]);
+
+ // Cycle through view modes
+ const cycleViewMode = useCallback(() => {
+ setViewMode((current) => {
+ if (current === "split") return "task";
+ if (current === "task") return "output";
+ return "split";
+ });
+ }, []);
+
+ // Get label for current view mode
+ const getViewModeLabel = (mode: ViewMode): string => {
+ switch (mode) {
+ case "split": return "Split";
+ case "task": return "Task";
+ case "output": return "Output";
+ }
+ };
+
+ return (
+ <div className="relative z-10 h-screen flex flex-col overflow-hidden">
+ <Masthead showTicker={false} showNav />
+
+ <main className="flex-1 p-4 md:p-6 min-h-0 overflow-hidden flex flex-col">
+ {error && (
+ <div className="mb-4 p-3 border border-red-400/50 bg-red-400/10 text-red-400 font-mono text-sm shrink-0">
+ {error}
+ </div>
+ )}
+
+ {conflict?.hasConflict && (
+ <div className="mb-4 p-3 border border-yellow-400/50 bg-yellow-400/10 text-yellow-400 font-mono text-sm shrink-0">
+ <p>Version conflict detected. Please reload and try again.</p>
+ <button
+ onClick={clearConflict}
+ className="mt-2 px-3 py-1 border border-yellow-400/30 hover:border-yellow-400/50 text-xs uppercase"
+ >
+ Dismiss
+ </button>
+ </div>
+ )}
+
+ {/* Main content area - conditional based on route */}
+ <div className="flex-1 flex flex-col min-h-0 overflow-hidden gap-4">
+ {id && taskDetail ? (
+ <>
+ {/* Header with connection status and view toggle */}
+ <div className="flex items-center justify-between shrink-0">
+ <div className="flex items-center gap-2">
+ <span
+ className={`w-2 h-2 rounded-full ${
+ connected ? "bg-green-400" : "bg-yellow-400 animate-pulse"
+ }`}
+ />
+ <span className="font-mono text-[10px] text-[#75aafc] uppercase">
+ {connected ? "Connected" : "Connecting..."}
+ </span>
+ </div>
+ {/* View mode toggle */}
+ <button
+ onClick={cycleViewMode}
+ className="px-3 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ View: {getViewModeLabel(viewMode)}
+ </button>
+ </div>
+
+ {/* Split panel layout */}
+ <div
+ ref={containerRef}
+ className={`flex-1 flex min-h-0 overflow-hidden ${isResizing ? "select-none" : ""}`}
+ >
+ {/* Task detail panel */}
+ {(viewMode === "split" || viewMode === "task") && (
+ <div
+ className="min-h-0 overflow-hidden"
+ style={{
+ width: viewMode === "split" ? `${taskPanelPercent}%` : "100%",
+ flexShrink: 0,
+ }}
+ >
+ <TaskDetail
+ task={taskDetail}
+ loading={detailLoading}
+ onBack={handleBack}
+ onSave={handleSave}
+ onDelete={handleDelete}
+ onStart={handleStart}
+ onStop={handleStop}
+ onRestart={handleRestart}
+ onContinue={handleContinue}
+ onSelectSubtask={handleSelectTask}
+ onCreateSubtask={handleCreateSubtask}
+ onToggleSubtaskOutput={handleToggleSubtaskOutput}
+ viewingSubtaskId={viewingSubtaskId}
+ />
+ </div>
+ )}
+
+ {/* Resizable divider */}
+ {viewMode === "split" && (
+ <div
+ className="w-1 shrink-0 cursor-col-resize bg-[rgba(117,170,252,0.15)] hover:bg-[rgba(117,170,252,0.35)] transition-colors group flex items-center justify-center"
+ onMouseDown={handleResizeStart}
+ >
+ <div className="w-0.5 h-8 bg-[rgba(117,170,252,0.3)] group-hover:bg-[rgba(117,170,252,0.5)] rounded-full" />
+ </div>
+ )}
+
+ {/* Output panel */}
+ {(viewMode === "split" || viewMode === "output") && (
+ <div
+ className="panel min-h-0 overflow-hidden flex-1"
+ >
+ <TaskOutput
+ entries={taskOutputEntries}
+ isStreaming={isStreaming || taskDetail.status === "running"}
+ viewingSubtaskName={viewingSubtaskName}
+ onClearSubtaskView={viewingSubtaskId ? () => {
+ setViewingSubtaskId(null);
+ setViewingSubtaskName(null);
+ } : undefined}
+ onClear={() => {
+ setTaskOutputEntries([]);
+ if (activeOutputTaskId) {
+ clearPersistedOutput(activeOutputTaskId);
+ }
+ }}
+ taskId={activeOutputTaskId}
+ onUserInput={handleUserInput}
+ />
+ </div>
+ )}
+ </div>
+ </>
+ ) : id && detailLoading ? (
+ <div className="panel flex-1 flex items-center justify-center">
+ <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div>
+ </div>
+ ) : (
+ <div className="flex-1 min-h-0 overflow-hidden">
+ <TaskList
+ tasks={tasks}
+ loading={loading || creating}
+ onSelect={handleSelectTask}
+ onDelete={handleDelete}
+ onCreate={handleCreate}
+ />
+ </div>
+ )}
+
+ {/* Mesh Chat Input - always rendered to persist state across navigation */}
+ <div className="shrink-0">
+ <UnifiedMeshChatInput
+ context={chatContext}
+ onUpdate={id ? handleTaskUpdatedFromCli : fetchTasks}
+ />
+ </div>
+ </div>
+ </main>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/routes/settings.tsx b/makima/frontend/src/routes/settings.tsx
new file mode 100644
index 0000000..6d56e67
--- /dev/null
+++ b/makima/frontend/src/routes/settings.tsx
@@ -0,0 +1,724 @@
+import { useState, useEffect, type FormEvent } from "react";
+import { useAuth } from "../contexts/AuthContext";
+import { useNavigate } from "react-router";
+import { Masthead } from "../components/Masthead";
+import {
+ getApiKey,
+ createApiKey,
+ refreshApiKey,
+ revokeApiKey,
+ changePassword,
+ changeEmail,
+ deleteAccount,
+ type ApiKeyInfo,
+ type CreateApiKeyResponse,
+} from "../lib/api";
+
+// =============================================================================
+// Password Strength Indicator
+// =============================================================================
+
+interface PasswordStrength {
+ score: number;
+ label: string;
+ color: string;
+ requirements: { met: boolean; text: string }[];
+}
+
+function getPasswordStrength(password: string): PasswordStrength {
+ const requirements = [
+ { met: password.length >= 6, text: "At least 6 characters" },
+ ];
+
+ const score = requirements.filter((r) => r.met).length;
+
+ const label = score === 1 ? "Valid" : "Too short";
+ const color = score === 1 ? "bg-green-500" : "bg-red-500";
+
+ return { score, label, color, requirements };
+}
+
+// =============================================================================
+// Confirmation Dialog Component
+// =============================================================================
+
+interface ConfirmDialogProps {
+ isOpen: boolean;
+ title: string;
+ message: string;
+ confirmText: string;
+ confirmButtonClass?: string;
+ requireInput?: string;
+ inputPlaceholder?: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+}
+
+function ConfirmDialog({
+ isOpen,
+ title,
+ message,
+ confirmText,
+ confirmButtonClass = "bg-red-900/50 border-red-700 hover:bg-red-800/50",
+ requireInput,
+ inputPlaceholder,
+ onConfirm,
+ onCancel,
+}: ConfirmDialogProps) {
+ const [inputValue, setInputValue] = useState("");
+
+ useEffect(() => {
+ if (!isOpen) {
+ setInputValue("");
+ }
+ }, [isOpen]);
+
+ if (!isOpen) return null;
+
+ const canConfirm = !requireInput || inputValue === requireInput;
+
+ return (
+ <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
+ <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.35)] p-6 max-w-md w-full mx-4">
+ <h3 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff] mb-3">{title}</h3>
+ <p className="text-[#75aafc] text-xs font-mono mb-4">{message}</p>
+ {requireInput && (
+ <div className="mb-4">
+ <label className="block text-xs font-mono text-[#8899aa] mb-2">
+ Type <span className="text-[#9bc3ff]">{requireInput}</span> to confirm:
+ </label>
+ <input
+ type="text"
+ value={inputValue}
+ onChange={(e) => setInputValue(e.target.value)}
+ placeholder={inputPlaceholder}
+ className="w-full px-3 py-2 bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-sm placeholder-[#556677] focus:outline-none focus:border-[#3f6fb3]"
+ />
+ </div>
+ )}
+ <div className="flex gap-3 justify-end">
+ <button
+ onClick={onCancel}
+ className="px-4 py-2 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={onConfirm}
+ disabled={!canConfirm}
+ className={`px-4 py-2 border font-mono text-xs uppercase tracking-wide transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${confirmButtonClass}`}
+ >
+ {confirmText}
+ </button>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+// =============================================================================
+// Section Header Component
+// =============================================================================
+
+function SectionHeader({ children }: { children: React.ReactNode }) {
+ return (
+ <h2 className="text-[11px] font-mono uppercase tracking-wide text-[#8899aa] mb-3 pb-2 border-b border-[rgba(117,170,252,0.15)]">
+ {children}
+ </h2>
+ );
+}
+
+// =============================================================================
+// Form Input Component
+// =============================================================================
+
+function FormInput({
+ label,
+ type = "text",
+ value,
+ onChange,
+ placeholder,
+ required,
+ disabled,
+}: {
+ label: string;
+ type?: string;
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ required?: boolean;
+ disabled?: boolean;
+}) {
+ return (
+ <div>
+ <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa] mb-1">
+ {label}
+ </label>
+ <input
+ type={type}
+ value={value}
+ onChange={(e) => onChange(e.target.value)}
+ placeholder={placeholder}
+ required={required}
+ disabled={disabled}
+ className="w-full px-3 py-2 bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-sm placeholder-[#556677] focus:outline-none focus:border-[#3f6fb3] disabled:opacity-50"
+ />
+ </div>
+ );
+}
+
+// =============================================================================
+// Alert Components
+// =============================================================================
+
+function ErrorAlert({ children }: { children: React.ReactNode }) {
+ return (
+ <div className="border border-red-700/50 bg-red-900/20 text-red-400 px-3 py-2 mb-4 font-mono text-xs">
+ {children}
+ </div>
+ );
+}
+
+function SuccessAlert({ children }: { children: React.ReactNode }) {
+ return (
+ <div className="border border-green-700/50 bg-green-900/20 text-green-400 px-3 py-2 mb-4 font-mono text-xs">
+ {children}
+ </div>
+ );
+}
+
+// =============================================================================
+// Button Components
+// =============================================================================
+
+function PrimaryButton({
+ children,
+ onClick,
+ disabled,
+ type = "button",
+}: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ disabled?: boolean;
+ type?: "button" | "submit";
+}) {
+ return (
+ <button
+ type={type}
+ onClick={onClick}
+ disabled={disabled}
+ className="px-4 py-2 bg-[#3f6fb3] border border-[#75aafc] text-white font-mono text-xs uppercase tracking-wide hover:bg-[#4a7fc3] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {children}
+ </button>
+ );
+}
+
+function SecondaryButton({
+ children,
+ onClick,
+ disabled,
+}: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ disabled?: boolean;
+}) {
+ return (
+ <button
+ type="button"
+ onClick={onClick}
+ disabled={disabled}
+ className="px-4 py-2 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {children}
+ </button>
+ );
+}
+
+function DangerButton({
+ children,
+ onClick,
+ disabled,
+}: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ disabled?: boolean;
+}) {
+ return (
+ <button
+ type="button"
+ onClick={onClick}
+ disabled={disabled}
+ className="px-4 py-2 bg-red-900/30 border border-red-700/50 text-red-400 font-mono text-xs uppercase tracking-wide hover:bg-red-800/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {children}
+ </button>
+ );
+}
+
+// =============================================================================
+// Main Settings Page
+// =============================================================================
+
+export default function SettingsPage() {
+ const { user, isAuthConfigured, signOut } = useAuth();
+ const navigate = useNavigate();
+
+ // API Key state
+ const [apiKeyInfo, setApiKeyInfo] = useState<ApiKeyInfo | null>(null);
+ const [newKey, setNewKey] = useState<string | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [actionLoading, setActionLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [copied, setCopied] = useState(false);
+
+ // Password change state
+ const [passwordForm, setPasswordForm] = useState({
+ currentPassword: "",
+ newPassword: "",
+ confirmPassword: "",
+ });
+ const [passwordLoading, setPasswordLoading] = useState(false);
+ const [passwordError, setPasswordError] = useState<string | null>(null);
+ const [passwordSuccess, setPasswordSuccess] = useState<string | null>(null);
+
+ // Email change state
+ const [emailForm, setEmailForm] = useState({
+ password: "",
+ newEmail: "",
+ });
+ const [emailLoading, setEmailLoading] = useState(false);
+ const [emailError, setEmailError] = useState<string | null>(null);
+ const [emailSuccess, setEmailSuccess] = useState<string | null>(null);
+
+ // Account deletion state
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [deletePassword, setDeletePassword] = useState("");
+ const [deleteLoading, setDeleteLoading] = useState(false);
+ const [deleteError, setDeleteError] = useState<string | null>(null);
+
+ useEffect(() => {
+ loadApiKey();
+ }, []);
+
+ const loadApiKey = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const key = await getApiKey();
+ setApiKeyInfo(key);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load API key");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleCreate = async () => {
+ try {
+ setActionLoading(true);
+ setError(null);
+ setNewKey(null);
+ const response: CreateApiKeyResponse = await createApiKey("Web UI");
+ setNewKey(response.key);
+ setApiKeyInfo({
+ id: response.id,
+ prefix: response.prefix,
+ name: response.name,
+ lastUsedAt: null,
+ createdAt: response.createdAt,
+ });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to create API key");
+ } finally {
+ setActionLoading(false);
+ }
+ };
+
+ const handleRefresh = async () => {
+ try {
+ setActionLoading(true);
+ setError(null);
+ setNewKey(null);
+ const response = await refreshApiKey("Web UI (Refreshed)");
+ setNewKey(response.key);
+ setApiKeyInfo({
+ id: response.id,
+ prefix: response.prefix,
+ name: response.name,
+ lastUsedAt: null,
+ createdAt: response.createdAt,
+ });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to refresh API key");
+ } finally {
+ setActionLoading(false);
+ }
+ };
+
+ const handleRevoke = async () => {
+ if (!confirm("Are you sure you want to revoke this API key? Any applications using it will stop working.")) {
+ return;
+ }
+ try {
+ setActionLoading(true);
+ setError(null);
+ setNewKey(null);
+ await revokeApiKey();
+ setApiKeyInfo(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to revoke API key");
+ } finally {
+ setActionLoading(false);
+ }
+ };
+
+ const copyToClipboard = async () => {
+ if (!newKey) return;
+ try {
+ await navigator.clipboard.writeText(newKey);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ console.error("Failed to copy:", err);
+ }
+ };
+
+ // Password change handlers
+ const handlePasswordChange = async (e: FormEvent) => {
+ e.preventDefault();
+ setPasswordError(null);
+ setPasswordSuccess(null);
+
+ if (passwordForm.newPassword !== passwordForm.confirmPassword) {
+ setPasswordError("New passwords do not match");
+ return;
+ }
+
+ const strength = getPasswordStrength(passwordForm.newPassword);
+ if (strength.score < 1) {
+ setPasswordError("Password must be at least 6 characters");
+ return;
+ }
+
+ try {
+ setPasswordLoading(true);
+ await changePassword(passwordForm.currentPassword, passwordForm.newPassword);
+ setPasswordSuccess("Password changed successfully. Please sign in with your new password.");
+ setPasswordForm({ currentPassword: "", newPassword: "", confirmPassword: "" });
+ setTimeout(async () => {
+ await signOut();
+ navigate("/login");
+ }, 1500);
+ } catch (err) {
+ setPasswordError(err instanceof Error ? err.message : "Failed to change password");
+ } finally {
+ setPasswordLoading(false);
+ }
+ };
+
+ // Email change handlers
+ const handleEmailChange = async (e: FormEvent) => {
+ e.preventDefault();
+ setEmailError(null);
+ setEmailSuccess(null);
+
+ if (!emailForm.newEmail.includes("@")) {
+ setEmailError("Please enter a valid email address");
+ return;
+ }
+
+ try {
+ setEmailLoading(true);
+ await changeEmail(emailForm.password, emailForm.newEmail);
+ setEmailSuccess("Email changed successfully");
+ setEmailForm({ password: "", newEmail: "" });
+ } catch (err) {
+ setEmailError(err instanceof Error ? err.message : "Failed to change email");
+ } finally {
+ setEmailLoading(false);
+ }
+ };
+
+ // Account deletion handlers
+ const DELETE_CONFIRMATION = "DELETE MY ACCOUNT";
+
+ const handleDeleteAccount = async () => {
+ try {
+ setDeleteLoading(true);
+ setDeleteError(null);
+ await deleteAccount(deletePassword, DELETE_CONFIRMATION);
+ await signOut();
+ navigate("/login");
+ } catch (err) {
+ setDeleteError(err instanceof Error ? err.message : "Failed to delete account");
+ setDeleteLoading(false);
+ }
+ };
+
+ const passwordStrength = getPasswordStrength(passwordForm.newPassword);
+
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+
+ <main className="flex-1 max-w-4xl mx-auto p-6 w-full">
+ {/* Page Header */}
+ <div className="mb-8">
+ <h1 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff]">Settings</h1>
+ <div className="h-px bg-[rgba(117,170,252,0.35)] mt-2" />
+ </div>
+
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+ {/* Left Column */}
+ <div className="space-y-6">
+ {/* Account Info */}
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <SectionHeader>Account</SectionHeader>
+ {isAuthConfigured && user ? (
+ <div className="space-y-2 font-mono text-xs">
+ <div className="flex justify-between">
+ <span className="text-[#7788aa]">Email</span>
+ <span className="text-[#9bc3ff]">{user.email}</span>
+ </div>
+ <div className="flex justify-between">
+ <span className="text-[#7788aa]">User ID</span>
+ <span className="text-[#75aafc] text-[10px]">{user.id}</span>
+ </div>
+ </div>
+ ) : (
+ <p className="text-[#7788aa] font-mono text-xs">
+ {isAuthConfigured
+ ? "Not signed in"
+ : "Authentication not configured (API key mode)"}
+ </p>
+ )}
+ </section>
+
+ {/* API Key Section */}
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <SectionHeader>API Key</SectionHeader>
+ <p className="text-[#7788aa] font-mono text-[10px] mb-4">
+ Authenticate daemon and CLI tools. One active key at a time.
+ </p>
+
+ {error && <ErrorAlert>{error}</ErrorAlert>}
+
+ {newKey && (
+ <div className="border border-green-700/50 bg-green-900/20 p-3 mb-4">
+ <p className="text-green-400 font-mono text-[10px] mb-2">
+ Key created. Copy now - won't be shown again.
+ </p>
+ <div className="flex items-center gap-2">
+ <code className="flex-1 bg-black/50 px-2 py-1 text-[10px] font-mono text-green-400 break-all">
+ {newKey}
+ </code>
+ <button
+ onClick={copyToClipboard}
+ className="px-2 py-1 bg-green-900/50 border border-green-700/50 text-green-400 font-mono text-[10px] uppercase hover:bg-green-800/50 transition-colors"
+ >
+ {copied ? "Copied" : "Copy"}
+ </button>
+ </div>
+ </div>
+ )}
+
+ {loading ? (
+ <p className="text-[#7788aa] font-mono text-xs">Loading...</p>
+ ) : apiKeyInfo ? (
+ <div className="space-y-3">
+ <div className="font-mono text-xs">
+ <div className="flex justify-between mb-1">
+ <span className="text-[#7788aa]">Prefix</span>
+ <code className="text-[#75aafc]">{apiKeyInfo.prefix}...</code>
+ </div>
+ <div className="flex justify-between mb-1">
+ <span className="text-[#7788aa]">Created</span>
+ <span className="text-[#9bc3ff]">
+ {new Date(apiKeyInfo.createdAt).toLocaleDateString()}
+ </span>
+ </div>
+ {apiKeyInfo.lastUsedAt && (
+ <div className="flex justify-between">
+ <span className="text-[#7788aa]">Last used</span>
+ <span className="text-[#9bc3ff]">
+ {new Date(apiKeyInfo.lastUsedAt).toLocaleDateString()}
+ </span>
+ </div>
+ )}
+ </div>
+ <div className="flex gap-2 pt-2">
+ <SecondaryButton onClick={handleRefresh} disabled={actionLoading}>
+ {actionLoading ? "..." : "Rotate"}
+ </SecondaryButton>
+ <DangerButton onClick={handleRevoke} disabled={actionLoading}>
+ {actionLoading ? "..." : "Revoke"}
+ </DangerButton>
+ </div>
+ </div>
+ ) : (
+ <div>
+ <p className="text-[#7788aa] font-mono text-xs mb-3">No API key configured.</p>
+ <PrimaryButton onClick={handleCreate} disabled={actionLoading}>
+ {actionLoading ? "Creating..." : "Create API Key"}
+ </PrimaryButton>
+ </div>
+ )}
+ </section>
+
+ {/* Daemon Setup */}
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <SectionHeader>Daemon Setup</SectionHeader>
+ <p className="text-[#7788aa] font-mono text-[10px] mb-3">
+ Set your API key as an environment variable:
+ </p>
+ <code className="block bg-black/50 px-3 py-2 text-[11px] font-mono text-green-400 mb-3">
+ export MAKIMA_API_KEY="your-key"
+ </code>
+ <p className="text-[#7788aa] font-mono text-[10px]">
+ Then run: <code className="text-green-400">makima-daemon</code>
+ </p>
+ </section>
+ </div>
+
+ {/* Right Column */}
+ <div className="space-y-6">
+ {/* Password Change */}
+ {isAuthConfigured && user && (
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <SectionHeader>Change Password</SectionHeader>
+ {passwordError && <ErrorAlert>{passwordError}</ErrorAlert>}
+ {passwordSuccess && <SuccessAlert>{passwordSuccess}</SuccessAlert>}
+ <form onSubmit={handlePasswordChange} className="space-y-3">
+ <FormInput
+ label="Current Password"
+ type="password"
+ value={passwordForm.currentPassword}
+ onChange={(v) => setPasswordForm({ ...passwordForm, currentPassword: v })}
+ required
+ />
+ <FormInput
+ label="New Password"
+ type="password"
+ value={passwordForm.newPassword}
+ onChange={(v) => setPasswordForm({ ...passwordForm, newPassword: v })}
+ required
+ />
+ {passwordForm.newPassword && (
+ <div className="space-y-1">
+ <div className="flex items-center gap-2">
+ <div className="flex-1 h-1 bg-[#1a2a3a]">
+ <div
+ className={`h-full transition-all ${passwordStrength.color}`}
+ style={{ width: `${passwordStrength.score * 100}%` }}
+ />
+ </div>
+ <span className="text-[10px] font-mono text-[#9bc3ff]">
+ {passwordStrength.label}
+ </span>
+ </div>
+ </div>
+ )}
+ <FormInput
+ label="Confirm Password"
+ type="password"
+ value={passwordForm.confirmPassword}
+ onChange={(v) => setPasswordForm({ ...passwordForm, confirmPassword: v })}
+ required
+ />
+ {passwordForm.confirmPassword &&
+ passwordForm.newPassword !== passwordForm.confirmPassword && (
+ <p className="text-red-400 font-mono text-[10px]">Passwords do not match</p>
+ )}
+ <div className="pt-2">
+ <PrimaryButton
+ type="submit"
+ disabled={passwordLoading || passwordStrength.score < 1}
+ >
+ {passwordLoading ? "Changing..." : "Change Password"}
+ </PrimaryButton>
+ </div>
+ </form>
+ </section>
+ )}
+
+ {/* Email Change */}
+ {isAuthConfigured && user && (
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <SectionHeader>Change Email</SectionHeader>
+ {emailError && <ErrorAlert>{emailError}</ErrorAlert>}
+ {emailSuccess && <SuccessAlert>{emailSuccess}</SuccessAlert>}
+ <form onSubmit={handleEmailChange} className="space-y-3">
+ <FormInput
+ label="New Email"
+ type="email"
+ value={emailForm.newEmail}
+ onChange={(v) => setEmailForm({ ...emailForm, newEmail: v })}
+ placeholder="new@example.com"
+ required
+ />
+ <FormInput
+ label="Password (to confirm)"
+ type="password"
+ value={emailForm.password}
+ onChange={(v) => setEmailForm({ ...emailForm, password: v })}
+ required
+ />
+ <div className="pt-2">
+ <PrimaryButton type="submit" disabled={emailLoading}>
+ {emailLoading ? "Changing..." : "Change Email"}
+ </PrimaryButton>
+ </div>
+ </form>
+ </section>
+ )}
+
+ {/* Danger Zone */}
+ {isAuthConfigured && user && (
+ <section className="border border-red-900/50 bg-[#0d1b2d] p-4">
+ <h2 className="text-[11px] font-mono uppercase tracking-wide text-red-400 mb-3 pb-2 border-b border-red-900/30">
+ Danger Zone
+ </h2>
+ <p className="text-[#7788aa] font-mono text-[10px] mb-3">
+ Permanently delete your account and all data. This cannot be undone.
+ </p>
+ {deleteError && <ErrorAlert>{deleteError}</ErrorAlert>}
+ <div className="space-y-3">
+ <FormInput
+ label="Password"
+ type="password"
+ value={deletePassword}
+ onChange={setDeletePassword}
+ placeholder="Enter password to continue"
+ />
+ <DangerButton
+ onClick={() => setDeleteDialogOpen(true)}
+ disabled={!deletePassword || deleteLoading}
+ >
+ {deleteLoading ? "Deleting..." : "Delete Account"}
+ </DangerButton>
+ </div>
+ </section>
+ )}
+ </div>
+ </div>
+ </main>
+
+ {/* Delete Confirmation Dialog */}
+ <ConfirmDialog
+ isOpen={deleteDialogOpen}
+ title="Delete Account"
+ message="This will permanently delete your account and all your data. This action cannot be undone."
+ confirmText="Delete"
+ confirmButtonClass="bg-red-900/50 border border-red-700 text-red-400 hover:bg-red-800/50"
+ requireInput={DELETE_CONFIRMATION}
+ inputPlaceholder={`Type "${DELETE_CONFIRMATION}" to confirm`}
+ onConfirm={() => {
+ setDeleteDialogOpen(false);
+ handleDeleteAccount();
+ }}
+ onCancel={() => setDeleteDialogOpen(false)}
+ />
+ </div>
+ );
+}