diff options
| author | soryu <soryu@soryu.co> | 2026-01-17 06:44:22 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-17 16:39:28 +0000 |
| commit | 6b07707a4cc99c7e127a2bf6a0ca790fa033b5f5 (patch) | |
| tree | 23ae5355bf4fb16dd7c1cd5dbcd50f4b72c715dc /makima/frontend/src | |
| parent | bfc5d837c6212a8253accfdf95ae1a2fd692df4e (diff) | |
| download | soryu-6b07707a4cc99c7e127a2bf6a0ca790fa033b5f5.tar.gz soryu-6b07707a4cc99c7e127a2bf6a0ca790fa033b5f5.zip | |
feat(frontend): Add UI for phase transition confirmation requests
When phase_guard is enabled and a supervisor tries to advance the contract
phase, users now see a confirmation modal with:
- Current and proposed next phase visualization
- Phase deliverables checklist (if available)
- Summary of the phase work
- Options to "Approve & Advance" or "Request Changes" with feedback
Components added:
- PhaseConfirmationModal: Full modal dialog for phase confirmations
- PhaseConfirmationInline: Inline variant for task output view
- PhaseConfirmationNotification: Global notification wrapper
- PhaseConfirmationToast: Alternative toast-style notification
Integration:
- Added phase_confirmation message type to TaskOutput renderer
- Extended PendingQuestion API type with phase confirmation data
- Integrated notification into main app layout
The UI uses the existing supervisor question infrastructure (polling via
/api/v1/mesh/questions) and responds with APPROVE or CHANGES_REQUESTED
prefixed feedback.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'makima/frontend/src')
| -rw-r--r-- | makima/frontend/src/components/PhaseConfirmationNotification.tsx | 141 | ||||
| -rw-r--r-- | makima/frontend/src/components/contracts/PhaseConfirmationModal.tsx | 411 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/TaskOutput.tsx | 66 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 13 | ||||
| -rw-r--r-- | makima/frontend/src/main.tsx | 2 |
5 files changed, 633 insertions, 0 deletions
diff --git a/makima/frontend/src/components/PhaseConfirmationNotification.tsx b/makima/frontend/src/components/PhaseConfirmationNotification.tsx new file mode 100644 index 0000000..516211f --- /dev/null +++ b/makima/frontend/src/components/PhaseConfirmationNotification.tsx @@ -0,0 +1,141 @@ +import { useNavigate } from "react-router"; +import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext"; +import { PhaseConfirmationModal, type PhaseConfirmationData } from "./contracts/PhaseConfirmationModal"; +import type { PendingQuestion } from "../lib/api"; + +/** + * Notification component for phase confirmation requests. + * Shows a modal when there are pending phase_confirmation type questions. + * Uses the same question infrastructure as supervisor questions. + */ +export function PhaseConfirmationNotification() { + const { notificationQuestions, submitAnswer, dismissNotification } = + useSupervisorQuestions(); + + // Filter for phase_confirmation type questions + const phaseConfirmationQuestions = notificationQuestions.filter( + (q) => q.questionType === "phase_confirmation" + ); + + if (phaseConfirmationQuestions.length === 0) { + return null; + } + + // Show the first phase confirmation question as a modal + const question = phaseConfirmationQuestions[0]; + + // Build phase confirmation data from the question + const data: PhaseConfirmationData = { + questionId: question.questionId, + contractId: question.contractId, + contractName: question.phaseConfirmation?.contractName, + currentPhase: question.phaseConfirmation?.currentPhase || "research", + nextPhase: question.phaseConfirmation?.nextPhase || "specify", + summary: question.phaseConfirmation?.summary, + deliverables: question.phaseConfirmation?.deliverables, + }; + + const handleApprove = async (questionId: string) => { + const success = await submitAnswer(questionId, "APPROVE"); + if (success) { + dismissNotification(questionId); + } + }; + + const handleRequestChanges = async (questionId: string, feedback: string) => { + const success = await submitAnswer( + questionId, + `CHANGES_REQUESTED: ${feedback}` + ); + if (success) { + dismissNotification(questionId); + } + }; + + const handleDismiss = () => { + // Dismiss to notification (user can still respond via task output) + dismissNotification(question.questionId); + }; + + return ( + <PhaseConfirmationModal + data={data} + onApprove={handleApprove} + onRequestChanges={handleRequestChanges} + onDismiss={handleDismiss} + /> + ); +} + +/** + * Alternative: Notification toast-style for phase confirmations + * Shows as a small notification in the corner (like regular supervisor questions) + */ +export function PhaseConfirmationToast() { + const navigate = useNavigate(); + const { notificationQuestions, dismissNotification } = useSupervisorQuestions(); + + // Filter for phase_confirmation type questions + const phaseConfirmationQuestions = notificationQuestions.filter( + (q) => q.questionType === "phase_confirmation" + ); + + if (phaseConfirmationQuestions.length === 0) { + return null; + } + + const handleGoToTask = (question: PendingQuestion) => { + dismissNotification(question.questionId); + navigate(`/mesh/${question.taskId}`); + }; + + return ( + <div className="fixed bottom-4 right-4 z-50 max-w-md space-y-2"> + {phaseConfirmationQuestions.map((question) => ( + <div + key={question.questionId} + className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.5)] rounded-lg shadow-lg overflow-hidden" + > + {/* Header */} + <div className="flex items-center justify-between px-4 py-3 bg-[rgba(117,170,252,0.1)]"> + <div className="flex items-center gap-2"> + <span className="text-[#75aafc] text-lg">?</span> + <span className="font-mono text-sm text-[#9bc3ff] uppercase"> + Phase Transition + </span> + </div> + <button + onClick={() => handleGoToTask(question)} + className="px-3 py-1 font-mono text-xs text-[#75aafc] border border-[rgba(117,170,252,0.3)] hover:border-[rgba(117,170,252,0.5)] hover:bg-[rgba(117,170,252,0.1)] transition-colors uppercase" + > + Review + </button> + </div> + + {/* Content preview */} + <div className="px-4 py-3"> + {question.phaseConfirmation && ( + <div className="flex items-center gap-2 mb-2"> + <span className="font-mono text-xs text-purple-400"> + {question.phaseConfirmation.currentPhase} + </span> + <span className="text-[#555] font-mono text-xs">→</span> + <span className="font-mono text-xs text-green-400"> + {question.phaseConfirmation.nextPhase} + </span> + </div> + )} + <p className="text-sm text-[#dbe7ff] font-mono line-clamp-2"> + {question.question} + </p> + {question.phaseConfirmation?.contractName && ( + <p className="text-xs text-[#555] font-mono mt-1"> + Contract: {question.phaseConfirmation.contractName} + </p> + )} + </div> + </div> + ))} + </div> + ); +} diff --git a/makima/frontend/src/components/contracts/PhaseConfirmationModal.tsx b/makima/frontend/src/components/contracts/PhaseConfirmationModal.tsx new file mode 100644 index 0000000..96cc1d4 --- /dev/null +++ b/makima/frontend/src/components/contracts/PhaseConfirmationModal.tsx @@ -0,0 +1,411 @@ +import { useState } from "react"; +import type { ContractPhase } from "../../lib/api"; + +/** Phase configuration for styling */ +const phaseConfig: Record< + ContractPhase, + { label: string; color: string; borderColor: string } +> = { + research: { + label: "Research", + color: "text-purple-400", + borderColor: "border-purple-400/50", + }, + specify: { + label: "Specify", + color: "text-blue-400", + borderColor: "border-blue-400/50", + }, + plan: { + label: "Plan", + color: "text-cyan-400", + borderColor: "border-cyan-400/50", + }, + execute: { + label: "Execute", + color: "text-green-400", + borderColor: "border-green-400/50", + }, + review: { + label: "Review", + color: "text-yellow-400", + borderColor: "border-yellow-400/50", + }, +}; + +export interface PhaseConfirmationData { + questionId: string; + contractId: string; + contractName?: string; + currentPhase: ContractPhase; + nextPhase: ContractPhase; + summary?: string; + deliverables?: Array<{ + name: string; + completed: boolean; + }>; +} + +interface PhaseConfirmationModalProps { + data: PhaseConfirmationData; + onApprove: (questionId: string) => Promise<void>; + onRequestChanges: (questionId: string, feedback: string) => Promise<void>; + onDismiss?: () => void; +} + +export function PhaseConfirmationModal({ + data, + onApprove, + onRequestChanges, + onDismiss, +}: PhaseConfirmationModalProps) { + const [mode, setMode] = useState<"choice" | "feedback">("choice"); + const [feedback, setFeedback] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState<string | null>(null); + + const currentConfig = phaseConfig[data.currentPhase]; + const nextConfig = phaseConfig[data.nextPhase]; + + const handleApprove = async () => { + setSubmitting(true); + setError(null); + try { + await onApprove(data.questionId); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to approve"); + } finally { + setSubmitting(false); + } + }; + + const handleRequestChanges = () => { + setMode("feedback"); + }; + + const handleSubmitFeedback = async () => { + if (!feedback.trim()) return; + setSubmitting(true); + setError(null); + try { + await onRequestChanges(data.questionId, feedback.trim()); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to submit feedback"); + } finally { + setSubmitting(false); + } + }; + + const handleBack = () => { + setMode("choice"); + setFeedback(""); + setError(null); + }; + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"> + <div className="w-full max-w-lg mx-4 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-2xl"> + {/* Header */} + <div className="flex items-center justify-between p-4 border-b border-[rgba(117,170,252,0.2)]"> + <div className="flex items-center gap-2"> + <span className="text-amber-400 text-lg">?</span> + <h2 className="font-mono text-sm text-[#75aafc] uppercase tracking-wide"> + Phase Transition Confirmation + </h2> + </div> + {onDismiss && ( + <button + onClick={onDismiss} + className="text-[#555] hover:text-[#9bc3ff] transition-colors" + aria-label="Dismiss" + > + <span className="text-xl">×</span> + </button> + )} + </div> + + {/* Content */} + <div className="p-4 space-y-4"> + {/* Contract name */} + {data.contractName && ( + <p className="font-mono text-sm text-[#9bc3ff]"> + Contract: <span className="text-[#dbe7ff]">{data.contractName}</span> + </p> + )} + + {/* Phase transition indicator */} + <div className="flex items-center justify-center gap-3 py-4"> + <div + className={`px-4 py-2 border ${currentConfig.borderColor} ${currentConfig.color} font-mono text-sm uppercase`} + > + {currentConfig.label} + </div> + <div className="text-[#555] font-mono">→</div> + <div + className={`px-4 py-2 border ${nextConfig.borderColor} ${nextConfig.color} font-mono text-sm uppercase`} + > + {nextConfig.label} + </div> + </div> + + {/* Summary */} + {data.summary && ( + <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.15)] p-3"> + <p className="font-mono text-xs text-[#9bc3ff] mb-2 uppercase"> + Phase Summary + </p> + <p className="font-mono text-sm text-[#dbe7ff] whitespace-pre-wrap"> + {data.summary} + </p> + </div> + )} + + {/* Deliverables checklist */} + {data.deliverables && data.deliverables.length > 0 && ( + <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.15)] p-3"> + <p className="font-mono text-xs text-[#9bc3ff] mb-2 uppercase"> + Phase Deliverables + </p> + <ul className="space-y-1"> + {data.deliverables.map((d, idx) => ( + <li key={idx} className="flex items-center gap-2"> + <span + className={ + d.completed ? "text-green-400" : "text-[#555]" + } + > + {d.completed ? "+" : "-"} + </span> + <span + className={`font-mono text-sm ${ + d.completed ? "text-[#9bc3ff]" : "text-[#555]" + }`} + > + {d.name} + </span> + </li> + ))} + </ul> + </div> + )} + + {/* Error message */} + {error && ( + <div className="bg-red-900/20 border border-red-500/30 p-3 text-red-400 font-mono text-sm"> + {error} + </div> + )} + + {/* Choice mode */} + {mode === "choice" && ( + <div className="space-y-3 pt-4 border-t border-[rgba(117,170,252,0.2)]"> + <p className="font-mono text-sm text-[#dbe7ff]"> + Ready to advance to the {nextConfig.label.toLowerCase()} phase? + </p> + <div className="flex gap-3"> + <button + onClick={handleApprove} + disabled={submitting} + className="flex-1 px-4 py-2.5 font-mono text-sm text-[#dbe7ff] bg-green-700/30 border border-green-400/50 hover:bg-green-700/40 hover:border-green-400/70 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + {submitting ? "Advancing..." : "Approve & Advance"} + </button> + <button + onClick={handleRequestChanges} + disabled={submitting} + className="flex-1 px-4 py-2.5 font-mono text-sm text-amber-300 border border-amber-500/50 hover:border-amber-400/70 hover:bg-amber-900/20 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + Request Changes + </button> + </div> + </div> + )} + + {/* Feedback mode */} + {mode === "feedback" && ( + <div className="space-y-3 pt-4 border-t border-[rgba(117,170,252,0.2)]"> + <div> + <label className="block font-mono text-xs text-[#9bc3ff] uppercase mb-2"> + What changes are needed before advancing? + </label> + <textarea + value={feedback} + onChange={(e) => setFeedback(e.target.value)} + placeholder="Describe what needs to be changed or completed..." + rows={4} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none" + autoFocus + /> + </div> + <div className="flex gap-3"> + <button + onClick={handleBack} + disabled={submitting} + className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors uppercase" + > + ← Back + </button> + <button + onClick={handleSubmitFeedback} + disabled={submitting || !feedback.trim()} + className="flex-1 px-4 py-2 font-mono text-sm text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + {submitting ? "Submitting..." : "Submit Feedback"} + </button> + </div> + </div> + )} + </div> + </div> + </div> + ); +} + +/** Inline variant for task output view */ +interface PhaseConfirmationInlineProps { + data: PhaseConfirmationData; + isPending: boolean; + onApprove: (questionId: string) => Promise<void>; + onRequestChanges: (questionId: string, feedback: string) => Promise<void>; +} + +export function PhaseConfirmationInline({ + data, + isPending, + onApprove, + onRequestChanges, +}: PhaseConfirmationInlineProps) { + const [mode, setMode] = useState<"choice" | "feedback">("choice"); + const [feedback, setFeedback] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const currentConfig = phaseConfig[data.currentPhase]; + const nextConfig = phaseConfig[data.nextPhase]; + + const handleApprove = async () => { + setSubmitting(true); + try { + await onApprove(data.questionId); + } finally { + setSubmitting(false); + } + }; + + const handleSubmitFeedback = async () => { + if (!feedback.trim()) return; + setSubmitting(true); + try { + await onRequestChanges(data.questionId, feedback.trim()); + setFeedback(""); + } finally { + setSubmitting(false); + } + }; + + return ( + <div className="bg-[#0f1825] border border-[rgba(117,170,252,0.4)] rounded p-3 my-2"> + <div className="flex items-center gap-2 mb-3"> + <span className="text-[#75aafc] text-lg">?</span> + <span className="font-mono text-sm text-[#75aafc] uppercase"> + Phase Transition + </span> + {!isPending && ( + <span className="text-green-400 text-xs font-mono">(Responded)</span> + )} + </div> + + {/* Phase transition indicator */} + <div className="flex items-center gap-2 mb-3"> + <span className={`font-mono text-sm ${currentConfig.color}`}> + {currentConfig.label} + </span> + <span className="text-[#555] font-mono">→</span> + <span className={`font-mono text-sm ${nextConfig.color}`}> + {nextConfig.label} + </span> + </div> + + {/* Summary */} + {data.summary && ( + <p className="font-mono text-xs text-[#9bc3ff] mb-3 whitespace-pre-wrap"> + {data.summary} + </p> + )} + + {/* Deliverables */} + {data.deliverables && data.deliverables.length > 0 && ( + <div className="mb-3"> + <p className="font-mono text-[10px] text-[#555] uppercase mb-1"> + Deliverables + </p> + <div className="flex flex-wrap gap-2"> + {data.deliverables.map((d, idx) => ( + <span + key={idx} + className={`px-2 py-0.5 font-mono text-xs border ${ + d.completed + ? "border-green-400/30 text-green-400" + : "border-[#555] text-[#555]" + }`} + > + {d.completed ? "+" : "-"} {d.name} + </span> + ))} + </div> + </div> + )} + + {isPending && ( + <> + {mode === "choice" && ( + <div className="flex flex-wrap gap-2"> + <button + onClick={handleApprove} + disabled={submitting} + className="px-3 py-1.5 font-mono text-xs bg-green-700/30 border border-green-400/50 text-green-300 hover:bg-green-700/40 disabled:opacity-50 transition-colors" + > + {submitting ? "..." : "Approve & Advance"} + </button> + <button + onClick={() => setMode("feedback")} + disabled={submitting} + className="px-3 py-1.5 font-mono text-xs border border-amber-500/50 text-amber-300 hover:bg-amber-900/20 disabled:opacity-50 transition-colors" + > + Request Changes + </button> + </div> + )} + + {mode === "feedback" && ( + <div className="space-y-2"> + <textarea + value={feedback} + onChange={(e) => setFeedback(e.target.value)} + placeholder="Describe what changes are needed..." + rows={2} + className="w-full px-2 py-1.5 bg-[#0a1525] border border-[rgba(117,170,252,0.3)] text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#75aafc] resize-none" + autoFocus + /> + <div className="flex gap-2"> + <button + onClick={() => setMode("choice")} + disabled={submitting} + className="px-2 py-1 font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] transition-colors" + > + Cancel + </button> + <button + onClick={handleSubmitFeedback} + disabled={submitting || !feedback.trim()} + className="px-3 py-1 font-mono text-xs bg-[#0f3c78] border border-[#3f6fb3] text-[#dbe7ff] hover:bg-[#153667] disabled:opacity-50 disabled:cursor-not-allowed transition-colors" + > + {submitting ? "..." : "Submit"} + </button> + </div> + </div> + )} + </> + )} + </div> + ); +} diff --git a/makima/frontend/src/components/mesh/TaskOutput.tsx b/makima/frontend/src/components/mesh/TaskOutput.tsx index d53429d..7140c8a 100644 --- a/makima/frontend/src/components/mesh/TaskOutput.tsx +++ b/makima/frontend/src/components/mesh/TaskOutput.tsx @@ -2,6 +2,11 @@ import { useRef, useEffect, useState, useCallback } from "react"; import { SimpleMarkdown } from "../SimpleMarkdown"; import type { TaskOutputEvent } from "../../hooks/useTaskSubscription"; import { sendTaskMessage } from "../../lib/api"; +import { + PhaseConfirmationInline, + type PhaseConfirmationData, +} from "../contracts/PhaseConfirmationModal"; +import type { ContractPhase } from "../../lib/api"; interface TaskOutputProps { /** Array of parsed output events from the backend */ @@ -312,6 +317,15 @@ function OutputEntryRenderer({ entry, pendingQuestionIds, onAnswerQuestion }: Ou /> ); + case "phase_confirmation": + return ( + <PhaseConfirmationEntry + entry={entry} + pendingQuestionIds={pendingQuestionIds} + onAnswerQuestion={onAnswerQuestion} + /> + ); + default: return null; } @@ -520,3 +534,55 @@ function AuthRequiredEntry({ entry }: { entry: TaskOutputEvent }) { </div> ); } + +/** Entry for phase transition confirmations */ +function PhaseConfirmationEntry({ + entry, + pendingQuestionIds, + onAnswerQuestion, +}: { + entry: TaskOutputEvent; + pendingQuestionIds?: Set<string>; + onAnswerQuestion?: (questionId: string, response: string) => Promise<void>; +}) { + const questionId = entry.toolInput?.question_id as string; + const currentPhase = entry.toolInput?.current_phase as ContractPhase; + const nextPhase = entry.toolInput?.next_phase as ContractPhase; + const contractId = entry.toolInput?.contract_id as string; + const contractName = entry.toolInput?.contract_name as string | undefined; + const summary = entry.toolInput?.summary as string | undefined; + const deliverables = entry.toolInput?.deliverables as + | Array<{ name: string; completed: boolean }> + | undefined; + + const isPending = pendingQuestionIds?.has(questionId) ?? false; + + const data: PhaseConfirmationData = { + questionId, + contractId, + contractName, + currentPhase, + nextPhase, + summary, + deliverables, + }; + + const handleApprove = async (qId: string) => { + if (!onAnswerQuestion) return; + await onAnswerQuestion(qId, "APPROVE"); + }; + + const handleRequestChanges = async (qId: string, feedback: string) => { + if (!onAnswerQuestion) return; + await onAnswerQuestion(qId, `CHANGES_REQUESTED: ${feedback}`); + }; + + return ( + <PhaseConfirmationInline + data={data} + isPending={isPending} + onApprove={handleApprove} + onRequestChanges={handleRequestChanges} + /> + ); +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index ee04935..7760365 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -1938,6 +1938,19 @@ export interface PendingQuestion { choices: string[]; context: string | null; createdAt: string; + /** Question type - "general" for regular questions, "phase_confirmation" for phase transitions */ + questionType?: "general" | "phase_confirmation"; + /** Phase confirmation specific data (when questionType is "phase_confirmation") */ + phaseConfirmation?: { + currentPhase: ContractPhase; + nextPhase: ContractPhase; + contractName?: string; + summary?: string; + deliverables?: Array<{ + name: string; + completed: boolean; + }>; + }; } export interface AnswerQuestionRequest { diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx index 5fd6a4e..19f02d1 100644 --- a/makima/frontend/src/main.tsx +++ b/makima/frontend/src/main.tsx @@ -6,6 +6,7 @@ import { AuthProvider } from "./contexts/AuthContext"; import { SupervisorQuestionsProvider } from "./contexts/SupervisorQuestionsContext"; import { GridOverlay } from "./components/GridOverlay"; import { SupervisorQuestionNotification } from "./components/SupervisorQuestionNotification"; +import { PhaseConfirmationNotification } from "./components/PhaseConfirmationNotification"; import { ProtectedRoute } from "./components/ProtectedRoute"; import HomePage from "./routes/_index"; import ListenPage from "./routes/listen"; @@ -24,6 +25,7 @@ createRoot(document.getElementById("root")!).render( <BrowserRouter> <GridOverlay /> <SupervisorQuestionNotification /> + <PhaseConfirmationNotification /> <Routes> <Route path="/" element={<HomePage />} /> <Route path="/login" element={<LoginPage />} /> |
