summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-18 18:55:04 +0000
committersoryu <soryu@soryu.co>2026-01-18 18:55:04 +0000
commit9dbc2c3199047609a9f8496fec07ecdb10aee73d (patch)
treec5a4e19b2a83cac1901e81f2ff1bc7a465f8a9ff
parent273da072fa0573c935798dc723ed79fd71ab037a (diff)
downloadsoryu-9dbc2c3199047609a9f8496fec07ecdb10aee73d.tar.gz
soryu-9dbc2c3199047609a9f8496fec07ecdb10aee73d.zip
Add pushed heartbeats and multi-question select
-rw-r--r--makima/frontend/src/components/mesh/TaskOutput.tsx76
-rw-r--r--makima/frontend/src/lib/api.ts2
-rw-r--r--makima/frontend/tsconfig.tsbuildinfo2
-rw-r--r--makima/src/bin/makima.rs2
-rw-r--r--makima/src/daemon/api/supervisor.rs5
-rw-r--r--makima/src/daemon/cli/supervisor.rs4
-rw-r--r--makima/src/daemon/task/manager.rs34
-rw-r--r--makima/src/server/handlers/mesh_supervisor.rs9
-rw-r--r--makima/src/server/state.rs9
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!(