From ce050a6da7208da86693c7bf3bfbbc5f29c4b942 Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Thu, 26 Mar 2026 14:11:52 -0400 Subject: [PATCH 1/2] feat: idle-based ACP timeout with dual-deadline architecture --- .github/workflows/sprout-desktop-release.yml | 32 +- crates/sprout-acp/README.md | 5 +- crates/sprout-acp/src/acp.rs | 337 +++++++++++++++++- crates/sprout-acp/src/config.rs | 121 ++++++- crates/sprout-acp/src/main.rs | 12 +- crates/sprout-acp/src/pool.rs | 193 +++++++--- desktop/RELEASING.md | 105 +++--- desktop/scripts/build-release-config.mjs | 6 +- desktop/scripts/bump-version.mjs | 54 --- desktop/scripts/check-file-sizes.mjs | 6 +- .../scripts/publish-dmg-to-github-release.mjs | 18 +- .../publish-updater-to-github-release.mjs | 16 +- desktop/scripts/set-version-from-tag.mjs | 38 ++ desktop/src-tauri/build.rs | 11 + desktop/src-tauri/src/commands/agents.rs | 6 + desktop/src-tauri/src/commands/messages.rs | 19 + desktop/src-tauri/src/events.rs | 14 + desktop/src-tauri/src/lib.rs | 1 + .../src-tauri/src/managed_agents/runtime.rs | 23 +- desktop/src-tauri/src/managed_agents/types.rs | 12 + desktop/src-tauri/src/relay.rs | 19 +- desktop/src/app/AppShell.tsx | 44 ++- desktop/src/app/ChannelPane.tsx | 16 + desktop/src/app/useChannelPaneHandlers.ts | 54 ++- .../agents/ui/AddAgentToChannelDialog.tsx | 6 +- .../agents/ui/AddTeamToChannelDialog.tsx | 6 +- .../features/agents/ui/BatchImportDialog.tsx | 6 +- .../features/agents/ui/CreateAgentDialog.tsx | 6 +- .../src/features/agents/ui/PersonaDialog.tsx | 6 +- desktop/src/features/agents/ui/TeamDialog.tsx | 6 +- .../features/channels/useUnreadChannels.ts | 22 +- desktop/src/features/messages/hooks.ts | 35 ++ .../messages/lib/formatTimelineMessages.ts | 33 +- desktop/src/features/messages/types.ts | 1 + .../features/messages/ui/MessageActionBar.tsx | 32 +- .../features/messages/ui/MessageComposer.tsx | 93 ++++- .../src/features/messages/ui/MessageRow.tsx | 12 + .../features/messages/ui/MessageTimeline.tsx | 3 + .../messages/ui/TimelineMessageList.tsx | 7 + .../features/sidebar/ui/SidebarSection.tsx | 11 +- desktop/src/shared/api/tauri.ts | 14 + desktop/src/shared/api/types.ts | 4 + desktop/src/shared/constants/kinds.ts | 1 + desktop/src/shared/ui/sidebar.tsx | 2 +- desktop/src/testing/e2eBridge.ts | 8 + justfile | 70 +--- 46 files changed, 1217 insertions(+), 329 deletions(-) delete mode 100644 desktop/scripts/bump-version.mjs create mode 100644 desktop/scripts/set-version-from-tag.mjs diff --git a/.github/workflows/sprout-desktop-release.yml b/.github/workflows/sprout-desktop-release.yml index ebd35e9e84..536cf8e7eb 100644 --- a/.github/workflows/sprout-desktop-release.yml +++ b/.github/workflows/sprout-desktop-release.yml @@ -15,6 +15,9 @@ jobs: permissions: id-token: write contents: write + env: + RELEASE_VERSION: '' # set in the "Extract version from tag" step + SPROUT_RELAY_URL: wss://sprout-oss.stage.blox.sqprod.co steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -27,16 +30,19 @@ jobs: workspaces: desktop/src-tauri key: desktop-release-aarch64-apple-darwin - - name: Validate tag matches desktop versions + - name: Extract version from tag run: | - TAG_VERSION="${GITHUB_REF#refs/tags/desktop/v}" - PACKAGE_VERSION="$(node -p "require('./desktop/package.json').version")" - TAURI_VERSION="$(node -p "require('./desktop/src-tauri/tauri.conf.json').version")" - CARGO_VERSION="$(grep '^version' desktop/src-tauri/Cargo.toml | head -1 | sed 's/version = "//;s/"//')" - if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ] || [ "$TAG_VERSION" != "$TAURI_VERSION" ] || [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then - echo "::error::Tag version ($TAG_VERSION) must match package.json ($PACKAGE_VERSION), tauri.conf.json ($TAURI_VERSION), and Cargo.toml ($CARGO_VERSION)" - exit 1 - fi + RELEASE_VERSION="${GITHUB_REF#refs/tags/desktop/v}" + echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "$GITHUB_ENV" + echo "Release version: ${RELEASE_VERSION}" + + - name: Set version from tag + working-directory: desktop + run: node scripts/set-version-from-tag.mjs "$RELEASE_VERSION" + + - name: Regenerate Cargo lockfile + working-directory: desktop/src-tauri + run: cargo generate-lockfile - name: Validate release secrets env: @@ -97,14 +103,13 @@ jobs: - name: Create DMG from signed app run: | - VERSION="$(node -p "require('./desktop/package.json').version")" DMG_DIR="desktop/src-tauri/target/release/bundle/dmg" mkdir -p "${DMG_DIR}" rm -f "${DMG_DIR}"/*.dmg hdiutil create -volname "Sprout" \ -srcfolder desktop/src-tauri/target/release/bundle/macos/Sprout.app \ -ov -format UDZO \ - "${DMG_DIR}/Sprout_${VERSION}_aarch64.dmg" + "${DMG_DIR}/Sprout_${RELEASE_VERSION}_aarch64.dmg" - name: Create updater archive from signed app env: @@ -120,10 +125,9 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - VERSION="${GITHUB_REF#refs/tags/desktop/v}" gh release create "$GITHUB_REF_NAME" \ --repo "$GITHUB_REPOSITORY" \ - --title "Sprout Desktop v${VERSION}" \ + --title "Sprout Desktop v${RELEASE_VERSION}" \ --notes "See the assets to download and install this version." - name: Publish updater alias @@ -131,6 +135,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} GITHUB_REPOSITORY: ${{ github.repository }} + VERSION: ${{ env.RELEASE_VERSION }} run: pnpm run release:updater:publish - name: Publish DMG alias @@ -138,4 +143,5 @@ jobs: env: GH_TOKEN: ${{ github.token }} GITHUB_REPOSITORY: ${{ github.repository }} + VERSION: ${{ env.RELEASE_VERSION }} run: pnpm run release:dmg:publish diff --git a/crates/sprout-acp/README.md b/crates/sprout-acp/README.md index 9c7e4acb67..5a1941a23c 100644 --- a/crates/sprout-acp/README.md +++ b/crates/sprout-acp/README.md @@ -103,12 +103,13 @@ All configuration is via environment variables (or CLI flags — every env var h | `SPROUT_ACP_AGENT_COMMAND` | no | `goose` | Agent binary to spawn. | | `SPROUT_ACP_AGENT_ARGS` | no | `acp` | Agent arguments (comma-separated). | | `SPROUT_ACP_MCP_COMMAND` | no | `sprout-mcp-server` | Path to the Sprout MCP server binary. | -| `SPROUT_ACP_TURN_TIMEOUT` | no | `300` | Max seconds per agent turn before cancellation. | +| `SPROUT_ACP_IDLE_TIMEOUT` | no | `300` | Idle timeout: max seconds of silence before cancelling a turn. Resets on any agent stdout activity. | +| `SPROUT_ACP_MAX_TURN_DURATION` | no | `3600` | Absolute wall-clock cap per turn (safety valve). | | `SPROUT_API_TOKEN` | no | — | API token (required if relay enforces token auth). | **Note:** `SPROUT_ACP_AGENT_ARGS` splits on commas. For args with values, use: `-c,key="value"`. -**Legacy env vars:** `SPROUT_ACP_PRIVATE_KEY` and `SPROUT_ACP_API_TOKEN` are still accepted as fallbacks. +**Legacy env vars:** `SPROUT_ACP_PRIVATE_KEY`, `SPROUT_ACP_API_TOKEN`, and `SPROUT_ACP_TURN_TIMEOUT` (replaced by `SPROUT_ACP_IDLE_TIMEOUT`) are still accepted as fallbacks. ### Parallel Agents & Heartbeat diff --git a/crates/sprout-acp/src/acp.rs b/crates/sprout-acp/src/acp.rs index a31106f5f3..6e3c949732 100644 --- a/crates/sprout-acp/src/acp.rs +++ b/crates/sprout-acp/src/acp.rs @@ -5,7 +5,7 @@ //! 1. [`AcpClient::spawn`] — launch agent binary as subprocess //! 2. [`AcpClient::initialize`] — protocol version negotiation //! 3. [`AcpClient::session_new`] — create session with MCP server config -//! 4. [`AcpClient::session_prompt`] — send prompt, receive streaming updates, return stop reason +//! 4. [`AcpClient::session_prompt_with_idle_timeout`] — send prompt with idle/hard deadline, return stop reason //! 5. [`AcpClient::session_cancel`] / [`AcpClient::cancel_with_cleanup`] — cancel in-flight turn use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -76,9 +76,11 @@ pub enum AcpError { #[error("Agent process exited unexpectedly")] AgentExited, - #[allow(dead_code)] - #[error("Turn timed out")] - Timeout, + #[error("Idle timeout — no agent activity for {0:?}")] + IdleTimeout(std::time::Duration), + + #[error("Hard turn timeout exceeded")] + HardTimeout, #[error("Protocol error: {0}")] Protocol(String), @@ -112,8 +114,12 @@ pub struct AcpClient { permission_responded: bool, /// The JSON-RPC id of the most recently sent `session/prompt` request. /// Used by [`cancel_with_cleanup`] to drain the correct response. - /// Set in [`session_prompt`]; consumed in [`cancel_with_cleanup`]. + /// Set in [`session_prompt_with_idle_timeout`]; consumed in [`cancel_with_cleanup`]. last_prompt_id: Option, + /// Hard deadline for the current turn, set by `session_prompt_with_idle_timeout`. + /// Inherited by `cancel_with_cleanup` so the drain loop shares the same budget + /// rather than starting a fresh timer (prevents double-jeopardy). + current_hard_deadline: Option, } impl AcpClient { @@ -161,6 +167,7 @@ impl AcpClient { pending_permission_id: None, permission_responded: false, last_prompt_id: None, + current_hard_deadline: None, }) } @@ -248,14 +255,16 @@ impl AcpClient { self.send_request("session/set_model", params).await } - /// Send `session/prompt` and block until the agent returns a stop reason. + /// Send `session/prompt` with idle-based timeout instead of wall-clock. /// - /// While waiting, incoming `session/update` notifications are logged and - /// `session/request_permission` requests are auto-approved with `allow_once`. - pub async fn session_prompt( + /// The idle deadline resets on any stdout activity from the agent. The hard + /// deadline is an absolute wall-clock cap (safety valve). + pub async fn session_prompt_with_idle_timeout( &mut self, session_id: &str, prompt_text: &str, + idle_timeout: std::time::Duration, + max_duration: std::time::Duration, ) -> Result { let params = serde_json::json!({ "sessionId": session_id, @@ -263,12 +272,48 @@ impl AcpClient { { "type": "text", "text": prompt_text } ] }); - // Record the prompt request ID before send_request increments next_id. - // Used by cancel_with_cleanup to drain the correct response. + let hard_deadline = tokio::time::Instant::now() + max_duration; + self.current_hard_deadline = Some(hard_deadline); + self.last_prompt_id = Some(self.next_id); - let result = self.send_request("session/prompt", params).await?; - self.last_prompt_id = None; // Clear after normal completion. - self.parse_stop_reason(&result) + let id = self.next_id; + self.next_id += 1; + + let msg = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "session/prompt", + "params": params, + }); + + tracing::debug!(target: "acp::wire", "→ {}", &serde_json::to_string(&msg).unwrap_or_default()); + if let Err(e) = self.write_ndjson(&msg).await { + self.last_prompt_id = None; + self.current_hard_deadline = None; + return Err(e); + } + + let result = self + .read_until_response_with_idle_timeout(id, idle_timeout, hard_deadline) + .await; + + // On timeout errors, leave current_hard_deadline set so cancel_with_cleanup + // can inherit the remaining budget. Clear it on all other outcomes. + match &result { + Ok(_) => { + self.last_prompt_id = None; + self.current_hard_deadline = None; + } + Err(AcpError::IdleTimeout(_) | AcpError::HardTimeout) => { + // Leave last_prompt_id and current_hard_deadline set — + // caller will invoke cancel_with_cleanup. + } + Err(_) => { + self.last_prompt_id = None; + self.current_hard_deadline = None; + } + } + self.parse_stop_reason(&result?) } /// Send a `session/cancel` **notification** (no `id` field, no response expected). @@ -295,7 +340,33 @@ impl AcpClient { /// 3. Continue reading until the `session/prompt` response arrives with `stopReason: "cancelled"`. /// /// Returns the final [`StopReason`] (almost always [`StopReason::Cancelled`]). - pub async fn cancel_with_cleanup(&mut self, session_id: &str) -> Result { + pub async fn cancel_with_cleanup( + &mut self, + session_id: &str, + idle_timeout: std::time::Duration, + ) -> Result { + // Inherit the hard deadline from the timed-out turn so the drain loop + // doesn't start a fresh timer (prevents double-jeopardy). If the original + // deadline is already expired or near-expired, grant a 30s floor so the + // cancel notification has time to propagate and the agent can respond. + let stored_deadline = self.current_hard_deadline.take(); + let min_cleanup_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + let hard_deadline = match stored_deadline { + Some(d) if d > min_cleanup_deadline => d, + Some(_) => { + tracing::debug!( + "original hard deadline expired or near-expired — using 30s cleanup grace" + ); + min_cleanup_deadline + } + None => { + tracing::warn!( + "cancel_with_cleanup called without current_hard_deadline — using 30s fallback" + ); + min_cleanup_deadline + } + }; + // Validate precondition before any side effects — fail fast if there's // no in-flight prompt (prevents writing permission responses or cancel // notifications to the agent when no prompt is active). @@ -321,7 +392,12 @@ impl AcpClient { // Step 2: send session/cancel notification (no id) self.session_cancel(session_id).await?; tracing::info!(target: "acp::cancel", "sent session/cancel for {session_id}"); - let result = self.read_until_response(prompt_id).await?; + // Clamp idle timeout to at least 30s during cleanup — the cancel notification + // needs time to propagate and the agent may go silent while winding down. + let cleanup_idle = idle_timeout.max(std::time::Duration::from_secs(30)); + let result = self + .read_until_response_with_idle_timeout(prompt_id, cleanup_idle, hard_deadline) + .await?; self.parse_stop_reason(&result) } @@ -450,6 +526,104 @@ impl AcpClient { } } + /// Idle-aware message loop: like [`read_until_response`] but resets an idle + /// deadline on every stdout line. Fires [`AcpError::IdleTimeout`] on silence + /// or [`AcpError::HardTimeout`] on absolute wall-clock cap. + /// + /// `hard_deadline` is an absolute `Instant` (pre-computed by the caller) so + /// that `cancel_with_cleanup` can inherit the remaining budget from the + /// original turn rather than starting a fresh timer. + async fn read_until_response_with_idle_timeout( + &mut self, + expected_id: u64, + idle_timeout: std::time::Duration, + hard_deadline: tokio::time::Instant, + ) -> Result { + use tokio::time::Instant; + + let mut idle_deadline = Instant::now() + idle_timeout; + + loop { + // Determine which deadline fires first BEFORE sleeping — this is + // the classification we'll use on timeout, immune to scheduler jitter. + let idle_fires_first = idle_deadline <= hard_deadline; + let next_deadline = if idle_fires_first { + idle_deadline + } else { + hard_deadline + }; + let remaining = next_deadline.saturating_duration_since(Instant::now()); + + let read_result = tokio::time::timeout(remaining, async { + let mut line = String::new(); + let n = self.reader.read_line(&mut line).await?; + Ok::<(usize, String), std::io::Error>((n, line)) + }) + .await; + + match read_result { + Ok(Ok((0, _))) => return Err(AcpError::AgentExited), + Ok(Ok((_, line))) => { + // Any stdout activity resets the idle clock. + idle_deadline = Instant::now() + idle_timeout; + + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + tracing::debug!(target: "acp::wire", "← {trimmed}"); + + let msg: serde_json::Value = match serde_json::from_str(trimmed) { + Ok(v) => v, + Err(e) => { + tracing::warn!( + target: "acp::wire", + "failed to parse line as JSON: {e} — skipping" + ); + continue; + } + }; + + // Check for matching response. + if let Some(id) = msg.get("id") { + if *id == serde_json::json!(expected_id) { + if let Some(error) = msg.get("error") { + return Err(AcpError::Protocol(error.to_string())); + } + return Ok(msg["result"].clone()); + } + } + + // Dispatch notifications. + if let Some(method) = msg.get("method").and_then(|v| v.as_str()) { + match method { + "session/update" => self.handle_session_update(&msg), + "session/request_permission" => { + self.handle_permission_request(&msg).await?; + } + other => { + tracing::debug!(target: "acp::wire", "ignoring unknown method: {other}"); + } + } + } + } + Ok(Err(e)) => return Err(AcpError::Io(e)), + Err(_elapsed) => { + // Classification was determined before sleeping — not + // affected by scheduler jitter between deadline and wakeup. + if idle_fires_first { + tracing::warn!("idle timeout ({idle_timeout:?}) — no agent activity"); + return Err(AcpError::IdleTimeout(idle_timeout)); + } else { + tracing::warn!("hard turn timeout exceeded"); + return Err(AcpError::HardTimeout); + } + } + } + } + } + /// Log a `session/update` notification via tracing. /// /// The discriminator field is `sessionUpdate` (not `type`) per the ACP schema. @@ -1194,4 +1368,135 @@ mod tests { }) ); } + + // ── Error variant display ───────────────────────────────────────────── + + #[test] + fn idle_timeout_error_includes_duration() { + let err = AcpError::IdleTimeout(std::time::Duration::from_secs(300)); + let msg = err.to_string(); + assert!( + msg.contains("300"), + "IdleTimeout display should include duration: {msg}" + ); + } + + #[test] + fn hard_timeout_error_display() { + let err = AcpError::HardTimeout; + let msg = err.to_string(); + assert!( + msg.contains("Hard turn timeout"), + "HardTimeout display: {msg}" + ); + } + + // ── Async integration tests with real subprocess ────────────────────── + + async fn spawn_script(script: &str) -> AcpClient { + AcpClient::spawn("bash", &["-c".into(), script.into()]) + .await + .expect("failed to spawn test script") + } + + #[tokio::test] + async fn idle_timeout_fires_on_silent_process() { + let mut client = spawn_script("sleep 10").await; + let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + let result = client + .read_until_response_with_idle_timeout( + 999, + std::time::Duration::from_millis(100), + hard_deadline, + ) + .await; + assert!( + matches!(result, Err(AcpError::IdleTimeout(_))), + "expected IdleTimeout, got {result:?}" + ); + } + + #[tokio::test] + async fn hard_timeout_fires_when_deadline_is_immediate() { + let mut client = spawn_script("while true; do echo 'noise'; sleep 0.01; done").await; + let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(1); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + let result = client + .read_until_response_with_idle_timeout( + 999, + std::time::Duration::from_secs(60), + hard_deadline, + ) + .await; + assert!( + matches!(result, Err(AcpError::HardTimeout)), + "expected HardTimeout, got {result:?}" + ); + } + + #[tokio::test] + async fn idle_resets_on_stdout_activity() { + let mut client = + spawn_script("for i in $(seq 1 10); do echo 'keepalive'; sleep 0.05; done; sleep 10") + .await; + let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + let start = std::time::Instant::now(); + let result = client + .read_until_response_with_idle_timeout( + 999, + std::time::Duration::from_millis(200), + hard_deadline, + ) + .await; + let elapsed = start.elapsed(); + assert!(elapsed >= std::time::Duration::from_millis(400)); + assert!(elapsed < std::time::Duration::from_secs(3)); + assert!(matches!(result, Err(AcpError::IdleTimeout(_)))); + } + + #[tokio::test] + async fn response_returned_when_matching_id_arrives() { + let mut client = + spawn_script(r#"echo '{"jsonrpc":"2.0","id":42,"result":{"stopReason":"end_turn"}}'"#) + .await; + let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + let result = client + .read_until_response_with_idle_timeout( + 42, + std::time::Duration::from_secs(2), + hard_deadline, + ) + .await; + assert!(result.is_ok()); + assert_eq!(result.unwrap()["stopReason"].as_str(), Some("end_turn")); + } + + #[tokio::test] + async fn agent_exit_detected_as_eof() { + let mut client = spawn_script("exit 0").await; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + let result = client + .read_until_response_with_idle_timeout( + 999, + std::time::Duration::from_secs(2), + hard_deadline, + ) + .await; + assert!(matches!(result, Err(AcpError::AgentExited))); + } + + #[tokio::test] + async fn idle_fires_before_hard_when_idle_is_shorter() { + let mut client = spawn_script("sleep 10").await; + let idle = std::time::Duration::from_millis(100); + let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + let result = client + .read_until_response_with_idle_timeout(999, idle, hard_deadline) + .await; + assert!( + matches!(result, Err(AcpError::IdleTimeout(_))), + "idle should fire before hard when idle << hard, got {result:?}" + ); + } } diff --git a/crates/sprout-acp/src/config.rs b/crates/sprout-acp/src/config.rs index a30002199c..ca02a710b5 100644 --- a/crates/sprout-acp/src/config.rs +++ b/crates/sprout-acp/src/config.rs @@ -161,8 +161,18 @@ pub struct CliArgs { )] pub mcp_command: String, - #[arg(long, env = "SPROUT_ACP_TURN_TIMEOUT", default_value = "300")] - pub turn_timeout: u64, + /// Idle timeout: max seconds of silence before killing a turn. + /// Resets on any agent stdout activity. + #[arg(long, env = "SPROUT_ACP_IDLE_TIMEOUT")] + pub idle_timeout: Option, + + /// Absolute wall-clock cap per turn (safety valve). + #[arg(long, env = "SPROUT_ACP_MAX_TURN_DURATION", default_value = "3600")] + pub max_turn_duration: u64, + + /// Deprecated: alias for --idle-timeout. If both set, --idle-timeout wins. + #[arg(long, env = "SPROUT_ACP_TURN_TIMEOUT", hide = true)] + pub turn_timeout: Option, #[arg( long, @@ -293,7 +303,8 @@ pub struct Config { pub agent_command: String, pub agent_args: Vec, pub mcp_command: String, - pub turn_timeout_secs: u64, + pub idle_timeout_secs: u64, + pub max_turn_duration_secs: u64, pub agents: u32, pub heartbeat_interval_secs: u64, pub heartbeat_prompt: Option, @@ -436,7 +447,44 @@ impl Config { agent_command, agent_args, mcp_command: args.mcp_command, - turn_timeout_secs: args.turn_timeout, + // Deprecated --turn-timeout is a fallback for backward compat. + // New deployments should use --idle-timeout exclusively. + // Precedence: explicit --idle-timeout > --turn-timeout (deprecated) > default 300. + idle_timeout_secs: { + let raw = match (args.idle_timeout, args.turn_timeout) { + (Some(idle), Some(_turn)) => { + tracing::warn!( + "--turn-timeout / SPROUT_ACP_TURN_TIMEOUT is deprecated and ignored \ + when --idle-timeout / SPROUT_ACP_IDLE_TIMEOUT is also set" + ); + idle + } + (Some(idle), None) => idle, + (None, Some(turn)) => { + tracing::warn!( + "--turn-timeout / SPROUT_ACP_TURN_TIMEOUT is deprecated; \ + use --idle-timeout / SPROUT_ACP_IDLE_TIMEOUT instead" + ); + turn + } + (None, None) => 300, // default + }; + if raw == 0 { + tracing::warn!("idle timeout of 0 is invalid — using 1s minimum"); + 1 + } else { + raw + } + }, + max_turn_duration_secs: { + let raw = args.max_turn_duration; + if raw == 0 { + tracing::warn!("max turn duration of 0 is invalid — using 60s minimum"); + 60 + } else { + raw + } + }, agents: args.agents, heartbeat_interval_secs: args.heartbeat_interval, heartbeat_prompt, @@ -461,13 +509,14 @@ impl Config { /// Human-readable summary (no secrets). pub fn summary(&self) -> String { format!( - "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} timeout={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} model={} permission_mode={}", + "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} model={} permission_mode={}", self.relay_url, self.keys.public_key().to_hex(), self.agent_command, self.agent_args.join(" "), self.mcp_command, - self.turn_timeout_secs, + self.idle_timeout_secs, + self.max_turn_duration_secs, self.agents, self.heartbeat_interval_secs, self.subscribe_mode, @@ -759,7 +808,8 @@ mod tests { agent_command: "goose".into(), agent_args: vec!["acp".into()], mcp_command: "sprout-mcp-server".into(), - turn_timeout_secs: 300, + idle_timeout_secs: 300, + max_turn_duration_secs: 3600, agents: 1, heartbeat_interval_secs: 0, heartbeat_prompt: None, @@ -1432,4 +1482,61 @@ channels = "ALL" ); } } + + // ── Idle timeout config precedence ───────────────────────────────────── + + /// Helper: resolve idle_timeout_secs using the same precedence logic as Config::from_args. + /// Precedence: explicit --idle-timeout > --turn-timeout (deprecated) > default 300. + fn resolve_idle_timeout(idle: Option, turn: Option) -> u64 { + let raw = match (idle, turn) { + (Some(idle), Some(_)) => idle, + (Some(idle), None) => idle, + (None, Some(turn)) => turn, + (None, None) => 300, + }; + if raw == 0 { + 1 + } else { + raw + } + } + + #[test] + fn idle_timeout_explicit_wins_over_deprecated() { + assert_eq!(resolve_idle_timeout(Some(120), Some(600)), 120); + } + + #[test] + fn idle_timeout_falls_back_to_deprecated_turn_timeout() { + assert_eq!(resolve_idle_timeout(None, Some(600)), 600); + } + + #[test] + fn idle_timeout_defaults_to_300_when_neither_set() { + assert_eq!(resolve_idle_timeout(None, None), 300); + } + + #[test] + fn idle_timeout_zero_clamped_to_one() { + assert_eq!(resolve_idle_timeout(Some(0), None), 1); + } + + #[test] + fn idle_timeout_zero_from_deprecated_clamped_to_one() { + assert_eq!(resolve_idle_timeout(None, Some(0)), 1); + } + + #[test] + fn test_config_summary_includes_idle_and_max_turn() { + let config = test_config(SubscribeMode::Mentions); + let summary = config.summary(); + assert!( + summary.contains("idle_timeout=300s"), + "summary should include idle_timeout: {summary}" + ); + assert!( + summary.contains("max_turn=3600s"), + "summary should include max_turn: {summary}" + ); + } } diff --git a/crates/sprout-acp/src/main.rs b/crates/sprout-acp/src/main.rs index c4a9db7de1..4046cca097 100644 --- a/crates/sprout-acp/src/main.rs +++ b/crates/sprout-acp/src/main.rs @@ -218,7 +218,8 @@ async fn main() -> Result<()> { let ctx = Arc::new(PromptContext { mcp_servers: build_mcp_servers(&config), initial_message: config.initial_message.clone(), - turn_timeout: Duration::from_secs(config.turn_timeout_secs), + idle_timeout: Duration::from_secs(config.idle_timeout_secs), + max_turn_duration: Duration::from_secs(config.max_turn_duration_secs), dedup_mode: config.dedup_mode, system_prompt: config.system_prompt.clone(), heartbeat_prompt: config.heartbeat_prompt.clone(), @@ -665,7 +666,9 @@ async fn main() -> Result<()> { // ── Shutdown sequence ───────────────────────────────────────────────────── tracing::info!("shutdown: waiting for in-flight prompts"); - let grace = Duration::from_secs(config.turn_timeout_secs + 5); + // 30 s is generous for in-flight prompts to be cancelled; using + // max_turn_duration here would cause Ctrl+C to hang for up to an hour. + let grace = Duration::from_secs(30); let shutdown_result = tokio::time::timeout(grace, async { while let Some(result) = pool.join_set.join_next().await { if let Err(e) = result { @@ -824,11 +827,12 @@ async fn handle_prompt_result( let agent_index = result.agent.index; match result.outcome { - PromptOutcome::AgentExited => { + // Fatal outcomes: the agent subprocess is dead or poisoned — respawn it. + PromptOutcome::AgentExited | PromptOutcome::Timeout => { tracing::debug!( agent = agent_index, outcome = outcome_label, - "agent_returned" + "agent_returned — respawning" ); let index = result.agent.index; match respawn_agent_into(result.agent, config).await { diff --git a/crates/sprout-acp/src/pool.rs b/crates/sprout-acp/src/pool.rs index 6a03606d67..4755d0fb04 100644 --- a/crates/sprout-acp/src/pool.rs +++ b/crates/sprout-acp/src/pool.rs @@ -166,7 +166,8 @@ pub enum PromptOutcome { pub struct PromptContext { pub mcp_servers: Vec, pub initial_message: Option, - pub turn_timeout: Duration, + pub idle_timeout: Duration, + pub max_turn_duration: Duration, pub dedup_mode: DedupMode, pub system_prompt: Option, pub heartbeat_prompt: Option, @@ -357,9 +358,14 @@ async fn create_session_and_apply_model( } } - // Apply permission mode if not the agent's built-in default. - if !ctx.permission_mode.is_default() { - apply_permission_mode(&mut agent.acp, &resp.session_id, &ctx.permission_mode).await; + // Apply permission mode if not the agent's built-in default AND the agent + // advertises the requested mode in session/new. Agents that don't support + // the mode (e.g., goose crashes on unrecognized set_config_option values) + // are safely skipped — the harness auto-approves via handle_permission_request. + if !ctx.permission_mode.is_default() + && agent_supports_mode(&resp.raw, ctx.permission_mode.as_wire_str()) + { + apply_permission_mode(&mut agent.acp, &resp.session_id, &ctx.permission_mode).await?; } Ok(resp.session_id) @@ -424,10 +430,33 @@ async fn apply_model_switch( /// Set the session permission mode via `session/set_config_option`. /// -/// Non-fatal: logs and proceeds on timeout or error. The agent falls back +/// Non-fatal for most errors: logs and proceeds. The agent falls back /// to its default permission mode (`"default"`), which still works via +/// Check if the agent's `session/new` response advertises a given mode ID +/// in `result.modes.availableModes[].id`. Returns `false` if the modes +/// field is absent or the mode isn't listed. +fn agent_supports_mode(session_new_result: &serde_json::Value, mode_wire: &str) -> bool { + session_new_result + .get("modes") + .and_then(|m| m.get("availableModes")) + .and_then(|a| a.as_array()) + .map(|modes| { + modes + .iter() + .any(|m| m.get("id").and_then(|v| v.as_str()) == Some(mode_wire)) + }) + .unwrap_or(false) +} + /// per-tool auto-approval in `handle_permission_request`. -async fn apply_permission_mode(acp: &mut AcpClient, session_id: &str, mode: &PermissionMode) { +/// +/// **Fatal exception:** if the agent process exits (e.g., goose crashes on +/// unrecognized methods), returns `Err(AgentExited)` so the caller can respawn. +async fn apply_permission_mode( + acp: &mut AcpClient, + session_id: &str, + mode: &PermissionMode, +) -> Result<(), AcpError> { let wire = mode.as_wire_str(); let result = tokio::time::timeout(PERMISSION_MODE_TIMEOUT, async { acp.session_set_config_option(session_id, "mode", wire) @@ -442,6 +471,16 @@ async fn apply_permission_mode(acp: &mut AcpClient, session_id: &str, mode: &Per "applied permission mode {wire:?} on session {session_id}" ); } + Ok(Err(AcpError::AgentExited)) => { + // Fatal: the agent process crashed (e.g., goose doesn't support + // session/set_config_option and exits instead of returning an error). + // Propagate so the caller can respawn. + tracing::error!( + target: "pool::permission", + "agent exited while setting permission mode {wire:?} — process crashed" + ); + return Err(AcpError::AgentExited); + } Ok(Err(e)) => { tracing::warn!( target: "pool::permission", @@ -455,6 +494,7 @@ async fn apply_permission_mode(acp: &mut AcpClient, session_id: &str, mode: &Per ); } } + Ok(()) } /// Core async function spawned for each prompt. @@ -578,20 +618,24 @@ pub async fn run_prompt_task( target: "pool::session", "sending initial_message to session {session_id} for channel {cid}" ); - let init_result = timeout( - ctx.turn_timeout, - agent.acp.session_prompt(&session_id, initial_msg), - ) - .await; + let init_result = agent + .acp + .session_prompt_with_idle_timeout( + &session_id, + initial_msg, + ctx.idle_timeout, + ctx.max_turn_duration, + ) + .await; match init_result { - Ok(Ok(stop_reason)) => { + Ok(stop_reason) => { tracing::info!( target: "pool::session", "initial_message complete for channel {cid}: {stop_reason:?}" ); } - Ok(Err(AcpError::AgentExited)) => { + Err(AcpError::AgentExited) => { agent.state.invalidate_all(); let _ = result_tx.send(PromptResult { agent, @@ -601,26 +645,17 @@ pub async fn run_prompt_task( }); return; } - Ok(Err(e)) => { - tracing::error!( - target: "pool::session", - "initial_message failed for channel {cid}: {e} — invalidating session" - ); - agent.state.invalidate(&source); - let _ = result_tx.send(PromptResult { - agent, - source, - outcome: PromptOutcome::Error(e), - batch: requeue_batch_if_queue(&ctx, batch), - }); - return; - } - Err(_elapsed) => { + Err(AcpError::IdleTimeout(_)) => { tracing::warn!( target: "pool::session", - "initial_message timed out for channel {cid} — cancelling" + "initial_message idle timeout ({}s) for channel {cid} — cancelling", + ctx.idle_timeout.as_secs() ); - match agent.acp.cancel_with_cleanup(&session_id).await { + match agent + .acp + .cancel_with_cleanup(&session_id, ctx.idle_timeout) + .await + { Ok(_) => { agent.state.invalidate(&source); } @@ -650,6 +685,35 @@ pub async fn run_prompt_task( }); return; } + Err(AcpError::HardTimeout) => { + tracing::error!( + target: "pool::session", + "hard timeout ({}s cap) during initial_message for channel {cid} — agent process is unrecoverable", + ctx.max_turn_duration.as_secs() + ); + agent.state.invalidate_all(); + let _ = result_tx.send(PromptResult { + agent, + source, + outcome: PromptOutcome::Timeout, + batch: requeue_batch_if_queue(&ctx, batch), + }); + return; + } + Err(e) => { + tracing::error!( + target: "pool::session", + "initial_message failed for channel {cid}: {e} — invalidating session" + ); + agent.state.invalidate(&source); + let _ = result_tx.send(PromptResult { + agent, + source, + outcome: PromptOutcome::Error(e), + batch: requeue_batch_if_queue(&ctx, batch), + }); + return; + } } } } @@ -712,14 +776,18 @@ pub async fn run_prompt_task( // ── Send the actual prompt ──────────────────────────────────────────── - let prompt_result = timeout( - ctx.turn_timeout, - agent.acp.session_prompt(&session_id, &prompt_text), - ) - .await; + let prompt_result = agent + .acp + .session_prompt_with_idle_timeout( + &session_id, + &prompt_text, + ctx.idle_timeout, + ctx.max_turn_duration, + ) + .await; match prompt_result { - Ok(Ok(stop_reason)) => { + Ok(stop_reason) => { log_stop_reason(&source, &stop_reason); // ── Session rotation on context exhaustion ──────────────── @@ -763,7 +831,7 @@ pub async fn run_prompt_task( batch: None, }); } - Ok(Err(AcpError::AgentExited)) => { + Err(AcpError::AgentExited) => { tracing::error!(target: "pool::prompt", "agent {} exited during prompt", agent.index); agent.state.invalidate_all(); let _ = result_tx.send(PromptResult { @@ -773,27 +841,21 @@ pub async fn run_prompt_task( batch: requeue_batch_if_queue(&ctx, batch), }); } - Ok(Err(e)) => { - tracing::error!(target: "pool::prompt", "session_prompt error: {e}"); - // Invalidate only the affected session. - agent.state.invalidate(&source); - let _ = result_tx.send(PromptResult { - agent, - source, - outcome: PromptOutcome::Error(e), - batch: requeue_batch_if_queue(&ctx, batch), - }); - } - Err(_elapsed) => { + Err(AcpError::IdleTimeout(_)) => { tracing::warn!( target: "pool::prompt", - "turn timeout ({}s) — cancelling session {session_id}", - ctx.turn_timeout.as_secs() + "idle timeout ({}s) — cancelling session {session_id}", + ctx.idle_timeout.as_secs() ); - match agent.acp.cancel_with_cleanup(&session_id).await { + match agent + .acp + .cancel_with_cleanup(&session_id, ctx.idle_timeout) + .await + { Ok(stop_reason) => { log_stop_reason(&source, &stop_reason); - // Session is still valid after a clean cancel. + // Timeout triggers respawn in handle_prompt_result — + // session state will be discarded with the old agent. let _ = result_tx.send(PromptResult { agent, source, @@ -830,6 +892,31 @@ pub async fn run_prompt_task( } } } + Err(AcpError::HardTimeout) => { + tracing::error!( + target: "pool::prompt", + "hard timeout ({}s cap) — agent process is unrecoverable, invalidating all sessions", + ctx.max_turn_duration.as_secs() + ); + agent.state.invalidate_all(); + let _ = result_tx.send(PromptResult { + agent, + source, + outcome: PromptOutcome::Timeout, + batch: requeue_batch_if_queue(&ctx, batch), + }); + } + Err(e) => { + tracing::error!(target: "pool::prompt", "session_prompt error: {e}"); + // Invalidate only the affected session. + agent.state.invalidate(&source); + let _ = result_tx.send(PromptResult { + agent, + source, + outcome: PromptOutcome::Error(e), + batch: requeue_batch_if_queue(&ctx, batch), + }); + } } // _reaction_guard drops here → spawns clear_reactions for all exit paths. } diff --git a/desktop/RELEASING.md b/desktop/RELEASING.md index 2295190f8e..ce7037c550 100644 --- a/desktop/RELEASING.md +++ b/desktop/RELEASING.md @@ -38,42 +38,29 @@ Store the password you chose in `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. ## Cutting a Release -### 1. Prepare - -From `main`, run: - -```bash -just desktop-prepare -``` - -For example: +From any branch (typically `main`): ```bash -just desktop-prepare 0.2.0 +just desktop-release 0.3.0 ``` -This creates a release branch, bumps versions in `package.json`, -`tauri.conf.json`, and `Cargo.toml`, and opens a PR. - -### 2. Review & Merge - -Review the PR, ensure CI passes, then merge to `main`. - -### 3. Release - -From `main` (after pulling the merged changes), run: +Or equivalently: ```bash -just desktop-release +git tag desktop/v0.3.0 +git push origin desktop/v0.3.0 ``` -This tags the commit and pushes the tag — CI handles the rest. +That's it. CI extracts the version from the tag and writes it into +`package.json`, `tauri.conf.json`, and `Cargo.toml` at build time. The +versions checked into the repo are not used for releases — the tag is the +source of truth. -### 4. Verify +### Verify Check GitHub Releases for: -- The **versioned release** (e.g. `sprout-desktop-v0.2.0`) +- The **versioned release** (e.g. `desktop/v0.3.0`) - The **`sprout-desktop-latest` rolling release** (updated with every release) --- @@ -82,17 +69,19 @@ Check GitHub Releases for: The `sprout-desktop-release.yml` workflow: -1. **Validates** the tag version matches the version in `package.json`, - `tauri.conf.json`, and `Cargo.toml`. -2. **Validates** all required secrets are present. -3. **Builds** the release config with signing and updater settings. -4. **Builds** the Tauri app (unsigned). -5. **Signs and notarizes** the macOS bundle via `block/apple-codesign-action`. -6. **Re-packages** the signed app into a DMG and updater archive. -7. **Signs** the updater archive with the Tauri updater key. -8. **Publishes** the updater manifest (`latest.json`) to the rolling - `sprout-desktop-latest` release. -9. **Publishes** the DMG to both the versioned and rolling releases. +1. **Extracts the version** from the git tag once into `RELEASE_VERSION`. +2. **Sets the version** into `package.json`, `tauri.conf.json`, and + `Cargo.toml` using `set-version-from-tag.mjs`. +3. **Regenerates `Cargo.lock`** to match the patched `Cargo.toml`. +4. **Validates** all required secrets are present. +5. **Builds** the release config with the updater public key and endpoint. +6. **Builds** the Tauri app (unsigned). +7. **Signs and notarizes** the macOS bundle via `block/apple-codesign-action`. +8. **Re-packages** the signed app into a DMG and updater archive. +9. **Signs** the updater archive with the Tauri updater key. +10. **Publishes** the updater manifest (`latest.json`) to the rolling + `sprout-desktop-latest` release. +11. **Publishes** the DMG to both the versioned and rolling releases. --- @@ -100,7 +89,7 @@ The `sprout-desktop-release.yml` workflow: Local builds will not be codesigned or notarized — that only happens in CI via `block/apple-codesign-action`. Local builds are useful for testing the -updater config and DMG packaging. +updater runtime config and DMG packaging. ```bash # Set updater env vars @@ -111,8 +100,14 @@ export SPROUT_UPDATER_ENDPOINT="https://github.com/block/sprout/releases/downloa cd desktop pnpm run tauri:release:config -# Build (unsigned) -just desktop-release-build +# Build (unsigned) — pass a version to set it before building +just desktop-release-build version=0.3.0 +``` + +You can also set the version separately without building: + +```bash +just desktop-set-version 0.3.0 ``` --- @@ -135,13 +130,30 @@ and signature for the latest version. The app connects to the relay via the `SPROUT_RELAY_URL` environment variable. -- **Release builds**: Set this to the production relay URL (e.g. - `wss://relay.sprout.example.com`). Configure it in the environment before - building, or set it in the CI workflow. +- **Production releases**: The GitHub release workflow currently builds the app + with `SPROUT_RELAY_URL=wss://sprout-oss.stage.blox.sqprod.co`, which is baked + into the release binary as its default relay URL. +- **Local release builds**: Export `SPROUT_RELAY_URL` before running + `just desktop-release-build` if you want a non-localhost relay URL compiled + into the app. - **Development**: If not set, it defaults to `ws://localhost:3000`. --- +## How Versioning Works + +The git tag is the single source of truth for the release version. The version +fields in `package.json`, `tauri.conf.json`, and `Cargo.toml` on `main` are +**not** used for releases — CI overwrites them at build time from the tag. + +This means the tagged commit will show a different version in its source files +than what the release actually contains. This is an accepted tradeoff — the tag +is the canonical version, the commit is just the code state at release time. +This is standard practice in ecosystems like Docker, Go, and Rust where the +tag drives the version. + +--- + ## Troubleshooting - **"Missing required desktop release secrets"**: Ensure all secrets listed in @@ -152,6 +164,11 @@ The app connects to the relay via the `SPROUT_RELAY_URL` environment variable. `CODESIGN_S3_BUCKET` are configured correctly. Check the `block/apple-codesign-action` step logs for details. -- **Version mismatch**: The tag version must exactly match all three version - files (`package.json`, `tauri.conf.json`, `Cargo.toml`). Use - `just desktop-prepare` to ensure consistency. +- **Build failures**: If versions are wrong, check that the tag follows the + format `desktop/v` (e.g. `desktop/v0.3.0`). CI extracts the version + from the tag automatically. + +- **"A public key has been found, but no private key"**: The Tauri build should + not require `TAURI_SIGNING_PRIVATE_KEY`. If you see this, the build is trying + to generate updater artifacts before the signed app bundle exists. The updater + archive is supposed to be created and signed later from the notarized app. diff --git a/desktop/scripts/build-release-config.mjs b/desktop/scripts/build-release-config.mjs index 2a15673f0d..0ffd7a6b87 100644 --- a/desktop/scripts/build-release-config.mjs +++ b/desktop/scripts/build-release-config.mjs @@ -13,17 +13,13 @@ const baseConfig = JSON.parse(readFileSync(baseConfigPath, "utf-8")); const releaseConfig = { ...baseConfig }; -releaseConfig.bundle = { - ...(releaseConfig.bundle ?? baseConfig.bundle ?? {}), - createUpdaterArtifacts: "v1Compatible", -}; - releaseConfig.bundle.macOS = { ...(releaseConfig.bundle?.macOS ?? baseConfig.bundle?.macOS ?? {}), minimumSystemVersion: "10.15", }; if (publicKey && endpoint) { + // Build-time updater artifacts are created later from the signed app bundle. releaseConfig.plugins = { ...(baseConfig.plugins ?? {}), updater: { diff --git a/desktop/scripts/bump-version.mjs b/desktop/scripts/bump-version.mjs deleted file mode 100644 index 3be4be0a5d..0000000000 --- a/desktop/scripts/bump-version.mjs +++ /dev/null @@ -1,54 +0,0 @@ -import { readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; - -const version = process.argv[2]; - -if (!version) { - console.error("Usage: node scripts/bump-version.mjs "); - process.exit(1); -} - -if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { - console.error( - `Invalid version "${version}". Expected semver format (e.g. 1.2.3 or 1.2.3-beta.1)`, - ); - process.exit(1); -} - -const packageJsonPath = resolve(process.cwd(), "package.json"); -const tauriConfigPath = resolve(process.cwd(), "src-tauri/tauri.conf.json"); -const cargoTomlPath = resolve(process.cwd(), "src-tauri/Cargo.toml"); - -const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); -if (packageJson.version !== version) { - packageJson.version = version; - writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); - console.log(`Updated package.json to ${version}`); -} else { - console.log(`package.json already at ${version}`); -} - -const tauriConfig = JSON.parse(readFileSync(tauriConfigPath, "utf8")); -if (tauriConfig.version !== version) { - tauriConfig.version = version; - writeFileSync(tauriConfigPath, `${JSON.stringify(tauriConfig, null, 2)}\n`); - console.log(`Updated tauri.conf.json to ${version}`); -} else { - console.log(`tauri.conf.json already at ${version}`); -} - -const cargoToml = readFileSync(cargoTomlPath, "utf8"); -const currentCargoVersion = cargoToml.match(/^version = "(.*)"$/m)?.[1]; -if (!currentCargoVersion) { - throw new Error(`Could not find version field in ${cargoTomlPath}`); -} -if (currentCargoVersion !== version) { - const updatedCargoToml = cargoToml.replace( - /^version = ".*"$/m, - `version = "${version}"`, - ); - writeFileSync(cargoTomlPath, updatedCargoToml); - console.log(`Updated Cargo.toml to ${version}`); -} else { - console.log(`Cargo.toml already at ${version}`); -} diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 1604dcc300..12590d072b 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -31,16 +31,16 @@ const rules = [ // Exceptions should stay rare and temporary. Prefer splitting files instead. const overrides = new Map([ ["src-tauri/src/managed_agents/persona_card.rs", 772], // PNG/ZIP persona card codec + provider/model fields + 27 unit tests (~350 lines of tests); rustfmt adds line breaks around long literals/builders - ["src/app/AppShell.tsx", 775], + ["src/app/AppShell.tsx", 810], // message edit state + handlers + ChannelPane edit prop threading ["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation ["src/features/channels/ui/ChannelManagementSheet.tsx", 800], - ["src/features/messages/ui/MessageComposer.tsx", 665], // media upload handlers (paste, drop, dialog) + channelId reset effect + ["src/features/messages/ui/MessageComposer.tsx", 700], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 850], // channels + forums creation forms ["src/features/tokens/ui/TokenSettingsCard.tsx", 800], ["src/shared/api/relayClientSession.ts", 740], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator ["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions - ["src-tauri/src/commands/agents.rs", 845], // remote agent lifecycle routing (local + provider branches) + scope enforcement; rustfmt adds line breaks around long tuple/closure blocks + ["src-tauri/src/commands/agents.rs", 849], // remote agent lifecycle routing (local + provider branches) + scope enforcement; rustfmt adds line breaks around long tuple/closure blocks ["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests ["src/features/agents/ui/AgentsView.tsx", 790], // remote agent stop/delete + channel UUID resolution + presence-aware delete guard + persona/team import + provider/model fields ["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes diff --git a/desktop/scripts/publish-dmg-to-github-release.mjs b/desktop/scripts/publish-dmg-to-github-release.mjs index 3ddaeac9d1..ecf2bcc117 100644 --- a/desktop/scripts/publish-dmg-to-github-release.mjs +++ b/desktop/scripts/publish-dmg-to-github-release.mjs @@ -1,10 +1,10 @@ import { execFileSync } from "node:child_process"; import { join, resolve } from "node:path"; -import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { cpSync, existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; const repo = process.env.GITHUB_REPOSITORY ?? "block/sprout"; -const version = process.env.VERSION ?? readVersionFromConfig(); +const version = requireVersionEnv(); const versionTag = `desktop/v${version}`; const latestTag = "sprout-desktop-latest"; const dryRun = process.env.DRY_RUN === "true" || process.env.DRY_RUN === "1"; @@ -26,14 +26,14 @@ const dmgDir = resolve( ); const dmgPath = join(dmgDir, dmgName); -function readVersionFromConfig() { - const configPath = resolve(process.cwd(), "src-tauri/tauri.conf.json"); - const config = JSON.parse(readFileSync(configPath, "utf-8")); - const configVersion = config?.version; - if (typeof configVersion !== "string" || !configVersion.trim()) { - throw new Error(`Could not determine version from ${configPath}`); +function requireVersionEnv() { + const v = process.env.VERSION; + if (!v || !v.trim()) { + throw new Error( + "VERSION env var is required. CI sets this from the git tag; for local use, run: VERSION=x.y.z pnpm run release:dmg:publish", + ); } - return configVersion; + return v.trim(); } function quote(arg) { diff --git a/desktop/scripts/publish-updater-to-github-release.mjs b/desktop/scripts/publish-updater-to-github-release.mjs index ed777dd99f..ff6c7baa72 100644 --- a/desktop/scripts/publish-updater-to-github-release.mjs +++ b/desktop/scripts/publish-updater-to-github-release.mjs @@ -12,8 +12,7 @@ import { tmpdir } from "node:os"; import { basename, join, resolve } from "node:path"; const repo = process.env.GITHUB_REPOSITORY ?? "block/sprout"; -const tauriConfigPath = resolve(process.cwd(), "src-tauri/tauri.conf.json"); -const version = process.env.VERSION ?? readVersionFromConfig(); +const version = requireVersionEnv(); const latestTag = "sprout-desktop-latest"; const tauriTarget = process.env.TAURI_TARGET ?? "aarch64-apple-darwin"; const updaterPlatform = process.env.UPDATER_PLATFORM ?? "darwin-aarch64"; @@ -31,13 +30,14 @@ const bundleDir = resolve( ); const latestPath = join(bundleDir, "latest.json"); -function readVersionFromConfig() { - const config = JSON.parse(readFileSync(tauriConfigPath, "utf-8")); - const configVersion = config?.version; - if (typeof configVersion !== "string" || !configVersion.trim()) { - throw new Error(`Could not determine version from ${tauriConfigPath}`); +function requireVersionEnv() { + const v = process.env.VERSION; + if (!v || !v.trim()) { + throw new Error( + "VERSION env var is required. CI sets this from the git tag; for local use, run: VERSION=x.y.z pnpm run release:updater:publish", + ); } - return configVersion; + return v.trim(); } function requirePath(path) { diff --git a/desktop/scripts/set-version-from-tag.mjs b/desktop/scripts/set-version-from-tag.mjs new file mode 100644 index 0000000000..df2c49b07f --- /dev/null +++ b/desktop/scripts/set-version-from-tag.mjs @@ -0,0 +1,38 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const version = process.argv[2]; + +if (!version) { + console.error("Usage: node scripts/set-version-from-tag.mjs "); + process.exit(1); +} + +if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + console.error( + `Invalid version "${version}". Expected semver format (e.g. 1.2.3 or 1.2.3-beta.1)`, + ); + process.exit(1); +} + +const packageJsonPath = resolve(process.cwd(), "package.json"); +const tauriConfigPath = resolve(process.cwd(), "src-tauri/tauri.conf.json"); +const cargoTomlPath = resolve(process.cwd(), "src-tauri/Cargo.toml"); + +const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); +packageJson.version = version; +writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); +console.log(`Set package.json to ${version}`); + +const tauriConfig = JSON.parse(readFileSync(tauriConfigPath, "utf8")); +tauriConfig.version = version; +writeFileSync(tauriConfigPath, `${JSON.stringify(tauriConfig, null, 2)}\n`); +console.log(`Set tauri.conf.json to ${version}`); + +const cargoToml = readFileSync(cargoTomlPath, "utf8"); +const updatedCargoToml = cargoToml.replace( + /^version = ".*"$/m, + `version = "${version}"`, +); +writeFileSync(cargoTomlPath, updatedCargoToml); +console.log(`Set Cargo.toml to ${version}`); diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index d860e1e6a7..cb6618e1c4 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -1,3 +1,14 @@ fn main() { + println!("cargo:rerun-if-env-changed=SPROUT_RELAY_URL"); + println!("cargo:rerun-if-env-changed=SPROUT_RELAY_HTTP"); + + if let Ok(relay_url) = std::env::var("SPROUT_RELAY_URL") { + println!("cargo:rustc-env=SPROUT_DESKTOP_BUILD_RELAY_URL={relay_url}"); + } + + if let Ok(relay_http) = std::env::var("SPROUT_RELAY_HTTP") { + println!("cargo:rustc-env=SPROUT_DESKTOP_BUILD_RELAY_HTTP={relay_http}"); + } + tauri_build::build() } diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 9128497d5f..4dab530454 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -31,6 +31,8 @@ fn build_deploy_payload(record: &ManagedAgentRecord) -> serde_json::Value { "system_prompt": &record.system_prompt, "model": &record.model, "turn_timeout_seconds": record.turn_timeout_seconds, + "idle_timeout_seconds": record.idle_timeout_seconds, + "max_turn_duration_seconds": record.max_turn_duration_seconds, "parallelism": record.parallelism, }) } @@ -361,6 +363,10 @@ pub async fn create_managed_agent( .turn_timeout_seconds .filter(|seconds| *seconds > 0) .unwrap_or(DEFAULT_AGENT_TURN_TIMEOUT_SECONDS), + // 0 or None → harness uses its own default (300s idle, 3600s max). + // The harness CLI also clamps 0 → minimum, so both paths are safe. + idle_timeout_seconds: input.idle_timeout_seconds.filter(|s| *s > 0), + max_turn_duration_seconds: input.max_turn_duration_seconds.filter(|s| *s > 0), parallelism: input .parallelism .filter(|count| (1..=32).contains(count)) diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index a50158a283..fcdda30ffc 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -294,6 +294,25 @@ pub async fn remove_reaction( Ok(()) } +#[tauri::command] +pub async fn edit_message( + channel_id: String, + event_id: String, + content: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let channel_uuid = uuid::Uuid::parse_str(&channel_id) + .map_err(|_| format!("invalid channel UUID: {channel_id}"))?; + let target_eid = EventId::from_hex(&event_id).map_err(|e| format!("invalid event ID: {e}"))?; + let trimmed = content.trim(); + if trimmed.is_empty() { + return Err("edit content must not be empty".into()); + } + let builder = events::build_message_edit(channel_uuid, target_eid, trimmed)?; + submit_event(builder, &state).await?; + Ok(()) +} + #[tauri::command] pub async fn delete_message(event_id: String, state: State<'_, AppState>) -> Result<(), String> { let target_eid = EventId::from_hex(&event_id).map_err(|e| format!("invalid event ID: {e}"))?; diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 72755712da..27d15bee84 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -275,6 +275,20 @@ pub fn build_forum_comment( Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags)) } +/// Kind 40003 — edit a message. +pub fn build_message_edit( + channel_id: Uuid, + target_event_id: EventId, + content: &str, +) -> Result { + check_content(content)?; + let tags = vec![ + tag(vec!["h", &channel_id.to_string()])?, + tag(vec!["e", &target_event_id.to_hex()])?, + ]; + Ok(EventBuilder::new(Kind::Custom(40003), content).tags(tags)) +} + /// Kind 5 — NIP-09 deletion (messages). pub fn build_delete_compat(target_event_id: EventId) -> Result { let tags = vec![tag(vec!["e", &target_event_id.to_hex()])?]; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index cb5b390f4c..93f62efee7 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -246,6 +246,7 @@ pub fn run() { send_channel_message, get_forum_posts, get_forum_thread, + edit_message, delete_message, add_reaction, remove_reaction, diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 0399515f0c..84bc2461de 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -200,6 +200,8 @@ pub fn build_managed_agent_summary( agent_args: record.agent_args.clone(), mcp_command: record.mcp_command.clone(), turn_timeout_seconds: record.turn_timeout_seconds, + idle_timeout_seconds: record.idle_timeout_seconds, + max_turn_duration_seconds: record.max_turn_duration_seconds, parallelism: record.parallelism, system_prompt: record.system_prompt.clone(), model: record.model.clone(), @@ -294,10 +296,23 @@ pub fn start_managed_agent_process( command.env("SPROUT_ACP_AGENT_COMMAND", &record.agent_command); command.env("SPROUT_ACP_AGENT_ARGS", agent_args.join(",")); command.env("SPROUT_ACP_MCP_COMMAND", &resolved_mcp_command); - command.env( - "SPROUT_ACP_TURN_TIMEOUT", - record.turn_timeout_seconds.to_string(), - ); + // Timeout configuration: always set both IDLE_TIMEOUT and the deprecated TURN_TIMEOUT + // so older harness binaries (which only read TURN_TIMEOUT) still get a value. + if let Some(idle) = record.idle_timeout_seconds { + command.env("SPROUT_ACP_IDLE_TIMEOUT", idle.to_string()); + // Mirror to deprecated var for older harness binaries. + command.env("SPROUT_ACP_TURN_TIMEOUT", idle.to_string()); + } else { + command.env( + "SPROUT_ACP_TURN_TIMEOUT", + record.turn_timeout_seconds.to_string(), + ); + } + + let max_dur = record + .max_turn_duration_seconds + .unwrap_or(super::types::DEFAULT_AGENT_MAX_TURN_DURATION_SECONDS); + command.env("SPROUT_ACP_MAX_TURN_DURATION", max_dur.to_string()); command.env("SPROUT_ACP_AGENTS", record.parallelism.to_string()); command.env( "GOOSE_MODE", diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index b9db7c8558..588f7b8096 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -57,6 +57,12 @@ pub struct ManagedAgentRecord { pub agent_args: Vec, pub mcp_command: String, pub turn_timeout_seconds: u64, + /// Idle timeout in seconds. If set, overrides turn_timeout_seconds. + #[serde(default)] + pub idle_timeout_seconds: Option, + /// Absolute wall-clock cap per turn. + #[serde(default)] + pub max_turn_duration_seconds: Option, #[serde(default = "default_agent_parallelism")] pub parallelism: u32, pub system_prompt: Option, @@ -100,6 +106,8 @@ pub struct ManagedAgentSummary { pub agent_args: Vec, pub mcp_command: String, pub turn_timeout_seconds: u64, + pub idle_timeout_seconds: Option, + pub max_turn_duration_seconds: Option, pub parallelism: u32, pub system_prompt: Option, pub model: Option, @@ -131,6 +139,8 @@ pub struct CreateManagedAgentRequest { pub agent_args: Vec, pub mcp_command: Option, pub turn_timeout_seconds: Option, + pub idle_timeout_seconds: Option, + pub max_turn_duration_seconds: Option, pub parallelism: Option, pub system_prompt: Option, pub avatar_url: Option, @@ -312,6 +322,8 @@ pub const DEFAULT_MCP_COMMAND: &str = "sprout-mcp-server"; pub const DEFAULT_AGENT_ARG: &str = "acp"; /// 10 min — agents with tool-heavy turns regularly exceed the previous 5 min default. pub const DEFAULT_AGENT_TURN_TIMEOUT_SECONDS: u64 = 600; +/// 1 hour — absolute wall-clock safety cap per turn. +pub const DEFAULT_AGENT_MAX_TURN_DURATION_SECONDS: u64 = 3600; pub const DEFAULT_AGENT_PARALLELISM: u32 = 1; fn default_agent_parallelism() -> u32 { diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 63a8fd2c03..825da6e76b 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -7,8 +7,19 @@ use sha2::{Digest, Sha256}; use crate::app_state::AppState; +const DEFAULT_RELAY_WS_URL: &str = "ws://localhost:3000"; + +fn configured_env_var(name: &str) -> Option { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + pub fn relay_ws_url() -> String { - std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) + configured_env_var("SPROUT_RELAY_URL") + .or_else(|| option_env!("SPROUT_DESKTOP_BUILD_RELAY_URL").map(str::to_string)) + .unwrap_or_else(|| DEFAULT_RELAY_WS_URL.to_string()) } pub fn relay_http_base_url(relay_url: &str) -> String { @@ -26,7 +37,11 @@ pub fn relay_http_base_url(relay_url: &str) -> String { } pub fn relay_api_base_url() -> String { - if let Ok(base) = std::env::var("SPROUT_RELAY_HTTP") { + if let Some(base) = configured_env_var("SPROUT_RELAY_HTTP") { + return base.trim_end_matches('/').to_string(); + } + + if let Some(base) = option_env!("SPROUT_DESKTOP_BUILD_RELAY_HTTP") { return base.trim().trim_end_matches('/').to_string(); } diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 776a9c2a72..f3166bb513 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -22,6 +22,7 @@ import { HomeView } from "@/features/home/ui/HomeView"; import { useChannelMessagesQuery, mergeMessages, + useEditMessageMutation, useSendMessageMutation, useChannelSubscription, useToggleReactionMutation, @@ -86,6 +87,7 @@ export function AppShell() { const [searchAnchorEvent, setSearchAnchorEvent] = React.useState(null); const [replyTargetId, setReplyTargetId] = React.useState(null); + const [editTargetId, setEditTargetId] = React.useState(null); const lastNonSettingsViewRef = React.useRef("home"); const queryClient = useQueryClient(); const identityQuery = useIdentityQuery(); @@ -134,6 +136,7 @@ export function AppShell() { identityQuery.data, ); const toggleReactionMutation = useToggleReactionMutation(); + const editMessageMutation = useEditMessageMutation(activeChannel); const availableChannelIds = React.useMemo( () => new Set(channels.map((channel) => channel.id)), [channels], @@ -206,14 +209,29 @@ export function AppShell() { timelineMessages.find((message) => message.id === replyTargetId) ?? null, [replyTargetId, timelineMessages], ); + const editTargetMessage = React.useMemo( + () => + timelineMessages.find((message) => message.id === editTargetId) ?? null, + [editTargetId, timelineMessages], + ); - const { handleCancelReply, handleReply, handleSend, handleToggleReaction } = - useChannelPaneHandlers({ - replyTargetId, - sendMessageMutation, - setReplyTargetId, - toggleReactionMutation, - }); + const { + handleCancelEdit, + handleCancelReply, + handleEdit, + handleEditSave, + handleReply, + handleSend, + handleToggleReaction, + } = useChannelPaneHandlers({ + editMessageMutation, + editTargetId, + replyTargetId, + sendMessageMutation, + setEditTargetId, + setReplyTargetId, + toggleReactionMutation, + }); const handleTargetReached = React.useCallback((messageId: string) => { setSearchAnchor((current) => @@ -713,10 +731,22 @@ export function AppShell() { void; onCancelReply: () => void; + onEdit?: (message: TimelineMessage) => void; + onEditSave?: (content: string) => Promise; onReply: (message: TimelineMessage) => void; onSend: ( content: string, @@ -36,10 +44,14 @@ type ChannelPaneProps = { export const ChannelPane = React.memo(function ChannelPane({ activeChannel, currentPubkey, + editTarget = null, isSending, isTimelineLoading, messages, + onCancelEdit, onCancelReply, + onEdit, + onEditSave, onReply, onSend, onTargetReached, @@ -71,6 +83,7 @@ export const ChannelPane = React.memo(function ChannelPane({ } isLoading={isTimelineLoading} messages={messages} + onEdit={onEdit} onReply={onReply} onTargetReached={onTargetReached} onToggleReaction={onToggleReaction} @@ -92,8 +105,11 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel.channelType === "forum" || isSending } + editTarget={editTarget} isSending={isSending} + onCancelEdit={onCancelEdit} onCancelReply={onCancelReply} + onEditSave={onEditSave} onSend={onSend} placeholder={ activeChannel?.archivedAt diff --git a/desktop/src/app/useChannelPaneHandlers.ts b/desktop/src/app/useChannelPaneHandlers.ts index 72a54737c7..832272e069 100644 --- a/desktop/src/app/useChannelPaneHandlers.ts +++ b/desktop/src/app/useChannelPaneHandlers.ts @@ -1,7 +1,10 @@ import * as React from "react"; -import type { useSendMessageMutation } from "@/features/messages/hooks"; -import type { useToggleReactionMutation } from "@/features/messages/hooks"; +import type { + useEditMessageMutation, + useSendMessageMutation, + useToggleReactionMutation, +} from "@/features/messages/hooks"; /** * Stable callback references for ChannelPane so that keystroke-driven @@ -12,13 +15,19 @@ import type { useToggleReactionMutation } from "@/features/messages/hooks"; * rather than listing the whole mutation as a dependency. */ export function useChannelPaneHandlers({ + editMessageMutation, + editTargetId, replyTargetId, sendMessageMutation, + setEditTargetId, setReplyTargetId, toggleReactionMutation, }: { + editMessageMutation: ReturnType; + editTargetId: string | null; replyTargetId: string | null; sendMessageMutation: ReturnType; + setEditTargetId: React.Dispatch>; setReplyTargetId: React.Dispatch>; toggleReactionMutation: ReturnType; }) { @@ -26,9 +35,15 @@ export function useChannelPaneHandlers({ const replyTargetIdRef = React.useRef(replyTargetId); replyTargetIdRef.current = replyTargetId; + const editTargetIdRef = React.useRef(editTargetId); + editTargetIdRef.current = editTargetId; + const sendMutateRef = React.useRef(sendMessageMutation.mutateAsync); sendMutateRef.current = sendMessageMutation.mutateAsync; + const editMutateRef = React.useRef(editMessageMutation.mutateAsync); + editMutateRef.current = editMessageMutation.mutateAsync; + const toggleMutateRef = React.useRef(toggleReactionMutation.mutateAsync); toggleMutateRef.current = toggleReactionMutation.mutateAsync; @@ -36,13 +51,43 @@ export function useChannelPaneHandlers({ setReplyTargetId(null); }, [setReplyTargetId]); + const handleCancelEdit = React.useCallback(() => { + setEditTargetId(null); + }, [setEditTargetId]); + + const handleEdit = React.useCallback( + (message: { id: string }) => { + setEditTargetId((current) => + current === message.id ? null : message.id, + ); + // Clear reply when entering edit mode. + setReplyTargetId(null); + }, + [setEditTargetId, setReplyTargetId], + ); + + const handleEditSave = React.useCallback( + async (content: string) => { + const eventId = editTargetIdRef.current; + if (!eventId) { + return; + } + + await editMutateRef.current({ eventId, content }); + setEditTargetId(null); + }, + [setEditTargetId], + ); + const handleReply = React.useCallback( (message: { id: string }) => { setReplyTargetId((current) => current === message.id ? null : message.id, ); + // Clear edit when entering reply mode. + setEditTargetId(null); }, - [setReplyTargetId], + [setReplyTargetId, setEditTargetId], ); const handleSend = React.useCallback( @@ -74,7 +119,10 @@ export function useChannelPaneHandlers({ ); return { + handleCancelEdit, handleCancelReply, + handleEdit, + handleEditSave, handleReply, handleSend, handleToggleReaction, diff --git a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx index 9644ffd8ca..f8e1c9dfe4 100644 --- a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx @@ -112,7 +112,7 @@ export function AddAgentToChannelDialog({
- + Add agent to channel Add {agent?.name ?? "this agent"} to a channel so desktop chat can @@ -122,7 +122,7 @@ export function AddAgentToChannelDialog({ -
+
-
+
+ ) : null} + {hasReplyAction ? ( +
+ ) : replyTarget ? (
void; onToggleReaction?: ( message: TimelineMessage, emoji: string, @@ -240,6 +242,7 @@ export const MessageRow = React.memo( ) : null} + {message.edited ? ( +

+ (edited) +

+ ) : null}

{message.time}

@@ -308,6 +319,7 @@ export const MessageRow = React.memo( prev.message.depth === next.message.depth && prev.message.kind === next.message.kind && prev.message.pending === next.message.pending && + prev.message.edited === next.message.edited && prev.message.reactions === next.message.reactions && prev.message.tags === next.message.tags && prev.message.role === next.message.role && diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index afdcdd2b4d..0116779939 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -18,6 +18,7 @@ type MessageTimelineProps = { activeReplyTargetId?: string | null; currentPubkey?: string; profiles?: UserProfileLookup; + onEdit?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; onToggleReaction?: ( message: TimelineMessage, @@ -37,6 +38,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ activeReplyTargetId = null, currentPubkey, profiles, + onEdit, onReply, onToggleReaction, targetMessageId = null, @@ -106,6 +108,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ currentPubkey={currentPubkey} highlightedMessageId={highlightedMessageId} messages={messages} + onEdit={onEdit} onReply={onReply} onToggleReaction={onToggleReaction} profiles={profiles} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index d9ba312215..3bf84ab993 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -11,6 +11,7 @@ type TimelineMessageListProps = { currentPubkey?: string; highlightedMessageId?: string | null; messages: TimelineMessage[]; + onEdit?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; onToggleReaction?: ( message: TimelineMessage, @@ -25,6 +26,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ currentPubkey, highlightedMessageId = null, messages, + onEdit, onReply, onToggleReaction, profiles, @@ -44,6 +46,11 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ activeReplyTargetId={activeReplyTargetId} highlighted={message.id === highlightedMessageId} message={message} + onEdit={ + onEdit && currentPubkey && message.pubkey === currentPubkey + ? onEdit + : undefined + } onToggleReaction={onToggleReaction} onReply={onReply} profiles={profiles} diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index b1a2a95145..50a6d5cff8 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -159,7 +159,7 @@ export function ChannelMenuButton({ presenceStatus={presenceStatus} /> {resolvedLabel} - {hasUnread && !isActive ? ( + {hasUnread && !isActive && channel.channelType !== "dm" ? (