diff options
| author | soryu <soryu@soryu.co> | 2026-01-20 23:20:32 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-20 23:20:32 +0000 |
| commit | 7155e6cd7ddf25a5a4d4f6d6abecd49f0cf519dc (patch) | |
| tree | 2475a951c4bcba685b010909bf4abd5351cb3623 | |
| parent | 055e2c4a72e3b2331a18fdc9f8132ef990af7e38 (diff) | |
| download | soryu-7155e6cd7ddf25a5a4d4f6d6abecd49f0cf519dc.tar.gz soryu-7155e6cd7ddf25a5a4d4f6d6abecd49f0cf519dc.zip | |
Add non-blocking persistent contract completion questions (#14)
* [WIP] Heartbeat checkpoint - 2026-01-20 22:40:37 UTC
* Task completion checkpoint
| -rw-r--r-- | makima/frontend/src/components/SupervisorQuestionNotification.tsx | 9 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/ContractCompleteQuestion.tsx | 165 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 4 | ||||
| -rw-r--r-- | makima/frontend/src/routes/mesh.tsx | 63 | ||||
| -rw-r--r-- | makima/src/bin/makima.rs | 2 | ||||
| -rw-r--r-- | makima/src/daemon/api/supervisor.rs | 10 | ||||
| -rw-r--r-- | makima/src/daemon/cli/supervisor.rs | 8 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh_supervisor.rs | 28 | ||||
| -rw-r--r-- | makima/src/server/state.rs | 5 |
9 files changed, 269 insertions, 25 deletions
diff --git a/makima/frontend/src/components/SupervisorQuestionNotification.tsx b/makima/frontend/src/components/SupervisorQuestionNotification.tsx index 1457d86..b1cbacc 100644 --- a/makima/frontend/src/components/SupervisorQuestionNotification.tsx +++ b/makima/frontend/src/components/SupervisorQuestionNotification.tsx @@ -5,7 +5,12 @@ export function SupervisorQuestionNotification() { const navigate = useNavigate(); const { notificationQuestions, dismissNotification } = useSupervisorQuestions(); - if (notificationQuestions.length === 0) { + // Filter out contract_complete questions - they are displayed on the task page instead + const filteredQuestions = notificationQuestions.filter( + (q) => q.questionType !== "contract_complete" + ); + + if (filteredQuestions.length === 0) { return null; } @@ -16,7 +21,7 @@ export function SupervisorQuestionNotification() { return ( <div className="fixed bottom-4 right-4 z-50 max-w-md space-y-2"> - {notificationQuestions.map((question) => ( + {filteredQuestions.map((question) => ( <div key={question.questionId} className="bg-[#0d1b2d] border border-amber-500/50 rounded-lg shadow-lg overflow-hidden" diff --git a/makima/frontend/src/components/mesh/ContractCompleteQuestion.tsx b/makima/frontend/src/components/mesh/ContractCompleteQuestion.tsx new file mode 100644 index 0000000..d4ef618 --- /dev/null +++ b/makima/frontend/src/components/mesh/ContractCompleteQuestion.tsx @@ -0,0 +1,165 @@ +import { useState } from "react"; +import type { PendingQuestion } from "../../lib/api"; + +interface ContractCompleteQuestionProps { + question: PendingQuestion; + onAnswer: (questionId: string, response: string) => Promise<void>; +} + +/** + * Component for displaying contract_complete type questions prominently on the task page. + * These questions persist until answered and are not shown as floating notifications. + */ +export function ContractCompleteQuestion({ + question, + onAnswer, +}: ContractCompleteQuestionProps) { + const [submitting, setSubmitting] = useState(false); + const [minimized, setMinimized] = useState(false); + const [customInput, setCustomInput] = useState(""); + const [showCustom, setShowCustom] = useState(false); + + const handleAnswer = async (response: string) => { + if (submitting) return; + setSubmitting(true); + try { + await onAnswer(question.questionId, response); + } finally { + setSubmitting(false); + } + }; + + const handleCustomSubmit = async () => { + if (!customInput.trim() || submitting) return; + await handleAnswer(customInput.trim()); + setCustomInput(""); + setShowCustom(false); + }; + + // Default choices for contract completion questions + const defaultChoices = + question.choices.length > 0 + ? question.choices + : ["Yes, contract is complete", "No, more work needed"]; + + if (minimized) { + return ( + <div className="fixed bottom-4 left-4 z-40"> + <button + onClick={() => setMinimized(false)} + className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-500 text-white font-mono text-sm rounded-lg shadow-lg transition-colors" + > + <span className="w-2 h-2 bg-white rounded-full animate-pulse" /> + Contract Review Pending + </button> + </div> + ); + } + + return ( + <div className="bg-gradient-to-r from-green-900/40 to-emerald-900/40 border-2 border-green-500/60 rounded-lg shadow-xl my-4 overflow-hidden"> + {/* Header */} + <div className="flex items-center justify-between px-4 py-3 bg-green-900/50 border-b border-green-500/30"> + <div className="flex items-center gap-3"> + <div className="w-8 h-8 flex items-center justify-center bg-green-500/20 rounded-full"> + <span className="text-green-400 text-xl">?</span> + </div> + <div> + <h3 className="font-mono text-sm text-green-300 uppercase tracking-wide"> + Contract Completion Review + </h3> + <p className="text-xs text-green-400/60"> + Please review and respond + </p> + </div> + </div> + <button + onClick={() => setMinimized(true)} + className="px-2 py-1 text-xs font-mono text-green-400/70 hover:text-green-300 border border-green-500/30 hover:border-green-400/50 rounded transition-colors" + title="Minimize (question will remain pending)" + > + Minimize + </button> + </div> + + {/* Content */} + <div className="p-4 space-y-4"> + {/* Context */} + {question.context && ( + <div className="text-xs text-green-300/70 font-mono uppercase tracking-wide"> + {question.context} + </div> + )} + + {/* Question */} + <div className="text-green-100 font-mono text-base leading-relaxed"> + {question.question} + </div> + + {/* Choices */} + <div className="flex flex-wrap gap-3 pt-2"> + {defaultChoices.map((choice, idx) => ( + <button + key={idx} + onClick={() => handleAnswer(choice)} + disabled={submitting} + className={`px-4 py-2.5 font-mono text-sm border rounded-md transition-all disabled:opacity-50 disabled:cursor-not-allowed ${ + idx === 0 + ? "bg-green-500/20 border-green-400/60 hover:bg-green-500/30 text-green-100 hover:border-green-400" + : "bg-amber-500/20 border-amber-400/60 hover:bg-amber-500/30 text-amber-100 hover:border-amber-400" + }`} + > + {submitting ? "..." : choice} + </button> + ))} + </div> + + {/* Custom input option */} + {!showCustom ? ( + <button + onClick={() => setShowCustom(true)} + className="text-xs text-green-400/70 hover:text-green-300 font-mono transition-colors" + > + + Provide custom response + </button> + ) : ( + <div className="flex gap-2 pt-2"> + <input + type="text" + value={customInput} + onChange={(e) => setCustomInput(e.target.value)} + placeholder="Type your response..." + disabled={submitting} + className="flex-1 px-3 py-2 bg-[#0a1525] border border-green-500/30 text-green-100 text-sm font-mono rounded focus:outline-none focus:border-green-400" + onKeyDown={(e) => { + if (e.key === "Enter" && customInput.trim()) { + handleCustomSubmit(); + } + if (e.key === "Escape") { + setShowCustom(false); + setCustomInput(""); + } + }} + /> + <button + onClick={handleCustomSubmit} + disabled={submitting || !customInput.trim()} + className="px-4 py-2 bg-green-500 text-black text-sm font-medium rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors hover:bg-green-400" + > + {submitting ? "..." : "Submit"} + </button> + <button + onClick={() => { + setShowCustom(false); + setCustomInput(""); + }} + className="px-2 py-2 text-green-400/70 hover:text-green-300 text-sm font-mono" + > + Cancel + </button> + </div> + )} + </div> + </div> + ); +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 78e52cd..14ec9f2 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -1948,8 +1948,8 @@ export interface PendingQuestion { createdAt: string; /** Whether multiple choices can be selected */ multiSelect?: boolean; - /** Question type - "general" for regular questions, "phase_confirmation" for phase transitions */ - questionType?: "general" | "phase_confirmation"; + /** Question type - "general" for regular questions, "phase_confirmation" for phase transitions, "contract_complete" for contract completion */ + questionType?: "general" | "phase_confirmation" | "contract_complete"; /** Phase confirmation specific data (when questionType is "phase_confirmation") */ phaseConfirmation?: { currentPhase: ContractPhase; diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx index a8d3574..142cc54 100644 --- a/makima/frontend/src/routes/mesh.tsx +++ b/makima/frontend/src/routes/mesh.tsx @@ -5,6 +5,7 @@ import { TaskList } from "../components/mesh/TaskList"; import { TaskDetail } from "../components/mesh/TaskDetail"; import { TaskOutput } from "../components/mesh/TaskOutput"; import { UnifiedMeshChatInput } from "../components/mesh/UnifiedMeshChatInput"; +import { ContractCompleteQuestion } from "../components/mesh/ContractCompleteQuestion"; import { useTasks } from "../hooks/useTasks"; import { useTaskSubscription, type TaskUpdateEvent, type TaskOutputEvent } from "../hooks/useTaskSubscription"; import type { TaskWithSubtasks, MeshChatContext, ContractSummary, ContractWithRelations, DaemonDirectory, TaskSummary } from "../lib/api"; @@ -89,6 +90,14 @@ export default function MeshPage() { [pendingQuestions] ); + // Filter contract_complete questions for the current task + const contractCompleteQuestionsForTask = useMemo( + () => pendingQuestions.filter( + (q) => q.questionType === "contract_complete" && q.taskId === id + ), + [pendingQuestions, id] + ); + // Handler for answering supervisor questions const handleAnswerQuestion = useCallback(async (questionId: string, response: string) => { await submitAnswer(questionId, response); @@ -751,27 +760,41 @@ export default function MeshPage() { {/* Output panel */} {(viewMode === "split" || viewMode === "output") && ( <div - className="panel min-h-0 overflow-hidden flex-1" + className="panel min-h-0 overflow-hidden flex-1 flex flex-col" > - <TaskOutput - entries={taskOutputEntries} - isStreaming={isStreaming || taskDetail.status === "running"} - viewingSubtaskName={viewingSubtaskName} - onClearSubtaskView={viewingSubtaskId ? () => { - setViewingSubtaskId(null); - setViewingSubtaskName(null); - } : undefined} - onClear={() => { - setTaskOutputEntries([]); - if (activeOutputTaskId) { - clearPersistedOutput(activeOutputTaskId); - } - }} - taskId={activeOutputTaskId} - onUserInput={handleUserInput} - pendingQuestionIds={pendingQuestionIds} - onAnswerQuestion={handleAnswerQuestion} - /> + {/* Contract complete questions - shown prominently at top */} + {contractCompleteQuestionsForTask.length > 0 && ( + <div className="shrink-0 px-3 pt-3"> + {contractCompleteQuestionsForTask.map((question) => ( + <ContractCompleteQuestion + key={question.questionId} + question={question} + onAnswer={handleAnswerQuestion} + /> + ))} + </div> + )} + <div className="flex-1 min-h-0 overflow-hidden"> + <TaskOutput + entries={taskOutputEntries} + isStreaming={isStreaming || taskDetail.status === "running"} + viewingSubtaskName={viewingSubtaskName} + onClearSubtaskView={viewingSubtaskId ? () => { + setViewingSubtaskId(null); + setViewingSubtaskName(null); + } : undefined} + onClear={() => { + setTaskOutputEntries([]); + if (activeOutputTaskId) { + clearPersistedOutput(activeOutputTaskId); + } + }} + taskId={activeOutputTaskId} + onUserInput={handleUserInput} + pendingQuestionIds={pendingQuestionIds} + onAnswerQuestion={handleAnswerQuestion} + /> + </div> </div> )} </div> diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index 37aa045..f91ceef 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -354,7 +354,7 @@ async fn run_supervisor( .map(|c| c.split(',').map(|s| s.trim().to_string()).collect()) .unwrap_or_default(); let result = client - .supervisor_ask(&args.question, choices, args.context, args.timeout, args.phaseguard, args.multi_select) + .supervisor_ask(&args.question, choices, args.context, args.timeout, args.phaseguard, args.multi_select, args.non_blocking, args.question_type) .await?; println!("{}", serde_json::to_string(&result.0)?); } diff --git a/makima/src/daemon/api/supervisor.rs b/makima/src/daemon/api/supervisor.rs index bd3aefd..74c27e0 100644 --- a/makima/src/daemon/api/supervisor.rs +++ b/makima/src/daemon/api/supervisor.rs @@ -76,6 +76,12 @@ pub struct AskQuestionRequest { /// When true, allow selecting multiple choices (response will be comma-separated) #[serde(default)] pub multi_select: bool, + /// When true, return immediately without waiting for response + #[serde(default)] + pub non_blocking: bool, + /// Question type: general, phase_confirmation, or contract_complete + #[serde(default)] + pub question_type: String, } // Generic response type for JSON output @@ -209,6 +215,8 @@ impl ApiClient { timeout_seconds: i32, phaseguard: bool, multi_select: bool, + non_blocking: bool, + question_type: String, ) -> Result<JsonValue, ApiError> { let req = AskQuestionRequest { question: question.to_string(), @@ -217,6 +225,8 @@ impl ApiClient { timeout_seconds, phaseguard, multi_select, + non_blocking, + question_type, }; self.post("/api/v1/mesh/supervisor/questions", &req).await } diff --git a/makima/src/daemon/cli/supervisor.rs b/makima/src/daemon/cli/supervisor.rs index 798a55f..7e17135 100644 --- a/makima/src/daemon/cli/supervisor.rs +++ b/makima/src/daemon/cli/supervisor.rs @@ -188,6 +188,14 @@ pub struct AskArgs { /// Allow selecting multiple choices (response will be comma-separated) #[arg(long, default_value = "false")] pub multi_select: bool, + + /// Non-blocking mode - returns immediately without waiting for response + #[arg(long, default_value = "false")] + pub non_blocking: bool, + + /// Question type (general, phase_confirmation, contract_complete) + #[arg(long, default_value = "general")] + pub question_type: String, } /// Arguments for status command (get contract status including phase). diff --git a/makima/src/server/handlers/mesh_supervisor.rs b/makima/src/server/handlers/mesh_supervisor.rs index df8f77c..e5d33c7 100644 --- a/makima/src/server/handlers/mesh_supervisor.rs +++ b/makima/src/server/handlers/mesh_supervisor.rs @@ -76,6 +76,16 @@ pub struct AskQuestionRequest { /// When true, allow selecting multiple choices (response will be comma-separated) #[serde(default)] pub multi_select: bool, + /// When true, return immediately without waiting for response + #[serde(default)] + pub non_blocking: bool, + /// Question type: general, phase_confirmation, or contract_complete + #[serde(default = "default_question_type")] + pub question_type: String, +} + +fn default_question_type() -> String { + "general".to_string() } fn default_question_timeout() -> i32 { @@ -124,6 +134,9 @@ pub struct PendingQuestionSummary { /// Whether multiple choices can be selected #[serde(default)] pub multi_select: bool, + /// Question type: general, phase_confirmation, or contract_complete + #[serde(default)] + pub question_type: String, } /// Request to create a checkpoint. @@ -1532,6 +1545,7 @@ pub async fn ask_question( request.choices.clone(), request.context.clone(), request.multi_select, + request.question_type.clone(), ); // Broadcast question as task output entry for the task's chat @@ -1540,6 +1554,7 @@ pub async fn ask_question( "choices": request.choices, "context": request.context, "multi_select": request.multi_select, + "question_type": request.question_type, }); state.broadcast_task_output(TaskOutputNotification { task_id: supervisor_id, @@ -1572,6 +1587,18 @@ pub async fn ask_question( ).await; } + // If non_blocking mode is enabled, return immediately with the question_id + if request.non_blocking { + return ( + StatusCode::OK, + Json(AskQuestionResponse { + question_id, + response: None, + timed_out: false, + }), + ).into_response(); + } + // Poll for response with timeout let timeout_duration = std::time::Duration::from_secs(request.timeout_seconds.max(1) as u64); let start = std::time::Instant::now(); @@ -1644,6 +1671,7 @@ pub async fn list_pending_questions( context: q.context, created_at: q.created_at, multi_select: q.multi_select, + question_type: q.question_type, }) .collect(); diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs index c5736af..38aadf5 100644 --- a/makima/src/server/state.rs +++ b/makima/src/server/state.rs @@ -146,6 +146,8 @@ pub struct PendingSupervisorQuestion { pub created_at: chrono::DateTime<chrono::Utc>, /// Whether multiple choices can be selected pub multi_select: bool, + /// Question type: general, phase_confirmation, or contract_complete + pub question_type: String, } /// Response to a supervisor question @@ -666,6 +668,7 @@ impl AppState { choices: Vec<String>, context: Option<String>, multi_select: bool, + question_type: String, ) -> Uuid { let question_id = Uuid::new_v4(); let now = chrono::Utc::now(); @@ -683,6 +686,7 @@ impl AppState { context: context.clone(), created_at: now, multi_select, + question_type: question_type.clone(), }, ); @@ -704,6 +708,7 @@ impl AppState { question_id = %question_id, task_id = %task_id, contract_id = %contract_id, + question_type = %question_type, "Supervisor question added" ); |
