diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3080497..2e4e43e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -18,6 +18,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -888,6 +900,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "portable-pty", + "rusqlite", "serde", "serde_json", "tauri", @@ -901,6 +914,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tauri-plugin-window-state", + "toml 0.8.2", "ureq", "window-vibrancy 0.5.3", ] @@ -1157,6 +1171,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.5.0" @@ -1693,6 +1719,9 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] [[package]] name = "hashbrown" @@ -1709,6 +1738,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -2355,6 +2393,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3582,6 +3631,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -5311,6 +5374,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7daf1ec..ca09542 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -46,6 +46,10 @@ ureq = { version = "2", features = ["json"] } chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc", "getrandom"] } getrandom = "0.2" tauri-plugin-clipboard-manager = "2" +# 一键导入:读 opencode / zcode 的会话库(只读)和 Codex 的 config.toml。 +# bundled 自带 SQLite,不依赖系统库。 +rusqlite = { version = "0.32", features = ["bundled"] } +toml = "0.8" # 仅桌面端的插件(updater / 单实例 / 进程重启) [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] diff --git a/src-tauri/src/agent_import/mcp.rs b/src-tauri/src/agent_import/mcp.rs new file mode 100644 index 0000000..b6d292c --- /dev/null +++ b/src-tauri/src/agent_import/mcp.rs @@ -0,0 +1,462 @@ +//! MCP servers the other agents are configured with, as JuCode +//! `mcp_servers` entries: {name, transport: "stdio" | "http", command, args, +//! env, url, headers, enabled, timeout_seconds} (JuCode-CLI config.rs). +//! +//! Read from Claude Code's `~/.claude.json` (`mcpServers`, also per project) +//! and its plugins' `.mcp.json`; Codex's `config.toml` (`[mcp_servers.*]`) +//! and its enabled plugins; opencode's `opencode.json(c)` (`mcp`); zcode's +//! config and plugins; omp's `~/.omp/agent/mcp.json`. A server JuCode could +//! not start as written is left out: an SSE endpoint, a command relative to +//! the tool's own folder, or a `${…}` placeholder only its app fills in. + +use super::{codex_config, plugins, Roots}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, HashSet}; +use std::fs; +use std::path::Path; + +#[derive(Serialize, Debug, Clone)] +pub struct Found { + pub source: String, + /// Where it is set when not in the tool's own settings: the plugin it + /// comes with, the project folder it is set for. + pub from: String, + pub name: String, + pub transport: &'static str, + pub command: Option, + pub args: Option>, + pub url: Option, + /// JuCode already has a server of this name. + pub present: bool, + /// The `mcp_servers` entry to add. + pub entry: Value, +} + +/// JuCode-CLI `DEFAULT_MCP_TIMEOUT_SECONDS`. +const DEFAULT_TIMEOUT: u64 = 60; + +/// A server as some tool writes it, before it is checked. +#[derive(Default, Debug)] +struct Raw { + kind: Option, + command: Option, + args: Vec, + env: BTreeMap, + url: Option, + headers: BTreeMap, + disabled: bool, + timeout_s: Option, +} + +fn strings(v: &Value) -> Vec { + v.as_array().into_iter().flatten().filter_map(|x| x.as_str().map(str::to_string)).collect() +} + +fn string_map(v: &Value) -> BTreeMap { + v.as_object() + .into_iter() + .flatten() + .map(|(k, v)| (k.clone(), v.as_str().map_or_else(|| v.to_string(), str::to_string))) + .collect() +} + +fn text(v: &Value) -> Option { + v.as_str().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string) +} + +fn millis(v: &Value) -> Option { + v.as_f64().filter(|ms| *ms > 0.0).map(|ms| (ms / 1000.0).ceil() as u64) +} + +/// The `mcpServers` shape Claude Code, omp and plugin `.mcp.json` files use. +fn standard(v: &Value) -> Raw { + let (command, args) = match &v["command"] { + // Some write the whole command line as a list. + Value::Array(_) => { + let mut all = strings(&v["command"]); + let first = (!all.is_empty()).then(|| all.remove(0)); + all.extend(strings(&v["args"])); + (first, all) + } + other => (text(other), strings(&v["args"])), + }; + Raw { + kind: text(&v["type"]).or_else(|| text(&v["transport"])), + command, + args, + env: string_map(&v["env"]), + url: text(&v["url"]), + headers: string_map(&v["headers"]), + disabled: v["disabled"] == json!(true) || v["enabled"] == json!(false), + timeout_s: millis(&v["timeout"]).or_else(|| millis(&v["timeoutMs"])), + } +} + +/// Codex `[mcp_servers.]`. +fn codex(v: &Value) -> Raw { + Raw { + kind: text(&v["type"]), + command: text(&v["command"]), + args: strings(&v["args"]), + env: string_map(&v["env"]), + url: text(&v["url"]), + headers: string_map(&v["http_headers"]), + disabled: v["enabled"] == json!(false), + timeout_s: v["tool_timeout_sec"].as_f64().filter(|s| *s > 0.0).map(|s| s.ceil() as u64), + } +} + +/// opencode `mcp.`: `local` runs `command` (a list), `remote` is a URL. +fn opencode(v: &Value) -> Raw { + let mut all = strings(&v["command"]); + let command = (!all.is_empty()).then(|| all.remove(0)); + Raw { + kind: text(&v["type"]), + command, + args: all, + env: string_map(&v["environment"]), + url: text(&v["url"]), + headers: string_map(&v["headers"]), + disabled: v["enabled"] == json!(false), + timeout_s: millis(&v["timeout"]), + } +} + +/// A server name JuCode accepts (`^[A-Za-z0-9_-]+$`). +fn clean_name(name: &str) -> Option { + let name: String = name + .trim() + .chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '-' }) + .collect(); + let name = name.trim_matches('-').to_string(); + (!name.is_empty()).then_some(name) +} + +/// The JuCode entry for a server, or None when it could not run as written. +/// `plugin` fills in the plugin-folder placeholders of a plugin's servers. +fn entry(name: &str, mut raw: Raw, plugin: Option<&Path>) -> Option { + let name = clean_name(name)?; + if let Some(dir) = plugin { + let dir = dir.display().to_string(); + let fill = |s: &mut String| { + for key in ["${CLAUDE_PLUGIN_ROOT}", "${ZCODE_PLUGIN_ROOT}", "${PLUGIN_ROOT}"] { + *s = s.replace(key, &dir); + } + }; + raw.command.iter_mut().for_each(fill); + raw.url.iter_mut().for_each(fill); + raw.args.iter_mut().for_each(fill); + raw.env.values_mut().for_each(fill); + raw.headers.values_mut().for_each(fill); + } + let unfilled = |s: &String| s.contains("${"); + if raw.command.iter().chain(&raw.url).chain(&raw.args).chain(raw.env.values()).chain(raw.headers.values()).any(unfilled) { + return None; + } + let http = match raw.kind.as_deref() { + Some("stdio" | "local") => false, + Some("http" | "streamable-http" | "streamableHttp" | "streamable_http" | "remote") => true, + // JuCode speaks streamable HTTP, not the older SSE transport. + Some(_) => return None, + None => raw.command.is_none() && raw.url.is_some(), + }; + let timeout = raw.timeout_s.unwrap_or(DEFAULT_TIMEOUT).clamp(1, 3600); + if http { + let url = raw.url.filter(|u| u.starts_with("http://") || u.starts_with("https://"))?; + Some(json!({ + "name": name, "transport": "http", "command": "", "args": [], "env": {}, + "url": url, "headers": raw.headers, "enabled": !raw.disabled, "timeout_seconds": timeout, + })) + } else { + let command = raw.command?; + // Relative to the tool's own folder (`./bin/server`): not found from here. + if command.starts_with("./") || command.starts_with("../") || command.starts_with(".\\") { + return None; + } + Some(json!({ + "name": name, "transport": "stdio", "command": command, "args": raw.args, "env": raw.env, + "url": "", "headers": {}, "enabled": !raw.disabled, "timeout_seconds": timeout, + })) + } +} + +/// The servers of a `.mcp.json`: under `mcpServers`, or the file itself. +fn server_map(v: &Value) -> Option<&serde_json::Map> { + v["mcpServers"].as_object().or_else(|| { + let map = v.as_object()?; + map.values().all(|s| s.get("command").is_some() || s.get("url").is_some()).then_some(map) + }) +} + +/// JSON with comments and trailing commas (opencode.jsonc). +fn strip_jsonc(text: &str) -> String { + let mut plain = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + let mut in_string = false; + let mut escaped = false; + while let Some(c) = chars.next() { + if in_string { + plain.push(c); + match c { + _ if escaped => escaped = false, + '\\' => escaped = true, + '"' => in_string = false, + _ => {} + } + continue; + } + match (c, chars.peek()) { + ('"', _) => { + in_string = true; + plain.push(c); + } + ('/', Some('/')) => { + while chars.peek().is_some_and(|n| *n != '\n') { + chars.next(); + } + } + ('/', Some('*')) => { + chars.next(); + let mut prev = ' '; + for n in chars.by_ref() { + if prev == '*' && n == '/' { + break; + } + prev = n; + } + } + _ => plain.push(c), + } + } + // Trailing commas, outside strings. + let mut out = String::with_capacity(plain.len()); + let chars: Vec = plain.chars().collect(); + let (mut in_string, mut escaped) = (false, false); + for (i, &c) in chars.iter().enumerate() { + if in_string { + match c { + _ if escaped => escaped = false, + '\\' => escaped = true, + '"' => in_string = false, + _ => {} + } + } else if c == '"' { + in_string = true; + } else if c == ',' && chars[i + 1..].iter().find(|n| !n.is_whitespace()).is_some_and(|n| matches!(n, '}' | ']')) { + continue; + } + out.push(c); + } + out +} + +fn read_jsonc(path: &Path) -> Value { + fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&strip_jsonc(&t)).ok()).unwrap_or_default() +} + +/// Names JuCode's config already has. +fn configured(roots: &Roots) -> HashSet { + let config = crate::read_json(&roots.jucode.join("config.json")); + config["mcp_servers"] + .as_array() + .into_iter() + .flatten() + .filter_map(|s| s["name"].as_str().map(str::to_string)) + .collect() +} + +pub fn scan(roots: &Roots) -> Vec { + // (source, from, name, raw server, plugin folder) + let mut all: Vec<(&'static str, String, String, Raw, Option)> = Vec::new(); + + let claude = crate::read_json(&roots.home.join(".claude.json")); + for (name, v) in claude["mcpServers"].as_object().into_iter().flatten() { + all.push(("claude", String::new(), name.clone(), standard(v), None)); + } + for (project, p) in claude["projects"].as_object().into_iter().flatten() { + for (name, v) in p["mcpServers"].as_object().into_iter().flatten() { + all.push(("claude", project.clone(), name.clone(), standard(v), None)); + } + } + + let config = codex_config(roots); + for (name, v) in config["mcp_servers"].as_object().into_iter().flatten() { + all.push(("codex", String::new(), name.clone(), codex(v), None)); + } + + // opencode.jsonc overrides opencode.json, server by server. + let dir = roots.config_home.join("opencode"); + let mut servers: BTreeMap = BTreeMap::new(); + for file in ["opencode.json", "opencode.jsonc"] { + let v = read_jsonc(&dir.join(file)); + for (name, s) in v["mcp"].as_object().into_iter().flatten() { + servers.insert(name.clone(), s.clone()); + } + } + for (name, v) in &servers { + all.push(("opencode", String::new(), name.clone(), opencode(v), None)); + } + + let zcode = crate::read_json(&roots.home.join(".zcode").join("cli").join("config.json")); + for (name, v) in zcode["mcp"].as_object().into_iter().flatten() { + all.push(("zcode", String::new(), name.clone(), opencode(v), None)); + } + for (name, v) in zcode["mcpServers"].as_object().into_iter().flatten() { + all.push(("zcode", String::new(), name.clone(), standard(v), None)); + } + + for file in ["mcp.json", ".mcp.json"] { + let v = read_jsonc(&roots.omp_agent().join(file)); + let off: HashSet = strings(&v["disabledServers"]).into_iter().collect(); + for (name, s) in server_map(&v).into_iter().flatten() { + let mut raw = standard(s); + raw.disabled |= off.contains(name); + all.push(("omp", String::new(), name.clone(), raw, None)); + } + } + + for plugin in plugins(roots) { + let v = read_jsonc(&plugin.dir.join(".mcp.json")); + for (name, s) in server_map(&v).into_iter().flatten() { + all.push((plugin.source, plugin.name.clone(), name.clone(), standard(s), Some(plugin.dir.clone()))); + } + } + + let have = configured(roots); + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for (source, from, name, raw, plugin) in all { + let Some(entry) = entry(&name, raw, plugin.as_deref()) else { continue }; + let name = entry["name"].as_str().unwrap_or_default().to_string(); + // The same server set for several projects is offered once. + if !seen.insert((source, name.clone())) { + continue; + } + let stdio = entry["transport"] == "stdio"; + out.push(Found { + source: source.to_string(), + from, + present: have.contains(&name), + transport: if stdio { "stdio" } else { "http" }, + command: stdio.then(|| entry["command"].as_str().unwrap_or_default().to_string()), + args: stdio.then(|| strings(&entry["args"])), + url: (!stdio).then(|| entry["url"].as_str().unwrap_or_default().to_string()), + name, + entry, + }); + } + let rank = |s: &str| ["claude", "codex", "opencode", "zcode", "omp"].iter().position(|x| *x == s); + out.sort_by(|a, b| (rank(&a.source), &a.from, &a.name).cmp(&(rank(&b.source), &b.from, &b.name))); + out +} + +#[cfg(test)] +mod tests { + use super::super::testutil; + use super::*; + + #[test] + fn maps_each_tools_format() { + let roots = testutil::roots("mcp"); + testutil::write( + &roots.home.join(".claude.json"), + &json!({ + "mcpServers": { + "files": {"type": "stdio", "command": "npx", "args": ["-y", "server-files"], "env": {"ROOT": "/tmp"}}, + "docs api": {"type": "http", "url": "https://mcp.example.com/mcp", "headers": {"Authorization": "Bearer x"}}, + "old": {"type": "sse", "url": "https://sse.example.com"}, + "needs-env": {"command": "srv", "env": {"TOKEN": "${MY_TOKEN}"}} + }, + "projects": {"/p": {"mcpServers": {"files": {"command": "other"}, "proj": {"command": "proj-srv"}}}} + }) + .to_string(), + ); + testutil::write( + &roots.codex.join("config.toml"), + "[mcp_servers.node_repl]\ncommand = \"/bin/node_repl\"\nargs = []\ntool_timeout_sec = 120\n\ + [mcp_servers.node_repl.env]\nA = \"1\"\n\ + [mcp_servers.computer-use]\ntype = \"stdio\"\ncommand = \"./App.app/client\"\nenabled = false\n\ + [mcp_servers.remote]\nurl = \"https://r.example.com/mcp\"\nenabled = false\n\ + [plugins.\"notion@curated\"]\nenabled = true\n", + ); + testutil::write( + &roots.codex.join("plugins/cache/curated/notion/1dc19589/.mcp.json"), + &json!({"mcpServers": {"notion": {"type": "http", "url": "https://mcp.notion.com/mcp"}}}).to_string(), + ); + testutil::write( + &roots.config_home.join("opencode/opencode.jsonc"), + "{\n // mine\n \"mcp\": {\n \"jira\": {\"type\": \"local\", \"command\": [\"bun\", \"x\", \"jira-mcp\"], \"environment\": {\"K\": \"v\"}, \"timeout\": 5000,},\n \"web\": {\"type\": \"remote\", \"url\": \"https://w.example.com/mcp\", \"enabled\": false},\n },\n}\n", + ); + // Joined one part at a time: the scan reads it back with native separators. + let plugin = roots.zcode_plugins().join("official").join("android").join("0.1.0"); + testutil::write( + &plugin.join(".mcp.json"), + &json!({"mcpServers": { + "android": {"command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/dist/server.js"]}, + "img": {"type": "http", "url": "${ZCODE_BASE_URL}/mcp"} + }}) + .to_string(), + ); + testutil::write( + &roots.omp_agent().join("mcp.json"), + &json!({"mcpServers": {"gh": {"command": "gh-mcp"}}, "disabledServers": ["gh"]}).to_string(), + ); + testutil::write(&roots.jucode.join("config.json"), &json!({"mcp_servers": [{"name": "files"}]}).to_string()); + + let found = scan(&roots); + let by = |source: &str, name: &str| { + found.iter().find(|f| f.source == source && f.name == name).unwrap_or_else(|| panic!("{source} {name}")) + }; + let names: Vec<(&str, &str)> = found.iter().map(|f| (f.source.as_str(), f.name.as_str())).collect(); + assert_eq!( + names, + [ + ("claude", "docs-api"), + ("claude", "files"), + ("claude", "proj"), + ("codex", "node_repl"), + ("codex", "remote"), + ("codex", "notion"), + ("opencode", "jira"), + ("opencode", "web"), + ("zcode", "android"), + ("omp", "gh"), + ], + "SSE, unfilled placeholders and folder-relative commands are left out; a name set again for a project is offered once" + ); + let files = by("claude", "files"); + assert!(files.present); + assert_eq!( + files.entry, + json!({"name": "files", "transport": "stdio", "command": "npx", "args": ["-y", "server-files"], "env": {"ROOT": "/tmp"}, + "url": "", "headers": {}, "enabled": true, "timeout_seconds": 60}) + ); + assert_eq!(by("claude", "proj").from, "/p"); + let docs = by("claude", "docs-api"); + assert_eq!((docs.transport, docs.url.as_deref()), ("http", Some("https://mcp.example.com/mcp"))); + assert_eq!(docs.entry["headers"], json!({"Authorization": "Bearer x"})); + assert!(!docs.present); + let repl = &by("codex", "node_repl").entry; + assert_eq!((repl["timeout_seconds"].as_u64(), repl["env"].clone()), (Some(120), json!({"A": "1"}))); + assert_eq!(by("codex", "remote").entry["enabled"], false); + let jira = &by("opencode", "jira").entry; + assert_eq!((jira["command"].as_str(), jira["args"].clone()), (Some("bun"), json!(["x", "jira-mcp"]))); + assert_eq!((jira["env"].clone(), jira["timeout_seconds"].as_u64()), (json!({"K": "v"}), Some(5))); + assert_eq!(by("opencode", "web").entry["enabled"], false); + let android = by("zcode", "android"); + assert_eq!(android.from, "android"); + assert_eq!(android.entry["args"], json!([format!("{}/dist/server.js", plugin.display())])); + assert_eq!(by("omp", "gh").entry["enabled"], false); + assert_eq!(by("codex", "notion").from, "notion"); + } + + #[test] + fn jsonc_comments_and_trailing_commas() { + let text = "{\"a\": \"x // not a comment\", /* gone */ \"b\": [1, 2,], // end\n}"; + assert_eq!(serde_json::from_str::(&strip_jsonc(text)).unwrap(), json!({"a": "x // not a comment", "b": [1, 2]})); + assert_eq!(clean_name("my server.v2"), Some("my-server-v2".into())); + assert_eq!(clean_name("中文"), None); + } +} diff --git a/src-tauri/src/agent_import/mod.rs b/src-tauri/src/agent_import/mod.rs new file mode 100644 index 0000000..46f678e --- /dev/null +++ b/src-tauri/src/agent_import/mod.rs @@ -0,0 +1,370 @@ +//! 一键导入: the conversations, skills and MCP servers of the other coding +//! agents on this machine (Claude Code, Codex, opencode, zcode, omp), listed +//! in one scan and imported in one go. Their files are never changed; the +//! only writes outside `~/.jucode` are the Claude Code and Codex conversation +//! copies, added next to that engine's own sessions (`~/.claude/projects`, +//! `~/.codex/sessions`). +//! +//! - Conversations: Claude Code and Codex ones become a cleaned copy in the +//! engine's own format (native_import) and continue on that engine. +//! opencode, zcode and omp have no engine here, so theirs are converted +//! into JuCode sessions (sessions.rs). Either way +//! `~/.jucode/native-imports.json` records the import, and importing again +//! reuses the first copy. +//! - Skills are copied into `~/.jucode/skills` (skills.rs). +//! - MCP servers are mapped to `mcp_servers` entries (mcp.rs). The desktop +//! adds them through the daemon (`mcp_set`), as Settings → MCP does, so +//! open sessions pick them up. + +mod mcp; +mod sessions; +mod skills; + +use crate::native_import::{self, Homes}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Where everything is read from and written to (injectable so tests run +/// against a fixture home). +pub struct Roots { + pub home: PathBuf, + /// `~/.jucode`: sessions, skills, config.json and the import registry. + pub jucode: PathBuf, + pub claude: PathBuf, + pub codex: PathBuf, + /// `$XDG_DATA_HOME` or `~/.local/share` (opencode's database). + pub data_home: PathBuf, + /// `$XDG_CONFIG_HOME` or `~/.config` (opencode's config and skills). + pub config_home: PathBuf, +} + +impl Roots { + fn real() -> Roots { + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .unwrap_or_default(); + let xdg = |var: &str, fallback: &str| { + std::env::var_os(var) + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .unwrap_or_else(|| home.join(fallback)) + }; + let native = native_import::homes(); + Roots { + jucode: crate::jucode_dir(), + claude: native.claude, + codex: native.codex, + data_home: xdg("XDG_DATA_HOME", ".local/share"), + config_home: xdg("XDG_CONFIG_HOME", ".config"), + home, + } + } + + fn native(&self) -> Homes { + Homes { claude: self.claude.clone(), codex: self.codex.clone(), registry: self.registry() } + } + + fn registry(&self) -> PathBuf { + self.jucode.join("native-imports.json") + } + + fn opencode_db(&self) -> PathBuf { + self.data_home.join("opencode").join("opencode.db") + } + + fn zcode_db(&self) -> PathBuf { + self.home.join(".zcode").join("cli").join("db").join("db.sqlite") + } + + fn zcode_plugins(&self) -> PathBuf { + self.home.join(".zcode").join("cli").join("plugins").join("cache") + } + + fn omp_agent(&self) -> PathBuf { + self.home.join(".omp").join("agent") + } +} + +/// A plugin another agent has installed: skills and MCP servers come with +/// it (`skills/`, `.mcp.json`). +pub(crate) struct Plugin { + pub source: &'static str, + pub name: String, + pub dir: PathBuf, +} + +/// Codex's `config.toml` as JSON (null when missing or unreadable). +fn codex_config(roots: &Roots) -> serde_json::Value { + std::fs::read_to_string(roots.codex.join("config.toml")) + .ok() + .and_then(|text| text.parse::().ok()) + .and_then(|table| serde_json::to_value(table).ok()) + .unwrap_or_default() +} + +/// The newest version folder of a plugin cache entry (`//`). +fn latest_version(dir: &Path) -> Option { + let key = |p: &PathBuf| -> Vec<(u64, String)> { + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); + name.split(['.', '-', '+']) + .map(|part| part.parse::().map_or((0, part.to_string()), |n| (n, String::new()))) + .collect() + }; + std::fs::read_dir(dir) + .ok()? + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_dir() && !p.file_name().and_then(|n| n.to_str()).unwrap_or(".").starts_with('.')) + .max_by(|a, b| key(a).cmp(&key(b))) +} + +/// Installed plugins: Claude Code's registry (less the ones turned off in its +/// settings), the ones Codex's config enables, and every one in zcode's cache. +fn plugins(roots: &Roots) -> Vec { + let mut out = Vec::new(); + let installed = crate::read_json(&roots.claude.join("plugins").join("installed_plugins.json")); + let enabled = crate::read_json(&roots.claude.join("settings.json")); + for (key, entry) in installed["plugins"].as_object().into_iter().flatten() { + if enabled["enabledPlugins"][key] == serde_json::json!(false) { + continue; + } + // One install (older registries) or one per scope. + let entries = entry.as_array().cloned().unwrap_or_else(|| vec![entry.clone()]); + let dir = entries.iter().rev().filter_map(|e| e["installPath"].as_str()).map(PathBuf::from).find(|p| p.is_dir()); + if let Some(dir) = dir { + out.push(Plugin { source: "claude", name: key.split('@').next().unwrap_or(key).to_string(), dir }); + } + } + let config = codex_config(roots); + for (key, entry) in config["plugins"].as_object().into_iter().flatten() { + let Some((name, market)) = key.split_once('@') else { continue }; + if entry["enabled"] == serde_json::json!(false) || name.contains(['/', '\\']) || market.contains(['/', '\\']) { + continue; + } + if let Some(dir) = latest_version(&roots.codex.join("plugins").join("cache").join(market).join(name)) { + out.push(Plugin { source: "codex", name: name.to_string(), dir }); + } + } + for market in std::fs::read_dir(roots.zcode_plugins()).into_iter().flatten().flatten() { + for plugin in std::fs::read_dir(market.path()).into_iter().flatten().flatten() { + if let Some(dir) = latest_version(&plugin.path()) { + out.push(Plugin { source: "zcode", name: plugin.file_name().to_string_lossy().into_owned(), dir }); + } + } + } + out +} + +#[derive(Serialize, Debug)] +pub struct Scan { + pub sessions: Vec, + pub skills: Vec, + pub mcp: Vec, +} + +fn scan(roots: &Roots) -> Scan { + Scan { sessions: sessions::scan(roots), skills: skills::scan(roots), mcp: mcp::scan(roots) } +} + +#[derive(Deserialize, Debug)] +pub struct SessionPick { + pub source: String, + pub id: String, + /// The folder the scan found it in (Claude Code and Codex copies are + /// written per folder). + #[serde(default)] + pub cwd: String, +} + +#[derive(Deserialize, Debug)] +pub struct SkillPick { + pub source: String, + pub path: String, +} + +#[derive(Deserialize, Debug, Default)] +pub struct Selection { + #[serde(default)] + pub sessions: Vec, + #[serde(default)] + pub skills: Vec, +} + +/// What became of one picked item. +#[derive(Serialize, Debug)] +pub struct Outcome { + /// "session" or "skill". + pub kind: &'static str, + pub source: String, + /// The item as the scan named it: a session id, a skill folder. + pub key: String, + /// "imported"; "existing" when an earlier import (or a skill of that + /// name) is already there; "failed". + pub status: &'static str, + /// Sessions: the copy to open. Skills: the folder it was copied to. + pub target: Option, + pub title: Option, + /// Sessions: the folder the conversation belongs to, and the engine its + /// copy continues on ("jucode", "claude", "codex"). + pub cwd: Option, + pub engine: Option, + /// Whether `cwd` is still a folder on this machine (only then can it be a + /// project). + pub cwd_exists: bool, + pub error: Option, +} + +impl Outcome { + fn failed(kind: &'static str, source: &str, key: &str, error: String) -> Outcome { + Outcome { + kind, + source: source.to_string(), + key: key.to_string(), + status: "failed", + target: None, + title: None, + cwd: None, + engine: None, + cwd_exists: false, + error: Some(error), + } + } +} + +fn apply(roots: &Roots, selection: Selection) -> Vec { + // One import at a time, here or from a history picker: each extends the + // registry. + let _one = native_import::IMPORTING.lock().unwrap_or_else(|e| e.into_inner()); + let mut out = Vec::new(); + for pick in &selection.sessions { + out.push(match sessions::import(roots, pick) { + Ok(done) => Outcome { + kind: "session", + source: pick.source.clone(), + key: pick.id.clone(), + status: if done.reused { "existing" } else { "imported" }, + target: Some(done.id), + title: Some(done.title), + cwd_exists: Path::new(&done.cwd).is_dir(), + cwd: Some(done.cwd), + engine: Some(done.engine.to_string()), + error: None, + }, + Err(e) => Outcome::failed("session", &pick.source, &pick.id, e), + }); + } + if !selection.skills.is_empty() { + let found = skills::scan(roots); + for pick in &selection.skills { + out.push(match skills::import(roots, &found, &pick.source, &pick.path) { + Ok((copied, dest)) => Outcome { + kind: "skill", + source: pick.source.clone(), + key: pick.path.clone(), + status: if copied { "imported" } else { "existing" }, + target: Some(dest.display().to_string()), + title: None, + cwd: None, + engine: None, + cwd_exists: false, + error: None, + }, + Err(e) => Outcome::failed("skill", &pick.source, &pick.path, e), + }); + } + } + out +} + +// --- commands ---------------------------------------------------------------- + +/// Everything the other agents on this machine have that JuCode can take +/// over: conversations (all folders), skills and MCP servers. +#[tauri::command(async)] +pub fn import_scan() -> Scan { + scan(&Roots::real()) +} + +/// Imports the picked conversations and skills (MCP servers go through the +/// daemon, see the module docs); one outcome per item, in order. +#[tauri::command(async)] +pub fn import_apply(selection: Selection) -> Vec { + apply(&Roots::real(), selection) +} + +#[cfg(test)] +pub(crate) mod testutil { + use super::Roots; + use std::fs; + use std::path::{Path, PathBuf}; + + /// A fresh fake home for one test. + pub fn roots(name: &str) -> Roots { + let home = std::env::temp_dir().join(format!("jucode-agent-import-{}-{name}", std::process::id())); + let _ = fs::remove_dir_all(&home); + fs::create_dir_all(&home).unwrap(); + Roots { + jucode: home.join(".jucode"), + claude: home.join(".claude"), + codex: home.join(".codex"), + data_home: home.join(".local/share"), + config_home: home.join(".config"), + home, + } + } + + pub fn write(path: &Path, text: &str) -> PathBuf { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, text).unwrap(); + path.to_path_buf() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Scans this machine's real agents (read-only) and converts a few of the + /// opencode / zcode / omp conversations into a temporary JuCode home. + /// `cargo test --lib real_machine -- --ignored --nocapture` + #[test] + #[ignore] + fn real_machine_scan_and_convert() { + let real = Roots::real(); + let started = std::time::Instant::now(); + let found = scan(&real); + println!("scan took {:?}", started.elapsed()); + // For a look at the page with this machine's data (tests/browser). + if let Some(path) = std::env::var_os("IMPORT_SCAN_JSON") { + std::fs::write(path, serde_json::to_string(&found).unwrap()).unwrap(); + } + let count = |s: &str| found.sessions.iter().filter(|x| x.source == s).count(); + for source in ["claude", "codex", "opencode", "zcode", "omp"] { + println!("{source}: {} conversations", count(source)); + } + for s in found.sessions.iter().take(3) { + println!(" e.g. {} {} {:?} {} prompts", s.source, s.id, s.title, s.messages); + } + println!("skills: {}", found.skills.len()); + for s in &found.skills { + println!(" {} {} {} present={}", s.source, s.plugin.as_deref().unwrap_or("-"), s.name, s.present); + } + println!("mcp: {}", found.mcp.len()); + for m in &found.mcp { + println!(" {} {} {} present={} {}", m.source, m.from, m.name, m.present, m.entry); + } + // Converted copies go to a temporary JuCode home, never ~/.jucode. + let tmp = testutil::roots("real"); + let into = Roots { jucode: tmp.jucode.clone(), ..Roots::real() }; + for source in ["opencode", "zcode", "omp"] { + for s in found.sessions.iter().filter(|x| x.source == source).take(3) { + let pick = SessionPick { source: s.source.clone(), id: s.id.clone(), cwd: s.cwd.clone() }; + let done = sessions::import(&into, &pick).expect("converts"); + let dir = sessions::jucode_session_dir(&into.jucode, &done.cwd); + let journal = std::fs::read_to_string(dir.join(format!("{}.jsonl", done.id))).unwrap(); + println!("{source} {} → {} ({} journal lines) in {}", s.id, done.id, journal.lines().count(), dir.display()); + } + } + } +} diff --git a/src-tauri/src/agent_import/sessions.rs b/src-tauri/src/agent_import/sessions.rs new file mode 100644 index 0000000..c99bdee --- /dev/null +++ b/src-tauri/src/agent_import/sessions.rs @@ -0,0 +1,1226 @@ +//! Conversations of the other agents, across every folder. +//! +//! Claude Code and Codex: listed from their session files and imported with +//! native_import (a cleaned copy resumed on that engine). +//! +//! opencode, zcode and omp: converted into a JuCode session, the journal and +//! summary `jucode` itself writes (JuCode-CLI agent-core session.rs): +//! `~/.jucode/sessions//.jsonl` and `.json`. +//! What a model needs to continue comes along: the prompts, the assistant's +//! text, and each tool call with its result (a `function_call` item and a +//! `tool_output` entry). Reasoning, step markers and compactions are dropped: +//! reasoning only replays on the account that wrote it, and a compaction's +//! summary stands for history the copy has in full. +//! +//! opencode and zcode (an opencode fork) keep conversations in SQLite — +//! `session`, `message` (one row per message, JSON `data`) and `part` (its +//! text, tool calls, reasoning …) — read here read-only. omp (oh-my-pi) keeps +//! a JSONL tree per conversation under `~/.omp/agent/sessions//`; the +//! active branch runs from the last entry back through `parentId`. + +use super::{Roots, SessionPick}; +use crate::claude_history::{is_session_id, is_synthetic, preview_of, row_text, truncate_chars}; +use crate::native_import::{ + claude_copy_path, codex_first_prompt, codex_names, codex_threads, existing_copy, import_claude, + import_codex, is_conversation, load_registry, mtime_ms, now_ms, save_registry, ImportRecord, + JUCODE_ORIGINATOR, MAX_TITLE_CHARS, +}; +use rusqlite::{params, Connection, OpenFlags, OptionalExtension}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// A conversation offered for import. +#[derive(Serialize, Debug, Clone)] +pub struct Found { + /// "claude", "codex", "opencode", "zcode" or "omp". + pub source: String, + pub id: String, + pub title: String, + /// The folder it ran in. + pub cwd: String, + pub updated_at_ms: u64, + /// Prompts the user sent. + pub messages: usize, + /// Imported before: importing again opens that copy. + pub imported: bool, +} + +pub fn scan(roots: &Roots) -> Vec { + let registry = load_registry(&roots.registry()); + let mut out = scan_claude(roots, ®istry); + out.extend(scan_codex(roots, ®istry)); + out.extend(scan_db(roots, Db::Opencode, ®istry)); + out.extend(scan_db(roots, Db::Zcode, ®istry)); + out.extend(scan_omp(roots, ®istry)); + out.sort_by_key(|s| std::cmp::Reverse(s.updated_at_ms)); + out +} + +pub struct Imported { + pub id: String, + pub title: String, + pub cwd: String, + pub engine: &'static str, + /// An earlier copy was reused. + pub reused: bool, +} + +pub fn import(roots: &Roots, pick: &SessionPick) -> Result { + match pick.source.as_str() { + "claude" | "codex" => { + let h = roots.native(); + // An import that writes a copy records it; one that finds a copy does not. + let before = load_registry(&h.registry).len(); + let (engine, done) = if pick.source == "claude" { + ("claude", import_claude(&h, &pick.cwd, &pick.id)?) + } else { + ("codex", import_codex(&h, &pick.cwd, &pick.id)?) + }; + Ok(Imported { + id: done.id, + title: done.title, + cwd: folder_key(&pick.cwd), + engine, + reused: load_registry(&h.registry).len() == before, + }) + } + "opencode" => import_converted(roots, "opencode", &pick.id, load_db(roots, Db::Opencode, &pick.id)?), + "zcode" => import_converted(roots, "zcode", &pick.id, load_db(roots, Db::Zcode, &pick.id)?), + "omp" => import_converted(roots, "omp", &pick.id, load_omp(roots, &pick.id)?), + other => Err(format!("unknown source: {other}")), + } +} + +fn copies_of<'a>(registry: &'a [ImportRecord], source: &str) -> HashSet<&'a str> { + registry.iter().filter(|r| r.source == source).map(|r| r.to.as_str()).collect() +} + +/// Calls `each` with every line of a file that contains one of `needles`, +/// parsed. Lines are searched before they are parsed: session files reach +/// 100 MB and most lines are not wanted. +fn matching_rows(path: &Path, needles: &[&str], mut each: impl FnMut(Value)) { + let Ok(file) = fs::File::open(path) else { return }; + let mut reader = BufReader::new(file); + let mut line = Vec::new(); + loop { + line.clear(); + match reader.read_until(b'\n', &mut line) { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + let Ok(text) = std::str::from_utf8(&line) else { continue }; + if !needles.iter().any(|n| text.contains(n)) { + continue; + } + if let Ok(v) = serde_json::from_str::(text) { + each(v); + } + } +} + +// --- Claude Code ------------------------------------------------------------- + +fn scan_claude(roots: &Roots, registry: &[ImportRecord]) -> Vec { + let h = roots.native(); + let copies = copies_of(registry, "claude"); + let mut out = Vec::new(); + let Ok(dirs) = fs::read_dir(h.claude.join("projects")) else { return out }; + for dir in dirs.flatten().map(|e| e.path()).filter(|p| p.is_dir()) { + let Ok(files) = fs::read_dir(&dir) else { continue }; + for path in files.flatten().map(|e| e.path()) { + if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { + continue; + } + let Some(id) = path.file_stem().and_then(|s| s.to_str()).filter(|s| is_session_id(s)) else { continue }; + if copies.contains(id) { + continue; + } + // The folder comes from the rows (the directory name is lossy). + let mut cwd: Option = None; + let mut prompts = 0; + matching_rows(&path, &["\"type\":\"user\"", "\"cwd\":"], |v| { + if cwd.is_none() { + cwd = v["cwd"].as_str().map(str::to_string); + } + let tool_result = v.pointer("/message/content").and_then(Value::as_array).is_some_and(|c| { + c.iter().any(|b| b["type"].as_str() == Some("tool_result")) + }); + if v["type"].as_str() == Some("user") && !tool_result && !is_synthetic(&v) && row_text(&v).is_some() { + prompts += 1; + } + }); + let Some(cwd) = cwd.filter(|_| prompts > 0) else { continue }; + // native_import finds the file again by folder; one it cannot find + // (a folder renamed since) is not offered. + if claude_copy_path(&h, &cwd, id) != path { + continue; + } + out.push(Found { + source: "claude".into(), + id: id.into(), + title: preview_of(&path), + imported: existing_copy(registry, "claude", id, |to| claude_copy_path(&h, &cwd, to).exists()).is_some(), + cwd, + updated_at_ms: mtime_ms(&path), + messages: prompts, + }); + } + } + out +} + +// --- Codex ------------------------------------------------------------------- + +fn codex_prompts(path: &Path) -> usize { + let mut n = 0; + matching_rows(path, &["\"user_message\""], |v| { + let p = &v["payload"]; + let text = p["message"].as_str().unwrap_or("").trim(); + if v["type"].as_str() == Some("event_msg") + && p["type"].as_str() == Some("user_message") + && !text.is_empty() + && !text.starts_with('<') + { + n += 1; + } + }); + n +} + +fn scan_codex(roots: &Roots, registry: &[ImportRecord]) -> Vec { + let threads = codex_threads(&roots.codex); + if threads.is_empty() { + return Vec::new(); + } + let copies = copies_of(registry, "codex"); + let names = codex_names(&roots.codex); + let mut out = Vec::new(); + for (id, segs) in &threads { + if copies.contains(id.as_str()) + || !is_conversation(segs) + || segs[0].meta["originator"].as_str() == Some(JUCODE_ORIGINATOR) + { + continue; + } + let Some(cwd) = segs[0].meta["cwd"].as_str() else { continue }; + let live: Vec<_> = segs.iter().filter(|s| !s.archived).collect(); + // A fork's own files: its prompts since the fork. + let prompts = live.iter().map(|s| codex_prompts(&s.path)).sum(); + let title = names + .get(id) + .map(|n| truncate_chars(n, MAX_TITLE_CHARS)) + .unwrap_or_else(|| codex_first_prompt(&segs[0].path)); + if prompts == 0 && title.is_empty() { + continue; + } + out.push(Found { + source: "codex".into(), + id: id.clone(), + title, + cwd: cwd.to_string(), + updated_at_ms: live.iter().map(|s| mtime_ms(&s.path)).max().unwrap_or(0), + messages: prompts, + imported: existing_copy(registry, "codex", id, |to| threads.contains_key(to)).is_some(), + }); + } + out +} + +// --- converted conversations ------------------------------------------------- + +/// One step of a conversation, as a JuCode session entry records it. +#[derive(Debug, Clone, PartialEq)] +pub(super) enum Turn { + User(String), + Assistant(String), + Call { call_id: String, name: String, arguments: String }, + Output { call_id: String, name: String, output: String }, +} + +impl Turn { + /// The entry `kind` (JuCode-CLI session.rs `entry_to_json`). + fn kind(&self) -> Value { + match self { + Turn::User(text) => json!({ "type": "user", "content": text }), + Turn::Assistant(text) => json!({ + "type": "response_item", + "item": { "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": text }] } + }), + Turn::Call { call_id, name, arguments } => json!({ + "type": "response_item", + "item": { "type": "function_call", "call_id": call_id, "name": name, "arguments": arguments } + }), + Turn::Output { call_id, name, output } => json!({ + "type": "tool_output", "call_id": call_id, "name": name, "output": output, "image": null + }), + } + } +} + +/// A conversation read from another agent, ready to write. +pub(super) struct Conversation { + /// Its title there; empty when it had none. + pub title: String, + pub cwd: String, + pub updated_s: u64, + /// Each with the second it happened (0: unknown). + pub turns: Vec<(u64, Turn)>, +} + +/// Longest tool output kept; the model saw the rest once, and a resumed +/// session would carry it in every request. +const MAX_OUTPUT_CHARS: usize = 32_000; +/// The session name the CLI derives from the first prompt (session.rs +/// `SESSION_LABEL_MAX_CHARS`). +const LABEL_CHARS: usize = 72; + +/// A tool-call id the Responses API accepts. +fn clean_call_id(raw: &str) -> String { + let id: String = raw.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-')).take(64).collect(); + if id.is_empty() { "call".to_string() } else { id } +} + +/// A function name the APIs accept (`^[A-Za-z0-9_-]{1,64}$`). +fn clean_tool_name(raw: &str) -> String { + let name: String = raw + .chars() + .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '_' | '-') { c } else { '_' }) + .take(64) + .collect(); + if name.is_empty() { "tool".to_string() } else { name } +} + +fn clip(text: String) -> String { + match text.char_indices().nth(MAX_OUTPUT_CHARS) { + Some((cut, _)) => format!("{}\n[truncated on import]", &text[..cut]), + None => text, + } +} + +/// Makes the turns a valid request history: ids cleaned and unique, every +/// call followed by its output (a call without one, or an output without its +/// call, is dropped — the API rejects either), empty text gone and an +/// assistant's consecutive texts joined. +pub(super) fn tidy(turns: Vec<(u64, Turn)>) -> Vec<(u64, Turn)> { + let mut out: Vec<(u64, Turn)> = Vec::with_capacity(turns.len()); + let mut used: HashSet = HashSet::new(); + // A call's id as written there → the id it gets here, until answered. + let mut open: HashMap = HashMap::new(); + let mut answered: HashSet = HashSet::new(); + for (at, turn) in turns { + match turn { + Turn::User(text) => { + let text = text.trim(); + if !text.is_empty() { + out.push((at, Turn::User(text.to_string()))); + } + } + Turn::Assistant(text) => { + let text = text.trim(); + if text.is_empty() { + continue; + } + if let Some((_, Turn::Assistant(prev))) = out.last_mut() { + prev.push_str("\n\n"); + prev.push_str(text); + } else { + out.push((at, Turn::Assistant(text.to_string()))); + } + } + Turn::Call { call_id, name, arguments } => { + let base = clean_call_id(&call_id); + let mut id = base.clone(); + let mut n = 2; + while !used.insert(id.clone()) { + id = format!("{base}_{n}"); + n += 1; + } + open.insert(call_id, id.clone()); + out.push((at, Turn::Call { call_id: id, name: clean_tool_name(&name), arguments })); + } + Turn::Output { call_id, name, output } => { + let Some(id) = open.remove(&call_id) else { continue }; + answered.insert(id.clone()); + out.push((at, Turn::Output { call_id: id, name: clean_tool_name(&name), output: clip(output) })); + } + } + } + out.retain(|(_, t)| !matches!(t, Turn::Call { call_id, .. } if !answered.contains(call_id))); + out +} + +fn first_prompt(turns: &[(u64, Turn)]) -> String { + turns + .iter() + .find_map(|(_, t)| match t { + Turn::User(text) => text.lines().map(str::trim).find(|l| !l.is_empty()), + _ => None, + }) + .map(|l| truncate_chars(l, MAX_TITLE_CHARS)) + .unwrap_or_default() +} + +/// The folder as the desktop and the daemon name it: its real path while it +/// exists (projects are added canonicalized), else as recorded. +pub(super) fn folder_key(cwd: &str) -> String { + let trimmed = if cwd.len() > 1 { cwd.trim_end_matches(['/', '\\']) } else { cwd }; + match fs::canonicalize(trimmed) { + Ok(real) => { + let real = real.to_string_lossy().into_owned(); + // Windows canonicalizes to `\\?\C:\...`; sessions are keyed by `C:\...`. + match real.strip_prefix(r"\\?\") { + Some(plain) if !plain.starts_with("UNC\\") => plain.to_string(), + _ => real, + } + } + Err(_) => trimmed.to_string(), + } +} + +/// `sessions/` (session.rs `hash_path`). +pub(super) fn jucode_session_dir(jucode: &Path, cwd: &str) -> PathBuf { + let mut hash = 0xcbf29ce484222325u64; + for byte in cwd.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + jucode.join("sessions").join(format!("{hash:016x}")) +} + +/// session.rs `single_line_with_limit`: what the CLI shows as a name. +fn label_of(text: &str) -> String { + let compact = text.split_whitespace().collect::>().join(" "); + let mut chars = compact.chars(); + let prefix: String = chars.by_ref().take(LABEL_CHARS).collect(); + if chars.next().is_some() { format!("{prefix}...") } else { prefix } +} + +fn write_file(path: &Path, text: &str) -> Result<(), String> { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("session"); + let tmp = path.with_file_name(format!(".{name}.import.tmp")); + fs::write(&tmp, text).map_err(|e| format!("cannot write {}: {e}", tmp.display()))?; + fs::rename(&tmp, path).map_err(|e| format!("cannot write {}: {e}", path.display())) +} + +/// Writes the turns as a new JuCode session in `dir`, the way a first save +/// writes one (entries, then the state); returns its id. The summary goes +/// last: a listing never sees a session without its journal. +pub(super) fn write_session(dir: &Path, title: &str, updated_s: u64, turns: &[(u64, Turn)]) -> Result { + fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default(); + // `s{secs:x}{nanos:08x}` like the CLI's; bumped past any taken one. + let mut nanos = now.subsec_nanos(); + let sid = loop { + let sid = format!("s{:x}{nanos:08x}", now.as_secs()); + if !dir.join(format!("{sid}.json")).exists() && !dir.join(format!("{sid}.jsonl")).exists() { + break sid; + } + nanos = nanos.wrapping_add(1); + }; + let created = turns.iter().map(|(at, _)| *at).find(|at| *at > 0).unwrap_or(updated_s); + let updated = updated_s.max(created); + let mut journal = String::new(); + for (i, (at, turn)) in turns.iter().enumerate() { + let id = i as u64 + 1; + let entry = json!({ + "id": id, + "parent_id": (id > 1).then(|| id - 1), + "created_at": if *at > 0 { *at } else { created }, + "kind": turn.kind(), + }); + journal.push_str(&json!({ "type": "entry", "entry": entry }).to_string()); + journal.push('\n'); + } + let leaf = turns.len() as u64; + let state = json!({ + "version": 1, + "id": sid, + "created_at": created, + "updated_at": updated, + "leaf_id": leaf, + "next_id": leaf + 1, + "branch_heads": { "root": leaf }, + "resume_summary": null, + "resume_status": null, + "resume_summary_updated_at": null, + "goal": null, + }); + journal.push_str(&json!({ "type": "state", "state": state }).to_string()); + journal.push('\n'); + let summary = json!({ + "version": 3, + "id": sid, + "created_at": created, + "updated_at": updated, + "leaf_id": leaf, + "next_id": leaf + 1, + "branch_heads": { "root": leaf }, + "entries_count": turns.len(), + // The daemon lists a session by this; its title there reads better + // than the first prompt the CLI would derive. + "label": label_of(title), + "resume_summary": null, + "resume_status": null, + "resume_summary_updated_at": null, + "goal": null, + "journal": format!("{sid}.jsonl"), + }); + write_file(&dir.join(format!("{sid}.jsonl")), &journal)?; + let text = serde_json::to_string_pretty(&summary).map_err(|e| e.to_string())? + "\n"; + write_file(&dir.join(format!("{sid}.json")), &text)?; + Ok(sid) +} + +fn import_converted(roots: &Roots, source: &str, id: &str, conv: Conversation) -> Result { + let cwd = folder_key(&conv.cwd); + let dir = jucode_session_dir(&roots.jucode, &cwd); + let mut registry = load_registry(&roots.registry()); + let title = if conv.title.is_empty() { first_prompt(&conv.turns) } else { conv.title }; + if let Some(to) = existing_copy(®istry, source, id, |to| dir.join(format!("{to}.json")).exists()) { + return Ok(Imported { id: to, title, cwd, engine: "jucode", reused: true }); + } + let turns = tidy(conv.turns); + if !turns.iter().any(|(_, t)| matches!(t, Turn::User(_))) { + return Err("this conversation has no messages to import".to_string()); + } + let sid = write_session(&dir, &title, conv.updated_s, &turns)?; + registry.push(ImportRecord { source: source.into(), from: id.into(), to: sid.clone(), at_ms: now_ms() }); + save_registry(&roots.registry(), ®istry)?; + Ok(Imported { id: sid, title, cwd, engine: "jucode", reused: false }) +} + +fn converted_imported(roots: &Roots, registry: &[ImportRecord], source: &str, id: &str, cwd: &str) -> bool { + let dir = jucode_session_dir(&roots.jucode, &folder_key(cwd)); + existing_copy(registry, source, id, |to| dir.join(format!("{to}.json")).exists()).is_some() +} + +// --- opencode / zcode -------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Debug)] +enum Db { + Opencode, + Zcode, +} + +impl Db { + fn source(self) -> &'static str { + match self { + Db::Opencode => "opencode", + Db::Zcode => "zcode", + } + } + + fn path(self, roots: &Roots) -> PathBuf { + match self { + Db::Opencode => roots.opencode_db(), + Db::Zcode => roots.zcode_db(), + } + } +} + +fn open_db(path: &Path) -> Option { + if !path.is_file() { + return None; + } + let conn = + Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX).ok()?; + // The app may be writing; wait for it rather than fail. + let _ = conn.busy_timeout(Duration::from_secs(3)); + Some(conn) +} + +fn columns(conn: &Connection, table: &str) -> HashSet { + let Ok(mut stmt) = conn.prepare(&format!("PRAGMA table_info({table})")) else { return HashSet::new() }; + stmt.query_map([], |row| row.get::<_, String>(1)) + .map(|rows| rows.flatten().collect()) + .unwrap_or_default() +} + +/// The app's own title, when it is one: opencode names an untitled session +/// "New session - ", zcode marks a placeholder with `title_source`. +fn real_title(title: &str, title_source: Option<&str>) -> String { + let t = title.trim(); + if t.is_empty() + || title_source == Some("default") + || t.starts_with("New session - ") + || t.starts_with("Child session - ") + { + return String::new(); + } + truncate_chars(t, MAX_TITLE_CHARS) +} + +/// Text the user wrote: not the app's own additions (`synthetic`) or parts +/// it left out (`ignored`). +fn written(part: &Value) -> Option<&str> { + (part["type"].as_str() == Some("text") && part["synthetic"] != json!(true) && part["ignored"] != json!(true)) + .then(|| part["text"].as_str()) + .flatten() + .filter(|t| !t.trim().is_empty()) +} + +struct DbSession { + id: String, + cwd: String, + title: String, + updated_ms: u64, + prompts: usize, +} + +fn db_sessions(conn: &Connection) -> rusqlite::Result> { + let cols = columns(conn, "session"); + // Sub-agent sessions resume only through their parent. + let mut filter = "s.parent_id IS NULL".to_string(); + if cols.contains("task_type") { + filter.push_str(" AND s.task_type = 'interactive'"); + } + let title_source = if cols.contains("title_source") { "s.title_source" } else { "NULL" }; + let sql = format!( + "SELECT s.id, s.directory, s.title, {title_source}, s.time_updated, + (SELECT COUNT(*) FROM message m + WHERE m.session_id = s.id AND json_extract(m.data, '$.role') = 'user' + AND NOT EXISTS (SELECT 1 FROM part p WHERE p.message_id = m.id + AND json_extract(p.data, '$.type') = 'compaction')) + FROM session s WHERE {filter}" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([], |row| { + let title: String = row.get(2)?; + let title_source: Option = row.get(3)?; + Ok(DbSession { + id: row.get(0)?, + cwd: row.get(1)?, + title: real_title(&title, title_source.as_deref()), + updated_ms: row.get::<_, i64>(4)?.max(0) as u64, + prompts: row.get::<_, i64>(5)?.max(0) as usize, + }) + })?; + rows.collect() +} + +/// The first thing the user wrote in a session (a title for an untitled one). +fn db_first_prompt(conn: &Connection, id: &str) -> String { + let sql = "SELECT p.data FROM part p JOIN message m ON m.id = p.message_id + WHERE m.session_id = ?1 AND json_extract(m.data, '$.role') = 'user' + AND json_extract(p.data, '$.type') = 'text' + ORDER BY m.time_created, m.id, p.id LIMIT 20"; + let Ok(mut stmt) = conn.prepare(sql) else { return String::new() }; + let Ok(rows) = stmt.query_map(params![id], |row| row.get::<_, String>(0)) else { return String::new() }; + for data in rows.flatten() { + let Ok(part) = serde_json::from_str::(&data) else { continue }; + if let Some(line) = written(&part).and_then(|t| t.lines().map(str::trim).find(|l| !l.is_empty())) { + return truncate_chars(line, MAX_TITLE_CHARS); + } + } + String::new() +} + +fn scan_db(roots: &Roots, db: Db, registry: &[ImportRecord]) -> Vec { + let Some(conn) = open_db(&db.path(roots)) else { return Vec::new() }; + let Ok(list) = db_sessions(&conn) else { return Vec::new() }; + list.into_iter() + .filter(|s| s.prompts > 0) + .map(|s| Found { + source: db.source().into(), + title: if s.title.is_empty() { db_first_prompt(&conn, &s.id) } else { s.title }, + imported: converted_imported(roots, registry, db.source(), &s.id, &s.cwd), + id: s.id, + cwd: s.cwd, + updated_at_ms: s.updated_ms, + messages: s.prompts, + }) + .collect() +} + +fn text_of(v: &Value) -> String { + match v { + Value::Null => String::new(), + Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +/// How an attached file is mentioned in the prompt it came with. +fn file_label(part: &Value) -> String { + let local = part["source"]["path"] + .as_str() + .or_else(|| part["url"].as_str().filter(|u| u.starts_with('/'))) + .or_else(|| part["url"].as_str().and_then(|u| u.strip_prefix("file://"))); + let name = local.or_else(|| part["filename"].as_str()).unwrap_or("attachment"); + format!("[file: {name}]") +} + +/// A tool part's call and its result. +fn db_tool(part: &Value, at: u64) -> [(u64, Turn); 2] { + let call_id = part["callID"].as_str().unwrap_or("").to_string(); + let name = part["tool"].as_str().unwrap_or("tool").to_string(); + let state = &part["state"]; + let arguments = match &state["input"] { + Value::Null => "{}".to_string(), + input => input.to_string(), + }; + let output = match state["status"].as_str() { + Some("completed") => text_of(&state["output"]), + Some("error") => format!("Error: {}", text_of(&state["error"])), + _ => "[the tool did not finish]".to_string(), + }; + [(at, Turn::Call { call_id: call_id.clone(), name: name.clone(), arguments }), (at, Turn::Output { call_id, name, output })] +} + +/// Messages (in order) and their parts as turns. +pub(super) fn convert_db(messages: &[(String, Value)], parts: &HashMap>) -> Vec<(u64, Turn)> { + let none = Vec::new(); + let mut turns = Vec::new(); + for (id, data) in messages { + let parts = parts.get(id).unwrap_or(&none); + // A compaction: its marker, and the summary standing for the history + // before it (opencode: an assistant message with `summary: true`; + // zcode: a user message carrying a compaction part). + if parts.iter().any(|p| p["type"].as_str() == Some("compaction")) || data["summary"] == json!(true) { + continue; + } + let at = data["time"]["created"].as_u64().unwrap_or(0) / 1000; + match data["role"].as_str() { + Some("user") => { + let mut text = Vec::new(); + for p in parts { + if let Some(t) = written(p) { + text.push(t.to_string()); + } else if p["type"].as_str() == Some("file") { + text.push(file_label(p)); + } + } + turns.push((at, Turn::User(text.join("\n\n")))); + } + Some("assistant") => { + for p in parts { + match p["type"].as_str() { + Some("text") => { + if let Some(t) = written(p) { + turns.push((at, Turn::Assistant(t.to_string()))); + } + } + Some("tool") => turns.extend(db_tool(p, at)), + // reasoning, step-start/-finish, patch, snapshot, timeline … + _ => {} + } + } + } + _ => {} + } + } + turns +} + +fn load_db(roots: &Roots, db: Db, id: &str) -> Result { + let conn = open_db(&db.path(roots)).ok_or_else(|| format!("{} has no conversations here", db.source()))?; + let err = |e: rusqlite::Error| format!("cannot read {}: {e}", db.path(roots).display()); + let title_source = if columns(&conn, "session").contains("title_source") { "title_source" } else { "NULL" }; + let head: Option<(String, String, Option, i64)> = conn + .query_row( + &format!("SELECT directory, title, {title_source}, time_updated FROM session WHERE id = ?1"), + params![id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional() + .map_err(err)?; + let (cwd, title, title_source, updated_ms) = + head.ok_or_else(|| format!("{} conversation {id} not found", db.source()))?; + // zcode numbers messages and parts; opencode's ids sort by time. + let order = |table: &str, by: &str| { + if columns(&conn, table).contains("sequence") { format!("{by}sequence, time_created, id") } else { format!("{by}time_created, id") } + }; + let mut messages = Vec::new(); + { + let mut stmt = conn + .prepare(&format!("SELECT id, data FROM message WHERE session_id = ?1 ORDER BY {}", order("message", ""))) + .map_err(err)?; + let rows = stmt.query_map(params![id], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))).map_err(err)?; + for row in rows { + let (mid, data) = row.map_err(err)?; + messages.push((mid, serde_json::from_str::(&data).unwrap_or(Value::Null))); + } + } + let mut parts: HashMap> = HashMap::new(); + { + let part_order = if columns(&conn, "part").contains("sequence") { "message_id, sequence, id" } else { "message_id, id" }; + let mut stmt = conn + .prepare(&format!("SELECT message_id, data FROM part WHERE session_id = ?1 ORDER BY {part_order}")) + .map_err(err)?; + let rows = stmt.query_map(params![id], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))).map_err(err)?; + for row in rows { + let (mid, data) = row.map_err(err)?; + if let Ok(v) = serde_json::from_str::(&data) { + parts.entry(mid).or_default().push(v); + } + } + } + Ok(Conversation { + title: real_title(&title, title_source.as_deref()), + cwd, + updated_s: updated_ms.max(0) as u64 / 1000, + turns: convert_db(&messages, &parts), + }) +} + +// --- omp --------------------------------------------------------------------- + +struct OmpFile { + id: String, + cwd: String, + title: String, + entries: Vec, +} + +fn omp_files(roots: &Roots) -> Vec { + let mut out = Vec::new(); + let Ok(dirs) = fs::read_dir(roots.omp_agent().join("sessions")) else { return out }; + for dir in dirs.flatten().map(|e| e.path()).filter(|p| p.is_dir()) { + let Ok(files) = fs::read_dir(&dir) else { continue }; + out.extend( + files + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("jsonl")), + ); + } + out +} + +/// A session file: an optional fixed-width `title` slot, the `session` +/// header, then the entries. +fn read_omp(path: &Path) -> Option { + let text = fs::read_to_string(path).ok()?; + let mut slot_title = String::new(); + let mut header: Option = None; + let mut entries = Vec::new(); + for line in text.lines() { + let Ok(v) = serde_json::from_str::(line) else { continue }; + match (&header, v["type"].as_str()) { + (None, Some("title")) => slot_title = v["title"].as_str().unwrap_or("").to_string(), + (None, Some("session")) => header = Some(v), + (Some(_), _) => entries.push(v), + _ => {} + } + } + let header = header?; + // A sub-agent's or a fork's session names the one it came from; the + // conversation itself is the parent's. + if header["parentSession"].as_str().is_some_and(|p| !p.is_empty()) { + return None; + } + let title = Some(slot_title.trim()) + .filter(|t| !t.is_empty()) + .or_else(|| header["title"].as_str().map(str::trim)) + .map(|t| truncate_chars(t, MAX_TITLE_CHARS)) + .unwrap_or_default(); + Some(OmpFile { id: header["id"].as_str()?.to_string(), cwd: header["cwd"].as_str()?.to_string(), title, entries }) +} + +/// The active branch: from the last entry back to the root (omp rebuilds +/// its leaf from the last physical entry). +fn omp_branch(entries: &[Value]) -> Vec<&Value> { + let by_id: HashMap<&str, &Value> = entries.iter().filter_map(|e| Some((e["id"].as_str()?, e))).collect(); + let mut path = Vec::new(); + let mut seen = HashSet::new(); + let mut current = entries.iter().rev().find_map(|e| e["id"].as_str()); + while let Some(id) = current { + if !seen.insert(id) { + break; + } + let Some(entry) = by_id.get(id) else { break }; + path.push(*entry); + current = entry["parentId"].as_str(); + } + path.reverse(); + path +} + +/// Text of a message's content (a string, or text and image blocks). +fn omp_text(content: &Value) -> String { + match content { + Value::String(s) => s.clone(), + Value::Array(blocks) => blocks + .iter() + .filter_map(|b| match b["type"].as_str() { + Some("text") => b["text"].as_str().map(str::to_string), + Some("image") => Some("[image]".to_string()), + _ => None, + }) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +pub(super) fn convert_omp(entries: &[Value]) -> Vec<(u64, Turn)> { + let mut turns = Vec::new(); + for entry in omp_branch(entries) { + // compaction, branch_summary, model_change, custom … carry no turns. + if entry["type"].as_str() != Some("message") { + continue; + } + let m = &entry["message"]; + let at = m["timestamp"].as_u64().unwrap_or(0) / 1000; + match m["role"].as_str() { + Some("user") if m["synthetic"] != json!(true) => turns.push((at, Turn::User(omp_text(&m["content"])))), + Some("assistant") => { + for block in m["content"].as_array().into_iter().flatten() { + match block["type"].as_str() { + Some("text") => { + turns.push((at, Turn::Assistant(block["text"].as_str().unwrap_or("").to_string()))) + } + Some("toolCall") => turns.push(( + at, + Turn::Call { + call_id: block["id"].as_str().unwrap_or("").to_string(), + name: block["name"].as_str().unwrap_or("tool").to_string(), + arguments: match &block["arguments"] { + Value::Null => "{}".to_string(), + Value::String(s) => s.clone(), + args => args.to_string(), + }, + }, + )), + // thinking, redactedThinking + _ => {} + } + } + } + Some("toolResult") => { + let text = omp_text(&m["content"]); + turns.push(( + at, + Turn::Output { + call_id: m["toolCallId"].as_str().unwrap_or("").to_string(), + name: m["toolName"].as_str().unwrap_or("tool").to_string(), + output: if m["isError"] == json!(true) { format!("Error: {text}") } else { text }, + }, + )) + } + // developer, bashExecution, fileMention … + _ => {} + } + } + turns +} + +fn scan_omp(roots: &Roots, registry: &[ImportRecord]) -> Vec { + let mut out = Vec::new(); + for path in omp_files(roots) { + let Some(file) = read_omp(&path) else { continue }; + let turns = convert_omp(&file.entries); + let prompts = turns.iter().filter(|(_, t)| matches!(t, Turn::User(s) if !s.trim().is_empty())).count(); + if prompts == 0 { + continue; + } + out.push(Found { + source: "omp".into(), + title: if file.title.is_empty() { first_prompt(&turns) } else { file.title }, + imported: converted_imported(roots, registry, "omp", &file.id, &file.cwd), + id: file.id, + cwd: file.cwd, + updated_at_ms: mtime_ms(&path), + messages: prompts, + }); + } + out +} + +fn load_omp(roots: &Roots, id: &str) -> Result { + for path in omp_files(roots) { + let Some(file) = read_omp(&path) else { continue }; + if file.id != id { + continue; + } + return Ok(Conversation { + title: file.title, + cwd: file.cwd, + updated_s: mtime_ms(&path) / 1000, + turns: convert_omp(&file.entries), + }); + } + Err(format!("omp conversation {id} not found")) +} + +#[cfg(test)] +mod tests { + use super::super::testutil; + use super::*; + + fn call(id: &str) -> Turn { + Turn::Call { call_id: id.into(), name: "bash".into(), arguments: "{}".into() } + } + fn output(id: &str) -> Turn { + Turn::Output { call_id: id.into(), name: "bash".into(), output: "ok".into() } + } + + #[test] + fn tidy_pairs_calls_and_cleans_ids() { + let turns = vec![ + (1, Turn::User(" hi ".into())), + (1, Turn::Assistant("one".into())), + (1, Turn::Assistant("two".into())), + (2, call("call:1/x")), + (2, output("call:1/x")), + (3, call("lost")), + (3, output("never-called")), + (4, call("dup")), + (4, output("dup")), + (4, call("dup")), + (4, output("dup")), + (5, Turn::Assistant(" ".into())), + ]; + let got = tidy(turns); + assert_eq!( + got.iter().map(|(_, t)| t.clone()).collect::>(), + vec![ + Turn::User("hi".into()), + Turn::Assistant("one\n\ntwo".into()), + call("call1x"), + output("call1x"), + call("dup"), + output("dup"), + call("dup_2"), + output("dup_2"), + ] + ); + let long = tidy(vec![(0, call("a")), (0, Turn::Output { call_id: "a".into(), name: "mcp.x y".into(), output: "x".repeat(MAX_OUTPUT_CHARS + 5) })]); + match &long[1].1 { + Turn::Output { name, output, .. } => { + assert_eq!(name, "mcp_x_y"); + assert!(output.ends_with("[truncated on import]")); + } + other => panic!("{other:?}"), + } + } + + fn opencode_db(path: &Path, zcode: bool) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let conn = Connection::open(path).unwrap(); + let extra = if zcode { ", task_type text not null default 'interactive', title_source text" } else { "" }; + let seq = if zcode { ", sequence integer" } else { "" }; + conn.execute_batch(&format!( + "CREATE TABLE session (id text primary key, project_id text, parent_id text, directory text not null, + title text not null, time_created integer not null, time_updated integer not null{extra}); + CREATE TABLE message (id text primary key, session_id text not null, time_created integer not null, + time_updated integer not null, data text not null{seq}); + CREATE TABLE part (id text primary key, message_id text not null, session_id text not null, + time_created integer not null, time_updated integer not null, data text not null{seq});" + )) + .unwrap(); + let session = |id: &str, parent: Option<&str>, title: &str| { + conn.execute( + "INSERT INTO session (id, project_id, parent_id, directory, title, time_created, time_updated) VALUES (?1, 'p', ?2, '/proj/oc', ?3, 1000, 1790000000000)", + params![id, parent, title], + ) + .unwrap(); + }; + session("ses_main", None, "New session - 2026-08-18T10:34:46.692Z"); + session("ses_child", Some("ses_main"), "child"); + let message = |id: &str, session: &str, at: i64, data: Value| { + conn.execute( + "INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?1, ?2, ?3, ?3, ?4)", + params![id, session, at, data.to_string()], + ) + .unwrap(); + }; + let part = |id: &str, msg: &str, data: Value| { + conn.execute( + "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?1, ?2, 'ses_main', 0, 0, ?3)", + params![id, msg, data.to_string()], + ) + .unwrap(); + }; + message("msg_1", "ses_main", 1_790_000_000_000, json!({"role": "user", "time": {"created": 1_790_000_000_000u64}})); + part("prt_1", "msg_1", json!({"type": "text", "text": "fix the bug"})); + part("prt_2", "msg_1", json!({"type": "text", "text": "Called the Read tool", "synthetic": true})); + part("prt_3", "msg_1", json!({"type": "file", "filename": "a.png", "url": "data:image/png;base64,xx"})); + message("msg_2", "ses_main", 1_790_000_001_000, json!({"role": "assistant", "time": {"created": 1_790_000_001_000u64}})); + part("prt_4", "msg_2", json!({"type": "step-start"})); + part("prt_5", "msg_2", json!({"type": "reasoning", "text": "hmm"})); + part("prt_6", "msg_2", json!({"type": "tool", "callID": "call-1", "tool": "bash", "state": {"status": "completed", "input": {"command": "ls"}, "output": "a.txt"}})); + part("prt_7", "msg_2", json!({"type": "tool", "callID": "call-2", "tool": "read", "state": {"status": "error", "input": {"filePath": "x"}, "error": "no such file"}})); + part("prt_8", "msg_2", json!({"type": "step-finish"})); + message("msg_3", "ses_main", 1_790_000_002_000, json!({"role": "assistant", "time": {"created": 1_790_000_002_000u64}})); + part("prt_9", "msg_3", json!({"type": "text", "text": "Done."})); + // A compaction: the trigger and its summary are left out. + message("msg_4", "ses_main", 1_790_000_003_000, json!({"role": "user", "time": {"created": 1_790_000_003_000u64}})); + part("prt_10", "msg_4", json!({"type": "compaction", "auto": true})); + message("msg_5", "ses_main", 1_790_000_004_000, json!({"role": "assistant", "summary": true, "time": {"created": 1_790_000_004_000u64}})); + part("prt_11", "msg_5", json!({"type": "text", "text": "Summary of everything"})); + message("msg_6", "ses_main", 1_790_000_005_000, json!({"role": "user", "time": {"created": 1_790_000_005_000u64}})); + part("prt_12", "msg_6", json!({"type": "text", "text": "thanks"})); + message("msg_c", "ses_child", 1_790_000_000_000, json!({"role": "user"})); + } + + #[test] + fn opencode_converts_and_reimport_reuses() { + let roots = testutil::roots("opencode"); + opencode_db(&roots.opencode_db(), false); + let found = scan(&roots); + assert_eq!(found.len(), 1, "the sub-agent session is not offered"); + let s = &found[0]; + assert_eq!((s.source.as_str(), s.id.as_str(), s.messages), ("opencode", "ses_main", 2)); + assert_eq!(s.title, "fix the bug", "the placeholder title gives way to the first prompt"); + assert!(!s.imported); + + let pick = SessionPick { source: "opencode".into(), id: "ses_main".into(), cwd: String::new() }; + let done = import(&roots, &pick).unwrap(); + assert!(!done.reused && done.engine == "jucode" && done.cwd == "/proj/oc"); + let dir = jucode_session_dir(&roots.jucode, "/proj/oc"); + let lines: Vec = fs::read_to_string(dir.join(format!("{}.jsonl", done.id))) + .unwrap() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect(); + let kinds: Vec = lines.iter().filter(|l| l["type"] == "entry").map(|l| l["entry"]["kind"].clone()).collect(); + assert_eq!( + kinds, + vec![ + json!({"type": "user", "content": "fix the bug\n\n[file: a.png]"}), + json!({"type": "response_item", "item": {"type": "function_call", "call_id": "call-1", "name": "bash", "arguments": "{\"command\":\"ls\"}"}}), + json!({"type": "tool_output", "call_id": "call-1", "name": "bash", "output": "a.txt", "image": null}), + json!({"type": "response_item", "item": {"type": "function_call", "call_id": "call-2", "name": "read", "arguments": "{\"filePath\":\"x\"}"}}), + json!({"type": "tool_output", "call_id": "call-2", "name": "read", "output": "Error: no such file", "image": null}), + json!({"type": "response_item", "item": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Done."}]}}), + json!({"type": "user", "content": "thanks"}), + ] + ); + // Entries chain one after another; the state closes the journal. + let entries: Vec<&Value> = lines.iter().filter(|l| l["type"] == "entry").map(|l| &l["entry"]).collect(); + assert_eq!(entries[0]["parent_id"], Value::Null); + assert!(entries.windows(2).all(|w| w[1]["parent_id"] == w[0]["id"])); + assert_eq!(entries[0]["created_at"], 1_790_000_000u64); + let state = &lines.last().unwrap()["state"]; + assert_eq!(lines.last().unwrap()["type"], "state"); + assert_eq!((state["leaf_id"].as_u64(), state["next_id"].as_u64()), (Some(7), Some(8))); + assert_eq!(state["branch_heads"], json!({"root": 7})); + let summary: Value = serde_json::from_str(&fs::read_to_string(dir.join(format!("{}.json", done.id))).unwrap()).unwrap(); + assert_eq!(summary["version"], 3); + assert_eq!(summary["entries_count"], 7); + assert_eq!(summary["label"], "fix the bug"); + assert_eq!(summary["journal"], format!("{}.jsonl", done.id)); + assert_eq!(summary["updated_at"], 1_790_000_000u64); + assert!(done.id.starts_with('s') && done.id.len() > 9); + + let again = import(&roots, &pick).unwrap(); + assert!(again.reused); + assert_eq!(again.id, done.id); + assert!(scan(&roots)[0].imported); + assert!(import(&roots, &SessionPick { source: "opencode".into(), id: "nope".into(), cwd: String::new() }).is_err()); + } + + #[test] + fn zcode_keeps_interactive_sessions_in_sequence() { + let roots = testutil::roots("zcode"); + let path = roots.zcode_db(); + opencode_db(&path, true); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "UPDATE session SET title = 'Named', title_source = 'generated' WHERE id = 'ses_main'; + INSERT INTO session (id, parent_id, directory, title, time_created, time_updated, task_type) + VALUES ('sess_sub', NULL, '/proj/oc', 'sub', 0, 0, 'subagent_child'); + UPDATE message SET sequence = CAST(substr(id, 5) AS integer); + UPDATE message SET sequence = 0 WHERE id = 'msg_6';", + ) + .unwrap(); + drop(conn); + let found = scan(&roots); + assert_eq!(found.iter().map(|s| s.id.as_str()).collect::>(), ["ses_main"]); + assert_eq!(found[0].title, "Named"); + let conv = load_db(&roots, Db::Zcode, "ses_main").unwrap(); + assert_eq!(conv.turns.first().map(|(_, t)| t.clone()), Some(Turn::User("thanks".into())), "zcode's sequence wins over time"); + } + + #[test] + fn omp_follows_the_active_branch() { + let roots = testutil::roots("omp"); + let cwd = roots.home.join("work"); + fs::create_dir_all(&cwd).unwrap(); + let cwd = cwd.to_string_lossy().to_string(); + let rows = [ + json!({"type": "title", "v": 1, "title": "Port the parser", "updatedAt": "x", "pad": " "}), + json!({"type": "session", "version": 3, "id": "019c625b-b900-7000-8000-000000000001", "cwd": cwd}), + json!({"type": "model_change", "id": "m0", "parentId": null, "model": "anthropic/x"}), + json!({"type": "message", "id": "a1", "parentId": "m0", "message": {"role": "user", "content": "port it", "timestamp": 1_790_000_000_000u64}}), + json!({"type": "message", "id": "a2", "parentId": "a1", "message": {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "hm", "thinkingSignature": "sig"}, + {"type": "text", "text": "Reading."}, + {"type": "toolCall", "id": "toolu_01", "name": "read", "arguments": {"path": "a.rs"}}]}}), + json!({"type": "message", "id": "a3", "parentId": "a2", "message": {"role": "toolResult", "toolCallId": "toolu_01", "toolName": "read", "content": [{"type": "text", "text": "fn main() {}"}], "isError": false}}), + json!({"type": "message", "id": "b1", "parentId": "a3", "message": {"role": "user", "content": [{"type": "text", "text": "abandoned"}]}}), + json!({"type": "compaction", "id": "a4", "parentId": "a3", "summary": "x", "firstKeptEntryId": "a1"}), + json!({"type": "message", "id": "a5", "parentId": "a4", "message": {"role": "user", "content": [{"type": "text", "text": "now test it"}, {"type": "image", "data": "..", "mimeType": "image/png"}]}}), + ]; + let text: Vec = rows.iter().map(|r| r.to_string()).collect(); + testutil::write(&roots.omp_agent().join("sessions/-work/2026_019c.jsonl"), &(text.join("\n") + "\n")); + // A sub-agent's file is not a conversation of its own. + testutil::write( + &roots.omp_agent().join("sessions/-work/2026_sub.jsonl"), + &format!("{}\n", json!({"type": "session", "id": "sub", "cwd": cwd, "parentSession": "019c625b"})), + ); + let found = scan(&roots); + assert_eq!(found.len(), 1); + assert_eq!((found[0].title.as_str(), found[0].messages), ("Port the parser", 2)); + let conv = load_omp(&roots, "019c625b-b900-7000-8000-000000000001").unwrap(); + let turns: Vec = tidy(conv.turns).into_iter().map(|(_, t)| t).collect(); + assert_eq!( + turns, + vec![ + Turn::User("port it".into()), + Turn::Assistant("Reading.".into()), + Turn::Call { call_id: "toolu_01".into(), name: "read".into(), arguments: "{\"path\":\"a.rs\"}".into() }, + Turn::Output { call_id: "toolu_01".into(), name: "read".into(), output: "fn main() {}".into() }, + Turn::User("now test it\n[image]".into()), + ] + ); + let pick = SessionPick { source: "omp".into(), id: found[0].id.clone(), cwd: String::new() }; + let done = import(&roots, &pick).unwrap(); + assert_eq!(done.cwd, folder_key(&cwd)); + assert!(jucode_session_dir(&roots.jucode, &done.cwd).join(format!("{}.json", done.id)).exists()); + } + + #[test] + fn claude_sessions_are_found_by_the_folder_in_their_rows() { + let roots = testutil::roots("claude-scan"); + let cwd = "/proj/claude demo"; + let id = "aaaaaaaa-1111-4111-8111-111111111111"; + let path = claude_copy_path(&roots.native(), cwd, id); + let rows = [ + json!({"type": "queue-operation", "content": "x"}), + json!({"type": "user", "cwd": cwd, "uuid": "u1", "message": {"role": "user", "content": "hello there"}}), + json!({"type": "assistant", "cwd": cwd, "uuid": "a1", "message": {"content": [{"type": "tool_use", "id": "t", "name": "Bash", "input": {}}]}}), + json!({"type": "user", "cwd": cwd, "uuid": "u2", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t", "content": "ok"}]}}), + json!({"type": "user", "cwd": cwd, "isMeta": true, "message": {"role": "user", "content": ""}}), + json!({"type": "user", "cwd": cwd, "uuid": "u3", "message": {"role": "user", "content": [{"type": "text", "text": "and again"}]}}), + ]; + let text: Vec = rows.iter().map(|r| r.to_string()).collect(); + testutil::write(&path, &text.join("\n")); + let found = scan(&roots); + assert_eq!(found.len(), 1); + assert_eq!((found[0].cwd.as_str(), found[0].messages, found[0].title.as_str()), (cwd, 2, "hello there")); + let done = import(&roots, &SessionPick { source: "claude".into(), id: id.into(), cwd: cwd.into() }).unwrap(); + assert!(!done.reused && done.engine == "claude"); + let found = scan(&roots); + assert_eq!(found.len(), 1, "the copy is not offered"); + assert!(found[0].imported); + assert!(import(&roots, &SessionPick { source: "claude".into(), id: id.into(), cwd: cwd.into() }).unwrap().reused); + } + + #[test] + fn session_dir_hash_matches_the_cli() { + // A folder `jucode` saved sessions for (seen under ~/.jucode/sessions). + assert_eq!( + jucode_session_dir(Path::new("/x"), "/Users/chad/Documents/JuCode"), + Path::new("/x/sessions/16b727a85fdb3de5") + ); + assert_eq!(label_of(" a\n b "), "a b"); + assert_eq!(label_of(&"x".repeat(80)), format!("{}...", "x".repeat(72))); + } +} diff --git a/src-tauri/src/agent_import/skills.rs b/src-tauri/src/agent_import/skills.rs new file mode 100644 index 0000000..a5bd1a2 --- /dev/null +++ b/src-tauri/src/agent_import/skills.rs @@ -0,0 +1,232 @@ +//! Skills of the other agents: folders with a SKILL.md, in each tool's +//! skills directory and in the plugins it has installed. Importing copies +//! the folder into `~/.jucode/skills`, where JuCode loads skills from; a +//! skill JuCode already has by that name (there or in `~/.agents/skills`) +//! is left alone. + +use super::{plugins, Roots}; +use serde::Serialize; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Serialize, Debug, Clone)] +pub struct Found { + pub source: String, + pub name: String, + pub description: String, + /// The skill's folder. + pub path: String, + /// The plugin it comes with, if any. + pub plugin: Option, + /// JuCode already has a skill of this name. + pub present: bool, +} + +/// How deep skills may sit under a skills folder (JuCode descends into +/// folders without a SKILL.md, as a skill pack lays them out). +const MAX_DEPTH: usize = 3; + +struct Skill { + name: String, + description: String, + dir: PathBuf, +} + +/// `key: value` from a SKILL.md's front matter (a folded `>` / `|` value +/// reads as its lines joined). +fn front_matter(text: &str, key: &str) -> Option { + let body = text.strip_prefix("---")?; + let end = body.find("\n---")?; + let mut lines = body[..end].lines(); + while let Some(line) = lines.next() { + let Some(value) = line.strip_prefix(key).and_then(|rest| rest.trim_start().strip_prefix(':')) else { continue }; + let value = value.trim(); + let value = if matches!(value, "|" | ">" | "|-" | ">-" | "") { + lines + .by_ref() + .take_while(|l| l.starts_with(' ') || l.starts_with('\t')) + .map(str::trim) + .collect::>() + .join(" ") + } else { + value.trim_matches(|c| c == '"' || c == '\'').to_string() + }; + return Some(value).filter(|v| !v.is_empty()); + } + None +} + +fn find_skills(dir: &Path, depth: usize, out: &mut Vec) { + let Ok(read) = fs::read_dir(dir) else { return }; + let mut entries: Vec = read.flatten().map(|e| e.path()).collect(); + entries.sort(); + for path in entries { + let hidden = path.file_name().and_then(|n| n.to_str()).is_none_or(|n| n.starts_with('.')); + // Hidden folders hold a tool's own (Codex's `.system`). + if hidden || !path.is_dir() { + continue; + } + let file = path.join("SKILL.md"); + match fs::read_to_string(&file) { + Ok(text) => { + let folder = path.file_name().and_then(|n| n.to_str()).unwrap_or("skill").to_string(); + let description = front_matter(&text, "description").unwrap_or_default(); + let name = front_matter(&text, "name").unwrap_or(folder); + out.push(Skill { name, description, dir: path }); + } + Err(_) if depth < MAX_DEPTH => find_skills(&path, depth + 1, out), + Err(_) => {} + } + } +} + +/// Names of the skills JuCode loads for every project. +fn installed_names(roots: &Roots) -> HashSet { + let mut skills = Vec::new(); + find_skills(&roots.jucode.join("skills"), 0, &mut skills); + find_skills(&roots.home.join(".agents").join("skills"), 0, &mut skills); + skills.into_iter().map(|s| s.name).collect() +} + +pub fn scan(roots: &Roots) -> Vec { + let mut places: Vec<(&'static str, PathBuf, Option)> = vec![ + ("claude", roots.claude.join("skills"), None), + ("codex", roots.codex.join("skills"), None), + ("opencode", roots.config_home.join("opencode").join("skills"), None), + ("opencode", roots.config_home.join("opencode").join("skill"), None), + ("zcode", roots.home.join(".zcode").join("skills"), None), + ("omp", roots.omp_agent().join("skills"), None), + ]; + places.extend(plugins(roots).into_iter().map(|p| (p.source, p.dir.join("skills"), Some(p.name)))); + let installed = installed_names(roots); + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for (source, dir, plugin) in places { + let mut skills = Vec::new(); + find_skills(&dir, 0, &mut skills); + for skill in skills { + let path = skill.dir.display().to_string(); + if !seen.insert((source, path.clone())) { + continue; + } + out.push(Found { + source: source.to_string(), + present: installed.contains(&skill.name) || dest(roots, &skill.dir).is_some_and(|d| d.exists()), + name: skill.name, + description: skill.description, + path, + plugin: plugin.clone(), + }); + } + } + out +} + +/// Where a skill is copied to: its folder name under `~/.jucode/skills`. +fn dest(roots: &Roots, dir: &Path) -> Option { + let folder = dir.file_name()?.to_str()?; + (!folder.starts_with('.')).then(|| roots.jucode.join("skills").join(folder)) +} + +fn copy_dir(src: &Path, dst: &Path) -> Result<(), String> { + fs::create_dir_all(dst).map_err(|e| format!("cannot create {}: {e}", dst.display()))?; + let read = fs::read_dir(src).map_err(|e| format!("cannot read {}: {e}", src.display()))?; + for entry in read.flatten() { + let name = entry.file_name(); + if matches!(name.to_str(), Some(".git" | "node_modules" | ".DS_Store")) { + continue; + } + let from = entry.path(); + let to = dst.join(&name); + // Symlinks inside a skill are skipped: one may point anywhere on the + // machine (a skill folder that is itself a link, as zcode links + // skills in from Codex, is still read through). + let Ok(kind) = entry.file_type() else { continue }; + if kind.is_dir() { + copy_dir(&from, &to)?; + } else if kind.is_file() { + fs::copy(&from, &to).map_err(|e| format!("cannot copy {}: {e}", from.display()))?; + } + } + Ok(()) +} + +/// Copies one scanned skill; `Ok((false, …))` when JuCode already has it. +pub fn import(roots: &Roots, found: &[Found], source: &str, path: &str) -> Result<(bool, PathBuf), String> { + // Only what the scan offered: the path comes back from the page. + let skill = found + .iter() + .find(|s| s.source == source && s.path == path) + .ok_or_else(|| format!("skill not found: {path}"))?; + let src = Path::new(&skill.path); + let dest = dest(roots, src).ok_or_else(|| format!("not a skill folder: {path}"))?; + if dest.exists() || installed_names(roots).contains(&skill.name) { + return Ok((false, dest)); + } + let parent = dest.parent().unwrap_or(&roots.jucode); + let tmp = parent.join(format!(".import-{}.tmp", dest.file_name().and_then(|n| n.to_str()).unwrap_or("skill"))); + let _ = fs::remove_dir_all(&tmp); + copy_dir(src, &tmp).inspect_err(|_| { + let _ = fs::remove_dir_all(&tmp); + })?; + fs::rename(&tmp, &dest).map_err(|e| format!("cannot write {}: {e}", dest.display()))?; + Ok((true, dest)) +} + +#[cfg(test)] +mod tests { + use super::super::testutil; + use super::*; + + fn skill(dir: &Path, name: &str, desc: &str) { + testutil::write(&dir.join("SKILL.md"), &format!("---\nname: {name}\ndescription: \"{desc}\"\n---\n\nBody\n")); + } + + #[test] + fn finds_skills_and_plugin_skills_and_copies_them() { + let roots = testutil::roots("skills"); + skill(&roots.claude.join("skills/review"), "review", "Review code"); + testutil::write(&roots.claude.join("skills/review/scripts/run.sh"), "echo hi"); + testutil::write(&roots.claude.join("skills/review/node_modules/x/index.js"), "x"); + #[cfg(unix)] + std::os::unix::fs::symlink(&roots.home, roots.claude.join("skills/review/home")).unwrap(); + skill(&roots.codex.join("skills/.system/imagegen"), "imagegen", "Codex's own"); + skill(&roots.codex.join("skills/pack/inner"), "inner-skill", "Nested in a pack"); + // A zcode plugin, two versions: the newer one counts. + let cache = roots.zcode_plugins().join("official/docs"); + skill(&cache.join("0.1.9/skills/docx"), "docx-old", "old"); + skill(&cache.join("0.1.10/skills/docx"), "docx", "new"); + // JuCode already has one by that name. + skill(&roots.home.join(".agents/skills/inner"), "inner-skill", "mine"); + + let found = scan(&roots); + let names: Vec<(&str, &str, Option<&str>, bool)> = + found.iter().map(|s| (s.source.as_str(), s.name.as_str(), s.plugin.as_deref(), s.present)).collect(); + assert_eq!( + names, + [("claude", "review", None, false), ("codex", "inner-skill", None, true), ("zcode", "docx", Some("docs"), false)] + ); + assert_eq!(found[0].description, "Review code"); + + let review = &found[0]; + let (copied, dest) = import(&roots, &found, "claude", &review.path).unwrap(); + assert!(copied); + assert_eq!(dest, roots.jucode.join("skills/review")); + assert_eq!(fs::read_to_string(dest.join("scripts/run.sh")).unwrap(), "echo hi"); + assert!(!dest.join("node_modules").exists()); + assert!(fs::symlink_metadata(dest.join("home")).is_err(), "a symlink is not followed"); + assert!(!import(&roots, &found, "claude", &review.path).unwrap().0, "a second import leaves it"); + assert!(!import(&roots, &found, "codex", &found[1].path).unwrap().0, "a name JuCode has is skipped"); + assert!(import(&roots, &found, "claude", "/etc").is_err(), "only scanned skills are copied"); + assert!(scan(&roots)[0].present); + } + + #[test] + fn front_matter_reads_quoted_and_folded_values() { + let text = "---\nname: 'x'\ndescription: >\n first line\n second\nother: y\n---\nbody"; + assert_eq!(front_matter(text, "name").as_deref(), Some("x")); + assert_eq!(front_matter(text, "description").as_deref(), Some("first line second")); + assert_eq!(front_matter("no front matter", "name"), None); + } +} diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index 1cdd77d..cfb92d0 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -78,8 +78,10 @@ fn place(app: &AppHandle, child: &WebviewWindow, x: f64, y: f64, width: f64, hei } /// Creates the child browser window at the given panel bounds (or navigates the -/// existing one) and loads `url`. -#[tauri::command] +/// existing one) and loads `url`. Async: building a webview window inside a +/// synchronous command deadlocks on Windows (WebView2), leaving the panel blank +/// and every later browser command waiting behind it. +#[tauri::command(async)] pub fn browser_open( app: AppHandle, url: String, diff --git a/src-tauri/src/claude_history.rs b/src-tauri/src/claude_history.rs index b8af149..e85f709 100644 --- a/src-tauri/src/claude_history.rs +++ b/src-tauri/src/claude_history.rs @@ -84,7 +84,7 @@ pub(crate) fn truncate_chars(s: &str, max: usize) -> String { /// Rows Claude injects that are not conversation content: subagent streams, /// meta caveats and the post-compaction continuation summary. -fn is_synthetic(v: &serde_json::Value) -> bool { +pub(crate) fn is_synthetic(v: &serde_json::Value) -> bool { v["isSidechain"].as_bool() == Some(true) || v["isMeta"].as_bool() == Some(true) || v["isCompactSummary"].as_bool() == Some(true) @@ -94,7 +94,7 @@ fn is_synthetic(v: &serde_json::Value) -> bool { /// Extracts the user/assistant text of a session-file row, or None. /// String contents starting with `<` are slash-command echoes /// (`…`, `…`) — never conversation text. -fn row_text(v: &serde_json::Value) -> Option { +pub(crate) fn row_text(v: &serde_json::Value) -> Option { let content = &v["message"]["content"]; if let Some(s) = content.as_str() { let t = s.trim(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bf86652..212c854 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Emitter, Manager}; mod acp_registry; +mod agent_import; mod app_cli; #[cfg(desktop)] mod app_update; @@ -818,6 +819,7 @@ fn diagnostic_logs() -> Vec { [ ("jucode.log", dir.join("logs").join("jucode.log")), ("daemon.log", dir.join("daemon").join("daemon.log")), + ("desktop-ui.log", dir.join("logs").join("desktop-ui.log")), ] .into_iter() .filter_map(|(name, path)| { @@ -841,6 +843,55 @@ fn submit_feedback(ticket: serde_json::Value) -> Result serde_json::Value { + let jucode = jucode_dir(); + serde_json::json!({ + "version": app.package_info().version.to_string(), + "cli": app_cli::app_cli_version(), + "os": std::env::consts::OS, + "arch": std::env::consts::ARCH, + "logs": jucode.join("logs").display().to_string(), + "daemon_log": jucode.join("daemon").join("daemon.log").display().to_string(), + }) +} + +/// A UI error, appended to ~/.jucode/logs/desktop-ui.log (bounded), so a +/// blank pane on a machine nobody here can see still says what broke. +#[tauri::command] +fn log_ui_error(message: String) { + use std::io::Write; + let dir = jucode_dir().join("logs"); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("desktop-ui.log"); + // Keep it small: start over past 512 KB. + if std::fs::metadata(&path).map(|m| m.len() > 512 * 1024).unwrap_or(false) { + let _ = std::fs::remove_file(&path); + } + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or_default(); + let line: String = message.chars().take(4000).collect(); + if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&path) { + let _ = writeln!(file, "{stamp} {} {}", std::env::consts::OS, line.replace('\n', " | ")); + } +} + +/// Opens the folder holding the engine and daemon logs (made when missing). +#[tauri::command] +fn open_logs_folder(app: AppHandle) -> Result<(), String> { + use tauri_plugin_opener::OpenerExt; + let dir = jucode_dir(); + std::fs::create_dir_all(dir.join("logs")).map_err(|e| e.to_string())?; + app.opener() + .open_path(dir.display().to_string(), None::<&str>) + .map_err(|e| e.to_string()) +} + /// Anonymous usage counts (src/lib/telemetry.svelte.ts), with this app's /// version and platform; no login needed. #[tauri::command(async)] @@ -2043,16 +2094,28 @@ fn resolve_file_ref(root: String, rel: String) -> Option { let rel = Path::new(&rel); let direct = root.join(rel); if direct.is_file() { - return Some(direct.display().to_string()); + return Some(ui_path(&direct)); } let mut found = Vec::new(); find_ref(&root, rel, 0, &mut found); match found.as_slice() { - [one] => one.canonicalize().ok().filter(|p| p.starts_with(&root)).map(|p| p.display().to_string()), + [one] => one.canonicalize().ok().filter(|p| p.starts_with(&root)).map(|p| ui_path(&p)), _ => None, } } +/// A canonical path as the UI opens it: on Windows `canonicalize` adds a +/// `\\?\` prefix the UI does not read as absolute, so it joined the path to +/// the project root again and the file did not open. +fn ui_path(path: &Path) -> String { + let text = path.display().to_string(); + match text.strip_prefix(r"\\?\") { + Some(unc) if unc.starts_with(r"UNC\") => format!(r"\\{}", &unc[4..]), + Some(local) => local.to_string(), + None => text, + } +} + const REF_DEPTH: usize = 4; fn find_ref(dir: &Path, rel: &Path, depth: usize, out: &mut Vec) { @@ -2742,6 +2805,71 @@ fn git(args: Vec, cwd: Option) -> Result { } } +/// Undoes the agent's own edits to one file: `diffs` are the edit tools' +/// unified diffs for it, oldest first; they are reverse-applied newest first +/// against the working tree by their context, so the user's own changes +/// elsewhere in the file stay (a hunk whose context changed refuses). Every diff +/// must touch only `path` (inside `cwd`). Nothing is written unless all of +/// them apply (`git apply --check` first). +#[tauri::command(async)] +fn revert_agent_edits(cwd: String, path: String, diffs: Vec) -> Result<(), String> { + use std::io::Write; + let dir = PathBuf::from(&cwd); + if path.is_empty() || Path::new(&path).is_absolute() || path.split(['/', '\\']).any(|part| part == "..") { + return Err(format!("not a project path: {path}")); + } + if diffs.is_empty() || diffs.len() > 200 { + return Err("no agent edits to undo".to_string()); + } + // Each diff names its file in `diff --git a/

b/

` / `+++ b/

` + // headers; any other file name is refused. + for diff in &diffs { + for line in diff.lines() { + let named = line + .strip_prefix("+++ b/") + .or_else(|| line.strip_prefix("--- a/")) + .map(str::trim); + if let Some(named) = named { + if named != path && named != "/dev/null" { + return Err(format!("edit touches another file: {named}")); + } + } + } + } + let patch: String = diffs + .iter() + .rev() + .map(|diff| if diff.ends_with('\n') { diff.clone() } else { format!("{diff}\n") }) + .collect(); + let run = |check: bool| -> Result<(), String> { + let mut cmd = Command::new("git"); + no_window(&mut cmd); + cmd.current_dir(&dir).env("GIT_TERMINAL_PROMPT", "0").args(["apply", "-R", "--whitespace=nowarn"]); + if check { + cmd.arg("--check"); + } + cmd.args(["--include", &path, "-"]); + cmd.stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + let mut child = cmd.spawn().map_err(|e| format!("failed to run git: {e}"))?; + child + .stdin + .take() + .ok_or("git stdin unavailable")? + .write_all(patch.as_bytes()) + .map_err(|e| e.to_string())?; + let output = child.wait_with_output().map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } + }; + run(true)?; + run(false) +} + /// Runs a fixed git plumbing command in `dir` (optionally with an isolated index /// file). A stable checkpoint identity is set so `commit-tree` works even in a /// repo without a configured user. Returns trimmed stdout on success. @@ -3276,6 +3404,8 @@ pub fn run() { put_cloud_settings, diagnostic_logs, submit_feedback, + revert_agent_edits, + log_ui_error, send_telemetry, fetch_jucode_models, fetch_jucode_groups, @@ -3305,6 +3435,8 @@ pub fn run() { native_import::native_sessions, app_cli::install_cli_command, app_cli::app_cli_version, + about_app, + open_logs_folder, app_cli::replace_daemon, #[cfg(desktop)] app_update::update_check, @@ -3313,6 +3445,8 @@ pub fn run() { #[cfg(desktop)] app_update::update_policy, native_import::import_native_session, + agent_import::import_scan, + agent_import::import_apply, pty_open, pty_write, pty_resize, @@ -3350,7 +3484,15 @@ pub fn run() { #[cfg(test)] mod tests { - use super::{read_json_strict, resolve_file_ref, valid_app_data_name}; + use super::{read_json_strict, resolve_file_ref, ui_path, valid_app_data_name}; + use std::path::Path; + + #[test] + fn ui_path_drops_the_windows_verbatim_prefix() { + assert_eq!(ui_path(Path::new(r"\\?\C:\p\a.html")), r"C:\p\a.html"); + assert_eq!(ui_path(Path::new(r"\\?\UNC\srv\share\a.ts")), r"\\srv\share\a.ts"); + assert_eq!(ui_path(Path::new("/Users/me/a.ts")), "/Users/me/a.ts"); + } #[test] fn resolve_file_ref_finds_a_unique_nested_match() { @@ -3363,8 +3505,9 @@ mod tests { } let r = root.display().to_string(); let canon = root.canonicalize().unwrap(); - assert_eq!(resolve_file_ref(r.clone(), "top.ts".into()), Some(canon.join("top.ts").display().to_string())); - assert_eq!(resolve_file_ref(r.clone(), "src/x.ts".into()), Some(canon.join("a/repo/src/x.ts").display().to_string())); + // As the UI gets it: without the `\\?\` prefix canonicalize adds on Windows. + assert_eq!(resolve_file_ref(r.clone(), "top.ts".into()), Some(ui_path(&canon.join("top.ts")))); + assert_eq!(resolve_file_ref(r.clone(), "src/x.ts".into()), Some(ui_path(&canon.join("a/repo/src/x.ts")))); assert_eq!(resolve_file_ref(r.clone(), "src/y.ts".into()), None); assert_eq!(resolve_file_ref(r.clone(), "src/z.ts".into()), None); std::fs::remove_dir_all(&root).unwrap(); diff --git a/src-tauri/src/native_import.rs b/src-tauri/src/native_import.rs index 75dfc71..9c9fdd1 100644 --- a/src-tauri/src/native_import.rs +++ b/src-tauri/src/native_import.rs @@ -59,11 +59,11 @@ pub struct ImportedSession { } #[derive(Serialize, Deserialize, Clone, Debug)] -struct ImportRecord { - source: String, - from: String, - to: String, - at_ms: u64, +pub(crate) struct ImportRecord { + pub source: String, + pub from: String, + pub to: String, + pub at_ms: u64, } /// Where each engine keeps its sessions and where imports are recorded @@ -74,7 +74,7 @@ pub struct Homes { pub registry: PathBuf, } -fn homes() -> Homes { +pub(crate) fn homes() -> Homes { let home = std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) .map(PathBuf::from) @@ -89,21 +89,21 @@ fn homes() -> Homes { } const MAX_LISTED: usize = 50; -const MAX_TITLE_CHARS: usize = 80; +pub(crate) const MAX_TITLE_CHARS: usize = 80; /// Bytes scanned for a Codex title when the thread has no name. const MAX_TITLE_SCAN: u64 = 1024 * 1024; /// Files in a `history_base` chain before giving up (a cycle guard; real /// threads reach a handful). const MAX_CHAIN: usize = 256; -fn now_ms() -> u64 { +pub(crate) fn now_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0) } -fn mtime_ms(path: &Path) -> u64 { +pub(crate) fn mtime_ms(path: &Path) -> u64 { fs::metadata(path) .and_then(|m| m.modified()) .ok() @@ -161,14 +161,14 @@ fn utc_parts(ms: u64) -> (i64, u32, u32, u32, u32, u32) { // --- the import registry ----------------------------------------------------- -fn load_registry(path: &Path) -> Vec { +pub(crate) fn load_registry(path: &Path) -> Vec { fs::read_to_string(path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } -fn save_registry(path: &Path, records: &[ImportRecord]) -> Result<(), String> { +pub(crate) fn save_registry(path: &Path, records: &[ImportRecord]) -> Result<(), String> { if let Some(dir) = path.parent() { fs::create_dir_all(dir).map_err(|e| e.to_string())?; } @@ -184,7 +184,7 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { // --- Claude Code ------------------------------------------------------------- -fn claude_copy_path(h: &Homes, cwd: &str, id: &str) -> PathBuf { +pub(crate) fn claude_copy_path(h: &Homes, cwd: &str, id: &str) -> PathBuf { project_dir(&h.claude, cwd).join(format!("{id}.jsonl")) } @@ -208,7 +208,7 @@ fn list_claude(h: &Homes, cwd: &str) -> Result, String> { .collect()) } -fn existing_copy( +pub(crate) fn existing_copy( registry: &[ImportRecord], source: &str, from: &str, @@ -288,7 +288,7 @@ fn json_lines(path: &Path) -> Result> })) } -fn import_claude(h: &Homes, cwd: &str, id: &str) -> Result { +pub(crate) fn import_claude(h: &Homes, cwd: &str, id: &str) -> Result { if !is_session_id(id) { return Err(format!("invalid session id: {id}")); } @@ -316,14 +316,14 @@ fn import_claude(h: &Homes, cwd: &str, id: &str) -> Result` suffix for the others. file_id: String, /// Under `archived_sessions`: read as a fork's base, never listed. - archived: bool, + pub archived: bool, } fn first_line(path: &Path) -> Option { @@ -352,7 +352,7 @@ fn rollout_files(codex: &Path) -> Vec { } /// Every thread's segments, oldest first, keyed by thread id. -fn codex_threads(codex: &Path) -> HashMap> { +pub(crate) fn codex_threads(codex: &Path) -> HashMap> { let mut threads: HashMap> = HashMap::new(); let archive = codex.join("archived_sessions"); for path in rollout_files(codex) { @@ -385,7 +385,7 @@ fn same_dir(a: &str, b: &str) -> bool { } /// `session_index.jsonl`: the names Codex gave its threads (last one wins). -fn codex_names(codex: &Path) -> HashMap { +pub(crate) fn codex_names(codex: &Path) -> HashMap { let mut names = HashMap::new(); let Ok(text) = fs::read_to_string(codex.join("session_index.jsonl")) else { return names }; for v in text.lines().filter_map(|l| serde_json::from_str::(l).ok()) { @@ -400,7 +400,7 @@ fn codex_names(codex: &Path) -> HashMap { /// The first thing the user typed (Codex injects AGENTS.md and environment /// blocks as user messages too; those start with `<` or a heading). -fn codex_first_prompt(path: &Path) -> String { +pub(crate) fn codex_first_prompt(path: &Path) -> String { let Ok(file) = fs::File::open(path) else { return String::new() }; for line in BufReader::new(file).take(MAX_TITLE_SCAN).lines().map_while(Result::ok) { let Ok(v) = serde_json::from_str::(&line) else { continue }; @@ -449,7 +449,7 @@ fn codex_default_provider(codex: &Path) -> String { /// A conversation someone had, not a sub-agent a conversation spawned (those /// resume only through their parent). -fn is_conversation(segments: &[Segment]) -> bool { +pub(crate) fn is_conversation(segments: &[Segment]) -> bool { let meta = &segments[0].meta; !segments.iter().all(|s| s.archived) && meta["thread_source"].as_str() != Some("subagent") @@ -457,7 +457,7 @@ fn is_conversation(segments: &[Segment]) -> bool { } /// Threads JuCode's own Codex sessions wrote: already JuCode sessions. -const JUCODE_ORIGINATOR: &str = "jucode-daemon"; +pub(crate) const JUCODE_ORIGINATOR: &str = "jucode-daemon"; fn codex_origin(meta: &Value) -> String { match meta["originator"].as_str().unwrap_or("") { @@ -598,7 +598,7 @@ fn clean_codex( out.flush().map_err(|e| e.to_string()) } -fn import_codex(h: &Homes, cwd: &str, id: &str) -> Result { +pub(crate) fn import_codex(h: &Homes, cwd: &str, id: &str) -> Result { if !is_session_id(id) { return Err(format!("invalid thread id: {id}")); } @@ -651,7 +651,7 @@ pub fn native_sessions(source: String, cwd: String) -> Result /// Copies one of them for JuCode (see module docs); returns the copy's id, /// which the engine resumes. An earlier copy is reused. /// Imports run one at a time: each reads, extends and rewrites the registry. -static IMPORTING: std::sync::Mutex<()> = std::sync::Mutex::new(()); +pub(crate) static IMPORTING: std::sync::Mutex<()> = std::sync::Mutex::new(()); #[tauri::command(async)] pub fn import_native_session(source: String, cwd: String, id: String) -> Result { diff --git a/src/lib/AgentRunsPanel.svelte b/src/lib/AgentRunsPanel.svelte index 7b6ed8e..08924af 100644 --- a/src/lib/AgentRunsPanel.svelte +++ b/src/lib/AgentRunsPanel.svelte @@ -1,5 +1,5 @@ +

{#if questions} @@ -283,7 +321,7 @@ variant="primary" size="sm" onclick={() => onRespond(buildApproveOp(approval.callId, 'allow'))} - >{isElicitation ? t('shell.elicitAccept') : t('shell.allowOnce')}{isElicitation ? t('shell.elicitAccept') : t('shell.allowOnce')}1 {#if !isElicitation} @@ -291,7 +329,7 @@ variant="secondary" size="sm" onclick={() => (scopeKeys ? (scopeOpen = !scopeOpen) : allowAlways())} - >{scopeKeys ? t('shell.allowAlwaysScoped') : t('shell.allowAlways')}{scopeKeys ? t('shell.allowAlwaysScoped') : t('shell.allowAlways')}2 {#if scopeOpen} onRespond(buildApproveOp(approval.callId, 'deny'))} - >{isElicitation ? t('shell.elicitDecline') : t('shell.deny')}{isElicitation ? t('shell.elicitDecline') : t('shell.deny')}3
{/if} @@ -571,4 +609,13 @@ .diff .ctx { display: block; } + .approval-actions :global(kbd) { + margin-left: 6px; + padding: 0 4px; + border-radius: 3px; + background: color-mix(in oklab, currentColor 14%, transparent); + font: inherit; + font-size: 10px; + opacity: 0.75; + } diff --git a/src/lib/ChangesPanel.svelte b/src/lib/ChangesPanel.svelte index 51b099a..7951b76 100644 --- a/src/lib/ChangesPanel.svelte +++ b/src/lib/ChangesPanel.svelte @@ -7,12 +7,32 @@ import Modal from '$lib/ui/Modal.svelte'; import Notice from '$lib/ui/Notice.svelte'; import { confirm } from '$lib/ui/confirm.svelte'; - import { git } from '$lib/protocol'; + import { git, revertAgentEdits } from '$lib/protocol'; import { editorStore } from '$lib/editor/editorStore.svelte'; import { t } from '$lib/i18n'; // `files` is the session-tracked set of agent-edited paths; onRevert removes one. - let { cwd = '', files = [], onRevert }: { cwd?: string; files?: string[]; onRevert?: (p: string) => void } = $props(); + // `agentDiffs`: the agent's own edits per project-relative path, so a + // revert undoes only them and keeps the user's changes. + let { + cwd = '', + files = [], + agentDiffs = {}, + onRevert + }: { + cwd?: string; + files?: string[]; + agentDiffs?: Record; + /** A file was reverted: `rel` is its `agentDiffs` key. */ + onRevert?: (p: string, rel: string) => void; + } = $props(); + /** A listed path (absolute or project-relative, either separator) as the + * diffs' headers name it: project-relative, `/`-separated. */ + function relOf(path: string): string { + const p = path.replace(/\\/g, '/'); + const root = cwd.replace(/\\/g, '/').replace(/\/+$/, ''); + return root && p.startsWith(root + '/') ? p.slice(root.length + 1) : p; + } const dir = () => cwd || undefined; let stats = $state>({}); @@ -76,6 +96,31 @@ } async function revert(path: string) { + const rel = relOf(path); + const diffs = agentDiffs[rel] ?? []; + // The agent's own edits are known: undo exactly them, keep the user's. + // When they no longer apply the file stays as it is: a whole-file + // revert would drop the user's changes. + if (diffs.length && cwd) { + const ok = await confirm({ + title: t('dock.changes.revertTitle'), + message: t('dock.changes.revertAgentConfirm', { path }), + confirmLabel: t('dock.changes.revert'), + danger: true + }); + if (!ok) return; + busy = true; + error = ''; + try { + await revertAgentEdits(cwd, rel, diffs); + onRevert?.(path, rel); + } catch (e) { + error = t('dock.changes.revertAgentFailed', { error: String(e) }); + } finally { + busy = false; + } + return; + } const ok = await confirm({ title: t('dock.changes.revertTitle'), message: t('dock.changes.revertConfirm', { path }), @@ -90,7 +135,7 @@ const untracked = st.trimStart().startsWith('??'); if (untracked) await git(['clean', '-fd', '--', path], dir()); else await git(['restore', '--worktree', '--', path], dir()); - onRevert?.(path); + onRevert?.(path, rel); } catch (e) { error = String(e); } finally { diff --git a/src/lib/ChatPane.svelte b/src/lib/ChatPane.svelte index 46f1493..a69a0d2 100644 --- a/src/lib/ChatPane.svelte +++ b/src/lib/ChatPane.svelte @@ -1,10 +1,13 @@ @@ -33,7 +38,10 @@ import CircleNotchIcon from 'phosphor-svelte/lib/CircleNotchIcon'; import { open } from '@tauri-apps/plugin-dialog'; import { treeRows } from '$lib/tree'; - import { buildSetApprovalModeOp, needsClaudeYoloRespawn, type ApprovalMode, type ApproveOp } from '$lib/approval'; + import { buildSetApprovalModeOp, needsClaudeYoloRespawn, toEngineMode, type ApprovalMode, type ApproveOp } from '$lib/approval'; + import type { PlanAction } from '$lib/PlanCard.svelte'; + import XIcon from 'phosphor-svelte/lib/XIcon'; + import ClipboardTextIcon from 'phosphor-svelte/lib/ClipboardTextIcon'; import { processVideo, sessionHistory, @@ -64,11 +72,15 @@ import RateLimitBanner from '$lib/RateLimitBanner.svelte'; import Button from '$lib/ui/Button.svelte'; import TaskStrip from '$lib/TaskStrip.svelte'; - import { parseFileHref } from '$lib/fileRefs'; + import ProgressCard from '$lib/agents/ProgressCard.svelte'; + import { agentRows, type AgentRow } from '$lib/agentProgress'; + import { parseToolOutput, toolTarget, toolVerb } from '$lib/toolSummary'; + import { isAbsolutePath, joinPath, parseFileHref, pathExt } from '$lib/fileRefs'; import type { Msg } from '$lib/chat.svelte'; import type { SessionSwitch } from '$lib/composer/SessionSwitches.svelte'; import Modal from '$lib/ui/Modal.svelte'; import { toast } from '$lib/ui/toast.svelte'; + import { planPages } from '$lib/planPages.svelte'; import Picker from '$lib/shell/Picker.svelte'; import Segmented from '$lib/ui/Segmented.svelte'; import type { SectionKey } from '$lib/settings/nav'; @@ -105,8 +117,10 @@ /** Providers with configured auth (for "not configured" hints). */ providers?: string[]; providersList?: ProviderOption[]; - /** This pane's session is `store.activeId` — overlays and window keys - * only run on the active pane, so two panes never fight over them. */ + /** This pane's session is `store.activeId` and no page (settings, the + * desk) covers it — overlays and window keys only run on the active + * pane, so two panes never fight over them and a hidden one never + * answers. */ isActive?: boolean; onRegister?: (id: string, api: ChatPaneApi) => void; /** Passes the api back so a remount (tile drag) can't drop the fresh one. */ @@ -193,41 +207,15 @@ let mark = $state(-1); const marks = $derived(chat.messages.flatMap((m) => (m.kind === 'user' && !parseDelivery(m.text) ? [m.text] : []))); - // The conversation column's width: dragging either edge resizes it about - // the centre; the width is kept once the drag ends. - // Narrow enough to read, wide enough for the composer's bar on one line. - const CHAT_MIN = 640; - const EDGE = 10; // px from the text to the drag strip's centre + // The conversation column has one fixed width (as in ChatGPT and Claude): + // narrow enough to read, wide enough for the composer's bar on one line. + const CHAT_W = 768; let wrapW = $state(0); - let dragW = $state(null); - let hot = $state<{ edge: string; y: number } | null>(null); - const chatW = $derived(Math.max(CHAT_MIN, dragW ?? prefs.chatWidth)); + const chatW = CHAT_W; // The least room beside the column (more with the rail at the left edge), // and the room it has: main's padding-inline. const chatPad = $derived(marks.length > 1 ? 56 : 32); const pad = $derived(Math.max(chatPad, (wrapW - chatW) / 2)); - function startResize(e: PointerEvent) { - e.preventDefault(); - const el = e.currentTarget as HTMLElement; - const box = el.parentElement!.getBoundingClientRect(); - const centre = box.left + box.width / 2; - el.setPointerCapture(e.pointerId); - const move = (ev: PointerEvent) => { - dragW = Math.max(CHAT_MIN, Math.min(box.width - 2 * chatPad, 2 * (Math.abs(ev.clientX - centre) - EDGE))); - }; - const end = () => { - if (dragW !== null) prefs.setChatWidth(dragW); - dragW = null; - el.removeEventListener('pointermove', move); - }; - el.addEventListener('pointermove', move); - el.addEventListener('pointerup', end, { once: true }); - el.addEventListener('pointercancel', end, { once: true }); - } - function trackEdge(edge: string, e: PointerEvent) { - const box = (e.currentTarget as HTMLElement).getBoundingClientRect(); - hot = { edge, y: e.clientY - box.top }; - } // In-conversation find (⌘F). The raw input updates per keystroke; the actual // scan (findHits) keys off the debounced `findQuery` so the O(n) message scan @@ -455,18 +443,6 @@ const isImage = (p: string) => /\.(png|jpe?g|gif|webp|bmp)$/i.test(p); const base = (p: string) => p.replace(/\/+$/, '').split('/').pop() || p; - // Engine subagent lifecycle status → localized label (falls back to the raw value). - // 'done' is an alias of 'completed'. - const AGENT_STATUS_KEY: Record = { - started: 'started', - running: 'running', - completed: 'completed', - done: 'completed', - interrupted: 'interrupted', - stopped: 'interrupted', - failed: 'failed', - closed: 'closed' - }; // The agent trace (AgentRunsPanel, a workbench panel) of this session. const traceable = $derived(caps(chat).agentTrace && !!onOpenTrace); function openTrace(agentId: string | null) { @@ -481,7 +457,39 @@ if (chat.agentRuns.workflows.some((w) => w.toolUseId === m.callId)) return { label: t('dock.agents.open'), run: () => openTrace(null) }; return null; } - const agentStatus = (s: string) => (AGENT_STATUS_KEY[s] ? t(`shell.agentStatus.${AGENT_STATUS_KEY[s]}`) : s); + // The subagents of this conversation: every run (the message list's cards + // find theirs), and the current turn's (the progress card). Derived from + // the trace and lifecycle state, never from the streaming text. + const lastTool = (label: string) => { + const m = chat.subagentLastTool[label]; + return m ? `${toolVerb(m.name)} ${toolTarget(m.name, parseToolOutput(m.output))}`.trim() : ''; + }; + const allAgents = $derived(agentRows({ runs: chat.agentRuns, subagents: chat.subagents, lastTool })); + const turnAgents = $derived( + agentRows({ runs: chat.agentRuns, subagents: chat.subagents, lastTool, since: chat.turnStartedAt || Number.MAX_SAFE_INTEGER }) + ); + function openAgent(row: AgentRow) { + openTrace(row.workflow ? null : row.id); + } + + // Plan mode: a proposed plan runs once approved, in the mode picked on its + // card; revising it sends the next message as the user's feedback. + const planMode = $derived( + chat.modeBeforePlan && chat.modeBeforePlan !== 'plan' ? chat.modeBeforePlan : 'edits' + ); + function planAction(id: string, action: PlanAction) { + const plan = chat.messages.find((m) => m.kind === 'plan' && m.id === id); + if (action.decision === 'approve') { + const mode = toEngineMode(action.mode) as Exclude, 'plan'>; + send({ op: 'approve_plan', id, decision: 'approve', mode }); + chat.setApprovalMode(action.mode); + if (plan?.kind === 'plan') plan.status = 'approved'; + chat.planRevising = null; + return; + } + chat.planRevising = id; + composerEl?.focus(); + } // pickers (tree / model / resume) — this pane's session const pickerTitle = $derived( @@ -722,6 +730,19 @@ content = content.replace(re, () => `\n\n${block}\n\n`); } content = content.replace(/\n{3,}/g, '\n\n').trim(); + // Revising a proposed plan: the message is the user's feedback on it. + const revising = chat.planRevising; + if (revising && !files.length && !videos.length && !imagePaths.length) { + const plan = chat.messages.find((m) => m.kind === 'plan' && m.id === revising); + if (plan?.kind === 'plan') plan.status = 'revising'; + chat.planRevising = null; + if (!chat.busy || chat.restarting) chat.optimisticUser(content); + send({ op: 'approve_plan', id: revising, decision: 'revise', feedback: content }); + input = ''; + webRefs = []; + quotes = []; + return; + } if (files.length) content += `${content ? '\n\n' : ''}Attached files (read these):\n${files.join('\n')}`; for (const v of videos) { @@ -1010,9 +1031,8 @@ const ref = parseFileHref(href); const rel = ref.path; if (!rel) return; - const joined = `${cwd.replace(/\/+$/, '')}/${rel.replace(/^\.?\//, '')}`; - const abs = rel.startsWith('/') ? rel : ((await resolveFileRef(cwd, rel).catch(() => null)) ?? joined); - const ext = abs.split('/').pop()?.split('.').pop()?.toLowerCase() ?? ''; + const abs = isAbsolutePath(rel) ? rel : ((await resolveFileRef(cwd, rel).catch(() => null)) ?? joinPath(cwd, rel)); + const ext = pathExt(abs); if ((ext === 'html' || ext === 'htm') && prefs.htmlOpenInBrowser) { browser.open(`file://${abs}`); } else { @@ -1066,7 +1086,8 @@ stop: () => { if (chat.busy) stop(); }, - openModelMenu: () => composerRef?.openModelMenu() + openModelMenu: () => composerRef?.openModelMenu(), + planAction }; $effect(() => { onRegister?.(session.id, api); @@ -1110,17 +1131,6 @@
{/if} - {#if Object.keys(chat.subagents).length} -
- {#each Object.entries(chat.subagents) as [path, info] (path)} - {#if info.label && traceable} - - {:else} - {info.label || path} · {agentStatus(info.status)}{#if info.message}{info.message}{/if} - {/if} - {/each} -
- {/if} send({ op: 'stop_task', task_id: id })} @@ -1142,10 +1152,11 @@ /> {/if} -
+
+
- autoRetry.now(chat)} onAutoRetryCancel={() => autoRetry.cancel(chat)} {findActive} {scroller} onEdit={editMessage} onCite={citeText} onNote={noteRequirement} onRewind={rewindToMessage} onFile={openChatFile} onDismiss={(m) => (chat.messages = chat.messages.filter((x) => x !== m))} backend={chat.backendId} onErrorAction={fixError} traceOf={traceable ? traceOf : undefined} /> + autoRetry.now(chat)} onAutoRetryCancel={() => autoRetry.cancel(chat)} {findActive} {scroller} onEdit={editMessage} onCite={citeText} onNote={noteRequirement} onRewind={rewindToMessage} onFile={openChatFile} onDismiss={(m) => (chat.messages = chat.messages.filter((x) => x !== m))} backend={chat.backendId} onErrorAction={fixError} traceOf={traceable ? traceOf : undefined} agents={allAgents} onOpenAgent={traceable ? openAgent : undefined} onPlan={planAction} {planMode} onOpenPlan={(id) => planPages.open(session.id, id)} />
{#if chat.booting && chat.engineState !== 'exited'}
@@ -1171,21 +1182,6 @@ {#if marks.length > 1} messageList?.jumpToMark(n)} /> {/if} - {#each ['left', 'right'] as edge (edge)} - - {/each}
{#if !atBottom} @@ -1205,7 +1201,7 @@ {#if chat.pendingApproval && !chat.pendingApproval.questions?.length}
{#key chat.pendingApproval.callId} - + {/key}
{/if} @@ -1219,6 +1215,17 @@ + {#if chat.planRevising} +
+
+ + {t('chat.planCard.revising')} + {t('chat.planCard.revisingHint')} + +
+
+ {/if} + {#if gate && !chat.pendingApproval}
@@ -1389,7 +1396,7 @@ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28); cursor: pointer; z-index: 10; - animation: jump-in var(--t-med) var(--ease-spring); + animation: jump-in var(--t-med) var(--ease-out); transition: background var(--t-fast) var(--ease-out), transform var(--t-fast) var(--ease-spring), @@ -1399,7 +1406,7 @@ @keyframes jump-in { from { opacity: 0; - transform: translateX(-50%) translateY(6px) scale(0.9); + transform: translateX(-50%) translateY(4px); } to { opacity: 1; @@ -1448,43 +1455,48 @@ font-weight: 500; } - .agents { + .revising { display: flex; - flex-wrap: wrap; + align-items: center; gap: 8px; - padding: 8px 18px; - border-bottom: 1px solid var(--hairline); + width: fit-content; + max-width: 100%; + padding: 5px 6px 5px 12px; + border-radius: var(--r-full); + background: var(--surface2); + color: var(--text); + font-size: var(--fs-xs); } - .agent { - display: inline-flex; - align-items: center; - gap: 6px; - font-family: var(--font-mono); - font-size: var(--fs-2xs); - color: var(--dim); - min-width: 0; + .rv-label { + flex: none; + font-weight: 500; } - .interm { - margin: 0 0 8px; - font-size: var(--fs-xs); + .rv-hint { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; color: var(--dim); - text-align: center; } - button.agent { + .rv-x { + display: inline-flex; + flex: none; + padding: 3px; border: none; + border-radius: var(--r-full); background: none; - padding: 0; + color: var(--dim); cursor: pointer; } - button.agent:hover { + .rv-x:hover { + background: var(--surface2); color: var(--text); } - .agent-msg { - color: var(--dim2); - max-width: 360px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + .interm { + margin: 0 0 8px; + font-size: var(--fs-xs); + color: var(--dim); + text-align: center; } .mainwrap { @@ -1506,34 +1518,6 @@ centres the column. */ padding-inline: max(var(--chat-pad), calc((100% - var(--chat-w)) / 2)); } - /* The column's edge: hovering shows a short bar at the pointer, dragging - resizes the column. */ - .edge { - position: absolute; - top: 0; - bottom: 0; - width: 12px; - transform: translateX(-50%); - cursor: col-resize; - z-index: 4; - } - .edge span { - position: absolute; - left: 5px; - width: 2px; - height: 120px; - border-radius: 1px; - transform: translateY(-50%); - background: linear-gradient(transparent, var(--dim), transparent); - pointer-events: none; - } - .edge.active span { - background: linear-gradient(transparent, var(--text), transparent); - } - .resizing { - cursor: col-resize; - user-select: none; - } .welcome { margin: auto; display: flex; diff --git a/src/lib/Composer.svelte b/src/lib/Composer.svelte index 174d2ea..4b8cc95 100644 --- a/src/lib/Composer.svelte +++ b/src/lib/Composer.svelte @@ -322,8 +322,8 @@ if (document.activeElement === el) caretToEnd(); }); - // Claude exposes two extra native modes (plan / auto) between ask and edits; - // other backends keep the shared three (gated by extendedApprovalModes). + // Claude exposes two extra native modes (plan / auto) between ask and edits + // (extendedApprovalModes); JuCode adds plan to the shared three. const APPROVAL_MODES: Record = { ask: { key: 'ask', label: t('chat.approvalAsk'), desc: t('chat.approvalAskDesc'), icon: HandIcon }, plan: { key: 'plan', label: t('chat.approvalPlan'), desc: t('chat.approvalPlanDesc'), icon: ClipboardTextIcon }, @@ -338,7 +338,9 @@ ? ['ask', 'edits', 'auto', 'all'] : bcaps.extendedApprovalModes ? ['ask', 'plan', 'auto', 'edits', 'all'] - : ['ask', 'edits', 'all'] + : bcaps.planMode + ? ['ask', 'plan', 'edits', 'all'] + : ['ask', 'edits', 'all'] ).map((k) => ({ ...APPROVAL_MODES[k], checked: chat.approvalMode === k, @@ -368,7 +370,7 @@ }); } const sections = [add]; - if (bcaps.approvalModes && bcaps.extendedApprovalModes) { + if (bcaps.approvalModes && bcaps.planMode && !chat.agent) { sections.push({ label: t('chat.modeSection'), items: [ @@ -1120,13 +1122,12 @@ align-items: center; gap: 6px; min-width: 0; - animation: model-in var(--t-med) var(--ease-spring); + animation: model-in var(--t-med) var(--ease-out); } @keyframes model-in { from { opacity: 0; - transform: translateY(8px); - filter: blur(3px); + transform: translateY(4px); } } .flatbtn.model .e { diff --git a/src/lib/DeskPage.svelte b/src/lib/DeskPage.svelte index b25ee5f..577286d 100644 --- a/src/lib/DeskPage.svelte +++ b/src/lib/DeskPage.svelte @@ -1,4 +1,5 @@ + +
+ + + + + {#if menuOpen} + ((mode = k as ApprovalMode), (menuOpen = false))} + onClose={() => (menuOpen = false)} + /> + {/if} + + + +
+ + diff --git a/src/lib/PlanCard.svelte b/src/lib/PlanCard.svelte new file mode 100644 index 0000000..df6bb65 --- /dev/null +++ b/src/lib/PlanCard.svelte @@ -0,0 +1,144 @@ + + + + +
+ +
+{#if actionable && status === 'pending' && onAction} +
+{/if} + + diff --git a/src/lib/ProjectPage.svelte b/src/lib/ProjectPage.svelte index 00f48ce..6255ac1 100644 --- a/src/lib/ProjectPage.svelte +++ b/src/lib/ProjectPage.svelte @@ -1,4 +1,5 @@ -
+
{#if shownReq}
{#key shownReq.id} diff --git a/src/lib/ProposalPane.svelte b/src/lib/ProposalPane.svelte new file mode 100644 index 0000000..be93e49 --- /dev/null +++ b/src/lib/ProposalPane.svelte @@ -0,0 +1,189 @@ + + +
+ {#if plan} +
+ + {t('chat.planCard.label')} + {#if plan.status === 'approved' || plan.status === 'revising' || plan.status === 'drafting'} + {t(`chat.planCard.status.${plan.status}`)} + {/if} + + +
+
+
+ {#if plan.title}

{plan.title}

{/if} + {#each blocks.slice(0, shown) as block, i (i)} +
+ {/each} + {#if plan.status === 'drafting'}{/if} +
+
+ {#if actionable && plan.status === 'pending' && onAction} +
+ {/if} + {:else} +

{t('chat.planCard.gone')}

+ {/if} +
+ + diff --git a/src/lib/RemoteSession.svelte b/src/lib/RemoteSession.svelte index 803af6b..abb0bea 100644 --- a/src/lib/RemoteSession.svelte +++ b/src/lib/RemoteSession.svelte @@ -27,7 +27,7 @@ import { loadSession, saveSession } from '$lib/remote/cache'; import ModelMenu from '$lib/remote/ModelMenu.svelte'; import TaskStrip from '$lib/TaskStrip.svelte'; - import { parseFileHref } from '$lib/fileRefs'; + import { joinPath, parseFileHref } from '$lib/fileRefs'; import AgentRunsPanel from '$lib/AgentRunsPanel.svelte'; import TreeStructureIcon from 'phosphor-svelte/lib/TreeStructureIcon'; import type { Msg } from '$lib/chat.svelte'; @@ -62,7 +62,8 @@ onBack, onFiles, onFile, - onChanges + onChanges, + active = false }: { /** An existing session; omit to start a new one as `agent`, in `cwd`, * or as a chat. */ @@ -85,6 +86,8 @@ /** Routes daemon frames and exits for `id` here; returns an unregister. */ register: (id: string, onFrame: (raw: string) => void, onExit: () => void) => () => void; onBack: () => void; + /** The page on top of the shown tab: its approval card answers keys. */ + active?: boolean; } = $props(); const host = useHost(); const { daemon, agents: agentDirectory } = host; @@ -399,7 +402,7 @@ let draftMode = $state(null); const shownMode = $derived(draftMode ?? chat.approvalMode); const APPROVAL = $derived( - (isAgent ? ['ask', 'edits', 'auto', 'all'] : bcaps.extendedApprovalModes ? ['ask', 'plan', 'auto', 'edits', 'all'] : ['ask', 'edits', 'all']).map( + (isAgent ? ['ask', 'edits', 'auto', 'all'] : bcaps.extendedApprovalModes ? ['ask', 'plan', 'auto', 'edits', 'all'] : bcaps.planMode ? ['ask', 'plan', 'edits', 'all'] : ['ask', 'edits', 'all']).map( (k) => ({ ...APPROVAL_MODES[k], checked: shownMode === k, disabled: isAgent }) ) ); @@ -508,8 +511,7 @@ function openReplyFile(href: string) { const ref = parseFileHref(href); if (!ref.path) return; - const base = (cwd ?? '').replace(/\/+$/, ''); - onFile?.(ref.path.startsWith('/') || !base ? ref.path : `${base}/${ref.path.replace(/^\.?\//, '')}`, ref.line); + onFile?.(joinPath(cwd ?? '', ref.path), ref.line); } // The agent trace sheet (AgentRunsPanel). let traceOpen = $state(false); @@ -622,7 +624,7 @@ {#if chat.pendingApproval}
{#key chat.pendingApproval.callId} - + {/key}
{/if} @@ -931,13 +933,13 @@ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28); transform: translateX(-50%); cursor: pointer; - animation: jump-in var(--t-med) var(--ease-spring); + animation: jump-in var(--t-med) var(--ease-out); transition: transform var(--t-fast) var(--ease-spring); } @keyframes jump-in { from { opacity: 0; - transform: translateX(-50%) translateY(6px) scale(0.9); + transform: translateX(-50%) translateY(4px); } } .jump:active { @@ -1071,13 +1073,12 @@ align-items: center; gap: 6px; min-width: 0; - animation: model-in var(--t-med) var(--ease-spring); + animation: model-in var(--t-med) var(--ease-out); } @keyframes model-in { from { opacity: 0; - transform: translateY(8px); - filter: blur(3px); + transform: translateY(4px); } } .flatbtn.model .e { @@ -1100,7 +1101,7 @@ background var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out), opacity var(--t-med) var(--ease-out); - animation: pop-in var(--t-med) var(--ease-spring); + animation: pop-in var(--t-med) var(--ease-out); } .cact:active:not(:disabled) { transform: scale(0.9); diff --git a/src/lib/Sidebar.svelte b/src/lib/Sidebar.svelte index 8f1ca41..ea799e0 100644 --- a/src/lib/Sidebar.svelte +++ b/src/lib/Sidebar.svelte @@ -19,6 +19,8 @@ import CheckIcon from 'phosphor-svelte/lib/CheckIcon'; import PushPinIcon from 'phosphor-svelte/lib/PushPinIcon'; import PushPinSlashIcon from 'phosphor-svelte/lib/PushPinSlashIcon'; + import { rowIn } from '$lib/ui/motion'; + import Collapse from '$lib/ui/Collapse.svelte'; import Button from '$lib/ui/Button.svelte'; import { t } from '$lib/i18n'; import { withShortcut } from '$lib/shortcuts'; @@ -317,6 +319,7 @@ {@const req = s.chat.sessionId ? reqs.bySession.get(s.chat.sessionId) : undefined} {/if}
- {#if open} + {#each showAll[p.id] || query ? active : firstSessions(active) as s (s.id)}{@render sessRow(s, true, false, p)}{/each} {#if active.length > SHOW_LIMIT && !query} @@ -574,7 +577,7 @@ {/if} {@render archived(p, arch, true)} - {/if} +
{/if} {/each} @@ -1004,7 +1007,7 @@ color: var(--text); } .caret :global(svg) { - transition: transform var(--t-fast) var(--ease-out); + transition: transform var(--t-base) var(--ease-base); } .caret.open :global(svg) { transform: rotate(90deg); diff --git a/src/lib/ToolCard.svelte b/src/lib/ToolCard.svelte index 9023013..e74e5bd 100644 --- a/src/lib/ToolCard.svelte +++ b/src/lib/ToolCard.svelte @@ -2,7 +2,7 @@ import CircleNotchIcon from 'phosphor-svelte/lib/CircleNotchIcon'; import CaretRightIcon from 'phosphor-svelte/lib/CaretRightIcon'; import { untrack } from 'svelte'; - import { slide } from 'svelte/transition'; + import Collapse from '$lib/ui/Collapse.svelte'; import { t } from '$lib/i18n'; import Notice from '$lib/ui/Notice.svelte'; import { parseToolOutput, toolIcon, toolTarget, toolVerb, unwrapShell } from '$lib/toolSummary'; @@ -155,38 +155,40 @@ {#if trace} {/if} - {#if !collapsed && !isRead} -
- {#if errorText} -
{errorText}
- {:else if !parsed} - {#if output}
{output}
{/if} - {:else if imageSrc} - {target} - {:else if unsupportedImage} -
{t('chat.unsupportedImage')}
- {:else if kind === 'binary'} -
{t('chat.binaryFile')}{#if bytes !== null} · {fmtBytes(bytes)}{/if}
- {:else if diffLines.length} -
{#each diffLines as d (d)}{d.line}
-{/each}
- {:else if command || stdout || stderr || exitCode !== null} - {#if command}
$ {command}
{/if} - {#if stdout}
{stdout}
{/if} - {#if stderr}
{stderr}
{/if} - {:else if hasEntries} - {#if entries.length}
{entries.join('\n')}
{:else}
{t('chat.emptyDir')}
{/if} - {:else if symbols.length} -
{#each symbols as sym (sym.line)}{sym.line}  {sym.symbol}
-{/each}
- {:else if kind === 'text'} - {#if content}
{content}
{:else}
{t('chat.emptyFile')}
{/if} - {:else if bytes !== null || truncated} -
{[bytes !== null ? fmtBytes(bytes) : '', truncated ? t('chat.truncated') : ''].filter(Boolean).join(' · ')}
- {:else if fallbackText} -
{fallbackText}
- {/if} -
+ {#if !isRead} + +
+ {#if errorText} +
{errorText}
+ {:else if !parsed} + {#if output}
{output}
{/if} + {:else if imageSrc} + {target} + {:else if unsupportedImage} +
{t('chat.unsupportedImage')}
+ {:else if kind === 'binary'} +
{t('chat.binaryFile')}{#if bytes !== null} · {fmtBytes(bytes)}{/if}
+ {:else if diffLines.length} +
{#each diffLines as d (d)}{d.line}
+	{/each}
+ {:else if command || stdout || stderr || exitCode !== null} + {#if command}
$ {command}
{/if} + {#if stdout}
{stdout}
{/if} + {#if stderr}
{stderr}
{/if} + {:else if hasEntries} + {#if entries.length}
{entries.join('\n')}
{:else}
{t('chat.emptyDir')}
{/if} + {:else if symbols.length} +
{#each symbols as sym (sym.line)}{sym.line}  {sym.symbol}
+	{/each}
+ {:else if kind === 'text'} + {#if content}
{content}
{:else}
{t('chat.emptyFile')}
{/if} + {:else if bytes !== null || truncated} +
{[bytes !== null ? fmtBytes(bytes) : '', truncated ? t('chat.truncated') : ''].filter(Boolean).join(' · ')}
+ {:else if fallbackText} +
{fallbackText}
+ {/if} +
+
{/if}
@@ -218,7 +220,7 @@ .chev { display: inline-flex; color: var(--dim2); - transition: transform var(--t-med) var(--ease-spring); + transition: transform var(--t-base) var(--ease-base); flex-shrink: 0; } .chev.open { diff --git a/src/lib/about.ts b/src/lib/about.ts new file mode 100644 index 0000000..a543476 --- /dev/null +++ b/src/lib/about.ts @@ -0,0 +1,29 @@ +// This app's version, the engine it ships and where the logs are, from the +// app's own command (the `app` plugin's getVersion can be left out of a +// capability, which showed the version as … on Windows). +import { invoke } from '@tauri-apps/api/core'; + +export interface AboutApp { + version: string; + cli: string | null; + os: string; + arch: string; + logs: string; + daemon_log: string; +} + +let cached: Promise | null = null; + +export function aboutApp(): Promise { + cached ??= invoke('about_app').catch(() => null); + return cached; +} + +/** The app version ('' when unknown). */ +export async function appVersion(): Promise { + return (await aboutApp())?.version ?? ''; +} + +export function openLogsFolder(): Promise { + return invoke('open_logs_folder'); +} diff --git a/src/lib/agentImport.test.ts b/src/lib/agentImport.test.ts new file mode 100644 index 0000000..921a1ce --- /dev/null +++ b/src/lib/agentImport.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from 'vitest'; +import { + byProject, + bySource, + defaultPicks, + folderName, + importedFolders, + planMcp, + sessionKey, + skillKey, + summarize +} from './agentImport'; +import type { ImportMcp, ImportOutcome, ImportSession, ImportSkill } from './protocol'; + +const session = (over: Partial = {}): ImportSession => ({ + source: 'zcode', + id: 's1', + title: 'Fix it', + cwd: '/p/a', + updated_at_ms: 1000, + messages: 3, + imported: false, + ...over +}); +const skill = (over: Partial = {}): ImportSkill => ({ + source: 'codex', + name: 'review', + description: '', + path: '/h/.codex/skills/review', + plugin: null, + present: false, + ...over +}); +const server = (over: Partial = {}): ImportMcp => ({ + source: 'claude', + from: '', + name: 'files', + transport: 'stdio', + command: 'npx', + args: [], + url: null, + present: false, + entry: { name: 'files', transport: 'stdio', command: 'npx', enabled: true }, + ...over +}); +const outcome = (over: Partial = {}): ImportOutcome => ({ + kind: 'session', + source: 'zcode', + key: 's1', + status: 'imported', + target: 'sabc', + title: 'Fix it', + cwd: '/p/a', + engine: 'jucode', + cwd_exists: true, + error: null, + ...over +}); + +describe('grouping', () => { + it('orders tools as listed and drops empty ones', () => { + const groups = bySource([session({ source: 'omp' }), session({ source: 'claude' }), session({ source: 'omp', id: 's2' })]); + expect(groups.map((g) => [g.source, g.items.length])).toEqual([ + ['claude', 1], + ['omp', 2] + ]); + }); + + it('puts the most recently used folder first, newest conversation first', () => { + const groups = byProject([ + session({ id: 'old', cwd: '/p/a', updated_at_ms: 1 }), + session({ id: 'b', cwd: '/p/b', updated_at_ms: 5 }), + session({ id: 'new', cwd: '/p/a', updated_at_ms: 9 }) + ]); + expect(groups.map((g) => [g.cwd, g.sessions.map((s) => s.id)])).toEqual([ + ['/p/a', ['new', 'old']], + ['/p/b', ['b']] + ]); + }); + + it('names a folder by its last component', () => { + expect(folderName('/Users/me/proj/')).toBe('proj'); + expect(folderName('C:\\work\\app')).toBe('app'); + }); +}); + +describe('defaultPicks', () => { + it('skips what was imported, what JuCode has, what comes with a plugin, and every MCP server', () => { + const picks = defaultPicks({ + sessions: [session(), session({ id: 'done', imported: true })], + skills: [skill(), skill({ name: 'had', path: '/x', present: true }), skill({ name: 'pdf', path: '/y', plugin: 'pdf' })], + mcp: [server(), server({ name: 'had', present: true }), server({ name: 'notion', from: 'notion' })] + }); + expect(Object.keys(picks).sort()).toEqual( + [sessionKey(session()), skillKey(skill())].sort() + ); + }); +}); + +describe('planMcp', () => { + it('skips names already configured and the second pick of a name', () => { + const a = server(); + const b = server({ source: 'codex', name: 'files' }); + const c = server({ name: 'search' }); + const d = server({ name: 'mine' }); + const plan = planMcp([a, b, c, d], ['mine']); + expect(plan.add).toEqual([a, c]); + expect(plan.skip).toEqual([b, d]); + }); +}); + +describe('summarize', () => { + it('counts per kind and lists failures', () => { + const summary = summarize( + [ + outcome(), + outcome({ key: 's2', status: 'existing' }), + outcome({ key: 's3', status: 'failed', title: null, error: 'gone' }), + outcome({ kind: 'skill', key: '/h/skills/review', status: 'imported', cwd: null }), + outcome({ kind: 'skill', key: '/h/skills/pdf', status: 'failed', title: null, error: 'denied' }) + ], + [ + { server: server(), status: 'imported' }, + { server: server({ name: 'x' }), status: 'failed', error: 'no daemon' } + ] + ); + expect(summary.sessions).toEqual({ imported: 1, existing: 1, failed: 1 }); + expect(summary.skills).toEqual({ imported: 1, existing: 0, failed: 1 }); + expect(summary.mcp).toEqual({ imported: 1, existing: 0, failed: 1 }); + expect(summary.failures).toEqual([ + { label: 's3', error: 'gone' }, + { label: 'pdf', error: 'denied' }, + { label: 'x', error: 'no daemon' } + ]); + }); + + it('lists the folders of imported conversations that still exist, once each', () => { + expect( + importedFolders([ + outcome(), + outcome({ key: 's2', status: 'existing' }), + outcome({ key: 's3', cwd: '/p/gone', cwd_exists: false }), + outcome({ key: 's4', status: 'failed', cwd: '/p/c' }), + outcome({ key: 's5', cwd: '/p/b' }), + outcome({ kind: 'skill', cwd: null }) + ]) + ).toEqual(['/p/a', '/p/b']); + }); +}); diff --git a/src/lib/agentImport.ts b/src/lib/agentImport.ts new file mode 100644 index 0000000..0e15f85 --- /dev/null +++ b/src/lib/agentImport.ts @@ -0,0 +1,138 @@ +// Pure logic for Settings → 导入 (one-click import from the other coding +// agents): grouping what the scan found, what is picked by default, which +// MCP servers still need adding, and the summary of a run. Framework-free so +// it is unit-testable. + +import type { ImportMcp, ImportOutcome, ImportScan, ImportSession, ImportSkill, ImportSource } from './protocol'; + +export const SOURCES: ImportSource[] = ['claude', 'codex', 'opencode', 'zcode', 'omp']; + +export const SOURCE_LABELS: Record = { + claude: 'Claude Code', + codex: 'Codex', + opencode: 'opencode', + zcode: 'zcode', + omp: 'omp' +}; + +export const sessionKey = (s: ImportSession) => `session:${s.source}:${s.id}`; +export const skillKey = (s: ImportSkill) => `skill:${s.source}:${s.path}`; +export const mcpKey = (m: ImportMcp) => `mcp:${m.source}:${m.from}:${m.name}`; + +/** Last path component (either separator). */ +export function folderName(path: string): string { + return path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path; +} + +export interface ProjectGroup { + cwd: string; + sessions: ImportSession[]; +} +export interface SourceGroup { + source: ImportSource; + items: T[]; +} + +/** Items per tool, in SOURCES order; tools with nothing are left out. */ +export function bySource(items: T[]): SourceGroup[] { + return SOURCES.map((source) => ({ source, items: items.filter((x) => x.source === source) })).filter( + (g) => g.items.length > 0 + ); +} + +/** A tool's conversations per folder: the most recently used folder first, + * newest conversation first within it. */ +export function byProject(sessions: ImportSession[]): ProjectGroup[] { + const groups = new Map(); + for (const s of [...sessions].sort((a, b) => b.updated_at_ms - a.updated_at_ms)) { + const list = groups.get(s.cwd); + if (list) list.push(s); + else groups.set(s.cwd, [s]); + } + return [...groups].map(([cwd, list]) => ({ cwd, sessions: list })); +} + +/** What a scan picks by default: conversations not imported yet and skills + * JuCode does not have. Skills that come with a plugin are left for the + * user to choose: they often rely on the tool they came with. MCP servers + * are never picked by default: one runs a command, so only the user turns + * it on. */ +export function defaultPicks(scan: ImportScan): Record { + const picks: Record = {}; + for (const s of scan.sessions) if (!s.imported) picks[sessionKey(s)] = true; + for (const s of scan.skills) if (!s.present && !s.plugin) picks[skillKey(s)] = true; + return picks; +} + +export interface McpPlan { + add: ImportMcp[]; + /** A server of that name is already configured (or picked twice). */ + skip: ImportMcp[]; +} + +/** Splits the picked servers into ones to add and ones whose name JuCode + * already has; the first of two picks with one name wins. */ +export function planMcp(picked: ImportMcp[], configured: Iterable): McpPlan { + const taken = new Set(configured); + const plan: McpPlan = { add: [], skip: [] }; + for (const m of picked) { + if (taken.has(m.name)) plan.skip.push(m); + else { + taken.add(m.name); + plan.add.push(m); + } + } + return plan; +} + +export interface Tally { + imported: number; + existing: number; + failed: number; +} +export interface McpResult { + server: ImportMcp; + status: 'imported' | 'existing' | 'failed'; + error?: string; +} +export interface RunSummary { + sessions: Tally; + skills: Tally; + mcp: Tally; + /** What failed, with why. */ + failures: { label: string; error: string }[]; +} + +function tally(statuses: string[]): Tally { + return { + imported: statuses.filter((s) => s === 'imported').length, + existing: statuses.filter((s) => s === 'existing').length, + failed: statuses.filter((s) => s === 'failed').length + }; +} + +export function summarize(outcomes: ImportOutcome[], mcp: McpResult[]): RunSummary { + const of = (kind: ImportOutcome['kind']) => outcomes.filter((o) => o.kind === kind).map((o) => o.status); + return { + sessions: tally(of('session')), + skills: tally(of('skill')), + mcp: tally(mcp.map((r) => r.status)), + failures: [ + ...outcomes + .filter((o) => o.status === 'failed') + .map((o) => ({ label: o.title || (o.kind === 'skill' ? folderName(o.key) : o.key), error: o.error ?? '' })), + ...mcp.filter((r) => r.status === 'failed').map((r) => ({ label: r.server.name, error: r.error ?? '' })) + ] + }; +} + +/** Folders of the conversations a run brought over that still exist: the + * projects to show them under. */ +export function importedFolders(outcomes: ImportOutcome[]): string[] { + const out: string[] = []; + for (const o of outcomes) { + if (o.kind !== 'session' || o.status === 'failed' || !o.cwd || !o.cwd_exists) continue; + if (!out.includes(o.cwd)) out.push(o.cwd); + } + return out; +} diff --git a/src/lib/agentProgress.test.ts b/src/lib/agentProgress.test.ts new file mode 100644 index 0000000..1e04dc6 --- /dev/null +++ b/src/lib/agentProgress.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect } from 'vitest'; +import { + agentOfCall, + agentRows, + cardMode, + pillParts, + planShown, + planSummary, + rowElapsed, + shortElapsed, + shortPath, + spawnPayload, + stepState, + waitTargets, + type AgentRow +} from './agentProgress'; +import type { AgentRun, WorkflowRun } from './chat.svelte'; + +const run = (over: Partial): AgentRun => ({ + id: 'a1', + label: '', + phase: 0, + model: '', + state: 'running', + startedAt: 1000, + durationMs: 0, + tokens: 0, + toolCalls: 0, + prompt: '', + result: '', + error: '', + type: 'subagent', + toolUseId: '', + activity: '', + effort: '', + ...over +}); + +describe('plan summary', () => { + it('reads every engine’s step words as four', () => { + expect(['pending', 'in_progress', 'inProgress', 'completed', 'skipped', 'odd'].map(stepState)).toEqual([ + 'pending', + 'active', + 'active', + 'done', + 'skipped', + 'pending' + ]); + }); + + it('counts done steps and finds the current one', () => { + const p = planSummary([ + { step: 'Read', status: 'completed' }, + { step: 'Fix', status: 'in_progress' }, + { step: 'Test', status: 'pending' } + ]); + expect([p.done, p.total, p.current]).toEqual([1, 3, 1]); + // No step marked active: the first one still to do, once work began. + expect(planSummary([{ step: 'a', status: 'completed' }, { step: 'b', status: 'pending' }]).current).toBe(1); + expect(planSummary([{ step: 'a', status: 'pending' }]).current).toBe(-1); + expect(planSummary([{ step: 'a', status: 'skipped' }, { step: 'b', status: 'completed' }]).done).toBe(2); + }); + + it('shows a plan of this turn, or an open one while a turn runs', () => { + const open = planSummary([{ step: 'a', status: 'pending' }]); + const done = planSummary([{ step: 'a', status: 'completed' }]); + expect(planShown(open, true, false)).toBe(true); + expect(planShown(open, false, false)).toBe(false); + expect(planShown(done, false, true)).toBe(true); + expect(planShown(planSummary([]), true, true)).toBe(false); + }); +}); + +describe('subagent rows', () => { + it('joins the agent trace with the lifecycle and prefers the engine’s activity', () => { + const rows = agentRows({ + runs: { + workflows: [], + agents: [run({ id: '/root/scan_auth', label: '', model: 'gpt-5.5', activity: 'Read src/auth.ts', toolUseId: 'call_1' })] + }, + subagents: { '/root/scan_auth': { status: 'running', message: 'started', label: 'scan_auth', startedAt: 900 } } + }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ id: '/root/scan_auth', label: 'scan_auth', model: 'gpt-5.5', state: 'running', activity: 'Read src/auth.ts', toolUseId: 'call_1' }); + }); + + it('falls back to the subagent’s last call, then its lifecycle message, then its task', () => { + const base = { runs: { workflows: [], agents: [] } }; + const [byTool] = agentRows({ ...base, subagents: { t1: { status: 'running', message: 'Reading', label: 'Scan auth' } }, lastTool: (l) => (l === 'Scan auth' ? 'Read auth.ts' : '') }); + expect(byTool!.activity).toBe('Read auth.ts'); + const [byMessage] = agentRows({ ...base, subagents: { t1: { status: 'running', message: 'Reading the config', label: 'Scan' } } }); + expect(byMessage!.activity).toBe('Reading the config'); + const [quiet] = agentRows({ ...base, subagents: { t1: { status: 'running', message: 'started' } } }); + expect(quiet!.activity).toBe(''); + const [task] = agentRows({ runs: { workflows: [], agents: [run({ prompt: 'Find the bug\nin auth' })] }, subagents: {} }); + expect(task!.activity).toBe('Find the bug'); + }); + + it('shows a finished agent’s result and a failed one’s error', () => { + const rows = agentRows({ + runs: { workflows: [], agents: [run({ id: 'a', state: 'completed', result: 'Found 2 issues\n…' }), run({ id: 'b', state: 'failed', error: 'timeout' })] }, + subagents: {} + }); + expect(rows.map((r) => [r.state, r.activity])).toEqual([ + ['done', 'Found 2 issues'], + ['failed', 'timeout'] + ]); + }); + + it('lists a workflow as one row with its progress', () => { + const w: WorkflowRun = { + id: 'w1', + toolUseId: 'call_w', + name: 'review', + description: 'Review the diff', + status: 'running', + startedAt: 1, + durationMs: 0, + tokens: 0, + toolCalls: 0, + phases: [], + agents: [run({ id: 'x', state: 'completed', label: 'Find' }), run({ id: 'y', state: 'running', label: 'Verify' })] + }; + const [row] = agentRows({ runs: { workflows: [w], agents: [] }, subagents: {} }); + expect(row).toMatchObject({ workflow: true, label: 'review', progress: { done: 1, total: 2 }, activity: 'Verify' }); + }); + + it('keeps old finished runs out once a turn started after them', () => { + const runs = { workflows: [], agents: [run({ id: 'old', state: 'completed', startedAt: 1000 }), run({ id: 'new', startedAt: 90_000 })] }; + expect(agentRows({ runs, subagents: {}, since: 60_000 }).map((r) => r.id)).toEqual(['new']); + expect(agentRows({ runs, subagents: {} }).map((r) => r.id)).toEqual(['old', 'new']); + }); + + it('reads lifecycle-only subagents (older engines) and their time', () => { + const [row] = agentRows({ runs: { workflows: [], agents: [] }, subagents: { '/root/w': { status: 'errored', message: 'boom', startedAt: 10, endedAt: 2010 } } }); + expect(row).toMatchObject({ label: 'w', state: 'failed', error: 'boom', durationMs: 2000 }); + }); + + it('times a running agent from its start', () => { + const row = { state: 'running', startedAt: 1000, durationMs: 0, endedAt: 0 } as AgentRow; + expect(rowElapsed(row, 4000)).toBe(3000); + expect(rowElapsed({ ...row, state: 'done', durationMs: 1200 }, 9000)).toBe(1200); + expect(rowElapsed({ ...row, state: 'done', endedAt: 1500 }, 9000)).toBe(500); + }); + + it('writes running times short', () => { + expect([800, 8_400, 65_000, 3_720_000].map(shortElapsed)).toEqual(['0s', '8s', '1m05s', '1h02m']); + }); + + it('finds the agent a spawn call started', () => { + const rows = [{ toolUseId: 'c1', id: 'a' }, { toolUseId: 'c2', id: 'b' }] as AgentRow[]; + expect(agentOfCall(rows, 'c2')?.id).toBe('b'); + expect(agentOfCall(rows, '')).toBeUndefined(); + expect(shortPath('/root/scan_auth')).toBe('scan_auth'); + expect(shortPath('Scan auth')).toBe('Scan auth'); + }); +}); + +describe('spawn and wait payloads', () => { + it('reads the agent a spawn call names', () => { + expect(spawnPayload('{"task_name":"scan","path":"/root/scan","status":"running"}')).toEqual({ name: 'scan', task: '', path: '/root/scan' }); + expect(spawnPayload('{"description":"Scan auth","prompt":"Find the bug"}')).toMatchObject({ name: 'Scan auth', task: 'Find the bug' }); + expect(spawnPayload('not json')).toEqual({ name: '', task: '', path: '' }); + }); + + it('reads which agents a wait names and their states', () => { + expect(waitTargets('{"status":{"/root/a":"running","/root/b":{"completed":"ok"},"/root/c":{"errored":"x"},"/root/d":"closed"},"timed_out":true}')).toEqual([ + { id: '/root/a', state: 'running', timedOut: true }, + { id: '/root/b', state: 'done', timedOut: true }, + { id: '/root/c', state: 'failed', timedOut: true }, + { id: '/root/d', state: 'stopped', timedOut: true } + ]); + expect(waitTargets('{"description":"pong: completed\\nping: running"}').map((w) => [w.id, w.state])).toEqual([ + ['pong', 'done'], + ['ping', 'running'] + ]); + expect(waitTargets('')).toEqual([]); + }); +}); + +describe('progress card', () => { + const plan = planSummary([ + { step: 'a', status: 'completed' }, + { step: 'b', status: 'in_progress' } + ]); + const runningRow = { state: 'running' } as AgentRow; + const doneRow = { state: 'done' } as AgentRow; + const base = { plan, rows: [] as AgentRow[], busy: true, fold: 'auto' as const, endedAt: 0, now: 10_000, planFresh: true }; + + it('opens while a turn has a plan or subagents', () => { + expect(cardMode(base)).toBe('card'); + expect(cardMode({ ...base, plan: planSummary([]), rows: [runningRow] })).toBe('card'); + expect(cardMode({ ...base, plan: planSummary([]) })).toBe('hidden'); + }); + + it('folds as the user says', () => { + expect(cardMode({ ...base, fold: 'folded' })).toBe('pill'); + expect(cardMode({ ...base, busy: false, endedAt: 1, fold: 'open' })).toBe('card'); + }); + + it('folds to the pill a few seconds after the turn, unless an agent still runs', () => { + const done = planSummary([{ step: 'a', status: 'completed' }]); + const after = { ...base, plan: done, busy: false, rows: [doneRow], endedAt: 8000 }; + expect(cardMode({ ...after, now: 9000 })).toBe('card'); + expect(cardMode({ ...after, now: 12_500 })).toBe('pill'); + expect(cardMode({ ...after, now: 12_500, rows: [runningRow] })).toBe('card'); + // Reopened later (no turn ended in this run): folded straight away. + expect(cardMode({ ...after, endedAt: 0 })).toBe('pill'); + }); + + it('writes the pill', () => { + expect(pillParts(plan, [runningRow, doneRow], true)).toEqual({ steps: '1/2', agents: 2, running: 1 }); + expect(pillParts(plan, [], false).steps).toBe(''); + }); +}); diff --git a/src/lib/agentProgress.ts b/src/lib/agentProgress.ts new file mode 100644 index 0000000..7864640 --- /dev/null +++ b/src/lib/agentProgress.ts @@ -0,0 +1,309 @@ +// What the conversation shows of a turn's progress: its todo list (the plan) +// and the subagents it runs, as the progress card at the top of the chat and +// the subagent cards in the message list read them. Pure: the components pass +// in the chat's state and a clock. + +import type { AgentRun, PlanStep, SubagentInfo, WorkflowRun } from './chat.svelte'; +import { runState, type RunState } from './agentTrace'; + +export type StepState = 'pending' | 'active' | 'done' | 'skipped'; + +/** A plan step's status as one of four (engines say in_progress, inProgress, completed …). */ +export function stepState(status: string): StepState { + switch (status) { + case 'in_progress': + case 'inProgress': + case 'active': + case 'running': + return 'active'; + case 'completed': + case 'done': + return 'done'; + case 'skipped': + case 'cancelled': + case 'canceled': + return 'skipped'; + default: + return 'pending'; + } +} + +export interface PlanSummary { + steps: { text: string; state: StepState }[]; + /** Steps done or skipped. */ + done: number; + total: number; + /** The step being worked on (the first active one, else the first pending + * one while work remains); -1 when there is none. */ + current: number; +} + +export function planSummary(plan: PlanStep[]): PlanSummary { + const steps = plan.map((p) => ({ text: p.step, state: stepState(p.status) })); + const done = steps.filter((s) => s.state === 'done' || s.state === 'skipped').length; + let current = steps.findIndex((s) => s.state === 'active'); + if (current < 0 && done < steps.length && done > 0) current = steps.findIndex((s) => s.state === 'pending'); + return { steps, done, total: steps.length, current }; +} + +/** One subagent as the progress card and its spawn card show it. */ +export interface AgentRow { + /** The id its conversation is read by (agent_runs / lifecycle path). */ + id: string; + label: string; + model: string; + state: RunState; + startedAt: number; + /** Its reported running time, ms. */ + durationMs: number; + /** When this client saw it end, ms (0: not seen). */ + endedAt: number; + /** Its latest action, in one line. */ + activity: string; + /** The call that started it. */ + toolUseId: string; + prompt: string; + result: string; + error: string; + /** A Workflow (claude), not one agent: opens the trace's run list. */ + workflow?: boolean; + /** Workflow agents finished out of all. */ + progress?: { done: number; total: number }; +} + +const firstLine = (s: string) => s.split('\n').find((l) => l.trim())?.trim() ?? ''; + +/** Lifecycle words that are not news (an agent queued, started, finished). */ +const QUIET = new Set(['', 'reserved', 'started', 'finished', 'queued message', 'close requested']); + +export interface RowInput { + runs: { workflows: WorkflowRun[]; agents: AgentRun[] }; + subagents: Record; + /** The latest call a claude subagent made, by its label, in one line. */ + lastTool?: (label: string) => string; + /** Only runs started since (ms), besides the running ones and the ones the + * lifecycle still lists; 0 keeps every run. */ + since?: number; +} + +/** The subagents to show, oldest first: the agent trace's runs (when the engine + * sends one), joined with what their lifecycle events say, then the + * subagents only the lifecycle (or a spawn call) reports. */ +export function agentRows({ runs, subagents, lastTool, since = 0 }: RowInput): AgentRow[] { + const rows: AgentRow[] = []; + const seen = new Set(); + // A few seconds of slack: the engine stamps a run with its own clock. + const recent = (state: RunState, startedAt: number, id: string) => + state === 'running' || state === 'queued' || id in subagents || !since || startedAt >= since - 5000; + for (const w of runs.workflows) { + const state = runState(w.status); + if (!recent(state, w.startedAt, w.id)) continue; + seen.add(w.id); + const done = w.agents.filter((a) => !['running', 'queued'].includes(runState(a.state))).length; + const live = w.agents.filter((a) => runState(a.state) === 'running'); + rows.push({ + id: w.id, + label: w.name || firstLine(w.description) || w.id, + model: '', + state, + startedAt: w.startedAt, + durationMs: w.durationMs, + endedAt: 0, + activity: live.length ? live.map((a) => a.label).filter(Boolean).join(' · ') : firstLine(w.description), + toolUseId: w.toolUseId, + prompt: w.description, + result: '', + error: '', + workflow: true, + progress: { done, total: w.agents.length } + }); + } + for (const a of runs.agents) { + const life = subagents[a.id]; + const state = runState(a.state || life?.status || ''); + if (!a.id || !recent(state, a.startedAt, a.id)) continue; + seen.add(a.id); + const label = a.label || life?.label || a.id; + rows.push({ + id: a.id, + label: shortPath(label), + model: a.model || life?.model || '', + state, + startedAt: a.startedAt || life?.startedAt || 0, + durationMs: a.durationMs, + endedAt: life?.endedAt ?? 0, + activity: activityOf(state, a.activity, lastTool?.(label) ?? '', life?.message ?? '', a.result, a.error, a.prompt), + toolUseId: a.toolUseId || life?.toolUseId || '', + prompt: a.prompt, + result: a.result, + error: a.error + }); + } + for (const [id, life] of Object.entries(subagents)) { + if (seen.has(id)) continue; + const state = runState(life.status); + const label = life.label || id; + rows.push({ + id, + label: shortPath(label), + model: life.model ?? '', + state, + startedAt: life.startedAt ?? 0, + durationMs: life.endedAt && life.startedAt ? life.endedAt - life.startedAt : 0, + endedAt: life.endedAt ?? 0, + activity: activityOf(state, '', lastTool?.(label) ?? '', life.message, '', state === 'failed' ? life.message : '', ''), + toolUseId: life.toolUseId ?? '', + prompt: '', + result: '', + error: state === 'failed' ? life.message : '' + }); + } + return rows; +} + +/** A JuCode agent path (`/root/scan_auth`) by its own name. */ +export const shortPath = (label: string) => (label.startsWith('/') ? label.split('/').filter(Boolean).pop() || label : label); + +function activityOf( + state: RunState, + activity: string, + tool: string, + message: string, + result: string, + error: string, + prompt: string +): string { + if (state === 'failed' && error) return firstLine(error); + if (state === 'done' && result) return firstLine(result); + const said = QUIET.has(message) ? '' : message; + return firstLine(activity) || tool || firstLine(said) || (state === 'running' || state === 'queued' ? firstLine(prompt) : ''); +} + +/** How long an agent has run (running: since it started), ms. */ +export function rowElapsed(row: AgentRow, now: number): number { + if (row.state === 'running' && row.startedAt > 0) return Math.max(row.durationMs, now - row.startedAt); + if (row.durationMs > 0) return row.durationMs; + return row.endedAt && row.startedAt ? row.endedAt - row.startedAt : 0; +} + +/** The subagent a spawn call started (by the call's id). */ +export const agentOfCall = (rows: AgentRow[], callId: string) => (callId ? rows.find((r) => r.toolUseId === callId) : undefined); + +/** Tool names that start a subagent, and the ones that wait for subagents. */ +export const SPAWN_TOOLS = new Set(['spawn_agent', 'Task', 'Agent']); +export const WAIT_TOOLS = new Set(['wait_agent', 'agent_wait']); + +/** What a spawn call's own payload says (its arguments or its output): + * the agent's name and task, for an engine that sends no trace. */ +export function spawnPayload(output: string): { name: string; task: string; path: string } { + const o = parse(output); + const s = (k: string) => (typeof o?.[k] === 'string' ? (o[k] as string) : ''); + return { + name: s('description') || s('task_name') || s('name'), + task: s('prompt') || s('message') || s('task'), + path: s('path') + }; +} + +export type WaitState = RunState | 'unknown'; + +/** The agents a wait call names and their states, from its output: + * `{"status": {"/root/x": "running" | {"completed": …} | {"errored": …}}}` + * (JuCode), or Codex's `{"description": "label: status\n…"}`. */ +export function waitTargets(output: string): { id: string; state: WaitState; timedOut: boolean }[] { + const o = parse(output); + if (!o) return []; + const timedOut = o.timed_out === true; + if (o.status && typeof o.status === 'object' && !Array.isArray(o.status)) { + return Object.entries(o.status as Record).map(([id, v]) => ({ + id, + state: + typeof v === 'string' + ? runState(v === 'errored' ? 'failed' : v === 'interrupted' || v === 'closed' ? 'stopped' : v) + : v && typeof v === 'object' + ? 'completed' in v + ? 'done' + : 'errored' in v + ? 'failed' + : 'unknown' + : 'unknown', + timedOut + })); + } + if (typeof o.description === 'string') { + return o.description + .split(/\n|,\s*/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const m = /^(.*?):\s*(\w+)$/.exec(line); + return m ? { id: m[1]!, state: runState(m[2]!), timedOut } : { id: line, state: 'unknown' as const, timedOut }; + }); + } + return []; +} + +function parse(output: string): Record | null { + try { + const v = JSON.parse(output); + return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null; + } catch { + return null; + } +} + +/** The card's look: hidden (nothing to show), open, or folded to its pill. */ +export type CardMode = 'hidden' | 'card' | 'pill'; + +/** What the user chose for this session's card: follow the turn, keep it + * open, or keep it folded. */ +export type CardFold = 'auto' | 'open' | 'folded'; + +export interface CardInput { + plan: PlanSummary; + rows: AgentRow[]; + busy: boolean; + fold: CardFold; + /** The turn ended at (ms; 0: none ended in this run), and now. */ + endedAt: number; + now: number; + /** The plan changed during this run's latest turn. */ + planFresh: boolean; + /** How long the card stays open once the turn is over, ms. */ + linger?: number; +} + +/** Whether the plan is worth showing: it changed in this turn, or the turn + * running now still has steps to do. */ +export const planShown = (plan: PlanSummary, busy: boolean, planFresh: boolean) => + plan.total > 0 && (planFresh || (busy && plan.done < plan.total)); + +/** Shown while a turn has a plan or subagents. It folds to its pill when the + * user folds it, or a few seconds after the turn is over with no agent + * still running; the pill stays until the next turn. */ +export function cardMode({ plan, rows, busy, fold, endedAt, now, planFresh, linger = 4000 }: CardInput): CardMode { + if (!planShown(plan, busy, planFresh) && !rows.length) return 'hidden'; + if (fold === 'folded') return 'pill'; + if (fold === 'open') return 'card'; + const live = rows.some((r) => r.state === 'running' || r.state === 'queued'); + const over = !busy && !live && (endedAt === 0 || now - endedAt >= linger); + return over ? 'pill' : 'card'; +} + +/** The pill's text parts: steps done of all, and subagents (running ones while any run). */ +export function pillParts(plan: PlanSummary, rows: AgentRow[], showPlan: boolean): { steps: string; agents: number; running: number } { + return { + steps: showPlan ? `${plan.done}/${plan.total}` : '', + agents: rows.length, + running: rows.filter((r) => r.state === 'running' || r.state === 'queued').length + }; +} + +/** A running time as the cards show it: 8s, 1m05s, 1h02m. */ +export function shortElapsed(ms: number): string { + const s = Math.max(0, Math.floor(ms / 1000)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`; + return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`; +} diff --git a/src/lib/agentTrace.test.ts b/src/lib/agentTrace.test.ts index 1af18d6..a52e5d1 100644 --- a/src/lib/agentTrace.test.ts +++ b/src/lib/agentTrace.test.ts @@ -16,7 +16,9 @@ const agent = (id: string, startedAt: number, durationMs: number, state = 'done' result: '', error: '', type: '', - toolUseId: '' + toolUseId: '', + activity: '', + effort: '' }); describe('agent trace', () => { diff --git a/src/lib/agentTrace.ts b/src/lib/agentTrace.ts index 6542b3f..81b3c4d 100644 --- a/src/lib/agentTrace.ts +++ b/src/lib/agentTrace.ts @@ -20,11 +20,14 @@ export function runState(state: string): RunState { return 'done'; case 'failed': case 'error': + case 'errored': return 'failed'; case 'stopped': case 'killed': case 'cancelled': case 'skipped': + case 'interrupted': + case 'closed': return 'stopped'; default: return state ? 'queued' : 'done'; diff --git a/src/lib/agents/AgentTranscript.svelte b/src/lib/agents/AgentTranscript.svelte new file mode 100644 index 0000000..cec8441 --- /dev/null +++ b/src/lib/agents/AgentTranscript.svelte @@ -0,0 +1,130 @@ + + +{#if transcript?.error} +

{transcript.error}

+{:else if !transcript} +

{t('dock.agents.loading')}

+{:else} + {#if transcript.task} +
+ +
+
+ {/if} + {#if transcript.messages.length} +
+ {}} + onRewind={() => {}} + /> +
+ {:else} +

{live ? t('dock.agents.loading') : t('dock.agents.noMessages')}

+ {/if} +{/if} + + diff --git a/src/lib/agents/Elapsed.svelte b/src/lib/agents/Elapsed.svelte new file mode 100644 index 0000000..65d63f1 --- /dev/null +++ b/src/lib/agents/Elapsed.svelte @@ -0,0 +1,29 @@ + + +{#if ms > 0 || live}{shortElapsed(ms)}{/if} + + diff --git a/src/lib/agents/ProgressCard.svelte b/src/lib/agents/ProgressCard.svelte new file mode 100644 index 0000000..5559682 --- /dev/null +++ b/src/lib/agents/ProgressCard.svelte @@ -0,0 +1,360 @@ + + +{#if mode === 'pill'} + +{:else if mode === 'card'} +
+
+ {t('chat.progress.label')} + {#if showPlan}{t('chat.progress.count', { done: plan.done, total: plan.total })}{/if} + + +
+
+ {#if showPlan} +
{t('chat.progress.todo')}
+
    + {#each plan.steps as s, i (i)} +
  1. + + {s.text} +
  2. + {/each} +
+ {/if} + {#if rows.length} +
{t('chat.progress.agents')}{rows.length}
+
    + {#each rows as r (r.id)} +
  • + +
  • + {/each} +
+ {/if} +
+
+{/if} + + diff --git a/src/lib/agents/StateIcon.svelte b/src/lib/agents/StateIcon.svelte new file mode 100644 index 0000000..5cb255d --- /dev/null +++ b/src/lib/agents/StateIcon.svelte @@ -0,0 +1,49 @@ + + + + + + + diff --git a/src/lib/agents/SubagentCard.svelte b/src/lib/agents/SubagentCard.svelte new file mode 100644 index 0000000..215bec2 --- /dev/null +++ b/src/lib/agents/SubagentCard.svelte @@ -0,0 +1,248 @@ + + +{#if wait} +
+
+ + {waitingOn.length ? t('chat.subagentCard.waitN', { n: waitingOn.length }) : t('chat.subagentCard.wait')} + {#if timedOut}{t('chat.subagentCard.timedOut')}{/if} + {#if running}{/if} +
+ {#if waitingOn.length} +
+ {#each waitingOn as w (w.id)} + + {/each} +
+ {/if} +
+{:else} + +{/if} + + diff --git a/src/lib/app.css b/src/lib/app.css index eb65ac7..3b4ff2e 100644 --- a/src/lib/app.css +++ b/src/lib/app.css @@ -310,17 +310,21 @@ body { } /* ---------- Motion system ---------- One set of entrances for the whole app, all on the --t-* durations and the - --ease-* curves above: + --ease-* curves above. Things fade and move a few pixels; nothing scales, + bounces or runs longer than --t-med (layout: --t-sidebar): scrim-in backdrops behind modals --t-fast --ease-out - sheet-in modals and sheets (settings, desk, …) --t-med --ease-spring - pop-in popovers that open upward from an anchor (composer) --t-med --ease-spring - drop-in menus that open downward from an anchor (title bar) --t-med --ease-spring - pane-in a session or panel coming into view --t-med --ease-out + sheet-in modals and sheets --t-med --ease-out + pop-in popovers that open upward from an anchor (composer) --t-med --ease-out + drop-in menus that open downward from an anchor (title bar) --t-med --ease-out + pane-in a session, panel or page coming into view --t-med --ease-out rise/fade small in-flow reveals (tray, labels) --t-fast/med --ease-out spin loading icons (class .spin) 0.8s linear, infinite pulse live / recording indicators (class .pulse) 1.2s, infinite - Exit animations (modals, toasts) use the Svelte transitions in ui/motion.ts. - Reduced motion collapses all of them (see the media query at the end). */ + The sidebar's width moves on --t-sidebar / --ease-standard (Sidebar.svelte). + Exits (pages, settings sections, modals, toasts) and sidebar rows arriving + use the Svelte transitions in ui/motion.ts. Reduced motion collapses all of + them (see the media query at the end); a window being resized runs no + transitions (.win-resizing, +layout.svelte). */ .pulse { animation: pulse 1.2s var(--ease-in-out) infinite; } @@ -332,13 +336,13 @@ body { @keyframes sheet-in { from { opacity: 0; - transform: translateY(10px) scale(0.985); + transform: translateY(6px); } } @keyframes drop-in { from { opacity: 0; - transform: translateY(-6px) scale(0.97); + transform: translateY(-4px); } } @keyframes pane-in { @@ -432,7 +436,7 @@ body { @keyframes pop-in { from { opacity: 0; - transform: translateY(6px) scale(0.97); + transform: translateY(4px); } to { opacity: 1; @@ -443,6 +447,14 @@ body { animation: spin 0.8s linear infinite; } +/* While the window is resized (or zoomed), layout jumps to its new size: + nothing transitions along with it. */ +:root.win-resizing *, +:root.win-resizing *::before, +:root.win-resizing *::after { + transition: none !important; +} + /* Respect users who prefer reduced motion: drop animations/transitions and smooth scrolling (keeps a tiny duration so animationend handlers still fire). */ @media (prefers-reduced-motion: reduce) { diff --git a/src/lib/backends/caps.ts b/src/lib/backends/caps.ts index f0b1dd5..5fdc9a5 100644 --- a/src/lib/backends/caps.ts +++ b/src/lib/backends/caps.ts @@ -7,6 +7,7 @@ import type { BackendCaps } from './types'; export const CLAUDE_CAPS: BackendCaps = { approvalModes: true, // set_permission_mode control request (live, acked) extendedApprovalModes: true, // native plan + auto permission modes + planMode: true, hunkApproval: true, // MultiEdit splits into per-edit hunks (single Edit stays whole-call) steer: false, // stdin is already a queue: mid-turn messages run as the next turn interrupt: true, // control_request subtype interrupt @@ -33,6 +34,7 @@ export const CLAUDE_CAPS: BackendCaps = { export const CODEX_CAPS: BackendCaps = { approvalModes: true, // thread/start approvalPolicy+sandbox, per-turn overrides extendedApprovalModes: true, // plan: collaborationMode plan; auto: the auto_review approvals reviewer + planMode: true, hunkApproval: false, // codex approvals are whole-patch accept/decline steer: true, // the daemon holds mid-turn messages; turn/steer joins them to the running turn interrupt: true, // turn/interrupt @@ -57,6 +59,7 @@ export const CODEX_CAPS: BackendCaps = { export const ACP_CAPS: BackendCaps = { approvalModes: false, // ACP session modes are agent-defined ids — no safe mapping extendedApprovalModes: false, + planMode: false, hunkApproval: false, // permission responses are whole-call option picks steer: false, // no mid-turn injection; queued messages run as the next turn interrupt: true, // session/cancel (core protocol, all agents) diff --git a/src/lib/backends/jucode.test.ts b/src/lib/backends/jucode.test.ts index ef3c651..1343dd8 100644 --- a/src/lib/backends/jucode.test.ts +++ b/src/lib/backends/jucode.test.ts @@ -21,6 +21,16 @@ describe('jucode adapter (passthrough)', () => { expect(sent('full-auto')).toBe('full-access'); expect(adapter.translate({ type: 'approval_mode', mode: 'manual' })[0].mode).toBe('read-only'); expect(adapter.translate({ type: 'approval_mode', mode: 'full-access' })[0].mode).toBe('full-auto'); + // Plan mode is the same word on both sides. + expect(sent('plan')).toBe('plan'); + expect(adapter.translate({ type: 'approval_mode', mode: 'plan' })[0].mode).toBe('plan'); + }); + + it('runs an approved plan in the engine’s mode', () => { + const line = (op: Op) => JSON.parse(adapter.encodeOp(op)![0]); + expect(line({ op: 'approve_plan', id: 'p1', decision: 'approve', mode: 'read-only' })).toEqual({ op: 'approve_plan', id: 'p1', decision: 'approve', mode: 'manual' }); + expect(line({ op: 'approve_plan', id: 'p1', decision: 'approve', mode: 'full-auto' }).mode).toBe('full-access'); + expect(line({ op: 'approve_plan', id: 'p1', decision: 'revise', feedback: 'smaller' })).toEqual({ op: 'approve_plan', id: 'p1', decision: 'revise', feedback: 'smaller' }); }); it('declares every capability', () => { @@ -29,12 +39,13 @@ describe('jucode adapter (passthrough)', () => { // permission modes), not a superset capability — the native engine uses the // shared read-only/auto-edit/full-auto trio, so it is legitimately false. // mcpEngineOwned / ruleScopes describe claude's own config, not features - // the native engine lacks. + // the native engine lacks. planMode waits for the CLI release that adds it. for (const [key, value] of Object.entries(JUCODE_CAPS)) { - if (key === 'extendedApprovalModes' || key === 'mcpEngineOwned' || key === 'ruleScopes' || key === 'sideQuestions' || key === 'agentTrace') continue; + if (key === 'extendedApprovalModes' || key === 'mcpEngineOwned' || key === 'ruleScopes' || key === 'sideQuestions' || key === 'agentTrace' || key === 'planMode') continue; expect(value, `cap ${key} must be true for the native engine`).toBe(true); } expect(JUCODE_CAPS.extendedApprovalModes).toBe(false); + expect(JUCODE_CAPS.planMode).toBe(false); expect(adapter.caps).toEqual(JUCODE_CAPS); }); diff --git a/src/lib/backends/jucode.ts b/src/lib/backends/jucode.ts index f3c957d..3795c25 100644 --- a/src/lib/backends/jucode.ts +++ b/src/lib/backends/jucode.ts @@ -1,7 +1,8 @@ // Native-engine adapter: the desktop's Op / AgentEvent dialect is the jucode // wire protocol, so events pass through untouched except for two things: the // `hello` version frame, and approval-mode names (the engine says manual / -// full-access where the desktop's shared trio says read-only / full-auto). +// full-access where the desktop says read-only / full-auto; plan and auto are +// the same on both sides). import type { Op } from '$lib/protocol'; import type { BackendCaps, EngineAdapter, NormalizedEvent } from './types'; @@ -9,6 +10,9 @@ import type { BackendCaps, EngineAdapter, NormalizedEvent } from './types'; export const JUCODE_CAPS: BackendCaps = { approvalModes: true, extendedApprovalModes: false, + // Plan mode (proposed_plan cards, approve_plan): off until the bundled CLI + // is the release that adds it; 0.4.11 rejects approval mode `plan`. + planMode: false, hunkApproval: true, steer: true, interrupt: true, @@ -27,7 +31,9 @@ export const JUCODE_CAPS: BackendCaps = { mcpEngineOwned: false, ruleScopes: false, sideQuestions: false, - agentTrace: false + // agent_runs / subagent_transcript (an older engine refuses both: ChatState + // drops the refusal and the spawn calls report the agents). + agentTrace: true }; /** Wire protocol version this desktop speaks (`hello.protocol`). */ @@ -37,6 +43,7 @@ const TO_ENGINE_MODE: Record = { 'read-only': 'manual', 'auto-edit': 'auto-edit', auto: 'auto', + plan: 'plan', 'full-auto': 'full-access' }; const FROM_ENGINE_MODE: Record = { @@ -71,6 +78,9 @@ export function createJucodeAdapter(): EngineAdapter { if (op.op === 'set_approval_mode') { return [JSON.stringify({ ...op, mode: TO_ENGINE_MODE[op.mode] ?? op.mode })]; } + if (op.op === 'approve_plan' && op.mode) { + return [JSON.stringify({ ...op, mode: TO_ENGINE_MODE[op.mode] ?? op.mode })]; + } return [JSON.stringify(op)]; } }; diff --git a/src/lib/backends/types.ts b/src/lib/backends/types.ts index c220d98..9f39b9b 100644 --- a/src/lib/backends/types.ts +++ b/src/lib/backends/types.ts @@ -43,6 +43,9 @@ export interface BackendCaps { /** Backend exposes claude's extra approval modes ('plan' + 'auto') in the * picker, on top of the shared ask/edits/all trio. */ extendedApprovalModes: boolean; + /** A plan approval mode: the agent researches and proposes a plan, and + * works once the user approves it (claude, codex and jucode). */ + planMode: boolean; /** Per-hunk partial approval of edit tools (approve op with `hunks`). */ hunkApproval: boolean; /** Queue-jumping a busy turn (steer op + queued-messages strip). */ diff --git a/src/lib/chat.svelte.ts b/src/lib/chat.svelte.ts index baf26e5..963d022 100644 --- a/src/lib/chat.svelte.ts +++ b/src/lib/chat.svelte.ts @@ -75,9 +75,15 @@ export type Msg = isError: boolean; /** The Task subagent that made this call (claude). */ subagent?: string; + /** A subagent call's arguments as the engine showed them before its + * output replaced them (the agent's name and task). */ + args?: string; } | { kind: 'system'; text: string } - | { kind: 'error'; text: string }; + | { kind: 'error'; text: string } + /** A plan the engine proposes in plan mode (`proposed_plan`): `text` is + * its Markdown; status pending / approved / revising. */ + | { kind: 'plan'; id: string; title: string; text: string; status: string }; export interface TreeNode { id: string; @@ -162,6 +168,30 @@ function fallbackReason(reason: string): string { return t(`chat.fallbackReason.${known.includes(reason) ? reason : 'other'}`); } +/** A subagent as its lifecycle events (and, on an older JuCode engine, its + * spawn_agent call) report it: by path / task id. */ +export interface SubagentInfo { + status: string; + message: string; + label?: string; + model?: string; + /** The call that started it (spawn_agent / Agent). */ + toolUseId?: string; + /** When this client first saw it, and saw it end (ms). */ + startedAt?: number; + endedAt?: number; +} + +/** The lifecycle words of an agent that has stopped for good. */ +const FINAL_AGENT = ['completed', 'done', 'failed', 'errored', 'stopped', 'interrupted', 'closed', 'killed', 'cancelled']; + +/** Calls that start a subagent: their arguments name it. */ +const SUBAGENT_CALLS = new Set(['spawn_agent', 'Task', 'Agent']); + +/** A proposed plan's status as the engine words it. */ +const PLAN_STATUS = ['pending', 'approved', 'revising']; +const planStatus = (v: unknown) => (typeof v === 'string' && PLAN_STATUS.includes(v) ? v : 'pending'); + /** One agent of a Workflow, or a Task subagent (claude's agent trace). */ export interface AgentRun { id: string; @@ -181,6 +211,9 @@ export interface AgentRun { /** Task subagents: the subagent type and the Agent call that started it. */ type: string; toolUseId: string; + /** Its latest action in one line (jucode), and its reasoning effort. */ + activity: string; + effort: string; } export interface WorkflowRun { id: string; @@ -212,7 +245,9 @@ function agentRun(raw: Record): AgentRun { result: str(raw.result) || str(raw.summary), error: str(raw.error), type: str(raw.type), - toolUseId: str(raw.tool_use_id) + toolUseId: str(raw.tool_use_id), + activity: str(raw.activity), + effort: str(raw.effort) }; } @@ -240,6 +275,31 @@ export interface TurnDiff { * `delayMs` from `at` (ms), because of `reason` (the provider's error). */ /** What an automatic retry sends when the failed turn is kept (autoRetry.ts). * Recognised again in a reloaded transcript, where it shows as a note. */ +/** What a tool card keeps of an output in memory: a long one (a build log, + * a big file read) keeps its head and tail. Structured (JSON) outputs are + * kept whole when they fit, else their string fields are cut the same way, + * so a card still parses them. The engine's session keeps the full output. */ +const TOOL_OUTPUT_MAX = 64 * 1024; +export function boundedOutput(output: string): string { + if (output.length <= TOOL_OUTPUT_MAX) return output; + const cut = (text: string, max: number) => + text.length <= max ? text : `${text.slice(0, max / 2)}\n… (${text.length - max} characters not shown) …\n${text.slice(-max / 2)}`; + try { + const value = JSON.parse(output) as unknown; + if (value && typeof value === 'object' && !Array.isArray(value)) { + const out: Record = {}; + // An image's pixels are kept whole (cut, they would not decode). + for (const [k, v] of Object.entries(value as Record)) + out[k] = typeof v === 'string' && k !== 'base64' ? cut(v, TOOL_OUTPUT_MAX / 2) : v; + const text = JSON.stringify(out); + if (text.length <= TOOL_OUTPUT_MAX * 2 || typeof out.base64 === 'string') return text; + } + } catch { + /* plain text */ + } + return cut(output, TOOL_OUTPUT_MAX); +} + export const AUTO_CONTINUE = '继续(连接中断后自动重试)'; export interface RetryState { @@ -355,8 +415,24 @@ export class ChatState { inTerminal = $state(false); /** An MCP server's sign-in page, to open once (codex mcp_login). */ mcpLoginUrl = $state(''); - /** Subagents by id; `label` names them when the id doesn't (claude's task ids). */ - subagents = $state>({}); + /** Subagents by id; `label` names them when the id doesn't (claude's task ids). + * Finished ones stay until the next turn starts. */ + subagents = $state>({}); + /** The latest tool call each Task subagent made, by its label (claude + * attributes the calls of a subagent to it, `tool_start.subagent`). */ + subagentLastTool = $state>>({}); + /** The engine has sent an agent trace (`agent_runs`): it answers the trace ops + * (an older JuCode engine refuses them). */ + agentRunsSeen = $state(false); + /** When the current (or last) turn started and ended, ms (0: not this run). */ + turnStartedAt = $state(0); + turnEndedAt = $state(0); + /** When the plan last changed, ms. */ + planAt = $state(0); + /** The proposed plan the next message revises (its id); null otherwise. */ + planRevising = $state(null); + /** The mode before plan mode, which approving a plan offers first. */ + modeBeforePlan = $state(null); /** Background work of a claude session (Workflow, background shell or * agent, Monitor), as the engine last listed it; `message` is the latest * progress line. Per engine process: a restart clears it. */ @@ -410,6 +486,9 @@ export class ChatState { // Per user-turn file edits (path → added/removed line counts), keyed by the // 0-based user-turn index — drives the turn-level diff timeline. turnEdits = $state>>({}); + /** The agent's own edits per file (the edit tools' unified diffs, oldest + * first), so 还原 can undo exactly them and keep the user's changes. */ + agentDiffs: Record = {}; // Approval policy, enforced engine-side ('ask' ↔ read-only, 'edits' ↔ // auto-edit, 'all' ↔ full-auto). The engine is the source of truth: this // mirrors its `approval_mode` events, except right after engine startup where @@ -512,6 +591,7 @@ export class ChatState { * for pushing it to the engine via `set_approval_mode`). Also invoked by the * approval_mode event handler so engine-driven changes persist too. */ setApprovalMode(mode: ApprovalMode) { + if (mode === 'plan' && this.approvalMode !== 'plan') this.modeBeforePlan = this.approvalMode; this.approvalMode = mode; try { localStorage.setItem('jucode-approval-mode', mode); @@ -582,6 +662,7 @@ export class ChatState { /** Stamp the turn's total elapsed onto its last assistant message. */ #endTurn() { + if (this.#turnStart !== null) this.turnEndedAt = Date.now(); this.#collapseReasoning(); if (this.#sending?.state !== 'failed') this.#setSend(null); this.call = null; @@ -693,6 +774,10 @@ export class ChatState { #startTurn() { if (this.#turnStart !== null) return; this.#turnStart = Date.now(); + this.turnStartedAt = this.#turnStart; + this.turnEndedAt = 0; + // The agents a finished turn ran leave with it. + for (const [id, a] of Object.entries(this.subagents)) if (a.endedAt) delete this.subagents[id]; this.#segStart = this.#turnStart; this.#firstOutput = null; this.#turnIn = this.#turnOut = this.#turnTools = this.#turnSegments = 0; @@ -916,6 +1001,30 @@ export class ChatState { this.commands = p.commands ?? []; } + /** A spawn_agent call finished: an engine that reports its subagents + * only there (JuCode before the agent trace) names the agent in the + * output (`path`, `task_name`, `status`). */ + #spawned(callId: string, output: string) { + let out: Record; + try { + out = JSON.parse(output) as Record; + } catch { + return; + } + const path = str(out.path); + if (!path) return; + const prev = this.subagents[path]; + this.subagents[path] = { + status: prev?.status ?? (str(out.status) || 'running'), + message: prev?.message ?? '', + label: prev?.label ?? (str(out.task_name) || undefined), + ...(prev?.model ? { model: prev.model } : {}), + toolUseId: prev?.toolUseId ?? callId, + startedAt: prev?.startedAt ?? Date.now(), + ...(prev?.endedAt ? { endedAt: prev.endedAt } : {}) + }; + } + handle(ev: AgentEvent) { // The engine has spoken — the child is up, so the boot animation ends. this.booting = false; @@ -1071,11 +1180,15 @@ export class ChatState { // once the card has read it (tool_update / tool_output never showed). const pushed = this.messages[this.messages.length - 1]; if (callId && pushed?.kind === 'tool') this.#toolsByCallId.set(callId, pushed); + if (pushed?.kind === 'tool' && pushed.subagent) this.subagentLastTool[pushed.subagent] = pushed; } break; case 'tool_update': { const t = this.#tool(str(ev.call_id)); - if (t) t.output = str(ev.output); + if (t) { + t.output = boundedOutput(str(ev.output)); + if (SUBAGENT_CALLS.has(t.name)) t.args = t.output; + } break; } case 'tool_output': { @@ -1087,10 +1200,11 @@ export class ChatState { this.#segStart = Date.now(); const t = this.#tool(str(ev.call_id)); if (t) { - t.output = str(ev.output); + t.output = boundedOutput(str(ev.output)); t.running = false; t.isError = ev.is_error === true; } + if (str(ev.name) === 'spawn_agent' && ev.is_error !== true) this.#spawned(str(ev.call_id), str(ev.output)); // A successful browser_open acknowledgement carries the URL the agent // wants shown — drive the embedded browser panel. if (ev.is_error !== true && str(ev.name) === 'browser_open') { @@ -1107,6 +1221,13 @@ export class ChatState { const out = JSON.parse(str(ev.output)) as Record; const paths = [out.path, ...(Array.isArray(out.paths) ? out.paths : [])]; const edited: string[] = []; + // One file's edit: keep its diff (bounded) for an exact undo, + // by the project-relative name its diff header carries. + const label = /^\+\+\+ b\/(.+)$/m.exec(str(out.diff))?.[1]; + if (label && typeof out.path === 'string' && !Array.isArray(out.paths)) { + const list = (this.agentDiffs[label] ??= []); + if (list.length < 200) list.push(str(out.diff)); + } for (const p of paths) { if (typeof p === 'string' && p) { edited.push(p); @@ -1253,6 +1374,8 @@ export class ChatState { isError: false }; if (role === 'branch') return { kind: 'system', text: `branch: ${str(it.label)}` }; + if (role === 'plan') + return { kind: 'plan', id: str(it.id), title: str(it.title), text: str(it.content), status: planStatus(it.status) }; return null; }) .filter((m): m is Msg => m !== null); @@ -1308,9 +1431,12 @@ export class ChatState { case 'goal': this.goal = ev.goal ? (ev.goal as unknown as Goal) : null; break; - case 'plan': - this.plan = arr(ev.plan); + case 'plan': { + const next = arr(ev.plan); + if (JSON.stringify(next) !== JSON.stringify(this.plan)) this.planAt = Date.now(); + this.plan = next; break; + } case 'approval_request': this.pendingApproval = { callId: str(ev.call_id), @@ -1390,15 +1516,49 @@ export class ChatState { } case 'subagent_lifecycle': { const path = str(ev.path); - // A labelled (claude) subagent that ended leaves the strip: its - // card holds the result. - if (path && str(ev.label) && ['completed', 'failed', 'stopped'].includes(str(ev.status))) delete this.subagents[path]; - else if (path) - this.subagents[path] = { - status: str(ev.status), - message: str(ev.message), - ...(str(ev.label) ? { label: str(ev.label) } : {}) - }; + if (!path) break; + const prev = this.subagents[path]; + // `message`: a message was queued for it, its state is unchanged. + const status = str(ev.status) === 'message' ? (prev?.status ?? 'running') : str(ev.status); + const now = Date.now(); + const ended = FINAL_AGENT.includes(status); + this.subagents[path] = { + status, + message: str(ev.message) || (str(ev.status) === 'message' ? (prev?.message ?? '') : ''), + ...((str(ev.label) || prev?.label) ? { label: str(ev.label) || prev?.label } : {}), + ...((str(ev.model) || prev?.model) ? { model: str(ev.model) || prev?.model } : {}), + ...((str(ev.tool_use_id) || prev?.toolUseId) ? { toolUseId: str(ev.tool_use_id) || prev?.toolUseId } : {}), + startedAt: prev?.startedAt ?? now, + ...(ended ? { endedAt: prev?.endedAt ?? now } : {}) + }; + break; + } + // The plan as the model writes it: one message grows, and the + // proposed_plan with the same id completes it. + case 'plan_draft': { + const id = str(ev.id); + const known = this.messages.find((m) => m.kind === 'plan' && m.id === id); + if (known?.kind === 'plan') { + if (str(ev.title)) known.title = str(ev.title); + known.text += str(ev.append); + } else { + this.#collapseReasoning(); + this.#closeSegment(Date.now()); + this.messages.push({ kind: 'plan', id, title: str(ev.title), text: str(ev.append), status: 'drafting' }); + } + break; + } + case 'proposed_plan': { + const id = str(ev.id); + const plan = { kind: 'plan' as const, id, title: str(ev.title), text: str(ev.markdown), status: planStatus(ev.status) }; + const known = this.messages.find((m) => m.kind === 'plan' && m.id === id); + if (known?.kind === 'plan') Object.assign(known, plan); + else { + this.#collapseReasoning(); + this.#closeSegment(Date.now()); + this.messages.push(plan); + } + if (plan.status !== 'pending' && this.planRevising === id) this.planRevising = null; break; } case 'background_tasks': { @@ -1451,6 +1611,7 @@ export class ChatState { }); break; case 'agent_runs': { + this.agentRunsSeen = true; const n = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0); this.agentRuns = { workflows: arr>(ev.workflows).map((w) => ({ @@ -1486,7 +1647,8 @@ export class ChatState { kind: 'tool', callId: str(it.call_id), name: str(it.name), - output: str(it.output), + // The call's arguments name what it acted on until its output does. + output: str(it.output) || (it.input && typeof it.input === 'object' ? JSON.stringify(it.input) : str(it.input)), running: it.running === true, isError: it.is_error === true }; @@ -1592,6 +1754,13 @@ export class ChatState { }); break; case 'error': { + // An engine without the agent trace refuses its ops: nothing failed. + const refused = /^unknown op: (agent_runs|subagent_transcript)$/.exec(str(ev.message)); + if (refused) { + if (refused[1] === 'subagent_transcript' && this.agentFocus && !this.subagentTranscripts[this.agentFocus]) + this.subagentTranscripts[this.agentFocus] = { task: '', messages: [], error: t('dock.agents.noTranscript') }; + break; + } this.retry = null; // A turn was being sent or run (not, say, a refused command). const started = this.#turnStart !== null; diff --git a/src/lib/chat.test.ts b/src/lib/chat.test.ts index ef356ac..7392fbe 100644 --- a/src/lib/chat.test.ts +++ b/src/lib/chat.test.ts @@ -618,11 +618,16 @@ describe('claude session extras', () => { c.handle({ type: 'model_fallback', from: 'Opus 5.5', to: 'Sonnet 5.5', reason: 'overloaded' }); expect(c.messages.at(-1)).toEqual({ kind: 'system', text: '模型已从 Opus 5.5 切换到 Sonnet 5.5(原模型过载)' }); c.handle({ type: 'subagent_lifecycle', path: 'a1', label: 'Scan auth', status: 'running', message: 'Reading' }); - expect(c.subagents.a1).toEqual({ status: 'running', message: 'Reading', label: 'Scan auth' }); - c.handle({ type: 'subagent_lifecycle', path: 'a1', label: 'Scan auth', status: 'completed', message: '' }); - expect(c.subagents.a1).toBeUndefined(); + expect(c.subagents.a1).toMatchObject({ status: 'running', message: 'Reading', label: 'Scan auth' }); c.handle({ type: 'tool_start', call_id: 't2', name: 'read', subagent: 'Scan auth' }); expect(c.messages.at(-1)).toMatchObject({ kind: 'tool', subagent: 'Scan auth' }); + expect(c.subagentLastTool['Scan auth']).toMatchObject({ callId: 't2', name: 'read' }); + // A finished subagent stays (the progress card shows it) until the next turn. + c.handle({ type: 'subagent_lifecycle', path: 'a1', label: 'Scan auth', status: 'completed', message: '' }); + expect(c.subagents.a1).toMatchObject({ status: 'completed', label: 'Scan auth' }); + expect(c.subagents.a1.endedAt).toBeGreaterThan(0); + c.handle({ type: 'connecting' }); + expect(c.subagents.a1).toBeUndefined(); c.handle({ type: 'model_status', model: 'claude-opus-5-5', fast: true, fast_available: true, thinking_summaries: false, state: 'idle' }); expect([c.fast, c.fastAvailable, c.thinkingSummaries]).toEqual([true, true, false]); c.handle({ type: 'model_status', model: 'gpt-5.5', state: 'idle' }); @@ -666,6 +671,91 @@ describe('claude session extras', () => { expect(c.messages).toEqual([]); }); + it('keeps a subagent call’s arguments once its result replaces them', () => { + const c = new ChatState(); + c.handle({ type: 'tool_start', call_id: 't1', name: 'Task' }); + c.handle({ type: 'tool_update', call_id: 't1', output: '{"description":"Scan auth","prompt":"Find it"}' }); + c.handle({ type: 'tool_output', call_id: 't1', name: 'Task', output: 'Found it in auth.ts', is_error: false }); + expect(c.messages.at(-1)).toMatchObject({ output: 'Found it in auth.ts', args: '{"description":"Scan auth","prompt":"Find it"}' }); + }); + + it('reads a JuCode subagent from its spawn call and its lifecycle', () => { + const c = new ChatState(); + c.handle({ type: 'connecting' }); + c.handle({ type: 'tool_start', call_id: 'call_1', name: 'spawn_agent' }); + c.handle({ type: 'subagent_lifecycle', path: '/root/scan', status: 'pending', message: 'reserved' }); + c.handle({ type: 'tool_output', call_id: 'call_1', name: 'spawn_agent', output: '{"task_name":"scan","path":"/root/scan","status":"running"}', is_error: false }); + expect(c.subagents['/root/scan']).toMatchObject({ status: 'pending', label: 'scan', toolUseId: 'call_1' }); + c.handle({ type: 'subagent_lifecycle', path: '/root/scan', status: 'running', message: 'started', model: 'gpt-5.5' }); + c.handle({ type: 'subagent_lifecycle', path: '/root/scan', status: 'message', message: 'queued message' }); + expect(c.subagents['/root/scan']).toMatchObject({ status: 'running', model: 'gpt-5.5', toolUseId: 'call_1' }); + c.handle({ type: 'subagent_lifecycle', path: '/root/scan', status: 'errored', message: 'timeout' }); + expect(c.subagents['/root/scan'].endedAt).toBeGreaterThan(0); + }); + + it('reads the native agent trace’s activity and effort, and tool inputs in a transcript', () => { + const c = new ChatState(); + c.handle({ type: 'agent_runs', workflows: [], agents: [{ id: '/root/x', label: 'x', state: 'running', activity: 'Ran cargo test', effort: 'low', tool_use_id: 'c9' }] }); + expect(c.agentRunsSeen).toBe(true); + expect(c.agentRuns.agents[0]).toMatchObject({ state: 'running', activity: 'Ran cargo test', effort: 'low', toolUseId: 'c9' }); + c.handle({ type: 'subagent_transcript', agent_id: '/root/x', items: [{ role: 'user', content: 'go' }, { role: 'tool', call_id: 't', name: 'bash', input: { command: 'ls' }, running: true }] }); + expect(c.subagentTranscripts['/root/x'].messages[0]).toMatchObject({ kind: 'tool', output: '{"command":"ls"}', running: true }); + }); + + it('takes an older engine’s refusal of the trace ops quietly', () => { + setLocale('zh'); + const c = new ChatState(); + c.agentFocus = '/root/x'; + c.handle({ type: 'error', message: 'unknown op: agent_runs' }); + c.handle({ type: 'error', message: 'unknown op: subagent_transcript' }); + expect(c.messages).toEqual([]); + expect(c.lastError).toBeNull(); + expect(c.subagentTranscripts['/root/x'].error).toBeTruthy(); + }); + + it('shows a proposed plan and follows its status', () => { + const c = new ChatState(); + c.handle({ type: 'proposed_plan', id: 'p1', title: 'Fix auth', markdown: '## Steps\n1. a', status: 'pending' }); + expect(c.messages.at(-1)).toEqual({ kind: 'plan', id: 'p1', title: 'Fix auth', text: '## Steps\n1. a', status: 'pending' }); + c.planRevising = 'p1'; + c.handle({ type: 'proposed_plan', id: 'p1', title: 'Fix auth', markdown: '## Steps\n1. a', status: 'revising' }); + expect(c.messages).toHaveLength(1); + expect(c.messages[0]).toMatchObject({ status: 'revising' }); + expect(c.planRevising).toBeNull(); + c.handle({ type: 'proposed_plan', id: 'p2', title: 'Fix auth v2', markdown: 'x', status: 'odd' }); + expect(c.messages.at(-1)).toMatchObject({ id: 'p2', status: 'pending' }); + c.handle({ type: 'transcript', items: [{ role: 'plan', id: 'p2', title: 'T', content: 'body', status: 'approved' }] }); + expect(c.messages).toEqual([{ kind: 'plan', id: 'p2', title: 'T', text: 'body', status: 'approved' }]); + }); + + it('grows one plan while the model writes it, and completes it in place', () => { + const c = new ChatState(); + c.handle({ type: 'plan_draft', id: 'p1', title: 'Sna', append: '' }); + c.handle({ type: 'plan_draft', id: 'p1', title: 'Snake', append: '## Go' }); + c.handle({ type: 'plan_draft', id: 'p1', title: 'Snake', append: 'al' }); + expect(c.messages).toEqual([{ kind: 'plan', id: 'p1', title: 'Snake', text: '## Goal', status: 'drafting' }]); + const draft = c.messages[0]; + c.handle({ type: 'proposed_plan', id: 'p1', title: 'Snake', markdown: '## Goal\n1. a', status: 'pending' }); + expect(c.messages).toHaveLength(1); + expect(c.messages[0]).toBe(draft); + expect(c.messages[0]).toMatchObject({ text: '## Goal\n1. a', status: 'pending' }); + }); + + it('remembers the mode before plan mode and when the plan changed', () => { + const c = new ChatState(); + c.setApprovalMode('edits'); + c.setApprovalMode('plan'); + c.setApprovalMode('plan'); + expect(c.modeBeforePlan).toBe('edits'); + c.handle({ type: 'plan', plan: [{ step: 'a', status: 'pending' }] }); + const at = c.planAt; + expect(at).toBeGreaterThan(0); + c.handle({ type: 'connecting' }); + expect(c.turnStartedAt).toBeGreaterThan(0); + c.handle({ type: 'status', message: 'ready' }); + expect(c.turnEndedAt).toBeGreaterThanOrEqual(c.turnStartedAt); + }); + it('shows the images a message was sent with, live and in a transcript', () => { const c = new ChatState(); c.handle({ type: 'user_message', content: 'look', images: ['/u/a.png'] }); @@ -725,3 +815,24 @@ describe('gateway session costs', () => { expect(last?.kind === 'assistant' && last.turn?.billing?.gateway_cost).toBe(0); }); }); + +import { boundedOutput } from './chat.svelte'; +describe('boundedOutput', () => { + it('keeps a short output, cuts a long one to its head and tail', () => { + expect(boundedOutput('ok')).toBe('ok'); + const long = 'a'.repeat(50_000) + 'MIDDLE' + 'z'.repeat(50_000); + const out = boundedOutput(long); + expect(out.length).toBeLessThan(70_000); + expect(out.startsWith('aaa')).toBe(true); + expect(out.endsWith('zzz')).toBe(true); + expect(out).toContain('characters not shown'); + }); + it('keeps JSON parseable and an image whole', () => { + const json = JSON.stringify({ stdout: 'x'.repeat(200_000), exit_code: 0 }); + const out = JSON.parse(boundedOutput(json)); + expect(out.exit_code).toBe(0); + expect(out.stdout.length).toBeLessThan(40_000); + const image = JSON.stringify({ kind: 'image', base64: 'A'.repeat(300_000) }); + expect(JSON.parse(boundedOutput(image)).base64.length).toBe(300_000); + }); +}); diff --git a/src/lib/cloudSync.svelte.ts b/src/lib/cloudSync.svelte.ts index b7d6b72..2f25469 100644 --- a/src/lib/cloudSync.svelte.ts +++ b/src/lib/cloudSync.svelte.ts @@ -114,6 +114,7 @@ const SPECS: Spec[] = [ configField('reasoning_effort', isString), configField('compact_model', isString), configField('title_model', isString), + configField('image_model', isString), configField('compaction_threshold_percent', isNumber), configField('retry_attempts', isNumber), configField('connect_timeout_seconds', isNumber), diff --git a/src/lib/composer/BranchMenu.svelte b/src/lib/composer/BranchMenu.svelte index 3a6aba1..517785c 100644 --- a/src/lib/composer/BranchMenu.svelte +++ b/src/lib/composer/BranchMenu.svelte @@ -149,7 +149,7 @@ z-index: 81; width: min(340px, calc(100vw - 32px)); transform-origin: bottom left; - animation: pop-in var(--t-med) var(--ease-spring); + animation: pop-in var(--t-med) var(--ease-out); } .field { display: flex; diff --git a/src/lib/composer/MentionMenu.svelte b/src/lib/composer/MentionMenu.svelte index 3228cc7..2b18041 100644 --- a/src/lib/composer/MentionMenu.svelte +++ b/src/lib/composer/MentionMenu.svelte @@ -59,7 +59,7 @@ overflow: hidden; box-shadow: var(--shadow-pop); transform-origin: bottom center; - animation: pop-in var(--t-med) var(--ease-spring); + animation: pop-in var(--t-med) var(--ease-out); } .slash-item { display: flex; diff --git a/src/lib/composer/ModelMenu.svelte b/src/lib/composer/ModelMenu.svelte index 1589fa8..d7e4253 100644 --- a/src/lib/composer/ModelMenu.svelte +++ b/src/lib/composer/ModelMenu.svelte @@ -308,7 +308,7 @@ gap: 0; padding: 6px; transform-origin: bottom right; - animation: pop-in var(--t-med) var(--ease-spring); + animation: pop-in var(--t-med) var(--ease-out); } /* GroupPicker renders its own section, hence :global. */ .mm > :global(section + section) { @@ -481,7 +481,7 @@ } /* The check lands on a newly picked model. */ .pop-check:not(.off) :global(svg) { - animation: pop-in var(--t-med) var(--ease-spring); + animation: pop-in var(--t-med) var(--ease-out); } .empty { padding: 14px 12px; diff --git a/src/lib/daemon.ts b/src/lib/daemon.ts index 1ff0e2c..352032a 100644 --- a/src/lib/daemon.ts +++ b/src/lib/daemon.ts @@ -301,7 +301,11 @@ export class DaemonClient { state.dirty = false; let reply: Frame; try { reply = await this.request({ op: 'session_usage', session }); } - catch (error) { reply = { type: 'session_usage', session, billing_error: String(error) }; } + catch (error) { + // A daemon from before settled usage: nothing to show, nothing to retry. + if (/unknown op/i.test(String(error))) { this.#forgetUsage(session); return; } + reply = { type: 'session_usage', session, billing_error: String(error) }; + } if (this.#usage.get(session) !== state) return; // detached, reopened or disconnected state.inFlight = false; const desktopId = this.#toDesktop.get(session); diff --git a/src/lib/fileRefs.test.ts b/src/lib/fileRefs.test.ts index 69633c6..12123ed 100644 --- a/src/lib/fileRefs.test.ts +++ b/src/lib/fileRefs.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { codeFileRef, fileHref, parseFileHref, splitFileRefs } from './fileRefs'; +import { codeFileRef, fileHref, isAbsolutePath, joinPath, parseFileHref, pathExt, splitFileRefs } from './fileRefs'; const refs = (text: string) => splitFileRefs(text).filter((p) => typeof p !== 'string'); @@ -38,4 +38,32 @@ describe('file references', () => { expect(parseFileHref('file:///abs/b.rs?x=1#L3C2')).toEqual({ path: '/abs/b.rs', line: 3, col: 2 }); expect(parseFileHref('docs/c.md')).toEqual({ path: 'docs/c.md' }); }); + + it('reads the links marked percent-encodes, Windows paths included', () => { + expect(parseFileHref('/Users/me/%E9%B9%88%E9%B9%95.html')).toEqual({ path: '/Users/me/鹈鹕.html' }); + expect(parseFileHref('C:%5CUsers%5Cme%5Ca.html#L3')).toEqual({ path: 'C:\\Users\\me\\a.html', line: 3 }); + expect(parseFileHref('file:///C:/Users/me/a.html')).toEqual({ path: 'C:/Users/me/a.html' }); + expect(parseFileHref('docs/100%.md')).toEqual({ path: 'docs/100%.md' }); + }); + + it('never opens a network share a reply links to', () => { + expect(parseFileHref('%5C%5Cserver%5Cshare%5Ca.ts')).toEqual({ path: '' }); + expect(parseFileHref('file:////server/share/a.ts#L3')).toEqual({ path: '' }); + expect(parseFileHref('//server/share/a.ts')).toEqual({ path: '' }); + expect(isAbsolutePath('\\\\server\\share\\a.ts')).toBe(false); + }); + + it('joins and reads paths with either separator', () => { + for (const p of ['/abs/a.ts', 'C:\\p\\a.ts', 'c:/p/a.ts']) { + expect(isAbsolutePath(p)).toBe(true); + expect(joinPath('C:\\proj', p)).toBe(p); + } + expect(isAbsolutePath('src/a.ts')).toBe(false); + expect(joinPath('C:\\proj\\', './src/a.ts')).toBe('C:\\proj/src/a.ts'); + expect(joinPath('/proj/', 'src/a.ts')).toBe('/proj/src/a.ts'); + expect(joinPath('', 'src/a.ts')).toBe('src/a.ts'); + expect(pathExt('C:\\p\\site.v2\\Index.HTML')).toBe('html'); + expect(pathExt('C:\\p.d\\README')).toBe(''); + expect(pathExt('/p/.env')).toBe(''); + }); }); diff --git a/src/lib/fileRefs.ts b/src/lib/fileRefs.ts index e947da1..432988a 100644 --- a/src/lib/fileRefs.ts +++ b/src/lib/fileRefs.ts @@ -69,10 +69,43 @@ export function fileHref(ref: FileRef): string { export function parseFileHref(href: string): FileRef { const clean = href.replace(/^file:\/\//, ''); const [main, hash = ''] = clean.split('#'); - const path = main.split('?')[0].trim(); + // marked percent-encodes a link: 鹈鹕 and a Windows `\` arrive as %XX. + let path = decodePath(main.split('?')[0].trim()); + // `file:///C:/x` leaves `/C:/x`; on Windows the drive starts the path. + if (/^\/[A-Za-z]:[\\/]/.test(path)) path = path.slice(1); + // A network share (`\\server\share`, `//server/share`) is never opened + // from a reply: Windows would connect to the server and send the user's + // credentials to it. + if (/^[\\/]{2}/.test(path)) return { path: '' }; const m = /^L(\d+)(?::(\d+)|C(\d+))?$/.exec(hash); if (!m) return { path }; const line = Number(m[1]); const col = Number(m[2] ?? m[3]); return { path, ...(line > 0 ? { line } : {}), ...(col > 0 ? { col } : {}) }; } + +function decodePath(path: string): string { + try { + return decodeURIComponent(path); + } catch { + return path; // a literal `%` that is no escape + } +} + +/** An absolute path on any OS: `/x`, `C:\x` or `C:/x`. */ +export function isAbsolutePath(path: string): boolean { + return /^(?:\/|[A-Za-z]:[\\/])/.test(path); +} + +/** `path` under `root`, unless it is absolute already. */ +export function joinPath(root: string, path: string): string { + if (isAbsolutePath(path) || !root) return path; + return `${root.replace(/[\\/]+$/, '')}/${path.replace(/^\.?[\\/]/, '')}`; +} + +/** A path's extension, lowercased; '' when it has none. Either separator. */ +export function pathExt(path: string): string { + const name = path.split(/[\\/]/).pop() ?? ''; + const dot = name.lastIndexOf('.'); + return dot > 0 ? name.slice(dot + 1).toLowerCase() : ''; +} diff --git a/src/lib/i18n/messages/chat.ts b/src/lib/i18n/messages/chat.ts index add8eaf..6603920 100644 --- a/src/lib/i18n/messages/chat.ts +++ b/src/lib/i18n/messages/chat.ts @@ -18,7 +18,7 @@ const chat = { approvalQuestion: '工具调用如何审批?', approvalAgent: '跟随 Agent 的设置,在 Agent 页面修改', approvalAskDesc: '修改文件和执行命令前都先询问', - approvalPlanDesc: '只读分析并给出方案,不改动文件', + approvalPlanDesc: '先调研并给出计划,确认后再动手', approvalAutoDesc: '由模型判断,仅对有风险的操作询问', approvalEditsDesc: '自动修改文件,执行命令前询问', approvalAllDesc: '不再询问,可改动任何文件并执行任何命令', @@ -255,6 +255,47 @@ const chat = { questionSubmitHint: '至少选择一项', questionCancel: '取消', questionCancelDesc: '不回答,拒绝这次提问', + // Progress card (top right of the conversation) and subagent cards + progress: { + label: '进度', + todo: '待办', + agents: '子智能体', + count: '{done}/{total}', + agentsN: '{n} 个子智能体', + runningN: '{n} 个运行中', + fold: '收起进度', + unfold: '展开进度', + step: { pending: '待开始', active: '进行中', done: '已完成', skipped: '已跳过' }, + open: '查看 {name} 在做什么', + noActivity: '还没有动作' + }, + subagentCard: { + spawn: '子智能体', + wait: '等待子智能体', + waitN: '等待 {n} 个子智能体', + timedOut: '等待超时', + open: '查看', + starting: '正在派出' + }, + agentDetail: { + title: '子智能体', + close: '关闭', + task: '任务' + }, + planCard: { + label: '计划', + viewFull: '查看完整计划', + openPage: '在右侧查看计划', + gone: '这份计划已不在对话中', + fold: '收起', + approve: '批准并执行', + modeTitle: '执行时的审批模式', + revise: '修改计划', + revising: '正在修改计划', + revisingHint: '说说要怎么改,发送后计划会按你的意见重写', + cancelRevise: '取消修改', + status: { approved: '已批准', revising: '修改中', drafting: '撰写中' } + }, // Indicator phaseConnecting: '连接中', phaseWaiting: '等待响应', @@ -280,7 +321,7 @@ const chat = { approvalQuestion: 'How should tool calls be approved?', approvalAgent: "Follows the agent's setting; change it on the agent's page", approvalAskDesc: 'Ask before editing files or running commands', - approvalPlanDesc: 'Read-only analysis and a plan; no file changes', + approvalPlanDesc: 'Research and propose a plan; work once you approve', approvalAutoDesc: 'The model decides; ask only for risky actions', approvalEditsDesc: 'Edit files automatically; ask before commands', approvalAllDesc: 'Never ask; may change any file and run any command', @@ -517,6 +558,47 @@ const chat = { questionSubmitHint: 'Select at least one', questionCancel: 'Cancel', questionCancelDesc: 'Skip the question and deny it', + // Progress card (top right of the conversation) and subagent cards + progress: { + label: 'Progress', + todo: 'To-dos', + agents: 'Subagents', + count: '{done}/{total}', + agentsN: '{n} subagents', + runningN: '{n} running', + fold: 'Fold progress', + unfold: 'Show progress', + step: { pending: 'To do', active: 'In progress', done: 'Done', skipped: 'Skipped' }, + open: 'See what {name} is doing', + noActivity: 'No actions yet' + }, + subagentCard: { + spawn: 'Subagent', + wait: 'Waiting for subagents', + waitN: 'Waiting for {n} subagents', + timedOut: 'Wait timed out', + open: 'Open', + starting: 'Starting' + }, + agentDetail: { + title: 'Subagent', + close: 'Close', + task: 'Task' + }, + planCard: { + label: 'Plan', + viewFull: 'View the full plan', + openPage: 'Open the plan beside the chat', + gone: 'This plan is no longer in the conversation', + fold: 'Fold', + approve: 'Approve and run', + modeTitle: 'Approval mode for the work', + revise: 'Revise the plan', + revising: 'Revising the plan', + revisingHint: 'Say what to change; the plan is rewritten when you send', + cancelRevise: 'Cancel revising', + status: { approved: 'Approved', revising: 'Revising', drafting: 'Writing' } + }, // Indicator phaseConnecting: 'Connecting', phaseWaiting: 'Awaiting response', diff --git a/src/lib/i18n/messages/dock.ts b/src/lib/i18n/messages/dock.ts index ee924a3..528e171 100644 --- a/src/lib/i18n/messages/dock.ts +++ b/src/lib/i18n/messages/dock.ts @@ -28,13 +28,14 @@ const dock = { toolCalls: '{n} 次工具调用', progress: '{done}/{total} 个智能体', empty: '还没有子智能体运行', - emptyHint: '开启 Ultracode,或让 Claude 调用 Agent 工具后,这里会显示每个智能体的运行轨迹、耗时和 token 消耗。', + emptyHint: '智能体派出子智能体(或开启 Claude 的 Ultracode)后,这里会显示每个子智能体的运行轨迹、耗时和 token 消耗。', col: { agent: '智能体', timeline: '时间线', time: '耗时', tokens: 'Tokens', tools: '工具' }, state: { running: '运行中', done: '已完成', failed: '失败', stopped: '已停止', queued: '排队中' }, unit: { s: '秒', m: '分', h: '小时' }, open: '查看追踪', result: '结果', - openAgent: '查看子智能体会话' + openAgent: '查看子智能体会话', + noTranscript: '这个引擎还不能读取子智能体的会话' }, dock: { empty: '没有打开的面板', @@ -181,7 +182,9 @@ const dock = { emptyHint: 'AI 编辑文件后会在此列出', revert: '还原', revertFile: '还原此文件', - revertConfirm: '还原「{path}」到改动前?此操作不可撤销。', + revertConfirm: '把「{path}」还原到上次提交的版本?你自己在这个文件里没提交的改动也会一起丢掉,此操作不可撤销。', + revertAgentConfirm: '撤销智能体对「{path}」的修改?你自己的改动会保留。', + revertAgentFailed: '无法只撤销智能体的修改(文件之后又改动过),文件未改动:{error}', revertTitle: '还原文件', newFileDiff: '(新文件或无可显示的 diff)' }, @@ -237,13 +240,14 @@ const dock = { toolCalls: '{n} tool calls', progress: '{done}/{total} agents', empty: 'No subagents have run', - emptyHint: 'Turn on Ultracode, or have Claude use the Agent tool, to see each agent’s path, time and tokens here.', + emptyHint: 'Once the agent starts subagents (or Claude runs Ultracode), each one’s path, time and tokens show here.', col: { agent: 'Agent', timeline: 'Timeline', time: 'Time', tokens: 'Tokens', tools: 'Tools' }, state: { running: 'Running', done: 'Done', failed: 'Failed', stopped: 'Stopped', queued: 'Queued' }, unit: { s: 's', m: 'm', h: 'h' }, open: 'View trace', result: 'Result', - openAgent: 'View the subagent’s conversation' + openAgent: 'View the subagent’s conversation', + noTranscript: "This engine can't read a subagent's conversation yet" }, dock: { empty: 'No open panels', @@ -390,7 +394,9 @@ const dock = { emptyHint: 'Files the AI edits will appear here', revert: 'Revert', revertFile: 'Revert this file', - revertConfirm: 'Revert "{path}" to before the changes? This cannot be undone.', + revertConfirm: 'Restore "{path}" to its last commit? Your own uncommitted changes to it are lost too. This cannot be undone.', + revertAgentConfirm: 'Undo the agent\'s edits to "{path}"? Your own changes stay.', + revertAgentFailed: 'Could not undo only the agent\'s edits (the file changed since); the file is unchanged: {error}', revertTitle: 'Revert file', newFileDiff: '(New file or no diff to display)' }, diff --git a/src/lib/i18n/messages/settings.ts b/src/lib/i18n/messages/settings.ts index 6caae98..3e94679 100644 --- a/src/lib/i18n/messages/settings.ts +++ b/src/lib/i18n/messages/settings.ts @@ -19,6 +19,40 @@ const settings = { jucode: 'JuCode CLI', about: '关于' }, + import: { + title: '从其他工具导入', + intro: '把本机 Claude Code、Codex、opencode、zcode、omp 里的会话、技能和 MCP 服务器导入 JuCode。原工具的文件不会改动。', + scan: '扫描', + rescan: '重新扫描', + scanning: '正在扫描…', + scanFailed: '扫描失败:{msg}', + nothing: '没有找到可导入的内容', + sessions: '会话', + skills: '技能', + mcp: 'MCP 服务器', + selectAll: '全选', + selected: '已选 {n} / {total}', + prompts: '{n} 轮', + untitled: '无标题', + imported: '已导入', + present: '已有', + off: '未启用', + plugin: '插件 {name}', + sessionsHint: 'Claude Code 和 Codex 的会话继续用原来的后端;其他工具的会话转成 JuCode 会话。', + skillsHint: '复制到 ~/.jucode/skills。插件带的技能可能依赖原工具,默认不选。', + mcpHint: '加到 JuCode 的 MCP 配置里。插件带的服务器默认不选。', + run: '一键导入', + running: '正在导入…', + pickSome: '先选要导入的内容', + resultTitle: '导入完成', + resultSessions: '会话:新导入 {imported},已有 {existing}', + resultSkills: '技能:新导入 {imported},已有 {existing}', + resultMcp: 'MCP 服务器:新导入 {imported},已有 {existing}', + failedCount: '{n} 项失败:', + where: '导入的会话在对应项目的「历史」里打开,Claude Code 和 Codex 的在历史的同名标签下。', + addFolders: '把 {n} 个项目文件夹加到侧栏', + foldersAdded: '已加到侧栏' + }, section: { general: '通用', account: '账户', @@ -32,7 +66,8 @@ const settings = { agents: '后端', acp: '外部智能体(ACP)', daemon: '后台服务', - updates: '更新' + updates: '更新', + import: '导入' }, page: { back: '返回', @@ -253,6 +288,10 @@ const settings = { titleModelHint: '后台服务按对话内容为所有智能体的对话生成和更新名称:第 1、3 轮后各一次,之后每 5 轮一次;手动改过名的对话不会被覆盖。模型从 JuCode CLI 的默认提供商中选。', followMainModel: '跟随主模型', network: '网络', + images: '图片生成', + imageModel: '生图模型', + imageModelHint: '智能体画图或改图时使用', + imageModelAuto: '自动选择', retryAttempts: '重试次数', connectTimeout: '连接超时', readTimeout: '读取超时', @@ -343,6 +382,9 @@ const settings = { footHint: '更改会自动保存,对新建的会话生效。', update: { groupLabel: '应用更新', + engineVersion: '引擎版本', + logs: '日志', + openLogs: '打开日志文件夹', currentVersion: '当前版本', check: '检查更新', checking: '检查中…', @@ -380,7 +422,7 @@ const settings = { }, marketplace: { title: '扩展市场', - subtitle: '从 JuCode 与 Anthropic 安装技能扩展。', + subtitle: '从 Anthropic 和社区仓库(Ikaleio、Superpowers、Composio)安装技能。', search: '搜索扩展…', all: '全部', allTags: '全部标签', @@ -463,6 +505,40 @@ const settings = { jucode: 'JuCode CLI', about: 'About' }, + import: { + title: 'Import from other tools', + intro: 'Bring conversations, skills and MCP servers over from Claude Code, Codex, opencode, zcode and omp on this computer. Their files are left as they are.', + scan: 'Scan', + rescan: 'Scan again', + scanning: 'Scanning…', + scanFailed: 'Scan failed: {msg}', + nothing: 'Nothing to import was found', + sessions: 'Conversations', + skills: 'Skills', + mcp: 'MCP servers', + selectAll: 'Select all', + selected: '{n} of {total} selected', + prompts: '{n} prompts', + untitled: 'Untitled', + imported: 'Imported', + present: 'Already added', + off: 'Off', + plugin: 'plugin {name}', + sessionsHint: 'Claude Code and Codex conversations continue on their own backend; the others become JuCode conversations.', + skillsHint: 'Copied to ~/.jucode/skills. Skills from plugins may rely on their tool and are not selected by default.', + mcpHint: 'Added to JuCode\'s MCP servers. Servers from plugins are not selected by default.', + run: 'Import', + running: 'Importing…', + pickSome: 'Select something to import first', + resultTitle: 'Import finished', + resultSessions: 'Conversations: {imported} imported, {existing} already there', + resultSkills: 'Skills: {imported} imported, {existing} already there', + resultMcp: 'MCP servers: {imported} imported, {existing} already there', + failedCount: '{n} failed:', + where: 'Open imported conversations from the project\'s History; Claude Code and Codex ones are under their own tab there.', + addFolders: 'Add {n} project folders to the sidebar', + foldersAdded: 'Added to the sidebar' + }, section: { general: 'General', account: 'Account', @@ -476,7 +552,8 @@ const settings = { agents: 'Backends', acp: 'External agents (ACP)', daemon: 'Background service', - updates: 'Updates' + updates: 'Updates', + import: 'Import' }, page: { back: 'Back', @@ -697,6 +774,10 @@ const settings = { titleModelHint: 'The background service names the conversations of every agent and renames them as they move on: after turns 1 and 3, then every 5th. A conversation you renamed yourself keeps its name. The model comes from the JuCode CLI default provider.', followMainModel: 'Same as the main model', network: 'Network', + images: 'Image generation', + imageModel: 'Image model', + imageModelHint: 'Used when the agent draws or edits images', + imageModelAuto: 'Automatic', retryAttempts: 'Retry attempts', connectTimeout: 'Connect timeout', readTimeout: 'Read timeout', @@ -787,6 +868,9 @@ const settings = { footHint: 'Changes save automatically and apply to new sessions.', update: { groupLabel: 'App updates', + engineVersion: 'Engine version', + logs: 'Logs', + openLogs: 'Open logs folder', currentVersion: 'Current version', check: 'Check for updates', checking: 'Checking…', @@ -824,7 +908,7 @@ const settings = { }, marketplace: { title: 'Marketplace', - subtitle: 'Install skills from JuCode and Anthropic.', + subtitle: 'Install skills from Anthropic and community repositories (Ikaleio, Superpowers, Composio).', search: 'Search extensions…', all: 'All', allTags: 'All tags', diff --git a/src/lib/i18n/messages/shell.ts b/src/lib/i18n/messages/shell.ts index 75f1faf..68234a7 100644 --- a/src/lib/i18n/messages/shell.ts +++ b/src/lib/i18n/messages/shell.ts @@ -4,8 +4,10 @@ const shell = { zh: { // notifications / runtime notifyDone: '对话完成:{title}', + notifyApproval: '等你批准:{what}', + notifyPlan: '计划待批准:{title}', cacheMiss: { - message: '「{title}」缓存未命中:本次请求输入 {input} token,命中缓存 {cached}。5 秒内不操作将继续执行。', + message: '「{title}」缓存未命中:本次输入 {input} token,只命中 {cached} token。不操作的话,5 秒后继续执行。', stop: '停止任务' }, untitled: '未命名', @@ -98,6 +100,8 @@ const shell = { // header / navigator toggleSidebar: '会话列表 · ⌘B', chatGone: '该对话已关闭', + paneError: '这个对话显示出错了,已记录到日志。', + paneErrorRetry: '重新显示', // workspaces workspace: { @@ -904,6 +908,8 @@ const shell = { en: { // notifications / runtime notifyDone: 'Conversation done: {title}', + notifyApproval: 'Waiting for your approval: {what}', + notifyPlan: 'Plan to approve: {title}', cacheMiss: { message: '{title}: prompt cache miss. {cached} of {input} input tokens were cached. Continuing in 5 seconds.', stop: 'Stop task' @@ -998,6 +1004,8 @@ const shell = { // header / navigator toggleSidebar: 'Session list · ⌘B', chatGone: 'This conversation is closed', + paneError: 'This conversation failed to display; it was logged.', + paneErrorRetry: 'Show again', // workspaces workspace: { diff --git a/src/lib/markdown.test.ts b/src/lib/markdown.test.ts index 910c346..f1241b6 100644 --- a/src/lib/markdown.test.ts +++ b/src/lib/markdown.test.ts @@ -28,3 +28,13 @@ describe('renderMarkdown file references', () => { expect(html).not.toContain(''); }); }); + +describe('renderMarkdown tables', () => { + it('wraps a table so a wide one scrolls in its own box', () => { + const html = renderMarkdown('| 项 | 状态 |\n| --- | :-: |\n| 解析 | ✅ |\n| 渲染 | ⏳ |'); + expect(html.startsWith('
')).toBe(true); + expect(html.trim().endsWith('
')).toBe(true); + expect(html).toContain('状态'); + expect(html.match(//g)).toHaveLength(3); + }); +}); diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 77a8680..e930094 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -114,5 +114,8 @@ marked.use({ }); export function renderMarkdown(text: string): string { - return marked.parse(text, { async: false }) as string; + // A table scrolls sideways in its own box when it is wider than the column. + return (marked.parse(text, { async: false }) as string) + .replace(//g, '
') + .replace(/<\/table>/g, '
'); } diff --git a/src/lib/planPages.svelte.ts b/src/lib/planPages.svelte.ts new file mode 100644 index 0000000..9f98072 --- /dev/null +++ b/src/lib/planPages.svelte.ts @@ -0,0 +1,45 @@ +// A plan the agent proposes opens in a page of its own beside the chat, as +// the embedded browser does: one tab per plan, kept until the user closes it. +// The tab's panel names the session and the plan (`proposal::`). + +const PREFIX = 'proposal:'; + +export const proposalPanel = (sessionId: string, planId: string) => `${PREFIX}${sessionId}:${planId}`; + +/** The session and plan a `proposal:` panel shows; null for any other panel. */ +export function proposalOf(panel: string): { sessionId: string; planId: string } | null { + if (!panel.startsWith(PREFIX)) return null; + const rest = panel.slice(PREFIX.length); + const at = rest.indexOf(':'); + return at > 0 ? { sessionId: rest.slice(0, at), planId: rest.slice(at + 1) } : null; +} + +/** Markdown in blocks (blank-line separated, a code fence kept whole), so a + * page can bring a plan in one block at a time. */ +export function planBlocks(text: string): string[] { + const blocks: string[] = []; + let current: string[] = []; + let fenced = false; + for (const line of text.split('\n')) { + if (/^\s*(```|~~~)/.test(line)) fenced = !fenced; + if (!fenced && line.trim() === '' && current.length) { + blocks.push(current.join('\n')); + current = []; + continue; + } + if (line.trim() !== '' || current.length) current.push(line); + } + if (current.length) blocks.push(current.join('\n')); + return blocks; +} + +class PlanPages { + /** The last page asked for; the page shell opens (or shows) its tab. */ + request = $state<{ sessionId: string; planId: string; n: number } | null>(null); + + open(sessionId: string, planId: string) { + this.request = { sessionId, planId, n: (this.request?.n ?? 0) + 1 }; + } +} + +export const planPages = new PlanPages(); diff --git a/src/lib/planPages.test.ts b/src/lib/planPages.test.ts new file mode 100644 index 0000000..5efc6cd --- /dev/null +++ b/src/lib/planPages.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { planBlocks, proposalOf, proposalPanel } from './planPages.svelte'; + +describe('plan pages', () => { + it('names a session and a plan in the panel and reads them back', () => { + expect(proposalOf(proposalPanel('s1', 'plan-2'))).toEqual({ sessionId: 's1', planId: 'plan-2' }); + expect(proposalOf(proposalPanel('s1', 'a:b'))).toEqual({ sessionId: 's1', planId: 'a:b' }); + expect(proposalOf('plan')).toBeNull(); + expect(proposalOf('browser')).toBeNull(); + }); + + it('splits a plan into blocks and keeps a code fence whole', () => { + const text = '## Goal\n\nDo it.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n```\n\n- one\n- two\n'; + expect(planBlocks(text)).toEqual(['## Goal', 'Do it.', '```ts\nconst a = 1;\n\nconst b = 2;\n```', '- one\n- two']); + expect(planBlocks('')).toEqual([]); + }); +}); diff --git a/src/lib/protocol.ts b/src/lib/protocol.ts index a78aeb3..8c33956 100644 --- a/src/lib/protocol.ts +++ b/src/lib/protocol.ts @@ -74,7 +74,15 @@ export type Op = | { op: 'permission_rules' } // claude: the agent trace (Workflows, Task subagents) and one subagent's conversation. | { op: 'agent_runs' } - | { op: 'subagent_transcript'; agent_id: string }; + | { op: 'subagent_transcript'; agent_id: string } + // jucode plan mode: run a proposed plan in `mode`, or revise it with `feedback`. + | { + op: 'approve_plan'; + id: string; + decision: 'approve' | 'revise'; + mode?: 'read-only' | 'auto' | 'auto-edit' | 'full-auto'; + feedback?: string; + }; /** Saves an MCP server change (`mcp_set` / `mcp_remove` / `mcp_toggle`) for * every session; open JuCode sessions apply it at once. */ @@ -169,6 +177,73 @@ export function importNativeSession(source: NativeSource, cwd: string, id: strin return invoke('import_native_session', { source, cwd, id }); } +// 一键导入: what the other coding agents on this machine have — conversations +// in every folder, skills and MCP servers (src-tauri/src/agent_import). +export type ImportSource = 'claude' | 'codex' | 'opencode' | 'zcode' | 'omp'; +export interface ImportSession { + source: ImportSource; + id: string; + title: string; + cwd: string; + updated_at_ms: number; + /** Prompts the user sent. */ + messages: number; + /** Imported before: importing again opens that copy. */ + imported: boolean; +} +export interface ImportSkill { + source: ImportSource; + name: string; + description: string; + path: string; + /** The plugin it comes with. */ + plugin: string | null; + /** JuCode already has a skill of this name. */ + present: boolean; +} +export interface ImportMcp { + source: ImportSource; + /** The plugin or project folder it is set for; empty for the tool's own settings. */ + from: string; + name: string; + transport: 'stdio' | 'http'; + command: string | null; + args: string[] | null; + url: string | null; + present: boolean; + /** The `mcp_servers` entry to add. */ + entry: McpServerEntry; +} +export interface ImportScan { + sessions: ImportSession[]; + skills: ImportSkill[]; + mcp: ImportMcp[]; +} +export interface ImportOutcome { + kind: 'session' | 'skill'; + source: ImportSource; + /** Session id or skill folder, as the scan named it. */ + key: string; + status: 'imported' | 'existing' | 'failed'; + /** Session: the copy to open. Skill: where it was copied. */ + target: string | null; + title: string | null; + cwd: string | null; + /** The backend the copy continues on. */ + engine: 'jucode' | 'claude' | 'codex' | null; + cwd_exists: boolean; + error: string | null; +} +export function importScan(): Promise { + return invoke('import_scan'); +} +export function importApply(selection: { + sessions: { source: ImportSource; id: string; cwd: string }[]; + skills: { source: ImportSource; path: string }[]; +}): Promise { + return invoke('import_apply', { selection }); +} + /** One conversation saved in a directory, by any engine, as the daemon * lists it (`session_history`). `updated_at` is in milliseconds. */ export interface HistoryItem { @@ -214,15 +289,19 @@ export function removeAuthKey(provider: string): Promise { return invoke('remove_auth_key', { provider }); } -// Skills marketplace: the daemon combines JuCode with github.com/anthropics/skills -// and installs into the backend's personal skills directory. -export type SkillSource = 'jucode' | 'anthropic'; +// Skills marketplace: the daemon combines github.com/anthropics/skills with +// community skill repositories and installs into the backend's personal skills +// directory. +/** `anthropic`, or a community repository's `owner/repo`. */ +export type SkillSource = string; export interface MarketSkill { id: string; name: string; description: string; tags: string[]; source: SkillSource; + /** The source as shown: Anthropic, Ikaleio, Superpowers… */ + sourceName?: string; isDefault: boolean; installed: boolean; license: string; @@ -532,6 +611,10 @@ export function listProviders(): Promise { export function git(args: string[], cwd?: string): Promise { return invoke('git', { args, cwd }); } +/** Undoes only the agent's edits to `path` (its recorded diffs), keeping the user's. */ +export function revertAgentEdits(cwd: string, path: string, diffs: string[]): Promise { + return invoke('revert_agent_edits', { cwd, path, diffs }); +} // 并行任务 worktree 的容器目录(/.jucode-worktrees/)。 export function worktreeBase(cwd: string): Promise { return invoke('worktree_base', { cwd }); diff --git a/src/lib/remote/HostView.svelte b/src/lib/remote/HostView.svelte index 9d92e0f..4a15d60 100644 --- a/src/lib/remote/HostView.svelte +++ b/src/lib/remote/HostView.svelte @@ -358,6 +358,7 @@ onFiles={root ? () => push({ kind: 'files', root, title: baseName(root) }) : undefined} onFile={root ? (file: string, line?: number) => push({ kind: 'files', root, title: baseName(root), file, line }) : undefined} onChanges={root ? () => push({ kind: 'changes', root, title: baseName(root) }) : undefined} + active={!hidden && name === tab && i === stacks[name].length - 1} /> {:else} {@render loadingPage(screen.title)} @@ -635,7 +636,7 @@ color: #000; font-size: var(--fs-2xs); line-height: 16px; - animation: pop-in var(--t-med) var(--ease-spring); + animation: pop-in var(--t-med) var(--ease-out); } /* Connecting and pair-again: one centered column across the page, with the switcher in the corner. */ diff --git a/src/lib/remote/ModelMenu.svelte b/src/lib/remote/ModelMenu.svelte index 034a1f7..8761e06 100644 --- a/src/lib/remote/ModelMenu.svelte +++ b/src/lib/remote/ModelMenu.svelte @@ -267,7 +267,7 @@ gap: 0; padding: 6px; transform-origin: bottom right; - animation: pop-in var(--t-med) var(--ease-spring); + animation: pop-in var(--t-med) var(--ease-out); } .mm > section + section { margin-top: 6px; @@ -357,7 +357,7 @@ visibility: hidden; } .pop-check:not(.off) :global(svg:not(.spin)) { - animation: pop-in var(--t-med) var(--ease-spring); + animation: pop-in var(--t-med) var(--ease-out); } .empty { display: flex; diff --git a/src/lib/session.svelte.ts b/src/lib/session.svelte.ts index 692fe0d..6d4909b 100644 --- a/src/lib/session.svelte.ts +++ b/src/lib/session.svelte.ts @@ -334,7 +334,9 @@ export class SessionStore { // At the start: the sidebar lists a project's first sessions only. project.sessions.unshift(s); this.activeId = s.id; - if (firstMessage) { + // Only text starts the chat: a click handler wired straight through passes + // its event here, which showed as a broken first message. + if (typeof firstMessage === 'string' && firstMessage) { s.chat.optimisticUser(firstMessage); dispatch(s.id, { op: 'user_message', content: firstMessage }); } diff --git a/src/lib/session.test.ts b/src/lib/session.test.ts index fdf3075..617a87b 100644 --- a/src/lib/session.test.ts +++ b/src/lib/session.test.ts @@ -65,6 +65,15 @@ describe('SessionStore lifecycle', () => { expect(p.sessions[0].chat.sessionId).toBe(`conv-${id}`); }); + it('a click event handed in as the first message starts an empty draft', () => { + const store = new SessionStore(); + const p = proj(); + store.projects.push(p); + store.addSession(p, new Event('click') as unknown as string); + expect(p.sessions[0].chat.messages).toEqual([]); + expect(p.sessions[0].draft).toBe(true); + }); + it('a draft records backend, model and effort, and applies them when it starts', async () => { const store = new SessionStore(); const p = proj(); diff --git a/src/lib/settings/ImportSection.svelte b/src/lib/settings/ImportSection.svelte new file mode 100644 index 0000000..eda4eb4 --- /dev/null +++ b/src/lib/settings/ImportSection.svelte @@ -0,0 +1,474 @@ + + + + + + + {#if scanError} +
{scanError}
+ {:else if empty} + + {/if} +
+ +{#snippet groupHead(kind: string, source: ImportSource, keys: string[], total: number)} + {@const id = `${kind}:${source}`} +
+ setAll(keys, on)} /> + +
+{/snippet} + +{#snippet selectAll(keys: string[])} + {#if keys.length} + setAll(keys, on)}> + {t('settings.import.selectAll')} · {t('settings.import.selected', { n: count(keys), total: keys.length })} + + {/if} +{/snippet} + +{#if found && found.sessions.length} + + {#snippet action()}{@render selectAll(sessionKeys(found!.sessions))}{/snippet} + {#each sessionGroups as g (g.source)} +
+ {@render groupHead('session', g.source, sessionKeys(g.items), g.items.length)} + + {#each byProject(g.items) as p (p.cwd)} +
+ {folderName(p.cwd)} + {p.cwd} +
+ {#each p.sessions as s (s.id)} + {@const key = sessionKey(s)} +
+ (picks[key] = on)}> + {s.title || t('settings.import.untitled')} + + + {#if s.imported}{t('settings.import.imported')}{/if} + {t('settings.import.prompts', { n: s.messages })} · {date(s.updated_at_ms)} + +
+ {/each} + {/each} +
+
+ {/each} +
+{/if} + +{#if found && found.skills.length} + + {#snippet action()}{@render selectAll(skillKeys(found!.skills))}{/snippet} + {#each skillGroups as g (g.source)} +
+ {@render groupHead('skill', g.source, skillKeys(g.items), g.items.length)} + + {#each g.items as s (s.path)} + {@const key = skillKey(s)} +
+ (picks[key] = on)}> + {s.name} + + {#if s.plugin}{t('settings.import.plugin', { name: s.plugin })}{/if} + {s.description} + {#if s.present}{t('settings.import.present')}{/if} +
+ {/each} +
+
+ {/each} +
+{/if} + +{#if found && found.mcp.length} + + {#snippet action()}{@render selectAll(mcpKeys(found!.mcp))}{/snippet} + {#each mcpGroups as g (g.source)} +
+ {@render groupHead('mcp', g.source, mcpKeys(g.items), g.items.length)} + + {#each g.items as m (mcpKey(m))} + {@const key = mcpKey(m)} + {@const what = m.transport === 'http' ? m.url : [m.command, ...(m.args ?? [])].join(' ')} +
+ (picks[key] = on)}> + {m.name} + + {m.transport} + {#if m.from}{m.from.startsWith('/') ? folderName(m.from) : t('settings.import.plugin', { name: m.from })}{/if} + {what} + {#if !m.entry.enabled}{t('settings.import.off')}{/if} + {#if m.present}{t('settings.import.present')}{/if} +
+ {/each} +
+
+ {/each} +
+{/if} + +{#if found && !empty} + + + + + {#if runError} +
{runError}
+ {/if} + {#if summary} +
+ +
{t('settings.import.resultTitle')}
+ {#if summary.sessions.imported + summary.sessions.existing + summary.sessions.failed} +
{t('settings.import.resultSessions', { ...summary.sessions })}
+ {/if} + {#if summary.skills.imported + summary.skills.existing + summary.skills.failed} +
{t('settings.import.resultSkills', { ...summary.skills })}
+ {/if} + {#if summary.mcp.imported + summary.mcp.existing + summary.mcp.failed} +
{t('settings.import.resultMcp', { ...summary.mcp })}
+ {/if} + {#if summary.failures.length} +
{t('settings.import.failedCount', { n: summary.failures.length })}
+
    + {#each summary.failures as f, i (i)} +
  • {f.label} {f.error}
  • + {/each} +
+ {/if} + {#if summary.sessions.imported + summary.sessions.existing} +
{t('settings.import.where')}
+ {/if} +
+ {#if folders.length && onAddFolders} +
+ {#if foldersAdded} + {t('settings.import.foldersAdded')} + {:else} + + {/if} +
+ {/if} +
+ {/if} +
+{/if} + + diff --git a/src/lib/settings/SettingsPage.svelte b/src/lib/settings/SettingsPage.svelte index febcab2..dc3f8f5 100644 --- a/src/lib/settings/SettingsPage.svelte +++ b/src/lib/settings/SettingsPage.svelte @@ -1,4 +1,5 @@ @@ -46,6 +52,12 @@ {/if} + {#if about?.cli} + + {/if} + + + diff --git a/src/lib/ui/PopMenu.svelte b/src/lib/ui/PopMenu.svelte index ec5bd01..48863b5 100644 --- a/src/lib/ui/PopMenu.svelte +++ b/src/lib/ui/PopMenu.svelte @@ -77,24 +77,24 @@ left: 0; bottom: calc(100% + 8px); transform-origin: bottom left; - animation: pop-in var(--t-med) var(--ease-spring); + animation: pop-in var(--t-med) var(--ease-out); } .up-right { right: 0; bottom: calc(100% + 8px); transform-origin: bottom right; - animation: pop-in var(--t-med) var(--ease-spring); + animation: pop-in var(--t-med) var(--ease-out); } .down-left { left: 0; top: calc(100% + 6px); transform-origin: top left; - animation: drop-in var(--t-med) var(--ease-spring); + animation: drop-in var(--t-med) var(--ease-out); } .down-right { right: 0; top: calc(100% + 6px); transform-origin: top right; - animation: drop-in var(--t-med) var(--ease-spring); + animation: drop-in var(--t-med) var(--ease-out); } diff --git a/src/lib/ui/Select.svelte b/src/lib/ui/Select.svelte index ede9407..e66cdf3 100644 --- a/src/lib/ui/Select.svelte +++ b/src/lib/ui/Select.svelte @@ -127,7 +127,7 @@ border-radius: var(--r-lg); box-shadow: var(--shadow-pop); transform-origin: bottom left; - animation: pop-in var(--t-med) var(--ease-spring); + animation: pop-in var(--t-med) var(--ease-out); } .opt { display: flex; diff --git a/src/lib/ui/Toaster.svelte b/src/lib/ui/Toaster.svelte index 442973d..6275e78 100644 --- a/src/lib/ui/Toaster.svelte +++ b/src/lib/ui/Toaster.svelte @@ -8,7 +8,8 @@ import WarningIcon from 'phosphor-svelte/lib/WarningIcon'; import XIcon from 'phosphor-svelte/lib/XIcon'; import { fly } from 'svelte/transition'; - import { T_FAST, T_MED } from './motion'; + import { cubicOut } from 'svelte/easing'; + import { T_FAST, T_MED, motionMs } from './motion'; import { toast, type ToastTone } from './toast.svelte'; import { t } from '$lib/i18n'; @@ -45,8 +46,8 @@ style:z-index={toast.items.length - i} style:transform="translate(-50%, {offset(i)}px) scale({expanded ? 1 : 1 - i * 0.04})" style:opacity={!expanded && i > 2 ? 0 : 1} - in:fly={{ y: -12, duration: T_MED }} - out:fly={{ y: -8, duration: T_FAST }} + in:fly={{ y: -8, duration: motionMs(T_MED), easing: cubicOut }} + out:fly={{ y: -6, duration: motionMs(T_FAST), easing: cubicOut }} > {it.message} @@ -91,7 +92,7 @@ font-size: var(--fs-sm); transform-origin: top center; transition: - transform var(--t-med) var(--ease-spring), + transform var(--t-med) var(--ease-out), opacity var(--t-med) var(--ease-out); } /* Errors tint the card; other tones colour only their icon. */ diff --git a/src/lib/ui/motion.ts b/src/lib/ui/motion.ts index 8b8fce9..9ecb187 100644 --- a/src/lib/ui/motion.ts +++ b/src/lib/ui/motion.ts @@ -1,7 +1,9 @@ // Svelte transitions on the app's motion tokens (app.css: --t-*, --ease-*), for // elements that also need an exit animation. CSS-only entrances use the -// keyframes in app.css. -import { cubicOut, backOut } from 'svelte/easing'; +// keyframes in app.css. Everything here fades and moves a few pixels: no +// scale, no overshoot, nothing longer than T_MED; reduced motion makes them +// instant. +import { cubicOut } from 'svelte/easing'; import type { TransitionConfig } from 'svelte/transition'; export const T_FAST = 140; @@ -9,16 +11,59 @@ export const T_MED = 220; const reduced = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches; +/** `ms`, or 0 when the user asked for reduced motion (for svelte/transition's own). */ +export const motionMs = (ms: number) => (reduced() ? 0 : ms); + /** Backdrops. */ export function scrim(_node: Element, { duration = T_FAST } = {}): TransitionConfig { - return { duration: reduced() ? 0 : duration, easing: cubicOut, css: (t) => `opacity: ${t}` }; + return { duration: motionMs(duration), easing: cubicOut, css: (t) => `opacity: ${t}` }; } -/** Modals, sheets and floating cards: rise and settle in, sink out. */ -export function sheet(_node: Element, { y = 10, duration = T_MED } = {}): TransitionConfig { +/** Modals and sheets: rise in, sink out. */ +export function sheet(_node: Element, { y = 6, duration = T_MED } = {}): TransitionConfig { return { - duration: reduced() ? 0 : duration, - easing: backOut, - css: (t, u) => `opacity: ${Math.min(1, t * 1.6)}; transform: translateY(${u * y}px) scale(${0.985 + 0.015 * t})` + duration: motionMs(duration), + easing: cubicOut, + css: (t, u) => `opacity: ${t}; transform: translateY(${u * y}px)` + }; +} + +/** A page or section coming in (settings sections): fade and rise a little. */ +export function paneIn(_node: Element, { y = 4, duration = T_MED } = {}): TransitionConfig { + return { + duration: motionMs(duration), + easing: cubicOut, + css: (t, u) => `opacity: ${t}; transform: translateY(${u * y}px)` + }; +} + +/** A page leaving (settings, home, project page), or a settings section + * giving way to the next: fade and sink a little. */ +export function paneOut(_node: Element, { y = 4, duration = T_FAST } = {}): TransitionConfig { + return { + duration: motionMs(duration), + easing: cubicOut, + css: (t, u) => `opacity: ${t}; transform: translateY(${u * y}px)` + }; +} + +// Rows that arrive in the same update: a list loading or filling in, which +// appears at once instead of sliding row by row. +let arriving = 0; + +/** A list row arriving (a new session in the sidebar): slide in from the left. */ +export function rowIn(_node: Element, { x = -6, duration = 180 } = {}): () => TransitionConfig { + arriving++; + // Svelte calls a returned function in the next microtask, after every + // row of this update has counted itself. + return () => { + const together = arriving; + queueMicrotask(() => (arriving = 0)); + if (together > 2) return { duration: 0 }; + return { + duration: motionMs(duration), + easing: cubicOut, + css: (t, u) => `opacity: ${t}; transform: translateX(${u * x}px)` + }; }; } diff --git a/src/lib/updater.svelte.ts b/src/lib/updater.svelte.ts index a401732..b5ecd6c 100644 --- a/src/lib/updater.svelte.ts +++ b/src/lib/updater.svelte.ts @@ -3,7 +3,7 @@ // 检查和下载在 Rust 侧(src-tauri/src/app_update.rs):先走 GitHub, // 不通或太慢时换 JuCode 服务器上的同一份签名安装包。 import { Channel, invoke } from '@tauri-apps/api/core'; -import { getVersion } from '@tauri-apps/api/app'; +import { appVersion } from '$lib/about'; import { relaunch } from '@tauri-apps/plugin-process'; import { workspaces } from '$lib/workbench/workspaceStore.svelte'; @@ -113,7 +113,7 @@ export class UpdaterState { async #checkRequired() { const [min, current] = await Promise.all([ invoke('update_policy').catch(() => ''), - getVersion().catch(() => '') + appVersion() ]); this.required = min && current && olderThan(current, min) ? min : ''; } diff --git a/src/lib/updater.test.ts b/src/lib/updater.test.ts index 2e7a4f2..387ae74 100644 --- a/src/lib/updater.test.ts +++ b/src/lib/updater.test.ts @@ -10,7 +10,7 @@ vi.mock('@tauri-apps/api/core', () => ({ } })); vi.mock('@tauri-apps/plugin-process', () => ({ relaunch })); -vi.mock('@tauri-apps/api/app', () => ({ getVersion: () => Promise.resolve('0.4.0') })); +vi.mock('$lib/about', () => ({ appVersion: () => Promise.resolve('0.4.0') })); /** `update_check` finds 0.3.2 on `source`; `install` answers `update_install`. */ function commands(install: () => Promise = () => Promise.resolve(), source = 'github') { diff --git a/src/lib/workbench/TabChromePopover.svelte b/src/lib/workbench/TabChromePopover.svelte index 24ab76c..bdac01f 100644 --- a/src/lib/workbench/TabChromePopover.svelte +++ b/src/lib/workbench/TabChromePopover.svelte @@ -242,7 +242,7 @@ background: var(--panel); border-radius: var(--r-lg); box-shadow: var(--shadow-pop); - animation: pop-in var(--t-med) var(--ease-spring); + animation: pop-in var(--t-med) var(--ease-out); } .field { display: flex; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 2fc7aeb..0b59b9e 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -21,6 +21,20 @@ prefs.init(); // Persist detection + reflect the active locale on for a11y. setLocale(getLocale()); + // Resizing or zooming the window: layout jumps, nothing animates along + // (app.css .win-resizing), until 150 ms after the last resize event. + const root = document.documentElement; + let settle: ReturnType | undefined; + const onResize = () => { + root.classList.add('win-resizing'); + clearTimeout(settle); + settle = setTimeout(() => root.classList.remove('win-resizing'), 150); + }; + window.addEventListener('resize', onResize); + return () => { + window.removeEventListener('resize', onResize); + clearTimeout(settle); + }; }); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index bc7b9a9..e81569f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -8,6 +8,7 @@ import ChatCircleTextIcon from 'phosphor-svelte/lib/ChatCircleTextIcon'; import SessionMark from '$lib/SessionMark.svelte'; import { sessionStatus } from '$lib/sessionStatus'; + import { joinPath, parseFileHref, pathExt } from '$lib/fileRefs'; import PlusIcon from 'phosphor-svelte/lib/PlusIcon'; import ListChecksIcon from 'phosphor-svelte/lib/ListChecksIcon'; import TargetIcon from 'phosphor-svelte/lib/TargetIcon'; @@ -66,6 +67,7 @@ leavesOf, openTab, serializeLayout, + splitLeaf, type TileLayout, type TileTab } from '$lib/workbench/tiles'; @@ -87,6 +89,7 @@ import ChatPane, { type ChatPaneApi, type ProviderOption } from '$lib/ChatPane.svelte'; import SettingsPage from '$lib/settings/SettingsPage.svelte'; import type { SectionKey } from '$lib/settings/nav'; + import { folderName } from '$lib/agentImport'; import Welcome from '$lib/welcome/Welcome.svelte'; import Marketplace from '$lib/Marketplace.svelte'; import Sidebar from '$lib/Sidebar.svelte'; @@ -109,6 +112,9 @@ import { agentDirectory, agentsOfWorkspace } from '$lib/agents.svelte'; import { CHATS_ENABLED, type Project, type WorktreeMeta } from '$lib/types'; import PlanPanel from '$lib/PlanPanel.svelte'; + import ProposalPane from '$lib/ProposalPane.svelte'; + import { planPages, proposalOf, proposalPanel } from '$lib/planPages.svelte'; + import type { ApprovalMode } from '$lib/approval'; import GoalPanel from '$lib/GoalPanel.svelte'; import ChangesPanel from '$lib/ChangesPanel.svelte'; import TurnsPanel from '$lib/TurnsPanel.svelte'; @@ -423,6 +429,8 @@ const tui = tuiBackendOf(tab.panel); if (tui) return tuiTabTitle(tui); if (tab.panel === 'audit') return t('editor.title'); + const proposal = proposalOf(tab.panel); + if (proposal) return planOf(proposal.sessionId, proposal.planId)?.title || t('chat.planCard.label'); return (ALL_PANELS as readonly string[]).includes(tab.panel) ? t(`dock.tabs.${tab.panel}`) : tab.panel; } function tuiReady(sid: string): boolean { @@ -557,6 +565,35 @@ else applyTiles(openTab(tiles, focusedLeaf, { id: newTabId(), panel: kind })); } + /** The approval mode a plan runs in once approved: the one before plan mode. */ + function planModeOf(sessionId: string): ApprovalMode { + const before = sessionMap.get(sessionId)?.chat.modeBeforePlan; + return before && before !== 'plan' ? before : 'edits'; + } + /** A plan message of a session, for its page. */ + function planOf(sessionId: string, planId: string) { + const m = sessionMap.get(sessionId)?.chat.messages.find((x) => x.kind === 'plan' && x.id === planId); + return m?.kind === 'plan' ? m : undefined; + } + /** A plan's page beside its session's chat: shown again if open, else a + * new tab (one per plan, closed one by one). */ + function openPlanPage(sessionId: string, planId: string) { + const panel = proposalPanel(sessionId, planId); + const existing = findPanelTab(panel); + if (existing) return applyTiles(activateTab(tiles, existing.id)); + const tab = { id: newTabId(), panel }; + // Plan pages share one column right of the chat, as tabs. + const plans = leavesOf(tiles.root).find((leaf) => leaf.tabs.some((x) => x.panel.startsWith('proposal:'))); + if (plans) return applyTiles(openTab(tiles, plans.id, tab)); + const chat = leafOfTab(tiles.root, chatPanel(sessionId)); + applyTiles(chat ? splitLeaf(tiles, chat.id, 'right', tab).layout : openTab(tiles, focusedLeaf, tab)); + } + $effect(() => { + const request = planPages.request; + if (!request || !tilesReady) return; + untrack(() => openPlanPage(request.sessionId, request.planId)); + }); + // The workbench-active session always has a chat tile: activating a session // (sidebar click, ⌘N, resume, deep link…) opens or focuses it. $effect(() => { @@ -868,6 +905,17 @@ projectChromeFor = { id: p.id, x: ev.clientX, y: ev.clientY }; } + /** Settings → 导入: the folders of imported conversations become projects + * (without a chat; their conversations are in the project's 历史). */ + function addImportedFolders(paths: string[]): number { + let added = 0; + for (const path of paths) { + if (store.userProjects.some((p) => normPath(p.path) === normPath(path))) continue; + store.addProjectShell({ id: store.uid(), name: folderName(path), path }); + added++; + } + return added; + } async function addProject() { const path = await open({ directory: true, title: t('shell.pickDirTitle') }); if (!path || Array.isArray(path)) return; @@ -900,6 +948,13 @@ store.removeProject(p); } + /** A pane's render error: logged with the session for the logs folder. */ + function reportUiError(error: unknown, sid: string) { + telemetry.track('error:pane'); + const text = error instanceof Error ? `${error.message}\n${error.stack ?? ''}` : String(error); + invoke('log_ui_error', { message: `pane ${sid}: ${text}` }).catch(() => {}); + } + async function removeSession(id: string) { const s = sessionMap.get(id); // A draft that never started has nothing to lose. @@ -1005,12 +1060,17 @@ function openActiveFile(href: string) { const cwd = activeProject?.path; if (!cwd) return; - const rel = href.replace(/^file:\/\//, '').split(/[?#]/)[0].trim(); + const rel = parseFileHref(href).path; if (!rel) return; - const abs = rel.startsWith('/') ? rel : `${cwd.replace(/\/+$/, '')}/${rel.replace(/^\.?\//, '')}`; - const ext = abs.split('/').pop()?.split('.').pop()?.toLowerCase() ?? ''; + const abs = joinPath(cwd, rel); + const ext = pathExt(abs); if ((ext === 'html' || ext === 'htm') && prefs.htmlOpenInBrowser) { - browser.open(`file://${abs}`); + // The embedded browser loads http(s) only (a file:// URL was refused + // and left it on its empty page): serve the file from the loopback + // preview server, as chat links do, so its relative assets load too. + invoke('preview_url', { path: abs }) + .then((url) => browser.open(url)) + .catch((e) => toast.error(t('chat.fileOpenFailed', { path: rel, error: String(e) }))); } else { editorStore.open(abs, cwd).catch((e) => console.error('open file', e)); } @@ -1066,7 +1126,13 @@ duration: 15000, action: { label: t('settings.help.noticeAction'), run: () => openSettings('general') } }); - const onUiError = () => telemetry.track('error:ui'); + const onUiError = (e: Event) => { + // (window-level errors; a pane's own are caught by its boundary) + telemetry.track('error:ui'); + const err = e instanceof ErrorEvent ? e.error ?? e.message : (e as PromiseRejectionEvent).reason; + const text = err instanceof Error ? `${err.message}\n${err.stack ?? ''}` : String(err); + invoke('log_ui_error', { message: text }).catch(() => {}); + }; window.addEventListener('error', onUiError); window.addEventListener('unhandledrejection', onUiError); const savedSb = Number(localStorage.getItem('jucode-sidebar-width')); @@ -1085,6 +1151,9 @@ const s = sessionMap.get(sessionId); if (!s) return; const wasBusy = s.chat.busy; + const hadApproval = s.chat.pendingApproval?.callId ?? null; + const hadPlan = s.chat.messages.findLast((m) => m.kind === 'plan' && m.status === 'pending'); + const lastPlan = s.chat.messages.findLast((m) => m.kind === 'plan'); // Capture the raw frame for the diagnostics trace so a mis-parsed or // dropped tool frame is inspectable after the fact. s.chat.captureFrame(data); @@ -1137,7 +1206,22 @@ s.chat.unseen = true; notifyDone(s.chat.title); } - // This session's tile (if any) sticks to the bottom while streaming. + // Something waits on the user while the window is in the + // background: an action to allow, or a plan to approve. + if (!document.hasFocus()) { + const ask = s.chat.pendingApproval; + if (ask && ask.callId !== hadApproval && !ask.questions?.length) + void notify(s.chat.title, t('shell.notifyApproval', { what: ask.summary.slice(0, 120) })); + const plan = s.chat.messages.findLast((m) => m.kind === 'plan' && m.status === 'pending'); + if (plan && plan !== hadPlan && plan.kind === 'plan') + void notify(s.chat.title, t('shell.notifyPlan', { title: plan.title })); + } + // A plan the model starts writing (or proposes whole) opens in its + // page beside this session's chat. + const newPlan = s.chat.messages.findLast((m) => m.kind === 'plan'); + if (newPlan && newPlan !== lastPlan && newPlan.kind === 'plan' && (newPlan.status === 'drafting' || newPlan.status === 'pending') && tilesReady && chatSessionsIn(tiles).includes(s.id)) + openPlanPage(s.id, newPlan.id); + // This session's tile (if any) sticks to the bottom while streaming. panes.get(s.id)?.scrollToEnd(); }; daemon.onFrame = deliver; @@ -1442,27 +1526,55 @@ /> {/if} {:else} - openPanelTile('agents')} - /> + + reportUiError(e, sid)}> + openPanelTile('agents')} + /> + {#snippet failed(error, reset)} +
+

{t('shell.paneError')}

+ {String((error as Error)?.message ?? error).slice(0, 300)} + +
+ {/snippet} +
{/if} {:else}
{t('shell.chatGone')}
{/if} {:else if tab.panel === 'plan'} + {:else if proposalOf(tab.panel)} + {@const p = proposalOf(tab.panel)!} + {@const plan = planOf(p.sessionId, p.planId)} + {@const lastPlan = sessionMap.get(p.sessionId)?.chat.messages.findLast((m) => m.kind === 'plan')} + { + panes.get(p.sessionId)?.planAction(id, action); + if (action.decision === 'revise') panes.get(p.sessionId)?.focusComposer(); + }} + /> {:else if tab.panel === 'goal'} {:else if tab.panel === 'agents'}{#if chat && activeId}{#key activeId} activeId && dispatch(activeId, op)} />{/key}{/if} - {:else if tab.panel === 'changes'} chat && (chat.changedFiles = chat.changedFiles.filter((x) => x !== p))} /> + {:else if tab.panel === 'changes'} { + if (!chat) return; + chat.changedFiles = chat.changedFiles.filter((x) => x !== p); + delete chat.agentDiffs[rel]; + }} /> {:else if tab.panel === 'turns'} {:else if tab.panel === 'files'} {:else if tab.panel === 'git'} openTaskProject(path, meta)} onTaskRemoved={closeTaskProject} /> @@ -1489,6 +1601,7 @@ showMarket = true; }} onFeedback={() => (showFeedback = true)} + onAddFolders={addImportedFolders} onClose={closeSettings} /> {/if} @@ -1863,4 +1976,30 @@ border-color: var(--accent); background: var(--surface2); } + .pane-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + height: 100%; + padding: 24px; + color: var(--dim); + text-align: center; + } + .pane-error code { + max-width: 560px; + color: var(--dim2); + font-size: var(--fs-xs); + word-break: break-word; + } + .pane-error button { + padding: 6px 14px; + border: 1px solid var(--border); + border-radius: var(--r-md); + background: var(--panel); + color: var(--text); + font: inherit; + cursor: pointer; + } diff --git a/src/routes/remote/+page.svelte b/src/routes/remote/+page.svelte index 26dfcdb..a4cae71 100644 --- a/src/routes/remote/+page.svelte +++ b/src/routes/remote/+page.svelte @@ -850,7 +850,7 @@ max-width: min(340px, calc(100vw - 32px)); text-align: left; transform-origin: top right; - animation: drop-in var(--t-med) var(--ease-spring); + animation: drop-in var(--t-med) var(--ease-out); } .switch-menu.left { right: auto;