diff options
Diffstat (limited to 'makima/frontend/src')
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveDAG.tsx | 19 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveDetail.tsx | 61 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/StepNode.tsx | 12 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/TaskSlideOutPanel.tsx | 179 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 16 | ||||
| -rw-r--r-- | makima/frontend/src/routes/daemons.tsx | 261 |
6 files changed, 347 insertions, 201 deletions
diff --git a/makima/frontend/src/components/directives/DirectiveDAG.tsx b/makima/frontend/src/components/directives/DirectiveDAG.tsx index f225356..7fa2ccf 100644 --- a/makima/frontend/src/components/directives/DirectiveDAG.tsx +++ b/makima/frontend/src/components/directives/DirectiveDAG.tsx @@ -28,6 +28,7 @@ interface DirectiveDAGProps { onComplete?: (stepId: string) => void; onFail?: (stepId: string) => void; onSkip?: (stepId: string) => void; + onViewTask?: (taskId: string) => void; } interface Layer { @@ -52,7 +53,7 @@ function topoSort(steps: DirectiveStep[]): Layer[] { })); } -export function DirectiveDAG({ steps, specializedSteps, onComplete, onFail, onSkip }: DirectiveDAGProps) { +export function DirectiveDAG({ steps, specializedSteps, onComplete, onFail, onSkip, onViewTask }: DirectiveDAGProps) { const layers = useMemo(() => topoSort(steps), [steps]); const orchestratorSteps = specializedSteps?.filter(s => s.type === "orchestrator") ?? []; @@ -70,7 +71,7 @@ export function DirectiveDAG({ steps, specializedSteps, onComplete, onFail, onSk <div className="flex flex-col gap-4 items-center py-4"> {/* Orchestrator steps (Planning/Cleanup/Orders) - rendered above regular steps */} {orchestratorSteps.map(step => ( - <SpecializedStepNode key={step.id} step={step} /> + <SpecializedStepNode key={step.id} step={step} onViewTask={onViewTask} /> ))} {/* Connector line if both orchestrator step and regular steps exist */} @@ -96,6 +97,7 @@ export function DirectiveDAG({ steps, specializedSteps, onComplete, onFail, onSk onComplete={onComplete ? () => onComplete(step.id) : undefined} onFail={onFail ? () => onFail(step.id) : undefined} onSkip={onSkip ? () => onSkip(step.id) : undefined} + onViewTask={onViewTask} /> ))} </div> @@ -111,13 +113,13 @@ export function DirectiveDAG({ steps, specializedSteps, onComplete, onFail, onSk {/* Completion steps (PR creation) - rendered below regular steps */} {completionSteps.map(step => ( - <SpecializedStepNode key={step.id} step={step} /> + <SpecializedStepNode key={step.id} step={step} onViewTask={onViewTask} /> ))} </div> ); } -function SpecializedStepNode({ step }: { step: SpecializedStep }) { +function SpecializedStepNode({ step, onViewTask }: { step: SpecializedStep; onViewTask?: (taskId: string) => void }) { const themeColors = step.type === "orchestrator" ? { bg: "bg-[#1a1a30]", @@ -145,12 +147,13 @@ function SpecializedStepNode({ step }: { step: SpecializedStep }) { <span className={`text-[11px] font-mono ${themeColors.text} flex-1 truncate`}> {step.name} </span> - <a - href={`/exec/${step.taskId}`} - className="text-[9px] font-mono text-[#556677] hover:text-white underline" + <button + type="button" + onClick={() => onViewTask?.(step.taskId)} + className="text-[9px] font-mono text-[#556677] hover:text-white underline bg-transparent border-none p-0 cursor-pointer" > View task - </a> + </button> </div> ); } diff --git a/makima/frontend/src/components/directives/DirectiveDetail.tsx b/makima/frontend/src/components/directives/DirectiveDetail.tsx index 8f39207..5f3489a 100644 --- a/makima/frontend/src/components/directives/DirectiveDetail.tsx +++ b/makima/frontend/src/components/directives/DirectiveDetail.tsx @@ -3,6 +3,7 @@ import type { DirectiveWithSteps, DirectiveStatus, UpdateDirectiveRequest } from import { DirectiveDAG } from "./DirectiveDAG"; import type { SpecializedStep } from "./DirectiveDAG"; import { DirectiveLogStream } from "./DirectiveLogStream"; +import { TaskSlideOutPanel } from "./TaskSlideOutPanel"; import { useMultiTaskSubscription } from "../../hooks/useMultiTaskSubscription"; import { useSupervisorQuestions } from "../../contexts/SupervisorQuestionsContext"; @@ -53,6 +54,11 @@ export function DirectiveDetail({ const [pickingUpOrders, setPickingUpOrders] = useState(false); const [pickUpResult, setPickUpResult] = useState<string | null>(null); const [creatingPR, setCreatingPR] = useState(false); + const [slideOutTaskId, setSlideOutTaskId] = useState<string | null>(null); + + const handleViewTask = (taskId: string) => { + setSlideOutTaskId(taskId); + }; // Sync goalText and reset editing state when directive changes useEffect(() => { @@ -178,7 +184,15 @@ export function DirectiveDetail({ }; + // Find the task name for the slide-out panel + const slideOutTaskName = slideOutTaskId + ? (directive.steps.find((s) => s.taskId === slideOutTaskId)?.name ?? + taskMap.get(slideOutTaskId) ?? + undefined) + : undefined; + return ( + <> <div className="flex flex-col h-full overflow-y-auto"> {/* Header */} <div className="px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]"> @@ -228,21 +242,31 @@ export function DirectiveDetail({ {/* Reconcile mode toggle */} <div className="flex items-center gap-2 mb-2"> - <button - type="button" - onClick={() => onUpdate({ reconcileMode: !directive.reconcileMode })} - className={`text-[10px] font-mono border rounded px-2 py-0.5 transition-colors ${ - directive.reconcileMode - ? "text-amber-400 border-amber-800 bg-amber-900/20" - : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]" - }`} - > - {directive.reconcileMode ? "Reconcile: ON" : "Reconcile: OFF"} - </button> + <div className="flex items-center border border-[#2a3a5a] rounded overflow-hidden"> + {(["auto", "semi-auto", "manual"] as const).map((mode) => { + const isActive = directive.reconcileMode === mode; + const modeStyles: Record<string, string> = { + auto: isActive ? "text-[#9bc3ff] bg-[#1a2540]" : "text-[#445566] hover:text-[#7788aa]", + "semi-auto": isActive ? "text-amber-400 bg-amber-900/20" : "text-[#445566] hover:text-[#7788aa]", + manual: isActive ? "text-orange-400 bg-orange-900/20" : "text-[#445566] hover:text-[#7788aa]", + }; + const labels: Record<string, string> = { auto: "Auto", "semi-auto": "Semi", manual: "Manual" }; + return ( + <button + key={mode} + type="button" + onClick={() => onUpdate({ reconcileMode: mode })} + className={`text-[10px] font-mono px-2 py-0.5 transition-colors border-r border-[#2a3a5a] last:border-r-0 ${modeStyles[mode]}`} + > + {labels[mode]} + </button> + ); + })} + </div> <span className="text-[9px] font-mono text-[#445566]"> - {directive.reconcileMode - ? "Questions pause execution" - : "Questions timeout after 30s"} + {directive.reconcileMode === "auto" && "Questions timeout after 30s"} + {directive.reconcileMode === "semi-auto" && "Questions pause execution"} + {directive.reconcileMode === "manual" && "Tasks ask clarifying questions"} </span> </div> @@ -424,6 +448,7 @@ export function DirectiveDetail({ onComplete={onCompleteStep} onFail={onFailStep} onSkip={onSkipStep} + onViewTask={handleViewTask} /> </div> @@ -445,6 +470,14 @@ export function DirectiveDetail({ </div> )} </div> + + <TaskSlideOutPanel + taskId={slideOutTaskId || ""} + taskName={slideOutTaskName} + isOpen={!!slideOutTaskId} + onClose={() => setSlideOutTaskId(null)} + /> + </> ); } diff --git a/makima/frontend/src/components/directives/StepNode.tsx b/makima/frontend/src/components/directives/StepNode.tsx index 775b898..f854297 100644 --- a/makima/frontend/src/components/directives/StepNode.tsx +++ b/makima/frontend/src/components/directives/StepNode.tsx @@ -23,9 +23,10 @@ interface StepNodeProps { onComplete?: () => void; onFail?: () => void; onSkip?: () => void; + onViewTask?: (taskId: string) => void; } -export function StepNode({ step, onComplete, onFail, onSkip }: StepNodeProps) { +export function StepNode({ step, onComplete, onFail, onSkip, onViewTask }: StepNodeProps) { const colors = STATUS_COLORS[step.status] || STATUS_COLORS.pending; const label = STATUS_LABELS[step.status] || step.status.toUpperCase(); const isContractBacked = !!step.contractType; @@ -66,12 +67,13 @@ export function StepNode({ step, onComplete, onFail, onSkip }: StepNodeProps) { </a> )} {step.taskId && !step.contractId && ( - <a - href={`/exec/${step.taskId}`} - className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc] underline block mb-1" + <button + type="button" + onClick={() => onViewTask?.(step.taskId!)} + className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc] underline block mb-1 bg-transparent border-none p-0 cursor-pointer text-left" > {step.status === "running" ? "Auto-executing..." : "View task"} - </a> + </button> )} {(step.status === "running" || step.status === "ready") && ( <div className="flex gap-1 mt-1"> diff --git a/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx b/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx new file mode 100644 index 0000000..29fce23 --- /dev/null +++ b/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx @@ -0,0 +1,179 @@ +import { useState, useEffect, useCallback } from "react"; +import { useTaskSubscription } from "../../hooks/useTaskSubscription"; +import type { TaskOutputEvent } from "../../hooks/useTaskSubscription"; +import { TaskOutput } from "../mesh/TaskOutput"; +import { WorktreeFilesPanel } from "../mesh/WorktreeFilesPanel"; +import { getTaskOutput } from "../../lib/api"; + +interface TaskSlideOutPanelProps { + taskId: string; + taskName?: string; + isOpen: boolean; + onClose: () => void; +} + +export function TaskSlideOutPanel({ + taskId, + taskName, + isOpen, + onClose, +}: TaskSlideOutPanelProps) { + const [entries, setEntries] = useState<TaskOutputEvent[]>([]); + const [isStreaming, setIsStreaming] = useState(false); + const [loadingHistory, setLoadingHistory] = useState(false); + + // Escape key handler + useEffect(() => { + const handleEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + if (isOpen) document.addEventListener("keydown", handleEsc); + return () => document.removeEventListener("keydown", handleEsc); + }, [isOpen, onClose]); + + // Load historical output when panel opens with a taskId + useEffect(() => { + if (!isOpen || !taskId) { + setEntries([]); + setIsStreaming(false); + return; + } + + let cancelled = false; + setLoadingHistory(true); + + getTaskOutput(taskId) + .then((res) => { + if (cancelled) return; + // Map TaskOutputEntry to TaskOutputEvent + const mapped: TaskOutputEvent[] = res.entries.map((e) => ({ + taskId: e.taskId, + messageType: e.messageType, + content: e.content, + toolName: e.toolName, + toolInput: e.toolInput, + isError: e.isError, + costUsd: e.costUsd, + durationMs: e.durationMs, + isPartial: false, + })); + setEntries(mapped); + }) + .catch((err) => { + if (cancelled) return; + console.error("Failed to load task output history:", err); + }) + .finally(() => { + if (!cancelled) setLoadingHistory(false); + }); + + return () => { + cancelled = true; + }; + }, [isOpen, taskId]); + + // Handle live output events + const handleOutput = useCallback( + (event: TaskOutputEvent) => { + if (event.isPartial) return; + setEntries((prev) => [...prev, event]); + setIsStreaming(true); + }, + [] + ); + + // Handle task updates (to detect completion) + const handleUpdate = useCallback( + (event: { status: string }) => { + if ( + event.status === "completed" || + event.status === "failed" || + event.status === "cancelled" + ) { + setIsStreaming(false); + } else if (event.status === "running") { + setIsStreaming(true); + } + }, + [] + ); + + // Subscribe to live output + useTaskSubscription({ + taskId: isOpen ? taskId : null, + subscribeOutput: isOpen && !!taskId, + onOutput: handleOutput, + onUpdate: handleUpdate, + }); + + return ( + <> + {/* Backdrop overlay */} + <div + className={`fixed inset-0 bg-black/30 z-50 transition-opacity duration-300 ${ + isOpen ? "opacity-100" : "opacity-0 pointer-events-none" + }`} + onClick={onClose} + /> + + {/* Slide-out panel */} + <div + className={`fixed top-0 right-0 h-full w-[550px] max-w-[90vw] z-50 bg-[#0d1117] border-l border-[rgba(117,170,252,0.2)] shadow-xl shadow-black/50 flex flex-col transition-transform duration-300 ease-in-out ${ + isOpen ? "translate-x-0" : "translate-x-full" + }`} + > + {/* Header */} + <div className="flex items-center justify-between px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.25)] shrink-0"> + <div className="flex items-center gap-2 min-w-0 flex-1"> + <span className="text-[10px] font-mono text-[#75aafc] uppercase tracking-wide shrink-0"> + Task + </span> + <span className="text-[12px] font-mono text-white truncate"> + {taskName || taskId} + </span> + {isStreaming && ( + <span className="flex items-center gap-1 px-1.5 py-0.5 bg-green-400/10 border border-green-400/30 shrink-0"> + <span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" /> + <span className="text-green-400 font-mono text-[9px] uppercase"> + Live + </span> + </span> + )} + </div> + <button + type="button" + onClick={onClose} + className="text-[#7788aa] hover:text-white font-mono text-sm transition-colors ml-2 shrink-0 w-6 h-6 flex items-center justify-center" + > + ✕ + </button> + </div> + + {/* Content */} + <div className="flex-1 flex flex-col min-h-0 overflow-hidden"> + {/* Task Output section (~60% height) */} + <div className="flex-[3] min-h-0 flex flex-col border-b border-[rgba(117,170,252,0.15)]"> + {loadingHistory ? ( + <div className="flex-1 flex items-center justify-center"> + <span className="font-mono text-xs text-[#555] animate-pulse"> + Loading output... + </span> + </div> + ) : ( + <TaskOutput + entries={entries} + isStreaming={isStreaming} + taskId={taskId} + /> + )} + </div> + + {/* Worktree Changes section (~40% height) */} + <div className="flex-[2] min-h-0 overflow-y-auto"> + {taskId && <WorktreeFilesPanel taskId={taskId} />} + </div> + </div> + </div> + </> + ); +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 155c716..4923c1d 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3133,8 +3133,8 @@ export interface Directive { completionTaskId: string | null; /** Whether the memory system is enabled for this directive */ memoryEnabled: boolean; - /** Whether questions pause execution indefinitely until answered */ - reconcileMode: boolean; + /** Reconcile mode: auto (timeout), semi-auto (pause), or manual (ask questions) */ + reconcileMode: string; goalUpdatedAt: string; startedAt: string | null; version: number; @@ -3178,8 +3178,8 @@ export interface DirectiveSummary { completionTaskId: string | null; /** Whether the memory system is enabled for this directive */ memoryEnabled: boolean; - /** Whether questions pause execution indefinitely until answered */ - reconcileMode: boolean; + /** Reconcile mode: auto (timeout), semi-auto (pause), or manual (ask questions) */ + reconcileMode: string; version: number; createdAt: string; updatedAt: string; @@ -3202,8 +3202,8 @@ export interface CreateDirectiveRequest { baseBranch?: string; /** Enable the memory system for this directive (default: false) */ memoryEnabled?: boolean; - /** Whether questions pause execution indefinitely until answered (default: false) */ - reconcileMode?: boolean; + /** Reconcile mode: auto (timeout), semi-auto (pause), or manual (ask questions) (default: auto) */ + reconcileMode?: string; } export interface UpdateDirectiveRequest { @@ -3216,8 +3216,8 @@ export interface UpdateDirectiveRequest { orchestratorTaskId?: string; /** Enable or disable the memory system for this directive */ memoryEnabled?: boolean; - /** Whether questions pause execution indefinitely until answered */ - reconcileMode?: boolean; + /** Reconcile mode: auto (timeout), semi-auto (pause), or manual (ask questions) */ + reconcileMode?: string; version?: number; } diff --git a/makima/frontend/src/routes/daemons.tsx b/makima/frontend/src/routes/daemons.tsx index ca167fe..aa48deb 100644 --- a/makima/frontend/src/routes/daemons.tsx +++ b/makima/frontend/src/routes/daemons.tsx @@ -6,7 +6,6 @@ import { listDaemons, restartDaemon, triggerDaemonReauth, - submitDaemonAuthCode, getDaemonReauthStatus, type Daemon, type DaemonListResponse, @@ -43,7 +42,7 @@ function ErrorAlert({ children }: { children: React.ReactNode }) { type ReauthState = | { phase: "initiating" } | { phase: "url_ready"; loginUrl: string; requestId: string } - | { phase: "submitting"; requestId: string } + | { phase: "waiting_for_auth"; loginUrl: string; requestId: string } | { phase: "success" } | { phase: "error"; message: string }; @@ -55,7 +54,6 @@ function ReauthModal({ 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 @@ -67,6 +65,53 @@ function ReauthModal({ }; }, []); + // Start polling for status updates (URL ready, then completion) + const startPolling = useCallback( + (requestId: string) => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + 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 === "url_ready" && status.loginUrl) { + setState((prev) => { + // Only update if we haven't already shown the URL + if (prev.phase === "initiating") { + return { + phase: "url_ready", + loginUrl: status.loginUrl!, + requestId, + }; + } + return prev; + }); + // Keep polling for completion - don't stop here + } else if (status.status === "failed") { + setState({ + phase: "error", + message: status.error || "Reauth failed", + }); + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + } + } catch { + // Polling errors are non-fatal, keep trying + } + }, 2000); + }, + [daemon.id], + ); + // Trigger reauth on mount useEffect(() => { let cancelled = false; @@ -74,45 +119,7 @@ function ReauthModal({ 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); + startPolling(res.requestId); } catch (err) { if (cancelled) return; setState({ @@ -126,110 +133,28 @@ function ReauthModal({ 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", - }); + }, [daemon.id, startPolling]); + + // When URL is shown, transition to waiting_for_auth after user clicks the link + const handleOpenedLink = useCallback(() => { + setState((prev) => { + if (prev.phase === "url_ready") { + return { + phase: "waiting_for_auth", + loginUrl: prev.loginUrl, + requestId: prev.requestId, + }; } - }, - [authCode, daemon.id, state], - ); + return prev; + }); + }, []); 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); + startPolling(res.requestId); } catch (err) { setState({ phase: "error", @@ -241,7 +166,7 @@ function ReauthModal({ } }; trigger(); - }, [daemon.id]); + }, [daemon.id, startPolling]); return ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"> @@ -272,46 +197,50 @@ function ReauthModal({ </div> )} - {/* URL Ready */} + {/* URL Ready - user needs to click the link */} {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: + Click the button below to open the OAuth login page. Authentication will complete automatically. </p> <a href={state.loginUrl} target="_blank" rel="noopener noreferrer" + onClick={handleOpenedLink} 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 + 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 className="flex items-center gap-2 pt-1"> + <div className="w-2 h-2 bg-amber-500/50 rounded-full animate-pulse" /> + <span className="text-[10px] font-mono text-[#7788aa]"> + Waiting for authentication to complete... + </span> + </div> </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> + {/* Waiting for auth - user has clicked the link, waiting for token */} + {state.phase === "waiting_for_auth" && ( + <div className="space-y-3"> + <div className="flex items-center gap-2 py-2"> + <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]"> + Waiting for authentication to complete... + </span> + </div> + <p className="text-[10px] font-mono text-[#556677]"> + Complete the login in your browser. The token will be saved automatically. + </p> + <a + href={state.loginUrl} + target="_blank" + rel="noopener noreferrer" + className="inline-block text-[10px] font-mono text-amber-500/70 hover:text-amber-400 underline" + > + Open login page again + </a> </div> )} |
