diff --git a/apps/tauri/src-tauri/src/acp/commands.rs b/apps/tauri/src-tauri/src/acp/commands.rs index 29ed29d..2257ff7 100644 --- a/apps/tauri/src-tauri/src/acp/commands.rs +++ b/apps/tauri/src-tauri/src/acp/commands.rs @@ -1,15 +1,21 @@ use std::collections::HashMap; use tokio::sync::Mutex; -use tauri::{AppHandle, State}; +use tauri::{AppHandle, State, Emitter}; use super::connection::AcpConnection; use super::types::*; -pub struct AcpState(pub Mutex>); +pub struct AcpState { + pub connections: Mutex>, + pub configs: Mutex>, +} impl Default for AcpState { fn default() -> Self { - Self(Mutex::new(HashMap::new())) + Self { + connections: Mutex::new(HashMap::new()), + configs: Mutex::new(HashMap::new()), + } } } @@ -22,7 +28,7 @@ pub async fn acp_connect( app_handle: AppHandle, state: State<'_, AcpState>, ) -> Result<(), String> { - let mut connections = state.0.lock().await; + let mut connections = state.connections.lock().await; if connections.contains_key(&workspace_id) { return Ok(()); } @@ -30,13 +36,26 @@ pub async fn acp_connect( let binary = binary_path .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| std::env::var("COPILOT_PATH").unwrap_or_else(|_| "copilot".to_string())); + + let config = ConnectionConfig { + binary: binary.clone(), + cwd: cwd.clone(), + gh_token: gh_token.clone(), + }; + + let _ = app_handle.emit("acp:connection-status", &ConnectionStatusEvent { + workspace_id: workspace_id.clone(), + status: "connecting".to_string(), + attempt: None, + }); + let conn = AcpConnection::spawn( &binary, &["--acp", "--stdio"], &cwd, gh_token, workspace_id.clone(), - app_handle, + app_handle.clone(), ) .await?; @@ -52,7 +71,9 @@ pub async fn acp_connect( .await?; conn.send_notification("initialized", None).await?; + conn.emit_status("connected", None); + state.configs.lock().await.insert(workspace_id.clone(), config); connections.insert(workspace_id, conn); Ok(()) } @@ -62,7 +83,8 @@ pub async fn acp_disconnect( workspace_id: String, state: State<'_, AcpState>, ) -> Result<(), String> { - let mut connections = state.0.lock().await; + state.configs.lock().await.remove(&workspace_id); + let mut connections = state.connections.lock().await; if let Some(conn) = connections.remove(&workspace_id) { conn.shutdown().await; } @@ -75,7 +97,7 @@ pub async fn acp_new_session( cwd: String, state: State<'_, AcpState>, ) -> Result { - let connections = state.0.lock().await; + let connections = state.connections.lock().await; let conn = connections .get(&workspace_id) .ok_or("Not connected")?; @@ -103,7 +125,7 @@ pub async fn acp_list_sessions( cwd: String, state: State<'_, AcpState>, ) -> Result, String> { - let connections = state.0.lock().await; + let connections = state.connections.lock().await; let conn = connections .get(&workspace_id) .ok_or("Not connected")?; @@ -133,7 +155,7 @@ pub async fn acp_load_session( cwd: String, state: State<'_, AcpState>, ) -> Result { - let connections = state.0.lock().await; + let connections = state.connections.lock().await; let conn = connections .get(&workspace_id) .ok_or("Not connected")?; @@ -167,7 +189,7 @@ pub async fn acp_send_prompt( text: String, state: State<'_, AcpState>, ) -> Result<(), String> { - let connections = state.0.lock().await; + let connections = state.connections.lock().await; let conn = connections .get(&workspace_id) .ok_or("Not connected")?; @@ -196,7 +218,7 @@ pub async fn acp_set_mode( mode: String, state: State<'_, AcpState>, ) -> Result<(), String> { - let connections = state.0.lock().await; + let connections = state.connections.lock().await; let conn = connections .get(&workspace_id) .ok_or("Not connected")?; @@ -221,7 +243,7 @@ pub async fn acp_cancel( session_id: String, state: State<'_, AcpState>, ) -> Result<(), String> { - let connections = state.0.lock().await; + let connections = state.connections.lock().await; let conn = connections .get(&workspace_id) .ok_or("Not connected")?; @@ -237,8 +259,36 @@ pub async fn acp_cancel( Ok(()) } +#[tauri::command] +pub async fn acp_check_health( + workspace_id: String, + app_handle: AppHandle, + state: State<'_, AcpState>, +) -> Result { + let connections = state.connections.lock().await; + let status = if let Some(conn) = connections.get(&workspace_id) { + if conn.is_alive().await { + conn.emit_status("connected", None); + "connected" + } else { + conn.emit_status("disconnected", None); + "disconnected" + } + } else { + let event = ConnectionStatusEvent { + workspace_id: workspace_id.clone(), + status: "disconnected".to_string(), + attempt: None, + }; + let _ = app_handle.emit("acp:connection-status", &event); + "disconnected" + }; + Ok(status.to_string()) +} + pub async fn disconnect_all(state: &AcpState) { - let mut connections = state.0.lock().await; + state.configs.lock().await.clear(); + let mut connections = state.connections.lock().await; for (id, conn) in connections.drain() { eprintln!("[acp] Disconnecting workspace {}", id); conn.shutdown().await; diff --git a/apps/tauri/src-tauri/src/acp/connection.rs b/apps/tauri/src-tauri/src/acp/connection.rs index 89d49bb..a1b8a42 100644 --- a/apps/tauri/src-tauri/src/acp/connection.rs +++ b/apps/tauri/src-tauri/src/acp/connection.rs @@ -9,14 +9,18 @@ use tauri::{AppHandle, Emitter}; use super::types::*; type PendingMap = Arc>>>>; +type ChildRef = Arc>>; pub struct AcpConnection { - child: Mutex>, + child: ChildRef, writer_tx: mpsc::Sender, pending: PendingMap, next_id: AtomicU64, reader_handle: Mutex>>, writer_handle: Mutex>>, + heartbeat_handle: Mutex>>, + app_handle: AppHandle, + workspace_id: String, } impl AcpConnection { @@ -55,17 +59,27 @@ impl AcpConnection { stdout, pending.clone(), writer_tx.clone(), - workspace_id, - app_handle, + workspace_id.clone(), + app_handle.clone(), + )); + + let child_arc: ChildRef = Arc::new(Mutex::new(Some(child))); + let heartbeat_handle = tokio::spawn(Self::heartbeat_task( + child_arc.clone(), + workspace_id.clone(), + app_handle.clone(), )); Ok(Self { - child: Mutex::new(Some(child)), + child: child_arc, writer_tx, pending, next_id: AtomicU64::new(1), reader_handle: Mutex::new(Some(reader_handle)), writer_handle: Mutex::new(Some(writer_handle)), + heartbeat_handle: Mutex::new(Some(heartbeat_handle)), + app_handle, + workspace_id, }) } @@ -155,6 +169,43 @@ impl AcpConnection { } } eprintln!("[acp] Reader task ended for workspace {}", workspace_id); + let event = ConnectionStatusEvent { + workspace_id: workspace_id.clone(), + status: "disconnected".to_string(), + attempt: None, + }; + let _ = app_handle.emit("acp:connection-status", &event); + } + + async fn heartbeat_task(child: ChildRef, workspace_id: String, app_handle: AppHandle) { + let interval = std::time::Duration::from_secs(15); + 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 event = ConnectionStatusEvent { + workspace_id, + status: "disconnected".to_string(), + attempt: None, + }; + 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; + } + } } fn handle_session_update( @@ -241,6 +292,11 @@ impl AcpConnection { } drop(child_guard); + let mut heartbeat = self.heartbeat_handle.lock().await; + if let Some(handle) = heartbeat.take() { + handle.abort(); + } + let mut reader = self.reader_handle.lock().await; if let Some(handle) = reader.take() { handle.abort(); @@ -253,4 +309,24 @@ impl AcpConnection { self.pending.lock().await.clear(); } + + /// Returns true if the child process is still running. + pub async fn is_alive(&self) -> bool { + let mut guard = self.child.lock().await; + if let Some(ref mut c) = *guard { + matches!(c.try_wait(), Ok(None)) + } else { + false + } + } + + /// Emits a connection-status event to the frontend. + pub fn emit_status(&self, status: &str, attempt: Option) { + let event = ConnectionStatusEvent { + workspace_id: self.workspace_id.clone(), + status: status.to_string(), + attempt, + }; + let _ = self.app_handle.emit("acp:connection-status", &event); + } } diff --git a/apps/tauri/src-tauri/src/acp/types.rs b/apps/tauri/src-tauri/src/acp/types.rs index dcf2bea..8ccc8a6 100644 --- a/apps/tauri/src-tauri/src/acp/types.rs +++ b/apps/tauri/src-tauri/src/acp/types.rs @@ -137,3 +137,20 @@ pub struct SessionUpdateEvent { pub update_type: String, pub payload: serde_json::Value, } + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ConnectionStatusEvent { + pub workspace_id: String, + pub status: String, // "connecting" | "connected" | "disconnected" | "reconnecting" + #[serde(skip_serializing_if = "Option::is_none")] + pub attempt: Option, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] // stored for future reconnect support +pub struct ConnectionConfig { + pub binary: String, + pub cwd: String, + pub gh_token: Option, +} diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 3c1e745..a906547 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -50,6 +50,7 @@ fn render_markdown(content: String) -> String { options.extension.tasklist = true; options.extension.strikethrough = true; options.extension.autolink = true; + options.render.escape = true; markdown_to_html(&content, &options) } @@ -236,6 +237,206 @@ fn dismiss_cli_prompt(app: tauri::AppHandle) { } } +#[derive(Debug, Serialize, Clone)] +pub struct DiagnosticsResult { + platform: String, + arch: String, + copilot_binary_used: String, + copilot_binary_found: bool, + copilot_version: Option, + copilot_version_error: Option, + gh_token_set: bool, + path_env: String, + home_env: String, + acp_ok: bool, + acp_elapsed_ms: Option, + acp_error: Option, + acp_command: Option, + acp_stderr: Option, +} + +async fn test_acp_connection(binary: &str, gh_token: Option<&str>) -> (bool, Option, Option, String, Option) { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + let timeout = std::time::Duration::from_secs(10); + let start = std::time::Instant::now(); + + let command_str = format!("{} --acp --stdio", binary); + + let mut cmd = tokio::process::Command::new(binary); + cmd.args(["--acp", "--stdio"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + + if let Some(token) = gh_token.filter(|s| !s.trim().is_empty()) { + cmd.env("GH_TOKEN", token.trim()); + } + + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => return (false, None, Some(format!("spawn failed: {}", e)), command_str, None), + }; + + let mut stdin = match child.stdin.take() { + Some(s) => s, + None => return (false, None, Some("failed to capture stdin".to_string()), command_str, None), + }; + let stdout = match child.stdout.take() { + Some(s) => s, + None => return (false, None, Some("failed to capture stdout".to_string()), command_str, None), + }; + let stderr = child.stderr.take(); + + let init_msg = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}"# .to_string() + "\n"; + + if let Err(e) = stdin.write_all(init_msg.as_bytes()).await { + return (false, None, Some(format!("write failed: {}", e)), command_str, None); + } + let _ = stdin.flush().await; + + // Collect stderr in background + let stderr_task = tokio::spawn(async move { + if let Some(s) = stderr { + let mut reader = BufReader::new(s); + let mut buf = String::new(); + let _ = tokio::time::timeout( + std::time::Duration::from_secs(10), + async { + loop { + let mut line = String::new(); + match reader.read_line(&mut line).await { + Ok(0) | Err(_) => break, + Ok(_) => buf.push_str(&line), + } + } + } + ).await; + if buf.is_empty() { None } else { Some(buf.trim().to_string()) } + } else { + None + } + }); + + let read_future = async { + let reader = BufReader::new(stdout); + let mut lines = reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + let line = line.trim().to_string(); + if line.is_empty() { continue; } + if let Ok(v) = serde_json::from_str::(&line) { + if v.get("id").and_then(|i| i.as_u64()) == Some(1) { + if v.get("error").is_some() { + let err = v["error"].to_string(); + return Err(format!("initialize error: {}", err)); + } + return Ok(()); + } + } + } + Err("connection closed without initialize response".to_string()) + }; + + match tokio::time::timeout(timeout, read_future).await { + Ok(Ok(())) => { + let _ = child.kill().await; + let stderr_out = stderr_task.await.ok().flatten(); + (true, Some(start.elapsed().as_millis() as u64), None, command_str, stderr_out) + } + Ok(Err(e)) => { + let _ = child.kill().await; + let stderr_out = stderr_task.await.ok().flatten(); + (false, Some(start.elapsed().as_millis() as u64), Some(e), command_str, stderr_out) + } + Err(_) => { + let _ = child.kill().await; + let stderr_out = stderr_task.await.ok().flatten(); + (false, Some(start.elapsed().as_millis() as u64), Some("timeout after 10s — binary may be waiting for auth or network".to_string()), command_str, stderr_out) + } + } +} + +#[tauri::command] +async fn run_diagnostics(binary_path: Option, gh_token: Option) -> DiagnosticsResult { + let binary = binary_path + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| { + std::env::var("COPILOT_PATH").unwrap_or_else(|_| "copilot".to_string()) + }); + + let platform = std::env::consts::OS.to_string(); + let arch = std::env::consts::ARCH.to_string(); + let gh_token_set = std::env::var("GH_TOKEN") + .ok() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false) + || gh_token + .as_deref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + let path_env = std::env::var("PATH").unwrap_or_default(); + let home_env = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_default(); + + let version_result = tokio::time::timeout( + std::time::Duration::from_secs(5), + tokio::process::Command::new(&binary) + .kill_on_drop(true) + .arg("--version") + .output(), + ) + .await; + + let (copilot_binary_found, copilot_version, copilot_version_error) = match version_result { + Ok(Ok(output)) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (true, Some(version), None) + } + Ok(Ok(output)) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let msg = if !stderr.is_empty() { stderr } else { stdout }; + ( + true, + None, + Some(format!("exit {:?}: {}", output.status.code(), msg)), + ) + } + Ok(Err(e)) => (false, None, Some(format!("spawn failed: {}", e))), + Err(_) => (true, None, Some("timeout after 5s".to_string())), + }; + + // Only test ACP if the binary is found + let (acp_ok, acp_elapsed_ms, acp_error, acp_command, acp_stderr) = if copilot_binary_found { + let token_ref = gh_token.as_deref(); + test_acp_connection(&binary, token_ref).await + } else { + (false, None, Some("binary not found — skipping ACP test".to_string()), format!("{} --acp --stdio", binary), None) + }; + + DiagnosticsResult { + platform, + arch, + copilot_binary_used: binary, + copilot_binary_found, + copilot_version, + copilot_version_error, + gh_token_set, + path_env, + home_env, + acp_ok, + acp_elapsed_ms, + acp_error, + acp_command: Some(acp_command), + acp_stderr, + } +} + #[tauri::command] fn get_cli_suggested_paths() -> Vec { #[cfg(target_os = "macos")] @@ -533,6 +734,11 @@ pub fn run() { .plugin( tauri_plugin_window_state::Builder::default() .with_denylist(&["whisper", "settings"]) + .with_state_flags( + tauri_plugin_window_state::StateFlags::SIZE + | tauri_plugin_window_state::StateFlags::POSITION + | tauri_plugin_window_state::StateFlags::MAXIMIZED + ) .build() ) .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { @@ -585,13 +791,21 @@ pub fn run() { setup_macos_menu(app)?; if let Some(main_window) = app.get_webview_window("main") { + // Always start hidden — frontend calls show() after React mounts. + // This overrides any visible=true that window_state may have restored + // from a previous session saved before this fix was applied. + let _ = main_window.hide(); + let w = main_window.clone(); main_window.on_window_event(move |event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { - use tauri_plugin_window_state::AppHandleExt; - let _ = w.app_handle().save_window_state(tauri_plugin_window_state::StateFlags::all()); + use tauri_plugin_window_state::{AppHandleExt, StateFlags}; api.prevent_close(); let _ = w.hide(); + // Save size/position AFTER hiding so VISIBLE is never stored as true. + // We always control visibility manually from the frontend. + let flags = StateFlags::SIZE | StateFlags::POSITION | StateFlags::MAXIMIZED; + let _ = w.app_handle().save_window_state(flags); } }); } @@ -759,6 +973,7 @@ pub fn run() { acp::commands::acp_send_prompt, acp::commands::acp_set_mode, acp::commands::acp_cancel, + acp::commands::acp_check_health, sessions::count_workspace_sessions, sessions::session_list, sessions::session_create, @@ -771,6 +986,7 @@ pub fn run() { plan_file::plan_write, plan_file::plan_read, plan_file::plan_path, + run_diagnostics, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/apps/tauri/src/SettingsApp.tsx b/apps/tauri/src/SettingsApp.tsx index 4b19e30..9c795bd 100644 --- a/apps/tauri/src/SettingsApp.tsx +++ b/apps/tauri/src/SettingsApp.tsx @@ -5,9 +5,10 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { WhisperSettings } from "@/components/settings/WhisperSettings"; import { GeneralSettings } from "@/components/settings/GeneralSettings"; import { CliInstallSettings } from "@/components/settings/CliInstallSettings"; +import { DiagnosticsSettings } from "@/components/settings/DiagnosticsSettings"; import { Toaster } from "@/components/ui/sonner"; import { useTranslation } from "react-i18next"; -import { Mic, Settings, Terminal } from "lucide-react"; +import { Activity, Mic, Settings, Terminal } from "lucide-react"; const { getCurrentWindow } = window.__TAURI__.window; const { listen } = window.__TAURI__.event; @@ -59,6 +60,10 @@ export function SettingsApp() { {t("settings.cli")} + + + {t("settings.diagnostics")} + @@ -69,6 +74,9 @@ export function SettingsApp() { + + + diff --git a/apps/tauri/src/components/ActiveSessionView.tsx b/apps/tauri/src/components/ActiveSessionView.tsx index d2bfe3c..0622f93 100644 --- a/apps/tauri/src/components/ActiveSessionView.tsx +++ b/apps/tauri/src/components/ActiveSessionView.tsx @@ -188,10 +188,10 @@ export function ActiveSessionView({ {session.name} @@ -324,6 +324,7 @@ export function ActiveSessionView({ onCancel={acp.cancel} onClearErrors={acp.clearErrors} onReconnect={handleReconnect} + disabled={!isConnected} initialPrompt={session.initial_prompt || undefined} /> @@ -336,7 +337,7 @@ export function ActiveSessionView({ ref={planPanelRef} className="relative" defaultSize={60} - minSize={30} + minSize={20} collapsible collapsedSize={0} onCollapse={() => setPlanCollapsed(true)} diff --git a/apps/tauri/src/components/DirectoryWorkspace.tsx b/apps/tauri/src/components/DirectoryWorkspace.tsx index 5ea13e4..d7f8878 100644 --- a/apps/tauri/src/components/DirectoryWorkspace.tsx +++ b/apps/tauri/src/components/DirectoryWorkspace.tsx @@ -216,7 +216,7 @@ export function DirectoryWorkspace() { ) : (
-
+
{local.sessions.map((session) => ( 0 && (

{t("home.documents")}

-
+
{fileWorkspaces.map((workspace) => ( 0 && (

{t("home.workspaces")}

-
+
{dirWorkspaces.map((workspace) => ( (null); const articleRef = useRef(null); - const outlineRef = useRef(null); - const reviewRef = useRef(null); - const [outlineCollapsed, setOutlineCollapsed] = useState(!!embedded); - const [reviewCollapsed, setReviewCollapsed] = useState(true); + const [outlineOpen, setOutlineOpen] = useState(!embedded); + const [reviewOpen, setReviewOpen] = useState(false); const review = useComments(); const prevPhaseRef = useRef(phase); @@ -185,17 +181,17 @@ export function MarkdownViewer({ useEffect(() => { if (!embedded) { - reviewRef.current?.collapse(); + setReviewOpen(false); } }, [resolvedPath, embedded]); useEffect(() => { const enteringReview = prevPhaseRef.current !== "reviewing" && phase === "reviewing"; - if (embedded && enteringReview && reviewRef.current?.isCollapsed()) { - reviewRef.current.expand(); + if (embedded && enteringReview) { + setReviewOpen(true); } - if (!embedded && review.isPanelOpen && reviewRef.current?.isCollapsed()) { - reviewRef.current.expand(); + if (!embedded && review.isPanelOpen) { + setReviewOpen(true); } prevPhaseRef.current = phase; }, [phase, review.isPanelOpen, embedded]); @@ -205,7 +201,7 @@ export function MarkdownViewer({ const reviewText = review.generateReview(); const hasComments = review.comments.some((c) => !c.resolved); review.resolveAll(); - reviewRef.current?.collapse(); + setReviewOpen(false); review.setIsPanelOpen(false); onApprovePlan(hasComments ? reviewText : undefined); }, [review.generateReview, review.comments, review.resolveAll, review.setIsPanelOpen, onApprovePlan]); @@ -277,11 +273,9 @@ export function MarkdownViewer({ variant="ghost" size="icon" onClick={() => { - if (reviewRef.current?.isCollapsed()) { - reviewRef.current.expand(); - } else { - reviewRef.current?.collapse(); - } + const next = !reviewOpen; + setReviewOpen(next); + review.setIsPanelOpen(next); }} className="h-8 w-8 relative" title={t("review.togglePanel")} @@ -341,105 +335,69 @@ export function MarkdownViewer({ function renderContent() { const isEmbedded = !!embedded; - const outlineDefault = isEmbedded ? 0 : 15; - const contentDefault = isEmbedded ? 100 : 55; - const reviewDefault = isEmbedded ? 0 : 30; - const autoSaveKey = resolvedPath ? `arandu-layout:${resolvedPath}` : undefined; return ( -
- + {/* Scroll content */} +
- setOutlineCollapsed(true)} - onExpand={() => setOutlineCollapsed(false)} +
+
+
+
+ + {/* Outline toggle button */} + {!outlineOpen && headings.length > 0 && ( + + )} + + {/* Review toggle button */} + {!reviewOpen && ( + + )} + + {/* Outline overlay */} + {outlineOpen && headings.length > 0 && ( +
outlineRef.current?.collapse()} + onClose={() => setOutlineOpen(false)} /> - - - +
+ )} - -
- {outlineCollapsed && headings.length > 0 && ( - - )} - {reviewCollapsed && ( - - )} -
-
-
-
-
-
-
- - - - { - setReviewCollapsed(true); - review.setIsPanelOpen(false); - }} - onExpand={() => { - setReviewCollapsed(false); - review.setIsPanelOpen(true); - }} - > + {/* Review overlay */} + {reviewOpen && ( +
reviewRef.current?.collapse()} + onClose={() => { setReviewOpen(false); review.setIsPanelOpen(false); }} onAddComment={review.addComment} onResolveComment={review.resolveComment} onDeleteComment={review.deleteComment} @@ -449,8 +407,8 @@ export function MarkdownViewer({ onApprovePlan={onApprovePlan ? handleApprove : undefined} onRequestChanges={onRequestChanges} /> - - +
+ )}
); } diff --git a/apps/tauri/src/components/MicButton.tsx b/apps/tauri/src/components/MicButton.tsx index 7cc84bf..8ecf01d 100644 --- a/apps/tauri/src/components/MicButton.tsx +++ b/apps/tauri/src/components/MicButton.tsx @@ -66,6 +66,7 @@ export function MicButton({ size = "default", onTranscriptionComplete }: MicButt
diff --git a/apps/tauri/src/components/ReviewPanel.tsx b/apps/tauri/src/components/ReviewPanel.tsx index 2d3f123..9d7f14e 100644 --- a/apps/tauri/src/components/ReviewPanel.tsx +++ b/apps/tauri/src/components/ReviewPanel.tsx @@ -198,7 +198,7 @@ export function ReviewPanel({ placeholder={t("review.commentPlaceholder")} className="min-h-[100px] max-h-[200px] text-xs resize-y" onKeyDown={(e) => { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(); } diff --git a/apps/tauri/src/components/SessionCard.tsx b/apps/tauri/src/components/SessionCard.tsx index b0e89d7..7b0e4a1 100644 --- a/apps/tauri/src/components/SessionCard.tsx +++ b/apps/tauri/src/components/SessionCard.tsx @@ -1,6 +1,7 @@ import { X } from "lucide-react"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; import { AlertDialog, AlertDialogAction, @@ -44,7 +45,7 @@ export function SessionCard({ session, onSelect, onDelete }: SessionCardProps) { return ( onSelect(session)} > @@ -78,14 +79,16 @@ export function SessionCard({ session, onSelect, onDelete }: SessionCardProps) { -

{session.name}

-
- - - {t(PHASE_KEYS[session.phase])} - +
+

{session.name}

+
+ + + {t(PHASE_KEYS[session.phase])} + +
-
+
{formatDistanceToNow(new Date(session.updated_at), { addSuffix: true, locale: getDateLocale(i18n.language), diff --git a/apps/tauri/src/components/TerminalChat.tsx b/apps/tauri/src/components/TerminalChat.tsx index 0948b65..30c8bc7 100644 --- a/apps/tauri/src/components/TerminalChat.tsx +++ b/apps/tauri/src/components/TerminalChat.tsx @@ -46,7 +46,7 @@ export function TerminalChat({
{messages.length === 0 ? ( -
+
{initialPrompt ? (

{t("sessions.formPrompt")}

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

)} - {onReconnect && ( - diff --git a/apps/tauri/src/components/TerminalInput.tsx b/apps/tauri/src/components/TerminalInput.tsx index a84a496..dc11372 100644 --- a/apps/tauri/src/components/TerminalInput.tsx +++ b/apps/tauri/src/components/TerminalInput.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useRef, useState, useEffect } from "react"; import { Send, Square } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; @@ -22,6 +22,37 @@ export function TerminalInput({ }: TerminalInputProps) { const { t } = useTranslation(); const [input, setInput] = useState(""); + const [inputHeight, setInputHeight] = useState(80); + const dragRef = useRef<{ y: number; h: number } | null>(null); + const dragListenersRef = useRef<{ onMove: (ev: MouseEvent) => void; onUp: () => void } | null>(null); + + useEffect(() => { + return () => { + if (dragListenersRef.current) { + document.removeEventListener("mousemove", dragListenersRef.current.onMove); + document.removeEventListener("mouseup", dragListenersRef.current.onUp); + } + }; + }, []); + + const handleDragStart = (e: React.MouseEvent) => { + e.preventDefault(); + dragRef.current = { y: e.clientY, h: inputHeight }; + const onMove = (ev: MouseEvent) => { + if (!dragRef.current) return; + const delta = dragRef.current.y - ev.clientY; + setInputHeight(Math.min(400, Math.max(80, dragRef.current.h + delta))); + }; + const onUp = () => { + dragRef.current = null; + dragListenersRef.current = null; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + dragListenersRef.current = { onMove, onUp }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }; const handleSend = () => { if (!input.trim() || isStreaming || disabled) return; @@ -30,7 +61,7 @@ export function TerminalInput({ }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); handleSend(); } @@ -38,12 +69,19 @@ export function TerminalInput({ return (
+
+
+