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.
This commit is contained in:
Generated
+84
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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, Str
|
||||
request(socket_path, "new_session", Some(agent_id), None).await
|
||||
}
|
||||
|
||||
fn fallback_transcript(state: &Value) -> 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::<Value>(line).ok())
|
||||
.filter(|entry| entry.get("type").and_then(Value::as_str) == Some("message"))
|
||||
.filter_map(|entry| entry.get("message").cloned())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.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::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
json!({ "data": { "commands": commands } })
|
||||
}
|
||||
|
||||
pub async fn load_agent(socket_path: &str, agent_id: &str) -> Result<Value, String> {
|
||||
// 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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user