feat: add multi-session desktop workspace
This commit is contained in:
Generated
+1217
-1
File diff suppressed because it is too large
Load Diff
+8
-1
@@ -12,6 +12,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:release": "tauri build --no-bundle"
|
||||
@@ -25,10 +27,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.4",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
"vite": "^7.0.4",
|
||||
"vitest": "^3.2.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ tauri-build = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["io-util", "net", "rt", "macros"] }
|
||||
tokio = { version = "1", features = ["io-util", "net", "rt", "macros", "time"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
|
||||
+858
-1
@@ -1,10 +1,12 @@
|
||||
use serde::Serialize;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -83,6 +85,181 @@ pub async fn request(
|
||||
result_from_response(value)
|
||||
}
|
||||
|
||||
async fn typed_request<T: DeserializeOwned>(
|
||||
socket_path: &str,
|
||||
operation: &str,
|
||||
payload: Option<Value>,
|
||||
) -> Result<T, String> {
|
||||
let value = request(socket_path, operation, None, payload).await?;
|
||||
serde_json::from_value(value)
|
||||
.map_err(|error| format!("Bridge returned an invalid {operation} result: {error}"))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeSummary {
|
||||
pub runtime_id: String,
|
||||
pub worktree_path: String,
|
||||
pub state: String,
|
||||
pub label: String,
|
||||
pub attention: bool,
|
||||
pub queue_count: u64,
|
||||
pub last_activity: String,
|
||||
pub opened_at: String,
|
||||
pub active_tool: Option<String>,
|
||||
pub agent_id: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub session_path: Option<String>,
|
||||
pub error: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DirectoryWorkspace {
|
||||
pub worktree_path: String,
|
||||
pub is_home: bool,
|
||||
pub open_count: u64,
|
||||
pub working_count: u64,
|
||||
pub attention_count: u64,
|
||||
pub recovering_count: u64,
|
||||
pub error_count: u64,
|
||||
pub runtimes: Vec<RuntimeSummary>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Workspace {
|
||||
pub bridge_instance_id: String,
|
||||
pub latest_seq: u64,
|
||||
pub directories: Vec<DirectoryWorkspace>,
|
||||
pub issue: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkspaceSummary {
|
||||
pub bridge_instance_id: String,
|
||||
pub latest_seq: u64,
|
||||
pub open_count: u64,
|
||||
pub working_count: u64,
|
||||
pub attention_count: u64,
|
||||
pub recovering_count: u64,
|
||||
pub error_count: u64,
|
||||
pub directory_count: u64,
|
||||
pub resource_warning: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct RuntimeResult {
|
||||
pub runtime: RuntimeSummary,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CloseRuntimeResult {
|
||||
pub runtime_id: String,
|
||||
pub session_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DirectorySession {
|
||||
pub path: String,
|
||||
pub id: String,
|
||||
pub cwd: String,
|
||||
pub name: Option<String>,
|
||||
pub parent_session_path: Option<String>,
|
||||
pub created: Option<String>,
|
||||
pub modified: String,
|
||||
pub message_count: u64,
|
||||
pub first_message: Option<String>,
|
||||
pub is_current: bool,
|
||||
pub runtime_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct DirectorySessionsResult {
|
||||
pub sessions: Vec<DirectorySession>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeSnapshot {
|
||||
pub bridge_instance_id: String,
|
||||
pub latest_seq: u64,
|
||||
pub runtime: RuntimeSummary,
|
||||
pub state: Option<Value>,
|
||||
pub stats: Option<Value>,
|
||||
pub transcript: Option<Value>,
|
||||
pub commands: Option<Value>,
|
||||
pub models: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub extensions: Vec<Value>,
|
||||
}
|
||||
|
||||
pub async fn get_workspace(socket_path: &str) -> Result<Workspace, String> {
|
||||
typed_request(socket_path, "get_workspace", None).await
|
||||
}
|
||||
pub async fn get_workspace_summary(socket_path: &str) -> Result<WorkspaceSummary, String> {
|
||||
typed_request(socket_path, "get_workspace_summary", None).await
|
||||
}
|
||||
pub async fn create_session_runtime(
|
||||
socket_path: &str,
|
||||
worktree_path: &str,
|
||||
) -> Result<RuntimeResult, String> {
|
||||
typed_request(
|
||||
socket_path,
|
||||
"create_session_runtime",
|
||||
Some(json!({ "worktreePath": worktree_path })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn open_session_runtime(
|
||||
socket_path: &str,
|
||||
worktree_path: &str,
|
||||
session_path: &str,
|
||||
) -> Result<RuntimeResult, String> {
|
||||
typed_request(
|
||||
socket_path,
|
||||
"open_session_runtime",
|
||||
Some(json!({ "worktreePath": worktree_path, "sessionPath": session_path })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn close_session_runtime(
|
||||
socket_path: &str,
|
||||
runtime_id: &str,
|
||||
) -> Result<CloseRuntimeResult, String> {
|
||||
typed_request(
|
||||
socket_path,
|
||||
"close_session_runtime",
|
||||
Some(json!({ "runtimeId": runtime_id })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn list_directory_sessions(
|
||||
socket_path: &str,
|
||||
worktree_path: &str,
|
||||
) -> Result<DirectorySessionsResult, String> {
|
||||
typed_request(
|
||||
socket_path,
|
||||
"list_directory_sessions",
|
||||
Some(json!({ "worktreePath": worktree_path })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn get_session_runtime_snapshot(
|
||||
socket_path: &str,
|
||||
runtime_id: &str,
|
||||
) -> Result<RuntimeSnapshot, String> {
|
||||
typed_request(
|
||||
socket_path,
|
||||
"get_session_runtime_snapshot",
|
||||
Some(json!({ "runtimeId": runtime_id })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_agents(socket_path: &str) -> Result<Value, String> {
|
||||
request(socket_path, "list_agents", None, None).await
|
||||
}
|
||||
@@ -246,6 +423,32 @@ pub async fn set_thinking_level(
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn set_session_name(socket_path: &str, agent_id: &str, name: &str) -> Result<(), String> {
|
||||
request(
|
||||
socket_path,
|
||||
"set_session_name",
|
||||
Some(agent_id),
|
||||
Some(json!({ "name": name })),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn compact(
|
||||
socket_path: &str,
|
||||
agent_id: &str,
|
||||
custom_instructions: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
request(
|
||||
socket_path,
|
||||
"compact",
|
||||
Some(agent_id),
|
||||
custom_instructions.map(|instructions| json!({ "customInstructions": instructions })),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn command(socket_path: &str, agent_id: &str, operation: &str) -> Result<(), String> {
|
||||
request(socket_path, operation, Some(agent_id), None)
|
||||
.await
|
||||
@@ -268,6 +471,303 @@ pub async fn respond_to_extension(
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkspaceEvent {
|
||||
pub bridge_instance_id: String,
|
||||
pub seq: u64,
|
||||
#[serde(flatten)]
|
||||
pub body: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WorkspaceReplay {
|
||||
bridge_instance_id: String,
|
||||
first_available_seq: u64,
|
||||
latest_seq: u64,
|
||||
truncated: bool,
|
||||
events: Vec<WorkspaceEvent>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(
|
||||
tag = "kind",
|
||||
rename_all = "camelCase",
|
||||
rename_all_fields = "camelCase"
|
||||
)]
|
||||
pub enum WorkspaceHostEvent {
|
||||
Connected {
|
||||
bridge_instance_id: String,
|
||||
first_available_seq: u64,
|
||||
latest_seq: u64,
|
||||
},
|
||||
Event {
|
||||
event: WorkspaceEvent,
|
||||
},
|
||||
ResetRequired {
|
||||
bridge_instance_id: String,
|
||||
latest_seq: u64,
|
||||
reason: String,
|
||||
},
|
||||
Disconnected {
|
||||
message: String,
|
||||
retry_in_ms: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct SubscriptionCursor {
|
||||
pub bridge_instance_id: Option<String>,
|
||||
pub seq: u64,
|
||||
}
|
||||
|
||||
fn accept_replay(
|
||||
cursor: &mut SubscriptionCursor,
|
||||
replay: &WorkspaceReplay,
|
||||
) -> Vec<WorkspaceHostEvent> {
|
||||
if let Some(expected) = &cursor.bridge_instance_id {
|
||||
if expected != &replay.bridge_instance_id {
|
||||
cursor.bridge_instance_id = Some(replay.bridge_instance_id.clone());
|
||||
cursor.seq = replay.latest_seq;
|
||||
return vec![WorkspaceHostEvent::ResetRequired {
|
||||
bridge_instance_id: replay.bridge_instance_id.clone(),
|
||||
latest_seq: replay.latest_seq,
|
||||
reason: "epochChanged".into(),
|
||||
}];
|
||||
}
|
||||
}
|
||||
if replay.truncated {
|
||||
cursor.bridge_instance_id = Some(replay.bridge_instance_id.clone());
|
||||
cursor.seq = replay.latest_seq;
|
||||
return vec![WorkspaceHostEvent::ResetRequired {
|
||||
bridge_instance_id: replay.bridge_instance_id.clone(),
|
||||
latest_seq: replay.latest_seq,
|
||||
reason: "replayTruncated".into(),
|
||||
}];
|
||||
}
|
||||
cursor.bridge_instance_id = Some(replay.bridge_instance_id.clone());
|
||||
let mut output = vec![WorkspaceHostEvent::Connected {
|
||||
bridge_instance_id: replay.bridge_instance_id.clone(),
|
||||
first_available_seq: replay.first_available_seq,
|
||||
latest_seq: replay.latest_seq,
|
||||
}];
|
||||
for event in &replay.events {
|
||||
if event.bridge_instance_id != replay.bridge_instance_id || event.seq <= cursor.seq {
|
||||
continue;
|
||||
}
|
||||
if event.seq != cursor.seq + 1 {
|
||||
cursor.seq = replay.latest_seq;
|
||||
output.push(WorkspaceHostEvent::ResetRequired {
|
||||
bridge_instance_id: replay.bridge_instance_id.clone(),
|
||||
latest_seq: replay.latest_seq,
|
||||
reason: "sequenceGap".into(),
|
||||
});
|
||||
return output;
|
||||
}
|
||||
cursor.seq = event.seq;
|
||||
output.push(WorkspaceHostEvent::Event {
|
||||
event: event.clone(),
|
||||
});
|
||||
}
|
||||
if cursor.seq < replay.latest_seq {
|
||||
cursor.seq = replay.latest_seq;
|
||||
output.push(WorkspaceHostEvent::ResetRequired {
|
||||
bridge_instance_id: replay.bridge_instance_id.clone(),
|
||||
latest_seq: replay.latest_seq,
|
||||
reason: "sequenceGap".into(),
|
||||
});
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn accept_live(
|
||||
cursor: &mut SubscriptionCursor,
|
||||
event: WorkspaceEvent,
|
||||
) -> Option<WorkspaceHostEvent> {
|
||||
if cursor.bridge_instance_id.as_deref() != Some(&event.bridge_instance_id) {
|
||||
cursor.bridge_instance_id = Some(event.bridge_instance_id.clone());
|
||||
cursor.seq = event.seq;
|
||||
return Some(WorkspaceHostEvent::ResetRequired {
|
||||
bridge_instance_id: event.bridge_instance_id,
|
||||
latest_seq: event.seq,
|
||||
reason: "epochChanged".into(),
|
||||
});
|
||||
}
|
||||
if event.seq <= cursor.seq {
|
||||
return None;
|
||||
}
|
||||
if event.seq != cursor.seq + 1 {
|
||||
cursor.seq = event.seq;
|
||||
return Some(WorkspaceHostEvent::ResetRequired {
|
||||
bridge_instance_id: event.bridge_instance_id,
|
||||
latest_seq: event.seq,
|
||||
reason: "sequenceGap".into(),
|
||||
});
|
||||
}
|
||||
cursor.seq = event.seq;
|
||||
Some(WorkspaceHostEvent::Event { event })
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SubscriptionGeneration(Mutex<u64>);
|
||||
|
||||
impl SubscriptionGeneration {
|
||||
pub fn replace(&self) -> Result<u64, String> {
|
||||
let mut current = self
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "Could not replace workspace subscription".to_owned())?;
|
||||
*current = current.saturating_add(1);
|
||||
Ok(*current)
|
||||
}
|
||||
|
||||
pub fn invalidate(&self) -> Result<(), String> {
|
||||
self.replace().map(|_| ())
|
||||
}
|
||||
|
||||
fn run_if_current<T>(
|
||||
&self,
|
||||
expected_generation: u64,
|
||||
operation: impl FnOnce() -> Result<T, String>,
|
||||
) -> Result<T, String> {
|
||||
let current = self
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "Could not inspect workspace subscription".to_owned())?;
|
||||
if *current != expected_generation {
|
||||
return Err("Workspace subscription was replaced".to_owned());
|
||||
}
|
||||
operation()
|
||||
}
|
||||
|
||||
fn is_current(&self, expected_generation: u64) -> Result<bool, String> {
|
||||
self.0
|
||||
.lock()
|
||||
.map(|current| *current == expected_generation)
|
||||
.map_err(|_| "Could not inspect workspace subscription".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn retry_delay(attempt: usize) -> u64 {
|
||||
match attempt {
|
||||
0 => 250,
|
||||
1 => 500,
|
||||
2 => 1000,
|
||||
_ => 2000,
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_workspace(
|
||||
app: &AppHandle,
|
||||
generation: &SubscriptionGeneration,
|
||||
expected_generation: u64,
|
||||
event: WorkspaceHostEvent,
|
||||
) -> Result<(), String> {
|
||||
generation.run_if_current(expected_generation, || {
|
||||
app.emit("workspace-bridge", event)
|
||||
.map_err(|error| format!("Could not publish workspace event: {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
async fn subscribe_workspace_once(
|
||||
socket_path: &str,
|
||||
cursor: &mut SubscriptionCursor,
|
||||
app: &AppHandle,
|
||||
generation: &SubscriptionGeneration,
|
||||
expected_generation: u64,
|
||||
) -> Result<(), String> {
|
||||
let mut reader = connect_and_send(
|
||||
socket_path,
|
||||
"subscribe_workspace",
|
||||
None,
|
||||
Some(json!({ "cursor": cursor.seq })),
|
||||
)
|
||||
.await?;
|
||||
let mut handshake = String::new();
|
||||
if reader
|
||||
.read_line(&mut handshake)
|
||||
.await
|
||||
.map_err(|error| format!("Bridge subscription failed: {error}"))?
|
||||
== 0
|
||||
{
|
||||
return Err("Bridge subscription closed before handshake".into());
|
||||
}
|
||||
let replay_value: Value = serde_json::from_str(&handshake)
|
||||
.map_err(|error| format!("Bridge emitted invalid JSON: {error}"))?;
|
||||
let replay: WorkspaceReplay = serde_json::from_value(result_from_response(replay_value)?)
|
||||
.map_err(|error| format!("Bridge returned an invalid workspace replay: {error}"))?;
|
||||
for event in accept_replay(cursor, &replay) {
|
||||
emit_workspace(app, generation, expected_generation, event)?;
|
||||
}
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader
|
||||
.read_line(&mut line)
|
||||
.await
|
||||
.map_err(|error| format!("Bridge subscription failed: {error}"))?
|
||||
== 0
|
||||
{
|
||||
return Err("Bridge subscription closed".into());
|
||||
}
|
||||
let envelope: Value = serde_json::from_str(&line)
|
||||
.map_err(|error| format!("Bridge emitted invalid JSON: {error}"))?;
|
||||
let event: WorkspaceEvent = serde_json::from_value(
|
||||
envelope
|
||||
.get("event")
|
||||
.cloned()
|
||||
.ok_or_else(|| "Bridge event envelope is missing event".to_owned())?,
|
||||
)
|
||||
.map_err(|error| format!("Bridge emitted an invalid workspace event: {error}"))?;
|
||||
if let Some(event) = accept_live(cursor, event) {
|
||||
emit_workspace(app, generation, expected_generation, event)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn subscribe_workspace(
|
||||
socket_path: String,
|
||||
mut cursor: SubscriptionCursor,
|
||||
app: AppHandle,
|
||||
generation: Arc<SubscriptionGeneration>,
|
||||
expected_generation: u64,
|
||||
) -> Result<(), String> {
|
||||
let mut attempt = 0usize;
|
||||
loop {
|
||||
if !generation.is_current(expected_generation)? {
|
||||
return Ok(());
|
||||
}
|
||||
match subscribe_workspace_once(
|
||||
&socket_path,
|
||||
&mut cursor,
|
||||
&app,
|
||||
&generation,
|
||||
expected_generation,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => attempt = 0,
|
||||
Err(error) => {
|
||||
let delay = retry_delay(attempt);
|
||||
if error == "Workspace subscription was replaced" {
|
||||
return Ok(());
|
||||
}
|
||||
emit_workspace(
|
||||
&app,
|
||||
&generation,
|
||||
expected_generation,
|
||||
WorkspaceHostEvent::Disconnected {
|
||||
message: error,
|
||||
retry_in_ms: delay,
|
||||
},
|
||||
)?;
|
||||
sleep(Duration::from_millis(delay)).await;
|
||||
attempt = (attempt + 1).min(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn subscribe(
|
||||
socket_path: String,
|
||||
agent_id: String,
|
||||
@@ -445,4 +945,361 @@ mod tests {
|
||||
server.await.expect("server succeeds");
|
||||
std::fs::remove_file(path).expect("socket cleanup");
|
||||
}
|
||||
|
||||
fn runtime(id: &str) -> Value {
|
||||
json!({
|
||||
"runtimeId": id,
|
||||
"worktreePath": "/tmp/project",
|
||||
"state": "idle",
|
||||
"label": "Session",
|
||||
"attention": false,
|
||||
"queueCount": 0,
|
||||
"lastActivity": "2026-01-01T00:00:00Z",
|
||||
"openedAt": "2026-01-01T00:00:00Z"
|
||||
})
|
||||
}
|
||||
|
||||
async fn assert_typed_frame<F, Fut>(operation: &'static str, payload: Value, call: F)
|
||||
where
|
||||
F: FnOnce(String) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<(), String>>,
|
||||
{
|
||||
let path = format!(
|
||||
"{}/pi-status-ui-frame-{operation}-{}-{}.sock",
|
||||
env::temp_dir().display(),
|
||||
std::process::id(),
|
||||
operation.len()
|
||||
);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let listener = UnixListener::bind(&path).expect("listener");
|
||||
let expected_payload = payload.clone();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("connection");
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await.unwrap();
|
||||
let frame: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(frame["op"], operation);
|
||||
assert_eq!(
|
||||
frame.get("payload").cloned().unwrap_or(Value::Null),
|
||||
expected_payload
|
||||
);
|
||||
let result = match operation {
|
||||
"close_session_runtime" => json!({"runtimeId":"r"}),
|
||||
"list_directory_sessions" => json!({"sessions":[]}),
|
||||
"get_session_runtime_snapshot" => {
|
||||
json!({"bridgeInstanceId":"b","latestSeq":0,"runtime":runtime("r"),"extensions":[]})
|
||||
}
|
||||
_ => json!({"runtime":runtime("r")}),
|
||||
};
|
||||
let response = format!("{}\n", json!({"id":"tauri-ui","ok":true,"result":result}));
|
||||
reader
|
||||
.get_mut()
|
||||
.write_all(response.as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
call(path.clone()).await.unwrap();
|
||||
server.await.unwrap();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
async fn assert_no_payload<T, F, Fut>(operation: &'static str, result: Value, call: F) -> T
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce(String) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<T, String>>,
|
||||
{
|
||||
let path = format!(
|
||||
"{}/pi-status-ui-empty-{operation}-{}.sock",
|
||||
env::temp_dir().display(),
|
||||
std::process::id()
|
||||
);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let listener = UnixListener::bind(&path).unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await.unwrap();
|
||||
let frame: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(frame["op"], operation);
|
||||
assert!(frame.get("payload").is_none());
|
||||
let response = format!("{}\n", json!({"id":"tauri-ui","ok":true,"result":result}));
|
||||
reader
|
||||
.get_mut()
|
||||
.write_all(response.as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
let value = call(path.clone()).await.unwrap();
|
||||
server.await.unwrap();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
value
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_wrappers_emit_exact_operations_without_payloads() {
|
||||
let workspace: Workspace = assert_no_payload(
|
||||
"get_workspace",
|
||||
json!({"bridgeInstanceId":"b","latestSeq":0,"directories":[]}),
|
||||
|path| async move { get_workspace(&path).await },
|
||||
)
|
||||
.await;
|
||||
assert_eq!(workspace.bridge_instance_id, "b");
|
||||
let summary: WorkspaceSummary = assert_no_payload(
|
||||
"get_workspace_summary",
|
||||
json!({"bridgeInstanceId":"b","latestSeq":0,"openCount":0,"workingCount":0,"attentionCount":0,"recoveringCount":0,"errorCount":0,"directoryCount":0,"resourceWarning":false}),
|
||||
|path| async move { get_workspace_summary(&path).await },
|
||||
)
|
||||
.await;
|
||||
assert_eq!(summary.open_count, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_wrappers_emit_exact_operations_and_payloads() {
|
||||
assert_typed_frame(
|
||||
"create_session_runtime",
|
||||
json!({"worktreePath":"/tmp/project"}),
|
||||
|path| async move {
|
||||
create_session_runtime(&path, "/tmp/project")
|
||||
.await
|
||||
.map(|_| ())
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_typed_frame(
|
||||
"open_session_runtime",
|
||||
json!({"worktreePath":"/tmp/project","sessionPath":"/tmp/project/s.jsonl"}),
|
||||
|path| async move {
|
||||
open_session_runtime(&path, "/tmp/project", "/tmp/project/s.jsonl")
|
||||
.await
|
||||
.map(|_| ())
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_typed_frame(
|
||||
"close_session_runtime",
|
||||
json!({"runtimeId":"r"}),
|
||||
|path| async move { close_session_runtime(&path, "r").await.map(|_| ()) },
|
||||
)
|
||||
.await;
|
||||
assert_typed_frame(
|
||||
"list_directory_sessions",
|
||||
json!({"worktreePath":"/tmp/project"}),
|
||||
|path| async move {
|
||||
list_directory_sessions(&path, "/tmp/project")
|
||||
.await
|
||||
.map(|_| ())
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_typed_frame(
|
||||
"get_session_runtime_snapshot",
|
||||
json!({"runtimeId":"r"}),
|
||||
|path| async move { get_session_runtime_snapshot(&path, "r").await.map(|_| ()) },
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_multi_runtime_workspace_dormant_snapshot_and_directory_sessions() {
|
||||
let workspace: Workspace = serde_json::from_value(json!({
|
||||
"bridgeInstanceId":"bridge", "latestSeq":4,
|
||||
"directories":[{"worktreePath":"/tmp/project","isHome":true,"openCount":2,"workingCount":1,"attentionCount":0,"recoveringCount":0,"errorCount":0,"runtimes":[runtime("one"),runtime("two")]}]
|
||||
})).unwrap();
|
||||
assert_eq!(workspace.directories[0].runtimes.len(), 2);
|
||||
let snapshot: RuntimeSnapshot = serde_json::from_value(json!({"bridgeInstanceId":"bridge","latestSeq":4,"runtime":runtime("failed"),"extensions":[]})).unwrap();
|
||||
assert!(snapshot.state.is_none());
|
||||
let sessions: DirectorySessionsResult = serde_json::from_value(json!({
|
||||
"sessions": [
|
||||
{"path":"/tmp/project/one.jsonl","id":"one","cwd":"/tmp/project","modified":"2026-01-01T00:00:00Z","messageCount":2,"isCurrent":false,"runtimeId":"runtime-one"},
|
||||
{"path":"/tmp/project/two.jsonl","id":"two","cwd":"/tmp/project","modified":"2026-01-02T00:00:00Z","messageCount":0,"isCurrent":false}
|
||||
]
|
||||
})).unwrap();
|
||||
assert_eq!(
|
||||
sessions.sessions[0].runtime_id.as_deref(),
|
||||
Some("runtime-one")
|
||||
);
|
||||
assert!(sessions.sessions[1].runtime_id.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_subscription_frame_uses_global_cursor_without_agent() {
|
||||
let path = format!(
|
||||
"{}/pi-status-ui-workspace-subscribe-{}.sock",
|
||||
env::temp_dir().display(),
|
||||
std::process::id()
|
||||
);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let listener = UnixListener::bind(&path).unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await.unwrap();
|
||||
let frame: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(frame["op"], "subscribe_workspace");
|
||||
assert_eq!(frame["payload"], json!({"cursor":17}));
|
||||
assert!(frame.get("agentId").is_none());
|
||||
});
|
||||
let _reader = connect_and_send(
|
||||
&path,
|
||||
"subscribe_workspace",
|
||||
None,
|
||||
Some(json!({"cursor":17})),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
server.await.unwrap();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
fn event(epoch: &str, seq: u64) -> WorkspaceEvent {
|
||||
serde_json::from_value(
|
||||
json!({"bridgeInstanceId":epoch,"seq":seq,"type":"runtime_event","data":{}}),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_host_events_use_exact_camel_case_json() {
|
||||
let event = event("bridge-1", 7);
|
||||
let cases = [
|
||||
(
|
||||
WorkspaceHostEvent::Connected {
|
||||
bridge_instance_id: "bridge-1".into(),
|
||||
first_available_seq: 3,
|
||||
latest_seq: 7,
|
||||
},
|
||||
json!({
|
||||
"kind": "connected",
|
||||
"bridgeInstanceId": "bridge-1",
|
||||
"firstAvailableSeq": 3,
|
||||
"latestSeq": 7
|
||||
}),
|
||||
),
|
||||
(
|
||||
WorkspaceHostEvent::Event {
|
||||
event: event.clone(),
|
||||
},
|
||||
json!({ "kind": "event", "event": event }),
|
||||
),
|
||||
(
|
||||
WorkspaceHostEvent::ResetRequired {
|
||||
bridge_instance_id: "bridge-1".into(),
|
||||
latest_seq: 7,
|
||||
reason: "sequenceGap".into(),
|
||||
},
|
||||
json!({
|
||||
"kind": "resetRequired",
|
||||
"bridgeInstanceId": "bridge-1",
|
||||
"latestSeq": 7,
|
||||
"reason": "sequenceGap"
|
||||
}),
|
||||
),
|
||||
(
|
||||
WorkspaceHostEvent::Disconnected {
|
||||
message: "offline".into(),
|
||||
retry_in_ms: 500,
|
||||
},
|
||||
json!({
|
||||
"kind": "disconnected",
|
||||
"message": "offline",
|
||||
"retryInMs": 500
|
||||
}),
|
||||
),
|
||||
];
|
||||
for (host_event, expected) in cases {
|
||||
assert_eq!(serde_json::to_value(host_event).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_waits_for_an_in_flight_emission_and_rejects_stale_work() {
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
let generation = Arc::new(SubscriptionGeneration::default());
|
||||
let first = generation.replace().unwrap();
|
||||
let (entered_tx, entered_rx) = mpsc::channel();
|
||||
let (release_tx, release_rx) = mpsc::channel();
|
||||
let emitter_generation = Arc::clone(&generation);
|
||||
let emitter = thread::spawn(move || {
|
||||
emitter_generation.run_if_current(first, || {
|
||||
entered_tx.send(()).unwrap();
|
||||
release_rx.recv().unwrap();
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
entered_rx.recv().unwrap();
|
||||
|
||||
let replacement_generation = Arc::clone(&generation);
|
||||
let (replaced_tx, replaced_rx) = mpsc::channel();
|
||||
let replacement = thread::spawn(move || {
|
||||
let next = replacement_generation.replace().unwrap();
|
||||
replaced_tx.send(next).unwrap();
|
||||
});
|
||||
assert!(replaced_rx
|
||||
.recv_timeout(StdDuration::from_millis(50))
|
||||
.is_err());
|
||||
release_tx.send(()).unwrap();
|
||||
assert!(emitter.join().unwrap().is_ok());
|
||||
assert_eq!(replaced_rx.recv().unwrap(), first + 1);
|
||||
replacement.join().unwrap();
|
||||
assert_eq!(
|
||||
generation.run_if_current(first, || Ok(())),
|
||||
Err("Workspace subscription was replaced".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
(0..6).map(retry_delay).collect::<Vec<_>>(),
|
||||
vec![250, 500, 1000, 2000, 2000, 2000]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_and_live_decisions_handle_epochs_truncation_and_gaps() {
|
||||
let mut cursor = SubscriptionCursor {
|
||||
bridge_instance_id: Some("a".into()),
|
||||
seq: 1,
|
||||
};
|
||||
let replay = WorkspaceReplay {
|
||||
bridge_instance_id: "a".into(),
|
||||
first_available_seq: 1,
|
||||
latest_seq: 3,
|
||||
truncated: false,
|
||||
events: vec![event("a", 2), event("a", 3)],
|
||||
};
|
||||
assert_eq!(accept_replay(&mut cursor, &replay).len(), 3);
|
||||
assert_eq!(cursor.seq, 3);
|
||||
assert!(matches!(
|
||||
accept_live(&mut cursor, event("a", 4)),
|
||||
Some(WorkspaceHostEvent::Event { .. })
|
||||
));
|
||||
assert!(accept_live(&mut cursor, event("a", 4)).is_none());
|
||||
assert!(
|
||||
matches!(accept_live(&mut cursor, event("a",6)), Some(WorkspaceHostEvent::ResetRequired { reason, .. }) if reason == "sequenceGap")
|
||||
);
|
||||
let changed = WorkspaceReplay {
|
||||
bridge_instance_id: "b".into(),
|
||||
first_available_seq: 1,
|
||||
latest_seq: 9,
|
||||
truncated: false,
|
||||
events: vec![],
|
||||
};
|
||||
assert!(
|
||||
matches!(&accept_replay(&mut cursor, &changed)[0], WorkspaceHostEvent::ResetRequired { reason, .. } if reason == "epochChanged")
|
||||
);
|
||||
let truncated = WorkspaceReplay {
|
||||
bridge_instance_id: "b".into(),
|
||||
first_available_seq: 10,
|
||||
latest_seq: 12,
|
||||
truncated: true,
|
||||
events: vec![],
|
||||
};
|
||||
assert!(
|
||||
matches!(&accept_replay(&mut cursor, &truncated)[0], WorkspaceHostEvent::ResetRequired { reason, .. } if reason == "replayTruncated")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+176
-5
@@ -1,10 +1,29 @@
|
||||
mod bridge;
|
||||
mod ui_state;
|
||||
|
||||
use serde_json::Value;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager, State};
|
||||
|
||||
struct Subscription(Mutex<Option<JoinHandle<()>>>);
|
||||
struct LegacySubscription(Mutex<Option<JoinHandle<()>>>);
|
||||
struct WorkspaceSubscription {
|
||||
generation: Arc<bridge::SubscriptionGeneration>,
|
||||
task: Mutex<Option<JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
fn replace_subscription<T>(
|
||||
task_slot: &Mutex<Option<T>>,
|
||||
generation: &bridge::SubscriptionGeneration,
|
||||
create: impl FnOnce(u64) -> T,
|
||||
) -> Result<Option<T>, String> {
|
||||
let mut slot = task_slot
|
||||
.lock()
|
||||
.map_err(|_| "Could not update workspace subscription".to_owned())?;
|
||||
let expected_generation = generation.replace()?;
|
||||
let previous = slot.take();
|
||||
*slot = Some(create(expected_generation));
|
||||
Ok(previous)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum WindowAction {
|
||||
@@ -26,6 +45,66 @@ fn socket_path() -> Result<String, String> {
|
||||
bridge::default_socket_path()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_workspace() -> Result<bridge::Workspace, String> {
|
||||
bridge::get_workspace(&socket_path()?).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_workspace_summary() -> Result<bridge::WorkspaceSummary, String> {
|
||||
bridge::get_workspace_summary(&socket_path()?).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_session_runtime(worktree_path: String) -> Result<bridge::RuntimeResult, String> {
|
||||
bridge::create_session_runtime(&socket_path()?, &worktree_path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_session_runtime(
|
||||
worktree_path: String,
|
||||
session_path: String,
|
||||
) -> Result<bridge::RuntimeResult, String> {
|
||||
bridge::open_session_runtime(&socket_path()?, &worktree_path, &session_path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn close_session_runtime(runtime_id: String) -> Result<bridge::CloseRuntimeResult, String> {
|
||||
bridge::close_session_runtime(&socket_path()?, &runtime_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_directory_sessions(
|
||||
worktree_path: String,
|
||||
) -> Result<bridge::DirectorySessionsResult, String> {
|
||||
bridge::list_directory_sessions(&socket_path()?, &worktree_path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_session_runtime_snapshot(
|
||||
runtime_id: String,
|
||||
) -> Result<bridge::RuntimeSnapshot, String> {
|
||||
bridge::get_session_runtime_snapshot(&socket_path()?, &runtime_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn load_ui_state(app: AppHandle) -> Result<ui_state::UiStateV1, String> {
|
||||
let directory = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| error.to_string())?;
|
||||
ui_state::load_from(&directory)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_ui_state(app: AppHandle, state: ui_state::UiStateV1) -> Result<(), String> {
|
||||
let directory = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| error.to_string())?;
|
||||
ui_state::save_to(&directory, &state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_agents() -> Result<Value, String> {
|
||||
bridge::list_agents(&socket_path()?).await
|
||||
@@ -96,6 +175,16 @@ async fn set_thinking_level(agent_id: String, level: String) -> Result<(), Strin
|
||||
bridge::set_thinking_level(&socket_path()?, &agent_id, &level).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_session_name(agent_id: String, name: String) -> Result<(), String> {
|
||||
bridge::set_session_name(&socket_path()?, &agent_id, &name).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn compact(agent_id: String, custom_instructions: Option<String>) -> Result<(), String> {
|
||||
bridge::compact(&socket_path()?, &agent_id, custom_instructions.as_deref()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn respond_to_extension(
|
||||
agent_id: String,
|
||||
@@ -108,7 +197,7 @@ async fn respond_to_extension(
|
||||
#[tauri::command]
|
||||
fn subscribe_agent(
|
||||
app: AppHandle,
|
||||
subscriptions: State<'_, Subscription>,
|
||||
subscriptions: State<'_, LegacySubscription>,
|
||||
agent_id: String,
|
||||
cursor: u64,
|
||||
) -> Result<(), String> {
|
||||
@@ -140,11 +229,65 @@ fn subscribe_agent(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn subscribe_workspace(
|
||||
app: AppHandle,
|
||||
subscription: State<'_, WorkspaceSubscription>,
|
||||
cursor: Option<ui_state::WorkspaceCursor>,
|
||||
) -> Result<(), String> {
|
||||
let socket = socket_path()?;
|
||||
let event_app = app.clone();
|
||||
let generation = Arc::clone(&subscription.generation);
|
||||
let previous = replace_subscription(
|
||||
&subscription.task,
|
||||
&subscription.generation,
|
||||
move |expected_generation| {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let initial = bridge::SubscriptionCursor {
|
||||
bridge_instance_id: cursor
|
||||
.as_ref()
|
||||
.map(|value| value.bridge_instance_id.clone()),
|
||||
seq: cursor.map(|value| value.seq).unwrap_or(0),
|
||||
};
|
||||
let _ = bridge::subscribe_workspace(
|
||||
socket,
|
||||
initial,
|
||||
event_app,
|
||||
generation,
|
||||
expected_generation,
|
||||
)
|
||||
.await;
|
||||
})
|
||||
},
|
||||
)?;
|
||||
if let Some(task) = previous {
|
||||
task.abort();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn unsubscribe_workspace(subscription: State<'_, WorkspaceSubscription>) -> Result<(), String> {
|
||||
let mut slot = subscription
|
||||
.task
|
||||
.lock()
|
||||
.map_err(|_| "Could not clear workspace subscription".to_owned())?;
|
||||
subscription.generation.invalidate()?;
|
||||
if let Some(task) = slot.take() {
|
||||
task.abort();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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)))
|
||||
.manage(LegacySubscription(Mutex::new(None)))
|
||||
.manage(WorkspaceSubscription {
|
||||
generation: Arc::new(bridge::SubscriptionGeneration::default()),
|
||||
task: Mutex::new(None),
|
||||
})
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
match window_action(&args, window.is_visible().unwrap_or(false)) {
|
||||
@@ -159,6 +302,17 @@ pub fn run() {
|
||||
}
|
||||
}))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_workspace,
|
||||
get_workspace_summary,
|
||||
create_session_runtime,
|
||||
open_session_runtime,
|
||||
close_session_runtime,
|
||||
list_directory_sessions,
|
||||
get_session_runtime_snapshot,
|
||||
load_ui_state,
|
||||
save_ui_state,
|
||||
subscribe_workspace,
|
||||
unsubscribe_workspace,
|
||||
list_agents,
|
||||
list_directories,
|
||||
select_worktree,
|
||||
@@ -173,6 +327,8 @@ pub fn run() {
|
||||
restart,
|
||||
set_model,
|
||||
set_thinking_level,
|
||||
set_session_name,
|
||||
compact,
|
||||
respond_to_extension,
|
||||
subscribe_agent
|
||||
]);
|
||||
@@ -186,6 +342,21 @@ pub fn run() {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serialized_subscription_replacement_returns_the_previous_task() {
|
||||
let tasks = Mutex::new(None::<u64>);
|
||||
let generation = bridge::SubscriptionGeneration::default();
|
||||
assert_eq!(
|
||||
replace_subscription(&tasks, &generation, |current| current).unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
replace_subscription(&tasks, &generation, |current| current).unwrap(),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(*tasks.lock().unwrap(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_hides_a_visible_window_and_shows_a_hidden_window() {
|
||||
let toggle = vec!["--toggle".to_owned()];
|
||||
@@ -193,7 +364,7 @@ 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),
|
||||
window_action(&["--show".to_owned()], true),
|
||||
WindowAction::ShowAndFocus
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const VERSION: u32 = 1;
|
||||
const MAX_ITEMS: usize = 256;
|
||||
const MAX_TOTAL_ITEMS: usize = 2048;
|
||||
const MAX_DRAFT_BYTES: usize = 128 * 1024;
|
||||
const MAX_TOTAL_DRAFT_BYTES: usize = 1024 * 1024;
|
||||
const MAX_TOTAL_STRING_BYTES: usize = 1536 * 1024;
|
||||
const MAX_FILE_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct WorkspaceCursor {
|
||||
pub bridge_instance_id: String,
|
||||
pub seq: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ScrollState {
|
||||
pub at_bottom: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scroll_top: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct LastSeenState {
|
||||
pub bridge_instance_id: String,
|
||||
pub seq: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_activity: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UiStateV1 {
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
pub directory_order: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub runtime_order_by_directory: BTreeMap<String, Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selected_runtime_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub drafts: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub scroll: BTreeMap<String, ScrollState>,
|
||||
#[serde(default)]
|
||||
pub last_seen: BTreeMap<String, LastSeenState>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_cursor: Option<WorkspaceCursor>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub interface_scale: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dismissed_collision_warning: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for UiStateV1 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: VERSION,
|
||||
directory_order: Vec::new(),
|
||||
runtime_order_by_directory: BTreeMap::new(),
|
||||
selected_runtime_id: None,
|
||||
drafts: BTreeMap::new(),
|
||||
scroll: BTreeMap::new(),
|
||||
last_seen: BTreeMap::new(),
|
||||
workspace_cursor: None,
|
||||
interface_scale: None,
|
||||
dismissed_collision_warning: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded(value: &str, field: &str) -> Result<(), String> {
|
||||
if value.is_empty() || value.len() > 4096 {
|
||||
Err(format!("{field} must contain 1 to 4096 bytes"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(state: &UiStateV1) -> Result<(), String> {
|
||||
if state.version != VERSION {
|
||||
return Err(format!("Unsupported UI state version: {}", state.version));
|
||||
}
|
||||
if state.directory_order.len() > MAX_ITEMS
|
||||
|| state.runtime_order_by_directory.len() > MAX_ITEMS
|
||||
|| state.drafts.len() > MAX_ITEMS
|
||||
|| state.scroll.len() > MAX_ITEMS
|
||||
|| state.last_seen.len() > MAX_ITEMS
|
||||
{
|
||||
return Err("UI state contains too many entries".to_owned());
|
||||
}
|
||||
let mut total_items = state.directory_order.len()
|
||||
+ state.runtime_order_by_directory.len()
|
||||
+ state.drafts.len()
|
||||
+ state.scroll.len()
|
||||
+ state.last_seen.len();
|
||||
let mut total_string_bytes = 0usize;
|
||||
for path in &state.directory_order {
|
||||
bounded(path, "directory path")?;
|
||||
total_string_bytes = total_string_bytes.saturating_add(path.len());
|
||||
}
|
||||
for (path, runtimes) in &state.runtime_order_by_directory {
|
||||
bounded(path, "directory path")?;
|
||||
total_string_bytes = total_string_bytes.saturating_add(path.len());
|
||||
if runtimes.len() > MAX_ITEMS {
|
||||
return Err("UI state contains too many runtime entries".to_owned());
|
||||
}
|
||||
total_items = total_items.saturating_add(runtimes.len());
|
||||
for runtime_id in runtimes {
|
||||
bounded(runtime_id, "runtime id")?;
|
||||
total_string_bytes = total_string_bytes.saturating_add(runtime_id.len());
|
||||
}
|
||||
}
|
||||
if total_items > MAX_TOTAL_ITEMS {
|
||||
return Err("UI state contains too many aggregate entries".to_owned());
|
||||
}
|
||||
if let Some(runtime_id) = &state.selected_runtime_id {
|
||||
bounded(runtime_id, "selected runtime id")?;
|
||||
total_string_bytes = total_string_bytes.saturating_add(runtime_id.len());
|
||||
}
|
||||
let mut total_draft_bytes = 0usize;
|
||||
for (runtime_id, draft) in &state.drafts {
|
||||
bounded(runtime_id, "draft runtime id")?;
|
||||
if draft.len() > MAX_DRAFT_BYTES {
|
||||
return Err("A UI draft exceeds the size limit".to_owned());
|
||||
}
|
||||
total_draft_bytes = total_draft_bytes.saturating_add(draft.len());
|
||||
total_string_bytes = total_string_bytes
|
||||
.saturating_add(runtime_id.len())
|
||||
.saturating_add(draft.len());
|
||||
}
|
||||
if total_draft_bytes > MAX_TOTAL_DRAFT_BYTES {
|
||||
return Err("UI drafts exceed the total size limit".to_owned());
|
||||
}
|
||||
for (runtime_id, scroll) in &state.scroll {
|
||||
bounded(runtime_id, "runtime id")?;
|
||||
total_string_bytes = total_string_bytes.saturating_add(runtime_id.len());
|
||||
if scroll
|
||||
.scroll_top
|
||||
.is_some_and(|value| !value.is_finite() || value < 0.0)
|
||||
{
|
||||
return Err("Scroll position is invalid".to_owned());
|
||||
}
|
||||
}
|
||||
for (runtime_id, last_seen) in &state.last_seen {
|
||||
bounded(runtime_id, "runtime id")?;
|
||||
bounded(
|
||||
&last_seen.bridge_instance_id,
|
||||
"last-seen bridge instance id",
|
||||
)?;
|
||||
total_string_bytes = total_string_bytes
|
||||
.saturating_add(runtime_id.len())
|
||||
.saturating_add(last_seen.bridge_instance_id.len());
|
||||
if let Some(activity) = &last_seen.last_activity {
|
||||
bounded(activity, "last activity")?;
|
||||
total_string_bytes = total_string_bytes.saturating_add(activity.len());
|
||||
}
|
||||
}
|
||||
if let Some(cursor) = &state.workspace_cursor {
|
||||
bounded(&cursor.bridge_instance_id, "bridge instance id")?;
|
||||
total_string_bytes = total_string_bytes.saturating_add(cursor.bridge_instance_id.len());
|
||||
}
|
||||
if total_string_bytes > MAX_TOTAL_STRING_BYTES {
|
||||
return Err("UI state strings exceed the total size limit".to_owned());
|
||||
}
|
||||
if let Some(scale) = state.interface_scale {
|
||||
if !scale.is_finite() || !(0.5..=2.0).contains(&scale) {
|
||||
return Err("Interface scale is outside the supported range".to_owned());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn state_path(app_data_dir: &Path) -> PathBuf {
|
||||
app_data_dir.join("ui-workspace-v1.json")
|
||||
}
|
||||
|
||||
pub fn load_from(app_data_dir: &Path) -> Result<UiStateV1, String> {
|
||||
let path = state_path(app_data_dir);
|
||||
let mut file = match fs::File::open(&path) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(UiStateV1::default())
|
||||
}
|
||||
Err(error) => return Err(format!("Could not read UI state: {error}")),
|
||||
};
|
||||
let metadata = file
|
||||
.metadata()
|
||||
.map_err(|error| format!("Could not inspect UI state: {error}"))?;
|
||||
if metadata.len() > MAX_FILE_BYTES as u64 {
|
||||
return Err("UI state file exceeds the size limit".to_owned());
|
||||
}
|
||||
let mut content = Vec::with_capacity(metadata.len() as usize);
|
||||
Read::by_ref(&mut file)
|
||||
.take((MAX_FILE_BYTES + 1) as u64)
|
||||
.read_to_end(&mut content)
|
||||
.map_err(|error| format!("Could not read UI state: {error}"))?;
|
||||
if content.len() > MAX_FILE_BYTES {
|
||||
return Err("UI state file exceeds the size limit".to_owned());
|
||||
}
|
||||
let state: UiStateV1 = serde_json::from_slice(&content)
|
||||
.map_err(|error| format!("UI state is corrupt or unsupported: {error}"))?;
|
||||
validate(&state)?;
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
pub fn save_to(app_data_dir: &Path, state: &UiStateV1) -> Result<(), String> {
|
||||
validate(state)?;
|
||||
fs::create_dir_all(app_data_dir)
|
||||
.map_err(|error| format!("Could not create app data: {error}"))?;
|
||||
set_mode(app_data_dir, 0o700)?;
|
||||
let target = state_path(app_data_dir);
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let temporary = app_data_dir.join(format!(".ui-workspace-{}-{nonce}.tmp", std::process::id()));
|
||||
let encoded = serde_json::to_vec_pretty(state).map_err(|error| error.to_string())?;
|
||||
if encoded.len().saturating_add(1) > MAX_FILE_BYTES {
|
||||
return Err("UI state file exceeds the size limit".to_owned());
|
||||
}
|
||||
let result = (|| {
|
||||
let mut file = create_private_file(&temporary)?;
|
||||
file.write_all(&encoded)
|
||||
.map_err(|error| format!("Could not write UI state: {error}"))?;
|
||||
file.write_all(b"\n")
|
||||
.map_err(|error| format!("Could not write UI state: {error}"))?;
|
||||
file.sync_all()
|
||||
.map_err(|error| format!("Could not sync UI state: {error}"))?;
|
||||
fs::rename(&temporary, &target)
|
||||
.map_err(|error| format!("Could not replace UI state: {error}"))?;
|
||||
set_mode(&target, 0o600)?;
|
||||
sync_directory(app_data_dir)?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn sync_directory(path: &Path) -> Result<(), String> {
|
||||
let directory = OpenOptions::new()
|
||||
.read(true)
|
||||
.open(path)
|
||||
.map_err(|error| format!("Could not open app data directory for sync: {error}"))?;
|
||||
directory
|
||||
.sync_all()
|
||||
.map_err(|error| format!("Could not sync app data directory: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn sync_directory(_path: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn create_private_file(path: &Path) -> Result<std::fs::File, String> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.map_err(|error| format!("Could not create temporary UI state: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn create_private_file(path: &Path) -> Result<std::fs::File, String> {
|
||||
OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(path)
|
||||
.map_err(|error| format!("Could not create temporary UI state: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_mode(path: &Path, mode: u32) -> Result<(), String> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(mode))
|
||||
.map_err(|error| format!("Could not secure UI state path: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_mode(_path: &Path, _mode: u32) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"pi-ui-state-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_only_round_trip_and_missing_default() {
|
||||
let root = temp_dir("roundtrip");
|
||||
assert_eq!(load_from(&root).unwrap(), UiStateV1::default());
|
||||
let mut state = UiStateV1 {
|
||||
selected_runtime_id: Some("runtime-1".into()),
|
||||
interface_scale: Some(1.1),
|
||||
..UiStateV1::default()
|
||||
};
|
||||
state.drafts.insert("runtime-1".into(), "draft".into());
|
||||
save_to(&root, &state).unwrap();
|
||||
assert_eq!(load_from(&root).unwrap(), state);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
fs::metadata(&root).unwrap().permissions().mode() & 0o777,
|
||||
0o700
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(state_path(&root))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
fn temporary_files(root: &Path) -> Vec<PathBuf> {
|
||||
fs::read_dir(root)
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| {
|
||||
name.starts_with(".ui-workspace-") && name.ends_with(".tmp")
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_save_replaces_valid_state_without_leaving_temporary_files() {
|
||||
let root = temp_dir("replace");
|
||||
let first = UiStateV1 {
|
||||
selected_runtime_id: Some("runtime-1".into()),
|
||||
..UiStateV1::default()
|
||||
};
|
||||
let second = UiStateV1 {
|
||||
selected_runtime_id: Some("runtime-2".into()),
|
||||
interface_scale: Some(1.2),
|
||||
..UiStateV1::default()
|
||||
};
|
||||
save_to(&root, &first).unwrap();
|
||||
save_to(&root, &second).unwrap();
|
||||
assert_eq!(load_from(&root).unwrap(), second);
|
||||
assert!(temporary_files(&root).is_empty());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_corruption_versions_unknown_fields_and_limits_without_overwrite() {
|
||||
let root = temp_dir("invalid");
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let path = state_path(&root);
|
||||
for invalid in [
|
||||
"{broken",
|
||||
r#"{"version":2}"#,
|
||||
r#"{"version":1,"transcript":[]}"#,
|
||||
] {
|
||||
fs::write(&path, invalid).unwrap();
|
||||
assert!(load_from(&root).is_err());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), invalid);
|
||||
}
|
||||
|
||||
let baseline = UiStateV1 {
|
||||
selected_runtime_id: Some("baseline".into()),
|
||||
..UiStateV1::default()
|
||||
};
|
||||
save_to(&root, &baseline).unwrap();
|
||||
let original = fs::read(&path).unwrap();
|
||||
let mut oversized = UiStateV1::default();
|
||||
oversized
|
||||
.drafts
|
||||
.insert("runtime".into(), "x".repeat(MAX_DRAFT_BYTES + 1));
|
||||
assert!(save_to(&root, &oversized).is_err());
|
||||
assert_eq!(fs::read(&path).unwrap(), original);
|
||||
assert!(temporary_files(&root).is_empty());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_aggregate_and_raw_file_size_limits() {
|
||||
let root = temp_dir("aggregate");
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let mut aggregate = UiStateV1::default();
|
||||
for directory in 0..9 {
|
||||
aggregate.runtime_order_by_directory.insert(
|
||||
format!("/directory-{directory}"),
|
||||
(0..MAX_ITEMS)
|
||||
.map(|runtime| format!("runtime-{directory}-{runtime}"))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
assert!(validate(&aggregate)
|
||||
.unwrap_err()
|
||||
.contains("aggregate entries"));
|
||||
|
||||
let path = state_path(&root);
|
||||
fs::write(&path, vec![b' '; MAX_FILE_BYTES + 1]).unwrap();
|
||||
assert!(load_from(&root)
|
||||
.unwrap_err()
|
||||
.contains("file exceeds the size limit"));
|
||||
assert_eq!(
|
||||
fs::metadata(&path).unwrap().len(),
|
||||
(MAX_FILE_BYTES + 1) as u64
|
||||
);
|
||||
assert!(temporary_files(&root).is_empty());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
+504
-777
File diff suppressed because it is too large
Load Diff
+508
-1615
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,253 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { Model, RuntimeView, TodoTask } from "../workspace/types";
|
||||
|
||||
export function messageText(message: RuntimeView["messages"][number]) {
|
||||
if (typeof message.content === "string") return message.content;
|
||||
return (message.content ?? [])
|
||||
.map((part) => {
|
||||
if (part.type === "text") return part.text ?? "";
|
||||
if (part.type === "thinking") return `Thinking: ${part.thinking ?? ""}`;
|
||||
if (part.type === "toolCall") return `Tool: ${part.name ?? "running"}`;
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
function todos(messages: RuntimeView["messages"]) {
|
||||
const order: Record<TodoTask["status"], number> = {
|
||||
in_progress: 0,
|
||||
pending: 1,
|
||||
completed: 2,
|
||||
deleted: 3,
|
||||
};
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
if (
|
||||
messages[index].role === "toolResult" &&
|
||||
messages[index].toolName === "todo" &&
|
||||
Array.isArray(messages[index].details?.tasks)
|
||||
)
|
||||
return messages[index]
|
||||
.details!.tasks!.filter((task) => task.status !== "deleted")
|
||||
.sort((a, b) => order[a.status] - order[b.status]);
|
||||
}
|
||||
return [] as TodoTask[];
|
||||
}
|
||||
export function ConversationWorkspace({
|
||||
runtime,
|
||||
onDraft,
|
||||
onSubmit,
|
||||
onAbort,
|
||||
onScroll,
|
||||
onModel,
|
||||
onThinking,
|
||||
isSubmitting = false,
|
||||
}: {
|
||||
runtime: RuntimeView;
|
||||
onDraft: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onAbort: () => void;
|
||||
onScroll: (atBottom: boolean, scrollTop: number) => void;
|
||||
onModel: (model: Model) => void;
|
||||
onThinking: (level: string) => void;
|
||||
isSubmitting?: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const tasks = useMemo(() => todos(runtime.messages), [runtime.messages]);
|
||||
const isWorking =
|
||||
isSubmitting ||
|
||||
runtime.progress.phase === "working" ||
|
||||
runtime.progress.phase === "recovering";
|
||||
useEffect(() => {
|
||||
if (runtime.scroll.atBottom && ref.current)
|
||||
ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}, [runtime.messages, runtime.pendingSubmissions, runtime.scroll.atBottom]);
|
||||
return (
|
||||
<div
|
||||
className="workflow"
|
||||
id="session-workspace"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`session-tab-${runtime.summary.runtimeId}`}
|
||||
>
|
||||
<div className="workflow-main">
|
||||
<section
|
||||
className={`transcript ${isWorking ? "working" : runtime.progress.phase}`}
|
||||
aria-label="Conversation"
|
||||
ref={ref}
|
||||
onScroll={(event) => {
|
||||
const node = event.currentTarget;
|
||||
onScroll(
|
||||
node.scrollHeight - node.scrollTop - node.clientHeight <= 4,
|
||||
node.scrollTop,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{isWorking && (
|
||||
<div className="agent-working" role="status" aria-live="polite">
|
||||
<span className="working-bars" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<div>
|
||||
<strong>Pi is working</strong>
|
||||
<span>
|
||||
{isSubmitting
|
||||
? "Sending your prompt…"
|
||||
: runtime.progress.detail}
|
||||
</span>
|
||||
{runtime.progress.tool && (
|
||||
<small>Using {runtime.progress.tool}</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{runtime.loading && !runtime.messages.length && (
|
||||
<p className="muted">Loading session…</p>
|
||||
)}
|
||||
{!runtime.loading && !runtime.messages.length && (
|
||||
<p className="muted">No messages yet.</p>
|
||||
)}
|
||||
{runtime.messages.map((message, index) => (
|
||||
<article
|
||||
className={`message ${message.role ?? "system"}`}
|
||||
key={index}
|
||||
>
|
||||
<strong>{message.role ?? "message"}</strong>
|
||||
<pre>{messageText(message)}</pre>
|
||||
</article>
|
||||
))}
|
||||
{runtime.pendingSubmissions.map((pending) => (
|
||||
<article
|
||||
className={`message user pending-message ${pending.phase}`}
|
||||
key={pending.id}
|
||||
>
|
||||
<strong>
|
||||
You{" "}
|
||||
<span>
|
||||
{pending.phase === "sending"
|
||||
? "Sending…"
|
||||
: "Sent · waiting for Pi"}
|
||||
</span>
|
||||
</strong>
|
||||
<pre>{pending.text}</pre>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<aside className="todos-pane" aria-label="Current todos">
|
||||
<div className="todos-heading">
|
||||
<strong>Todos</strong>
|
||||
<span>
|
||||
{tasks.filter((task) => task.status !== "completed").length}
|
||||
</span>
|
||||
</div>
|
||||
{tasks.length ? (
|
||||
<ol className="todo-list">
|
||||
{tasks.map((task) => (
|
||||
<li className={task.status} key={task.id}>
|
||||
<div>
|
||||
<strong>{task.subject}</strong>
|
||||
{task.activeForm && <small>{task.activeForm}</small>}
|
||||
</div>
|
||||
<span>{task.status.replace("_", " ")}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="muted">No active todos yet.</p>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
<footer className="workflow-footer">
|
||||
{Object.values(runtime.extensionWidgets)
|
||||
.flatMap((widget) => widget.lines)
|
||||
.map((line, index) => (
|
||||
<div className="extension-widget" key={`${line}-${index}`}>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
<section
|
||||
className="pi-controls"
|
||||
aria-label="Model and thinking controls"
|
||||
>
|
||||
<label>
|
||||
Model
|
||||
<select
|
||||
value={`${runtime.state.model?.provider ?? ""}/${runtime.state.model?.id ?? ""}`}
|
||||
onChange={(event) => {
|
||||
const [provider, id] = event.currentTarget.value.split("/");
|
||||
onModel({ provider, id });
|
||||
}}
|
||||
>
|
||||
<option value="">Current model</option>
|
||||
{runtime.models.map((model) => (
|
||||
<option
|
||||
key={`${model.provider}/${model.id}`}
|
||||
value={`${model.provider}/${model.id}`}
|
||||
>
|
||||
{model.provider}/{model.id ?? model.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Thinking
|
||||
<select
|
||||
value={runtime.state.thinkingLevel ?? "medium"}
|
||||
onChange={(event) => onThinking(event.currentTarget.value)}
|
||||
>
|
||||
{["off", "minimal", "low", "medium", "high", "xhigh", "max"].map(
|
||||
(level) => (
|
||||
<option key={level}>{level}</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
<div
|
||||
className={`composer-progress ${isSubmitting ? "working" : runtime.progress.phase}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="work-dot" />
|
||||
<span>
|
||||
{isSubmitting ? "Sending prompt to Pi…" : runtime.progress.detail}
|
||||
</span>
|
||||
{runtime.progress.tool && <small>{runtime.progress.tool}</small>}
|
||||
</div>
|
||||
<div className="composer">
|
||||
<textarea
|
||||
aria-label="Prompt"
|
||||
value={runtime.draft}
|
||||
disabled={isSubmitting}
|
||||
onChange={(event) => onDraft(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="Ask Pi…"
|
||||
/>
|
||||
<div className="composer-actions">
|
||||
{runtime.state.isStreaming && (
|
||||
<button type="button" className="quiet" onClick={onAbort}>
|
||||
Abort
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!runtime.summary.agentId ||
|
||||
!runtime.draft.trim()
|
||||
}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{isSubmitting ? "Sending…" : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { DirectoryWorkspace, WorkspaceState } from "../workspace/types";
|
||||
|
||||
type Props = {
|
||||
state: WorkspaceState;
|
||||
onSelect: (path: string) => void;
|
||||
onAdd: () => void;
|
||||
onForget: (directory: DirectoryWorkspace) => void;
|
||||
};
|
||||
export function DirectorySidebar({ state, onSelect, onAdd, onForget }: Props) {
|
||||
return <aside className="directory-sidebar" aria-label="Directories">
|
||||
<div className="sidebar-heading"><strong>Directories</strong><button onClick={onAdd} aria-label="Add directory">+</button></div>
|
||||
<nav>{state.directoryOrder.map((path) => {
|
||||
const directory = state.directoriesByPath[path]; if (!directory) return null;
|
||||
const selected = path === state.selectedDirectoryPath;
|
||||
return <div className={`directory-row ${selected ? "selected" : ""}`} key={path}>
|
||||
<button className="directory-select" aria-current={selected ? "page" : undefined} onClick={() => onSelect(path)} title={path}>
|
||||
<span>{path.split("/").filter(Boolean).slice(-1)[0] || path}</span>
|
||||
<small>{directory.workingCount ? `${directory.workingCount} working` : `${directory.openCount} open`}</small>
|
||||
<span className="directory-badges" aria-label={`${directory.attentionCount} attention, ${directory.errorCount} errors`}>
|
||||
{directory.attentionCount > 0 && <b className="attention">!{directory.attentionCount}</b>}
|
||||
{directory.errorCount > 0 && <b className="error">×{directory.errorCount}</b>}
|
||||
</span>
|
||||
</button>
|
||||
{!directory.isHome && <button className="forget-directory" aria-label={`Forget ${path}`} onClick={() => onForget(directory)}>×</button>}
|
||||
</div>;
|
||||
})}</nav>
|
||||
</aside>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Extension } from "../workspace/types";
|
||||
|
||||
function controls(container: HTMLElement | null) {
|
||||
return [...(container?.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
|
||||
) ?? [])];
|
||||
}
|
||||
|
||||
export function ExtensionDialog({ extension, onRespond }: { extension?: Extension; onRespond: (response: Record<string, unknown>) => void }) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const opener = useRef<HTMLElement | null>(null);
|
||||
const respond = useRef(onRespond);
|
||||
const [value, setValue] = useState("");
|
||||
respond.current = onRespond;
|
||||
|
||||
useEffect(() => {
|
||||
if (!extension) return;
|
||||
opener.current = document.activeElement as HTMLElement | null;
|
||||
setValue(extension.initialValue ?? extension.text ?? "");
|
||||
const frame = requestAnimationFrame(() => controls(ref.current)[0]?.focus());
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
respond.current({ cancelled: true });
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = controls(ref.current);
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", keydown);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener("keydown", keydown);
|
||||
opener.current?.focus();
|
||||
};
|
||||
}, [extension]);
|
||||
|
||||
if (!extension) return null;
|
||||
return <div className="extension-backdrop"><section ref={ref} className="extension" role="dialog" aria-modal="true" aria-labelledby="extension-title">
|
||||
<h2 id="extension-title">{extension.title ?? "Pi needs input"}</h2>{extension.message && <p>{extension.message}</p>}
|
||||
{extension.method === "select" && <div className="extension-options">{extension.options?.map((option) => <button key={option} onClick={() => onRespond({ value: option })}>{option}</button>)}</div>}
|
||||
{extension.method === "confirm" && <div className="extension-options"><button onClick={() => onRespond({ confirmed: true })}>Confirm</button><button onClick={() => onRespond({ confirmed: false })}>Decline</button></div>}
|
||||
{extension.method === "input" && <><input aria-label="Extension value" value={value} placeholder={extension.placeholder} onChange={(event) => setValue(event.currentTarget.value)} /><button onClick={() => onRespond({ value })}>Submit</button></>}
|
||||
{extension.method === "editor" && <><textarea aria-label="Extension value" value={value} onChange={(event) => setValue(event.currentTarget.value)} /><button onClick={() => onRespond({ value })}>Submit</button></>}
|
||||
<button className="quiet" onClick={() => onRespond({ cancelled: true })}>Cancel</button>
|
||||
</section></div>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { DirectorySession } from "../workspace/types";
|
||||
export function SessionPicker({ sessions, onOpen, onClose }: { sessions: DirectorySession[]; onOpen: (session: DirectorySession) => void; onClose: () => void }) {
|
||||
const dialogRef = useRef<HTMLElement>(null);
|
||||
const openerRef = useRef<HTMLElement | null>(document.activeElement as HTMLElement | null);
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
const controls = () => [...(dialog?.querySelectorAll<HTMLElement>('button:not(:disabled), [href], [tabindex]:not([tabindex="-1"])') ?? [])];
|
||||
controls()[0]?.focus();
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") { event.preventDefault(); onClose(); return; }
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = controls(); const first = focusable[0]; const last = focusable[focusable.length - 1];
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
||||
};
|
||||
window.addEventListener("keydown", keydown);
|
||||
return () => { window.removeEventListener("keydown", keydown); openerRef.current?.focus(); };
|
||||
}, [onClose]);
|
||||
return <div className="modal-backdrop"><section ref={dialogRef} className="session-picker" role="dialog" aria-modal="true" aria-labelledby="session-picker-title">
|
||||
<div className="picker-heading"><h2 id="session-picker-title">Saved sessions</h2><button onClick={onClose}>Close</button></div>
|
||||
{sessions.length ? <div className="session-list">{sessions.map((session) => <button key={session.path} onClick={() => onOpen(session)}>
|
||||
<span>{session.name ?? session.firstMessage ?? "Untitled session"}</span><small>{session.runtimeId ? "Already open" : `${session.messageCount} messages · ${new Date(session.modified).toLocaleDateString()}`}</small>
|
||||
</button>)}</div> : <p className="muted">No saved sessions for this directory.</p>}
|
||||
</section></div>;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useRef, type KeyboardEvent } from "react";
|
||||
import type { RuntimeView } from "../workspace/types";
|
||||
|
||||
type Props = {
|
||||
runtimes: RuntimeView[];
|
||||
selectedId?: string;
|
||||
onSelect: (id: string) => void;
|
||||
onClose: (runtime: RuntimeView) => void;
|
||||
onNew: () => void;
|
||||
onResume: () => void;
|
||||
busy?: boolean;
|
||||
closingRuntimeId?: string;
|
||||
restoreFocus?: boolean;
|
||||
onFocusRestored?: () => void;
|
||||
};
|
||||
export function SessionTabs({
|
||||
runtimes,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onClose,
|
||||
onNew,
|
||||
onResume,
|
||||
busy,
|
||||
closingRuntimeId,
|
||||
restoreFocus,
|
||||
onFocusRestored,
|
||||
}: Props) {
|
||||
const refs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const newSessionRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
if (!restoreFocus) return;
|
||||
(selectedId ? refs.current[selectedId] : newSessionRef.current)?.focus();
|
||||
onFocusRestored?.();
|
||||
}, [restoreFocus, selectedId, onFocusRestored]);
|
||||
function keydown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
|
||||
let next: number | undefined;
|
||||
if (!runtimes.length) return;
|
||||
if (event.key === "ArrowRight") next = (index + 1) % runtimes.length;
|
||||
else if (event.key === "ArrowLeft")
|
||||
next = (index - 1 + runtimes.length) % runtimes.length;
|
||||
else if (event.key === "Home") next = 0;
|
||||
else if (event.key === "End") next = runtimes.length - 1;
|
||||
if (next === undefined) return;
|
||||
event.preventDefault();
|
||||
refs.current[runtimes[next].summary.runtimeId]?.focus();
|
||||
onSelect(runtimes[next].summary.runtimeId);
|
||||
}
|
||||
return (
|
||||
<div className="session-strip">
|
||||
<div className="session-tab-bar" aria-label="Sessions">
|
||||
<div className="session-tabs" role="tablist" aria-label="Open sessions">
|
||||
{runtimes.map((runtime, index) => {
|
||||
const summary = runtime.summary;
|
||||
const selected = summary.runtimeId === selectedId;
|
||||
return (
|
||||
<div
|
||||
className={`session-tab ${selected ? "selected" : ""}`}
|
||||
key={summary.runtimeId}
|
||||
>
|
||||
<button
|
||||
role="tab"
|
||||
id={`session-tab-${summary.runtimeId}`}
|
||||
aria-controls="session-workspace"
|
||||
aria-selected={selected}
|
||||
tabIndex={selected || (!selectedId && index === 0) ? 0 : -1}
|
||||
ref={(element) => {
|
||||
refs.current[summary.runtimeId] = element;
|
||||
}}
|
||||
onKeyDown={(event) => keydown(event, index)}
|
||||
onClick={() => onSelect(summary.runtimeId)}
|
||||
>
|
||||
<span>{summary.label}</span>
|
||||
<small className={summary.state}>
|
||||
{summary.state}
|
||||
{summary.queueCount
|
||||
? ` · ${summary.queueCount} queued`
|
||||
: ""}
|
||||
</small>
|
||||
{runtime.unread && (
|
||||
<i className="unread-dot" aria-label="Unread activity" />
|
||||
)}
|
||||
{summary.attention && (
|
||||
<b className="attention-dot" aria-label="Needs attention">
|
||||
!
|
||||
</b>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="close-session"
|
||||
aria-label={`Close ${summary.label}`}
|
||||
disabled={closingRuntimeId === summary.runtimeId}
|
||||
onClick={() => onClose(runtime)}
|
||||
>
|
||||
{closingRuntimeId === summary.runtimeId ? "…" : "×"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="session-tab-actions" aria-label="Session actions">
|
||||
<button className="load-session" disabled={busy} onClick={onResume}>
|
||||
<span aria-hidden="true">⌄</span>
|
||||
{busy ? "Working…" : "Load"}
|
||||
</button>
|
||||
<button
|
||||
className="new-session"
|
||||
ref={newSessionRef}
|
||||
disabled={busy}
|
||||
onClick={onNew}
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
{busy ? "Starting…" : "New"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DirectorySidebar } from "./DirectorySidebar";
|
||||
import { SessionTabs } from "./SessionTabs";
|
||||
import { SessionPicker } from "./SessionPicker";
|
||||
import {
|
||||
initialWorkspaceState,
|
||||
reconcileWorkspace,
|
||||
runtimeView,
|
||||
} from "../workspace/reducer";
|
||||
import type { Workspace } from "../workspace/types";
|
||||
const summary = (id: string, state = "idle") => ({
|
||||
runtimeId: id,
|
||||
worktreePath: "/repo",
|
||||
state,
|
||||
label: id,
|
||||
attention: id === "b",
|
||||
queueCount: id === "b" ? 2 : 0,
|
||||
lastActivity: id,
|
||||
openedAt: id,
|
||||
});
|
||||
const workspace: Workspace = {
|
||||
bridgeInstanceId: "e",
|
||||
latestSeq: 0,
|
||||
directories: [
|
||||
{
|
||||
worktreePath: "/repo",
|
||||
isHome: false,
|
||||
openCount: 2,
|
||||
workingCount: 1,
|
||||
attentionCount: 1,
|
||||
recoveringCount: 0,
|
||||
errorCount: 1,
|
||||
runtimes: [summary("a"), summary("b", "streaming")],
|
||||
},
|
||||
],
|
||||
};
|
||||
describe("multi-session navigation", () => {
|
||||
it("renders directory aggregates and accessible keyboard session tabs", async () => {
|
||||
const state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
const onSelect = vi.fn();
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<DirectorySidebar
|
||||
state={state}
|
||||
onSelect={vi.fn()}
|
||||
onAdd={vi.fn()}
|
||||
onForget={vi.fn()}
|
||||
/>
|
||||
<SessionTabs
|
||||
runtimes={[
|
||||
runtimeView(summary("a")),
|
||||
runtimeView(summary("b", "streaming")),
|
||||
]}
|
||||
selectedId="a"
|
||||
onSelect={onSelect}
|
||||
onClose={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
expect(screen.getByLabelText(/1 attention, 1 errors/)).toBeInTheDocument();
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
expect(tabs[0]).toHaveAttribute("aria-selected", "true");
|
||||
await userEvent.click(tabs[0]);
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
expect(onSelect).toHaveBeenCalledWith("b");
|
||||
rerender(
|
||||
<SessionTabs
|
||||
runtimes={[runtimeView(summary("b", "streaming"))]}
|
||||
selectedId="b"
|
||||
onSelect={onSelect}
|
||||
onClose={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("tab")).toHaveAccessibleName(/b/);
|
||||
});
|
||||
it("restores focus to the selected adjacent tab after close", () => {
|
||||
const onFocusRestored = vi.fn();
|
||||
render(
|
||||
<SessionTabs
|
||||
runtimes={[runtimeView(summary("a")), runtimeView(summary("b"))]}
|
||||
selectedId="b"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
restoreFocus
|
||||
onFocusRestored={onFocusRestored}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("tab", { name: /b/ })).toHaveFocus();
|
||||
expect(onFocusRestored).toHaveBeenCalled();
|
||||
});
|
||||
it("traps and restores focus for the saved-session picker", async () => {
|
||||
const user = userEvent.setup();
|
||||
const opener = document.createElement("button");
|
||||
document.body.append(opener);
|
||||
opener.focus();
|
||||
const onClose = vi.fn();
|
||||
const { unmount } = render(
|
||||
<SessionPicker
|
||||
sessions={[
|
||||
{
|
||||
path: "/s",
|
||||
id: "s",
|
||||
cwd: "/repo",
|
||||
modified: new Date().toISOString(),
|
||||
messageCount: 0,
|
||||
},
|
||||
]}
|
||||
onOpen={vi.fn()}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
const close = screen.getByRole("button", { name: "Close" });
|
||||
expect(close).toHaveFocus();
|
||||
await user.keyboard("{Tab}");
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Untitled session/ }),
|
||||
).toHaveFocus();
|
||||
await user.keyboard("{Tab}");
|
||||
expect(close).toHaveFocus();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
unmount();
|
||||
expect(opener).toHaveFocus();
|
||||
document.body.removeChild(opener);
|
||||
});
|
||||
it("routes new, saved-open, and close controls through callbacks", async () => {
|
||||
const onNew = vi.fn(),
|
||||
onResume = vi.fn(),
|
||||
onClose = vi.fn();
|
||||
render(
|
||||
<SessionTabs
|
||||
runtimes={[runtimeView(summary("a"))]}
|
||||
selectedId="a"
|
||||
onSelect={vi.fn()}
|
||||
onClose={onClose}
|
||||
onNew={onNew}
|
||||
onResume={onResume}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Load" }));
|
||||
await userEvent.click(screen.getByLabelText("Close a"));
|
||||
expect(onNew).toHaveBeenCalled();
|
||||
expect(onResume).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
afterEach(cleanup);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { legacyPreferences, removeLegacyPreferences, presentationState } from "./persistence";
|
||||
import { initialWorkspaceState, reconcileWorkspace, workspaceReducer } from "./reducer";
|
||||
import type { Workspace } from "./types";
|
||||
const workspace: Workspace = { bridgeInstanceId: "e", latestSeq: 7, directories: [{ worktreePath: "/repo", isHome: true, openCount: 1, workingCount: 0, attentionCount: 0, recoveringCount: 0, errorCount: 0, runtimes: [{ runtimeId: "r", worktreePath: "/repo", state: "idle", label: "r", attention: false, queueCount: 0, lastActivity: "now", openedAt: "now", agentId: "ephemeral" }] }] };
|
||||
describe("presentation persistence", () => {
|
||||
it("persists only allowlisted presentation state, never agent or session payloads", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace); state = workspaceReducer(state, { type: "draftChanged", runtimeId: "r", draft: "safe draft" });
|
||||
const persisted = presentationState(state); const encoded = JSON.stringify(persisted);
|
||||
expect(persisted.drafts.r).toBe("safe draft"); expect(encoded).not.toContain("ephemeral"); expect(encoded).not.toContain("messages"); expect(encoded).not.toContain("extensions");
|
||||
});
|
||||
it("reads and removes legacy worktree/scale only after caller saves", () => {
|
||||
const getItem = vi.fn((key: string) => key.includes("selected") ? "/repo" : "1.2"); expect(legacyPreferences({ getItem })).toEqual({ path: "/repo", scale: 1.2 });
|
||||
const removeItem = vi.fn(); removeLegacyPreferences({ removeItem }); expect(removeItem).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { UiState, WorkspaceState } from "./types";
|
||||
|
||||
export const selectedPathKey = "pi-status-ui.selected-worktree";
|
||||
export const uiScaleKey = "pi-status-ui.interface-scale";
|
||||
|
||||
export function presentationState(state: WorkspaceState): UiState {
|
||||
const drafts: Record<string, string> = {};
|
||||
const scroll: UiState["scroll"] = {};
|
||||
const lastSeen: UiState["lastSeen"] = {};
|
||||
for (const [id, runtime] of Object.entries(state.runtimesById)) {
|
||||
if (runtime.draft) drafts[id] = runtime.draft;
|
||||
scroll[id] = runtime.scroll;
|
||||
if (!runtime.unread && state.bridge.instanceId)
|
||||
lastSeen[id] = {
|
||||
bridgeInstanceId: state.bridge.instanceId,
|
||||
seq: state.bridge.latestSeq,
|
||||
lastActivity: runtime.summary.lastActivity,
|
||||
};
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
directoryOrder: state.directoryOrder,
|
||||
runtimeOrderByDirectory: state.runtimeOrderByDirectory,
|
||||
selectedRuntimeId: state.selectedRuntimeId,
|
||||
drafts,
|
||||
scroll,
|
||||
lastSeen,
|
||||
workspaceCursor: state.bridge.instanceId
|
||||
? {
|
||||
bridgeInstanceId: state.bridge.instanceId,
|
||||
seq: state.bridge.latestSeq,
|
||||
}
|
||||
: undefined,
|
||||
interfaceScale: state.interfaceScale,
|
||||
dismissedCollisionWarning: state.dismissedCollisionWarning,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadPresentation() {
|
||||
return invoke<UiState>("load_ui_state");
|
||||
}
|
||||
export async function savePresentation(state: UiState) {
|
||||
await invoke("save_ui_state", { state });
|
||||
}
|
||||
export function legacyPreferences(storage: Pick<Storage, "getItem">) {
|
||||
const path = storage.getItem(selectedPathKey) ?? undefined;
|
||||
const raw = Number(storage.getItem(uiScaleKey));
|
||||
const scale =
|
||||
Number.isFinite(raw) && raw >= 0.9 && raw <= 1.4 ? raw : undefined;
|
||||
return { path, scale };
|
||||
}
|
||||
export function removeLegacyPreferences(storage: Pick<Storage, "removeItem">) {
|
||||
storage.removeItem(selectedPathKey);
|
||||
storage.removeItem(uiScaleKey);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyWorkspaceEvent,
|
||||
initialWorkspaceState,
|
||||
reconcileWorkspace,
|
||||
workspaceReducer,
|
||||
} from "./reducer";
|
||||
import type { RuntimeSnapshot, Workspace } from "./types";
|
||||
const runtime = (id: string, path = "/repo") => ({
|
||||
runtimeId: id,
|
||||
worktreePath: path,
|
||||
state: "idle",
|
||||
label: id,
|
||||
attention: false,
|
||||
queueCount: 0,
|
||||
lastActivity: id,
|
||||
openedAt: id,
|
||||
});
|
||||
const workspace: Workspace = {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 0,
|
||||
directories: [
|
||||
{
|
||||
worktreePath: "/repo",
|
||||
isHome: false,
|
||||
openCount: 2,
|
||||
workingCount: 0,
|
||||
attentionCount: 0,
|
||||
recoveringCount: 0,
|
||||
errorCount: 0,
|
||||
runtimes: [runtime("a"), runtime("b")],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("workspace reducer", () => {
|
||||
it("isolates drafts, snapshots and background unread by runtime", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
type: "draftChanged",
|
||||
runtimeId: "a",
|
||||
draft: "draft a",
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotRequested",
|
||||
runtimeId: "b",
|
||||
generation: 1,
|
||||
});
|
||||
const snapshot: RuntimeSnapshot = {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 0,
|
||||
runtime: runtime("b"),
|
||||
transcript: { data: { messages: [{ role: "user", content: "b" }] } },
|
||||
extensions: [],
|
||||
};
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotLoaded",
|
||||
runtimeId: "b",
|
||||
generation: 1,
|
||||
snapshot,
|
||||
});
|
||||
state = applyWorkspaceEvent(state, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 1,
|
||||
type: "runtime_event",
|
||||
data: {
|
||||
runtimeId: "b",
|
||||
eventType: "transcript",
|
||||
eventData: { event: { type: "message_end" } },
|
||||
},
|
||||
});
|
||||
expect(state.runtimesById.a.draft).toBe("draft a");
|
||||
expect(state.runtimesById.a.messages).toEqual([]);
|
||||
expect(state.runtimesById.b.messages[0].content).toBe("b");
|
||||
expect(state.runtimesById.b.unread).toBe(true);
|
||||
});
|
||||
it("removes the optimistic submission when Pi returns its structured user message", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
type: "submissionAdded",
|
||||
runtimeId: "a",
|
||||
submission: {
|
||||
id: 1,
|
||||
text: "Hello Pi",
|
||||
userMessageCount: 0,
|
||||
phase: "sent",
|
||||
},
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotRequested",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
});
|
||||
const snapshot: RuntimeSnapshot = {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 1,
|
||||
runtime: runtime("a"),
|
||||
transcript: {
|
||||
data: {
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "Hello Pi" }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
extensions: [],
|
||||
};
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotLoaded",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
snapshot,
|
||||
});
|
||||
expect(state.runtimesById.a.pendingSubmissions).toEqual([]);
|
||||
});
|
||||
it("rejects out-of-order snapshots and detects duplicate/gap/epoch events", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotRequested",
|
||||
runtimeId: "a",
|
||||
generation: 2,
|
||||
});
|
||||
const snap: RuntimeSnapshot = {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 0,
|
||||
runtime: runtime("a"),
|
||||
extensions: [],
|
||||
};
|
||||
expect(
|
||||
workspaceReducer(state, {
|
||||
type: "snapshotLoaded",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
snapshot: snap,
|
||||
}).runtimesById.a.loading,
|
||||
).toBe(true);
|
||||
const first = applyWorkspaceEvent(state, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 1,
|
||||
data: { runtimeId: "a", runtime: runtime("a") },
|
||||
});
|
||||
expect(
|
||||
applyWorkspaceEvent(first, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 1,
|
||||
data: { runtimeId: "a" },
|
||||
}),
|
||||
).toBe(first);
|
||||
expect(
|
||||
applyWorkspaceEvent(first, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 3,
|
||||
data: { runtimeId: "a" },
|
||||
}).bridge.status,
|
||||
).toBe("resyncing");
|
||||
expect(
|
||||
applyWorkspaceEvent(first, {
|
||||
bridgeInstanceId: "new",
|
||||
seq: 2,
|
||||
data: { runtimeId: "a" },
|
||||
}).bridge.status,
|
||||
).toBe("resyncing");
|
||||
});
|
||||
it("selects an adjacent tab on close, preserves drafts, and keeps failed dormant runtimes", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace, {
|
||||
version: 1,
|
||||
directoryOrder: ["/repo"],
|
||||
runtimeOrderByDirectory: { "/repo": ["b", "a"] },
|
||||
selectedRuntimeId: "b",
|
||||
drafts: { a: "saved" },
|
||||
scroll: {},
|
||||
lastSeen: {},
|
||||
});
|
||||
expect(state.runtimesById.a.draft).toBe("saved");
|
||||
state = applyWorkspaceEvent(state, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 1,
|
||||
type: "runtime_closed",
|
||||
data: { runtimeId: "b" },
|
||||
});
|
||||
expect(state.selectedRuntimeId).toBe("a");
|
||||
const failed: Workspace = {
|
||||
...workspace,
|
||||
directories: [
|
||||
{
|
||||
...workspace.directories[0],
|
||||
runtimes: [
|
||||
{
|
||||
...runtime("failed"),
|
||||
state: "failed",
|
||||
attention: true,
|
||||
error: { message: "missing" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(
|
||||
reconcileWorkspace(initialWorkspaceState, failed).runtimesById.failed
|
||||
.summary.agentId,
|
||||
).toBeUndefined();
|
||||
});
|
||||
it("accepts fresh snapshots after a bridge epoch reset while preserving UI state", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
type: "draftChanged",
|
||||
runtimeId: "a",
|
||||
draft: "keep me",
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "scrollChanged",
|
||||
runtimeId: "a",
|
||||
atBottom: false,
|
||||
scrollTop: 42,
|
||||
});
|
||||
state.runtimesById.a.loadedThroughSeq = 100;
|
||||
state = workspaceReducer(state, {
|
||||
type: "hostEvent",
|
||||
host: {
|
||||
kind: "resetRequired",
|
||||
bridgeInstanceId: "new-epoch",
|
||||
latestSeq: 2,
|
||||
reason: "epochChanged",
|
||||
},
|
||||
});
|
||||
const restored = reconcileWorkspace(state, {
|
||||
...workspace,
|
||||
bridgeInstanceId: "new-epoch",
|
||||
latestSeq: 2,
|
||||
});
|
||||
expect(restored.runtimesById.a.loadedThroughSeq).toBe(0);
|
||||
expect(restored.runtimesById.a.draft).toBe("keep me");
|
||||
expect(restored.runtimesById.a.scroll.scrollTop).toBe(42);
|
||||
const requested = workspaceReducer(restored, {
|
||||
type: "snapshotRequested",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
});
|
||||
const snapshot: RuntimeSnapshot = {
|
||||
bridgeInstanceId: "new-epoch",
|
||||
latestSeq: 2,
|
||||
runtime: runtime("a"),
|
||||
extensions: [],
|
||||
};
|
||||
const accepted = workspaceReducer(requested, {
|
||||
type: "snapshotLoaded",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
snapshot,
|
||||
});
|
||||
expect(accepted.runtimesById.a.loadedThroughSeq).toBe(2);
|
||||
expect(accepted.runtimesById.a.extensions).toEqual([]);
|
||||
expect(accepted.runtimesById.a.messages).toEqual([]);
|
||||
});
|
||||
it("clears stale extension and transcript state for every runtime after an epoch change", () => {
|
||||
const state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state.runtimesById.b.messages = [{ role: "assistant", content: "old" }];
|
||||
state.runtimesById.b.extensions = [
|
||||
{ id: "dead-request", method: "confirm" },
|
||||
];
|
||||
state.runtimesById.b.unread = true;
|
||||
const reset = workspaceReducer(state, {
|
||||
type: "hostEvent",
|
||||
host: {
|
||||
kind: "resetRequired",
|
||||
bridgeInstanceId: "new-epoch",
|
||||
latestSeq: 1,
|
||||
reason: "epochChanged",
|
||||
},
|
||||
});
|
||||
const restored = reconcileWorkspace(reset, {
|
||||
...workspace,
|
||||
bridgeInstanceId: "new-epoch",
|
||||
latestSeq: 1,
|
||||
});
|
||||
expect(restored.runtimesById.b.messages).toEqual([]);
|
||||
expect(restored.runtimesById.b.extensions).toEqual([]);
|
||||
expect(restored.runtimesById.b.unread).toBe(false);
|
||||
});
|
||||
it("requests reconciliation when a runtime lifecycle event changes the open tabs", () => {
|
||||
const state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
const next = applyWorkspaceEvent(state, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 1,
|
||||
type: "runtime_opened",
|
||||
data: { runtimeId: "external", runtime: runtime("external") },
|
||||
});
|
||||
expect(next.bridge.status).toBe("resyncing");
|
||||
});
|
||||
it("clears unread only when selected, active, and at latest", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state.runtimesById.a.unread = true;
|
||||
state = workspaceReducer(state, { type: "setWindowActive", active: false });
|
||||
state = workspaceReducer(state, { type: "markRead", runtimeId: "a" });
|
||||
expect(state.runtimesById.a.unread).toBe(true);
|
||||
state = workspaceReducer(state, { type: "setWindowActive", active: true });
|
||||
state = workspaceReducer(state, {
|
||||
type: "scrollChanged",
|
||||
runtimeId: "a",
|
||||
atBottom: false,
|
||||
});
|
||||
state = workspaceReducer(state, { type: "markRead", runtimeId: "a" });
|
||||
expect(state.runtimesById.a.unread).toBe(true);
|
||||
state = workspaceReducer(state, {
|
||||
type: "scrollChanged",
|
||||
runtimeId: "a",
|
||||
atBottom: true,
|
||||
});
|
||||
state = workspaceReducer(state, { type: "markRead", runtimeId: "a" });
|
||||
expect(state.runtimesById.a.unread).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,507 @@
|
||||
import type {
|
||||
Extension,
|
||||
HostEvent,
|
||||
RuntimeSnapshot,
|
||||
RuntimeSummary,
|
||||
RuntimeView,
|
||||
UiState,
|
||||
Workspace,
|
||||
WorkspaceEvent,
|
||||
WorkspaceState,
|
||||
} from "./types";
|
||||
|
||||
const idleProgress = {
|
||||
phase: "idle" as const,
|
||||
detail: "Ready for your next prompt",
|
||||
toolCount: 0,
|
||||
};
|
||||
|
||||
function messageText(message: RuntimeView["messages"][number]) {
|
||||
if (typeof message.content === "string") return message.content;
|
||||
return (message.content ?? [])
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function runtimeView(
|
||||
summary: RuntimeSummary,
|
||||
previous?: RuntimeView,
|
||||
): RuntimeView {
|
||||
return {
|
||||
summary,
|
||||
state: previous?.state ?? {},
|
||||
stats: previous?.stats ?? {},
|
||||
messages: previous?.messages ?? [],
|
||||
commands: previous?.commands ?? [],
|
||||
models: previous?.models ?? [],
|
||||
extensions: previous?.extensions ?? [],
|
||||
extensionStatuses: previous?.extensionStatuses ?? {},
|
||||
extensionWidgets: previous?.extensionWidgets ?? {},
|
||||
notifications: previous?.notifications ?? [],
|
||||
progress: previous?.progress ?? idleProgress,
|
||||
pendingSubmissions: previous?.pendingSubmissions ?? [],
|
||||
draft: previous?.draft ?? "",
|
||||
scroll: previous?.scroll ?? { atBottom: true },
|
||||
unread: previous?.unread ?? false,
|
||||
loading: previous?.loading ?? false,
|
||||
error: previous?.error,
|
||||
requestGeneration: previous?.requestGeneration ?? 0,
|
||||
loadedThroughSeq: previous?.loadedThroughSeq ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function resetRuntimeView(
|
||||
summary: RuntimeSummary,
|
||||
previous: RuntimeView,
|
||||
): RuntimeView {
|
||||
return {
|
||||
...runtimeView(summary, previous),
|
||||
state: {},
|
||||
stats: {},
|
||||
messages: [],
|
||||
commands: [],
|
||||
models: [],
|
||||
extensions: [],
|
||||
extensionStatuses: {},
|
||||
extensionWidgets: {},
|
||||
notifications: [],
|
||||
pendingSubmissions: [],
|
||||
unread: false,
|
||||
progress: idleProgress,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
requestGeneration: 0,
|
||||
loadedThroughSeq: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const initialWorkspaceState: WorkspaceState = {
|
||||
bridge: { latestSeq: 0, status: "connecting" },
|
||||
directoriesByPath: {},
|
||||
directoryOrder: [],
|
||||
runtimesById: {},
|
||||
runtimeOrderByDirectory: {},
|
||||
windowActive: true,
|
||||
resourceWarning: false,
|
||||
dismissedCollisionWarning: false,
|
||||
interfaceScale: 1,
|
||||
};
|
||||
|
||||
function ordered(current: string[] | undefined, authoritative: string[]) {
|
||||
const valid = new Set(authoritative);
|
||||
return [
|
||||
...(current ?? []).filter((id) => valid.has(id)),
|
||||
...authoritative.filter((id) => !(current ?? []).includes(id)),
|
||||
];
|
||||
}
|
||||
|
||||
export function reconcileWorkspace(
|
||||
state: WorkspaceState,
|
||||
workspace: Workspace,
|
||||
persisted?: UiState,
|
||||
): WorkspaceState {
|
||||
const epochChanged = Boolean(
|
||||
state.bridge.instanceId &&
|
||||
state.bridge.instanceId !== workspace.bridgeInstanceId,
|
||||
);
|
||||
const directoriesByPath: WorkspaceState["directoriesByPath"] = {};
|
||||
const runtimesById: WorkspaceState["runtimesById"] = {};
|
||||
const runtimeOrderByDirectory: WorkspaceState["runtimeOrderByDirectory"] = {};
|
||||
for (const directory of workspace.directories) {
|
||||
directoriesByPath[directory.worktreePath] = directory;
|
||||
for (const runtime of directory.runtimes) {
|
||||
const previous = state.runtimesById[runtime.runtimeId];
|
||||
runtimesById[runtime.runtimeId] =
|
||||
epochChanged && previous
|
||||
? resetRuntimeView(runtime, previous)
|
||||
: runtimeView(runtime, previous);
|
||||
}
|
||||
const ids = directory.runtimes.map((runtime) => runtime.runtimeId);
|
||||
runtimeOrderByDirectory[directory.worktreePath] = ordered(
|
||||
persisted?.runtimeOrderByDirectory[directory.worktreePath] ??
|
||||
state.runtimeOrderByDirectory[directory.worktreePath],
|
||||
ids,
|
||||
);
|
||||
}
|
||||
const paths = workspace.directories.map(
|
||||
(directory) => directory.worktreePath,
|
||||
);
|
||||
const directoryOrder = ordered(
|
||||
persisted?.directoryOrder ?? state.directoryOrder,
|
||||
paths,
|
||||
);
|
||||
const preferred = persisted?.selectedRuntimeId ?? state.selectedRuntimeId;
|
||||
const selectedRuntimeId =
|
||||
preferred && runtimesById[preferred]
|
||||
? preferred
|
||||
: directoryOrder.flatMap(
|
||||
(path) => runtimeOrderByDirectory[path] ?? [],
|
||||
)[0];
|
||||
const selectedDirectoryPath = selectedRuntimeId
|
||||
? runtimesById[selectedRuntimeId]?.summary.worktreePath
|
||||
: directoryOrder[0];
|
||||
if (persisted) {
|
||||
for (const [id, view] of Object.entries(runtimesById)) {
|
||||
view.draft = persisted.drafts[id] ?? view.draft;
|
||||
view.scroll = persisted.scroll[id] ?? view.scroll;
|
||||
const seen = persisted.lastSeen[id];
|
||||
if (
|
||||
seen?.bridgeInstanceId === workspace.bridgeInstanceId &&
|
||||
seen.seq < workspace.latestSeq &&
|
||||
seen.lastActivity !== view.summary.lastActivity
|
||||
)
|
||||
view.unread = true;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
bridge: {
|
||||
instanceId: workspace.bridgeInstanceId,
|
||||
latestSeq: workspace.latestSeq,
|
||||
status: "ready",
|
||||
},
|
||||
directoriesByPath,
|
||||
directoryOrder,
|
||||
runtimesById,
|
||||
runtimeOrderByDirectory,
|
||||
selectedRuntimeId,
|
||||
selectedDirectoryPath,
|
||||
dismissedCollisionWarning:
|
||||
persisted?.dismissedCollisionWarning ?? state.dismissedCollisionWarning,
|
||||
interfaceScale: persisted?.interfaceScale ?? state.interfaceScale,
|
||||
};
|
||||
}
|
||||
|
||||
function meaningful(eventType?: string, event?: Record<string, unknown>) {
|
||||
if (["extension_ui_request", "agent_state"].includes(eventType ?? ""))
|
||||
return true;
|
||||
if (eventType === "transcript")
|
||||
return ["message_end", "turn_end", "agent_end"].includes(
|
||||
String(event?.type ?? ""),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
function extensionPresentation(view: RuntimeView, extension: Extension) {
|
||||
if (["select", "confirm", "input", "editor"].includes(extension.method ?? ""))
|
||||
view.extensions = [
|
||||
...view.extensions.filter((entry) => entry.id !== extension.id),
|
||||
extension,
|
||||
];
|
||||
else if (extension.method === "notify")
|
||||
view.notifications = [...view.notifications, extension].slice(-4);
|
||||
else if (extension.method === "setStatus" && extension.statusKey) {
|
||||
const next = { ...view.extensionStatuses };
|
||||
if (extension.statusText) next[extension.statusKey] = extension.statusText;
|
||||
else delete next[extension.statusKey];
|
||||
view.extensionStatuses = next;
|
||||
} else if (extension.method === "setWidget" && extension.widgetKey) {
|
||||
const next = { ...view.extensionWidgets };
|
||||
if (extension.widgetLines)
|
||||
next[extension.widgetKey] = {
|
||||
lines: extension.widgetLines,
|
||||
placement: extension.widgetPlacement ?? "aboveEditor",
|
||||
};
|
||||
else delete next[extension.widgetKey];
|
||||
view.extensionWidgets = next;
|
||||
} else if (extension.method === "set_editor_text")
|
||||
view.draft = extension.text ?? "";
|
||||
}
|
||||
|
||||
export function applyWorkspaceEvent(
|
||||
state: WorkspaceState,
|
||||
event: WorkspaceEvent,
|
||||
): WorkspaceState {
|
||||
if (
|
||||
state.bridge.instanceId &&
|
||||
event.bridgeInstanceId !== state.bridge.instanceId
|
||||
)
|
||||
return {
|
||||
...state,
|
||||
bridge: {
|
||||
...state.bridge,
|
||||
status: "resyncing",
|
||||
error: "Bridge restarted",
|
||||
},
|
||||
};
|
||||
if (event.seq <= state.bridge.latestSeq) return state;
|
||||
if (event.seq !== state.bridge.latestSeq + 1)
|
||||
return {
|
||||
...state,
|
||||
bridge: {
|
||||
...state.bridge,
|
||||
status: "resyncing",
|
||||
error: "Workspace event gap",
|
||||
},
|
||||
};
|
||||
const id = event.data?.runtimeId;
|
||||
if (
|
||||
["runtime_opened", "runtime_recovered", "runtime_restore_failed"].includes(
|
||||
event.type ?? "",
|
||||
)
|
||||
)
|
||||
return {
|
||||
...state,
|
||||
bridge: { ...state.bridge, latestSeq: event.seq, status: "resyncing" },
|
||||
};
|
||||
if (!id || (!state.runtimesById[id] && !event.data?.runtime))
|
||||
return {
|
||||
...state,
|
||||
bridge: {
|
||||
...state.bridge,
|
||||
latestSeq: event.seq,
|
||||
status: "resyncing",
|
||||
error: "Unknown runtime",
|
||||
},
|
||||
};
|
||||
const runtimesById = { ...state.runtimesById };
|
||||
let view = runtimeView(
|
||||
event.data?.runtime ?? runtimesById[id].summary,
|
||||
runtimesById[id],
|
||||
);
|
||||
view = { ...view, summary: event.data?.runtime ?? view.summary };
|
||||
if (event.type === "runtime_closed") {
|
||||
delete runtimesById[id];
|
||||
const path = view.summary.worktreePath;
|
||||
const order = (state.runtimeOrderByDirectory[path] ?? []).filter(
|
||||
(runtimeId) => runtimeId !== id,
|
||||
);
|
||||
const runtimeOrderByDirectory = {
|
||||
...state.runtimeOrderByDirectory,
|
||||
[path]: order,
|
||||
};
|
||||
const selectedRuntimeId =
|
||||
state.selectedRuntimeId === id
|
||||
? (order[0] ?? Object.keys(runtimesById)[0])
|
||||
: state.selectedRuntimeId;
|
||||
return {
|
||||
...state,
|
||||
bridge: { ...state.bridge, latestSeq: event.seq },
|
||||
runtimesById,
|
||||
runtimeOrderByDirectory,
|
||||
selectedRuntimeId,
|
||||
selectedDirectoryPath: selectedRuntimeId
|
||||
? runtimesById[selectedRuntimeId]?.summary.worktreePath
|
||||
: path,
|
||||
};
|
||||
}
|
||||
const type = event.data?.eventType;
|
||||
const data = event.data?.eventData;
|
||||
const payload = data?.event ?? {};
|
||||
if (type === "agent_state" && data?.state) {
|
||||
view.summary = { ...view.summary, state: data.state };
|
||||
view.progress =
|
||||
data.state === "streaming"
|
||||
? {
|
||||
...view.progress,
|
||||
phase: "working",
|
||||
detail: "Thinking and preparing a response",
|
||||
}
|
||||
: data.state === "recovering"
|
||||
? {
|
||||
...view.progress,
|
||||
phase: "recovering",
|
||||
detail: "Recovering the Pi session",
|
||||
}
|
||||
: data.state === "error"
|
||||
? { ...view.progress, phase: "error", detail: "Pi needs attention" }
|
||||
: idleProgress;
|
||||
} else if (type === "tool") {
|
||||
const ending = String(payload.type ?? "").endsWith("_end");
|
||||
view.progress = {
|
||||
...view.progress,
|
||||
phase: "working",
|
||||
detail: ending ? "Continuing after tool result" : "Running a tool",
|
||||
tool: ending ? undefined : String(payload.toolName ?? "tool"),
|
||||
toolCount:
|
||||
view.progress.toolCount +
|
||||
(String(payload.type ?? "").endsWith("_start") ? 1 : 0),
|
||||
};
|
||||
} else if (type === "queue") {
|
||||
const queueCount = Array.isArray(payload.followUp)
|
||||
? payload.followUp.length
|
||||
: view.summary.queueCount;
|
||||
view.summary = { ...view.summary, queueCount };
|
||||
view.progress = {
|
||||
...view.progress,
|
||||
queueCount,
|
||||
detail: queueCount
|
||||
? `${queueCount} follow-up${queueCount === 1 ? "" : "s"} queued`
|
||||
: view.progress.detail,
|
||||
};
|
||||
} else if (type === "extension_ui_request")
|
||||
extensionPresentation(view, payload as Extension);
|
||||
if (id !== state.selectedRuntimeId && meaningful(type, payload))
|
||||
view.unread = true;
|
||||
view.loadedThroughSeq = Math.max(view.loadedThroughSeq, event.seq);
|
||||
runtimesById[id] = view;
|
||||
return {
|
||||
...state,
|
||||
bridge: { ...state.bridge, latestSeq: event.seq, status: "ready" },
|
||||
runtimesById,
|
||||
};
|
||||
}
|
||||
|
||||
export type WorkspaceAction =
|
||||
| { type: "workspaceLoaded"; workspace: Workspace; persisted?: UiState }
|
||||
| { type: "hostEvent"; host: HostEvent }
|
||||
| { type: "snapshotRequested"; runtimeId: string; generation: number }
|
||||
| {
|
||||
type: "snapshotLoaded";
|
||||
runtimeId: string;
|
||||
generation: number;
|
||||
snapshot: RuntimeSnapshot;
|
||||
}
|
||||
| {
|
||||
type: "snapshotFailed";
|
||||
runtimeId: string;
|
||||
generation: number;
|
||||
error: string;
|
||||
}
|
||||
| { type: "selectRuntime"; runtimeId: string }
|
||||
| { type: "selectDirectory"; worktreePath: string }
|
||||
| { type: "draftChanged"; runtimeId: string; draft: string }
|
||||
| {
|
||||
type: "scrollChanged";
|
||||
runtimeId: string;
|
||||
atBottom: boolean;
|
||||
scrollTop?: number;
|
||||
}
|
||||
| { type: "markRead"; runtimeId: string }
|
||||
| { type: "setWindowActive"; active: boolean }
|
||||
| { type: "setScale"; scale: number }
|
||||
| { type: "setResourceWarning"; value: boolean }
|
||||
| { type: "dismissCollisionWarning" }
|
||||
| {
|
||||
type: "submissionAdded";
|
||||
runtimeId: string;
|
||||
submission: RuntimeView["pendingSubmissions"][number];
|
||||
}
|
||||
| { type: "submissionSent"; runtimeId: string; id: number }
|
||||
| { type: "submissionRemoved"; runtimeId: string; id: number };
|
||||
|
||||
export function workspaceReducer(
|
||||
state: WorkspaceState,
|
||||
action: WorkspaceAction,
|
||||
): WorkspaceState {
|
||||
if (action.type === "workspaceLoaded")
|
||||
return reconcileWorkspace(state, action.workspace, action.persisted);
|
||||
if (action.type === "hostEvent") {
|
||||
if (action.host.kind === "event")
|
||||
return applyWorkspaceEvent(state, action.host.event);
|
||||
if (action.host.kind === "resetRequired")
|
||||
return {
|
||||
...state,
|
||||
bridge: {
|
||||
...state.bridge,
|
||||
latestSeq: 0,
|
||||
status: "resyncing",
|
||||
error: action.host.reason,
|
||||
},
|
||||
};
|
||||
if (action.host.kind === "disconnected")
|
||||
return {
|
||||
...state,
|
||||
bridge: {
|
||||
...state.bridge,
|
||||
status: "error",
|
||||
error: action.host.message,
|
||||
},
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
bridge: {
|
||||
instanceId: action.host.bridgeInstanceId,
|
||||
latestSeq: Math.max(state.bridge.latestSeq, action.host.latestSeq),
|
||||
status: "ready",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (action.type === "selectDirectory")
|
||||
return {
|
||||
...state,
|
||||
selectedDirectoryPath: action.worktreePath,
|
||||
selectedRuntimeId:
|
||||
state.runtimeOrderByDirectory[action.worktreePath]?.[0],
|
||||
};
|
||||
if (action.type === "selectRuntime")
|
||||
return {
|
||||
...state,
|
||||
selectedRuntimeId: action.runtimeId,
|
||||
selectedDirectoryPath:
|
||||
state.runtimesById[action.runtimeId]?.summary.worktreePath,
|
||||
};
|
||||
if (action.type === "setWindowActive")
|
||||
return { ...state, windowActive: action.active };
|
||||
if (action.type === "setScale")
|
||||
return { ...state, interfaceScale: action.scale };
|
||||
if (action.type === "setResourceWarning")
|
||||
return { ...state, resourceWarning: action.value };
|
||||
if (action.type === "dismissCollisionWarning")
|
||||
return { ...state, dismissedCollisionWarning: true };
|
||||
const runtimeId = "runtimeId" in action ? action.runtimeId : undefined;
|
||||
if (!runtimeId || !state.runtimesById[runtimeId]) return state;
|
||||
const view = { ...state.runtimesById[runtimeId] };
|
||||
if (action.type === "snapshotRequested") {
|
||||
view.loading = true;
|
||||
view.error = undefined;
|
||||
view.requestGeneration = action.generation;
|
||||
} else if (action.type === "snapshotFailed") {
|
||||
if (view.requestGeneration !== action.generation) return state;
|
||||
view.loading = false;
|
||||
view.error = action.error;
|
||||
} else if (action.type === "snapshotLoaded") {
|
||||
if (
|
||||
view.requestGeneration !== action.generation ||
|
||||
action.snapshot.bridgeInstanceId !== state.bridge.instanceId ||
|
||||
action.snapshot.latestSeq < view.loadedThroughSeq
|
||||
)
|
||||
return state;
|
||||
view.loading = false;
|
||||
view.summary = action.snapshot.runtime;
|
||||
view.state = action.snapshot.state?.data ?? {};
|
||||
view.stats = action.snapshot.stats?.data ?? {};
|
||||
view.messages = action.snapshot.transcript?.data?.messages ?? [];
|
||||
const receivedUserMessages = view.messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map(messageText);
|
||||
view.pendingSubmissions = view.pendingSubmissions.filter(
|
||||
(pending) => !receivedUserMessages.includes(pending.text),
|
||||
);
|
||||
view.commands = action.snapshot.commands?.data?.commands ?? [];
|
||||
view.models = action.snapshot.models?.data?.models ?? [];
|
||||
view.extensions = action.snapshot.extensions ?? [];
|
||||
view.loadedThroughSeq = action.snapshot.latestSeq;
|
||||
view.progress = view.state.isStreaming
|
||||
? {
|
||||
phase: "working",
|
||||
detail: "Thinking and preparing a response",
|
||||
toolCount: 0,
|
||||
}
|
||||
: idleProgress;
|
||||
} else if (action.type === "draftChanged") view.draft = action.draft;
|
||||
else if (action.type === "scrollChanged")
|
||||
view.scroll = { atBottom: action.atBottom, scrollTop: action.scrollTop };
|
||||
else if (action.type === "markRead") {
|
||||
if (
|
||||
state.selectedRuntimeId === runtimeId &&
|
||||
state.windowActive &&
|
||||
view.scroll.atBottom
|
||||
)
|
||||
view.unread = false;
|
||||
} else if (action.type === "submissionAdded")
|
||||
view.pendingSubmissions = [...view.pendingSubmissions, action.submission];
|
||||
else if (action.type === "submissionSent")
|
||||
view.pendingSubmissions = view.pendingSubmissions.map((item) =>
|
||||
item.id === action.id ? { ...item, phase: "sent" } : item,
|
||||
);
|
||||
else if (action.type === "submissionRemoved")
|
||||
view.pendingSubmissions = view.pendingSubmissions.filter(
|
||||
(item) => item.id !== action.id,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
runtimesById: { ...state.runtimesById, [runtimeId]: view },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
export type RuntimeError = { code?: string; message?: string };
|
||||
|
||||
export type RuntimeSummary = {
|
||||
runtimeId: string;
|
||||
worktreePath: string;
|
||||
state: string;
|
||||
label: string;
|
||||
attention: boolean;
|
||||
queueCount: number;
|
||||
activeTool?: string;
|
||||
lastActivity: string;
|
||||
openedAt: string;
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
sessionPath?: string;
|
||||
error?: RuntimeError;
|
||||
};
|
||||
|
||||
export type DirectoryWorkspace = {
|
||||
worktreePath: string;
|
||||
isHome: boolean;
|
||||
openCount: number;
|
||||
workingCount: number;
|
||||
attentionCount: number;
|
||||
recoveringCount: number;
|
||||
errorCount: number;
|
||||
runtimes: RuntimeSummary[];
|
||||
};
|
||||
|
||||
export type Workspace = {
|
||||
bridgeInstanceId: string;
|
||||
latestSeq: number;
|
||||
directories: DirectoryWorkspace[];
|
||||
issue?: { code?: string; message?: string };
|
||||
};
|
||||
|
||||
export type WorkspaceSummary = {
|
||||
bridgeInstanceId: string;
|
||||
latestSeq: number;
|
||||
openCount: number;
|
||||
workingCount: number;
|
||||
attentionCount: number;
|
||||
recoveringCount: number;
|
||||
errorCount: number;
|
||||
directoryCount: number;
|
||||
resourceWarning: boolean;
|
||||
};
|
||||
|
||||
export type TodoTask = {
|
||||
id: number;
|
||||
subject: string;
|
||||
activeForm?: string;
|
||||
status: "pending" | "in_progress" | "completed" | "deleted";
|
||||
};
|
||||
export type Message = {
|
||||
role?: string;
|
||||
toolName?: string;
|
||||
details?: { tasks?: TodoTask[] };
|
||||
content?:
|
||||
| string
|
||||
| Array<{ type?: string; text?: string; thinking?: string; name?: string }>;
|
||||
};
|
||||
export type AgentState = {
|
||||
isStreaming?: boolean;
|
||||
thinkingLevel?: string;
|
||||
sessionName?: string;
|
||||
sessionId?: string;
|
||||
messageCount?: number;
|
||||
pendingMessageCount?: number;
|
||||
model?: {
|
||||
provider?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
contextWindow?: number;
|
||||
};
|
||||
};
|
||||
export type SessionStats = {
|
||||
tokens?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
total?: number;
|
||||
};
|
||||
contextUsage?: {
|
||||
tokens?: number | null;
|
||||
contextWindow?: number;
|
||||
percent?: number | null;
|
||||
};
|
||||
};
|
||||
export type Command = { name?: string; description?: string };
|
||||
export type Model = { provider?: string; id?: string; name?: string };
|
||||
export type Extension = {
|
||||
id?: string;
|
||||
method?: string;
|
||||
title?: string;
|
||||
message?: string;
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
initialValue?: string;
|
||||
text?: string;
|
||||
notifyType?: "info" | "warning" | "error";
|
||||
statusKey?: string;
|
||||
statusText?: string;
|
||||
widgetKey?: string;
|
||||
widgetLines?: string[];
|
||||
widgetPlacement?: "aboveEditor" | "belowEditor";
|
||||
};
|
||||
export type WorkProgress = {
|
||||
phase: "idle" | "working" | "recovering" | "error";
|
||||
detail: string;
|
||||
tool?: string;
|
||||
queueCount?: number;
|
||||
toolCount: number;
|
||||
};
|
||||
export type PendingSubmission = {
|
||||
id: number;
|
||||
text: string;
|
||||
userMessageCount: number;
|
||||
phase: "sending" | "sent";
|
||||
};
|
||||
export type RuntimeSnapshot = {
|
||||
bridgeInstanceId: string;
|
||||
latestSeq: number;
|
||||
runtime: RuntimeSummary;
|
||||
state?: { data?: AgentState };
|
||||
stats?: { data?: SessionStats };
|
||||
transcript?: { data?: { messages?: Message[] } };
|
||||
commands?: { data?: { commands?: Command[] } };
|
||||
models?: { data?: { models?: Model[] } };
|
||||
extensions: Extension[];
|
||||
};
|
||||
export type DirectorySession = {
|
||||
path: string;
|
||||
id: string;
|
||||
cwd: string;
|
||||
name?: string;
|
||||
modified: string;
|
||||
messageCount: number;
|
||||
firstMessage?: string;
|
||||
runtimeId?: string;
|
||||
};
|
||||
export type WorkspaceEvent = {
|
||||
bridgeInstanceId: string;
|
||||
seq: number;
|
||||
type?: string;
|
||||
agentId?: string;
|
||||
data?: {
|
||||
runtimeId?: string;
|
||||
worktreePath?: string;
|
||||
sessionId?: string;
|
||||
runtime?: RuntimeSummary;
|
||||
eventType?: string;
|
||||
eventData?: { state?: string; event?: Record<string, unknown> };
|
||||
recovery?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
export type HostEvent =
|
||||
| {
|
||||
kind: "connected";
|
||||
bridgeInstanceId: string;
|
||||
firstAvailableSeq: number;
|
||||
latestSeq: number;
|
||||
}
|
||||
| { kind: "event"; event: WorkspaceEvent }
|
||||
| {
|
||||
kind: "resetRequired";
|
||||
bridgeInstanceId: string;
|
||||
latestSeq: number;
|
||||
reason: string;
|
||||
}
|
||||
| { kind: "disconnected"; message: string; retryInMs: number };
|
||||
|
||||
export type RuntimeView = {
|
||||
summary: RuntimeSummary;
|
||||
state: AgentState;
|
||||
stats: SessionStats;
|
||||
messages: Message[];
|
||||
commands: Command[];
|
||||
models: Model[];
|
||||
extensions: Extension[];
|
||||
extensionStatuses: Record<string, string>;
|
||||
extensionWidgets: Record<
|
||||
string,
|
||||
{ lines: string[]; placement: "aboveEditor" | "belowEditor" }
|
||||
>;
|
||||
notifications: Extension[];
|
||||
progress: WorkProgress;
|
||||
pendingSubmissions: PendingSubmission[];
|
||||
draft: string;
|
||||
scroll: { atBottom: boolean; scrollTop?: number };
|
||||
unread: boolean;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
requestGeneration: number;
|
||||
loadedThroughSeq: number;
|
||||
};
|
||||
|
||||
export type WorkspaceState = {
|
||||
bridge: {
|
||||
instanceId?: string;
|
||||
latestSeq: number;
|
||||
status: "connecting" | "ready" | "resyncing" | "error";
|
||||
error?: string;
|
||||
};
|
||||
directoriesByPath: Record<string, DirectoryWorkspace>;
|
||||
directoryOrder: string[];
|
||||
runtimesById: Record<string, RuntimeView>;
|
||||
runtimeOrderByDirectory: Record<string, string[]>;
|
||||
selectedDirectoryPath?: string;
|
||||
selectedRuntimeId?: string;
|
||||
windowActive: boolean;
|
||||
resourceWarning: boolean;
|
||||
dismissedCollisionWarning: boolean;
|
||||
interfaceScale: number;
|
||||
};
|
||||
|
||||
export type UiState = {
|
||||
version: 1;
|
||||
directoryOrder: string[];
|
||||
runtimeOrderByDirectory: Record<string, string[]>;
|
||||
selectedRuntimeId?: string;
|
||||
drafts: Record<string, string>;
|
||||
scroll: Record<string, { atBottom: boolean; scrollTop?: number }>;
|
||||
lastSeen: Record<
|
||||
string,
|
||||
{ bridgeInstanceId: string; seq: number; lastActivity?: string }
|
||||
>;
|
||||
workspaceCursor?: { bridgeInstanceId: string; seq: number };
|
||||
interfaceScale?: number;
|
||||
dismissedCollisionWarning?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useCallback, useEffect, useReducer, useRef } from "react";
|
||||
import { initialWorkspaceState, workspaceReducer } from "./reducer";
|
||||
import { legacyPreferences, loadPresentation, presentationState, removeLegacyPreferences, savePresentation } from "./persistence";
|
||||
import type { HostEvent, RuntimeSnapshot, UiState, Workspace, WorkspaceSummary } from "./types";
|
||||
|
||||
export function useWorkspace() {
|
||||
const [state, dispatch] = useReducer(workspaceReducer, initialWorkspaceState);
|
||||
const stateRef = useRef(state); stateRef.current = state;
|
||||
const loadGeneration = useRef<Record<string, number>>({});
|
||||
const timers = useRef<Record<string, number>>({});
|
||||
const initialized = useRef(false);
|
||||
const legacyMigrationPending = useRef(false);
|
||||
const disposed = useRef(true);
|
||||
const refreshGeneration = useRef(0);
|
||||
|
||||
const loadSnapshot = useCallback(async (runtimeId: string) => {
|
||||
if (disposed.current) return;
|
||||
const generation = (loadGeneration.current[runtimeId] ?? 0) + 1;
|
||||
loadGeneration.current[runtimeId] = generation;
|
||||
dispatch({ type: "snapshotRequested", runtimeId, generation });
|
||||
try {
|
||||
const snapshot = await invoke<RuntimeSnapshot>("get_session_runtime_snapshot", { runtimeId });
|
||||
if (!disposed.current)
|
||||
dispatch({ type: "snapshotLoaded", runtimeId, generation, snapshot });
|
||||
} catch (error) {
|
||||
if (!disposed.current)
|
||||
dispatch({ type: "snapshotFailed", runtimeId, generation, error: String(error) });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleSnapshot = useCallback((runtimeId: string) => {
|
||||
if (timers.current[runtimeId]) return;
|
||||
timers.current[runtimeId] = window.setTimeout(() => {
|
||||
delete timers.current[runtimeId]; void loadSnapshot(runtimeId);
|
||||
}, 120);
|
||||
}, [loadSnapshot]);
|
||||
|
||||
const refresh = useCallback(async (initialUiState?: UiState, reloadAllSnapshots = false) => {
|
||||
const generation = ++refreshGeneration.current;
|
||||
const cancelled = () => disposed.current || generation !== refreshGeneration.current;
|
||||
const workspace = await invoke<Workspace>("get_workspace");
|
||||
if (cancelled()) return;
|
||||
let nextPersisted = initialUiState;
|
||||
if (nextPersisted) {
|
||||
const legacy = legacyPreferences(localStorage);
|
||||
const candidate = nextPersisted.selectedRuntimeId
|
||||
? undefined
|
||||
: workspace.directories.find((directory) => directory.worktreePath === legacy.path)?.runtimes[0];
|
||||
nextPersisted = {
|
||||
...nextPersisted,
|
||||
...(candidate ? { selectedRuntimeId: candidate.runtimeId } : {}),
|
||||
...(nextPersisted.interfaceScale === undefined && legacy.scale !== undefined
|
||||
? { interfaceScale: legacy.scale }
|
||||
: {}),
|
||||
};
|
||||
legacyMigrationPending.current = Boolean(
|
||||
(!initialUiState?.selectedRuntimeId && candidate) ||
|
||||
(initialUiState?.interfaceScale === undefined && legacy.scale !== undefined),
|
||||
);
|
||||
}
|
||||
dispatch({ type: "workspaceLoaded", workspace, persisted: nextPersisted });
|
||||
const summary = await invoke<WorkspaceSummary>("get_workspace_summary");
|
||||
if (cancelled()) return;
|
||||
dispatch({ type: "setResourceWarning", value: summary.resourceWarning });
|
||||
const selected = nextPersisted?.selectedRuntimeId && workspace.directories.some((directory) => directory.runtimes.some((runtime) => runtime.runtimeId === nextPersisted?.selectedRuntimeId))
|
||||
? nextPersisted.selectedRuntimeId : workspace.directories[0]?.runtimes[0]?.runtimeId;
|
||||
const runtimeIds = workspace.directories.flatMap((directory) => directory.runtimes.map((runtime) => runtime.runtimeId));
|
||||
for (const runtimeId of reloadAllSnapshots ? runtimeIds : selected ? [selected] : []) {
|
||||
if (cancelled()) return;
|
||||
void loadSnapshot(runtimeId);
|
||||
}
|
||||
if (cancelled()) return;
|
||||
await invoke("subscribe_workspace", { cursor: { bridgeInstanceId: workspace.bridgeInstanceId, seq: workspace.latestSeq } });
|
||||
if (cancelled()) await invoke("unsubscribe_workspace").catch(() => {});
|
||||
}, [loadSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
disposed.current = false;
|
||||
let stop: (() => void) | undefined;
|
||||
void listen<HostEvent>("workspace-bridge", (message) => {
|
||||
if (disposed.current) return;
|
||||
const host = message.payload;
|
||||
dispatch({ type: "hostEvent", host });
|
||||
if (host.kind === "resetRequired") void refresh(undefined, true);
|
||||
else if (host.kind === "event") {
|
||||
const runtimeId = host.event.data?.runtimeId;
|
||||
const eventType = host.event.type;
|
||||
const piType = String(host.event.data?.eventData?.event?.type ?? "");
|
||||
if (["runtime_opened", "runtime_closed", "runtime_recovered", "runtime_restore_failed"].includes(eventType ?? "")) {
|
||||
void refresh();
|
||||
} else if (runtimeId && (host.event.data?.eventType === "transcript" || piType === "agent_settled" || piType === "compaction_end")) {
|
||||
scheduleSnapshot(runtimeId);
|
||||
}
|
||||
}
|
||||
}).then((unlisten) => { if (disposed.current) unlisten(); else stop = unlisten; });
|
||||
void (async () => {
|
||||
try {
|
||||
const loaded = await loadPresentation();
|
||||
if (disposed.current) return;
|
||||
await refresh(loaded);
|
||||
if (disposed.current) return;
|
||||
initialized.current = true;
|
||||
} catch (error) {
|
||||
if (!disposed.current)
|
||||
dispatch({ type: "hostEvent", host: { kind: "disconnected", message: String(error), retryInMs: 0 } });
|
||||
}
|
||||
})();
|
||||
const appWindow = getCurrentWindow();
|
||||
const visibility = () => dispatch({ type: "setWindowActive", active: document.visibilityState === "visible" && document.hasFocus() });
|
||||
window.addEventListener("focus", visibility); window.addEventListener("blur", visibility); document.addEventListener("visibilitychange", visibility);
|
||||
return () => {
|
||||
disposed.current = true;
|
||||
refreshGeneration.current += 1;
|
||||
stop?.();
|
||||
void invoke("unsubscribe_workspace").catch(() => {});
|
||||
window.removeEventListener("focus", visibility);
|
||||
window.removeEventListener("blur", visibility);
|
||||
document.removeEventListener("visibilitychange", visibility);
|
||||
Object.values(timers.current).forEach(clearTimeout);
|
||||
void appWindow;
|
||||
};
|
||||
}, [refresh, scheduleSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized.current) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
void savePresentation(presentationState(state)).then(() => {
|
||||
if (legacyMigrationPending.current) {
|
||||
removeLegacyPreferences(localStorage);
|
||||
legacyMigrationPending.current = false;
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [state.directoryOrder, state.runtimeOrderByDirectory, state.selectedRuntimeId, state.interfaceScale, state.bridge.latestSeq, state.runtimesById]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = state.selectedRuntimeId; if (!id) return;
|
||||
if (!state.runtimesById[id]?.loading && !state.runtimesById[id]?.messages.length) void loadSnapshot(id);
|
||||
}, [state.selectedRuntimeId, loadSnapshot]);
|
||||
|
||||
return { state, dispatch, refresh, loadSnapshot, scheduleSnapshot };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
export default defineConfig({ plugins: [react()], test: { environment: "jsdom", setupFiles: ["./src/test-setup.ts"] } });
|
||||
Reference in New Issue
Block a user