diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c0158f4..30aa0ff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.46] - 2026-09-14 + +### Added + +- **Agent orchestration v1** lets a lead agent coordinate up to four workers in the same checkout. Choose worker providers and models, review and edit the proposed assignments before starting, and let the lead manage dependencies, review results, request corrections, and redirect supported workers mid-turn. Overlapping write scopes are queued, and worker approvals and questions are routed through the lead. In #228. +- Orchestration workers are grouped under their lead in the sidebar, with status, model, and expandable details. **View agents** opens their transcripts beside the lead. Run history survives restarts, interrupted work pauses for review, and stopping the lead stops its workers too. +- Completed agent turns show provider-reported token usage, cache metrics, and output rate in a hover or keyboard-focus preview, with metrics retained in saved conversations. +- Web links in user messages show compact page-title and favicon previews while preserving the surrounding text. Preview requests validate public destinations and keep fetching outside the webview. +- Settings → Appearance includes a **Dark-mode lightness** slider and remembers its value independently of light mode. +- Chat backgrounds have separate opacity controls for empty and active sessions, including project-specific overrides. In #191 by @shxntanu. +- GitHub pull-request reviews in the Inbox can switch between changed hunks and full-file context. In #189 by @UtkarshRahim. +- **Close All Tabs** is available from the menu and Command/Ctrl+Shift+W. It closes the active tab's editor files first, then closes the workspace tabs on a subsequent invocation, retaining a blank session and confirming unsaved files and running terminals. In #215 by @kartava. +- Notes can be moved between projects without changing their order or losing edits during navigation. In #187 by @ognjeeen. + +### Changed + +- Closed conversations are prefetched on hover or press and retained in a bounded cache for faster reopening, with safeguards against stale loads and duplicate tabs. +- Inbox cards mount progressively to keep large lists responsive. In #209 by @notsapinho. +- Reordering tabs and projects uses shared motion settings, with smoother scrolling and consecutive drag gestures. In #206 by @ognjeeen. +- Sidebar session cards place linked work-item and archive controls together in the footer, show a single provider icon, and retain the default cursor on reorderable items. +- Linked work-item update notices now sit inside their session pane, make the agent action more prominent, and offer clearer open and dismiss controls. + ### Fixed - OpenCode falls back to readable local paths for unsupported attachment formats instead of sending provider-rejected file parts, and repairs sessions already stuck on an unsupported file turn. Fixes #211. +- **Supervised** access explicitly sets Claude Code's permission mode, preventing local default settings from silently switching the session to automatic approvals or bypassed permissions. In #203 by @prkl78. +- OMP advisor interjections no longer fold away complete answers or break assistant streams. Saved conversations recover interjection boundaries and status-split continuations, while long interjections collapse by default. In #156 by @elijah7x. +- Continuing a conversation dismisses its due reminder and linked-update notices while preserving future reminders. Linked-activity sounds no longer repeat when the same update remounts. +- Opening the sidebar project picker focuses its search field reliably. In #226 by @actuallyakshat. +- Background GitHub CLI and related helper commands no longer flash console windows on Windows. In #221 by @korefs. +- Nested transcript scrollers keep receiving wheel gestures when the outer transcript reaches an edge, including gestures over SVG icons and containers that scroll on only one axis. +- Orchestration cleanup releases checkout reservations when a window closes and removes stale run and worker ownership records when conversations are deleted. Worker tabs wait for their lead to open, sidebar controls remain independently keyboard accessible, and completed or stopped runs release the Undo lock. ## [0.1.45] - 2026-09-13 @@ -35,10 +64,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Claude Sonnet 5 appears only with Claude Code 2.1.197 or newer, preventing older CLI versions from receiving an unsupported model argument. In #199 by @nulljosh. - Claude model choices resolve consistently between the CLI's short live aliases and MonoCode's full startup model IDs, so relaunching no longer switches a saved session to a different model family. -### Fixed - -- **Supervised** access now reaches Claude Code as a permission flag. It was the one mode MonoCode sent nothing for, so the CLI fell back to `permissions.defaultMode` from your settings files, and a session the picker labelled Supervised could run as `auto` or `bypassPermissions` without asking. - ## [0.1.44] - 2026-09-12 ### Added @@ -747,7 +772,8 @@ First public release. macOS (Apple Silicon) only. - Updater endpoint and minisign public key are injected at release time rather than committed, so forks do not inherit the maintainer's update channel. - macOS release builds sign with `APPLE_SIGNING_IDENTITY` via a config overlay; the committed default remains ad-hoc `-` for community builds. -[Unreleased]: https://github.com/hardbeat920/monocode/compare/v0.1.45...HEAD +[Unreleased]: https://github.com/hardbeat920/monocode/compare/v0.1.46...HEAD +[0.1.46]: https://github.com/hardbeat920/monocode/compare/v0.1.45...v0.1.46 [0.1.45]: https://github.com/hardbeat920/monocode/compare/v0.1.44...v0.1.45 [0.1.44]: https://github.com/hardbeat920/monocode/compare/v0.1.43...v0.1.44 [0.1.43]: https://github.com/hardbeat920/monocode/compare/v0.1.42...v0.1.43 diff --git a/Cargo.lock b/Cargo.lock index 00ec6fd3..159dc1da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2214,7 +2214,7 @@ dependencies = [ [[package]] name = "monocode" -version = "0.1.45" +version = "0.1.46" dependencies = [ "base64 0.22.1", "block2", @@ -2238,6 +2238,8 @@ dependencies = [ "tauri-plugin-updater", "tauri-plugin-window-state", "ureq", + "url", + "uuid", "windows-sys 0.61.2", ] diff --git a/Cargo.toml b/Cargo.toml index 02a91329..d981956e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["src-tauri"] exclude = ["vendor/portable-pty"] [workspace.package] -version = "0.1.45" +version = "0.1.46" edition = "2021" license = "MIT" diff --git a/README.md b/README.md index 5f04dfc0..8d3aed86 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,16 @@ Small, focused pull requests are welcome. Anything large is worth an issue first ## Build from source +For the macOS fork, run `npm ci` followed by `npm run build:fork`. The app and +DMG are written under `target/release/bundle/`. This uses the existing +**MonoCode Fork** identity (`com.monocode.fork.worktrees`) and its session data, +with upstream automatic updates disabled. + +When building from a session inside MonoCode Fork, leave the running app in +place. Quit it before replacing `~/Applications/MonoCode Fork.app` with the +new bundle, then reopen it. Build in this checkout's own `target` directory so +other running development builds are unaffected. + Supports macOS, Linux, and Windows. Need Node.js 20+ and a current stable Rust toolchain. On Linux, ensure standard Tauri prerequisites are installed (e.g. `libwebkit2gtk-4.1-dev`, `libgtk-3-dev`, `libsoup-3.0-dev`, `libjavascriptcoregtk-4.1-dev`). On Windows, the installer bootstraps the [WebView2](https://developer.microsoft.com/microsoft-edge/webview2/) runtime when it is missing. diff --git a/index.html b/index.html index 28eb0578..9771ec7e 100644 --- a/index.html +++ b/index.html @@ -11,6 +11,9 @@ var root = document.documentElement; var hue = localStorage.getItem("monocode.themeHue"); var sat = localStorage.getItem("monocode.themeSaturation"); + var darkLightness = localStorage.getItem( + "monocode.themeDarkLightness", + ); var opacity = localStorage.getItem("monocode.sidebarOpacity"); if (hue != null) { var h = Math.max(0, Math.min(360, Number(hue) || 0)); @@ -20,6 +23,10 @@ var s = Math.max(0, Math.min(100, Number(sat) || 0)); root.style.setProperty("--theme-saturation", s + "%"); } + if (darkLightness != null) { + var l = Math.max(0, Math.min(30, Number(darkLightness) || 0)); + root.style.setProperty("--theme-dark-lightness", l + "%"); + } if (opacity != null) { var o = Math.max(0.15, Math.min(1, Number(opacity) || 0.85)); root.style.setProperty("--sidebar-opacity", String(o)); @@ -50,8 +57,9 @@ :root { --theme-hue: 240; --theme-saturation: 0%; + --theme-dark-lightness: 9%; --sidebar-opacity: 0.85; - --background-lightness: 9%; + --background-lightness: var(--theme-dark-lightness); --color-background-base: hsl( var(--theme-hue) var(--theme-saturation) var(--background-lightness) ); diff --git a/package-lock.json b/package-lock.json index 365f4c59..1af001d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "monocode-desktop", - "version": "0.1.45", + "version": "0.1.46", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "monocode-desktop", - "version": "0.1.45", + "version": "0.1.46", "dependencies": { "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.12", diff --git a/package.json b/package.json index 3d463eb2..79a00607 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "name": "monocode-desktop", "private": true, - "version": "0.1.45", + "version": "0.1.46", "type": "module", "scripts": { "dev": "vite", "dev:worktrees": "vite --host 127.0.0.1 --port 1422", "dev:stable": "vite --mode stable", "build": "tsc && vite build", + "build:fork": "tauri build --bundles app,dmg --config src-tauri/tauri.fork.conf.json", "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bd41a10e..cda2a765 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,11 +20,13 @@ tauri = { version = "2", features = ["protocol-asset", "macos-private-api"] } tauri-plugin-opener = "2" serde.workspace = true serde_json.workspace = true +uuid = { version = "1", features = ["v4"] } base64 = "0.22" sha2 = "0.10.9" tauri-plugin-dialog = "2" rusqlite = { version = "0.40.2", features = ["bundled"], default-features = false } ureq = { version = "2.12.1", default-features = false, features = ["tls", "gzip"] } +url = "2.5" tauri-plugin-process = "2" [target.'cfg(unix)'.dependencies] diff --git a/src-tauri/src/control.rs b/src-tauri/src/control.rs new file mode 100644 index 00000000..20542e53 --- /dev/null +++ b/src-tauri/src/control.rs @@ -0,0 +1,552 @@ +//! Authenticated loopback transport. App windows own execution; callers never +//! receive arbitrary Tauri command access or direct database write access. +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Component, Path}; +use std::process::Command; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow}; + +#[derive(Clone)] +struct Grant { + window: String, + session: String, + cwd: String, + token: String, +} +struct Pending { + window: String, + reply: mpsc::Sender, +} +struct ActiveTurn { + window: String, + cwd: String, +} +#[derive(Default)] +struct Inner { + grants: HashMap, + pending: HashMap, + workers: HashMap, + active: HashMap, +} +impl Inner { + fn window_sessions(&self, label: &str) -> Vec { + let leads: Vec = self + .grants + .values() + .filter(|grant| grant.window == label) + .map(|grant| grant.session.clone()) + .collect(); + let mut ids = leads.clone(); + ids.extend( + self.workers + .iter() + .filter(|(_, lead)| leads.contains(lead)) + .map(|(id, _)| id.clone()), + ); + ids.extend( + self.active + .iter() + .filter(|(_, turn)| turn.window == label) + .map(|(id, _)| id.clone()), + ); + ids.sort(); + ids.dedup(); + ids + } + fn close_window(&mut self, label: &str) -> Vec { + let ids = self.window_sessions(label); + self.grants.retain(|id, _| !ids.contains(id)); + self.workers.retain(|id, _| !ids.contains(id)); + self.active.retain(|id, _| !ids.contains(id)); + self.pending.retain(|_, pending| { + if pending.window != label { + return true; + } + let _ = pending + .reply + .send(json!({"ok":false,"error":"MonoCode window closed"})); + false + }); + ids + } +} +pub struct ControlHost { + endpoint: String, + inner: Arc>, +} + +fn paths_overlap(a: &str, b: &str) -> bool { + a == b || a.starts_with(&format!("{b}/")) || b.starts_with(&format!("{a}/")) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Request { + token: String, + action: String, + input: Value, + request_id: String, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct Event { + id: String, + session_id: String, + request_id: String, + action: String, + input: Value, +} + +pub fn init(app: &AppHandle) -> Result<(), String> { + let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?; + let endpoint = listener + .local_addr() + .map_err(|e| e.to_string())? + .to_string(); + let inner = Arc::new(Mutex::new(Inner::default())); + app.manage(ControlHost { + endpoint, + inner: inner.clone(), + }); + let app = app.clone(); + std::thread::spawn(move || { + // Limit concurrent readers, including unauthenticated sockets. + let (tx, rx) = mpsc::sync_channel::(32); + let rx = Arc::new(Mutex::new(rx)); + for _ in 0..8 { + let rx = rx.clone(); + let app = app.clone(); + let inner = inner.clone(); + std::thread::spawn(move || loop { + let stream = match rx.lock() { + Ok(rx) => rx.recv(), + Err(_) => return, + }; + let Ok(stream) = stream else { return }; + serve(stream, &app, &inner); + }); + } + for stream in listener.incoming().flatten() { + let _ = tx.try_send(stream); + } + }); + Ok(()) +} + +fn serve(mut stream: TcpStream, app: &AppHandle, inner: &Arc>) { + let _ = stream.set_read_timeout(Some(Duration::from_secs(3))); + let _ = stream.set_write_timeout(Some(Duration::from_secs(3))); + let result = (|| -> Result { + let mut raw = String::new(); + BufReader::new(&mut stream) + .take(262_145) + .read_line(&mut raw) + .map_err(|e| e.to_string())?; + if raw.len() > 262_144 { + return Err("Request exceeds 256 KiB".into()); + } + let request: Request = serde_json::from_str(&raw).map_err(|_| "Invalid control request")?; + if !request.input.is_object() + || request.request_id.is_empty() + || request.request_id.len() > 128 + { + return Err("Invalid input or request ID".into()); + } + let id = uuid::Uuid::new_v4().to_string(); + let (tx, rx) = mpsc::channel(); + let grant = { + let mut host = inner.lock().map_err(|_| "Control service unavailable")?; + let grant = host + .grants + .values() + .find(|g| g.token == request.token) + .cloned() + .ok_or("Connection revoked or unauthorized")?; + if host.pending.len() >= 24 { + return Err("Too many pending control requests".into()); + } + host.pending.insert( + id.clone(), + Pending { + window: grant.window.clone(), + reply: tx, + }, + ); + grant + }; + let event = Event { + id: id.clone(), + session_id: grant.session, + request_id: request.request_id, + action: request.action, + input: request.input, + }; + let delivered = app.emit_to(grant.window.as_str(), "monocode-control-request", event); + let result = if delivered.is_err() { + Err("MonoCode executor is unavailable".into()) + } else { + rx.recv_timeout(Duration::from_secs(35)) + .map_err(|_| "Control request timed out. Retry with the same request ID.".into()) + }; + if let Ok(mut host) = inner.lock() { + host.pending.remove(&id); + } + result + })(); + let response = result.unwrap_or_else(|error| json!({"ok": false, "error": error})); + let _ = writeln!(stream, "{response}"); +} + +#[tauri::command] +pub fn control_enable( + window: WebviewWindow, + host: State<'_, ControlHost>, + session_id: String, + cwd: String, +) -> Result { + let cwd = std::fs::canonicalize(crate::fs::expand_home(&cwd)).map_err(|e| e.to_string())?; + if !cwd.is_dir() { + return Err("Choose a project folder first".into()); + } + let cwd = cwd.to_string_lossy().replace('\\', "/").to_lowercase(); + let mut inner = host + .inner + .lock() + .map_err(|_| "Control service unavailable")?; + if inner + .active + .iter() + .any(|(id, turn)| id != &session_id && paths_overlap(&turn.cwd, &cwd)) + { + return Err( + "Another session is running in this checkout. Stop it before enabling orchestration." + .into(), + ); + } + if inner.grants.values().any(|g| { + paths_overlap(&g.cwd, &cwd) && (g.session != session_id || g.window != window.label()) + }) { + return Err("This checkout already has an orchestrator in another session".into()); + } + inner.grants.insert( + session_id.clone(), + Grant { + window: window.label().into(), + session: session_id, + cwd, + token: format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ), + }, + ); + let executable = std::env::current_exe().map_err(|e| e.to_string())?; + Ok(executable.to_string_lossy().into_owned()) +} + +#[tauri::command] +pub fn control_disable( + window: WebviewWindow, + host: State<'_, ControlHost>, + session_id: String, +) -> Result<(), String> { + let mut inner = host + .inner + .lock() + .map_err(|_| "Control service unavailable")?; + if inner + .grants + .get(&session_id) + .is_some_and(|g| g.window == window.label()) + { + inner.grants.remove(&session_id); + inner.workers.retain(|_, parent| parent != &session_id); + } + Ok(()) +} + +#[tauri::command] +pub fn control_attach_worker( + window: WebviewWindow, + host: State<'_, ControlHost>, + lead_id: String, + session_id: String, +) -> Result<(), String> { + let mut inner = host + .inner + .lock() + .map_err(|_| "Control service unavailable")?; + if !inner + .grants + .get(&lead_id) + .is_some_and(|grant| grant.window == window.label()) + { + return Err("Lead connection is inactive".into()); + } + inner.workers.insert(session_id, lead_id); + Ok(()) +} + +#[tauri::command] +pub fn control_authorize_turn( + window: WebviewWindow, + host: State<'_, ControlHost>, + session_id: String, + cwd: String, +) -> Result<(), String> { + let cwd = std::fs::canonicalize(crate::fs::expand_home(&cwd)) + .map_err(|e| e.to_string())? + .to_string_lossy() + .replace('\\', "/") + .to_lowercase(); + let mut inner = host + .inner + .lock() + .map_err(|_| "Control service unavailable")?; + if let Some(lead) = inner + .grants + .values() + .find(|grant| paths_overlap(&grant.cwd, &cwd)) + { + if lead.window != window.label() + || (lead.session != session_id && inner.workers.get(&session_id) != Some(&lead.session)) + { + return Err("This checkout is controlled by an orchestrator. Stop that run before starting independent work.".into()); + } + } + inner.active.insert( + session_id, + ActiveTurn { + window: window.label().to_string(), + cwd, + }, + ); + Ok(()) +} + +#[tauri::command] +pub fn control_turn_finished(host: State<'_, ControlHost>, session_id: String) { + if let Ok(mut inner) = host.inner.lock() { + inner.active.remove(&session_id); + } +} + +pub fn window_closed(app: &AppHandle, label: &str) { + let host = app.state::(); + let ids = { + let Ok(inner) = host.inner.lock() else { return }; + inner.window_sessions(label) + }; + for id in &ids { + let _ = crate::harness::harness_kill(app.state(), id.clone()); + } + if let Ok(mut inner) = host.inner.lock() { + inner.close_window(label); + }; +} + +pub fn configure_child(app: &AppHandle, session_id: &str, cmd: &mut Command) { + cmd.env_remove("MONOCODE_CONTROL_ENDPOINT") + .env_remove("MONOCODE_CONTROL_TOKEN"); + let Some(host) = app.try_state::() else { + return; + }; + if let Ok(inner) = host.inner.lock() { + if let Some(grant) = inner.grants.get(session_id) { + cmd.env("MONOCODE_CONTROL_ENDPOINT", &host.endpoint) + .env("MONOCODE_CONTROL_TOKEN", &grant.token); + } + }; +} + +#[tauri::command] +pub fn control_reply( + window: WebviewWindow, + host: State<'_, ControlHost>, + id: String, + response: Value, +) -> Result<(), String> { + let mut inner = host + .inner + .lock() + .map_err(|_| "Control service unavailable")?; + if inner + .pending + .get(&id) + .is_some_and(|p| p.window == window.label()) + { + if let Some(pending) = inner.pending.remove(&id) { + let _ = pending.reply.send(response); + } + } + Ok(()) +} + +#[tauri::command] +pub fn control_save( + store: State<'_, crate::session_store::SessionStore>, + lead_id: String, + state: String, +) -> Result<(), String> { + if state.len() > 8_000_000 { + return Err("Orchestration history is too large".into()); + } + let run: Value = serde_json::from_str(&state).map_err(|_| "Invalid run state")?; + let conn = store.lock_conn()?; + crate::session_store::save_orchestration(&conn, &lead_id, &run).map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub fn control_load( + store: State<'_, crate::session_store::SessionStore>, + lead_id: String, +) -> Result, String> { + use rusqlite::OptionalExtension; + store + .lock_conn()? + .query_row( + "SELECT state FROM orchestration_runs WHERE lead_id=?1", + [lead_id], + |r| r.get(0), + ) + .optional() + .map_err(|e| e.to_string()) +} + +fn resolve_scope(root: &Path, value: &str) -> Result { + let path = Path::new(value); + if value.is_empty() + || path.is_absolute() + || path + .components() + .any(|c| matches!(c, Component::ParentDir | Component::Prefix(_))) + { + return Err("Write scopes must be project-relative paths without '..'".into()); + } + let root = std::fs::canonicalize(root).map_err(|e| e.to_string())?; + let mut existing = root.join(path); + let mut missing = Vec::new(); + while !existing.exists() { + missing.push(existing.file_name().ok_or("Invalid scope")?.to_os_string()); + if !existing.pop() { + return Err("Invalid scope".into()); + } + } + existing = std::fs::canonicalize(existing).map_err(|e| e.to_string())?; + if !existing.starts_with(&root) { + return Err("Write scope points outside the project".into()); + } + for part in missing.into_iter().rev() { + existing.push(part); + } + Ok(existing.to_string_lossy().replace('\\', "/").to_lowercase()) +} + +#[tauri::command] +pub fn control_scopes(cwd: String, files: Vec) -> Result, String> { + if files.len() > 64 { + return Err("At most 64 write scopes per task".into()); + } + files + .iter() + .map(|file| resolve_scope(&crate::fs::expand_home(&cwd), file)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn closing_a_window_releases_ordinary_turns_and_owned_orchestration() { + let mut inner = Inner::default(); + for (id, window) in [ + ("ordinary", "closing"), + ("lead", "closing"), + ("other", "open"), + ] { + inner.active.insert( + id.into(), + ActiveTurn { + window: window.into(), + cwd: format!("/{id}"), + }, + ); + } + for (id, window) in [("lead", "closing"), ("other", "open")] { + inner.grants.insert( + id.into(), + Grant { + window: window.into(), + session: id.into(), + cwd: format!("/{id}"), + token: id.into(), + }, + ); + } + inner.workers.insert("worker".into(), "lead".into()); + inner.workers.insert("other-worker".into(), "other".into()); + let (reply, response) = mpsc::channel(); + inner.pending.insert( + "pending".into(), + Pending { + window: "closing".into(), + reply, + }, + ); + let (reply, other_response) = mpsc::channel(); + inner.pending.insert( + "other-pending".into(), + Pending { + window: "open".into(), + reply, + }, + ); + + assert_eq!( + inner.close_window("closing"), + ["lead", "ordinary", "worker"] + ); + assert_eq!(inner.active.len(), 1); + assert_eq!(inner.active["other"].window, "open"); + assert_eq!(inner.grants.len(), 1); + assert!(inner.grants.contains_key("other")); + assert_eq!(inner.workers.len(), 1); + assert_eq!(inner.workers["other-worker"], "other"); + assert_eq!(response.try_recv().unwrap()["ok"], false); + assert!(other_response.try_recv().is_err()); + assert!(inner.pending.contains_key("other-pending")); + assert!(inner.close_window("closing").is_empty()); + } + + #[test] + fn scopes_reject_escape_and_resolve_new_files() { + let root = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string()); + std::fs::create_dir_all(&root).unwrap(); + assert!(resolve_scope(&root, "../escape").is_err()); + assert!(resolve_scope(&root, "/absolute").is_err()); + assert!(resolve_scope(&root, "src/new.ts") + .unwrap() + .ends_with("/src/new.ts")); + #[cfg(unix)] + { + std::os::unix::fs::symlink(std::env::temp_dir(), root.join("outside")).unwrap(); + assert!(resolve_scope(&root, "outside/file").is_err()); + } + std::fs::remove_dir_all(root).unwrap(); + } + #[test] + fn checkout_reservations_include_nested_folders() { + assert!(paths_overlap("/repo", "/repo/src")); + assert!(paths_overlap("/repo/src", "/repo")); + assert!(!paths_overlap("/repo", "/repo2")); + } +} diff --git a/src-tauri/src/control_cli.rs b/src-tauri/src/control_cli.rs new file mode 100644 index 00000000..3856f5d4 --- /dev/null +++ b/src-tauri/src/control_cli.rs @@ -0,0 +1,373 @@ +//! The desktop executable also provides a small, JSON-only control client. +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +use serde_json::{json, Value}; + +const USAGE: &str = r#"MonoCode local control — supervise this orchestration run from the lead agent. + +Usage: {exe} control ACTION [--json JSON | --input FILE|-] [--request-id ID] + +Actions, with the JSON object each one takes: + list {} + The run, every task with its status and latest result, and the + harness/model IDs you may assign. + delegate {"title":"Short title","harness":"", + "model":"","prompt":"Self-contained instructions", + "files":["src/feature"],"dependsOn":[""]} + Queue a worker and return its taskId. "model" is optional and + defaults to the first model list allows for that harness. + "files" is the write scope: project-relative paths, where a + directory covers its descendants and ["."] reserves the whole + checkout. "dependsOn" holds taskIds that must be reviewed first. + get {"taskId":"..."} + One task, including its latest result. + wait {"timeoutSeconds":20} + Block until a task changes state, or until the timeout (0-25). + Returns at once when nothing is running or queued. + respond {"taskId":"...","requestId":7,"decision":"allow"|"deny"} + Answer an approval an agent is blocked on. Agents never prompt the + user; list, get and wait report the prompt as that task's + "needsInput", and it stays stopped until you decide. + answer {"taskId":"...","requestId":9,"answers":{"":[""]}} + Answer a question an agent asked, or pass "skip":true instead of + "answers". The question and its options come from needsInput. + steer {"taskId":"...","text":"..."} + Redirect an agent that is still running, without discarding the + work it has already done. Use this the moment you see it going + the wrong way; message only lands once it has stopped. + message {"taskId":"...","text":"..."} + Send a completed or failed worker another turn; it keeps its + session, scope and history. + cancel {"taskId":"..."} + Cancel a task, whether it is running or still queued. + review {"taskId":"..."} + Accept a completed task's result. + finish {} + End the run, once every task is accepted or cancelled. + +Usual loop: list -> delegate ... -> wait or get -> steer an agent that drifts, +unblock one with respond or answer -> inspect the changes yourself -> message +for corrections -> review each task -> finish. + +Output is one JSON line: {"ok":true,"result":...} or {"ok":false,"error":"..."}. +The exit code is 0 only when "ok" is true. + +Input must be a JSON object; unknown fields are rejected rather than ignored. +--json takes it inline, --input FILE reads a file, --input - reads stdin. + +Every call carries a request ID, and the run applies each ID at most once. A +failed response reports the ID it used whenever the outcome is unknown — a +timeout, say. Retry that exact call with --request-id ID; retrying a delegate +under a fresh ID instead would queue a second worker. + +Tasks run inside the MonoCode app, not in this process. Exiting this CLI, or a +failure here, never cancels a task that was already accepted. + +MonoCode sets MONOCODE_CONTROL_ENDPOINT and MONOCODE_CONTROL_TOKEN for the lead +agent's process only. They are already in your environment; never print them. +"#; + +const ACTIONS: [&str; 11] = [ + "list", "delegate", "get", "steer", "message", "cancel", "wait", "review", "finish", "respond", + "answer", +]; + +/// Quote for the shell the lead agent actually runs commands in, and only when +/// the path needs it. The path is absolute, so a leading slash means a POSIX +/// shell — where a backslash escapes rather than separates, and so is never +/// safe bare. +fn quoted(value: &str) -> String { + if !value.starts_with('/') { + return if value.contains([' ', '\t', '"']) { + format!("\"{}\"", value.replace('"', "")) + } else { + value.into() + }; + } + if value + .chars() + .all(|c| c.is_ascii_alphanumeric() || "._/-:".contains(c)) + { + value.into() + } else { + format!("'{}'", value.replace('\'', r"'\''")) + } +} + +pub fn help() -> String { + let exe = std::env::current_exe() + .map(|path| quoted(&path.to_string_lossy())) + .unwrap_or_else(|_| "monocode".into()); + USAGE.replace("{exe}", &exe) +} + +enum Parsed { + Help, + Call(String, Value, String), +} + +pub fn run(args: Vec) -> i32 { + let parsed = match parse_args(&args) { + Ok(parsed) => parsed, + Err(error) => { + println!("{}", json!({"ok": false, "error": error})); + return 1; + } + }; + let (action, input, request_id) = match parsed { + Parsed::Help => { + println!("{}", help()); + return 0; + } + Parsed::Call(action, input, request_id) => (action, input, request_id), + }; + match send(&action, &input, &request_id) { + Ok(mut value) => { + if value.get("ok").and_then(Value::as_bool) == Some(true) { + println!("{value}"); + return 0; + } + // MonoCode may have timed out waiting on its own executor, so a + // failure here is not proof the call was rejected either. + if let Some(object) = value.as_object_mut() { + object + .entry("requestId") + .or_insert_with(|| json!(request_id)); + object + .entry("retryWith") + .or_insert_with(|| json!(format!("--request-id {request_id}"))); + } + println!("{value}"); + 1 + } + Err(Failure { error, sent }) => { + // The call may have reached the run even though its answer was + // lost. Hand back the request ID so a retry cannot duplicate it. + let mut response = json!({"ok": false, "error": error}); + if sent { + response["requestId"] = json!(request_id); + response["retryWith"] = json!(format!("--request-id {request_id}")); + } + println!("{response}"); + 1 + } + } +} + +struct Failure { + error: String, + /// The request was already on the wire, so the run may have applied it. + sent: bool, +} +fn unsent(error: impl Into) -> Failure { + Failure { + error: error.into(), + sent: false, + } +} +fn sent(error: impl Into) -> Failure { + Failure { + error: error.into(), + sent: true, + } +} + +fn send(action: &str, input: &Value, request_id: &str) -> Result { + let endpoint = std::env::var("MONOCODE_CONTROL_ENDPOINT").map_err(|_| { + unsent("No MonoCode connection. Confirm the Orchestrator proposal in MonoCode first.") + })?; + let token = std::env::var("MONOCODE_CONTROL_TOKEN") + .map_err(|_| unsent("No MonoCode session credential. Start the lead from MonoCode."))?; + let address: SocketAddr = endpoint + .parse() + .map_err(|_| unsent("Invalid MonoCode endpoint"))?; + if !address.ip().is_loopback() { + return Err(unsent("MonoCode control only connects to localhost")); + } + let mut stream = TcpStream::connect_timeout(&address, Duration::from_secs(3)) + .map_err(|_| unsent("MonoCode is not running or this connection has expired."))?; + stream + .set_read_timeout(Some(Duration::from_secs(40))) + .map_err(|e| unsent(e.to_string()))?; + stream + .set_write_timeout(Some(Duration::from_secs(5))) + .map_err(|e| unsent(e.to_string()))?; + writeln!( + stream, + "{}", + json!({"token":token,"action":action,"input":input,"requestId":request_id}) + ) + .map_err(|e| sent(e.to_string()))?; + let mut line = String::new(); + BufReader::new(stream) + .take(2_000_001) + .read_line(&mut line) + .map_err(|e| sent(format!("No reply from MonoCode: {e}")))?; + if line.len() > 2_000_000 { + return Err(sent("MonoCode response is too large")); + } + serde_json::from_str(&line).map_err(|_| sent("MonoCode returned an invalid response")) +} + +fn read_capped(mut source: impl Read) -> Result { + let mut raw = String::new(); + source + .by_ref() + .take(262_145) + .read_to_string(&mut raw) + .map_err(|e| e.to_string())?; + if raw.len() > 262_144 { + return Err("Input exceeds 256 KiB".into()); + } + Ok(raw) +} + +fn parse_args(args: &[String]) -> Result { + let is_help = |value: &str| matches!(value, "help" | "--help" | "-h"); + let Some(action) = args.first() else { + return Ok(Parsed::Help); + }; + if is_help(action) { + return Ok(Parsed::Help); + } + let action = action.clone(); + if !ACTIONS.contains(&action.as_str()) { + return Err(format!( + "Unknown action: {action}. Use one of: {}. Run control --help.", + ACTIONS.join(", ") + )); + } + let mut input = None; + let mut request_id = uuid::Uuid::new_v4().to_string(); + let mut index = 1; + while index < args.len() { + let flag = &args[index]; + // `control delegate --help` should explain the command, not fail. + if is_help(flag) { + return Ok(Parsed::Help); + } + if !flag.starts_with("--") { + return Err(format!( + "Unexpected argument: {flag}. Pass the JSON object as --json ''." + )); + } + let value = args.get(index + 1).ok_or_else(|| { + format!("Missing value for {flag}. Run control --help for the argument list.") + })?; + match flag.as_str() { + "--request-id" => request_id = value.clone(), + "--json" | "--input" => { + if input.is_some() { + return Err("Supply only one input".into()); + } + let raw = if flag == "--json" { + if value.len() > 262_144 { + return Err("Input exceeds 256 KiB".into()); + } + value.clone() + } else if value == "-" { + read_capped(std::io::stdin())? + } else { + read_capped(std::fs::File::open(value).map_err(|e| e.to_string())?)? + }; + let parsed: Value = serde_json::from_str(&raw) + .map_err(|e| format!("Invalid JSON: {e}. Pass one JSON object, e.g. --json '{{\"taskId\":\"...\"}}'."))?; + if !parsed.is_object() { + return Err("Input must be a JSON object".into()); + } + input = Some(parsed); + } + _ => { + return Err(format!( + "Unknown option: {flag}. Supported: --json, --input, --request-id." + )) + } + } + index += 2; + } + if request_id.is_empty() || request_id.len() > 128 { + return Err("Invalid request ID".into()); + } + Ok(Parsed::Call( + action, + input.unwrap_or_else(|| json!({})), + request_id, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + fn args(values: &[&str]) -> Vec { + values.iter().map(|s| s.to_string()).collect() + } + fn call(values: &[&str]) -> Result<(String, Value, String), String> { + match parse_args(&args(values))? { + Parsed::Call(action, input, id) => Ok((action, input, id)), + Parsed::Help => Err("help".into()), + } + } + #[test] + fn validates_inputs_without_invoking_a_shell() { + let (_, input, id) = call(&[ + "delegate", + "--json", + r#"{"prompt":"$(touch nope) `hello`\nnext"}"#, + "--request-id", + "retry-1", + ]) + .unwrap(); + assert_eq!(id, "retry-1"); + assert_eq!(input["prompt"], "$(touch nope) `hello`\nnext"); + assert!(call(&["delegate", "--json", "[]"]).is_err()); + assert!(call(&["delegate", "--json", "{}", "--json", "{}"]).is_err()); + assert!(call(&["unknown"]).is_err()); + } + #[test] + fn explains_help_and_malformed_invocations() { + assert!(matches!(parse_args(&args(&[])), Ok(Parsed::Help))); + assert!(matches!(parse_args(&args(&["--help"])), Ok(Parsed::Help))); + // Agents commonly probe a subcommand for its own usage text. + assert!(matches!( + parse_args(&args(&["delegate", "--help"])), + Ok(Parsed::Help) + )); + assert!(call(&["get", r#"{"taskId":"x"}"#]) + .unwrap_err() + .contains("--json")); + assert!(call(&["get", "--json"]).unwrap_err().contains("--help")); + assert!(call(&["get", "--taskId", "x"]) + .unwrap_err() + .contains("Unknown option")); + assert!(call(&["get", "--json", "{taskId}"]) + .unwrap_err() + .contains("Invalid JSON")); + } + #[test] + fn help_names_every_action_and_the_real_executable() { + let text = help(); + for action in ACTIONS { + assert!(text.contains(action), "help omits {action}"); + } + assert!(!text.contains("{exe}")); + assert!(text.contains("--request-id")); + } + #[test] + fn quotes_the_control_path_only_when_the_shell_needs_it() { + assert_eq!( + quoted("/Applications/MonoCode.app/Contents/MacOS/monocode"), + "/Applications/MonoCode.app/Contents/MacOS/monocode" + ); + assert_eq!(quoted("/Users/a b/MonoCode"), "'/Users/a b/MonoCode'"); + assert_eq!(quoted("C:\\Tools\\monocode.exe"), "C:\\Tools\\monocode.exe"); + assert_eq!( + quoted("C:\\Program Files\\MonoCode\\monocode.exe"), + "\"C:\\Program Files\\MonoCode\\monocode.exe\"" + ); + // A backslash escapes in a POSIX shell, so bare would rewrite the path. + assert_eq!(quoted("/Users/a\\b/MonoCode"), "'/Users/a\\b/MonoCode'"); + assert_eq!(quoted("/Users/it's/MonoCode"), r"'/Users/it'\''s/MonoCode'"); + } +} diff --git a/src-tauri/src/fs.rs b/src-tauri/src/fs.rs index 7fece027..12a8a396 100644 --- a/src-tauri/src/fs.rs +++ b/src-tauri/src/fs.rs @@ -1,6 +1,5 @@ use std::collections::{HashMap, HashSet}; -use std::io::ErrorKind; -use std::io::Write; +use std::io::{BufRead, BufReader, ErrorKind, Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::Mutex; @@ -23,6 +22,312 @@ pub struct DirEntry { ignored: bool, } +#[derive(Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct OmpInterjectionAnchor { + id: String, + after_assistant_text: String, + after_occurrence: usize, + after_assistant_text_concat: String, + after_concat_occurrence: usize, + /// A directly following text-only answer can have been coalesced into the + /// parent block by old builds. Require both full texts before splitting it. + following_assistant_text: Option, + following_assistant_text_concat: Option, + text: String, + custom_type: String, + severity: Option, +} + +/// One active-path assistant message in file order. Both join forms of its +/// text parts are alternatives for the same message, never two messages. +#[derive(Serialize, Debug, PartialEq, Eq)] +pub struct OmpAssistantText { + text: String, + concat: String, +} + +/// Recover displayed OMP custom messages that older MonoCode builds omitted +/// from their persisted transcript. The provider id is already stored with the +/// session; matching the original JSONL keeps the repair deterministic instead +/// of guessing from neighbouring reasoning text. +#[tauri::command(async)] +pub fn omp_session_interjections( + provider_session_id: String, +) -> Result, String> { + let Some(path) = omp_session_path(&provider_session_id)? else { + return Ok(Vec::new()); + }; + parse_omp_interjections(&path) +} + +#[tauri::command(async)] +pub fn omp_active_assistant_texts( + provider_session_id: String, +) -> Result, String> { + let Some(path) = omp_session_path(&provider_session_id)? else { + return Ok(Vec::new()); + }; + active_omp_assistant_texts(&path) +} + +fn omp_session_path(provider_session_id: &str) -> Result, String> { + if provider_session_id.is_empty() + || !provider_session_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + { + return Err("Invalid OMP provider session id".into()); + } + let root = dirs_home() + .map(PathBuf::from) + .ok_or("Home directory is unavailable")? + .join(".omp/agent/sessions"); + Ok(find_omp_session_file(&root, provider_session_id)) +} + +fn find_omp_session_file(root: &Path, provider_session_id: &str) -> Option { + let suffix = format!("_{provider_session_id}.jsonl"); + let projects = std::fs::read_dir(root).ok()?; + for project in projects.flatten() { + let path = project.path(); + if path.is_file() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(&suffix)) + { + return Some(path); + } + if !path.is_dir() { + continue; + } + let Ok(entries) = std::fs::read_dir(path) else { + continue; + }; + for entry in entries.flatten() { + let candidate = entry.path(); + if candidate.is_file() + && candidate + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(&suffix)) + { + return Some(candidate); + } + } + } + None +} + +fn omp_message_text(value: &serde_json::Value, separator: &str) -> String { + if let Some(text) = value.as_str() { + return text.to_owned(); + } + value + .as_array() + .into_iter() + .flatten() + .filter_map(|part| { + (part.get("type").and_then(serde_json::Value::as_str) == Some("text")) + .then(|| part.get("text").and_then(serde_json::Value::as_str)) + .flatten() + }) + .collect::>() + .join(separator) +} + +fn read_omp_entries(path: &Path) -> Result, String> { + let file = std::fs::File::open(path).map_err(|error| format!("{}: {error}", path.display()))?; + let mut entries = Vec::new(); + for line in BufReader::new(file).lines() { + let Ok(line) = line else { continue }; + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + entries.push(value); + } + Ok(entries) +} + +fn omp_active_ids(entries: &[serde_json::Value]) -> HashSet<&str> { + let nodes: HashMap<_, _> = entries + .iter() + .filter_map(|value| value["id"].as_str().map(|id| (id, value))) + .collect(); + let mut active = HashSet::new(); + let mut cursor = entries.last().and_then(|value| value["id"].as_str()); + while let Some(id) = cursor { + if !active.insert(id) { + break; + } + cursor = nodes.get(id).and_then(|value| value["parentId"].as_str()); + } + active +} + +// Return the ordered sequence, not per-text counts: a status split may only +// be merged with the message at its own source position. +fn active_omp_assistant_texts(path: &Path) -> Result, String> { + let entries = read_omp_entries(path)?; + let active = omp_active_ids(&entries); + Ok(entries + .iter() + .filter(|value| { + value["type"] == "message" + && value["message"]["role"] == "assistant" + && value["id"].as_str().is_some_and(|id| active.contains(id)) + }) + .map(|value| { + let content = &value["message"]["content"]; + OmpAssistantText { + text: omp_message_text(content, "\n"), + concat: omp_message_text(content, ""), + } + }) + .filter(|message| !message.text.trim().is_empty()) + .collect()) +} + +fn parse_omp_interjections(path: &Path) -> Result, String> { + let entries = read_omp_entries(path)?; + let nodes: HashMap<_, _> = entries + .iter() + .filter_map(|value| value["id"].as_str().map(|id| (id, value))) + .collect(); + let active = omp_active_ids(&entries); + let mut assistants: HashMap<&str, (String, usize, String, usize)> = HashMap::new(); + let mut occurrences: HashMap = HashMap::new(); + let mut concat_occurrences: HashMap = HashMap::new(); + let mut out: Vec = Vec::new(); + // Metadata and compaction participate in ancestry, not anchor text. + // Keep file order for occurrences and notes, but exclude abandoned branches. + for value in &entries { + if !value["id"].as_str().is_some_and(|id| active.contains(id)) { + continue; + } + match value.get("type").and_then(serde_json::Value::as_str) { + Some("message") + if value + .pointer("/message/role") + .and_then(serde_json::Value::as_str) + == Some("assistant") => + { + let Some(id) = value.get("id").and_then(serde_json::Value::as_str) else { + continue; + }; + let text = omp_message_text(&value["message"]["content"], "\n"); + let concat_text = omp_message_text(&value["message"]["content"], ""); + if text.trim().is_empty() { + continue; + } + if let Some(anchor) = out.last_mut() { + let text_only = value["message"]["content"] + .as_array() + .is_some_and(|parts| parts.iter().all(|part| part["type"] == "text")); + if text_only + && value.get("parentId").and_then(serde_json::Value::as_str) + == Some(anchor.id.as_str()) + { + anchor.following_assistant_text = Some(text.clone()); + anchor.following_assistant_text_concat = Some(concat_text.clone()); + } + } + let occurrence = occurrences.entry(text.clone()).or_default(); + *occurrence += 1; + let concat_occurrence = concat_occurrences.entry(concat_text.clone()).or_default(); + *concat_occurrence += 1; + assistants.insert(id, (text, *occurrence, concat_text, *concat_occurrence)); + } + Some("custom_message") + if value.get("display").and_then(serde_json::Value::as_bool) == Some(true) => + { + let Some(id) = value.get("id").and_then(serde_json::Value::as_str) else { + continue; + }; + let Some(parent_id) = value.get("parentId").and_then(serde_json::Value::as_str) + else { + continue; + }; + let mut ancestor = Some(parent_id); + let mut seen = HashSet::new(); + let mut assistant = None; + while let Some(id) = ancestor { + if !active.contains(id) || !seen.insert(id) { + break; + } + if let Some(found) = assistants.get(id) { + assistant = Some(found); + break; + } + let Some(parent) = nodes.get(id) else { break }; + if matches!( + parent + .pointer("/message/role") + .and_then(serde_json::Value::as_str), + Some("user" | "assistant") + ) { + break; + } + ancestor = parent["parentId"].as_str(); + } + let Some((after_assistant_text, after_occurrence, concat_text, concat_occurrence)) = + assistant + else { + continue; + }; + let custom_type = value + .get("customType") + .and_then(serde_json::Value::as_str) + .unwrap_or("custom") + .to_owned(); + let mut severity = None; + let mut note_bodies = Vec::new(); + if custom_type == "advisor" { + for note in value + .pointer("/details/notes") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + { + if let Some(body) = note.get("note").and_then(serde_json::Value::as_str) { + note_bodies.push(body); + } + let next = note.get("severity").and_then(serde_json::Value::as_str); + if next == Some("blocker") + || (next == Some("concern") && severity.as_deref() != Some("blocker")) + || (next == Some("nit") && severity.is_none()) + { + severity = next.map(str::to_owned); + } + } + } + let text = if note_bodies.is_empty() { + omp_message_text(&value["content"], "\n") + } else { + note_bodies.join("\n\n") + }; + // Tool results, metadata and note chains seal the same preceding + // assistant prose. Notes resolving there stack in source order. + out.push(OmpInterjectionAnchor { + id: id.to_owned(), + after_assistant_text: after_assistant_text.clone(), + after_occurrence: *after_occurrence, + after_assistant_text_concat: concat_text.clone(), + after_concat_occurrence: *concat_occurrence, + following_assistant_text: None, + following_assistant_text_concat: None, + text, + custom_type, + severity, + }); + } + _ => {} + } + } + Ok(out) +} + /// Immediate children of `path` (project tree). Folders first, then files. #[tauri::command(async)] pub fn list_dir(path: String) -> Result, String> { @@ -782,14 +1087,17 @@ pub struct GitHubPrDiff { const MAX_PR_DIFF_BYTES: usize = 2 * 1024 * 1024; /// Unified diff and file stats for a pull request, via `gh`. +/// When `full_context` is true, prefer a large-context `git diff` between the PR OIDs. #[tauri::command] pub async fn git_github_pr_diff( cwd: String, repo: String, number: i64, + full_context: Option, ) -> Result { + let full_context = full_context.unwrap_or(false); tauri::async_runtime::spawn_blocking(move || { - git_github_pr_diff_for(&expand_home(&cwd), &repo, number) + git_github_pr_diff_for(&expand_home(&cwd), &repo, number, full_context) }) .await .map_err(|e| e.to_string())? @@ -2587,29 +2895,41 @@ fn github_avatar_url(login: &str) -> String { format!("https://avatars.githubusercontent.com/{encoded}?s=64") } -fn git_github_pr_diff_for(root: &Path, repo: &str, number: i64) -> Result { +const PR_FULL_CONTEXT_LINES: &str = "999999"; + +fn git_github_pr_diff_for( + root: &Path, + repo: &str, + number: i64, + full_context: bool, +) -> Result { let (owner, name) = split_github_repo(repo)?; let repo = format!("{owner}/{name}"); if number <= 0 { return Err("Invalid pull request number".into()); } let number = number.to_string(); + let fields = if full_context { + "files,additions,deletions,baseRefOid,headRefOid" + } else { + "files,additions,deletions" + }; let json = gh_run( root, - &[ - "pr", - "view", - &number, - "--repo", - &repo, - "--json", - "files,additions,deletions", - ], + &["pr", "view", &number, "--repo", &repo, "--json", fields], false, )?; let mut diff = parse_github_pr_diff_meta(&json)?; - let patch = gh_run(root, &["pr", "diff", &number, "--repo", &repo], true)?; - if patch.len() > MAX_PR_DIFF_BYTES { + let (patch, truncated) = if full_context { + let (base, head) = parse_github_pr_oids(&json)?; + git_diff_full_context(root, &base, &head, Some(&repo))? + } else { + ( + gh_run(root, &["pr", "diff", &number, "--repo", &repo], true)?, + false, + ) + }; + if truncated || patch.len() > MAX_PR_DIFF_BYTES { diff.truncated = true; } else { diff.patch = patch; @@ -2621,6 +2941,166 @@ fn git_github_pr_diff_for(root: &Path, repo: &str, number: i64) -> Result Result<(String, String), String> { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Row { + base_ref_oid: String, + head_ref_oid: String, + } + let row: Row = serde_json::from_str(json).map_err(|error| error.to_string())?; + let base = row.base_ref_oid.trim(); + let head = row.head_ref_oid.trim(); + if base.is_empty() || head.is_empty() { + return Err("Pull request is missing base or head commit".into()); + } + Ok((base.to_string(), head.to_string())) +} + +fn git_diff_full_context( + root: &Path, + base: &str, + head: &str, + repo: Option<&str>, +) -> Result<(String, bool), String> { + ensure_git_commit(root, base, repo)?; + ensure_git_commit(root, head, repo)?; + ensure_merge_base(root, base, head, repo)?; + let context = format!("-U{PR_FULL_CONTEXT_LINES}"); + let three_dot = format!("{base}...{head}"); + let (bytes, truncated) = git_output_capped( + root, + &[ + "diff", + "--no-color", + "--no-ext-diff", + "--default-prefix", + &context, + &three_dot, + ], + MAX_PR_DIFF_BYTES, + ) + .ok_or_else(|| format!("git diff failed for {base}...{head}"))?; + if truncated { + return Ok((String::new(), true)); + } + Ok((String::from_utf8_lossy(&bytes).into_owned(), false)) +} + +fn ensure_merge_base( + root: &Path, + base: &str, + head: &str, + repo: Option<&str>, +) -> Result<(), String> { + if merge_base_exists(root, base, head) { + return Ok(()); + } + if let Some(remote) = github_fetch_remote(root, repo) { + for deepen in ["50", "200", "800"] { + let _ = git_output(root, &["fetch", "--no-tags", "--deepen", deepen, &remote]); + if merge_base_exists(root, base, head) { + return Ok(()); + } + } + } + Err(format!( + "Cannot find a merge base for {base} and {head}. Fetch more history and try again." + )) +} + +fn merge_base_exists(root: &Path, base: &str, head: &str) -> bool { + git_output(root, &["merge-base", base, head]).is_some() +} + +fn ensure_git_commit(root: &Path, oid: &str, repo: Option<&str>) -> Result<(), String> { + let spec = format!("{oid}^{{commit}}"); + if git_output(root, &["cat-file", "-e", &spec]).is_some() { + return Ok(()); + } + if let Some(remote) = github_fetch_remote(root, repo) { + let _ = git_output(root, &["fetch", "--no-tags", "--depth", "1", &remote, oid]); + } + if git_output(root, &["cat-file", "-e", &spec]).is_some() { + return Ok(()); + } + Err(format!( + "Missing git commit {oid}. Fetch the pull request refs and try again." + )) +} + +fn github_fetch_remote(root: &Path, repo: Option<&str>) -> Option { + if let Some(repo) = repo { + let url = format!("https://github.com/{repo}.git"); + return Some(remote_matching_github_url(root, &url).unwrap_or(url)); + } + if let Some(name) = gh_resolved_remote(root) { + return Some(name); + } + if let Some(url) = gh_repo_view_url(root) { + if let Some(name) = remote_matching_github_url(root, &url) { + return Some(name); + } + } + git_remote_name(root) +} + +fn gh_resolved_remote(root: &Path) -> Option { + let listed = git_stdout( + root, + &["config", "--get-regexp", r"remote\..*\.gh-resolved"], + )?; + for line in listed.lines() { + let key = line.split_whitespace().next()?; + let name = key.strip_prefix("remote.")?.strip_suffix(".gh-resolved")?; + if !name.is_empty() { + return Some(name.to_string()); + } + } + None +} + +fn gh_repo_view_url(root: &Path) -> Option { + let text = gh_stdout(root, &["repo", "view", "--json", "url"])?; + serde_json::from_str::(&text) + .ok()? + .get("url")? + .as_str() + .map(str::to_owned) + .filter(|url| !url.trim().is_empty()) +} + +fn remote_matching_github_url(root: &Path, url: &str) -> Option { + let remotes = git_stdout(root, &["remote", "-v"])?; + let wanted = normalize_github_remote_url(url); + for line in remotes.lines() { + let mut parts = line.split_whitespace(); + let name = parts.next()?; + let remote_url = parts.next()?; + if normalize_github_remote_url(remote_url) == wanted { + return Some(name.to_string()); + } + } + None +} + +fn normalize_github_remote_url(url: &str) -> String { + let trimmed = url.trim().trim_end_matches('/').trim_end_matches(".git"); + if let Some((_, rest)) = trimmed.split_once("github.com:") { + return format!( + "github.com/{}", + rest.trim_start_matches('/').to_ascii_lowercase() + ); + } + if let Some((_, rest)) = trimmed.split_once("github.com/") { + return format!( + "github.com/{}", + rest.trim_start_matches('/').to_ascii_lowercase() + ); + } + trimmed.to_ascii_lowercase() +} + fn parse_github_pr_diff_meta(json: &str) -> Result { #[derive(Deserialize)] struct FileRow { @@ -2816,6 +3296,7 @@ fn gh_run(root: &Path, args: &[&str], allow_empty: bool) -> Result Option> { .env("GIT_TERMINAL_PROMPT", "0") .output() .ok()?; - if output.status.success() { - return Some(output.stdout); - } - // `git diff` exits 1 when the files differ. - if output.status.code() == Some(1) && args.first().copied() == Some("diff") { + if git_status_ok(&output.status, args) { return Some(output.stdout); } None } +fn git_output_capped(root: &Path, args: &[&str], max_bytes: usize) -> Option<(Vec, bool)> { + let mut child = git_cmd() + .arg("--no-pager") + .arg("-C") + .arg(root) + .args(args) + .env("GIT_OPTIONAL_LOCKS", "0") + .env("GIT_TERMINAL_PROMPT", "0") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + let mut stdout = child.stdout.take()?; + let mut buf = Vec::new(); + let mut chunk = [0u8; 8192]; + loop { + let n = match stdout.read(&mut chunk) { + Ok(0) => break, + Ok(n) => n, + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + }; + let remaining = max_bytes.saturating_sub(buf.len()); + if n > remaining { + buf.extend_from_slice(&chunk[..remaining]); + let _ = child.kill(); + let _ = child.wait(); + return Some((buf, true)); + } + buf.extend_from_slice(&chunk[..n]); + } + let status = child.wait().ok()?; + if git_status_ok(&status, args) { + Some((buf, false)) + } else { + None + } +} + +fn git_status_ok(status: &std::process::ExitStatus, args: &[&str]) -> bool { + status.success() || (status.code() == Some(1) && args.first().copied() == Some("diff")) +} + fn git_branch(root: &Path) -> Option { git_head_branch(root).or_else(|| git_stdout(root, &["rev-parse", "--short", "HEAD"])) } @@ -4197,6 +4721,168 @@ mod tests { static TMP_SEQ: AtomicU64 = AtomicU64::new(0); + fn assistant_text(text: &str, concat: &str) -> OmpAssistantText { + OmpAssistantText { + text: text.into(), + concat: concat.into(), + } + } + + #[test] + fn omp_active_assistant_texts_keep_active_message_order_with_both_forms() { + let dir = tmp("omp-active-texts"); + let path = dir.0.join("session.jsonl"); + let records = [ + serde_json::json!({"type":"message","id":"u","message":{"role":"user","content":"User only"}}), + serde_json::json!({"type":"message","id":"abandoned","parentId":"u","message":{"role":"assistant","content":"Off branch"}}), + serde_json::json!({"type":"message","id":"a","parentId":"u","message":{"role":"assistant","content":[{"type":"text","text":"First."},{"type":"thinking","thinking":"Hidden"},{"type":"text","text":"Second."}]}}), + serde_json::json!({"type":"message","id":"tool","parentId":"a","message":{"role":"assistant","content":[{"type":"toolCall","name":"read"}]}}), + serde_json::json!({"type":"message","id":"b","parentId":"tool","message":{"role":"assistant","content":"Plain"}}), + serde_json::json!({"type":"custom_message","id":"note","parentId":"b","content":"Note only"}), + ]; + let jsonl = records.iter().map(|v| format!("{v}\n")).collect::(); + std::fs::write(&path, jsonl).unwrap(); + assert_eq!( + active_omp_assistant_texts(&path).unwrap(), + [ + assistant_text("First.\nSecond.", "First.Second."), + assistant_text("Plain", "Plain"), + ] + ); + } + + #[test] + fn omp_active_assistant_texts_repeat_equal_messages_in_file_order() { + let dir = tmp("omp-active-texts-repeats"); + let path = dir.0.join("session.jsonl"); + let records = [ + serde_json::json!({"type":"message","id":"a","message":{"role":"assistant","content":"First."}}), + serde_json::json!({"type":"message","id":"off","parentId":"a","message":{"role":"assistant","content":"First.Second."}}), + serde_json::json!({"type":"message","id":"b","parentId":"a","message":{"role":"assistant","content":[{"type":"text","text":"Second."}]}}), + serde_json::json!({"type":"message","id":"c","parentId":"b","message":{"role":"assistant","content":"First.Second."}}), + serde_json::json!({"type":"message","id":"d","parentId":"c","message":{"role":"assistant","content":"First.Second."}}), + ]; + std::fs::write( + &path, + records.iter().map(|v| format!("{v}\n")).collect::(), + ) + .unwrap(); + assert_eq!( + active_omp_assistant_texts(&path).unwrap(), + [ + assistant_text("First.", "First."), + assistant_text("Second.", "Second."), + assistant_text("First.Second.", "First.Second."), + assistant_text("First.Second.", "First.Second."), + ] + ); + } + + #[test] + fn omp_interjections_require_displayed_assistant_anchors() { + let dir = tmp("omp-interjections"); + let path = dir.0.join("session.jsonl"); + std::fs::write( + &path, + concat!( + "{\"type\":\"message\",\"id\":\"u\",\"message\":{\"role\":\"user\",\"content\":\"Go\"}}\n", + "{\"type\":\"custom_message\",\"id\":\"orphan\",\"parentId\":\"u\",\"display\":true}\n", + "{\"type\":\"message\",\"id\":\"a1\",\"parentId\":\"orphan\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Answer\"}]}}\n", + "{\"type\":\"custom_message\",\"id\":\"hidden\",\"parentId\":\"a1\",\"display\":false}\n", + "invalid partial line\n", + "{\"type\":\"message\",\"id\":\"a2\",\"parentId\":\"hidden\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Answer\"}]}}\n", + "{\"type\":\"custom_message\",\"id\":\"review\",\"parentId\":\"a2\",\"display\":true,\"customType\":\"advisor\",\"details\":{\"notes\":[{\"note\":\"First\",\"severity\":\"nit\"},{\"note\":\"Second\",\"severity\":\"blocker\"}]}}\n", + "{\"type\":\"message\",\"id\":\"a3\",\"parentId\":\"review\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Checked\"}]}}\n", + ), + ) + .unwrap(); + let anchors = parse_omp_interjections(&path).unwrap(); + assert_eq!(anchors.len(), 1); + assert_eq!(anchors[0].after_assistant_text, "Answer"); + assert_eq!(anchors[0].after_occurrence, 2); + assert_eq!( + anchors[0].following_assistant_text.as_deref(), + Some("Checked") + ); + assert_eq!(anchors[0].text, "First\n\nSecond"); + assert_eq!(anchors[0].severity.as_deref(), Some("blocker")); + } + + #[test] + fn omp_interjections_do_not_coalesce_across_reasoning() { + let dir = tmp("omp-interjections-reasoning"); + let path = dir.0.join("session.jsonl"); + std::fs::write( + &path, + concat!( + "{\"type\":\"message\",\"id\":\"a\",\"message\":{\"role\":\"assistant\",\"content\":\"Answer\"}}\n", + "{\"type\":\"custom_message\",\"id\":\"review\",\"parentId\":\"a\",\"display\":true,\"content\":\"Check\"}\n", + "{\"type\":\"message\",\"id\":\"b\",\"parentId\":\"review\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"Wait\"},{\"type\":\"text\",\"text\":\"Checked\"}]}}\n", + ), + ) + .unwrap(); + let anchors = parse_omp_interjections(&path).unwrap(); + assert_eq!(anchors[0].following_assistant_text, None); + assert_eq!(anchors[0].text, "Check"); + } + #[test] + fn omp_interjections_follow_active_ancestry_and_keep_text_forms() { + let dir = tmp("omp-interjections-ancestry"); + let path = dir.0.join("session.jsonl"); + std::fs::write( + &path, + concat!( + "{\"type\":\"message\",\"id\":\"a\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"One\"},{\"type\":\"text\",\"text\":\"Two\"}]}}\n", + "{\"type\":\"custom_message\",\"id\":\"abandoned\",\"parentId\":\"a\",\"display\":true,\"content\":\"Wrong branch\"}\n", + "{\"type\":\"message\",\"id\":\"t\",\"parentId\":\"a\",\"message\":{\"role\":\"toolResult\",\"content\":\"Done\"}}\n", + "{\"type\":\"custom_message\",\"id\":\"first\",\"parentId\":\"t\",\"display\":true,\"content\":\"First\"}\n", + "{\"type\":\"custom_message\",\"id\":\"second\",\"parentId\":\"first\",\"display\":true,\"content\":\"Second\"}\n", + "{\"type\":\"compaction\",\"id\":\"meta\",\"parentId\":\"second\"}\n", + "{\"type\":\"custom_message\",\"id\":\"third\",\"parentId\":\"meta\",\"display\":true,\"content\":\"Third\"}\n", + "{\"type\":\"message\",\"id\":\"b\",\"parentId\":\"third\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Three\"},{\"type\":\"text\",\"text\":\"Four\"}]}}\n", + ), + ) + .unwrap(); + let anchors = parse_omp_interjections(&path).unwrap(); + assert_eq!( + anchors.iter().map(|a| a.id.as_str()).collect::>(), + ["first", "second", "third"] + ); + for anchor in &anchors { + assert_eq!(anchor.after_assistant_text, "One\nTwo"); + assert_eq!(anchor.after_assistant_text_concat, "OneTwo"); + assert_eq!(anchor.after_occurrence, 1); + assert_eq!(anchor.after_concat_occurrence, 1); + } + assert_eq!(anchors[0].following_assistant_text, None); + assert_eq!(anchors[1].following_assistant_text, None); + assert_eq!( + anchors[2].following_assistant_text.as_deref(), + Some("Three\nFour") + ); + assert_eq!( + anchors[2].following_assistant_text_concat.as_deref(), + Some("ThreeFour") + ); + } + + #[test] + fn omp_interjections_do_not_cross_users_or_empty_assistant_messages() { + let dir = tmp("omp-interjections-barriers"); + let path = dir.0.join("session.jsonl"); + for role in ["user", "assistant"] { + let records = [ + serde_json::json!({"type":"message","id":"a","message":{"role":"assistant","content":"Earlier"}}), + serde_json::json!({"type":"message","id":"barrier","parentId":"a","message":{"role":role,"content":[]}}), + serde_json::json!({"type":"message","id":"tool","parentId":"barrier","message":{"role":"toolResult","content":"Done"}}), + serde_json::json!({"type":"custom_message","id":"note","parentId":"tool","display":true,"content":"Note"}), + ]; + let jsonl = records.map(|record| record.to_string()).join("\n"); + std::fs::write(&path, jsonl).unwrap(); + assert!(parse_omp_interjections(&path).unwrap().is_empty()); + } + } + #[test] fn stat_files_returns_mtime_for_existing_files_only() { let dir = tmp("stat-files"); @@ -5647,6 +6333,242 @@ mod tests { assert!(!diff.truncated); } + #[test] + fn parse_github_pr_oids_reads_base_and_head() { + let json = r#"{ + "baseRefOid": "aaa111", + "headRefOid": "bbb222", + "files": [] + }"#; + assert_eq!( + parse_github_pr_oids(json).unwrap(), + ("aaa111".into(), "bbb222".into()) + ); + } + + #[test] + fn git_diff_full_context_includes_distant_lines() { + let dir = tmp("git-full-context"); + let original = (1..=40) + .map(|i| format!("line-{i}")) + .collect::>() + .join("\n") + + "\n"; + if !init_git_commit(&dir.0, &[("big.txt", &original)]) { + return; + } + let base = git_run(&dir.0, &["rev-parse", "HEAD"]) + .unwrap() + .trim() + .to_string(); + let updated = original.replace("line-30", "LINE-30"); + std::fs::write(dir.0.join("big.txt"), &updated).unwrap(); + if !git(&dir.0, &["add", "."]) || !git(&dir.0, &["commit", "-m", "edit"]) { + return; + } + let head = git_run(&dir.0, &["rev-parse", "HEAD"]) + .unwrap() + .trim() + .to_string(); + let (patch, truncated) = git_diff_full_context(&dir.0, &base, &head, None).unwrap(); + assert!(!truncated); + assert!( + patch.contains("line-1"), + "expected distant context in patch:\n{patch}" + ); + assert!(patch.contains("LINE-30"), "expected changed line:\n{patch}"); + let default = git_run(&dir.0, &["diff", &base, &head]).unwrap_or_default(); + assert!( + !default.contains("line-1"), + "default context should omit distant lines" + ); + } + + #[test] + fn git_diff_full_context_uses_canonical_plain_output() { + let dir = tmp("git-full-context-canonical"); + if !init_git_commit(&dir.0, &[("file.txt", "alpha\n")]) { + return; + } + let base = git_run(&dir.0, &["rev-parse", "HEAD"]) + .unwrap() + .trim() + .to_string(); + std::fs::write(dir.0.join("file.txt"), "beta\n").unwrap(); + if !git(&dir.0, &["add", "."]) || !git(&dir.0, &["commit", "-m", "edit"]) { + return; + } + let head = git_run(&dir.0, &["rev-parse", "HEAD"]) + .unwrap() + .trim() + .to_string(); + let helper = dir.0.join("ext-diff.sh"); + std::fs::write(&helper, "#!/bin/sh\necho EXTERNAL\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(&helper).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&helper, permissions).unwrap(); + } + if !git(&dir.0, &["config", "color.ui", "always"]) + || !git(&dir.0, &["config", "color.diff", "always"]) + || !git(&dir.0, &["config", "diff.noprefix", "true"]) + || !git( + &dir.0, + &["config", "diff.external", &helper.to_string_lossy()], + ) + { + return; + } + let raw = git_run(&dir.0, &["diff", &format!("{base}...{head}")]).unwrap_or_default(); + assert!( + raw.contains("EXTERNAL") || !raw.contains("diff --git a/file.txt b/file.txt"), + "hostile git settings should change a default diff:\n{raw}" + ); + let (patch, truncated) = git_diff_full_context(&dir.0, &base, &head, None).unwrap(); + assert!(!truncated); + assert!( + patch.contains("diff --git a/file.txt b/file.txt"), + "expected canonical prefixes:\n{patch}" + ); + assert!( + !patch.contains('\u{1b}') && !patch.contains("EXTERNAL"), + "expected plain git diff output:\n{patch}" + ); + } + + #[test] + fn git_diff_full_context_errors_without_a_merge_base() { + let dir = tmp("git-full-context-unrelated"); + if !init_git_commit(&dir.0, &[("a.txt", "alpha\n")]) { + return; + } + let base = git_run(&dir.0, &["rev-parse", "HEAD"]) + .unwrap() + .trim() + .to_string(); + if !git(&dir.0, &["checkout", "--orphan", "other"]) { + return; + } + let _ = std::fs::remove_file(dir.0.join("a.txt")); + std::fs::write(dir.0.join("b.txt"), "beta\n").unwrap(); + if !git(&dir.0, &["add", "."]) || !git(&dir.0, &["commit", "-m", "other"]) { + return; + } + let head = git_run(&dir.0, &["rev-parse", "HEAD"]) + .unwrap() + .trim() + .to_string(); + assert!(git_run(&dir.0, &["merge-base", &base, &head]).is_none()); + let two_dot = git_run(&dir.0, &["diff", &base, &head]).unwrap_or_default(); + assert!( + two_dot.contains("alpha") || two_dot.contains("beta"), + "two-dot should invent a comparison:\n{two_dot}" + ); + let err = git_diff_full_context(&dir.0, &base, &head, None).unwrap_err(); + assert!( + err.to_lowercase().contains("merge base"), + "expected merge-base error, got {err}" + ); + } + + #[test] + fn github_full_context_fetch_targets_the_selected_inbox_repository() { + let local = tmp("git-full-context-selected-repo"); + assert!(init_git_commit(&local.0, &[("local.txt", "local\n")])); + assert!(git( + &local.0, + &[ + "remote", + "add", + "origin", + "https://github.com/me/widget.git" + ] + )); + assert!(git( + &local.0, + &[ + "remote", + "add", + "upstream", + "git@github.com:acme/widget.git" + ] + )); + assert!(git( + &local.0, + &["config", "remote.origin.gh-resolved", "base"] + )); + assert_eq!( + github_fetch_remote(&local.0, Some("acme/widget")).as_deref(), + Some("upstream") + ); + assert_eq!( + github_fetch_remote(&local.0, Some("me/widget")).as_deref(), + Some("origin") + ); + assert_eq!( + github_fetch_remote(&local.0, Some("other/widget")).as_deref(), + Some("https://github.com/other/widget.git") + ); + } + + #[test] + fn ensure_git_commit_fetches_from_gh_resolved_remote() { + let remote = tmp("git-full-context-upstream"); + if !init_git_commit(&remote.0, &[("note.txt", "hello\n")]) { + return; + } + let oid = git_run(&remote.0, &["rev-parse", "HEAD"]) + .unwrap() + .trim() + .to_string(); + let decoy = tmp("git-full-context-origin"); + if !init_git_commit(&decoy.0, &[("other.txt", "decoy\n")]) { + return; + } + let local = tmp("git-full-context-local"); + if !init_git_commit(&local.0, &[("local.txt", "local\n")]) { + return; + } + let upstream_url = remote.0.to_string_lossy().into_owned(); + let origin_url = decoy.0.to_string_lossy().into_owned(); + if !git(&local.0, &["remote", "add", "origin", &origin_url]) + || !git(&local.0, &["remote", "add", "upstream", &upstream_url]) + || !git(&local.0, &["config", "remote.upstream.gh-resolved", "base"]) + { + return; + } + assert_eq!( + github_fetch_remote(&local.0, None).as_deref(), + Some("upstream") + ); + let spec = format!("{oid}^{{commit}}"); + assert!(git_output(&local.0, &["cat-file", "-e", &spec]).is_none()); + ensure_git_commit(&local.0, &oid, None).unwrap(); + assert!(git_output(&local.0, &["cat-file", "-e", &spec]).is_some()); + } + + #[test] + fn git_output_capped_stops_before_buffering_the_rest() { + let dir = tmp("git-output-capped"); + let big = "x".repeat(80_000); + if !init_git_commit(&dir.0, &[("big.txt", &format!("{big}\n"))]) { + return; + } + std::fs::write(dir.0.join("big.txt"), format!("y{big}\n")).unwrap(); + if !git(&dir.0, &["add", "."]) || !git(&dir.0, &["commit", "-m", "edit"]) { + return; + } + let (bytes, truncated) = + git_output_capped(&dir.0, &["diff", "HEAD~1", "HEAD"], 1024).unwrap(); + assert!(truncated); + assert!(bytes.len() <= 1024); + let (head, truncated) = git_output_capped(&dir.0, &["rev-parse", "HEAD"], 1024).unwrap(); + assert!(!truncated); + assert!(!head.is_empty()); + } + #[test] fn git_stage_rejects_path_escape() { let dir = tmp("git-stage-escape"); diff --git a/src-tauri/src/harness.rs b/src-tauri/src/harness.rs index 10c2d4e0..236d8e74 100644 --- a/src-tauri/src/harness.rs +++ b/src-tauri/src/harness.rs @@ -368,6 +368,8 @@ pub fn harness_spawn( .stderr(Stdio::piped()); prepare_child(&mut cmd, &command); + crate::control::configure_child(&app, &session_id, &mut cmd); + let mut child = spawn_managed(&mut cmd).map_err(|e| format!("Failed to start {command}: {e}"))?; let pid = child.id(); diff --git a/src-tauri/src/inbox_media.rs b/src-tauri/src/inbox_media.rs index c19e51c4..3351c350 100644 --- a/src-tauri/src/inbox_media.rs +++ b/src-tauri/src/inbox_media.rs @@ -270,6 +270,7 @@ fn github_auth_token() -> Option { .env("GIT_TERMINAL_PROMPT", "0") .env("GH_PAGER", "cat"); crate::harness::apply_gui_env(&mut cmd); + crate::hide_window_console(&mut cmd); let output = cmd.output().ok()?; if !output.status.success() { return None; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cc25c078..7dda374d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,12 +2,15 @@ use tauri::Manager; mod chat_background; mod checkpoint; +mod control; +pub mod control_cli; mod cursor_store; mod fs; mod gitlab; mod harness; mod inbox_media; mod linear; +mod link_preview; #[cfg(target_os = "macos")] mod macos; mod menu; @@ -79,12 +82,33 @@ pub(crate) fn hide_window_console(cmd: &mut std::process::Command) { #[cfg(windows)] { use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - cmd.creation_flags(CREATE_NO_WINDOW); + cmd.creation_flags(WINDOWS_BACKGROUND_CREATION_FLAGS); } let _ = cmd; } +#[cfg(windows)] +const WINDOWS_BACKGROUND_CREATION_FLAGS: u32 = 0x0800_0000; // CREATE_NO_WINDOW + +#[cfg(all(test, windows))] +mod background_command_tests { + use super::*; + + #[test] + fn background_commands_keep_piped_output_and_exit_status() { + assert_eq!(WINDOWS_BACKGROUND_CREATION_FLAGS, 0x0800_0000); + + let mut cmd = std::process::Command::new("cmd.exe"); + cmd.args(["/D", "/C", "(echo stdout)&(echo stderr 1>&2)&exit /b 7"]); + hide_window_console(&mut cmd); + + let output = cmd.output().expect("background command should run"); + assert_eq!(output.status.code(), Some(7)); + assert!(String::from_utf8_lossy(&output.stdout).contains("stdout")); + assert!(String::from_utf8_lossy(&output.stderr).contains("stderr")); + } +} + /// Finder-launched .app bundles often omit HOME/USER/SHELL. Fall back to the /// passwd database so harness CLIs still find `~/.fx` and the login keychain. pub(crate) fn passwd_identity() -> Option { @@ -177,6 +201,7 @@ pub fn run() { harness::reap_orphaned_harness_processes(); session_store::init(app.handle())?; worktrees::init(app.handle())?; + control::init(app.handle())?; reminders::init(app.handle()); checkpoint::init(app.handle())?; menu::install(app.handle())?; @@ -200,6 +225,15 @@ pub fn run() { menu::dispatch(app, event.id().as_ref()); }) .invoke_handler(tauri::generate_handler![ + control::control_enable, + control::control_disable, + control::control_reply, + control::control_save, + control::control_load, + control::control_scopes, + control::control_attach_worker, + control::control_authorize_turn, + control::control_turn_finished, default_cwd, home_dir, notifications::notification_permission, @@ -276,6 +310,7 @@ pub fn run() { worktrees::worktree_pin, worktrees::worktree_retirement_plan, worktrees::worktree_retire, + link_preview::fetch_link_preview, fs::git_branches, fs::git_checkout, fs::git_create_branch, @@ -294,6 +329,8 @@ pub fn run() { fs::read_binary_file, fs::write_attachment, fs::read_text_file, + fs::omp_session_interjections, + fs::omp_active_assistant_texts, fs::write_text_file, skills::list_skills, search::search_project, @@ -394,6 +431,7 @@ pub fn run() { .. } => { let other_window = handle.webview_windows().keys().any(|name| name != &label); + control::window_closed(handle, &label); if !other_window { reap_harness_children(handle); } diff --git a/src-tauri/src/link_preview.rs b/src-tauri/src/link_preview.rs new file mode 100644 index 00000000..4a38c9e9 --- /dev/null +++ b/src-tauri/src/link_preview.rs @@ -0,0 +1,526 @@ +use std::io::Read; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs}; +use std::time::Duration; + +use base64::Engine; +use serde::Serialize; +use url::{Host, Url}; + +const HTTP_TIMEOUT: Duration = Duration::from_secs(7); +const MAX_REDIRECTS: usize = 5; +const MAX_PAGE_BYTES: usize = 512 * 1024; +const MAX_ICON_BYTES: usize = 256 * 1024; +const USER_AGENT: &str = "Mozilla/5.0 (compatible; MonoCode-LinkPreview/1.0)"; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LinkPreviewMetadata { + title: Option, + favicon_data_url: Option, +} + +struct FetchedResource { + final_url: Url, + content_type: Option, + bytes: Vec, +} + +/// Fetch metadata in the native host so the webview's deliberately narrow CSP +/// can remain intact. Every hop is checked before a request is made. +#[tauri::command] +pub async fn fetch_link_preview(url: String) -> Result { + tauri::async_runtime::spawn_blocking(move || fetch_link_preview_sync(&url)) + .await + .map_err(|error| error.to_string())? +} + +fn fetch_link_preview_sync(raw: &str) -> Result { + let url = parse_public_url(raw)?; + let page = fetch_resource(url, "text/html,application/xhtml+xml;q=0.9", MAX_PAGE_BYTES)?; + if !is_html(&page.content_type) { + return Ok(LinkPreviewMetadata { + title: None, + favicon_data_url: None, + }); + } + + let html = String::from_utf8_lossy(&page.bytes); + let title = document_title(&html); + let default_icon = page + .final_url + .join("/favicon.ico") + .map_err(|_| "Could not resolve this site's icon".to_string())?; + let linked_icon = document_icon_href(&html) + .and_then(|href| page.final_url.join(&href).ok()) + .filter(is_http_url); + + let favicon_data_url = linked_icon + .and_then(fetch_icon_data_url) + .or_else(|| fetch_icon_data_url(default_icon)); + + Ok(LinkPreviewMetadata { + title, + favicon_data_url, + }) +} + +fn fetch_icon_data_url(url: Url) -> Option { + let icon = fetch_resource(url, "image/*,*/*;q=0.1", MAX_ICON_BYTES).ok()?; + let mime = image_mime(icon.content_type.as_deref(), &icon.bytes)?; + let encoded = base64::engine::general_purpose::STANDARD.encode(icon.bytes); + Some(format!("data:{mime};base64,{encoded}")) +} + +fn fetch_resource(mut url: Url, accept: &str, max_bytes: usize) -> Result { + let agent = ureq::AgentBuilder::new() + .timeout(HTTP_TIMEOUT) + .redirects(0) + .build(); + + for _ in 0..=MAX_REDIRECTS { + validate_public_target(&url)?; + let response = match agent + .get(url.as_str()) + .set("Accept", accept) + .set("User-Agent", USER_AGENT) + .call() + { + Ok(response) => response, + Err(ureq::Error::Status(status, response)) if is_redirect(status) => response, + Err(ureq::Error::Status(status, _)) => { + return Err(format!("Link preview request failed ({status})")); + } + Err(_) => return Err("Could not load link preview".into()), + }; + + if is_redirect(response.status()) { + let location = response + .header("Location") + .ok_or_else(|| "Link preview redirect has no destination".to_string())?; + url = url + .join(location) + .map_err(|_| "Link preview redirect is invalid".to_string())?; + if !is_http_url(&url) { + return Err("Link preview redirect is not HTTP".into()); + } + continue; + } + + if !(200..300).contains(&response.status()) { + return Err(format!( + "Link preview request failed ({})", + response.status() + )); + } + if let Some(length) = response + .header("Content-Length") + .and_then(|value| value.parse::().ok()) + { + if length > max_bytes { + return Err("Link preview response is too large".into()); + } + } + let content_type = response.header("Content-Type").map(str::to_string); + let mut reader = response.into_reader().take(max_bytes as u64 + 1); + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .map_err(|_| "Could not read link preview".to_string())?; + if bytes.len() > max_bytes { + return Err("Link preview response is too large".into()); + } + return Ok(FetchedResource { + final_url: url, + content_type, + bytes, + }); + } + + Err("Too many link preview redirects".into()) +} + +fn parse_public_url(raw: &str) -> Result { + let value = raw.trim(); + if value.is_empty() || value.len() > 8192 { + return Err("Link preview URL is invalid".into()); + } + let url = Url::parse(value).map_err(|_| "Link preview URL is invalid".to_string())?; + if !is_http_url(&url) + || !url.username().is_empty() + || url.password().is_some() + || url.host_str().is_none() + { + return Err("Link preview URL is invalid".into()); + } + validate_public_target(&url)?; + Ok(url) +} + +fn is_http_url(url: &Url) -> bool { + url.scheme() == "https" || url.scheme() == "http" +} + +fn validate_public_target(url: &Url) -> Result<(), String> { + if !is_http_url(url) || !url.username().is_empty() || url.password().is_some() { + return Err("Link preview target is invalid".into()); + } + let host = match url + .host() + .ok_or_else(|| "Link preview target is invalid".to_string())? + { + Host::Ipv4(ip) => return public_ip_result(IpAddr::V4(ip)), + Host::Ipv6(ip) => return public_ip_result(IpAddr::V6(ip)), + Host::Domain(host) => host.trim_end_matches('.').to_ascii_lowercase(), + }; + if host == "localhost" || host.ends_with(".localhost") || host.ends_with(".local") { + return Err("Local addresses cannot be previewed".into()); + } + + let port = url + .port_or_known_default() + .ok_or_else(|| "Link preview target has no port".to_string())?; + let addresses: Vec<_> = (host.as_str(), port) + .to_socket_addrs() + .map_err(|_| "Could not resolve link preview host".to_string())? + .collect(); + if addresses.is_empty() || addresses.iter().any(|address| !is_public_ip(address.ip())) { + return Err("Private addresses cannot be previewed".into()); + } + Ok(()) +} + +fn public_ip_result(ip: IpAddr) -> Result<(), String> { + if is_public_ip(ip) { + Ok(()) + } else { + Err("Private addresses cannot be previewed".into()) + } +} + +fn is_public_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => is_public_ipv4(ip), + IpAddr::V6(ip) => is_public_ipv6(ip), + } +} + +fn is_public_ipv4(ip: Ipv4Addr) -> bool { + let [a, b, c, _] = ip.octets(); + !(a == 0 + || a == 10 + || a == 127 + || (a == 100 && (64..=127).contains(&b)) + || (a == 169 && b == 254) + || (a == 172 && (16..=31).contains(&b)) + || (a == 192 && b == 0 && c == 0) + || (a == 192 && b == 0 && c == 2) + || (a == 192 && b == 168) + || (a == 198 && (b == 18 || b == 19)) + || (a == 198 && b == 51 && c == 100) + || (a == 203 && b == 0 && c == 113) + || a >= 224) +} + +fn is_public_ipv6(ip: Ipv6Addr) -> bool { + if let Some(ipv4) = ip.to_ipv4() { + return is_public_ipv4(ipv4); + } + let segments = ip.segments(); + if ip.is_unspecified() + || ip.is_loopback() + || ip.is_multicast() + || segments[0] & 0xfe00 == 0xfc00 + || segments[0] & 0xffc0 == 0xfe80 + || segments[0] & 0xffc0 == 0xfec0 + || (segments[0] == 0x2001 && segments[1] == 0x0db8) + { + return false; + } + true +} + +fn is_redirect(status: u16) -> bool { + matches!(status, 301 | 302 | 303 | 307 | 308) +} + +fn is_html(content_type: &Option) -> bool { + content_type.as_deref().is_none_or(|value| { + let mime = value.split(';').next().unwrap_or("").trim(); + mime.eq_ignore_ascii_case("text/html") || mime.eq_ignore_ascii_case("application/xhtml+xml") + }) +} + +fn image_mime(content_type: Option<&str>, bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + return Some("image/png"); + } + if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + return Some("image/jpeg"); + } + if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + return Some("image/gif"); + } + if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" { + return Some("image/webp"); + } + if bytes.starts_with(&[0, 0, 1, 0]) { + return Some("image/x-icon"); + } + match content_type + .and_then(|value| value.split(';').next()) + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("image/vnd.microsoft.icon") | Some("image/x-icon") => Some("image/x-icon"), + _ => None, + } +} + +fn document_title(html: &str) -> Option { + for key in ["og:title", "twitter:title"] { + for tag in opening_tags(html, "meta") { + let marker = attribute(tag, "property").or_else(|| attribute(tag, "name")); + if marker + .as_deref() + .is_some_and(|value| value.eq_ignore_ascii_case(key)) + { + if let Some(value) = attribute(tag, "content").and_then(clean_text) { + return Some(value); + } + } + } + } + + let lower = html.to_ascii_lowercase(); + let start = lower.find("")? + opening_end + 1; + clean_text(html[opening_end + 1..close].to_string()) +} + +fn document_icon_href(html: &str) -> Option { + opening_tags(html, "link").into_iter().find_map(|tag| { + let rel = attribute(tag, "rel")?; + if !rel + .split_ascii_whitespace() + .any(|part| part.eq_ignore_ascii_case("icon")) + { + return None; + } + attribute(tag, "href").filter(|href| !href.trim().is_empty()) + }) +} + +fn opening_tags<'a>(html: &'a str, name: &str) -> Vec<&'a str> { + let lower = html.to_ascii_lowercase(); + let needle = format!("<{name}"); + let mut tags = Vec::new(); + let mut offset = 0; + while let Some(relative) = lower[offset..].find(&needle) { + let start = offset + relative; + let after = start + needle.len(); + let boundary = lower.as_bytes().get(after).copied(); + if boundary.is_some_and(|byte| byte.is_ascii_whitespace() || byte == b'/' || byte == b'>') { + if let Some(end) = tag_end(html, start) { + tags.push(&html[start..=end]); + offset = end + 1; + continue; + } + } + offset = after; + } + tags +} + +fn tag_end(html: &str, start: usize) -> Option { + let mut quote = None; + for (relative, byte) in html.as_bytes()[start..].iter().copied().enumerate() { + match (quote, byte) { + (Some(expected), value) if value == expected => quote = None, + (None, b'\'' | b'"') => quote = Some(byte), + (None, b'>') => return Some(start + relative), + _ => {} + } + } + None +} + +fn attribute(tag: &str, wanted: &str) -> Option { + let bytes = tag.as_bytes(); + let mut cursor = 1; + while cursor < bytes.len() && is_attribute_name_byte(bytes[cursor]) { + cursor += 1; + } + while cursor < bytes.len() { + while cursor < bytes.len() && (bytes[cursor].is_ascii_whitespace() || bytes[cursor] == b'/') + { + cursor += 1; + } + let name_start = cursor; + while cursor < bytes.len() && is_attribute_name_byte(bytes[cursor]) { + cursor += 1; + } + if cursor == name_start { + cursor += 1; + continue; + } + let name = &tag[name_start..cursor]; + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + if bytes.get(cursor) != Some(&b'=') { + continue; + } + cursor += 1; + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + let (value_start, value_end) = match bytes.get(cursor).copied() { + Some(quote @ (b'\'' | b'"')) => { + cursor += 1; + let start = cursor; + while cursor < bytes.len() && bytes[cursor] != quote { + cursor += 1; + } + (start, cursor) + } + Some(_) => { + let start = cursor; + while cursor < bytes.len() + && !bytes[cursor].is_ascii_whitespace() + && bytes[cursor] != b'>' + { + cursor += 1; + } + (start, cursor) + } + None => return None, + }; + if name.eq_ignore_ascii_case(wanted) { + return Some(decode_entities(&tag[value_start..value_end])); + } + cursor = cursor.saturating_add(1); + } + None +} + +fn is_attribute_name_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':') +} + +fn clean_text(value: String) -> Option { + let mut without_tags = String::with_capacity(value.len()); + let mut in_tag = false; + for character in value.chars() { + match character { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag => without_tags.push(character), + _ => {} + } + } + let decoded = decode_entities(&without_tags); + let collapsed = decoded.split_whitespace().collect::>().join(" "); + if collapsed.is_empty() { + return None; + } + Some(collapsed.chars().take(240).collect()) +} + +fn decode_entities(value: &str) -> String { + let mut result = String::with_capacity(value.len()); + let mut rest = value; + while let Some(index) = rest.find('&') { + result.push_str(&rest[..index]); + let entity_start = &rest[index + 1..]; + let Some(end) = entity_start.find(';').filter(|end| *end <= 12) else { + result.push('&'); + rest = entity_start; + continue; + }; + let entity = &entity_start[..end]; + let decoded = match entity { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" | "#39" => Some('\''), + "nbsp" => Some(' '), + value if value.starts_with("#x") || value.starts_with("#X") => { + u32::from_str_radix(&value[2..], 16) + .ok() + .and_then(char::from_u32) + } + value if value.starts_with('#') => value[1..].parse().ok().and_then(char::from_u32), + _ => None, + }; + if let Some(character) = decoded { + result.push(character); + } else { + result.push('&'); + result.push_str(entity); + result.push(';'); + } + rest = &entity_start[end + 1..]; + } + result.push_str(rest); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_social_title_and_icon_attributes_in_any_order() { + let html = r#" + + Fallback & title + + + + "#; + assert_eq!(document_title(html).as_deref(), Some("A nicer \"title\"")); + assert_eq!( + document_icon_href(html).as_deref(), + Some("/assets/icon.png") + ); + } + + #[test] + fn falls_back_to_the_document_title() { + let html = " Example <b>docs</b> & API "; + assert_eq!(document_title(html).as_deref(), Some("Example docs & API")); + } + + #[test] + fn rejects_local_and_special_networks() { + for address in [ + "127.0.0.1", + "10.0.0.1", + "169.254.1.1", + "172.20.1.1", + "192.168.1.1", + "100.64.0.1", + "192.0.2.1", + "::1", + "::127.0.0.1", + "::ffff:127.0.0.1", + "fc00::1", + "fe80::1", + ] { + assert!(!is_public_ip(address.parse().unwrap()), "{address}"); + } + assert!(is_public_ip("8.8.8.8".parse().unwrap())); + assert!(is_public_ip("2606:4700:4700::1111".parse().unwrap())); + } + + #[test] + fn accepts_only_plain_http_urls() { + assert!(parse_public_url("file:///tmp/page.html").is_err()); + assert!(parse_public_url("https://user:secret@example.com").is_err()); + assert!(parse_public_url("http://localhost:3000").is_err()); + assert!(parse_public_url("http://[::1]:3000").is_err()); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 3b2b9e17..8af4da08 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,6 +1,11 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + if std::env::args().nth(1).as_deref() == Some("control") { + std::process::exit(monocode_lib::control_cli::run( + std::env::args().skip(2).collect(), + )); + } #[cfg(all(debug_assertions, target_os = "macos"))] monocode_lib::ensure_macos_dev_bundle(); monocode_lib::run() diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 6c58ff7d..af79def9 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -25,30 +25,33 @@ pub fn dispatch(app: &AppHandle, id: &str) { | "open_model_picker" | "open_settings" | "check_for_updates" => { let _ = app.emit(id, ()); } - "zoom_in" | "zoom_out" | "zoom_reset" => { - // Zoom targets one window: a broadcast would make every window - // increment the shared scale setting on a single menu click. - let mut windows: Vec<_> = app.webview_windows().into_values().collect(); - windows.sort_by(|a, b| a.label().cmp(b.label())); - let target = windows + // Zoom and Close All Tabs target one window: a broadcast would make + // every window act on a single menu click. + "zoom_in" | "zoom_out" | "zoom_reset" | "close_all_tabs" => emit_to_focused(app, id), + _ => {} + } +} + +/// Emit `id` to the focused window, falling back to a visible one, then any. +fn emit_to_focused(app: &AppHandle, id: &str) { + let mut windows: Vec<_> = app.webview_windows().into_values().collect(); + windows.sort_by(|a, b| a.label().cmp(b.label())); + let target = windows + .iter() + .find(|window| window.is_focused().unwrap_or(false)) + .or_else(|| { + windows .iter() - .find(|window| window.is_focused().unwrap_or(false)) - .or_else(|| { - windows - .iter() - .find(|window| window.is_visible().unwrap_or(false)) - }) - .or(windows.first()); - match target { - Some(window) => { - let _ = app.emit_to(window.label(), id, ()); - } - None => { - let _ = app.emit(id, ()); - } - } + .find(|window| window.is_visible().unwrap_or(false)) + }) + .or(windows.first()); + match target { + Some(window) => { + let _ = app.emit_to(window.label(), id, ()); + } + None => { + let _ = app.emit(id, ()); } - _ => {} } } @@ -97,6 +100,9 @@ fn build(app: &AppHandle) -> tauri::Result> { let close_other_tabs = MenuItemBuilder::with_id("close_other_tabs", "Close Other Tabs") .accelerator("CmdOrCtrl+Alt+T") .build(app)?; + let close_all_tabs = MenuItemBuilder::with_id("close_all_tabs", "Close All Tabs") + .accelerator("CmdOrCtrl+Shift+W") + .build(app)?; let next_tab = MenuItemBuilder::with_id("next_tab", "Next Tab") .accelerator("CmdOrCtrl+Shift+]") .build(app)?; @@ -159,6 +165,7 @@ fn build(app: &AppHandle) -> tauri::Result> { .item(&split_down) .item(&close_tab) .item(&close_other_tabs) + .item(&close_all_tabs) .separator() .item(&prev_tab) .item(&next_tab) diff --git a/src-tauri/src/session_store.rs b/src-tauri/src/session_store.rs index 2389efe4..099dd69f 100644 --- a/src-tauri/src/session_store.rs +++ b/src-tauri/src/session_store.rs @@ -4,7 +4,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{json, Value}; use tauri::{AppHandle, Emitter, Manager, State}; const MIGRATION_V1: &str = r#" @@ -118,6 +118,10 @@ pub struct SessionUpsert { #[serde(rename_all = "camelCase")] pub struct SessionSummary { pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub orchestration_lead_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub orchestration: Option, pub cwd: String, pub harness: String, pub model: String, @@ -145,6 +149,8 @@ pub struct SessionSummary { #[serde(rename_all = "camelCase")] pub struct SessionRecord { pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub orchestration_lead_id: Option, pub cwd: String, pub harness: String, pub model: String, @@ -694,9 +700,128 @@ fn migrate(conn: &Connection) -> rusqlite::Result<()> { )?; crate::notes::ensure_notes_table(conn)?; crate::reminders::ensure_table(conn)?; + ensure_orchestration_history(conn)?; + Ok(()) +} + +fn ensure_orchestration_history(conn: &Connection) -> rusqlite::Result<()> { + let indexed: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name = 'orchestration_sidebar')", + [], + |row| row.get(0), + )?; + let tx = conn.unchecked_transaction()?; + tx.execute_batch( + "CREATE TABLE IF NOT EXISTS orchestration_runs (lead_id TEXT PRIMARY KEY, state TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS orchestration_sidebar (lead_id TEXT PRIMARY KEY, summary TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS orchestration_workers (session_id TEXT PRIMARY KEY, lead_id TEXT NOT NULL);", + )?; + if !indexed { + // One-time compatibility pass for the preview that listed workers as + // separate chats. Normal sidebar reads never scan transcripts/run blobs. + let runs = tx + .prepare("SELECT lead_id, state FROM orchestration_runs")? + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })? + .collect::>>()?; + for (lead, state) in runs { + if let Ok(run) = serde_json::from_str::(&state) { + index_orchestration(&tx, &lead, &run)?; + } + } + let workers = tx.prepare("SELECT id, blocks_json FROM sessions WHERE blocks_json LIKE '%orchestrationLeadId%'")? + .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))? + .collect::>>()?; + for (id, blocks) in workers { + if let Ok(blocks) = serde_json::from_str::(&blocks) { + remember_worker_from_blocks(&tx, &id, &blocks)?; + } + } + } + tx.commit() +} + +fn remember_worker(conn: &Connection, id: &str, lead: &str) -> rusqlite::Result<()> { + if id != lead && validate_id(id, "Worker").is_ok() && validate_id(lead, "Lead").is_ok() { + // Keep earlier workers indexed when a lead starts a subsequent run. + conn.execute( + "INSERT OR IGNORE INTO orchestration_workers(session_id, lead_id) VALUES (?1, ?2)", + params![id, lead], + )?; + } Ok(()) } +fn remember_worker_from_blocks( + conn: &Connection, + id: &str, + blocks: &Value, +) -> rusqlite::Result<()> { + if let Some(blocks) = blocks.as_array() { + for block in blocks { + if block["role"] == "user" { + if let Some(lead) = block["orchestrationLeadId"].as_str() { + remember_worker(conn, id, lead)?; + break; + } + } + } + } + Ok(()) +} + +fn index_orchestration(conn: &Connection, lead: &str, run: &Value) -> rusqlite::Result<()> { + let Some(tasks) = run["tasks"].as_array() else { + return Ok(()); + }; + let mut summaries = Vec::new(); + for task in tasks { + let Some(id) = task["sessionId"].as_str() else { + continue; + }; + remember_worker(conn, id, lead)?; + summaries.push(serde_json::json!({ + "sessionId": id, "title": task["title"], "harness": task["harness"], + "model": task["model"], "status": task["status"], + })); + } + let summary = serde_json::json!({ "status": run["status"], "tasks": summaries }); + conn.execute("INSERT INTO orchestration_sidebar(lead_id, summary) VALUES (?1, ?2) ON CONFLICT(lead_id) DO UPDATE SET summary = excluded.summary", params![lead, summary.to_string()])?; + Ok(()) +} + +pub(crate) fn save_orchestration( + conn: &Connection, + lead: &str, + run: &Value, +) -> rusqlite::Result<()> { + let tx = conn.unchecked_transaction()?; + tx.execute("INSERT INTO orchestration_runs(lead_id, state) VALUES (?1, ?2) ON CONFLICT(lead_id) DO UPDATE SET state = excluded.state", params![lead, run.to_string()])?; + index_orchestration(&tx, lead, run)?; + tx.commit() +} + +fn worker_parent(conn: &Connection, id: &str) -> rusqlite::Result> { + conn.query_row( + "SELECT lead_id FROM orchestration_workers WHERE session_id = ?1", + [id], + |row| row.get(0), + ) + .optional() +} + +fn orchestration_summary(conn: &Connection, id: &str) -> rusqlite::Result> { + Ok(optional_json( + conn.query_row( + "SELECT summary FROM orchestration_sidebar WHERE lead_id = ?1", + [id], + |row| row.get(0), + ) + .optional()?, + )) +} + fn upsert_session(conn: &Connection, session: &SessionUpsert) -> rusqlite::Result { let now = now_millis(); let model_settings = serde_json::to_string(&session.model_settings) @@ -825,8 +950,11 @@ fn upsert_session(conn: &Connection, session: &SessionUpsert) -> rusqlite::Resul ], )?; + remember_worker_from_blocks(conn, &session.id, &session.blocks)?; Ok(SessionSummary { id: session.id.clone(), + orchestration_lead_id: worker_parent(conn, &session.id)?, + orchestration: orchestration_summary(conn, &session.id)?, cwd: session.cwd.clone(), harness: session.harness.clone(), model: session.model.clone(), @@ -1096,11 +1224,13 @@ fn list_by_project(conn: &Connection, cwd: &str) -> rusqlite::Result rusqlite::Result rusqlite::Result> { let mut statement = conn.prepare( "SELECT id, cwd, harness, model, runtime_mode, title, provider_session_id, created_at, updated_at, branch, archived, pinned, - linked_work_item_json + linked_work_item_json, + (SELECT summary FROM orchestration_sidebar WHERE lead_id = sessions.id) FROM sessions WHERE has_user_message = 1 AND linked_work_item_json IS NOT NULL AND id NOT IN (SELECT id FROM sessions WHERE inbox_ask IS NOT NULL) + AND id NOT IN (SELECT session_id FROM orchestration_workers) ORDER BY updated_at DESC, id ASC", )?; let rows = statement.query_map([], |row| { @@ -1146,6 +1280,8 @@ fn list_linked(conn: &Connection) -> rusqlite::Result> { let pinned: i64 = row.get(11)?; Ok(SessionSummary { id: row.get(0)?, + orchestration_lead_id: None, + orchestration: optional_json(row.get(13)?), cwd: row.get(1)?, harness: row.get(2)?, model: row.get(3)?, @@ -1210,9 +1346,115 @@ fn touch_session_worktree(conn: &Connection, session_id: &str) -> rusqlite::Resu } fn delete_session(conn: &Connection, session_id: &str) -> rusqlite::Result<()> { - touch_session_worktree(conn, session_id)?; - conn.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?; - Ok(()) + let tx = conn.unchecked_transaction()?; + touch_session_worktree(&tx, session_id)?; + let parent = worker_parent(&tx, session_id)?; + // Ownership is also carried in transcripts for older clients. Release + // that metadata along with the index so reopening a worker stays detached. + let workers = tx.prepare("SELECT id, blocks_json FROM sessions WHERE id IN (SELECT session_id FROM orchestration_workers WHERE lead_id = ?1)")? + .query_map([session_id], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))? + .collect::>>()?; + for (id, raw) in workers { + let mut blocks: Value = serde_json::from_str(&raw).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e)) + })?; + if let Some(blocks) = blocks.as_array_mut() { + for block in blocks { + if block["orchestrationLeadId"] == session_id { + if let Some(block) = block.as_object_mut() { + block.remove("orchestrationLeadId"); + } + } + } + } + tx.execute( + "UPDATE sessions SET blocks_json = ?1 WHERE id = ?2", + params![blocks.to_string(), id], + )?; + } + tx.execute( + "DELETE FROM orchestration_runs WHERE lead_id = ?1", + [session_id], + )?; + tx.execute( + "DELETE FROM orchestration_sidebar WHERE lead_id = ?1", + [session_id], + )?; + if let Some(parent) = parent.filter(|id| id != session_id) { + let raw: Option = tx + .query_row( + "SELECT state FROM orchestration_runs WHERE lead_id = ?1", + [&parent], + |row| row.get(0), + ) + .optional()?; + if let Some(raw) = raw { + let mut run: Value = serde_json::from_str(&raw).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Text, + Box::new(e), + ) + })?; + let mut removed = false; + if let Some(tasks) = run["tasks"].as_array_mut() { + let ids: Vec = tasks + .iter() + .filter(|task| task["sessionId"] == session_id) + .filter_map(|task| task["id"].as_str().map(str::to_string)) + .collect(); + let before = tasks.len(); + tasks.retain(|task| task["sessionId"] != session_id); + removed = before != tasks.len(); + if removed { + for task in tasks { + if let Some(deps) = task["dependsOn"].as_array_mut() { + deps.retain(|dep| !ids.iter().any(|id| dep == id)); + } + // Removing a prerequisite must not release queued work. + // The app stops the run before deleting any current member. + if matches!( + task["status"].as_str(), + Some("queued" | "running" | "cancelling") + ) { + task["status"] = json!("cancelled"); + task["accepted"] = json!(false); + task["delivered"] = json!(true); + } + } + } + } + if removed { + if matches!(run["status"].as_str(), Some("active" | "paused")) { + run["status"] = json!("stopped"); + run["error"] = + json!("A worker conversation was deleted. Start a new run to continue."); + } + run["requests"] = json!({}); + tx.execute( + "UPDATE orchestration_runs SET state = ?1 WHERE lead_id = ?2", + params![run.to_string(), parent], + )?; + index_orchestration(&tx, &parent, &run)?; + } + } + // Also handle summaries left by an older client without a run record. + if let Some(mut summary) = orchestration_summary(&tx, &parent)? { + if let Some(tasks) = summary["tasks"].as_array_mut() { + tasks.retain(|task| task["sessionId"] != session_id); + } + tx.execute( + "UPDATE orchestration_sidebar SET summary = ?1 WHERE lead_id = ?2", + params![summary.to_string(), parent], + )?; + } + } + tx.execute( + "DELETE FROM orchestration_workers WHERE session_id = ?1 OR lead_id = ?1", + [session_id], + )?; + tx.execute("DELETE FROM sessions WHERE id = ?1", [session_id])?; + tx.commit() } fn set_archived(conn: &Connection, session_id: &str, archived: bool) -> rusqlite::Result<()> { @@ -1267,6 +1509,7 @@ fn get_session(conn: &Connection, session_id: &str) -> rusqlite::Result(&raw).unwrap(), other); + migrate(&conn).unwrap(); + assert!(worker_parent(&conn, "current").unwrap().is_none()); + } + + #[test] + fn deleting_a_worker_prunes_parent_state_and_does_not_release_dependencies() { + let store = SessionStore::open_in_memory().unwrap(); + let conn = store.conn.lock().unwrap(); + for id in ["lead", "worker", "dependent", "earlier"] { + upsert_session(&conn, &sample(id, "/tmp/a", id)).unwrap(); + } + remember_worker(&conn, "earlier", "lead").unwrap(); + save_orchestration( + &conn, + "lead", + &json!({"status":"active", "requests":{"old":{"result":"worker"}}, "tasks":[ + {"id":"task", "sessionId":"worker", "status":"running", "dependsOn":[]}, + {"id":"next", "sessionId":"dependent", "status":"queued", "dependsOn":["task"]} + ]}), + ) + .unwrap(); + delete_session(&conn, "worker").unwrap(); + assert!(get_session(&conn, "worker").unwrap().is_none()); + assert!(worker_parent(&conn, "worker").unwrap().is_none()); + assert_eq!( + worker_parent(&conn, "earlier").unwrap().as_deref(), + Some("lead") + ); + let raw: String = conn + .query_row( + "SELECT state FROM orchestration_runs WHERE lead_id = 'lead'", + [], + |row| row.get(0), + ) + .unwrap(); + let run: Value = serde_json::from_str(&raw).unwrap(); + assert_eq!(run["status"], "stopped"); + assert_eq!(run["requests"], json!({})); + assert_eq!(run["tasks"].as_array().unwrap().len(), 1); + assert_eq!(run["tasks"][0]["sessionId"], "dependent"); + assert_eq!(run["tasks"][0]["status"], "cancelled"); + assert_eq!(run["tasks"][0]["dependsOn"], json!([])); + let summary = orchestration_summary(&conn, "lead").unwrap().unwrap(); + assert_eq!(summary["tasks"].as_array().unwrap().len(), 1); + assert_eq!(summary["tasks"][0]["sessionId"], "dependent"); + assert_eq!(summary["tasks"][0]["status"], "cancelled"); + } + + #[test] + fn failed_session_deletion_rolls_back_orchestration_cleanup() { + let store = SessionStore::open_in_memory().unwrap(); + let conn = store.conn.lock().unwrap(); + for id in ["lead", "worker"] { + upsert_session(&conn, &sample(id, "/tmp/a", id)).unwrap(); + } + save_orchestration( + &conn, + "lead", + &json!({"status":"stopped", "tasks":[{"id":"task", "sessionId":"worker"}]}), + ) + .unwrap(); + conn.execute_batch("CREATE TRIGGER reject_delete BEFORE DELETE ON sessions BEGIN SELECT RAISE(ABORT, 'test failure'); END;").unwrap(); + for id in ["lead", "worker"] { + assert!(delete_session(&conn, id).is_err()); + assert!(get_session(&conn, id).unwrap().is_some()); + assert_eq!( + worker_parent(&conn, "worker").unwrap().as_deref(), + Some("lead") + ); + assert_eq!( + orchestration_summary(&conn, "lead").unwrap().unwrap()["tasks"][0]["sessionId"], + "worker" + ); + } + } + #[test] fn migration_v6_adds_archived_column() { let store = SessionStore::open_in_memory().unwrap(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 20f3048f..21530fbe 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "MonoCode", - "version": "0.1.45", + "version": "0.1.46", "identifier": "com.monocode.desktop", "build": { "beforeDevCommand": "npm run dev", diff --git a/src-tauri/tauri.fork.conf.json b/src-tauri/tauri.fork.conf.json new file mode 100644 index 00000000..a5ba0911 --- /dev/null +++ b/src-tauri/tauri.fork.conf.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "MonoCode Fork", + "identifier": "com.monocode.fork.worktrees", + "bundle": { + "createUpdaterArtifacts": false + }, + "plugins": { + "updater": { + "pubkey": "", + "endpoints": [] + } + } +} diff --git a/src/App.tsx b/src/App.tsx index ccb1fcb8..98f82cf4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,30 @@ import { WorktreeRetirementDialog } from "./chrome/WorktreeRetirementDialog"; import { AppToaster } from "./chrome/AppToaster"; import { toast } from "sonner"; import { invoke } from "@tauri-apps/api/core"; +import { orchestrator, type ControlOutcome } from "./lib/orchestration"; +import { modelsFor } from "./lib/models"; +import { isHarnessAvailable } from "./lib/harness/availability"; +import { + completeOrchestrationProposal, + orchestrationPlanningPrompt, + proposalBlock, + validateOrchestrationSettings, + withOrchestrationProposal, + type OrchestrationProposal, +} from "./lib/orchestrationPlan"; +import { discoverOrchestrationSettings } from "./lib/orchestrationCatalog"; +import { + attachOrchestrationWorkers, + consolidateOrchestrationTabs, + prepareOrchestrationWorkerDetails, + releaseOrchestrationWorker, +} from "./lib/orchestrationWorkspace"; +import { + OrchestrationActions, + OrchestrationWorkers, + type OrchestrationWorkerDetail, +} from "./chrome/OrchestrationActions"; +import { flushSync } from "react-dom"; import { listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { ask, message } from "@tauri-apps/plugin-dialog"; @@ -77,6 +101,7 @@ import { } from "./lib/fileIndex"; import { closeLeaf, + closeSurfacePanes, findSurfacePane, firstLeafId, focusedFileTab, @@ -96,10 +121,12 @@ import { nextTerminalTitle, openChangesTab, openCommitTab, + newAgentTab, openEditorTab, openSessionChangesTab, openTerminalTab, removePane, + resetTabToSession, replaceLeafId, setSplitRatio, siblingLeafId, @@ -244,12 +271,14 @@ import { import { applyPlaceSessionOnPane, filterTabsForProject, + findOpenSessionTab, planWorkspaceTabClose, workspaceTabCwd, focusedWorkspaceTabCwd, } from "./lib/workspaceTabGroups"; import { runSessionRemoval } from "./lib/sessionRemoval"; import { + HARNESSES, HARNESS_LABEL, HARNESS_TITLE, canReplaceSessionTitle, @@ -292,12 +321,12 @@ import { upsertSession, type SessionSummary, } from "./lib/sessionStore"; +import { rememberLoadedSession } from "./lib/sessionCache"; import { syncDockBadge } from "./lib/dockBadge"; import { liveAgentsFromSessions } from "./lib/liveAgents"; import { hiddenApprovalNotices } from "./lib/approvalToast"; import { useSessionReminders } from "./hooks/useSessionReminders"; import { ReminderNotices } from "./chrome/ReminderNotices"; -import { LinkedWorkItemUpdateNotice } from "./chrome/LinkedWorkItemUpdateNotice"; import { nextUnseenFinishedSessions } from "./lib/sessionDone"; import { loadNotificationsEnabled, @@ -361,7 +390,6 @@ import type { ConnectableInboxSource } from "./lib/inboxFilters"; import { InboxView } from "./surfaces/InboxView"; import type { InboxSessionPortal } from "./surfaces/InboxDiscussionPanel"; import { inboxAskKey, inboxAskPrompt } from "./lib/inboxAsk"; -import { requestAddToChat } from "./lib/quoteDraft"; import { NotesView } from "./surfaces/NotesView"; import { githubWorkItemThread, @@ -678,6 +706,20 @@ export default function App({ useState(null); const openingInboxSessions = useRef(new Map>()); const [notesViewOpen, setNotesViewOpen] = useState(false); + const [inspectedWorkerId, setInspectedWorkerId] = useState( + null, + ); + // Set while the lead's tab is still opening; the agent tab lands on the + // commit that brings it in. + const [workerDetailRequest, setWorkerDetailRequest] = useState<{ + leadId: string; + workers: OrchestrationWorkerDetail[]; + } | null>(null); + const orchestrationRuns = useSyncExternalStore( + orchestrator.subscribe, + orchestrator.snapshot, + orchestrator.snapshot, + ); const notesEnabled = useSyncExternalStore( subscribeNotesEnabled, loadNotesEnabled, @@ -820,6 +862,11 @@ export default function App({ const observedSessions = useRef(new Map()); const pendingPersist = useRef(new Map()); const removingSessionIds = useRef(new Set()); + const loadedSessionCache = useRef(new Map()); + const sessionLoads = useRef(new Map>()); + const sessionLoadEpochs = useRef(new Map()); + const openingSessionIds = useRef(new Set()); + const activeSessionPrefetch = useRef | null>(null); // Tokens arrive many times per frame; apply them once so React/markdown aren't // recomputed for every delta. const harnessQueued = useRef(new Map()); @@ -862,6 +909,7 @@ export default function App({ const stopSessionForRemoval = useCallback( async (sessionId: string): Promise => { + await orchestrator.stopForSession(sessionId); const open = sessionsRef.current.find( (session) => session.id === sessionId, ); @@ -1028,7 +1076,10 @@ export default function App({ const nextBusySessionIds = useMemo(() => { const ids = new Set(); for (const session of sessions) { - if (session.busy) ids.add(session.id); + if (session.busy) { + ids.add(session.id); + if (session.orchestrationLeadId) ids.add(session.orchestrationLeadId); + } } return ids; }, [sessions]); @@ -1074,7 +1125,10 @@ export default function App({ const nextApprovalSessionIds = useMemo(() => { const ids = new Set(); for (const session of sessions) { - if (sessionNeedsInput(session)) ids.add(session.id); + if (sessionNeedsInput(session)) { + ids.add(session.id); + if (session.orchestrationLeadId) ids.add(session.orchestrationLeadId); + } } return ids; }, [sessions]); @@ -1127,8 +1181,6 @@ export default function App({ [sessions, activeTabId, tabs, composerFocused], ); const [reminderNoticesHeight, setReminderNoticesHeight] = useState(0); - const [linkedActivityNoticeHeight, setLinkedActivityNoticeHeight] = - useState(0); useEffect(() => { syncDockBadge(sessions); @@ -1423,16 +1475,38 @@ export default function App({ for (const session of sessions) { if (session.inboxAsk) visibleIds.add(session.id); } + // Internal workers stay attached to the lead, even while idle between + // turns. They must not be discarded merely because they have no tab. + for (const session of sessions) { + if ( + session.orchestrationLeadId && + (visibleIds.has(session.orchestrationLeadId) || + orchestrationRuns.some( + (run) => + run.leadId === session.orchestrationLeadId && + ["active", "paused"].includes(run.status), + )) + ) + visibleIds.add(session.id); + } + for (const sessionId of visibleIds) { + openingSessionIds.current.delete(sessionId); + loadedSessionCache.current.delete(sessionId); + } const keepUnseen = liveAgentsEnabled; const idleDetached = sessions.filter( (session) => !visibleIds.has(session.id) && !session.busy && + !openingSessionIds.current.has(session.id) && !(keepUnseen && unseenFinishedRef.current.has(session.id)), ); if (idleDetached.length === 0) return; for (const session of idleDetached) { if (skipForgetSessionIds.current.has(session.id)) continue; + if (shouldPersistSession(session)) { + rememberLoadedSession(loadedSessionCache.current, session); + } persistSession(session); for (const harness of sessionChildHarnesses(session)) { void forgetHarnessSession(harness, session.id); @@ -1443,11 +1517,12 @@ export default function App({ (session) => visibleIds.has(session.id) || session.busy || + openingSessionIds.current.has(session.id) || (keepUnseen && unseenFinishedRef.current.has(session.id)) || skipForgetSessionIds.current.has(session.id), ), ); - }, [sessions, tabs, persistSession, liveAgentsEnabled]); + }, [sessions, tabs, persistSession, liveAgentsEnabled, orchestrationRuns]); const activateTab = useCallback((id: string, paneId?: string) => { const tab = tabsRef.current.find((entry) => entry.id === id); @@ -2157,7 +2232,7 @@ export default function App({ ); const onCloseTabs = useCallback( - (ids: string[], fallbackId: string) => { + (ids: string[], fallbackId: string, opts?: { confirmed?: boolean }) => { const current = tabsRef.current; const closingIds = new Set(ids); const closing = current.filter((tab) => closingIds.has(tab.id)); @@ -2198,6 +2273,12 @@ export default function App({ void refreshHistory(sidebarCwd); }; + // The caller already confirmed unsaved files and terminals. + if (opts?.confirmed) { + finishClose(); + return; + } + void (async () => { if (unsaved.length > 0) { const ok = await confirmDiscardUnsaved( @@ -2483,6 +2564,139 @@ export default function App({ [tabs, persistSession, refreshHistory, sidebarCwd], ); + const onCloseAllTabs = useCallback(() => { + const tab = tabsRef.current.find( + (entry) => entry.id === activeTabIdRef.current, + ); + if (!tab) return; + + const seedSession = (cwd: string) => { + const seed = sessionsRef.current[0]; + return newSession( + seed?.harness ?? "claude", + cwd, + seed?.model, + seed?.runtimeMode, + seed?.modelSettings, + ); + }; + + // Stage one: files open in the active tab's editor panes close first. + // Only when none are open does the command close every workspace tab. + const editorFiles = tab.editorPanes.flatMap((pane) => pane.files); + if (editorFiles.length > 0) { + const remaining = closeSurfacePanes(tab, "editor"); + if (!remaining) { + const closePlan = planWorkspaceTabClose({ + tabs: tabsRef.current, + sessions: sessionsRef.current, + closingTabId: tab.id, + scope: tabCloseScope, + }); + if (closePlan.action === "close") { + onCloseTab(tab.id); + return; + } + } + const unsaved = editorFiles.filter( + (file) => isFilesystemTab(file) && dirtyFilesRef.current.has(file.id), + ); + + const finishClose = () => { + let nextTab: WorkspaceTab; + let focusesSession: boolean; + if (remaining) { + nextTab = remaining; + focusesSession = sessionsRef.current.some( + (session) => session.id === remaining.focusedId, + ); + } else { + // The tab held only editor panes and must stay: seed a session. + const session = seedSession(editorFiles[0].cwd || projectCwd); + setSessions((prev) => [...prev, session]); + nextTab = resetTabToSession(tab, session.id); + focusesSession = true; + } + setTabs((prev) => + prev.map((entry) => (entry.id === tab.id ? nextTab : entry)), + ); + setDirtyFiles((prev) => { + const updated = new Set(prev); + for (const file of editorFiles) updated.delete(file.id); + return updated; + }); + setComposerFocused(focusesSession); + }; + + void (async () => { + if (unsaved.length > 0) { + const ok = await confirmDiscardUnsaved( + "Close all open files with unsaved changes?", + ); + if (!ok) return; + } + finishClose(); + })(); + return; + } + + // Stage two: the workspace always keeps one tab, so close every other + // tab and reset the active one to a blank session. Every confirmation + // runs before any tab changes, so a cancelled prompt leaves all tabs. + const otherIds = tabsRef.current + .filter((entry) => entry.id !== tab.id) + .map((entry) => entry.id); + const terminalFiles = (tab.terminalPanes ?? []).flatMap( + (pane) => pane.files, + ); + const closingFiles = [ + ...tabsRef.current + .filter((entry) => otherIds.includes(entry.id)) + .flatMap((entry) => [ + ...entry.editorPanes.flatMap((pane) => pane.files), + ...(entry.terminalPanes ?? []).flatMap((pane) => pane.files), + ]), + ...terminalFiles, + ]; + const unsaved = closingFiles.filter( + (file) => isFilesystemTab(file) && dirtyFilesRef.current.has(file.id), + ); + const terminals = closingFiles.filter((file) => file.terminal); + + void (async () => { + if (unsaved.length > 0) { + const ok = await confirmDiscardUnsaved( + "Close all tabs with unsaved files?", + ); + if (!ok) return; + } + if (terminals.length > 0) { + const ok = await confirmCloseTerminals(terminals); + if (!ok) return; + } + if (otherIds.length > 0) { + onCloseTabs(otherIds, tab.id, { confirmed: true }); + } + const hasSession = leafIds(tab.layout).some((paneId) => + sessionsRef.current.some((session) => session.id === paneId), + ); + if (hasSession) { + // No editor files remain, so this commits without a prompt. + onClearTabSession(tab.id); + return; + } + // The tab held no session: seed one so the workspace stays usable. + const session = seedSession(terminalFiles[0]?.cwd || projectCwd); + setSessions((prev) => [...prev, session]); + setTabs((prev) => + prev.map((entry) => + entry.id === tab.id ? resetTabToSession(entry, session.id) : entry, + ), + ); + setComposerFocused(true); + })(); + }, [onCloseTab, onCloseTabs, onClearTabSession, projectCwd, tabCloseScope]); + const onClosePane = useCallback( (sessionId?: string) => { // The project terminal is shared by every workspace tab in the project. @@ -2788,10 +3002,13 @@ export default function App({ ); const focusOpenSession = useCallback((sessionId: string) => { - const tab = tabsRef.current.find((entry) => - leafIds(entry.layout).includes(sessionId), + const tab = findOpenSessionTab( + tabsRef.current, + sessionsRef.current, + sessionId, ); if (!tab) return false; + loadedSessionCache.current.delete(sessionId); setActiveTabId(tab.id); setTabs((prev) => prev.map((entry) => @@ -2844,20 +3061,71 @@ export default function App({ return true; }, []); + const invalidateLoadedSession = useCallback((sessionId: string) => { + openingSessionIds.current.delete(sessionId); + loadedSessionCache.current.delete(sessionId); + sessionLoads.current.delete(sessionId); + sessionLoadEpochs.current.set( + sessionId, + (sessionLoadEpochs.current.get(sessionId) ?? 0) + 1, + ); + }, []); + + const loadStoredSession = useCallback( + (sessionId: string): Promise => { + const cached = loadedSessionCache.current.get(sessionId); + if (cached) { + // The cache owns closed sessions only. Transfer this reference into + // live state instead of retaining a stale duplicate while it changes. + loadedSessionCache.current.delete(sessionId); + return Promise.resolve(cached); + } + + const pending = sessionLoads.current.get(sessionId); + if (pending) return pending; + + const epoch = sessionLoadEpochs.current.get(sessionId) ?? 0; + const loading = getSession(sessionId) + .then((loaded) => { + if ( + !loaded || + removingSessionIds.current.has(sessionId) || + (sessionLoadEpochs.current.get(sessionId) ?? 0) !== epoch + ) { + return null; + } + const restored = restoreSessionCheckout(loaded); + return restored; + }) + .catch(() => null); + sessionLoads.current.set(sessionId, loading); + void loading.then(() => { + if (sessionLoads.current.get(sessionId) === loading) { + sessionLoads.current.delete(sessionId); + } + }); + return loading; + }, + [], + ); + const ensureOpenSession = useCallback( async (sessionId: string): Promise => { const open = sessionsRef.current.find( (session) => session.id === sessionId, ); if (open && !shouldPersistSession(open)) return open; - let loaded = open ?? (await getSession(sessionId).catch(() => null)); - if (!loaded) { + openingSessionIds.current.add(sessionId); + let loaded = open ?? (await loadStoredSession(sessionId)); + if (!loaded || removingSessionIds.current.has(sessionId)) { + openingSessionIds.current.delete(sessionId); void refreshHistory(sidebarCwd); return null; } try { loaded = await resumeArchivedWorktreeSession(loaded); } catch (error) { + openingSessionIds.current.delete(sessionId); void message( `Could not reopen this conversation.\n\n${String(error)}`, { @@ -2867,6 +3135,11 @@ export default function App({ ); return null; } + if (removingSessionIds.current.has(sessionId)) { + openingSessionIds.current.delete(sessionId); + return null; + } + loadedSessionCache.current.delete(sessionId); setHistory((current) => current.map((entry) => entry.id === sessionId ? { ...entry, archived: false } : entry, @@ -2883,6 +3156,10 @@ export default function App({ return next.find((entry) => entry.id === sessionId) ?? loaded; } const restored = restoreSessionCheckout(loaded); + const appeared = sessionsRef.current.find( + (session) => session.id === sessionId, + ); + if (appeared) return appeared; if (restored.providerSessionId && isLiveHarness(restored.harness)) { bindHarnessSession( restored.harness, @@ -2899,7 +3176,36 @@ export default function App({ } return restored; }, - [refreshHistory, sidebarCwd], + [loadStoredSession, refreshHistory, sidebarCwd], + ); + + const onPrefetchHistorySession = useCallback( + (sessionId: string) => { + if ( + removingSessionIds.current.has(sessionId) || + sessionsRef.current.some((session) => session.id === sessionId) || + loadedSessionCache.current.has(sessionId) || + sessionLoads.current.has(sessionId) || + activeSessionPrefetch.current + ) { + return; + } + const loading = loadStoredSession(sessionId); + activeSessionPrefetch.current = loading; + void loading.then((loaded) => { + if ( + loaded && + !removingSessionIds.current.has(sessionId) && + !sessionsRef.current.some((session) => session.id === sessionId) + ) { + rememberLoadedSession(loadedSessionCache.current, loaded); + } + if (activeSessionPrefetch.current === loading) { + activeSessionPrefetch.current = null; + } + }); + }, + [loadStoredSession], ); const revealLinkedSessionUpdate = useCallback( @@ -3089,22 +3395,30 @@ export default function App({ const onSelectHistorySession = useCallback( async (sessionId: string) => { - const linkedUpdate = linkedSessionUpdatesRef.current.get(sessionId); - const session = await ensureOpenSession(sessionId); + let session = await ensureOpenSession(sessionId); if (!session || session.inboxAsk) return; - if (focusOpenSession(sessionId)) { - if (linkedUpdate) revealLinkedSessionUpdate(sessionId, linkedUpdate); + const parentId = + session.orchestrationLeadId ?? + orchestrator.forSession(sessionId)?.leadId; + if (parentId && parentId !== sessionId) { + setInspectedWorkerId(sessionId); + session = await ensureOpenSession(parentId); + if (!session) return; + } + const linkedUpdate = linkedSessionUpdatesRef.current.get(session.id); + if (focusOpenSession(session.id)) { + if (linkedUpdate) revealLinkedSessionUpdate(session.id, linkedUpdate); return; } if (replaceBlankPaneWithSession(session)) { - if (linkedUpdate) revealLinkedSessionUpdate(sessionId, linkedUpdate); + if (linkedUpdate) revealLinkedSessionUpdate(session.id, linkedUpdate); return; } const tab = newTab(session.id); appendTab(tab, session.cwd); setActiveTabId(tab.id); setComposerFocused(true); - if (linkedUpdate) revealLinkedSessionUpdate(sessionId, linkedUpdate); + if (linkedUpdate) revealLinkedSessionUpdate(session.id, linkedUpdate); }, [ appendTab, @@ -3157,6 +3471,21 @@ export default function App({ .map((session) => session.id), ); + const dismissNoticesForContinuedSession = useCallback( + (sessionId: string) => { + void sessionReminders.dismissDue(sessionId); + const updatedAt = sessionsRef.current.find( + (session) => session.id === sessionId, + )?.linkedWorkItemUpdateCard?.updatedAt; + if (updatedAt == null) return; + markLinkedSessionUpdateSeen(sessionId, updatedAt); + setLinkedWorkItemUpdateCard(sessionId, (card) => + card?.updatedAt === updatedAt ? undefined : card, + ); + }, + [sessionReminders.dismissDue, setLinkedWorkItemUpdateCard], + ); + const onPlaceSessionOnPane = useCallback( async (sessionId: string, targetId: string, edge: PaneEdge) => { if (sessionId === targetId) return; @@ -3219,6 +3548,7 @@ export default function App({ async (sessionId: string, displayTitle: string) => { const trimmed = displayTitle.trim(); if (!trimmed) return; + invalidateLoadedSession(sessionId); const open = sessionsRef.current.find( (session) => session.id === sessionId, @@ -3229,6 +3559,7 @@ export default function App({ setSessions((prev) => prev.map((session) => (session.id === sessionId ? updated : session)), ); + loadedSessionCache.current.delete(sessionId); persistSession(updated); } else { const restored = await getSession(sessionId).catch(() => null); @@ -3240,12 +3571,16 @@ export default function App({ ...restored, title: formatSessionTitle(restored.harness, trimmed), }; - await upsertSession(updated).catch(() => undefined); - lastPersisted.current.set(sessionId, persistFingerprint(updated)); + const saved = await upsertSession(updated).catch(() => null); + if (saved) { + const cached = restoreSessionCheckout(updated); + rememberLoadedSession(loadedSessionCache.current, cached); + lastPersisted.current.set(sessionId, persistFingerprint(updated)); + } } void refreshHistory(sidebarCwd); }, - [persistSession, refreshHistory, sidebarCwd], + [invalidateLoadedSession, persistSession, refreshHistory, sidebarCwd], ); const onRemoveHistorySession = useCallback( @@ -3271,6 +3606,7 @@ export default function App({ return false; removingSessionIds.current.add(sessionId); + invalidateLoadedSession(sessionId); pendingPersist.current.delete(sessionId); let savedSummary: SessionSummary | undefined; try { @@ -3310,6 +3646,12 @@ export default function App({ ); }, stop: async () => { + const run = + mode === "delete" + ? orchestrator.forSession(sessionId) + : undefined; + if (run && (run.status === "active" || run.status === "paused")) + await orchestrator.stopRun(run.leadId); await stopSessionForRemoval(sessionId); }, updateSession: (stopped) => { @@ -3322,7 +3664,33 @@ export default function App({ persist: async (latest) => { if (latest) await flushSessionCheckpoint(sessionId); if (mode === "delete") { - await deleteSession(sessionId); + await orchestrator.deleteSession(sessionId, () => + deleteSession(sessionId), + ); + const released = sessionsRef.current.map((session) => + releaseOrchestrationWorker(session, sessionId), + ); + sessionsRef.current = released; + setSessions(released); + for (const [id, cached] of loadedSessionCache.current) { + if (releaseOrchestrationWorker(cached, sessionId) !== cached) + invalidateLoadedSession(id); + } + // Pending reads may still carry the deleted lead's ownership. + for (const id of sessionLoads.current.keys()) + invalidateLoadedSession(id); + for (const [id, pending] of pendingPersist.current) { + pendingPersist.current.set( + id, + releaseOrchestrationWorker(pending, sessionId), + ); + } + const releaseSummary = (entry: SessionSummary) => + entry.orchestrationLeadId === sessionId + ? { ...entry, orchestrationLeadId: undefined } + : entry; + setHistory((current) => current.map(releaseSummary)); + setStoredLinkedSessions((current) => current.map(releaseSummary)); return; } if (latest && shouldPersistSession(latest)) { @@ -3376,6 +3744,9 @@ export default function App({ ), ); if (mode === "archive") { + if (latest && shouldPersistSession(latest)) { + rememberLoadedSession(loadedSessionCache.current, latest); + } const archived = savedSummary ?? summary ?? @@ -3407,6 +3778,7 @@ export default function App({ [ activateTab, history, + invalidateLoadedSession, refreshHistory, sidebarCwd, stopSessionForRemoval, @@ -3810,9 +4182,17 @@ export default function App({ const projectSessionIds = new Set( projectSessions.map((session) => session.id), ); - for (const id of projectSessionIds) cancelledWorktreeNames.current.add(id); + for (const id of projectSessionIds) + cancelledWorktreeNames.current.add(id); if (options.purgeData) { + const cachedOrLoading = new Set([ + ...loadedSessionCache.current.keys(), + ...sessionLoads.current.keys(), + ]); + for (const sessionId of cachedOrLoading) { + invalidateLoadedSession(sessionId); + } for (const session of projectSessions) { pendingPersist.current.delete(session.id); if (session.busy) { @@ -3833,6 +4213,9 @@ export default function App({ } else { for (const session of projectSessions) { if (session.busy) continue; + if (shouldPersistSession(session)) { + rememberLoadedSession(loadedSessionCache.current, session); + } persistSession(session); pendingPersist.current.delete(session.id); for (const id of sessionChildHarnesses(session)) { @@ -3894,7 +4277,7 @@ export default function App({ } } }, - [activeTabId, onSelectProject, persistSession], + [activeTabId, invalidateLoadedSession, onSelectProject, persistSession], ); const onRestoreProject = useCallback( @@ -4127,8 +4510,36 @@ export default function App({ intent?: TurnIntent; planBlockId?: string; buildTarget?: PlanBuildTarget; + managed?: boolean; + onSettled?: (outcome: ControlOutcome) => void; }, ) => { + const controlError = orchestrator.submissionError( + sessionId, + options?.managed, + ); + if (controlError) { + enqueueHarnessEvent(sessionId, { type: "status", text: controlError }); + flushHarnessEvents(); + return; + } + if (options?.managed) { + const target = sessionsRef.current.find((s) => s.id === sessionId); + if ( + !target || + target.busy || + target.pendingSwitch || + isPreparingHandoff(target) || + removingSessionIds.current.has(sessionId) + ) { + options.onSettled?.({ + status: "failed", + text: "", + error: "Session is unavailable or already running", + }); + return; + } + } if (removingSessionIds.current.has(sessionId)) return; const storedCurrent = sessionsRef.current.find((s) => s.id === sessionId); if (!storedCurrent) return; @@ -4136,6 +4547,22 @@ export default function App({ ? withPlanBuildTarget(storedCurrent, options.buildTarget) : storedCurrent; const intent = options?.intent ?? "default"; + if (intent === "orchestrate") { + try { + const run = orchestrator.forSession(sessionId); + if (run && ["active", "paused"].includes(run.status)) + throw new Error( + "Stop the current orchestration run before preparing another proposal.", + ); + } catch (error) { + enqueueHarnessEvent(sessionId, { + type: "status", + text: error instanceof Error ? error.message : String(error), + }); + flushHarnessEvents(); + return; + } + } const approvedPlan = options?.planBlockId ? current.blocks.find( (block) => @@ -4180,7 +4607,7 @@ export default function App({ if (current.busy && !pendingSwitch) { const followUpBehavior = - intent === "plan" + intent === "plan" || intent === "orchestrate" ? "queue" : (options?.followUpBehavior ?? loadFollowUpBehavior()); if (followUpBehavior === "queue") { @@ -4209,6 +4636,7 @@ export default function App({ : s, ), ); + dismissNoticesForContinuedSession(sessionId); return; } if ( @@ -4224,6 +4652,7 @@ export default function App({ flushHarnessEvents(); return; } + dismissNoticesForContinuedSession(sessionId); const visible = displayAttachments(attachments); const cards = userTurnCards(noteCard); setSessions((prev) => @@ -4278,6 +4707,26 @@ export default function App({ const gen = (turnGen.current.get(sessionId) ?? 0) + 1; turnGen.current.set(sessionId, gen); + const proposalId = + intent === "orchestrate" ? crypto.randomUUID() : undefined; + let proposalDraft: OrchestrationProposal | undefined = proposalId + ? { + version: 1, + leadId: sessionId, + cwd: workCwd, + request: harnessText, + author: { + harness: current.harness, + model: current.model, + name: resolveModel(current.harness, current.model).name, + }, + settings: { choices: [], maxWorkers: 2 }, + status: "planning", + title: "Orchestration plan", + summary: "", + tasks: [], + } + : undefined; const isFirstTurn = current.blocks.length === 0; const placeholderTitle = canReplaceSessionTitle( current.title, @@ -4301,7 +4750,11 @@ export default function App({ : card ? SECOND_OPINION_TITLE : submittedText; - const cards = rawCommand ? undefined : userTurnCards(noteCard, card); + const cards = { + ...(rawCommand ? undefined : userTurnCards(noteCard, card)), + // The orchestrator writes these turns, not the user; hide them. + ...(options?.managed ? { internal: true } : {}), + }; const live = isLiveHarness(current.harness); const queuedHandoff = live && !pendingSwitch ? pendingHandoff(current) : null; @@ -4310,6 +4763,7 @@ export default function App({ void cancelHarnessTurn(pendingSwitch.from, sessionId); } + dismissNoticesForContinuedSession(sessionId); setSessions((prev) => prev.map((s) => { if (s.id !== sessionId) return s; @@ -4461,10 +4915,50 @@ export default function App({ if (pendingSwitch) { void forgetHarnessSession(pendingSwitch.from, sessionId); } + options?.onSettled?.({ + status: "failed", + text: "", + error: "Harness is not connected", + }); return; } + if (proposalId && proposalDraft) { + const draft = proposalDraft; + setSessions((prev) => + prev.map((session) => + session.id === sessionId + ? { + ...session, + blocks: [...session.blocks, proposalBlock(proposalId, draft)], + } + : session, + ), + ); + } + + let controlOutcome: ControlOutcome = { + status: "failed", + text: "", + error: "Turn did not complete", + }; + let controlText = ""; + let proposalText = ""; + let nativeProposalText = ""; void (async () => { + if (proposalDraft && proposalId) { + const settings = await discoverOrchestrationSettings(); + if (turnGen.current.get(sessionId) !== gen) return; + proposalDraft = { ...proposalDraft, settings }; + const discovering = proposalDraft; + setSessions((prev) => + prev.map((session) => + session.id === sessionId + ? withOrchestrationProposal(session, proposalId, discovering) + : session, + ), + ); + } let wrap = handoffCard ? { from: handoffCard.from, @@ -4516,6 +5010,22 @@ export default function App({ let providerFailureSeen = false; const routePlanEvent = (event: HarnessEvent): HarnessEvent | null => { if (event.type === "session.error") providerFailureSeen = true; + if (proposalDraft) { + if (event.type === "message.delta") { + proposalText = (proposalText + event.text).slice(-200_000); + return null; + } + if (event.type === "message.completed") { + proposalText += "\n"; + return null; + } + if (event.type === "plan") { + nativeProposalText = event.append + ? nativeProposalText + event.text + : event.text; + return null; + } + } if (intent !== "plan") return event; if (event.type === "plan") { nativePlanSeen = true; @@ -4607,7 +5117,9 @@ export default function App({ await setSessionArchived(sessionId, false); } if (turnGen.current.get(sessionId) !== gen) return; - await beginSessionTurn(sessionId, workCwd); + if (!orchestrator.forSession(sessionId)) { + await beginSessionTurn(sessionId, workCwd); + } setHistory((entries) => entries.map((entry) => entry.id === sessionId && entry.archived @@ -4617,6 +5129,7 @@ export default function App({ ); } if (turnGen.current.get(sessionId) !== gen) return; + if (proposalDraft) proposalDraft = { ...proposalDraft, cwd: workCwd }; const prepared = await prepareAttachments(attachments); const prompt = intent === "build" && approvedPlan @@ -4626,8 +5139,11 @@ export default function App({ sessionId, cwd: workCwd, }); - const turnPrompt = - intent === "plan" && !rawCommand ? planTurnPrompt(prompt) : prompt; + const turnPrompt = proposalDraft + ? orchestrationPlanningPrompt(prompt, proposalDraft.settings) + : intent === "plan" && !rawCommand + ? planTurnPrompt(prompt) + : prompt; const earlier = queuedHandoff ? userMessagesAfterHandoff(current) : []; @@ -4638,21 +5154,34 @@ export default function App({ model: current.model, modelSettings: current.modelSettings, runtimeMode: current.runtimeMode, - intent, - text: inboxAskPrompt( - rawCommand ? undefined : current.inboxAsk, - wrap && !rawCommand - ? wrapHandoffPrompt( - wrap.text, - wrap.from, - turnPrompt.trim() || CONTINUE_PROMPT, - earlier, - ) - : turnPrompt, + intent: intent === "orchestrate" ? "plan" : intent, + // A lead drives the control CLI over loopback; without this the + // harness sandbox denies the socket and it cannot supervise. + controlsAgents: orchestrator.run(sessionId)?.status === "active", + text: orchestrator.prompt( + sessionId, + inboxAskPrompt( + rawCommand ? undefined : current.inboxAsk, + wrap && !rawCommand + ? wrapHandoffPrompt( + wrap.text, + wrap.from, + turnPrompt.trim() || CONTINUE_PROMPT, + earlier, + ) + : turnPrompt, + ), ), attachments: prepared, onEvent: (event) => { if (turnGen.current.get(sessionId) !== gen) return; + orchestrator.observe(sessionId, event); + if (options?.onSettled && event.type === "message.delta") + controlText = (controlText + event.text).slice(-20_000); + if (options?.onSettled && event.type === "message.completed") + controlText += "\n"; + if (event.type === "session.error") + controlOutcome.error = event.message; if ( wrap && (event.type === "session.started" || @@ -4661,7 +5190,8 @@ export default function App({ revealHandoff(wrap.text); } nudgeOpenEditors(event, workCwd); - trackSessionEdits(sessionId, workCwd, event); + if (!orchestrator.forSession(sessionId)) + trackSessionEdits(sessionId, workCwd, event); const routed = routePlanEvent(event); if (routed) enqueueHarnessEvent(sessionId, routed); }, @@ -4685,6 +5215,7 @@ export default function App({ if (wrap) revealHandoff(wrap.text); const message = error instanceof Error ? error.message : String(error); + controlOutcome.error = message; if (!providerFailureSeen) { enqueueHarnessEvent(sessionId, { type: "session.error", @@ -4695,6 +5226,16 @@ export default function App({ } finally { if (turnGen.current.get(sessionId) !== gen) return; flushHarnessEvents(); + controlOutcome = { + status: + providerFailureSeen || + isProviderFailureText(controlText) || + !buildSucceeded + ? "failed" + : "completed", + text: controlText.trim(), + ...(providerFailureSeen ? { error: controlOutcome.error } : {}), + }; // A failed provider can leave its process alive with a dead event // stream or poisoned turn state. Park it now; the next prompt will // reconnect and resume through a fresh transport. @@ -4712,9 +5253,22 @@ export default function App({ providerFailureSeen || isProviderFailureText(lastAssistantTextInTurn(stopped)); const finalized = - intent === "plan" && !nativePlanSeen && !providerFailed - ? promoteLastAssistantToPlan(stopped, planEventKey) - : stopped; + proposalDraft && proposalId + ? withOrchestrationProposal( + stopped, + proposalId, + completeOrchestrationProposal( + proposalDraft, + nativeProposalText || proposalText, + providerFailed || !buildSucceeded + ? (controlOutcome.error ?? + "The lead could not finish planning.") + : undefined, + ), + ) + : intent === "plan" && !nativePlanSeen && !providerFailed + ? promoteLastAssistantToPlan(stopped, planEventKey) + : stopped; return approvedPlan && intent === "build" ? withPlanStatus( finalized, @@ -4744,9 +5298,51 @@ export default function App({ nudgeWatchedFiles(); window.setTimeout(() => nudgeWatchedFiles(), 150); } - })(); + })() + .catch((error: unknown) => { + controlOutcome = { + status: "failed", + text: controlText, + error: error instanceof Error ? error.message : String(error), + }; + if (turnGen.current.get(sessionId) === gen) { + enqueueHarnessEvent(sessionId, { + type: "session.error", + message: controlOutcome.error!, + }); + flushHarnessEvents(); + setSessions((prev) => + prev.map((session) => + session.id === sessionId + ? proposalId && proposalDraft + ? withOrchestrationProposal( + stopStreaming(session), + proposalId, + completeOrchestrationProposal( + proposalDraft, + "", + controlOutcome.error, + ), + ) + : stopStreaming(session) + : session, + ), + ); + } + }) + .finally(() => { + options?.onSettled?.( + turnGen.current.get(sessionId) !== gen + ? { status: "cancelled", text: controlText } + : controlOutcome, + ); + }); }, - [enqueueHarnessEvent, flushHarnessEvents], + [ + dismissNoticesForContinuedSession, + enqueueHarnessEvent, + flushHarnessEvents, + ], ); const onUpdatePlan = useCallback( @@ -4795,6 +5391,7 @@ export default function App({ !session || session.busy || block?.role !== "plan" || + !!block.orchestration || !block.text.trim() || block.plan?.status === "streaming" || block.plan?.status === "building" || @@ -4926,14 +5523,23 @@ export default function App({ ? queuedMessageForSubmit(session, messageId, "steer") : undefined; if (!session || !message) return; + if (message.intent === "orchestrate" && session.busy) { + enqueueHarnessEvent(sessionId, { + type: "status", + text: "Orchestration planning will start after the current turn finishes.", + }); + flushHarnessEvents(); + return; + } onSubmit(sessionId, message.text, message.attachments, { followUpBehavior: "steer", queuedMessageId: message.id, noteCard: message.noteCard, handoffCard: message.handoffCard, + intent: message.intent, }); }, - [onSubmit], + [onSubmit, enqueueHarnessEvent, flushHarnessEvents], ); const onResumeQueue = useCallback( @@ -5176,8 +5782,15 @@ export default function App({ ); const onStop = useCallback( - (sessionId: string) => { + (sessionId: string, managed = false) => { cancelledWorktreeNames.current.add(sessionId); + if (!managed) { + const stopping = orchestrator.stopForSession(sessionId); + if (stopping) { + void stopping.catch(console.error); + return; + } + } const session = sessionsRef.current.find((s) => s.id === sessionId); turnGen.current.set(sessionId, (turnGen.current.get(sessionId) ?? 0) + 1); flushHarnessEvents(); @@ -5284,13 +5897,416 @@ export default function App({ const onOpenApprovalSession = useCallback( (sessionId: string) => { - if (!focusOpenSession(sessionId)) { + const parentId = + sessionsRef.current.find((session) => session.id === sessionId) + ?.orchestrationLeadId ?? orchestrator.forSession(sessionId)?.leadId; + if (parentId && parentId !== sessionId) { + setInspectedWorkerId(sessionId); + if (!focusOpenSession(parentId)) void onSelectHistorySession(parentId); + } else if (!focusOpenSession(sessionId)) { void onSelectHistorySession(sessionId); } }, [focusOpenSession, onSelectHistorySession], ); + useEffect(() => { + setSessions((prev) => attachOrchestrationWorkers(prev, orchestrationRuns)); + }, [orchestrationRuns]); + + useEffect(() => { + const next = consolidateOrchestrationTabs( + tabs, + activeTabId, + orchestrationRuns, + ); + if (next.tabs !== tabs) setTabs(next.tabs); + if (next.activeTabId !== activeTabId) setActiveTabId(next.activeTabId); + }, [tabs, activeTabId, orchestrationRuns]); + + useLayoutEffect(() => { + orchestrator.bind({ + session: (id) => sessionsRef.current.find((session) => session.id === id), + sessions: () => sessionsRef.current, + choices: () => + HARNESSES.filter(isHarnessAvailable).map((harness) => ({ + harness, + models: modelsFor(harness).map(({ id, name }) => ({ id, name })), + })), + createWorker: async (run, task) => { + await invoke("control_attach_worker", { + leadId: run.leadId, + sessionId: task.sessionId, + }); + const existing = sessionsRef.current.find( + (session) => session.id === task.sessionId, + ); + const lead = sessionsRef.current.find( + (session) => session.id === run.leadId, + ); + if (!lead) throw new Error("Lead session is unavailable"); + if (existing) { + if ( + existing.harness !== task.harness || + existing.model !== task.model || + sessionWorkCwd(existing) !== run.cwd + ) + throw new Error( + "This worker's configuration changed. Restore its approved harness, model and project before retrying.", + ); + // The lead's runtime mode governs its agents, including across a + // change mid-run: auto stays auto, supervised asks the lead. + if (existing.runtimeMode !== lead.runtimeMode) { + const synced = { ...existing, runtimeMode: lead.runtimeMode }; + await upsertSession(synced); + const next = sessionsRef.current.map((session) => + session.id === synced.id ? synced : session, + ); + sessionsRef.current = next; + setSessions(next); + } + return; + } + const restored = await getSession(task.sessionId); + if ( + restored && + (restored.harness !== task.harness || restored.model !== task.model) + ) + throw new Error( + "The saved worker no longer matches its approved model. Create a new assignment.", + ); + const base = restored + ? { + ...restored, + busy: false, + cwd: lead.cwd, + worktreeCwd: lead.worktreeCwd, + runtimeMode: lead.runtimeMode, + } + : { + ...newSession( + task.harness, + lead.cwd, + task.model, + lead.runtimeMode, + ), + id: task.sessionId, + title: task.title, + }; + const worker: Session = { + ...base, + worktreeCwd: run.cwd === lead.cwd ? undefined : run.cwd, + workspaceChoice: { mode: "local" }, + orchestrationLeadId: run.leadId, + }; + if (worker.providerSessionId) + bindHarnessSession( + worker.harness, + worker.id, + worker.providerSessionId, + sessionWorkCwd(worker), + ); + await upsertSession(worker); + const next = [...sessionsRef.current, worker]; + sessionsRef.current = next; + setSessions(next); + // Workers belong to the lead's agent panel; no workspace tab is created. + }, + submit: (id, text, done) => { + // Commit the new turn before the scheduler or confirmation updates + // another session snapshot in the same event loop. + flushSync(() => + onSubmit(id, text, [], { managed: true, onSettled: done }), + ); + }, + steer: async (id, text) => { + const session = sessionsRef.current.find((entry) => entry.id === id); + if (!session) throw new Error("This agent is no longer available"); + if (!session.busy) + throw new Error( + "This agent is not running a turn; send it a fresh one with message.", + ); + if ( + !isLiveHarness(session.harness) || + !canSteerHarness(session.harness) + ) + throw new Error( + `${session.harness} cannot take guidance mid-turn. Wait for the turn to finish, then use message.`, + ); + // Record it on the worker before dispatch, so its own transcript shows + // why it changed course even if the harness call then fails. + const next = sessionsRef.current.map((entry) => + entry.id === id ? appendSteerUser(entry, text) : entry, + ); + sessionsRef.current = next; + setSessions(next); + await steerHarnessTurn({ + harness: session.harness, + sessionId: id, + cwd: sessionWorkCwd(session), + model: session.model, + modelSettings: session.modelSettings, + text, + }); + }, + respondApproval: (id, requestId, decision) => { + const session = sessionsRef.current.find((entry) => entry.id === id); + if (session) + respondHarnessApproval(session.harness, id, requestId, decision); + }, + answerQuestion: (id, requestId, reply) => { + const session = sessionsRef.current.find((entry) => entry.id === id); + if (session) + respondHarnessQuestion(session.harness, id, requestId, reply); + }, + stop: async (id) => { + const session = sessionsRef.current.find((entry) => entry.id === id); + onStop(id, true); + try { + if (session) + await Promise.all( + sessionChildHarnesses(session).map((harness) => + stopHarnessSession(harness, id), + ), + ); + } finally { + // Also reap processes left behind by a renderer reload, before the + // corresponding session has been restored in this window. + await invoke("harness_kill", { sessionId: id }); + await invoke("control_turn_finished", { sessionId: id }); + } + }, + }); + }, [onSubmit, onStop]); + + useEffect(() => { + orchestrator.sync(); + }, [sessions]); + + useEffect(() => { + const listening = listen<{ + id: string; + sessionId: string; + requestId: string; + action: string; + input: Record; + }>("monocode-control-request", ({ payload }) => { + void orchestrator + .handle( + payload.sessionId, + payload.requestId, + payload.action, + payload.input, + ) + .then( + (result) => + invoke("control_reply", { + id: payload.id, + response: { ok: true, result }, + }), + (error: unknown) => + invoke("control_reply", { + id: payload.id, + response: { + ok: false, + error: error instanceof Error ? error.message : String(error), + }, + }), + ) + .catch(console.error); + }); + return () => { + void listening.then((unlisten) => unlisten()); + }; + }, []); + + const confirmingOrchestration = useRef(new Set()); + const queueWorkerPanes = useCallback( + (workers: OrchestrationWorkerDetail[]) => { + // Finished workers are not open; load stored transcripts before the + // tabs appear so the pane does not flash the empty state. + void prepareOrchestrationWorkerDetails(workers, { + openLead: async (leadId) => { + if (!focusOpenSession(leadId)) await onSelectHistorySession(leadId); + }, + openWorker: ensureOpenSession, + hasSession: (id) => + sessionsRef.current.some((session) => session.id === id), + }) + .then((request) => { + if (request?.workers.length) setWorkerDetailRequest(request); + }) + .catch(console.error); + }, + [ensureOpenSession, focusOpenSession, onSelectHistorySession], + ); + const onOpenWorkerDetails = useCallback( + (worker: OrchestrationWorkerDetail) => { + setInspectedWorkerId(worker.sessionId); + queueWorkerPanes([worker]); + }, + [queueWorkerPanes], + ); + useEffect(() => { + if (!workerDetailRequest) return; + const { leadId, workers } = workerDetailRequest; + const tab = tabs.find((entry) => leafIds(entry.layout).includes(leadId)); + if (!tab) { + // Still opening: this runs again on the commit that lands the lead. If + // the lead never arrived at all, drop the request rather than let it + // fire against some later tab change. + if (!sessionsRef.current.some((entry) => entry.id === leadId)) { + setWorkerDetailRequest(null); + } + return; + } + setWorkerDetailRequest(null); + // Every agent of a run shares one pane, the way files do: `openEditorTab` + // focuses an open tab, adds to the pane already beside the lead, or splits + // one off when there is none. + const cwd = + sessionsRef.current.find((entry) => entry.id === leadId)?.cwd ?? + projectCwdRef.current; + const files = workers.map((worker) => + newAgentTab(worker.title, cwd, { + sessionId: worker.sessionId, + leadId, + harness: worker.harness, + }), + ); + setTabs((prev) => + prev.map((entry) => { + if (entry.id !== tab.id) return entry; + const opened = files.reduce( + (next, file) => openEditorTab(next, file), + entry, + ); + // Leave the first worker focused so View agents lands on the start + // of the run rather than the last tab added. + return files[0] ? openEditorTab(opened, files[0]) : opened; + }), + ); + setActiveTabId(tab.id); + setComposerFocused(false); + }, [tabs, workerDetailRequest]); + const orchestrationWorkers = useMemo( + () => ({ + selectedId: inspectedWorkerId, + inspect: setInspectedWorkerId, + openDetails: onOpenWorkerDetails, + }), + [inspectedWorkerId, onOpenWorkerDetails], + ); + const updateOrchestrationCard = useCallback( + (leadId: string, blockId: string, proposal: OrchestrationProposal) => { + const next = sessionsRef.current.map((session) => + session.id === leadId + ? withOrchestrationProposal(session, blockId, proposal) + : session, + ); + sessionsRef.current = next; + setSessions(next); + return next.find((session) => session.id === leadId); + }, + [], + ); + const orchestrationActions = useMemo( + () => ({ + open: onOpenApprovalSession, + openAgents: queueWorkerPanes, + update: ( + leadId: string, + blockId: string, + edited: OrchestrationProposal, + ) => { + const session = sessionsRef.current.find( + (entry) => entry.id === leadId, + ); + const proposal = session?.blocks.find( + (block) => block.id === blockId, + )?.orchestration; + if ( + !session || + session.busy || + proposal?.status !== "ready" || + confirmingOrchestration.current.has(leadId) + ) + return; + // Keep the discovered catalog authoritative while allowing task and parallelism edits. + const settings = validateOrchestrationSettings({ + ...proposal.settings, + maxWorkers: edited.settings.maxWorkers, + }); + updateOrchestrationCard(leadId, blockId, { + ...proposal, + settings, + tasks: edited.tasks, + }); + }, + confirm: async (leadId: string, blockId: string) => { + if (confirmingOrchestration.current.has(leadId)) return; + confirmingOrchestration.current.add(leadId); + let proposal: OrchestrationProposal | undefined; + try { + await orchestrator.hydrate(leadId); + const session = sessionsRef.current.find( + (entry) => entry.id === leadId, + ); + proposal = session?.blocks.find( + (block) => block.id === blockId, + )?.orchestration; + if (!session || session.busy || proposal?.status !== "ready") + throw new Error( + "Wait for the proposal to finish before confirming.", + ); + if ( + session.harness !== proposal.author.harness || + session.model !== proposal.author.model + ) + throw new Error( + "The lead model has changed. Switch back to the model shown on this card, or generate a new proposal.", + ); + const starting = updateOrchestrationCard(leadId, blockId, { + ...proposal, + status: "starting", + })!; + // Save the edited card before anything can execute. + await upsertSession(starting); + await orchestrator.startApproved(leadId, blockId, proposal); + updateOrchestrationCard(leadId, blockId, { + ...proposal, + status: "approved", + }); + } catch (error) { + if (proposal) + updateOrchestrationCard(leadId, blockId, { + ...proposal, + status: "ready", + }); + throw error; + } finally { + confirmingOrchestration.current.delete(leadId); + } + }, + retry: (leadId: string, blockId: string) => { + const session = sessionsRef.current.find( + (entry) => entry.id === leadId, + ); + const proposal = session?.blocks.find( + (block) => block.id === blockId, + )?.orchestration; + if (!session || session.busy || !proposal) return; + onSubmit(leadId, proposal.request, [], { intent: "orchestrate" }); + }, + }), + [ + onOpenApprovalSession, + queueWorkerPanes, + onSubmit, + updateOrchestrationCard, + ], + ); + const onSelectLiveAgent = useCallback( (sessionId: string) => { setSearchViewOpen(false); @@ -5322,15 +6338,21 @@ export default function App({ const sidebarHistory = useMemo( () => - historyWithLiveSessions(history, sessions, sidebarCwd, { - ...(projectBranches?.current - ? { branch: projectBranches.current } - : {}), - ...(sidebarCwd && sidebarCwd !== "~" - ? { repo: projectName(sidebarCwd) } - : {}), - }), - [history, projectBranches, sessions, sidebarCwd], + historyWithLiveSessions( + history, + sessions, + sidebarCwd, + { + ...(projectBranches?.current + ? { branch: projectBranches.current } + : {}), + ...(sidebarCwd && sidebarCwd !== "~" + ? { repo: projectName(sidebarCwd) } + : {}), + }, + orchestrationRuns, + ), + [history, projectBranches, sessions, sidebarCwd, orchestrationRuns], ); const { unseen: inboxUnseen, @@ -5372,7 +6394,9 @@ export default function App({ sessions .filter( (session) => - !session.inboxAsk && sameProjectPath(session.cwd, sidebarCwd), + !session.inboxAsk && + !session.orchestrationLeadId && + sameProjectPath(session.cwd, sidebarCwd), ) .map((session) => summaryFromSession(session, { @@ -5618,6 +6642,7 @@ export default function App({ onNew, onArchiveFocusedSession, onCloseOtherTabs, + onCloseAllTabs, onClosePane, onNext, onPrev, @@ -5645,6 +6670,7 @@ export default function App({ onNew, onArchiveFocusedSession, onCloseOtherTabs, + onCloseAllTabs, onClosePane, onNext, onPrev, @@ -5768,6 +6794,7 @@ export default function App({ if (cmd === "new") run("new", a.onNew); else if (cmd === "close-others") run("close-others", a.onCloseOtherTabs); + else if (cmd === "close-all") run("close-all", a.onCloseAllTabs); else if (cmd === "close") run("close", a.onClosePane); else if (cmd === "next") run("next", a.onNext); else if (cmd === "prev") run("prev", a.onPrev); @@ -5872,6 +6899,9 @@ export default function App({ listen("close_other_tabs", () => run("close-others", actions.current.onCloseOtherTabs), ), + listen("close_all_tabs", () => + run("close-all", actions.current.onCloseAllTabs), + ), listen("close_tab", () => run("close", actions.current.onClosePane)), listen("next_tab", () => run("next", actions.current.onNext)), listen("prev_tab", () => run("prev", actions.current.onPrev)), @@ -6007,8 +7037,12 @@ export default function App({ onSteerQueuedMessage, onResumeQueue, onInboxCardDismiss, + onLinkedWorkItemUpdateCardDismiss, onNoteCardDismiss, onHandoffCardDismiss, + onOpenLinkedWorkItem, + onArchiveSession: onArchiveHistorySession, + onDeleteSession: onDeleteHistorySession, onApproval, onQuestionReply, onQuestionInteraction, @@ -6022,483 +7056,443 @@ export default function App({ }; return ( -
- - session.busy && session.cwd ? [session.cwd] : [], - )} - liveAgents={liveAgents} - onSelectAgent={onSelectLiveAgent} - onSelectProject={onSelectProject} - onOpenProject={pickProject} - onRemoveProject={onRemoveProject} - onNew={onNew} - openSessions={openProjectSessions} - onNewTerminal={onNewTerminal} - onSearch={onOpenSearch} - onOpenInbox={onOpenInbox} - onOpenInboxItem={onOpenLinkedWorkItem} - onOpenNotes={notesEnabled ? onOpenNotes : undefined} - onGoToFile={onGoToFile} - searchActive={searchViewOpen} - inboxActive={inboxViewOpen} - notesActive={notesViewOpen} - notesEnabled={notesEnabled} - projectRailOpen={projectRailOpen} - onToggleProjectRail={onToggleProjectRail} - unseenFinishedIds={unseenFinishedIds} - inboxUnseen={inboxUnseen} - linkedSessionUpdateIds={linkedSessionUpdateIds} - settingsOpen={settingsOpen} - settingsSection={settingsSection} - onOpenSettings={onOpenSettings} - onSelectSettingsSection={onSelectSettingsSection} - onCloseSettings={onCloseSettings} - updateNotice={updateNotice} - onOpenWhatsNew={onOpenWhatsNew} - onDismissUpdate={() => setUpdateNotice(null)} - /> - -
+ +
- {!IS_MAC ? ( - onCloseTab(activeTabId) : undefined - } - onCloseOtherTabs={onCloseOtherTabs} - onPickProject={pickProject} - onFindInProject={onFindInProject} - onSearch={onOpenSearch} - onOpenInbox={onOpenInbox} - onOpenNotes={notesEnabled ? onOpenNotes : undefined} - onZoomIn={() => { - const next = saveUiScale(zoomInUiScale(loadUiScale())); - void applyUiScale(next); - }} - onZoomOut={() => { - const next = saveUiScale(zoomOutUiScale(loadUiScale())); - void applyUiScale(next); - }} - onZoomReset={() => { - saveUiScale(UI_SCALE_DEFAULT); - void applyUiScale(UI_SCALE_DEFAULT); - }} - /> - ) : null} - + session.busy && session.cwd ? [session.cwd] : [], + )} + liveAgents={liveAgents} + onSelectAgent={onSelectLiveAgent} + onSelectProject={onSelectProject} + onOpenProject={pickProject} + onRemoveProject={onRemoveProject} onNew={onNew} + openSessions={openProjectSessions} onNewTerminal={onNewTerminal} - onShowTerminal={onShowProjectTerminal} - projectTerminalActive={ - !!currentProjectDock && currentProjectDock.pane.files.length > 0 - } - onOpenSettings={onOpenSettings} + onSearch={onOpenSearch} onOpenInbox={onOpenInbox} + onOpenInboxItem={onOpenLinkedWorkItem} onOpenNotes={notesEnabled ? onOpenNotes : undefined} - onClose={onCloseTitleTab} - onCloseMany={onCloseTabs} - onReorder={onReorderTabs} onGoToFile={onGoToFile} - recents={recents} - onSelectProject={onSelectProject} + searchActive={searchViewOpen} + inboxActive={inboxViewOpen} + notesActive={notesViewOpen} + notesEnabled={notesEnabled} + projectRailOpen={projectRailOpen} + onToggleProjectRail={onToggleProjectRail} + unseenFinishedIds={unseenFinishedIds} + inboxUnseen={inboxUnseen} + linkedSessionUpdateIds={linkedSessionUpdateIds} + settingsOpen={settingsOpen} + settingsSection={settingsSection} + onOpenSettings={onOpenSettings} + onSelectSettingsSection={onSelectSettingsSection} + onCloseSettings={onCloseSettings} + updateNotice={updateNotice} + onOpenWhatsNew={onOpenWhatsNew} + onDismissUpdate={() => setUpdateNotice(null)} /> -
+
- {projectTerminals.map((dock) => { - const show = - dock.open && sameProjectPath(dock.projectPath, gitCwd); - return ( -
- - onOpenTerminal( - active ? sessionWorkCwd(active) : projectCwd, - ) - } - onSelectTerminal={onSelectProjectTerminal} - onCloseTerminal={onCloseProjectTerminal} - onCloseOtherTerminals={onCloseOtherProjectTerminals} - onReorderTerminals={onReorderProjectTerminals} - onTerminalMetaChange={onTerminalMetaChange} - /> -
- ); - })} -
-
- {tabs.map((tab) => ( -
-
- - onRatio(tab.id, splitId, index, ratio) + {!IS_MAC ? ( + onCloseTab(activeTabId) : undefined + } + onCloseOtherTabs={onCloseOtherTabs} + onCloseAllTabs={onCloseAllTabs} + onPickProject={pickProject} + onFindInProject={onFindInProject} + onSearch={onOpenSearch} + onOpenInbox={onOpenInbox} + onOpenNotes={notesEnabled ? onOpenNotes : undefined} + onZoomIn={() => { + const next = saveUiScale(zoomInUiScale(loadUiScale())); + void applyUiScale(next); + }} + onZoomOut={() => { + const next = saveUiScale(zoomOutUiScale(loadUiScale())); + void applyUiScale(next); + }} + onZoomReset={() => { + saveUiScale(UI_SCALE_DEFAULT); + void applyUiScale(UI_SCALE_DEFAULT); + }} + /> + ) : null} + 0 + } + onOpenSettings={onOpenSettings} + onOpenInbox={onOpenInbox} + onOpenNotes={notesEnabled ? onOpenNotes : undefined} + onClose={onCloseTitleTab} + onCloseMany={onCloseTabs} + onReorder={onReorderTabs} + onGoToFile={onGoToFile} + recents={recents} + onSelectProject={onSelectProject} + /> + +
+
+ {projectTerminals.map((dock) => { + const show = + dock.open && sameProjectPath(dock.projectPath, gitCwd); + return ( +
+ + onOpenTerminal( + active ? sessionWorkCwd(active) : projectCwd, + ) } - editorNavigation={editorNavigation} - onUpdatePlan={onUpdatePlan} - onMovePane={onMovePane} + onSelectTerminal={onSelectProjectTerminal} + onCloseTerminal={onCloseProjectTerminal} + onCloseOtherTerminals={onCloseOtherProjectTerminals} + onReorderTerminals={onReorderProjectTerminals} onTerminalMetaChange={onTerminalMetaChange} />
+ ); + })} +
+
+ {tabs.map((tab) => ( +
+
+ + onRatio(tab.id, splitId, index, ratio) + } + editorNavigation={editorNavigation} + onUpdatePlan={onUpdatePlan} + onMovePane={onMovePane} + onTerminalMetaChange={onTerminalMetaChange} + /> +
+
+ ))}
- ))} +
-
+
- -
- {searchViewOpen ? ( - !session.inboxAsk)} - focusToken={searchViewFocusToken} - besideRail={projectRailOpen} - onClose={onLeaveSearch} - onToggleSidebar={onToggleSidebar} - onOpenFile={onOpenFile} - onOpenSession={onSelectHistorySession} - onOpenProject={onSelectProject} - /> - ) : null} -
- {sessions - .filter((session) => session.inboxAsk) - .map((session) => { - const visible = - inboxViewOpen && inboxAskPortal?.sessionId === session.id; - return ( - - - - ); - })} -
- {inboxViewOpen ? ( - - ) : null} - {notesViewOpen ? ( - - ) : null} - {settingsOpen ? ( - - onRemoveProject(path, { purgeData: true }) + {searchViewOpen ? ( + !session.inboxAsk)} + focusToken={searchViewFocusToken} + besideRail={projectRailOpen} + onClose={onLeaveSearch} + onToggleSidebar={onToggleSidebar} + onOpenFile={onOpenFile} + onOpenSession={onSelectHistorySession} + onOpenProject={onSelectProject} + /> + ) : null} +
+ {sessions + .filter((session) => session.inboxAsk) + .map((session) => { + const visible = + inboxViewOpen && inboxAskPortal?.sessionId === session.id; + return ( + + + + ); + })} +
+ {inboxViewOpen ? ( + + ) : null} + {notesViewOpen ? ( + + ) : null} + {settingsOpen ? ( + + onRemoveProject(path, { purgeData: true }) + } + onOpenWhatsNew={onOpenWhatsNew} + /> + ) : null} + {searchViewOpen || + inboxViewOpen || + notesViewOpen || + settingsOpen ? null : ( + + )} +
+ + {filePickerOpen ? ( + setFilePickerOpen(false)} + /> + ) : null} + + {archiveRetirementPlans[0] ? ( + + setArchiveRetirementPlans((current) => current.slice(1)) + } + onRetired={() => { + for (const repo of new Set( + archiveRetirementPlans[0].entries.map((entry) => entry.repo), + )) { + refreshWorktrees(repo); + } + notifyGitChanged(); + }} + /> + ) : null} + + - ) : null} - {searchViewOpen || - inboxViewOpen || - notesViewOpen || - settingsOpen ? null : ( - openSettings()} + onHeightChange={setReminderNoticesHeight} /> - )} - - - {filePickerOpen ? ( - setFilePickerOpen(false)} - /> - ) : null} - - {archiveRetirementPlans[0] ? ( - - setArchiveRetirementPlans((current) => current.slice(1)) - } - onRetired={() => { - for (const repo of new Set( - archiveRetirementPlans[0].entries.map((entry) => entry.repo), - )) { - refreshWorktrees(repo); - } - notifyGitChanged(); - }} - /> - ) : null} - - - session.id === activeTab?.focusedId) - ?.linkedWorkItemUpdateCard - } - topOffset={12 + (reminderNoticesHeight ? reminderNoticesHeight + 8 : 0)} - onAcknowledge={() => { - const session = sessions.find( - (entry) => entry.id === activeTab?.focusedId, - ); - const updatedAt = session?.linkedWorkItemUpdateCard?.updatedAt; - if (session && updatedAt != null) { - markLinkedSessionUpdateSeen(session.id, updatedAt); - } - }} - onDismiss={() => { - if (activeTab?.focusedId) { - onLinkedWorkItemUpdateCardDismiss(activeTab.focusedId); - } - }} - onOpenDiscussion={() => { - const session = sessions.find( - (entry) => entry.id === activeTab?.focusedId, - ); - if (session?.linkedWorkItem) { - onOpenLinkedWorkItem(session.linkedWorkItem); - } - }} - onAddToChat={(text) => { - requestAddToChat(text, "plain"); - setComposerFocused(true); - }} - onArchiveSession={() => { - const sessionId = activeTab?.focusedId; - return sessionId - ? onArchiveHistorySession(sessionId, true) - : Promise.resolve(false); - }} - onDeleteSession={() => { - const sessionId = activeTab?.focusedId; - return sessionId - ? onDeleteHistorySession(sessionId) - : Promise.resolve(false); - }} - onHeightChange={setLinkedActivityNoticeHeight} - /> - openSettings()} - onHeightChange={setReminderNoticesHeight} - /> - {whatsNewVersion ? ( - setWhatsNewVersion(null)} - /> - ) : null} - + {whatsNewVersion ? ( + setWhatsNewVersion(null)} + /> + ) : null} + + + ); } - function conversationTitle(session: Session): string { const title = sessionDisplayTitle(session.title, session.harness); return title === "New session" ? "" : title; diff --git a/src/chrome/Composer.tsx b/src/chrome/Composer.tsx index 8fa6df44..d302512c 100644 --- a/src/chrome/Composer.tsx +++ b/src/chrome/Composer.tsx @@ -11,6 +11,7 @@ import { Pencil, Play, Plus, + Share, Square, StickyNote, Trash2, @@ -68,7 +69,7 @@ import type { MessageQueueStatus, QueuedMessage, RuntimeMode, - TurnIntent, + ComposerTurnOptions, } from "../lib/session"; import { HARNESS_TITLE, harnessSupportsAttachments } from "../lib/session"; import type { @@ -185,7 +186,7 @@ type Props = { onSubmit: ( text: string, attachments: Attachment[], - options?: { intent?: TurnIntent }, + options?: ComposerTurnOptions, ) => void; onStop?: () => void; onCompactContext?: () => boolean; @@ -473,6 +474,7 @@ export function Composer({ const [fileDrag, setFileDrag] = useState(false); const [plusOpen, setPlusOpen] = useState(false); const [planSelected, setPlanSelected] = useState(false); + const [orchestrationSelected, setOrchestrationSelected] = useState(false); const [slash, setSlash] = useState(null); const [skillActive, setSkillActive] = useState(0); const [creatingSkill, setCreatingSkill] = useState(false); @@ -836,7 +838,10 @@ export function Composer({ syncHasValue(next, attachmentsRef.current); setSlash(null); setCreatingSkill(false); - if (planCommand) setPlanSelected(true); + if (planCommand) { + setPlanSelected(true); + setOrchestrationSelected(false); + } el.focus(); }, [onPlaceInFolder, openSessionFolderPicker, syncHasValue], @@ -1011,7 +1016,12 @@ export function Composer({ const files = attachments; if (!text && files.length === 0 && !noteCard && !handoffCard) return; onSubmit(text, files, { - intent: planSelected || command.planning ? "plan" : "default", + intent: + planSelected || command.planning + ? "plan" + : orchestrationSelected + ? "orchestrate" + : "default", }); if (!ref.current) return; ref.current.value = ""; @@ -1020,6 +1030,7 @@ export function Composer({ onDraftChange?.(""); setAttachments([]); setPlanSelected(false); + setOrchestrationSelected(false); setSessionFolderSelected(false); setSessionFolderOpen(false); setPlusOpen(false); @@ -1452,9 +1463,9 @@ export function Composer({ Upload file - + {attachmentsSupported - ? "Attach files or images to this message" + ? "Attach files or images" : `${HARNESS_TITLE[harness]} does not support attachments`} @@ -1465,6 +1476,7 @@ export function Composer({ onMouseDown={(e) => e.preventDefault()} onClick={() => { setPlanSelected((selected) => !selected); + setOrchestrationSelected(false); setPlusOpen(false); ref.current?.focus(); }} @@ -1473,17 +1485,64 @@ export function Composer({ Plan mode - - Create a plan to review before building + + Review a plan before building {planSelected ? ( ) : null} + {!hideTopBar && ( + + )} ) : null} + {orchestrationSelected && ( + + )} {planSelected ? ( - - +
@@ -309,18 +275,10 @@ export function LinkedWorkItemUpdateNotice({ ) : null}
- +
- , - document.body, + ); } diff --git a/src/chrome/MenuBar.tsx b/src/chrome/MenuBar.tsx index fdc8f493..bfbcf9ac 100644 --- a/src/chrome/MenuBar.tsx +++ b/src/chrome/MenuBar.tsx @@ -15,6 +15,7 @@ type Props = { onShowSourceControl?: () => void; onCloseCurrentTab?: () => void; onCloseOtherTabs?: () => void; + onCloseAllTabs?: () => void; onPickProject?: () => void; onFindInProject?: () => void; onSearch?: () => void; @@ -34,6 +35,7 @@ export function MenuBar({ onShowSourceControl, onCloseCurrentTab, onCloseOtherTabs, + onCloseAllTabs, onPickProject, onFindInProject, onSearch, @@ -141,6 +143,9 @@ export function MenuBar({ case "close_other_tabs": onCloseOtherTabs?.(); break; + case "close_all_tabs": + onCloseAllTabs?.(); + break; case "toggle_sidebar": onToggleSidebar(); break; @@ -168,6 +173,7 @@ export function MenuBar({ closeMenu, onCloseCurrentTab, onCloseOtherTabs, + onCloseAllTabs, onFindInProject, onGoToFile, onNew, @@ -205,6 +211,12 @@ export function MenuBar({ label: "Close Other Tabs", shortcut: `${MOD}${ALT}T`, }, + { + kind: "item", + id: "close_all_tabs", + label: "Close All Tabs", + shortcut: `${MOD}${SHIFT}W`, + }, { kind: "sep" }, { kind: "item", id: "check_for_updates", label: "Check for Updates…" }, ]; diff --git a/src/chrome/OrchestrationActions.ts b/src/chrome/OrchestrationActions.ts new file mode 100644 index 00000000..05d6ee45 --- /dev/null +++ b/src/chrome/OrchestrationActions.ts @@ -0,0 +1,38 @@ +import { createContext } from "react"; +import type { OrchestrationProposal } from "../lib/orchestrationPlan"; +import type { HarnessId } from "../lib/session"; + +export type OrchestrationWorkerDetail = { + sessionId: string; + leadId: string; + title: string; + harness: HarnessId; +}; + +/** + * The lead's sidebar card lists workers. Approvals still go to the lead, not + * to the user; `openDetails` is the one way to watch a worker's transcript. + */ +export const OrchestrationWorkers = createContext<{ + selectedId: string | null; + inspect(sessionId: string | null): void; + /** + * Open this worker beside its lead, for when the card's model line is not + * enough. Absent wherever the card renders without a workspace behind it. + */ + openDetails?(worker: OrchestrationWorkerDetail): void; +}>({ selectedId: null, inspect: () => {} }); + +// Shared by transcript cards in both ordinary and split session panes. +export const OrchestrationActions = createContext<{ + update( + leadId: string, + blockId: string, + proposal: OrchestrationProposal, + ): void; + confirm(leadId: string, blockId: string): Promise; + retry(leadId: string, blockId: string): void; + open(sessionId: string): void; + /** Open every worker of a run as tabs beside the lead, not sidebar rows. */ + openAgents?(workers: OrchestrationWorkerDetail[]): void; +} | null>(null); diff --git a/src/chrome/OrchestrationFlow.test.ts b/src/chrome/OrchestrationFlow.test.ts new file mode 100644 index 00000000..f61e3863 --- /dev/null +++ b/src/chrome/OrchestrationFlow.test.ts @@ -0,0 +1,747 @@ +// @vitest-environment happy-dom +import { act, createElement, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(async () => []), + isTauri: () => false, + convertFileSrc: (path: string) => path, +})); +vi.mock("@tauri-apps/api/event", () => ({ listen: async () => () => {} })); +vi.mock("@tauri-apps/api/webview", () => ({ + getCurrentWebview: () => ({ onDragDropEvent: async () => () => {} }), +})); +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ onFocusChanged: async () => () => {} }), +})); +vi.mock("../lib/harness/availability", () => ({ + getHarnessAvailabilitySnapshot: () => 0, + hasProbedHarnessAvailability: () => true, + isHarnessAvailable: () => true, + harnessUnavailableHint: () => "", + probeHarnessAvailability: async () => {}, + subscribeHarnessAvailability: () => () => {}, +})); +vi.mock("../lib/harness/registry", async (importOriginal) => ({ + ...(await importOriginal()), + refreshHarnessCatalogs: async () => {}, + isLiveHarness: () => true, +})); +vi.mock("./ModelPicker", () => ({ ModelPicker: () => null })); +vi.mock("./SessionReview", () => ({ + SessionReview: ({ undoLocked }: { undoLocked: boolean }) => + createElement("div", { "data-review-undo-locked": String(undoLocked) }), +})); +vi.mock("../lib/orchestration", async (importOriginal) => ({ + ...(await importOriginal()), + orchestrator: { + subscribe: () => () => {}, + snapshot: () => emptyRuns, + hydrate: async () => {}, + waitingFor: () => undefined, + }, +})); + +import { Composer } from "./Composer"; +import { AgentTranscript } from "../surfaces/AgentTranscript"; +import { SessionPane } from "../surfaces/SessionPane"; +import { + OrchestrationActions, + OrchestrationWorkers, +} from "./OrchestrationActions"; +import { newSession } from "../lib/session"; +import type { OrchestrationRun, OrchestrationTask } from "../lib/orchestration"; +import type { OrchestrationSummary } from "../lib/orchestrationSummary"; +import { OrchestrationSidebarAgents } from "./OrchestrationSidebarAgents"; +import { + modelsFor, + setHarnessModels, + resetHarnessModelOverlays, +} from "../lib/models"; +import type { OrchestrationProposal } from "../lib/orchestrationPlan"; +import { invoke } from "@tauri-apps/api/core"; + +const emptyRuns: OrchestrationRun[] = []; +let container: HTMLDivElement; +let root: Root; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + localStorage.clear(); + setHarnessModels("codex", [ + { id: "codex:one", harness: "codex", name: "Worker One" }, + { id: "codex:two", harness: "codex", name: "Worker Two" }, + ]); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(() => { + act(() => root.unmount()); + container.remove(); + resetHarnessModelOverlays(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + emptyRuns.length = 0; +}); +const button = (text: string) => + [...document.querySelectorAll("button")].find((element) => + element.textContent?.includes(text), + )!; +async function click(element: Element) { + expect(element).toBeTruthy(); + await act(async () => { + (element as HTMLElement).click(); + }); +} +async function press(element: Element, key: string) { + await act(async () => { + element.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); + }); +} +async function input( + element: HTMLInputElement | HTMLTextAreaElement, + value: string, +) { + const prototype = + element instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : HTMLInputElement.prototype; + await act(async () => { + Object.getOwnPropertyDescriptor(prototype, "value")!.set!.call( + element, + value, + ); + element.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +describe("orchestration composer and card", () => { + it("toggles a badge through the plus menu and submits without a team setup or execution", async () => { + const model = modelsFor("codex")[0]; + const submit = vi.fn(); + await act(async () => + root.render( + createElement(Composer, { + focused: false, + harness: "codex", + model: model.id, + runtimeMode: "supervised", + cwd: "/repo", + executionCwd: "/repo", + sessionId: "lead", + initialDraft: "Build settings", + hideProjectPicker: true, + hideBranchPicker: true, + onFocus: () => {}, + onCwdChange: () => {}, + onModelChange: () => {}, + onRuntimeModeChange: () => {}, + onSubmit: submit, + }), + ), + ); + await click( + document.querySelector( + 'button[aria-label="Add files or choose a mode"]', + )!, + ); + expect(button("Plan mode")).toBeTruthy(); + await click(button("Orchestrator")); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect( + document.querySelector('[aria-label="Turn off Orchestrator mode"]'), + ).not.toBeNull(); + await click( + document.querySelector('[aria-label="Turn off Orchestrator mode"]')!, + ); + expect( + document.querySelector('[aria-label="Turn off Orchestrator mode"]'), + ).toBeNull(); + await click( + document.querySelector( + 'button[aria-label="Add files or choose a mode"]', + )!, + ); + await click(button("Plan mode")); + expect( + document.querySelector('[title="Turn off Plan mode"]'), + ).not.toBeNull(); + await click( + document.querySelector( + 'button[aria-label="Add files or choose a mode"]', + )!, + ); + await click(button("Orchestrator")); + expect(document.querySelector('[title="Turn off Plan mode"]')).toBeNull(); + const textarea = container.querySelector("textarea")!; + await act(async () => + textarea.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ), + ); + expect(submit).toHaveBeenCalledWith("Build settings", [], { + intent: "orchestrate", + }); + expect( + document.querySelector('[aria-label="Turn off Orchestrator mode"]'), + ).toBeNull(); + expect( + vi + .mocked(invoke) + .mock.calls.some(([command]) => command === "control_enable"), + ).toBe(false); + }); + it("lets the user change an assignment's model and waits for explicit confirmation", async () => { + const choices = [ + { harness: "codex" as const, model: "codex:one", name: "Worker One" }, + { harness: "claude" as const, model: "claude:two", name: "Worker Two" }, + ]; + const initial: OrchestrationProposal = { + version: 1, + leadId: "lead", + cwd: "/repo", + request: "Build", + author: choices[0], + settings: { choices, maxWorkers: 2 }, + status: "ready", + title: "Build settings", + summary: "Split UI and persistence", + tasks: [ + { + id: "ui", + title: "Settings UI", + prompt: "Build the form", + harness: "codex", + model: "codex:one", + files: ["src/settings"], + dependsOn: [], + }, + ], + }; + const confirm = vi.fn(async (_proposal: OrchestrationProposal) => {}); + function Card() { + const [proposal, setProposal] = useState(initial); + return createElement( + OrchestrationActions.Provider, + { + value: { + update: (_id, _block, edited) => setProposal(edited), + confirm: async () => confirm(proposal), + retry: () => {}, + open: () => {}, + }, + }, + createElement(AgentTranscript, { + blocks: [ + { + id: "card", + role: "plan", + text: "Readable plan", + orchestration: proposal, + }, + ], + }), + ); + } + await act(async () => root.render(createElement(Card))); + expect(container.textContent).toContain("Settings UI"); + expect( + container.querySelector("[data-orchestration-review]"), + ).not.toBeNull(); + expect(container.textContent).not.toContain("Readable plan"); + // The lead's summary stays out of the card; the task rows carry the plan. + expect(container.textContent).not.toContain("Split UI and persistence"); + expect(confirm).not.toHaveBeenCalled(); + await click( + document.querySelector('[aria-label="Model for Settings UI"]')!, + ); + expect(document.activeElement).toBe( + document.querySelector('[aria-label="Search assignment models"]'), + ); + // Marks the surface as a picker, which is what keeps the composer from + // pulling focus straight back out of the search field. + expect( + document + .querySelector('[aria-label="Search assignment models"]') + ?.closest("[data-model-picker]"), + ).not.toBeNull(); + await input( + document.querySelector('[aria-label="Search assignment models"]')!, + "Claude", + ); + expect( + document.querySelector("[data-popover-side]")?.textContent, + ).not.toContain("Worker One"); + // Arrows walk the list from the search field and Enter takes the highlight. + await input( + document.querySelector('[aria-label="Search assignment models"]')!, + "Worker", + ); + await press( + document.querySelector('[aria-label="Search assignment models"]')!, + "ArrowDown", + ); + await press( + document.querySelector('[aria-label="Search assignment models"]')!, + "Enter", + ); + expect(container.textContent).toContain("Worker Two"); + // The pointer reaches the same rows. + await click( + document.querySelector('[aria-label="Model for Settings UI"]')!, + ); + await click( + [...document.querySelectorAll('[role="option"]')].find((row) => + row.textContent?.includes("Worker Two"), + )!, + ); + expect(container.textContent).toContain("Worker Two"); + expect(container.querySelector("textarea")).toBeNull(); + await click( + document.querySelector('[aria-label="Details for Settings UI"]')!, + ); + await input( + document.querySelector('[aria-label="Instructions for task 1"]')!, + "Build the accessible form and check keyboard navigation", + ); + await click( + [ + ...document.querySelectorAll('[aria-label="Parallel workers"] button'), + ].find((option) => option.textContent === "1")!, + ); + expect(confirm).not.toHaveBeenCalled(); + await click(button("Confirm & start")); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + settings: expect.objectContaining({ maxWorkers: 1 }), + tasks: [ + expect.objectContaining({ + harness: "claude", + model: "claude:two", + prompt: "Build the accessible form and check keyboard navigation", + }), + ], + }), + ); + }); + it("never offers confirmation for a proposal that is still being generated", async () => { + const proposal: OrchestrationProposal = { + version: 1, + leadId: "lead", + cwd: "/repo", + request: "Build", + author: { harness: "codex", model: "test", name: "Lead" }, + settings: { choices: [], maxWorkers: 2 }, + status: "planning", + title: "Planning", + summary: "", + tasks: [], + }; + await act(async () => + root.render( + createElement(AgentTranscript, { + busy: true, + blocks: [ + { + id: "draft", + role: "plan", + text: "", + orchestration: proposal, + }, + ], + }), + ), + ); + expect(container.querySelector("[data-orchestration-review]")).toBeNull(); + expect(container.textContent).not.toContain("Confirm & start"); + }); + it("opens worker panes from View agents instead of the sidebar", async () => { + const open = vi.fn(); + const openAgents = vi.fn(); + const task: OrchestrationTask = { + id: "engine", + sessionId: "worker-a", + title: "Audit engine", + harness: "codex", + model: "codex:two", + prompt: "Review the engine", + files: [], + scopes: [], + dependsOn: [], + status: "cancelled", + accepted: false, + delivered: false, + result: "", + }; + emptyRuns.push({ + version: 1, + leadId: "lead", + cwd: "/repo", + status: "stopped", + allowedHarnesses: ["codex", "cursor"], + proposalId: "card", + maxWorkers: 2, + cli: "monocode", + tasks: [ + task, + { + ...task, + id: "ui", + sessionId: "worker-b", + title: "Audit UI", + harness: "cursor", + model: "cursor:composer-2.5", + status: "completed", + }, + ], + continuations: 0, + requests: {}, + }); + await act(async () => + root.render( + createElement( + OrchestrationActions.Provider, + { + value: { + update: () => {}, + confirm: async () => {}, + retry: () => {}, + open, + openAgents, + }, + }, + createElement(AgentTranscript, { + blocks: [ + { + id: "card", + role: "plan", + text: "", + orchestration: { + version: 1, + leadId: "lead", + cwd: "/repo", + request: "Review", + author: { + harness: "codex", + model: "codex:one", + name: "Lead", + }, + settings: { choices: [], maxWorkers: 2 }, + status: "approved", + title: "Review local-agent orchestration branch", + summary: "", + tasks: [ + { + id: "engine", + title: "Audit engine", + prompt: "Review the engine", + harness: "codex", + model: "codex:two", + files: [], + dependsOn: [], + }, + { + id: "ui", + title: "Audit UI", + prompt: "Review the UI", + harness: "cursor", + model: "cursor:composer-2.5", + files: [], + dependsOn: [], + }, + ], + }, + }, + ], + }), + ), + ), + ); + await click(button("View agents")); + expect(open).not.toHaveBeenCalled(); + expect(openAgents).toHaveBeenCalledWith([ + { + sessionId: "worker-a", + leadId: "lead", + title: "Audit engine", + harness: "codex", + }, + { + sessionId: "worker-b", + leadId: "lead", + title: "Audit UI", + harness: "cursor", + }, + ]); + }); + it("gives the lead's transcript and composer the whole pane", async () => { + const lead = { + ...newSession("codex", "/repo", "codex:one"), + id: "lead", + blocks: [ + { id: "user", role: "user" as const, text: "Build the feature" }, + { + id: "answer", + role: "assistant" as const, + text: "I am coordinating the work.", + }, + ], + }; + const task: OrchestrationTask = { + id: "task", + sessionId: "worker", + title: "UI worker", + harness: "codex", + model: "codex:two", + prompt: "Build UI", + files: ["ui"], + scopes: ["/repo/ui"], + dependsOn: [], + status: "running", + accepted: false, + delivered: false, + result: "", + }; + emptyRuns.push({ + version: 1, + leadId: lead.id, + cwd: lead.cwd, + status: "active", + allowedHarnesses: ["codex"], + maxWorkers: 2, + cli: "monocode", + tasks: [ + task, + { + ...task, + id: "second-task", + sessionId: "second", + title: "Check worker", + }, + ], + continuations: 0, + requests: {}, + }); + const submit = vi.fn(); + const approve = vi.fn(); + const reply = vi.fn(); + const open = vi.fn(); + const noop = () => {}; + function LeadPane({ id = lead.id, undoLocked = false } = {}) { + const [selectedId, inspect] = useState(null); + return createElement( + OrchestrationActions.Provider, + { value: { update: noop, confirm: async () => {}, retry: noop, open } }, + createElement( + OrchestrationWorkers.Provider, + { value: { selectedId, inspect } }, + createElement(SessionPane, { + session: { ...lead, id }, + reviewUndoLocked: undoLocked, + visible: true, + focused: true, + inSplit: false, + composerFocused: true, + recents: [], + onFocus: noop, + onClose: noop, + onCwdChange: noop, + onBranchChange: noop, + onModelChange: noop, + onModelSettingsChange: noop, + onRuntimeModeChange: noop, + onSubmit: submit, + onStop: noop, + onCompactContext: () => false, + onPlaceSessionInFolder: noop, + onDeleteQueuedMessage: noop, + onEditQueuedMessage: noop, + onQueuedMessageEditingChange: noop, + onSteerQueuedMessage: noop, + onResumeQueue: noop, + onApproval: approve, + onQuestionReply: reply, + onOpenFile: noop, + onOpenDiff: noop, + onOpenPlan: noop, + onBuildPlan: noop, + onNewTerminal: noop, + }), + ), + ); + } + await act(async () => root.render(createElement(LeadPane))); + // Agents live on the sidebar card now; nothing narrows the lead's pane. + expect(container.querySelector("[data-orchestration-agents]")).toBeNull(); + expect(container.textContent).toContain("I am coordinating the work."); + expect(container.textContent).not.toContain("UI worker"); + expect(open).not.toHaveBeenCalled(); + const composer = container.querySelector("textarea")!; + await input(composer, "Ask the UI worker to check keyboard navigation."); + await act(async () => { + composer.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + }); + expect(submit).toHaveBeenCalledWith( + "lead", + "Ask the UI worker to check keyboard navigation.", + [], + { intent: "default" }, + ); + for (const status of ["active", "paused", "finished", "stopped"] as const) { + emptyRuns[0] = { ...emptyRuns[0], status }; + for (const id of ["lead", "worker", "unrelated"]) { + await act(async () => root.render(createElement(LeadPane, { id }))); + expect( + container + .querySelector("[data-review-undo-locked]") + ?.getAttribute("data-review-undo-locked"), + ).toBe( + String( + id !== "unrelated" && (status === "active" || status === "paused"), + ), + ); + } + } + await act(async () => + root.render(createElement(LeadPane, { undoLocked: true })), + ); + expect( + container + .querySelector("[data-review-undo-locked]") + ?.getAttribute("data-review-undo-locked"), + ).toBe("true"); + }); + + it("shows a blocked agent as the lead's to answer, not the user's", async () => { + const summary: OrchestrationSummary = { + status: "active", + live: true, + tasks: [ + { + sessionId: "worker", + title: "UI worker", + harness: "codex", + model: "codex:two", + status: "running", + needsInput: true, + }, + { + sessionId: "second", + title: "Check worker", + harness: "codex", + model: "codex:two", + status: "running", + needsInput: true, + }, + ], + }; + function Card() { + const [selectedId, inspect] = useState(null); + return createElement( + OrchestrationWorkers.Provider, + { value: { selectedId, inspect } }, + createElement(OrchestrationSidebarAgents, { leadId: "lead", summary }), + ); + } + await act(async () => root.render(createElement(Card))); + expect(container.textContent).toContain("UI worker"); + // A blocked agent expands itself so its model is visible, but the + // approval still belongs to the lead, not to this card. + expect( + container.querySelector( + '[data-orchestration-agent="worker"] [aria-expanded="true"]', + ), + ).not.toBeNull(); + expect(container.textContent).toContain("Worker Two"); + expect(button("Allow")).toBeUndefined(); + expect(button("Deny")).toBeUndefined(); + expect(container.textContent).not.toContain("Which check should I run?"); + }); + + it("inspects an agent without taking the card's click or its tab", async () => { + const summary: OrchestrationSummary = { + status: "active", + live: true, + tasks: [ + { + sessionId: "worker", + title: "UI worker", + harness: "codex", + model: "codex:two", + status: "running", + }, + ], + }; + const selectCard = vi.fn(); + const openDetails = vi.fn(); + function Card() { + const [selectedId, inspect] = useState(null); + return createElement( + "div", + { onClick: selectCard }, + createElement( + OrchestrationWorkers.Provider, + { value: { selectedId, inspect, openDetails } }, + createElement(OrchestrationSidebarAgents, { + leadId: "lead", + summary, + }), + ), + ); + } + await act(async () => root.render(createElement(Card))); + await click( + container.querySelector('[aria-label="Agent details: UI worker"]')!, + ); + // Expanding a row is not a request to open the lead's tab. + expect(selectCard).not.toHaveBeenCalled(); + await click(button("See details")); + expect(openDetails).toHaveBeenCalledWith({ + sessionId: "worker", + leadId: "lead", + title: "UI worker", + harness: "codex", + }); + expect(selectCard).not.toHaveBeenCalled(); + }); + + it("expands any number of agents at once", async () => { + const summary: OrchestrationSummary = { + status: "active", + live: true, + tasks: ["one", "two", "three"].map((id) => ({ + sessionId: id, + title: `Agent ${id}`, + harness: "codex" as const, + model: "codex:two", + status: "running" as const, + })), + }; + function Card() { + const [selectedId, inspect] = useState(null); + return createElement( + OrchestrationWorkers.Provider, + { value: { selectedId, inspect } }, + createElement(OrchestrationSidebarAgents, { leadId: "lead", summary }), + ); + } + await act(async () => root.render(createElement(Card))); + const row = (id: string) => + container.querySelector( + `[data-orchestration-agent="${id}"] button`, + )!; + const openIds = () => + [...container.querySelectorAll("[data-orchestration-agent]")] + .filter((entry) => entry.querySelector("[aria-expanded=true]")) + .map((entry) => entry.getAttribute("data-orchestration-agent")); + await click(row("one")); + await click(row("three")); + expect(openIds()).toEqual(["one", "three"]); + // Collapsing one leaves the other where it was. + await click(row("one")); + expect(openIds()).toEqual(["three"]); + }); +}); diff --git a/src/chrome/OrchestrationPreview.tsx b/src/chrome/OrchestrationPreview.tsx new file mode 100644 index 00000000..ebd5ae3a --- /dev/null +++ b/src/chrome/OrchestrationPreview.tsx @@ -0,0 +1,610 @@ +import { + useContext, + useEffect, + useLayoutEffect, + useRef, + useState, + useSyncExternalStore, + type KeyboardEvent as ReactKeyboardEvent, +} from "react"; +import { HARNESS_TITLE, type Block } from "../lib/session"; +import type { + OrchestrationChoice, + ProposedTask, +} from "../lib/orchestrationPlan"; +import { orchestrator } from "../lib/orchestration"; +import { resizeComposer } from "../lib/composerResize"; +import { useLockOverscroll } from "../hooks/useLockOverscroll"; +import { OrchestrationActions } from "./OrchestrationActions"; +import { HarnessIcon } from "./HarnessIcon"; +import { Popover } from "./Popover"; +import { + Check, + ChevronDown, + ChevronRight, + CircleDashed, + CircleHelp, + MessageMultiple, + Play, + Search, +} from "./icons"; + +function AssignmentModel({ + task, + choices, + onChange, +}: { + task: ProposedTask; + choices: OrchestrationChoice[]; + onChange(choice: OrchestrationChoice): void; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [active, setActive] = useState(0); + const anchor = useRef(null); + const search = useRef(null); + const activeRef = useRef(null); + const listOverscroll = useLockOverscroll(); + const selected = choices.find( + (choice) => choice.harness === task.harness && choice.model === task.model, + ); + const matches = choices.filter((choice) => + `${choice.name} ${choice.model} ${HARNESS_TITLE[choice.harness]}` + .toLowerCase() + .includes(query.trim().toLowerCase()), + ); + // Two things fought this field for focus. The composer takes focus back + // unless a picker surface is in the DOM, which `data-model-picker` below + // now declares; and the popover measures itself with `visibility: hidden` + // on its first pass, where nothing can be focused. Placement flushes in a + // layout effect ahead of this one, so focus now for the pass that is + // already on screen and again next frame, once placement has landed. + // Re-focusing a focused field is a no-op. + useEffect(() => { + if (!open) return; + search.current?.focus({ preventScroll: true }); + const frame = requestAnimationFrame(() => + search.current?.focus({ preventScroll: true }), + ); + return () => cancelAnimationFrame(frame); + }, [open]); + useEffect(() => { + activeRef.current?.scrollIntoView({ block: "nearest" }); + }, [active]); + const pick = (choice: OrchestrationChoice) => { + onChange(choice); + setOpen(false); + anchor.current?.focus(); + }; + // Arrows walk the list from the search field, the way the app's other + // pickers work. The keys stop here so the transcript underneath does not + // scroll along with the highlight. + const onSearchKey = (event: ReactKeyboardEvent) => { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + event.stopPropagation(); + if (!matches.length) return; + const step = event.key === "ArrowDown" ? 1 : -1; + setActive((index) => + Math.min(matches.length - 1, Math.max(0, index + step)), + ); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + const choice = matches[active]; + if (choice) pick(choice); + } + }; + return ( +
+ + {open && ( + setOpen(false)} + data-model-picker + className="flex flex-col overflow-hidden" + > + +
+ {matches.map((choice, index) => ( + + ))} + {!matches.length && ( +

+ No matching models +

+ )} +
+
+ )} +
+ ); +} + +function WorkerHelp() { + const [hovered, setHovered] = useState(false); + const [open, setOpen] = useState(false); + const anchor = useRef(null); + return ( + <> + + {(hovered || open) && ( + setOpen(false)} + className={`px-2.5 py-2 ${open ? "" : "pointer-events-none"}`} + > +
+ How many workers run at once +
+
+ The rest of the tasks wait their turn, and a task that depends on + another waits for it either way. Every worker edits this same + project folder, so a lower number means fewer changes landing in it + at the same time. +
+
+ )} + + ); +} + +function InstructionsField({ + label, + value, + className, + onChange, +}: { + label: string; + value: string; + className: string; + onChange(value: string): void; +}) { + const ref = useRef(null); + const lockOverscroll = useLockOverscroll(); + // The same growth the composer uses: fit the text, stop at `max-h-40` and + // scroll from there. The value is controlled, so one layout effect covers + // typing and edits that arrive from the lead alike. + useLayoutEffect(() => { + if (ref.current) resizeComposer(ref.current); + }, [value]); + return ( +