diff --git a/test/tauri-ui.test.js b/test/tauri-ui.test.js index bafbbde..f1c9b3e 100644 --- a/test/tauri-ui.test.js +++ b/test/tauri-ui.test.js @@ -23,6 +23,20 @@ test("renders every Pi RPC extension UI method without replying to fire-and-forg assert.match(source, /stripAnsi\(extension\.statusText\)/); assert.match(source, /className="pi-controls"/); assert.match(source, /aria-label="Model and thinking controls"/); + assert.match(source, /const uiScaleKey/); + assert.match(source, /function clampUiScale/); + assert.match(source, /function updateUiScale/); + assert.match(source, /localStorage\.setItem\(uiScaleKey/); + assert.match(source, /className="scale-settings"/); + assert.match(source, /aria-label="Interface scale"/); + assert.match(source, /aria-label="Decrease interface scale"/); + assert.match(source, /aria-label="Increase interface scale"/); + assert.match(source, /type="range"/); + assert.match(source, /style=\{\{ zoom: uiScale \}\}/); + assert.doesNotMatch( + source, + /className="pi-controls"[\s\S]*aria-label="Interface scale"/, + ); assert.doesNotMatch(source, /
/); assert.doesNotMatch( source, @@ -35,7 +49,7 @@ test("renders every Pi RPC extension UI method without replying to fire-and-forg ); assert.match(source, /useLayoutEffect/); assert.match(source, /transcript\.scrollTop = transcript\.scrollHeight/); - assert.match(source, /requestAnimationFrame\(scrollToLatest\)/); + assert.match(source, /requestAnimationFrame\(scrollTranscriptToLatest\)/); assert.match(source, /const loadGeneration = useRef\(0\)/); assert.match( source, @@ -55,6 +69,12 @@ test("renders every Pi RPC extension UI method without replying to fire-and-forg source, /const lastEventSequence = useRef>/, ); + assert.match(source, /const eventReloadInFlight = useRef\(false\)/); + assert.match(source, /const queuedEventReload = useRef/); + assert.match(source, /function reloadAgentAfterEvent\(agentId: string\)/); + assert.match(source, /void reloadAgentAfterEvent\(selectedId\)/); + assert.match(source, /let disposed = false;/); + assert.match(source, /if \(disposed\) stop\(\);/); assert.match(source, /generation !== loadGeneration\.current/); assert.match(source, /current\.phase !== "working"/); assert.match(source, /bridgeEvent\.seq <= \(lastEventSequence\.current/); @@ -181,6 +201,19 @@ test("shows bridge-managed directory status and sessions for the opened director /state: loadedState\.isStreaming \? "streaming" : "idle"/, ); assert.match(component, /className="directory-add"/); + assert.match( + component, + /import \{ open \} from "@tauri-apps\/plugin-dialog"/, + ); + assert.match(component, /function browseForFolder/); + assert.match(component, /directory: true/); + assert.match(component, /multiple: false/); + assert.match(component, /Browse…/); + assert.match(component, /const \[isAddingDirectory, setIsAddingDirectory\]/); + assert.match(component, /aria-busy=\{isAddingDirectory\}/); + assert.match(component, /className="directory-add-status" role="status"/); + assert.match(component, /pendingDirectoryPath/); + assert.match(component, /Added \$\{worktreeLabel\(pendingDirectoryPath\)\}/); assert.match(component, /Add directory/); assert.doesNotMatch(component, /className="folder-form"/); assert.doesNotMatch(component, /Remembered folders/); @@ -192,6 +225,9 @@ test("shows bridge-managed directory status and sessions for the opened director assert.match(component, /New session/); assert.match(component, /state\.isStreaming/); assert.match(styles, /\.directory-add \{/); + assert.match(styles, /\.directory-add-status \{/); + assert.match(styles, /\.directory-add-spinner \{/); + assert.match(styles, /directory-add-spin/); assert.match(styles, /\.directory-tab \{/); assert.match(styles, /\.directory-tab-button \{/); assert.match(styles, /\.directory-close \{/); @@ -235,6 +271,28 @@ test("shows an optimistic prompt until the transcript receives it", async () => assert.match(styles, /\.pending-message\.sending/); }); +test("offers a lower-center control when the conversation is above its latest message", async () => { + const [component, styles] = await Promise.all([ + readFile("ui/src/App.tsx", "utf8"), + readFile("ui/src/App.css", "utf8"), + ]); + assert.match( + component, + /const \[isTranscriptAtBottom, setIsTranscriptAtBottom\]/, + ); + assert.match(component, /function updateTranscriptScrollPosition/); + assert.match(component, /function scrollTranscriptToLatest/); + assert.match(component, /onScroll=\{updateTranscriptScrollPosition\}/); + assert.match(component, /!isTranscriptAtBottom/); + assert.match(component, /aria-label="Scroll to latest message"/); + assert.match(component, /className="scroll-to-latest"/); + assert.match(component, /scrollTranscriptToLatest\(\);/); + assert.match(styles, /\.transcript-container \{/); + assert.match(styles, /\.scroll-to-latest \{[\s\S]*left: 50%/); + assert.match(styles, /\.scroll-to-latest \{[\s\S]*bottom: 14px/); + assert.match(styles, /\.scroll-to-latest \{[\s\S]*border-radius: 50%/); +}); + test("distinguishes user and assistant messages", async () => { const styles = await readFile("ui/src/App.css", "utf8"); assert.match(styles, /\.message\.user \{/); @@ -293,9 +351,36 @@ test("uses a compact workflow layout that keeps the composer and transcript stab assert.match(styles, /conic-gradient\(/); assert.match(styles, /transcript-border-orbit/); assert.match(styles, /prefers-reduced-motion: reduce/); + assert.match(styles, /font-size: 18px/); + assert.match(styles, /\.directory-tab-button \{[\s\S]*font-size: 13px/); + assert.match(styles, /\.message pre \{[\s\S]*font-size: 14px/); assert.match( styles, - /\.workflow-main \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) 210px/, + /\.workflow-main \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) 250px/, ); + assert.match( + styles, + /\.pi-controls \{[\s\S]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/, + ); + assert.match(styles, /\.scale-settings \{/); + assert.match( + styles, + /\.scale-adjustment \{[\s\S]*grid-template-columns: auto minmax\(0, 1fr\) auto/, + ); + assert.match(styles, /\.scale-adjustment input\[type="range"\] \{/); assert.match(styles, /\.pi-controls \{[\s\S]*padding: 4px 6px/); }); + +test("configures a larger desktop window and enables the folder dialog", async () => { + const [config, capability, rust] = await Promise.all([ + readFile("ui/src-tauri/tauri.conf.json", "utf8"), + readFile("ui/src-tauri/capabilities/default.json", "utf8"), + readFile("ui/src-tauri/src/lib.rs", "utf8"), + ]); + assert.match(config, /"width": 1100/); + assert.match(config, /"height": 840/); + assert.match(config, /"minWidth": 640/); + assert.match(config, /"minHeight": 520/); + assert.match(capability, /"dialog:default"/); + assert.match(rust, /\.plugin\(tauri_plugin_dialog::init\(\)\)/); +}); diff --git a/ui/package-lock.json b/ui/package-lock.json index b73a2cc..17467f0 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,14 +1,16 @@ { - "name": "ui", + "name": "pi-status-ui", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ui", + "name": "pi-status-ui", "version": "0.1.0", + "license": "Apache-2.0", "dependencies": { "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-opener": "^2", "react": "^19.1.0", "react-dom": "^19.1.0" @@ -1434,6 +1436,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", + "integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.5.4", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", diff --git a/ui/package.json b/ui/package.json index c732c1f..66f621e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -17,17 +17,18 @@ "tauri:release": "tauri build --no-bundle" }, "dependencies": { - "react": "^19.1.0", - "react-dom": "^19.1.0", "@tauri-apps/api": "^2", - "@tauri-apps/plugin-opener": "^2" + "@tauri-apps/plugin-dialog": "^2.7.2", + "@tauri-apps/plugin-opener": "^2", + "react": "^19.1.0", + "react-dom": "^19.1.0" }, "devDependencies": { + "@tauri-apps/cli": "^2", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", "typescript": "~5.8.3", - "vite": "^7.0.4", - "@tauri-apps/cli": "^2" + "vite": "^7.0.4" } } diff --git a/ui/src-tauri/Cargo.lock b/ui/src-tauri/Cargo.lock index 9e86038..9a98307 100644 --- a/ui/src-tauri/Cargo.lock +++ b/ui/src-tauri/Cargo.lock @@ -2168,6 +2168,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2383,6 +2384,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-single-instance", "tokio", ] @@ -2685,6 +2687,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3363,6 +3389,64 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.19", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.19", + "toml 1.1.3+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-single-instance" version = "2.4.3" diff --git a/ui/src-tauri/Cargo.toml b/ui/src-tauri/Cargo.toml index 2ecf3f3..c47bf73 100644 --- a/ui/src-tauri/Cargo.toml +++ b/ui/src-tauri/Cargo.toml @@ -24,6 +24,7 @@ tauri = { version = "2", features = [] } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["io-util", "net", "rt", "macros"] } +tauri-plugin-dialog = "2" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/ui/src-tauri/capabilities/default.json b/ui/src-tauri/capabilities/default.json index 45461e2..c45ddda 100644 --- a/ui/src-tauri/capabilities/default.json +++ b/ui/src-tauri/capabilities/default.json @@ -2,10 +2,13 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for the main window", - "windows": ["main"], + "windows": [ + "main" + ], "permissions": [ "core:default", "core:window:allow-hide", - "core:window:allow-start-dragging" + "core:window:allow-start-dragging", + "dialog:default" ] -} +} \ No newline at end of file diff --git a/ui/src-tauri/src/bridge.rs b/ui/src-tauri/src/bridge.rs index f9c5dc5..dc1e015 100644 --- a/ui/src-tauri/src/bridge.rs +++ b/ui/src-tauri/src/bridge.rs @@ -1,6 +1,7 @@ use serde::Serialize; use serde_json::{json, Value}; use std::env; +use std::path::Path; use tauri::{AppHandle, Emitter}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; @@ -132,22 +133,67 @@ pub async fn new_session(socket_path: &str, agent_id: &str) -> Result Value { + let Some(path) = state.pointer("/data/sessionFile").and_then(Value::as_str) else { + return json!({ "data": { "messages": [] } }); + }; + let path = Path::new(path); + if !path.is_absolute() + || path.extension().and_then(|extension| extension.to_str()) != Some("jsonl") + { + return json!({ "data": { "messages": [] } }); + } + let messages = std::fs::read_to_string(path) + .ok() + .map(|content| { + content + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|entry| entry.get("type").and_then(Value::as_str) == Some("message")) + .filter_map(|entry| entry.get("message").cloned()) + .collect::>() + }) + .unwrap_or_default(); + json!({ "data": { "messages": messages } }) +} + +fn compact_commands(value: Value) -> Value { + let commands = value + .pointer("/data/commands") + .and_then(Value::as_array) + .map(|commands| { + commands + .iter() + .map(|command| { + let mut compact = serde_json::Map::new(); + for field in ["name", "description"] { + if let Some(value) = command.get(field) { + compact.insert(field.to_owned(), value.clone()); + } + } + Value::Object(compact) + }) + .collect::>() + }) + .unwrap_or_default(); + json!({ "data": { "commands": commands } }) +} + pub async fn load_agent(socket_path: &str, agent_id: &str) -> Result { // Older bridge daemons can still serve the conversation while they await a restart. let stats = request(socket_path, "get_session_stats", Some(agent_id), None) .await .unwrap_or_else(|_| json!({ "data": {} })); - let (state, transcript, commands, models) = tokio::try_join!( + let (state, commands, models) = tokio::try_join!( request(socket_path, "get_state", Some(agent_id), None), - request(socket_path, "get_transcript", Some(agent_id), None), request(socket_path, "get_commands", Some(agent_id), None), request(socket_path, "get_available_models", Some(agent_id), None), )?; Ok(json!({ "state": state, "stats": stats, - "transcript": transcript, - "commands": commands, + "transcript": fallback_transcript(&state), + "commands": compact_commands(commands), "models": models })) } @@ -164,7 +210,9 @@ pub async fn submit_prompt(socket_path: &str, agent_id: &str, message: &str) -> } pub async fn abort(socket_path: &str, agent_id: &str) -> Result<(), String> { - request(socket_path, "abort", Some(agent_id), None).await.map(|_| ()) + request(socket_path, "abort", Some(agent_id), None) + .await + .map(|_| ()) } pub async fn set_model( @@ -313,6 +361,35 @@ mod tests { std::fs::remove_file(path).expect("socket cleanup"); } + #[test] + fn reads_messages_from_the_saved_session_file_when_transcript_rpc_is_unavailable() { + let path = format!( + "{}/pi-status-ui-history-{}.jsonl", + env::temp_dir().display(), + std::process::id() + ); + std::fs::write( + &path, + concat!( + "{\"type\":\"session\",\"id\":\"session-1\"}\n", + "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"Hello\"}}\n", + "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Hi\"}}\n" + ), + ) + .expect("session fixture"); + let transcript = fallback_transcript(&json!({ + "data": { "sessionFile": path } + })); + assert_eq!( + transcript["data"]["messages"], + json!([ + { "role": "user", "content": "Hello" }, + { "role": "assistant", "content": "Hi" } + ]) + ); + std::fs::remove_file(path).expect("session cleanup"); + } + #[tokio::test] async fn loads_state_history_commands_and_models_as_one_snapshot() { let path = format!( @@ -323,7 +400,7 @@ mod tests { let _ = std::fs::remove_file(&path); let listener = UnixListener::bind(&path).expect("listener"); let server = tokio::spawn(async move { - for _ in 0..5 { + for _ in 0..4 { let (stream, _) = listener.accept().await.expect("connection"); tokio::spawn(async move { let mut line = String::new(); @@ -336,8 +413,11 @@ mod tests { Some("get_session_stats") => json!({ "data": { "contextUsage": { "tokens": 32000, "contextWindow": 200000 } } }), - Some("get_transcript") => json!({ "data": { "messages": [] } }), - Some("get_commands") => json!({ "data": { "commands": [] } }), + Some("get_commands") => json!({ "data": { "commands": [{ + "name": "resume", + "description": "Resume a saved session", + "sourceInfo": { "path": "/very/large/extension/metadata" } + }] } }), Some("get_available_models") => json!({ "data": { "models": [] } }), other => panic!("unexpected operation: {other:?}"), }; @@ -346,7 +426,11 @@ mod tests { })) .expect("response JSON"); response.push(b'\n'); - reader.get_mut().write_all(&response).await.expect("response"); + reader + .get_mut() + .write_all(&response) + .await + .expect("response"); }); } }); @@ -354,6 +438,10 @@ mod tests { assert_eq!(snapshot["state"]["data"]["isStreaming"], false); assert_eq!(snapshot["stats"]["data"]["contextUsage"]["tokens"], 32000); assert_eq!(snapshot["transcript"]["data"]["messages"], json!([])); + assert_eq!( + snapshot["commands"]["data"]["commands"], + json!([{ "name": "resume", "description": "Resume a saved session" }]) + ); server.await.expect("server succeeds"); std::fs::remove_file(path).expect("socket cleanup"); } diff --git a/ui/src-tauri/src/lib.rs b/ui/src-tauri/src/lib.rs index 25eb0f4..4f3833b 100644 --- a/ui/src-tauri/src/lib.rs +++ b/ui/src-tauri/src/lib.rs @@ -123,7 +123,9 @@ fn subscribe_agent( } let event_app = app.clone(); let task = tauri::async_runtime::spawn(async move { - if let Err(error) = bridge::subscribe(socket, agent_id.clone(), cursor, event_app.clone()).await { + if let Err(error) = + bridge::subscribe(socket, agent_id.clone(), cursor, event_app.clone()).await + { let _ = event_app.emit( "bridge-error", serde_json::json!({ "agentId": agent_id, "message": error }), @@ -141,6 +143,7 @@ fn subscribe_agent( #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let builder = tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) .manage(Subscription(Mutex::new(None))) .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { if let Some(window) = app.get_webview_window("main") { @@ -189,6 +192,9 @@ mod tests { assert_eq!(window_action(&toggle, true), WindowAction::Hide); assert_eq!(window_action(&toggle, false), WindowAction::ShowAndFocus); - assert_eq!(window_action(&vec!["--show".to_owned()], true), WindowAction::ShowAndFocus); + assert_eq!( + window_action(&vec!["--show".to_owned()], true), + WindowAction::ShowAndFocus + ); } } diff --git a/ui/src-tauri/tauri.conf.json b/ui/src-tauri/tauri.conf.json index 7bc8072..5612bfb 100644 --- a/ui/src-tauri/tauri.conf.json +++ b/ui/src-tauri/tauri.conf.json @@ -13,10 +13,10 @@ "windows": [ { "title": "Pi Status UI", - "width": 900, - "height": 720, - "minWidth": 480, - "minHeight": 420, + "width": 1100, + "height": 840, + "minWidth": 640, + "minHeight": 520, "center": true, "decorations": false, "transparent": true, diff --git a/ui/src/App.css b/ui/src/App.css index 4781fb7..b6c8388 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -6,6 +6,7 @@ :root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; + font-size: 18px; color: #ebebe8; background: #101111; } @@ -27,8 +28,8 @@ } body { margin: 0; - min-width: 480px; - min-height: 420px; + min-width: 640px; + min-height: 520px; background: transparent; } button, @@ -41,10 +42,10 @@ button { cursor: pointer; border: 0; border-radius: 7px; - padding: 8px 12px; + padding: 9px 14px; background: #dcecc8; color: #152012; - font-size: 12px; + font-size: 14px; font-weight: 750; } button:hover { @@ -66,8 +67,8 @@ textarea:focus-visible { overflow: hidden; display: flex; flex-direction: column; - gap: 9px; - padding: 14px; + gap: 12px; + padding: 18px; border: 1px solid #3b403a; border-radius: 14px; background: rgba(18, 20, 19, 0.97); @@ -101,23 +102,23 @@ textarea:focus-visible { .brand { flex: 0 0 auto; color: #b8df84; - font-size: 10px; + font-size: 12px; font-weight: 900; letter-spacing: 0.16em; } h1 { min-width: 0; margin: 0; - font-size: 16px; + font-size: 20px; line-height: 1.15; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .status-row { - margin-top: 3px; + margin-top: 4px; color: #898f89; - font-size: 11px; + font-size: 13px; white-space: nowrap; overflow: hidden; } @@ -139,10 +140,10 @@ h1 { flex: 0 0 auto; border: 1px solid #404740; border-radius: 999px; - padding: 2px 6px; + padding: 3px 8px; color: #afb3ad; background: #202320; - font-size: 10px; + font-size: 12px; font-weight: 800; } .state-pill.working { @@ -171,8 +172,8 @@ h1 { .directory-tab-list { display: flex; align-items: center; - gap: 6px; - min-height: 32px; + gap: 7px; + min-height: 38px; } .directory-tabs { overflow-x: auto; @@ -184,7 +185,7 @@ h1 { .agent-caption { flex: 0 0 auto; color: #70776f; - font-size: 10px; + font-size: 12px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; @@ -197,16 +198,16 @@ h1 { .directory-tab-button { display: flex; align-items: center; - gap: 6px; + gap: 7px; min-width: 0; - max-width: 180px; + max-width: 220px; border: 1px solid #303530; border-right: 0; border-radius: 7px 0 0 7px; - padding: 5px 8px; + padding: 7px 10px; background: #1b1e1b; color: #aeb4ac; - font-size: 11px; + font-size: 13px; font-weight: 650; } .directory-tab-button > span { @@ -216,7 +217,7 @@ h1 { } .directory-tab-button small { color: #70776f; - font-size: 10px; + font-size: 11px; font-weight: 600; } .directory-tab-button.selected { @@ -236,13 +237,13 @@ h1 { border-color: #7da856; } .directory-close { - min-width: 24px; + min-width: 30px; border: 1px solid #303530; border-radius: 0 7px 7px 0; - padding: 3px 6px; + padding: 4px 7px; background: #1b1e1b; color: #aeb4ac; - font-size: 15px; + font-size: 18px; line-height: 1; } .directory-tab:focus-within .directory-tab-button, @@ -259,7 +260,7 @@ h1 { } .directory-add-toggle { flex: 0 0 auto; - padding: 5px 8px; + padding: 7px 10px; white-space: nowrap; } .directory-add { @@ -269,12 +270,34 @@ h1 { gap: 5px; } .directory-add input { - width: 220px; - padding: 5px 7px; - font-size: 11px; + width: 280px; + padding: 7px 9px; + font-size: 13px; } .directory-add button { - padding: 5px 8px; + padding: 7px 10px; +} +.directory-add-status { + display: inline-flex; + align-items: center; + gap: 6px; + color: #c4ed8b; + font-size: 13px; + font-weight: 700; + white-space: nowrap; +} +.directory-add-spinner { + width: 14px; + height: 14px; + border: 2px solid #52703a; + border-top-color: #c4ed8b; + border-radius: 50%; + animation: directory-add-spin 0.75s linear infinite; +} +@keyframes directory-add-spin { + to { + transform: rotate(360deg); + } } .notifications { @@ -311,9 +334,36 @@ h1 { .workflow-main { min-height: 0; display: grid; - grid-template-columns: minmax(0, 1fr) 210px; + grid-template-columns: minmax(0, 1fr) 250px; gap: 8px; } +.transcript-container { + position: relative; + min-height: 0; +} +.transcript-container .transcript { + height: 100%; +} +.scroll-to-latest { + position: absolute; + z-index: 1; + left: 50%; + bottom: 14px; + width: 40px; + height: 40px; + padding: 0; + border: 1px solid #6f8b56; + border-radius: 50%; + background: #263020; + color: #dcecc8; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); + font-size: 22px; + line-height: 1; + transform: translateX(-50%); +} +.scroll-to-latest:hover { + background: #35452b; +} .transcript, .settings { min-height: 0; @@ -499,14 +549,14 @@ h1 { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; - font-size: 12px; + font-size: 14px; line-height: 1.38; color: #d7dbd5; } .muted { margin: 0; color: #858b85; - font-size: 12px; + font-size: 14px; } .warning { color: #ffb4aa; @@ -519,7 +569,7 @@ h1 { } .pi-controls { display: grid; - grid-template-columns: minmax(0, 1fr) 150px; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; padding: 4px 6px; border: 1px solid #303730; @@ -577,10 +627,10 @@ textarea:focus { outline-offset: 1px; } textarea { - min-height: 48px; - max-height: 100px; + min-height: 64px; + max-height: 140px; resize: vertical; - font-size: 12px; + font-size: 14px; line-height: 1.35; } .composer textarea:focus { @@ -739,11 +789,49 @@ textarea { } h2 { margin: 0; - font-size: 14px; + font-size: 17px; } .settings-actions { justify-content: flex-start; } +.scale-settings { + display: grid; + gap: 8px; + border-top: 1px solid #303830; + padding-top: 10px; +} +.scale-settings-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} +.scale-settings-heading output { + color: #b8df84; + font-variant-numeric: tabular-nums; + font-weight: 800; +} +.scale-adjustment { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; +} +.scale-adjustment button { + min-width: 32px; + padding: 5px 8px; + font-size: 16px; + line-height: 1; +} +.scale-adjustment button:disabled { + cursor: not-allowed; + opacity: 0.5; +} +.scale-adjustment input[type="range"] { + min-width: 0; + padding: 0; + accent-color: #b8df84; +} .session-panel { display: grid; gap: 8px; @@ -859,7 +947,7 @@ h2 { grid-template-columns: minmax(0, 1fr) 170px; } .pi-controls { - grid-template-columns: minmax(0, 1fr) 120px; + grid-template-columns: repeat(2, minmax(0, 1fr)); } .transcript { padding: 7px 9px; diff --git a/ui/src/App.tsx b/ui/src/App.tsx index fc7438f..abab11c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -9,6 +9,7 @@ import { import { listen } from "@tauri-apps/api/event"; import { invoke } from "@tauri-apps/api/core"; import { getCurrentWindow } from "@tauri-apps/api/window"; +import { open } from "@tauri-apps/plugin-dialog"; import "./App.css"; type Agent = { id: string; worktreePath: string; state: string }; @@ -131,6 +132,10 @@ const thinkingLevels = [ "max", ]; const selectedPathKey = "pi-status-ui.selected-worktree"; +const uiScaleKey = "pi-status-ui.interface-scale"; +const minimumUiScale = 0.9; +const maximumUiScale = 1.4; +const uiScaleStep = 0.05; const controlCommands: Command[] = [ { name: "model", @@ -181,6 +186,10 @@ function formatTokens(value?: number | null) { return String(value); } +function clampUiScale(value: number) { + return Math.min(maximumUiScale, Math.max(minimumUiScale, value)); +} + function currentTodos(messages: Message[]) { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; @@ -221,14 +230,21 @@ function App() { const [state, setState] = useState({}); const [stats, setStats] = useState({}); const [messages, setMessages] = useState([]); + const [isTranscriptAtBottom, setIsTranscriptAtBottom] = useState(true); const [pendingSubmissions, setPendingSubmissions] = useState< PendingSubmission[] >([]); const [models, setModels] = useState([]); const [commands, setCommands] = useState([]); const [message, setMessage] = useState(""); + const [uiScale, setUiScale] = useState(() => { + const storedScale = Number(localStorage.getItem(uiScaleKey)); + return Number.isFinite(storedScale) ? clampUiScale(storedScale) : 1; + }); const [folderPath, setFolderPath] = useState(""); const [addingDirectory, setAddingDirectory] = useState(false); + const [isAddingDirectory, setIsAddingDirectory] = useState(false); + const [pendingDirectoryPath, setPendingDirectoryPath] = useState(); const [status, setStatus] = useState("Connecting to Pi Status Bridge…"); const [view, setView] = useState<"conversation" | "settings">("conversation"); const [commandFollowUp, setCommandFollowUp] = useState(); @@ -253,6 +269,8 @@ function App() { const bridgeRetryTimer = useRef(undefined); const bridgeRetryAttempt = useRef(0); const lastEventSequence = useRef>({}); + const eventReloadInFlight = useRef(false); + const queuedEventReload = useRef(undefined); const [workProgress, setWorkProgress] = useState({ phase: "idle", detail: "Ready for your next prompt", @@ -338,6 +356,20 @@ function App() { } } + function reloadAgentAfterEvent(agentId: string) { + if (eventReloadInFlight.current) { + queuedEventReload.current = agentId; + return; + } + eventReloadInFlight.current = true; + void loadAgent(agentId).finally(() => { + eventReloadInFlight.current = false; + const queuedAgentId = queuedEventReload.current; + queuedEventReload.current = undefined; + if (queuedAgentId) reloadAgentAfterEvent(queuedAgentId); + }); + } + function scheduleBridgeReconnect(preferredPath?: string) { if (bridgeRetryTimer.current !== undefined) return; const delay = Math.min(250 * 2 ** bridgeRetryAttempt.current, 2_000); @@ -402,20 +434,55 @@ function App() { }; }, []); + useEffect(() => { + if ( + !pendingDirectoryPath || + !directories.some( + (directory) => directory.worktreePath === pendingDirectoryPath, + ) + ) + return; + setPendingDirectoryPath(undefined); + setIsAddingDirectory(false); + setAddingDirectory(false); + setStatus(`Added ${worktreeLabel(pendingDirectoryPath)}`); + }, [directories, pendingDirectoryPath]); + useEffect(() => { if (selectedId) void loadSessions(selectedId); else setSessions([]); }, [selectedId]); + function updateTranscriptScrollPosition() { + const transcript = transcriptRef.current; + if (!transcript) return; + const isAtBottom = + transcript.scrollHeight - + transcript.scrollTop - + transcript.clientHeight <= + 4; + setIsTranscriptAtBottom((current) => + current === isAtBottom ? current : isAtBottom, + ); + } + + function scrollTranscriptToLatest() { + const transcript = transcriptRef.current; + if (!transcript) return; + transcript.scrollTop = transcript.scrollHeight; + setIsTranscriptAtBottom(true); + } + useLayoutEffect(() => { - const scrollToLatest = () => { - const transcript = transcriptRef.current; - if (transcript) transcript.scrollTop = transcript.scrollHeight; - }; - scrollToLatest(); - const frame = requestAnimationFrame(scrollToLatest); + setIsTranscriptAtBottom(true); + }, [selectedId]); + + useLayoutEffect(() => { + if (!isTranscriptAtBottom) return; + scrollTranscriptToLatest(); + const frame = requestAnimationFrame(scrollTranscriptToLatest); return () => cancelAnimationFrame(frame); - }, [messages, pendingSubmissions, selectedId]); + }, [isTranscriptAtBottom, messages, pendingSubmissions, selectedId]); useEffect(() => { const userMessages = messages.filter((entry) => entry.role === "user"); @@ -579,6 +646,7 @@ function App() { } useEffect(() => { + let disposed = false; let unlisten: (() => void) | undefined; let unlistenError: (() => void) | undefined; void listen("bridge-event", (event) => { @@ -608,10 +676,11 @@ function App() { ); } updateWorkProgress(bridgeEvent); - if (selectedId) void loadAgent(selectedId); + if (selectedId) void reloadAgentAfterEvent(selectedId); } }).then((stop) => { - unlisten = stop; + if (disposed) stop(); + else unlisten = stop; }); void listen<{ message?: string }>("bridge-error", (event) => { setStatus( @@ -619,9 +688,11 @@ function App() { ); scheduleBridgeReconnect(); }).then((stop) => { - unlistenError = stop; + if (disposed) stop(); + else unlistenError = stop; }); return () => { + disposed = true; unlisten?.(); unlistenError?.(); }; @@ -783,28 +854,54 @@ function App() { setCommandFollowUp(undefined); } + function updateUiScale(nextScale: number) { + const scale = clampUiScale(nextScale); + setUiScale(scale); + localStorage.setItem(uiScaleKey, String(scale)); + } + async function activateWorktree(worktreePath: string) { + setIsAddingDirectory(true); + setPendingDirectoryPath(worktreePath); + setStatus("Adding directory…"); try { const result = await invoke<{ agent: Agent }>("select_worktree", { worktreePath, }); - localStorage.setItem(selectedPathKey, worktreePath); + const addedPath = result.agent.worktreePath; + localStorage.setItem(selectedPathKey, addedPath); + setPendingDirectoryPath(addedPath); setFolderPath(""); - setAddingDirectory(false); setSelectedId(result.agent.id); - await refreshAgents(worktreePath); + await refreshAgents(addedPath); setView("conversation"); + } catch (error) { + setPendingDirectoryPath(undefined); + setIsAddingDirectory(false); + setStatus(String(error)); + } + } + + async function browseForFolder() { + try { + const selectedPath = await open({ + directory: true, + multiple: false, + defaultPath: folderPath || undefined, + }); + if (selectedPath) setFolderPath(selectedPath); } catch (error) { setStatus(String(error)); } } async function addFolder() { - if (!folderPath.startsWith("/")) { + const worktreePath = folderPath.trim(); + if (!worktreePath.startsWith("/")) { setStatus("Enter an absolute folder path"); return; } - await activateWorktree(folderPath); + await activateWorktree(worktreePath); } async function submit() { @@ -940,7 +1037,7 @@ function App() { const workspaceLabel = extensionTitle ?? worktreeLabel(selected?.worktreePath) ?? "Pi"; return ( -
+
{ @@ -1069,12 +1166,14 @@ function App() { {addingDirectory && (
{ event.preventDefault(); @@ -1083,12 +1182,28 @@ function App() { > setFolderPath(event.currentTarget.value)} placeholder="/absolute/path/to/project" aria-label="Directory path" /> - + + + {isAddingDirectory && ( + + + )} )} @@ -1120,6 +1235,44 @@ function App() { {state.messageCount ?? 0} messages ·{" "} {state.pendingMessageCount ?? 0} queued

+
+
+

Interface scale

+ {Math.round(uiScale * 100)}% +
+
+ + + updateUiScale(Number(event.currentTarget.value)) + } + step={uiScaleStep} + type="range" + value={uiScale} + /> + +
+
-
- {messages.length ? ( - messages.map((entry, index) => ( +
+
+ {messages.length ? ( + messages.map((entry, index) => ( +
+ {entry.role ?? "message"} +
{messageText(entry)}
+
+ )) + ) : ( +

No messages yet.

+ )} + {pendingSubmissions.map((pending) => (
- {entry.role ?? "message"} -
{messageText(entry)}
+ + You{" "} + + {pending.phase === "sending" + ? "Sending…" + : "Sent · waiting for Pi"} + + +
{pending.text}
- )) - ) : ( -

No messages yet.

- )} - {pendingSubmissions.map((pending) => ( -
+ {!isTranscriptAtBottom && ( +
- ))} -
+ + Scroll to latest message + + )} +