From c3175acf43857dea32ce3febb2066c4e9d29553a Mon Sep 17 00:00:00 2001 From: William Correa Date: Thu, 5 Mar 2026 03:31:44 -0300 Subject: [PATCH 01/15] feat(acp): Add heartbeat, dead connection cleanup and connection logging - Add Rust-side heartbeat task with 15s ping interval and 3-strike disconnect - Clean up dead connections in `acp_connect` and `acp_check_health` - Emit structured connection log events via `acp:log` - Add `HeartbeatEvent`, `ConnectionLogEntry` and `ConnectionConfig` types - Add `chat_panel_size` field to session persistence --- apps/tauri/src-tauri/src/acp/commands.rs | 21 ++- apps/tauri/src-tauri/src/acp/connection.rs | 149 ++++++++++++++++++--- apps/tauri/src-tauri/src/acp/types.rs | 19 +++ apps/tauri/src-tauri/src/lib.rs | 1 + apps/tauri/src-tauri/src/sessions.rs | 43 +++++- 5 files changed, 204 insertions(+), 29 deletions(-) diff --git a/apps/tauri/src-tauri/src/acp/commands.rs b/apps/tauri/src-tauri/src/acp/commands.rs index 2257ff7..a44fc8a 100644 --- a/apps/tauri/src-tauri/src/acp/commands.rs +++ b/apps/tauri/src-tauri/src/acp/commands.rs @@ -29,8 +29,13 @@ pub async fn acp_connect( state: State<'_, AcpState>, ) -> Result<(), String> { let mut connections = state.connections.lock().await; - if connections.contains_key(&workspace_id) { - return Ok(()); + if let Some(existing) = connections.get(&workspace_id) { + if existing.is_alive().await { + return Ok(()); + } + let dead = connections.remove(&workspace_id).unwrap(); + dead.shutdown().await; + state.configs.lock().await.remove(&workspace_id); } let binary = binary_path @@ -72,6 +77,7 @@ pub async fn acp_connect( conn.send_notification("initialized", None).await?; conn.emit_status("connected", None); + conn.emit_log("info", "connect", &format!("Connected via {}", binary)); state.configs.lock().await.insert(workspace_id.clone(), config); connections.insert(workspace_id, conn); @@ -86,6 +92,7 @@ pub async fn acp_disconnect( state.configs.lock().await.remove(&workspace_id); let mut connections = state.connections.lock().await; if let Some(conn) = connections.remove(&workspace_id) { + conn.emit_log("info", "disconnect", "Disconnected by user"); conn.shutdown().await; } Ok(()) @@ -202,9 +209,10 @@ pub async fn acp_send_prompt( }], }; - conn.send_request( + conn.send_request_with_timeout( "session/prompt", Some(serde_json::to_value(¶ms).map_err(|e| e.to_string())?), + std::time::Duration::from_secs(600), ) .await?; @@ -265,13 +273,16 @@ pub async fn acp_check_health( app_handle: AppHandle, state: State<'_, AcpState>, ) -> Result { - let connections = state.connections.lock().await; + let mut connections = state.connections.lock().await; let status = if let Some(conn) = connections.get(&workspace_id) { if conn.is_alive().await { conn.emit_status("connected", None); "connected" } else { - conn.emit_status("disconnected", None); + let dead = connections.remove(&workspace_id).unwrap(); + dead.emit_status("disconnected", None); + dead.shutdown().await; + state.configs.lock().await.remove(&workspace_id); "disconnected" } } else { diff --git a/apps/tauri/src-tauri/src/acp/connection.rs b/apps/tauri/src-tauri/src/acp/connection.rs index a1b8a42..a0a01b9 100644 --- a/apps/tauri/src-tauri/src/acp/connection.rs +++ b/apps/tauri/src-tauri/src/acp/connection.rs @@ -15,7 +15,7 @@ pub struct AcpConnection { child: ChildRef, writer_tx: mpsc::Sender, pending: PendingMap, - next_id: AtomicU64, + next_id: Arc, reader_handle: Mutex>>, writer_handle: Mutex>>, heartbeat_handle: Mutex>>, @@ -64,8 +64,12 @@ impl AcpConnection { )); let child_arc: ChildRef = Arc::new(Mutex::new(Some(child))); + let next_id = Arc::new(AtomicU64::new(1)); let heartbeat_handle = tokio::spawn(Self::heartbeat_task( child_arc.clone(), + writer_tx.clone(), + pending.clone(), + next_id.clone(), workspace_id.clone(), app_handle.clone(), )); @@ -74,7 +78,7 @@ impl AcpConnection { child: child_arc, writer_tx, pending, - next_id: AtomicU64::new(1), + next_id, reader_handle: Mutex::new(Some(reader_handle)), writer_handle: Mutex::new(Some(writer_handle)), heartbeat_handle: Mutex::new(Some(heartbeat_handle)), @@ -169,6 +173,7 @@ impl AcpConnection { } } eprintln!("[acp] Reader task ended for workspace {}", workspace_id); + emit_log_raw(&app_handle, &workspace_id, "warn", "reader_exit", "Reader task ended — stdout closed"); let event = ConnectionStatusEvent { workspace_id: workspace_id.clone(), status: "disconnected".to_string(), @@ -177,17 +182,101 @@ impl AcpConnection { let _ = app_handle.emit("acp:connection-status", &event); } - async fn heartbeat_task(child: ChildRef, workspace_id: String, app_handle: AppHandle) { + async fn heartbeat_task( + child: ChildRef, + writer_tx: mpsc::Sender, + pending: PendingMap, + next_id: Arc, + workspace_id: String, + app_handle: AppHandle, + ) { let interval = std::time::Duration::from_secs(15); + let ping_timeout = std::time::Duration::from_secs(5); + let mut consecutive_failures: u32 = 0; + loop { tokio::time::sleep(interval).await; - let mut guard = child.lock().await; - if let Some(ref mut c) = *guard { - match c.try_wait() { - Ok(Some(status)) => { - eprintln!("[acp] Heartbeat: process exited (status: {:?}) for workspace {}", status, workspace_id); - *guard = None; - drop(guard); + + { + let mut guard = child.lock().await; + if let Some(ref mut c) = *guard { + match c.try_wait() { + Ok(Some(status)) => { + eprintln!("[acp] Heartbeat: process exited (status: {:?}) for workspace {}", status, workspace_id); + *guard = None; + drop(guard); + emit_log_raw(&app_handle, &workspace_id, "error", "process_exit", &format!("Process exited with status: {:?}", status)); + let event = ConnectionStatusEvent { + workspace_id, + status: "disconnected".to_string(), + attempt: None, + }; + let _ = app_handle.emit("acp:connection-status", &event); + return; + } + Ok(None) => {} + Err(e) => { + eprintln!("[acp] Heartbeat: try_wait error for workspace {}: {}", workspace_id, e); + } + } + } else { + return; + } + } + + let id = next_id.fetch_add(1, Ordering::SeqCst); + let request = JsonRpcRequest::new(id, "ping", None); + let line = match serde_json::to_string(&request) { + Ok(l) => l + "\n", + Err(_) => continue, + }; + + let (tx, rx) = oneshot::channel(); + pending.lock().await.insert(id, tx); + + let start = std::time::Instant::now(); + if writer_tx.send(line).await.is_err() { + consecutive_failures += 1; + eprintln!("[acp] Heartbeat: writer channel closed for workspace {}", workspace_id); + pending.lock().await.remove(&id); + if consecutive_failures >= 3 { + let event = ConnectionStatusEvent { + workspace_id, + status: "disconnected".to_string(), + attempt: None, + }; + let _ = app_handle.emit("acp:connection-status", &event); + return; + } + continue; + } + + let timestamp = chrono::Utc::now().to_rfc3339(); + match tokio::time::timeout(ping_timeout, rx).await { + Ok(_) => { + let latency = start.elapsed().as_millis() as u64; + consecutive_failures = 0; + let event = HeartbeatEvent { + workspace_id: workspace_id.clone(), + status: "healthy".to_string(), + latency_ms: Some(latency), + timestamp, + }; + let _ = app_handle.emit("acp:heartbeat", &event); + } + Err(_) => { + consecutive_failures += 1; + pending.lock().await.remove(&id); + let event = HeartbeatEvent { + workspace_id: workspace_id.clone(), + status: "degraded".to_string(), + latency_ms: None, + timestamp: timestamp.clone(), + }; + let _ = app_handle.emit("acp:heartbeat", &event); + emit_log_raw(&app_handle, &workspace_id, "warn", "ping_timeout", &format!("Ping timeout ({}/3)", consecutive_failures)); + if consecutive_failures >= 3 { + emit_log_raw(&app_handle, &workspace_id, "error", "disconnect", "Disconnected after 3 consecutive ping timeouts"); let event = ConnectionStatusEvent { workspace_id, status: "disconnected".to_string(), @@ -196,14 +285,7 @@ impl AcpConnection { let _ = app_handle.emit("acp:connection-status", &event); return; } - Ok(None) => {} // still running - Err(e) => { - eprintln!("[acp] Heartbeat: try_wait error for workspace {}: {}", workspace_id, e); - } } - } else { - // child was already taken (shutdown called) - return; } } } @@ -244,6 +326,15 @@ impl AcpConnection { &self, method: &str, params: Option, + ) -> Result { + self.send_request_with_timeout(method, params, std::time::Duration::from_secs(30)).await + } + + pub async fn send_request_with_timeout( + &self, + method: &str, + params: Option, + timeout: std::time::Duration, ) -> Result { let id = self.next_id.fetch_add(1, Ordering::SeqCst); let request = JsonRpcRequest::new(id, method, params); @@ -260,7 +351,7 @@ impl AcpConnection { .await .map_err(|_| "Writer channel closed".to_string())?; - let result = tokio::time::timeout(std::time::Duration::from_secs(30), rx) + let result = tokio::time::timeout(timeout, rx) .await .map_err(|_| format!("Timeout waiting for response to {}", method))? .map_err(|_| "Response channel dropped".to_string())?; @@ -329,4 +420,26 @@ impl AcpConnection { }; let _ = self.app_handle.emit("acp:connection-status", &event); } + + pub fn emit_log(&self, level: &str, event: &str, message: &str) { + let entry = ConnectionLogEntry { + timestamp: chrono::Utc::now().to_rfc3339(), + level: level.to_string(), + event: event.to_string(), + message: message.to_string(), + workspace_id: self.workspace_id.clone(), + }; + let _ = self.app_handle.emit("acp:log", &entry); + } +} + +pub fn emit_log_raw(app_handle: &AppHandle, workspace_id: &str, level: &str, event: &str, message: &str) { + let entry = ConnectionLogEntry { + timestamp: chrono::Utc::now().to_rfc3339(), + level: level.to_string(), + event: event.to_string(), + message: message.to_string(), + workspace_id: workspace_id.to_string(), + }; + let _ = app_handle.emit("acp:log", &entry); } diff --git a/apps/tauri/src-tauri/src/acp/types.rs b/apps/tauri/src-tauri/src/acp/types.rs index 8ccc8a6..9ffb90d 100644 --- a/apps/tauri/src-tauri/src/acp/types.rs +++ b/apps/tauri/src-tauri/src/acp/types.rs @@ -147,6 +147,25 @@ pub struct ConnectionStatusEvent { pub attempt: Option, } +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct HeartbeatEvent { + pub workspace_id: String, + pub status: String, + pub latency_ms: Option, + pub timestamp: String, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ConnectionLogEntry { + pub timestamp: String, + pub level: String, + pub event: String, + pub message: String, + pub workspace_id: String, +} + #[derive(Debug, Clone)] #[allow(dead_code)] // stored for future reconnect support pub struct ConnectionConfig { diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 7e20275..d18a3ca 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -982,6 +982,7 @@ pub fn run() { sessions::session_update_plan, sessions::session_update_plan_file_path, sessions::session_update_phase, + sessions::session_update_chat_panel_size, sessions::session_delete, sessions::forget_workspace_data, plan_file::plan_write, diff --git a/apps/tauri/src-tauri/src/sessions.rs b/apps/tauri/src-tauri/src/sessions.rs index 701b672..e4ef27d 100644 --- a/apps/tauri/src-tauri/src/sessions.rs +++ b/apps/tauri/src-tauri/src/sessions.rs @@ -11,6 +11,7 @@ pub struct SessionRecord { pub plan_markdown: String, pub plan_file_path: Option, pub phase: String, + pub chat_panel_size: Option, pub created_at: String, pub updated_at: String, } @@ -42,6 +43,15 @@ pub fn init_sessions_table(conn: &Connection) -> Result<(), String> { .map_err(|e| format!("Migration failed: {}", e))?; } + // Migration: add chat_panel_size column + let has_chat_panel_size: bool = conn + .prepare("SELECT chat_panel_size FROM sessions LIMIT 0") + .is_ok(); + if !has_chat_panel_size { + conn.execute_batch("ALTER TABLE sessions ADD COLUMN chat_panel_size REAL;") + .map_err(|e| format!("Migration failed: {}", e))?; + } + Ok(()) } @@ -49,7 +59,7 @@ pub fn list_sessions(conn: &Connection, workspace_path: &str) -> Result Result Result Result { conn.query_row( "SELECT id, workspace_path, acp_session_id, name, initial_prompt, - plan_markdown, plan_file_path, phase, created_at, updated_at + plan_markdown, plan_file_path, phase, chat_panel_size, created_at, updated_at FROM sessions WHERE id = ?1", params![id], |row| { @@ -93,8 +104,9 @@ pub fn get_session(conn: &Connection, id: &str) -> Result plan_markdown: row.get(5)?, plan_file_path: row.get(6)?, phase: row.get(7)?, - created_at: row.get(8)?, - updated_at: row.get(9)?, + chat_panel_size: row.get(8)?, + created_at: row.get(9)?, + updated_at: row.get(10)?, }) }, ) @@ -168,6 +180,15 @@ pub fn update_plan_file_path(conn: &Connection, id: &str, plan_file_path: &str) Ok(()) } +pub fn update_chat_panel_size(conn: &Connection, id: &str, size: f64) -> Result<(), String> { + conn.execute( + "UPDATE sessions SET chat_panel_size = ?1 WHERE id = ?2", + params![size, id], + ) + .map_err(|e| format!("Update chat_panel_size error: {}", e))?; + Ok(()) +} + pub fn delete_session(conn: &Connection, id: &str) -> Result<(), String> { conn.execute("DELETE FROM sessions WHERE id = ?1", params![id]) .map_err(|e| format!("Delete error: {}", e))?; @@ -300,6 +321,16 @@ pub fn session_update_plan_file_path( update_plan_file_path(&conn, &id, &plan_file_path) } +#[tauri::command] +pub fn session_update_chat_panel_size( + id: String, + size: f64, + db: tauri::State, +) -> Result<(), String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + update_chat_panel_size(&conn, &id, size) +} + #[tauri::command] pub fn session_delete( id: String, From 5a1b0e8d339b65e51e09bba24b8bcaeb660269ce Mon Sep 17 00:00:00 2001 From: William Correa Date: Thu, 5 Mar 2026 03:32:09 -0300 Subject: [PATCH 02/15] feat(acp): Persist session state across minimize/restore and reload history - Add in-memory session cache with global event listener independent of React lifecycle - Keep DirectoryWorkspace mounted via CSS hidden when minimized - Add `persistedWorkspaceId` to AppContext for mount/visibility separation - Always call `acp_load_session` to replay full conversation from ACP backend - Restore cached messages on "already loaded" fallback - Add periodic 30s heartbeat in `useAcpConnection` for proactive disconnect detection - Remove auto-disconnect on unmount; move disconnect to explicit close/forget - Add connection logs panel with `useAcpLogs` hook and `ConnectionLogs` component - Add i18n strings for connection logs and ACP status --- apps/tauri/src/App.tsx | 32 +- .../src/components/ActiveSessionView.tsx | 131 ++++--- apps/tauri/src/components/ConnectionLogs.tsx | 92 +++++ .../src/components/DirectoryWorkspace.tsx | 41 ++- apps/tauri/src/components/ErrorConsole.tsx | 12 +- apps/tauri/src/components/MarkdownViewer.tsx | 2 +- apps/tauri/src/components/TerminalChat.tsx | 16 +- apps/tauri/src/components/TerminalMessage.tsx | 106 ++++-- apps/tauri/src/contexts/AppContext.tsx | 27 +- apps/tauri/src/hooks/useAcpConnection.ts | 65 +++- apps/tauri/src/hooks/useAcpLogs.ts | 48 +++ apps/tauri/src/hooks/useAcpSession.ts | 334 +++++------------- apps/tauri/src/index.css | 207 ++++++++--- apps/tauri/src/lib/format-path.ts | 5 + apps/tauri/src/lib/session-cache.ts | 208 +++++++++++ apps/tauri/src/locales/en.json | 5 + apps/tauri/src/locales/pt-BR.json | 5 + apps/tauri/src/types/acp.ts | 15 + apps/tauri/src/types/index.ts | 1 + 19 files changed, 906 insertions(+), 446 deletions(-) create mode 100644 apps/tauri/src/components/ConnectionLogs.tsx create mode 100644 apps/tauri/src/hooks/useAcpLogs.ts create mode 100644 apps/tauri/src/lib/session-cache.ts diff --git a/apps/tauri/src/App.tsx b/apps/tauri/src/App.tsx index d90ea26..ab4230f 100644 --- a/apps/tauri/src/App.tsx +++ b/apps/tauri/src/App.tsx @@ -28,7 +28,7 @@ function AppContent() { const { view, openFile, openDirectory, minimizeWorkspace, isMinimizing, isExpanding, cardRect, expandedWorkspaceId, - finishExpand, finishMinimize, + persistedWorkspaceId, finishExpand, finishMinimize, } = useApp(); const mainRef = useRef(null); const overlayRef = useRef(null); @@ -229,7 +229,7 @@ function AppContent() {
- {(view === "file-expanded" || view === "directory-expanded") && ( + {view === "file-expanded" && ( <> {isAnimating && (
- {view === "file-expanded" ? : } + +
+
+ + )} + + {(view === "directory-expanded" || persistedWorkspaceId) && ( + <> + {view === "directory-expanded" && isAnimating && ( +
+ )} +
+
+
diff --git a/apps/tauri/src/components/ActiveSessionView.tsx b/apps/tauri/src/components/ActiveSessionView.tsx index 0622f93..d1f7840 100644 --- a/apps/tauri/src/components/ActiveSessionView.tsx +++ b/apps/tauri/src/components/ActiveSessionView.tsx @@ -7,30 +7,20 @@ import { Button } from "@/components/ui/button"; import { TerminalChat } from "./TerminalChat"; import { MarkdownViewer } from "./MarkdownViewer"; import { useAcpSession } from "@/hooks/useAcpSession"; +import { useAcpLogs } from "@/hooks/useAcpLogs"; import { usePlanWorkflow } from "@/hooks/usePlanWorkflow"; +import { ConnectionLogs } from "./ConnectionLogs"; import type { SessionRecord, PlanPhase } from "@/types"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { invoke } from "@tauri-apps/api/core"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { AlertCircle, FileText, Loader2, MessageSquare, Minimize2, Plug, RefreshCw, X } from "lucide-react"; -import { Trans } from "react-i18next"; +import { AlertCircle, FileText, Loader2, MessageSquare, Minimize2, Plug, Unplug, RefreshCw } from "lucide-react"; import type { ImperativePanelHandle } from "react-resizable-panels"; const PHASES: PlanPhase[] = ["idle", "planning", "reviewing", "executing", "done"]; @@ -59,9 +49,8 @@ interface ActiveSessionViewProps { isConnecting?: boolean; onPhaseChange?: (sessionId: string, phase: PlanPhase) => void; onConnect?: () => Promise; - onReconnect?: () => Promise; + onDisconnect?: () => Promise; onMinimize?: () => void; - onEnd?: () => void; } export function ActiveSessionView({ @@ -72,9 +61,8 @@ export function ActiveSessionView({ isConnecting, onPhaseChange, onConnect, - onReconnect, + onDisconnect, onMinimize, - onEnd, }: ActiveSessionViewProps) { const initRef = useRef(false); const chatPanelRef = useRef(null); @@ -84,6 +72,7 @@ export function ActiveSessionView({ const [initError, setInitError] = useState(null); const acp = useAcpSession(workspaceId, workspacePath, isConnected); + const acpLogs = useAcpLogs(workspaceId); const plan = usePlanWorkflow({ workspaceId, @@ -105,8 +94,9 @@ export function ActiveSessionView({ const doInit = useCallback(async () => { setInitError(null); try { + const isNewSession = !session.acp_session_id; let acpId: string; - if (session.acp_session_id) { + if (!isNewSession) { acpId = await acp.startSession(session.acp_session_id); } else { acpId = await acp.startSession(); @@ -117,13 +107,16 @@ export function ActiveSessionView({ } if (session.phase === "idle") { - plan.startPlanning(acpId, session.initial_prompt); + const prompt = isNewSession && session.name + ? `${session.name}\n\n${session.initial_prompt}` + : session.initial_prompt; + plan.startPlanning(acpId, prompt); } } catch (e) { console.error("[ActiveSessionView] init error:", e); setInitError(String(e)); } - }, [isConnected, session.acp_session_id, session.id, session.phase, session.initial_prompt, acp.startSession, plan.startPlanning]); + }, [isConnected, session.acp_session_id, session.id, session.phase, session.initial_prompt, session.name, acp.startSession, plan.startPlanning]); useEffect(() => { if (!isConnected || initRef.current) return; @@ -134,10 +127,32 @@ export function ActiveSessionView({ const handleReconnect = useCallback(async () => { initRef.current = false; setInitError(null); - if (onReconnect) { - await onReconnect(); + if (onDisconnect) { + await onDisconnect(); + } + if (onConnect) { + await onConnect(); } - }, [onReconnect]); + }, [onDisconnect, onConnect]); + + const saveTimerRef = useRef>(); + const handleLayout = useCallback((sizes: number[]) => { + if (!session.id || sizes[0] === 0) return; + clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => { + invoke("session_update_chat_panel_size", { id: session.id, size: sizes[0] }).catch(console.error); + }, 500); + }, [session.id]); + + useEffect(() => { + return () => clearTimeout(saveTimerRef.current); + }, []); + + const chatDefaultSize = useMemo(() => session.chat_panel_size ?? 40, [session.id]); + const planDefaultSize = useMemo(() => { + if (session.chat_panel_size != null) return 100 - session.chat_panel_size; + return 60; + }, [session.id]); const { t } = useTranslation(); const currentPhase = plan.phase ?? session.phase; @@ -215,7 +230,22 @@ export function ActiveSessionView({ ))} - {!isConnected && !isConnecting && onConnect && ( + {isConnecting ? ( + + + {t("acp.connecting")} + + ) : isConnected && onDisconnect ? ( + + ) : !isConnected && onConnect ? ( - )} - {isConnecting && ( - - - {t("acp.connecting")} - - )} + ) : null}
)} +
{onMinimize && ( )} - {onEnd && ( - - - - - - - {t("sessions.endSessionTitle")} - - }} - /> - - - - {t("common.cancel")} - - {t("sessions.endSession")} - - - - - )}
@@ -336,7 +333,7 @@ export function ActiveSessionView({ id="session-plan" ref={planPanelRef} className="relative" - defaultSize={60} + defaultSize={planDefaultSize} minSize={20} collapsible collapsedSize={0} diff --git a/apps/tauri/src/components/ConnectionLogs.tsx b/apps/tauri/src/components/ConnectionLogs.tsx new file mode 100644 index 0000000..40ebecc --- /dev/null +++ b/apps/tauri/src/components/ConnectionLogs.tsx @@ -0,0 +1,92 @@ +import { useTranslation } from "react-i18next"; +import { Activity, Copy, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import type { AcpConnectionLogEntry } from "@/types/acp"; + +const LEVEL_STYLES: Record = { + info: "text-blue-500", + warn: "text-yellow-500", + error: "text-red-500", +}; + +interface ConnectionLogsProps { + logs: AcpConnectionLogEntry[]; + hasRecentErrors: boolean; + onClear: () => void; +} + +export function ConnectionLogs({ logs, hasRecentErrors, onClear }: ConnectionLogsProps) { + const { t } = useTranslation(); + + const handleCopy = () => { + const text = logs + .map((l) => `[${l.timestamp}] [${l.level.toUpperCase()}] ${l.event}: ${l.message}`) + .join("\n"); + navigator.clipboard.writeText(text); + }; + + return ( + + + + + + + {t("acp.logs")} + + {logs.length === 0 ? ( +

+ {t("acp.logsEmpty")} +

+ ) : ( + <> + +
+ {logs.map((entry, idx) => ( +
+ + {new Date(entry.timestamp).toLocaleTimeString()} + {" "} + + {entry.level.toUpperCase()} + {" "} + {entry.event}:{" "} + {entry.message} +
+ ))} +
+
+
+ + +
+ + )} +
+
+ ); +} diff --git a/apps/tauri/src/components/DirectoryWorkspace.tsx b/apps/tauri/src/components/DirectoryWorkspace.tsx index 83e349f..006eaff 100644 --- a/apps/tauri/src/components/DirectoryWorkspace.tsx +++ b/apps/tauri/src/components/DirectoryWorkspace.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef } from "react"; +import { useState, useCallback, useRef, useEffect } from "react"; import type { PlanPhase } from "@/types"; import { Loader2, Minimize2, Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -10,12 +10,15 @@ import { ActiveSessionView } from "./ActiveSessionView"; import { SessionCard } from "./SessionCard"; import { useLocalSessions } from "@/hooks/useLocalSessions"; import { useAcpConnection } from "@/hooks/useAcpConnection"; +import { uiStore } from "@/lib/session-cache"; import type { SessionRecord } from "@/types"; export function DirectoryWorkspace() { const { t } = useTranslation(); - const { workspaces, expandedWorkspaceId, minimizeWorkspace } = useApp(); - const workspace = workspaces.find((w) => w.id === expandedWorkspaceId); + const { workspaces, expandedWorkspaceId, persistedWorkspaceId, minimizeWorkspace } = useApp(); + const workspace = workspaces.find((w) => w.id === (expandedWorkspaceId ?? persistedWorkspaceId)); + + const cachedMountedSessionId = workspace ? uiStore.get(workspace.id)?.mountedSessionId ?? null : null; const [showNewForm, setShowNewForm] = useState(false); const [isCreating, setIsCreating] = useState(false); @@ -25,6 +28,26 @@ export function DirectoryWorkspace() { const local = useLocalSessions(workspace?.path ?? ""); const connection = useAcpConnection(workspace?.id ?? "", workspace?.path ?? ""); + useEffect(() => { + if (cachedMountedSessionId && !mountedSessionRef.current && !local.loading && local.sessions.length > 0) { + const session = local.sessions.find((s) => s.id === cachedMountedSessionId); + if (session) { + mountedSessionRef.current = session; + setBrowsing(false); + } + } + }, [cachedMountedSessionId, local.sessions, local.loading]); + + useEffect(() => { + return () => { + if (workspace?.id) { + uiStore.set(workspace.id, { + mountedSessionId: mountedSessionRef.current?.id ?? null, + }); + } + }; + }, [workspace?.id]); + const mountedSession = mountedSessionRef.current ? local.sessions.find((s) => s.id === mountedSessionRef.current!.id) ?? mountedSessionRef.current : null; @@ -79,11 +102,6 @@ export function DirectoryWorkspace() { setBrowsing(true); }, []); - const handleReconnect = useCallback(async () => { - await connection.disconnect(); - await connection.connect(); - }, [connection]); - if (!workspace) return null; return ( @@ -136,13 +154,8 @@ export function DirectoryWorkspace() { isConnecting={connection.isConnecting} onPhaseChange={handlePhaseChange} onConnect={connection.connect} - onReconnect={handleReconnect} + onDisconnect={connection.disconnect} onMinimize={handleBackToSessions} - onEnd={async () => { - await connection.disconnect(); - mountedSessionRef.current = null; - setBrowsing(true); - }} /> )} diff --git a/apps/tauri/src/components/ErrorConsole.tsx b/apps/tauri/src/components/ErrorConsole.tsx index ae7ac8f..b74c166 100644 --- a/apps/tauri/src/components/ErrorConsole.tsx +++ b/apps/tauri/src/components/ErrorConsole.tsx @@ -5,23 +5,23 @@ interface ErrorConsoleProps { onClear: () => void; } -export function ErrorConsole({ errors, onClear }: ErrorConsoleProps) { +export function ErrorConsole ({ errors, onClear }: ErrorConsoleProps) { if (errors.length === 0) return null; const lastError = errors[errors.length - 1]; return ( -
- -

+

+ +

{lastError} {errors.length > 1 && ( - (+{errors.length - 1}) + (+{errors.length - 1}) )}

- )}
) : (
diff --git a/apps/tauri/src/components/TerminalMessage.tsx b/apps/tauri/src/components/TerminalMessage.tsx index 1a9a287..de09f1a 100644 --- a/apps/tauri/src/components/TerminalMessage.tsx +++ b/apps/tauri/src/components/TerminalMessage.tsx @@ -1,7 +1,8 @@ import { useMemo } from "react"; -import Markdown from "react-markdown"; +import Markdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import type { AcpMessage } from "@/types/acp"; +import { shortenPaths } from "@/lib/format-path"; interface TerminalMessageProps { message: AcpMessage; @@ -11,37 +12,81 @@ interface TerminalMessageProps { const remarkPlugins = [remarkGfm]; +const markdownComponents: Components = { + table: ({ children, ...props }) => ( +
+ {children}
+
+ ), +}; + export function TerminalMessage({ message, isLast, isStreaming }: TerminalMessageProps) { if (message.role === "user") { + const lines = message.content.split("\n"); + const firstLine = lines[0]; + const isCollapsible = lines.length > 1 || message.content.length > 120; + + if (!isCollapsible) { + return ( +
+ + + {message.content} + +
+ ); + } + + const rest = lines.slice(1).join("\n"); return ( -
- - - {message.content} - +
+
+ + {firstLine} + + {rest && ( +
+ {rest} +
+ )} +
); } if (message.type === "tool") { + const title = shortenPaths(message.toolTitle || message.content); + const content = message.toolTitle ? shortenPaths(message.content) : ""; + const contentLines = content ? content.split('\n').filter(l => l.trim()) : []; + const hasContent = message.toolStatus === "completed" && contentLines.length > 0; + return (
- - + + -
- - {message.toolTitle || message.content} - {message.toolStatus === "completed" && ( - done - )} - +
+ + {title} + {message.toolStatus === "completed" && ( -
-              {message.content}
-            
+ done )} -
+ {hasContent && content.length <= 120 && contentLines.length === 1 ? ( +
+ └ {contentLines[0]} +
+ ) : hasContent && ( +
+ + └ {contentLines.length} lines... + +
+                {content}
+              
+
+ )} +
); } @@ -49,12 +94,17 @@ export function TerminalMessage({ message, isLast, isStreaming }: TerminalMessag if (message.type === "thinking") { return (
- - + + -
- {message.content} -
+
+ + Thinking… + +
+ {message.content} +
+
); } @@ -64,7 +114,7 @@ export function TerminalMessage({ message, isLast, isStreaming }: TerminalMessag
- {message.content} + {message.content}
); @@ -79,11 +129,11 @@ function AgentMessage({ message, isLast, isStreaming }: TerminalMessageProps) { return (
- + -
- {content} +
+ {content} {showCursor && }
diff --git a/apps/tauri/src/contexts/AppContext.tsx b/apps/tauri/src/contexts/AppContext.tsx index 96babd7..def82ae 100644 --- a/apps/tauri/src/contexts/AppContext.tsx +++ b/apps/tauri/src/contexts/AppContext.tsx @@ -1,5 +1,6 @@ import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; import type { Workspace, CardRect } from '@/types'; +import { clearWorkspaceCaches } from '@/lib/session-cache'; const { invoke } = window.__TAURI__.core; const { open: openDialog } = window.__TAURI__.dialog; @@ -27,6 +28,7 @@ interface AppContextValue { view: 'home' | 'file-expanded' | 'directory-expanded'; workspaces: Workspace[]; expandedWorkspaceId: string | null; + persistedWorkspaceId: string | null; isMinimizing: boolean; isExpanding: boolean; cardRect: CardRect | null; @@ -48,6 +50,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) { const [workspaces, setWorkspaces] = useState(loadWorkspaces); const [expandedWorkspaceId, setExpandedWorkspaceId] = useState(null); const [isMinimizing, setIsMinimizing] = useState(false); + const [persistedWorkspaceId, setPersistedWorkspaceId] = useState(null); const [isExpanding, setIsExpanding] = useState(false); const [cardRect, setCardRect] = useState(null); @@ -105,6 +108,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) { const existing = workspaces.find((w) => w.type === 'directory' && w.path === dirPath); if (existing) { setExpandedWorkspaceId(existing.id); + setPersistedWorkspaceId(existing.id); setView('directory-expanded'); setWorkspaces((prev) => prev.map((w) => (w.id === existing.id ? { ...w, lastAccessed: new Date() } : w)) @@ -122,6 +126,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) { }; setWorkspaces((prev) => [...prev, newWorkspace]); setExpandedWorkspaceId(id); + setPersistedWorkspaceId(id); setView('directory-expanded'); }, [workspaces]); @@ -134,6 +139,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) { setIsExpanding(true); } setExpandedWorkspaceId(id); + if (workspace.type === 'directory') { + setPersistedWorkspaceId(id); + } setView(workspace.type === 'file' ? 'file-expanded' : 'directory-expanded'); }, [workspaces]); @@ -161,12 +169,19 @@ export function AppProvider({ children }: { children: React.ReactNode }) { if (workspace?.type === 'file') { invoke('unwatch_file', { path: workspace.path }).catch(console.error); } + if (workspace?.type === 'directory') { + invoke('acp_disconnect', { workspaceId: id }).catch(console.error); + clearWorkspaceCaches(id); + } setWorkspaces((prev) => prev.filter((w) => w.id !== id)); + if (persistedWorkspaceId === id) { + setPersistedWorkspaceId(null); + } if (expandedWorkspaceId === id) { setExpandedWorkspaceId(null); setView('home'); } - }, [expandedWorkspaceId, workspaces]); + }, [expandedWorkspaceId, persistedWorkspaceId, workspaces]); const forgetWorkspace = useCallback(async (id: string) => { const workspace = workspaces.find((w) => w.id === id); @@ -180,18 +195,26 @@ export function AppProvider({ children }: { children: React.ReactNode }) { if (workspace.type === 'file') { invoke('unwatch_file', { path: workspace.path }).catch(console.error); } + if (workspace.type === 'directory') { + invoke('acp_disconnect', { workspaceId: id }).catch(console.error); + clearWorkspaceCaches(id); + } setWorkspaces((prev) => prev.filter((w) => w.id !== id)); + if (persistedWorkspaceId === id) { + setPersistedWorkspaceId(null); + } if (expandedWorkspaceId === id) { setExpandedWorkspaceId(null); setView('home'); } - }, [expandedWorkspaceId, workspaces]); + }, [expandedWorkspaceId, persistedWorkspaceId, workspaces]); const value: AppContextValue = { view, workspaces, expandedWorkspaceId, + persistedWorkspaceId, isMinimizing, isExpanding, cardRect, diff --git a/apps/tauri/src/hooks/useAcpConnection.ts b/apps/tauri/src/hooks/useAcpConnection.ts index f79c89b..5a3cc08 100644 --- a/apps/tauri/src/hooks/useAcpConnection.ts +++ b/apps/tauri/src/hooks/useAcpConnection.ts @@ -1,5 +1,6 @@ import { useState, useCallback, useRef, useEffect } from "react"; import type { AcpConnectionStatusEvent } from "@/types/acp"; +import { connectionStore } from "@/lib/session-cache"; export type AcpConnectionStatus = | "idle" @@ -21,20 +22,27 @@ export function useAcpConnection( workspaceId: string, workspacePath: string ): UseAcpConnectionReturn { + const cached = connectionStore.get(workspaceId); const [connectionStatus, setConnectionStatus] = - useState("idle"); - const [connectionError, setConnectionError] = useState(null); - const connectedRef = useRef(false); + useState(cached?.status ?? "idle"); + const [connectionError, setConnectionError] = useState(cached?.error ?? null); + const connectedRef = useRef(cached?.status === "connected"); + + const statusRef = useRef(connectionStatus); + statusRef.current = connectionStatus; + const errorRef = useRef(connectionError); + errorRef.current = connectionError; const isConnected = connectionStatus === "connected"; const isConnecting = connectionStatus === "connecting"; // Listen to Rust-side connection status events useEffect(() => { - // Reset state immediately when workspaceId changes to avoid stale UI - connectedRef.current = false; - setConnectionStatus("idle"); - setConnectionError(null); + if (!connectionStore.has(workspaceId)) { + connectedRef.current = false; + setConnectionStatus("idle"); + setConnectionError(null); + } let cancelled = false; let unlisten: (() => void) | null = null; @@ -65,15 +73,40 @@ export function useAcpConnection( }; }, [workspaceId]); - // Check health when the window regains visibility (e.g., unminimize) + // Periodic heartbeat + visibility check useEffect(() => { + const checkHealth = () => { + if (!connectedRef.current) return; + window.__TAURI__.core.invoke("acp_check_health", { workspaceId }).catch(() => {}); + }; + const onVisible = () => { - if (!document.hidden && connectedRef.current) { - window.__TAURI__.core.invoke("acp_check_health", { workspaceId }).catch(() => {}); - } + if (!document.hidden) checkHealth(); }; document.addEventListener("visibilitychange", onVisible); - return () => document.removeEventListener("visibilitychange", onVisible); + + const interval = setInterval(checkHealth, 30_000); + + return () => { + document.removeEventListener("visibilitychange", onVisible); + clearInterval(interval); + }; + }, [workspaceId]); + + // Verify cached connection status on mount + useEffect(() => { + if (!connectionStore.has(workspaceId)) return; + window.__TAURI__.core.invoke("acp_check_health", { workspaceId }) + .then((status) => { + const s = status as AcpConnectionStatus; + connectedRef.current = s === "connected"; + setConnectionStatus(s); + if (s === "connected") setConnectionError(null); + }) + .catch(() => { + connectedRef.current = false; + setConnectionStatus("disconnected"); + }); }, [workspaceId]); const connect = useCallback(async () => { @@ -123,8 +156,12 @@ export function useAcpConnection( useEffect(() => { return () => { - window.__TAURI__.core.invoke("acp_disconnect", { workspaceId }).catch(() => {}); - connectedRef.current = false; + if (workspaceId) { + connectionStore.set(workspaceId, { + status: statusRef.current, + error: errorRef.current, + }); + } }; }, [workspaceId]); diff --git a/apps/tauri/src/hooks/useAcpLogs.ts b/apps/tauri/src/hooks/useAcpLogs.ts new file mode 100644 index 0000000..dcde70d --- /dev/null +++ b/apps/tauri/src/hooks/useAcpLogs.ts @@ -0,0 +1,48 @@ +import { useState, useEffect, useCallback } from "react"; +import type { AcpConnectionLogEntry } from "@/types/acp"; + +const MAX_LOGS = 200; + +interface UseAcpLogsReturn { + logs: AcpConnectionLogEntry[]; + clearLogs: () => void; + hasRecentErrors: boolean; +} + +export function useAcpLogs(workspaceId: string): UseAcpLogsReturn { + const [logs, setLogs] = useState([]); + + useEffect(() => { + setLogs([]); + let cancelled = false; + let unlisten: (() => void) | null = null; + + window.__TAURI__.event + .listen("acp:log", (event: { payload: AcpConnectionLogEntry }) => { + if (cancelled) return; + if (event.payload.workspaceId !== workspaceId) return; + setLogs((prev) => { + const next = [...prev, event.payload]; + return next.length > MAX_LOGS ? next.slice(next.length - MAX_LOGS) : next; + }); + }) + .then((fn: () => void) => { + if (cancelled) fn(); + else unlisten = fn; + }) + .catch(() => {}); + + return () => { + cancelled = true; + unlisten?.(); + }; + }, [workspaceId]); + + const clearLogs = useCallback(() => { + setLogs([]); + }, []); + + const hasRecentErrors = logs.some((l) => l.level === "error"); + + return { logs, clearLogs, hasRecentErrors }; +} diff --git a/apps/tauri/src/hooks/useAcpSession.ts b/apps/tauri/src/hooks/useAcpSession.ts index 573151e..7a8e4b8 100644 --- a/apps/tauri/src/hooks/useAcpSession.ts +++ b/apps/tauri/src/hooks/useAcpSession.ts @@ -1,11 +1,16 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import type { AcpSessionInfo, - AcpSessionUpdate, AcpMessage, } from "@/types/acp"; +import { + sessionStore, + subscribeSession, + updateSessionEntry, + addUserMessage, + type SessionEntry, +} from "@/lib/session-cache"; interface UseAcpSessionReturn { isStreaming: boolean; @@ -23,69 +28,79 @@ interface UseAcpSessionReturn { clearMessages: () => void; } -let msgCounter = 0; -function nextMsgId() { - return `msg-${++msgCounter}-${Date.now()}`; -} - function extractModes( info: AcpSessionInfo, - setAvailableModes: (m: string[]) => void, - setCurrentMode: (m: string) => void + cb: (modes: string[], current: string | null) => void ) { if (info.modes) { - setAvailableModes(info.modes.availableModes.map((m) => m.id)); - if (info.modes.currentModeId) setCurrentMode(info.modes.currentModeId); + cb( + info.modes.availableModes.map((m) => m.id), + info.modes.currentModeId ?? null + ); } } +const EMPTY_SESSION: SessionEntry = { + messages: [], + activeAcpSessionId: null, + currentMode: null, + availableModes: [], + agentPlanFilePath: null, + isStreaming: false, +}; + export function useAcpSession( workspaceId: string, workspacePath: string, isConnected: boolean ): UseAcpSessionReturn { - const [isStreaming, setIsStreaming] = useState(false); + const cached = sessionStore.get(workspaceId); + + const [messages, setMessages] = useState(cached?.messages ?? []); + const [isStreaming, setIsStreaming] = useState(cached?.isStreaming ?? false); + const [currentMode, setCurrentMode] = useState(cached?.currentMode ?? null); + const [availableModes, setAvailableModes] = useState(cached?.availableModes ?? []); + const [activeAcpSessionId, setActiveAcpSessionId] = useState(cached?.activeAcpSessionId ?? null); + const [agentPlanFilePath, setAgentPlanFilePath] = useState(cached?.agentPlanFilePath ?? null); const [errors, setErrors] = useState([]); - const [messages, setMessages] = useState([]); - const [currentMode, setCurrentMode] = useState(null); - const [availableModes, setAvailableModes] = useState([]); - const [activeAcpSessionId, setActiveAcpSessionId] = useState(null); - const [agentPlanFilePath, setAgentPlanFilePath] = useState(null); - const activeAcpSessionIdRef = useRef(null); - const idleTimerRef = useRef | null>(null); + const activeAcpSessionIdRef = useRef(cached?.activeAcpSessionId ?? null); const workspaceIdRef = useRef(workspaceId); workspaceIdRef.current = workspaceId; + const idleTimerRef = useRef | null>(null); useEffect(() => { - let cancelled = false; - let unlisten: UnlistenFn | null = null; + const entry = sessionStore.get(workspaceId); + if (entry) { + setMessages(entry.messages); + setIsStreaming(entry.isStreaming); + setCurrentMode(entry.currentMode); + setAvailableModes(entry.availableModes); + setActiveAcpSessionId(entry.activeAcpSessionId); + setAgentPlanFilePath(entry.agentPlanFilePath); + activeAcpSessionIdRef.current = entry.activeAcpSessionId; + } - listen("acp:session-update", (event) => { - if (cancelled) return; - const update = event.payload; - if (update.workspaceId !== workspaceIdRef.current) return; - const currentAcpId = activeAcpSessionIdRef.current; - if ( - currentAcpId && - update.sessionId !== currentAcpId - ) { - return; - } - handleSessionUpdate(update); - }).then((fn) => { - if (cancelled) { - fn(); + const unsubscribe = subscribeSession(workspaceId, (e) => { + setMessages(e.messages); + setCurrentMode(e.currentMode); + setAvailableModes(e.availableModes); + setActiveAcpSessionId(e.activeAcpSessionId); + setAgentPlanFilePath(e.agentPlanFilePath); + activeAcpSessionIdRef.current = e.activeAcpSessionId; + + if (e.isStreaming) { + setIsStreaming(true); + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + idleTimerRef.current = setTimeout(() => setIsStreaming(false), 800); } else { - unlisten = fn; + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + setIsStreaming(false); } }); - return () => { - cancelled = true; - unlisten?.(); - }; - }, []); + return unsubscribe; + }, [workspaceId]); useEffect(() => { return () => { @@ -93,226 +108,62 @@ export function useAcpSession( }; }, []); - const resetIdleTimer = useCallback(() => { - if (idleTimerRef.current) clearTimeout(idleTimerRef.current); - idleTimerRef.current = setTimeout(() => { - setIsStreaming(false); - }, 800); - }, []); - - const handleSessionUpdate = useCallback((update: AcpSessionUpdate) => { - const { updateType, payload } = update; - const p = payload as Record; - - switch (updateType) { - case "agent_message_chunk": { - const content = p.content as Record | undefined; - const text = content?.type === "text" ? (content.text as string) : ""; - if (!text) break; - - if (/^(Warning:|Info:|🔬|Experimental)/.test(text)) { - setMessages((prev) => [ - ...prev, - { - id: nextMsgId(), - role: "assistant", - type: "notice", - content: text, - timestamp: new Date(), - }, - ]); - } else { - setMessages((prev) => { - const last = prev[prev.length - 1]; - if (last?.role === "assistant" && !last.type) { - return [ - ...prev.slice(0, -1), - { ...last, content: last.content + text }, - ]; - } - return [ - ...prev, - { - id: nextMsgId(), - role: "assistant", - content: text, - timestamp: new Date(), - }, - ]; - }); - } - setIsStreaming(true); - resetIdleTimer(); - break; - } - - case "agent_thought_chunk": { - const content = p.content as Record | undefined; - const text = content?.type === "text" ? (content.text as string) : ""; - if (!text) break; - - setMessages((prev) => { - const last = prev[prev.length - 1]; - if (last?.role === "assistant" && last.type === "thinking") { - return [ - ...prev.slice(0, -1), - { ...last, content: last.content + text }, - ]; - } - return [ - ...prev, - { - id: nextMsgId(), - role: "assistant", - type: "thinking", - content: text, - timestamp: new Date(), - }, - ]; - }); - setIsStreaming(true); - resetIdleTimer(); - break; - } - - case "end_turn": - if (idleTimerRef.current) clearTimeout(idleTimerRef.current); - setIsStreaming(false); - break; - - case "tool_call": { - setMessages((prev) => [ - ...prev, - { - id: nextMsgId(), - role: "assistant", - type: "tool", - content: (p.title as string) || (p.kind as string) || "Tool call", - timestamp: new Date(), - toolCallId: p.toolCallId as string, - toolTitle: p.title as string, - toolStatus: (p.status as string) || "pending", - }, - ]); - - const locations = p.locations as Array<{ path: string }> | undefined; - const rawInput = p.rawInput as Record | undefined; - const filePath = - locations?.[0]?.path || - (rawInput?.path as string) || - (rawInput?.file_path as string) || - ""; - if (filePath.endsWith("/plan.md")) { - setAgentPlanFilePath(filePath); - } - - setIsStreaming(true); - resetIdleTimer(); - break; - } - - case "tool_call_update": { - if (p.status !== "completed") break; - const rawOutput = p.rawOutput as Record | undefined; - const summary = rawOutput?.content as string; - if (!summary) break; - - setMessages((prev) => - prev.map((msg) => - msg.toolCallId === (p.toolCallId as string) - ? { - ...msg, - content: `${msg.toolTitle || "Tool"}: ${summary}`, - toolStatus: "completed", - } - : msg - ) - ); - break; - } - - case "user_message_chunk": { - const content = p.content; - let text = ""; - if (Array.isArray(content)) { - text = content - .filter( - (c: Record) => c?.type === "text" - ) - .map((c: Record) => (c.text as string) ?? "") - .join(""); - } else if ( - typeof content === "object" && - content !== null && - (content as Record).type === "text" - ) { - text = (content as Record).text as string; - } - if (!text) break; - - setMessages((prev) => { - const last = prev[prev.length - 1]; - if (last?.role === "user") { - return [ - ...prev.slice(0, -1), - { ...last, content: last.content + text }, - ]; - } - return [ - ...prev, - { - id: nextMsgId(), - role: "user", - content: text, - timestamp: new Date(), - }, - ]; - }); - break; - } - - case "current_mode_update": - setCurrentMode(p.currentModeId as string); - break; - } - }, []); - const startSession = useCallback( async (existingAcpSessionId?: string): Promise => { if (!isConnected) throw new Error("Not connected to ACP"); setErrors([]); - setMessages([]); let acpId: string; if (existingAcpSessionId) { + const previousEntry = sessionStore.get(workspaceId); + + const fresh: SessionEntry = { + ...EMPTY_SESSION, + activeAcpSessionId: existingAcpSessionId, + }; + sessionStore.set(workspaceId, fresh); + setMessages([]); + setIsStreaming(false); + try { - // Fresh load — ACP will replay history via events const info = await invoke("acp_load_session", { workspaceId, sessionId: existingAcpSessionId, cwd: workspacePath, }); acpId = info.sessionId; - extractModes(info, setAvailableModes, setCurrentMode); + extractModes(info, (modes, current) => { + updateSessionEntry(workspaceId, { availableModes: modes, currentMode: current }); + }); } catch (e) { if (String(e).includes("already loaded")) { acpId = existingAcpSessionId; + if (previousEntry && previousEntry.messages.length > 0) { + updateSessionEntry(workspaceId, { messages: previousEntry.messages }); + } } else { throw e; } } } else { + const fresh: SessionEntry = { ...EMPTY_SESSION }; + sessionStore.set(workspaceId, fresh); + setMessages([]); + setIsStreaming(false); + const info = await invoke("acp_new_session", { workspaceId, cwd: workspacePath, }); acpId = info.sessionId; - extractModes(info, setAvailableModes, setCurrentMode); + extractModes(info, (modes, current) => { + updateSessionEntry(workspaceId, { availableModes: modes, currentMode: current }); + }); } activeAcpSessionIdRef.current = acpId; - setActiveAcpSessionId(acpId); + updateSessionEntry(workspaceId, { activeAcpSessionId: acpId }); return acpId; }, [isConnected, workspaceId, workspacePath] @@ -323,16 +174,7 @@ export function useAcpSession( const sid = activeAcpSessionIdRef.current; if (!sid) return; - setMessages((prev) => [ - ...prev, - { - id: nextMsgId(), - role: "user", - content: text, - timestamp: new Date(), - }, - ]); - setIsStreaming(true); + addUserMessage(workspaceId, text); try { await invoke("acp_send_prompt", { @@ -342,7 +184,7 @@ export function useAcpSession( }); } catch (e) { setErrors((prev) => [...prev, String(e)]); - setIsStreaming(false); + updateSessionEntry(workspaceId, { isStreaming: false }); } }, [workspaceId] @@ -358,7 +200,7 @@ export function useAcpSession( sessionId: sid, mode, }); - setCurrentMode(mode); + updateSessionEntry(workspaceId, { currentMode: mode }); } catch (e) { setErrors((prev) => [...prev, String(e)]); } @@ -374,14 +216,16 @@ export function useAcpSession( workspaceId, sessionId: sid, }); - setIsStreaming(false); + updateSessionEntry(workspaceId, { isStreaming: false }); } catch (e) { setErrors((prev) => [...prev, String(e)]); } }, [workspaceId]); const clearErrors = useCallback(() => setErrors([]), []); - const clearMessages = useCallback(() => setMessages([]), []); + const clearMessages = useCallback(() => { + updateSessionEntry(workspaceId, { messages: [] }); + }, [workspaceId]); return { isStreaming, diff --git a/apps/tauri/src/index.css b/apps/tauri/src/index.css index 646cfba..862c8b6 100644 --- a/apps/tauri/src/index.css +++ b/apps/tauri/src/index.css @@ -357,16 +357,51 @@ html, body, #root { /* Terminal message grid */ .terminal-msg { display: grid; - grid-template-columns: 18px 1fr; + grid-template-columns: 14px 1fr; align-items: start; - gap: 0 8px; + gap: 0 4px; padding: 6px 16px; } .terminal-msg-user { + background: hsl(var(--muted)); border-top: 1px solid hsl(var(--border) / 0.5); + border-bottom: 1px solid hsl(var(--border) / 0.5); margin-top: 4px; padding-top: 10px; + padding-bottom: 10px; +} + +/* User message — collapsible */ +.terminal-msg-user--collapsible { + display: block; +} + +.terminal-msg-user--collapsible details summary { + list-style: none; + cursor: pointer; + user-select: none; +} + +.terminal-msg-user--collapsible details summary::-webkit-details-marker { + display: none; +} + +.terminal-msg-user--collapsible details summary::before { + content: "›"; + font-weight: bold; + color: hsl(var(--muted-foreground)); + margin-right: 6px; + display: inline-block; + transition: transform 0.15s ease; +} + +.terminal-msg-user--collapsible details[open] summary::before { + transform: rotate(90deg); +} + +.terminal-user-body { + padding-left: 18px; } /* Dot indicators */ @@ -392,6 +427,12 @@ html, body, #root { box-shadow: 0 0 6px #3dd68c80; } +.dot-pending { + border: 1.5px solid hsl(var(--muted-foreground)); + background: transparent; + opacity: 0.4; +} + .dot-thinking { border: 1.5px solid hsl(var(--muted-foreground)); background: transparent; @@ -421,46 +462,65 @@ html, body, #root { ); } -/* Tool details styling */ -.terminal-msg details summary { +/* Tool content — single-line (always visible) */ +.tool-content-inline { + margin-top: 2px; + font-size: 11px; + color: hsl(var(--muted-foreground) / 0.7); + white-space: pre-wrap; + word-break: break-word; +} + +/* Tool content — multi-line (collapsible) */ +.tool-content { + margin-top: 2px; +} + +.tool-content summary { list-style: none; - display: flex; - align-items: baseline; + font-size: 11px; + color: hsl(var(--muted-foreground) / 0.7); + cursor: pointer; + user-select: none; + white-space: pre-wrap; } -.terminal-msg details summary::-webkit-details-marker { +.tool-content summary::-webkit-details-marker { display: none; } -.terminal-msg details summary::before { - content: "›"; - display: inline-block; - margin-right: 6px; - font-weight: bold; - transition: transform 0.15s ease; - color: hsl(var(--muted-foreground)); - flex-shrink: 0; +.tool-content > pre { + margin-top: 2px; + font-size: 11px; + color: hsl(var(--muted-foreground) / 0.8); + white-space: pre-wrap; + word-break: break-word; + overflow: hidden; + padding-left: 12px; + border-left: 1px solid hsl(var(--border) / 0.5); + max-height: 192px; + overflow-y: auto; } -.terminal-msg details[open] summary::before { - transform: rotate(90deg); +/* Thinking block — collapsed by default */ +.terminal-thinking summary { + list-style: none; } -.terminal-msg details summary:hover { - background: hsl(var(--muted) / 0.5); - border-radius: 4px; +.terminal-thinking summary::-webkit-details-marker { + display: none; } -.terminal-msg details[open] summary { - background: hsl(var(--muted) / 0.3); - border-radius: 4px 4px 0 0; +.terminal-thinking summary::before { + content: "∴ "; } -.terminal-msg details[open] > pre { - background: hsl(var(--muted) / 0.15); - border-radius: 0 0 4px 4px; - padding: 8px 10px; - margin-top: 0; +.terminal-thinking[open] summary { + margin-bottom: 2px; +} + +.terminal-thinking .terminal-markdown code { + color: hsl(var(--muted-foreground) / 0.5); } /* Streaming cursor */ @@ -532,26 +592,16 @@ html, body, #root { margin: 8px 0 4px; } -.terminal-markdown ul, -.terminal-markdown ol { - margin: 4px 0 8px 16px; - padding-left: 4px; -} - .terminal-markdown li + li { margin-top: 3px; } -.terminal-markdown li::marker { - color: hsl(var(--muted-foreground)); -} - .terminal-markdown code { - color: hsl(var(--foreground)); - background: hsl(var(--muted)); - border: 1px solid hsl(var(--border)); - border-radius: 4px; - padding: 1px 5px; + color: #56d4dd; + background: none; + border: none; + border-radius: 0; + padding: 0; font-size: 0.9em; } @@ -573,15 +623,18 @@ html, body, #root { color: inherit; } +.terminal-table-wrapper { + overflow-x: auto; + margin: 6px 0 10px; + border-radius: 8px; + border: 1px solid hsl(var(--border)); +} + .terminal-markdown table { border-collapse: separate; border-spacing: 0; - border: 1px solid hsl(var(--border)); - border-radius: 8px; - margin: 6px 0 10px; width: 100%; font-size: 0.9em; - overflow: hidden; } .terminal-markdown th, @@ -621,14 +674,62 @@ html, body, #root { margin: 4px 0 8px; } +.terminal-markdown ul, +.terminal-markdown ol { + list-style: none; + margin: 4px 0 8px 0; + padding-left: 4px; +} + +.terminal-markdown ul > li:not(.task-list-item) { + position: relative; + padding-left: 1em; +} + +.terminal-markdown ul > li:not(.task-list-item)::before { + content: "•"; + position: absolute; + left: 0; + color: hsl(var(--muted-foreground)); +} + +.terminal-markdown .task-list-item, +.terminal-markdown li:has(> input[type="checkbox"]) { + padding-left: 0; +} + +.terminal-markdown .task-list-item::before, +.terminal-markdown li:has(> input[type="checkbox"])::before { + content: none; +} + +.terminal-markdown .task-list-item input[type="checkbox"], +.terminal-markdown li > input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 14px; + height: 14px; + border: 1.5px solid hsl(var(--muted-foreground)); + border-radius: 50%; + background: transparent; + vertical-align: middle; + margin: 0 8px 0 0; + pointer-events: none; + position: relative; +} + +.terminal-markdown .task-list-item input[type="checkbox"]:checked, +.terminal-markdown li > input[type="checkbox"]:checked { + background: #3dd68c; + border-color: #3dd68c; +} + .terminal-markdown a { - color: hsl(var(--foreground)); - text-decoration: underline; - text-underline-offset: 3px; - text-decoration-color: hsl(var(--muted-foreground) / 0.4); - transition: text-decoration-color 0.15s ease; + color: #56d4dd; + text-decoration: none; } .terminal-markdown a:hover { - text-decoration-color: hsl(var(--foreground)); + text-decoration: underline; + text-underline-offset: 3px; } diff --git a/apps/tauri/src/lib/format-path.ts b/apps/tauri/src/lib/format-path.ts index f1d379b..7dfccb2 100644 --- a/apps/tauri/src/lib/format-path.ts +++ b/apps/tauri/src/lib/format-path.ts @@ -14,3 +14,8 @@ export function shortenPath(path: string): string { } return p; } + +export function shortenPaths(text: string): string { + if (!cachedHome) return text; + return text.replaceAll(cachedHome, "~"); +} diff --git a/apps/tauri/src/lib/session-cache.ts b/apps/tauri/src/lib/session-cache.ts new file mode 100644 index 0000000..292a41d --- /dev/null +++ b/apps/tauri/src/lib/session-cache.ts @@ -0,0 +1,208 @@ +import type { AcpMessage, AcpSessionUpdate } from "@/types/acp"; +import type { AcpConnectionStatus } from "@/hooks/useAcpConnection"; + +interface ConnectionEntry { + status: AcpConnectionStatus; + error: string | null; +} + +export interface SessionEntry { + messages: AcpMessage[]; + activeAcpSessionId: string | null; + currentMode: string | null; + availableModes: string[]; + agentPlanFilePath: string | null; + isStreaming: boolean; +} + +interface UiEntry { + mountedSessionId: string | null; +} + +const connections = new Map(); +const sessions = new Map(); +const ui = new Map(); + +export const connectionStore = { + get: (id: string) => connections.get(id), + set: (id: string, entry: ConnectionEntry) => connections.set(id, entry), + has: (id: string) => connections.has(id), +}; + +export const sessionStore = { + get: (id: string) => sessions.get(id), + set: (id: string, entry: SessionEntry) => sessions.set(id, entry), +}; + +export const uiStore = { + get: (id: string) => ui.get(id), + set: (id: string, entry: UiEntry) => ui.set(id, entry), +}; + +export function clearWorkspaceCaches(workspaceId: string) { + connections.delete(workspaceId); + sessions.delete(workspaceId); + subscribers.delete(workspaceId); + ui.delete(workspaceId); +} + +let msgCounter = 0; +function nextMsgId() { + return `msg-${++msgCounter}-${Date.now()}`; +} + +function processSessionUpdate(entry: SessionEntry, update: AcpSessionUpdate): SessionEntry { + const { updateType, payload } = update; + const p = payload as Record; + const msgs = [...entry.messages]; + let { isStreaming, agentPlanFilePath, currentMode } = entry; + + switch (updateType) { + case "agent_message_chunk": { + const content = p.content as Record | undefined; + const text = content?.type === "text" ? (content.text as string) : ""; + if (!text) break; + if (/^(Warning:|Info:|🔬|Experimental)/.test(text)) { + msgs.push({ id: nextMsgId(), role: "assistant", type: "notice", content: text, timestamp: new Date() }); + } else { + const last = msgs[msgs.length - 1]; + if (last?.role === "assistant" && !last.type) { + msgs[msgs.length - 1] = { ...last, content: last.content + text }; + } else { + msgs.push({ id: nextMsgId(), role: "assistant", content: text, timestamp: new Date() }); + } + } + isStreaming = true; + break; + } + case "agent_thought_chunk": { + const content = p.content as Record | undefined; + const text = content?.type === "text" ? (content.text as string) : ""; + if (!text) break; + const last = msgs[msgs.length - 1]; + if (last?.role === "assistant" && last.type === "thinking") { + msgs[msgs.length - 1] = { ...last, content: last.content + text }; + } else { + msgs.push({ id: nextMsgId(), role: "assistant", type: "thinking", content: text, timestamp: new Date() }); + } + isStreaming = true; + break; + } + case "end_turn": + isStreaming = false; + break; + case "tool_call": { + msgs.push({ + id: nextMsgId(), + role: "assistant", + type: "tool", + content: (p.title as string) || (p.kind as string) || "Tool call", + timestamp: new Date(), + toolCallId: p.toolCallId as string, + toolTitle: p.title as string, + toolStatus: (p.status as string) || "pending", + }); + const locations = p.locations as Array<{ path: string }> | undefined; + const rawInput = p.rawInput as Record | undefined; + const filePath = locations?.[0]?.path || (rawInput?.path as string) || (rawInput?.file_path as string) || ""; + if (filePath.endsWith("/plan.md")) agentPlanFilePath = filePath; + isStreaming = true; + break; + } + case "tool_call_update": { + if (p.status !== "completed") break; + const rawOutput = p.rawOutput as Record | undefined; + const summary = rawOutput?.content as string; + if (!summary) break; + const idx = msgs.findIndex((m) => m.toolCallId === (p.toolCallId as string)); + if (idx !== -1) { + msgs[idx] = { ...msgs[idx], content: `${msgs[idx].toolTitle || "Tool"}: ${summary}`, toolStatus: "completed" }; + } + break; + } + case "user_message_chunk": { + const content = p.content; + let text = ""; + if (Array.isArray(content)) { + text = content.filter((c: Record) => c?.type === "text").map((c: Record) => (c.text as string) ?? "").join(""); + } else if (typeof content === "object" && content !== null && (content as Record).type === "text") { + text = (content as Record).text as string; + } + if (!text) break; + const last = msgs[msgs.length - 1]; + if (last?.role === "user") { + msgs[msgs.length - 1] = { ...last, content: last.content + text }; + } else { + msgs.push({ id: nextMsgId(), role: "user", content: text, timestamp: new Date() }); + } + break; + } + case "current_mode_update": + currentMode = p.currentModeId as string; + break; + } + + return { ...entry, messages: msgs, isStreaming, agentPlanFilePath, currentMode }; +} + +type SessionSubscriber = (entry: SessionEntry) => void; +const subscribers = new Map>(); + +function notifySubscribers(workspaceId: string, entry: SessionEntry) { + const subs = subscribers.get(workspaceId); + if (subs) { + for (const cb of subs) cb(entry); + } +} + +let listenerSetup = false; +function ensureGlobalListener() { + if (listenerSetup) return; + listenerSetup = true; + + window.__TAURI__.event.listen("acp:session-update", (event: { payload: AcpSessionUpdate }) => { + const update = event.payload; + const { workspaceId, sessionId } = update; + const entry = sessions.get(workspaceId); + if (!entry) return; + if (entry.activeAcpSessionId && sessionId !== entry.activeAcpSessionId) return; + + const newEntry = processSessionUpdate(entry, update); + sessions.set(workspaceId, newEntry); + notifySubscribers(workspaceId, newEntry); + }).catch(console.error); +} + +export function subscribeSession(workspaceId: string, cb: SessionSubscriber): () => void { + ensureGlobalListener(); + let subs = subscribers.get(workspaceId); + if (!subs) { + subs = new Set(); + subscribers.set(workspaceId, subs); + } + subs.add(cb); + return () => { + subs!.delete(cb); + if (subs!.size === 0) subscribers.delete(workspaceId); + }; +} + +export function updateSessionEntry(workspaceId: string, partial: Partial) { + const entry = sessions.get(workspaceId); + if (!entry) return; + const newEntry = { ...entry, ...partial }; + sessions.set(workspaceId, newEntry); + notifySubscribers(workspaceId, newEntry); +} + +export function addUserMessage(workspaceId: string, text: string) { + const entry = sessions.get(workspaceId); + if (!entry) return; + const newEntry = { + ...entry, + messages: [...entry.messages, { id: nextMsgId(), role: "user" as const, content: text, timestamp: new Date() }], + isStreaming: true, + }; + sessions.set(workspaceId, newEntry); + notifySubscribers(workspaceId, newEntry); +} diff --git a/apps/tauri/src/locales/en.json b/apps/tauri/src/locales/en.json index 3afefbd..78fd814 100644 --- a/apps/tauri/src/locales/en.json +++ b/apps/tauri/src/locales/en.json @@ -90,7 +90,12 @@ "disconnected": "Disconnected", "streaming": "Generating…", "cancel": "Stop", + "disconnect": "Disconnect", "reconnect": "Reconnect", + "logs": "Connection Logs", + "logsEmpty": "No connection events recorded", + "logsClear": "Clear", + "logsCopy": "Copy", "modeAsk": "Ask", "modePlan": "Plan", "modeCode": "Code", diff --git a/apps/tauri/src/locales/pt-BR.json b/apps/tauri/src/locales/pt-BR.json index f562aaa..ad480d5 100644 --- a/apps/tauri/src/locales/pt-BR.json +++ b/apps/tauri/src/locales/pt-BR.json @@ -90,7 +90,12 @@ "disconnected": "Desconectado", "streaming": "Gerando…", "cancel": "Parar", + "disconnect": "Desconectar", "reconnect": "Reconectar", + "logs": "Logs de Conexão", + "logsEmpty": "Nenhum evento de conexão registrado", + "logsClear": "Limpar", + "logsCopy": "Copiar", "modeAsk": "Perguntar", "modePlan": "Plano", "modeCode": "Código", diff --git a/apps/tauri/src/types/acp.ts b/apps/tauri/src/types/acp.ts index ca719b1..8e39304 100644 --- a/apps/tauri/src/types/acp.ts +++ b/apps/tauri/src/types/acp.ts @@ -44,3 +44,18 @@ export interface AcpConnectionStatusEvent { status: "connecting" | "connected" | "disconnected" | "reconnecting"; attempt?: number; } + +export interface AcpHeartbeatEvent { + workspaceId: string; + status: "healthy" | "degraded" | "disconnected"; + latencyMs?: number; + timestamp: string; +} + +export interface AcpConnectionLogEntry { + timestamp: string; + level: "info" | "warn" | "error"; + event: string; + message: string; + workspaceId: string; +} diff --git a/apps/tauri/src/types/index.ts b/apps/tauri/src/types/index.ts index bb025ce..3d86ded 100644 --- a/apps/tauri/src/types/index.ts +++ b/apps/tauri/src/types/index.ts @@ -62,6 +62,7 @@ export interface SessionRecord { plan_markdown: string; plan_file_path: string | null; phase: PlanPhase; + chat_panel_size: number | null; created_at: string; updated_at: string; } From 989a1dc4b7d4a9c4b43092847f871f368ca6a66a Mon Sep 17 00:00:00 2001 From: William Correa Date: Thu, 5 Mar 2026 03:32:28 -0300 Subject: [PATCH 03/15] test(acp): Add tests for connection hook and logs hook - Test connect/disconnect lifecycle, status events, workspace isolation - Test cached state restoration and health check on mount - Verify disconnect is not called on unmount - Test log capture, filtering and clear behavior --- .../__tests__/hooks/useAcpConnection.test.ts | 191 ++++++++++++++++++ .../src/__tests__/hooks/useAcpLogs.test.ts | 145 +++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 apps/tauri/src/__tests__/hooks/useAcpConnection.test.ts create mode 100644 apps/tauri/src/__tests__/hooks/useAcpLogs.test.ts diff --git a/apps/tauri/src/__tests__/hooks/useAcpConnection.test.ts b/apps/tauri/src/__tests__/hooks/useAcpConnection.test.ts new file mode 100644 index 0000000..60c0079 --- /dev/null +++ b/apps/tauri/src/__tests__/hooks/useAcpConnection.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useAcpConnection } from "@/hooks/useAcpConnection"; +import { clearWorkspaceCaches } from "@/lib/session-cache"; + +const mockInvoke = globalThis.__TAURI__.core.invoke as ReturnType; +const mockListen = globalThis.__TAURI__.event.listen as ReturnType; + +let capturedListeners: Record void>; + +beforeEach(() => { + mockInvoke.mockReset(); + mockListen.mockReset(); + capturedListeners = {}; + + mockListen.mockImplementation((eventName: string, handler: (event: { payload: unknown }) => void) => { + capturedListeners[eventName] = handler; + return Promise.resolve(() => {}); + }); + + mockInvoke.mockImplementation(() => Promise.resolve()); + localStorage.clear(); + clearWorkspaceCaches("ws-1"); + clearWorkspaceCaches("ws-2"); + clearWorkspaceCaches("ws-other"); +}); + +describe("useAcpConnection", () => { + it("starts with idle status", () => { + const { result } = renderHook(() => useAcpConnection("ws-1", "/path")); + expect(result.current.connectionStatus).toBe("idle"); + expect(result.current.isConnected).toBe(false); + expect(result.current.isConnecting).toBe(false); + expect(result.current.connectionError).toBeNull(); + }); + + it("connect() calls acp_connect and transitions to connecting", async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === "acp_connect") return Promise.resolve(); + if (cmd === "acp_check_health") return Promise.resolve("connected"); + return Promise.resolve(); + }); + + const { result } = renderHook(() => useAcpConnection("ws-1", "/path")); + + await act(async () => { + await result.current.connect(); + }); + + expect(mockInvoke).toHaveBeenCalledWith("acp_connect", expect.objectContaining({ + workspaceId: "ws-1", + cwd: "/path", + })); + expect(result.current.connectionStatus).toBe("connected"); + expect(result.current.isConnected).toBe(true); + }); + + it("connect() failure sets connectionError and status to disconnected", async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === "acp_connect") return Promise.reject("spawn failed"); + return Promise.resolve(); + }); + + const { result } = renderHook(() => useAcpConnection("ws-1", "/path")); + + await act(async () => { + await result.current.connect(); + }); + + expect(result.current.connectionError).toBe("spawn failed"); + expect(result.current.connectionStatus).toBe("disconnected"); + expect(result.current.isConnected).toBe(false); + }); + + it("disconnect() calls acp_disconnect and resets to idle", async () => { + const { result } = renderHook(() => useAcpConnection("ws-1", "/path")); + + await act(async () => { + await result.current.disconnect(); + }); + + expect(mockInvoke).toHaveBeenCalledWith("acp_disconnect", { workspaceId: "ws-1" }); + expect(result.current.connectionStatus).toBe("idle"); + }); + + it("ignores connect() when already connecting", async () => { + let resolveConnect: () => void; + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === "acp_connect") { + return new Promise((resolve) => { resolveConnect = resolve; }); + } + return Promise.resolve(); + }); + + const { result } = renderHook(() => useAcpConnection("ws-1", "/path")); + + act(() => { + result.current.connect(); + }); + + expect(result.current.isConnecting).toBe(true); + + await act(async () => { + result.current.connect(); + }); + + expect(mockInvoke).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveConnect!(); + }); + }); + + it("resets state when workspaceId changes", () => { + const { result, rerender } = renderHook( + ({ wsId }) => useAcpConnection(wsId, "/path"), + { initialProps: { wsId: "ws-1" } } + ); + + expect(result.current.connectionStatus).toBe("idle"); + + rerender({ wsId: "ws-2" }); + + expect(result.current.connectionStatus).toBe("idle"); + expect(result.current.connectionError).toBeNull(); + }); + + it("responds to acp:connection-status events", async () => { + const { result } = renderHook(() => useAcpConnection("ws-1", "/path")); + + await act(async () => { + await vi.waitFor(() => expect(capturedListeners["acp:connection-status"]).toBeDefined()); + }); + + act(() => { + capturedListeners["acp:connection-status"]({ + payload: { workspaceId: "ws-1", status: "connected" }, + }); + }); + + expect(result.current.isConnected).toBe(true); + expect(result.current.connectionStatus).toBe("connected"); + }); + + it("ignores events for different workspaceId", async () => { + const { result } = renderHook(() => useAcpConnection("ws-1", "/path")); + + await act(async () => { + await vi.waitFor(() => expect(capturedListeners["acp:connection-status"]).toBeDefined()); + }); + + act(() => { + capturedListeners["acp:connection-status"]({ + payload: { workspaceId: "ws-other", status: "connected" }, + }); + }); + + expect(result.current.isConnected).toBe(false); + expect(result.current.connectionStatus).toBe("idle"); + }); + + it("does not disconnect on unmount (connection stays alive for restore)", () => { + const { unmount } = renderHook(() => useAcpConnection("ws-1", "/path")); + + unmount(); + + expect(mockInvoke).not.toHaveBeenCalledWith("acp_disconnect", expect.anything()); + }); + + it("reads localStorage for copilot path and gh token", async () => { + localStorage.setItem("arandu-copilot-path", "/custom/copilot"); + localStorage.setItem("arandu-gh-token", "ghp_test123"); + + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === "acp_connect") return Promise.resolve(); + if (cmd === "acp_check_health") return Promise.resolve("connected"); + return Promise.resolve(); + }); + + const { result } = renderHook(() => useAcpConnection("ws-1", "/path")); + + await act(async () => { + await result.current.connect(); + }); + + expect(mockInvoke).toHaveBeenCalledWith("acp_connect", expect.objectContaining({ + binaryPath: "/custom/copilot", + ghToken: "ghp_test123", + })); + }); +}); diff --git a/apps/tauri/src/__tests__/hooks/useAcpLogs.test.ts b/apps/tauri/src/__tests__/hooks/useAcpLogs.test.ts new file mode 100644 index 0000000..bee8f32 --- /dev/null +++ b/apps/tauri/src/__tests__/hooks/useAcpLogs.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useAcpLogs } from "@/hooks/useAcpLogs"; +import type { AcpConnectionLogEntry } from "@/types/acp"; + +const mockListen = globalThis.__TAURI__.event.listen as ReturnType; + +let capturedListener: ((event: { payload: AcpConnectionLogEntry }) => void) | null; +let unlistenFn: ReturnType; + +beforeEach(() => { + mockListen.mockReset(); + capturedListener = null; + unlistenFn = vi.fn(); + + mockListen.mockImplementation((_eventName: string, handler: (event: { payload: AcpConnectionLogEntry }) => void) => { + capturedListener = handler; + return Promise.resolve(unlistenFn); + }); +}); + +function makeLog(overrides: Partial = {}): AcpConnectionLogEntry { + return { + timestamp: new Date().toISOString(), + level: "info", + event: "connect", + message: "Connected", + workspaceId: "ws-1", + ...overrides, + }; +} + +describe("useAcpLogs", () => { + it("starts with empty logs", () => { + const { result } = renderHook(() => useAcpLogs("ws-1")); + expect(result.current.logs).toEqual([]); + expect(result.current.hasRecentErrors).toBe(false); + }); + + it("accumulates log entries from acp:log events", async () => { + const { result } = renderHook(() => useAcpLogs("ws-1")); + + await act(async () => { + await vi.waitFor(() => expect(capturedListener).not.toBeNull()); + }); + + act(() => { + capturedListener!({ payload: makeLog({ message: "First" }) }); + }); + expect(result.current.logs).toHaveLength(1); + expect(result.current.logs[0].message).toBe("First"); + + act(() => { + capturedListener!({ payload: makeLog({ message: "Second" }) }); + }); + expect(result.current.logs).toHaveLength(2); + }); + + it("caps at 200 entries", async () => { + const { result } = renderHook(() => useAcpLogs("ws-1")); + + await act(async () => { + await vi.waitFor(() => expect(capturedListener).not.toBeNull()); + }); + + act(() => { + for (let i = 0; i < 210; i++) { + capturedListener!({ payload: makeLog({ message: `Log ${i}` }) }); + } + }); + + expect(result.current.logs).toHaveLength(200); + expect(result.current.logs[0].message).toBe("Log 10"); + expect(result.current.logs[199].message).toBe("Log 209"); + }); + + it("filters by workspaceId", async () => { + const { result } = renderHook(() => useAcpLogs("ws-1")); + + await act(async () => { + await vi.waitFor(() => expect(capturedListener).not.toBeNull()); + }); + + act(() => { + capturedListener!({ payload: makeLog({ workspaceId: "ws-other", message: "Other" }) }); + }); + + expect(result.current.logs).toHaveLength(0); + + act(() => { + capturedListener!({ payload: makeLog({ workspaceId: "ws-1", message: "Mine" }) }); + }); + + expect(result.current.logs).toHaveLength(1); + }); + + it("clearLogs() empties the array", async () => { + const { result } = renderHook(() => useAcpLogs("ws-1")); + + await act(async () => { + await vi.waitFor(() => expect(capturedListener).not.toBeNull()); + }); + + act(() => { + capturedListener!({ payload: makeLog() }); + capturedListener!({ payload: makeLog() }); + }); + expect(result.current.logs).toHaveLength(2); + + act(() => { + result.current.clearLogs(); + }); + expect(result.current.logs).toHaveLength(0); + }); + + it("cleans up listener on unmount", async () => { + const { unmount } = renderHook(() => useAcpLogs("ws-1")); + + await act(async () => { + await vi.waitFor(() => expect(capturedListener).not.toBeNull()); + }); + + unmount(); + + expect(unlistenFn).toHaveBeenCalled(); + }); + + it("hasRecentErrors is true when error-level logs exist", async () => { + const { result } = renderHook(() => useAcpLogs("ws-1")); + + await act(async () => { + await vi.waitFor(() => expect(capturedListener).not.toBeNull()); + }); + + act(() => { + capturedListener!({ payload: makeLog({ level: "info" }) }); + }); + expect(result.current.hasRecentErrors).toBe(false); + + act(() => { + capturedListener!({ payload: makeLog({ level: "error" }) }); + }); + expect(result.current.hasRecentErrors).toBe(true); + }); +}); From a9b460121bf6da8b84d84260bbc640026070ea4a Mon Sep 17 00:00:00 2001 From: William Correa Date: Thu, 5 Mar 2026 03:37:28 -0300 Subject: [PATCH 04/15] style(ui): Replace X icon with Trash2 on session delete button --- apps/tauri/src/components/SessionCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/tauri/src/components/SessionCard.tsx b/apps/tauri/src/components/SessionCard.tsx index 7b0e4a1..e244f8a 100644 --- a/apps/tauri/src/components/SessionCard.tsx +++ b/apps/tauri/src/components/SessionCard.tsx @@ -1,4 +1,4 @@ -import { X } from "lucide-react"; +import { Trash2 } from "lucide-react"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -56,7 +56,7 @@ export function SessionCard({ session, onSelect, onDelete }: SessionCardProps) { className="absolute top-1.5 right-1.5 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive/10 hover:text-destructive" onClick={(e) => e.stopPropagation()} > - + e.stopPropagation()}> From d703ddfbaa979a97338f6101921ad6dfb0108b43 Mon Sep 17 00:00:00 2001 From: William Correa Date: Thu, 5 Mar 2026 03:45:21 -0300 Subject: [PATCH 05/15] fix(acp): Reset session init on disconnect and auto-reload on "not found" - Reset `initRef` when `isConnected` goes false so reconnect triggers `doInit` and reloads the session in the new copilot process - Auto-recover in `sendPrompt`: on "Session not found" (-32602), try `acp_load_session` then retry the prompt before surfacing the error --- .../src/components/ActiveSessionView.tsx | 6 ++++- apps/tauri/src/hooks/useAcpSession.ts | 22 +++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/tauri/src/components/ActiveSessionView.tsx b/apps/tauri/src/components/ActiveSessionView.tsx index d1f7840..a60310b 100644 --- a/apps/tauri/src/components/ActiveSessionView.tsx +++ b/apps/tauri/src/components/ActiveSessionView.tsx @@ -119,7 +119,11 @@ export function ActiveSessionView({ }, [isConnected, session.acp_session_id, session.id, session.phase, session.initial_prompt, session.name, acp.startSession, plan.startPlanning]); useEffect(() => { - if (!isConnected || initRef.current) return; + if (!isConnected) { + initRef.current = false; + return; + } + if (initRef.current) return; initRef.current = true; doInit(); }, [isConnected]); diff --git a/apps/tauri/src/hooks/useAcpSession.ts b/apps/tauri/src/hooks/useAcpSession.ts index 7a8e4b8..9c72275 100644 --- a/apps/tauri/src/hooks/useAcpSession.ts +++ b/apps/tauri/src/hooks/useAcpSession.ts @@ -183,11 +183,29 @@ export function useAcpSession( text, }); } catch (e) { - setErrors((prev) => [...prev, String(e)]); + const err = String(e); + if (err.includes("not found") || err.includes("-32602")) { + try { + await invoke("acp_load_session", { + workspaceId, + sessionId: sid, + cwd: workspacePath, + }); + await invoke("acp_send_prompt", { + workspaceId, + sessionId: sid, + text, + }); + return; + } catch { + // reload failed — fall through to show original error + } + } + setErrors((prev) => [...prev, err]); updateSessionEntry(workspaceId, { isStreaming: false }); } }, - [workspaceId] + [workspaceId, workspacePath] ); const setMode = useCallback( From 3b2afc6392e8feeb71a4c435299dce25953fc6d7 Mon Sep 17 00:00:00 2001 From: William Correa Date: Thu, 5 Mar 2026 03:50:17 -0300 Subject: [PATCH 06/15] fix(acp): Add 60s streaming timeout to auto-clear stale Stop button If no streaming event arrives within 60s, `isStreaming` is reset to false automatically. Prevents the Stop button from staying red indefinitely when `end_turn` is never received. --- apps/tauri/src/lib/session-cache.ts | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/apps/tauri/src/lib/session-cache.ts b/apps/tauri/src/lib/session-cache.ts index 292a41d..9ade9b3 100644 --- a/apps/tauri/src/lib/session-cache.ts +++ b/apps/tauri/src/lib/session-cache.ts @@ -44,6 +44,7 @@ export function clearWorkspaceCaches(workspaceId: string) { sessions.delete(workspaceId); subscribers.delete(workspaceId); ui.delete(workspaceId); + clearStreamingTimer(workspaceId); } let msgCounter = 0; @@ -147,6 +148,32 @@ function processSessionUpdate(entry: SessionEntry, update: AcpSessionUpdate): Se type SessionSubscriber = (entry: SessionEntry) => void; const subscribers = new Map>(); +const streamingTimers = new Map>(); + +const STREAMING_TIMEOUT_MS = 60_000; + +function resetStreamingTimer(workspaceId: string) { + const existing = streamingTimers.get(workspaceId); + if (existing) clearTimeout(existing); + + streamingTimers.set(workspaceId, setTimeout(() => { + streamingTimers.delete(workspaceId); + const entry = sessions.get(workspaceId); + if (entry?.isStreaming) { + const updated = { ...entry, isStreaming: false }; + sessions.set(workspaceId, updated); + notifySubscribers(workspaceId, updated); + } + }, STREAMING_TIMEOUT_MS)); +} + +function clearStreamingTimer(workspaceId: string) { + const existing = streamingTimers.get(workspaceId); + if (existing) { + clearTimeout(existing); + streamingTimers.delete(workspaceId); + } +} function notifySubscribers(workspaceId: string, entry: SessionEntry) { const subs = subscribers.get(workspaceId); @@ -170,6 +197,12 @@ function ensureGlobalListener() { const newEntry = processSessionUpdate(entry, update); sessions.set(workspaceId, newEntry); notifySubscribers(workspaceId, newEntry); + + if (newEntry.isStreaming) { + resetStreamingTimer(workspaceId); + } else { + clearStreamingTimer(workspaceId); + } }).catch(console.error); } From 2db1c6dd76f1e5b7e51af59023858c16e0ef2759 Mon Sep 17 00:00:00 2001 From: William Correa Date: Thu, 5 Mar 2026 04:21:25 -0300 Subject: [PATCH 07/15] fix: replace native button with shadcn Button and localize aria-label in ErrorConsole - Use Button (variant=ghost, size=icon) from shadcn/ui - Add useTranslation() and use t('common.dismiss') for aria-label - Add common.dismiss key to pt-BR.json ("Dispensar") and en.json ("Dismiss") Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/tauri/src/components/ErrorConsole.tsx | 19 ++++++++++++------- apps/tauri/src/locales/en.json | 3 ++- apps/tauri/src/locales/pt-BR.json | 3 ++- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/tauri/src/components/ErrorConsole.tsx b/apps/tauri/src/components/ErrorConsole.tsx index b74c166..8117df1 100644 --- a/apps/tauri/src/components/ErrorConsole.tsx +++ b/apps/tauri/src/components/ErrorConsole.tsx @@ -1,3 +1,5 @@ +import { Button } from "@/components/ui/button"; +import { useTranslation } from "react-i18next"; import { AlertCircle, X } from "lucide-react"; interface ErrorConsoleProps { @@ -6,6 +8,7 @@ interface ErrorConsoleProps { } export function ErrorConsole ({ errors, onClear }: ErrorConsoleProps) { + const { t } = useTranslation(); if (errors.length === 0) return null; const lastError = errors[errors.length - 1]; @@ -19,13 +22,15 @@ export function ErrorConsole ({ errors, onClear }: ErrorConsoleProps) { (+{errors.length - 1}) )}

- +
); } diff --git a/apps/tauri/src/locales/en.json b/apps/tauri/src/locales/en.json index 78fd814..8ee0d5c 100644 --- a/apps/tauri/src/locales/en.json +++ b/apps/tauri/src/locales/en.json @@ -5,7 +5,8 @@ "minimize": "Minimize", "loading": "Loading...", "reload": "Reload", - "tryAgain": "Try Again" + "tryAgain": "Try Again", + "dismiss": "Dismiss" }, "home": { "tagline": "Intelligent development companion", diff --git a/apps/tauri/src/locales/pt-BR.json b/apps/tauri/src/locales/pt-BR.json index ad480d5..1d78edc 100644 --- a/apps/tauri/src/locales/pt-BR.json +++ b/apps/tauri/src/locales/pt-BR.json @@ -5,7 +5,8 @@ "minimize": "Minimizar", "loading": "Carregando...", "reload": "Recarregar", - "tryAgain": "Tentar Novamente" + "tryAgain": "Tentar Novamente", + "dismiss": "Dispensar" }, "home": { "tagline": "Seu parceiro inteligente no desenvolvimento de software", From a6c93ae100ec3b8ea448aba2bb7d03861176904a Mon Sep 17 00:00:00 2001 From: William Correa Date: Thu, 5 Mar 2026 04:24:42 -0300 Subject: [PATCH 08/15] fix(acp): Avoid awaiting async ops while holding connections mutex In `acp_connect` and `acp_check_health`, `is_alive().await` and `shutdown().await` were called while the connections mutex was held, potentially stalling other ACP commands behind long waits. Refactored both functions to remove the entry under the lock, drop the lock, perform async checks/shutdown outside it, then re-acquire the lock only to re-insert (if still alive) or finalize cleanup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/tauri/src-tauri/src/acp/commands.rs | 26 +++++++++++++++--------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/tauri/src-tauri/src/acp/commands.rs b/apps/tauri/src-tauri/src/acp/commands.rs index a44fc8a..db9c190 100644 --- a/apps/tauri/src-tauri/src/acp/commands.rs +++ b/apps/tauri/src-tauri/src/acp/commands.rs @@ -28,13 +28,16 @@ pub async fn acp_connect( app_handle: AppHandle, state: State<'_, AcpState>, ) -> Result<(), String> { - let mut connections = state.connections.lock().await; - if let Some(existing) = connections.get(&workspace_id) { + let existing = { + let mut connections = state.connections.lock().await; + connections.remove(&workspace_id) + }; + if let Some(existing) = existing { if existing.is_alive().await { + state.connections.lock().await.insert(workspace_id.clone(), existing); return Ok(()); } - let dead = connections.remove(&workspace_id).unwrap(); - dead.shutdown().await; + existing.shutdown().await; state.configs.lock().await.remove(&workspace_id); } @@ -80,7 +83,7 @@ pub async fn acp_connect( conn.emit_log("info", "connect", &format!("Connected via {}", binary)); state.configs.lock().await.insert(workspace_id.clone(), config); - connections.insert(workspace_id, conn); + state.connections.lock().await.insert(workspace_id, conn); Ok(()) } @@ -273,15 +276,18 @@ pub async fn acp_check_health( app_handle: AppHandle, state: State<'_, AcpState>, ) -> Result { - let mut connections = state.connections.lock().await; - let status = if let Some(conn) = connections.get(&workspace_id) { + let existing = { + let mut connections = state.connections.lock().await; + connections.remove(&workspace_id) + }; + let status = if let Some(conn) = existing { if conn.is_alive().await { conn.emit_status("connected", None); + state.connections.lock().await.insert(workspace_id.clone(), conn); "connected" } else { - let dead = connections.remove(&workspace_id).unwrap(); - dead.emit_status("disconnected", None); - dead.shutdown().await; + conn.emit_status("disconnected", None); + conn.shutdown().await; state.configs.lock().await.remove(&workspace_id); "disconnected" } From 0ab279cd7383826a33bec3bbccb6f07433e2f4b9 Mon Sep 17 00:00:00 2001 From: William Correa Date: Thu, 5 Mar 2026 04:24:47 -0300 Subject: [PATCH 09/15] fix(acp): Ensure `onConnect` runs even if `onDisconnect` rejects In `handleReconnect`, if `onDisconnect()` rejected the promise chain would abort before calling `onConnect()`, leaving the UI stuck in an error state. Wrapped `onDisconnect` in try/catch so `onConnect` always executes regardless of disconnect errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/tauri/src/components/ActiveSessionView.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/tauri/src/components/ActiveSessionView.tsx b/apps/tauri/src/components/ActiveSessionView.tsx index a60310b..0be3aea 100644 --- a/apps/tauri/src/components/ActiveSessionView.tsx +++ b/apps/tauri/src/components/ActiveSessionView.tsx @@ -131,12 +131,12 @@ export function ActiveSessionView({ const handleReconnect = useCallback(async () => { initRef.current = false; setInitError(null); - if (onDisconnect) { - await onDisconnect(); - } - if (onConnect) { - await onConnect(); + try { + if (onDisconnect) await onDisconnect(); + } catch (e) { + console.warn("[ActiveSessionView] disconnect during reconnect failed:", e); } + if (onConnect) await onConnect(); }, [onDisconnect, onConnect]); const saveTimerRef = useRef>(); From 6ff725935f142b3c31ecf0961c861a421fde9dcd Mon Sep 17 00:00:00 2001 From: William Correa Date: Thu, 5 Mar 2026 04:36:29 -0300 Subject: [PATCH 10/15] fix(acp): Fix listener guard and add streaming timer in session-cache Flip listenerSetup flag only after the Tauri event listener resolves, allowing retries if setup fails. Also call resetStreamingTimer() in addUserMessage so the isStreaming flag is always auto-cleared if no backend events follow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/tauri/src/lib/session-cache.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/tauri/src/lib/session-cache.ts b/apps/tauri/src/lib/session-cache.ts index 9ade9b3..06464c3 100644 --- a/apps/tauri/src/lib/session-cache.ts +++ b/apps/tauri/src/lib/session-cache.ts @@ -185,7 +185,6 @@ function notifySubscribers(workspaceId: string, entry: SessionEntry) { let listenerSetup = false; function ensureGlobalListener() { if (listenerSetup) return; - listenerSetup = true; window.__TAURI__.event.listen("acp:session-update", (event: { payload: AcpSessionUpdate }) => { const update = event.payload; @@ -203,6 +202,8 @@ function ensureGlobalListener() { } else { clearStreamingTimer(workspaceId); } + }).then(() => { + listenerSetup = true; }).catch(console.error); } @@ -238,4 +239,5 @@ export function addUserMessage(workspaceId: string, text: string) { }; sessions.set(workspaceId, newEntry); notifySubscribers(workspaceId, newEntry); + resetStreamingTimer(workspaceId); } From d2575c33bc8e03d14d2c51f108ab41609cd99cd9 Mon Sep 17 00:00:00 2001 From: William Correa Date: Thu, 5 Mar 2026 04:36:37 -0300 Subject: [PATCH 11/15] fix(ui): Replace hardcoded hex colors with HSL CSS variables in index.css Add --terminal-code CSS variable and replace all #3dd68c and #56d4dd hex values with hsl(var(--success)) and hsl(var(--terminal-code)) respectively, ensuring colors respect the app theme system. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/tauri/src/index.css | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/tauri/src/index.css b/apps/tauri/src/index.css index 862c8b6..3545f03 100644 --- a/apps/tauri/src/index.css +++ b/apps/tauri/src/index.css @@ -61,6 +61,7 @@ html, body, #root { --surface-foreground: 0 0% 8%; --terminal: 0 0% 22%; --terminal-foreground: 0 0% 86%; + --terminal-code: 185 67% 45%; --status-bar: 0 0% 95%; --status-bar-foreground: 0 0% 40%; } @@ -114,6 +115,7 @@ html, body, #root { --surface-foreground: 0 0% 92%; --terminal: 0 0% 8%; --terminal-foreground: 0 0% 78%; + --terminal-code: 185 60% 60%; --status-bar: 0 0% 12%; --status-bar-foreground: 0 0% 70%; } @@ -423,8 +425,8 @@ html, body, #root { } .dot-tool { - background: #3dd68c; - box-shadow: 0 0 6px #3dd68c80; + background: hsl(var(--success)); + box-shadow: 0 0 6px hsl(var(--success) / 0.5); } .dot-pending { @@ -532,7 +534,7 @@ html, body, #root { .streaming-cursor { width: 2px; height: 13px; - background: #3dd68c; + background: hsl(var(--success)); display: inline-block; vertical-align: text-bottom; margin-left: 1px; @@ -546,7 +548,7 @@ html, body, #root { } .terminal-caret { - color: #3dd68c; + color: hsl(var(--success)); font-weight: bold; animation: blink-caret 1.1s step-end infinite; } @@ -597,7 +599,7 @@ html, body, #root { } .terminal-markdown code { - color: #56d4dd; + color: hsl(var(--terminal-code)); background: none; border: none; border-radius: 0; @@ -720,12 +722,12 @@ html, body, #root { .terminal-markdown .task-list-item input[type="checkbox"]:checked, .terminal-markdown li > input[type="checkbox"]:checked { - background: #3dd68c; - border-color: #3dd68c; + background: hsl(var(--success)); + border-color: hsl(var(--success)); } .terminal-markdown a { - color: #56d4dd; + color: hsl(var(--terminal-code)); text-decoration: none; } From 2778aaf2ea2f2c68fe80cea595b4c48b57c1bdcb Mon Sep 17 00:00:00 2001 From: William Correa Date: Thu, 5 Mar 2026 04:36:44 -0300 Subject: [PATCH 12/15] fix(ui): Use semantic token and localize done label in TerminalMessage Replace hex color class with text-success/60 semantic token and localize the hardcoded "done" label via useTranslation(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/tauri/src/components/TerminalMessage.tsx | 4 +++- apps/tauri/src/locales/en.json | 3 +++ apps/tauri/src/locales/pt-BR.json | 3 +++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/tauri/src/components/TerminalMessage.tsx b/apps/tauri/src/components/TerminalMessage.tsx index de09f1a..4215812 100644 --- a/apps/tauri/src/components/TerminalMessage.tsx +++ b/apps/tauri/src/components/TerminalMessage.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import Markdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import type { AcpMessage } from "@/types/acp"; @@ -21,6 +22,7 @@ const markdownComponents: Components = { }; export function TerminalMessage({ message, isLast, isStreaming }: TerminalMessageProps) { + const { t } = useTranslation(); if (message.role === "user") { const lines = message.content.split("\n"); const firstLine = lines[0]; @@ -70,7 +72,7 @@ export function TerminalMessage({ message, isLast, isStreaming }: TerminalMessag {title} {message.toolStatus === "completed" && ( - done + {t("terminal.done")} )} {hasContent && content.length <= 120 && contentLines.length === 1 ? (
diff --git a/apps/tauri/src/locales/en.json b/apps/tauri/src/locales/en.json index 8ee0d5c..767a9b6 100644 --- a/apps/tauri/src/locales/en.json +++ b/apps/tauri/src/locales/en.json @@ -242,5 +242,8 @@ "showResolved": "Show resolved", "cancel": "Cancel", "submit": "Add Comment" + }, + "terminal": { + "done": "done" } } diff --git a/apps/tauri/src/locales/pt-BR.json b/apps/tauri/src/locales/pt-BR.json index 1d78edc..1a1dd01 100644 --- a/apps/tauri/src/locales/pt-BR.json +++ b/apps/tauri/src/locales/pt-BR.json @@ -242,5 +242,8 @@ "showResolved": "Mostrar resolvidos", "cancel": "Cancelar", "submit": "Adicionar Comentário" + }, + "terminal": { + "done": "feito" } } \ No newline at end of file From 6c651825685121d2f04b4efb5ba897a7f690ba56 Mon Sep 17 00:00:00 2001 From: William Correa Date: Thu, 5 Mar 2026 04:36:50 -0300 Subject: [PATCH 13/15] fix(acp): Full state reset on session load and clear idle timer on workspace change Reset all local state fields (currentMode, availableModes, activeAcpSessionId, agentPlanFilePath) when loading an existing session to prevent stale UI state. Also clear idleTimerRef in the workspaceId effect cleanup so timers don't fire on the wrong workspace. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/tauri/src/hooks/useAcpSession.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/tauri/src/hooks/useAcpSession.ts b/apps/tauri/src/hooks/useAcpSession.ts index 9c72275..fe45137 100644 --- a/apps/tauri/src/hooks/useAcpSession.ts +++ b/apps/tauri/src/hooks/useAcpSession.ts @@ -99,7 +99,10 @@ export function useAcpSession( } }); - return unsubscribe; + return () => { + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + unsubscribe(); + }; }, [workspaceId]); useEffect(() => { @@ -125,6 +128,10 @@ export function useAcpSession( sessionStore.set(workspaceId, fresh); setMessages([]); setIsStreaming(false); + setCurrentMode(null); + setAvailableModes([]); + setActiveAcpSessionId(null); + setAgentPlanFilePath(null); try { const info = await invoke("acp_load_session", { @@ -151,6 +158,10 @@ export function useAcpSession( sessionStore.set(workspaceId, fresh); setMessages([]); setIsStreaming(false); + setCurrentMode(null); + setAvailableModes([]); + setActiveAcpSessionId(null); + setAgentPlanFilePath(null); const info = await invoke("acp_new_session", { workspaceId, From ee7884147c5f01149ba3f00b04f0dd24c09770cd Mon Sep 17 00:00:00 2001 From: William Correa Date: Thu, 5 Mar 2026 04:36:53 -0300 Subject: [PATCH 14/15] fix(ui): Use @/ alias for ConnectionLogs and re-trigger init on session change Replace relative import with @/ path alias. Add a dedicated effect to reset initRef when session identity changes so doInit() reruns when a different session is mounted on the same connected workspace. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/tauri/src/components/ActiveSessionView.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/tauri/src/components/ActiveSessionView.tsx b/apps/tauri/src/components/ActiveSessionView.tsx index 0be3aea..ea73869 100644 --- a/apps/tauri/src/components/ActiveSessionView.tsx +++ b/apps/tauri/src/components/ActiveSessionView.tsx @@ -9,7 +9,7 @@ import { MarkdownViewer } from "./MarkdownViewer"; import { useAcpSession } from "@/hooks/useAcpSession"; import { useAcpLogs } from "@/hooks/useAcpLogs"; import { usePlanWorkflow } from "@/hooks/usePlanWorkflow"; -import { ConnectionLogs } from "./ConnectionLogs"; +import { ConnectionLogs } from "@/components/ConnectionLogs"; import type { SessionRecord, PlanPhase } from "@/types"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -118,6 +118,10 @@ export function ActiveSessionView({ } }, [isConnected, session.acp_session_id, session.id, session.phase, session.initial_prompt, session.name, acp.startSession, plan.startPlanning]); + useEffect(() => { + initRef.current = false; + }, [session.id, session.acp_session_id]); + useEffect(() => { if (!isConnected) { initRef.current = false; @@ -126,7 +130,7 @@ export function ActiveSessionView({ if (initRef.current) return; initRef.current = true; doInit(); - }, [isConnected]); + }, [isConnected, session.id, session.acp_session_id]); const handleReconnect = useCallback(async () => { initRef.current = false; From 1da95a83028c8b4c155c68fa9ef27a0b14ff99f3 Mon Sep 17 00:00:00 2001 From: William Correa Date: Thu, 5 Mar 2026 04:45:28 -0300 Subject: [PATCH 15/15] fix(acp): Avoid holding connections mutex during shutdown in acp_disconnect Drop the connections lock before calling conn.shutdown().await, consistent with the same fix applied to acp_connect and acp_check_health. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/tauri/src-tauri/src/acp/commands.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/tauri/src-tauri/src/acp/commands.rs b/apps/tauri/src-tauri/src/acp/commands.rs index db9c190..cea4f0b 100644 --- a/apps/tauri/src-tauri/src/acp/commands.rs +++ b/apps/tauri/src-tauri/src/acp/commands.rs @@ -93,8 +93,8 @@ pub async fn acp_disconnect( state: State<'_, AcpState>, ) -> Result<(), String> { state.configs.lock().await.remove(&workspace_id); - let mut connections = state.connections.lock().await; - if let Some(conn) = connections.remove(&workspace_id) { + let conn = state.connections.lock().await.remove(&workspace_id); + if let Some(conn) = conn { conn.emit_log("info", "disconnect", "Disconnected by user"); conn.shutdown().await; }