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:
2026-07-29 14:18:49 +02:00
parent a792c577ef
commit 12b9b83d51
11 changed files with 646 additions and 112 deletions
+97 -9
View File
@@ -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");
}
+8 -2
View File
@@ -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
);
}
}