Skip to content

Commit e2fff0f

Browse files
wilcorreaCopilot
andauthored
feat: ACP health check, diagnostics page and visual polish
* feat(acp): add health check, heartbeat and connection status events - Add `ConnectionStatusEvent` and `ConnectionConfig` to `types.rs` - Implement `heartbeat_task` to detect dead process every 15s - Add `is_alive()` and `emit_status()` helpers to `AcpConnection` - Refactor `AcpState` to hold `connections` and `configs` separately - Add `acp_check_health` command for on-demand connection health check - Emit `acp:connection-status` event on connect, disconnect and process exit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(acp): add reactive connection status to frontend hook - Define `AcpConnectionStatus` type (idle | connecting | connected | disconnected | reconnecting) - Listen to `acp:connection-status` events emitted by the Rust backend - Call `acp_check_health` when window regains visibility - Expose `connectionStatus` from `useAcpConnection` hook - Add `AcpConnectionStatusEvent` interface to `types/acp.ts` Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(settings): add ACP connection diagnostics page - Add `run_diagnostics` Rust command to check platform, binary, version, GH_TOKEN and ACP connectivity - Test ACP connection in isolation with a 10s timeout and stderr capture - Add `DiagnosticsSettings` component with visual report and copy-to-clipboard button - Integrate 'Diagnostics' tab into the settings window (`SettingsApp`) - Add pt-BR and en translations for the diagnostics section - Update home tagline and chat placeholder copy in pt-BR Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(viewer): replace resizable panels with overlay drawers in MarkdownViewer - Remove dependency on `ResizablePanelGroup` / `ImperativePanelHandle` - Replace lateral panels with absolute overlay drawers controlled by boolean state - Add floating toggle buttons to open outline (left) and review (right) - Simplify collapse/expand logic by removing imperative refs - Preserve auto-open behaviour when entering the 'reviewing' phase Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ui): visual and UX adjustments across cards, grids and input components - Increase padding and minimum height of workspace and session cards (120px) - Use `line-clamp-2` on card titles for improved readability - Reduce grid column count and increase gap between cards - Add drag handle to `TerminalInput` for resizable height - Change send shortcut from Cmd/Ctrl+Enter to Enter (Shift+Enter inserts newline) - Show reconnect button only when connection is inactive (`disabled` prop) - Display `acp_session_id` in the active session header - Widen new session dialog and increase textarea to 20 rows - Add `type="button"` to `MicButton` to prevent accidental form submission - Reduce plan panel `minSize` from 30 to 20 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(acp): only check health when window becomes visible, not hidden The visibilitychange event fires on both hide and show transitions. Guard with `document.hidden` so health checks only run when the window regains focus, not when the user tabs away. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address PR review comments - sync reviewOpen state with review.setIsPanelOpen on toggle - block connect calls when status is 'reconnecting' - enable comrak render.escape to prevent raw HTML pass-through (XSS) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(review): address CodeRabbit review comments - SessionCard: use cn() utility instead of template-literal class merging - TerminalInput: store drag listeners in ref and clean up on unmount - TerminalInput: guard Enter-to-send with isComposing check for IME support - DiagnosticsSettings: add explicit error state and catch block in runDiagnostics - DiagnosticsSettings: await clipboard write before setting copied feedback - DiagnosticsSettings: localize ACP Command/Stderr/Home/Path labels via i18n - useAcpConnection: switch from @tauri-apps/api package imports to window.__TAURI__ globals Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(theme): add semantic success and warning design tokens Add --success, --success-foreground, --warning, --warning-foreground CSS variables to index.css for both light and dark themes, following the same HSL pattern as --destructive. Register them in tailwind.config.ts so that text-success, bg-success, text-warning, bg-warning etc. become available as Tailwind utilities. Replace hardcoded text-green-500 / text-yellow-500 / text-green-600 dark:text-green-400 in DiagnosticsSettings and CliInstallSettings with the new theme-aware text-success and text-warning tokens. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(review): address CodeRabbit follow-up comments - lib.rs: trim whitespace from gh_token before marking it as set; add USERPROFILE fallback for home_env on Windows - useAcpConnection: add .catch() to the listen() promise chain to surface failed event-listener registration as an error state - useAcpConnection: remove connectedRef guards from disconnect and unmount cleanup so teardown always runs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(review): address CodeRabbit follow-up comments (round 2) - lib.rs: apply trim() to binary_path value before using it in spawn, not only for the emptiness check; a path with leading/trailing whitespace would fail process spawn unnecessarily - useAcpConnection: reset connectedRef, connectionStatus and connectionError at the top of the workspaceId effect so the UI never shows stale state from a previous workspace while waiting for the first event from the new one Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(review): address CodeRabbit follow-up comments (round 3) - lib.rs: fix GH_TOKEN env var check to trim whitespace before treating it as set, matching the same treatment applied to the gh_token argument - lib.rs: add kill_on_drop(true) to the --version subprocess so it is terminated when the 5s timeout drops the future, preventing process leak - useAcpConnection: consolidate connectedRef updates to a single assignment (status === 'connected') so all status transitions (reconnecting, connecting, etc.) keep the ref in sync, not just connected/disconnected - useAcpConnection: add post-connect fallback health query after acp_connect succeeds to avoid stuck 'connecting' state when the status event fires before the listener is registered Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b27e126 commit e2fff0f

