diff options
| author | soryu <soryu@soryu.co> | 2026-01-18 18:55:04 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-18 18:55:04 +0000 |
| commit | 9dbc2c3199047609a9f8496fec07ecdb10aee73d (patch) | |
| tree | c5a4e19b2a83cac1901e81f2ff1bc7a465f8a9ff | |
| parent | 273da072fa0573c935798dc723ed79fd71ab037a (diff) | |
| download | soryu-9dbc2c3199047609a9f8496fec07ecdb10aee73d.tar.gz soryu-9dbc2c3199047609a9f8496fec07ecdb10aee73d.zip | |
Add pushed heartbeats and multi-question select
| -rw-r--r-- | makima/frontend/src/components/mesh/TaskOutput.tsx | 76 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 2 | ||||
| -rw-r--r-- | makima/frontend/tsconfig.tsbuildinfo | 2 | ||||
| -rw-r--r-- | makima/src/bin/makima.rs | 2 | ||||
| -rw-r--r-- | makima/src/daemon/api/supervisor.rs | 5 | ||||
| -rw-r--r-- | makima/src/daemon/cli/supervisor.rs | 4 | ||||
| -rw-r--r-- | makima/src/daemon/task/manager.rs | 34 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh_supervisor.rs | 9 | ||||
| -rw-r--r-- | makima/src/server/state.rs | 9 |
9 files changed, 125 insertions, 18 deletions
diff --git a/makima/frontend/src/components/mesh/TaskOutput.tsx b/makima/frontend/src/components/mesh/TaskOutput.tsx index 7140c8a..c98b174 100644 --- a/makima/frontend/src/components/mesh/TaskOutput.tsx +++ b/makima/frontend/src/components/mesh/TaskOutput.tsx @@ -343,18 +343,48 @@ function SupervisorQuestionEntry({ const questionId = entry.toolInput?.question_id as string; const choices = (entry.toolInput?.choices as string[]) || []; const context = entry.toolInput?.context as string | null; + const multiSelect = (entry.toolInput?.multi_select as boolean) ?? false; const [customInput, setCustomInput] = useState(""); const [showOther, setShowOther] = useState(false); const [submitting, setSubmitting] = useState(false); + const [selectedChoices, setSelectedChoices] = useState<Set<string>>(new Set()); const isPending = pendingQuestionIds?.has(questionId) ?? false; const handleChoiceSelect = async (choice: string) => { if (!onAnswerQuestion || submitting) return; + + if (multiSelect) { + // Toggle selection for multi-select mode + setSelectedChoices(prev => { + const newSet = new Set(prev); + if (newSet.has(choice)) { + newSet.delete(choice); + } else { + newSet.add(choice); + } + return newSet; + }); + } else { + // Single select - submit immediately + setSubmitting(true); + try { + await onAnswerQuestion(questionId, choice); + } finally { + setSubmitting(false); + } + } + }; + + const handleMultiSelectSubmit = async () => { + if (!onAnswerQuestion || submitting || selectedChoices.size === 0) return; setSubmitting(true); try { - await onAnswerQuestion(questionId, choice); + // Join selected choices with comma + const response = Array.from(selectedChoices).join(", "); + await onAnswerQuestion(questionId, response); + setSelectedChoices(new Set()); } finally { setSubmitting(false); } @@ -376,6 +406,9 @@ function SupervisorQuestionEntry({ <div className="flex items-center gap-2 text-amber-400 font-semibold mb-2"> <span>?</span> <span>Question</span> + {multiSelect && isPending && ( + <span className="text-amber-300 text-xs font-normal">(select multiple)</span> + )} {!isPending && ( <span className="text-green-400 text-xs font-normal">(Answered)</span> )} @@ -391,19 +424,40 @@ function SupervisorQuestionEntry({ <div className="space-y-2"> {choices.length > 0 && ( <div className="flex flex-wrap gap-2"> - {choices.map((choice, idx) => ( - <button - key={idx} - onClick={() => handleChoiceSelect(choice)} - disabled={submitting} - className="px-3 py-1.5 text-sm font-mono bg-amber-500/20 border border-amber-500/50 hover:bg-amber-500/30 disabled:opacity-50 text-amber-100 transition-colors" - > - {choice} - </button> - ))} + {choices.map((choice, idx) => { + const isSelected = selectedChoices.has(choice); + return ( + <button + key={idx} + onClick={() => handleChoiceSelect(choice)} + disabled={submitting} + className={`px-3 py-1.5 text-sm font-mono border transition-colors disabled:opacity-50 ${ + multiSelect && isSelected + ? "bg-amber-500/50 border-amber-400 text-amber-50" + : "bg-amber-500/20 border-amber-500/50 hover:bg-amber-500/30 text-amber-100" + }`} + > + {multiSelect && ( + <span className="mr-1.5">{isSelected ? "✓" : "○"}</span> + )} + {choice} + </button> + ); + })} </div> )} + {/* Submit button for multi-select mode */} + {multiSelect && selectedChoices.size > 0 && ( + <button + onClick={handleMultiSelectSubmit} + disabled={submitting} + className="px-4 py-1.5 bg-amber-500 text-black text-sm font-medium rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors hover:bg-amber-400" + > + {submitting ? "Submitting..." : `Submit (${selectedChoices.size} selected)`} + </button> + )} + {/* Other option */} {!showOther ? ( <button diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 4652347..abf72b8 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -1948,6 +1948,8 @@ export interface PendingQuestion { choices: string[]; context: string | null; 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"; /** Phase confirmation specific data (when questionType is "phase_confirmation") */ diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo index a3ef773..b8f6c2b 100644 --- a/makima/frontend/tsconfig.tsbuildinfo +++ b/makima/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/autopilotpanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/workflow/phasecolumn.tsx","./src/components/workflow/workflowboard.tsx","./src/components/workflow/workflowcontractcard.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usecontracts.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contracts.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/workflow.tsx","./src/types/messages.ts"],"version":"5.9.3"}
\ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/autopilotpanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/workflow/phasecolumn.tsx","./src/components/workflow/workflowboard.tsx","./src/components/workflow/workflowcontractcard.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usecontracts.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contracts.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/workflow.tsx","./src/types/messages.ts"],"version":"5.9.3"}
\ No newline at end of file diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index 45dd5b7..6ed1761 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -350,7 +350,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) + .supervisor_ask(&args.question, choices, args.context, args.timeout, args.phaseguard, args.multi_select) .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 adeda22..1dc699e 100644 --- a/makima/src/daemon/api/supervisor.rs +++ b/makima/src/daemon/api/supervisor.rs @@ -73,6 +73,9 @@ pub struct AskQuestionRequest { pub timeout_seconds: i32, /// When true, the request will block indefinitely until user responds (no timeout) pub phaseguard: bool, + /// When true, allow selecting multiple choices (response will be comma-separated) + #[serde(default)] + pub multi_select: bool, } // Generic response type for JSON output @@ -205,6 +208,7 @@ impl ApiClient { context: Option<String>, timeout_seconds: i32, phaseguard: bool, + multi_select: bool, ) -> Result<JsonValue, ApiError> { let req = AskQuestionRequest { question: question.to_string(), @@ -212,6 +216,7 @@ impl ApiClient { context, timeout_seconds, phaseguard, + multi_select, }; 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 0e2da32..ae1a126 100644 --- a/makima/src/daemon/cli/supervisor.rs +++ b/makima/src/daemon/cli/supervisor.rs @@ -184,6 +184,10 @@ pub struct AskArgs { /// Block indefinitely until user responds (no timeout) #[arg(long, default_value = "false")] pub phaseguard: bool, + + /// Allow selecting multiple choices (response will be comma-separated) + #[arg(long, default_value = "false")] + pub multi_select: bool, } /// Arguments for status command (get contract status including phase). diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs index c3ccfa4..029d026 100644 --- a/makima/src/daemon/task/manager.rs +++ b/makima/src/daemon/task/manager.rs @@ -3665,10 +3665,11 @@ impl TaskManagerInner { _ = heartbeat_interval.tick(), if heartbeat_enabled => { // Create periodic heartbeat commit to preserve work-in-progress match self.create_heartbeat_commit(task_id, &working_dir).await { - Ok(sha) => { + Ok((sha, pushed)) => { + let status = if pushed { "pushed" } else { "local only" }; let msg = DaemonMessage::task_output( task_id, - format!("[Heartbeat] Created WIP checkpoint: {}\n", &sha[..8]), + format!("[Heartbeat] WIP checkpoint {} ({})\n", &sha[..8], status), false, ); let _ = ws_tx.send(msg).await; @@ -4151,12 +4152,12 @@ impl TaskManagerInner { } /// Create a heartbeat commit with all uncommitted changes (WIP checkpoint). - /// Returns the commit SHA on success, or an error message if nothing to commit. + /// Returns (commit SHA, push succeeded) on success, or an error message if nothing to commit. async fn create_heartbeat_commit( &self, task_id: Uuid, worktree_path: &std::path::Path, - ) -> Result<String, String> { + ) -> Result<(String, bool), String> { // 1. Check for uncommitted changes using git status --porcelain let status_output = tokio::process::Command::new("git") .current_dir(worktree_path) @@ -4220,7 +4221,30 @@ impl TaskManagerInner { let sha = String::from_utf8_lossy(&sha_output.stdout).trim().to_string(); tracing::info!(task_id = %task_id, sha = %sha, "Created heartbeat commit"); - Ok(sha) + // 5. Push to remote (best effort - don't fail if push fails) + let push_output = tokio::process::Command::new("git") + .current_dir(worktree_path) + .args(["push"]) + .output() + .await; + + let pushed = match push_output { + Ok(output) if output.status.success() => { + tracing::info!(task_id = %task_id, sha = %sha, "Pushed heartbeat commit to remote"); + true + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!(task_id = %task_id, sha = %sha, error = %stderr, "Failed to push heartbeat commit (commit saved locally)"); + false + } + Err(e) => { + tracing::warn!(task_id = %task_id, sha = %sha, error = %e, "Failed to run git push (commit saved locally)"); + false + } + }; + + Ok((sha, pushed)) } } diff --git a/makima/src/server/handlers/mesh_supervisor.rs b/makima/src/server/handlers/mesh_supervisor.rs index 8c7ecb5..a8af3fb 100644 --- a/makima/src/server/handlers/mesh_supervisor.rs +++ b/makima/src/server/handlers/mesh_supervisor.rs @@ -73,6 +73,9 @@ pub struct AskQuestionRequest { /// When true, the request will block indefinitely until user responds (no timeout) #[serde(default)] pub phaseguard: bool, + /// When true, allow selecting multiple choices (response will be comma-separated) + #[serde(default)] + pub multi_select: bool, } fn default_question_timeout() -> i32 { @@ -118,6 +121,9 @@ pub struct PendingQuestionSummary { pub choices: Vec<String>, pub context: Option<String>, pub created_at: chrono::DateTime<chrono::Utc>, + /// Whether multiple choices can be selected + #[serde(default)] + pub multi_select: bool, } /// Request to create a checkpoint. @@ -1521,6 +1527,7 @@ pub async fn ask_question( request.question.clone(), request.choices.clone(), request.context.clone(), + request.multi_select, ); // Broadcast question as task output entry for the task's chat @@ -1528,6 +1535,7 @@ pub async fn ask_question( "question_id": question_id.to_string(), "choices": request.choices, "context": request.context, + "multi_select": request.multi_select, }); state.broadcast_task_output(TaskOutputNotification { task_id: supervisor_id, @@ -1631,6 +1639,7 @@ pub async fn list_pending_questions( choices: q.choices, context: q.context, created_at: q.created_at, + multi_select: q.multi_select, }) .collect(); diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs index d022834..86f38c8 100644 --- a/makima/src/server/state.rs +++ b/makima/src/server/state.rs @@ -128,6 +128,9 @@ pub struct SupervisorQuestionNotification { pub pending: bool, /// When the question was asked pub created_at: chrono::DateTime<chrono::Utc>, + /// Whether multiple choices can be selected + #[serde(default)] + pub multi_select: bool, } /// Stored supervisor question for persistence @@ -141,6 +144,8 @@ pub struct PendingSupervisorQuestion { pub choices: Vec<String>, pub context: Option<String>, pub created_at: chrono::DateTime<chrono::Utc>, + /// Whether multiple choices can be selected + pub multi_select: bool, } /// Response to a supervisor question @@ -654,6 +659,7 @@ impl AppState { question: String, choices: Vec<String>, context: Option<String>, + multi_select: bool, ) -> Uuid { let question_id = Uuid::new_v4(); let now = chrono::Utc::now(); @@ -670,6 +676,7 @@ impl AppState { choices: choices.clone(), context: context.clone(), created_at: now, + multi_select, }, ); @@ -684,6 +691,7 @@ impl AppState { context, pending: true, created_at: now, + multi_select, }); tracing::info!( @@ -742,6 +750,7 @@ impl AppState { context: question.1.context, pending: false, created_at: question.1.created_at, + multi_select: question.1.multi_select, }); tracing::info!( |
