From 360f462c6c816ea42890ac8e2f869f5cbf09f5f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 11 May 2026 08:16:43 +0200 Subject: [PATCH 01/15] perf(tauri): add minimal desktop transport benchmark Reintroduce the smallest Tauri-native event transport slice needed to compare it against the browser EventSource path on real sessions. Add a frontend/server benchmark hook so the desktop runtime can report end-to-end transport behavior without pulling in the broader UI performance changes from the original PR. # Conflicts: # packages/tauri-app/src-tauri/src/cli_manager.rs # Conflicts: # packages/tauri-app/src-tauri/src/main.rs --- packages/server/src/server/http-server.ts | 10 +- .../tauri-app/src-tauri/src/cli_manager.rs | 65 +- .../src-tauri/src/desktop_event_transport.rs | 724 ++++++++++++++++++ .../src/desktop_event_transport/assembler.rs | 501 ++++++++++++ .../src/desktop_event_transport/stream.rs | 185 +++++ .../src/desktop_event_transport/tests.rs | 575 ++++++++++++++ .../src/desktop_event_transport/transport.rs | 569 ++++++++++++++ packages/tauri-app/src-tauri/src/main.rs | 24 + .../ui/src/lib/event-transport-contract.ts | 78 ++ packages/ui/src/lib/event-transport.ts | 83 ++ packages/ui/src/lib/native/desktop-events.ts | 163 ++++ packages/ui/src/lib/server-events.ts | 154 +++- packages/ui/src/main.tsx | 27 + packages/ui/src/transport-bench.tsx | 145 ++++ 14 files changed, 3264 insertions(+), 39 deletions(-) create mode 100644 packages/tauri-app/src-tauri/src/desktop_event_transport.rs create mode 100644 packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs create mode 100644 packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs create mode 100644 packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs create mode 100644 packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs create mode 100644 packages/ui/src/lib/event-transport-contract.ts create mode 100644 packages/ui/src/lib/event-transport.ts create mode 100644 packages/ui/src/lib/native/desktop-events.ts create mode 100644 packages/ui/src/transport-bench.tsx diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index faa53d3fd..6b50490b6 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -87,6 +87,7 @@ export function createHttpServer(deps: HttpServerDeps) { const proxyLogger = deps.logger.child({ component: "proxy" }) const apiLogger = deps.logger.child({ component: "http" }) const sseLogger = deps.logger.child({ component: "sse" }) + const perfLogger = deps.logger.child({ component: "perf242" }) const sseClients = new Set<() => void>() const registerSseClient = (cleanup: () => void) => { @@ -199,7 +200,7 @@ export function createHttpServer(deps: HttpServerDeps) { const rawUrl = request.raw.url ?? request.url const pathname = (rawUrl.split("?")[0] ?? "").trim() - const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout"]) + const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout", "/api/perf-log"]) const publicPagePaths = new Set(["/login"]) if (deps.authManager.isTokenBootstrapEnabled()) { publicPagePaths.add("/auth/token") @@ -268,6 +269,13 @@ export function createHttpServer(deps: HttpServerDeps) { reply.code(404).send({ message: "UI bundle missing" }) }) + app.post("/api/perf-log", async (request, reply) => { + console.log("[perf242-route]", JSON.stringify(request.body ?? {})) + perfLogger.info(request.body ?? {}, "frontend perf log") + reply.code(204) + return null + }) + registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager }) registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index e44828f55..98210b4ff 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -1,4 +1,5 @@ use crate::managed_node::resolve_bundled_node_binary; +use crate::desktop_event_transport::DesktopEventStreamConfig; use dirs::home_dir; use parking_lot::Mutex; use regex::Regex; @@ -185,12 +186,13 @@ fn kill_process_tree_windows(pid: u32, force: bool) -> bool { } fn navigate_main(app: &AppHandle, url: &str) { if let Some(win) = app.webview_windows().get("main") { - let mut display = url.to_string(); + let final_url = augment_launch_url(url); + let mut display = final_url.clone(); if let Some(hash_index) = display.find('#') { display.replace_range(hash_index + 1.., "[REDACTED]"); } log_line(&format!("navigating main to {display}")); - if let Ok(parsed) = Url::parse(url) { + if let Ok(parsed) = Url::parse(&final_url) { let _ = win.navigate(parsed); } else { log_line("failed to parse URL for navigation"); @@ -200,6 +202,23 @@ fn navigate_main(app: &AppHandle, url: &str) { } } +fn augment_launch_url(base_url: &str) -> String { + let launch_query = std::env::var("CODENOMAD_UI_LAUNCH_QUERY") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + let Some(launch_query) = launch_query else { + return base_url.to_string(); + }; + + if base_url.contains('?') { + return format!("{}&{}", base_url, launch_query.trim_start_matches('?')); + } + + format!("{}?{}", base_url, launch_query.trim_start_matches('?')) +} + fn extract_cookie_value(set_cookie: &str, name: &str) -> Option { let prefix = format!("{name}="); let cookie_kv = set_cookie.split(';').next()?.trim(); @@ -456,6 +475,8 @@ pub struct CliProcessManager { job: Arc>>, ready: Arc, bootstrap_token: Arc>>, + session_cookie: Arc>>, + auth_cookie_name: Arc>>, } impl CliProcessManager { @@ -467,6 +488,8 @@ impl CliProcessManager { job: Arc::new(Mutex::new(None)), ready: Arc::new(AtomicBool::new(false)), bootstrap_token: Arc::new(Mutex::new(None)), + session_cookie: Arc::new(Mutex::new(None)), + auth_cookie_name: Arc::new(Mutex::new(None)), } } @@ -475,6 +498,8 @@ impl CliProcessManager { self.stop()?; self.ready.store(false, Ordering::SeqCst); *self.bootstrap_token.lock() = None; + *self.session_cookie.lock() = None; + *self.auth_cookie_name.lock() = None; { let mut status = self.status.lock(); status.state = CliState::Starting; @@ -491,6 +516,8 @@ impl CliProcessManager { let job_arc = self.job.clone(); let ready_flag = self.ready.clone(); let token_arc = self.bootstrap_token.clone(); + let session_cookie_arc = self.session_cookie.clone(); + let auth_cookie_name_arc = self.auth_cookie_name.clone(); thread::spawn(move || { if let Err(err) = Self::spawn_cli( app.clone(), @@ -500,6 +527,8 @@ impl CliProcessManager { job_arc, ready_flag, token_arc, + session_cookie_arc, + auth_cookie_name_arc, dev, ) { log_line(&format!("cli spawn failed: {err}")); @@ -594,6 +623,7 @@ impl CliProcessManager { status.port = None; status.url = None; status.error = None; + *self.session_cookie.lock() = None; Ok(()) } @@ -602,6 +632,25 @@ impl CliProcessManager { self.status.lock().clone() } + pub fn desktop_event_stream_config(&self) -> Option { + let base_url = self.status.lock().url.clone()?; + let events_url = format!("{}/api/events", base_url.trim_end_matches('/')); + let client_id = format!("tauri-{}", std::process::id()); + let cookie_name = self + .auth_cookie_name + .lock() + .clone() + .unwrap_or_else(|| SESSION_COOKIE_NAME_PREFIX.to_string()); + + Some(DesktopEventStreamConfig { + base_url, + events_url, + client_id, + cookie_name, + session_cookie: self.session_cookie.lock().clone(), + }) + } + fn spawn_cli( app: AppHandle, status: Arc>, @@ -609,6 +658,8 @@ impl CliProcessManager { #[cfg(windows)] job_holder: Arc>>, ready: Arc, bootstrap_token: Arc>>, + session_cookie: Arc>>, + auth_cookie_name_holder: Arc>>, dev: bool, ) -> anyhow::Result<()> { log_line("resolving CLI entry"); @@ -619,6 +670,7 @@ impl CliProcessManager { resolution.runner, resolution.entry, host )); let auth_cookie_name = Arc::new(generate_auth_cookie_name()); + *auth_cookie_name_holder.lock() = Some(auth_cookie_name.as_str().to_string()); let args = resolution.build_args(dev, &host, auth_cookie_name.as_str()); log_line(&format!("CLI args: {:?}", args)); if dev { @@ -723,6 +775,7 @@ impl CliProcessManager { let app_clone = app.clone(); let ready_clone = ready.clone(); let token_clone = bootstrap_token.clone(); + let session_cookie_clone = session_cookie.clone(); let auth_cookie_name_clone = auth_cookie_name.clone(); thread::spawn(move || { @@ -742,6 +795,7 @@ impl CliProcessManager { let status = status_clone.clone(); let ready = ready_clone.clone(); let token = token_clone.clone(); + let session_cookie = session_cookie_clone.clone(); let auth_cookie_name = auth_cookie_name_clone.clone(); thread::spawn(move || { Self::process_stream( @@ -751,6 +805,7 @@ impl CliProcessManager { &status, &ready, &token, + &session_cookie, auth_cookie_name.as_str(), ); }); @@ -761,6 +816,7 @@ impl CliProcessManager { let status = status_clone.clone(); let ready = ready_clone.clone(); let token = token_clone.clone(); + let session_cookie = session_cookie_clone.clone(); let auth_cookie_name = auth_cookie_name_clone.clone(); thread::spawn(move || { Self::process_stream( @@ -770,6 +826,7 @@ impl CliProcessManager { &status, &ready, &token, + &session_cookie, auth_cookie_name.as_str(), ); }); @@ -894,6 +951,7 @@ impl CliProcessManager { status: &Arc>, ready: &Arc, bootstrap_token: &Arc>>, + session_cookie: &Arc>>, auth_cookie_name: &str, ) { let mut buffer = String::new(); @@ -946,6 +1004,7 @@ impl CliProcessManager { status, ready, bootstrap_token, + session_cookie, auth_cookie_name, url, ); @@ -963,6 +1022,7 @@ impl CliProcessManager { status: &Arc>, ready: &Arc, bootstrap_token: &Arc>>, + session_cookie: &Arc>>, auth_cookie_name: &str, base_url: String, ) { @@ -995,6 +1055,7 @@ impl CliProcessManager { log_line(&format!("failed to set session cookie: {err}")); navigate_main(app, &format!("{base_url}/login")); } else { + *session_cookie.lock() = Some(session_id.clone()); navigate_main(app, &base_url); } } diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport.rs new file mode 100644 index 000000000..49cc75521 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport.rs @@ -0,0 +1,724 @@ +use parking_lot::Mutex; +use reqwest::blocking::{Client, Response}; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::io::{BufRead, BufReader}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::mpsc::{self, RecvTimeoutError, SyncSender}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tauri::{AppHandle, Emitter, Manager, Url}; + +mod assembler; +mod stream; +mod transport; + +use stream::*; +use transport::*; + +const EVENT_BATCH_NAME: &str = "desktop:event-batch"; +const EVENT_STATUS_NAME: &str = "desktop:event-stream-status"; +const FLUSH_INTERVAL_MS: u64 = 16; +const DELTA_STREAM_WINDOW_MS: u64 = 48; +const ACTIVE_STREAM_DISPLAY_WINDOW_MS: u64 = 16; +const ACTIVE_STREAM_DISPLAY_CHUNK_MAX: usize = 96; +const ACTIVE_STREAM_STORE_WINDOW_MS: u64 = 250; +const ACTIVE_STREAM_SNAPSHOT_WINDOW_MS: u64 = 200; +const ACTIVE_STREAM_HOLD_WINDOW_MS: u64 = 12; +const ACTIVE_SESSION_MAX_BATCH_EVENTS: usize = 64; +const MAX_BATCH_EVENTS: usize = 256; +const DEFAULT_RECONNECT_INITIAL_DELAY_MS: u64 = 1_000; +const DEFAULT_RECONNECT_MAX_DELAY_MS: u64 = 10_000; +const DEFAULT_RECONNECT_MULTIPLIER: f64 = 2.0; +const STREAM_CONNECT_TIMEOUT_MS: u64 = 5_000; +const STREAM_TCP_KEEPALIVE_MS: u64 = 30_000; +const STREAM_STALL_TIMEOUT_MS: u64 = 30_000; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DesktopEventStreamConfig { + pub base_url: String, + pub events_url: String, + pub client_id: String, + pub cookie_name: String, + pub session_cookie: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DesktopEventsStartRequest { + pub reconnect: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DesktopEventReconnectPolicy { + pub initial_delay_ms: Option, + pub max_delay_ms: Option, + pub multiplier: Option, + pub max_attempts: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopEventsStartResult { + pub started: bool, + pub generation: Option, + pub reason: Option, +} + +#[derive(Clone, Debug, PartialEq)] +struct ResolvedDesktopEventReconnectPolicy { + initial_delay_ms: u64, + max_delay_ms: u64, + multiplier: f64, + max_attempts: Option, +} + +impl ResolvedDesktopEventReconnectPolicy { + fn resolve(policy: Option<&DesktopEventReconnectPolicy>) -> Self { + let initial_delay_ms = policy + .and_then(|value| value.initial_delay_ms) + .unwrap_or(DEFAULT_RECONNECT_INITIAL_DELAY_MS) + .max(1); + let max_delay_ms = policy + .and_then(|value| value.max_delay_ms) + .unwrap_or(DEFAULT_RECONNECT_MAX_DELAY_MS) + .max(initial_delay_ms); + let multiplier = policy + .and_then(|value| value.multiplier) + .filter(|value| value.is_finite() && *value >= 1.0) + .unwrap_or(DEFAULT_RECONNECT_MULTIPLIER); + let max_attempts = policy + .and_then(|value| value.max_attempts) + .filter(|value| *value > 0); + + Self { + initial_delay_ms, + max_delay_ms, + multiplier, + max_attempts, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +struct DesktopEventTransportConfig { + stream: DesktopEventStreamConfig, + reconnect: ResolvedDesktopEventReconnectPolicy, +} + +impl DesktopEventTransportConfig { + fn new(stream: DesktopEventStreamConfig, request: &DesktopEventsStartRequest) -> Self { + Self { + stream, + reconnect: ResolvedDesktopEventReconnectPolicy::resolve(request.reconnect.as_ref()), + } + } +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct WorkspaceEventBatchPayload { + generation: u64, + sequence: u64, + emitted_at: u128, + events: Vec, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct DesktopEventStreamStatusPayload { + generation: u64, + state: &'static str, + reconnect_attempt: u32, + terminal: bool, + reason: Option, + next_delay_ms: Option, + status_code: Option, + stats: DesktopEventTransportStats, +} + +#[derive(Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +struct DesktopEventTransportStats { + raw_events: u64, + emitted_events: u64, + emitted_batches: u64, + delta_coalesces: u64, + snapshot_coalesces: u64, + status_coalesces: u64, + superseded_deltas_dropped: u64, +} + +struct DesktopEventTransportState { + stop: Option>, + config: Option, + active_target: Option, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct ActiveSessionTarget { + pub instance_id: String, + pub session_id: String, +} + +pub struct DesktopEventTransportManager { + state: Arc>, + generation: Arc, +} + +enum ReaderMessage { + Activity, + Event(Value), + End(Option), +} + +enum PendingEntry { + Delta { + key: String, + scope: String, + instance_id: String, + session_id: Option, + event: Value, + started_at: Instant, + }, + Status { + key: String, + event: Value, + }, + Snapshot { + key: String, + event: Value, + }, + Event(Value), +} + +enum EventDeliveryPolicy { + CoalesceDelta(String), + CoalesceStatus(String), + CoalesceSnapshot(String), + Passthrough, +} + +enum OpenStreamErrorKind { + Unauthorized, + Http, + Transport, +} + +struct OpenStreamError { + kind: OpenStreamErrorKind, + message: String, + status_code: Option, +} + +#[derive(Default)] +struct PendingBatch { + events: Vec, +} + +#[derive(Clone)] +struct ActiveTextDelta { + instance_id: String, + session_id: String, + message_id: String, + part_id: String, + delta: String, +} + +struct ActiveTextPartBuffer { + instance_id: String, + session_id: String, + message_id: String, + part_id: String, + display_pending: String, + store_pending: String, + last_display_emit: Instant, + last_store_emit: Instant, +} + +impl ActiveTextPartBuffer { + fn new(delta: ActiveTextDelta, now: Instant) -> Self { + Self { + instance_id: delta.instance_id, + session_id: delta.session_id, + message_id: delta.message_id, + part_id: delta.part_id, + display_pending: delta.delta.clone(), + store_pending: delta.delta, + last_display_emit: now, + last_store_emit: now, + } + } +} + +#[derive(Clone)] +struct ActiveTextSnapshot { + key: String, + instance_id: String, + session_id: String, + message_id: String, + part_id: String, + event: Value, +} + +struct BufferedTextSnapshot { + instance_id: String, + session_id: String, + message_id: String, + part_id: String, + event: Value, + buffered_at: Instant, +} + +#[derive(Default)] +struct ActiveTextAssembler { + parts: HashMap, +} + +#[derive(Default)] +struct ActiveTextSnapshotBuffer { + parts: HashMap, +} + +impl DesktopEventTransportManager { + pub fn new() -> Self { + Self { + state: Arc::new(Mutex::new(DesktopEventTransportState { + stop: None, + config: None, + active_target: None, + })), + generation: Arc::new(AtomicU64::new(0)), + } + } + + pub fn set_active_session_target(&self, target: Option) { + let mut state = self.state.lock(); + state.active_target = target; + } + + pub fn start( + &self, + app: AppHandle, + stream_config: Option, + request: Option, + ) -> DesktopEventsStartResult { + let Some(stream_config) = stream_config else { + return DesktopEventsStartResult { + started: false, + generation: None, + reason: Some("desktop event stream unavailable".to_string()), + }; + }; + + let request = request.unwrap_or_default(); + let transport_config = DesktopEventTransportConfig::new(stream_config, &request); + + let mut state = self.state.lock(); + if state.config.as_ref() == Some(&transport_config) { + if let Some(stop) = &state.stop { + if !stop.load(Ordering::SeqCst) { + return DesktopEventsStartResult { + started: true, + generation: Some(self.generation.load(Ordering::SeqCst)), + reason: None, + }; + } + } + } + + if let Some(stop) = state.stop.take() { + stop.store(true, Ordering::SeqCst); + } + + let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1; + let stop = Arc::new(AtomicBool::new(false)); + state.stop = Some(stop.clone()); + state.config = Some(transport_config.clone()); + let shared_state = self.state.clone(); + let shared_generation = self.generation.clone(); + drop(state); + + thread::spawn(move || { + run_transport_loop( + app, + shared_state, + shared_generation, + generation, + stop, + transport_config, + ) + }); + + DesktopEventsStartResult { + started: true, + generation: Some(generation), + reason: None, + } + } + + pub fn stop(&self) { + let mut state = self.state.lock(); + if let Some(stop) = state.stop.take() { + stop.store(true, Ordering::SeqCst); + } + state.config = None; + state.active_target = None; + self.generation.fetch_add(1, Ordering::SeqCst); + } +} + +fn classify_event(event: &Value) -> EventDeliveryPolicy { + if let Some(key) = delta_key(event) { + return EventDeliveryPolicy::CoalesceDelta(key); + } + + if let Some(key) = status_key(event) { + return EventDeliveryPolicy::CoalesceStatus(key); + } + + if let Some(key) = snapshot_key(event) { + return EventDeliveryPolicy::CoalesceSnapshot(key); + } + + EventDeliveryPolicy::Passthrough +} + +fn coalesced_payload_event<'a>(event: &'a Value) -> &'a Value { + if event.get("type").and_then(Value::as_str) == Some("instance.event") { + event.get("event").unwrap_or(event) + } else { + event + } +} + +fn coalesced_instance_id(event: &Value) -> &str { + event + .get("instanceId") + .and_then(Value::as_str) + .unwrap_or_default() +} + +fn event_session_id(event: &Value) -> Option<&str> { + let inner = coalesced_payload_event(event); + let inner_type = inner.get("type")?.as_str()?; + let props = inner.get("properties")?; + + match inner_type { + "session.updated" => props + .get("info") + .and_then(|info| info.get("id")) + .and_then(Value::as_str) + .or_else(|| { + props + .get("sessionID") + .or_else(|| props.get("sessionId")) + .and_then(Value::as_str) + }), + "message.updated" => props + .get("info") + .and_then(|info| info.get("sessionID").or_else(|| info.get("sessionId"))) + .and_then(Value::as_str), + "message.part.updated" => props + .get("part") + .and_then(|part| part.get("sessionID").or_else(|| part.get("sessionId"))) + .and_then(Value::as_str), + "message.part.delta" + | "message.removed" + | "message.part.removed" + | "session.compacted" + | "session.diff" + | "session.idle" + | "session.status" => props + .get("sessionID") + .or_else(|| props.get("sessionId")) + .and_then(Value::as_str), + _ => None, + } +} + +fn parse_active_text_delta( + event: &Value, + active_target: Option<&ActiveSessionTarget>, +) -> Option { + let active_target = active_target?; + let instance_id = coalesced_instance_id(event); + if instance_id != active_target.instance_id { + return None; + } + let inner = coalesced_payload_event(event); + if inner.get("type")?.as_str()? != "message.part.delta" { + return None; + } + + let props = inner.get("properties")?; + let field = props.get("field")?.as_str()?; + if field != "text" { + return None; + } + + let event_session = props + .get("sessionID") + .or_else(|| props.get("sessionId")) + .and_then(Value::as_str)?; + if event_session != active_target.session_id { + return None; + } + + Some(ActiveTextDelta { + instance_id: instance_id.to_string(), + session_id: event_session.to_string(), + message_id: props + .get("messageID") + .or_else(|| props.get("messageId")) + .and_then(Value::as_str)? + .to_string(), + part_id: props + .get("partID") + .or_else(|| props.get("partId")) + .and_then(Value::as_str)? + .to_string(), + delta: props.get("delta")?.as_str()?.to_string(), + }) +} + +fn make_assistant_stream_chunk_event(entry: &ActiveTextPartBuffer, delta: &str) -> Value { + serde_json::json!({ + "type": "instance.event", + "instanceId": entry.instance_id, + "event": { + "type": "assistant.stream.chunk", + "properties": { + "sessionID": entry.session_id, + "messageID": entry.message_id, + "partID": entry.part_id, + "field": "text", + "delta": delta, + } + } + }) +} + +fn make_message_part_delta_event(entry: &ActiveTextPartBuffer, delta: &str) -> Value { + serde_json::json!({ + "type": "instance.event", + "instanceId": entry.instance_id, + "event": { + "type": "message.part.delta", + "properties": { + "sessionID": entry.session_id, + "messageID": entry.message_id, + "partID": entry.part_id, + "field": "text", + "delta": delta, + } + } + }) +} + +fn parse_active_text_snapshot( + event: &Value, + active_target: Option<&ActiveSessionTarget>, +) -> Option { + let active_target = active_target?; + let instance_id = coalesced_instance_id(event); + if instance_id != active_target.instance_id { + return None; + } + + let inner = coalesced_payload_event(event); + if inner.get("type")?.as_str()? != "message.part.updated" { + return None; + } + + let part = inner.get("properties")?.get("part")?; + if part.get("type")?.as_str()? != "text" { + return None; + } + if part.get("text")?.as_str().is_none() { + return None; + } + + let event_session = part + .get("sessionID") + .or_else(|| part.get("sessionId")) + .and_then(Value::as_str)?; + if event_session != active_target.session_id { + return None; + } + + let message_id = part + .get("messageID") + .or_else(|| part.get("messageId")) + .and_then(Value::as_str)?; + let part_id = part.get("id")?.as_str()?; + + Some(ActiveTextSnapshot { + key: format!( + "{}:{}:{}:{}", + instance_id, event_session, message_id, part_id + ), + instance_id: instance_id.to_string(), + session_id: event_session.to_string(), + message_id: message_id.to_string(), + part_id: part_id.to_string(), + event: event.clone(), + }) +} + +fn snapshot_key(event: &Value) -> Option { + let instance_id = coalesced_instance_id(event); + let inner = coalesced_payload_event(event); + let inner_type = inner.get("type")?.as_str()?; + let props = inner.get("properties")?; + + match inner_type { + "message.part.updated" => { + let session_id = props + .get("part") + .and_then(|part| part.get("sessionID").or_else(|| part.get("sessionId"))) + .and_then(Value::as_str)?; + let message_id = props + .get("part") + .and_then(|part| part.get("messageID").or_else(|| part.get("messageId"))) + .and_then(Value::as_str)?; + let part_id = props + .get("part") + .and_then(|part| part.get("id")) + .and_then(Value::as_str)?; + + Some(format!( + "message.part.updated:{}:{}:{}:{}", + instance_id, session_id, message_id, part_id + )) + } + "message.updated" => { + let info = props.get("info")?; + let session_id = info + .get("sessionID") + .or_else(|| info.get("sessionId")) + .and_then(Value::as_str)?; + let message_id = info.get("id").and_then(Value::as_str)?; + + Some(format!( + "message.updated:{}:{}:{}", + instance_id, session_id, message_id + )) + } + "session.updated" | "session.status" => { + let session_id = props + .get("info") + .and_then(|info| info.get("id")) + .and_then(Value::as_str) + .or_else(|| { + props + .get("sessionID") + .or_else(|| props.get("sessionId")) + .and_then(Value::as_str) + })?; + + Some(format!("{}:{}:{}", inner_type, instance_id, session_id)) + } + _ => None, + } +} + +fn delta_scope(event: &Value) -> Option { + let instance_id = coalesced_instance_id(event); + let inner = coalesced_payload_event(event); + if inner.get("type")?.as_str()? != "message.part.delta" { + return None; + } + + let props = inner.get("properties")?; + let session_id = props + .get("sessionID") + .or_else(|| props.get("sessionId")) + .and_then(Value::as_str) + .unwrap_or_default(); + let message_id = props + .get("messageID") + .or_else(|| props.get("messageId")) + .and_then(Value::as_str)?; + let part_id = props + .get("partID") + .or_else(|| props.get("partId")) + .and_then(Value::as_str)?; + + Some(format!( + "message.part:{}:{}:{}:{}", + instance_id, session_id, message_id, part_id + )) +} + +fn delta_key(event: &Value) -> Option { + let scope = delta_scope(event)?; + let props = coalesced_payload_event(event).get("properties")?; + let field = props.get("field")?.as_str()?; + + Some(format!("{}:{}", scope, field)) +} + +fn snapshot_superseded_delta_scope(event: &Value) -> Option { + let instance_id = coalesced_instance_id(event); + let inner = coalesced_payload_event(event); + if inner.get("type")?.as_str()? != "message.part.updated" { + return None; + } + + let part = inner.get("properties")?.get("part")?; + let session_id = part + .get("sessionID") + .or_else(|| part.get("sessionId")) + .and_then(Value::as_str)?; + let message_id = part + .get("messageID") + .or_else(|| part.get("messageId")) + .and_then(Value::as_str)?; + let part_id = part.get("id")?.as_str()?; + + Some(format!( + "message.part:{}:{}:{}:{}", + instance_id, session_id, message_id, part_id + )) +} + +fn append_delta(target: &mut Value, event: &Value) { + let next_delta = coalesced_payload_event(event) + .get("properties") + .and_then(|value| value.get("delta")) + .and_then(Value::as_str) + .unwrap_or_default(); + + if let Some(existing_delta) = coalesced_payload_event_mut(target) + .and_then(|event| event.get_mut("properties")) + .and_then(Value::as_object_mut) + .and_then(|props| props.get_mut("delta")) + { + let combined = existing_delta.as_str().unwrap_or_default().to_string() + next_delta; + *existing_delta = Value::String(combined); + } +} + +fn coalesced_payload_event_mut(event: &mut Value) -> Option<&mut serde_json::Map> { + if event.get("type").and_then(Value::as_str) == Some("instance.event") { + event.get_mut("event").and_then(Value::as_object_mut) + } else { + event.as_object_mut() + } +} + +fn status_key(event: &Value) -> Option { + match event.get("type")?.as_str()? { + "instance.eventStatus" => Some(coalesced_instance_id(event).to_string()), + "session.status" => snapshot_key(event), + _ => None, + } +} + +#[cfg(test)] +mod tests; diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs new file mode 100644 index 000000000..82299452e --- /dev/null +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs @@ -0,0 +1,501 @@ +use super::*; + +impl PendingBatch { + pub(super) fn push(&mut self, event: Value, stats: &mut DesktopEventTransportStats) { + match classify_event(&event) { + EventDeliveryPolicy::CoalesceDelta(key) => { + let Some(scope) = delta_scope(&event) else { + self.events.push(PendingEntry::Event(event)); + return; + }; + + if let Some(PendingEntry::Delta { + key: existing_key, + event: existing_event, + .. + }) = self.events.last_mut() + { + if existing_key == &key { + append_delta(existing_event, &event); + stats.delta_coalesces = stats.delta_coalesces.saturating_add(1); + return; + } + } + + self.events.push(PendingEntry::Delta { + key, + scope, + instance_id: coalesced_instance_id(&event).to_string(), + session_id: event_session_id(&event).map(|value| value.to_string()), + event, + started_at: Instant::now(), + }); + } + EventDeliveryPolicy::CoalesceStatus(key) => { + if let Some(PendingEntry::Status { + key: existing_key, + event: existing_event, + }) = self.events.last_mut() + { + if existing_key == &key { + *existing_event = event; + stats.status_coalesces = stats.status_coalesces.saturating_add(1); + return; + } + } + + self.events.push(PendingEntry::Status { key, event }); + } + EventDeliveryPolicy::CoalesceSnapshot(key) => { + if let Some(part_scope) = snapshot_superseded_delta_scope(&event) { + let mut dropped = 0_u64; + while matches!( + self.events.last(), + Some(PendingEntry::Delta { scope, .. }) if scope == &part_scope + ) { + self.events.pop(); + dropped = dropped.saturating_add(1); + } + if dropped > 0 { + stats.superseded_deltas_dropped = + stats.superseded_deltas_dropped.saturating_add(dropped); + } + } + + if let Some(PendingEntry::Snapshot { + key: existing_key, + event: existing_event, + }) = self.events.last_mut() + { + if existing_key == &key { + *existing_event = event; + stats.snapshot_coalesces = stats.snapshot_coalesces.saturating_add(1); + return; + } + } + + self.events.push(PendingEntry::Snapshot { key, event }); + } + EventDeliveryPolicy::Passthrough => { + self.events.push(PendingEntry::Event(event)); + } + } + } + + pub(super) fn take_events(&mut self) -> Vec { + let pending = std::mem::take(&mut self.events); + pending + .into_iter() + .map(|entry| match entry { + PendingEntry::Delta { event, .. } => event, + PendingEntry::Status { event, .. } => event, + PendingEntry::Snapshot { event, .. } => event, + PendingEntry::Event(event) => event, + }) + .collect() + } + + pub(super) fn is_empty(&self) -> bool { + self.events.is_empty() + } + + pub(super) fn pending_len(&self) -> usize { + self.events.len() + } + + pub(super) fn should_hold_single_delta( + &self, + now: Instant, + active_target: Option<&ActiveSessionTarget>, + ) -> bool { + matches!( + self.events.as_slice(), + [PendingEntry::Delta { started_at, instance_id, session_id, .. }] + if now.duration_since(*started_at) < Duration::from_millis( + if active_target + .map(|target| { + target.instance_id.as_str() == instance_id.as_str() + && target.session_id.as_str() == session_id.as_deref().unwrap_or_default() + }) + .unwrap_or(false) + { + ACTIVE_STREAM_HOLD_WINDOW_MS + } else { + DELTA_STREAM_WINDOW_MS + } + ) + ) + } +} + +impl ActiveTextAssembler { + pub(super) fn absorb(&mut self, delta: ActiveTextDelta, now: Instant) -> Vec { + let key = format!( + "{}:{}:{}:{}", + delta.instance_id, delta.session_id, delta.message_id, delta.part_id + ); + + match self.parts.entry(key) { + std::collections::hash_map::Entry::Occupied(mut occupied) => { + let entry = occupied.get_mut(); + if entry.display_pending.is_empty() && entry.store_pending.is_empty() { + entry.instance_id = delta.instance_id.clone(); + entry.session_id = delta.session_id.clone(); + entry.message_id = delta.message_id.clone(); + entry.part_id = delta.part_id.clone(); + } + + entry.display_pending.push_str(&delta.delta); + entry.store_pending.push_str(&delta.delta); + Self::collect_due_for_part(entry, now) + } + std::collections::hash_map::Entry::Vacant(vacant) => { + let mut entry = ActiveTextPartBuffer::new(delta, now); + entry.last_display_emit = now + .checked_sub(Duration::from_millis(ACTIVE_STREAM_DISPLAY_WINDOW_MS)) + .unwrap_or(now); + let emitted = Self::collect_due_for_part(&mut entry, now); + vacant.insert(entry); + emitted + } + } + } + + pub(super) fn take_due(&mut self, now: Instant) -> Vec { + let mut emitted = Vec::new(); + let mut empty_keys = Vec::new(); + + for (key, entry) in self.parts.iter_mut() { + emitted.extend(Self::collect_due_for_part(entry, now)); + if entry.display_pending.is_empty() && entry.store_pending.is_empty() { + empty_keys.push(key.clone()); + } + } + + for key in empty_keys { + self.parts.remove(&key); + } + + emitted + } + + pub(super) fn flush_for_event(&mut self, event: &Value, now: Instant) -> Vec { + let instance_id = coalesced_instance_id(event); + let payload = coalesced_payload_event(event); + let event_type = payload.get("type").and_then(Value::as_str); + + match event_type { + Some("message.updated") | Some("message.removed") => { + let props = payload.get("properties"); + let session_id = event_session_id(event); + let message_id = props + .and_then(|value| { + value + .get("info") + .and_then(|info| info.get("id")) + .or_else(|| value.get("messageID")) + .or_else(|| value.get("messageId")) + }) + .and_then(Value::as_str); + if let (Some(session_id), Some(message_id)) = (session_id, message_id) { + return self.flush_message(instance_id, session_id, message_id, now); + } + } + Some("message.part.updated") | Some("message.part.removed") => { + let props = payload.get("properties"); + let session_id = event_session_id(event); + let message_id = props + .and_then(|value| { + value + .get("part") + .and_then(|part| { + part.get("messageID").or_else(|| part.get("messageId")) + }) + .or_else(|| value.get("messageID")) + .or_else(|| value.get("messageId")) + }) + .and_then(Value::as_str); + let part_id = props + .and_then(|value| { + value + .get("part") + .and_then(|part| part.get("id")) + .or_else(|| value.get("partID")) + .or_else(|| value.get("partId")) + }) + .and_then(Value::as_str); + if let (Some(session_id), Some(message_id), Some(part_id)) = + (session_id, message_id, part_id) + { + return self.flush_part(instance_id, session_id, message_id, part_id, now); + } + } + _ => {} + } + + Vec::new() + } + + pub(super) fn flush_message( + &mut self, + instance_id: &str, + session_id: &str, + message_id: &str, + now: Instant, + ) -> Vec { + let keys: Vec = self + .parts + .iter() + .filter(|(_, entry)| { + entry.instance_id == instance_id + && entry.session_id == session_id + && entry.message_id == message_id + }) + .map(|(key, _)| key.clone()) + .collect(); + + let mut emitted = Vec::new(); + for key in keys { + if let Some(mut entry) = self.parts.remove(&key) { + emitted.extend(Self::flush_all_for_part(&mut entry, now)); + } + } + emitted + } + + pub(super) fn flush_part( + &mut self, + instance_id: &str, + session_id: &str, + message_id: &str, + part_id: &str, + now: Instant, + ) -> Vec { + let key = format!("{}:{}:{}:{}", instance_id, session_id, message_id, part_id); + if let Some(mut entry) = self.parts.remove(&key) { + return Self::flush_all_for_part(&mut entry, now); + } + Vec::new() + } + + pub(super) fn flush_store_only_all(&mut self, now: Instant) -> Vec { + let mut emitted = Vec::new(); + for entry in self.parts.values_mut() { + if !entry.store_pending.is_empty() { + emitted.push(make_message_part_delta_event(entry, &entry.store_pending)); + entry.store_pending.clear(); + entry.last_store_emit = now; + } + entry.display_pending.clear(); + entry.last_display_emit = now; + } + self.parts.clear(); + emitted + } + + fn collect_due_for_part(entry: &mut ActiveTextPartBuffer, now: Instant) -> Vec { + let mut emitted = Vec::new(); + + // Display lane — emit preview chunks frequently (~16ms / 96 chars). + if !entry.display_pending.is_empty() + && (now.duration_since(entry.last_display_emit) + >= Duration::from_millis(ACTIVE_STREAM_DISPLAY_WINDOW_MS) + || entry.display_pending.len() >= ACTIVE_STREAM_DISPLAY_CHUNK_MAX) + { + emitted.push(make_assistant_stream_chunk_event( + entry, + &entry.display_pending, + )); + entry.display_pending.clear(); + entry.last_display_emit = now; + } + + // Store lane — emit canonical deltas infrequently (~250ms) to avoid + // flooding the JS reactive graph with store mutations that + // trigger expensive re-render cascades during active streaming. + // Explicit flush triggers (message.updated, message.part.updated, + // session change, disconnect) still flush immediately via + // flush_for_event / flush_all_for_part / flush_store_only_all. + if !entry.store_pending.is_empty() + && now.duration_since(entry.last_store_emit) + >= Duration::from_millis(ACTIVE_STREAM_STORE_WINDOW_MS) + { + emitted.push(make_message_part_delta_event(entry, &entry.store_pending)); + entry.store_pending.clear(); + entry.last_store_emit = now; + } + + emitted + } + + fn flush_all_for_part(entry: &mut ActiveTextPartBuffer, now: Instant) -> Vec { + let mut emitted = Vec::new(); + if !entry.display_pending.is_empty() { + emitted.push(make_assistant_stream_chunk_event( + entry, + &entry.display_pending, + )); + entry.display_pending.clear(); + entry.last_display_emit = now; + } + if !entry.store_pending.is_empty() { + emitted.push(make_message_part_delta_event(entry, &entry.store_pending)); + entry.store_pending.clear(); + entry.last_store_emit = now; + } + emitted + } +} + +impl ActiveTextSnapshotBuffer { + pub(super) fn buffer(&mut self, snapshot: ActiveTextSnapshot, now: Instant) { + match self.parts.entry(snapshot.key) { + std::collections::hash_map::Entry::Occupied(mut occupied) => { + let entry = occupied.get_mut(); + entry.instance_id = snapshot.instance_id; + entry.session_id = snapshot.session_id; + entry.message_id = snapshot.message_id; + entry.part_id = snapshot.part_id; + entry.event = snapshot.event; + } + std::collections::hash_map::Entry::Vacant(vacant) => { + vacant.insert(BufferedTextSnapshot { + instance_id: snapshot.instance_id, + session_id: snapshot.session_id, + message_id: snapshot.message_id, + part_id: snapshot.part_id, + event: snapshot.event, + buffered_at: now, + }); + } + } + } + + pub(super) fn take_due(&mut self, now: Instant) -> Vec { + let keys: Vec = self + .parts + .iter() + .filter(|(_, entry)| { + now.duration_since(entry.buffered_at) + >= Duration::from_millis(ACTIVE_STREAM_SNAPSHOT_WINDOW_MS) + }) + .map(|(key, _)| key.clone()) + .collect(); + + self.take_entries(keys) + } + + pub(super) fn flush_for_event(&mut self, event: &Value) -> Vec { + let instance_id = coalesced_instance_id(event); + let payload = coalesced_payload_event(event); + let event_type = payload.get("type").and_then(Value::as_str); + + match event_type { + Some("message.updated") | Some("message.removed") => { + let props = payload.get("properties"); + let session_id = event_session_id(event); + let message_id = props + .and_then(|value| { + value + .get("info") + .and_then(|info| info.get("id")) + .or_else(|| value.get("messageID")) + .or_else(|| value.get("messageId")) + }) + .and_then(Value::as_str); + if let (Some(session_id), Some(message_id)) = (session_id, message_id) { + return self.flush_message(instance_id, session_id, message_id); + } + } + Some("message.part.removed") => { + let props = payload.get("properties"); + let session_id = event_session_id(event); + let message_id = props + .and_then(|value| { + value + .get("part") + .and_then(|part| { + part.get("messageID").or_else(|| part.get("messageId")) + }) + .or_else(|| value.get("messageID")) + .or_else(|| value.get("messageId")) + }) + .and_then(Value::as_str); + let part_id = props + .and_then(|value| { + value + .get("part") + .and_then(|part| part.get("id")) + .or_else(|| value.get("partID")) + .or_else(|| value.get("partId")) + }) + .and_then(Value::as_str); + if let (Some(session_id), Some(message_id), Some(part_id)) = + (session_id, message_id, part_id) + { + return self.flush_part(instance_id, session_id, message_id, part_id); + } + } + _ => {} + } + + Vec::new() + } + + pub(super) fn flush_message( + &mut self, + instance_id: &str, + session_id: &str, + message_id: &str, + ) -> Vec { + let keys: Vec = self + .parts + .iter() + .filter(|(_, entry)| { + entry.instance_id == instance_id + && entry.session_id == session_id + && entry.message_id == message_id + }) + .map(|(key, _)| key.clone()) + .collect(); + + self.take_entries(keys) + } + + pub(super) fn flush_part( + &mut self, + instance_id: &str, + session_id: &str, + message_id: &str, + part_id: &str, + ) -> Vec { + let keys: Vec = self + .parts + .iter() + .filter(|(_, entry)| { + entry.instance_id == instance_id + && entry.session_id == session_id + && entry.message_id == message_id + && entry.part_id == part_id + }) + .map(|(key, _)| key.clone()) + .collect(); + + self.take_entries(keys) + } + + pub(super) fn flush_all(&mut self) -> Vec { + let keys: Vec = self.parts.keys().cloned().collect(); + self.take_entries(keys) + } + + fn take_entries(&mut self, keys: Vec) -> Vec { + let mut emitted = Vec::new(); + for key in keys { + if let Some(entry) = self.parts.remove(&key) { + emitted.push(entry.event); + } + } + emitted + } +} diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs new file mode 100644 index 000000000..e861a1b31 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs @@ -0,0 +1,185 @@ +use super::*; + +pub(super) fn build_stream_client() -> Result { + Client::builder() + .connect_timeout(Duration::from_millis(STREAM_CONNECT_TIMEOUT_MS)) + .tcp_keepalive(Duration::from_millis(STREAM_TCP_KEEPALIVE_MS)) + // Note: reqwest's blocking client doesn't expose a per-read timeout. + // The global `.timeout()` would kill the entire SSE stream, so we + // rely on: + // 1. tcp_keepalive to detect dead connections (OS will RST after + // several unacked probes, typically ~2 min). + // 2. Consumer-side stall detection (STREAM_STALL_TIMEOUT_MS). + // 3. Reader thread breaking on channel send error (consumer dropped). + .build() + .map_err(|error: reqwest::Error| OpenStreamError { + kind: OpenStreamErrorKind::Transport, + message: error.to_string(), + status_code: None, + }) +} + +pub(super) fn open_stream( + app: &AppHandle, + client: &Client, + config: &DesktopEventStreamConfig, +) -> Result { + let connection_id = generate_connection_id(); + let url = format!( + "{}?clientId={}&connectionId={}", + config.events_url, config.client_id, connection_id + ); + + let mut request = client.get(&url).header("Accept", "text/event-stream"); + + if let Some(session_cookie) = resolve_session_cookie(app, config) { + request = request.header( + "Cookie", + format!("{}={}", config.cookie_name, session_cookie), + ); + } + + let response = request.send().map_err(|error| OpenStreamError { + kind: OpenStreamErrorKind::Transport, + message: error.to_string(), + status_code: None, + })?; + + if response.status().is_success() { + return Ok(response); + } + + let status = response.status(); + let kind = if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) { + OpenStreamErrorKind::Unauthorized + } else { + OpenStreamErrorKind::Http + }; + + Err(OpenStreamError { + kind, + message: format!("desktop event stream unavailable ({status})"), + status_code: Some(status.as_u16()), + }) +} + +fn resolve_session_cookie(app: &AppHandle, config: &DesktopEventStreamConfig) -> Option { + read_session_cookie_from_webview(app, &config.base_url, &config.cookie_name) + .or_else(|| config.session_cookie.clone()) + .filter(|value| !value.is_empty()) +} + +fn generate_connection_id() -> String { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let tid = std::thread::current().id(); + format!("tauri-{}-{:?}", ts, tid) +} + +fn read_session_cookie_from_webview( + app: &AppHandle, + base_url: &str, + cookie_name: &str, +) -> Option { + let url = Url::parse(base_url).ok()?; + let host = url.host_str()?.to_ascii_lowercase(); + let path = url.path(); + let windows = app.webview_windows(); + let window = windows.get("main")?; + let cookies = window.cookies().ok()?; + cookies + .into_iter() + .filter(|cookie: &tauri::webview::cookie::Cookie<'static>| cookie.name() == cookie_name) + .filter(|cookie: &tauri::webview::cookie::Cookie<'static>| { + let Some(domain) = cookie.domain() else { + return true; + }; + + let normalized_domain = domain.trim_start_matches('.').to_ascii_lowercase(); + host == normalized_domain || host.ends_with(&format!(".{}", normalized_domain)) + }) + .filter(|cookie: &tauri::webview::cookie::Cookie<'static>| { + let Some(cookie_path) = cookie.path() else { + return true; + }; + + path.starts_with(cookie_path) + }) + .map(|cookie: tauri::webview::cookie::Cookie<'static>| cookie.value().to_string()) + .next() +} + +pub(super) fn read_sse( + response: Response, + tx: SyncSender, + stop: Arc, + generation_atomic: Arc, + generation: u64, +) { + let mut reader = BufReader::new(response); + let mut line = String::new(); + let mut data_lines: Vec = Vec::new(); + + loop { + if stop.load(Ordering::SeqCst) || !generation_matches(&generation_atomic, generation) { + let _ = tx.send(ReaderMessage::End(Some("stopped".to_string()))); + return; + } + + line.clear(); + match reader.read_line(&mut line) { + Ok(0) => { + if let Some(event) = parse_sse_payload(&data_lines) { + let _ = tx.send(ReaderMessage::Event(event)); + } + let _ = tx.send(ReaderMessage::End(Some("stream closed".to_string()))); + return; + } + Ok(_) => { + if tx.send(ReaderMessage::Activity).is_err() { + return; // consumer dropped — stop reading + } + let trimmed = line.trim_end_matches(['\r', '\n']); + if trimmed.is_empty() { + if let Some(event) = parse_sse_payload(&data_lines) { + if tx.send(ReaderMessage::Event(event)).is_err() { + return; // consumer dropped + } + } + data_lines.clear(); + continue; + } + + if trimmed.starts_with(':') { + continue; + } + + if let Some(data) = trimmed.strip_prefix("data:") { + data_lines.push(data.strip_prefix(' ').unwrap_or(data).to_string()); + } + } + Err(error) => { + if let Some(event) = parse_sse_payload(&data_lines) { + let _ = tx.send(ReaderMessage::Event(event)); + } + let _ = tx.send(ReaderMessage::End(Some(error.to_string()))); + return; + } + } + } +} + +fn parse_sse_payload(lines: &[String]) -> Option { + if lines.is_empty() { + return None; + } + + let payload = lines.join("\n").trim().to_string(); + if payload.is_empty() { + return None; + } + + serde_json::from_str::(&payload).ok() +} diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs new file mode 100644 index 000000000..e464ef8c9 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs @@ -0,0 +1,575 @@ +use super::*; +use serde_json::json; + +fn fresh_stats() -> DesktopEventTransportStats { + DesktopEventTransportStats::default() +} + +fn delta_event(delta: &str) -> Value { + json!({ + "type": "instance.event", + "instanceId": "inst-1", + "event": { + "type": "message.part.delta", + "properties": { + "sessionID": "sess-1", + "messageID": "msg-1", + "partID": "part-1", + "field": "text", + "delta": delta, + } + } + }) +} + +fn delta_event_for(part_id: &str, delta: &str) -> Value { + json!({ + "type": "instance.event", + "instanceId": "inst-1", + "event": { + "type": "message.part.delta", + "properties": { + "sessionID": "sess-1", + "messageID": "msg-1", + "partID": part_id, + "field": "text", + "delta": delta, + } + } + }) +} + +fn direct_delta_event(delta: &str) -> Value { + json!({ + "type": "message.part.delta", + "properties": { + "sessionID": "sess-1", + "messageID": "msg-1", + "partID": "part-1", + "field": "text", + "delta": delta, + } + }) +} + +fn direct_message_part_updated_event(text: &str) -> Value { + json!({ + "type": "message.part.updated", + "properties": { + "part": { + "id": "part-1", + "type": "text", + "text": text, + "sessionID": "sess-1", + "messageID": "msg-1" + } + } + }) +} + +fn message_part_updated_event(text: &str) -> Value { + json!({ + "type": "instance.event", + "instanceId": "inst-1", + "event": { + "type": "message.part.updated", + "properties": { + "part": { + "id": "part-1", + "type": "text", + "text": text, + "sessionID": "sess-1", + "messageID": "msg-1" + } + } + } + }) +} + +fn active_target() -> ActiveSessionTarget { + ActiveSessionTarget { + instance_id: "inst-1".to_string(), + session_id: "sess-1".to_string(), + } +} + +#[test] +fn coalesces_message_part_delta_events() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(delta_event("Hello"), &mut stats); + pending.push(delta_event(" world"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0]["event"]["properties"]["delta"].as_str(), + Some("Hello world") + ); +} + +#[test] +fn last_write_wins_for_status_events() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push( + json!({ + "type": "instance.eventStatus", + "instanceId": "inst-1", + "status": "connecting" + }), + &mut stats, + ); + pending.push( + json!({ + "type": "instance.eventStatus", + "instanceId": "inst-1", + "status": "connected" + }), + &mut stats, + ); + + let events = pending.take_events(); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["status"].as_str(), Some("connected")); +} + +#[test] +fn last_write_wins_for_consecutive_snapshot_events() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(message_part_updated_event("Hello"), &mut stats); + pending.push(message_part_updated_event("Hello world"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0]["event"]["properties"]["part"]["text"].as_str(), + Some("Hello world") + ); +} + +#[test] +fn interleaved_snapshot_keys_keep_order() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(message_part_updated_event("A1"), &mut stats); + pending.push( + json!({ + "type": "instance.event", + "instanceId": "inst-1", + "event": { + "type": "message.part.updated", + "properties": { + "part": { + "id": "part-2", + "type": "text", + "text": "B1", + "sessionID": "sess-1", + "messageID": "msg-1" + } + } + } + }), + &mut stats, + ); + pending.push(message_part_updated_event("A2"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 3); + assert_eq!( + events[0]["event"]["properties"]["part"]["id"].as_str(), + Some("part-1") + ); + assert_eq!( + events[1]["event"]["properties"]["part"]["id"].as_str(), + Some("part-2") + ); + assert_eq!( + events[2]["event"]["properties"]["part"]["text"].as_str(), + Some("A2") + ); +} + +#[test] +fn snapshot_replaces_trailing_deltas_for_same_part() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(delta_event("Hello"), &mut stats); + pending.push(message_part_updated_event("Hello world"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0]["event"]["type"].as_str(), + Some("message.part.updated") + ); + assert_eq!( + events[0]["event"]["properties"]["part"]["text"].as_str(), + Some("Hello world") + ); +} + +#[test] +fn structural_events_force_coalesced_flush_before_append() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(delta_event("Hello"), &mut stats); + pending.push( + json!({ + "type": "instance.event", + "instanceId": "inst-1", + "event": { + "type": "message.updated", + "properties": { + "id": "msg-1" + } + } + }), + &mut stats, + ); + + let events = pending.take_events(); + assert_eq!(events.len(), 2); + assert_eq!( + events[0]["event"]["type"].as_str(), + Some("message.part.delta") + ); + assert_eq!(events[1]["event"]["type"].as_str(), Some("message.updated")); +} + +#[test] +fn interleaved_delta_keys_keep_order() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(delta_event_for("part-1", "A1"), &mut stats); + pending.push(delta_event_for("part-2", "B1"), &mut stats); + pending.push(delta_event_for("part-1", "A2"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 3); + assert_eq!( + events[0]["event"]["properties"]["partID"].as_str(), + Some("part-1") + ); + assert_eq!( + events[0]["event"]["properties"]["delta"].as_str(), + Some("A1") + ); + assert_eq!( + events[1]["event"]["properties"]["partID"].as_str(), + Some("part-2") + ); + assert_eq!( + events[1]["event"]["properties"]["delta"].as_str(), + Some("B1") + ); + assert_eq!( + events[2]["event"]["properties"]["partID"].as_str(), + Some("part-1") + ); + assert_eq!( + events[2]["event"]["properties"]["delta"].as_str(), + Some("A2") + ); +} + +#[test] +fn reconnect_delay_grows_and_caps() { + let policy = ResolvedDesktopEventReconnectPolicy { + initial_delay_ms: 100, + max_delay_ms: 500, + multiplier: 2.0, + max_attempts: None, + }; + + assert_eq!(compute_reconnect_delay_ms(1, &policy), 100); + assert_eq!(compute_reconnect_delay_ms(2, &policy), 200); + assert_eq!(compute_reconnect_delay_ms(3, &policy), 400); + assert_eq!(compute_reconnect_delay_ms(4, &policy), 500); +} + +#[test] +fn holds_single_delta_within_stream_window() { + let pending = PendingBatch { + events: vec![PendingEntry::Delta { + key: "delta-key".to_string(), + scope: "delta-scope".to_string(), + instance_id: "inst-1".to_string(), + session_id: Some("sess-1".to_string()), + event: delta_event("Hello"), + started_at: Instant::now(), + }], + }; + + assert!(pending.should_hold_single_delta(Instant::now(), None)); +} + +#[test] +fn flushes_single_delta_after_stream_window() { + let started_at = Instant::now() - Duration::from_millis(DELTA_STREAM_WINDOW_MS + 1); + let pending = PendingBatch { + events: vec![PendingEntry::Delta { + key: "delta-key".to_string(), + scope: "delta-scope".to_string(), + instance_id: "inst-1".to_string(), + session_id: Some("sess-1".to_string()), + event: delta_event("Hello"), + started_at, + }], + }; + + assert!(!pending.should_hold_single_delta(Instant::now(), None)); +} + +#[test] +fn active_session_uses_shorter_hold_window() { + // Delta aged past the active-stream window but within the base window. + // Active session should flush faster, so this should NOT be held. + let started_at = Instant::now() - Duration::from_millis(ACTIVE_STREAM_HOLD_WINDOW_MS + 10); + let pending = PendingBatch { + events: vec![PendingEntry::Delta { + key: "delta-key".to_string(), + scope: "delta-scope".to_string(), + instance_id: "inst-1".to_string(), + session_id: Some("sess-1".to_string()), + event: delta_event("Hello"), + started_at, + }], + }; + + let active_target = active_target(); + let other_target = ActiveSessionTarget { + instance_id: "inst-1".to_string(), + session_id: "sess-2".to_string(), + }; + + // Active session: uses ACTIVE_STREAM_HOLD_WINDOW_MS, so this stale delta is not held. + assert!(!pending.should_hold_single_delta(Instant::now(), Some(&active_target))); + // Non-matching session: still uses the wider base window. + assert!(pending.should_hold_single_delta(Instant::now(), Some(&other_target))); +} + +#[test] +fn active_session_holds_fresh_delta() { + // A very fresh delta should be held even for the active session's shorter window. + let started_at = Instant::now() - Duration::from_millis(5); + let pending = PendingBatch { + events: vec![PendingEntry::Delta { + key: "delta-key".to_string(), + scope: "delta-scope".to_string(), + instance_id: "inst-1".to_string(), + session_id: Some("sess-1".to_string()), + event: delta_event("Hello"), + started_at, + }], + }; + + let active_target = active_target(); + + assert!(pending.should_hold_single_delta(Instant::now(), Some(&active_target))); +} + +#[test] +fn assembler_emits_first_preview_chunk_immediately() { + let mut assembler = ActiveTextAssembler::default(); + let now = Instant::now(); + + let emitted = assembler.absorb( + ActiveTextDelta { + instance_id: "inst-1".to_string(), + session_id: "sess-1".to_string(), + message_id: "msg-1".to_string(), + part_id: "part-1".to_string(), + delta: "Hello".to_string(), + }, + now, + ); + + assert_eq!(emitted.len(), 1); + assert_eq!( + coalesced_payload_event(&emitted[0]) + .get("type") + .and_then(Value::as_str), + Some("assistant.stream.chunk") + ); + assert_eq!( + coalesced_payload_event(&emitted[0]) + .get("properties") + .and_then(|props| props.get("delta")) + .and_then(Value::as_str), + Some("Hello") + ); +} + +#[test] +fn snapshot_buffer_coalesces_updates_within_window() { + let mut buffer = ActiveTextSnapshotBuffer::default(); + let now = Instant::now(); + + buffer.buffer( + parse_active_text_snapshot(&message_part_updated_event("A"), Some(&active_target())) + .unwrap(), + now, + ); + buffer.buffer( + parse_active_text_snapshot(&message_part_updated_event("AB"), Some(&active_target())) + .unwrap(), + now + Duration::from_millis(40), + ); + buffer.buffer( + parse_active_text_snapshot(&message_part_updated_event("ABC"), Some(&active_target())) + .unwrap(), + now + Duration::from_millis(80), + ); + + let early = buffer.take_due(now + Duration::from_millis(ACTIVE_STREAM_SNAPSHOT_WINDOW_MS - 1)); + assert!(early.is_empty()); + + let emitted = + buffer.take_due(now + Duration::from_millis(ACTIVE_STREAM_SNAPSHOT_WINDOW_MS + 1)); + assert_eq!(emitted.len(), 1); + assert_eq!( + emitted[0]["event"]["properties"]["part"]["text"].as_str(), + Some("ABC") + ); +} + +#[test] +fn snapshot_buffer_flushes_latest_snapshot_before_message_update() { + let mut buffer = ActiveTextSnapshotBuffer::default(); + let now = Instant::now(); + + buffer.buffer( + parse_active_text_snapshot(&message_part_updated_event("Hello"), Some(&active_target())) + .unwrap(), + now, + ); + buffer.buffer( + parse_active_text_snapshot( + &message_part_updated_event("Hello world"), + Some(&active_target()), + ) + .unwrap(), + now + Duration::from_millis(25), + ); + + let flushed = buffer.flush_for_event(&json!({ + "type": "instance.event", + "instanceId": "inst-1", + "event": { + "type": "message.updated", + "properties": { + "info": { + "id": "msg-1", + "sessionID": "sess-1" + } + } + } + })); + + assert_eq!(flushed.len(), 1); + assert_eq!( + flushed[0]["event"]["properties"]["part"]["text"].as_str(), + Some("Hello world") + ); +} + +#[test] +fn assembler_keeps_first_delta_after_full_flush() { + let mut assembler = ActiveTextAssembler::default(); + let now = Instant::now(); + let delta = ActiveTextDelta { + instance_id: "inst-1".to_string(), + session_id: "sess-1".to_string(), + message_id: "msg-1".to_string(), + part_id: "part-1".to_string(), + delta: "Hello".to_string(), + }; + + let _ = assembler.absorb(delta.clone(), now); + let _ = assembler.flush_message("inst-1", "sess-1", "msg-1", now); + let _ = assembler.absorb( + ActiveTextDelta { + delta: " world".to_string(), + ..delta + }, + now, + ); + let emitted = assembler.flush_store_only_all(now + Duration::from_millis(1)); + + assert!(emitted.iter().any(|event| { + coalesced_payload_event(event) + .get("type") + .and_then(Value::as_str) + == Some("message.part.delta") + && coalesced_payload_event(event) + .get("properties") + .and_then(|props| props.get("delta")) + .and_then(Value::as_str) + == Some(" world") + })); +} + +#[test] +fn flush_store_only_all_preserves_canonical_text_without_preview() { + let mut assembler = ActiveTextAssembler::default(); + let now = Instant::now(); + let _ = assembler.absorb( + ActiveTextDelta { + instance_id: "inst-1".to_string(), + session_id: "sess-1".to_string(), + message_id: "msg-1".to_string(), + part_id: "part-1".to_string(), + delta: "Hello".to_string(), + }, + now, + ); + + let emitted = assembler.flush_store_only_all(now + Duration::from_millis(1)); + assert_eq!(emitted.len(), 1); + assert_eq!( + coalesced_payload_event(&emitted[0]) + .get("type") + .and_then(Value::as_str), + Some("message.part.delta") + ); + assert_eq!( + coalesced_payload_event(&emitted[0]) + .get("properties") + .and_then(|props| props.get("delta")) + .and_then(Value::as_str), + Some("Hello") + ); +} + +#[test] +fn coalesces_direct_message_part_delta_events() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(direct_delta_event("Hello"), &mut stats); + pending.push(direct_delta_event(" world"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0]["properties"]["delta"].as_str(), + Some("Hello world") + ); +} + +#[test] +fn direct_snapshot_replaces_trailing_direct_deltas_for_same_part() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(direct_delta_event("Hello"), &mut stats); + pending.push(direct_message_part_updated_event("Hello world"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["type"].as_str(), Some("message.part.updated")); + assert_eq!( + events[0]["properties"]["part"]["text"].as_str(), + Some("Hello world") + ); +} diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs new file mode 100644 index 000000000..0c7da3a29 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs @@ -0,0 +1,569 @@ +use super::*; + +pub(super) fn run_transport_loop( + app: AppHandle, + state: Arc>, + generation_atomic: Arc, + generation: u64, + stop: Arc, + config: DesktopEventTransportConfig, +) { + let mut reconnect_attempt = 0_u32; + let mut stats = DesktopEventTransportStats::default(); + + let client = match build_stream_client() { + Ok(client) => client, + Err(error) => { + emit_status( + &app, + generation, + "error", + 0, + true, + Some(error.message), + None, + None, + &stats, + ); + return; + } + }; + + loop { + if stop.load(Ordering::SeqCst) || !generation_matches(&generation_atomic, generation) { + break; + } + + emit_status( + &app, + generation, + "connecting", + reconnect_attempt, + false, + None, + None, + None, + &stats, + ); + + match open_stream(&app, &client, &config.stream) { + Ok(response) => { + reconnect_attempt = 0; + emit_status( + &app, + generation, + "connected", + reconnect_attempt, + false, + None, + None, + None, + &stats, + ); + + let disconnect_reason = consume_stream( + &app, + response, + &state, + &generation_atomic, + generation, + stop.clone(), + &mut stats, + ); + if stop.load(Ordering::SeqCst) + || !generation_matches(&generation_atomic, generation) + { + break; + } + + if !schedule_retry( + &app, + &generation_atomic, + generation, + stop.clone(), + &config.reconnect, + &mut reconnect_attempt, + "disconnected", + disconnect_reason, + None, + &stats, + ) { + break; + } + } + Err(error) => { + let state_name = match error.kind { + OpenStreamErrorKind::Unauthorized => "unauthorized", + OpenStreamErrorKind::Http | OpenStreamErrorKind::Transport => "error", + }; + + if !schedule_retry( + &app, + &generation_atomic, + generation, + stop.clone(), + &config.reconnect, + &mut reconnect_attempt, + state_name, + Some(error.message), + error.status_code, + &stats, + ) { + break; + } + } + } + } + + emit_status( + &app, + generation, + "stopped", + reconnect_attempt, + true, + None, + None, + None, + &stats, + ); +} + +fn schedule_retry( + app: &AppHandle, + generation_atomic: &Arc, + generation: u64, + stop: Arc, + policy: &ResolvedDesktopEventReconnectPolicy, + reconnect_attempt: &mut u32, + state_name: &'static str, + reason: Option, + status_code: Option, + stats: &DesktopEventTransportStats, +) -> bool { + *reconnect_attempt = reconnect_attempt.saturating_add(1); + let terminal = policy + .max_attempts + .map(|max_attempts| *reconnect_attempt >= max_attempts) + .unwrap_or(false); + let next_delay_ms = if terminal { + None + } else { + Some(compute_reconnect_delay_ms(*reconnect_attempt, policy)) + }; + + emit_status( + app, + generation, + state_name, + *reconnect_attempt, + terminal, + reason, + next_delay_ms, + status_code, + stats, + ); + + if terminal { + return false; + } + + if let Some(delay_ms) = next_delay_ms { + wait_with_cancellation(generation_atomic, generation, stop, delay_ms); + } + + true +} + +fn wait_with_cancellation( + generation_atomic: &Arc, + generation: u64, + stop: Arc, + delay_ms: u64, +) { + let mut remaining_ms = delay_ms; + while remaining_ms > 0 { + if stop.load(Ordering::SeqCst) || !generation_matches(generation_atomic, generation) { + return; + } + + let chunk_ms = remaining_ms.min(100); + thread::sleep(Duration::from_millis(chunk_ms)); + remaining_ms -= chunk_ms; + } +} + +fn consume_stream( + app: &AppHandle, + response: Response, + state: &Arc>, + generation_atomic: &Arc, + generation: u64, + stop: Arc, + stats: &mut DesktopEventTransportStats, +) -> Option { + let (tx, rx) = mpsc::sync_channel::(4096); + let reader_stop = stop.clone(); + let reader_generation_atomic = generation_atomic.clone(); + thread::spawn(move || { + read_sse( + response, + tx, + reader_stop, + reader_generation_atomic, + generation, + ) + }); + + let mut pending = PendingBatch::default(); + let mut active_text_assembler = ActiveTextAssembler::default(); + let mut active_text_snapshots = ActiveTextSnapshotBuffer::default(); + let mut sequence = 0_u64; + let mut last_active_target: Option = None; + let mut last_reader_activity = Instant::now(); + + loop { + if stop.load(Ordering::SeqCst) || !generation_matches(generation_atomic, generation) { + return Some("stopped".to_string()); + } + + match rx.recv_timeout(Duration::from_millis(FLUSH_INTERVAL_MS)) { + Ok(ReaderMessage::Activity) => { + last_reader_activity = Instant::now(); + } + Ok(ReaderMessage::Event(event)) => { + last_reader_activity = Instant::now(); + stats.raw_events = stats.raw_events.saturating_add(1); + + let now = Instant::now(); + let active_target = state.lock().active_target.clone(); + let max_batch_events = if active_target.is_some() { + ACTIVE_SESSION_MAX_BATCH_EVENTS + } else { + MAX_BATCH_EVENTS + }; + let mut should_flush_active = false; + if active_target != last_active_target { + for flushed in active_text_assembler.flush_store_only_all(now) { + pending.push(flushed, stats); + } + for flushed in active_text_snapshots.flush_all() { + pending.push(flushed, stats); + } + last_active_target = active_target.clone(); + } + + let due = active_text_assembler.take_due(now); + if !due.is_empty() { + should_flush_active = true; + } + for flushed in due { + pending.push(flushed, stats); + } + + let snapshot_due = active_text_snapshots.take_due(now); + if !snapshot_due.is_empty() { + should_flush_active = true; + } + for flushed in snapshot_due { + pending.push(flushed, stats); + } + + let flushes = active_text_assembler.flush_for_event(&event, now); + if !flushes.is_empty() { + should_flush_active = true; + } + for flushed in flushes { + pending.push(flushed, stats); + } + + let snapshot_flushes = active_text_snapshots.flush_for_event(&event); + if !snapshot_flushes.is_empty() { + should_flush_active = true; + } + for flushed in snapshot_flushes { + pending.push(flushed, stats); + } + + if let Some(snapshot) = parse_active_text_snapshot(&event, active_target.as_ref()) { + active_text_snapshots.buffer(snapshot, now); + + if should_flush_active { + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + + if pending.pending_len() >= max_batch_events { + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + continue; + } + + if let Some(delta) = parse_active_text_delta(&event, active_target.as_ref()) { + let assembled_events = active_text_assembler.absorb(delta, now); + if !assembled_events.is_empty() { + should_flush_active = true; + } + for assembled in assembled_events { + pending.push(assembled, stats); + } + + if should_flush_active { + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + + if pending.pending_len() >= max_batch_events { + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + continue; + } + + pending.push(event, stats); + if should_flush_active { + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + if pending.pending_len() >= max_batch_events { + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + } + Ok(ReaderMessage::End(reason)) => { + for flushed in active_text_assembler.take_due(Instant::now()) { + pending.push(flushed, stats); + } + for flushed in active_text_assembler.flush_store_only_all(Instant::now()) { + pending.push(flushed, stats); + } + for flushed in active_text_snapshots.take_due(Instant::now()) { + pending.push(flushed, stats); + } + for flushed in active_text_snapshots.flush_all() { + pending.push(flushed, stats); + } + if !pending.is_empty() { + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + return reason; + } + Err(RecvTimeoutError::Timeout) => { + if last_reader_activity.elapsed() >= Duration::from_millis(STREAM_STALL_TIMEOUT_MS) + { + for flushed in active_text_assembler.take_due(Instant::now()) { + pending.push(flushed, stats); + } + for flushed in active_text_assembler.flush_store_only_all(Instant::now()) { + pending.push(flushed, stats); + } + for flushed in active_text_snapshots.take_due(Instant::now()) { + pending.push(flushed, stats); + } + for flushed in active_text_snapshots.flush_all() { + pending.push(flushed, stats); + } + if !pending.is_empty() { + sequence += 1; + emit_batch( + app, + generation, + &mut pending, + sequence, + generation_atomic, + stats, + ); + } + return Some("stream stalled".to_string()); + } + + for flushed in active_text_assembler.take_due(Instant::now()) { + pending.push(flushed, stats); + } + for flushed in active_text_snapshots.take_due(Instant::now()) { + pending.push(flushed, stats); + } + if !pending.is_empty() { + if pending.should_hold_single_delta( + Instant::now(), + state.lock().active_target.as_ref(), + ) { + continue; + } + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + } + Err(RecvTimeoutError::Disconnected) => { + for flushed in active_text_assembler.take_due(Instant::now()) { + pending.push(flushed, stats); + } + for flushed in active_text_assembler.flush_store_only_all(Instant::now()) { + pending.push(flushed, stats); + } + for flushed in active_text_snapshots.take_due(Instant::now()) { + pending.push(flushed, stats); + } + for flushed in active_text_snapshots.flush_all() { + pending.push(flushed, stats); + } + if !pending.is_empty() { + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + return Some("reader disconnected".to_string()); + } + } + } +} + +fn emit_pending_batch( + app: &AppHandle, + generation: u64, + pending: &mut PendingBatch, + sequence: &mut u64, + generation_atomic: &Arc, + stats: &mut DesktopEventTransportStats, +) { + if pending.is_empty() { + return; + } + + *sequence += 1; + emit_batch( + app, + generation, + pending, + *sequence, + generation_atomic, + stats, + ); +} + +fn emit_batch( + app: &AppHandle, + generation: u64, + pending: &mut PendingBatch, + sequence: u64, + generation_atomic: &Arc, + stats: &mut DesktopEventTransportStats, +) { + if !generation_matches(generation_atomic, generation) { + return; + } + + let events = pending.take_events(); + if events.is_empty() { + return; + } + + stats.emitted_batches = stats.emitted_batches.saturating_add(1); + stats.emitted_events = stats.emitted_events.saturating_add(events.len() as u64); + + let _ = app.emit( + EVENT_BATCH_NAME, + WorkspaceEventBatchPayload { + generation, + sequence, + emitted_at: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + events, + }, + ); +} + +fn emit_status( + app: &AppHandle, + generation: u64, + state_name: &'static str, + reconnect_attempt: u32, + terminal: bool, + reason: Option, + next_delay_ms: Option, + status_code: Option, + stats: &DesktopEventTransportStats, +) { + let _ = app.emit( + EVENT_STATUS_NAME, + DesktopEventStreamStatusPayload { + generation, + state: state_name, + reconnect_attempt, + terminal, + reason, + next_delay_ms, + status_code, + stats: stats.clone(), + }, + ); +} + +pub(super) fn generation_matches(generation_atomic: &Arc, generation: u64) -> bool { + generation_atomic.load(Ordering::SeqCst) == generation +} + +pub(super) fn compute_reconnect_delay_ms( + attempt: u32, + policy: &ResolvedDesktopEventReconnectPolicy, +) -> u64 { + let exponent = attempt.saturating_sub(1) as i32; + let scaled = (policy.initial_delay_ms as f64) * policy.multiplier.powi(exponent); + (scaled.round().max(policy.initial_delay_ms as f64) as u64).min(policy.max_delay_ms) +} diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 43fccc43a..7ad5d7c2a 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -3,11 +3,13 @@ #[allow(dead_code)] mod cert_manager; mod cli_manager; +mod desktop_event_transport; #[cfg(target_os = "linux")] mod linux_tls; mod managed_node; use cli_manager::{CliProcessManager, CliStatus}; +use desktop_event_transport::{DesktopEventTransportManager, DesktopEventsStartRequest, DesktopEventsStartResult}; use keepawake::KeepAwake; use serde::Deserialize; use serde_json::json; @@ -49,6 +51,7 @@ const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client"; pub struct AppState { pub manager: CliProcessManager, + pub desktop_events: DesktopEventTransportManager, pub wake_lock: Mutex>, pub zoom_level: Mutex, pub remote_origins: Mutex>, @@ -133,6 +136,7 @@ fn cli_get_status(state: tauri::State) -> CliStatus { #[tauri::command] fn cli_restart(app: AppHandle, state: tauri::State) -> Result { let dev_mode = is_dev_mode(); + state.desktop_events.stop(); state.manager.stop().map_err(|e| e.to_string())?; state .manager @@ -141,6 +145,21 @@ fn cli_restart(app: AppHandle, state: tauri::State) -> Result, + request: Option, +) -> DesktopEventsStartResult { + let config = state.manager.desktop_event_stream_config(); + state.desktop_events.start(app, config, request) +} + +#[tauri::command] +fn desktop_events_stop(state: tauri::State) { + state.desktop_events.stop(); +} + #[tauri::command] fn wake_lock_start( state: tauri::State, @@ -563,6 +582,7 @@ fn main() { .plugin(navigation_guard) .manage(AppState { manager: CliProcessManager::new(), + desktop_events: DesktopEventTransportManager::new(), wake_lock: Mutex::new(None), zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL), remote_origins: Mutex::new(HashMap::new()), @@ -617,6 +637,8 @@ fn main() { .invoke_handler(tauri::generate_handler![ cli_get_status, cli_restart, + desktop_events_start, + desktop_events_stop, wake_lock_start, wake_lock_stop, needs_local_certificate_install, @@ -722,6 +744,7 @@ fn main() { let app = app_handle.clone(); std::thread::spawn(move || { if let Some(state) = app.try_state::() { + state.desktop_events.stop(); let _ = state.manager.stop(); } app.exit(0); @@ -773,6 +796,7 @@ fn main() { let app = app_handle.clone(); std::thread::spawn(move || { if let Some(state) = app.try_state::() { + state.desktop_events.stop(); let _ = state.manager.stop(); } app.exit(0); diff --git a/packages/ui/src/lib/event-transport-contract.ts b/packages/ui/src/lib/event-transport-contract.ts new file mode 100644 index 000000000..f72301288 --- /dev/null +++ b/packages/ui/src/lib/event-transport-contract.ts @@ -0,0 +1,78 @@ +export interface DesktopEventTransportReconnectPolicy { + initialDelayMs: number + maxDelayMs: number + multiplier: number + maxAttempts?: number +} + +export interface DesktopEventTransportStartOptions { + reconnect?: Partial +} + +export type DesktopEventTransportState = + | "connecting" + | "connected" + | "disconnected" + | "unauthorized" + | "error" + | "stopped" + +export interface DesktopEventTransportStats { + rawEvents: number + emittedEvents: number + emittedBatches: number + deltaCoalesces: number + snapshotCoalesces: number + statusCoalesces: number + supersededDeltasDropped: number +} + +export interface DesktopEventTransportStatusPayload { + generation: number + state: DesktopEventTransportState + reconnectAttempt: number + terminal: boolean + reason?: string + nextDelayMs?: number + statusCode?: number + stats?: DesktopEventTransportStats +} + +export interface DesktopEventsStartResult { + started: boolean + generation?: number + reason?: string +} + +export interface DesktopEventActiveSessionTarget { + instanceId: string + sessionId: string +} + +export interface AssistantStreamChunkEvent { + type: "assistant.stream.chunk" + properties: { + sessionID: string + messageID: string + partID: string + field: "text" + delta: string + } +} + +export const DEFAULT_DESKTOP_EVENT_RECONNECT_POLICY: DesktopEventTransportReconnectPolicy = { + initialDelayMs: 1000, + maxDelayMs: 10000, + multiplier: 2, +} + +export function resolveDesktopEventTransportStartOptions( + options?: DesktopEventTransportStartOptions, +): Required { + return { + reconnect: { + ...DEFAULT_DESKTOP_EVENT_RECONNECT_POLICY, + ...options?.reconnect, + }, + } +} diff --git a/packages/ui/src/lib/event-transport.ts b/packages/ui/src/lib/event-transport.ts new file mode 100644 index 000000000..f114e2537 --- /dev/null +++ b/packages/ui/src/lib/event-transport.ts @@ -0,0 +1,83 @@ +import type { WorkspaceEventPayload } from "../../../server/src/api-types" +import { serverApi } from "./api-client" +import { + resolveDesktopEventTransportStartOptions, + type DesktopEventTransportStartOptions, +} from "./event-transport-contract" +import { getLogger } from "./logger" +import { runtimeEnv } from "./runtime-env" +import { connectTauriWorkspaceEvents } from "./native/desktop-events" + +const log = getLogger("sse") +const FORCE_BROWSER_TRANSPORT_STORAGE_KEY = "perf242-force-browser-events" + +export interface WorkspaceEventTransportCallbacks { + onBatch: (events: WorkspaceEventPayload[]) => void + onError?: () => void + onOpen?: () => void + onPing?: (payload: { ts?: number }) => void +} + +export interface WorkspaceEventConnection { + disconnect: () => void +} + +async function connectBrowserWorkspaceEvents( + callbacks: WorkspaceEventTransportCallbacks, +): Promise { + const source = serverApi.connectEvents((event) => { + callbacks.onBatch([event]) + }, callbacks.onError, callbacks.onPing) + source.onopen = () => callbacks.onOpen?.() + return { + disconnect() { + source.close() + }, + } +} + +function shouldForceBrowserTransport(): boolean { + if (typeof window === "undefined") return false + try { + const params = new URLSearchParams(window.location.search) + if (params.get("forceBrowserEvents") === "1") { + return true + } + return window.localStorage?.getItem(FORCE_BROWSER_TRANSPORT_STORAGE_KEY) === "1" + } catch { + return false + } +} + +export async function connectWorkspaceEvents( + callbacks: WorkspaceEventTransportCallbacks, + options?: DesktopEventTransportStartOptions, +): Promise { + if (runtimeEnv.host === "tauri" && !shouldForceBrowserTransport()) { + try { + const conn = await connectTauriWorkspaceEvents( + callbacks, + resolveDesktopEventTransportStartOptions(options), + ) + ;(globalThis as any).__TRANSPORT_TYPE = "rust-native" + log.info("Event transport: rust-native (desktop_event_transport)") + return conn + } catch (error) { + log.warn("Failed to start native desktop event transport, falling back to browser EventSource", error) + } + } else if (runtimeEnv.host === "tauri") { + log.info("Event transport: browser-eventsource forced by localStorage override") + } + + ;(globalThis as any).__TRANSPORT_TYPE = "browser-eventsource" + log.info(`Event transport: browser-eventsource (host=${runtimeEnv.host})`) + return connectBrowserWorkspaceEvents(callbacks) +} + +export type { + DesktopEventsStartResult, + DesktopEventTransportReconnectPolicy, + DesktopEventTransportStartOptions, + DesktopEventTransportState, + DesktopEventTransportStatusPayload, +} from "./event-transport-contract" diff --git a/packages/ui/src/lib/native/desktop-events.ts b/packages/ui/src/lib/native/desktop-events.ts new file mode 100644 index 000000000..b033e19a6 --- /dev/null +++ b/packages/ui/src/lib/native/desktop-events.ts @@ -0,0 +1,163 @@ +import { invoke } from "@tauri-apps/api/core" +import { listen } from "@tauri-apps/api/event" +import type { WorkspaceEventPayload } from "../../../../server/src/api-types" +import type { + DesktopEventActiveSessionTarget, + DesktopEventsStartResult, + DesktopEventTransportStartOptions, + DesktopEventTransportStatusPayload, +} from "../event-transport-contract" +import type { WorkspaceEventConnection, WorkspaceEventTransportCallbacks } from "../event-transport" +import { getLogger } from "../logger" + +const log = getLogger("sse") + +interface WorkspaceEventBatchPayload { + generation: number + sequence: number + emittedAt: number + events: WorkspaceEventPayload[] +} + +export async function connectTauriWorkspaceEvents( + callbacks: WorkspaceEventTransportCallbacks, + options: DesktopEventTransportStartOptions, +): Promise { + let closed = false + let opened = false + let expectedGeneration: number | null = null + let terminalErrorRaised = false + const pendingBatches: WorkspaceEventBatchPayload[] = [] + const pendingStatuses: DesktopEventTransportStatusPayload[] = [] + + const matchesGeneration = (generation: number) => expectedGeneration === generation + + const handleBatchPayload = (payload: WorkspaceEventBatchPayload) => { + if (!payload || !matchesGeneration(payload.generation)) return + + if (!opened) { + opened = true + callbacks.onOpen?.() + } + + const events = payload.events ?? [] + if (events.length === 0) { + return + } + + callbacks.onBatch(events) + } + + const handleStatusPayload = (payload: DesktopEventTransportStatusPayload) => { + if (!payload || !matchesGeneration(payload.generation)) return + + if (payload.state === "connected" && !opened) { + opened = true + callbacks.onOpen?.() + } + + if (payload.state === "unauthorized") { + log.warn("Native desktop event transport is waiting for authentication", { + reason: payload.reason, + reconnectAttempt: payload.reconnectAttempt, + nextDelayMs: payload.nextDelayMs, + stats: payload.stats, + }) + } else if (payload.state === "error") { + log.warn("Native desktop event transport reported an error", { + reason: payload.reason, + reconnectAttempt: payload.reconnectAttempt, + nextDelayMs: payload.nextDelayMs, + statusCode: payload.statusCode, + stats: payload.stats, + }) + } else if ((payload.state === "disconnected" || payload.state === "stopped") && payload.stats) { + log.info("Native desktop event transport stats", { + state: payload.state, + reconnectAttempt: payload.reconnectAttempt, + stats: payload.stats, + }) + } + + if (payload.state === "stopped") { + callbacks.onError?.() + return + } + + if (payload.terminal && !terminalErrorRaised) { + terminalErrorRaised = true + callbacks.onError?.() + } + } + + const flushPending = () => { + if (expectedGeneration === null) return + for (const payload of pendingStatuses.splice(0, pendingStatuses.length)) { + handleStatusPayload(payload) + } + for (const payload of pendingBatches.splice(0, pendingBatches.length)) { + handleBatchPayload(payload) + } + } + + const unlistenBatch = await listen("desktop:event-batch", (event) => { + if (closed) return + const payload = event.payload + if (!payload) return + if (expectedGeneration === null) { + pendingBatches.push(payload) + return + } + handleBatchPayload(payload) + }) + + const unlistenStatus = await listen("desktop:event-stream-status", (event) => { + if (closed) return + const payload = event.payload + if (!payload) return + if (expectedGeneration === null) { + pendingStatuses.push(payload) + return + } + handleStatusPayload(payload) + }) + + try { + const result = await invoke("desktop_events_start", { request: options }) + if (!result?.started) { + throw new Error(result?.reason ?? "desktop event transport unavailable") + } + expectedGeneration = result.generation ?? null + flushPending() + } catch (error) { + unlistenBatch() + unlistenStatus() + throw error + } + + return { + disconnect() { + if (closed) { + return + } + + closed = true + unlistenBatch() + unlistenStatus() + void invoke("desktop_events_stop").catch((error) => { + log.warn("Failed to stop native desktop event transport", error) + }) + }, + } +} + +export async function setTauriDesktopActiveSession(target: DesktopEventActiveSessionTarget | null): Promise { + try { + await invoke("desktop_events_set_active_session", { + instanceId: target?.instanceId ?? null, + sessionId: target?.sessionId ?? null, + }) + } catch (error) { + log.warn("Failed to update native desktop active session", error) + } +} diff --git a/packages/ui/src/lib/server-events.ts b/packages/ui/src/lib/server-events.ts index 833e6c2aa..0bbb1dc95 100644 --- a/packages/ui/src/lib/server-events.ts +++ b/packages/ui/src/lib/server-events.ts @@ -1,12 +1,38 @@ +import { batch as solidBatch } from "solid-js" import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../server/src/api-types" import { serverApi } from "./api-client" import { getClientIdentity } from "./client-identity" +import { connectWorkspaceEvents, type WorkspaceEventConnection } from "./event-transport" import { getLogger } from "./logger" const RETRY_BASE_DELAY = 1000 const RETRY_MAX_DELAY = 10000 const log = getLogger("sse") +type Perf242ServerEventMetrics = { + batchesReceived: number + eventsReceived: number + maxBatchSize: number +} + +let perf242ServerEventMetrics: Perf242ServerEventMetrics = { + batchesReceived: 0, + eventsReceived: 0, + maxBatchSize: 0, +} + +export function resetPerf242ServerEventMetrics() { + perf242ServerEventMetrics = { + batchesReceived: 0, + eventsReceived: 0, + maxBatchSize: 0, + } +} + +export function getPerf242ServerEventMetrics(): Perf242ServerEventMetrics { + return { ...perf242ServerEventMetrics } +} + function logSse(message: string, context?: Record) { if (context) { log.info(message, context) @@ -18,65 +44,121 @@ function logSse(message: string, context?: Record) { class ServerEvents { private handlers = new Map void>>() private openHandlers = new Set<() => void>() - private source: EventSource | null = null + private connection: WorkspaceEventConnection | null = null + private connectGeneration = 0 private retryDelay = RETRY_BASE_DELAY - private reconnectTimer: ReturnType | null = null + private retryTimer: ReturnType | null = null constructor() { - this.connect() + void this.connect() } - private connect() { - if (this.reconnectTimer !== null) { - clearTimeout(this.reconnectTimer) - this.reconnectTimer = null - } - if (this.source) { - this.source.close() + private async connect() { + const generation = ++this.connectGeneration + this.clearReconnectTimer() + + if (this.connection) { + this.connection.disconnect() + this.connection = null } + logSse("Connecting to backend events stream") - this.source = serverApi.connectEvents( - (event) => this.dispatch(event), - () => this.scheduleReconnect(), - (payload) => { - void serverApi - .sendClientConnectionPong({ - ...getClientIdentity(), - pingTs: payload.ts, - }) - .catch((error) => { - log.error("Failed to send client connection pong", error) - }) - }, - ) - this.source.onopen = () => { - logSse("Events stream connected") - this.retryDelay = RETRY_BASE_DELAY - this.openHandlers.forEach((handler) => handler()) + + try { + const connection = await connectWorkspaceEvents({ + onBatch: (events) => this.dispatchBatch(events), + onError: () => { + if (generation !== this.connectGeneration) { + return + } + this.scheduleReconnect() + }, + onOpen: () => { + if (generation !== this.connectGeneration) { + return + } + logSse("Events stream connected") + this.retryDelay = RETRY_BASE_DELAY + this.openHandlers.forEach((handler) => handler()) + }, + onPing: (payload) => { + void serverApi + .sendClientConnectionPong({ + ...getClientIdentity(), + pingTs: payload.ts, + }) + .catch((error) => { + log.error("Failed to send client connection pong", error) + }) + }, + }) + + if (generation !== this.connectGeneration) { + connection.disconnect() + return + } + + this.connection = connection + } catch (error) { + logSse("Events stream failed to connect, scheduling reconnect", { + error: error instanceof Error ? error.message : String(error), + }) + this.scheduleReconnect() } } private scheduleReconnect() { - if (this.reconnectTimer !== null) { + if (this.retryTimer) { return } - const source = this.source - this.source = null + + if (this.connection) { + this.connection.disconnect() + this.connection = null + } + logSse("Events stream disconnected, scheduling reconnect", { delayMs: this.retryDelay }) - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null + this.retryTimer = setTimeout(() => { + this.retryTimer = null this.retryDelay = Math.min(this.retryDelay * 2, RETRY_MAX_DELAY) - this.connect() + void this.connect() }, this.retryDelay) - source?.close() + } + + private clearReconnectTimer() { + if (!this.retryTimer) { + return + } + + clearTimeout(this.retryTimer) + this.retryTimer = null } private dispatch(event: WorkspaceEventPayload) { - logSse(`event ${event.type}`) this.handlers.get("*")?.forEach((handler) => handler(event)) this.handlers.get(event.type)?.forEach((handler) => handler(event)) } + private dispatchBatch(events: WorkspaceEventPayload[]) { + if (events.length === 0) { + return + } + + perf242ServerEventMetrics.batchesReceived += 1 + perf242ServerEventMetrics.eventsReceived += events.length + perf242ServerEventMetrics.maxBatchSize = Math.max( + perf242ServerEventMetrics.maxBatchSize, + events.length, + ) + + logSse("event batch", { size: events.length }) + solidBatch(() => { + for (const event of events) { + this.dispatch(event) + } + }) + } + on(type: WorkspaceEventType | "*", handler: (event: WorkspaceEventPayload) => void): () => void { if (!this.handlers.has(type)) { this.handlers.set(type, new Set()) diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index 4be1fc57e..b4e5bc946 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -1,5 +1,6 @@ import { render } from "solid-js/web" import App from "./App" +import TransportBench from "./transport-bench" import { ThemeProvider } from "./lib/theme" import { ConfigProvider } from "./stores/preferences" import { InstanceConfigProvider } from "./stores/instance-config" @@ -16,10 +17,35 @@ if (!root) { } const mount = root +const bootParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : new URLSearchParams() +const isPerf242TransportBench = + import.meta.env.VITE_PERF242_TRANSPORT_BENCH === "1" + || bootParams.get("perf242TransportBench") === "1" if (typeof document !== "undefined") { document.documentElement.dataset.runtimeHost = runtimeEnv.host document.documentElement.dataset.runtimePlatform = runtimeEnv.platform + + if (bootParams.get("perf242TransportBench") === "1") { + const payload = { + stage: "frontend-bootstrap", + host: runtimeEnv.host, + search: window.location.search, + } + + void fetch("/api/perf-log", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify(payload), + keepalive: true, + }).catch(() => { + console.info("[perf242] frontend-bootstrap", { + host: runtimeEnv.host, + search: window.location.search, + }) + }) + } } async function bootstrap() { @@ -54,6 +80,7 @@ async function bootstrap() { + {isPerf242TransportBench ? : null} diff --git a/packages/ui/src/transport-bench.tsx b/packages/ui/src/transport-bench.tsx new file mode 100644 index 000000000..84bf61de5 --- /dev/null +++ b/packages/ui/src/transport-bench.tsx @@ -0,0 +1,145 @@ +import { onMount } from "solid-js" +import { runtimeEnv } from "./lib/runtime-env" +import { getPerf242ServerEventMetrics, resetPerf242ServerEventMetrics } from "./lib/server-events" +import { selectInstanceTab } from "./stores/app-tabs" +import { createInstance, instances } from "./stores/instances" +import { + fetchSessions, + getSessions, + loadMessages, + runShellCommand, + setActiveParentSession, + setActiveSession, +} from "./stores/sessions" + +const benchParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : new URLSearchParams() +const PERF242_BENCH_FOLDER = benchParams.get("folder") || import.meta.env.VITE_PERF242_BENCH_FOLDER || "D:\\CodeNomad" +const PERF242_BENCH_SESSION_ID = benchParams.get("sessionId") || import.meta.env.VITE_PERF242_BENCH_SESSION_ID || "" +const PERF242_BENCH_BINARY = benchParams.get("binary") || import.meta.env.VITE_PERF242_BENCH_BINARY || "opencode" +const PERF242_BENCH_COMMAND = benchParams.get("command") || import.meta.env.VITE_PERF242_BENCH_COMMAND + || `node -e "for (let i = 1; i <= 400; i += 1) console.log('line ' + i)"` + +let perf242TransportBenchStarted = false + +function waitForMs(delayMs: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, delayMs)) +} + +async function waitForCondition(predicate: () => boolean, timeoutMs = 15000): Promise { + const start = performance.now() + while (performance.now() - start < timeoutMs) { + if (predicate()) return true + await waitForMs(100) + } + return predicate() +} + +async function emitPerf242Log(payload: Record): Promise { + console.info("[perf242]", payload) + try { + await fetch("/api/perf-log", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify(payload), + keepalive: true, + }) + } catch (error) { + console.warn("[perf242] failed to emit server log", { host: runtimeEnv.host, error }) + } +} + +export default function TransportBench() { + onMount(() => { + if (perf242TransportBenchStarted) return + perf242TransportBenchStarted = true + + void (async () => { + await emitPerf242Log({ + stage: "bench-init", + host: runtimeEnv.host, + folder: PERF242_BENCH_FOLDER, + sessionId: PERF242_BENCH_SESSION_ID, + }) + + if (!PERF242_BENCH_SESSION_ID) { + await emitPerf242Log({ stage: "bench-skipped", reason: "missing-session-id" }) + return + } + + let instanceId = Array.from(instances().values()).find((instance) => instance.folder === PERF242_BENCH_FOLDER)?.id + if (!instanceId) { + await emitPerf242Log({ stage: "create-instance", folder: PERF242_BENCH_FOLDER, binary: PERF242_BENCH_BINARY }) + instanceId = await createInstance(PERF242_BENCH_FOLDER, PERF242_BENCH_BINARY) + } + + selectInstanceTab(instanceId) + await emitPerf242Log({ stage: "instance-ready", instanceId }) + await fetchSessions(instanceId) + await emitPerf242Log({ stage: "sessions-fetched", instanceId, sessionCount: getSessions(instanceId).length }) + + const targetSession = getSessions(instanceId).find((session) => session.id === PERF242_BENCH_SESSION_ID) + if (!targetSession) { + await emitPerf242Log({ + stage: "bench-error", + reason: "session-not-found", + instanceId, + sessionId: PERF242_BENCH_SESSION_ID, + }) + return + } + + const parentSessionId = targetSession.parentId ?? targetSession.id + setActiveParentSession(instanceId, parentSessionId) + if (targetSession.id !== parentSessionId) { + setActiveSession(instanceId, targetSession.id) + } + + await emitPerf242Log({ stage: "session-selected", instanceId, sessionId: targetSession.id, parentSessionId }) + await loadMessages(instanceId, targetSession.id, true) + await emitPerf242Log({ stage: "messages-loaded", instanceId, sessionId: targetSession.id }) + await waitForMs(500) + + resetPerf242ServerEventMetrics() + await emitPerf242Log({ + stage: "start", + folder: PERF242_BENCH_FOLDER, + sessionId: targetSession.id, + transportType: (globalThis as any).__TRANSPORT_TYPE ?? "unknown", + command: PERF242_BENCH_COMMAND, + }) + + const startedAt = performance.now() + await runShellCommand(instanceId, targetSession.id, PERF242_BENCH_COMMAND) + + const sawWorking = await waitForCondition(() => { + const session = getSessions(instanceId).find((value) => value.id === targetSession.id) + return session?.status === "working" + }, 10000) + + const reachedIdle = await waitForCondition(() => { + const session = getSessions(instanceId).find((value) => value.id === targetSession.id) + return sawWorking ? session?.status === "idle" : false + }, 120000) + + await emitPerf242Log({ + stage: reachedIdle ? "complete" : "timeout", + sessionId: targetSession.id, + instanceId, + transportType: (globalThis as any).__TRANSPORT_TYPE ?? "unknown", + elapsedMs: Math.round((performance.now() - startedAt) * 10) / 10, + sawWorking, + reachedIdle, + metrics: getPerf242ServerEventMetrics(), + }) + })().catch(async (error) => { + await emitPerf242Log({ + stage: "error", + error: error instanceof Error ? error.stack ?? error.message : String(error), + }) + throw error + }) + }) + + return null +} From 090efe98fc60cd51bf665939c20acb82ad90e326 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 9 May 2026 15:29:40 +0200 Subject: [PATCH 02/15] fix(tauri): restore desktop transport heartbeat parity Handle named SSE ping events with explicit pong replies so the native transport survives the server heartbeat window, and remove the unused active-session fast path while keeping the benchmark harness authenticated. # Conflicts: # packages/tauri-app/src-tauri/src/cli_manager.rs --- packages/server/src/server/http-server.ts | 2 +- .../tauri-app/src-tauri/src/cli_manager.rs | 19 +- .../src-tauri/src/desktop_event_transport.rs | 266 +----------- .../src/desktop_event_transport/assembler.rs | 397 +----------------- .../src/desktop_event_transport/stream.rs | 49 ++- .../src/desktop_event_transport/tests.rs | 236 +---------- .../src/desktop_event_transport/transport.rs | 199 ++------- .../ui/src/lib/event-transport-contract.ts | 16 - packages/ui/src/lib/native/desktop-events.ts | 12 - packages/ui/src/transport-bench.tsx | 16 +- 10 files changed, 88 insertions(+), 1124 deletions(-) diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 6b50490b6..3d830376d 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -200,7 +200,7 @@ export function createHttpServer(deps: HttpServerDeps) { const rawUrl = request.raw.url ?? request.url const pathname = (rawUrl.split("?")[0] ?? "").trim() - const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout", "/api/perf-log"]) + const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout"]) const publicPagePaths = new Set(["/login"]) if (deps.authManager.isTokenBootstrapEnabled()) { publicPagePaths.add("/auth/token") diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 98210b4ff..2ae45addc 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -317,6 +317,15 @@ fn generate_auth_cookie_name() -> String { format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}") } +fn generate_transport_connection_id() -> String { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let tid = std::thread::current().id(); + format!("tauri-{}-{:?}", ts, tid) +} + const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json"; #[derive(Debug, Deserialize)] @@ -646,6 +655,7 @@ impl CliProcessManager { base_url, events_url, client_id, + connection_id: generate_transport_connection_id(), cookie_name, session_cookie: self.session_cookie.lock().clone(), }) @@ -1276,7 +1286,8 @@ fn resolve_dev_entry(_app: &AppHandle) -> Option { } fn resolve_prod_entry(_app: &AppHandle) -> Option { - let mut candidates = Vec::new(); + let base = workspace_root(); + let mut candidates = vec![base.as_ref().map(|p| p.join("packages/server/dist/bin.js"))]; if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { @@ -1294,12 +1305,6 @@ fn resolve_prod_entry(_app: &AppHandle) -> Option { } } - let base = workspace_root(); - candidates.push( - base.as_ref() - .map(|p| p.join("packages/server/dist/bin.js")), - ); - first_existing(candidates) } diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport.rs index 49cc75521..f9b0240c8 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport.rs @@ -3,7 +3,6 @@ use reqwest::blocking::{Client, Response}; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::HashMap; use std::io::{BufRead, BufReader}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{self, RecvTimeoutError, SyncSender}; @@ -23,12 +22,6 @@ const EVENT_BATCH_NAME: &str = "desktop:event-batch"; const EVENT_STATUS_NAME: &str = "desktop:event-stream-status"; const FLUSH_INTERVAL_MS: u64 = 16; const DELTA_STREAM_WINDOW_MS: u64 = 48; -const ACTIVE_STREAM_DISPLAY_WINDOW_MS: u64 = 16; -const ACTIVE_STREAM_DISPLAY_CHUNK_MAX: usize = 96; -const ACTIVE_STREAM_STORE_WINDOW_MS: u64 = 250; -const ACTIVE_STREAM_SNAPSHOT_WINDOW_MS: u64 = 200; -const ACTIVE_STREAM_HOLD_WINDOW_MS: u64 = 12; -const ACTIVE_SESSION_MAX_BATCH_EVENTS: usize = 64; const MAX_BATCH_EVENTS: usize = 256; const DEFAULT_RECONNECT_INITIAL_DELAY_MS: u64 = 1_000; const DEFAULT_RECONNECT_MAX_DELAY_MS: u64 = 10_000; @@ -42,6 +35,7 @@ pub struct DesktopEventStreamConfig { pub base_url: String, pub events_url: String, pub client_id: String, + pub connection_id: String, pub cookie_name: String, pub session_cookie: Option, } @@ -156,13 +150,6 @@ struct DesktopEventTransportStats { struct DesktopEventTransportState { stop: Option>, config: Option, - active_target: Option, -} - -#[derive(Clone, PartialEq, Eq)] -pub struct ActiveSessionTarget { - pub instance_id: String, - pub session_id: String, } pub struct DesktopEventTransportManager { @@ -173,6 +160,7 @@ pub struct DesktopEventTransportManager { enum ReaderMessage { Activity, Event(Value), + Ping(Value), End(Option), } @@ -180,8 +168,6 @@ enum PendingEntry { Delta { key: String, scope: String, - instance_id: String, - session_id: Option, event: Value, started_at: Instant, }, @@ -220,87 +206,17 @@ struct PendingBatch { events: Vec, } -#[derive(Clone)] -struct ActiveTextDelta { - instance_id: String, - session_id: String, - message_id: String, - part_id: String, - delta: String, -} - -struct ActiveTextPartBuffer { - instance_id: String, - session_id: String, - message_id: String, - part_id: String, - display_pending: String, - store_pending: String, - last_display_emit: Instant, - last_store_emit: Instant, -} - -impl ActiveTextPartBuffer { - fn new(delta: ActiveTextDelta, now: Instant) -> Self { - Self { - instance_id: delta.instance_id, - session_id: delta.session_id, - message_id: delta.message_id, - part_id: delta.part_id, - display_pending: delta.delta.clone(), - store_pending: delta.delta, - last_display_emit: now, - last_store_emit: now, - } - } -} - -#[derive(Clone)] -struct ActiveTextSnapshot { - key: String, - instance_id: String, - session_id: String, - message_id: String, - part_id: String, - event: Value, -} - -struct BufferedTextSnapshot { - instance_id: String, - session_id: String, - message_id: String, - part_id: String, - event: Value, - buffered_at: Instant, -} - -#[derive(Default)] -struct ActiveTextAssembler { - parts: HashMap, -} - -#[derive(Default)] -struct ActiveTextSnapshotBuffer { - parts: HashMap, -} - impl DesktopEventTransportManager { pub fn new() -> Self { Self { state: Arc::new(Mutex::new(DesktopEventTransportState { stop: None, config: None, - active_target: None, })), generation: Arc::new(AtomicU64::new(0)), } } - pub fn set_active_session_target(&self, target: Option) { - let mut state = self.state.lock(); - state.active_target = target; - } - pub fn start( &self, app: AppHandle, @@ -339,19 +255,11 @@ impl DesktopEventTransportManager { let stop = Arc::new(AtomicBool::new(false)); state.stop = Some(stop.clone()); state.config = Some(transport_config.clone()); - let shared_state = self.state.clone(); let shared_generation = self.generation.clone(); drop(state); thread::spawn(move || { - run_transport_loop( - app, - shared_state, - shared_generation, - generation, - stop, - transport_config, - ) + run_transport_loop(app, shared_generation, generation, stop, transport_config) }); DesktopEventsStartResult { @@ -367,7 +275,6 @@ impl DesktopEventTransportManager { stop.store(true, Ordering::SeqCst); } state.config = None; - state.active_target = None; self.generation.fetch_add(1, Ordering::SeqCst); } } @@ -403,173 +310,6 @@ fn coalesced_instance_id(event: &Value) -> &str { .unwrap_or_default() } -fn event_session_id(event: &Value) -> Option<&str> { - let inner = coalesced_payload_event(event); - let inner_type = inner.get("type")?.as_str()?; - let props = inner.get("properties")?; - - match inner_type { - "session.updated" => props - .get("info") - .and_then(|info| info.get("id")) - .and_then(Value::as_str) - .or_else(|| { - props - .get("sessionID") - .or_else(|| props.get("sessionId")) - .and_then(Value::as_str) - }), - "message.updated" => props - .get("info") - .and_then(|info| info.get("sessionID").or_else(|| info.get("sessionId"))) - .and_then(Value::as_str), - "message.part.updated" => props - .get("part") - .and_then(|part| part.get("sessionID").or_else(|| part.get("sessionId"))) - .and_then(Value::as_str), - "message.part.delta" - | "message.removed" - | "message.part.removed" - | "session.compacted" - | "session.diff" - | "session.idle" - | "session.status" => props - .get("sessionID") - .or_else(|| props.get("sessionId")) - .and_then(Value::as_str), - _ => None, - } -} - -fn parse_active_text_delta( - event: &Value, - active_target: Option<&ActiveSessionTarget>, -) -> Option { - let active_target = active_target?; - let instance_id = coalesced_instance_id(event); - if instance_id != active_target.instance_id { - return None; - } - let inner = coalesced_payload_event(event); - if inner.get("type")?.as_str()? != "message.part.delta" { - return None; - } - - let props = inner.get("properties")?; - let field = props.get("field")?.as_str()?; - if field != "text" { - return None; - } - - let event_session = props - .get("sessionID") - .or_else(|| props.get("sessionId")) - .and_then(Value::as_str)?; - if event_session != active_target.session_id { - return None; - } - - Some(ActiveTextDelta { - instance_id: instance_id.to_string(), - session_id: event_session.to_string(), - message_id: props - .get("messageID") - .or_else(|| props.get("messageId")) - .and_then(Value::as_str)? - .to_string(), - part_id: props - .get("partID") - .or_else(|| props.get("partId")) - .and_then(Value::as_str)? - .to_string(), - delta: props.get("delta")?.as_str()?.to_string(), - }) -} - -fn make_assistant_stream_chunk_event(entry: &ActiveTextPartBuffer, delta: &str) -> Value { - serde_json::json!({ - "type": "instance.event", - "instanceId": entry.instance_id, - "event": { - "type": "assistant.stream.chunk", - "properties": { - "sessionID": entry.session_id, - "messageID": entry.message_id, - "partID": entry.part_id, - "field": "text", - "delta": delta, - } - } - }) -} - -fn make_message_part_delta_event(entry: &ActiveTextPartBuffer, delta: &str) -> Value { - serde_json::json!({ - "type": "instance.event", - "instanceId": entry.instance_id, - "event": { - "type": "message.part.delta", - "properties": { - "sessionID": entry.session_id, - "messageID": entry.message_id, - "partID": entry.part_id, - "field": "text", - "delta": delta, - } - } - }) -} - -fn parse_active_text_snapshot( - event: &Value, - active_target: Option<&ActiveSessionTarget>, -) -> Option { - let active_target = active_target?; - let instance_id = coalesced_instance_id(event); - if instance_id != active_target.instance_id { - return None; - } - - let inner = coalesced_payload_event(event); - if inner.get("type")?.as_str()? != "message.part.updated" { - return None; - } - - let part = inner.get("properties")?.get("part")?; - if part.get("type")?.as_str()? != "text" { - return None; - } - if part.get("text")?.as_str().is_none() { - return None; - } - - let event_session = part - .get("sessionID") - .or_else(|| part.get("sessionId")) - .and_then(Value::as_str)?; - if event_session != active_target.session_id { - return None; - } - - let message_id = part - .get("messageID") - .or_else(|| part.get("messageId")) - .and_then(Value::as_str)?; - let part_id = part.get("id")?.as_str()?; - - Some(ActiveTextSnapshot { - key: format!( - "{}:{}:{}:{}", - instance_id, event_session, message_id, part_id - ), - instance_id: instance_id.to_string(), - session_id: event_session.to_string(), - message_id: message_id.to_string(), - part_id: part_id.to_string(), - event: event.clone(), - }) -} - fn snapshot_key(event: &Value) -> Option { let instance_id = coalesced_instance_id(event); let inner = coalesced_payload_event(event); diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs index 82299452e..f91bcb760 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs @@ -25,8 +25,6 @@ impl PendingBatch { self.events.push(PendingEntry::Delta { key, scope, - instance_id: coalesced_instance_id(&event).to_string(), - session_id: event_session_id(&event).map(|value| value.to_string()), event, started_at: Instant::now(), }); @@ -103,399 +101,12 @@ impl PendingBatch { self.events.len() } - pub(super) fn should_hold_single_delta( - &self, - now: Instant, - active_target: Option<&ActiveSessionTarget>, - ) -> bool { + pub(super) fn should_hold_single_delta(&self, now: Instant) -> bool { matches!( self.events.as_slice(), - [PendingEntry::Delta { started_at, instance_id, session_id, .. }] - if now.duration_since(*started_at) < Duration::from_millis( - if active_target - .map(|target| { - target.instance_id.as_str() == instance_id.as_str() - && target.session_id.as_str() == session_id.as_deref().unwrap_or_default() - }) - .unwrap_or(false) - { - ACTIVE_STREAM_HOLD_WINDOW_MS - } else { - DELTA_STREAM_WINDOW_MS - } - ) + [PendingEntry::Delta { started_at, .. }] + if now.duration_since(*started_at) + < Duration::from_millis(DELTA_STREAM_WINDOW_MS) ) } } - -impl ActiveTextAssembler { - pub(super) fn absorb(&mut self, delta: ActiveTextDelta, now: Instant) -> Vec { - let key = format!( - "{}:{}:{}:{}", - delta.instance_id, delta.session_id, delta.message_id, delta.part_id - ); - - match self.parts.entry(key) { - std::collections::hash_map::Entry::Occupied(mut occupied) => { - let entry = occupied.get_mut(); - if entry.display_pending.is_empty() && entry.store_pending.is_empty() { - entry.instance_id = delta.instance_id.clone(); - entry.session_id = delta.session_id.clone(); - entry.message_id = delta.message_id.clone(); - entry.part_id = delta.part_id.clone(); - } - - entry.display_pending.push_str(&delta.delta); - entry.store_pending.push_str(&delta.delta); - Self::collect_due_for_part(entry, now) - } - std::collections::hash_map::Entry::Vacant(vacant) => { - let mut entry = ActiveTextPartBuffer::new(delta, now); - entry.last_display_emit = now - .checked_sub(Duration::from_millis(ACTIVE_STREAM_DISPLAY_WINDOW_MS)) - .unwrap_or(now); - let emitted = Self::collect_due_for_part(&mut entry, now); - vacant.insert(entry); - emitted - } - } - } - - pub(super) fn take_due(&mut self, now: Instant) -> Vec { - let mut emitted = Vec::new(); - let mut empty_keys = Vec::new(); - - for (key, entry) in self.parts.iter_mut() { - emitted.extend(Self::collect_due_for_part(entry, now)); - if entry.display_pending.is_empty() && entry.store_pending.is_empty() { - empty_keys.push(key.clone()); - } - } - - for key in empty_keys { - self.parts.remove(&key); - } - - emitted - } - - pub(super) fn flush_for_event(&mut self, event: &Value, now: Instant) -> Vec { - let instance_id = coalesced_instance_id(event); - let payload = coalesced_payload_event(event); - let event_type = payload.get("type").and_then(Value::as_str); - - match event_type { - Some("message.updated") | Some("message.removed") => { - let props = payload.get("properties"); - let session_id = event_session_id(event); - let message_id = props - .and_then(|value| { - value - .get("info") - .and_then(|info| info.get("id")) - .or_else(|| value.get("messageID")) - .or_else(|| value.get("messageId")) - }) - .and_then(Value::as_str); - if let (Some(session_id), Some(message_id)) = (session_id, message_id) { - return self.flush_message(instance_id, session_id, message_id, now); - } - } - Some("message.part.updated") | Some("message.part.removed") => { - let props = payload.get("properties"); - let session_id = event_session_id(event); - let message_id = props - .and_then(|value| { - value - .get("part") - .and_then(|part| { - part.get("messageID").or_else(|| part.get("messageId")) - }) - .or_else(|| value.get("messageID")) - .or_else(|| value.get("messageId")) - }) - .and_then(Value::as_str); - let part_id = props - .and_then(|value| { - value - .get("part") - .and_then(|part| part.get("id")) - .or_else(|| value.get("partID")) - .or_else(|| value.get("partId")) - }) - .and_then(Value::as_str); - if let (Some(session_id), Some(message_id), Some(part_id)) = - (session_id, message_id, part_id) - { - return self.flush_part(instance_id, session_id, message_id, part_id, now); - } - } - _ => {} - } - - Vec::new() - } - - pub(super) fn flush_message( - &mut self, - instance_id: &str, - session_id: &str, - message_id: &str, - now: Instant, - ) -> Vec { - let keys: Vec = self - .parts - .iter() - .filter(|(_, entry)| { - entry.instance_id == instance_id - && entry.session_id == session_id - && entry.message_id == message_id - }) - .map(|(key, _)| key.clone()) - .collect(); - - let mut emitted = Vec::new(); - for key in keys { - if let Some(mut entry) = self.parts.remove(&key) { - emitted.extend(Self::flush_all_for_part(&mut entry, now)); - } - } - emitted - } - - pub(super) fn flush_part( - &mut self, - instance_id: &str, - session_id: &str, - message_id: &str, - part_id: &str, - now: Instant, - ) -> Vec { - let key = format!("{}:{}:{}:{}", instance_id, session_id, message_id, part_id); - if let Some(mut entry) = self.parts.remove(&key) { - return Self::flush_all_for_part(&mut entry, now); - } - Vec::new() - } - - pub(super) fn flush_store_only_all(&mut self, now: Instant) -> Vec { - let mut emitted = Vec::new(); - for entry in self.parts.values_mut() { - if !entry.store_pending.is_empty() { - emitted.push(make_message_part_delta_event(entry, &entry.store_pending)); - entry.store_pending.clear(); - entry.last_store_emit = now; - } - entry.display_pending.clear(); - entry.last_display_emit = now; - } - self.parts.clear(); - emitted - } - - fn collect_due_for_part(entry: &mut ActiveTextPartBuffer, now: Instant) -> Vec { - let mut emitted = Vec::new(); - - // Display lane — emit preview chunks frequently (~16ms / 96 chars). - if !entry.display_pending.is_empty() - && (now.duration_since(entry.last_display_emit) - >= Duration::from_millis(ACTIVE_STREAM_DISPLAY_WINDOW_MS) - || entry.display_pending.len() >= ACTIVE_STREAM_DISPLAY_CHUNK_MAX) - { - emitted.push(make_assistant_stream_chunk_event( - entry, - &entry.display_pending, - )); - entry.display_pending.clear(); - entry.last_display_emit = now; - } - - // Store lane — emit canonical deltas infrequently (~250ms) to avoid - // flooding the JS reactive graph with store mutations that - // trigger expensive re-render cascades during active streaming. - // Explicit flush triggers (message.updated, message.part.updated, - // session change, disconnect) still flush immediately via - // flush_for_event / flush_all_for_part / flush_store_only_all. - if !entry.store_pending.is_empty() - && now.duration_since(entry.last_store_emit) - >= Duration::from_millis(ACTIVE_STREAM_STORE_WINDOW_MS) - { - emitted.push(make_message_part_delta_event(entry, &entry.store_pending)); - entry.store_pending.clear(); - entry.last_store_emit = now; - } - - emitted - } - - fn flush_all_for_part(entry: &mut ActiveTextPartBuffer, now: Instant) -> Vec { - let mut emitted = Vec::new(); - if !entry.display_pending.is_empty() { - emitted.push(make_assistant_stream_chunk_event( - entry, - &entry.display_pending, - )); - entry.display_pending.clear(); - entry.last_display_emit = now; - } - if !entry.store_pending.is_empty() { - emitted.push(make_message_part_delta_event(entry, &entry.store_pending)); - entry.store_pending.clear(); - entry.last_store_emit = now; - } - emitted - } -} - -impl ActiveTextSnapshotBuffer { - pub(super) fn buffer(&mut self, snapshot: ActiveTextSnapshot, now: Instant) { - match self.parts.entry(snapshot.key) { - std::collections::hash_map::Entry::Occupied(mut occupied) => { - let entry = occupied.get_mut(); - entry.instance_id = snapshot.instance_id; - entry.session_id = snapshot.session_id; - entry.message_id = snapshot.message_id; - entry.part_id = snapshot.part_id; - entry.event = snapshot.event; - } - std::collections::hash_map::Entry::Vacant(vacant) => { - vacant.insert(BufferedTextSnapshot { - instance_id: snapshot.instance_id, - session_id: snapshot.session_id, - message_id: snapshot.message_id, - part_id: snapshot.part_id, - event: snapshot.event, - buffered_at: now, - }); - } - } - } - - pub(super) fn take_due(&mut self, now: Instant) -> Vec { - let keys: Vec = self - .parts - .iter() - .filter(|(_, entry)| { - now.duration_since(entry.buffered_at) - >= Duration::from_millis(ACTIVE_STREAM_SNAPSHOT_WINDOW_MS) - }) - .map(|(key, _)| key.clone()) - .collect(); - - self.take_entries(keys) - } - - pub(super) fn flush_for_event(&mut self, event: &Value) -> Vec { - let instance_id = coalesced_instance_id(event); - let payload = coalesced_payload_event(event); - let event_type = payload.get("type").and_then(Value::as_str); - - match event_type { - Some("message.updated") | Some("message.removed") => { - let props = payload.get("properties"); - let session_id = event_session_id(event); - let message_id = props - .and_then(|value| { - value - .get("info") - .and_then(|info| info.get("id")) - .or_else(|| value.get("messageID")) - .or_else(|| value.get("messageId")) - }) - .and_then(Value::as_str); - if let (Some(session_id), Some(message_id)) = (session_id, message_id) { - return self.flush_message(instance_id, session_id, message_id); - } - } - Some("message.part.removed") => { - let props = payload.get("properties"); - let session_id = event_session_id(event); - let message_id = props - .and_then(|value| { - value - .get("part") - .and_then(|part| { - part.get("messageID").or_else(|| part.get("messageId")) - }) - .or_else(|| value.get("messageID")) - .or_else(|| value.get("messageId")) - }) - .and_then(Value::as_str); - let part_id = props - .and_then(|value| { - value - .get("part") - .and_then(|part| part.get("id")) - .or_else(|| value.get("partID")) - .or_else(|| value.get("partId")) - }) - .and_then(Value::as_str); - if let (Some(session_id), Some(message_id), Some(part_id)) = - (session_id, message_id, part_id) - { - return self.flush_part(instance_id, session_id, message_id, part_id); - } - } - _ => {} - } - - Vec::new() - } - - pub(super) fn flush_message( - &mut self, - instance_id: &str, - session_id: &str, - message_id: &str, - ) -> Vec { - let keys: Vec = self - .parts - .iter() - .filter(|(_, entry)| { - entry.instance_id == instance_id - && entry.session_id == session_id - && entry.message_id == message_id - }) - .map(|(key, _)| key.clone()) - .collect(); - - self.take_entries(keys) - } - - pub(super) fn flush_part( - &mut self, - instance_id: &str, - session_id: &str, - message_id: &str, - part_id: &str, - ) -> Vec { - let keys: Vec = self - .parts - .iter() - .filter(|(_, entry)| { - entry.instance_id == instance_id - && entry.session_id == session_id - && entry.message_id == message_id - && entry.part_id == part_id - }) - .map(|(key, _)| key.clone()) - .collect(); - - self.take_entries(keys) - } - - pub(super) fn flush_all(&mut self) -> Vec { - let keys: Vec = self.parts.keys().cloned().collect(); - self.take_entries(keys) - } - - fn take_entries(&mut self, keys: Vec) -> Vec { - let mut emitted = Vec::new(); - for key in keys { - if let Some(entry) = self.parts.remove(&key) { - emitted.push(entry.event); - } - } - emitted - } -} diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs index e861a1b31..1cd5d00b9 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs @@ -24,10 +24,9 @@ pub(super) fn open_stream( client: &Client, config: &DesktopEventStreamConfig, ) -> Result { - let connection_id = generate_connection_id(); let url = format!( "{}?clientId={}&connectionId={}", - config.events_url, config.client_id, connection_id + config.events_url, config.client_id, config.connection_id ); let mut request = client.get(&url).header("Accept", "text/event-stream"); @@ -69,15 +68,6 @@ fn resolve_session_cookie(app: &AppHandle, config: &DesktopEventStreamConfig) -> .filter(|value| !value.is_empty()) } -fn generate_connection_id() -> String { - let ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - let tid = std::thread::current().id(); - format!("tauri-{}-{:?}", ts, tid) -} - fn read_session_cookie_from_webview( app: &AppHandle, base_url: &str, @@ -120,6 +110,7 @@ pub(super) fn read_sse( ) { let mut reader = BufReader::new(response); let mut line = String::new(); + let mut event_name: Option = None; let mut data_lines: Vec = Vec::new(); loop { @@ -131,9 +122,7 @@ pub(super) fn read_sse( line.clear(); match reader.read_line(&mut line) { Ok(0) => { - if let Some(event) = parse_sse_payload(&data_lines) { - let _ = tx.send(ReaderMessage::Event(event)); - } + let _ = flush_sse_frame(&tx, &event_name, &data_lines); let _ = tx.send(ReaderMessage::End(Some("stream closed".to_string()))); return; } @@ -143,11 +132,10 @@ pub(super) fn read_sse( } let trimmed = line.trim_end_matches(['\r', '\n']); if trimmed.is_empty() { - if let Some(event) = parse_sse_payload(&data_lines) { - if tx.send(ReaderMessage::Event(event)).is_err() { - return; // consumer dropped - } + if flush_sse_frame(&tx, &event_name, &data_lines).is_err() { + return; } + event_name = None; data_lines.clear(); continue; } @@ -156,14 +144,17 @@ pub(super) fn read_sse( continue; } + if let Some(name) = trimmed.strip_prefix("event:") { + event_name = Some(name.strip_prefix(' ').unwrap_or(name).to_string()); + continue; + } + if let Some(data) = trimmed.strip_prefix("data:") { data_lines.push(data.strip_prefix(' ').unwrap_or(data).to_string()); } } Err(error) => { - if let Some(event) = parse_sse_payload(&data_lines) { - let _ = tx.send(ReaderMessage::Event(event)); - } + let _ = flush_sse_frame(&tx, &event_name, &data_lines); let _ = tx.send(ReaderMessage::End(Some(error.to_string()))); return; } @@ -171,6 +162,22 @@ pub(super) fn read_sse( } } +fn flush_sse_frame( + tx: &SyncSender, + event_name: &Option, + lines: &[String], +) -> Result<(), ()> { + let Some(payload) = parse_sse_payload(lines) else { + return Ok(()); + }; + + if event_name.as_deref() == Some("codenomad.client.ping") { + tx.send(ReaderMessage::Ping(payload)).map_err(|_| ()) + } else { + tx.send(ReaderMessage::Event(payload)).map_err(|_| ()) + } +} + fn parse_sse_payload(lines: &[String]) -> Option { if lines.is_empty() { return None; diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs index e464ef8c9..d9ba344b9 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs @@ -86,13 +86,6 @@ fn message_part_updated_event(text: &str) -> Value { }) } -fn active_target() -> ActiveSessionTarget { - ActiveSessionTarget { - instance_id: "inst-1".to_string(), - session_id: "sess-1".to_string(), - } -} - #[test] fn coalesces_message_part_delta_events() { let mut pending = PendingBatch::default(); @@ -295,14 +288,12 @@ fn holds_single_delta_within_stream_window() { events: vec![PendingEntry::Delta { key: "delta-key".to_string(), scope: "delta-scope".to_string(), - instance_id: "inst-1".to_string(), - session_id: Some("sess-1".to_string()), event: delta_event("Hello"), started_at: Instant::now(), }], }; - assert!(pending.should_hold_single_delta(Instant::now(), None)); + assert!(pending.should_hold_single_delta(Instant::now())); } #[test] @@ -312,235 +303,12 @@ fn flushes_single_delta_after_stream_window() { events: vec![PendingEntry::Delta { key: "delta-key".to_string(), scope: "delta-scope".to_string(), - instance_id: "inst-1".to_string(), - session_id: Some("sess-1".to_string()), - event: delta_event("Hello"), - started_at, - }], - }; - - assert!(!pending.should_hold_single_delta(Instant::now(), None)); -} - -#[test] -fn active_session_uses_shorter_hold_window() { - // Delta aged past the active-stream window but within the base window. - // Active session should flush faster, so this should NOT be held. - let started_at = Instant::now() - Duration::from_millis(ACTIVE_STREAM_HOLD_WINDOW_MS + 10); - let pending = PendingBatch { - events: vec![PendingEntry::Delta { - key: "delta-key".to_string(), - scope: "delta-scope".to_string(), - instance_id: "inst-1".to_string(), - session_id: Some("sess-1".to_string()), event: delta_event("Hello"), started_at, }], }; - let active_target = active_target(); - let other_target = ActiveSessionTarget { - instance_id: "inst-1".to_string(), - session_id: "sess-2".to_string(), - }; - - // Active session: uses ACTIVE_STREAM_HOLD_WINDOW_MS, so this stale delta is not held. - assert!(!pending.should_hold_single_delta(Instant::now(), Some(&active_target))); - // Non-matching session: still uses the wider base window. - assert!(pending.should_hold_single_delta(Instant::now(), Some(&other_target))); -} - -#[test] -fn active_session_holds_fresh_delta() { - // A very fresh delta should be held even for the active session's shorter window. - let started_at = Instant::now() - Duration::from_millis(5); - let pending = PendingBatch { - events: vec![PendingEntry::Delta { - key: "delta-key".to_string(), - scope: "delta-scope".to_string(), - instance_id: "inst-1".to_string(), - session_id: Some("sess-1".to_string()), - event: delta_event("Hello"), - started_at, - }], - }; - - let active_target = active_target(); - - assert!(pending.should_hold_single_delta(Instant::now(), Some(&active_target))); -} - -#[test] -fn assembler_emits_first_preview_chunk_immediately() { - let mut assembler = ActiveTextAssembler::default(); - let now = Instant::now(); - - let emitted = assembler.absorb( - ActiveTextDelta { - instance_id: "inst-1".to_string(), - session_id: "sess-1".to_string(), - message_id: "msg-1".to_string(), - part_id: "part-1".to_string(), - delta: "Hello".to_string(), - }, - now, - ); - - assert_eq!(emitted.len(), 1); - assert_eq!( - coalesced_payload_event(&emitted[0]) - .get("type") - .and_then(Value::as_str), - Some("assistant.stream.chunk") - ); - assert_eq!( - coalesced_payload_event(&emitted[0]) - .get("properties") - .and_then(|props| props.get("delta")) - .and_then(Value::as_str), - Some("Hello") - ); -} - -#[test] -fn snapshot_buffer_coalesces_updates_within_window() { - let mut buffer = ActiveTextSnapshotBuffer::default(); - let now = Instant::now(); - - buffer.buffer( - parse_active_text_snapshot(&message_part_updated_event("A"), Some(&active_target())) - .unwrap(), - now, - ); - buffer.buffer( - parse_active_text_snapshot(&message_part_updated_event("AB"), Some(&active_target())) - .unwrap(), - now + Duration::from_millis(40), - ); - buffer.buffer( - parse_active_text_snapshot(&message_part_updated_event("ABC"), Some(&active_target())) - .unwrap(), - now + Duration::from_millis(80), - ); - - let early = buffer.take_due(now + Duration::from_millis(ACTIVE_STREAM_SNAPSHOT_WINDOW_MS - 1)); - assert!(early.is_empty()); - - let emitted = - buffer.take_due(now + Duration::from_millis(ACTIVE_STREAM_SNAPSHOT_WINDOW_MS + 1)); - assert_eq!(emitted.len(), 1); - assert_eq!( - emitted[0]["event"]["properties"]["part"]["text"].as_str(), - Some("ABC") - ); -} - -#[test] -fn snapshot_buffer_flushes_latest_snapshot_before_message_update() { - let mut buffer = ActiveTextSnapshotBuffer::default(); - let now = Instant::now(); - - buffer.buffer( - parse_active_text_snapshot(&message_part_updated_event("Hello"), Some(&active_target())) - .unwrap(), - now, - ); - buffer.buffer( - parse_active_text_snapshot( - &message_part_updated_event("Hello world"), - Some(&active_target()), - ) - .unwrap(), - now + Duration::from_millis(25), - ); - - let flushed = buffer.flush_for_event(&json!({ - "type": "instance.event", - "instanceId": "inst-1", - "event": { - "type": "message.updated", - "properties": { - "info": { - "id": "msg-1", - "sessionID": "sess-1" - } - } - } - })); - - assert_eq!(flushed.len(), 1); - assert_eq!( - flushed[0]["event"]["properties"]["part"]["text"].as_str(), - Some("Hello world") - ); -} - -#[test] -fn assembler_keeps_first_delta_after_full_flush() { - let mut assembler = ActiveTextAssembler::default(); - let now = Instant::now(); - let delta = ActiveTextDelta { - instance_id: "inst-1".to_string(), - session_id: "sess-1".to_string(), - message_id: "msg-1".to_string(), - part_id: "part-1".to_string(), - delta: "Hello".to_string(), - }; - - let _ = assembler.absorb(delta.clone(), now); - let _ = assembler.flush_message("inst-1", "sess-1", "msg-1", now); - let _ = assembler.absorb( - ActiveTextDelta { - delta: " world".to_string(), - ..delta - }, - now, - ); - let emitted = assembler.flush_store_only_all(now + Duration::from_millis(1)); - - assert!(emitted.iter().any(|event| { - coalesced_payload_event(event) - .get("type") - .and_then(Value::as_str) - == Some("message.part.delta") - && coalesced_payload_event(event) - .get("properties") - .and_then(|props| props.get("delta")) - .and_then(Value::as_str) - == Some(" world") - })); -} - -#[test] -fn flush_store_only_all_preserves_canonical_text_without_preview() { - let mut assembler = ActiveTextAssembler::default(); - let now = Instant::now(); - let _ = assembler.absorb( - ActiveTextDelta { - instance_id: "inst-1".to_string(), - session_id: "sess-1".to_string(), - message_id: "msg-1".to_string(), - part_id: "part-1".to_string(), - delta: "Hello".to_string(), - }, - now, - ); - - let emitted = assembler.flush_store_only_all(now + Duration::from_millis(1)); - assert_eq!(emitted.len(), 1); - assert_eq!( - coalesced_payload_event(&emitted[0]) - .get("type") - .and_then(Value::as_str), - Some("message.part.delta") - ); - assert_eq!( - coalesced_payload_event(&emitted[0]) - .get("properties") - .and_then(|props| props.get("delta")) - .and_then(Value::as_str), - Some("Hello") - ); + assert!(!pending.should_hold_single_delta(Instant::now())); } #[test] diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs index 0c7da3a29..9ecf6bfd6 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs @@ -1,8 +1,23 @@ use super::*; +fn send_connection_pong(client: &Client, config: &DesktopEventStreamConfig, payload: &Value) { + let body = serde_json::json!({ + "clientId": config.client_id, + "connectionId": config.connection_id, + "pingTs": payload.get("ts").and_then(Value::as_u64), + }); + + let _ = client + .post(format!( + "{}/api/client-connections/pong", + config.base_url.trim_end_matches('/') + )) + .json(&body) + .send(); +} + pub(super) fn run_transport_loop( app: AppHandle, - state: Arc>, generation_atomic: Arc, generation: u64, stop: Arc, @@ -63,8 +78,9 @@ pub(super) fn run_transport_loop( let disconnect_reason = consume_stream( &app, + &client, + &config.stream, response, - &state, &generation_atomic, generation, stop.clone(), @@ -194,8 +210,9 @@ fn wait_with_cancellation( fn consume_stream( app: &AppHandle, + client: &Client, + stream_config: &DesktopEventStreamConfig, response: Response, - state: &Arc>, generation_atomic: &Arc, generation: u64, stop: Arc, @@ -215,10 +232,7 @@ fn consume_stream( }); let mut pending = PendingBatch::default(); - let mut active_text_assembler = ActiveTextAssembler::default(); - let mut active_text_snapshots = ActiveTextSnapshotBuffer::default(); let mut sequence = 0_u64; - let mut last_active_target: Option = None; let mut last_reader_activity = Instant::now(); loop { @@ -230,132 +244,16 @@ fn consume_stream( Ok(ReaderMessage::Activity) => { last_reader_activity = Instant::now(); } + Ok(ReaderMessage::Ping(payload)) => { + last_reader_activity = Instant::now(); + send_connection_pong(client, stream_config, &payload); + } Ok(ReaderMessage::Event(event)) => { last_reader_activity = Instant::now(); stats.raw_events = stats.raw_events.saturating_add(1); - let now = Instant::now(); - let active_target = state.lock().active_target.clone(); - let max_batch_events = if active_target.is_some() { - ACTIVE_SESSION_MAX_BATCH_EVENTS - } else { - MAX_BATCH_EVENTS - }; - let mut should_flush_active = false; - if active_target != last_active_target { - for flushed in active_text_assembler.flush_store_only_all(now) { - pending.push(flushed, stats); - } - for flushed in active_text_snapshots.flush_all() { - pending.push(flushed, stats); - } - last_active_target = active_target.clone(); - } - - let due = active_text_assembler.take_due(now); - if !due.is_empty() { - should_flush_active = true; - } - for flushed in due { - pending.push(flushed, stats); - } - - let snapshot_due = active_text_snapshots.take_due(now); - if !snapshot_due.is_empty() { - should_flush_active = true; - } - for flushed in snapshot_due { - pending.push(flushed, stats); - } - - let flushes = active_text_assembler.flush_for_event(&event, now); - if !flushes.is_empty() { - should_flush_active = true; - } - for flushed in flushes { - pending.push(flushed, stats); - } - - let snapshot_flushes = active_text_snapshots.flush_for_event(&event); - if !snapshot_flushes.is_empty() { - should_flush_active = true; - } - for flushed in snapshot_flushes { - pending.push(flushed, stats); - } - - if let Some(snapshot) = parse_active_text_snapshot(&event, active_target.as_ref()) { - active_text_snapshots.buffer(snapshot, now); - - if should_flush_active { - emit_pending_batch( - app, - generation, - &mut pending, - &mut sequence, - generation_atomic, - stats, - ); - } - - if pending.pending_len() >= max_batch_events { - emit_pending_batch( - app, - generation, - &mut pending, - &mut sequence, - generation_atomic, - stats, - ); - } - continue; - } - - if let Some(delta) = parse_active_text_delta(&event, active_target.as_ref()) { - let assembled_events = active_text_assembler.absorb(delta, now); - if !assembled_events.is_empty() { - should_flush_active = true; - } - for assembled in assembled_events { - pending.push(assembled, stats); - } - - if should_flush_active { - emit_pending_batch( - app, - generation, - &mut pending, - &mut sequence, - generation_atomic, - stats, - ); - } - - if pending.pending_len() >= max_batch_events { - emit_pending_batch( - app, - generation, - &mut pending, - &mut sequence, - generation_atomic, - stats, - ); - } - continue; - } - pending.push(event, stats); - if should_flush_active { - emit_pending_batch( - app, - generation, - &mut pending, - &mut sequence, - generation_atomic, - stats, - ); - } - if pending.pending_len() >= max_batch_events { + if pending.pending_len() >= MAX_BATCH_EVENTS { emit_pending_batch( app, generation, @@ -367,18 +265,6 @@ fn consume_stream( } } Ok(ReaderMessage::End(reason)) => { - for flushed in active_text_assembler.take_due(Instant::now()) { - pending.push(flushed, stats); - } - for flushed in active_text_assembler.flush_store_only_all(Instant::now()) { - pending.push(flushed, stats); - } - for flushed in active_text_snapshots.take_due(Instant::now()) { - pending.push(flushed, stats); - } - for flushed in active_text_snapshots.flush_all() { - pending.push(flushed, stats); - } if !pending.is_empty() { emit_pending_batch( app, @@ -394,18 +280,6 @@ fn consume_stream( Err(RecvTimeoutError::Timeout) => { if last_reader_activity.elapsed() >= Duration::from_millis(STREAM_STALL_TIMEOUT_MS) { - for flushed in active_text_assembler.take_due(Instant::now()) { - pending.push(flushed, stats); - } - for flushed in active_text_assembler.flush_store_only_all(Instant::now()) { - pending.push(flushed, stats); - } - for flushed in active_text_snapshots.take_due(Instant::now()) { - pending.push(flushed, stats); - } - for flushed in active_text_snapshots.flush_all() { - pending.push(flushed, stats); - } if !pending.is_empty() { sequence += 1; emit_batch( @@ -420,17 +294,8 @@ fn consume_stream( return Some("stream stalled".to_string()); } - for flushed in active_text_assembler.take_due(Instant::now()) { - pending.push(flushed, stats); - } - for flushed in active_text_snapshots.take_due(Instant::now()) { - pending.push(flushed, stats); - } if !pending.is_empty() { - if pending.should_hold_single_delta( - Instant::now(), - state.lock().active_target.as_ref(), - ) { + if pending.should_hold_single_delta(Instant::now()) { continue; } emit_pending_batch( @@ -444,18 +309,6 @@ fn consume_stream( } } Err(RecvTimeoutError::Disconnected) => { - for flushed in active_text_assembler.take_due(Instant::now()) { - pending.push(flushed, stats); - } - for flushed in active_text_assembler.flush_store_only_all(Instant::now()) { - pending.push(flushed, stats); - } - for flushed in active_text_snapshots.take_due(Instant::now()) { - pending.push(flushed, stats); - } - for flushed in active_text_snapshots.flush_all() { - pending.push(flushed, stats); - } if !pending.is_empty() { emit_pending_batch( app, diff --git a/packages/ui/src/lib/event-transport-contract.ts b/packages/ui/src/lib/event-transport-contract.ts index f72301288..e4d91629c 100644 --- a/packages/ui/src/lib/event-transport-contract.ts +++ b/packages/ui/src/lib/event-transport-contract.ts @@ -44,22 +44,6 @@ export interface DesktopEventsStartResult { reason?: string } -export interface DesktopEventActiveSessionTarget { - instanceId: string - sessionId: string -} - -export interface AssistantStreamChunkEvent { - type: "assistant.stream.chunk" - properties: { - sessionID: string - messageID: string - partID: string - field: "text" - delta: string - } -} - export const DEFAULT_DESKTOP_EVENT_RECONNECT_POLICY: DesktopEventTransportReconnectPolicy = { initialDelayMs: 1000, maxDelayMs: 10000, diff --git a/packages/ui/src/lib/native/desktop-events.ts b/packages/ui/src/lib/native/desktop-events.ts index b033e19a6..875a24e16 100644 --- a/packages/ui/src/lib/native/desktop-events.ts +++ b/packages/ui/src/lib/native/desktop-events.ts @@ -2,7 +2,6 @@ import { invoke } from "@tauri-apps/api/core" import { listen } from "@tauri-apps/api/event" import type { WorkspaceEventPayload } from "../../../../server/src/api-types" import type { - DesktopEventActiveSessionTarget, DesktopEventsStartResult, DesktopEventTransportStartOptions, DesktopEventTransportStatusPayload, @@ -150,14 +149,3 @@ export async function connectTauriWorkspaceEvents( }, } } - -export async function setTauriDesktopActiveSession(target: DesktopEventActiveSessionTarget | null): Promise { - try { - await invoke("desktop_events_set_active_session", { - instanceId: target?.instanceId ?? null, - sessionId: target?.sessionId ?? null, - }) - } catch (error) { - log.warn("Failed to update native desktop active session", error) - } -} diff --git a/packages/ui/src/transport-bench.tsx b/packages/ui/src/transport-bench.tsx index 84bf61de5..957012cf9 100644 --- a/packages/ui/src/transport-bench.tsx +++ b/packages/ui/src/transport-bench.tsx @@ -13,11 +13,19 @@ import { } from "./stores/sessions" const benchParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : new URLSearchParams() +const PERF242_BENCH_MODE = benchParams.get("mode") || "short" const PERF242_BENCH_FOLDER = benchParams.get("folder") || import.meta.env.VITE_PERF242_BENCH_FOLDER || "D:\\CodeNomad" -const PERF242_BENCH_SESSION_ID = benchParams.get("sessionId") || import.meta.env.VITE_PERF242_BENCH_SESSION_ID || "" +const PERF242_BENCH_SESSION_ID = + benchParams.get("sessionId") + || import.meta.env.VITE_PERF242_BENCH_SESSION_ID + || "ses_21feb15b3ffeLz3uRModK4KKnG" const PERF242_BENCH_BINARY = benchParams.get("binary") || import.meta.env.VITE_PERF242_BENCH_BINARY || "opencode" -const PERF242_BENCH_COMMAND = benchParams.get("command") || import.meta.env.VITE_PERF242_BENCH_COMMAND - || `node -e "for (let i = 1; i <= 400; i += 1) console.log('line ' + i)"` +const PERF242_SHORT_COMMAND = `node -e "for (let i = 1; i <= 400; i += 1) console.log('line ' + i)"` +const PERF242_LONG_COMMAND = `powershell -NoProfile -Command Start-Sleep -Seconds 70` +const PERF242_BENCH_COMMAND = + benchParams.get("command") + || import.meta.env.VITE_PERF242_BENCH_COMMAND + || (PERF242_BENCH_MODE === "long" ? PERF242_LONG_COMMAND : PERF242_SHORT_COMMAND) let perf242TransportBenchStarted = false @@ -120,7 +128,7 @@ export default function TransportBench() { const reachedIdle = await waitForCondition(() => { const session = getSessions(instanceId).find((value) => value.id === targetSession.id) return sawWorking ? session?.status === "idle" : false - }, 120000) + }, PERF242_BENCH_MODE === "long" ? 180000 : 120000) await emitPerf242Log({ stage: reachedIdle ? "complete" : "timeout", From 74fa1c3ad3a23299c2ef40ade9502c38222d7722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 1 May 2026 15:54:03 +0200 Subject: [PATCH 03/15] fix(bench): gate perf242 transport harness Require the explicit bench build flag before the UI harness can be activated, remove query-string command overrides, gate perf logging behind a runtime flag, and add a regression test for named SSE ping frames. --- packages/server/src/server/http-server.ts | 15 ++-- .../src/desktop_event_transport/stream.rs | 73 +++++++++++++++---- packages/ui/src/main.tsx | 7 +- packages/ui/src/transport-bench.tsx | 13 ++-- 4 files changed, 77 insertions(+), 31 deletions(-) diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 3d830376d..ad365cea7 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -88,6 +88,7 @@ export function createHttpServer(deps: HttpServerDeps) { const apiLogger = deps.logger.child({ component: "http" }) const sseLogger = deps.logger.child({ component: "sse" }) const perfLogger = deps.logger.child({ component: "perf242" }) + const perf242BenchEnabled = process.env.PERF242_TRANSPORT_BENCH === "1" const sseClients = new Set<() => void>() const registerSseClient = (cleanup: () => void) => { @@ -269,12 +270,14 @@ export function createHttpServer(deps: HttpServerDeps) { reply.code(404).send({ message: "UI bundle missing" }) }) - app.post("/api/perf-log", async (request, reply) => { - console.log("[perf242-route]", JSON.stringify(request.body ?? {})) - perfLogger.info(request.body ?? {}, "frontend perf log") - reply.code(204) - return null - }) + if (perf242BenchEnabled) { + app.post("/api/perf-log", async (request, reply) => { + console.log("[perf242-route]", JSON.stringify(request.body ?? {})) + perfLogger.info(request.body ?? {}, "frontend perf log") + reply.code(204) + return null + }) + } registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager }) registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger }) diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs index 1cd5d00b9..1992a1292 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs @@ -131,7 +131,7 @@ pub(super) fn read_sse( return; // consumer dropped — stop reading } let trimmed = line.trim_end_matches(['\r', '\n']); - if trimmed.is_empty() { + if handle_sse_line(trimmed, &mut event_name, &mut data_lines) { if flush_sse_frame(&tx, &event_name, &data_lines).is_err() { return; } @@ -139,19 +139,6 @@ pub(super) fn read_sse( data_lines.clear(); continue; } - - if trimmed.starts_with(':') { - continue; - } - - if let Some(name) = trimmed.strip_prefix("event:") { - event_name = Some(name.strip_prefix(' ').unwrap_or(name).to_string()); - continue; - } - - if let Some(data) = trimmed.strip_prefix("data:") { - data_lines.push(data.strip_prefix(' ').unwrap_or(data).to_string()); - } } Err(error) => { let _ = flush_sse_frame(&tx, &event_name, &data_lines); @@ -162,6 +149,31 @@ pub(super) fn read_sse( } } +fn handle_sse_line( + trimmed: &str, + event_name: &mut Option, + data_lines: &mut Vec, +) -> bool { + if trimmed.is_empty() { + return true; + } + + if trimmed.starts_with(':') { + return false; + } + + if let Some(name) = trimmed.strip_prefix("event:") { + *event_name = Some(name.strip_prefix(' ').unwrap_or(name).to_string()); + return false; + } + + if let Some(data) = trimmed.strip_prefix("data:") { + data_lines.push(data.strip_prefix(' ').unwrap_or(data).to_string()); + } + + false +} + fn flush_sse_frame( tx: &SyncSender, event_name: &Option, @@ -190,3 +202,36 @@ fn parse_sse_payload(lines: &[String]) -> Option { serde_json::from_str::(&payload).ok() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn named_ping_event_is_routed_to_ping_channel() { + let (tx, rx) = mpsc::sync_channel(1); + let mut event_name = None; + let mut data_lines = Vec::new(); + + assert!(!handle_sse_line( + "event: codenomad.client.ping", + &mut event_name, + &mut data_lines + )); + assert!(!handle_sse_line( + r#"data: {"ts":123}"#, + &mut event_name, + &mut data_lines + )); + assert!(handle_sse_line("", &mut event_name, &mut data_lines)); + + flush_sse_frame(&tx, &event_name, &data_lines).expect("ping frame should flush"); + + match rx.recv().expect("ping frame should be emitted") { + ReaderMessage::Ping(payload) => { + assert_eq!(payload.get("ts").and_then(Value::as_u64), Some(123)); + } + _ => panic!("expected ping frame"), + } + } +} diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index b4e5bc946..86a29fbac 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -18,15 +18,16 @@ if (!root) { const mount = root const bootParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : new URLSearchParams() +const isPerf242TransportBenchBuild = import.meta.env.VITE_PERF242_TRANSPORT_BENCH === "1" const isPerf242TransportBench = - import.meta.env.VITE_PERF242_TRANSPORT_BENCH === "1" - || bootParams.get("perf242TransportBench") === "1" + isPerf242TransportBenchBuild + && bootParams.get("perf242TransportBench") === "1" if (typeof document !== "undefined") { document.documentElement.dataset.runtimeHost = runtimeEnv.host document.documentElement.dataset.runtimePlatform = runtimeEnv.platform - if (bootParams.get("perf242TransportBench") === "1") { + if (isPerf242TransportBench) { const payload = { stage: "frontend-bootstrap", host: runtimeEnv.host, diff --git a/packages/ui/src/transport-bench.tsx b/packages/ui/src/transport-bench.tsx index 957012cf9..290e8a956 100644 --- a/packages/ui/src/transport-bench.tsx +++ b/packages/ui/src/transport-bench.tsx @@ -13,19 +13,16 @@ import { } from "./stores/sessions" const benchParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : new URLSearchParams() -const PERF242_BENCH_MODE = benchParams.get("mode") || "short" -const PERF242_BENCH_FOLDER = benchParams.get("folder") || import.meta.env.VITE_PERF242_BENCH_FOLDER || "D:\\CodeNomad" +const PERF242_BENCH_MODE = benchParams.get("mode") === "long" ? "long" : "short" +const PERF242_BENCH_FOLDER = import.meta.env.VITE_PERF242_BENCH_FOLDER || "D:\\CodeNomad" const PERF242_BENCH_SESSION_ID = - benchParams.get("sessionId") - || import.meta.env.VITE_PERF242_BENCH_SESSION_ID + import.meta.env.VITE_PERF242_BENCH_SESSION_ID || "ses_21feb15b3ffeLz3uRModK4KKnG" -const PERF242_BENCH_BINARY = benchParams.get("binary") || import.meta.env.VITE_PERF242_BENCH_BINARY || "opencode" +const PERF242_BENCH_BINARY = import.meta.env.VITE_PERF242_BENCH_BINARY || "opencode" const PERF242_SHORT_COMMAND = `node -e "for (let i = 1; i <= 400; i += 1) console.log('line ' + i)"` const PERF242_LONG_COMMAND = `powershell -NoProfile -Command Start-Sleep -Seconds 70` const PERF242_BENCH_COMMAND = - benchParams.get("command") - || import.meta.env.VITE_PERF242_BENCH_COMMAND - || (PERF242_BENCH_MODE === "long" ? PERF242_LONG_COMMAND : PERF242_SHORT_COMMAND) + PERF242_BENCH_MODE === "long" ? PERF242_LONG_COMMAND : PERF242_SHORT_COMMAND let perf242TransportBenchStarted = false From 0049c7b21c53120e51ca046d7c8aab2e2652ad0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 1 May 2026 16:07:51 +0200 Subject: [PATCH 04/15] fix(tauri): authenticate desktop heartbeat pongs Reuse the desktop event transport session cookie for the native /api/client-connections/pong request so heartbeat updates follow the same auth contract as the SSE stream, and cover it with a request-header regression test. --- .../src/desktop_event_transport/stream.rs | 57 ++++++++++++++++--- .../src/desktop_event_transport/transport.rs | 16 ++++-- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs index 1992a1292..ef6c851f9 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs @@ -1,4 +1,5 @@ use super::*; +use reqwest::blocking::RequestBuilder; pub(super) fn build_stream_client() -> Result { Client::builder() @@ -29,14 +30,11 @@ pub(super) fn open_stream( config.events_url, config.client_id, config.connection_id ); - let mut request = client.get(&url).header("Accept", "text/event-stream"); - - if let Some(session_cookie) = resolve_session_cookie(app, config) { - request = request.header( - "Cookie", - format!("{}={}", config.cookie_name, session_cookie), - ); - } + let request = attach_session_cookie( + client.get(&url).header("Accept", "text/event-stream"), + app, + config, + ); let response = request.send().map_err(|error| OpenStreamError { kind: OpenStreamErrorKind::Transport, @@ -68,6 +66,30 @@ fn resolve_session_cookie(app: &AppHandle, config: &DesktopEventStreamConfig) -> .filter(|value| !value.is_empty()) } +pub(super) fn attach_session_cookie( + request: RequestBuilder, + app: &AppHandle, + config: &DesktopEventStreamConfig, +) -> RequestBuilder { + attach_session_cookie_value( + request, + &config.cookie_name, + resolve_session_cookie(app, config).as_deref(), + ) +} + +fn attach_session_cookie_value( + request: RequestBuilder, + cookie_name: &str, + session_cookie: Option<&str>, +) -> RequestBuilder { + let Some(session_cookie) = session_cookie.filter(|value| !value.is_empty()) else { + return request; + }; + + request.header("Cookie", format!("{}={}", cookie_name, session_cookie)) +} + fn read_session_cookie_from_webview( app: &AppHandle, base_url: &str, @@ -234,4 +256,23 @@ mod tests { _ => panic!("expected ping frame"), } } + + #[test] + fn session_cookie_is_attached_to_requests() { + let request = attach_session_cookie_value( + Client::new().post("http://localhost/api/client-connections/pong"), + "codenomad_session", + Some("cookie-value"), + ) + .build() + .expect("request should build"); + + assert_eq!( + request + .headers() + .get("Cookie") + .and_then(|value| value.to_str().ok()), + Some("codenomad_session=cookie-value") + ); + } } diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs index 9ecf6bfd6..5f0ed2314 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs @@ -1,19 +1,25 @@ use super::*; -fn send_connection_pong(client: &Client, config: &DesktopEventStreamConfig, payload: &Value) { +fn send_connection_pong( + app: &AppHandle, + client: &Client, + config: &DesktopEventStreamConfig, + payload: &Value, +) { let body = serde_json::json!({ "clientId": config.client_id, "connectionId": config.connection_id, "pingTs": payload.get("ts").and_then(Value::as_u64), }); - let _ = client + let request = client .post(format!( "{}/api/client-connections/pong", config.base_url.trim_end_matches('/') )) - .json(&body) - .send(); + .json(&body); + + let _ = attach_session_cookie(request, app, config).send(); } pub(super) fn run_transport_loop( @@ -246,7 +252,7 @@ fn consume_stream( } Ok(ReaderMessage::Ping(payload)) => { last_reader_activity = Instant::now(); - send_connection_pong(client, stream_config, &payload); + send_connection_pong(app, client, stream_config, &payload); } Ok(ReaderMessage::Event(event)) => { last_reader_activity = Instant::now(); From 39d0ae182fa6dd5581c06e75701794ad2b191f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 9 May 2026 15:38:42 +0200 Subject: [PATCH 05/15] feat(settings): add Tauri transport fallback toggle Expose a device-level setting that lets Tauri users disable the native Rust desktop event transport and fall back to the browser EventSource path when debugging transport issues or comparing behavior. Persist the preference in UI settings, mirror it into localStorage so startup transport selection can read it synchronously, and restart the backend event stream immediately when the toggle changes so the new transport takes effect without a full app restart. Validation: npx tsc --noEmit --pretty -p packages/ui/tsconfig.json; npx tsc --noEmit --pretty -p packages/server/tsconfig.json; cargo test --no-run --- .../lib/desktop-event-transport-preference.ts | 25 ++++++++++++++++ packages/ui/src/lib/event-transport.ts | 11 +++++-- .../ui/src/lib/i18n/messages/en/settings.ts | 2 ++ .../ui/src/lib/i18n/messages/es/settings.ts | 2 ++ .../ui/src/lib/i18n/messages/fr/settings.ts | 2 ++ .../ui/src/lib/i18n/messages/he/settings.ts | 2 ++ .../ui/src/lib/i18n/messages/ja/settings.ts | 2 ++ .../src/lib/i18n/messages/zh-Hans/settings.ts | 2 ++ packages/ui/src/lib/server-events.ts | 13 ++++++++ .../ui/src/lib/settings/behavior-registry.ts | 18 ++++++++++- packages/ui/src/stores/preferences.tsx | 30 +++++++++++++++++++ 11 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/lib/desktop-event-transport-preference.ts diff --git a/packages/ui/src/lib/desktop-event-transport-preference.ts b/packages/ui/src/lib/desktop-event-transport-preference.ts new file mode 100644 index 000000000..1ee0ebcb6 --- /dev/null +++ b/packages/ui/src/lib/desktop-event-transport-preference.ts @@ -0,0 +1,25 @@ +export const TAURI_NATIVE_EVENT_TRANSPORT_STORAGE_KEY = "codenomad-use-tauri-native-event-transport" + +export function readUseTauriNativeEventTransportPreference(): boolean { + if (typeof window === "undefined") { + return true + } + + try { + return window.localStorage?.getItem(TAURI_NATIVE_EVENT_TRANSPORT_STORAGE_KEY) !== "0" + } catch { + return true + } +} + +export function writeUseTauriNativeEventTransportPreference(enabled: boolean): void { + if (typeof window === "undefined") { + return + } + + try { + window.localStorage?.setItem(TAURI_NATIVE_EVENT_TRANSPORT_STORAGE_KEY, enabled ? "1" : "0") + } catch { + // Ignore localStorage failures and keep the in-memory preference only. + } +} diff --git a/packages/ui/src/lib/event-transport.ts b/packages/ui/src/lib/event-transport.ts index f114e2537..ae0ad60a2 100644 --- a/packages/ui/src/lib/event-transport.ts +++ b/packages/ui/src/lib/event-transport.ts @@ -4,6 +4,7 @@ import { resolveDesktopEventTransportStartOptions, type DesktopEventTransportStartOptions, } from "./event-transport-contract" +import { readUseTauriNativeEventTransportPreference } from "./desktop-event-transport-preference" import { getLogger } from "./logger" import { runtimeEnv } from "./runtime-env" import { connectTauriWorkspaceEvents } from "./native/desktop-events" @@ -53,7 +54,9 @@ export async function connectWorkspaceEvents( callbacks: WorkspaceEventTransportCallbacks, options?: DesktopEventTransportStartOptions, ): Promise { - if (runtimeEnv.host === "tauri" && !shouldForceBrowserTransport()) { + const nativeDesktopTransportEnabled = readUseTauriNativeEventTransportPreference() + + if (runtimeEnv.host === "tauri" && nativeDesktopTransportEnabled && !shouldForceBrowserTransport()) { try { const conn = await connectTauriWorkspaceEvents( callbacks, @@ -66,7 +69,11 @@ export async function connectWorkspaceEvents( log.warn("Failed to start native desktop event transport, falling back to browser EventSource", error) } } else if (runtimeEnv.host === "tauri") { - log.info("Event transport: browser-eventsource forced by localStorage override") + log.info( + nativeDesktopTransportEnabled + ? "Event transport: browser-eventsource forced by localStorage override" + : "Event transport: browser-eventsource forced by settings", + ) } ;(globalThis as any).__TRANSPORT_TYPE = "browser-eventsource" diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 35f3999c0..de4e0459d 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -155,6 +155,8 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.", "settings.behavior.keepUnseenSubagentIdle.title": "Keep subagent idle markers", "settings.behavior.keepUnseenSubagentIdle.subtitle": "Keep subagent idle markers visible until viewed instead of hiding them after 5 seconds.", + "settings.behavior.tauriNativeEventTransport.title": "Native Tauri event transport", + "settings.behavior.tauriNativeEventTransport.subtitle": "Use the Rust-native desktop event transport in Tauri. Disable this to fall back to the browser EventSource path.", "settings.behavior.promptVoiceInput.title": "Prompt voice input", "settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.", "settings.behavior.promptSubmit.title": "Enter to submit", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index a158ec511..6519d4a32 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -154,6 +154,8 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "Limpia automaticamente las sesiones en blanco al crear nuevas.", "settings.behavior.keepUnseenSubagentIdle.title": "Mantener marcadores idle de subagentes", "settings.behavior.keepUnseenSubagentIdle.subtitle": "Mantiene visibles los marcadores idle de subagentes hasta verlos, en lugar de ocultarlos despues de 5 segundos.", + "settings.behavior.tauriNativeEventTransport.title": "Transporte de eventos nativo de Tauri", + "settings.behavior.tauriNativeEventTransport.subtitle": "Usa el transporte de eventos de escritorio nativo en Rust dentro de Tauri. Desactivalo para volver a la ruta browser EventSource.", "settings.behavior.promptVoiceInput.title": "Prompt voice input", "settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.", "settings.behavior.promptSubmit.title": "Enter para enviar", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 722292878..bb3fbcecf 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -154,6 +154,8 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.", "settings.behavior.keepUnseenSubagentIdle.title": "Garder les marqueurs inactifs des sous-agents", "settings.behavior.keepUnseenSubagentIdle.subtitle": "Garde les marqueurs inactifs des sous-agents visibles jusqu'a consultation au lieu de les masquer apres 5 secondes.", + "settings.behavior.tauriNativeEventTransport.title": "Transport d'evenements natif Tauri", + "settings.behavior.tauriNativeEventTransport.subtitle": "Utiliser le transport d'evenements desktop natif Rust dans Tauri. Desactivez-le pour revenir au chemin browser EventSource.", "settings.behavior.promptVoiceInput.title": "Prompt voice input", "settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.", "settings.behavior.promptSubmit.title": "Entrer pour envoyer", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index cc8b59044..98ed8cbb7 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -153,6 +153,8 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "נקה אוטומטית סשנים ריקים בעת יצירת סשנים חדשים.", "settings.behavior.keepUnseenSubagentIdle.title": "השאר סמני idle של תתי-סוכנים", "settings.behavior.keepUnseenSubagentIdle.subtitle": "השאר סמני idle של תתי-סוכנים גלויים עד צפייה במקום להסתיר אותם אחרי 5 שניות.", + "settings.behavior.tauriNativeEventTransport.title": "תעבורת אירועים מקורית של Tauri", + "settings.behavior.tauriNativeEventTransport.subtitle": "השתמש בתעבורת האירועים השולחנית המקורית ב-Rust בתוך Tauri. כבה זאת כדי לחזור למסלול browser EventSource.", "settings.behavior.promptVoiceInput.title": "קלט קולי לפרומפט", "settings.behavior.promptVoiceInput.subtitle": "הצג את כפתור המיקרופון לקלט דיבור-לטקסט כאשר תכונת הקול מוגדרת.", "settings.behavior.promptSubmit.title": "Enter לשליחה", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index f764acbfc..3e1e26a8f 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -154,6 +154,8 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "新しいセッション作成時に空のセッションを自動的にクリーンアップします。", "settings.behavior.keepUnseenSubagentIdle.title": "サブエージェントの idle マーカーを保持", "settings.behavior.keepUnseenSubagentIdle.subtitle": "サブエージェントの idle マーカーを 5 秒後に隠さず、表示するまで残します。", + "settings.behavior.tauriNativeEventTransport.title": "Tauri ネイティブイベント転送", + "settings.behavior.tauriNativeEventTransport.subtitle": "Tauri で Rust ネイティブのデスクトップイベント転送を使います。無効にすると browser EventSource 経路に戻ります。", "settings.behavior.promptVoiceInput.title": "Prompt voice input", "settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.", "settings.behavior.promptSubmit.title": "Enterで送信", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index beae0a580..8287656f7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -154,6 +154,8 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "创建新会话时自动清理空会话。", "settings.behavior.keepUnseenSubagentIdle.title": "保留子智能体 idle 标记", "settings.behavior.keepUnseenSubagentIdle.subtitle": "让子智能体 idle 标记保持可见直到查看,而不是 5 秒后隐藏。", + "settings.behavior.tauriNativeEventTransport.title": "Tauri 原生事件传输", + "settings.behavior.tauriNativeEventTransport.subtitle": "在 Tauri 中使用 Rust 原生桌面事件传输。禁用后将回退到浏览器 EventSource 路径。", "settings.behavior.promptVoiceInput.title": "Prompt voice input", "settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.", "settings.behavior.promptSubmit.title": "回车发送", diff --git a/packages/ui/src/lib/server-events.ts b/packages/ui/src/lib/server-events.ts index 0bbb1dc95..0953e5886 100644 --- a/packages/ui/src/lib/server-events.ts +++ b/packages/ui/src/lib/server-events.ts @@ -172,6 +172,19 @@ class ServerEvents { this.openHandlers.add(handler) return () => this.openHandlers.delete(handler) } + + restart(reason = "manual restart"): void { + this.retryDelay = RETRY_BASE_DELAY + this.clearReconnectTimer() + + if (this.connection) { + this.connection.disconnect() + this.connection = null + } + + logSse("Restarting backend events stream", { reason }) + void this.connect() + } } export const serverEvents = new ServerEvents() diff --git a/packages/ui/src/lib/settings/behavior-registry.ts b/packages/ui/src/lib/settings/behavior-registry.ts index 25bd13e4b..fd21ba51e 100644 --- a/packages/ui/src/lib/settings/behavior-registry.ts +++ b/packages/ui/src/lib/settings/behavior-registry.ts @@ -6,7 +6,7 @@ import type { } from "../../stores/preferences" import type { Command } from "../commands" import { tGlobal } from "../i18n" -import { isWebHost } from "../runtime-env" +import { isTauriHost, isWebHost } from "../runtime-env" export type BehaviorSettingKind = "toggle" | "enum" @@ -280,6 +280,22 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS } }, }, + ...(isTauriHost() + ? [ + { + kind: "toggle" as const, + id: "behavior.tauriNativeEventTransport", + titleKey: "settings.behavior.tauriNativeEventTransport.title", + subtitleKey: "settings.behavior.tauriNativeEventTransport.subtitle", + get: (p: Preferences) => Boolean(p.useTauriNativeEventTransport ?? true), + set: (next: boolean) => { + if (updatePreferences) { + updatePreferences({ useTauriNativeEventTransport: next }) + } + }, + }, + ] + : []), { kind: "toggle", id: "behavior.promptVoiceInput", diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index a39b07969..e86dbf6dd 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -1,5 +1,6 @@ import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js" import type { Accessor, ParentComponent } from "solid-js" +import { writeUseTauriNativeEventTransportPreference } from "../lib/desktop-event-transport-preference" import { storage, type OwnerBucket } from "../lib/storage" import type { RemoteServerProfile } from "../../../server/src/api-types" import { @@ -67,6 +68,7 @@ export interface UiSettings { showUsageMetrics: boolean autoCleanupBlankSessions: boolean keepUnseenSubagentIdleStatus: boolean + useTauriNativeEventTransport: boolean // OS notifications osNotificationsEnabled: boolean @@ -147,6 +149,7 @@ const defaultUiSettings: UiSettings = { showUsageMetrics: true, autoCleanupBlankSessions: true, keepUnseenSubagentIdleStatus: false, + useTauriNativeEventTransport: true, osNotificationsEnabled: false, osNotificationsAllowWhenVisible: false, @@ -188,6 +191,8 @@ function normalizeUiSettings(input?: Partial | null): UiSettings { autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultUiSettings.autoCleanupBlankSessions, keepUnseenSubagentIdleStatus: sanitized.keepUnseenSubagentIdleStatus ?? defaultUiSettings.keepUnseenSubagentIdleStatus, + useTauriNativeEventTransport: + sanitized.useTauriNativeEventTransport ?? defaultUiSettings.useTauriNativeEventTransport, osNotificationsEnabled: sanitized.osNotificationsEnabled ?? defaultUiSettings.osNotificationsEnabled, osNotificationsAllowWhenVisible: sanitized.osNotificationsAllowWhenVisible ?? defaultUiSettings.osNotificationsAllowWhenVisible, @@ -413,6 +418,7 @@ async function ensureLoaded(): Promise { setUiConfigBucket(uiCfg as any) setServerConfigBucket(srvCfg as any) setUiStateBucket(uiSt as any) + syncDesktopEventTransportPreference((uiCfg as UiConfigBucket | undefined)?.settings) setIsLoaded(true) }) .catch((error) => { @@ -436,6 +442,26 @@ async function patchConfigOwner(owner: string, patch: unknown) { if (owner === "server") setServerConfigBucket(updated as any) } +function syncDesktopEventTransportPreference(settings?: Partial | null) { + writeUseTauriNativeEventTransportPreference( + normalizeUiSettings(settings).useTauriNativeEventTransport, + ) +} + +function restartDesktopEventTransportPreferenceIfNeeded(previous: UiSettings, next: UiSettings) { + if (previous.useTauriNativeEventTransport === next.useTauriNativeEventTransport) { + return + } + + void import("../lib/server-events") + .then(({ serverEvents }) => { + serverEvents.restart("desktop transport preference changed") + }) + .catch((error) => { + log.error("Failed to restart backend events stream after desktop transport preference change", error) + }) +} + async function patchStateOwner(owner: string, patch: unknown) { await ensureLoaded() const updated = await storage.patchStateOwner(owner, patch) @@ -444,7 +470,10 @@ async function patchStateOwner(owner: string, patch: unknown) { function updateUiSettings(updates: Partial) { const current = uiConfigBucket() + const previousSettings = normalizeUiSettings(current.settings) const nextSettings = normalizeUiSettings({ ...(current.settings ?? {}), ...updates }) + syncDesktopEventTransportPreference(nextSettings) + restartDesktopEventTransportPreferenceIfNeeded(previousSettings, nextSettings) const patch = { settings: nextSettings } void patchConfigOwner("ui", patch).catch((error) => log.error("Failed to patch ui settings", error)) } @@ -825,6 +854,7 @@ export const ConfigProvider: ParentComponent = (props) => { const unsubUi = storage.onConfigOwnerChanged("ui", (bucket) => { setUiConfigBucket(bucket as any) + syncDesktopEventTransportPreference((bucket as UiConfigBucket | undefined)?.settings) setIsLoaded(true) }) const unsubServer = storage.onConfigOwnerChanged("server", (bucket) => { From 843a47c98d6c8741f576d30cc2454c0695233718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 10 May 2026 14:56:36 +0200 Subject: [PATCH 06/15] fix(settings): keep Tauri transport fallback local Move the Tauri native transport fallback toggle out of the shared UI config and treat it as a true device-local preference. The setting now lives only in local state plus localStorage, so browser, Electron, and other Tauri clients do not inherit a host-specific transport choice through storage broadcasts. While updating the selector, remove the hidden forceBrowserEvents override so the visible Tauri fallback setting becomes the only shipped transport-selection path outside the benchmark build gates. Validation: npx tsc --noEmit --pretty -p packages/ui/tsconfig.json; npx tsc --noEmit --pretty -p packages/server/tsconfig.json; cargo test --no-run --- packages/ui/src/App.tsx | 4 +++ .../settings/appearance-settings-section.tsx | 4 +++ packages/ui/src/lib/event-transport.ts | 22 ++---------- packages/ui/src/lib/hooks/use-commands.ts | 4 +++ .../ui/src/lib/settings/behavior-registry.ts | 8 ++--- packages/ui/src/stores/preferences.tsx | 36 +++++++++---------- 6 files changed, 36 insertions(+), 42 deletions(-) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 44f0d23fd..804375f20 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -77,6 +77,8 @@ const App: Component = () => { const { t } = useI18n() const { preferences, + useTauriNativeEventTransport, + setUseTauriNativeEventTransport, serverSettings, recordWorkspaceLaunch, toggleShowThinkingBlocks, @@ -444,6 +446,8 @@ const App: Component = () => { const { commands: paletteCommands, executeCommand } = useCommands({ preferences, + useTauriNativeEventTransport, + setUseTauriNativeEventTransport, toggleAutoCleanupBlankSessions, toggleShowThinkingBlocks, toggleKeyboardShortcutHints, diff --git a/packages/ui/src/components/settings/appearance-settings-section.tsx b/packages/ui/src/components/settings/appearance-settings-section.tsx index 281366d67..cfb039817 100644 --- a/packages/ui/src/components/settings/appearance-settings-section.tsx +++ b/packages/ui/src/components/settings/appearance-settings-section.tsx @@ -17,6 +17,8 @@ export const AppearanceSettingsSection: Component = () => { const { themeMode, setThemeMode } = useTheme() const { preferences, + useTauriNativeEventTransport, + setUseTauriNativeEventTransport, updatePreferences, toggleShowThinkingBlocks, toggleKeyboardShortcutHints, @@ -36,6 +38,8 @@ export const AppearanceSettingsSection: Component = () => { const behaviorSettings = createMemo(() => getBehaviorSettings({ preferences, + useTauriNativeEventTransport, + setUseTauriNativeEventTransport, updatePreferences, toggleShowThinkingBlocks, toggleKeyboardShortcutHints, diff --git a/packages/ui/src/lib/event-transport.ts b/packages/ui/src/lib/event-transport.ts index ae0ad60a2..cb548e728 100644 --- a/packages/ui/src/lib/event-transport.ts +++ b/packages/ui/src/lib/event-transport.ts @@ -10,7 +10,6 @@ import { runtimeEnv } from "./runtime-env" import { connectTauriWorkspaceEvents } from "./native/desktop-events" const log = getLogger("sse") -const FORCE_BROWSER_TRANSPORT_STORAGE_KEY = "perf242-force-browser-events" export interface WorkspaceEventTransportCallbacks { onBatch: (events: WorkspaceEventPayload[]) => void @@ -37,26 +36,13 @@ async function connectBrowserWorkspaceEvents( } } -function shouldForceBrowserTransport(): boolean { - if (typeof window === "undefined") return false - try { - const params = new URLSearchParams(window.location.search) - if (params.get("forceBrowserEvents") === "1") { - return true - } - return window.localStorage?.getItem(FORCE_BROWSER_TRANSPORT_STORAGE_KEY) === "1" - } catch { - return false - } -} - export async function connectWorkspaceEvents( callbacks: WorkspaceEventTransportCallbacks, options?: DesktopEventTransportStartOptions, ): Promise { const nativeDesktopTransportEnabled = readUseTauriNativeEventTransportPreference() - if (runtimeEnv.host === "tauri" && nativeDesktopTransportEnabled && !shouldForceBrowserTransport()) { + if (runtimeEnv.host === "tauri" && nativeDesktopTransportEnabled) { try { const conn = await connectTauriWorkspaceEvents( callbacks, @@ -69,11 +55,7 @@ export async function connectWorkspaceEvents( log.warn("Failed to start native desktop event transport, falling back to browser EventSource", error) } } else if (runtimeEnv.host === "tauri") { - log.info( - nativeDesktopTransportEnabled - ? "Event transport: browser-eventsource forced by localStorage override" - : "Event transport: browser-eventsource forced by settings", - ) + log.info("Event transport: browser-eventsource forced by settings") } ;(globalThis as any).__TRANSPORT_TYPE = "browser-eventsource" diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index 160cd87ff..734ceb4e7 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -29,6 +29,8 @@ function splitKeywords(key: string): string[] { export interface UseCommandsOptions { preferences: Accessor + useTauriNativeEventTransport: Accessor + setUseTauriNativeEventTransport: (next: boolean) => void toggleShowThinkingBlocks: () => void toggleKeyboardShortcutHints: () => void toggleShowMessageTimeline: () => void @@ -419,6 +421,8 @@ export function useCommands(options: UseCommandsOptions) { registerBehaviorCommands((command) => commandRegistry.register(command), { preferences: options.preferences, + useTauriNativeEventTransport: options.useTauriNativeEventTransport, + setUseTauriNativeEventTransport: options.setUseTauriNativeEventTransport, toggleShowThinkingBlocks: options.toggleShowThinkingBlocks, toggleKeyboardShortcutHints: options.toggleKeyboardShortcutHints, toggleShowMessageTimeline: options.toggleShowMessageTimeline, diff --git a/packages/ui/src/lib/settings/behavior-registry.ts b/packages/ui/src/lib/settings/behavior-registry.ts index fd21ba51e..b28229a12 100644 --- a/packages/ui/src/lib/settings/behavior-registry.ts +++ b/packages/ui/src/lib/settings/behavior-registry.ts @@ -35,6 +35,8 @@ export type BehaviorSetting = BehaviorToggleSetting | BehaviorEnumSetting export type BehaviorRegistryActions = { preferences: Accessor + useTauriNativeEventTransport: Accessor + setUseTauriNativeEventTransport: (next: boolean) => void updatePreferences?: (updates: Partial) => void toggleShowThinkingBlocks: () => void toggleKeyboardShortcutHints: () => void @@ -287,11 +289,9 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS id: "behavior.tauriNativeEventTransport", titleKey: "settings.behavior.tauriNativeEventTransport.title", subtitleKey: "settings.behavior.tauriNativeEventTransport.subtitle", - get: (p: Preferences) => Boolean(p.useTauriNativeEventTransport ?? true), + get: () => actions.useTauriNativeEventTransport(), set: (next: boolean) => { - if (updatePreferences) { - updatePreferences({ useTauriNativeEventTransport: next }) - } + actions.setUseTauriNativeEventTransport(next) }, }, ] diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index e86dbf6dd..80882930d 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -1,6 +1,9 @@ import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js" import type { Accessor, ParentComponent } from "solid-js" -import { writeUseTauriNativeEventTransportPreference } from "../lib/desktop-event-transport-preference" +import { + readUseTauriNativeEventTransportPreference, + writeUseTauriNativeEventTransportPreference, +} from "../lib/desktop-event-transport-preference" import { storage, type OwnerBucket } from "../lib/storage" import type { RemoteServerProfile } from "../../../server/src/api-types" import { @@ -68,7 +71,6 @@ export interface UiSettings { showUsageMetrics: boolean autoCleanupBlankSessions: boolean keepUnseenSubagentIdleStatus: boolean - useTauriNativeEventTransport: boolean // OS notifications osNotificationsEnabled: boolean @@ -149,7 +151,6 @@ const defaultUiSettings: UiSettings = { showUsageMetrics: true, autoCleanupBlankSessions: true, keepUnseenSubagentIdleStatus: false, - useTauriNativeEventTransport: true, osNotificationsEnabled: false, osNotificationsAllowWhenVisible: false, @@ -191,8 +192,6 @@ function normalizeUiSettings(input?: Partial | null): UiSettings { autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultUiSettings.autoCleanupBlankSessions, keepUnseenSubagentIdleStatus: sanitized.keepUnseenSubagentIdleStatus ?? defaultUiSettings.keepUnseenSubagentIdleStatus, - useTauriNativeEventTransport: - sanitized.useTauriNativeEventTransport ?? defaultUiSettings.useTauriNativeEventTransport, osNotificationsEnabled: sanitized.osNotificationsEnabled ?? defaultUiSettings.osNotificationsEnabled, osNotificationsAllowWhenVisible: sanitized.osNotificationsAllowWhenVisible ?? defaultUiSettings.osNotificationsAllowWhenVisible, @@ -393,6 +392,9 @@ const [uiConfigBucket, setUiConfigBucket] = createSignal({}) const [serverConfigBucket, setServerConfigBucket] = createSignal({}) const [uiStateBucket, setUiStateBucket] = createSignal({}) const [isLoaded, setIsLoaded] = createSignal(false) +const [useTauriNativeEventTransport, setUseTauriNativeEventTransportSignal] = createSignal( + readUseTauriNativeEventTransportPreference(), +) const uiSettings = createMemo(() => normalizeUiSettings(uiConfigBucket().settings)) const themePreference = createMemo(() => uiConfigBucket().theme ?? "system") @@ -418,7 +420,6 @@ async function ensureLoaded(): Promise { setUiConfigBucket(uiCfg as any) setServerConfigBucket(srvCfg as any) setUiStateBucket(uiSt as any) - syncDesktopEventTransportPreference((uiCfg as UiConfigBucket | undefined)?.settings) setIsLoaded(true) }) .catch((error) => { @@ -442,17 +443,14 @@ async function patchConfigOwner(owner: string, patch: unknown) { if (owner === "server") setServerConfigBucket(updated as any) } -function syncDesktopEventTransportPreference(settings?: Partial | null) { - writeUseTauriNativeEventTransportPreference( - normalizeUiSettings(settings).useTauriNativeEventTransport, - ) -} - -function restartDesktopEventTransportPreferenceIfNeeded(previous: UiSettings, next: UiSettings) { - if (previous.useTauriNativeEventTransport === next.useTauriNativeEventTransport) { +function setUseTauriNativeEventTransport(enabled: boolean): void { + if (useTauriNativeEventTransport() === enabled) { return } + setUseTauriNativeEventTransportSignal(enabled) + writeUseTauriNativeEventTransportPreference(enabled) + void import("../lib/server-events") .then(({ serverEvents }) => { serverEvents.restart("desktop transport preference changed") @@ -470,10 +468,7 @@ async function patchStateOwner(owner: string, patch: unknown) { function updateUiSettings(updates: Partial) { const current = uiConfigBucket() - const previousSettings = normalizeUiSettings(current.settings) const nextSettings = normalizeUiSettings({ ...(current.settings ?? {}), ...updates }) - syncDesktopEventTransportPreference(nextSettings) - restartDesktopEventTransportPreferenceIfNeeded(previousSettings, nextSettings) const patch = { settings: nextSettings } void patchConfigOwner("ui", patch).catch((error) => log.error("Failed to patch ui settings", error)) } @@ -743,6 +738,8 @@ void ensureLoaded().catch((error: unknown) => { interface ConfigContextValue { isLoaded: Accessor preferences: typeof preferences + useTauriNativeEventTransport: typeof useTauriNativeEventTransport + setUseTauriNativeEventTransport: typeof setUseTauriNativeEventTransport updatePreferences: typeof updatePreferences themePreference: typeof themePreference setThemePreference: typeof setThemePreference @@ -801,6 +798,8 @@ const ConfigContext = createContext() const configContextValue: ConfigContextValue = { isLoaded, preferences, + useTauriNativeEventTransport, + setUseTauriNativeEventTransport, updatePreferences, themePreference, setThemePreference, @@ -854,7 +853,6 @@ export const ConfigProvider: ParentComponent = (props) => { const unsubUi = storage.onConfigOwnerChanged("ui", (bucket) => { setUiConfigBucket(bucket as any) - syncDesktopEventTransportPreference((bucket as UiConfigBucket | undefined)?.settings) setIsLoaded(true) }) const unsubServer = storage.onConfigOwnerChanged("server", (bucket) => { @@ -888,6 +886,8 @@ export function useConfig(): ConfigContextValue { export { preferences, + useTauriNativeEventTransport, + setUseTauriNativeEventTransport, uiState, serverSettings, recentFolders, From 654b424def481b8f5bd464839d0ef13db9d57958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 16 May 2026 12:04:55 +0200 Subject: [PATCH 07/15] fix(bench): align transport harness message loading The transport benchmark harness still called loadMessages with the old boolean force flag, which broke UI typecheck after the session API moved to an options object. This keeps the PR242 benchmark code compatible with the current Pagec_tauri integration baseline instead of leaving a validation-only regression on the stack. The change is limited to the benchmark harness and preserves the existing forced reload behavior by switching to the current { force: true } call shape. This keeps the fix scoped to the branch that introduced the transport bench so downstream integrations can cherry-pick a normal commit without rewriting history. Validation: npm run typecheck --workspace @codenomad/ui --- packages/ui/src/transport-bench.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/transport-bench.tsx b/packages/ui/src/transport-bench.tsx index 290e8a956..e5e4aa581 100644 --- a/packages/ui/src/transport-bench.tsx +++ b/packages/ui/src/transport-bench.tsx @@ -101,7 +101,7 @@ export default function TransportBench() { } await emitPerf242Log({ stage: "session-selected", instanceId, sessionId: targetSession.id, parentSessionId }) - await loadMessages(instanceId, targetSession.id, true) + await loadMessages(instanceId, targetSession.id, { force: true }) await emitPerf242Log({ stage: "messages-loaded", instanceId, sessionId: targetSession.id }) await waitForMs(500) From e915985e01b1ea040f9060ce1d88635f13a65fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 16 May 2026 17:25:56 +0200 Subject: [PATCH 08/15] refactor(tauri): remove temporary transport benchmark harness Drop the temporary in-app benchmark harness, perf logging endpoint, and batch metrics now that the native desktop transport has been validated on Windows and Linux. Keep the shipped PR focused on the transport implementation and the user-facing Tauri fallback setting rather than the instrumentation used during evaluation. The benchmark results are preserved in the PR description as historical validation, but the benchmark code itself no longer ships in the branch. Validation: npx tsc --noEmit --pretty -p packages/ui/tsconfig.json; npx tsc --noEmit --pretty -p packages/server/tsconfig.json; cargo test --no-run # Conflicts: # packages/ui/src/transport-bench.tsx --- packages/server/src/server/http-server.ts | 11 -- packages/ui/src/lib/event-transport.ts | 2 - packages/ui/src/lib/server-events.ts | 31 ----- packages/ui/src/main.tsx | 28 ---- packages/ui/src/transport-bench.tsx | 150 ---------------------- 5 files changed, 222 deletions(-) delete mode 100644 packages/ui/src/transport-bench.tsx diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index ad365cea7..faa53d3fd 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -87,8 +87,6 @@ export function createHttpServer(deps: HttpServerDeps) { const proxyLogger = deps.logger.child({ component: "proxy" }) const apiLogger = deps.logger.child({ component: "http" }) const sseLogger = deps.logger.child({ component: "sse" }) - const perfLogger = deps.logger.child({ component: "perf242" }) - const perf242BenchEnabled = process.env.PERF242_TRANSPORT_BENCH === "1" const sseClients = new Set<() => void>() const registerSseClient = (cleanup: () => void) => { @@ -270,15 +268,6 @@ export function createHttpServer(deps: HttpServerDeps) { reply.code(404).send({ message: "UI bundle missing" }) }) - if (perf242BenchEnabled) { - app.post("/api/perf-log", async (request, reply) => { - console.log("[perf242-route]", JSON.stringify(request.body ?? {})) - perfLogger.info(request.body ?? {}, "frontend perf log") - reply.code(204) - return null - }) - } - registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager }) registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) diff --git a/packages/ui/src/lib/event-transport.ts b/packages/ui/src/lib/event-transport.ts index cb548e728..3c13c3d4c 100644 --- a/packages/ui/src/lib/event-transport.ts +++ b/packages/ui/src/lib/event-transport.ts @@ -48,7 +48,6 @@ export async function connectWorkspaceEvents( callbacks, resolveDesktopEventTransportStartOptions(options), ) - ;(globalThis as any).__TRANSPORT_TYPE = "rust-native" log.info("Event transport: rust-native (desktop_event_transport)") return conn } catch (error) { @@ -58,7 +57,6 @@ export async function connectWorkspaceEvents( log.info("Event transport: browser-eventsource forced by settings") } - ;(globalThis as any).__TRANSPORT_TYPE = "browser-eventsource" log.info(`Event transport: browser-eventsource (host=${runtimeEnv.host})`) return connectBrowserWorkspaceEvents(callbacks) } diff --git a/packages/ui/src/lib/server-events.ts b/packages/ui/src/lib/server-events.ts index 0953e5886..52545b356 100644 --- a/packages/ui/src/lib/server-events.ts +++ b/packages/ui/src/lib/server-events.ts @@ -9,30 +9,6 @@ const RETRY_BASE_DELAY = 1000 const RETRY_MAX_DELAY = 10000 const log = getLogger("sse") -type Perf242ServerEventMetrics = { - batchesReceived: number - eventsReceived: number - maxBatchSize: number -} - -let perf242ServerEventMetrics: Perf242ServerEventMetrics = { - batchesReceived: 0, - eventsReceived: 0, - maxBatchSize: 0, -} - -export function resetPerf242ServerEventMetrics() { - perf242ServerEventMetrics = { - batchesReceived: 0, - eventsReceived: 0, - maxBatchSize: 0, - } -} - -export function getPerf242ServerEventMetrics(): Perf242ServerEventMetrics { - return { ...perf242ServerEventMetrics } -} - function logSse(message: string, context?: Record) { if (context) { log.info(message, context) @@ -144,13 +120,6 @@ class ServerEvents { return } - perf242ServerEventMetrics.batchesReceived += 1 - perf242ServerEventMetrics.eventsReceived += events.length - perf242ServerEventMetrics.maxBatchSize = Math.max( - perf242ServerEventMetrics.maxBatchSize, - events.length, - ) - logSse("event batch", { size: events.length }) solidBatch(() => { for (const event of events) { diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index 86a29fbac..4be1fc57e 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -1,6 +1,5 @@ import { render } from "solid-js/web" import App from "./App" -import TransportBench from "./transport-bench" import { ThemeProvider } from "./lib/theme" import { ConfigProvider } from "./stores/preferences" import { InstanceConfigProvider } from "./stores/instance-config" @@ -17,36 +16,10 @@ if (!root) { } const mount = root -const bootParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : new URLSearchParams() -const isPerf242TransportBenchBuild = import.meta.env.VITE_PERF242_TRANSPORT_BENCH === "1" -const isPerf242TransportBench = - isPerf242TransportBenchBuild - && bootParams.get("perf242TransportBench") === "1" if (typeof document !== "undefined") { document.documentElement.dataset.runtimeHost = runtimeEnv.host document.documentElement.dataset.runtimePlatform = runtimeEnv.platform - - if (isPerf242TransportBench) { - const payload = { - stage: "frontend-bootstrap", - host: runtimeEnv.host, - search: window.location.search, - } - - void fetch("/api/perf-log", { - method: "POST", - headers: { "content-type": "application/json" }, - credentials: "include", - body: JSON.stringify(payload), - keepalive: true, - }).catch(() => { - console.info("[perf242] frontend-bootstrap", { - host: runtimeEnv.host, - search: window.location.search, - }) - }) - } } async function bootstrap() { @@ -81,7 +54,6 @@ async function bootstrap() { - {isPerf242TransportBench ? : null} diff --git a/packages/ui/src/transport-bench.tsx b/packages/ui/src/transport-bench.tsx deleted file mode 100644 index e5e4aa581..000000000 --- a/packages/ui/src/transport-bench.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { onMount } from "solid-js" -import { runtimeEnv } from "./lib/runtime-env" -import { getPerf242ServerEventMetrics, resetPerf242ServerEventMetrics } from "./lib/server-events" -import { selectInstanceTab } from "./stores/app-tabs" -import { createInstance, instances } from "./stores/instances" -import { - fetchSessions, - getSessions, - loadMessages, - runShellCommand, - setActiveParentSession, - setActiveSession, -} from "./stores/sessions" - -const benchParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : new URLSearchParams() -const PERF242_BENCH_MODE = benchParams.get("mode") === "long" ? "long" : "short" -const PERF242_BENCH_FOLDER = import.meta.env.VITE_PERF242_BENCH_FOLDER || "D:\\CodeNomad" -const PERF242_BENCH_SESSION_ID = - import.meta.env.VITE_PERF242_BENCH_SESSION_ID - || "ses_21feb15b3ffeLz3uRModK4KKnG" -const PERF242_BENCH_BINARY = import.meta.env.VITE_PERF242_BENCH_BINARY || "opencode" -const PERF242_SHORT_COMMAND = `node -e "for (let i = 1; i <= 400; i += 1) console.log('line ' + i)"` -const PERF242_LONG_COMMAND = `powershell -NoProfile -Command Start-Sleep -Seconds 70` -const PERF242_BENCH_COMMAND = - PERF242_BENCH_MODE === "long" ? PERF242_LONG_COMMAND : PERF242_SHORT_COMMAND - -let perf242TransportBenchStarted = false - -function waitForMs(delayMs: number): Promise { - return new Promise((resolve) => window.setTimeout(resolve, delayMs)) -} - -async function waitForCondition(predicate: () => boolean, timeoutMs = 15000): Promise { - const start = performance.now() - while (performance.now() - start < timeoutMs) { - if (predicate()) return true - await waitForMs(100) - } - return predicate() -} - -async function emitPerf242Log(payload: Record): Promise { - console.info("[perf242]", payload) - try { - await fetch("/api/perf-log", { - method: "POST", - headers: { "content-type": "application/json" }, - credentials: "include", - body: JSON.stringify(payload), - keepalive: true, - }) - } catch (error) { - console.warn("[perf242] failed to emit server log", { host: runtimeEnv.host, error }) - } -} - -export default function TransportBench() { - onMount(() => { - if (perf242TransportBenchStarted) return - perf242TransportBenchStarted = true - - void (async () => { - await emitPerf242Log({ - stage: "bench-init", - host: runtimeEnv.host, - folder: PERF242_BENCH_FOLDER, - sessionId: PERF242_BENCH_SESSION_ID, - }) - - if (!PERF242_BENCH_SESSION_ID) { - await emitPerf242Log({ stage: "bench-skipped", reason: "missing-session-id" }) - return - } - - let instanceId = Array.from(instances().values()).find((instance) => instance.folder === PERF242_BENCH_FOLDER)?.id - if (!instanceId) { - await emitPerf242Log({ stage: "create-instance", folder: PERF242_BENCH_FOLDER, binary: PERF242_BENCH_BINARY }) - instanceId = await createInstance(PERF242_BENCH_FOLDER, PERF242_BENCH_BINARY) - } - - selectInstanceTab(instanceId) - await emitPerf242Log({ stage: "instance-ready", instanceId }) - await fetchSessions(instanceId) - await emitPerf242Log({ stage: "sessions-fetched", instanceId, sessionCount: getSessions(instanceId).length }) - - const targetSession = getSessions(instanceId).find((session) => session.id === PERF242_BENCH_SESSION_ID) - if (!targetSession) { - await emitPerf242Log({ - stage: "bench-error", - reason: "session-not-found", - instanceId, - sessionId: PERF242_BENCH_SESSION_ID, - }) - return - } - - const parentSessionId = targetSession.parentId ?? targetSession.id - setActiveParentSession(instanceId, parentSessionId) - if (targetSession.id !== parentSessionId) { - setActiveSession(instanceId, targetSession.id) - } - - await emitPerf242Log({ stage: "session-selected", instanceId, sessionId: targetSession.id, parentSessionId }) - await loadMessages(instanceId, targetSession.id, { force: true }) - await emitPerf242Log({ stage: "messages-loaded", instanceId, sessionId: targetSession.id }) - await waitForMs(500) - - resetPerf242ServerEventMetrics() - await emitPerf242Log({ - stage: "start", - folder: PERF242_BENCH_FOLDER, - sessionId: targetSession.id, - transportType: (globalThis as any).__TRANSPORT_TYPE ?? "unknown", - command: PERF242_BENCH_COMMAND, - }) - - const startedAt = performance.now() - await runShellCommand(instanceId, targetSession.id, PERF242_BENCH_COMMAND) - - const sawWorking = await waitForCondition(() => { - const session = getSessions(instanceId).find((value) => value.id === targetSession.id) - return session?.status === "working" - }, 10000) - - const reachedIdle = await waitForCondition(() => { - const session = getSessions(instanceId).find((value) => value.id === targetSession.id) - return sawWorking ? session?.status === "idle" : false - }, PERF242_BENCH_MODE === "long" ? 180000 : 120000) - - await emitPerf242Log({ - stage: reachedIdle ? "complete" : "timeout", - sessionId: targetSession.id, - instanceId, - transportType: (globalThis as any).__TRANSPORT_TYPE ?? "unknown", - elapsedMs: Math.round((performance.now() - startedAt) * 10) / 10, - sawWorking, - reachedIdle, - metrics: getPerf242ServerEventMetrics(), - }) - })().catch(async (error) => { - await emitPerf242Log({ - stage: "error", - error: error instanceof Error ? error.stack ?? error.message : String(error), - }) - throw error - }) - }) - - return null -} From 8e50a8ea5df481300a96a0c7c4b68b37b216b4a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 16 May 2026 18:00:24 +0200 Subject: [PATCH 09/15] fix(ui): ignore stale event stream connect failures Guard the async event-stream connect failure path with the same generation check used by the success callbacks so an older rejected connection attempt cannot tear down a newer healthy stream. This keeps Tauri restarts and other reconnect races from disconnecting the active transport after the local desktop transport preference changes or another connection attempt wins first. Validation: npx tsc --noEmit --pretty -p packages/ui/tsconfig.json; npx tsc --noEmit --pretty -p packages/server/tsconfig.json --- packages/ui/src/lib/server-events.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ui/src/lib/server-events.ts b/packages/ui/src/lib/server-events.ts index 52545b356..8f011011d 100644 --- a/packages/ui/src/lib/server-events.ts +++ b/packages/ui/src/lib/server-events.ts @@ -76,6 +76,10 @@ class ServerEvents { this.connection = connection } catch (error) { + if (generation !== this.connectGeneration) { + return + } + logSse("Events stream failed to connect, scheduling reconnect", { error: error instanceof Error ? error.message : String(error), }) From a1db48d4732a142984f9e170a8889b146f7ffc21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 5 Jun 2026 18:35:26 +0200 Subject: [PATCH 10/15] fix: task-078 restore UI typecheck on integrated batch Fix the current upstream/dev baseline type mismatches exposed during the integrated PR batch validation. Align the session SDK imports with the v2 surface and narrow the git status workspace payload typing so the merged batch typechecks cleanly without changing feature behavior. --- packages/ui/src/stores/session-api.ts | 4 ++-- packages/ui/src/types/session.ts | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 36609380f..3c12aa33d 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -7,7 +7,7 @@ import { type SessionStatus, } from "../types/session" import type { Message } from "../types/message" -import type { FileDiff } from "@opencode-ai/sdk/v2/client" +import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" import { instances } from "./instances" import { preferences, setAgentModelPreference } from "./preferences" @@ -74,7 +74,7 @@ async function loadSessionDiff(instanceId: string, sessionId: string, force = fa const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) try { - const diffs = await requestData( + const diffs = await requestData( client.session.diff({ sessionID: sessionId }), "session.diff", ) diff --git a/packages/ui/src/types/session.ts b/packages/ui/src/types/session.ts index 58cfa1e9d..1c65a8310 100644 --- a/packages/ui/src/types/session.ts +++ b/packages/ui/src/types/session.ts @@ -3,8 +3,9 @@ import type { Agent as SDKAgent, Provider as SDKProvider, Model as SDKModel, -} from "@opencode-ai/sdk" -import type { SessionStatus as SDKSessionStatus, FileDiff } from "@opencode-ai/sdk/v2/client" + SessionStatus as SDKSessionStatus, + SnapshotFileDiff, +} from "@opencode-ai/sdk/v2" // Export SDK types for external use export type { @@ -12,7 +13,7 @@ export type { Agent as SDKAgent, Provider as SDKProvider, Model as SDKModel -} from "@opencode-ai/sdk" +} from "@opencode-ai/sdk/v2" export type SessionStatus = "idle" | "working" | "compacting" @@ -62,7 +63,7 @@ export function mapSdkSessionRetry(status: SDKSessionStatus | null | undefined): // Our client-specific Session interface extending SDK Session export interface Session - extends Omit { + extends Omit { instanceId: string // Client-specific field parentId: string | null // Client-specific field (override parentID) agent: string // Client-specific field @@ -76,12 +77,12 @@ export interface Session status: SessionStatus // Single source of truth for session status retry?: SessionRetryState | null // Retry metadata for transient backoff states idleSince?: number | null // Timestamp set when work finished but the session has not been viewed yet - diff?: FileDiff[] // Session-level file diffs (hydrated via session.diff) + diff?: SnapshotFileDiff[] // Session-level file diffs (hydrated via session.diff) } // Adapter function to convert SDK Session to client Session export function createClientSession( - sdkSession: import("@opencode-ai/sdk").Session, + sdkSession: SDKSession, instanceId: string, agent: string = "", model: { providerId: string; modelId: string } = { providerId: "", modelId: "" }, From 1f79720ca99c4564a381937638cb63170e051af4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 6 Jun 2026 11:56:03 +0200 Subject: [PATCH 11/15] fix: task-083 address PR242 transport review feedback Prioritize packaged server entrypoint candidates before the workspace fallback in production desktop launches, compare native event transport starts without the generated connection id, and percent-encode unsafe cookie-value bytes before building Cookie headers. Adds focused Rust coverage for production candidate ordering, idempotent transport starts with fresh connection ids, material stream changes, and Cookie header delimiter/control-byte encoding. Validated with the Tauri Rust test suite. --- .../tauri-app/src-tauri/src/cli_manager.rs | 64 +++++++++++++++---- .../src-tauri/src/desktop_event_transport.rs | 15 ++++- .../src/desktop_event_transport/stream.rs | 49 +++++++++++++- .../src/desktop_event_transport/tests.rs | 31 +++++++++ 4 files changed, 143 insertions(+), 16 deletions(-) diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 2ae45addc..4cd79d06f 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -1,5 +1,5 @@ -use crate::managed_node::resolve_bundled_node_binary; use crate::desktop_event_transport::DesktopEventStreamConfig; +use crate::managed_node::resolve_bundled_node_binary; use dirs::home_dir; use parking_lot::Mutex; use regex::Regex; @@ -1287,25 +1287,36 @@ fn resolve_dev_entry(_app: &AppHandle) -> Option { fn resolve_prod_entry(_app: &AppHandle) -> Option { let base = workspace_root(); - let mut candidates = vec![base.as_ref().map(|p| p.join("packages/server/dist/bin.js"))]; + let exe_dir = std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(|dir| dir.to_path_buf())); - if let Ok(exe) = std::env::current_exe() { - if let Some(dir) = exe.parent() { - candidates.push(Some(dir.join("resources/server/dist/bin.js"))); + first_existing(prod_entry_candidates(exe_dir, base)) +} - let resources = dir.join("../Resources"); - candidates.push(Some(resources.join("server/dist/bin.js"))); - candidates.push(Some(resources.join("resources/server/dist/bin.js"))); +fn prod_entry_candidates( + exe_dir: Option, + workspace: Option, +) -> Vec> { + let mut candidates = Vec::new(); - let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")]; - for root in linux_resource_roots { - candidates.push(Some(root.join("server/dist/bin.js"))); - candidates.push(Some(root.join("resources/server/dist/bin.js"))); - } + if let Some(dir) = exe_dir { + candidates.push(Some(dir.join("resources/server/dist/bin.js"))); + + let resources = dir.join("../Resources"); + candidates.push(Some(resources.join("server/dist/bin.js"))); + candidates.push(Some(resources.join("resources/server/dist/bin.js"))); + + let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")]; + for root in linux_resource_roots { + candidates.push(Some(root.join("server/dist/bin.js"))); + candidates.push(Some(root.join("resources/server/dist/bin.js"))); } } - first_existing(candidates) + candidates.push(workspace.map(|p| p.join("packages/server/dist/bin.js"))); + + candidates } fn build_shell_command_string( @@ -1421,3 +1432,28 @@ fn normalize_path(path: PathBuf) -> String { rendered } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prod_entry_candidates_prefer_exe_relative_before_workspace_fallback() { + let exe_dir = PathBuf::from("/opt/codenomad/bin"); + let workspace = PathBuf::from("/workspace/codenomad"); + + let candidates = prod_entry_candidates(Some(exe_dir.clone()), Some(workspace.clone())) + .into_iter() + .flatten() + .collect::>(); + + assert_eq!( + candidates.first(), + Some(&exe_dir.join("resources/server/dist/bin.js")) + ); + assert_eq!( + candidates.last(), + Some(&workspace.join("packages/server/dist/bin.js")) + ); + } +} diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport.rs index f9b0240c8..372ca37c6 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport.rs @@ -111,6 +111,15 @@ impl DesktopEventTransportConfig { reconnect: ResolvedDesktopEventReconnectPolicy::resolve(request.reconnect.as_ref()), } } + + fn is_equivalent_start(&self, other: &Self) -> bool { + self.reconnect == other.reconnect + && self.stream.base_url == other.stream.base_url + && self.stream.events_url == other.stream.events_url + && self.stream.client_id == other.stream.client_id + && self.stream.cookie_name == other.stream.cookie_name + && self.stream.session_cookie == other.stream.session_cookie + } } #[derive(Clone, Serialize)] @@ -235,7 +244,11 @@ impl DesktopEventTransportManager { let transport_config = DesktopEventTransportConfig::new(stream_config, &request); let mut state = self.state.lock(); - if state.config.as_ref() == Some(&transport_config) { + if state + .config + .as_ref() + .is_some_and(|config| config.is_equivalent_start(&transport_config)) + { if let Some(stop) = &state.stop { if !stop.load(Ordering::SeqCst) { return DesktopEventsStartResult { diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs index ef6c851f9..33737f9c8 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs @@ -87,7 +87,35 @@ fn attach_session_cookie_value( return request; }; - request.header("Cookie", format!("{}={}", cookie_name, session_cookie)) + request.header( + "Cookie", + format!( + "{}={}", + cookie_name, + encode_cookie_header_value(session_cookie) + ), + ) +} + +fn encode_cookie_header_value(value: &str) -> String { + let mut encoded = String::new(); + + for byte in value.bytes() { + if is_cookie_header_value_byte(byte) { + encoded.push(byte as char); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + + encoded +} + +fn is_cookie_header_value_byte(byte: u8) -> bool { + matches!( + byte, + b'!' | b'#'..=b'+' | b'-'..=b':' | b'<'..=b'[' | b']'..=b'~' + ) } fn read_session_cookie_from_webview( @@ -275,4 +303,23 @@ mod tests { Some("codenomad_session=cookie-value") ); } + + #[test] + fn session_cookie_value_is_encoded_before_header_attachment() { + let request = attach_session_cookie_value( + Client::new().post("http://localhost/api/client-connections/pong"), + "codenomad_session", + Some("safe;\r\nInjected=bad value"), + ) + .build() + .expect("request should build"); + + assert_eq!( + request + .headers() + .get("Cookie") + .and_then(|value| value.to_str().ok()), + Some("codenomad_session=safe%3B%0D%0AInjected=bad%20value") + ); + } } diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs index d9ba344b9..f2440a201 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs @@ -5,6 +5,17 @@ fn fresh_stats() -> DesktopEventTransportStats { DesktopEventTransportStats::default() } +fn stream_config(connection_id: &str) -> DesktopEventStreamConfig { + DesktopEventStreamConfig { + base_url: "http://127.0.0.1:4096".to_string(), + events_url: "http://127.0.0.1:4096/api/events".to_string(), + client_id: "tauri-test".to_string(), + connection_id: connection_id.to_string(), + cookie_name: "codenomad_session".to_string(), + session_cookie: Some("cookie-value".to_string()), + } +} + fn delta_event(delta: &str) -> Value { json!({ "type": "instance.event", @@ -341,3 +352,23 @@ fn direct_snapshot_replaces_trailing_direct_deltas_for_same_part() { Some("Hello world") ); } + +#[test] +fn equivalent_transport_start_ignores_fresh_connection_id() { + let request = DesktopEventsStartRequest::default(); + let first = DesktopEventTransportConfig::new(stream_config("conn-1"), &request); + let second = DesktopEventTransportConfig::new(stream_config("conn-2"), &request); + + assert!(first.is_equivalent_start(&second)); +} + +#[test] +fn equivalent_transport_start_detects_material_stream_changes() { + let request = DesktopEventsStartRequest::default(); + let first = DesktopEventTransportConfig::new(stream_config("conn-1"), &request); + let mut changed_stream = stream_config("conn-2"); + changed_stream.session_cookie = Some("other-cookie".to_string()); + let second = DesktopEventTransportConfig::new(changed_stream, &request); + + assert!(!first.is_equivalent_start(&second)); +} From 30076576e83c77087447eee5c3d5e2c822abf192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 6 Jun 2026 15:14:55 +0200 Subject: [PATCH 12/15] fix: task-084 address Greptile transport rerun feedback Ensure native desktop terminal status handling raises onError only once per terminal sequence, including stopped events after terminal failures. Trim leading fragment markers from CODENOMAD_UI_LAUNCH_QUERY before appending launch query values, and add focused TypeScript and Rust tests for the regressions. --- .../tauri-app/src-tauri/src/cli_manager.rs | 37 ++++++++++++++++++- .../ui/src/lib/native/desktop-events.test.ts | 19 ++++++++++ packages/ui/src/lib/native/desktop-events.ts | 18 ++++++--- 3 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/lib/native/desktop-events.test.ts diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 4cd79d06f..00675684b 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -213,10 +213,18 @@ fn augment_launch_url(base_url: &str) -> String { }; if base_url.contains('?') { - return format!("{}&{}", base_url, launch_query.trim_start_matches('?')); + return format!( + "{}&{}", + base_url, + launch_query.trim_start_matches(['?', '#']) + ); } - format!("{}?{}", base_url, launch_query.trim_start_matches('?')) + format!( + "{}?{}", + base_url, + launch_query.trim_start_matches(['?', '#']) + ) } fn extract_cookie_value(set_cookie: &str, name: &str) -> Option { @@ -1436,6 +1444,9 @@ fn normalize_path(path: PathBuf) -> String { #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex as StdMutex; + + static ENV_LOCK: StdMutex<()> = StdMutex::new(()); #[test] fn prod_entry_candidates_prefer_exe_relative_before_workspace_fallback() { @@ -1456,4 +1467,26 @@ mod tests { Some(&workspace.join("packages/server/dist/bin.js")) ); } + + #[test] + fn augment_launch_url_trims_leading_fragment_marker() { + let _guard = ENV_LOCK.lock().expect("env lock poisoned"); + std::env::set_var("CODENOMAD_UI_LAUNCH_QUERY", "#debug=true"); + + let augmented = augment_launch_url("http://127.0.0.1:3000"); + + std::env::remove_var("CODENOMAD_UI_LAUNCH_QUERY"); + assert_eq!(augmented, "http://127.0.0.1:3000?debug=true"); + } + + #[test] + fn augment_launch_url_trims_fragment_marker_when_query_exists() { + let _guard = ENV_LOCK.lock().expect("env lock poisoned"); + std::env::set_var("CODENOMAD_UI_LAUNCH_QUERY", "#debug=true"); + + let augmented = augment_launch_url("http://127.0.0.1:3000?existing=true"); + + std::env::remove_var("CODENOMAD_UI_LAUNCH_QUERY"); + assert_eq!(augmented, "http://127.0.0.1:3000?existing=true&debug=true"); + } } diff --git a/packages/ui/src/lib/native/desktop-events.test.ts b/packages/ui/src/lib/native/desktop-events.test.ts new file mode 100644 index 000000000..c414b04b4 --- /dev/null +++ b/packages/ui/src/lib/native/desktop-events.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { createTerminalErrorNotifier } from "./desktop-events.ts" + +describe("createTerminalErrorNotifier", () => { + it("calls onError once for repeated terminal notifications", () => { + let errors = 0 + const notifyTerminalError = createTerminalErrorNotifier({ + onError: () => { + errors += 1 + }, + }) + + notifyTerminalError() + notifyTerminalError() + + assert.equal(errors, 1) + }) +}) diff --git a/packages/ui/src/lib/native/desktop-events.ts b/packages/ui/src/lib/native/desktop-events.ts index 875a24e16..01c9af6ea 100644 --- a/packages/ui/src/lib/native/desktop-events.ts +++ b/packages/ui/src/lib/native/desktop-events.ts @@ -18,6 +18,15 @@ interface WorkspaceEventBatchPayload { events: WorkspaceEventPayload[] } +export function createTerminalErrorNotifier(callbacks: Pick) { + let raised = false + return () => { + if (raised) return + raised = true + callbacks.onError?.() + } +} + export async function connectTauriWorkspaceEvents( callbacks: WorkspaceEventTransportCallbacks, options: DesktopEventTransportStartOptions, @@ -25,7 +34,7 @@ export async function connectTauriWorkspaceEvents( let closed = false let opened = false let expectedGeneration: number | null = null - let terminalErrorRaised = false + const notifyTerminalError = createTerminalErrorNotifier(callbacks) const pendingBatches: WorkspaceEventBatchPayload[] = [] const pendingStatuses: DesktopEventTransportStatusPayload[] = [] @@ -79,13 +88,12 @@ export async function connectTauriWorkspaceEvents( } if (payload.state === "stopped") { - callbacks.onError?.() + notifyTerminalError() return } - if (payload.terminal && !terminalErrorRaised) { - terminalErrorRaised = true - callbacks.onError?.() + if (payload.terminal) { + notifyTerminalError() } } From ce77488d2f715cec07b199ecbd7b3a28a948b77a Mon Sep 17 00:00:00 2001 From: Dark <107168337+JDis03@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:20:10 -0400 Subject: [PATCH 13/15] fix(ui): add retry logic to SSE pong to improve connection resilience (#519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem When the client receives a ping from the server, it responds with a pong via HTTP POST. On unstable networks (mobile, WiFi with poor signal, network switches), this POST can fail in multiple ways: - **Hung requests**: `fetch()` never rejects, blocking retries indefinitely - **Network disconnection**: `Failed to fetch` - **Brief disconnections**: transient network errors Previously, a single missed pong would cause the server to close the SSE connection after 45s, leaving message responses stuck in queue until the next message triggered a reconnection. ## Solution Three improvements to make the pong POST resilient: ### 1. Request timeout (10s) Each pong POST is now bounded with a 10s `AbortSignal` timeout. Hung requests fail fast instead of blocking indefinitely, allowing retries to start before the server's stale connection sweep. ### 2. Selective retry with `isRetryableError()` Only retries transient failures where recovery is possible: - `AbortError` / `TimeoutError` (hung or timed-out requests) - `Failed to fetch` (network disconnected) - `NetworkError` (browser network errors) Non-retryable errors like `404 Client connection not found` (permanently closed connection) fail immediately instead of wasting retry attempts. ### 3. Exponential backoff (3 attempts, 100ms → 2000ms) Handles burst failures gracefully without hammering the server. ## Changes - **`packages/ui/src/lib/retry-utils.ts`** (new): Reusable retry utility with `timeoutMs` and `shouldRetry` predicate support - **`packages/ui/src/lib/server-events.ts`**: Updated pong handler to use bounded timeout + selective retry ## Verification - Build passes: `npm run build:ui` ✅ - Manually verified in production logs: retries now visible as `Pong failed after retries` instead of single immediate failure - SSE monitor log at `~/.codenomad/logs/sse-monitor.log` shows `PONG_OK` / `PONG_FAIL` / `STALE` events for ongoing monitoring --- packages/ui/src/lib/api-client.ts | 10 +++-- packages/ui/src/lib/retry-utils.ts | 64 ++++++++++++++++++++++++++++ packages/ui/src/lib/server-events.ts | 24 +++++++---- 3 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 packages/ui/src/lib/retry-utils.ts diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index dfc512ea0..353360ea7 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -520,11 +520,15 @@ export const serverApi = { body: JSON.stringify({ ...identity, enabled }), }) }, - sendClientConnectionPong(payload: { clientId: string; connectionId: string; pingTs?: number }): Promise { - return request("/api/client-connections/pong", { + sendClientConnectionPong(payload: { clientId: string; connectionId: string; pingTs?: number }, signal?: AbortSignal): Promise { + const init: RequestInit = { method: "POST", body: JSON.stringify(payload), - }) + } + if (signal) { + init.signal = signal + } + return request("/api/client-connections/pong", init) }, fetchBackgroundProcessOutput( instanceId: string, diff --git a/packages/ui/src/lib/retry-utils.ts b/packages/ui/src/lib/retry-utils.ts new file mode 100644 index 000000000..d644cbe98 --- /dev/null +++ b/packages/ui/src/lib/retry-utils.ts @@ -0,0 +1,64 @@ +interface RetryOptions { + maxAttempts?: number + initialDelayMs?: number + maxDelayMs?: number + backoffMultiplier?: number + timeoutMs?: number + shouldRetry?: (error: Error, attempt: number) => boolean +} + +export async function retryWithBackoff( + fn: (signal?: AbortSignal) => Promise, + options: RetryOptions = {}, +): Promise { + const { + maxAttempts = 3, + initialDelayMs = 100, + maxDelayMs = 5000, + backoffMultiplier = 2, + timeoutMs, + shouldRetry = () => true, + } = options + + let lastError: Error | null = null + let delayMs = initialDelayMs + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + if (timeoutMs) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const result = await fn(controller.signal) + clearTimeout(timer) + return result + } catch (error) { + clearTimeout(timer) + throw error + } + } + + return await fn() + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + lastError = err + + if (attempt < maxAttempts && shouldRetry(err, attempt)) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + delayMs = Math.min(delayMs * backoffMultiplier, maxDelayMs) + } else { + throw err + } + } + } + + throw lastError || new Error("Failed after retries") +} + +export function isRetryableError(error: Error): boolean { + if (error.name === "AbortError" || error.name === "TimeoutError") return true + if (error.message.includes("Failed to fetch")) return true + if (error.message.includes("NetworkError")) return true + if (error.message.includes("timeout")) return true + return false +} diff --git a/packages/ui/src/lib/server-events.ts b/packages/ui/src/lib/server-events.ts index 833e6c2aa..0791d675e 100644 --- a/packages/ui/src/lib/server-events.ts +++ b/packages/ui/src/lib/server-events.ts @@ -2,6 +2,7 @@ import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../server/ import { serverApi } from "./api-client" import { getClientIdentity } from "./client-identity" import { getLogger } from "./logger" +import { retryWithBackoff, isRetryableError } from "./retry-utils" const RETRY_BASE_DELAY = 1000 const RETRY_MAX_DELAY = 10000 @@ -39,14 +40,21 @@ class ServerEvents { (event) => this.dispatch(event), () => this.scheduleReconnect(), (payload) => { - void serverApi - .sendClientConnectionPong({ - ...getClientIdentity(), - pingTs: payload.ts, - }) - .catch((error) => { - log.error("Failed to send client connection pong", error) - }) + const identity = getClientIdentity() + const pongPayload = { ...identity, pingTs: payload.ts } + + void retryWithBackoff( + (signal) => serverApi.sendClientConnectionPong(pongPayload, signal), + { + maxAttempts: 3, + initialDelayMs: 100, + maxDelayMs: 2000, + timeoutMs: 10000, + shouldRetry: (error) => isRetryableError(error), + }, + ).catch((error) => { + log.warn("Failed to send client connection pong after retries", error) + }) }, ) this.source.onopen = () => { From d9ff1e03cf423fc623878c0b226c19e0ad30612d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 8 Jun 2026 18:20:31 +0200 Subject: [PATCH 14/15] revert: remove tracked NomadWorks task artifacts (#531) ## Summary This PR removes tracked NomadWorks/OpenCode/task-tracking artifacts that were accidentally committed to the repository and now interfere with local NomadWorks usage for other contributors. NomadWorks generates local workflow state such as `tasks/`, SCR registries, and codemaps as needed. Keeping those generated artifacts tracked in the shared repository creates stale state, false active tasks, and confusing/broken NomadWorks behavior for other users who initialize or run the workflow locally. ## Problematic commits / artifacts The cleanup addresses artifacts introduced by the following Shantur-authored commits: - `e708c565ef6221dcded8a3da81c73e4755824f2d` - `docs(wake-lock): record wake-lock change workflow` - Introduced NomadWorks wake-lock SCR/task artifacts: - `docs/scrs/SCR-2026-04-21-001-wake-lock-system-sleep-only.md` - `tasks/current.md` - `tasks/discussions/DISCUSSION-001-wake-lock-behavior-change-for-macos-sleep-vs-screen-lock.md` - `tasks/todo/055-wake-lock-investigation.md` - `tasks/todo/056-wake-lock-behavior-change.md` - `tasks/todo/057-implement-system-sleep-only-wake-lock.md` - `a337c19b63198f076836644c9a9a4d29e8a47c18` - `Init nomadworks` - Introduced tracked NomadWorks/OpenCode initialization artifacts: - `.nomadworks/**` - root `codemap.yml` - `docs/scrs/current.md` - `docs/scrs/done.md` - `tasks/done.md` - `.opencode/opencode.jsonc` - `.opencode/package-lock.json` - The removed `.opencode/opencode.jsonc` only contained the OpenCode schema plus a commented NomadWorks plugin entry: - `"$schema": "https://opencode.ai/config.json"` - `// "@neuralnomads/nomadworks@0.1.0-rc.10"` - The removed `.opencode/package-lock.json` was an orphan lockfile with no tracked `.opencode/package.json`. It pinned old OpenCode plugin/runtime packages, notably: - `@opencode-ai/plugin@1.14.24` - `@opencode-ai/sdk@1.14.24` - Those pinned OpenCode versions are stale for the current CodeNomad/OpenCode integration, so keeping this lockfile is misleading and can imply an unsupported local plugin setup. - `b06b8104a519e7c3a798651c1980f3fc59284786` - `Add task specifications for Phase 5 advanced input features` - Introduced tracked task-planning artifacts including: - `tasks/todo/015-keyboard-shortcuts.md` - `tasks/todo/020-command-palette.md` - `tasks/todo/021-file-attachments.md` - `tasks/todo/022-long-paste-handling.md` - `tasks/todo/023-symbol-attachments.md` - `tasks/todo/024-agent-attachments.md` - `tasks/todo/025-image-clipboard-support.md` - `0a3ac6cbf24b4eaa19bba972ff691f7a6ff12275` - `docs: add theme overhaul task series` - Introduced tracked task-planning artifacts `tasks/todo/041-*` through `tasks/todo/045-*`. - `cb56eed4f94f6a656f78965386bb4ac5e3ab0d6f` - `docs: add tailwind refactor follow-up tasks` - Introduced tracked task-planning artifacts `tasks/todo/046-*` through `tasks/todo/048-*`. - `7267baf23d34e71e32e193540e08d718167415ac` - `docs: queue remaining style cleanup tasks` - Introduced tracked task-planning artifacts `tasks/todo/049-*` through `tasks/todo/054-*`. Some older mixed product commits also added `tasks/done/*` or `PROGRESS.md` while introducing real product code. Those commits should not be fully reverted because that would remove product functionality, so this PR removes the stale tracking files directly in a final cleanup commit. ## Changes - Reverts the previous partial NomadWorks cleanup so the full original faulty state can be reverted cleanly. - Fully reverts the original `Init nomadworks` commit. - Fully reverts the wake-lock NomadWorks workflow artifact commit. - Removes remaining stale historical progress/task-tracking files that are not used by CodeNomad runtime, builds, or GitHub workflows. - Removes stale `.opencode` root config/lock artifacts introduced by the NomadWorks initialization because the config only references a commented NomadWorks plugin and the lockfile pins unsupported old OpenCode packages without a matching package manifest. ## Validation - `git status --short` is clean. - `git diff --check upstream/dev..HEAD` passes with no output. - Final diff only removes tracked NomadWorks/OpenCode/task/progress artifacts; no runtime source files are changed. --- .opencode/opencode.jsonc | 6 - .opencode/package-lock.json | 376 -------- PROGRESS.md | 149 --- ...6-04-21-001-wake-lock-system-sleep-only.md | 79 -- tasks/README.md | 177 ---- tasks/current.md | 19 - ...r-change-for-macos-sleep-vs-screen-lock.md | 76 -- tasks/done/001-project-setup.md | 262 ------ tasks/done/002-empty-state-ui.md | 280 ------ tasks/done/003-process-manager.md | 430 --------- tasks/done/004-sdk-integration.md | 504 ----------- tasks/done/005-session-picker-modal.md | 333 ------- tasks/done/006-instance-session-tabs.md | 591 ------------ tasks/done/007-message-display.md | 812 ----------------- tasks/done/008-sse-integration.md | 445 --------- tasks/done/009-prompt-input-basic.md | 520 ----------- tasks/done/010-tool-call-rendering.md | 603 ------------- tasks/done/011-agent-model-selectors.md | 527 ----------- tasks/done/012-markdown-rendering.md | 417 --------- tasks/done/013-logs-tab.md | 479 ---------- tasks/done/015-keyboard-shortcuts.md | 849 ------------------ tasks/done/020-command-palette.md | 178 ---- tasks/done/021-file-attachments.md | 40 - tasks/done/022-long-paste-handling.md | 29 - tasks/done/024-agent-attachments.md | 31 - tasks/done/025-image-clipboard-support.md | 31 - tasks/done/041-tailwind-theme-hooks.md | 35 - tasks/done/042-style-token-scaffolding.md | 42 - tasks/done/043-color-variable-migration.md | 36 - tasks/done/044-typography-baseline.md | 34 - .../045-message-item-tailwind-refactor.md | 35 - tasks/done/046-prompt-input-refactor.md | 34 - tasks/done/047-tabs-tailwind-refactor.md | 35 - tasks/done/048-message-stream-refactor.md | 35 - tasks/done/049-unified-picker-refactor.md | 33 - tasks/done/050-selector-popover-refactor.md | 34 - tasks/done/051-command-palette-refactor.md | 34 - tasks/done/052-folder-info-panels-refactor.md | 34 - tasks/done/053-markdown-style-refactor.md | 34 - tasks/done/054-attachment-chip-refactor.md | 34 - tasks/todo/023-symbol-attachments.md | 37 - tasks/todo/055-wake-lock-investigation.md | 54 -- tasks/todo/056-wake-lock-behavior-change.md | 76 -- ...7-implement-system-sleep-only-wake-lock.md | 64 -- 44 files changed, 8963 deletions(-) delete mode 100644 .opencode/opencode.jsonc delete mode 100644 .opencode/package-lock.json delete mode 100644 PROGRESS.md delete mode 100644 docs/scrs/SCR-2026-04-21-001-wake-lock-system-sleep-only.md delete mode 100644 tasks/README.md delete mode 100644 tasks/current.md delete mode 100644 tasks/discussions/DISCUSSION-001-wake-lock-behavior-change-for-macos-sleep-vs-screen-lock.md delete mode 100644 tasks/done/001-project-setup.md delete mode 100644 tasks/done/002-empty-state-ui.md delete mode 100644 tasks/done/003-process-manager.md delete mode 100644 tasks/done/004-sdk-integration.md delete mode 100644 tasks/done/005-session-picker-modal.md delete mode 100644 tasks/done/006-instance-session-tabs.md delete mode 100644 tasks/done/007-message-display.md delete mode 100644 tasks/done/008-sse-integration.md delete mode 100644 tasks/done/009-prompt-input-basic.md delete mode 100644 tasks/done/010-tool-call-rendering.md delete mode 100644 tasks/done/011-agent-model-selectors.md delete mode 100644 tasks/done/012-markdown-rendering.md delete mode 100644 tasks/done/013-logs-tab.md delete mode 100644 tasks/done/015-keyboard-shortcuts.md delete mode 100644 tasks/done/020-command-palette.md delete mode 100644 tasks/done/021-file-attachments.md delete mode 100644 tasks/done/022-long-paste-handling.md delete mode 100644 tasks/done/024-agent-attachments.md delete mode 100644 tasks/done/025-image-clipboard-support.md delete mode 100644 tasks/done/041-tailwind-theme-hooks.md delete mode 100644 tasks/done/042-style-token-scaffolding.md delete mode 100644 tasks/done/043-color-variable-migration.md delete mode 100644 tasks/done/044-typography-baseline.md delete mode 100644 tasks/done/045-message-item-tailwind-refactor.md delete mode 100644 tasks/done/046-prompt-input-refactor.md delete mode 100644 tasks/done/047-tabs-tailwind-refactor.md delete mode 100644 tasks/done/048-message-stream-refactor.md delete mode 100644 tasks/done/049-unified-picker-refactor.md delete mode 100644 tasks/done/050-selector-popover-refactor.md delete mode 100644 tasks/done/051-command-palette-refactor.md delete mode 100644 tasks/done/052-folder-info-panels-refactor.md delete mode 100644 tasks/done/053-markdown-style-refactor.md delete mode 100644 tasks/done/054-attachment-chip-refactor.md delete mode 100644 tasks/todo/023-symbol-attachments.md delete mode 100644 tasks/todo/055-wake-lock-investigation.md delete mode 100644 tasks/todo/056-wake-lock-behavior-change.md delete mode 100644 tasks/todo/057-implement-system-sleep-only-wake-lock.md diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc deleted file mode 100644 index ddc29a013..000000000 --- a/.opencode/opencode.jsonc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "plugin": [ - // "@neuralnomads/nomadworks@0.1.0-rc.10" - ] -} \ No newline at end of file diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json deleted file mode 100644 index 408ff65b7..000000000 --- a/.opencode/package-lock.json +++ /dev/null @@ -1,376 +0,0 @@ -{ - "name": ".opencode", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@opencode-ai/plugin": "1.14.24" - } - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@opencode-ai/plugin": { - "version": "1.14.24", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.24.tgz", - "integrity": "sha512-upzw2a9KfzIkIvvjYSPJiyV6o85D3HLmhVvAJIwV8mYWxbvi2wP2NA0hJaMp2+GZVuUl/ra8WV8kacD1CWcb4w==", - "license": "MIT", - "dependencies": { - "@opencode-ai/sdk": "1.14.24", - "effect": "4.0.0-beta.48", - "zod": "4.1.8" - }, - "peerDependencies": { - "@opentui/core": ">=0.1.99", - "@opentui/solid": ">=0.1.99" - }, - "peerDependenciesMeta": { - "@opentui/core": { - "optional": true - }, - "@opentui/solid": { - "optional": true - } - } - }, - "node_modules/@opencode-ai/sdk": { - "version": "1.14.24", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.24.tgz", - "integrity": "sha512-hZWc1jx+gtZBM6Mff9iOMlXM1at9BbAGg0uNrQk8DuXpd8K19fu942emojdInO2zy0jC5/wWggsi7GJu7HMp/w==", - "license": "MIT", - "dependencies": { - "cross-spawn": "7.0.6" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/effect": { - "version": "4.0.0-beta.48", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz", - "integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "fast-check": "^4.6.0", - "find-my-way-ts": "^0.1.6", - "ini": "^6.0.0", - "kubernetes-types": "^1.30.0", - "msgpackr": "^1.11.9", - "multipasta": "^0.2.7", - "toml": "^4.1.1", - "uuid": "^13.0.0", - "yaml": "^2.8.3" - } - }, - "node_modules/fast-check": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", - "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT", - "dependencies": { - "pure-rand": "^8.0.0" - }, - "engines": { - "node": ">=12.17.0" - } - }, - "node_modules/find-my-way-ts": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", - "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", - "license": "MIT" - }, - "node_modules/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/kubernetes-types": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", - "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", - "license": "Apache-2.0" - }, - "node_modules/msgpackr": { - "version": "1.11.10", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz", - "integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==", - "license": "MIT", - "optionalDependencies": { - "msgpackr-extract": "^3.0.2" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" - } - }, - "node_modules/multipasta": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", - "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", - "license": "MIT" - }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pure-rand": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", - "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/toml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", - "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/zod": { - "version": "4.1.8", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/PROGRESS.md b/PROGRESS.md deleted file mode 100644 index cce670bff..000000000 --- a/PROGRESS.md +++ /dev/null @@ -1,149 +0,0 @@ -# CodeNomad - Development Progress - -## Completed Tasks - -### Task 001: Project Setup ✅ -- Set up Electron + SolidJS + Vite + TypeScript -- Configured TailwindCSS v3 (downgraded from v4 for electron-vite compatibility) -- Build pipeline with electron-vite -- Application window management -- Application menu with keyboard shortcuts - -### Task 002: Empty State UI & Folder Selection ✅ -- Empty state component with styled UI -- Native folder picker integration -- IPC handlers for folder selection -- UI state management with SolidJS signals -- Loading states with spinner -- Keyboard shortcuts (Cmd/Ctrl+N) - -### Task 003: Process Manager ✅ -- Process spawning: `opencode serve --port 0` -- Port detection from stdout (regex: `opencode server listening on http://...`) -- Process lifecycle management (spawn, kill, cleanup) -- IPC communication for instance management -- Instance state tracking (starting → ready → stopped/error) -- Auto-cleanup on app quit -- Error handling & timeout protection (10s) -- Graceful shutdown (SIGTERM → SIGKILL) - -### Task 004: SDK Integration ✅ -- Installed `@opencode-ai/sdk` package -- SDK manager for client lifecycle -- Session fetching from OpenCode server -- Agent fetching (`client.app.agents()`) -- Provider fetching (`client.config.providers()`) -- Session store with SolidJS signals -- Instance store updated with SDK client -- Loading states for async operations -- Error handling for network failures - -### Task 005: Session Picker Modal ✅ -- Modal dialog with Kobalte Dialog -- Lists ALL existing sessions (scrollable) -- Session metadata display (title, relative timestamp) -- Native HTML select dropdown for agents -- Auto-selects first agent by default -- Create new session with selected agent -- Cancel button stops instance and closes modal -- Resume session on click -- Empty state for no sessions -- Loading state for agents -- Keyboard navigation (Escape to cancel) - -## Current State - -**Working Features:** -- ✅ App launches with empty state -- ✅ Folder selection via native dialog -- ✅ OpenCode server spawning per folder -- ✅ Port extraction and process tracking -- ✅ SDK client connection to running servers -- ✅ Session list fetching and display -- ✅ Agent and provider data fetching -- ✅ Session picker modal on instance creation -- ✅ Resume existing sessions -- ✅ Create new sessions with agent selection - -**File Structure:** -``` -packages/opencode-client/ -├── electron/ -│ ├── main/ -│ │ ├── main.ts (window + IPC setup) -│ │ ├── menu.ts (app menu) -│ │ ├── ipc.ts (instance IPC handlers) -│ │ └── process-manager.ts (server spawning) -│ └── preload/ -│ └── index.ts (IPC bridge) -├── src/ -│ ├── components/ -│ │ ├── empty-state.tsx -│ │ └── session-picker.tsx -│ ├── lib/ -│ │ └── sdk-manager.ts -│ ├── stores/ -│ │ ├── ui.ts -│ │ ├── instances.ts -│ │ └── sessions.ts -│ ├── types/ -│ │ ├── electron.d.ts -│ │ ├── instance.ts -│ │ └── session.ts -│ └── App.tsx -├── tasks/ -│ ├── done/ (001-005) -│ └── todo/ (006+) -└── docs/ -``` - -## Next Steps - -### Task 006: Message Stream UI (NEXT) -- Message display component -- User/assistant message rendering -- Markdown support with syntax highlighting -- Tool use visualization -- Auto-scroll behavior - -### Task 007: Prompt Input -- Text input with multi-line support -- Send button -- File attachment support -- Keyboard shortcuts (Enter for new line; Cmd+Enter/Ctrl+Enter to send) - -### Task 008: Instance Tabs -- Tab bar for multiple instances -- Switch between instances -- Close instance tabs -- "+" button for new instance - -## Build & Test - -```bash -cd packages/opencode-client -bun run build -bunx electron . -``` - -**Known Issue:** -- Dev mode (`bun dev`) fails due to Bun workspace hoisting + electron-vite -- Workaround: Use production builds for testing - -## Dependencies - -- Electron 38 -- SolidJS 1.8 -- TailwindCSS 3.x -- @opencode-ai/sdk -- @kobalte/core (Dialog) -- Vite 5 -- TypeScript 5 - -## Stats - -- **Tasks completed:** 5/5 (Phase 1) -- **Files created:** 18+ -- **Lines of code:** ~1500+ -- **Build time:** ~7s -- **Bundle size:** 152KB (renderer) diff --git a/docs/scrs/SCR-2026-04-21-001-wake-lock-system-sleep-only.md b/docs/scrs/SCR-2026-04-21-001-wake-lock-system-sleep-only.md deleted file mode 100644 index 0bd0654d8..000000000 --- a/docs/scrs/SCR-2026-04-21-001-wake-lock-system-sleep-only.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -id: SCR-2026-04-21-001 -title: Wake lock should allow screen lock while preventing system sleep -status: draft ---- - -# Summary - -Refine wake-lock behavior so the product protects long-running active work from device/system sleep without intentionally keeping the display awake. The desired product experience is: users may lock the screen or let the display sleep, and in-platform work should continue whenever the platform can support that behavior. - -# Problem - -Current wake-lock behavior on desktop is oriented around display wake, which prevents normal screen lock or display sleep behavior on macOS and does not match the requested product outcome. The Product Owner wants wake lock to protect only against system/device sleep during active work, not against display sleep or screen lock. Scope includes Electron, Tauri, and web, with documented best-effort degradation where platform APIs cannot provide a system-sleep-only capability. - -# Requested Outcome - -- Allow the screen/display to sleep or lock normally while qualifying work is in progress. -- Prevent only system/device sleep during qualifying active work on platforms that support a system-sleep-only hold. -- Keep platform behavior aligned to a single product rule: never intentionally keep the display awake as a fallback for this feature. -- Apply the behavior across Electron, Tauri, and web using best-effort platform support with explicit limitation handling. - -# Product Scope - -## Active Work Definition - -For this change, **active work** means a user-initiated or product-initiated in-app operation that: - -- has started execution, -- is represented by the product as still in progress, -- is expected to continue without continuous foreground interaction, and -- would lose reliability or stop early if the device enters normal system sleep. - -Active work does **not** include: - -- the app merely being open or focused, -- idle viewing or reading states, -- paused, completed, failed, or cancelled work, -- states waiting indefinitely for new user input before further execution, or -- generic background presence without a currently running task. - -## Product Behavior Rule - -- When active work starts, the product may request a wake lock only if the platform can do so **without intentionally blocking screen lock or display sleep**. -- When active work ends, pauses, fails, is cancelled, or no longer needs protection, the product must release the wake lock promptly. -- The product intent is consistent across platforms, but implementation is **best-effort by platform capability**, not strict-identical by mechanism. - -## Fallback Policy - -- If a platform can provide **system-sleep-only** protection, the product should use it. -- If a platform can only provide a **display/screen wake** lock that keeps the screen awake, the product must **not** use that mode as a fallback for this feature. -- In unsupported or partially supported environments, the product should fall back to **no wake lock** rather than preserving the old display-wake behavior. -- Unsupported behavior must be treated as a documented platform limitation, not as a product failure. - -## Platform Expectations - -- **Electron:** In scope to use a system-sleep-only mode if available. -- **Tauri:** In scope to use a system-sleep-only mode if available through the chosen Tauri/native path. -- **Web:** Default expectation is unsupported or partially supported for this exact behavior unless a browser/runtime exposes a true system-sleep-only primitive. A screen wake lock that keeps the display awake is not an acceptable substitute. - -## Non-Goals - -- Keeping the display continuously awake during long-running work. -- Preserving current display-wake behavior on platforms where that is the only available wake-lock mode. -- Inventing platform-specific user settings to choose between display wake and system-sleep-only behavior as part of this SCR. - -# Acceptance Criteria - -- AC-1: The specification defines **active work** in user-observable product terms, including the states that do and do not qualify for wake-lock protection. -- AC-2: The specification defines a single cross-platform product rule: qualifying active work should protect against system sleep where possible, while screen lock and display sleep remain allowed. -- AC-3: The specification defines the fallback policy for unsupported platforms: if system-sleep-only protection is unavailable, the product must not substitute display/screen wake behavior and must instead degrade to no wake lock. -- AC-4: Platform expectations are documented for Electron, Tauri, and web, including the explicit expectation that web is best-effort and may remain unsupported for this exact behavior. -- AC-5: The specification defines wake-lock release expectations so protection ends promptly when qualifying active work is no longer running. -- AC-6: Any implementation derived from this SCR must document user-visible limitations for unsupported platforms in the appropriate product-facing documentation if final technical validation confirms those limitations. - -# Implementation Notes For Follow-On Technical Assessment - -- Electron and Tauri feasibility still requires technical validation of the exact API mode, lifecycle reliability, and background-execution behavior. -- Web feasibility still requires confirmation of browser/runtime support, permission constraints, visibility restrictions, and whether any supported runtime offers a true system-sleep-only primitive. -- If technical validation shows a desktop platform cannot provide system-sleep-only behavior safely, implementation should follow the fallback policy above rather than retaining display-wake behavior. diff --git a/tasks/README.md b/tasks/README.md deleted file mode 100644 index 285a1c86b..000000000 --- a/tasks/README.md +++ /dev/null @@ -1,177 +0,0 @@ -# Task Management - -This directory contains the task breakdown for building CodeNomad. - -## Structure - -- `todo/` - Tasks waiting to be worked on -- `done/` - Completed tasks (moved from todo/) - -## Task Naming Convention - -Tasks are numbered sequentially with a descriptive name: - -``` -001-project-setup.md -002-empty-state-ui.md -003-process-manager.md -... -``` - -## Task Format - -Each task file contains: - -1. **Goal** - What this task achieves -2. **Prerequisites** - What must be done first -3. **Acceptance Criteria** - Checklist of requirements -4. **Steps** - Detailed implementation guide -5. **Testing Checklist** - How to verify completion -6. **Dependencies** - What blocks/is blocked by this task -7. **Estimated Time** - Rough time estimate -8. **Notes** - Additional context - -## Workflow - -### Starting a Task - -1. Read the task file thoroughly -2. Ensure prerequisites are met -3. Check dependencies are complete -4. Create a feature branch: `feature/task-XXX-name` - -### Working on a Task - -1. Follow steps in order -2. Check off acceptance criteria as you complete them -3. Run tests frequently -4. Commit regularly with descriptive messages - -### Completing a Task - -1. Verify all acceptance criteria met -2. Run full testing checklist -3. Update task file with any notes/changes -4. Move task from `todo/` to `done/` -5. Create PR for review - -## Current Tasks - -### Phase 1: Foundation (Tasks 001-005) - -- [x] 001 - Project Setup -- [x] 002 - Empty State UI -- [x] 003 - Process Manager -- [x] 004 - SDK Integration -- [x] 005 - Session Picker Modal - -### Phase 2: Core Chat (Tasks 006-010) - -- [x] 006 - Instance & Session Tabs -- [x] 007 - Message Display -- [x] 008 - SSE Integration -- [x] 009 - Prompt Input (Basic) -- [x] 010 - Tool Call Rendering - -### Phase 3: Essential Features (Tasks 011-015) - -- [x] 011 - Agent/Model Selectors -- [x] 012 - Markdown Rendering -- [x] 013 - Logs Tab -- [ ] 014 - Error Handling -- [ ] 015 - Keyboard Shortcuts - -### Phase 4: Multi-Instance (Tasks 016-020) - -- [ ] 016 - Instance Tabs -- [ ] 017 - Instance Persistence -- [ ] 018 - Child Session Handling -- [ ] 019 - Instance Lifecycle -- [ ] 020 - Multiple SDK Clients - -### Phase 5: Advanced Input (Tasks 021-025) - -- [ ] 021 - Slash Commands -- [ ] 022 - File Attachments -- [ ] 023 - Drag & Drop -- [ ] 024 - Attachment Chips -- [ ] 025 - Input History - -### Phase 6: Polish (Tasks 026-030) - -- [ ] 026 - Message Actions -- [ ] 027 - Search in Session -- [ ] 028 - Session Management -- [ ] 029 - Settings UI -- [ ] 030 - Native Menus - -### Phase 7: System Integration (Tasks 031-035) - -- [ ] 031 - System Tray -- [ ] 032 - Notifications -- [ ] 033 - Auto-updater -- [ ] 034 - Crash Reporting -- [ ] 035 - Performance Profiling - -### Phase 8: Advanced (Tasks 036-040) - -- [ ] 036 - Virtual Scrolling -- [ ] 037 - Advanced Search -- [ ] 038 - Workspace Management -- [ ] 039 - Theme Customization -- [ ] 040 - Plugin System - -## Priority Levels - -Tasks are prioritized as follows: - -- **P0 (MVP)**: Must have for first release (Tasks 001-015) -- **P1 (Beta)**: Important for beta (Tasks 016-030) -- **P2 (v1.0)**: Should have for v1.0 (Tasks 031-035) -- **P3 (Future)**: Nice to have (Tasks 036-040) - -## Dependencies Graph - -``` -001 (Setup) - ├─ 002 (Empty State) - │ └─ 003 (Process Manager) - │ └─ 004 (SDK Integration) - │ └─ 005 (Session Picker) - │ ├─ 006 (Tabs) - │ │ └─ 007 (Messages) - │ │ └─ 008 (SSE) - │ │ └─ 009 (Input) - │ │ └─ 010 (Tool Calls) - │ │ └─ 011-015 (Essential Features) - │ │ └─ 016-020 (Multi-Instance) - │ │ └─ 021-025 (Advanced Input) - │ │ └─ 026-030 (Polish) - │ │ └─ 031-035 (System) - │ │ └─ 036-040 (Advanced) -``` - -## Tips - -- **Don't skip steps** - They're ordered for a reason -- **Test as you go** - Don't wait until the end -- **Keep tasks small** - Break down if >1 day of work -- **Document issues** - Note any blockers or problems -- **Ask questions** - If unclear, ask before proceeding - -## Tracking Progress - -Update this file as tasks complete: - -- Change `[ ]` to `[x]` in the task list -- Move completed task files to `done/` -- Update build roadmap doc - -## Getting Help - -If stuck on a task: - -1. Review prerequisites and dependencies -2. Check related documentation in `docs/` -3. Review similar patterns in existing code -4. Ask for clarification on unclear requirements diff --git a/tasks/current.md b/tasks/current.md deleted file mode 100644 index ce2b48fdf..000000000 --- a/tasks/current.md +++ /dev/null @@ -1,19 +0,0 @@ -# Current Tasks - -## Active Discussions - -- DISCUSSION-001 — Wake lock behavior change for macOS sleep vs screen lock — summarized, routed to task 056 - -## Active - -- 055-wake-lock-investigation.md — standard / investigation / logic — Assigned to tech_lead -- 056-wake-lock-behavior-change.md — complex / spec / logic — Assigned to business_analyst -- 057-implement-system-sleep-only-wake-lock.md — complex / implementation / logic — Assigned to workflow_runner - -## Todo - -- 023-symbol-attachments.md - -## Blocked - -- None. diff --git a/tasks/discussions/DISCUSSION-001-wake-lock-behavior-change-for-macos-sleep-vs-screen-lock.md b/tasks/discussions/DISCUSSION-001-wake-lock-behavior-change-for-macos-sleep-vs-screen-lock.md deleted file mode 100644 index dfc951c22..000000000 --- a/tasks/discussions/DISCUSSION-001-wake-lock-behavior-change-for-macos-sleep-vs-screen-lock.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -id: DISCUSSION-001 -title: "Wake lock behavior change for macOS sleep vs screen lock" -status: closed -summarized_by: business_analyst -source: runtime-transcript ---- - -# Discussion Summary - -## Topic -Change wake lock behavior so screen lock/display sleep is allowed while system sleep is still prevented during active work. - -## Purpose -Capture a workflow-ready summary of a requested product behavior change affecting desktop apps and web, including current behavior, desired behavior, scope, and unresolved platform feasibility. - -## Repository Truth Relevant To This Discussion -- Current desktop wake lock behavior is effectively configured as a display wake lock. -- Electron currently uses `prevent-display-sleep`. -- Tauri currently includes `display: true` in its wake-lock-related configuration. -- This current setup keeps the screen awake and blocks normal screen lock/display sleep on macOS. - -## Facts Established -- The reported problem is specific to current wake lock behavior preventing screen lock on macOS. -- The user wants wake lock to allow screen lock while still preventing the device from going to sleep. -- The requested scope was expanded beyond macOS-only behavior. -- The user explicitly requested coverage for all desktop apps and web. -- Browser/web platform limitations may affect how fully the requested behavior can be implemented. - -## Requirements Captured -- Wake lock must allow the display to sleep or lock normally. -- Wake lock must prevent only system sleep while work is active. -- On macOS, the screen should be able to turn off and lock while the machine remains awake enough to continue the task. -- The change should be researched and then applied, not just discussed. -- Scope should include all desktop apps and web, subject to technical feasibility. - -## Constraints -- The change affects multiple platforms and should not be treated as a macOS-only behavior change. -- Web support may be constrained by browser capabilities and wake lock API limitations. -- Platform-specific implementation details may differ between Electron, Tauri, and web. - -## Non-Goals -- Keeping the display continuously awake. -- Preserving the current display-wake behavior on macOS. -- Defining a macOS-only special case unless later justified. - -## Decisions Made -- Preferred product direction: allow display sleep/screen lock while preventing only system sleep during active work. -- Scope direction confirmed by the user: all desktop apps and web. -- The discussion should move into tracked workflow work with product and technical input before implementation. - -## Assumptions -- “Work is active” refers to periods when the application is performing a task that currently relies on wake lock protection. -- The intended outcome is continued task execution while the screen is locked or asleep, not continuous visual display. -- Some platforms may require best-effort behavior rather than identical implementation mechanics. - -## Open Questions -- What exact user-facing definition of “work is active” should trigger wake lock behavior across products? -- What behavior is achievable on web given browser/API support and permission constraints? -- If a platform cannot prevent only system sleep without also affecting display sleep, what fallback behavior is acceptable? -- Should platform-specific differences be exposed to users or documented in product behavior notes? - -## Risks Or Concerns -- Web may not support the requested behavior fully or consistently across browsers. -- A platform may not offer a clean “prevent system sleep only” mode, creating inconsistent behavior across products. -- Changing wake lock semantics could affect long-running task reliability if background execution assumptions are wrong. - -## Referenced Files Or Areas -- Electron wake lock implementation using `prevent-display-sleep` -- Tauri wake lock / `keepawake` configuration currently using `display: true` -- Cross-platform wake lock behavior for desktop apps -- Web wake lock behavior and browser capability research areas - -## Recommended Workflow Next Step -- assigned_to: product_manager -- why: Create a tracked task and SCR-ready handoff for cross-platform research and specification, then route to business analyst and technical architect for requirements and feasibility clarification before implementation. diff --git a/tasks/done/001-project-setup.md b/tasks/done/001-project-setup.md deleted file mode 100644 index fe55dc4ba..000000000 --- a/tasks/done/001-project-setup.md +++ /dev/null @@ -1,262 +0,0 @@ -# Task 001: Project Setup & Boilerplate - -## Goal - -Set up the basic Electron + SolidJS + Vite project structure with all necessary dependencies and configuration files. - -## Prerequisites - -- Node.js 18+ installed -- Bun package manager -- OpenCode CLI installed and accessible in PATH - -## Acceptance Criteria - -- [ ] Project structure matches documented layout -- [ ] All dependencies installed -- [ ] Dev server starts successfully -- [ ] Electron window launches -- [ ] Hot reload works for renderer -- [ ] TypeScript compilation works -- [ ] Basic "Hello World" renders - -## Steps - -### 1. Initialize Package - -- Create `package.json` with project metadata -- Set `name`: `@opencode-ai/client` -- Set `version`: `0.1.0` -- Set `type`: `module` -- Set `main`: `dist/main/main.js` - -### 2. Install Core Dependencies - -**Production:** - -- `electron` ^28.0.0 -- `solid-js` ^1.8.0 -- `@solidjs/router` ^0.13.0 -- `@opencode-ai/sdk` (from workspace) - -**Development:** - -- `electron-vite` ^2.0.0 -- `electron-builder` ^24.0.0 -- `vite` ^5.0.0 -- `vite-plugin-solid` ^2.10.0 -- `typescript` ^5.3.0 -- `tailwindcss` ^4.0.0 -- `@tailwindcss/vite` ^4.0.0 - -**UI Libraries:** - -- `@kobalte/core` ^0.13.0 -- `shiki` ^1.0.0 -- `marked` ^12.0.0 -- `lucide-solid` ^0.300.0 - -### 3. Create Directory Structure - -``` -packages/opencode-client/ -├── electron/ -│ ├── main/ -│ │ └── main.ts -│ ├── preload/ -│ │ └── index.ts -│ └── resources/ -│ └── icon.png -├── src/ -│ ├── components/ -│ ├── stores/ -│ ├── lib/ -│ ├── hooks/ -│ ├── types/ -│ ├── App.tsx -│ ├── main.tsx -│ └── index.css -├── docs/ -├── tasks/ -│ ├── todo/ -│ └── done/ -├── package.json -├── tsconfig.json -├── tsconfig.node.json -├── electron.vite.config.ts -├── tailwind.config.js -├── .gitignore -└── README.md -``` - -### 4. Configure TypeScript - -**tsconfig.json** (for renderer): - -- `target`: ES2020 -- `module`: ESNext -- `jsx`: preserve -- `jsxImportSource`: solid-js -- `moduleResolution`: bundler -- `strict`: true -- Path alias: `@/*` → `./src/*` - -**tsconfig.node.json** (for main & preload): - -- `target`: ES2020 -- `module`: ESNext -- `moduleResolution`: bundler -- Include: `electron/**/*.ts` - -### 5. Configure Electron Vite - -**electron.vite.config.ts:** - -- Main process config: External electron -- Preload config: External electron -- Renderer config: - - SolidJS plugin - - TailwindCSS plugin - - Path alias resolution - - Dev server port: 3000 - -### 6. Configure TailwindCSS - -**tailwind.config.js:** - -- Content: `['./src/**/*.{ts,tsx}']` -- Theme: Default (will customize later) -- Plugins: None initially - -**src/index.css:** - -```css -@import "tailwindcss"; -``` - -### 7. Create Main Process Entry - -**electron/main/main.ts:** - -- Import app, BrowserWindow from electron -- Set up window creation -- Window size: 1400x900 -- Min size: 800x600 -- Web preferences: - - preload: path to preload script - - contextIsolation: true - - nodeIntegration: false -- Load URL based on environment: - - Dev: http://localhost:3000 - - Prod: Load dist/index.html -- Handle app lifecycle: - - ready event - - window-all-closed (quit on non-macOS) - - activate (recreate window on macOS) - -### 8. Create Preload Script - -**electron/preload/index.ts:** - -- Import contextBridge, ipcRenderer -- Expose electronAPI object: - - Placeholder methods for future IPC -- Type definitions for window.electronAPI - -### 9. Create Renderer Entry - -**src/main.tsx:** - -- Import render from solid-js/web -- Import App component -- Render to #root element - -**src/App.tsx:** - -- Basic component with "Hello CodeNomad" -- Display environment info -- Basic styling with TailwindCSS - -**index.html:** - -- Root div with id="root" -- Link to src/main.tsx - -### 10. Add Scripts to package.json - -```json -{ - "scripts": { - "dev": "electron-vite dev", - "build": "electron-vite build", - "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json", - "preview": "electron-vite preview", - "package:mac": "electron-builder --mac", - "package:win": "electron-builder --win", - "package:linux": "electron-builder --linux" - } -} -``` - -### 11. Configure Electron Builder - -**electron-builder.yml** or in package.json: - -- appId: ai.opencode.client -- Product name: CodeNomad -- Build resources: electron/resources -- Files to include: dist/, package.json -- Directories: - - output: release - - buildResources: electron/resources -- Platform-specific configs (basic) - -### 12. Add .gitignore - -``` -node_modules/ -dist/ -release/ -.DS_Store -*.log -.vite/ -.electron-vite/ -``` - -### 13. Create README - -- Project description -- Prerequisites -- Installation instructions -- Development commands -- Build commands -- Architecture overview link - -## Verification Steps - -1. Run `bun install` -2. Run `bun run dev` -3. Verify Electron window opens -4. Verify "Hello CodeNomad" displays -5. Make a change to App.tsx -6. Verify hot reload updates UI -7. Run `bun run typecheck` -8. Verify no TypeScript errors -9. Run `bun run build` -10. Verify dist/ folder created - -## Dependencies for Next Tasks - -- Task 002 (Empty State) depends on this -- Task 003 (Process Manager) depends on this - -## Estimated Time - -2-3 hours - -## Notes - -- Keep this minimal - just the skeleton -- Don't add any business logic yet -- Focus on getting build pipeline working -- Use official Electron + Vite + Solid templates as reference diff --git a/tasks/done/002-empty-state-ui.md b/tasks/done/002-empty-state-ui.md deleted file mode 100644 index 4ef9563c1..000000000 --- a/tasks/done/002-empty-state-ui.md +++ /dev/null @@ -1,280 +0,0 @@ -# Task 002: Empty State UI & Folder Selection - -## Goal - -Create the initial empty state interface that appears when no instances are running, with folder selection capability. - -## Prerequisites - -- Task 001 completed (project setup) -- Basic understanding of SolidJS components -- Electron IPC understanding - -## Acceptance Criteria - -- [ ] Empty state displays when no instances exist -- [ ] "Select Folder" button visible and styled -- [ ] Clicking button triggers Electron dialog -- [ ] Selected folder path displays temporarily -- [ ] UI matches design spec (centered, clean) -- [ ] Keyboard shortcut Cmd/Ctrl+N works -- [ ] Error handling for cancelled selection - -## Steps - -### 1. Create Empty State Component - -**src/components/empty-state.tsx:** - -**Structure:** - -- Centered container -- Large folder icon (from lucide-solid) -- Subheading: "Select a folder to start coding with AI" -- Primary button: "Select Folder" -- Helper text: "Keyboard shortcut: Cmd/Ctrl+N" - -**Styling:** - -- Use TailwindCSS utilities -- Center vertically and horizontally -- Max width: 500px -- Padding: 32px -- Icon size: 64px -- Text sizes: Heading 24px, body 16px, helper 14px -- Colors: Follow design spec (light/dark mode) - -**Props:** - -- `onSelectFolder: () => void` - Callback when button clicked - -### 2. Create UI Store - -**src/stores/ui.ts:** - -**State:** - -```typescript -interface UIStore { - hasInstances: boolean - selectedFolder: string | null - isSelectingFolder: boolean -} -``` - -**Signals:** - -- `hasInstances` - Reactive boolean -- `selectedFolder` - Reactive string or null -- `isSelectingFolder` - Reactive boolean (loading state) - -**Actions:** - -- `setHasInstances(value: boolean)` -- `setSelectedFolder(path: string | null)` -- `setIsSelectingFolder(value: boolean)` - -### 3. Implement IPC for Folder Selection - -**electron/main/main.ts additions:** - -**IPC Handler:** - -- Register handler for 'dialog:selectFolder' -- Use `dialog.showOpenDialog()` with: - - `properties: ['openDirectory']` - - Title: "Select Project Folder" - - Button label: "Select" -- Return selected folder path or null if cancelled -- Handle errors gracefully - -**electron/preload/index.ts additions:** - -**Expose method:** - -```typescript -electronAPI: { - selectFolder: () => Promise -} -``` - -**Type definitions:** - -```typescript -interface ElectronAPI { - selectFolder: () => Promise -} - -declare global { - interface Window { - electronAPI: ElectronAPI - } -} -``` - -### 4. Update App Component - -**src/App.tsx:** - -**Logic:** - -- Import UI store -- Import EmptyState component -- Check if `hasInstances` is false -- If false, render EmptyState -- If true, render placeholder for instance UI (future) - -**Folder selection handler:** - -```typescript -async function handleSelectFolder() { - setIsSelectingFolder(true) - try { - const folder = await window.electronAPI.selectFolder() - if (folder) { - setSelectedFolder(folder) - // TODO: Will trigger instance creation in Task 003 - console.log("Selected folder:", folder) - } - } catch (error) { - console.error("Folder selection failed:", error) - // TODO: Show error toast (Task 010) - } finally { - setIsSelectingFolder(false) - } -} -``` - -### 5. Add Keyboard Shortcut - -**electron/main/menu.ts (new file):** - -**Create application menu:** - -- File menu: - - New Instance (Cmd/Ctrl+N) - - Click: Send 'menu:newInstance' to renderer - - Separator - - Quit (Cmd/Ctrl+Q) - -**Platform-specific menu:** - -- macOS: Include app menu with About, Hide, etc. -- Windows/Linux: Standard File menu - -**Register menu in main.ts:** - -- Import Menu, buildFromTemplate -- Create menu structure -- Set as application menu - -**electron/preload/index.ts additions:** - -```typescript -electronAPI: { - onNewInstance: (callback: () => void) => void -} -``` - -**src/App.tsx additions:** - -- Listen for 'newInstance' event -- Trigger handleSelectFolder when received - -### 6. Add Loading State - -**Button states:** - -- Default: "Select Folder" -- Loading: "Selecting..." with spinner icon -- Disabled when isSelectingFolder is true - -**Spinner component:** - -- Use lucide-solid Loader2 icon -- Add spin animation class -- Size: 16px - -### 7. Add Validation - -**Folder validation (in handler):** - -- Check if folder exists -- Check if readable -- Check if it's actually a directory -- Show appropriate error if invalid - -**Error messages:** - -- "Folder does not exist" -- "Cannot access folder (permission denied)" -- "Please select a directory, not a file" - -### 8. Style Refinements - -**Responsive behavior:** - -- Works at minimum window size (800x600) -- Maintains centering -- Text remains readable - -**Accessibility:** - -- Button has proper ARIA labels -- Keyboard focus visible -- Screen reader friendly text - -**Theme support:** - -- Test in light mode -- Test in dark mode (use prefers-color-scheme) -- Icons and text have proper contrast - -### 9. Add Helpful Context - -**Additional helper text:** - -- "Examples: ~/projects/my-app" -- "You can have multiple instances of the same folder" - -**Icon improvements:** - -- Use animated folder icon (optional) -- Add subtle entrance animation (fade in) - -## Testing Checklist - -**Manual Tests:** - -1. Launch app → Empty state appears -2. Click "Select Folder" → Dialog opens -3. Select folder → Path logged to console -4. Cancel dialog → No error, back to empty state -5. Press Cmd/Ctrl+N → Dialog opens -6. Select non-directory → Error shown -7. Select restricted folder → Permission error shown -8. Resize window → Layout stays centered - -**Edge Cases:** - -- Very long folder paths (ellipsis) -- Special characters in folder name -- Folder on network drive -- Folder that gets deleted while selected - -## Dependencies - -- **Blocks:** Task 003 (needs folder path to create instance) -- **Blocked by:** Task 001 (needs project setup) - -## Estimated Time - -2-3 hours - -## Notes - -- Keep UI simple and clean -- Focus on UX - clear messaging -- Don't implement instance creation yet (that's Task 003) -- Log selected folder to console for verification -- Prepare for state management patterns used in later tasks diff --git a/tasks/done/003-process-manager.md b/tasks/done/003-process-manager.md deleted file mode 100644 index c2671df5d..000000000 --- a/tasks/done/003-process-manager.md +++ /dev/null @@ -1,430 +0,0 @@ -# Task 003: OpenCode Server Process Management - -## Goal - -Implement the ability to spawn, manage, and kill OpenCode server processes from the Electron main process. - -## Prerequisites - -- Task 001 completed (project setup) -- Task 002 completed (folder selection working) -- OpenCode CLI installed and in PATH -- Understanding of Node.js child_process API - -## Acceptance Criteria - -- [ ] Can spawn `opencode serve` for a folder -- [ ] Parses stdout to extract port number -- [ ] Returns port and PID to renderer -- [ ] Handles spawn errors gracefully -- [ ] Can kill process on command -- [ ] Captures and forwards stdout/stderr -- [ ] Timeout protection (10 seconds) -- [ ] Process cleanup on app quit - -## Steps - -### 1. Create Process Manager Module - -**electron/main/process-manager.ts:** - -**Exports:** - -```typescript -interface ProcessInfo { - pid: number - port: number -} - -interface ProcessManager { - spawn(folder: string): Promise - kill(pid: number): Promise - getStatus(pid: number): "running" | "stopped" | "unknown" - getAllProcesses(): Map -} - -interface ProcessMeta { - pid: number - port: number - folder: string - startTime: number - childProcess: ChildProcess -} -``` - -### 2. Implement Spawn Logic - -**spawn(folder: string):** - -**Pre-flight checks:** - -- Verify `opencode` binary exists in PATH - - Use `which opencode` or `where opencode` - - If not found, reject with helpful error -- Verify folder exists and is directory - - Use `fs.stat()` to check - - If invalid, reject with error -- Verify folder is readable - - Check permissions - - If denied, reject with error - -**Process spawning:** - -- Use `child_process.spawn()` -- Command: `opencode` -- Args: `['serve', '--port', '0']` - - Port 0 = random available port -- Options: - - `cwd`: The selected folder - - `stdio`: `['ignore', 'pipe', 'pipe']` - - stdin: ignore - - stdout: pipe (we'll read it) - - stderr: pipe (for errors) - - `env`: Inherit process.env - - `shell`: false (security) - -**Port extraction:** - -- Listen to stdout data events -- Buffer output line by line -- Regex match: `/Server listening on port (\d+)/` or similar -- Extract port number when found -- Store process metadata -- Resolve promise with { pid, port } - -**Timeout handling:** - -- Set 10 second timeout -- If port not found within timeout: - - Kill the process - - Reject promise with timeout error -- Clear timeout once port found - -**Error handling:** - -- Listen to process 'error' event - - Common: ENOENT (binary not found) - - Reject promise immediately -- Listen to process 'exit' event - - If exits before port found: - - Read stderr buffer - - Reject with exit code and stderr - -### 3. Implement Kill Logic - -**kill(pid: number):** - -**Find process:** - -- Look up pid in internal Map -- If not found, reject with "Process not found" - -**Graceful shutdown:** - -- Send SIGTERM signal first -- Wait 2 seconds -- If still running, send SIGKILL -- Remove from internal Map -- Resolve when process exits - -**Cleanup:** - -- Close stdio streams -- Remove all event listeners -- Free resources - -### 4. Implement Status Check - -**getStatus(pid: number):** - -**Check if running:** - -- On Unix: Use `process.kill(pid, 0)` - - Returns true if running - - Throws if not running -- On Windows: Use tasklist or similar -- Return 'running', 'stopped', or 'unknown' - -### 5. Add Process Tracking - -**Internal state:** - -```typescript -const processes = new Map() -``` - -**Track all spawned processes:** - -- Add on successful spawn -- Remove on kill or exit -- Use for cleanup on app quit - -### 6. Implement Auto-cleanup - -**On app quit:** - -- Listen to app 'before-quit' event -- Kill all tracked processes -- Wait for all to exit (with timeout) -- Prevent quit until cleanup done - -**On process crash:** - -- Listen to process 'exit' event -- If unexpected exit: - - Log error - - Notify renderer via IPC - - Remove from tracking - -### 7. Add Logging - -**Log output forwarding:** - -- Listen to stdout/stderr -- Parse into lines -- Send to renderer via IPC events - - Event: 'instance:log' - - Payload: { pid, level: 'info' | 'error', message } - -**Log important events:** - -- Process spawned -- Port discovered -- Process exited -- Errors occurred - -### 8. Add IPC Handlers - -**electron/main/ipc.ts (new file):** - -**Register handlers:** - -```typescript -ipcMain.handle("process:spawn", async (event, folder: string) => { - return await processManager.spawn(folder) -}) - -ipcMain.handle("process:kill", async (event, pid: number) => { - return await processManager.kill(pid) -}) - -ipcMain.handle("process:status", async (event, pid: number) => { - return processManager.getStatus(pid) -}) -``` - -**Send events:** - -```typescript -// When process exits unexpectedly -webContents.send("process:exited", { pid, code, signal }) - -// When log output received -webContents.send("process:log", { pid, level, message }) -``` - -### 9. Update Preload Script - -**electron/preload/index.ts additions:** - -**Expose methods:** - -```typescript -electronAPI: { - spawnServer: (folder: string) => Promise<{ pid: number, port: number }> - killServer: (pid: number) => Promise - getServerStatus: (pid: number) => Promise - - onServerExited: (callback: (data: any) => void) => void - onServerLog: (callback: (data: any) => void) => void -} -``` - -**Type definitions:** - -```typescript -interface ProcessInfo { - pid: number - port: number -} - -interface ElectronAPI { - // ... previous methods - spawnServer: (folder: string) => Promise - killServer: (pid: number) => Promise - getServerStatus: (pid: number) => Promise<"running" | "stopped" | "unknown"> - onServerExited: (callback: (data: { pid: number; code: number }) => void) => void - onServerLog: (callback: (data: { pid: number; level: string; message: string }) => void) => void -} -``` - -### 10. Create Instance Store - -**src/stores/instances.ts:** - -**State:** - -```typescript -interface Instance { - id: string // UUID - folder: string - port: number - pid: number - status: "starting" | "ready" | "error" | "stopped" - error?: string -} - -interface InstanceStore { - instances: Map - activeInstanceId: string | null -} -``` - -**Actions:** - -```typescript -async function createInstance(folder: string) { - const id = generateId() - - // Add with 'starting' status - instances.set(id, { - id, - folder, - port: 0, - pid: 0, - status: "starting", - }) - - try { - // Spawn server - const { pid, port } = await window.electronAPI.spawnServer(folder) - - // Update with port and pid - instances.set(id, { - ...instances.get(id)!, - port, - pid, - status: "ready", - }) - - return id - } catch (error) { - // Update with error - instances.set(id, { - ...instances.get(id)!, - status: "error", - error: error.message, - }) - throw error - } -} - -async function removeInstance(id: string) { - const instance = instances.get(id) - if (!instance) return - - // Kill server - if (instance.pid) { - await window.electronAPI.killServer(instance.pid) - } - - // Remove from store - instances.delete(id) - - // If was active, clear active - if (activeInstanceId === id) { - activeInstanceId = null - } -} -``` - -### 11. Wire Up Folder Selection - -**src/App.tsx updates:** - -**After folder selected:** - -```typescript -async function handleSelectFolder() { - const folder = await window.electronAPI.selectFolder() - if (!folder) return - - try { - const instanceId = await createInstance(folder) - setActiveInstance(instanceId) - - // Hide empty state, show instance UI - setHasInstances(true) - } catch (error) { - console.error("Failed to create instance:", error) - // TODO: Show error toast - } -} -``` - -**Listen for process exit:** - -```typescript -onMount(() => { - window.electronAPI.onServerExited(({ pid }) => { - // Find instance by PID - const instance = Array.from(instances.values()).find((i) => i.pid === pid) - - if (instance) { - // Update status - instances.set(instance.id, { - ...instance, - status: "stopped", - }) - - // TODO: Show notification (Task 010) - } - }) -}) -``` - -## Testing Checklist - -**Manual Tests:** - -1. Select folder → Server spawns -2. Console shows "Spawned PID: XXX, Port: YYYY" -3. Check `ps aux | grep opencode` → Process running -4. Quit app → Process killed -5. Select invalid folder → Error shown -6. Select without opencode installed → Helpful error -7. Spawn multiple instances → All tracked -8. Kill one instance → Others continue running - -**Error Cases:** - -- opencode not in PATH -- Permission denied on folder -- Port already in use (should not happen with port 0) -- Server crashes immediately -- Timeout (server takes >10s to start) - -**Edge Cases:** - -- Very long folder path -- Folder with spaces in name -- Folder on network drive (slow to spawn) -- Multiple instances same folder (different ports) - -## Dependencies - -- **Blocks:** Task 004 (needs running server to connect SDK) -- **Blocked by:** Task 001, Task 002 - -## Estimated Time - -4-5 hours - -## Notes - -- Security: Never use shell execution with user input -- Cross-platform: Test on macOS, Windows, Linux -- Error messages must be actionable -- Log everything for debugging -- Consider rate limiting (max 10 instances?) -- Memory: Track process memory usage (future enhancement) diff --git a/tasks/done/004-sdk-integration.md b/tasks/done/004-sdk-integration.md deleted file mode 100644 index 0ac579ff1..000000000 --- a/tasks/done/004-sdk-integration.md +++ /dev/null @@ -1,504 +0,0 @@ -# Task 004: SDK Client Integration & Session Management - -## Goal - -Integrate the OpenCode SDK to communicate with running servers, fetch session lists, and manage session lifecycle. - -## Prerequisites - -- Task 003 completed (server spawning works) -- OpenCode SDK package available -- Understanding of HTTP/REST APIs -- Understanding of SolidJS reactivity - -## Acceptance Criteria - -- [ ] SDK client created per instance -- [ ] Can fetch session list from server -- [ ] Can create new session -- [ ] Can get session details -- [ ] Can delete session -- [ ] Client lifecycle tied to instance lifecycle -- [ ] Error handling for network failures -- [ ] Proper TypeScript types throughout - -## Steps - -### 1. Create SDK Manager Module - -**src/lib/sdk-manager.ts:** - -**Purpose:** - -- Manage SDK client instances -- One client per server (per port) -- Create, retrieve, destroy clients - -**Interface:** - -```typescript -interface SDKManager { - createClient(port: number): OpenCodeClient - getClient(port: number): OpenCodeClient | null - destroyClient(port: number): void - destroyAll(): void -} -``` - -**Implementation details:** - -- Store clients in Map -- Create client with base URL: `http://localhost:${port}` -- Handle client creation errors -- Clean up on destroy - -### 2. Update Instance Store - -**src/stores/instances.ts additions:** - -**Add client to Instance:** - -```typescript -interface Instance { - // ... existing fields - client: OpenCodeClient | null -} -``` - -**Update createInstance:** - -- After server spawns successfully -- Create SDK client for that port -- Store in instance.client -- Handle client creation errors - -**Update removeInstance:** - -- Destroy SDK client before removing -- Call sdkManager.destroyClient(port) - -### 3. Create Session Store - -**src/stores/sessions.ts:** - -**State structure:** - -```typescript -interface Session { - id: string - instanceId: string - title: string - parentId: string | null - agent: string - model: { - providerId: string - modelId: string - } - time: { - created: number - updated: number - } -} - -interface SessionStore { - // Sessions grouped by instance - sessions: Map> - - // Active session per instance - activeSessionId: Map -} -``` - -**Core actions:** - -```typescript -// Fetch all sessions for an instance -async function fetchSessions(instanceId: string): Promise - -// Create new session -async function createSession(instanceId: string, agent: string): Promise - -// Delete session -async function deleteSession(instanceId: string, sessionId: string): Promise - -// Set active session -function setActiveSession(instanceId: string, sessionId: string): void - -// Get active session -function getActiveSession(instanceId: string): Session | null - -// Get all sessions for instance -function getSessions(instanceId: string): Session[] -``` - -### 4. Implement Session Fetching - -**fetchSessions implementation:** - -```typescript -async function fetchSessions(instanceId: string) { - const instance = instances.get(instanceId) - if (!instance || !instance.client) { - throw new Error("Instance not ready") - } - - try { - const response = await instance.client.session.list() - - // Convert API response to Session objects - const sessionMap = new Map() - - for (const apiSession of response.data) { - sessionMap.set(apiSession.id, { - id: apiSession.id, - instanceId, - title: apiSession.title || "Untitled", - parentId: apiSession.parentId || null, - agent: "", // Will be populated from messages - model: { providerId: "", modelId: "" }, - time: { - created: apiSession.time.created, - updated: apiSession.time.updated, - }, - }) - } - - sessions.set(instanceId, sessionMap) - } catch (error) { - console.error("Failed to fetch sessions:", error) - throw error - } -} -``` - -### 5. Implement Session Creation - -**createSession implementation:** - -```typescript -async function createSession(instanceId: string, agent: string): Promise { - const instance = instances.get(instanceId) - if (!instance || !instance.client) { - throw new Error("Instance not ready") - } - - try { - const response = await instance.client.session.create({ - // OpenCode API might need specific params - }) - - const session: Session = { - id: response.data.id, - instanceId, - title: "New Session", - parentId: null, - agent, - model: { providerId: "", modelId: "" }, - time: { - created: Date.now(), - updated: Date.now(), - }, - } - - // Add to store - const instanceSessions = sessions.get(instanceId) || new Map() - instanceSessions.set(session.id, session) - sessions.set(instanceId, instanceSessions) - - return session - } catch (error) { - console.error("Failed to create session:", error) - throw error - } -} -``` - -### 6. Implement Session Deletion - -**deleteSession implementation:** - -```typescript -async function deleteSession(instanceId: string, sessionId: string): Promise { - const instance = instances.get(instanceId) - if (!instance || !instance.client) { - throw new Error("Instance not ready") - } - - try { - await instance.client.session.delete({ path: { id: sessionId } }) - - // Remove from store - const instanceSessions = sessions.get(instanceId) - if (instanceSessions) { - instanceSessions.delete(sessionId) - } - - // Clear active if it was active - if (activeSessionId.get(instanceId) === sessionId) { - activeSessionId.delete(instanceId) - } - } catch (error) { - console.error("Failed to delete session:", error) - throw error - } -} -``` - -### 7. Implement Agent & Model Fetching - -**Fetch available agents:** - -```typescript -interface Agent { - name: string - description: string - mode: string -} - -async function fetchAgents(instanceId: string): Promise { - const instance = instances.get(instanceId) - if (!instance || !instance.client) { - throw new Error("Instance not ready") - } - - try { - const response = await instance.client.agent.list() - return response.data.filter((agent) => agent.mode !== "subagent") - } catch (error) { - console.error("Failed to fetch agents:", error) - return [] - } -} -``` - -**Fetch available models:** - -```typescript -interface Provider { - id: string - name: string - models: Model[] -} - -interface Model { - id: string - name: string - providerId: string -} - -async function fetchProviders(instanceId: string): Promise { - const instance = instances.get(instanceId) - if (!instance || !instance.client) { - throw new Error("Instance not ready") - } - - try { - const response = await instance.client.config.providers() - return response.data.providers.map((provider) => ({ - id: provider.id, - name: provider.name, - models: Object.entries(provider.models).map(([id, model]) => ({ - id, - name: model.name, - providerId: provider.id, - })), - })) - } catch (error) { - console.error("Failed to fetch providers:", error) - return [] - } -} -``` - -### 8. Add Error Handling - -**Network error handling:** - -```typescript -function handleSDKError(error: any): string { - if (error.code === "ECONNREFUSED") { - return "Cannot connect to server. Is it running?" - } - if (error.code === "ETIMEDOUT") { - return "Request timed out. Please try again." - } - if (error.response?.status === 404) { - return "Resource not found" - } - if (error.response?.status === 500) { - return "Server error. Check logs." - } - return error.message || "Unknown error occurred" -} -``` - -**Retry logic (for transient failures):** - -```typescript -async function withRetry(fn: () => Promise, maxRetries = 3, delay = 1000): Promise { - let lastError - - for (let i = 0; i < maxRetries; i++) { - try { - return await fn() - } catch (error) { - lastError = error - if (i < maxRetries - 1) { - await new Promise((resolve) => setTimeout(resolve, delay)) - } - } - } - - throw lastError -} -``` - -### 9. Add Loading States - -**Track loading states:** - -```typescript -interface LoadingState { - fetchingSessions: Map - creatingSession: Map - deletingSession: Map> -} - -const loading: LoadingState = { - fetchingSessions: new Map(), - creatingSession: new Map(), - deletingSession: new Map(), -} -``` - -**Use in actions:** - -```typescript -async function fetchSessions(instanceId: string) { - loading.fetchingSessions.set(instanceId, true) - try { - // ... fetch logic - } finally { - loading.fetchingSessions.set(instanceId, false) - } -} -``` - -### 10. Wire Up to Instance Creation - -**src/stores/instances.ts updates:** - -**After server ready:** - -```typescript -async function createInstance(folder: string) { - // ... spawn server ... - - // Create SDK client - const client = sdkManager.createClient(port) - - // Update instance - instances.set(id, { - ...instances.get(id)!, - port, - pid, - client, - status: "ready", - }) - - // Fetch initial data - try { - await fetchSessions(id) - await fetchAgents(id) - await fetchProviders(id) - } catch (error) { - console.error("Failed to fetch initial data:", error) - // Don't fail instance creation, just log - } - - return id -} -``` - -### 11. Add Type Safety - -**src/types/session.ts:** - -```typescript -export interface Session { - id: string - instanceId: string - title: string - parentId: string | null - agent: string - model: { - providerId: string - modelId: string - } - time: { - created: number - updated: number - } -} - -export interface Agent { - name: string - description: string - mode: string -} - -export interface Provider { - id: string - name: string - models: Model[] -} - -export interface Model { - id: string - name: string - providerId: string -} -``` - -## Testing Checklist - -**Manual Tests:** - -1. Create instance → Sessions fetched automatically -2. Console shows session list -3. Create new session → Appears in list -4. Delete session → Removed from list -5. Network fails → Error message shown -6. Server not running → Graceful error - -**Error Cases:** - -- Server not responding (ECONNREFUSED) -- Request timeout -- 404 on session endpoint -- 500 server error -- Invalid session ID - -**Edge Cases:** - -- No sessions exist (empty list) -- Many sessions (100+) -- Session with very long title -- Parent-child session relationships - -## Dependencies - -- **Blocks:** Task 005 (needs session data) -- **Blocked by:** Task 003 (needs running server) - -## Estimated Time - -3-4 hours - -## Notes - -- Keep SDK calls isolated in store actions -- All SDK calls should have error handling -- Consider caching to reduce API calls -- Log all API calls for debugging -- Handle slow connections gracefully diff --git a/tasks/done/005-session-picker-modal.md b/tasks/done/005-session-picker-modal.md deleted file mode 100644 index 943d28ce5..000000000 --- a/tasks/done/005-session-picker-modal.md +++ /dev/null @@ -1,333 +0,0 @@ -# Task 005: Session Picker Modal - -## Goal - -Create the session picker modal that appears when an instance starts, allowing users to resume an existing session or create a new one. - -## Prerequisites - -- Task 004 completed (SDK integration, session fetching) -- Understanding of modal/dialog patterns -- Kobalte UI primitives knowledge - -## Acceptance Criteria - -- [ ] Modal appears after instance becomes ready -- [ ] Displays list of existing sessions -- [ ] Shows session metadata (title, timestamp) -- [ ] Allows creating new session with agent selection -- [ ] Can close modal (cancels instance creation) -- [ ] Keyboard navigation works (up/down, enter) -- [ ] Properly styled and accessible -- [ ] Loading states during fetch - -## Steps - -### 1. Create Session Picker Component - -**src/components/session-picker.tsx:** - -**Props:** - -```typescript -interface SessionPickerProps { - instanceId: string - open: boolean - onClose: () => void - onSessionSelect: (sessionId: string) => void - onNewSession: (agent: string) => void -} -``` - -**Structure:** - -- Modal backdrop (semi-transparent overlay) -- Modal dialog (centered card) -- Header: "OpenCode • {folder}" -- Section 1: Resume session list -- Separator: "or" -- Section 2: Create new session -- Footer: Cancel button - -### 2. Use Kobalte Dialog - -**Implementation approach:** - -```typescript -import { Dialog } from '@kobalte/core' - - !open && props.onClose()}> - - - - {/* Modal content */} - - - -``` - -**Styling:** - -- Overlay: Dark background, 50% opacity -- Content: White card, max-width 500px, centered -- Padding: 24px -- Border radius: 8px -- Shadow: Large elevation - -### 3. Create Session List Section - -**Resume Section:** - -- Header: "Resume a session:" -- List of sessions (max 10 recent) -- Each item shows: - - Title (truncated at 50 chars) - - Relative timestamp ("2h ago") - - Hover state - - Active selection state - -**Session Item Component:** - -```typescript -interface SessionItemProps { - session: Session - selected: boolean - onClick: () => void -} -``` - -**Empty state:** - -- Show when no sessions exist -- Text: "No previous sessions" -- Muted styling - -**Scrollable:** - -- If >5 sessions, add scroll -- Max height: 300px - -### 4. Create New Session Section - -**Structure:** - -- Header: "Start new session:" -- Agent selector dropdown -- "Start" button - -**Agent Selector:** - -- Dropdown using Kobalte Select -- Shows agent name -- Grouped by category if applicable -- Default: "Build" agent - -**Start Button:** - -- Primary button style -- Click triggers onNewSession callback -- Disabled while creating - -### 5. Add Loading States - -**While fetching sessions:** - -- Show skeleton list (3-4 placeholder items) -- Shimmer animation - -**While fetching agents:** - -- Agent dropdown shows "Loading..." -- Disabled state - -**While creating session:** - -- Start button shows spinner -- Disabled state -- Text changes to "Creating..." - -### 6. Wire Up to Instance Store - -**Show modal after instance ready:** - -**src/stores/ui.ts additions:** - -```typescript -interface UIStore { - sessionPickerInstance: string | null -} - -function showSessionPicker(instanceId: string) { - sessionPickerInstance = instanceId -} - -function hideSessionPicker() { - sessionPickerInstance = null -} -``` - -**src/stores/instances.ts updates:** - -```typescript -async function createInstance(folder: string) { - // ... spawn and connect ... - - // Show session picker - showSessionPicker(id) - - return id -} -``` - -### 7. Handle Session Selection - -**Resume session:** - -```typescript -function handleSessionSelect(sessionId: string) { - setActiveSession(instanceId, sessionId) - hideSessionPicker() - - // Will trigger session display in Task 006 -} -``` - -**Create new session:** - -```typescript -async function handleNewSession(agent: string) { - try { - const session = await createSession(instanceId, agent) - setActiveSession(instanceId, session.id) - hideSessionPicker() - } catch (error) { - // Show error toast (Task 010) - console.error("Failed to create session:", error) - } -} -``` - -### 8. Handle Cancel - -**Close modal:** - -```typescript -function handleClose() { - // Remove instance since user cancelled - await removeInstance(instanceId) - hideSessionPicker() -} -``` - -**Confirmation if needed:** - -- If server already started, ask "Stop server?" -- Otherwise, just close - -### 9. Add Keyboard Navigation - -**Keyboard shortcuts:** - -- Up/Down: Navigate session list -- Enter: Select highlighted session -- Escape: Close modal (cancel) -- Tab: Cycle through sections - -**Implement focus management:** - -- Auto-focus first session on open -- Trap focus within modal -- Restore focus on close - -### 10. Add Accessibility - -**ARIA attributes:** - -- `role="dialog"` -- `aria-labelledby="dialog-title"` -- `aria-describedby="dialog-description"` -- `aria-modal="true"` - -**Screen reader support:** - -- Announce "X sessions available" -- Announce selection changes -- Clear focus indicators - -### 11. Style Refinements - -**Light/Dark mode:** - -- Test in both themes -- Ensure contrast meets WCAG AA -- Use CSS variables for colors - -**Responsive:** - -- Works at minimum window size -- Mobile-friendly (future web version) -- Scales text appropriately - -**Animations:** - -- Fade in backdrop (200ms) -- Scale in content (200ms) -- Smooth transitions on hover - -### 12. Update App Component - -**src/App.tsx:** - -**Render session picker:** - -```typescript - - {(instanceId) => ( - ui.hideSessionPicker()} - onSessionSelect={(id) => handleSessionSelect(instanceId(), id)} - onNewSession={(agent) => handleNewSession(instanceId(), agent)} - /> - )} - -``` - -## Testing Checklist - -**Manual Tests:** - -1. Create instance → Modal appears -2. Shows session list if sessions exist -3. Shows empty state if no sessions -4. Click session → Modal closes, session activates -5. Select agent, click Start → New session created -6. Press Escape → Modal closes, instance removed -7. Keyboard navigation works -8. Screen reader announces content - -**Edge Cases:** - -- No sessions + no agents (error state) -- Very long session titles (truncate) -- Many sessions (scroll works) -- Create session fails (error shown) -- Slow network (loading states) - -## Dependencies - -- **Blocks:** Task 006 (needs active session) -- **Blocked by:** Task 004 (needs session data) - -## Estimated Time - -3-4 hours - -## Notes - -- Keep modal simple and focused -- Clear call-to-action -- Don't overwhelm with options -- Loading states crucial for UX -- Consider adding search if >20 sessions (future) diff --git a/tasks/done/006-instance-session-tabs.md b/tasks/done/006-instance-session-tabs.md deleted file mode 100644 index 1188a754c..000000000 --- a/tasks/done/006-instance-session-tabs.md +++ /dev/null @@ -1,591 +0,0 @@ -# Task 006: Instance & Session Tabs - -## Goal - -Create the two-level tab navigation system: instance tabs (Level 1) and session tabs (Level 2) that allow users to switch between projects and conversations. - -## Prerequisites - -- Task 005 completed (Session picker modal, active session selection) -- Understanding of tab navigation patterns -- Familiarity with SolidJS For/Show components -- Knowledge of keyboard accessibility - -## Acceptance Criteria - -- [ ] Instance tabs render at top level -- [ ] Session tabs render below instance tabs for active instance -- [ ] Can switch between instance tabs -- [ ] Can switch between session tabs within an instance -- [ ] Active tab is visually highlighted -- [ ] Tab labels show appropriate text (folder name, session title) -- [ ] Close buttons work on tabs (with confirmation) -- [ ] "+" button creates new instance/session -- [ ] Keyboard navigation works (Cmd/Ctrl+1-9 for tabs) -- [ ] Tabs scroll horizontally when many exist -- [ ] Properly styled and accessible - -## Steps - -### 1. Create Instance Tabs Component - -**src/components/instance-tabs.tsx:** - -**Props:** - -```typescript -interface InstanceTabsProps { - instances: Map - activeInstanceId: string | null - onSelect: (instanceId: string) => void - onClose: (instanceId: string) => void - onNew: () => void -} -``` - -**Structure:** - -```tsx -
-
- - {([id, instance]) => ( - onSelect(id)} - onClose={() => onClose(id)} - /> - )} - - -
-
-``` - -**Styling:** - -- Horizontal layout -- Background: Secondary background color -- Border bottom: 1px solid border color -- Height: 40px -- Padding: 0 8px -- Overflow-x: auto (for many tabs) - -### 2. Create Instance Tab Item Component - -**src/components/instance-tab.tsx:** - -**Props:** - -```typescript -interface InstanceTabProps { - instance: Instance - active: boolean - onSelect: () => void - onClose: () => void -} -``` - -**Structure:** - -```tsx - - -``` - -**Styling:** - -- Display: inline-flex -- Align items center -- Gap: 8px -- Padding: 8px 12px -- Border radius: 6px 6px 0 0 -- Max width: 200px -- Truncate text with ellipsis -- Active: Background accent color -- Inactive: Transparent background -- Hover: Light background - -**Folder Name Formatting:** - -```typescript -function formatFolderName(path: string): string { - const name = path.split("/").pop() || path - return `~/${name}` -} -``` - -**Handle Duplicates:** - -- If multiple instances have same folder name, add counter -- Example: `~/project`, `~/project (2)`, `~/project (3)` - -### 3. Create Session Tabs Component - -**src/components/session-tabs.tsx:** - -**Props:** - -```typescript -interface SessionTabsProps { - instanceId: string - sessions: Map - activeSessionId: string | null - onSelect: (sessionId: string) => void - onClose: (sessionId: string) => void - onNew: () => void -} -``` - -**Structure:** - -```tsx -
-
- - {([id, session]) => ( - onSelect(id)} - onClose={() => onClose(id)} - /> - )} - - onSelect("logs")} /> - -
-
-``` - -**Styling:** - -- Similar to instance tabs but smaller -- Height: 36px -- Font size: 13px -- Less prominent than instance tabs - -### 4. Create Session Tab Item Component - -**src/components/session-tab.tsx:** - -**Props:** - -```typescript -interface SessionTabProps { - session?: Session - special?: "logs" - active: boolean - onSelect: () => void - onClose?: () => void -} -``` - -**Structure:** - -```tsx - - - -``` - -**Styling:** - -- Max width: 150px -- Truncate with ellipsis -- Active: Underline or bold text -- Logs tab: Slightly different color/icon - -### 5. Add Tab State Management - -**src/stores/ui.ts updates:** - -```typescript -interface UIState { - instanceTabOrder: string[] - sessionTabOrder: Map - - reorderInstanceTabs: (newOrder: string[]) => void - reorderSessionTabs: (instanceId: string, newOrder: string[]) => void -} - -const [instanceTabOrder, setInstanceTabOrder] = createSignal([]) -const [sessionTabOrder, setSessionTabOrder] = createSignal>(new Map()) - -function reorderInstanceTabs(newOrder: string[]) { - setInstanceTabOrder(newOrder) -} - -function reorderSessionTabs(instanceId: string, newOrder: string[]) { - setSessionTabOrder((prev) => { - const next = new Map(prev) - next.set(instanceId, newOrder) - return next - }) -} -``` - -### 6. Wire Up Tab Selection - -**src/stores/instances.ts updates:** - -```typescript -function setActiveInstance(id: string) { - activeInstanceId = id - - // Auto-select first session or show session picker - const instance = instances.get(id) - if (instance) { - const sessions = Array.from(instance.sessions.values()) - if (sessions.length > 0 && !instance.activeSessionId) { - instance.activeSessionId = sessions[0].id - } - } -} - -function setActiveSession(instanceId: string, sessionId: string) { - const instance = instances.get(instanceId) - if (instance) { - instance.activeSessionId = sessionId - } -} -``` - -### 7. Handle Tab Close Actions - -**Close Instance Tab:** - -```typescript -async function handleCloseInstance(instanceId: string) { - const confirmed = await showConfirmDialog({ - title: "Stop OpenCode instance?", - message: `This will stop the server for ${instance.folder}`, - confirmText: "Stop Instance", - destructive: true, - }) - - if (confirmed) { - await removeInstance(instanceId) - } -} -``` - -**Close Session Tab:** - -```typescript -async function handleCloseSession(instanceId: string, sessionId: string) { - const session = getInstance(instanceId)?.sessions.get(sessionId) - - if (session && session.messages.length > 0) { - const confirmed = await showConfirmDialog({ - title: "Delete session?", - message: `This will permanently delete "${session.title}"`, - confirmText: "Delete", - destructive: true, - }) - - if (!confirmed) return - } - - await deleteSession(instanceId, sessionId) - - // Switch to another session - const instance = getInstance(instanceId) - const remainingSessions = Array.from(instance.sessions.values()) - if (remainingSessions.length > 0) { - setActiveSession(instanceId, remainingSessions[0].id) - } else { - // Show session picker - showSessionPicker(instanceId) - } -} -``` - -### 8. Handle New Tab Buttons - -**New Instance:** - -```typescript -async function handleNewInstance() { - const folder = await window.electronAPI.selectFolder() - if (folder) { - await createInstance(folder) - } -} -``` - -**New Session:** - -```typescript -async function handleNewSession(instanceId: string) { - // For now, use default agent - // Later (Task 011) will show agent selector - const session = await createSession(instanceId, "build") - setActiveSession(instanceId, session.id) -} -``` - -### 9. Update App Layout - -**src/App.tsx:** - -```tsx -
- 0} fallback={}> - - - - {(instance) => ( - <> - setActiveSession(instance().id, id)} - onClose={(id) => handleCloseSession(instance().id, id)} - onNew={() => handleNewSession(instance().id)} - /> - -
- {/* Message stream and input will go here in Task 007 */} - - - - -
Session content will appear here (Task 007)
-
-
- - )} -
-
-
-``` - -### 10. Add Keyboard Shortcuts - -**Keyboard navigation:** - -```typescript -// src/lib/keyboard.ts - -export function setupTabKeyboardShortcuts() { - window.addEventListener("keydown", (e) => { - // Cmd/Ctrl + 1-9: Switch instance tabs - if ((e.metaKey || e.ctrlKey) && e.key >= "1" && e.key <= "9") { - e.preventDefault() - const index = parseInt(e.key) - 1 - const instances = Array.from(instanceStore.instances.keys()) - if (instances[index]) { - setActiveInstance(instances[index]) - } - } - - // Cmd/Ctrl + N: New instance - if ((e.metaKey || e.ctrlKey) && e.key === "n") { - e.preventDefault() - handleNewInstance() - } - - // Cmd/Ctrl + T: New session - if ((e.metaKey || e.ctrlKey) && e.key === "t") { - e.preventDefault() - if (activeInstanceId()) { - handleNewSession(activeInstanceId()!) - } - } - - // Cmd/Ctrl + W: Close current tab - if ((e.metaKey || e.ctrlKey) && e.key === "w") { - e.preventDefault() - const instanceId = activeInstanceId() - const instance = getInstance(instanceId) - if (instance?.activeSessionId && instance.activeSessionId !== "logs") { - handleCloseSession(instanceId!, instance.activeSessionId) - } - } - }) -} -``` - -**Call in main.tsx:** - -```typescript -import { setupTabKeyboardShortcuts } from "./lib/keyboard" - -onMount(() => { - setupTabKeyboardShortcuts() -}) -``` - -### 11. Add Accessibility - -**ARIA attributes:** - -```tsx -
- -
- -
- {/* Session tabs */} -
-``` - -**Focus management:** - -- Tab key cycles through tabs -- Arrow keys navigate within tab list -- Focus indicators visible -- Skip links for screen readers - -### 12. Style Refinements - -**Horizontal scroll:** - -```css -.tabs-container { - display: flex; - overflow-x: auto; - overflow-y: hidden; - scrollbar-width: thin; -} - -.tabs-container::-webkit-scrollbar { - height: 4px; -} - -.tabs-container::-webkit-scrollbar-thumb { - background: var(--border-color); - border-radius: 2px; -} -``` - -**Tab animations:** - -```css -.instance-tab, -.session-tab { - transition: background-color 150ms ease; -} - -.instance-tab:hover, -.session-tab:hover { - background-color: var(--hover-background); -} - -.instance-tab.active, -.session-tab.active { - background-color: var(--active-background); -} -``` - -**Close button styling:** - -```css -.tab-close { - opacity: 0; - transition: opacity 150ms ease; -} - -.instance-tab:hover .tab-close, -.session-tab:hover .tab-close { - opacity: 1; -} - -.tab-close:hover { - background-color: var(--danger-background); - color: var(--danger-color); -} -``` - -## Testing Checklist - -**Manual Tests:** - -1. Create instance → Instance tab appears -2. Click instance tab → Switches active instance -3. Session tabs appear below active instance -4. Click session tab → Switches active session -5. Click "+" on instance tabs → Opens folder picker -6. Click "+" on session tabs → Creates new session -7. Click close on instance tab → Shows confirmation, closes -8. Click close on session tab → Closes session -9. Cmd/Ctrl+1 switches to first instance -10. Cmd/Ctrl+N opens new instance -11. Cmd/Ctrl+T creates new session -12. Cmd/Ctrl+W closes active session -13. Tabs scroll when many exist -14. Logs tab always visible and non-closable -15. Tab labels truncate long names - -**Edge Cases:** - -- Only one instance (no scrolling needed) -- Many instances (>10, horizontal scroll) -- No sessions in instance (only Logs tab visible) -- Duplicate folder names (counter added) -- Very long folder/session names (ellipsis) -- Close last session (session picker appears) -- Switch instance while session is streaming - -## Dependencies - -- **Blocks:** Task 007 (needs tab structure to display messages) -- **Blocked by:** Task 005 (needs session selection to work) - -## Estimated Time - -4-5 hours - -## Notes - -- Keep tab design clean and minimal -- Don't over-engineer tab reordering (can add later) -- Focus on functionality over fancy animations -- Ensure keyboard accessibility from the start -- Tab state will persist in Task 017 -- Context menus for tabs can be added in Task 026 diff --git a/tasks/done/007-message-display.md b/tasks/done/007-message-display.md deleted file mode 100644 index 6c01510dd..000000000 --- a/tasks/done/007-message-display.md +++ /dev/null @@ -1,812 +0,0 @@ -# Task 007: Message Display - -## Goal - -Create the message display component that renders user and assistant messages in a scrollable stream, showing message content, tool calls, and streaming states. - -> Note: This legacy task predates `message-stream-v2` and the normalized message store; the new implementation lives under `packages/ui/src/components/message-stream-v2.tsx`. - -## Prerequisites - -- Task 006 completed (Tab navigation in place) -- Understanding of message part structure from OpenCode SDK -- Familiarity with markdown rendering -- Knowledge of SolidJS For/Show components - -## Acceptance Criteria - -- [ ] Messages render in chronological order -- [ ] User messages display with correct styling -- [ ] Assistant messages display with agent label -- [ ] Text content renders properly -- [ ] Tool calls display inline with collapse/expand -- [ ] Auto-scroll to bottom on new messages -- [ ] Manual scroll up disables auto-scroll -- [ ] "Scroll to bottom" button appears when scrolled up -- [ ] Empty state shows when no messages -- [ ] Loading state shows when fetching messages -- [ ] Timestamps display for each message -- [ ] Messages are accessible and keyboard-navigable - -## Steps - -### 1. Define Message Types - -**src/types/message.ts:** - -```typescript -export interface Message { - id: string - sessionId: string - type: "user" | "assistant" - parts: MessagePart[] - timestamp: number - status: "sending" | "sent" | "streaming" | "complete" | "error" -} - -export type MessagePart = TextPart | ToolCallPart | ToolResultPart | ErrorPart - -export interface TextPart { - type: "text" - text: string -} - -export interface ToolCallPart { - type: "tool_call" - id: string - tool: string - input: any - status: "pending" | "running" | "success" | "error" -} - -export interface ToolResultPart { - type: "tool_result" - toolCallId: string - output: any - error?: string -} - -export interface ErrorPart { - type: "error" - message: string -} -``` - -### 2. Create Message Stream Component - -**src/components/message-stream.tsx:** - -```typescript -import { For, Show, createSignal, onMount, onCleanup } from "solid-js" -import { Message } from "../types/message" -import MessageItem from "./message-item" - -interface MessageStreamProps { - sessionId: string - messages: Message[] - loading?: boolean -} - -export default function MessageStream(props: MessageStreamProps) { - let containerRef: HTMLDivElement | undefined - const [autoScroll, setAutoScroll] = createSignal(true) - const [showScrollButton, setShowScrollButton] = createSignal(false) - - function scrollToBottom() { - if (containerRef) { - containerRef.scrollTop = containerRef.scrollHeight - setAutoScroll(true) - setShowScrollButton(false) - } - } - - function handleScroll() { - if (!containerRef) return - - const { scrollTop, scrollHeight, clientHeight } = containerRef - const isAtBottom = scrollHeight - scrollTop - clientHeight < 50 - - setAutoScroll(isAtBottom) - setShowScrollButton(!isAtBottom) - } - - onMount(() => { - if (autoScroll()) { - scrollToBottom() - } - }) - - // Auto-scroll when new messages arrive - const messagesLength = () => props.messages.length - createEffect(() => { - messagesLength() // Track changes - if (autoScroll()) { - setTimeout(scrollToBottom, 0) - } - }) - - return ( -
-
- -
-
-

Start a conversation

-

Type a message below or try:

-
    -
  • /init-project
  • -
  • Ask about your codebase
  • -
  • Attach files with @
  • -
-
-
-
- - -
-
-

Loading messages...

-
- - - - {(message) => ( - - )} - -
- - - - -
- ) -} -``` - -### 3. Create Message Item Component - -**src/components/message-item.tsx:** - -```typescript -import { For, Show } from "solid-js" -import { Message } from "../types/message" -import MessagePart from "./message-part" - -interface MessageItemProps { - message: Message -} - -export default function MessageItem(props: MessageItemProps) { - const isUser = () => props.message.type === "user" - const timestamp = () => { - const date = new Date(props.message.timestamp) - return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) - } - - return ( -
-
- - {isUser() ? "You" : "Assistant"} - - {timestamp()} -
- -
- - {(part) => } - -
- - -
- ⚠ Message failed to send -
-
-
- ) -} -``` - -### 4. Create Message Part Component - -**src/components/message-part.tsx:** - -```typescript -import { Show, Match, Switch } from "solid-js" -import { MessagePart as MessagePartType } from "../types/message" -import ToolCall from "./tool-call" - -interface MessagePartProps { - part: MessagePartType -} - -export default function MessagePart(props: MessagePartProps) { - return ( - - -
- {(props.part as any).text} -
-
- - - - - - -
- ⚠ {(props.part as any).message} -
-
-
- ) -} -``` - -### 5. Create Tool Call Component - -**src/components/tool-call.tsx:** - -```typescript -import { createSignal, Show } from "solid-js" -import { ToolCallPart } from "../types/message" - -interface ToolCallProps { - toolCall: ToolCallPart -} - -export default function ToolCall(props: ToolCallProps) { - const [expanded, setExpanded] = createSignal(false) - - const statusIcon = () => { - switch (props.toolCall.status) { - case "pending": - return "⏳" - case "running": - return "⏳" - case "success": - return "✓" - case "error": - return "✗" - default: - return "" - } - } - - const statusClass = () => { - return `tool-call-status-${props.toolCall.status}` - } - - function toggleExpanded() { - setExpanded(!expanded()) - } - - function formatToolSummary() { - // Create a brief summary of the tool call - const { tool, input } = props.toolCall - - switch (tool) { - case "bash": - return `bash: ${input.command}` - case "edit": - return `edit ${input.filePath}` - case "read": - return `read ${input.filePath}` - case "write": - return `write ${input.filePath}` - default: - return `${tool}` - } - } - - return ( -
- - - -
-
-

Input:

-
{JSON.stringify(props.toolCall.input, null, 2)}
-
- - -
-

Output:

-
{formatToolOutput()}
-
-
-
-
-
- ) - - function formatToolOutput() { - // This will be enhanced in later tasks - // For now, just stringify - return "Output will be displayed here" - } -} -``` - -### 6. Add Message Store Integration - -**src/stores/sessions.ts updates:** - -```typescript -interface Session { - // ... existing fields - messages: Message[] -} - -async function loadMessages(instanceId: string, sessionId: string) { - const instance = getInstance(instanceId) - if (!instance) return - - try { - // Fetch messages from SDK - const response = await instance.client.session.getMessages(sessionId) - - // Update session with messages - const session = instance.sessions.get(sessionId) - if (session) { - session.messages = response.messages.map(transformMessage) - } - } catch (error) { - console.error("Failed to load messages:", error) - throw error - } -} - -function transformMessage(apiMessage: any): Message { - return { - id: apiMessage.id, - sessionId: apiMessage.sessionId, - type: apiMessage.type, - parts: apiMessage.parts || [], - timestamp: apiMessage.timestamp || Date.now(), - status: "complete", - } -} -``` - -### 7. Update App to Show Messages - -**src/App.tsx updates:** - -```tsx - - {() => { - const session = instance().sessions.get(instance().activeSessionId!) - - return ( - Session not found
}> - {(s) => } - - ) - }} - -``` - -### 8. Add Styling - -**src/components/message-stream.css:** - -```css -.message-stream-container { - position: relative; - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.message-stream { - flex: 1; - overflow-y: auto; - padding: 16px; - display: flex; - flex-direction: column; - gap: 16px; -} - -.message-item { - display: flex; - flex-direction: column; - gap: 8px; - padding: 12px 16px; - border-radius: 8px; - max-width: 85%; -} - -.message-item.user { - align-self: flex-end; - background-color: var(--user-message-bg); -} - -.message-item.assistant { - align-self: flex-start; - background-color: var(--assistant-message-bg); -} - -.message-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; -} - -.message-sender { - font-weight: 600; - font-size: 14px; -} - -.message-timestamp { - font-size: 12px; - color: var(--text-muted); -} - -.message-content { - display: flex; - flex-direction: column; - gap: 8px; -} - -.message-text { - font-size: 14px; - line-height: 1.5; - white-space: pre-wrap; - word-wrap: break-word; -} - -.tool-call { - margin: 8px 0; - border: 1px solid var(--border-color); - border-radius: 6px; - overflow: hidden; -} - -.tool-call-header { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - width: 100%; - background-color: var(--secondary-bg); - border: none; - cursor: pointer; - font-family: monospace; - font-size: 13px; -} - -.tool-call-header:hover { - background-color: var(--hover-bg); -} - -.tool-call-icon { - font-size: 10px; -} - -.tool-call-summary { - flex: 1; - text-align: left; -} - -.tool-call-status { - font-size: 14px; -} - -.tool-call-status-success { - border-left: 3px solid var(--success-color); -} - -.tool-call-status-error { - border-left: 3px solid var(--error-color); -} - -.tool-call-status-running { - border-left: 3px solid var(--warning-color); -} - -.tool-call-details { - padding: 12px; - background-color: var(--code-bg); - display: flex; - flex-direction: column; - gap: 12px; -} - -.tool-call-section h4 { - font-size: 12px; - font-weight: 600; - margin-bottom: 4px; - color: var(--text-muted); -} - -.tool-call-section pre { - margin: 0; - padding: 8px; - background-color: var(--background); - border-radius: 4px; - overflow-x: auto; -} - -.tool-call-section code { - font-family: monospace; - font-size: 12px; - line-height: 1.4; -} - -.scroll-to-bottom { - position: absolute; - bottom: 16px; - right: 16px; - width: 40px; - height: 40px; - border-radius: 50%; - background-color: var(--accent-color); - color: white; - border: none; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); - cursor: pointer; - font-size: 20px; - display: flex; - align-items: center; - justify-content: center; - transition: transform 150ms ease; -} - -.scroll-to-bottom:hover { - transform: scale(1.1); -} - -.empty-state { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - padding: 48px; -} - -.empty-state-content { - text-align: center; - max-width: 400px; -} - -.empty-state-content h3 { - font-size: 18px; - margin-bottom: 12px; -} - -.empty-state-content p { - font-size: 14px; - color: var(--text-muted); - margin-bottom: 16px; -} - -.empty-state-content ul { - list-style: none; - padding: 0; - display: flex; - flex-direction: column; - gap: 8px; -} - -.empty-state-content li { - font-size: 14px; - color: var(--text-muted); -} - -.empty-state-content code { - background-color: var(--code-bg); - padding: 2px 6px; - border-radius: 3px; - font-family: monospace; - font-size: 13px; -} - -.loading-state { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 16px; - padding: 48px; -} - -.spinner { - width: 32px; - height: 32px; - border: 3px solid var(--border-color); - border-top-color: var(--accent-color); - border-radius: 50%; - animation: spin 1s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} -``` - -### 9. Add CSS Variables - -**src/index.css updates:** - -```css -:root { - /* Message colors */ - --user-message-bg: #e3f2fd; - --assistant-message-bg: #f5f5f5; - - /* Status colors */ - --success-color: #4caf50; - --error-color: #f44336; - --warning-color: #ff9800; - - /* Code colors */ - --code-bg: #f8f8f8; -} - -[data-theme="dark"] { - --user-message-bg: #1e3a5f; - --assistant-message-bg: #2a2a2a; - --code-bg: #1a1a1a; -} -``` - -### 10. Load Messages on Session Switch - -**src/hooks/use-session.ts:** - -```typescript -import { createEffect } from "solid-js" - -export function useSession(instanceId: string, sessionId: string) { - createEffect(() => { - // Load messages when session becomes active - if (sessionId && sessionId !== "logs") { - loadMessages(instanceId, sessionId).catch(console.error) - } - }) -} -``` - -**Use in App.tsx:** - -```tsx - - {(s) => { - useSession(instance().id, s().id) - - return - }} - -``` - -### 11. Add Accessibility - -**ARIA attributes:** - -```tsx -
- {/* Messages */} -
- -
- {/* Message content */} -
-``` - -**Keyboard navigation:** - -- Messages should be accessible via Tab key -- Tool calls can be expanded with Enter/Space -- Screen readers announce new messages - -### 12. Handle Long Messages - -**Text wrapping:** - -```css -.message-text { - overflow-wrap: break-word; - word-wrap: break-word; - hyphens: auto; -} -``` - -**Code blocks (for now, just basic):** - -```css -.message-text pre { - overflow-x: auto; - padding: 8px; - background-color: var(--code-bg); - border-radius: 4px; -} -``` - -## Testing Checklist - -**Manual Tests:** - -1. Empty session shows empty state -2. Messages load when switching sessions -3. User messages appear on right -4. Assistant messages appear on left -5. Timestamps display correctly -6. Tool calls appear inline -7. Tool calls expand/collapse on click -8. Auto-scroll works for new messages -9. Manual scroll up disables auto-scroll -10. Scroll to bottom button appears/works -11. Long messages wrap correctly -12. Multiple messages display properly -13. Messages are keyboard accessible - -**Edge Cases:** - -- Session with 1 message -- Session with 100+ messages -- Messages with very long text -- Messages with no parts -- Tool calls with large output -- Rapid message updates -- Switching sessions while loading - -## Dependencies - -- **Blocks:** Task 008 (SSE will update these messages in real-time) -- **Blocked by:** Task 006 (needs tab structure) - -## Estimated Time - -4-5 hours - -## Notes - -- Keep styling simple for now - markdown rendering comes in Task 012 -- Tool output formatting will be enhanced in Task 010 -- Focus on basic text display and structure -- Don't optimize for virtual scrolling yet (MVP principle) -- Message actions (copy, edit, etc.) come in Task 026 -- This is the foundation for real-time updates in Task 008 diff --git a/tasks/done/008-sse-integration.md b/tasks/done/008-sse-integration.md deleted file mode 100644 index ec49de66f..000000000 --- a/tasks/done/008-sse-integration.md +++ /dev/null @@ -1,445 +0,0 @@ -# Task 008: SSE Integration - Real-time Message Streaming - -> Note: References to `message-stream.tsx` here are legacy; the current UI uses `message-stream-v2.tsx` with the normalized message store. - -## Status: TODO - -## Objective - -Implement Server-Sent Events (SSE) integration to enable real-time message streaming from OpenCode servers. Each instance will maintain its own EventSource connection to receive live updates for sessions and messages. - -## Prerequisites - -- Task 006 (Instance/Session tabs) complete -- Task 007 (Message display) complete -- SDK client configured per instance -- Understanding of EventSource API - -## Context - -The OpenCode server emits events via SSE at the `/events` endpoint. These events include: - -- Message updates (streaming content) -- Session updates (new sessions, title changes) -- Tool execution status updates -- Server status changes - -We need to: - -1. Create an SSE manager to handle connections -2. Connect one EventSource per instance -3. Route events to the correct instance/session -4. Update reactive state to trigger UI updates -5. Implement reconnection logic for dropped connections - -## Implementation Steps - -### Step 1: Create SSE Manager Module - -Create `src/lib/sse-manager.ts`: - -```typescript -import { createSignal } from "solid-js" - -interface SSEConnection { - instanceId: string - eventSource: EventSource - reconnectAttempts: number - status: "connecting" | "connected" | "disconnected" | "error" -} - -interface MessageUpdateEvent { - type: "message_updated" - sessionId: string - messageId: string - parts: any[] - status: string -} - -interface SessionUpdateEvent { - type: "session_updated" - session: any -} - -class SSEManager { - private connections = new Map() - private maxReconnectAttempts = 5 - private baseReconnectDelay = 1000 - - connect(instanceId: string, port: number): void { - if (this.connections.has(instanceId)) { - this.disconnect(instanceId) - } - - const url = `http://localhost:${port}/events` - const eventSource = new EventSource(url) - - const connection: SSEConnection = { - instanceId, - eventSource, - reconnectAttempts: 0, - status: "connecting", - } - - this.connections.set(instanceId, connection) - - eventSource.onopen = () => { - connection.status = "connected" - connection.reconnectAttempts = 0 - console.log(`[SSE] Connected to instance ${instanceId}`) - } - - eventSource.onmessage = (event) => { - try { - const data = JSON.parse(event.data) - this.handleEvent(instanceId, data) - } catch (error) { - console.error("[SSE] Failed to parse event:", error) - } - } - - eventSource.onerror = () => { - connection.status = "error" - console.error(`[SSE] Connection error for instance ${instanceId}`) - this.handleReconnect(instanceId, port) - } - } - - disconnect(instanceId: string): void { - const connection = this.connections.get(instanceId) - if (connection) { - connection.eventSource.close() - this.connections.delete(instanceId) - console.log(`[SSE] Disconnected from instance ${instanceId}`) - } - } - - private handleEvent(instanceId: string, event: any): void { - switch (event.type) { - case "message_updated": - this.onMessageUpdate?.(instanceId, event as MessageUpdateEvent) - break - case "session_updated": - this.onSessionUpdate?.(instanceId, event as SessionUpdateEvent) - break - default: - console.warn("[SSE] Unknown event type:", event.type) - } - } - - private handleReconnect(instanceId: string, port: number): void { - const connection = this.connections.get(instanceId) - if (!connection) return - - if (connection.reconnectAttempts >= this.maxReconnectAttempts) { - console.error(`[SSE] Max reconnection attempts reached for ${instanceId}`) - connection.status = "disconnected" - return - } - - const delay = this.baseReconnectDelay * Math.pow(2, connection.reconnectAttempts) - connection.reconnectAttempts++ - - console.log(`[SSE] Reconnecting to ${instanceId} in ${delay}ms (attempt ${connection.reconnectAttempts})`) - - setTimeout(() => { - this.connect(instanceId, port) - }, delay) - } - - onMessageUpdate?: (instanceId: string, event: MessageUpdateEvent) => void - onSessionUpdate?: (instanceId: string, event: SessionUpdateEvent) => void - - getStatus(instanceId: string): SSEConnection["status"] | null { - return this.connections.get(instanceId)?.status ?? null - } -} - -export const sseManager = new SSEManager() -``` - -### Step 2: Integrate SSE Manager with Instance Store - -Update `src/stores/instances.ts` to use SSE manager: - -```typescript -import { sseManager } from "../lib/sse-manager" - -// In createInstance function, after SDK client is created: -async function createInstance(folder: string) { - // ... existing code to spawn server and create SDK client ... - - // Connect SSE - sseManager.connect(instance.id, port) - - // Set up event handlers - sseManager.onMessageUpdate = (instanceId, event) => { - handleMessageUpdate(instanceId, event) - } - - sseManager.onSessionUpdate = (instanceId, event) => { - handleSessionUpdate(instanceId, event) - } -} - -// In removeInstance function: -async function removeInstance(id: string) { - // Disconnect SSE before removing - sseManager.disconnect(id) - - // ... existing cleanup code ... -} -``` - -### Step 3: Handle Message Update Events - -Create message update handler in instance store: - -```typescript -function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent) { - const instance = instances.get(instanceId) - if (!instance) return - - const session = instance.sessions.get(event.sessionId) - if (!session) return - - // Find or create message - let message = session.messages.find((m) => m.id === event.messageId) - - if (!message) { - // New message - add it - message = { - id: event.messageId, - sessionId: event.sessionId, - type: "assistant", // Determine from event - parts: event.parts, - timestamp: Date.now(), - status: event.status, - } - session.messages.push(message) - } else { - // Update existing message - message.parts = event.parts - message.status = event.status - } - - // Trigger reactivity - update the map reference - instances.set(instanceId, { ...instance }) -} -``` - -### Step 4: Handle Session Update Events - -Create session update handler: - -```typescript -function handleSessionUpdate(instanceId: string, event: SessionUpdateEvent) { - const instance = instances.get(instanceId) - if (!instance) return - - const existingSession = instance.sessions.get(event.session.id) - - if (!existingSession) { - // New session - add it - const newSession = { - id: event.session.id, - instanceId, - title: event.session.title || "Untitled", - parentId: event.session.parentId, - agent: event.session.agent, - model: event.session.model, - messages: [], - status: "idle", - createdAt: Date.now(), - updatedAt: Date.now(), - } - - instance.sessions.set(event.session.id, newSession) - - // Auto-create tab for child sessions - if (event.session.parentId) { - console.log(`[SSE] New child session created: ${event.session.id}`) - // Optionally auto-switch to new session - // instance.activeSessionId = event.session.id - } - } else { - // Update existing session - existingSession.title = event.session.title || existingSession.title - existingSession.agent = event.session.agent || existingSession.agent - existingSession.model = event.session.model || existingSession.model - existingSession.updatedAt = Date.now() - } - - // Trigger reactivity - instances.set(instanceId, { ...instance }) -} -``` - -### Step 5: Add Connection Status Indicator - -Update `src/components/message-stream.tsx` to show connection status: - -```typescript -import { sseManager } from "../lib/sse-manager" - -function MessageStream(props) { - const connectionStatus = () => sseManager.getStatus(props.instanceId) - - return ( -
- {/* Connection status indicator */} -
- {connectionStatus() === "connected" && ( - -
- Connected - - )} - {connectionStatus() === "connecting" && ( - -
- Connecting... - - )} - {connectionStatus() === "error" && ( - -
- Disconnected - - )} -
- - {/* Existing message list */} - {/* ... */} -
- ) -} -``` - -### Step 6: Test SSE Connection - -Create a test utility to verify SSE is working: - -```typescript -// In browser console or test file: -async function testSSE() { - // Manually trigger a message - const response = await fetch("http://localhost:4096/session/SESSION_ID/message", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - prompt: "Hello, world!", - attachments: [], - }), - }) - - // Check console for SSE events - // Should see message_updated events arriving -} -``` - -### Step 7: Handle Edge Cases - -Add error handling for: - -```typescript -// Connection drops during message streaming -// - Reconnect logic should handle this automatically -// - Messages should resume from last known state - -// Multiple instances with different ports -// - Each instance has its own EventSource -// - Events routed correctly via instanceId - -// Instance removed while connected -// - EventSource closed before instance cleanup -// - No memory leaks - -// Page visibility changes (browser tab inactive) -// - EventSource may pause, reconnect on focus -// - Consider using Page Visibility API to manage connections -``` - -## Testing Checklist - -### Manual Testing - -- [ ] Open instance, verify SSE connection established -- [ ] Send message, verify streaming events arrive -- [ ] Check browser DevTools Network tab for SSE connection -- [ ] Verify connection status indicator shows "Connected" -- [ ] Kill server process, verify reconnection attempts -- [ ] Restart server, verify successful reconnection -- [ ] Open multiple instances, verify independent connections -- [ ] Switch between instances, verify events route correctly -- [ ] Close instance tab, verify EventSource closed cleanly - -### Testing Message Streaming - -- [ ] Send message, watch events in console -- [ ] Verify message parts update in real-time -- [ ] Check assistant response streams character by character -- [ ] Verify tool calls appear as they execute -- [ ] Confirm message status updates (streaming → complete) - -### Testing Child Sessions - -- [ ] Trigger action that creates child session -- [ ] Verify session_updated event received -- [ ] Confirm new session tab appears -- [ ] Check parentId correctly set - -### Testing Reconnection - -- [ ] Disconnect network, verify reconnection attempts -- [ ] Reconnect network, verify successful reconnection -- [ ] Verify exponential backoff delays -- [ ] Confirm max attempts limit works - -## Acceptance Criteria - -- [ ] SSE connection established when instance created -- [ ] Message updates arrive in real-time -- [ ] Session updates handled correctly -- [ ] Child sessions auto-create tabs -- [ ] Connection status visible in UI -- [ ] Reconnection logic works with exponential backoff -- [ ] Multiple instances have independent connections -- [ ] EventSource closed when instance removed -- [ ] No console errors during normal operation -- [ ] Events route to correct instance/session - -## Performance Considerations - -**Note: Per MVP principles, don't over-optimize** - -- Simple event handling - no batching needed -- Direct state updates trigger reactivity -- Reconnection uses exponential backoff -- Only optimize if lag occurs in testing - -## Future Enhancements (Post-MVP) - -- Event batching for high-frequency updates -- Delta updates instead of full message parts -- Offline queue for events missed during disconnect -- Page Visibility API integration -- Event compression for large payloads - -## References - -- [Technical Implementation - SSE Event Handling](../docs/technical-implementation.md#sse-event-handling) -- [Architecture - Communication Layer](../docs/architecture.md#communication-layer) -- [MDN - EventSource API](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) - -## Estimated Time - -3-4 hours - -## Notes - -- Keep reconnection logic simple for MVP -- Log all SSE events to console for debugging -- Test with long-running streaming responses -- Verify memory usage doesn't grow over time -- Consider adding SSE event debugging panel (optional) diff --git a/tasks/done/009-prompt-input-basic.md b/tasks/done/009-prompt-input-basic.md deleted file mode 100644 index 63c76182f..000000000 --- a/tasks/done/009-prompt-input-basic.md +++ /dev/null @@ -1,520 +0,0 @@ -# Task 009: Prompt Input Basic - Text Input with Send Functionality - -## Status: TODO - -## Objective - -Implement a basic prompt input component that allows users to type messages and send them to the OpenCode server. This enables testing of the SSE integration and completes the core chat interface loop. - -## Prerequisites - -- Task 007 (Message display) complete -- Task 008 (SSE integration) complete -- Active session available -- SDK client configured - -## Context - -The prompt input is the primary way users interact with OpenCode. For the MVP, we need: - -- Simple text input (multi-line textarea) -- Send button -- Basic keyboard shortcuts (Enter to send, Shift+Enter for new line) -- Loading state while assistant is responding -- Basic validation (empty message prevention) - -Advanced features (slash commands, file attachments, @ mentions) will come in Task 021-024. - -## Implementation Steps - -### Step 1: Create Prompt Input Component - -Create `src/components/prompt-input.tsx`: - -```typescript -import { createSignal, Show } from "solid-js" - -interface PromptInputProps { - instanceId: string - sessionId: string - onSend: (prompt: string) => Promise - disabled?: boolean -} - -export default function PromptInput(props: PromptInputProps) { - const [prompt, setPrompt] = createSignal("") - const [sending, setSending] = createSignal(false) - let textareaRef: HTMLTextAreaElement | undefined - - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault() - handleSend() - } - } - - async function handleSend() { - const text = prompt().trim() - if (!text || sending() || props.disabled) return - - setSending(true) - try { - await props.onSend(text) - setPrompt("") - - // Auto-resize textarea back to initial size - if (textareaRef) { - textareaRef.style.height = "auto" - } - } catch (error) { - console.error("Failed to send message:", error) - alert("Failed to send message: " + (error instanceof Error ? error.message : String(error))) - } finally { - setSending(false) - textareaRef?.focus() - } - } - - function handleInput(e: Event) { - const target = e.target as HTMLTextAreaElement - setPrompt(target.value) - - // Auto-resize textarea - target.style.height = "auto" - target.style.height = Math.min(target.scrollHeight, 200) + "px" - } - - const canSend = () => prompt().trim().length > 0 && !sending() && !props.disabled - - return ( -
-
-