diff --git a/apps/tauri/src-tauri/src/acp/commands.rs b/apps/tauri/src-tauri/src/acp/commands.rs index 2257ff7..cea4f0b 100644 --- a/apps/tauri/src-tauri/src/acp/commands.rs +++ b/apps/tauri/src-tauri/src/acp/commands.rs @@ -28,9 +28,17 @@ pub async fn acp_connect( app_handle: AppHandle, state: State<'_, AcpState>, ) -> Result<(), String> { - let mut connections = state.connections.lock().await; - if connections.contains_key(&workspace_id) { - return Ok(()); + 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(()); + } + existing.shutdown().await; + state.configs.lock().await.remove(&workspace_id); } let binary = binary_path @@ -72,9 +80,10 @@ 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); + state.connections.lock().await.insert(workspace_id, conn); Ok(()) } @@ -84,8 +93,9 @@ 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; } Ok(()) @@ -202,9 +212,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 +276,19 @@ pub async fn acp_check_health( app_handle: AppHandle, state: State<'_, AcpState>, ) -> Result { - let 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 { conn.emit_status("disconnected", None); + conn.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, 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/__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); + }); +}); diff --git a/apps/tauri/src/components/ActiveSessionView.tsx b/apps/tauri/src/components/ActiveSessionView.tsx index 0622f93..ea73869 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 "@/components/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,27 +107,60 @@ 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(() => { + initRef.current = false; + }, [session.id, session.acp_session_id]); useEffect(() => { - if (!isConnected || initRef.current) return; + if (!isConnected) { + initRef.current = false; + return; + } + if (initRef.current) return; initRef.current = true; doInit(); - }, [isConnected]); + }, [isConnected, session.id, session.acp_session_id]); const handleReconnect = useCallback(async () => { initRef.current = false; setInitError(null); - if (onReconnect) { - await onReconnect(); + try { + if (onDisconnect) await onDisconnect(); + } catch (e) { + console.warn("[ActiveSessionView] disconnect during reconnect failed:", e); } - }, [onReconnect]); + if (onConnect) await onConnect(); + }, [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 +238,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 +341,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..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 { @@ -5,27 +7,30 @@ interface ErrorConsoleProps { onClear: () => void; } -export function ErrorConsole({ errors, onClear }: ErrorConsoleProps) { +export function ErrorConsole ({ errors, onClear }: ErrorConsoleProps) { + const { t } = useTranslation(); 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/MarkdownViewer.tsx b/apps/tauri/src/components/MarkdownViewer.tsx index f6916d6..44a3bcd 100644 --- a/apps/tauri/src/components/MarkdownViewer.tsx +++ b/apps/tauri/src/components/MarkdownViewer.tsx @@ -405,7 +405,7 @@ export function MarkdownViewer({ variant="outline" size="icon" onClick={() => { setReviewOpen(true); review.setIsPanelOpen(true); }} - className="absolute top-3 right-3 z-20 h-8 w-8 text-muted-foreground hover:text-foreground shadow-sm" + className="absolute top-3 right-5 z-20 h-8 w-8 text-muted-foreground hover:text-foreground shadow-sm" title={t("review.togglePanel")} aria-label={t("review.togglePanel")} > 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()}> diff --git a/apps/tauri/src/components/TerminalChat.tsx b/apps/tauri/src/components/TerminalChat.tsx index 30c8bc7..4737391 100644 --- a/apps/tauri/src/components/TerminalChat.tsx +++ b/apps/tauri/src/components/TerminalChat.tsx @@ -1,7 +1,5 @@ import { useRef, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { RefreshCw } from "lucide-react"; -import { Button } from "@/components/ui/button"; import type { AcpMessage } from "@/types/acp"; import { TerminalMessage } from "./TerminalMessage"; import { TerminalInput } from "./TerminalInput"; @@ -14,7 +12,6 @@ interface TerminalChatProps { onSend: (text: string) => void; onCancel: () => void; onClearErrors: () => void; - onReconnect?: () => void; disabled?: boolean; placeholder?: string; initialPrompt?: string; @@ -27,7 +24,6 @@ export function TerminalChat({ onSend, onCancel, onClearErrors, - onReconnect, disabled, placeholder, initialPrompt, @@ -44,11 +40,11 @@ export function TerminalChat({ return (
-
+
{messages.length === 0 ? ( -
+
{initialPrompt ? ( -
+

{t("sessions.formPrompt")}

{initialPrompt}

@@ -57,12 +53,6 @@ export function TerminalChat({ {t("chat.emptyState")}

)} - {onReconnect && disabled && ( - - )}
) : (
diff --git a/apps/tauri/src/components/TerminalMessage.tsx b/apps/tauri/src/components/TerminalMessage.tsx index 1a9a287..4215812 100644 --- a/apps/tauri/src/components/TerminalMessage.tsx +++ b/apps/tauri/src/components/TerminalMessage.tsx @@ -1,7 +1,9 @@ import { useMemo } from "react"; -import Markdown from "react-markdown"; +import { useTranslation } from "react-i18next"; +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 +13,82 @@ interface TerminalMessageProps { const remarkPlugins = [remarkGfm]; +const markdownComponents: Components = { + table: ({ children, ...props }) => ( +
+ {children}
+
+ ), +}; + export function TerminalMessage({ message, isLast, isStreaming }: TerminalMessageProps) { + const { t } = useTranslation(); 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}
-            
+ {t("terminal.done")} )} -
+ {hasContent && content.length <= 120 && contentLines.length === 1 ? ( +
+ └ {contentLines[0]} +
+ ) : hasContent && ( +
+ + └ {contentLines.length} lines... + +
+                {content}
+              
+
+ )} +
); } @@ -49,12 +96,17 @@ export function TerminalMessage({ message, isLast, isStreaming }: TerminalMessag if (message.type === "thinking") { return (
- - + + -
- {message.content} -
+
+ + Thinking… + +
+ {message.content} +
+
); } @@ -64,7 +116,7 @@ export function TerminalMessage({ message, isLast, isStreaming }: TerminalMessag
- {message.content} + {message.content}
); @@ -79,11 +131,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..fe45137 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,82 @@ 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?.(); + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + unsubscribe(); }; - }, []); + }, [workspaceId]); useEffect(() => { return () => { @@ -93,226 +111,70 @@ 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); + setCurrentMode(null); + setAvailableModes([]); + setActiveAcpSessionId(null); + setAgentPlanFilePath(null); + 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); + setCurrentMode(null); + setAvailableModes([]); + setActiveAcpSessionId(null); + setAgentPlanFilePath(null); + 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 +185,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", { @@ -341,11 +194,29 @@ export function useAcpSession( text, }); } catch (e) { - setErrors((prev) => [...prev, String(e)]); - setIsStreaming(false); + 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( @@ -358,7 +229,7 @@ export function useAcpSession( sessionId: sid, mode, }); - setCurrentMode(mode); + updateSessionEntry(workspaceId, { currentMode: mode }); } catch (e) { setErrors((prev) => [...prev, String(e)]); } @@ -374,14 +245,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..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%; } @@ -357,16 +359,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 */ @@ -388,8 +425,14 @@ 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 { + border: 1.5px solid hsl(var(--muted-foreground)); + background: transparent; + opacity: 0.4; } .dot-thinking { @@ -421,46 +464,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 */ @@ -472,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; @@ -486,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; } @@ -532,26 +594,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: hsl(var(--terminal-code)); + background: none; + border: none; + border-radius: 0; + padding: 0; font-size: 0.9em; } @@ -573,15 +625,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 +676,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: hsl(var(--success)); + border-color: hsl(var(--success)); +} + .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: hsl(var(--terminal-code)); + 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..06464c3 --- /dev/null +++ b/apps/tauri/src/lib/session-cache.ts @@ -0,0 +1,243 @@ +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); + clearStreamingTimer(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>(); +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); + if (subs) { + for (const cb of subs) cb(entry); + } +} + +let listenerSetup = false; +function ensureGlobalListener() { + if (listenerSetup) return; + + 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); + + if (newEntry.isStreaming) { + resetStreamingTimer(workspaceId); + } else { + clearStreamingTimer(workspaceId); + } + }).then(() => { + listenerSetup = true; + }).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); + resetStreamingTimer(workspaceId); +} diff --git a/apps/tauri/src/locales/en.json b/apps/tauri/src/locales/en.json index 3afefbd..767a9b6 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", @@ -90,7 +91,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", @@ -236,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 f562aaa..1a1dd01 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", @@ -90,7 +91,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", @@ -236,5 +242,8 @@ "showResolved": "Mostrar resolvidos", "cancel": "Cancelar", "submit": "Adicionar Comentário" + }, + "terminal": { + "done": "feito" } } \ No newline at end of file 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; }