From ec9738a069e61529be040eff065318972b8a11e2 Mon Sep 17 00:00:00 2001 From: soryu Date: Wed, 4 Mar 2026 16:47:12 +0000 Subject: feat: task slide-out panel, 3-way reconcile toggle, daemon reauth fix (#85) * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Fix daemon reauth flow for new claude setup-token output format * feat: soryu-co/soryu - makima: Update frontend reconcile toggle to three-way switch * feat: soryu-co/soryu - makima: Add task slide-out panel to directive page --- .../src/components/directives/DirectiveDAG.tsx | 19 +- .../src/components/directives/DirectiveDetail.tsx | 61 +++-- .../src/components/directives/StepNode.tsx | 12 +- .../components/directives/TaskSlideOutPanel.tsx | 179 ++++++++++++++ makima/frontend/src/lib/api.ts | 16 +- makima/frontend/src/routes/daemons.tsx | 261 ++++++++------------- 6 files changed, 347 insertions(+), 201 deletions(-) create mode 100644 makima/frontend/src/components/directives/TaskSlideOutPanel.tsx (limited to 'makima/frontend') 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
{/* Orchestrator steps (Planning/Cleanup/Orders) - rendered above regular steps */} {orchestratorSteps.map(step => ( - + ))} {/* 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} /> ))}
@@ -111,13 +113,13 @@ export function DirectiveDAG({ steps, specializedSteps, onComplete, onFail, onSk {/* Completion steps (PR creation) - rendered below regular steps */} {completionSteps.map(step => ( - + ))} ); } -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 }) { {step.name} - onViewTask?.(step.taskId)} + className="text-[9px] font-mono text-[#556677] hover:text-white underline bg-transparent border-none p-0 cursor-pointer" > View task - + ); } 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(null); const [creatingPR, setCreatingPR] = useState(false); + const [slideOutTaskId, setSlideOutTaskId] = useState(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 ( + <>
{/* Header */}
@@ -228,21 +242,31 @@ export function DirectiveDetail({ {/* Reconcile mode toggle */}
- +
+ {(["auto", "semi-auto", "manual"] as const).map((mode) => { + const isActive = directive.reconcileMode === mode; + const modeStyles: Record = { + 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 = { auto: "Auto", "semi-auto": "Semi", manual: "Manual" }; + return ( + + ); + })} +
- {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"}
@@ -424,6 +448,7 @@ export function DirectiveDetail({ onComplete={onCompleteStep} onFail={onFailStep} onSkip={onSkipStep} + onViewTask={handleViewTask} />
@@ -445,6 +470,14 @@ export function DirectiveDetail({
)} + + 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) { )} {step.taskId && !step.contractId && ( - 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"} - + )} {(step.status === "running" || step.status === "ready") && (
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([]); + 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 */} +
+ + {/* Slide-out panel */} +
+ {/* Header */} +
+
+ + Task + + + {taskName || taskId} + + {isStreaming && ( + + + + Live + + + )} +
+ +
+ + {/* Content */} +
+ {/* Task Output section (~60% height) */} +
+ {loadingHistory ? ( +
+ + Loading output... + +
+ ) : ( + + )} +
+ + {/* Worktree Changes section (~40% height) */} +
+ {taskId && } +
+
+
+ + ); +} 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({ phase: "initiating" }); - const [authCode, setAuthCode] = useState(""); const pollingRef = useRef | 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 (
@@ -272,46 +197,50 @@ function ReauthModal({
)} - {/* URL Ready */} + {/* URL Ready - user needs to click the link */} {state.phase === "url_ready" && (

- 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.

- 1. Login to Claude + Login to Claude -
- 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" - /> - -
+
+
+ + Waiting for authentication to complete... + +
)} - {/* Submitting */} - {state.phase === "submitting" && ( -
-
- - Submitting auth code... - + {/* Waiting for auth - user has clicked the link, waiting for token */} + {state.phase === "waiting_for_auth" && ( +
+
+
+ + Waiting for authentication to complete... + +
+

+ Complete the login in your browser. The token will be saved automatically. +

+ + Open login page again +
)} -- cgit v1.2.3