24 files changed

Lines changed: 879 additions & 183 deletions

apps/tauri/src-tauri/src/acp/commands.rs

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
use std::collections::HashMap;
22
use tokio::sync::Mutex;
3-
use tauri::{AppHandle, State};
3+
use tauri::{AppHandle, State, Emitter};
44

55
use super::connection::AcpConnection;
66
use super::types::*;
77

8-
pub struct AcpState(pub Mutex<HashMap<String, AcpConnection>>);
8+
pub struct AcpState {
9+
pub connections: Mutex<HashMap<String, AcpConnection>>,
10+
pub configs: Mutex<HashMap<String, ConnectionConfig>>,
11+
}
912

1013
impl Default for AcpState {
1114
fn default() -> Self {
12-
Self(Mutex::new(HashMap::new()))
15+
Self {
16+
connections: Mutex::new(HashMap::new()),
17+
configs: Mutex::new(HashMap::new()),
18+
}
1319
}
1420
}
1521

@@ -22,21 +28,34 @@ pub async fn acp_connect(
2228
app_handle: AppHandle,
2329
state: State<'_, AcpState>,
2430
) -> Result<(), String> {
25-
let mut connections = state.0.lock().await;
31+
let mut connections = state.connections.lock().await;
2632
if connections.contains_key(&workspace_id) {
2733
return Ok(());
2834
}
2935

3036
let binary = binary_path
3137
.filter(|s| !s.trim().is_empty())
3238
.unwrap_or_else(|| std::env::var("COPILOT_PATH").unwrap_or_else(|_| "copilot".to_string()));
39+
40+
let config = ConnectionConfig {
41+
binary: binary.clone(),
42+
cwd: cwd.clone(),
43+
gh_token: gh_token.clone(),
44+
};
45+
46+
let _ = app_handle.emit("acp:connection-status", &ConnectionStatusEvent {
47+
workspace_id: workspace_id.clone(),
48+
status: "connecting".to_string(),
49+
attempt: None,
50+
});
51+
3352
let conn = AcpConnection::spawn(
3453
&binary,
3554
&["--acp", "--stdio"],
3655
&cwd,
3756
gh_token,
3857
workspace_id.clone(),
39-
app_handle,
58+
app_handle.clone(),
4059
)
4160
.await?;
4261

@@ -52,7 +71,9 @@ pub async fn acp_connect(
5271
.await?;
5372

5473
conn.send_notification("initialized", None).await?;
74+
conn.emit_status("connected", None);
5575

76+
state.configs.lock().await.insert(workspace_id.clone(), config);
5677
connections.insert(workspace_id, conn);
5778
Ok(())
5879
}
@@ -62,7 +83,8 @@ pub async fn acp_disconnect(
6283
workspace_id: String,
6384
state: State<'_, AcpState>,
6485
) -> Result<(), String> {
65-
let mut connections = state.0.lock().await;
86+
state.configs.lock().await.remove(&workspace_id);
87+
let mut connections = state.connections.lock().await;
6688
if let Some(conn) = connections.remove(&workspace_id) {
6789
conn.shutdown().await;
6890
}
@@ -75,7 +97,7 @@ pub async fn acp_new_session(
7597
cwd: String,
7698
state: State<'_, AcpState>,
7799
) -> Result<SessionInfo, String> {
78-
let connections = state.0.lock().await;
100+
let connections = state.connections.lock().await;
79101
let conn = connections
80102
.get(&workspace_id)
81103
.ok_or("Not connected")?;
@@ -103,7 +125,7 @@ pub async fn acp_list_sessions(
103125
cwd: String,
104126
state: State<'_, AcpState>,
105127
) -> Result<Vec<SessionSummary>, String> {
106-
let connections = state.0.lock().await;
128+
let connections = state.connections.lock().await;
107129
let conn = connections
108130
.get(&workspace_id)
109131
.ok_or("Not connected")?;
@@ -133,7 +155,7 @@ pub async fn acp_load_session(
133155
cwd: String,
134156
state: State<'_, AcpState>,
135157
) -> Result<SessionInfo, String> {
136-
let connections = state.0.lock().await;
158+
let connections = state.connections.lock().await;
137159
let conn = connections
138160
.get(&workspace_id)
139161
.ok_or("Not connected")?;
@@ -167,7 +189,7 @@ pub async fn acp_send_prompt(
167189
text: String,
168190
state: State<'_, AcpState>,
169191
) -> Result<(), String> {
170-
let connections = state.0.lock().await;
192+
let connections = state.connections.lock().await;
171193
let conn = connections
172194
.get(&workspace_id)
173195
.ok_or("Not connected")?;
@@ -196,7 +218,7 @@ pub async fn acp_set_mode(
196218
mode: String,
197219
state: State<'_, AcpState>,
198220
) -> Result<(), String> {
199-
let connections = state.0.lock().await;
221+
let connections = state.connections.lock().await;
200222
let conn = connections
201223
.get(&workspace_id)
202224
.ok_or("Not connected")?;
@@ -221,7 +243,7 @@ pub async fn acp_cancel(
221243
session_id: String,
222244
state: State<'_, AcpState>,
223245
) -> Result<(), String> {
224-
let connections = state.0.lock().await;
246+
let connections = state.connections.lock().await;
225247
let conn = connections
226248
.get(&workspace_id)
227249
.ok_or("Not connected")?;
@@ -237,8 +259,36 @@ pub async fn acp_cancel(
237259
Ok(())
238260
}
239261

262+
#[tauri::command]
263+
pub async fn acp_check_health(
264+
workspace_id: String,
265+
app_handle: AppHandle,
266+
state: State<'_, AcpState>,
267+
) -> Result<String, String> {
268+
let connections = state.connections.lock().await;
269+
let status = if let Some(conn) = connections.get(&workspace_id) {
270+
if conn.is_alive().await {
271+
conn.emit_status("connected", None);
272+
"connected"
273+
} else {
274+
conn.emit_status("disconnected", None);
275+
"disconnected"
276+
}
277+
} else {
278+
let event = ConnectionStatusEvent {
279+
workspace_id: workspace_id.clone(),
280+
status: "disconnected".to_string(),
281+
attempt: None,
282+
};
283+
let _ = app_handle.emit("acp:connection-status", &event);
284+
"disconnected"
285+
};
286+
Ok(status.to_string())
287+
}
288+
240289
pub async fn disconnect_all(state: &AcpState) {
241-
let mut connections = state.0.lock().await;
290+
state.configs.lock().await.clear();
291+
let mut connections = state.connections.lock().await;
242292
for (id, conn) in connections.drain() {
243293
eprintln!("[acp] Disconnecting workspace {}", id);
244294
conn.shutdown().await;

apps/tauri/src-tauri/src/acp/connection.rs

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,18 @@ use tauri::{AppHandle, Emitter};
99
use super::types::*;
1010

1111
type PendingMap = Arc<Mutex<HashMap<u64, oneshot::Sender<Result<serde_json::Value, JsonRpcError>>>>>;
12+
type ChildRef = Arc<Mutex<Option<Child>>>;
1213

1314
pub struct AcpConnection {
14-
child: Mutex<Option<Child>>,
15+
child: ChildRef,
1516
writer_tx: mpsc::Sender<String>,
1617
pending: PendingMap,
1718
next_id: AtomicU64,
1819
reader_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
1920
writer_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
21+
heartbeat_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
22+
app_handle: AppHandle,
23+
workspace_id: String,
2024
}
2125

2226
impl AcpConnection {
@@ -55,17 +59,27 @@ impl AcpConnection {
5559
stdout,
5660
pending.clone(),
5761
writer_tx.clone(),
58-
workspace_id,
59-
app_handle,
62+
workspace_id.clone(),
63+
app_handle.clone(),
64+
));
65+
66+
let child_arc: ChildRef = Arc::new(Mutex::new(Some(child)));
67+
let heartbeat_handle = tokio::spawn(Self::heartbeat_task(
68+
child_arc.clone(),
69+
workspace_id.clone(),
70+
app_handle.clone(),
6071
));
6172

6273
Ok(Self {
63-
child: Mutex::new(Some(child)),
74+
child: child_arc,
6475
writer_tx,
6576
pending,
6677
next_id: AtomicU64::new(1),
6778
reader_handle: Mutex::new(Some(reader_handle)),
6879
writer_handle: Mutex::new(Some(writer_handle)),
80+
heartbeat_handle: Mutex::new(Some(heartbeat_handle)),
81+
app_handle,
82+
workspace_id,
6983
})
7084
}
7185

@@ -155,6 +169,43 @@ impl AcpConnection {
155169
}
156170
}
157171
eprintln!("[acp] Reader task ended for workspace {}", workspace_id);
172+
let event = ConnectionStatusEvent {
173+
workspace_id: workspace_id.clone(),
174+
status: "disconnected".to_string(),
175+
attempt: None,
176+
};
177+
let _ = app_handle.emit("acp:connection-status", &event);
178+
}
179+
180+
async fn heartbeat_task(child: ChildRef, workspace_id: String, app_handle: AppHandle) {
181+
let interval = std::time::Duration::from_secs(15);
182+
loop {
183+
tokio::time::sleep(interval).await;
184+
let mut guard = child.lock().await;
185+
if let Some(ref mut c) = *guard {
186+
match c.try_wait() {
187+
Ok(Some(status)) => {
188+
eprintln!("[acp] Heartbeat: process exited (status: {:?}) for workspace {}", status, workspace_id);
189+
*guard = None;
190+
drop(guard);
191+
let event = ConnectionStatusEvent {
192+
workspace_id,
193+
status: "disconnected".to_string(),
194+
attempt: None,
195+
};
196+
let _ = app_handle.emit("acp:connection-status", &event);
197+
return;
198+
}
199+
Ok(None) => {} // still running
200+
Err(e) => {
201+
eprintln!("[acp] Heartbeat: try_wait error for workspace {}: {}", workspace_id, e);
202+
}
203+
}
204+
} else {
205+
// child was already taken (shutdown called)
206+
return;
207+
}
208+
}
158209
}
159210

160211
fn handle_session_update(
@@ -241,6 +292,11 @@ impl AcpConnection {
241292
}
242293
drop(child_guard);
243294

295+
let mut heartbeat = self.heartbeat_handle.lock().await;
296+
if let Some(handle) = heartbeat.take() {
297+
handle.abort();
298+
}
299+
244300
let mut reader = self.reader_handle.lock().await;
245301
if let Some(handle) = reader.take() {
246302
handle.abort();
@@ -253,4 +309,24 @@ impl AcpConnection {
253309

254310
self.pending.lock().await.clear();
255311
}
312+
313+
/// Returns true if the child process is still running.
314+
pub async fn is_alive(&self) -> bool {
315+
let mut guard = self.child.lock().await;
316+
if let Some(ref mut c) = *guard {
317+
matches!(c.try_wait(), Ok(None))
318+
} else {
319+
false
320+
}
321+
}
322+
323+
/// Emits a connection-status event to the frontend.
324+
pub fn emit_status(&self, status: &str, attempt: Option<u32>) {
325+
let event = ConnectionStatusEvent {
326+
workspace_id: self.workspace_id.clone(),
327+
status: status.to_string(),
328+
attempt,
329+
};
330+
let _ = self.app_handle.emit("acp:connection-status", &event);
331+
}
256332
}

apps/tauri/src-tauri/src/acp/types.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,20 @@ pub struct SessionUpdateEvent {
137137
pub update_type: String,
138138
pub payload: serde_json::Value,
139139
}
140+
141+
#[derive(Debug, Serialize, Clone)]
142+
#[serde(rename_all = "camelCase")]
143+
pub struct ConnectionStatusEvent {
144+
pub workspace_id: String,
145+
pub status: String, // "connecting" | "connected" | "disconnected" | "reconnecting"
146+
#[serde(skip_serializing_if = "Option::is_none")]
147+
pub attempt: Option<u32>,
148+
}
149+
150+
#[derive(Debug, Clone)]
151+
#[allow(dead_code)] // stored for future reconnect support
152+
pub struct ConnectionConfig {
153+
pub binary: String,
154+
pub cwd: String,
155+
pub gh_token: Option<String>,
156+
}

0 commit comments

Comments
 (0)