diff --git a/package.json b/package.json index 6b36f37b6..4acaf3de3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codeg", "private": true, - "version": "0.21.4", + "version": "0.21.5", "packageManager": "pnpm@11.9.0", "scripts": { "dev": "next dev --turbopack", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 29da54ec9..850c7c97d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1014,7 +1014,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "codeg" -version = "0.21.4" +version = "0.21.5" dependencies = [ "aes-gcm", "agent-client-protocol-schema", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6430a627e..fd514a82b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codeg" -version = "0.21.4" +version = "0.21.5" description = "Agent Code Generation App" authors = ["feitao"] edition = "2021" diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index beddd5542..925220f18 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -803,6 +803,63 @@ pub struct AcpAgentStatus { pub installed_version: Option, } +/// Severity of a single diagnostics check / the overall verdict. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DiagLevel { + /// Healthy / expected. + Ok, + /// Suspicious but not necessarily broken (e.g. slow `npm prefix -g`). + Warn, + /// A concrete problem that explains a failure. + Fail, + /// Neutral information (not a pass/fail signal). + Info, +} + +/// One labelled probe result inside a [`DiagSection`]. `value` and `hint` carry +/// dynamic data (paths, versions) and are rendered as plain text in the UI — +/// they are NEVER fed through i18n/ICU (see `label`, which is a language-neutral +/// technical string emitted by the backend). +#[derive(Debug, Clone, Serialize)] +pub struct DiagCheck { + pub label: String, + pub value: String, + pub status: DiagLevel, + pub hint: Option, +} + +/// A titled group of [`DiagCheck`]s. +#[derive(Debug, Clone, Serialize)] +pub struct DiagSection { + pub title: String, + pub checks: Vec, +} + +/// The one-line "likely cause" conclusion. `code` is a stable identifier the +/// frontend localizes via `DiagnosticsSettings.verdict.`; `summary` is a +/// pre-formatted English sentence used only inside [`AgentDiagnosticsReport::plain_text`] +/// so a copied report reads the same regardless of UI locale. +#[derive(Debug, Clone, Serialize)] +pub struct DiagnosticsVerdict { + pub level: DiagLevel, + pub code: String, + pub summary: String, +} + +/// Full environment-diagnostics report returned by `acp_env_diagnostics`. +/// +/// Plain `Serialize` with snake_case fields (the repo convention for response +/// DTOs), mirrored field-for-field by the `AgentDiagnosticsReport` TS interface. +#[derive(Debug, Clone, Serialize)] +pub struct AgentDiagnosticsReport { + pub generated_at: String, + pub agent_type: Option, + pub verdict: DiagnosticsVerdict, + pub sections: Vec, + pub plain_text: String, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AgentSkillScope { diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 3a712eaa4..166c62373 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -14,9 +14,9 @@ use crate::acp::opencode_plugins::{self, PluginCheckSummary}; use crate::acp::preflight::{self, PreflightResult}; use crate::acp::registry; use crate::acp::types::{ - AcpAgentInfo, AgentSkillContent, AgentSkillItem, AgentSkillLayout, AgentSkillLocation, - AgentSkillScope, AgentSkillsListResult, ConfigStaleKind, ConnectionStatus, GrokSettings, - GrokStructuredConfig, + AcpAgentInfo, AgentDiagnosticsReport, AgentSkillContent, AgentSkillItem, AgentSkillLayout, + AgentSkillLocation, AgentSkillScope, AgentSkillsListResult, ConfigStaleKind, ConnectionStatus, + DiagCheck, DiagLevel, DiagSection, DiagnosticsVerdict, GrokSettings, GrokStructuredConfig, }; #[cfg(feature = "tauri-runtime")] use crate::acp::types::{ConnectionInfo, ForkResultInfo, PromptInputBlock}; @@ -532,7 +532,10 @@ pub(crate) async fn verify_agent_installed(agent_type: AgentType) -> Result<(), /// Checks both the system global prefix and the user-local prefix /// (`~/.codeg/npm-global/`) so packages installed via the EACCES fallback are /// found as well. -async fn detect_npm_global_version(package_name: &str) -> Option { +/// +/// `pub(crate)` so env diagnostics can report the installed version it sees +/// (which covers both prefixes, unlike the connect-gate `resolve_npx_command`). +pub(crate) async fn detect_npm_global_version(package_name: &str) -> Option { let npm_path = which::which("npm").ok()?; // Try the default global prefix first. @@ -566,6 +569,10 @@ async fn npm_list_version( if let Some(p) = prefix { cmd.arg(format!("--prefix={}", p.display())); } + // `kill_on_drop` so a caller that bounds this with `tokio::time::timeout` + // (e.g. env diagnostics) actually terminates a hung `npm list` child rather + // than orphaning it. No effect on the normal path, which awaits to completion. + cmd.kill_on_drop(true); let output = cmd.output().await.ok()?; let stdout = String::from_utf8_lossy(&output.stdout); let json: serde_json::Value = serde_json::from_str(&stdout).ok()?; @@ -607,6 +614,1019 @@ async fn detect_local_version(agent_type: AgentType) -> Option { } } +// ============================================================================ +// Environment diagnostics (`acp_env_diagnostics`) +// +// Runs a set of READ-ONLY probes IN THE APP PROCESS (so they reflect the PATH +// the GUI app actually sees — which differs from the user's terminal PATH and +// is the root of most "installed but shows not-installed" reports). Never +// mutates PATH; never dumps arbitrary env (only a safe whitelist, redacted). +// Split into an impure `collect_diag_inputs` and a pure `build_report` so the +// verdict heuristic and rendering are unit-testable without shelling out. +// ============================================================================ + +/// Per-probe timeout for the external commands diagnostics runs. +const DIAG_PROBE_TIMEOUT: Duration = Duration::from_secs(5); + +/// Env var keys whose *values* are safe to surface in a copyable report. Chosen +/// so none carry credentials; still run through [`redact_secret`] defensively. +const DIAG_SAFE_ENV_KEYS: &[&str] = &[ + "SHELL", + "NVM_DIR", + "FNM_DIR", + "FNM_MULTISHELL_PATH", + "VOLTA_HOME", + "ASDF_DATA_DIR", + "MISE_DATA_DIR", + "N_PREFIX", + "HOMEBREW_PREFIX", + "npm_config_prefix", + "LANG", +]; + +/// Mask a value that may contain a secret, keyed on the env var *name*. If the +/// name looks credential-bearing the value is partially masked; otherwise it is +/// returned verbatim. Multibyte-safe (masks by `char`, mirroring the approach in +/// `models::model_provider::mask_api_key`) so it never panics on a UTF-8 value. +fn redact_secret(key: &str, value: &str) -> String { + let lower = key.to_ascii_lowercase(); + let secretish = ["key", "token", "secret", "password", "passwd", "auth", "credential"] + .iter() + .any(|needle| lower.contains(needle)); + if !secretish { + return value.to_string(); + } + let chars: Vec = value.chars().collect(); + let len = chars.len(); + if len == 0 { + return String::new(); + } + if len <= 8 { + return "•".repeat(len); + } + let first: String = chars[..2].iter().collect(); + let last: String = chars[len - 2..].iter().collect(); + format!("{first}{}{last}", "•".repeat(len.saturating_sub(4).min(16))) +} + +/// npm places a package's bin as `.cmd` on Windows, bare `` elsewhere. +fn diag_exe_name(cmd: &str) -> String { + if cfg!(windows) { + format!("{cmd}.cmd") + } else { + cmd.to_string() + } +} + +#[derive(Default, Clone)] +struct CmdProbe { + /// Absolute path `which` resolved to, if any (same resolution the connect + /// gate uses). + path: Option, + /// First `--version`/`-v` output line, if the command ran. + version: Option, +} + +#[derive(Default, Clone)] +struct AgentDiag { + name: String, + cmd: String, + distribution: &'static str, + package: Option, + node_required: Option, + /// Distribution-appropriate launchability, mirroring `verify_agent_installed`: + /// npx → `resolve_npx_command`; binary → cached/system binary; uvx → + /// `uvx_agent_launchable`. `None` = the agent cannot launch right now. This + /// (NOT `resolve_npx`) is what the verdict uses for non-npx agents, so a + /// working cached-binary / uvx agent is not misreported as node-missing. + launchable: Option, + /// `resolve_npx_command(cmd)` — the resolution the new-session page gates on + /// (npx agents only; `None` for binary/uvx). + resolve_npx: Option, + /// `/bin/` when it exists. + system_prefix_bin: Option, + /// `~/.codeg/npm-global/bin/` (EACCES fallback) when it exists. + user_prefix_bin: Option, + /// Homebrew global bin (`/opt/homebrew/bin/` etc.) when it exists (macOS). + homebrew_bin: Option, + /// Version seen by `detect_npm_global_version` (covers BOTH prefixes). + detected_version: Option, + /// `agent_setting.installed_version` recorded in the DB. + db_version: Option, +} + +#[derive(Default, Clone)] +struct TerminalProbe { + /// The login-shell probe actually ran and produced output. + ran: bool, + /// Dirs in the login-shell PATH that are ABSENT from the app PATH — the + /// smoking gun for a GUI PATH gap. + extra_dirs: Vec, + /// `command -v ` result in the login shell. + cmd_resolved: Option, + /// Why the probe didn't run (Windows / no `$SHELL` / timeout). + note: Option, +} + +#[derive(Default, Clone)] +struct DiagInputs { + os: String, + arch: String, + app_version: String, + app_path: Vec, + path_logs: Vec, + node: CmdProbe, + npm: CmdProbe, + npx: CmdProbe, + npm_prefix_g: Option, + npm_prefix_g_ms: u128, + npm_root_g: Option, + npm_config_prefix: Option, + cached_prefix: Option, + /// (candidate bin dir, whether it contains a `node` binary). + node_candidates: Vec<(String, bool)>, + selected_node_dir: Option, + safe_env: Vec<(String, String)>, + agent: Option, + terminal: TerminalProbe, +} + +/// Run a command with a timeout and return its first non-empty stdout line. +async fn diag_run(program: &Path, args: &[&str]) -> Option { + let mut cmd = crate::process::tokio_command(program); + cmd.args(args).kill_on_drop(true); + let out = tokio::time::timeout(DIAG_PROBE_TIMEOUT, cmd.output()) + .await + .ok()? + .ok()?; + let text = String::from_utf8_lossy(&out.stdout); + text.lines().find_map(|l| { + let t = l.trim(); + (!t.is_empty()).then(|| t.to_string()) + }) +} + +/// Resolve a command on the app PATH and probe its version. +async fn diag_cmd_probe(cmd: &str, version_args: &[&str]) -> CmdProbe { + let path = resolve_command_on_path(cmd); + let version = match &path { + Some(p) => diag_run(p, version_args).await, + None => None, + }; + CmdProbe { + path: path.map(|p| p.to_string_lossy().to_string()), + version, + } +} + +/// Major version from `v20.11.1` / `20.11.1`. +fn parse_node_major(v: &str) -> Option { + v.trim().trim_start_matches('v').split('.').next()?.parse().ok() +} + +/// Compare the user's login-shell PATH against the app PATH. Unix-only; on +/// Windows it returns a note (npm bins there are `.cmd` in the prefix root and a +/// robust login-shell probe is out of scope for v1). +#[cfg(not(windows))] +async fn diag_terminal_probe(cmd: &str, app_path: &[String]) -> TerminalProbe { + let mut probe = TerminalProbe::default(); + let shell = match std::env::var("SHELL") { + Ok(s) if !s.trim().is_empty() => s, + _ => { + probe.note = Some("$SHELL not set".to_string()); + return probe; + } + }; + // `cmd` is a static registry command name — no shell metacharacters. + let script = + format!("printf 'CODEG_PATH=%s\\n' \"$PATH\"; command -v {cmd} 2>/dev/null || true"); + let mut c = crate::process::tokio_command(&shell); + c.arg("-lic").arg(&script).kill_on_drop(true); + let out = match tokio::time::timeout(DIAG_PROBE_TIMEOUT, c.output()).await { + Ok(Ok(o)) => o, + _ => { + probe.note = Some("login shell probe timed out or failed".to_string()); + return probe; + } + }; + probe.ran = true; + let stdout = String::from_utf8_lossy(&out.stdout); + let mut term_path: Option = None; + for line in stdout.lines() { + if let Some(p) = line.strip_prefix("CODEG_PATH=") { + term_path = Some(p.to_string()); + } else if line.starts_with('/') && probe.cmd_resolved.is_none() { + probe.cmd_resolved = Some(line.trim().to_string()); + } + } + if let Some(tp) = term_path { + let app_set: std::collections::HashSet<&str> = app_path.iter().map(String::as_str).collect(); + let mut seen = std::collections::HashSet::new(); + probe.extra_dirs = tp + .split(':') + .filter(|d| !d.is_empty() && !app_set.contains(*d)) + .filter(|d| seen.insert(d.to_string())) + .map(String::from) + .collect(); + } + probe +} + +#[cfg(windows)] +async fn diag_terminal_probe(_cmd: &str, _app_path: &[String]) -> TerminalProbe { + TerminalProbe { + note: Some("Windows: compare PATH manually in PowerShell".to_string()), + ..Default::default() + } +} + +/// Per-agent resolution probes (the core signals for the not-installed symptom). +async fn collect_agent_diag( + db: &AppDatabase, + agent_type: AgentType, + npm_prefix_g: Option<&str>, +) -> AgentDiag { + let meta = registry::get_agent_meta(agent_type); + + let db_version = agent_setting_service::get_by_agent_type(&db.conn, agent_type) + .await + .ok() + .flatten() + .and_then(|m| m.installed_version); + + let mut diag = AgentDiag { + name: meta.name.to_string(), + db_version, + ..Default::default() + }; + + // Each distribution resolves launchability differently — mirror the exact + // gates `verify_agent_installed` uses so the report agrees with connect. + match meta.distribution { + registry::AgentDistribution::Npx { + cmd, + package, + node_required, + .. + } => { + diag.cmd = cmd.to_string(); + diag.distribution = "npx"; + diag.package = Some(package.to_string()); + diag.node_required = node_required.map(str::to_string); + + let resolve_npx = resolve_npx_command(cmd) + .await + .map(|p| p.to_string_lossy().to_string()); + diag.launchable = resolve_npx.clone(); + diag.resolve_npx = resolve_npx; + + diag.system_prefix_bin = npm_prefix_g.and_then(|p| { + let cand = npm_prefix_bin_dir(Path::new(p)).join(diag_exe_name(cmd)); + cand.is_file().then(|| cand.to_string_lossy().to_string()) + }); + diag.user_prefix_bin = crate::process::user_npm_prefix().and_then(|prefix| { + let cand = npm_prefix_bin_dir(&prefix).join(diag_exe_name(cmd)); + cand.is_file().then(|| cand.to_string_lossy().to_string()) + }); + diag.homebrew_bin = if cfg!(target_os = "macos") { + ["/opt/homebrew/bin", "/usr/local/bin"].iter().find_map(|d| { + let cand = Path::new(d).join(diag_exe_name(cmd)); + cand.is_file().then(|| cand.to_string_lossy().to_string()) + }) + } else { + None + }; + // `npm list` can hang on a stalled global prefix; bound it (the child + // is killed on drop via `npm_list_version`'s `kill_on_drop`). + diag.detected_version = tokio::time::timeout( + DIAG_PROBE_TIMEOUT, + detect_npm_global_version(&package_name_from_spec(package)), + ) + .await + .ok() + .flatten(); + } + registry::AgentDistribution::Binary { + cmd, + platforms, + dir_entry, + .. + } => { + diag.cmd = cmd.to_string(); + diag.distribution = "binary"; + // Mirror verify_agent_installed exactly: it first rejects unsupported + // platforms, then evaluates + // `find_best_cached_binary_for_agent(..)?.is_some() || (dir_entry && + // system)`. So an unsupported platform — and a cache-read *error* — + // both FAIL the gate (the latter propagated via `?`) rather than + // falling through to the system binary. Reflect both here so + // diagnostics never reports "ok" for a case where connect errors out. + let supported = platforms + .iter() + .any(|p| p.platform == registry::current_platform()); + diag.launchable = if !supported { + None + } else { + match binary_cache::find_best_cached_binary_for_agent(agent_type, cmd) { + Ok(Some((path, _version))) => Some(path), + Ok(None) if dir_entry.is_some() => resolve_system_agent_binary(cmd), + Ok(None) | Err(_) => None, + } + .map(|p| p.to_string_lossy().to_string()) + }; + } + registry::AgentDistribution::Uvx { + cmd, + package, + system_cmd, + .. + } => { + diag.cmd = cmd.to_string(); + diag.distribution = "uvx"; + diag.package = Some(package.to_string()); + diag.launchable = uvx_agent_launchable(system_cmd).then(|| { + resolve_uvx_command() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| format!("{cmd} (system CLI on PATH)")) + }); + } + } + + diag +} + +/// Gather all diagnostics signals (impure: runs commands / reads env, fs, DB). +async fn collect_diag_inputs(db: &AppDatabase, agent_type: Option) -> DiagInputs { + let mut inp = DiagInputs { + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + app_version: env!("CARGO_PKG_VERSION").to_string(), + ..Default::default() + }; + + if let Some(path_os) = std::env::var_os("PATH") { + inp.app_path = std::env::split_paths(&path_os) + .map(|p| p.to_string_lossy().to_string()) + .collect(); + } + + inp.path_logs = crate::commands::logging::get_recent_logs_core(20, None, Some("[PATH]")) + .into_iter() + .map(|r| r.message) + .collect(); + + inp.node = diag_cmd_probe("node", &["-v"]).await; + inp.npm = diag_cmd_probe("npm", &["-v"]).await; + inp.npx = diag_cmd_probe("npx", &["-v"]).await; + + if let Some(npm_path) = resolve_command_on_path("npm") { + let start = std::time::Instant::now(); + inp.npm_prefix_g = diag_run(&npm_path, &["prefix", "-g"]).await; + inp.npm_prefix_g_ms = start.elapsed().as_millis(); + inp.npm_root_g = diag_run(&npm_path, &["root", "-g"]).await; + inp.npm_config_prefix = diag_run(&npm_path, &["config", "get", "prefix"]).await; + } + inp.cached_prefix = cached_npm_global_prefix() + .await + .map(|p| p.to_string_lossy().to_string()); + + let home = home_dir_or_default(); + let node_bin = if cfg!(windows) { "node.exe" } else { "node" }; + for dir in crate::process::node_bin_dir_candidates(Some(home.as_path())) { + let has_node = dir.join(node_bin).is_file(); + let dir_str = dir.to_string_lossy().to_string(); + if has_node && inp.selected_node_dir.is_none() { + inp.selected_node_dir = Some(dir_str.clone()); + } + inp.node_candidates.push((dir_str, has_node)); + } + + for &key in DIAG_SAFE_ENV_KEYS { + if let Ok(val) = std::env::var(key) { + if !val.trim().is_empty() { + inp.safe_env.push((key.to_string(), redact_secret(key, &val))); + } + } + } + + if let Some(at) = agent_type { + let agent = collect_agent_diag(db, at, inp.npm_prefix_g.as_deref()).await; + inp.terminal = diag_terminal_probe(&agent.cmd, &inp.app_path).await; + inp.agent = Some(agent); + } + + inp +} + +fn diag_check(label: &str, value: &str, status: DiagLevel, hint: Option<&str>) -> DiagCheck { + DiagCheck { + label: label.to_string(), + value: value.to_string(), + status, + hint: hint.map(str::to_string), + } +} + +fn diag_verdict(level: DiagLevel, code: &str, summary: &str) -> DiagnosticsVerdict { + DiagnosticsVerdict { + level, + code: code.to_string(), + summary: summary.to_string(), + } +} + +/// Pure: derive the one-line "likely cause" from gathered inputs. +fn compute_verdict(inp: &DiagInputs) -> DiagnosticsVerdict { + let agent = match &inp.agent { + None => { + // Base environment report — Node/npm health only. + if inp.node.path.is_none() { + return diag_verdict( + DiagLevel::Fail, + "node_missing", + "Node.js was not found on the app's PATH.", + ); + } + if inp.npm.path.is_none() { + return diag_verdict( + DiagLevel::Fail, + "npm_missing", + "npm was not found on the app's PATH.", + ); + } + return diag_verdict( + DiagLevel::Ok, + "ok", + "The base Node.js environment looks healthy.", + ); + } + Some(a) => a, + }; + + // Binary/Uvx agents do not depend on Node/npm — judge them by their own + // launch gate so a working cached-binary / uvx agent is never misreported. + if agent.distribution != "npx" { + if agent.launchable.is_some() { + return diag_verdict( + DiagLevel::Ok, + "ok", + "The agent is launchable — the environment looks healthy.", + ); + } + if agent.db_version.is_some() { + return diag_verdict( + DiagLevel::Fail, + "installed_but_unresolved", + "The agent is recorded as installed, but the app cannot locate its runtime.", + ); + } + return diag_verdict( + DiagLevel::Info, + "not_installed", + "This agent does not appear to be installed.", + ); + } + + // NPX agents: Node/npm-aware heuristic. + if inp.node.path.is_none() { + return diag_verdict( + DiagLevel::Fail, + "node_missing", + "Node.js was not found on the app's PATH.", + ); + } + if inp.npm.path.is_none() { + return diag_verdict( + DiagLevel::Fail, + "npm_missing", + "npm was not found on the app's PATH.", + ); + } + + if let (Some(req), Some(ver)) = (agent.node_required.as_deref(), inp.node.version.as_deref()) { + if let (Some(rmaj), Some(nmaj)) = (parse_node_major(req), parse_node_major(ver)) { + if nmaj < rmaj { + return diag_verdict( + DiagLevel::Fail, + "node_too_old", + "The active Node.js is older than this agent requires.", + ); + } + } + } + + if agent.resolve_npx.is_none() { + if agent.db_version.is_some() || agent.detected_version.is_some() { + if agent.user_prefix_bin.is_some() { + return diag_verdict( + DiagLevel::Fail, + "user_prefix_not_on_path", + "Installed into the EACCES fallback prefix (~/.codeg/npm-global), which is not on the app's PATH.", + ); + } + if agent.homebrew_bin.is_some() { + return diag_verdict( + DiagLevel::Fail, + "homebrew_bin_not_on_path", + "Installed under Homebrew's bin, which is not on the app's PATH (Apple Silicon keg split).", + ); + } + if inp.terminal.cmd_resolved.is_some() { + return diag_verdict( + DiagLevel::Fail, + "terminal_only_path", + "The command resolves in your terminal but not in the app process — a GUI PATH gap.", + ); + } + if inp.npm_prefix_g_ms > NPM_PREFIX_TIMEOUT.as_millis() { + return diag_verdict( + DiagLevel::Warn, + "npm_prefix_timeout", + "`npm prefix -g` exceeded the 1.5s probe timeout, so fallback resolution was skipped.", + ); + } + return diag_verdict( + DiagLevel::Fail, + "installed_but_unresolved", + "The agent is recorded as installed, but the app cannot locate its executable.", + ); + } + return diag_verdict( + DiagLevel::Info, + "not_installed", + "This agent does not appear to be installed.", + ); + } + + diag_verdict( + DiagLevel::Ok, + "ok", + "The agent's command resolves — the environment looks healthy.", + ) +} + +/// Pure: turn gathered inputs into the structured sections + a copyable text +/// blob. `generated_at` is injected so the output is deterministic in tests. +fn build_report( + inp: &DiagInputs, + generated_at: String, + agent_type: Option, +) -> AgentDiagnosticsReport { + let verdict = compute_verdict(inp); + let mut sections: Vec = Vec::new(); + + // 1. Runtime + let mut runtime = vec![ + diag_check("os / arch", &format!("{} / {}", inp.os, inp.arch), DiagLevel::Info, None), + diag_check("app version", &inp.app_version, DiagLevel::Info, None), + ]; + let fix_failed = inp.path_logs.iter().any(|l| l.contains("fix_path_env failed")); + runtime.push(diag_check( + "fix_path_env", + if fix_failed { "failed at startup" } else { "no failure logged" }, + if fix_failed { DiagLevel::Warn } else { DiagLevel::Info }, + Some("app imports the login-shell PATH at startup; a failure leaves a narrow GUI PATH"), + )); + for (k, v) in &inp.safe_env { + runtime.push(diag_check(k, v, DiagLevel::Info, None)); + } + runtime.push(diag_check( + "PATH", + &format!("{} entries (full list in copied text)", inp.app_path.len()), + DiagLevel::Info, + None, + )); + sections.push(DiagSection { title: "Runtime".to_string(), checks: runtime }); + + // 2. Node / npm / npx + let node_status = |p: &CmdProbe| if p.path.is_some() { DiagLevel::Ok } else { DiagLevel::Fail }; + let cmd_value = |p: &CmdProbe| match (&p.path, &p.version) { + (Some(path), Some(ver)) => format!("{ver} ({path})"), + (Some(path), None) => path.clone(), + _ => "NOT FOUND".to_string(), + }; + sections.push(DiagSection { + title: "Node / npm".to_string(), + checks: vec![ + diag_check("node", &cmd_value(&inp.node), node_status(&inp.node), None), + diag_check("npm", &cmd_value(&inp.npm), node_status(&inp.npm), None), + diag_check("npx", &cmd_value(&inp.npx), node_status(&inp.npx), None), + ], + }); + + // 3. npm global prefix + let prefix_slow = inp.npm_prefix_g_ms > NPM_PREFIX_TIMEOUT.as_millis(); + sections.push(DiagSection { + title: "npm global prefix".to_string(), + checks: vec![ + diag_check( + "npm prefix -g", + &format!( + "{} ({} ms)", + inp.npm_prefix_g.as_deref().unwrap_or("N/A"), + inp.npm_prefix_g_ms + ), + if prefix_slow { DiagLevel::Warn } else { DiagLevel::Info }, + prefix_slow.then_some("exceeds the 1.5s gate used at detection time"), + ), + diag_check("npm root -g", inp.npm_root_g.as_deref().unwrap_or("N/A"), DiagLevel::Info, None), + diag_check( + "npm config get prefix", + inp.npm_config_prefix.as_deref().unwrap_or("N/A"), + DiagLevel::Info, + None, + ), + diag_check("cached prefix", inp.cached_prefix.as_deref().unwrap_or("N/A"), DiagLevel::Info, None), + ], + }); + + // 4. Target agent — launchability keyed to its distribution. + if let Some(a) = &inp.agent { + let launch_label = match a.distribution { + "npx" => format!("{} (resolve_npx_command)", a.cmd), + "binary" => format!("{} (cached / system binary)", a.cmd), + _ => format!("{} (uvx launchable)", a.cmd), + }; + let mut checks = vec![diag_check( + &launch_label, + a.launchable.as_deref().unwrap_or("NOT RESOLVED"), + if a.launchable.is_some() { DiagLevel::Ok } else { DiagLevel::Fail }, + (a.distribution == "npx").then_some("this is exactly what the new-session page checks"), + )]; + if let Some(p) = &a.package { + checks.push(diag_check("package", p, DiagLevel::Info, None)); + } + // npm-prefix detail only applies to npx agents. + if a.distribution == "npx" { + checks.push(diag_check( + "/bin/", + a.system_prefix_bin.as_deref().unwrap_or("absent"), + if a.system_prefix_bin.is_some() { DiagLevel::Ok } else { DiagLevel::Info }, + None, + )); + checks.push(diag_check( + "~/.codeg/npm-global/bin/", + a.user_prefix_bin.as_deref().unwrap_or("absent"), + if a.user_prefix_bin.is_some() { DiagLevel::Warn } else { DiagLevel::Info }, + a.user_prefix_bin.as_ref().map(|_| "EACCES fallback dir — reached by the connect gate only if it's on PATH"), + )); + if cfg!(target_os = "macos") { + checks.push(diag_check( + "homebrew bin/", + a.homebrew_bin.as_deref().unwrap_or("absent"), + if a.homebrew_bin.is_some() { DiagLevel::Warn } else { DiagLevel::Info }, + None, + )); + } + checks.push(diag_check( + "detect_npm_global_version", + a.detected_version.as_deref().unwrap_or("none"), + DiagLevel::Info, + Some("covers both prefixes — what the Settings badge uses"), + )); + } + checks.push(diag_check( + "DB installed_version", + a.db_version.as_deref().unwrap_or("none"), + DiagLevel::Info, + None, + )); + if let Some(req) = &a.node_required { + let ok = inp + .node + .version + .as_deref() + .and_then(parse_node_major) + .zip(parse_node_major(req)) + .map(|(n, r)| n >= r) + .unwrap_or(true); + checks.push(diag_check( + "node_required", + &format!("≥ {req}"), + if ok { DiagLevel::Ok } else { DiagLevel::Fail }, + (!ok).then_some("the active Node.js is older than required"), + )); + } + sections.push(DiagSection { + title: format!("Agent: {} ({})", a.name, a.distribution), + checks, + }); + } + + // 5. Node version managers (candidate bin dirs) + if !inp.node_candidates.is_empty() { + let checks = inp + .node_candidates + .iter() + .map(|(dir, has)| { + let selected = inp.selected_node_dir.as_deref() == Some(dir.as_str()); + diag_check( + "candidate", + dir, + if *has { DiagLevel::Ok } else { DiagLevel::Info }, + selected.then_some("← selected (first with a node binary)"), + ) + }) + .collect(); + sections.push(DiagSection { + title: "Node version-manager candidates".to_string(), + checks, + }); + } + + // 6. Terminal comparison + let term_checks = if inp.terminal.ran { + vec![ + diag_check( + "command -v (login shell)", + inp.terminal.cmd_resolved.as_deref().unwrap_or("not found"), + DiagLevel::Info, + None, + ), + diag_check( + "PATH dirs in terminal but not app", + &if inp.terminal.extra_dirs.is_empty() { + "none".to_string() + } else { + format!("{} (see copied text)", inp.terminal.extra_dirs.len()) + }, + if inp.terminal.extra_dirs.is_empty() { DiagLevel::Ok } else { DiagLevel::Warn }, + (!inp.terminal.extra_dirs.is_empty()) + .then_some("the app can't see these dirs — the likely GUI PATH gap"), + ), + ] + } else { + vec![diag_check( + "login shell probe", + inp.terminal.note.as_deref().unwrap_or("did not run"), + DiagLevel::Info, + None, + )] + }; + sections.push(DiagSection { title: "Terminal comparison".to_string(), checks: term_checks }); + + let plain_text = render_plain_text(inp, &verdict, §ions, &generated_at, agent_type); + + AgentDiagnosticsReport { + generated_at, + agent_type, + verdict, + sections, + plain_text, + } +} + +/// Pure: render a copyable text report (structured checks + verbose appendices +/// that are summarized in the UI). +fn render_plain_text( + inp: &DiagInputs, + verdict: &DiagnosticsVerdict, + sections: &[DiagSection], + generated_at: &str, + agent_type: Option, +) -> String { + let glyph = |s: DiagLevel| match s { + DiagLevel::Ok => "OK ", + DiagLevel::Warn => "WARN", + DiagLevel::Fail => "FAIL", + DiagLevel::Info => "-- ", + }; + let mut out = String::new(); + out.push_str("===== Codeg environment diagnostics =====\n"); + out.push_str(&format!("generated: {generated_at}\n")); + if let Some(at) = agent_type { + out.push_str(&format!("agent: {at:?}\n")); + } + out.push_str(&format!("verdict [{}]: {}\n", verdict.code, verdict.summary)); + for sec in sections { + out.push_str(&format!("\n## {}\n", sec.title)); + for c in &sec.checks { + out.push_str(&format!(" [{}] {}: {}\n", glyph(c.status), c.label, c.value)); + if let Some(h) = &c.hint { + out.push_str(&format!(" ↳ {h}\n")); + } + } + } + // Appendices (verbose lists shown only summarized in the UI). + out.push_str("\n## PATH (app process, in order)\n"); + for d in &inp.app_path { + out.push_str(&format!(" {d}\n")); + } + if !inp.terminal.extra_dirs.is_empty() { + out.push_str("\n## PATH dirs in terminal but NOT in app\n"); + for d in &inp.terminal.extra_dirs { + out.push_str(&format!(" {d}\n")); + } + } + if !inp.path_logs.is_empty() { + out.push_str("\n## recent [PATH] logs\n"); + for l in &inp.path_logs { + out.push_str(&format!(" {l}\n")); + } + } + out.push_str("===== end =====\n"); + out +} + +/// Gather environment-diagnostics for the given agent (or a base env report when +/// `agent_type` is `None`). Read-only; safe to call from the session page. +pub(crate) async fn acp_env_diagnostics_core( + db: &AppDatabase, + agent_type: Option, +) -> Result { + let inputs = collect_diag_inputs(db, agent_type).await; + let generated_at = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %z").to_string(); + Ok(build_report(&inputs, generated_at, agent_type)) +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn acp_env_diagnostics( + agent_type: Option, + db: State<'_, AppDatabase>, +) -> Result { + acp_env_diagnostics_core(&db, agent_type).await +} + +#[cfg(test)] +mod diagnostics_tests { + use super::*; + + fn base_inputs() -> DiagInputs { + DiagInputs { + node: CmdProbe { + path: Some("/usr/bin/node".to_string()), + version: Some("v20.11.1".to_string()), + }, + npm: CmdProbe { + path: Some("/usr/bin/npm".to_string()), + version: Some("10.2.4".to_string()), + }, + ..Default::default() + } + } + + fn agent_installed_unresolved() -> AgentDiag { + AgentDiag { + name: "Codex CLI".to_string(), + cmd: "codex-acp".to_string(), + distribution: "npx", + db_version: Some("1.1.2".to_string()), + ..Default::default() + } + } + + #[test] + fn redact_only_masks_secretish_keys() { + assert_eq!(redact_secret("NVM_DIR", "/home/u/.nvm"), "/home/u/.nvm"); + assert_eq!(redact_secret("SHELL", "/bin/zsh"), "/bin/zsh"); + // secret-ish key → masked, and multibyte-safe (no panic / no split). + let masked = redact_secret("XAI_API_KEY", "sk-测试secret-1234567890"); + assert!(masked.contains('•')); + assert!(!masked.contains("secret")); + assert_eq!(redact_secret("MY_TOKEN", "abcd"), "••••"); + } + + #[test] + fn verdict_node_missing() { + let inp = DiagInputs::default(); + assert_eq!(compute_verdict(&inp).code, "node_missing"); + } + + #[test] + fn verdict_ok_without_agent() { + assert_eq!(compute_verdict(&base_inputs()).code, "ok"); + } + + #[test] + fn verdict_user_prefix_not_on_path() { + let mut inp = base_inputs(); + let mut a = agent_installed_unresolved(); + a.user_prefix_bin = Some("/home/u/.codeg/npm-global/bin/codex-acp".to_string()); + inp.agent = Some(a); + let v = compute_verdict(&inp); + assert_eq!(v.code, "user_prefix_not_on_path"); + assert_eq!(v.level, DiagLevel::Fail); + } + + #[test] + fn verdict_homebrew_bin_not_on_path() { + let mut inp = base_inputs(); + let mut a = agent_installed_unresolved(); + a.homebrew_bin = Some("/opt/homebrew/bin/codex-acp".to_string()); + inp.agent = Some(a); + assert_eq!(compute_verdict(&inp).code, "homebrew_bin_not_on_path"); + } + + #[test] + fn verdict_terminal_only_path() { + let mut inp = base_inputs(); + let mut a = agent_installed_unresolved(); + a.detected_version = Some("1.1.2".to_string()); + inp.agent = Some(a); + inp.terminal.cmd_resolved = Some("/Users/u/.nvm/versions/node/v20/bin/codex-acp".to_string()); + assert_eq!(compute_verdict(&inp).code, "terminal_only_path"); + } + + #[test] + fn verdict_node_too_old() { + let mut inp = base_inputs(); + inp.node.version = Some("v18.19.0".to_string()); + let mut a = agent_installed_unresolved(); + a.node_required = Some("20.0.0".to_string()); + a.resolve_npx = Some("/x/codex-acp".to_string()); // resolves, but node too old + inp.agent = Some(a); + assert_eq!(compute_verdict(&inp).code, "node_too_old"); + } + + #[test] + fn verdict_ok_when_resolved() { + let mut inp = base_inputs(); + let mut a = agent_installed_unresolved(); + a.resolve_npx = Some("/usr/local/bin/codex-acp".to_string()); + inp.agent = Some(a); + assert_eq!(compute_verdict(&inp).code, "ok"); + } + + #[test] + fn verdict_not_installed_when_nothing_recorded() { + let mut inp = base_inputs(); + inp.agent = Some(AgentDiag { + cmd: "codex-acp".to_string(), + distribution: "npx", + ..Default::default() + }); + assert_eq!(compute_verdict(&inp).code, "not_installed"); + } + + #[test] + fn verdict_binary_launchable_ignores_node() { + // Binary agents (e.g. Cursor) do not need Node; a launchable cached/system + // binary must read "ok" even with no node/npm on PATH — regression guard + // against the npx model being applied to every distribution. + // node & npm absent + let inp = DiagInputs { + agent: Some(AgentDiag { + name: "Cursor".to_string(), + cmd: "cursor-agent".to_string(), + distribution: "binary", + launchable: Some("/Users/u/.local/bin/cursor-agent".to_string()), + ..Default::default() + }), + ..Default::default() + }; + assert_eq!(compute_verdict(&inp).code, "ok"); + } + + #[test] + fn verdict_binary_recorded_but_unresolved() { + let inp = DiagInputs { + agent: Some(AgentDiag { + cmd: "cursor-agent".to_string(), + distribution: "binary", + db_version: Some("2026.07.16".to_string()), + ..Default::default() + }), + ..Default::default() + }; + assert_eq!(compute_verdict(&inp).code, "installed_but_unresolved"); + } + + #[test] + fn verdict_uvx_launchable_ok_else_not_installed() { + let ok = DiagInputs { + agent: Some(AgentDiag { + cmd: "hermes".to_string(), + distribution: "uvx", + launchable: Some("/Users/u/.local/bin/uvx".to_string()), + ..Default::default() + }), + ..Default::default() + }; + assert_eq!(compute_verdict(&ok).code, "ok"); + + let missing = DiagInputs { + agent: Some(AgentDiag { + cmd: "hermes".to_string(), + distribution: "uvx", + ..Default::default() + }), + ..Default::default() + }; + assert_eq!(compute_verdict(&missing).code, "not_installed"); + } + + #[test] + fn build_report_is_deterministic_and_includes_plain_text() { + let inp = base_inputs(); + let r = build_report(&inp, "FIXED-TS".to_string(), None); + assert_eq!(r.generated_at, "FIXED-TS"); + assert!(r.plain_text.contains("Codeg environment diagnostics")); + assert!(r.plain_text.contains("verdict [ok]")); + assert!(!r.sections.is_empty()); + } +} + /// Process-local cache for dir-tree agents' system-binary `--version` probes, /// keyed by (path, mtime) so an upgrade re-probes but the status/list paths /// don't spawn a subprocess on every call. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f789bfb86..26becaf24 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1098,6 +1098,7 @@ mod tauri_app { acp_commands::acp_find_connection_for_conversation, acp_commands::acp_list_agents, acp_commands::acp_get_agent_status, + acp_commands::acp_env_diagnostics, acp_commands::acp_clear_binary_cache, acp_commands::acp_download_agent_binary, acp_commands::acp_install_uv_tool, diff --git a/src-tauri/src/process.rs b/src-tauri/src/process.rs index 5b5fc01c7..fa3717697 100644 --- a/src-tauri/src/process.rs +++ b/src-tauri/src/process.rs @@ -157,8 +157,11 @@ pub fn ensure_node_in_path() { } } -/// Search common Node.js version manager directories for a `node` binary and -/// return the containing bin directory. +/// Every candidate Node.js version-manager bin directory, newest-version-first +/// within each manager, WITHOUT checking which actually contains a `node` +/// binary — the caller decides. Read-only: never mutates PATH. Used by +/// [`find_node_bin_dir`] (which takes the first candidate that has `node`) and +/// by env diagnostics (which reports every candidate + whether it has `node`). /// /// `home` may be `None` in minimal environments (Docker, systemd without HOME). /// When `None`, only version managers whose location is determined by an @@ -175,11 +178,9 @@ pub fn ensure_node_in_path() { /// - **n** (Unix) — `$N_PREFIX` or `/usr/local` /// - **Homebrew** (macOS) — `/opt/homebrew/opt/node` or `/usr/local/opt/node` /// - **Scoop** (Windows) — `%SCOOP%\apps\nodejs*\current` -fn find_node_bin_dir(home: Option<&std::path::Path>) -> Option { +pub(crate) fn node_bin_dir_candidates(home: Option<&std::path::Path>) -> Vec { let mut candidates: Vec = Vec::new(); - let node_bin = if cfg!(windows) { "node.exe" } else { "node" }; - /// Extract a (major, minor, patch) tuple from a version directory name /// like `v20.11.1` or `20.11.1` for correct numeric sorting. /// Falls back to (0,0,0) for unparseable names so they sort last. @@ -465,8 +466,14 @@ fn find_node_bin_dir(home: Option<&std::path::Path>) -> Option { } } - // Return the first candidate that actually contains a `node` binary. candidates +} + +/// The first version-manager candidate bin directory that actually contains a +/// `node` binary. Thin wrapper over [`node_bin_dir_candidates`]. +fn find_node_bin_dir(home: Option<&std::path::Path>) -> Option { + let node_bin = if cfg!(windows) { "node.exe" } else { "node" }; + node_bin_dir_candidates(home) .into_iter() .find(|dir| dir.join(node_bin).is_file()) } diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index dc8f2b0fd..2a536b992 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -8,8 +8,8 @@ use crate::acp::error::AcpError; use crate::acp::opencode_plugins::PluginCheckSummary; use crate::acp::preflight::PreflightResult; use crate::acp::types::{ - AcpAgentInfo, AcpAgentStatus, AgentSkillContent, AgentSkillLayout, AgentSkillScope, - AgentSkillsListResult, ConnectionInfo, ForkResultInfo, + AcpAgentInfo, AcpAgentStatus, AgentDiagnosticsReport, AgentSkillContent, AgentSkillLayout, + AgentSkillScope, AgentSkillsListResult, ConnectionInfo, ForkResultInfo, }; use crate::app_error::{AppCommandError, AppErrorCode}; use crate::app_state::AppState; @@ -43,6 +43,24 @@ pub async fn acp_list_agents( Ok(Json(result)) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpEnvDiagnosticsParams { + #[serde(default)] + pub agent_type: Option, +} + +pub async fn acp_env_diagnostics( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let db = &state.db; + let result = acp_commands::acp_env_diagnostics_core(db, params.agent_type) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + Ok(Json(result)) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct AcpConnectParams { diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 940c9c257..41391f4b6 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -591,6 +591,10 @@ pub fn build_router( post(handlers::acp::acp_get_agent_status), ) .route("/acp_list_agents", post(handlers::acp::acp_list_agents)) + .route( + "/acp_env_diagnostics", + post(handlers::acp::acp_env_diagnostics), + ) .route("/acp_connect", post(handlers::acp::acp_connect)) .route("/acp_disconnect", post(handlers::acp::acp_disconnect)) .route( diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 312858466..c8fd92048 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "codeg", - "version": "0.21.4", + "version": "0.21.5", "identifier": "app.codeg", "build": { "beforeDevCommand": "pnpm tauri:before-dev", diff --git a/src/app/workspace/layout.tsx b/src/app/workspace/layout.tsx index 0f712eea4..3eb449f89 100644 --- a/src/app/workspace/layout.tsx +++ b/src/app/workspace/layout.tsx @@ -26,9 +26,7 @@ import { import { DelegationProvider } from "@/contexts/delegation-context" import { ConversationRuntimeProvider } from "@/contexts/conversation-runtime-context" import { TabProvider, useTabStore, useTabActions } from "@/contexts/tab-context" -import { SessionStatsProvider } from "@/contexts/session-stats-context" import { SidebarProvider, useSidebarContext } from "@/contexts/sidebar-context" -import { ConversationLocateProvider } from "@/contexts/conversation-locate-context" import { SearchDialogProvider } from "@/contexts/search-dialog-context" import { AutomationsViewProvider } from "@/contexts/automations-view-context" import { @@ -1149,30 +1147,26 @@ function WorkspaceLayoutInner({ children }: { children: React.ReactNode }) { {/* Always mounted: external-change conflicts must be resolvable even with the aux file tree closed. */} - - - - - - - - - {/* Inside WorkbenchRouteProvider: the + + + + + + + + {/* Inside WorkbenchRouteProvider: the listener calls openConversations() to surface a launcher-opened folder. */} - - - - {children} - - - - - - - - - + + + {children} + + + + + + + diff --git a/src/components/chat/composer-connection-status.tsx b/src/components/chat/composer-connection-status.tsx new file mode 100644 index 000000000..30f9fa6ca --- /dev/null +++ b/src/components/chat/composer-connection-status.tsx @@ -0,0 +1,101 @@ +"use client" + +import { useCallback, useSyncExternalStore } from "react" +import { + HeartHandshake, + HeartPulse, + HeartCrack, + HeartOff, + type LucideIcon, +} from "lucide-react" +import { useTranslations } from "next-intl" +import { useConnectionStore } from "@/contexts/acp-connections-context" +import { AGENT_LABELS } from "@/lib/types" +import { cn } from "@/lib/utils" + +// Connection-only states. The session "prompting" state is intentionally +// collapsed into "connected" (the connection stays up while the agent responds), +// and anything unknown falls back to "disconnected". +type ConnStatusKey = "connected" | "connecting" | "error" | "disconnected" + +// Colour-coded heart icons per connection state (HeartCrack stands in for the +// requested HeartX, which lucide does not ship). +const STATUS_ICON: Record< + ConnStatusKey, + { Icon: LucideIcon; className: string } +> = { + connected: { Icon: HeartHandshake, className: "text-emerald-500" }, + connecting: { Icon: HeartPulse, className: "text-amber-500 animate-pulse" }, + error: { Icon: HeartCrack, className: "text-red-500" }, + disconnected: { Icon: HeartOff, className: "text-muted-foreground/60" }, +} + +function toConnStatus(status: string | null): ConnStatusKey { + switch (status) { + case "connected": + case "prompting": + return "connected" + case "connecting": + return "connecting" + case "error": + return "error" + default: + return "disconnected" + } +} + +/** + * Connection-status icon shown in the row below the composer. Scoped to its own + * conversation via `tabId` (the connection `contextKey`) so tiled/multi-open + * composers each reflect their own connection. Shows connection state only — the + * session "prompting" state is treated as connected — and a colour-coded heart + * icon is the whole inline signal (no agent icon or model label); a native + * `title` carries the detail on hover. + */ +export function ComposerConnectionStatus({ tabId }: { tabId: string | null }) { + const t = useTranslations("Folder.statusBar.connection") + const store = useConnectionStore() + + const subscribeConn = useCallback( + (cb: () => void) => { + if (!tabId) return () => {} + return store.subscribeKey(tabId, cb) + }, + [store, tabId] + ) + const getConnSnapshot = useCallback( + () => (tabId ? store.getConnection(tabId) : undefined), + [store, tabId] + ) + const conn = useSyncExternalStore( + subscribeConn, + getConnSnapshot, + getConnSnapshot + ) + + const statusKey = toConnStatus(conn?.status ?? null) + const statusLabel = t(statusKey) + const agentType = conn?.agentType ?? null + const agentLabel = agentType ? AGENT_LABELS[agentType] : null + const titleText = !agentLabel + ? statusLabel + : statusKey === "error" && conn?.error + ? t("tooltipError", { agent: agentLabel, error: conn.error }) + : t("tooltip", { agent: agentLabel, status: statusLabel }) + + const { Icon, className } = STATUS_ICON[statusKey] + + // The native `title` (hover tooltip) lives on the wrapping span: React's SVG + // types don't declare a `title` attribute, and the span guarantees the native + // tooltip. The icon is left decorative (lucide auto-adds aria-hidden). + return ( + + + + ) +} diff --git a/src/components/layout/status-bar-tokens.tsx b/src/components/chat/composer-context-usage.tsx similarity index 72% rename from src/components/layout/status-bar-tokens.tsx rename to src/components/chat/composer-context-usage.tsx index dad545b68..9f4a7ff21 100644 --- a/src/components/layout/status-bar-tokens.tsx +++ b/src/components/chat/composer-context-usage.tsx @@ -3,8 +3,9 @@ import { useCallback, useSyncExternalStore } from "react" import { Coins } from "lucide-react" import { useTranslations } from "next-intl" -import { useSessionStats } from "@/contexts/session-stats-context" import { useConnectionStore } from "@/contexts/acp-connections-context" +import { useTabStore } from "@/contexts/tab-context" +import { useConversationRuntimeStore } from "@/stores/conversation-runtime-store" import { formatTokenCount } from "@/lib/token-format" import { formatContextWindowPercent } from "@/lib/context-window" import { @@ -18,42 +19,53 @@ const ICON_CENTER = 8 const ICON_VIEWBOX = 16 const ICON_CIRCUMFERENCE = 2 * Math.PI * ICON_RADIUS -export function StatusBarTokens() { +/** + * Context-window usage circle (+ token breakdown popover) shown in the row below + * the composer. Scoped to its own conversation via `tabId`: the live context + * window comes from that tab's connection (`contextKey`), and the fallback / + * token breakdown from that conversation's own runtime-store session stats — so + * every loaded/tiled composer shows its own context, not the active one's. + */ +export function ComposerContextUsage({ tabId }: { tabId: string | null }) { const t = useTranslations("Folder.statusBar.tokens") const store = useConnectionStore() - const { sessionStats } = useSessionStats() - const usage = sessionStats?.total_usage - - const subscribeActiveKey = useCallback( - (cb: () => void) => store.subscribeActiveKey(cb), - [store] - ) - const getActiveKey = useCallback(() => store.getActiveKey(), [store]) - const activeKey = useSyncExternalStore( - subscribeActiveKey, - getActiveKey, - getActiveKey + // This tab's own conversation → its per-conversation session stats, read + // straight from the runtime store where every panel already keeps them (keyed + // by the tab's runtime conversation id — the same resolution the panel uses + // for `effectiveConversationId`: runtimeConversationId ?? conversationId). + // Reading per-conversation (rather than one shared "active session" value) is + // what lets every loaded / tiled composer show its own context. + const runtimeConversationId = useTabStore((s) => { + const tab = s.tabs.find((x) => x.id === tabId) + if (!tab || tab.kind !== "conversation") return null + return tab.runtimeConversationId ?? tab.conversationId ?? null + }) + const sessionStats = useConversationRuntimeStore((s) => + runtimeConversationId != null + ? (s.byConversationId.get(runtimeConversationId)?.sessionStats ?? null) + : null ) + const usage = sessionStats?.total_usage const subscribeConn = useCallback( (cb: () => void) => { - if (!activeKey) return () => {} - return store.subscribeKey(activeKey, cb) + if (!tabId) return () => {} + return store.subscribeKey(tabId, cb) }, - [store, activeKey] + [store, tabId] ) const getConnSnapshot = useCallback( - () => (activeKey ? store.getConnection(activeKey) : undefined), - [store, activeKey] + () => (tabId ? store.getConnection(tabId) : undefined), + [store, tabId] ) - const activeConn = useSyncExternalStore( + const conn = useSyncExternalStore( subscribeConn, getConnSnapshot, getConnSnapshot ) - const rawLiveUsed = activeConn?.usage?.used ?? null - const rawLiveSize = activeConn?.usage?.size ?? null + const rawLiveUsed = conn?.usage?.used ?? null + const rawLiveSize = conn?.usage?.size ?? null // Treat live used=0 as "no data" so we fall back to sessionStats — // Claude Code sends used=0 for synthetic local commands (/context etc.) const liveContextUsed = @@ -108,10 +120,21 @@ export function StatusBarTokens() { if (!hasContext && !hasTokenSection) return null + // Native hover hint mirroring the popover's headline (the popover stays for + // the full breakdown on click). + const triggerTitle = hasContext + ? contextUsed != null && contextMax != null + ? `${t("contextWindow")}: ${formatContextWindowPercent(contextPercent)} (${formatTokenCount(contextUsed)} / ${formatTokenCount(contextMax)})` + : `${t("contextWindow")}: ${formatContextWindowPercent(contextPercent)}` + : `${t("tokenUsage")}: ${formatTokenCount(total ?? 0)}` + return ( - + + {selectedAgentNotInstalled ? ( + + ) : null} + ) : null} {composerBlockedMessage ? ( - + + {selectedAgentNotInstalled ? ( + + ) : null} + ) : null}
{messageListNode}
@@ -1653,6 +1659,11 @@ const ConversationTabView = memo(function ConversationTabView({ submitting={feedback.submitting} agentName={AGENT_LABELS[selectedAgent]} /> + ) }) diff --git a/src/components/conversations/sidebar-conversation-list.test.tsx b/src/components/conversations/sidebar-conversation-list.test.tsx index 899f6f85a..d063750e2 100644 --- a/src/components/conversations/sidebar-conversation-list.test.tsx +++ b/src/components/conversations/sidebar-conversation-list.test.tsx @@ -226,14 +226,6 @@ vi.mock("@/contexts/workbench-route-context", () => { } return { useWorkbenchRoute: () => value } }) -// The list registers its scrollToActive with the locate context on mount; stub -// it (these tests drive scrollToActive through the imperative ref instead). -vi.mock("@/contexts/conversation-locate-context", () => ({ - useConversationLocate: () => ({ - registerLocate: () => {}, - locateActiveConversation: () => {}, - }), -})) // These only mount when their state opens (never in these tests); stub to keep // the import graph light. diff --git a/src/components/conversations/sidebar-conversation-list.tsx b/src/components/conversations/sidebar-conversation-list.tsx index dce8f1103..44dd42878 100644 --- a/src/components/conversations/sidebar-conversation-list.tsx +++ b/src/components/conversations/sidebar-conversation-list.tsx @@ -38,7 +38,6 @@ import { useActiveFolder } from "@/contexts/active-folder-context" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { useTabActions, useTabStore } from "@/contexts/tab-context" import { useWorkbenchRoute } from "@/contexts/workbench-route-context" -import { useConversationLocate } from "@/contexts/conversation-locate-context" import { useTerminalContext } from "@/contexts/terminal-context" import { useThemeColor, useZoomLevel } from "@/hooks/use-appearance" import { useSortedAvailableAgents } from "@/hooks/use-sorted-available-agents" @@ -639,7 +638,6 @@ export function SidebarConversationList({ const { resolvedTheme } = useTheme() const { themeColor: appThemeColor } = useThemeColor() const { createTerminalInDirectory } = useTerminalContext() - const { registerLocate } = useConversationLocate() useZoomLevel() const folders = useAppWorkspaceStore((s) => s.folders) const allFolders = useAppWorkspaceStore((s) => s.allFolders) @@ -1123,15 +1121,6 @@ export function SidebarConversationList({ }, })) - // Publish scrollToActive to the locate context so the conversation detail - // header's "locate" button (which lives in another column) can reach it. The - // registered wrapper is stable; it always reads the current scrollToActiveRef. - useEffect(() => { - const scrollToActive = () => scrollToActiveRef.current() - registerLocate(scrollToActive) - return () => registerLocate(null) - }, [registerLocate]) - useEffect(() => { scrollToActiveRef.current = () => { if (!selectedConversation) return diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 75a0560f3..af240c975 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -252,43 +252,43 @@ export function Sidebar() { window's top edge, so its empty space must move the window. */}
- {/* Locate + expand/collapse move off the mobile header on desktop: - locate → the conversation detail header; expand/collapse → the - view-options menu below. Mobile keeps both standalone buttons so - its layout is unchanged. */} + {/* Locate the active conversation in the list below (moved here from + the conversation detail header). Always shown, sitting just before + the view-options funnel. The sidebar is unmounted while collapsed, + so `listRef` is live whenever this button is visible. */} + + {/* Expand/collapse-all keeps a standalone header button on mobile; on + desktop it's folded into the view-options menu below. */} {isMobile && ( - <> - - - + )} diff --git a/src/components/layout/status-bar-connection.tsx b/src/components/layout/status-bar-connection.tsx deleted file mode 100644 index 349fc339c..000000000 --- a/src/components/layout/status-bar-connection.tsx +++ /dev/null @@ -1,133 +0,0 @@ -"use client" - -import { useCallback, useSyncExternalStore } from "react" -import { Unplug } from "lucide-react" -import { useTranslations } from "next-intl" -import { useConnectionStore } from "@/contexts/acp-connections-context" -import { useTabStore } from "@/contexts/tab-context" -import { useAppWorkspaceStore } from "@/stores/app-workspace-store" -import { AgentIcon } from "@/components/agent-icon" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip" -import { AGENT_LABELS } from "@/lib/types" -import { cn } from "@/lib/utils" - -type ConnectionStatusLabelKey = - | "connected" - | "connecting" - | "prompting" - | "error" - -const STATUS_STYLE: Record< - string, - { className: string; labelKey: ConnectionStatusLabelKey } -> = { - connected: { className: "opacity-100", labelKey: "connected" }, - connecting: { - className: "opacity-100 animate-pulse", - labelKey: "connecting", - }, - prompting: { - className: "opacity-100 animate-pulse", - labelKey: "prompting", - }, - error: { className: "opacity-50", labelKey: "error" }, -} - -export function StatusBarConnection() { - const t = useTranslations("Folder.statusBar.connection") - const store = useConnectionStore() - const tabs = useTabStore((s) => s.tabs) - const activeTabId = useTabStore((s) => s.activeTabId) - - // Subscribe to activeKey changes - const subscribeActiveKey = useCallback( - (cb: () => void) => store.subscribeActiveKey(cb), - [store] - ) - const getActiveKey = useCallback(() => store.getActiveKey(), [store]) - const activeKey = useSyncExternalStore( - subscribeActiveKey, - getActiveKey, - getActiveKey - ) - - // Subscribe to the active connection's changes - const subscribeConn = useCallback( - (cb: () => void) => { - if (!activeKey) return () => {} - return store.subscribeKey(activeKey, cb) - }, - [store, activeKey] - ) - const getConnSnapshot = useCallback( - () => (activeKey ? store.getConnection(activeKey) : undefined), - [store, activeKey] - ) - const activeConn = useSyncExternalStore( - subscribeConn, - getConnSnapshot, - getConnSnapshot - ) - - const status = activeConn?.status ?? null - const agentType = activeConn?.agentType ?? null - - // Selecting the primitive model string keeps this component inert to every - // unrelated conversation update. - const model = useAppWorkspaceStore((s) => { - const tab = tabs.find((t) => t.id === activeTabId) - if (!tab || tab.kind !== "conversation") return null - const conv = s.conversations.find( - (c) => c.id === tab.conversationId && c.agent_type === tab.agentType - ) - return conv?.model ?? null - }) - - if (!agentType || !status || status === "disconnected") { - return ( - - - -
- - {model && {model}} -
-
- {t("disconnected")} -
-
- ) - } - - const style = STATUS_STYLE[status] - if (!style) return null - - const label = AGENT_LABELS[agentType] - const statusLabel = t(style.labelKey) - const tooltipText = - status === "error" && activeConn?.error - ? t("tooltipError", { agent: label, error: activeConn.error }) - : t("tooltip", { agent: label, status: statusLabel }) - - return ( - - - -
- - {model && {model}} -
-
- {tooltipText} -
-
- ) -} diff --git a/src/components/layout/status-bar.tsx b/src/components/layout/status-bar.tsx index 54ef91fc1..65956c6f5 100644 --- a/src/components/layout/status-bar.tsx +++ b/src/components/layout/status-bar.tsx @@ -2,8 +2,6 @@ import { StatusBarStats } from "@/components/layout/status-bar-stats" import { StatusBarTasks } from "@/components/layout/status-bar-tasks" -import { StatusBarTokens } from "@/components/layout/status-bar-tokens" -import { StatusBarConnection } from "@/components/layout/status-bar-connection" import { StatusBarAlerts } from "@/components/layout/status-bar-alerts" import { StatusBarUpdate } from "@/components/layout/status-bar-update" import { CommandDropdown } from "@/components/layout/command-dropdown" @@ -14,14 +12,13 @@ export function StatusBar() { if (isMobile) { // Mobile mirrors the desktop bar's right side: the command launcher + - // context-window circle + alerts. `h-8` (matching desktop) gives the h-6 - // command control room. The branch selector now lives in the below-composer + // alerts. `h-8` (matching desktop) gives the h-6 command control room. The + // branch selector and context-window circle now live in the below-composer // row, so the bar has nothing on the left and right-aligns its controls. return (
-
@@ -30,8 +27,9 @@ export function StatusBar() { return (
- {/* The branch selector moved to the below-composer folder/branch row; the - left side now carries just the workspace stats. */} + {/* The branch selector, context-window circle and agent connection status + moved to the below-composer folder/branch row; the left side now + carries just the workspace stats. */}
@@ -41,8 +39,6 @@ export function StatusBar() { {/* Command launcher (moved from the aux "session details" tab), taking the slot the old static branch label (StatusBarSessionInfo) held. */} - -
diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index aa373af29..fced13f09 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -24,7 +24,6 @@ import { LiveTurnStats } from "./live-turn-stats" import { ReplyArtifacts } from "./reply-artifacts" import { UserResourceLinks } from "./user-resource-links" import { UserImageAttachments } from "./user-image-attachments" -import { useSessionStats } from "@/contexts/session-stats-context" import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay" import { SubAgentOverlay } from "@/components/chat/sub-agent-overlay" import { normalizeToolName } from "@/lib/tool-call-normalization" @@ -56,12 +55,7 @@ import { buildPlanKey, extractLatestPlanEntriesFromMessages, } from "@/lib/agent-plan" -import type { - AgentType, - ConnectionStatus, - MessageTurn, - SessionStats, -} from "@/lib/types" +import type { AgentType, ConnectionStatus, MessageTurn } from "@/lib/types" import { copyTextToClipboard } from "@/lib/utils" import { VirtualizedMessageThread } from "@/components/message/virtualized-message-thread" import { @@ -79,7 +73,6 @@ interface MessageListViewProps { connStatus?: ConnectionStatus | null isActive?: boolean sendSignal?: number - sessionStats?: SessionStats | null detailLoading?: boolean detailError?: string | null /** @@ -598,7 +591,6 @@ export function MessageListView({ connStatus, isActive = true, sendSignal = 0, - sessionStats = null, detailLoading = false, detailError = null, acpLoadError = null, @@ -621,14 +613,6 @@ export function MessageListView({ selectTimelineTurns(s, conversationId) ) - const { setSessionStats } = useSessionStats() - - useEffect(() => { - if (isActive) { - setSessionStats(sessionStats) - } - }, [isActive, sessionStats, setSessionStats]) - const shouldUseSmoothResize = !( isActive && !detailLoading && diff --git a/src/components/message/sub-agent-session-dialog.test.tsx b/src/components/message/sub-agent-session-dialog.test.tsx index 54ebfc4a3..46fb5bf4f 100644 --- a/src/components/message/sub-agent-session-dialog.test.tsx +++ b/src/components/message/sub-agent-session-dialog.test.tsx @@ -405,7 +405,7 @@ describe("SubAgentSessionDialog", () => { expect(list).toHaveAttribute("data-has-on-reload", "false") expect(list).toHaveAttribute("data-has-on-new-session", "false") expect(list).toHaveAttribute("data-has-send-signal", "false") - // isActive=false suppresses session-stats side effects on the active panel. + // isActive=false marks this as a passive embed, not the live active panel. expect(list).toHaveAttribute("data-is-active", "false") expect(list).toHaveAttribute("data-conversation-id", "99") }) diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index 08d5246b1..a268b1bb7 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -29,6 +29,7 @@ import { Plus, RefreshCw, Save, + Stethoscope, Trash2, Wrench, } from "lucide-react" @@ -121,6 +122,7 @@ import { OpenCodeConnectDialog, OpenCodeCustomProviderDialog, } from "@/components/settings/opencode-connect-dialog" +import { AgentDiagnosticsDialog } from "@/components/settings/agent-diagnostics-dialog" import { buildConnectedModelOptions, buildConnectedProviders, @@ -3992,6 +3994,7 @@ export function AcpAgentSettings() { // effect deps (which would re-run the effect and self-cancel the request). const openCodeCatalogRequestedRef = useRef(false) const [openCodeConnectOpen, setOpenCodeConnectOpen] = useState(false) + const [diagnosticsOpen, setDiagnosticsOpen] = useState(false) // Add-a-custom-provider dialog (separate from the catalog connect dialog). const [openCodeCustomOpen, setOpenCodeCustomOpen] = useState(false) // When set, the connect dialog opens in edit mode for this connected provider. @@ -7282,7 +7285,7 @@ export function AcpAgentSettings() { {selectedAgent.distribution_type}
-
+
+ +
{selectedCurrent?.error && ( @@ -7358,9 +7367,20 @@ export function AcpAgentSettings() { {selectedCurrent.error}
)} -
- - {t("preflight.count", { count: selectedChecks.length })} +
+
+ + {t("preflight.count", { count: selectedChecks.length })} +
+
{selectedChecks.length > 0 ? ( selectedChecks.map((check) => diff --git a/src/components/settings/agent-diagnostics-dialog.test.tsx b/src/components/settings/agent-diagnostics-dialog.test.tsx new file mode 100644 index 000000000..184b2e422 --- /dev/null +++ b/src/components/settings/agent-diagnostics-dialog.test.tsx @@ -0,0 +1,103 @@ +import { render, screen, cleanup } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { AgentDiagnosticsDialog } from "./agent-diagnostics-dialog" +import { acpEnvDiagnostics } from "@/lib/api" +import { copyTextToClipboard } from "@/lib/utils" +import type { AgentDiagnosticsReport, AgentType } from "@/lib/types" + +// next-intl: return the key so we can assert on verdict. / button keys. +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})) + +const toastSuccess = vi.fn() +vi.mock("sonner", () => ({ + toast: { success: (m: string) => toastSuccess(m), error: vi.fn() }, +})) + +vi.mock("@/lib/api", () => ({ + acpEnvDiagnostics: vi.fn(), +})) + +// Keep cn() real; only stub the clipboard write. +vi.mock("@/lib/utils", async (importOriginal) => ({ + ...(await importOriginal()), + copyTextToClipboard: vi.fn().mockResolvedValue(true), +})) + +const REPORT: AgentDiagnosticsReport = { + generated_at: "2026-07-22 10:00:00 +0800", + agent_type: "codex" as AgentType, + verdict: { + level: "fail", + code: "user_prefix_not_on_path", + summary: "Installed into the fallback prefix.", + }, + sections: [ + { + title: "Node / npm", + checks: [ + { + label: "node", + value: "v20.11.1 (/usr/bin/node)", + status: "ok", + hint: null, + }, + { + label: "codex-acp (resolve_npx_command)", + value: "NOT RESOLVED", + status: "fail", + hint: "the new-session page checks this", + }, + ], + }, + ], + plain_text: + "===== Codeg environment diagnostics =====\nverdict [user_prefix_not_on_path]", +} + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe("AgentDiagnosticsDialog", () => { + it("runs on open and renders the verdict and check rows", async () => { + vi.mocked(acpEnvDiagnostics).mockResolvedValue(REPORT) + + render( + {}} + agentType={"codex" as AgentType} + /> + ) + + // Localized verdict via code (mock echoes the key). + expect( + await screen.findByText("verdict.user_prefix_not_on_path") + ).toBeTruthy() + // Structured check rows render label + value. + expect(screen.getByText("codex-acp (resolve_npx_command)")).toBeTruthy() + expect(screen.getByText("NOT RESOLVED")).toBeTruthy() + // Probe was invoked with the target agent. + expect(vi.mocked(acpEnvDiagnostics)).toHaveBeenCalledWith("codex") + }) + + it("copies the backend plain_text blob and toasts", async () => { + vi.mocked(acpEnvDiagnostics).mockResolvedValue(REPORT) + const user = userEvent.setup() + + render( {}} />) + + await screen.findByText("verdict.user_prefix_not_on_path") + await user.click(screen.getByText("copyAll")) + + expect(vi.mocked(copyTextToClipboard)).toHaveBeenCalledWith( + REPORT.plain_text + ) + expect(toastSuccess).toHaveBeenCalledWith("copied") + }) +}) diff --git a/src/components/settings/agent-diagnostics-dialog.tsx b/src/components/settings/agent-diagnostics-dialog.tsx new file mode 100644 index 000000000..b6dd9f3cc --- /dev/null +++ b/src/components/settings/agent-diagnostics-dialog.tsx @@ -0,0 +1,259 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { useTranslations } from "next-intl" +import { + AlertTriangle, + CheckCircle2, + Clock, + Copy, + Info, + Loader2, + RefreshCw, + XCircle, + type LucideIcon, +} from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { cn, copyTextToClipboard } from "@/lib/utils" +import { acpEnvDiagnostics } from "@/lib/api" +import type { AgentDiagnosticsReport, AgentType, DiagLevel } from "@/lib/types" + +export interface AgentDiagnosticsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + /** Agent to focus the report on. Omit for a base environment report. */ + agentType?: AgentType +} + +const LEVEL_DOT: Record = { + ok: "bg-emerald-500", + warn: "bg-amber-500", + fail: "bg-red-500", + info: "bg-muted-foreground/40", +} + +const LEVEL_TEXT: Record = { + ok: "text-emerald-500", + warn: "text-amber-500", + fail: "text-red-500", + info: "text-muted-foreground", +} + +const VERDICT_BANNER: Record = { + ok: "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", + warn: "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400", + fail: "border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400", + info: "border-border bg-muted/40 text-foreground", +} + +const VERDICT_ICON: Record = { + ok: CheckCircle2, + warn: AlertTriangle, + fail: XCircle, + info: Info, +} + +// Codes emitted by the backend `compute_verdict`. Kept in sync with the +// `DiagnosticsSettings.verdict.*` i18n keys; anything unknown falls back to the +// backend-provided English summary (never fed through ICU). +const VERDICT_CODES = [ + "ok", + "node_missing", + "npm_missing", + "not_installed", + "installed_but_unresolved", + "user_prefix_not_on_path", + "homebrew_bin_not_on_path", + "terminal_only_path", + "npm_prefix_timeout", + "node_too_old", +] as const + +type VerdictCode = (typeof VERDICT_CODES)[number] + +const isKnownVerdict = (code: string): code is VerdictCode => + (VERDICT_CODES as readonly string[]).includes(code) + +/** The one-line "likely cause" banner, colored + iconed by severity. */ +function VerdictBanner({ level, label }: { level: DiagLevel; label: string }) { + const Icon = VERDICT_ICON[level] + return ( +
+ + {label} +
+ ) +} + +export function AgentDiagnosticsDialog({ + open, + onOpenChange, + agentType, +}: AgentDiagnosticsDialogProps) { + const t = useTranslations("DiagnosticsSettings") + const [report, setReport] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const run = useCallback(async () => { + setLoading(true) + setError(null) + try { + setReport(await acpEnvDiagnostics(agentType)) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setLoading(false) + } + }, [agentType]) + + // Run once each time the dialog opens; clear when it closes. + useEffect(() => { + if (open) { + void run() + } else { + setReport(null) + setError(null) + } + }, [open, run]) + + const verdictLabel = (code: string, fallback: string): string => + isKnownVerdict(code) ? t(`verdict.${code}`) : fallback + + const onCopy = async () => { + if (!report) return + if (await copyTextToClipboard(report.plain_text)) { + toast.success(t("copied")) + } + } + + return ( + + + + {t("title")} + {t("description")} + + +
+ {loading && ( +
+ + {t("loading")} +
+ )} + + {!loading && error && ( +
+ {t("error")}: {error} +
+ )} + + {!loading && report && ( +
+ + + {report.sections.map((section, si) => ( +
+
+

+ {section.title} +

+
+
+ {section.checks.map((check, ci) => ( +
+ +
+
+ + {check.label} + + + {check.value} + +
+ {check.hint && ( +
+ {check.hint} +
+ )} +
+
+ ))} +
+
+ ))} + + {report.generated_at && ( +
+ + {report.generated_at} +
+ )} +
+ )} +
+ + + + + +
+
+ ) +} diff --git a/src/contexts/conversation-locate-context.tsx b/src/contexts/conversation-locate-context.tsx deleted file mode 100644 index 71ccdad2e..000000000 --- a/src/contexts/conversation-locate-context.tsx +++ /dev/null @@ -1,83 +0,0 @@ -"use client" - -import { - createContext, - useCallback, - useContext, - useMemo, - useRef, - type ReactNode, -} from "react" -import { useSidebarContext } from "@/contexts/sidebar-context" - -interface ConversationLocateContextValue { - /** - * The sidebar conversation list registers its `scrollToActive` here on mount - * and clears it (null) on unmount. Lifting it out of the sidebar lets the - * conversation detail header's "locate" button reach it even though the two - * live in different columns. - */ - registerLocate: (scrollToActive: (() => void) | null) => void - /** - * Scroll the sidebar conversation list to the active conversation, first - * opening the sidebar if it's collapsed (the list is unmounted while closed, - * so the request is queued and runs once the list registers on mount). - */ - locateActiveConversation: () => void -} - -const ConversationLocateContext = - createContext(null) - -export function useConversationLocate() { - const ctx = useContext(ConversationLocateContext) - if (!ctx) { - throw new Error( - "useConversationLocate must be used within ConversationLocateProvider" - ) - } - return ctx -} - -export function ConversationLocateProvider({ - children, -}: { - children: ReactNode -}) { - const { isOpen, toggle } = useSidebarContext() - const locateRef = useRef<(() => void) | null>(null) - const pendingRef = useRef(false) - - const registerLocate = useCallback((scrollToActive: (() => void) | null) => { - locateRef.current = scrollToActive - // A locate requested while the sidebar was collapsed is fulfilled the - // moment the freshly-mounted list registers. Defer one frame so the list - // is laid out (virtua measured) before we scroll to the active row. - if (scrollToActive && pendingRef.current) { - pendingRef.current = false - requestAnimationFrame(() => scrollToActive()) - } - }, []) - - const locateActiveConversation = useCallback(() => { - if (locateRef.current) { - locateRef.current() - return - } - // Sidebar collapsed → list unmounted. Queue the request and open the - // sidebar; `registerLocate` runs it when the list mounts. - pendingRef.current = true - if (!isOpen) toggle() - }, [isOpen, toggle]) - - const value = useMemo( - () => ({ registerLocate, locateActiveConversation }), - [registerLocate, locateActiveConversation] - ) - - return ( - - {children} - - ) -} diff --git a/src/contexts/session-stats-context.tsx b/src/contexts/session-stats-context.tsx deleted file mode 100644 index 72e9c1db8..000000000 --- a/src/contexts/session-stats-context.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client" - -import { createContext, useContext, useState, useCallback } from "react" -import type { SessionStats } from "@/lib/types" - -interface SessionStatsContextValue { - sessionStats: SessionStats | null - setSessionStats: (stats: SessionStats | null) => void -} - -const SessionStatsContext = createContext({ - sessionStats: null, - setSessionStats: () => {}, -}) - -export function SessionStatsProvider({ - children, -}: { - children: React.ReactNode -}) { - const [sessionStats, setSessionStatsRaw] = useState(null) - const setSessionStats = useCallback( - (stats: SessionStats | null) => setSessionStatsRaw(stats), - [] - ) - return ( - - {children} - - ) -} - -export function useSessionStats() { - return useContext(SessionStatsContext) -} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index dd54d376a..f66451db1 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -611,7 +611,8 @@ "saveCodeBuddyConfig": "حفظ إعدادات CodeBuddy", "saveKimiCodeConfig": "حفظ إعدادات Kimi Code", "customInstall": "تثبيت مخصص", - "saveKimiCodeRawConfig": "Save config.toml" + "saveKimiCodeRawConfig": "Save config.toml", + "diagnose": "تشخيص" }, "status": { "enabled": "مفعّل", @@ -3969,5 +3970,27 @@ "groupInstructions": "الوصف وموجّه النظام", "fieldDescription": "الوصف", "baseInstructions": "موجّه النظام (base_instructions)" + }, + "DiagnosticsSettings": { + "title": "تشخيص البيئة", + "description": "يتحقق من كيفية تحليل هذا التطبيق لواجهة سطر أوامر الوكيل داخل عمليته الخاصة، وقد يختلف ذلك عن الطرفية لديك.", + "loading": "جارٍ تشغيل التشخيص…", + "error": "فشل التشخيص", + "rerun": "إعادة التشغيل", + "copyAll": "نسخ الكل", + "copied": "تم نسخ التشخيص إلى الحافظة", + "button": "تشخيص", + "verdict": { + "ok": "تبدو البيئة سليمة. إذا استمر ظهوره كغير مُثبَّت، فأعد تشغيل التطبيق بالكامل لتحديث ذاكرة التخزين المؤقتة للبادئة.", + "node_missing": "لم يتم العثور على Node.js في مسار PATH الخاص بالتطبيق.", + "npm_missing": "لم يتم العثور على npm في مسار PATH الخاص بالتطبيق.", + "not_installed": "يبدو أن هذا الوكيل غير مُثبَّت.", + "installed_but_unresolved": "مُسجَّل كمُثبَّت، لكن لا يستطيع التطبيق تحديد موقع الملف التنفيذي.", + "user_prefix_not_on_path": "تم التثبيت في بادئة الاحتياط (~/.codeg/npm-global)، وهي ليست في مسار PATH الخاص بالتطبيق. أعد تشغيل التطبيق بالكامل وحاول مرة أخرى.", + "homebrew_bin_not_on_path": "تم التثبيت ضمن مجلد bin الخاص بـ Homebrew، وهو ليس في مسار PATH الخاص بالتطبيق (فصل keg على Apple Silicon).", + "terminal_only_path": "يُحلَّل الأمر في الطرفية لديك لكن ليس في التطبيق — وهي فجوة في مسار PATH لواجهة المستخدم الرسومية. شغّل التطبيق من الطرفية أو أعد التثبيت من إعدادات الوكلاء.", + "npm_prefix_timeout": "كان npm prefix -g بطيئًا جدًا (أكثر من 1.5 ثانية)، لذا تم تخطّي الكشف الاحتياطي. أعد تشغيل التطبيق وحاول مرة أخرى.", + "node_too_old": "إصدار Node.js النشط أقدم مما يتطلبه هذا الوكيل. يرجى ترقية Node.js." + } } } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index d80a5b69f..181946a40 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -611,7 +611,8 @@ "saveCodeBuddyConfig": "CodeBuddy-Konfiguration speichern", "saveKimiCodeConfig": "Kimi-Code-Konfiguration speichern", "customInstall": "Benutzerdefinierte Installation", - "saveKimiCodeRawConfig": "Save config.toml" + "saveKimiCodeRawConfig": "Save config.toml", + "diagnose": "Diagnose" }, "status": { "enabled": "Aktiviert", @@ -3969,5 +3970,27 @@ "groupInstructions": "Beschreibung & System-Prompt", "fieldDescription": "Beschreibung", "baseInstructions": "System-Prompt (base_instructions)" + }, + "DiagnosticsSettings": { + "title": "Umgebungsdiagnose", + "description": "Prüft, wie diese App die Agent-CLI im eigenen Prozess auflöst – das kann sich von deinem Terminal unterscheiden.", + "loading": "Diagnose wird ausgeführt…", + "error": "Diagnose fehlgeschlagen", + "rerun": "Erneut ausführen", + "copyAll": "Alles kopieren", + "copied": "Diagnose in die Zwischenablage kopiert", + "button": "Diagnose", + "verdict": { + "ok": "Die Umgebung sieht in Ordnung aus. Wird trotzdem „nicht installiert“ angezeigt, starte die App komplett neu, um den Prefix-Cache zu aktualisieren.", + "node_missing": "Node.js wurde im PATH der App nicht gefunden.", + "npm_missing": "npm wurde im PATH der App nicht gefunden.", + "not_installed": "Dieser Agent scheint nicht installiert zu sein.", + "installed_but_unresolved": "Als installiert vermerkt, aber die App findet die ausführbare Datei nicht.", + "user_prefix_not_on_path": "In das Ausweich-Prefix (~/.codeg/npm-global) installiert, das nicht im PATH der App liegt. Starte die App komplett neu und versuche es erneut.", + "homebrew_bin_not_on_path": "Unter dem Homebrew-bin installiert, das nicht im PATH der App liegt (Apple-Silicon-Keg-Trennung).", + "terminal_only_path": "Der Befehl wird in deinem Terminal aufgelöst, aber nicht in der App – eine GUI-PATH-Lücke. Starte die App aus einem Terminal oder installiere sie in den Agent-Einstellungen neu.", + "npm_prefix_timeout": "npm prefix -g war zu langsam (über 1,5 s), daher wurde die Ausweicherkennung übersprungen. Starte die App neu und versuche es erneut.", + "node_too_old": "Dein aktives Node.js ist älter als von diesem Agenten benötigt. Aktualisiere Node.js." + } } } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 783c55221..ef74f945e 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -611,7 +611,8 @@ "saveCodeBuddyConfig": "Save CodeBuddy Config", "saveKimiCodeConfig": "Save Kimi Code Config", "customInstall": "Custom install", - "saveKimiCodeRawConfig": "Save config.toml" + "saveKimiCodeRawConfig": "Save config.toml", + "diagnose": "Diagnose" }, "status": { "enabled": "Enabled", @@ -3969,5 +3970,27 @@ "groupInstructions": "Description & system prompt", "fieldDescription": "Description", "baseInstructions": "System prompt (base_instructions)" + }, + "DiagnosticsSettings": { + "title": "Environment diagnostics", + "description": "Checks how this app resolves the agent CLI inside its own process, which can differ from your terminal.", + "loading": "Running diagnostics…", + "error": "Diagnostics failed", + "rerun": "Re-run", + "copyAll": "Copy all", + "copied": "Diagnostics copied to clipboard", + "button": "Diagnose", + "verdict": { + "ok": "Environment looks healthy. If it still shows as not installed, fully restart the app so the prefix cache refreshes.", + "node_missing": "Node.js was not found on the app PATH.", + "npm_missing": "npm was not found on the app PATH.", + "not_installed": "This agent does not appear to be installed.", + "installed_but_unresolved": "The agent is recorded as installed, but the app cannot locate its executable.", + "user_prefix_not_on_path": "Installed into the fallback prefix (~/.codeg/npm-global), which is not on the app PATH. Fully restart the app and try again.", + "homebrew_bin_not_on_path": "Installed under the Homebrew bin, which is not on the app PATH (Apple Silicon keg split).", + "terminal_only_path": "The command resolves in your terminal but not in the app — a GUI PATH gap. Launch the app from a terminal, or reinstall from Agent Settings.", + "npm_prefix_timeout": "npm prefix -g was too slow (over 1.5s), so fallback detection was skipped. Restart the app and try again.", + "node_too_old": "Your active Node.js is older than this agent requires. Upgrade Node.js." + } } } diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 55ac967f5..bb75867d9 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -611,7 +611,8 @@ "saveCodeBuddyConfig": "Guardar configuración de CodeBuddy", "saveKimiCodeConfig": "Guardar configuración de Kimi Code", "customInstall": "Instalación personalizada", - "saveKimiCodeRawConfig": "Save config.toml" + "saveKimiCodeRawConfig": "Save config.toml", + "diagnose": "Diagnosticar" }, "status": { "enabled": "Habilitado", @@ -3969,5 +3970,27 @@ "groupInstructions": "Descripción y prompt del sistema", "fieldDescription": "Descripción", "baseInstructions": "Prompt del sistema (base_instructions)" + }, + "DiagnosticsSettings": { + "title": "Diagnóstico del entorno", + "description": "Comprueba cómo esta app resuelve la CLI del agente en su propio proceso, que puede diferir de tu terminal.", + "loading": "Ejecutando diagnóstico…", + "error": "El diagnóstico falló", + "rerun": "Volver a ejecutar", + "copyAll": "Copiar todo", + "copied": "Diagnóstico copiado al portapapeles", + "button": "Diagnosticar", + "verdict": { + "ok": "El entorno parece correcto. Si aún aparece como no instalado, reinicia por completo la app para actualizar la caché del prefijo.", + "node_missing": "No se encontró Node.js en el PATH de la app.", + "npm_missing": "No se encontró npm en el PATH de la app.", + "not_installed": "Este agente no parece estar instalado.", + "installed_but_unresolved": "Consta como instalado, pero la app no puede localizar su ejecutable.", + "user_prefix_not_on_path": "Se instaló en el prefijo de reserva (~/.codeg/npm-global), que no está en el PATH de la app. Reinicia por completo la app e inténtalo de nuevo.", + "homebrew_bin_not_on_path": "Se instaló en el bin de Homebrew, que no está en el PATH de la app (división de keg en Apple Silicon).", + "terminal_only_path": "El comando se resuelve en tu terminal pero no en la app: una diferencia de PATH de la GUI. Inicia la app desde una terminal o reinstala desde Ajustes de agentes.", + "npm_prefix_timeout": "npm prefix -g fue demasiado lento (más de 1,5 s), por lo que se omitió la detección de reserva. Reinicia la app e inténtalo de nuevo.", + "node_too_old": "Tu Node.js activo es más antiguo de lo que requiere este agente. Actualiza Node.js." + } } } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 0b71da52e..3b469bfa1 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -611,7 +611,8 @@ "saveCodeBuddyConfig": "Enregistrer la configuration CodeBuddy", "saveKimiCodeConfig": "Enregistrer la configuration de Kimi Code", "customInstall": "Installation personnalisée", - "saveKimiCodeRawConfig": "Save config.toml" + "saveKimiCodeRawConfig": "Save config.toml", + "diagnose": "Diagnostiquer" }, "status": { "enabled": "Activé", @@ -3969,5 +3970,27 @@ "groupInstructions": "Description et prompt système", "fieldDescription": "Description", "baseInstructions": "Invite système (base_instructions)" + }, + "DiagnosticsSettings": { + "title": "Diagnostic de l'environnement", + "description": "Vérifie comment cette app résout la CLI de l'agent dans son propre processus, ce qui peut différer de votre terminal.", + "loading": "Diagnostic en cours…", + "error": "Échec du diagnostic", + "rerun": "Relancer", + "copyAll": "Tout copier", + "copied": "Diagnostic copié dans le presse-papiers", + "button": "Diagnostiquer", + "verdict": { + "ok": "L'environnement semble sain. S'il indique toujours non installé, redémarrez complètement l'app pour actualiser le cache du préfixe.", + "node_missing": "Node.js est introuvable dans le PATH de l'app.", + "npm_missing": "npm est introuvable dans le PATH de l'app.", + "not_installed": "Cet agent ne semble pas installé.", + "installed_but_unresolved": "Enregistré comme installé, mais l'app ne trouve pas son exécutable.", + "user_prefix_not_on_path": "Installé dans le préfixe de secours (~/.codeg/npm-global), qui n'est pas dans le PATH de l'app. Redémarrez complètement l'app et réessayez.", + "homebrew_bin_not_on_path": "Installé sous le bin de Homebrew, qui n'est pas dans le PATH de l'app (séparation keg Apple Silicon).", + "terminal_only_path": "La commande se résout dans votre terminal mais pas dans l'app : un écart de PATH GUI. Lancez l'app depuis un terminal ou réinstallez depuis les Paramètres des agents.", + "npm_prefix_timeout": "npm prefix -g était trop lent (plus de 1,5 s), la détection de secours a été ignorée. Redémarrez l'app et réessayez.", + "node_too_old": "Votre Node.js actif est plus ancien que ce que cet agent requiert. Mettez à niveau Node.js." + } } } diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index fbd1be4ce..3ced87317 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -611,7 +611,8 @@ "saveCodeBuddyConfig": "CodeBuddy 設定を保存", "saveKimiCodeConfig": "Kimi Code の設定を保存", "customInstall": "カスタムインストール", - "saveKimiCodeRawConfig": "Save config.toml" + "saveKimiCodeRawConfig": "Save config.toml", + "diagnose": "診断" }, "status": { "enabled": "有効", @@ -3969,5 +3970,27 @@ "groupInstructions": "説明とシステムプロンプト", "fieldDescription": "説明", "baseInstructions": "システムプロンプト(base_instructions)" + }, + "DiagnosticsSettings": { + "title": "環境診断", + "description": "このアプリが自身のプロセス内でエージェントの CLI をどう解決するかを確認します(ターミナルとは異なる場合があります)。", + "loading": "診断を実行中…", + "error": "診断に失敗しました", + "rerun": "再実行", + "copyAll": "すべてコピー", + "copied": "診断情報をクリップボードにコピーしました", + "button": "診断", + "verdict": { + "ok": "環境は正常です。それでも未インストールと表示される場合は、アプリを完全に再起動してプレフィックスキャッシュを更新してください。", + "node_missing": "アプリの PATH に Node.js が見つかりませんでした。", + "npm_missing": "アプリの PATH に npm が見つかりませんでした。", + "not_installed": "このエージェントはインストールされていないようです。", + "installed_but_unresolved": "インストール済みと記録されていますが、アプリが実行ファイルを見つけられません。", + "user_prefix_not_on_path": "フォールバックのプレフィックス(~/.codeg/npm-global)にインストールされていますが、アプリの PATH に含まれていません。アプリを完全に再起動して再試行してください。", + "homebrew_bin_not_on_path": "Homebrew の bin にインストールされていますが、アプリの PATH に含まれていません(Apple Silicon の keg 分割)。", + "terminal_only_path": "このコマンドはターミナルでは解決されますが、アプリでは解決されません。GUI の PATH のずれです。ターミナルからアプリを起動するか、エージェント設定から再インストールしてください。", + "npm_prefix_timeout": "npm prefix -g が遅すぎました(1.5 秒超)。フォールバック検出をスキップしました。アプリを再起動して再試行してください。", + "node_too_old": "使用中の Node.js がこのエージェントの要件より古いです。Node.js をアップグレードしてください。" + } } } diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 68db8fb40..8203fc358 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -611,7 +611,8 @@ "saveCodeBuddyConfig": "CodeBuddy 구성 저장", "saveKimiCodeConfig": "Kimi Code 구성 저장", "customInstall": "사용자 지정 설치", - "saveKimiCodeRawConfig": "Save config.toml" + "saveKimiCodeRawConfig": "Save config.toml", + "diagnose": "진단" }, "status": { "enabled": "활성화됨", @@ -3969,5 +3970,27 @@ "groupInstructions": "설명 및 시스템 프롬프트", "fieldDescription": "설명", "baseInstructions": "시스템 프롬프트(base_instructions)" + }, + "DiagnosticsSettings": { + "title": "환경 진단", + "description": "이 앱이 자체 프로세스에서 에이전트 CLI를 어떻게 확인하는지 점검합니다(터미널과 다를 수 있음).", + "loading": "진단 실행 중…", + "error": "진단 실패", + "rerun": "다시 실행", + "copyAll": "전체 복사", + "copied": "진단 정보를 클립보드에 복사했습니다", + "button": "진단", + "verdict": { + "ok": "환경이 정상입니다. 그래도 설치되지 않았다고 표시되면 앱을 완전히 재시작하여 프리픽스 캐시를 새로 고치세요.", + "node_missing": "앱 PATH에서 Node.js를 찾을 수 없습니다.", + "npm_missing": "앱 PATH에서 npm을 찾을 수 없습니다.", + "not_installed": "이 에이전트가 설치되지 않은 것 같습니다.", + "installed_but_unresolved": "설치된 것으로 기록되어 있지만 앱이 실행 파일을 찾지 못합니다.", + "user_prefix_not_on_path": "폴백 프리픽스(~/.codeg/npm-global)에 설치되었지만 앱 PATH에 없습니다. 앱을 완전히 재시작한 후 다시 시도하세요.", + "homebrew_bin_not_on_path": "Homebrew bin에 설치되었지만 앱 PATH에 없습니다(Apple Silicon keg 분리).", + "terminal_only_path": "이 명령은 터미널에서는 확인되지만 앱에서는 확인되지 않습니다. GUI PATH 불일치입니다. 터미널에서 앱을 실행하거나 에이전트 설정에서 다시 설치하세요.", + "npm_prefix_timeout": "npm prefix -g가 너무 느립니다(1.5초 초과). 폴백 감지를 건너뛰었습니다. 앱을 재시작한 후 다시 시도하세요.", + "node_too_old": "현재 Node.js가 이 에이전트의 요구 버전보다 낮습니다. Node.js를 업그레이드하세요." + } } } diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index ff331c024..3be231bb9 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -611,7 +611,8 @@ "saveCodeBuddyConfig": "Salvar configuração do CodeBuddy", "saveKimiCodeConfig": "Salvar configuração do Kimi Code", "customInstall": "Instalação personalizada", - "saveKimiCodeRawConfig": "Save config.toml" + "saveKimiCodeRawConfig": "Save config.toml", + "diagnose": "Diagnosticar" }, "status": { "enabled": "Habilitado", @@ -3969,5 +3970,27 @@ "groupInstructions": "Descrição e prompt do sistema", "fieldDescription": "Descrição", "baseInstructions": "Prompt do sistema (base_instructions)" + }, + "DiagnosticsSettings": { + "title": "Diagnóstico do ambiente", + "description": "Verifica como este app resolve a CLI do agente no próprio processo, que pode diferir do seu terminal.", + "loading": "Executando diagnóstico…", + "error": "Falha no diagnóstico", + "rerun": "Executar novamente", + "copyAll": "Copiar tudo", + "copied": "Diagnóstico copiado para a área de transferência", + "button": "Diagnosticar", + "verdict": { + "ok": "O ambiente parece saudável. Se ainda aparecer como não instalado, reinicie o app completamente para atualizar o cache do prefixo.", + "node_missing": "Node.js não foi encontrado no PATH do app.", + "npm_missing": "npm não foi encontrado no PATH do app.", + "not_installed": "Este agente não parece estar instalado.", + "installed_but_unresolved": "Consta como instalado, mas o app não consegue localizar o executável.", + "user_prefix_not_on_path": "Instalado no prefixo de fallback (~/.codeg/npm-global), que não está no PATH do app. Reinicie o app completamente e tente novamente.", + "homebrew_bin_not_on_path": "Instalado no bin do Homebrew, que não está no PATH do app (divisão de keg no Apple Silicon).", + "terminal_only_path": "O comando é resolvido no seu terminal, mas não no app: uma diferença de PATH da GUI. Inicie o app por um terminal ou reinstale nas Configurações de agentes.", + "npm_prefix_timeout": "npm prefix -g foi muito lento (mais de 1,5 s), então a detecção de fallback foi ignorada. Reinicie o app e tente novamente.", + "node_too_old": "Seu Node.js ativo é mais antigo do que este agente requer. Atualize o Node.js." + } } } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 6c11d56f6..c2f815ae9 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -611,7 +611,8 @@ "saveCodeBuddyConfig": "保存 CodeBuddy 配置", "saveKimiCodeConfig": "保存 Kimi Code 配置", "customInstall": "自定义安装", - "saveKimiCodeRawConfig": "保存 config.toml" + "saveKimiCodeRawConfig": "保存 config.toml", + "diagnose": "诊断" }, "status": { "enabled": "启用", @@ -3969,5 +3970,27 @@ "groupInstructions": "描述与系统提示词", "fieldDescription": "描述", "baseInstructions": "系统提示词(base_instructions)" + }, + "DiagnosticsSettings": { + "title": "环境诊断", + "description": "检查本应用在自身进程中如何解析该智能体的 CLI——这可能与你的终端不同。", + "loading": "正在运行诊断…", + "error": "诊断失败", + "rerun": "重新运行", + "copyAll": "复制全部", + "copied": "诊断信息已复制到剪贴板", + "button": "诊断", + "verdict": { + "ok": "环境正常。若仍提示未安装,请彻底重启应用以刷新前缀缓存。", + "node_missing": "在应用的 PATH 上未找到 Node.js。", + "npm_missing": "在应用的 PATH 上未找到 npm。", + "not_installed": "该智能体似乎尚未安装。", + "installed_but_unresolved": "记录显示已安装,但应用无法定位其可执行文件。", + "user_prefix_not_on_path": "安装到了回退前缀(~/.codeg/npm-global),但它不在应用的 PATH 上。请彻底重启应用后重试。", + "homebrew_bin_not_on_path": "安装在 Homebrew 的 bin 目录下,但它不在应用的 PATH 上(Apple Silicon keg 拆分)。", + "terminal_only_path": "该命令在你的终端能解析,但在应用中不能——这是 GUI PATH 差异。请从终端启动应用,或在智能体设置中重新安装。", + "npm_prefix_timeout": "npm prefix -g 太慢(超过 1.5 秒),已跳过回退检测。请重启应用后重试。", + "node_too_old": "当前 Node.js 版本低于该智能体的要求。请升级 Node.js。" + } } } diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 31bf100f2..a89d65817 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -611,7 +611,8 @@ "saveCodeBuddyConfig": "儲存 CodeBuddy 設定", "saveKimiCodeConfig": "儲存 Kimi Code 設定", "customInstall": "自訂安裝", - "saveKimiCodeRawConfig": "儲存 config.toml" + "saveKimiCodeRawConfig": "儲存 config.toml", + "diagnose": "診斷" }, "status": { "enabled": "啟用", @@ -3969,5 +3970,27 @@ "groupInstructions": "描述與系統提示詞", "fieldDescription": "描述", "baseInstructions": "系統提示詞(base_instructions)" + }, + "DiagnosticsSettings": { + "title": "環境診斷", + "description": "檢查本應用在自身行程中如何解析該智能體的 CLI——這可能與你的終端機不同。", + "loading": "正在執行診斷…", + "error": "診斷失敗", + "rerun": "重新執行", + "copyAll": "複製全部", + "copied": "診斷資訊已複製到剪貼簿", + "button": "診斷", + "verdict": { + "ok": "環境正常。若仍提示未安裝,請徹底重新啟動應用以重新整理前綴快取。", + "node_missing": "在應用的 PATH 上找不到 Node.js。", + "npm_missing": "在應用的 PATH 上找不到 npm。", + "not_installed": "此智能體似乎尚未安裝。", + "installed_but_unresolved": "紀錄顯示已安裝,但應用無法定位其執行檔。", + "user_prefix_not_on_path": "安裝到了回退前綴(~/.codeg/npm-global),但它不在應用的 PATH 上。請徹底重新啟動應用後再試。", + "homebrew_bin_not_on_path": "安裝在 Homebrew 的 bin 目錄下,但它不在應用的 PATH 上(Apple Silicon keg 拆分)。", + "terminal_only_path": "此命令在你的終端機能解析,但在應用中不能——這是 GUI PATH 差異。請從終端機啟動應用,或在智能體設定中重新安裝。", + "npm_prefix_timeout": "npm prefix -g 太慢(超過 1.5 秒),已略過回退偵測。請重新啟動應用後再試。", + "node_too_old": "目前 Node.js 版本低於此智能體的要求。請升級 Node.js。" + } } } diff --git a/src/lib/api.ts b/src/lib/api.ts index 08c34cfbb..a7f738de0 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -31,6 +31,7 @@ import type { QuestionAnswer, AcpAgentInfo, AcpAgentStatus, + AgentDiagnosticsReport, GrokStructuredConfig, CursorStructuredConfig, CursorAuthStatus, @@ -331,6 +332,13 @@ export async function acpGetAgentStatus( return getTransport().call("acp_get_agent_status", { agentType }) } +// Run environment diagnostics for an agent (or a base env report when omitted). +export async function acpEnvDiagnostics( + agentType?: AgentType +): Promise { + return getTransport().call("acp_env_diagnostics", { agentType }) +} + export async function acpClearBinaryCache(agentType: AgentType): Promise { return getTransport().call("acp_clear_binary_cache", { agentType }) } diff --git a/src/lib/context-window.ts b/src/lib/context-window.ts index 07d0cfd49..a26d9e436 100644 --- a/src/lib/context-window.ts +++ b/src/lib/context-window.ts @@ -1,19 +1,20 @@ /** * Shared context-window percentage helpers. * - * The bottom status bar (`status-bar-tokens.tsx`) and the Session Details dialog - * both show a context-window usage percentage; keeping the precedence and the - * one-decimal formatting here ensures the two never drift apart. + * The below-composer context indicator (`composer-context-usage.tsx`) and the + * Session Details dialog both show a context-window usage percentage; keeping + * the precedence and the one-decimal formatting here ensures the two never + * drift apart. */ /** * Resolve the context-window usage percentage from a session-stats snapshot, - * mirroring the status bar's session-stats branch: trust the backend-provided + * mirroring the context indicator's session-stats branch: trust the backend-provided * `percent`, and only recompute from `used / max` when the backend figure is * absent. The result is clamped into 0–100. Returns `null` when nothing is * known. * - * (The status bar layers a live-connection tier on top of this — it prefers a + * (The context indicator layers a live-connection tier on top of this — it prefers a * `used/max` recompute from the live ACP connection before falling back to the * session stats handled here — but the dialog has no live connection, so it maps * onto exactly this branch.) @@ -31,7 +32,7 @@ export function resolveContextWindowPercent( /** * Format a context-window percentage keeping one decimal place (e.g. `87.3%`), - * matching the bottom status bar. Returns `--` for an unknown value. + * matching the below-composer context indicator. Returns `--` for an unknown value. */ export function formatContextWindowPercent(percent: number | null): string { if (percent == null) return "--" diff --git a/src/lib/types.ts b/src/lib/types.ts index dc6369c3f..9015c2deb 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1801,6 +1801,39 @@ export interface AcpAgentStatus { installed_version: string | null } +// Environment diagnostics (returned by acp_env_diagnostics). Mirrors the Rust +// AgentDiagnosticsReport in src-tauri/src/acp/types.rs (snake_case response DTO). +export type DiagLevel = "ok" | "warn" | "fail" | "info" + +export interface DiagCheck { + label: string + value: string + status: DiagLevel + hint: string | null +} + +export interface DiagSection { + title: string + checks: DiagCheck[] +} + +export interface DiagnosticsVerdict { + level: DiagLevel + // Stable id localized via DiagnosticsSettings.verdict.. + code: string + // Pre-formatted English sentence; used only in plain_text (copy blob). + summary: string +} + +export interface AgentDiagnosticsReport { + generated_at: string + agent_type: AgentType | null + verdict: DiagnosticsVerdict + sections: DiagSection[] + // Backend-rendered text for the "copy all" button. + plain_text: string +} + export type AgentSkillScope = "global" | "project" export type AgentSkillLayout = "markdown_file" | "skill_directory" diff --git a/src/stores/runtime-live-message-slice-decoupling.test.ts b/src/stores/runtime-live-message-slice-decoupling.test.ts index ed75b45f2..82105befb 100644 --- a/src/stores/runtime-live-message-slice-decoupling.test.ts +++ b/src/stores/runtime-live-message-slice-decoupling.test.ts @@ -9,8 +9,8 @@ import { const CID = 42 // A complete runtime session (mirrors the store's internal `createEmptySession`) -// with distinct non-null references for the three fields the conversation panel -// reads from it, seeded straight into the store. +// with distinct non-null references for the fields exercised here, seeded +// straight into the store. function seedSession(sessionStats: SessionStats) { useConversationRuntimeStore.setState({ byConversationId: new Map([ @@ -50,30 +50,40 @@ const liveMsg = (id: string): LiveMessage => ({ startedAt: 0, }) -// The three fields the keep-alive panel actually reads from its session. +// The two fields the keep-alive panel reads from its session via `useShallow`. function panelSlice() { const s = useConversationRuntimeStore.getState().byConversationId.get(CID) return { - sessionStats: s?.sessionStats ?? null, externalId: s?.externalId ?? null, syncState: s?.syncState ?? "idle", } } +// `sessionStats` is no longer part of the panel slice; the below-composer context +// indicator reads it per-conversation from this same store, so its reference +// stability across streaming is asserted separately here. +function sessionStatsOf() { + return ( + useConversationRuntimeStore.getState().byConversationId.get(CID) + ?.sessionStats ?? null + ) +} + // The keep-alive conversation panel (`ConversationTabView`) subscribes to a -// `useShallow` slice of {sessionStats, externalId, syncState} from its runtime -// session — NOT the whole session object. The live-message sink rewrites the -// session object on every streaming batch (~60/s, via SET_LIVE_MESSAGE); a -// whole-object selector would re-render the panel per token. These tests encode -// the store invariant that narrowing depends on: SET_LIVE_MESSAGE replaces the -// session object (so the OLD whole-object selector churned) while preserving the -// references of those three fields (so the slice is Object.is-stable and -// `useShallow` bails → no re-render) — and that a real change to one of the -// three still propagates (no over-suppression). +// `useShallow` slice of {externalId, syncState} from its runtime session — NOT +// the whole session object. The live-message sink rewrites the session object on +// every streaming batch (~60/s, via SET_LIVE_MESSAGE); a whole-object selector +// would re-render the panel per token. These tests encode the store invariant +// that narrowing depends on: SET_LIVE_MESSAGE replaces the session object (so the +// OLD whole-object selector churned) while preserving the references of those two +// fields (so the slice is Object.is-stable and `useShallow` bails → no re-render) +// — and that a real change to one still propagates (no over-suppression). +// `sessionStats` (read per-conversation by the context indicator) must survive +// streaming with the same reference too, so that read stays inert to live tokens. describe("runtime session panel slice is decoupled from live-message streaming", () => { afterEach(() => resetConversationRuntimeStore()) - it("replaces the session object but keeps the panel's three fields stable across a streaming batch", () => { + it("replaces the session object but keeps the panel slice + sessionStats stable across a streaming batch", () => { const stats: SessionStats = { total_usage: null, total_duration_ms: 0 } seedSession(stats) @@ -81,6 +91,7 @@ describe("runtime session panel slice is decoupled from live-message streaming", .getState() .byConversationId.get(CID) const beforeSlice = panelSlice() + const beforeStats = sessionStatsOf() // A streaming batch lands: the connection sink writes liveMessage (isLive). useConversationRuntimeStore @@ -98,10 +109,12 @@ describe("runtime session panel slice is decoupled from live-message streaming", // ...but every field the panel's narrow slice reads kept its identity, so // `useShallow` shallow-compares equal and the panel does NOT re-render. const afterSlice = panelSlice() - expect(afterSlice.sessionStats).toBe(beforeSlice.sessionStats) - expect(afterSlice.sessionStats).toBe(stats) expect(afterSlice.externalId).toBe(beforeSlice.externalId) expect(afterSlice.syncState).toBe(beforeSlice.syncState) + // sessionStats (read per-conversation by the context indicator) is likewise + // reference-stable across the streaming batch. + expect(sessionStatsOf()).toBe(beforeStats) + expect(sessionStatsOf()).toBe(stats) }) it("still propagates a real change to a slice field (no over-suppression)", () => { @@ -116,8 +129,8 @@ describe("runtime session panel slice is decoupled from live-message streaming", const afterSlice = panelSlice() expect(afterSlice.syncState).toBe("idle") expect(afterSlice.syncState).not.toBe(beforeSlice.syncState) - // Unrelated slice fields keep their identity across the syncState change. - expect(afterSlice.sessionStats).toBe(beforeSlice.sessionStats) + // Unrelated fields keep their identity across the syncState change. expect(afterSlice.externalId).toBe(beforeSlice.externalId) + expect(sessionStatsOf()).toBe(stats) }) })