From 12b9b83d51b4a435814110ade058fa37b3d8ba14 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Wed, 29 Jul 2026 14:18:49 +0200 Subject: [PATCH] feat(status-ui): enhance desktop session controls Add native directory selection, persisted interface scaling, and navigation back to the latest conversation message. Restore saved session transcripts when the bridge transcript RPC is unavailable so the desktop client remains useful during bridge compatibility gaps. --- test/tauri-ui.test.js | 89 ++++++++- ui/package-lock.json | 15 +- ui/package.json | 11 +- ui/src-tauri/Cargo.lock | 84 ++++++++ ui/src-tauri/Cargo.toml | 1 + ui/src-tauri/capabilities/default.json | 9 +- ui/src-tauri/src/bridge.rs | 106 +++++++++- ui/src-tauri/src/lib.rs | 10 +- ui/src-tauri/tauri.conf.json | 8 +- ui/src/App.css | 162 +++++++++++---- ui/src/App.tsx | 263 ++++++++++++++++++++----- 11 files changed, 646 insertions(+), 112 deletions(-) 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 + + )} +