diff options
| author | soryu <soryu@soryu.co> | 2026-03-02 15:18:31 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-02 15:18:31 +0000 |
| commit | 78cb861412850889424ae7d5ae5cd952a2b90295 (patch) | |
| tree | 7a6eb0693457886dbe0eea84c0c1489724791f79 /makima/frontend | |
| parent | 2bc1cd4717b587cd2b8ffccd723b62f888e61aa8 (diff) | |
| download | soryu-78cb861412850889424ae7d5ae5cd952a2b90295.tar.gz soryu-78cb861412850889424ae7d5ae5cd952a2b90295.zip | |
feat: move daemon reauth to daemons page, add contract-backed directive steps, rename Mesh to Exec (#84)
* feat: soryu-co/soryu - makima: Rename Mesh to Exec in navigation
* WIP: heartbeat checkpoint
* WIP: heartbeat checkpoint
* WIP: heartbeat checkpoint
* feat: soryu-co/soryu - makima: Add contract-backed steps to directive flow
* WIP: heartbeat checkpoint
Diffstat (limited to 'makima/frontend')
| -rw-r--r-- | makima/frontend/src/components/NavStrip.tsx | 2 | ||||
| -rw-r--r-- | makima/frontend/src/components/PhaseConfirmationNotification.tsx | 2 | ||||
| -rw-r--r-- | makima/frontend/src/components/SupervisorQuestionNotification.tsx | 2 | ||||
| -rw-r--r-- | makima/frontend/src/components/contracts/CommandModePanel.tsx | 2 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveDAG.tsx | 2 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/OrchestratorStepNode.tsx | 2 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/StepNode.tsx | 25 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/TaskOutput.tsx | 110 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 74 | ||||
| -rw-r--r-- | makima/frontend/src/main.tsx | 4 | ||||
| -rw-r--r-- | makima/frontend/src/routes/contracts.tsx | 6 | ||||
| -rw-r--r-- | makima/frontend/src/routes/daemons.tsx | 363 | ||||
| -rw-r--r-- | makima/frontend/src/routes/files.tsx | 2 | ||||
| -rw-r--r-- | makima/frontend/src/routes/login.tsx | 4 | ||||
| -rw-r--r-- | makima/frontend/src/routes/mesh.tsx | 22 |
15 files changed, 489 insertions, 133 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index 9556458..17013ac 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -14,7 +14,7 @@ const NAV_LINKS: NavLink[] = [ { label: "Directives", href: "/directives", requiresAuth: true }, { label: "Orders", href: "/orders", requiresAuth: true }, { label: "Contracts", href: "/contracts", requiresAuth: true }, - { label: "Mesh", href: "/mesh", requiresAuth: true }, + { label: "Exec", href: "/exec", requiresAuth: true }, { label: "Daemons", href: "/daemons", requiresAuth: true }, { label: "History", href: "/history", requiresAuth: true }, ]; diff --git a/makima/frontend/src/components/PhaseConfirmationNotification.tsx b/makima/frontend/src/components/PhaseConfirmationNotification.tsx index 516211f..2681fdc 100644 --- a/makima/frontend/src/components/PhaseConfirmationNotification.tsx +++ b/makima/frontend/src/components/PhaseConfirmationNotification.tsx @@ -86,7 +86,7 @@ export function PhaseConfirmationToast() { const handleGoToTask = (question: PendingQuestion) => { dismissNotification(question.questionId); - navigate(`/mesh/${question.taskId}`); + navigate(`/exec/${question.taskId}`); }; return ( diff --git a/makima/frontend/src/components/SupervisorQuestionNotification.tsx b/makima/frontend/src/components/SupervisorQuestionNotification.tsx index b1cbacc..e62638c 100644 --- a/makima/frontend/src/components/SupervisorQuestionNotification.tsx +++ b/makima/frontend/src/components/SupervisorQuestionNotification.tsx @@ -16,7 +16,7 @@ export function SupervisorQuestionNotification() { const handleGoToTask = (questionId: string, taskId: string) => { dismissNotification(questionId); - navigate(`/mesh/${taskId}`); + navigate(`/exec/${taskId}`); }; return ( diff --git a/makima/frontend/src/components/contracts/CommandModePanel.tsx b/makima/frontend/src/components/contracts/CommandModePanel.tsx index 832d5ec..b39b309 100644 --- a/makima/frontend/src/components/contracts/CommandModePanel.tsx +++ b/makima/frontend/src/components/contracts/CommandModePanel.tsx @@ -65,7 +65,7 @@ export function CommandModePanel({ contract, onUpdate }: CommandModePanelProps) const handleGoToSupervisor = useCallback(() => { if (supervisorStatus.supervisorTaskId) { - navigate(`/mesh/${supervisorStatus.supervisorTaskId}`); + navigate(`/exec/${supervisorStatus.supervisorTaskId}`); } }, [supervisorStatus.supervisorTaskId, navigate]); const config = statusConfig[supervisorStatus.status]; diff --git a/makima/frontend/src/components/directives/DirectiveDAG.tsx b/makima/frontend/src/components/directives/DirectiveDAG.tsx index 142df41..f225356 100644 --- a/makima/frontend/src/components/directives/DirectiveDAG.tsx +++ b/makima/frontend/src/components/directives/DirectiveDAG.tsx @@ -146,7 +146,7 @@ function SpecializedStepNode({ step }: { step: SpecializedStep }) { {step.name} </span> <a - href={`/mesh/${step.taskId}`} + href={`/exec/${step.taskId}`} className="text-[9px] font-mono text-[#556677] hover:text-white underline" > View task diff --git a/makima/frontend/src/components/directives/OrchestratorStepNode.tsx b/makima/frontend/src/components/directives/OrchestratorStepNode.tsx index 9c8e95e..3fccb4b 100644 --- a/makima/frontend/src/components/directives/OrchestratorStepNode.tsx +++ b/makima/frontend/src/components/directives/OrchestratorStepNode.tsx @@ -151,7 +151,7 @@ export function OrchestratorStepNode({ {/* Task link */} <a - href={`/mesh/${taskId}`} + href={`/exec/${taskId}`} className={`text-[9px] font-mono text-[#556677] hover:${colors.text} underline block`} > {status === "running" ? "View running task" : "View task"} diff --git a/makima/frontend/src/components/directives/StepNode.tsx b/makima/frontend/src/components/directives/StepNode.tsx index 2844b4a..775b898 100644 --- a/makima/frontend/src/components/directives/StepNode.tsx +++ b/makima/frontend/src/components/directives/StepNode.tsx @@ -28,10 +28,11 @@ interface StepNodeProps { export function StepNode({ step, onComplete, onFail, onSkip }: StepNodeProps) { const colors = STATUS_COLORS[step.status] || STATUS_COLORS.pending; const label = STATUS_LABELS[step.status] || step.status.toUpperCase(); + const isContractBacked = !!step.contractType; return ( <div - className={`${colors.bg} ${colors.border} border rounded px-3 py-2 min-w-[160px] max-w-[220px]`} + className={`${colors.bg} ${isContractBacked ? "border-2 border-dashed" : "border"} ${colors.border} rounded px-3 py-2 min-w-[160px] max-w-[220px]`} > <div className="flex items-center justify-between gap-2 mb-1"> <span className="text-[11px] font-mono text-white truncate font-medium"> @@ -41,14 +42,32 @@ export function StepNode({ step, onComplete, onFail, onSkip }: StepNodeProps) { {label} </span> </div> + {isContractBacked && ( + <div className="flex items-center gap-1 mb-1"> + <span className="text-[8px] font-mono text-violet-400 bg-violet-400/10 border border-violet-400/20 rounded px-1 py-0.5 uppercase tracking-wide"> + CONTRACT + </span> + <span className="text-[8px] font-mono text-violet-300/60"> + {step.contractType} + </span> + </div> + )} {step.description && ( <p className="text-[10px] text-[#7788aa] font-mono truncate mb-1"> {step.description} </p> )} - {step.taskId && ( + {step.contractId && ( + <a + href={`/contracts/${step.contractId}`} + className="text-[9px] font-mono text-violet-400/60 hover:text-violet-300 underline block mb-1" + > + {step.status === "running" ? "Contract running..." : "View contract"} + </a> + )} + {step.taskId && !step.contractId && ( <a - href={`/mesh/${step.taskId}`} + href={`/exec/${step.taskId}`} className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc] underline block mb-1" > {step.status === "running" ? "Auto-executing..." : "View task"} diff --git a/makima/frontend/src/components/mesh/TaskOutput.tsx b/makima/frontend/src/components/mesh/TaskOutput.tsx index 2db4250..1a376ad 100644 --- a/makima/frontend/src/components/mesh/TaskOutput.tsx +++ b/makima/frontend/src/components/mesh/TaskOutput.tsx @@ -309,7 +309,21 @@ function OutputEntryRenderer({ entry, pendingQuestionIds, onAnswerQuestion }: Ou ); case "auth_required": - return <AuthRequiredEntry entry={entry} />; + return ( + <div className="bg-amber-900/30 border border-amber-500/50 rounded p-3 my-2"> + <div className="flex items-center gap-2 text-amber-400 font-semibold mb-1"> + <span>⚠</span> + <span>Authentication Expired{entry.toolInput?.hostname ? ` (${entry.toolInput.hostname})` : ""}</span> + </div> + <p className="text-amber-200/80 text-sm"> + The daemon's OAuth token has expired. Go to the{" "} + <a href="/daemons" className="text-[#75aafc] hover:text-[#9bc3ff] underline"> + Daemons page + </a>{" "} + to reauthorize, then retry this task. + </p> + </div> + ); case "supervisor_question": return ( @@ -501,98 +515,8 @@ function SupervisorQuestionEntry({ ); } -function AuthRequiredEntry({ entry }: { entry: TaskOutputEvent }) { - const [authCode, setAuthCode] = useState(""); - const [submitting, setSubmitting] = useState(false); - const [submitted, setSubmitted] = useState(false); - const [error, setError] = useState<string | null>(null); - - const loginUrl = entry.toolInput?.loginUrl as string | undefined; - const hostname = entry.toolInput?.hostname as string | undefined; - // Get taskId from entry or fallback to toolInput (for robustness) - const taskId = entry.taskId || (entry.toolInput?.taskId as string | undefined); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!authCode.trim() || !taskId) return; - - setSubmitting(true); - setError(null); - - try { - // Send the auth code to the task via the message endpoint - await sendTaskMessage(taskId, `AUTH_CODE:${authCode.trim()}`); - setSubmitted(true); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to submit code"); - } finally { - setSubmitting(false); - } - }; - - if (submitted) { - return ( - <div className="bg-green-900/30 border border-green-500/50 rounded p-3 my-2"> - <div className="flex items-center gap-2 text-green-400 font-semibold"> - <span>✓</span> - <span>Authentication code submitted</span> - </div> - <p className="text-green-200/80 text-sm mt-1"> - Waiting for authentication to complete... - </p> - </div> - ); - } - - return ( - <div className="bg-amber-900/30 border border-amber-500/50 rounded p-3 my-2"> - <div className="flex items-center gap-2 text-amber-400 font-semibold mb-2"> - <span>🔐</span> - <span>Authentication Required{hostname ? ` (${hostname})` : ""}</span> - </div> - <p className="text-amber-200/80 text-sm mb-3"> - The daemon's OAuth token has expired. Click the button to login, then paste the code below: - </p> - - <div className="flex flex-col gap-3"> - {loginUrl ? ( - <a - href={loginUrl} - target="_blank" - rel="noopener noreferrer" - className="inline-block bg-amber-500 hover:bg-amber-400 text-black font-medium px-4 py-2 rounded transition-colors text-center" - > - 1. Login to Claude - </a> - ) : ( - <p className="text-red-400 text-sm">Login URL not available</p> - )} - - <form onSubmit={handleSubmit} className="flex gap-2"> - <input - type="text" - value={authCode} - onChange={(e) => setAuthCode(e.target.value)} - placeholder="2. Paste authentication code here" - className="flex-1 bg-[#0a1525] border border-amber-500/30 rounded px-3 py-2 text-amber-100 placeholder-amber-500/50 focus:outline-none focus:border-amber-400" - disabled={submitting} - /> - <button - type="submit" - disabled={submitting || !authCode.trim()} - className="bg-amber-500 hover:bg-amber-400 disabled:bg-amber-700 disabled:cursor-not-allowed text-black font-medium px-4 py-2 rounded transition-colors" - > - {submitting ? "..." : "Submit"} - </button> - </form> - - {error && ( - <p className="text-red-400 text-sm">{error}</p> - )} - </div> - </div> - ); -} +// AuthRequiredEntry has been removed - auth flow is now on the Daemons page. +// The "auth_required" case in OutputEntryRenderer shows a redirect message instead. /** Entry for phase transition confirmations */ function PhaseConfirmationEntry({ diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 458b69d..155c716 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -1258,6 +1258,76 @@ export function getDaemonDownloadUrl(platform: string): string { } // ============================================================================= +// Daemon Reauthorization +// ============================================================================= + +/** Response from the trigger daemon reauth endpoint */ +export interface TriggerReauthResponse { + success: boolean; + daemonId: string; + requestId: string; +} + +/** Response from the reauth status polling endpoint */ +export interface ReauthStatusResponse { + status: string; // "pending" | "url_ready" | "completed" | "failed" + loginUrl?: string; + error?: string; +} + +/** + * Trigger OAuth re-authentication on a daemon. + * Sends a reauth command to the daemon, which will spawn `claude setup-token` + * and return the OAuth login URL. + */ +export async function triggerDaemonReauth(daemonId: string): Promise<TriggerReauthResponse> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/daemons/${daemonId}/reauth`, { + method: "POST", + }); + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new Error(errorData.message || `Failed to trigger reauth: ${res.statusText}`); + } + return res.json(); +} + +/** + * Submit an OAuth auth code to a daemon's pending reauth flow. + */ +export async function submitDaemonAuthCode( + daemonId: string, + code: string, + requestId: string, +): Promise<void> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/daemons/${daemonId}/reauth/code`, { + method: "POST", + body: JSON.stringify({ code, requestId }), + }); + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new Error(errorData.message || `Failed to submit auth code: ${res.statusText}`); + } +} + +/** + * Get the status of a daemon reauth request. + * Used for polling to track reauth flow progress. + */ +export async function getDaemonReauthStatus( + daemonId: string, + requestId: string, +): Promise<ReauthStatusResponse> { + const res = await authFetch( + `${API_BASE}/api/v1/mesh/daemons/${daemonId}/reauth/${requestId}/status`, + ); + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new Error(errorData.message || `Failed to get reauth status: ${res.statusText}`); + } + return res.json(); +} + +// ============================================================================= // Mesh Chat Types for Task Orchestration // ============================================================================= @@ -3081,6 +3151,10 @@ export interface DirectiveStep { dependsOn: string[]; status: StepStatus; taskId: string | null; + /** Contract ID for contract-backed steps */ + contractId?: string | null; + /** Contract type (e.g. "simple", "specification", "execute") for contract-backed steps */ + contractType?: string | null; orderIndex: number; generation: number; startedAt: string | null; diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx index 32c05ba..4f7c525 100644 --- a/makima/frontend/src/main.tsx +++ b/makima/frontend/src/main.tsx @@ -98,7 +98,7 @@ createRoot(document.getElementById("root")!).render( } /> <Route - path="/mesh" + path="/exec" element={ <ProtectedRoute> <MeshPage /> @@ -106,7 +106,7 @@ createRoot(document.getElementById("root")!).render( } /> <Route - path="/mesh/:id" + path="/exec/:id" element={ <ProtectedRoute> <MeshPage /> 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" + > + ✕ + </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">✓</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]" - > - ⟳ 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" + > + 🔑 Reauthorize + </button> + <button + onClick={() => setRestartConfirmDaemonId(daemon.id)} + className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff]" + > + ⟳ 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); |
