chore: merge develop into work/companion

# Conflicts:
#	crates/tui/src/app.rs
This commit is contained in:
2026-08-21 07:18:07 +09:00
61 changed files with 7110 additions and 523 deletions
+125 -1
View File
@@ -1,4 +1,4 @@
use std::collections::VecDeque;
use std::collections::{HashMap, VecDeque};
use std::path::Path;
use std::time::{Duration, Instant};
@@ -292,6 +292,8 @@ pub struct App {
/// Selected Internal Worker transcript/task view. `None` is the parent (`main`)
/// view; the stable session identity survives projection reordering.
selected_internal_worker_session_id: Option<String>,
/// Terminal child-session fences, reset only by an authoritative snapshot.
removed_internal_workers: HashMap<String, u64>,
pub scroll: Scroll,
pub mode: Mode,
pub cache: FileCache,
@@ -371,6 +373,7 @@ impl App {
run_error_messages: Vec::new(),
internal_workers: Vec::new(),
selected_internal_worker_session_id: None,
removed_internal_workers: HashMap::new(),
scroll: Scroll::default(),
mode: Mode::Normal,
cache: FileCache::new(),
@@ -1410,10 +1413,16 @@ impl App {
revision,
event,
} => self.apply_internal_worker_event(worker, revision, *event),
Event::InternalWorkerRemoved { worker, revision } => {
self.remove_internal_worker(worker, revision)
}
Event::Status { status } => {
self.rewind_refresh_fence = false;
self.set_worker_status(status);
}
// Command telemetry is an operational Web Console surface. The
// TUI continues to render the final Bash ToolResult from history.
Event::Command { .. } => {}
Event::Completions { kind, entries } => {
// Apply only if the popup is still on the same
// (kind, prefix) the request was issued for; an
@@ -2112,6 +2121,7 @@ impl App {
if self.selected_internal_worker_index().is_none() {
self.selected_internal_worker_session_id = None;
}
self.removed_internal_workers.clear();
}
fn update_internal_worker_view_from_snapshot(
@@ -2175,6 +2185,12 @@ impl App {
revision: u64,
event: Event,
) {
if self
.removed_internal_workers
.contains_key(&worker.session_id)
{
return;
}
let index = self
.internal_workers
.iter()
@@ -2199,6 +2215,32 @@ impl App {
let _ = target.app.handle_worker_event(event);
}
fn remove_internal_worker(&mut self, worker: InternalWorkerRef, revision: u64) {
let session_id = worker.session_id;
let Some(index) = self
.internal_workers
.iter()
.position(|candidate| candidate.worker.session_id == session_id)
else {
if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) {
self.selected_internal_worker_session_id = None;
}
self.removed_internal_workers
.entry(session_id)
.and_modify(|current| *current = (*current).max(revision))
.or_insert(revision);
return;
};
if revision <= self.internal_workers[index].revision {
return;
}
self.internal_workers.remove(index);
if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) {
self.selected_internal_worker_session_id = None;
}
self.removed_internal_workers.insert(session_id, revision);
}
fn restore_snapshot(
&mut self,
entries: &[serde_json::Value],
@@ -3622,6 +3664,7 @@ mod completion_flow_tests {
finished: false,
},
],
commands: Vec::new(),
},
internal_workers: Vec::new(),
});
@@ -3876,6 +3919,87 @@ mod completion_flow_tests {
assert_eq!(app.task_pane_scroll, 3);
}
#[test]
fn terminal_internal_worker_removal_drops_descendants_and_fences_late_events() {
let mut app = App::new("parent".into());
let worker = InternalWorkerRef {
session_id: "child-session".into(),
name: "child".into(),
parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker,
};
let nested = InternalWorkerRef {
session_id: "grandchild-session".into(),
name: "grandchild".into(),
parent_session_id: Some("child-session".into()),
kind: protocol::InternalWorkerKind::SubWorker,
};
app.handle_worker_event(Event::InternalWorker {
worker: worker.clone(),
revision: 2,
event: Box::new(Event::InternalWorker {
worker: nested,
revision: 1,
event: Box::new(Event::TextDone {
text: "nested".into(),
}),
}),
});
assert_eq!(app.internal_workers.len(), 1);
assert_eq!(app.internal_workers[0].app.internal_workers.len(), 1);
app.cycle_worker_view();
assert_eq!(app.selected_worker_view().worker_name, "child");
app.handle_worker_event(Event::InternalWorkerRemoved {
worker: worker.clone(),
revision: 3,
});
app.handle_worker_event(Event::InternalWorker {
worker,
revision: 4,
event: Box::new(Event::TextDone {
text: "late".into(),
}),
});
assert!(app.internal_workers.is_empty());
assert_eq!(app.selected_worker_view().worker_name, "parent");
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
entries: Vec::new(),
status: WorkerStatus::Idle,
in_flight: Default::default(),
internal_workers: Vec::new(),
});
assert!(app.internal_workers.is_empty());
assert!(app.removed_internal_workers.is_empty());
}
#[test]
fn stale_internal_worker_removal_keeps_newer_projection() {
let mut app = App::new("parent".into());
let worker = InternalWorkerRef {
session_id: "child-session".into(),
name: "child".into(),
parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker,
};
app.handle_worker_event(Event::InternalWorker {
worker: worker.clone(),
revision: 4,
event: Box::new(Event::TextDone {
text: "current".into(),
}),
});
app.handle_worker_event(Event::InternalWorkerRemoved {
worker,
revision: 3,
});
assert_eq!(app.internal_workers.len(), 1);
assert_eq!(app.internal_workers[0].revision, 4);
}
#[test]
fn snapshot_authoritatively_replaces_internal_worker_views() {
let mut app = App::new("parent".into());