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/contracts.tsx6
-rw-r--r--makima/frontend/src/routes/daemons.tsx363
-rw-r--r--makima/frontend/src/routes/files.tsx2
-rw-r--r--makima/frontend/src/routes/login.tsx4
-rw-r--r--makima/frontend/src/routes/mesh.tsx22
5 files changed, 368 insertions, 29 deletions
diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx
index b85d667..ce9ceca 100644
--- a/makima/frontend/src/routes/contracts.tsx
+++ b/makima/frontend/src/routes/contracts.tsx
@@ -448,7 +448,7 @@ function ContractsPageContent() {
const handleTaskSelect = useCallback(
(taskId: string) => {
- navigate(`/mesh/${taskId}`);
+ navigate(`/exec/${taskId}`);
},
[navigate]
);
@@ -469,7 +469,7 @@ function ContractsPageContent() {
const refreshed = await fetchContract(contractDetail.id);
setContractDetail(refreshed);
// Navigate to the new task
- navigate(`/mesh/${task.id}`);
+ navigate(`/exec/${task.id}`);
} catch (e) {
console.error("Failed to create task:", e);
alert(e instanceof Error ? e.message : "Failed to create task");
@@ -515,7 +515,7 @@ function ContractsPageContent() {
const handleContextGoToSupervisor = useCallback(
(contract: ContractSummary) => {
if (contract.supervisorTaskId) {
- navigate(`/mesh/${contract.supervisorTaskId}`);
+ navigate(`/exec/${contract.supervisorTaskId}`);
}
},
[navigate]
diff --git a/makima/frontend/src/routes/daemons.tsx b/makima/frontend/src/routes/daemons.tsx
index 0f55190..ca167fe 100644
--- a/makima/frontend/src/routes/daemons.tsx
+++ b/makima/frontend/src/routes/daemons.tsx
@@ -1,10 +1,13 @@
-import { useState, useEffect } from "react";
+import { useState, useEffect, useCallback, useRef } from "react";
import { useAuth } from "../contexts/AuthContext";
import { useNavigate } from "react-router";
import { Masthead } from "../components/Masthead";
import {
listDaemons,
restartDaemon,
+ triggerDaemonReauth,
+ submitDaemonAuthCode,
+ getDaemonReauthStatus,
type Daemon,
type DaemonListResponse,
} from "../lib/api";
@@ -34,6 +37,333 @@ function ErrorAlert({ children }: { children: React.ReactNode }) {
}
// =============================================================================
+// Reauth Modal Component
+// =============================================================================
+
+type ReauthState =
+ | { phase: "initiating" }
+ | { phase: "url_ready"; loginUrl: string; requestId: string }
+ | { phase: "submitting"; requestId: string }
+ | { phase: "success" }
+ | { phase: "error"; message: string };
+
+function ReauthModal({
+ daemon,
+ onClose,
+}: {
+ daemon: Daemon;
+ onClose: () => void;
+}) {
+ const [state, setState] = useState<ReauthState>({ phase: "initiating" });
+ const [authCode, setAuthCode] = useState("");
+ const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
+
+ // Cleanup polling on unmount
+ useEffect(() => {
+ return () => {
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ }
+ };
+ }, []);
+
+ // Trigger reauth on mount
+ useEffect(() => {
+ let cancelled = false;
+ const trigger = async () => {
+ try {
+ const res = await triggerDaemonReauth(daemon.id);
+ if (cancelled) return;
+
+ // Start polling for status
+ const requestId = res.requestId;
+ pollingRef.current = setInterval(async () => {
+ try {
+ const status = await getDaemonReauthStatus(daemon.id, requestId);
+ if (cancelled) return;
+
+ if (status.status === "url_ready" && status.loginUrl) {
+ setState({
+ phase: "url_ready",
+ loginUrl: status.loginUrl,
+ requestId,
+ });
+ // Stop polling once we have the URL
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ pollingRef.current = null;
+ }
+ } else if (status.status === "failed") {
+ setState({
+ phase: "error",
+ message: status.error || "Reauth failed",
+ });
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ pollingRef.current = null;
+ }
+ } else if (status.status === "completed") {
+ setState({ phase: "success" });
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ pollingRef.current = null;
+ }
+ }
+ } catch {
+ // Polling errors are non-fatal, keep trying
+ }
+ }, 2000);
+ } catch (err) {
+ if (cancelled) return;
+ setState({
+ phase: "error",
+ message:
+ err instanceof Error ? err.message : "Failed to trigger reauth",
+ });
+ }
+ };
+ trigger();
+ return () => {
+ cancelled = true;
+ };
+ }, [daemon.id]);
+
+ const handleSubmitCode = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!authCode.trim() || state.phase !== "url_ready") return;
+
+ const requestId = state.requestId;
+ setState({ phase: "submitting", requestId });
+
+ try {
+ await submitDaemonAuthCode(daemon.id, authCode.trim(), requestId);
+
+ // Poll for completion
+ pollingRef.current = setInterval(async () => {
+ try {
+ const status = await getDaemonReauthStatus(
+ daemon.id,
+ requestId,
+ );
+ if (status.status === "completed") {
+ setState({ phase: "success" });
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ pollingRef.current = null;
+ }
+ } else if (status.status === "failed") {
+ setState({
+ phase: "error",
+ message: status.error || "Auth code submission failed",
+ });
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ pollingRef.current = null;
+ }
+ }
+ } catch {
+ // Keep polling
+ }
+ }, 2000);
+
+ // Also set a timeout so we don't poll forever
+ setTimeout(() => {
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ pollingRef.current = null;
+ }
+ // If still submitting after 30s, assume success (setup-token completed)
+ setState((prev) =>
+ prev.phase === "submitting" ? { phase: "success" } : prev,
+ );
+ }, 30000);
+ } catch (err) {
+ setState({
+ phase: "error",
+ message:
+ err instanceof Error
+ ? err.message
+ : "Failed to submit auth code",
+ });
+ }
+ },
+ [authCode, daemon.id, state],
+ );
+
+ const handleRetry = useCallback(() => {
+ setAuthCode("");
+ setState({ phase: "initiating" });
+ // Re-trigger will happen via the useEffect dependency change
+ // We need to manually trigger since daemon.id hasn't changed
+ const trigger = async () => {
+ try {
+ const res = await triggerDaemonReauth(daemon.id);
+ const requestId = res.requestId;
+ pollingRef.current = setInterval(async () => {
+ try {
+ const status = await getDaemonReauthStatus(
+ daemon.id,
+ requestId,
+ );
+ if (status.status === "url_ready" && status.loginUrl) {
+ setState({
+ phase: "url_ready",
+ loginUrl: status.loginUrl,
+ requestId,
+ });
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ pollingRef.current = null;
+ }
+ } else if (status.status === "failed") {
+ setState({
+ phase: "error",
+ message: status.error || "Reauth failed",
+ });
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ pollingRef.current = null;
+ }
+ }
+ } catch {
+ // Keep polling
+ }
+ }, 2000);
+ } catch (err) {
+ setState({
+ phase: "error",
+ message:
+ err instanceof Error
+ ? err.message
+ : "Failed to trigger reauth",
+ });
+ }
+ };
+ trigger();
+ }, [daemon.id]);
+
+ return (
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
+ <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.35)] p-6 max-w-md w-full mx-4 shadow-2xl">
+ {/* Header */}
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-xs font-mono uppercase tracking-wide text-[#9bc3ff]">
+ Reauthorize Daemon
+ </h3>
+ <button
+ onClick={onClose}
+ className="text-[#7788aa] hover:text-[#9bc3ff] text-sm font-mono"
+ >
+ &#x2715;
+ </button>
+ </div>
+ <p className="text-[10px] font-mono text-[#7788aa] mb-4">
+ {daemon.hostname || "Unknown Host"}
+ </p>
+
+ {/* Initiating */}
+ {state.phase === "initiating" && (
+ <div className="flex items-center gap-2 py-4">
+ <div className="w-3 h-3 border border-[#75aafc] border-t-transparent rounded-full animate-spin" />
+ <span className="text-[10px] font-mono text-[#7788aa]">
+ Initiating reauthorization...
+ </span>
+ </div>
+ )}
+
+ {/* URL Ready */}
+ {state.phase === "url_ready" && (
+ <div className="space-y-3">
+ <p className="text-[10px] font-mono text-[#7788aa]">
+ Click the button below to open the OAuth login page, then paste the code:
+ </p>
+ <a
+ href={state.loginUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="block text-center bg-amber-500 hover:bg-amber-400 text-black font-mono text-xs font-medium px-4 py-2 transition-colors"
+ >
+ 1. Login to Claude
+ </a>
+ <form onSubmit={handleSubmitCode} className="flex gap-2">
+ <input
+ type="text"
+ value={authCode}
+ onChange={(e) => setAuthCode(e.target.value)}
+ placeholder="2. Paste authentication code"
+ className="flex-1 bg-[#0a1525] border border-amber-500/30 px-3 py-2 text-xs font-mono text-amber-100 placeholder-amber-500/50 focus:outline-none focus:border-amber-400"
+ />
+ <button
+ type="submit"
+ disabled={!authCode.trim()}
+ className="bg-amber-500 hover:bg-amber-400 disabled:bg-amber-700 disabled:cursor-not-allowed text-black font-mono text-xs font-medium px-4 py-2 transition-colors"
+ >
+ Submit
+ </button>
+ </form>
+ </div>
+ )}
+
+ {/* Submitting */}
+ {state.phase === "submitting" && (
+ <div className="flex items-center gap-2 py-4">
+ <div className="w-3 h-3 border border-amber-400 border-t-transparent rounded-full animate-spin" />
+ <span className="text-[10px] font-mono text-[#7788aa]">
+ Submitting auth code...
+ </span>
+ </div>
+ )}
+
+ {/* Success */}
+ {state.phase === "success" && (
+ <div className="py-4">
+ <div className="flex items-center gap-2 text-green-400 mb-2">
+ <span className="text-sm">&#x2713;</span>
+ <span className="text-xs font-mono font-medium">
+ Authentication successful
+ </span>
+ </div>
+ <p className="text-[10px] font-mono text-[#7788aa] mb-3">
+ The daemon's OAuth token has been refreshed. Tasks can now run normally.
+ </p>
+ <button
+ onClick={onClose}
+ className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff]"
+ >
+ Close
+ </button>
+ </div>
+ )}
+
+ {/* Error */}
+ {state.phase === "error" && (
+ <div className="py-4">
+ <div className="border border-red-700/50 bg-red-900/20 text-red-400 px-3 py-2 mb-3 font-mono text-xs">
+ {state.message}
+ </div>
+ <div className="flex gap-2">
+ <button
+ onClick={handleRetry}
+ className="text-[10px] font-mono text-amber-400 hover:text-amber-300 px-2 py-1 border border-amber-700/50 bg-amber-900/20"
+ >
+ Retry
+ </button>
+ <button
+ onClick={onClose}
+ className="text-[10px] font-mono text-[#7788aa] hover:text-[#9bc3ff] px-2 py-1"
+ >
+ Close
+ </button>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
+
+// =============================================================================
// Daemons Page
// =============================================================================
@@ -47,6 +377,7 @@ export default function DaemonsPage() {
const [daemonsError, setDaemonsError] = useState<string | null>(null);
const [restartingDaemonId, setRestartingDaemonId] = useState<string | null>(null);
const [restartConfirmDaemonId, setRestartConfirmDaemonId] = useState<string | null>(null);
+ const [reauthDaemon, setReauthDaemon] = useState<Daemon | null>(null);
// Redirect if not authenticated
useEffect(() => {
@@ -292,7 +623,7 @@ export default function DaemonsPage() {
</div>
)}
</div>
- {/* Restart Section */}
+ {/* Actions Section */}
{daemon.status === "connected" && (
<div className="mt-3 pt-2 border-t border-[rgba(117,170,252,0.1)]">
{restartConfirmDaemonId === daemon.id ? (
@@ -318,12 +649,20 @@ export default function DaemonsPage() {
</div>
</div>
) : (
- <button
- onClick={() => setRestartConfirmDaemonId(daemon.id)}
- className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff]"
- >
- &#x27F3; Restart Daemon
- </button>
+ <div className="flex items-center gap-3">
+ <button
+ onClick={() => setReauthDaemon(daemon)}
+ className="text-[10px] font-mono text-amber-400 hover:text-amber-300"
+ >
+ &#x1F511; Reauthorize
+ </button>
+ <button
+ onClick={() => setRestartConfirmDaemonId(daemon.id)}
+ className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff]"
+ >
+ &#x27F3; Restart Daemon
+ </button>
+ </div>
)}
</div>
)}
@@ -335,6 +674,14 @@ export default function DaemonsPage() {
</div>
</div>
</main>
+
+ {/* Reauth Modal */}
+ {reauthDaemon && (
+ <ReauthModal
+ daemon={reauthDaemon}
+ onClose={() => setReauthDaemon(null)}
+ />
+ )}
</div>
);
}
diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx
index 6cfb3ca..b232aa0 100644
--- a/makima/frontend/src/routes/files.tsx
+++ b/makima/frontend/src/routes/files.tsx
@@ -839,7 +839,7 @@ function FilesPageContent() {
<div className="flex gap-2">
<button
onClick={() => {
- navigate(`/mesh/${createdTask.id}`);
+ navigate(`/exec/${createdTask.id}`);
setCreatedTask(null);
}}
className="px-3 py-1 font-mono text-xs text-[#0a1628] bg-[#75aafc] hover:bg-[#9bc3ff] transition-colors"
diff --git a/makima/frontend/src/routes/login.tsx b/makima/frontend/src/routes/login.tsx
index 63b3af3..0725a2d 100644
--- a/makima/frontend/src/routes/login.tsx
+++ b/makima/frontend/src/routes/login.tsx
@@ -18,7 +18,7 @@ export default function LoginPage() {
// Redirect if already authenticated
if (isAuthenticated && isAuthConfigured) {
- navigate("/mesh");
+ navigate("/exec");
return null;
}
@@ -34,7 +34,7 @@ export default function LoginPage() {
if (error) {
setError(error.message);
} else {
- navigate("/mesh");
+ navigate("/exec");
}
} else if (mode === "signup") {
const { error } = await signUp(email, password);
diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx
index 1d1db84..67129f9 100644
--- a/makima/frontend/src/routes/mesh.tsx
+++ b/makima/frontend/src/routes/mesh.tsx
@@ -191,14 +191,6 @@ export default function MeshPage() {
// Only process output for the task we're currently viewing
if (event.taskId === activeOutputTaskId) {
setTaskOutputEntries((prev) => {
- // For auth_required, only allow one per task (replace existing)
- if (event.messageType === "auth_required") {
- const hasExisting = prev.some(e => e.messageType === "auth_required");
- if (hasExisting) {
- return prev; // Skip duplicate auth_required
- }
- }
-
// Deduplicate by checking if last entry is identical
// This prevents duplicates from React StrictMode or WebSocket reconnects
const lastEntry = prev[prev.length - 1];
@@ -387,7 +379,7 @@ export default function MeshPage() {
const handleSelectTask = useCallback(
(taskId: string) => {
- navigate(`/mesh/${taskId}`);
+ navigate(`/exec/${taskId}`);
},
[navigate]
);
@@ -395,9 +387,9 @@ export default function MeshPage() {
const handleBack = useCallback(() => {
// If viewing a subtask, go back to parent
if (taskDetail?.parentTaskId) {
- navigate(`/mesh/${taskDetail.parentTaskId}`);
+ navigate(`/exec/${taskDetail.parentTaskId}`);
} else {
- navigate("/mesh");
+ navigate("/exec");
}
}, [navigate, taskDetail]);
@@ -408,9 +400,9 @@ export default function MeshPage() {
if (success && id === taskId) {
// If deleting current task, go back
if (taskDetail?.parentTaskId) {
- navigate(`/mesh/${taskDetail.parentTaskId}`);
+ navigate(`/exec/${taskDetail.parentTaskId}`);
} else {
- navigate("/mesh");
+ navigate("/exec");
}
}
}
@@ -523,7 +515,7 @@ export default function MeshPage() {
});
console.log(`[Mesh] Task branched, new task ID: ${result.task.id}`);
// Navigate to the new branched task
- navigate(`/mesh/${result.task.id}`);
+ navigate(`/exec/${result.task.id}`);
} catch (e) {
console.error("Failed to branch task:", e);
throw e; // Re-throw so the modal can display the error
@@ -617,7 +609,7 @@ export default function MeshPage() {
targetRepoPath: targetPath || undefined,
});
if (newTask) {
- navigate(`/mesh/${newTask.id}`);
+ navigate(`/exec/${newTask.id}`);
}
} finally {
setCreating(false);