summaryrefslogtreecommitdiff
path: root/makima/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend')
-rw-r--r--makima/frontend/src/components/directives/DirectiveDAG.tsx19
-rw-r--r--makima/frontend/src/components/directives/DirectiveDetail.tsx61
-rw-r--r--makima/frontend/src/components/directives/StepNode.tsx12
-rw-r--r--makima/frontend/src/components/directives/TaskSlideOutPanel.tsx179
-rw-r--r--makima/frontend/src/lib/api.ts16
-rw-r--r--makima/frontend/src/routes/daemons.tsx261
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>
)}