mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 01:49:49 -10:00
fix(tui): repair the UI views audit findings (ui-views lane bc872cd3, 98b1339b, fd54c004)
These are the ui-views lane's three WIP checkpoints, squashed with their authored dispositions. The old lane merge e977f624 was not carried; this applies only the lane's own changes onto the current integration. - U06-06: aplay/ffplay resolve only from fixed absolute prefixes, never PATH, with the exec bit checked; the macOS companion launcher is /usr/bin/open. - U05-01: a settled workflow run is never reopened by a late TaskStarted, and a late new row in a cancelled run is finalized Cancelled. - U05-02: a run_completed/task_completed without `status`, or still saying running/pending, fails closed instead of reading as success. - U05-04: delegate and fanout cards never let later envelopes change a terminal card or slot. - U05-05: a follow-up receipt is appended only to the focus on its named agent (or its fork target). - U05-m1/m2/m4/m6: phase routing destination hardening; a settled fanout reports its worst outcome; only bare Left/Right switch dock tabs; the fanout grid wraps within the render width. - U06-03: pet live batches are bounded by the owner's request byte limit as well as the 64-event count. - U06-m4 (partial): the export wait is aligned with the owner's work bound. - U09-03/05/m2/m3/m5/m6: fleet model drafts install only unchanged answers; extension tabs re-anchor selection by entity key; settings Tab/BackTab preview; automations and agent projections keep recorded errors and reasons; fleet list and wrapped detail views scroll by painted rows. - U09-01 (Refs #6555): fleet detail Save and rename refuse a source changed on disk since it was opened. - U08-09 (Refs #6555): the session picker lists the store off the UI thread and shows list errors, never an empty store. - A late Started for a recovery card names it without reopening it. Lane receipts, dated: an earlier broad run gave 763 passed / 1 failed (a completion-before-started recovery case). The fd54 correction then passed a 52/0 subset. No whole-set exact-head pass is claimed; this integration's qualification is the proof. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
1 parent
b42e145e46
commit
10be0bdb2f
19 files changed
+1384
-168
No files matched your search
@@ -2,9 +2,8 @@
|
||||
//! bell for a missing/unsupported WAV. Tests inject a sink and never call an OS
|
||||
//! player. A single in-flight WAV keeps repeated categories from stacking audio.
|
||||
use super::sound_policy::SoundCue;
|
||||
#[cfg(not(test))]
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AudioOutcome {
|
||||
@@ -30,6 +29,85 @@ pub fn emit_terminal(cue: &SoundCue, out: &mut dyn Write) -> AudioOutcome {
|
||||
|
||||
pub const WHALE_WAV: &[u8] = include_bytes!("../../assets/audio/codewhale-whale-call.wav");
|
||||
|
||||
/// Resolve an external audio player (`aplay`, the pet's `ffplay`) from fixed
|
||||
/// install prefixes. The ambient `PATH` is never consulted: an empty (`::`),
|
||||
/// relative (`.`) or repository-local entry would let a checked-out workspace
|
||||
/// plant a player that Codewhale then runs with the user's authority.
|
||||
///
|
||||
/// Known limitation: a player installed only outside these prefixes (a
|
||||
/// custom `~/bin`, a version manager shim) is refused and the caller reports
|
||||
/// audio as unavailable. That is deliberate — there is no `PATH` fallback.
|
||||
pub(crate) fn trusted_player(name: &str) -> io::Result<PathBuf> {
|
||||
trusted_player_in(name, &trusted_player_dirs())
|
||||
}
|
||||
|
||||
fn trusted_player_in(name: &str, dirs: &[PathBuf]) -> io::Result<PathBuf> {
|
||||
let file = if cfg!(windows) {
|
||||
format!("{name}.exe")
|
||||
} else {
|
||||
name.to_owned()
|
||||
};
|
||||
dirs.iter()
|
||||
.filter(|dir| dir.is_absolute())
|
||||
.map(|dir| dir.join(&file))
|
||||
.find(|candidate| is_executable_file(candidate))
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"audio player is not installed in a trusted system location",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn trusted_player_dirs() -> Vec<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
[
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
"/usr/local/bin",
|
||||
"/opt/homebrew/bin",
|
||||
"/home/linuxbrew/.linuxbrew/bin",
|
||||
"/run/current-system/sw/bin",
|
||||
]
|
||||
.into_iter()
|
||||
.map(PathBuf::from)
|
||||
.collect()
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Package-manager shim directories from known folders, never PATH.
|
||||
let mut found = Vec::new();
|
||||
if let Some(local) = dirs::data_local_dir() {
|
||||
found.push(local.join("Microsoft").join("WinGet").join("Links"));
|
||||
}
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
found.push(home.join("scoop").join("shims"));
|
||||
}
|
||||
if let Some(data) = std::env::var_os("ProgramData") {
|
||||
found.push(PathBuf::from(data).join("chocolatey").join("bin"));
|
||||
}
|
||||
found
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_executable_file(path: &Path) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
path.metadata()
|
||||
.is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
path.is_file()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub fn dispatch(cue: &SoundCue, out: &mut dyn Write) -> AudioOutcome {
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -140,9 +218,9 @@ fn play_file(path: &std::path::Path) -> io::Result<()> {
|
||||
#[cfg(all(not(test), any(target_os = "macos", target_os = "linux")))]
|
||||
fn play_file(path: &std::path::Path) -> io::Result<()> {
|
||||
#[cfg(target_os = "macos")]
|
||||
let player = "/usr/bin/afplay";
|
||||
let player = PathBuf::from("/usr/bin/afplay");
|
||||
#[cfg(target_os = "linux")]
|
||||
let player = "aplay";
|
||||
let player = trusted_player("aplay")?;
|
||||
let status = std::process::Command::new(player)
|
||||
.arg(path)
|
||||
.stdin(std::process::Stdio::null())
|
||||
@@ -219,6 +297,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_player_needs_an_executable_file_under_an_absolute_prefix() {
|
||||
assert!(trusted_player_dirs().iter().all(|dir| dir.is_absolute()));
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let name = "codewhale-test-player";
|
||||
let file = dir.path().join(if cfg!(windows) {
|
||||
format!("{name}.exe")
|
||||
} else {
|
||||
name.to_string()
|
||||
});
|
||||
let prefixes = [dir.path().to_path_buf()];
|
||||
assert!(trusted_player_in(name, &prefixes).is_err(), "absent");
|
||||
std::fs::write(&file, b"").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
assert!(
|
||||
trusted_player_in(name, &prefixes).is_err(),
|
||||
"a non-executable file is not a player"
|
||||
);
|
||||
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
assert_eq!(trusted_player_in(name, &prefixes).unwrap(), file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_audio_entry_is_silent_in_library_tests() {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -503,7 +503,18 @@ pub(crate) fn apply_follow_up_receipt(
|
||||
} else {
|
||||
crate::tui::app::StatusToastLevel::Warning
|
||||
};
|
||||
if let Some(focus) = app.agent_focus.as_mut() {
|
||||
// The receipt belongs to the agent it names (or the fork focus followed
|
||||
// above). A delayed receipt for another agent must not land in whichever
|
||||
// transcript happens to be focused now; the toast still reports it.
|
||||
let receipt_target = outcome
|
||||
.as_ref()
|
||||
.ok()
|
||||
.map(|receipt| receipt.target_agent_id.as_str());
|
||||
if let Some(focus) = app
|
||||
.agent_focus
|
||||
.as_mut()
|
||||
.filter(|focus| focus.is(agent_id) || receipt_target.is_some_and(|id| focus.is(id)))
|
||||
{
|
||||
focus.local_cells.push(HistoryCell::System {
|
||||
content: note.clone(),
|
||||
});
|
||||
@@ -1275,6 +1286,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// U05-05: a delayed follow-up receipt for another agent must not be
|
||||
/// appended to whichever transcript happens to be focused when it lands.
|
||||
#[test]
|
||||
fn delayed_follow_up_receipt_stays_out_of_another_agents_focus() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let mut app = test_app(tmp.path().to_path_buf());
|
||||
focus_agent(&mut app, "agent_b");
|
||||
let outcome = Ok(crate::tools::subagent::UserFollowUpOutcome {
|
||||
agent_id: "agent_a".to_string(),
|
||||
target_agent_id: "agent_a".to_string(),
|
||||
delivered: true,
|
||||
resumed: false,
|
||||
note: "delivered".to_string(),
|
||||
});
|
||||
apply_follow_up_receipt(&mut app, "agent_a", &outcome);
|
||||
let focus = app.agent_focus.as_ref().expect("focus kept");
|
||||
assert_eq!(focus.agent_id, "agent_b");
|
||||
assert!(
|
||||
focus.local_cells.is_empty(),
|
||||
"another agent's receipt leaked into this transcript: {:?}",
|
||||
focus.local_cells
|
||||
);
|
||||
assert!(app.status_message.is_some(), "the toast still reports it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_to_bottom_returns_the_focused_transcript_to_its_tail() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -46,6 +46,10 @@ impl Target {
|
||||
self.requested.elapsed() <= Duration::from_millis(500)
|
||||
}
|
||||
|
||||
/// `Ok` means the samples were valid and the stream is live, not that they
|
||||
/// were played: a full queue or a stale request drops them. Live audio is
|
||||
/// a bounded real-time sink — late samples never queue behind the world
|
||||
/// clock — so an overrun is a dropout, not a stream failure.
|
||||
pub fn send(&self, channels: [Vec<f32>; 2]) -> Result<(), ()> {
|
||||
let length = channels[0].len();
|
||||
if length > MAX_FRAMES
|
||||
@@ -104,31 +108,7 @@ impl Output {
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub fn start() -> io::Result<Self> {
|
||||
let mut command = Command::new("ffplay");
|
||||
command.args([
|
||||
"-nodisp",
|
||||
"-autoexit",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-probesize",
|
||||
"32",
|
||||
"-analyzeduration",
|
||||
"0",
|
||||
"-f",
|
||||
"f32le",
|
||||
"-sample_rate",
|
||||
"48000",
|
||||
"-ch_layout",
|
||||
"stereo",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
]);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
}
|
||||
Self::spawn(command)
|
||||
Self::spawn(player_command()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -194,6 +174,38 @@ impl Drop for Output {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pet's PCM player: `ffplay` from a fixed install prefix, never from the
|
||||
/// ambient `PATH` (see [`crate::notify::audio::trusted_player`]). A player
|
||||
/// that is not installed there fails the start and Watch reports audio as
|
||||
/// unavailable.
|
||||
fn player_command() -> io::Result<Command> {
|
||||
let mut command = Command::new(crate::notify::audio::trusted_player("ffplay")?);
|
||||
command.args([
|
||||
"-nodisp",
|
||||
"-autoexit",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-probesize",
|
||||
"32",
|
||||
"-analyzeduration",
|
||||
"0",
|
||||
"-f",
|
||||
"f32le",
|
||||
"-sample_rate",
|
||||
"48000",
|
||||
"-ch_layout",
|
||||
"stereo",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
]);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
}
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
fn play(command: &mut Command, rx: mpsc::Receiver<Packet>, control: &Control) -> io::Result<()> {
|
||||
if control.cancelled.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
@@ -354,6 +366,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// U06-06: the player is an absolute path from a trusted prefix or it is
|
||||
/// refused — never a bare name the ambient `PATH` resolves, where an empty
|
||||
/// or repository-local entry would run a workspace-planted `ffplay`.
|
||||
#[test]
|
||||
fn pet_player_is_never_resolved_through_path() {
|
||||
match player_command() {
|
||||
Ok(command) => assert!(
|
||||
std::path::Path::new(command.get_program()).is_absolute(),
|
||||
"player resolved through PATH: {:?}",
|
||||
command.get_program()
|
||||
),
|
||||
Err(error) => assert_eq!(error.kind(), io::ErrorKind::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_player_fails_without_opening_a_device_or_blocking_the_caller() {
|
||||
assert!(Output::start().is_err());
|
||||
|
||||
@@ -131,6 +131,40 @@ pub enum Notice {
|
||||
/// fetch failed and it is reconnecting. Only this marks the pet offline.
|
||||
Unreachable(String),
|
||||
}
|
||||
/// Producer events queued for the next `/v1/producer` post. The owner caps a
|
||||
/// batch at 64 events *and* its request body at [`owner::MAX_REQUEST_BYTES`];
|
||||
/// a batch that exceeds either is a coverage gap, never a post the owner must
|
||||
/// refuse (a 64-event batch of 16 KiB events is far over the body limit).
|
||||
#[derive(Default)]
|
||||
struct Batch {
|
||||
events: Vec<Value>,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl Batch {
|
||||
const MAX_EVENTS: usize = 64;
|
||||
/// Room left for the post's envelope (identity, epoch, source, sequence).
|
||||
const MAX_EVENT_BYTES: usize = owner::MAX_REQUEST_BYTES - 4 * 1024;
|
||||
|
||||
/// Queue one event. `Ok(false)` means it does not fit: the caller drops
|
||||
/// the batch and restarts the lease rather than posting a partial one.
|
||||
fn push(&mut self, text: &str) -> io::Result<bool> {
|
||||
// `+ 1` counts the separating comma in the posted JSON array.
|
||||
let bytes = self.bytes + text.len() + 1;
|
||||
if self.events.len() >= Self::MAX_EVENTS || bytes > Self::MAX_EVENT_BYTES {
|
||||
return Ok(false);
|
||||
}
|
||||
self.events.push(serde_json::from_str(text)?);
|
||||
self.bytes = bytes;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
self.events.clear();
|
||||
self.bytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Worker {
|
||||
pub tx: mpsc::SyncSender<Command>,
|
||||
pub latest: Arc<Mutex<Option<Presentation>>>,
|
||||
@@ -214,11 +248,17 @@ impl Client {
|
||||
}
|
||||
fn request(&self, path: &str, body: Option<&Value>) -> io::Result<Value> {
|
||||
let url = format!("http://127.0.0.1:{}{path}", self.descriptor.port);
|
||||
let request = if let Some(body) = body {
|
||||
let mut request = if let Some(body) = body {
|
||||
self.http.post(url).json(body)
|
||||
} else {
|
||||
self.http.get(url)
|
||||
};
|
||||
if path == "/v1/export" {
|
||||
// The owner serializes the recording on its world thread for up
|
||||
// to its own work bound; giving up sooner abandons an export the
|
||||
// owner still completes. Other calls keep the 2 s client bound.
|
||||
request = request.timeout(owner::WORK_REPLY_TIMEOUT + Duration::from_secs(1));
|
||||
}
|
||||
let response = request
|
||||
.bearer_auth(&self.descriptor.token)
|
||||
.send()
|
||||
@@ -262,7 +302,9 @@ impl Client {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use std::process::{Command as Process, Stdio};
|
||||
let mut process = Process::new("open");
|
||||
// The system launcher by absolute path: a bare `open` resolves
|
||||
// through `PATH`, where a workspace entry could shadow it.
|
||||
let mut process = Process::new("/usr/bin/open");
|
||||
if let Some(path) = std::env::var_os("CODEWHALE_PET_APP") {
|
||||
process.arg(path);
|
||||
} else {
|
||||
@@ -341,7 +383,7 @@ fn run(
|
||||
let mut changed = Instant::now();
|
||||
let mut fetched = Instant::now() - Duration::from_secs(1);
|
||||
let mut encoded = Instant::now() - Duration::from_secs(1);
|
||||
let mut events = Vec::new();
|
||||
let mut events = Batch::default();
|
||||
let mut producer_seq = None;
|
||||
let mut sequence = 0u64;
|
||||
let mut action: Option<Value> = None;
|
||||
@@ -357,9 +399,9 @@ fn run(
|
||||
match rx.recv_timeout(Duration::from_millis(2)) {
|
||||
Ok(command) => match command {
|
||||
Command::Observe(text) => {
|
||||
if events.len() < 64 {
|
||||
events.push(serde_json::from_str::<Value>(&text)?)
|
||||
} else {
|
||||
if !events.push(&text)? {
|
||||
// Overflow is a coverage gap, never a truncated batch:
|
||||
// restart the lease from sequence zero.
|
||||
events.clear();
|
||||
producer_seq = None;
|
||||
}
|
||||
@@ -489,7 +531,7 @@ fn run(
|
||||
if seq == 0 {
|
||||
events.clear();
|
||||
}
|
||||
let body = json!({"identity":s.identity,"epoch":s.epoch,"client":id,"source":s.source,"source_revision":s.source_revision,"seq":seq,"waiting":view.waiting,"events":events});
|
||||
let body = json!({"identity":s.identity,"epoch":s.epoch,"client":id,"source":s.source,"source_revision":s.source_revision,"seq":seq,"waiting":view.waiting,"events":events.events});
|
||||
producer_seq = client.post("/v1/producer", &body).ok().map(|_| seq);
|
||||
events.clear();
|
||||
last_produce = Instant::now();
|
||||
@@ -556,3 +598,31 @@ fn run(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// U06-03: a producer batch is bounded by the owner's request body limit,
|
||||
/// not only by its event count, so the worker never posts a body the
|
||||
/// owner must refuse (64 events of 16 KiB each is ~1 MiB).
|
||||
#[test]
|
||||
fn producer_batch_fits_the_owner_body_limit() {
|
||||
let mut batch = Batch::default();
|
||||
let large = format!("{{\"event\":\"x\",\"id\":\"{}\"}}", "a".repeat(16_000));
|
||||
let mut accepted = 0;
|
||||
while batch.push(&large).unwrap() {
|
||||
accepted += 1;
|
||||
}
|
||||
assert!(accepted > 0 && accepted < Batch::MAX_EVENTS, "{accepted}");
|
||||
let posted = serde_json::to_vec(&json!({"seq": u64::MAX, "events": batch.events})).unwrap();
|
||||
assert!(posted.len() <= owner::MAX_REQUEST_BYTES, "{}", posted.len());
|
||||
|
||||
batch.clear();
|
||||
let small = "{\"event\":\"tool_call_heartbeat\"}";
|
||||
for _ in 0..Batch::MAX_EVENTS {
|
||||
assert!(batch.push(small).unwrap());
|
||||
}
|
||||
assert!(!batch.push(small).unwrap(), "the event count still bounds");
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ use crate::fleet::files::{WorkspaceFile, same_file};
|
||||
|
||||
const LEASE: Duration = Duration::from_secs(2);
|
||||
const MAX_CLIENTS: usize = 4096;
|
||||
/// Request body limit for every owner route. Clients size batches to it.
|
||||
pub(super) const MAX_REQUEST_BYTES: usize = 64 * 1024;
|
||||
/// How long a route waits for the world thread's reply before answering 503.
|
||||
pub(super) const WORK_REPLY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -190,7 +194,7 @@ async fn submit(
|
||||
if state.tx.try_send(make(tx)).is_err() {
|
||||
return StatusCode::SERVICE_UNAVAILABLE.into_response();
|
||||
}
|
||||
match tokio::time::timeout(Duration::from_secs(5), rx).await {
|
||||
match tokio::time::timeout(WORK_REPLY_TIMEOUT, rx).await {
|
||||
Ok(Ok(result)) => answer(result),
|
||||
_ => StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||
}
|
||||
@@ -374,7 +378,7 @@ pub fn serve(root: PathBuf, requested_port: u16) -> anyhow::Result<()> {
|
||||
.route("/v1/frame", get(frame)).route("/v1/action", post(action))
|
||||
.route("/v1/producer", post(producer)).route("/v1/audio", post(audio))
|
||||
.route("/v1/export", get(export)).route("/v1/attach", post(attach))
|
||||
.layer(DefaultBodyLimit::max(64 * 1024))
|
||||
.layer(DefaultBodyLimit::max(MAX_REQUEST_BYTES))
|
||||
.layer(axum::middleware::from_fn(|request: axum::extract::Request, next: axum::middleware::Next| async move {
|
||||
let mut response = next.run(request).await;
|
||||
for (key, value) in [("cache-control", "no-store"), ("referrer-policy", "no-referrer"), ("x-content-type-options", "nosniff"), ("content-security-policy", "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' blob:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")] {
|
||||
|
||||
@@ -64,6 +64,10 @@ impl PreviewCache {
|
||||
self.entries.back().map(|(_, lines)| lines)
|
||||
}
|
||||
|
||||
fn remove(&mut self, id: &str) {
|
||||
self.entries.retain(|(key, _)| key != id);
|
||||
}
|
||||
|
||||
fn insert(&mut self, id: String, lines: Vec<String>) {
|
||||
self.entries.retain(|(key, _)| *key != id);
|
||||
self.entries.push_back((id, lines));
|
||||
@@ -93,9 +97,30 @@ struct PendingPreview {
|
||||
cell: Arc<Mutex<Option<PreviewLoad>>>,
|
||||
}
|
||||
|
||||
/// The session-store listing running off the event loop (U08-09). The
|
||||
/// store's metadata scan grows with every saved session, so the picker opens
|
||||
/// at once and fills in when the listing lands.
|
||||
struct PendingSessionList {
|
||||
cell: Arc<Mutex<Option<Result<Vec<SessionMetadata>, String>>>>,
|
||||
/// Row to land on once the list arrives (`new_selecting`).
|
||||
select: Option<String>,
|
||||
}
|
||||
|
||||
/// What the list pane says in place of rows it does not have yet.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum ListNotice<'a> {
|
||||
Loading,
|
||||
Failed(&'a str),
|
||||
}
|
||||
|
||||
pub struct SessionPickerView {
|
||||
/// Every session loaded from disk. The picker filters from this set.
|
||||
sessions: Vec<SessionMetadata>,
|
||||
/// The store listing still in flight, if any.
|
||||
pending_list: Option<PendingSessionList>,
|
||||
/// The store could not be listed. Shown instead of "no saved sessions",
|
||||
/// which would claim an empty store nobody observed.
|
||||
load_error: Option<String>,
|
||||
filtered: Vec<SessionMetadata>,
|
||||
selected: usize,
|
||||
list_scroll: Cell<usize>,
|
||||
@@ -148,10 +173,67 @@ impl SessionPickerView {
|
||||
/// other workspaces are hidden by default — press `a` inside the
|
||||
/// picker to expand to all workspaces (#1395).
|
||||
pub fn new(workspace: &Path, locale: Locale) -> Self {
|
||||
let sessions = SessionManager::default_location()
|
||||
.and_then(|manager| manager.list_sessions())
|
||||
.unwrap_or_default();
|
||||
Self::from_session_list(workspace, locale, sessions)
|
||||
Self::load(workspace, locale, None)
|
||||
}
|
||||
|
||||
/// Open over the session store. Listing it is blocking disk I/O that
|
||||
/// grows with the store, so inside a runtime it runs on the blocking pool
|
||||
/// and lands through `tick`; outside one (unit tests, headless callers)
|
||||
/// there is no loop to stall, so it loads inline.
|
||||
fn load(workspace: &Path, locale: Locale, select: Option<String>) -> Self {
|
||||
let mut view = Self::from_session_list(workspace, locale, Vec::new());
|
||||
if tokio::runtime::Handle::try_current().is_err() {
|
||||
view.apply_session_list(list_session_store(), select);
|
||||
return view;
|
||||
}
|
||||
let cell = Arc::new(Mutex::new(None));
|
||||
let slot = Arc::clone(&cell);
|
||||
crate::utils::spawn_blocking_supervised("session-picker-list", move || {
|
||||
let listed = list_session_store();
|
||||
if let Ok(mut guard) = slot.lock() {
|
||||
*guard = Some(listed);
|
||||
}
|
||||
});
|
||||
view.pending_list = Some(PendingSessionList { cell, select });
|
||||
view
|
||||
}
|
||||
|
||||
/// Install a finished store listing. A failure is shown as a failure —
|
||||
/// never as an empty store — and the list stays empty.
|
||||
fn apply_session_list(
|
||||
&mut self,
|
||||
listed: Result<Vec<SessionMetadata>, String>,
|
||||
select: Option<String>,
|
||||
) {
|
||||
match listed {
|
||||
Ok(sessions) => {
|
||||
self.sessions = sessions;
|
||||
self.load_error = None;
|
||||
self.apply_sort_and_filter();
|
||||
if let Some(session_id) = select {
|
||||
self.select_with_fallback(&session_id);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.sessions.clear();
|
||||
self.load_error =
|
||||
Some(tr(self.locale, MessageId::SessionsOpenFailed).replace("{error}", &error));
|
||||
self.apply_sort_and_filter();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the store listing once it lands. Returns whether the view changed.
|
||||
fn poll_session_list(&mut self) -> bool {
|
||||
let Some(pending) = self.pending_list.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let Some(listed) = pending.cell.lock().ok().and_then(|mut guard| guard.take()) else {
|
||||
return false;
|
||||
};
|
||||
let select = self.pending_list.take().and_then(|pending| pending.select);
|
||||
self.apply_session_list(listed, select);
|
||||
true
|
||||
}
|
||||
|
||||
/// Construct a picker scoped to `workspace` over an explicit session list.
|
||||
@@ -177,6 +259,8 @@ impl SessionPickerView {
|
||||
fn from_session_list(workspace: &Path, locale: Locale, sessions: Vec<SessionMetadata>) -> Self {
|
||||
let mut view = Self {
|
||||
sessions,
|
||||
pending_list: None,
|
||||
load_error: None,
|
||||
filtered: Vec::new(),
|
||||
selected: 0,
|
||||
list_scroll: Cell::new(0),
|
||||
@@ -218,9 +302,7 @@ impl SessionPickerView {
|
||||
/// anything, so widening the view cannot cross a workspace boundary
|
||||
/// behind the user's back.
|
||||
pub fn new_selecting(workspace: &Path, locale: Locale, session_id: &str) -> Self {
|
||||
let mut view = Self::new(workspace, locale);
|
||||
view.select_with_fallback(session_id);
|
||||
view
|
||||
Self::load(workspace, locale, Some(session_id.to_string()))
|
||||
}
|
||||
|
||||
/// Land the selection on `session_id`, widening one browse filter at a
|
||||
@@ -486,6 +568,16 @@ impl SessionPickerView {
|
||||
);
|
||||
}
|
||||
|
||||
/// What the list pane shows in place of rows it has not got: the listing
|
||||
/// is still in flight, or the store could not be listed.
|
||||
fn list_notice(&self) -> Option<ListNotice<'_>> {
|
||||
if self.pending_list.is_some() {
|
||||
Some(ListNotice::Loading)
|
||||
} else {
|
||||
self.load_error.as_deref().map(ListNotice::Failed)
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_label(&self) -> String {
|
||||
match self.sort_mode {
|
||||
SessionSortMode::Recent => tr(self.locale, MessageId::SessionsSortRecent),
|
||||
@@ -649,10 +741,12 @@ impl SessionPickerView {
|
||||
);
|
||||
return ViewAction::None;
|
||||
}
|
||||
// Update our local metadata cache.
|
||||
// Update our local metadata cache. The cached preview renders the old
|
||||
// title, so it is dropped rather than shown again (U08-09).
|
||||
if let Some(meta) = self.sessions.iter_mut().find(|s| s.id == session.id) {
|
||||
meta.title = new_title.to_string();
|
||||
}
|
||||
self.preview_cache.remove(&session.id);
|
||||
self.apply_sort_and_filter();
|
||||
self.refresh_preview();
|
||||
self.status =
|
||||
@@ -749,6 +843,14 @@ impl SessionPickerView {
|
||||
}
|
||||
}
|
||||
|
||||
/// List the saved-session store. Blocking; runs on the blocking pool when a
|
||||
/// runtime is available.
|
||||
fn list_session_store() -> Result<Vec<SessionMetadata>, String> {
|
||||
SessionManager::default_location()
|
||||
.and_then(|manager| manager.list_sessions())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Read one saved session and render its preview. Blocking; runs on the
|
||||
/// blocking pool when a runtime is available.
|
||||
fn load_preview(session_id: &str, locale: Locale) -> PreviewLoad {
|
||||
@@ -791,7 +893,8 @@ impl ModalView for SessionPickerView {
|
||||
}
|
||||
|
||||
fn tick(&mut self) -> ViewAction {
|
||||
if self.poll_preview() {
|
||||
let listed = self.poll_session_list();
|
||||
if self.poll_preview() || listed {
|
||||
ViewAction::Redraw
|
||||
} else {
|
||||
ViewAction::None
|
||||
@@ -1041,6 +1144,7 @@ impl ModalView for SessionPickerView {
|
||||
self.rename_mode,
|
||||
&self.rename_input,
|
||||
self.status.as_deref(),
|
||||
self.list_notice(),
|
||||
self.current_session_id.as_deref(),
|
||||
self.locale,
|
||||
);
|
||||
@@ -1116,6 +1220,7 @@ impl ModalView for SessionPickerView {
|
||||
self.rename_mode,
|
||||
&self.rename_input,
|
||||
self.status.as_deref(),
|
||||
self.list_notice(),
|
||||
self.current_session_id.as_deref(),
|
||||
self.locale,
|
||||
);
|
||||
@@ -1174,6 +1279,7 @@ fn build_list_lines(
|
||||
rename_mode: bool,
|
||||
rename_input: &str,
|
||||
status: Option<&str>,
|
||||
notice: Option<ListNotice<'_>>,
|
||||
current_session_id: Option<&str>,
|
||||
locale: Locale,
|
||||
) -> Vec<Line<'static>> {
|
||||
@@ -1208,6 +1314,23 @@ fn build_list_lines(
|
||||
}
|
||||
|
||||
if sessions.is_empty() {
|
||||
match notice {
|
||||
Some(ListNotice::Loading) => {
|
||||
lines.push(Line::from(Span::styled(
|
||||
"\u{2026}",
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
return lines;
|
||||
}
|
||||
Some(ListNotice::Failed(error)) => {
|
||||
lines.push(Line::from(Span::styled(
|
||||
truncate(error, width),
|
||||
Style::default().fg(palette::STATUS_ERROR),
|
||||
)));
|
||||
return lines;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
lines.push(Line::from(Span::styled(
|
||||
tr(locale, MessageId::SessionsEmptyTitle),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
@@ -1616,6 +1739,8 @@ mod tests {
|
||||
let workspace_scope = scope.map(PathBuf::from);
|
||||
let mut view = SessionPickerView {
|
||||
sessions: sessions.clone(),
|
||||
pending_list: None,
|
||||
load_error: None,
|
||||
filtered: sessions,
|
||||
selected: 0,
|
||||
list_scroll: Cell::new(0),
|
||||
@@ -1710,8 +1835,24 @@ mod tests {
|
||||
saved.metadata.title = "Before".to_string();
|
||||
manager.save_session(&saved).expect("save session");
|
||||
let mut view = picker_with(vec![saved.metadata.clone()], None);
|
||||
assert!(
|
||||
view.current_preview
|
||||
.iter()
|
||||
.any(|line| line == "Title: Before"),
|
||||
"{:?}",
|
||||
view.current_preview
|
||||
);
|
||||
|
||||
let action = view.rename_selected("After");
|
||||
// U08-09: the cached preview rendered the old title; it must not be
|
||||
// shown again after the rename.
|
||||
assert!(
|
||||
view.current_preview
|
||||
.iter()
|
||||
.any(|line| line == "Title: After"),
|
||||
"{:?}",
|
||||
view.current_preview
|
||||
);
|
||||
|
||||
let ViewAction::Emit(ViewEvent::SessionRenamed { metadata }) = action else {
|
||||
panic!("expected SessionRenamed event");
|
||||
@@ -1899,6 +2040,7 @@ mod tests {
|
||||
false,
|
||||
"",
|
||||
None,
|
||||
None,
|
||||
Some("session-01"),
|
||||
Locale::En,
|
||||
);
|
||||
@@ -2171,6 +2313,7 @@ mod tests {
|
||||
"",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Locale::En,
|
||||
);
|
||||
|
||||
@@ -2203,6 +2346,7 @@ mod tests {
|
||||
"",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Locale::En,
|
||||
);
|
||||
|
||||
@@ -2409,6 +2553,7 @@ mod tests {
|
||||
"",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Locale::En,
|
||||
);
|
||||
|
||||
@@ -2443,6 +2588,7 @@ mod tests {
|
||||
"",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Locale::En,
|
||||
);
|
||||
|
||||
@@ -2476,6 +2622,7 @@ mod tests {
|
||||
"",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Locale::En,
|
||||
);
|
||||
|
||||
@@ -2608,6 +2755,8 @@ mod tests {
|
||||
|
||||
let mut view = SessionPickerView {
|
||||
sessions: sessions.clone(),
|
||||
pending_list: None,
|
||||
load_error: None,
|
||||
filtered: sessions,
|
||||
selected: 0,
|
||||
list_scroll: Cell::new(0),
|
||||
@@ -2848,4 +2997,46 @@ mod tests {
|
||||
"an idle tick after the preview landed must not keep repainting"
|
||||
);
|
||||
}
|
||||
|
||||
/// U08-09: a session store that cannot be listed is reported as such,
|
||||
/// never painted as "No saved sessions yet".
|
||||
#[test]
|
||||
fn unreadable_session_store_is_reported_not_shown_empty() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
// A regular file where the store directory belongs cannot be listed.
|
||||
std::fs::write(tmp.path().join("sessions"), b"not a directory").expect("file");
|
||||
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
|
||||
let view = SessionPickerView::new(tmp.path(), Locale::En);
|
||||
assert!(view.load_error.is_some(), "the listing failure was dropped");
|
||||
let lines = build_list_lines(
|
||||
&view.filtered,
|
||||
0,
|
||||
120,
|
||||
0,
|
||||
5,
|
||||
false,
|
||||
"",
|
||||
"recent",
|
||||
false,
|
||||
false,
|
||||
"",
|
||||
None,
|
||||
view.list_notice(),
|
||||
None,
|
||||
Locale::En,
|
||||
);
|
||||
let text = lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(text.contains("Could not open sessions"), "{text}");
|
||||
assert!(!text.contains("No saved sessions yet"), "{text}");
|
||||
}
|
||||
}
|
||||
@@ -205,20 +205,25 @@ impl SettingsPickerController {
|
||||
self.recompute_filter(keep.as_deref());
|
||||
}
|
||||
|
||||
pub fn next_tab(&mut self) {
|
||||
/// Switch to the next tab. Previews the row it lands on only when that
|
||||
/// row is available — a disabled row is shown, never previewed.
|
||||
pub fn next_tab(&mut self) -> PickerNavResult {
|
||||
if self.tabs.is_empty() {
|
||||
return;
|
||||
return PickerNavResult::None;
|
||||
}
|
||||
let next = (self.active_tab + 1) % self.tabs.len();
|
||||
self.set_active_tab(next);
|
||||
self.preview_if_available()
|
||||
}
|
||||
|
||||
pub fn prev_tab(&mut self) {
|
||||
/// Switch to the previous tab; same preview rule as [`Self::next_tab`].
|
||||
pub fn prev_tab(&mut self) -> PickerNavResult {
|
||||
if self.tabs.is_empty() {
|
||||
return;
|
||||
return PickerNavResult::None;
|
||||
}
|
||||
let prev = (self.active_tab + self.tabs.len() - 1) % self.tabs.len();
|
||||
self.set_active_tab(prev);
|
||||
self.preview_if_available()
|
||||
}
|
||||
|
||||
/// Select by source index when that option is currently visible.
|
||||
|
||||
@@ -56,18 +56,11 @@ pub fn handle_nav_key(
|
||||
match key.code {
|
||||
KeyCode::Esc => controller.request_cancel(),
|
||||
KeyCode::Enter => controller.request_commit(),
|
||||
KeyCode::BackTab => {
|
||||
controller.prev_tab();
|
||||
PickerNavResult::Preview
|
||||
}
|
||||
KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
|
||||
controller.prev_tab();
|
||||
PickerNavResult::Preview
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
controller.next_tab();
|
||||
PickerNavResult::Preview
|
||||
}
|
||||
// A tab switch lands on whatever row the new tab keeps selected; it
|
||||
// previews that row only when it is available, like ↑/↓ do.
|
||||
KeyCode::BackTab => controller.prev_tab(),
|
||||
KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => controller.prev_tab(),
|
||||
KeyCode::Tab => controller.next_tab(),
|
||||
KeyCode::Up | KeyCode::Char('k')
|
||||
if !key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !key.modifiers.contains(KeyModifiers::ALT) =>
|
||||
@@ -336,8 +329,26 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(result, PickerNavResult::Preview);
|
||||
// U09-m2: the last ("extra") tab lands on its disabled first row,
|
||||
// which must not preview.
|
||||
assert_eq!(controller.active_tab(), controller.tabs().len() - 1);
|
||||
assert_eq!(controller.selected_id(), Some("locked"));
|
||||
assert_eq!(result, PickerNavResult::None);
|
||||
|
||||
assert_eq!(controller.move_down(), PickerNavResult::Preview);
|
||||
assert_eq!(controller.selected_id(), Some("dracula"));
|
||||
let result = handle_nav_key(
|
||||
&mut controller,
|
||||
KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE),
|
||||
false,
|
||||
);
|
||||
assert_eq!(controller.active_tab_name(), "core");
|
||||
assert_eq!(controller.selected_id(), Some("terminal"));
|
||||
assert_eq!(
|
||||
result,
|
||||
PickerNavResult::Preview,
|
||||
"an available row previews"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -653,7 +653,7 @@ fn deliver_fleet_draft_result(
|
||||
let installed = boxed
|
||||
.as_any_mut()
|
||||
.downcast_mut::<crate::tui::views::fleet_setup::FleetSetupView>()
|
||||
.map(|wizard| {
|
||||
.and_then(|wizard| {
|
||||
wizard.install_model_draft(
|
||||
draft,
|
||||
model_label.clone(),
|
||||
|
||||
@@ -54,6 +54,9 @@ pub(crate) struct AutomationRow {
|
||||
pub(crate) record: AutomationRecord,
|
||||
/// Newest first.
|
||||
pub(crate) runs: Vec<AutomationRunRecord>,
|
||||
/// The run history could not be read. Painted instead of "no recorded
|
||||
/// runs", which would claim an empty history nobody observed.
|
||||
pub(crate) runs_error: Option<String>,
|
||||
}
|
||||
|
||||
impl AutomationRow {
|
||||
@@ -73,6 +76,9 @@ pub struct AutomationsView {
|
||||
row: usize,
|
||||
detail_open: bool,
|
||||
detail_scroll: usize,
|
||||
/// Largest useful detail scroll in rendered (wrapped) rows, from the last
|
||||
/// paint; ↓ stops there instead of piling up invisible scroll.
|
||||
detail_max_scroll: Cell<usize>,
|
||||
locale: Locale,
|
||||
manager: Option<SharedAutomationManager>,
|
||||
/// Refreshes ride the modal tick, not every frame.
|
||||
@@ -99,6 +105,7 @@ impl AutomationsView {
|
||||
row: 0,
|
||||
detail_open: false,
|
||||
detail_scroll: 0,
|
||||
detail_max_scroll: Cell::new(usize::MAX),
|
||||
locale: app.ui_locale,
|
||||
manager: app.runtime_services.automations.clone(),
|
||||
last_refresh_at: Instant::now(),
|
||||
@@ -128,6 +135,7 @@ impl AutomationsView {
|
||||
row: 0,
|
||||
detail_open: false,
|
||||
detail_scroll: 0,
|
||||
detail_max_scroll: Cell::new(usize::MAX),
|
||||
locale,
|
||||
manager: None,
|
||||
last_refresh_at: Instant::now(),
|
||||
@@ -237,10 +245,15 @@ impl AutomationsView {
|
||||
self.rows = records
|
||||
.into_iter()
|
||||
.map(|record| {
|
||||
let runs = manager
|
||||
.list_runs(&record.id, Some(RECENT_RUNS))
|
||||
.unwrap_or_default();
|
||||
AutomationRow { record, runs }
|
||||
let (runs, runs_error) = match manager.list_runs(&record.id, Some(RECENT_RUNS)) {
|
||||
Ok(runs) => (runs, None),
|
||||
Err(error) => (Vec::new(), Some(error.to_string())),
|
||||
};
|
||||
AutomationRow {
|
||||
record,
|
||||
runs,
|
||||
runs_error,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.problem = None;
|
||||
@@ -546,7 +559,16 @@ impl AutomationsView {
|
||||
format!(" {}", tr(locale, MessageId::AutomationRecentRunsLabel)),
|
||||
Style::default().fg(palette::TEXT_PRIMARY).bold(),
|
||||
)));
|
||||
if row.runs.is_empty() {
|
||||
if let Some(error) = row.runs_error.as_deref() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(
|
||||
" {} · {}",
|
||||
tr(locale, MessageId::AutomationRunsUnavailable),
|
||||
display_text(error)
|
||||
),
|
||||
Style::default().fg(palette::STATUS_ERROR),
|
||||
)));
|
||||
} else if row.runs.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}", tr(locale, MessageId::AutomationNoRuns)),
|
||||
Style::default().fg(palette::TEXT_DIM),
|
||||
@@ -593,11 +615,12 @@ impl AutomationsView {
|
||||
return;
|
||||
};
|
||||
let lines = self.detail_lines(row);
|
||||
let visible = usize::from(area.height).max(1);
|
||||
let scroll = self.detail_scroll.min(lines.len().saturating_sub(visible));
|
||||
Paragraph::new(lines.into_iter().skip(scroll).collect::<Vec<_>>())
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
self.detail_max_scroll.set(super::render_wrapped_detail(
|
||||
lines,
|
||||
area,
|
||||
buf,
|
||||
self.detail_scroll,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -668,7 +691,10 @@ impl ModalView for AutomationsView {
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if self.detail_open {
|
||||
self.detail_scroll = self.detail_scroll.saturating_add(1);
|
||||
self.detail_scroll = self
|
||||
.detail_scroll
|
||||
.saturating_add(1)
|
||||
.min(self.detail_max_scroll.get());
|
||||
} else {
|
||||
self.move_row(1);
|
||||
}
|
||||
@@ -883,10 +909,12 @@ mod tests {
|
||||
AutomationRow {
|
||||
record: record("vision", AutomationStatus::Active),
|
||||
runs: vec![run("vision", AutomationRunStatus::Running)],
|
||||
runs_error: None,
|
||||
},
|
||||
AutomationRow {
|
||||
record: record("infra", AutomationStatus::Paused),
|
||||
runs: Vec::new(),
|
||||
runs_error: None,
|
||||
},
|
||||
],
|
||||
Locale::En,
|
||||
@@ -1093,4 +1121,60 @@ mod tests {
|
||||
assert!(text.contains("p pause"), "{text}");
|
||||
assert!(text.contains("follow you into every repository"), "{text}");
|
||||
}
|
||||
|
||||
/// U09-m3: a run history that could not be read is reported as such,
|
||||
/// never painted as "no recorded runs".
|
||||
#[test]
|
||||
fn unreadable_run_history_is_not_shown_as_empty() {
|
||||
let view = AutomationsView::from_rows(
|
||||
vec![AutomationRow {
|
||||
record: record("vision", AutomationStatus::Active),
|
||||
runs: Vec::new(),
|
||||
runs_error: Some("run store unreadable".to_string()),
|
||||
}],
|
||||
Locale::En,
|
||||
);
|
||||
let text = view
|
||||
.detail_lines(&view.rows[0])
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(text.contains("runs unavailable"), "{text}");
|
||||
assert!(text.contains("run store unreadable"), "{text}");
|
||||
assert!(!text.contains("no recorded runs"), "{text}");
|
||||
}
|
||||
|
||||
/// U09-m6: detail scroll counts wrapped rows, so a long prompt's tail is
|
||||
/// reachable and ↓ stops at the real end instead of piling up scroll.
|
||||
#[test]
|
||||
fn detail_scroll_reaches_the_wrapped_tail() {
|
||||
let mut long = record("long", AutomationStatus::Active);
|
||||
long.prompt = format!("{}TAILMARK", "word ".repeat(80));
|
||||
let mut view = AutomationsView::from_rows(
|
||||
vec![AutomationRow {
|
||||
record: long,
|
||||
runs: Vec::new(),
|
||||
runs_error: None,
|
||||
}],
|
||||
Locale::En,
|
||||
);
|
||||
view.detail_open = true;
|
||||
let area = Rect::new(0, 0, 30, 8);
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.render_detail(area, &mut buf);
|
||||
for _ in 0..200 {
|
||||
view.handle_key(key(KeyCode::Down));
|
||||
}
|
||||
assert_eq!(view.detail_scroll, view.detail_max_scroll.get());
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.render_detail(area, &mut buf);
|
||||
let text = rendered_text(area, &buf);
|
||||
assert!(text.contains("TAILMARK"), "{text}");
|
||||
}
|
||||
}
|
||||
@@ -1572,6 +1572,26 @@ enum VisibleEntry<'a> {
|
||||
Empty,
|
||||
}
|
||||
|
||||
/// Stable identity of a visible row across snapshot refreshes. An item is
|
||||
/// keyed by its own id alone: a refresh may regroup it (MCP login-first).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum EntryKey {
|
||||
Group(String),
|
||||
Item(String),
|
||||
Problem,
|
||||
}
|
||||
|
||||
impl EntryKey {
|
||||
fn of(entry: VisibleEntry<'_>) -> Option<Self> {
|
||||
match entry {
|
||||
VisibleEntry::Group(group) => Some(Self::Group(group.id.clone())),
|
||||
VisibleEntry::Item(_, item) => Some(Self::Item(item.id.clone())),
|
||||
VisibleEntry::Problem(_) => Some(Self::Problem),
|
||||
VisibleEntry::Empty => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct HitAreas {
|
||||
tabs: Vec<(Rect, ExtensionsTab)>,
|
||||
@@ -1893,11 +1913,33 @@ impl ExtensionsView {
|
||||
|
||||
/// Swap in a fresh read model while keeping everything the user is doing:
|
||||
/// active tab, focus, search query, selection, scroll, and folded groups
|
||||
/// all survive; the selection only moves when the refreshed list no
|
||||
/// longer reaches it.
|
||||
/// all survive. The selection follows its entity, not its row number: a
|
||||
/// refresh that reorders the list (a server logs in, a plugin is
|
||||
/// installed) keeps the same item selected, so a later `e`/`d` can never
|
||||
/// act on whatever row slid into the old index. It only moves when the
|
||||
/// selected entity is gone.
|
||||
pub fn refresh_snapshot(&mut self, snapshot: ExtensionsSnapshot) {
|
||||
let active = self.active_tab;
|
||||
let anchors = ExtensionsTab::ALL.map(|tab| {
|
||||
self.active_tab = tab;
|
||||
self.visible_entries()
|
||||
.get(self.selected[tab.index()])
|
||||
.copied()
|
||||
.and_then(EntryKey::of)
|
||||
});
|
||||
self.snapshot = snapshot;
|
||||
self.clamp_selection();
|
||||
for (tab, anchor) in ExtensionsTab::ALL.into_iter().zip(anchors) {
|
||||
self.active_tab = tab;
|
||||
if let Some(index) = anchor.and_then(|anchor| {
|
||||
self.visible_entries()
|
||||
.iter()
|
||||
.position(|entry| EntryKey::of(*entry).as_ref() == Some(&anchor))
|
||||
}) {
|
||||
self.selected[tab.index()] = index;
|
||||
}
|
||||
self.clamp_selection();
|
||||
}
|
||||
self.active_tab = active;
|
||||
}
|
||||
|
||||
fn set_tab(&mut self, tab: ExtensionsTab) {
|
||||
@@ -3324,4 +3366,67 @@ mod tests {
|
||||
other => panic!("expected the refreshed item, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// U09-05: a refresh that reorders the list keeps the selection on the
|
||||
/// same entity, so a later toggle acts on the row the user chose — never
|
||||
/// on whatever slid into the old index.
|
||||
#[test]
|
||||
fn refresh_keeps_selection_on_the_same_entity_when_rows_reorder() {
|
||||
fn row(id: &str) -> ExtensionItem {
|
||||
ExtensionItem {
|
||||
id: id.into(),
|
||||
label: id.into(),
|
||||
toggle: Some(ExtensionAction::Command {
|
||||
label: "disable".into(),
|
||||
command: format!("/plugin disable {id}"),
|
||||
disposition: RowActionDisposition::InPlace,
|
||||
}),
|
||||
..item_with_action(ExtensionAction::Status {
|
||||
label: "enabled".into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
fn snapshot(order: &[&str]) -> ExtensionsSnapshot {
|
||||
let mut snapshot = ExtensionsSnapshot::default();
|
||||
snapshot.tabs[ExtensionsTab::Plugins.index()] = ExtensionsTabModel {
|
||||
groups: vec![ExtensionGroup {
|
||||
id: "g".into(),
|
||||
label: "g".into(),
|
||||
items: order.iter().map(|id| row(id)).collect(),
|
||||
}],
|
||||
problem: None,
|
||||
};
|
||||
snapshot
|
||||
}
|
||||
let mut view = ExtensionsView::from_snapshot_with_locale(
|
||||
snapshot(&["alpha", "beta"]),
|
||||
ExtensionsTab::Plugins,
|
||||
Locale::En,
|
||||
);
|
||||
// Group heading, alpha, beta: select beta.
|
||||
view.selected[ExtensionsTab::Plugins.index()] = 2;
|
||||
assert_eq!(
|
||||
view.selected_item().map(|item| item.id.as_str()),
|
||||
Some("beta")
|
||||
);
|
||||
|
||||
view.refresh_snapshot(snapshot(&["beta", "alpha"]));
|
||||
assert_eq!(
|
||||
view.selected_item().map(|item| item.id.as_str()),
|
||||
Some("beta")
|
||||
);
|
||||
match view.toggle_selected() {
|
||||
ViewAction::Emit(ViewEvent::ExecutePanelCommand { command, .. }) => {
|
||||
assert_eq!(command, "/plugin disable beta");
|
||||
}
|
||||
other => panic!("expected the toggle command, got {other:?}"),
|
||||
}
|
||||
|
||||
// A selected entity that is gone falls back to the clamped row.
|
||||
view.refresh_snapshot(snapshot(&["alpha"]));
|
||||
assert_eq!(
|
||||
view.selected_item().map(|item| item.id.as_str()),
|
||||
Some("alpha")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -249,6 +249,32 @@ impl FleetDetailView {
|
||||
.and_then(|idx| self.fleet.members.get(idx))
|
||||
}
|
||||
|
||||
/// Whether the file on disk is still exactly what this editor loaded (or
|
||||
/// last wrote). Every write from the editor checks it first, so a save
|
||||
/// never replaces a newer version written by another session or by hand,
|
||||
/// and never recreates a file that was removed meanwhile.
|
||||
///
|
||||
/// Known limitation: this is check-then-write, not a lock; a write that
|
||||
/// lands between the check and the atomic rename is not detected.
|
||||
fn source_unchanged(&self) -> bool {
|
||||
self.saved_source.is_some()
|
||||
&& std::fs::read_to_string(&self.source).ok() == self.saved_source
|
||||
}
|
||||
|
||||
/// The pager shown when the source moved underneath the editor.
|
||||
fn stale_source_action(&self, title: &str) -> ViewAction {
|
||||
ViewAction::Emit(ViewEvent::OpenTextPager {
|
||||
title: title.to_string(),
|
||||
content: format!(
|
||||
"Nothing was written.\n\n{} changed on disk after this editor opened it \
|
||||
(another session or an external edit). Saving now would overwrite \
|
||||
that newer version.\n\nClose this editor and reopen the team to load \
|
||||
the current file, then make the change again.",
|
||||
self.source.display()
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn start_rename(&mut self) {
|
||||
self.rename_mode = true;
|
||||
self.rename_input = self.fleet.name.clone();
|
||||
@@ -264,6 +290,10 @@ impl FleetDetailView {
|
||||
self.rename_mode = false;
|
||||
return Some(ViewAction::None);
|
||||
}
|
||||
if !self.source_unchanged() {
|
||||
self.rename_mode = false;
|
||||
return Some(self.stale_source_action("Rename refused"));
|
||||
}
|
||||
// The rename must not collide with a different Fleet of the same slug
|
||||
// in this scope (the store refuses that at save).
|
||||
let old_name = self.fleet.name.clone();
|
||||
@@ -479,10 +509,7 @@ impl FleetDetailView {
|
||||
) -> Result<String, String> {
|
||||
// A picker belongs to one editor instance and the exact saved file
|
||||
// it opened. Never recreate a removed file or overwrite newer edits.
|
||||
if editor_id != self.editor_id
|
||||
|| self.saved_source.is_none()
|
||||
|| std::fs::read_to_string(&self.source).ok() != self.saved_source
|
||||
{
|
||||
if editor_id != self.editor_id || !self.source_unchanged() {
|
||||
return Err(tr(self.locale, MessageId::FleetRoutePickUnavailable).into_owned());
|
||||
}
|
||||
if let FleetRouteTarget::Member(idx) = target
|
||||
@@ -643,6 +670,9 @@ impl FleetDetailView {
|
||||
}
|
||||
|
||||
fn save(&self) -> Option<ViewAction> {
|
||||
if !self.source_unchanged() {
|
||||
return Some(self.stale_source_action("Save refused"));
|
||||
}
|
||||
match save_fleet(&self.fleet, self.scope, &self.workspace) {
|
||||
Ok(path) => Some(ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged {
|
||||
message: format!(
|
||||
@@ -2120,4 +2150,58 @@ mod tests {
|
||||
view.handle_key(key(KeyCode::Char('y')));
|
||||
assert_eq!(view.fleet.members, fleet.members);
|
||||
}
|
||||
|
||||
/// U09-01: an ordinary Save or rename never overwrites a Fleet file that
|
||||
/// changed on disk after the editor opened it; the newer version survives
|
||||
/// and the editor says to reload.
|
||||
#[test]
|
||||
fn save_and_rename_refuse_an_externally_newer_source() {
|
||||
let ws = tempfile::TempDir::new().unwrap();
|
||||
let fleet = sample_fleet("Fleet S");
|
||||
let path = save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
|
||||
let mut view = FleetDetailView::open(
|
||||
&app_in(ws.path().to_path_buf()),
|
||||
&Config::default(),
|
||||
"Fleet S",
|
||||
FleetScope::Workspace,
|
||||
)
|
||||
.expect("open");
|
||||
|
||||
// Another session saves a newer version of the same team.
|
||||
let mut newer = fleet.clone();
|
||||
newer.operator = Some(FleetOperator {
|
||||
provider: "openai".to_string(),
|
||||
model: "gpt-5.6".to_string(),
|
||||
reasoning: None,
|
||||
});
|
||||
save_fleet(&newer, FleetScope::Workspace, ws.path()).unwrap();
|
||||
let on_disk = std::fs::read(&path).unwrap();
|
||||
|
||||
view.fleet.operator = Some(FleetOperator {
|
||||
provider: "deepseek".to_string(),
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
reasoning: None,
|
||||
});
|
||||
let action = view.handle_key(key(KeyCode::Char('s')));
|
||||
assert!(
|
||||
matches!(&action, ViewAction::Emit(ViewEvent::OpenTextPager { title, .. }) if title == "Save refused"),
|
||||
"{action:?}"
|
||||
);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), on_disk);
|
||||
|
||||
view.handle_key(key(KeyCode::Char('r')));
|
||||
for _ in "Fleet S".chars() {
|
||||
view.handle_key(key(KeyCode::Backspace));
|
||||
}
|
||||
for ch in "Fleet T".chars() {
|
||||
view.handle_key(key(KeyCode::Char(ch)));
|
||||
}
|
||||
let action = view.handle_key(key(KeyCode::Enter));
|
||||
assert!(
|
||||
matches!(&action, ViewAction::Emit(ViewEvent::OpenTextPager { title, .. }) if title == "Rename refused"),
|
||||
"{action:?}"
|
||||
);
|
||||
assert_eq!(view.fleet.name, "Fleet S");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), on_disk);
|
||||
}
|
||||
}
|
||||
@@ -453,14 +453,30 @@ impl FleetListView {
|
||||
return;
|
||||
}
|
||||
|
||||
let rows_visible = usize::from(area.height).max(1);
|
||||
let scroll = self.row.saturating_sub(rows_visible.saturating_sub(1));
|
||||
// Entries paint two rows (name + summary), one while a delete is
|
||||
// armed. Scroll and fit by painted rows, not entry count, so the
|
||||
// selected entry is always fully on screen.
|
||||
let height = usize::from(area.height).max(1);
|
||||
let entry_height = |idx: usize| {
|
||||
if self.pending_delete == Some(idx) {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
};
|
||||
let row = self.row.min(self.entries.len() - 1);
|
||||
let mut scroll = row;
|
||||
let mut used = entry_height(row);
|
||||
while scroll > 0 && used + entry_height(scroll - 1) <= height {
|
||||
scroll -= 1;
|
||||
used += entry_height(scroll);
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let mut hitboxes = Vec::new();
|
||||
for (idx, entry) in self.entries.iter().enumerate() {
|
||||
if idx < scroll || idx >= scroll + rows_visible {
|
||||
continue;
|
||||
for (idx, entry) in self.entries.iter().enumerate().skip(scroll) {
|
||||
if !lines.is_empty() && lines.len() + entry_height(idx) > height {
|
||||
break;
|
||||
}
|
||||
let selected = idx == self.row;
|
||||
let is_selected_fleet = self
|
||||
@@ -1081,4 +1097,37 @@ provider = "deepseek"
|
||||
"hovered entry must show the shared hover band"
|
||||
);
|
||||
}
|
||||
|
||||
/// U09-m5: entries paint two rows (name + summary). Scrolling by entry
|
||||
/// count pushed the selected bottom entry below the list; every painted
|
||||
/// entry, the selected one included, must fit inside the list area.
|
||||
#[test]
|
||||
fn selected_entry_stays_on_screen_when_entries_are_two_rows() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let home = tempfile::TempDir::new().unwrap();
|
||||
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
|
||||
let ws = tempfile::TempDir::new().unwrap();
|
||||
for name in ["Alpha", "Bravo", "Charlie", "Delta", "Echo"] {
|
||||
save_in(ws.path(), FleetScope::Workspace, name);
|
||||
}
|
||||
let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
|
||||
assert_eq!(view.entries.len(), 5);
|
||||
let area = Rect::new(0, 0, 80, 4);
|
||||
for row in 0..view.entries.len() {
|
||||
view.row = row;
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.render_rows(area, &mut buf);
|
||||
let hitboxes = view.row_hitboxes.borrow();
|
||||
assert!(
|
||||
hitboxes.iter().any(|(_, idx)| *idx == row),
|
||||
"row {row} not painted: {hitboxes:?}"
|
||||
);
|
||||
assert!(
|
||||
hitboxes
|
||||
.iter()
|
||||
.all(|(rect, _)| rect.bottom() <= area.bottom()),
|
||||
"row {row}: {hitboxes:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -719,6 +719,14 @@ struct RouteAssignment {
|
||||
reasoning: Option<String>,
|
||||
}
|
||||
|
||||
/// The wizard answers a model draft was requested against (U09-03).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ModelDraftRequest {
|
||||
role: String,
|
||||
route: Option<(String, String)>,
|
||||
reasoning_effort: Option<String>,
|
||||
}
|
||||
|
||||
fn assignment_source(path: &Path) -> Result<Option<String>, String> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => Ok(Some(text)),
|
||||
@@ -765,6 +773,11 @@ pub struct FleetSetupView {
|
||||
/// pressing Esc. Keeping the preview and the save control in the same
|
||||
/// view means the footer's `g`/Enter hints are never a lie.
|
||||
model_draft_preview: Option<String>,
|
||||
/// The answers the in-flight model draft (`m` on Review) was requested
|
||||
/// against. Cleared with the draft whenever an answer changes, so a
|
||||
/// draft that lands late is refused instead of being installed onto
|
||||
/// answers it was never written for.
|
||||
model_draft_request: Option<ModelDraftRequest>,
|
||||
/// Model-step rows: `inherit` followed by one row per concrete model from
|
||||
/// every configured provider (#4093).
|
||||
model_choices: Vec<Choice>,
|
||||
@@ -812,6 +825,7 @@ impl FleetSetupView {
|
||||
let old_review_scroll = self.review_scroll;
|
||||
let old_model_draft = self.model_draft.clone();
|
||||
let old_model_draft_preview = self.model_draft_preview.clone();
|
||||
let old_model_draft_request = self.model_draft_request.clone();
|
||||
|
||||
*self = Self::from_snapshot(snapshot);
|
||||
|
||||
@@ -828,6 +842,7 @@ impl FleetSetupView {
|
||||
self.review_scroll = old_review_scroll;
|
||||
self.model_draft = old_model_draft;
|
||||
self.model_draft_preview = old_model_draft_preview;
|
||||
self.model_draft_request = old_model_draft_request;
|
||||
if self.step == Step::Composition && !self.has_composition_for_selected_role() {
|
||||
self.step = Step::Model;
|
||||
}
|
||||
@@ -1209,6 +1224,7 @@ impl FleetSetupView {
|
||||
review_scroll: 0,
|
||||
model_draft: None,
|
||||
model_draft_preview: None,
|
||||
model_draft_request: None,
|
||||
model_choices,
|
||||
model_routes,
|
||||
model_row_states,
|
||||
@@ -1224,21 +1240,34 @@ impl FleetSetupView {
|
||||
/// (returned here for the caller's status message) renders inline on the
|
||||
/// Review step — not in a separate pager — so the footer's `g`/Enter
|
||||
/// ratify hints stay true the instant the draft lands (#4093).
|
||||
///
|
||||
/// `None` refuses a draft that no longer matches the wizard: an answer
|
||||
/// changed (or the draft was discarded) while it was in flight. The
|
||||
/// request generation only orders requests; it cannot see an edit made
|
||||
/// after the last `m`, so the wizard checks its own answers (U09-03).
|
||||
pub fn install_model_draft(
|
||||
&mut self,
|
||||
mut draft: Box<crate::fleet::profile::FleetProfileDraft>,
|
||||
model_label: String,
|
||||
picked_route: Option<(String, String)>,
|
||||
reasoning_effort: Option<String>,
|
||||
) -> (String, String) {
|
||||
) -> Option<(String, String)> {
|
||||
let current = self.current_draft_request();
|
||||
if self.model_draft_request.as_ref() != Some(¤t)
|
||||
|| current.route != picked_route
|
||||
|| current.reasoning_effort != reasoning_effort
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.model_draft_request = None;
|
||||
// Re-inject the route the operator picked at `m`-press time (#4093). A
|
||||
// model draft comes from `from_untrusted_json`, which hard-sets
|
||||
// `provider: None` and echoes whatever `model` the model happened to
|
||||
// emit — so ratifying it verbatim would drop a concrete cross-provider
|
||||
// pick and persist the ambiguous, provider-scoped profile #4093 exists
|
||||
// to prevent. Pinning BOTH fields from the CARRIED route keeps the route
|
||||
// the user actually chose (the model only authored the prose), and is
|
||||
// immune to the selection changing while the async draft is in flight.
|
||||
// the user actually chose (the model only authored the prose); the
|
||||
// check above already refused a draft whose answers changed in flight.
|
||||
// `inherit` (a `None` route) leaves `model`/`provider` untouched,
|
||||
// matching the deterministic Enter path.
|
||||
if let Some((provider, model)) = picked_route {
|
||||
@@ -1261,7 +1290,7 @@ impl FleetSetupView {
|
||||
self.model_draft = Some(draft);
|
||||
self.model_draft_preview = Some(content.clone());
|
||||
self.review_scroll = 0;
|
||||
(title, content)
|
||||
Some((title, content))
|
||||
}
|
||||
|
||||
/// The planner role chosen (drives the profile file name and `role_hint`).
|
||||
@@ -1569,10 +1598,20 @@ impl FleetSetupView {
|
||||
}
|
||||
}
|
||||
|
||||
/// A draft is only valid for the answers it was requested against.
|
||||
/// A draft is only valid for the answers it was requested against —
|
||||
/// including one still in flight.
|
||||
fn discard_model_draft(&mut self) {
|
||||
self.model_draft = None;
|
||||
self.model_draft_preview = None;
|
||||
self.model_draft_request = None;
|
||||
}
|
||||
|
||||
fn current_draft_request(&self) -> ModelDraftRequest {
|
||||
ModelDraftRequest {
|
||||
role: self.selected_role(),
|
||||
route: self.selected_route(),
|
||||
reasoning_effort: self.selected_reasoning_effort(),
|
||||
}
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
@@ -2107,6 +2146,7 @@ impl ModalView for FleetSetupView {
|
||||
}
|
||||
KeyCode::Char('m') if self.step == Step::Review && self.snapshot.provider_ready => {
|
||||
let route = self.selected_route();
|
||||
self.model_draft_request = Some(self.current_draft_request());
|
||||
ViewAction::Emit(ViewEvent::FleetProfileModelDraftRequested {
|
||||
role: self.selected_role(),
|
||||
model: route
|
||||
@@ -2115,8 +2155,8 @@ impl ModalView for FleetSetupView {
|
||||
.unwrap_or_else(|| "inherit".to_string()),
|
||||
// Carry the picked provider so the redrafted profile keeps
|
||||
// the cross-provider route (#4093). `install_model_draft`
|
||||
// re-injects it authoritatively from the wizard's current
|
||||
// selection, but the event stays self-describing.
|
||||
// re-injects it and refuses a draft whose answers changed
|
||||
// while it was in flight; the event stays self-describing.
|
||||
provider: route.map(|(provider, _)| provider),
|
||||
reasoning_effort: self.selected_reasoning_effort(),
|
||||
locale: self.snapshot.locale,
|
||||
@@ -3858,7 +3898,7 @@ approval_required = true
|
||||
|
||||
// The host reconstructs the picked route from the event exactly as
|
||||
// `handle_fleet_profile_model_draft` does, and carries it to
|
||||
// `install_model_draft` (immune to the selection changing mid-draft).
|
||||
// `install_model_draft`, which refuses it if the answers changed.
|
||||
let picked_route = provider.map(|provider| (provider, model.clone()));
|
||||
|
||||
// The model returns a draft that (as always) has provider: None — the
|
||||
@@ -3869,12 +3909,14 @@ approval_required = true
|
||||
// Installing it re-injects the picked route, so the ratified draft keeps
|
||||
// BOTH the provider and the model the user actually chose, plus the
|
||||
// captured thinking tier.
|
||||
let (_title, content) = view.install_model_draft(
|
||||
drafted,
|
||||
"GLM-5.2".to_string(),
|
||||
picked_route,
|
||||
reasoning_effort,
|
||||
);
|
||||
let (_title, content) = view
|
||||
.install_model_draft(
|
||||
drafted,
|
||||
"GLM-5.2".to_string(),
|
||||
picked_route,
|
||||
reasoning_effort,
|
||||
)
|
||||
.expect("answers unchanged since `m`");
|
||||
let ratified = view.model_draft.as_deref().expect("draft installed");
|
||||
assert_eq!(ratified.provider.as_deref(), Some("zai"));
|
||||
assert_eq!(ratified.model.as_deref(), Some("glm-5.2"));
|
||||
@@ -4019,8 +4061,10 @@ approval_required = true
|
||||
|
||||
let mut view = FleetSetupView::from_snapshot(snapshot());
|
||||
to_review(&mut view);
|
||||
let (title, content) =
|
||||
view.install_model_draft(sample_draft(), "GLM-5.2".to_string(), None, None);
|
||||
view.handle_key(key(KeyCode::Char('m')));
|
||||
let (title, content) = view
|
||||
.install_model_draft(sample_draft(), "GLM-5.2".to_string(), None, None)
|
||||
.expect("answers unchanged since `m`");
|
||||
assert!(title.contains("GLM-5.2"));
|
||||
assert!(content.contains("id = \"reviewer\""), "{content}");
|
||||
assert!(content.contains("Nothing is saved until"), "{content}");
|
||||
@@ -4039,7 +4083,11 @@ approval_required = true
|
||||
fn changing_answers_discards_a_stale_draft() {
|
||||
let mut view = FleetSetupView::from_snapshot(snapshot());
|
||||
to_review(&mut view);
|
||||
let _ = view.install_model_draft(sample_draft(), "GLM-5.2".to_string(), None, None);
|
||||
view.handle_key(key(KeyCode::Char('m')));
|
||||
assert!(
|
||||
view.install_model_draft(sample_draft(), "GLM-5.2".to_string(), None, None)
|
||||
.is_some()
|
||||
);
|
||||
assert!(view.model_draft.is_some());
|
||||
|
||||
// Back to the role step and change the selection: the draft no
|
||||
@@ -4061,6 +4109,49 @@ approval_required = true
|
||||
assert_eq!(draft.id, "explore");
|
||||
}
|
||||
|
||||
/// U09-03: the request generation orders `m` presses but cannot see an
|
||||
/// answer changed after the last one. A draft that lands after the user
|
||||
/// changed the thinking tier (or role, or route) mid-flight is refused,
|
||||
/// never installed onto answers it was not written for.
|
||||
#[test]
|
||||
fn late_model_draft_is_refused_after_answers_change_in_flight() {
|
||||
let mut view = FleetSetupView::from_snapshot(snapshot());
|
||||
to_review(&mut view);
|
||||
let ViewAction::Emit(ViewEvent::FleetProfileModelDraftRequested {
|
||||
reasoning_effort, ..
|
||||
}) = view.handle_key(key(KeyCode::Char('m')))
|
||||
else {
|
||||
panic!("expected model draft request");
|
||||
};
|
||||
// While the draft is in flight the user cycles the thinking tier.
|
||||
view.handle_key(key(KeyCode::Char('t')));
|
||||
assert!(
|
||||
view.install_model_draft(
|
||||
sample_draft(),
|
||||
"GLM-5.2".to_string(),
|
||||
None,
|
||||
reasoning_effort.clone()
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(view.model_draft.is_none());
|
||||
let action = view.handle_key(key(KeyCode::Char('g')));
|
||||
let ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested { draft, .. }) =
|
||||
action
|
||||
else {
|
||||
panic!("expected the deterministic starter for the current answers");
|
||||
};
|
||||
assert_eq!(draft.id, "manager");
|
||||
|
||||
// A draft nobody requested (no `m`) is refused too.
|
||||
let mut view = FleetSetupView::from_snapshot(snapshot());
|
||||
to_review(&mut view);
|
||||
assert!(
|
||||
view.install_model_draft(sample_draft(), "GLM-5.2".to_string(), None, None)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_move_within_step_and_enter_advances() {
|
||||
let mut view = FleetSetupView::from_snapshot(snapshot());
|
||||
@@ -4603,15 +4694,20 @@ approval_required = true
|
||||
model: "model-a".to_string(),
|
||||
..snapshot()
|
||||
});
|
||||
let route = view
|
||||
let route_idx = view
|
||||
.model_routes
|
||||
.iter()
|
||||
.find(|(provider, model)| provider == "custom-b" && model == "model-b")
|
||||
.cloned()
|
||||
.position(|(provider, model)| provider == "custom-b" && model == "model-b")
|
||||
.expect("custom B route selectable while A is active");
|
||||
let route = view.model_routes[route_idx].clone();
|
||||
// Request the draft against the custom B answer, as `m` on Review does.
|
||||
view.model_idx = route_idx;
|
||||
view.step = Step::Review;
|
||||
view.handle_key(key(KeyCode::Char('m')));
|
||||
let draft = sample_draft();
|
||||
let (_, rendered) =
|
||||
view.install_model_draft(draft, "model-b".to_string(), Some(route), None);
|
||||
let (_, rendered) = view
|
||||
.install_model_draft(draft, "model-b".to_string(), Some(route), None)
|
||||
.expect("answers unchanged since `m`");
|
||||
assert!(rendered.contains("provider = \"custom-b\""), "{rendered}");
|
||||
}
|
||||
|
||||
|
||||
@@ -169,6 +169,24 @@ pub(crate) fn render_underwater_surface(
|
||||
inner
|
||||
}
|
||||
|
||||
/// Render wrapped detail text scrolled by *rendered* rows and return the
|
||||
/// largest useful scroll. Clamping by logical lines before wrapping left the
|
||||
/// wrapped tail unreachable and made each step jump a whole wrapped line.
|
||||
pub(crate) fn render_wrapped_detail(
|
||||
lines: Vec<Line<'static>>,
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
requested_scroll: usize,
|
||||
) -> usize {
|
||||
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
|
||||
let max_scroll = paragraph
|
||||
.line_count(area.width.max(1))
|
||||
.saturating_sub(usize::from(area.height).max(1));
|
||||
let scroll = u16::try_from(requested_scroll.min(max_scroll)).unwrap_or(u16::MAX);
|
||||
paragraph.scroll((scroll, 0)).render(area, buf);
|
||||
max_scroll
|
||||
}
|
||||
|
||||
/// Paint a scrollbar on the exact right edge of the panel it controls and
|
||||
/// return the content rect with that rail reserved. Nothing is drawn when all
|
||||
/// rows fit, so narrow surfaces do not spend a column on a fictional control.
|
||||
@@ -5754,7 +5772,7 @@ pub(crate) fn subagent_view_agents(
|
||||
agents.push(live_subagent_result(
|
||||
&card.agent_id,
|
||||
agent_type,
|
||||
lifecycle_to_subagent_status(card.status),
|
||||
lifecycle_to_subagent_status(card.status, card.summary.as_deref()),
|
||||
card.summary.as_deref().unwrap_or(card.agent_type.as_str()),
|
||||
Some("transcript"),
|
||||
None, // transcript-derived rows get nickname from manager on render
|
||||
@@ -5771,7 +5789,7 @@ pub(crate) fn subagent_view_agents(
|
||||
agents.push(live_subagent_result(
|
||||
&worker.agent_id,
|
||||
FleetRole::Worker,
|
||||
lifecycle_to_subagent_status(worker.status),
|
||||
lifecycle_to_subagent_status(worker.status, None),
|
||||
&objective,
|
||||
Some(card.kind.as_str()),
|
||||
None, // fanout worker rows get nickname from manager on render
|
||||
@@ -5814,14 +5832,27 @@ pub(crate) fn subagent_view_agents(
|
||||
agents
|
||||
}
|
||||
|
||||
fn lifecycle_to_subagent_status(status: AgentLifecycle) -> SubAgentStatus {
|
||||
/// Project a transcript card's lifecycle onto the manager status vocabulary.
|
||||
/// A failure or interruption keeps the reason the card recorded from its
|
||||
/// terminal envelope; only a card that recorded none gets the generic line.
|
||||
///
|
||||
/// Known limitation: `SubAgentStatus` has no pending state, so a spawned but
|
||||
/// not yet started worker reads as running here.
|
||||
fn lifecycle_to_subagent_status(status: AgentLifecycle, reason: Option<&str>) -> SubAgentStatus {
|
||||
let reason = |fallback: &str| {
|
||||
reason
|
||||
.map(str::trim)
|
||||
.filter(|reason| !reason.is_empty())
|
||||
.unwrap_or(fallback)
|
||||
.to_string()
|
||||
};
|
||||
match status {
|
||||
AgentLifecycle::Pending | AgentLifecycle::Running => SubAgentStatus::Running,
|
||||
AgentLifecycle::Completed => SubAgentStatus::Completed,
|
||||
AgentLifecycle::Failed => SubAgentStatus::Failed("failed in transcript".to_string()),
|
||||
AgentLifecycle::Failed => SubAgentStatus::Failed(reason("failed in transcript")),
|
||||
AgentLifecycle::Cancelled => SubAgentStatus::Cancelled,
|
||||
AgentLifecycle::Interrupted => {
|
||||
SubAgentStatus::Interrupted("interrupted in transcript".to_string())
|
||||
SubAgentStatus::Interrupted(reason("interrupted in transcript"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,11 +56,26 @@ fn status_style(status: &str) -> Style {
|
||||
}
|
||||
}
|
||||
|
||||
/// One glyph per projected child state. Only a real failure reads as `✗`:
|
||||
/// a cancellation is not a failure, and a state this view does not know
|
||||
/// must not borrow the failure mark (U09-m3).
|
||||
fn child_state_glyph(state: &str) -> &'static str {
|
||||
match state {
|
||||
"running" | "pending" => "•",
|
||||
"succeeded" => "✓",
|
||||
_ => "✗",
|
||||
"failed" | "budget_exceeded" => "✗",
|
||||
"cancelled" => "–",
|
||||
_ => "?",
|
||||
}
|
||||
}
|
||||
|
||||
/// A child with no completion receipt in a settled run did not finish as far
|
||||
/// as this record shows; it is not still running (U09-m3).
|
||||
fn child_display_state(child: &HostWorkflowChildRow, run_active: bool) -> &'static str {
|
||||
if !run_active && matches!(child.state, "running" | "pending") {
|
||||
"unknown"
|
||||
} else {
|
||||
child.state
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +95,8 @@ pub struct WorkflowsManagerView {
|
||||
row: usize,
|
||||
detail_open: bool,
|
||||
detail_scroll: usize,
|
||||
/// Largest useful detail scroll in rendered rows, from the last paint.
|
||||
detail_max_scroll: Cell<usize>,
|
||||
/// Receipt line for the last host action (cancel), shown under the header.
|
||||
status: Option<String>,
|
||||
workspace: PathBuf,
|
||||
@@ -99,6 +116,7 @@ impl WorkflowsManagerView {
|
||||
row: 0,
|
||||
detail_open: false,
|
||||
detail_scroll: 0,
|
||||
detail_max_scroll: Cell::new(usize::MAX),
|
||||
status: None,
|
||||
workspace: app.workspace.clone(),
|
||||
owner_session_id: app.current_session_id.clone(),
|
||||
@@ -334,7 +352,7 @@ impl WorkflowsManagerView {
|
||||
Style::default().fg(palette::TEXT_PRIMARY).bold(),
|
||||
)));
|
||||
for child in &detail.children {
|
||||
lines.push(child_row_line(child));
|
||||
lines.push(child_row_line(child, detail.line.active));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
@@ -377,24 +395,25 @@ impl WorkflowsManagerView {
|
||||
return;
|
||||
};
|
||||
let lines = self.detail_lines(detail);
|
||||
let visible = usize::from(area.height).max(1);
|
||||
let max_scroll = lines.len().saturating_sub(visible);
|
||||
let scroll = self.detail_scroll.min(max_scroll);
|
||||
Paragraph::new(lines.iter().skip(scroll).cloned().collect::<Vec<_>>())
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
self.detail_max_scroll.set(super::render_wrapped_detail(
|
||||
lines,
|
||||
area,
|
||||
buf,
|
||||
self.detail_scroll,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn child_row_line(child: &HostWorkflowChildRow) -> Line<'static> {
|
||||
fn child_row_line(child: &HostWorkflowChildRow, run_active: bool) -> Line<'static> {
|
||||
let name = child.label.clone().unwrap_or_else(|| child.task_id.clone());
|
||||
let state = child_display_state(child, run_active);
|
||||
let mut spans = vec![
|
||||
Span::styled(
|
||||
format!(" {} ", child_state_glyph(child.state)),
|
||||
status_style(child.state),
|
||||
format!(" {} ", child_state_glyph(state)),
|
||||
status_style(state),
|
||||
),
|
||||
Span::styled(name, Style::default().fg(palette::TEXT_SECONDARY)),
|
||||
Span::styled(format!(" · {}", child.state), status_style(child.state)),
|
||||
Span::styled(format!(" · {state}"), status_style(state)),
|
||||
];
|
||||
let mut meta = Vec::new();
|
||||
if let Some(role) = child.role.as_deref() {
|
||||
@@ -446,8 +465,11 @@ impl ModalView for WorkflowsManagerView {
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if self.detail_open {
|
||||
// The render path clamps to the last scrollable line.
|
||||
self.detail_scroll = self.detail_scroll.saturating_add(1);
|
||||
// Stop at the last rendered (wrapped) row of the detail.
|
||||
self.detail_scroll = self
|
||||
.detail_scroll
|
||||
.saturating_add(1)
|
||||
.min(self.detail_max_scroll.get());
|
||||
} else {
|
||||
self.move_row(1);
|
||||
}
|
||||
@@ -551,3 +573,33 @@ impl ModalView for WorkflowsManagerView {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn child(state: &'static str) -> HostWorkflowChildRow {
|
||||
HostWorkflowChildRow {
|
||||
task_id: "t1".to_string(),
|
||||
label: None,
|
||||
role: None,
|
||||
model: None,
|
||||
phase: None,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
/// U09-m3: a child with no completion receipt in a settled run is not
|
||||
/// "running", a cancellation is not a failure, and an unknown state never
|
||||
/// borrows the failure mark.
|
||||
#[test]
|
||||
fn child_rows_keep_their_source_of_truth_state() {
|
||||
assert_eq!(child_display_state(&child("running"), true), "running");
|
||||
assert_eq!(child_display_state(&child("running"), false), "unknown");
|
||||
assert_eq!(child_display_state(&child("succeeded"), false), "succeeded");
|
||||
assert_eq!(child_state_glyph("unknown"), "?");
|
||||
assert_eq!(child_state_glyph("cancelled"), "–");
|
||||
assert_eq!(child_state_glyph("failed"), "✗");
|
||||
assert_eq!(child_state_glyph("budget_exceeded"), "✗");
|
||||
}
|
||||
}
|
||||
@@ -365,14 +365,15 @@ impl FanoutCard {
|
||||
}
|
||||
|
||||
/// Update or insert a worker by id. Returns whether the visible state
|
||||
/// changed and the card should be redrawn.
|
||||
/// changed and the card should be redrawn. A settled worker is final: a
|
||||
/// delayed envelope for it never reopens or re-settles the slot.
|
||||
pub fn upsert_worker(&mut self, agent_id: &str, status: AgentLifecycle) -> bool {
|
||||
if let Some(slot) = self
|
||||
.workers
|
||||
.iter_mut()
|
||||
.find(|s| s.agent_id == agent_id || s.worker_id == agent_id)
|
||||
{
|
||||
if slot.agent_id == agent_id && slot.status == status {
|
||||
if slot.status.is_terminal() || (slot.agent_id == agent_id && slot.status == status) {
|
||||
return false;
|
||||
}
|
||||
slot.agent_id = agent_id.to_string();
|
||||
@@ -390,7 +391,7 @@ impl FanoutCard {
|
||||
/// circle for the same unit of work.
|
||||
pub fn claim_pending_worker(&mut self, agent_id: &str, status: AgentLifecycle) -> bool {
|
||||
if let Some(slot) = self.workers.iter_mut().find(|s| s.agent_id == agent_id) {
|
||||
if slot.status == status {
|
||||
if slot.status.is_terminal() || slot.status == status {
|
||||
return false;
|
||||
}
|
||||
slot.status = status;
|
||||
@@ -443,33 +444,51 @@ impl FanoutCard {
|
||||
s
|
||||
}
|
||||
|
||||
/// Header plus dot grid, one glyph per worker. The grid never runs past
|
||||
/// `width`: when it does not fit beside the header it wraps onto its own
|
||||
/// rows, so no worker's state is clipped off the transcript edge.
|
||||
#[must_use]
|
||||
pub fn render_lines(&self, _width: u16, theme: &palette::UiTheme) -> Vec<Line<'static>> {
|
||||
pub fn render_lines(&self, width: u16, theme: &palette::UiTheme) -> Vec<Line<'static>> {
|
||||
let header_status = self.aggregate_status();
|
||||
let count = self.workers.len();
|
||||
let count_label = if count == 1 { "agent" } else { "agents" };
|
||||
vec![Line::from(vec![
|
||||
let glyph = family_glyph(ToolFamily::Fanout);
|
||||
let count_text = format!("{count} {count_label}");
|
||||
let grid_style = Style::default()
|
||||
.fg(codewhale_palette::grammar::ChromeInk::Metadata.color(theme))
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let mut header = vec![
|
||||
Span::styled(
|
||||
family_glyph(ToolFamily::Fanout),
|
||||
glyph,
|
||||
Style::default()
|
||||
.fg(header_status.color(theme))
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("{count} {count_label}"),
|
||||
truncate_action(
|
||||
&count_text,
|
||||
usize::from(width).saturating_sub(UnicodeWidthStr::width(glyph) + 1),
|
||||
),
|
||||
Style::default()
|
||||
.fg(codewhale_palette::grammar::ChromeInk::Identity.color(theme))
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
self.dot_grid(),
|
||||
Style::default()
|
||||
.fg(codewhale_palette::grammar::ChromeInk::Metadata.color(theme))
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
])]
|
||||
];
|
||||
let grid: Vec<char> = self.dot_grid().chars().collect();
|
||||
let header_width = UnicodeWidthStr::width(glyph) + 1 + count_text.width() + 1;
|
||||
let width = usize::from(width).max(1);
|
||||
if header_width + grid.len() <= width {
|
||||
header.push(Span::raw(" "));
|
||||
header.push(Span::styled(grid.iter().collect::<String>(), grid_style));
|
||||
return vec![Line::from(header)];
|
||||
}
|
||||
let mut lines = vec![Line::from(header)];
|
||||
lines.extend(
|
||||
grid.chunks(width)
|
||||
.map(|row| Line::from(Span::styled(row.iter().collect::<String>(), grid_style))),
|
||||
);
|
||||
lines
|
||||
}
|
||||
|
||||
fn aggregate_status(&self) -> AgentLifecycle {
|
||||
@@ -477,22 +496,25 @@ impl FanoutCard {
|
||||
}
|
||||
|
||||
/// Public aggregate lifecycle for the activity shelf and other projectors.
|
||||
///
|
||||
/// A settled fanout reports its worst outcome: one failed worker among
|
||||
/// finished ones is a failed fanout, not a completed one; an
|
||||
/// interrupted or cancelled worker likewise outranks the completions.
|
||||
#[must_use]
|
||||
pub fn aggregate_status_public(&self) -> AgentLifecycle {
|
||||
let (done, running, failed, pending) = self.counts();
|
||||
let (done, running, _, pending) = self.counts();
|
||||
let any = |status: AgentLifecycle| self.workers.iter().any(|slot| slot.status == status);
|
||||
if running > 0 {
|
||||
AgentLifecycle::Running
|
||||
} else if pending > 0 {
|
||||
// Pending workers wait — amber attention, not "running" teal.
|
||||
AgentLifecycle::Pending
|
||||
} else if self
|
||||
.workers
|
||||
.iter()
|
||||
.any(|slot| matches!(slot.status, AgentLifecycle::Interrupted))
|
||||
{
|
||||
AgentLifecycle::Interrupted
|
||||
} else if failed > 0 && done == 0 {
|
||||
} else if any(AgentLifecycle::Failed) {
|
||||
AgentLifecycle::Failed
|
||||
} else if any(AgentLifecycle::Interrupted) {
|
||||
AgentLifecycle::Interrupted
|
||||
} else if any(AgentLifecycle::Cancelled) {
|
||||
AgentLifecycle::Cancelled
|
||||
} else if done > 0 {
|
||||
AgentLifecycle::Completed
|
||||
} else {
|
||||
@@ -559,6 +581,25 @@ pub fn apply_to_delegate(card: &mut DelegateCard, msg: &MailboxMessage) -> bool
|
||||
return false;
|
||||
}
|
||||
let was_terminal = card.status.is_terminal();
|
||||
// A settled agent is final. Mailbox producers are concurrent (a tool
|
||||
// task can outlive its agent), so a Started/Progress/tool envelope may
|
||||
// land after the terminal one; it must not reopen the card, stretch its
|
||||
// elapsed time, or rewrite its outcome. Only the child's own Work
|
||||
// snapshot still applies (it keeps the terminal status), and a late
|
||||
// `Started` may still name the role of a card that a completion-first
|
||||
// delivery opened with the `unknown` placeholder — identity, not state.
|
||||
if was_terminal {
|
||||
if let MailboxMessage::Started { agent_type, .. } = msg
|
||||
&& card.agent_type == "unknown"
|
||||
&& agent_type != "unknown"
|
||||
{
|
||||
card.agent_type = agent_type.clone();
|
||||
return true;
|
||||
}
|
||||
if !matches!(msg, MailboxMessage::WorkState { .. }) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if card.started_at.is_none()
|
||||
&& matches!(
|
||||
msg,
|
||||
@@ -1392,4 +1433,130 @@ mod tests {
|
||||
assert!(joined.contains("result:"), "{joined}");
|
||||
assert!(joined.contains("mapped 4 call sites"), "{joined}");
|
||||
}
|
||||
|
||||
/// U05-04: mailbox producers are concurrent, so a Started/Progress/tool
|
||||
/// envelope can land after an agent's terminal one. It must not reopen
|
||||
/// the card or its fanout slot, and must not move a settled count.
|
||||
#[test]
|
||||
fn late_envelopes_never_reopen_a_settled_agent() {
|
||||
let role = || crate::tools::subagent::FleetRole::Worker;
|
||||
let mut card = DelegateCard::new("agent_late", "general");
|
||||
apply_to_delegate(&mut card, &MailboxMessage::started("agent_late", role()));
|
||||
assert!(apply_to_delegate(
|
||||
&mut card,
|
||||
&MailboxMessage::Completed {
|
||||
agent_id: "agent_late".into(),
|
||||
summary: "done".into(),
|
||||
},
|
||||
));
|
||||
let finished_at = card.finished_at;
|
||||
for late in [
|
||||
MailboxMessage::started("agent_late", role()),
|
||||
MailboxMessage::progress("agent_late", "step 4: reading files"),
|
||||
MailboxMessage::ToolCallStarted {
|
||||
agent_id: "agent_late".into(),
|
||||
tool_name: "read_file".into(),
|
||||
step: 4,
|
||||
},
|
||||
MailboxMessage::Failed {
|
||||
agent_id: "agent_late".into(),
|
||||
error: "late".into(),
|
||||
},
|
||||
] {
|
||||
assert!(!apply_to_delegate(&mut card, &late), "{late:?}");
|
||||
}
|
||||
assert_eq!(card.status, AgentLifecycle::Completed);
|
||||
assert_eq!(card.summary.as_deref(), Some("done"));
|
||||
assert_eq!(card.finished_at, finished_at);
|
||||
|
||||
// A completion-first recovery card may still learn its role from a
|
||||
// late `Started`, but it stays settled.
|
||||
let mut recovered = DelegateCard::new("agent_r", "unknown");
|
||||
apply_to_delegate(
|
||||
&mut recovered,
|
||||
&MailboxMessage::Completed {
|
||||
agent_id: "agent_r".into(),
|
||||
summary: "done".into(),
|
||||
},
|
||||
);
|
||||
assert!(apply_to_delegate(
|
||||
&mut recovered,
|
||||
&MailboxMessage::started("agent_r", role())
|
||||
));
|
||||
assert_ne!(recovered.agent_type, "unknown");
|
||||
assert_eq!(recovered.status, AgentLifecycle::Completed);
|
||||
|
||||
let mut fanout = FanoutCard::new("rlm");
|
||||
for id in ["w_1", "w_2"] {
|
||||
apply_to_fanout(
|
||||
&mut fanout,
|
||||
&MailboxMessage::ChildSpawned {
|
||||
parent_id: "root".into(),
|
||||
child_id: id.into(),
|
||||
},
|
||||
);
|
||||
apply_to_fanout(&mut fanout, &MailboxMessage::started(id, role()));
|
||||
apply_to_fanout(
|
||||
&mut fanout,
|
||||
&MailboxMessage::Completed {
|
||||
agent_id: id.into(),
|
||||
summary: "ok".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
assert!(!apply_to_fanout(
|
||||
&mut fanout,
|
||||
&MailboxMessage::progress("w_1", "step 9: late")
|
||||
));
|
||||
assert!(!apply_to_fanout(
|
||||
&mut fanout,
|
||||
&MailboxMessage::started("w_2", role())
|
||||
));
|
||||
assert_eq!(fanout.worker_count(), 2);
|
||||
assert_eq!(fanout.counts(), (2, 0, 0, 0));
|
||||
assert_eq!(fanout.aggregate_status_public(), AgentLifecycle::Completed);
|
||||
}
|
||||
|
||||
/// U05-m2: a settled fanout headlines its worst outcome — one failure
|
||||
/// among completions is a failed fanout, not a completed one.
|
||||
#[test]
|
||||
fn mixed_fanout_outcomes_never_aggregate_to_completed() {
|
||||
let mut card = FanoutCard::new("rlm").with_workers(["w_1", "w_2", "w_3"]);
|
||||
card.upsert_worker("w_1", AgentLifecycle::Completed);
|
||||
card.upsert_worker("w_2", AgentLifecycle::Completed);
|
||||
card.upsert_worker("w_3", AgentLifecycle::Failed);
|
||||
assert_eq!(card.aggregate_status_public(), AgentLifecycle::Failed);
|
||||
|
||||
let mut card = FanoutCard::new("rlm").with_workers(["w_1", "w_2"]);
|
||||
card.upsert_worker("w_1", AgentLifecycle::Completed);
|
||||
card.upsert_worker("w_2", AgentLifecycle::Cancelled);
|
||||
assert_eq!(card.aggregate_status_public(), AgentLifecycle::Cancelled);
|
||||
|
||||
let mut card = FanoutCard::new("rlm").with_workers(["w_1"]);
|
||||
card.upsert_worker("w_1", AgentLifecycle::Completed);
|
||||
assert_eq!(card.aggregate_status_public(), AgentLifecycle::Completed);
|
||||
}
|
||||
|
||||
/// U05-m6: a wide fanout wraps its dot grid instead of running past the
|
||||
/// transcript width, and every worker keeps its glyph.
|
||||
#[test]
|
||||
fn fanout_grid_wraps_within_the_render_width() {
|
||||
let ids: Vec<String> = (0..40).map(|i| format!("w_{i}")).collect();
|
||||
let card = FanoutCard::new("rlm").with_workers(ids.iter().cloned());
|
||||
for width in [12_u16, 20, 80] {
|
||||
let lines = render_to_strings(&card.render_lines(width, &codewhale_palette::UI_THEME));
|
||||
for line in &lines {
|
||||
assert!(
|
||||
UnicodeWidthStr::width(line.as_str()) <= usize::from(width),
|
||||
"{width}: {line:?}"
|
||||
);
|
||||
}
|
||||
let glyphs = lines
|
||||
.join("")
|
||||
.chars()
|
||||
.filter(|ch| *ch == '\u{25CB}')
|
||||
.count();
|
||||
assert_eq!(glyphs, 40, "{width}: {lines:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -558,11 +558,13 @@ impl WorkflowPanelEvent {
|
||||
at_ms,
|
||||
}),
|
||||
"run_completed" => {
|
||||
// A terminal receipt without a readable status is not
|
||||
// evidence of success; it fails closed like an unknown one.
|
||||
let status = value
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.map(lifecycle_from_status)
|
||||
.unwrap_or(WorkflowPanelLifecycle::Succeeded);
|
||||
.unwrap_or(WorkflowPanelLifecycle::Failed);
|
||||
Some(Self::RunCompleted {
|
||||
status,
|
||||
error: opt_str(value, "error"),
|
||||
@@ -599,7 +601,7 @@ impl WorkflowPanelEvent {
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.map(WorkflowRowStatus::from_ir_status)
|
||||
.unwrap_or(WorkflowRowStatus::Succeeded);
|
||||
.unwrap_or(WorkflowRowStatus::Failed);
|
||||
Some(Self::TaskCompleted {
|
||||
task_id: opt_str(value, "task_id")?,
|
||||
status,
|
||||
@@ -1409,8 +1411,10 @@ impl WorkflowPanel {
|
||||
if self.lifecycle == WorkflowPanelLifecycle::Cancelled {
|
||||
return;
|
||||
}
|
||||
self.lifecycle = if matches!(status, WorkflowPanelLifecycle::Running) {
|
||||
WorkflowPanelLifecycle::Succeeded
|
||||
// A completion receipt that still says running/pending is
|
||||
// contradictory, not a success.
|
||||
self.lifecycle = if status.is_running() {
|
||||
WorkflowPanelLifecycle::Failed
|
||||
} else {
|
||||
status
|
||||
};
|
||||
@@ -1442,13 +1446,22 @@ impl WorkflowPanel {
|
||||
route,
|
||||
at_ms,
|
||||
} => {
|
||||
// A settled run is final. A child launch that raced the
|
||||
// cancel (or any late same-run start) is recorded, but it
|
||||
// never reopens the run or resets a row that already settled.
|
||||
let settled = self.lifecycle.is_terminal();
|
||||
if settled && self.find_row_mut(&task_id).is_some() {
|
||||
return;
|
||||
}
|
||||
if self.phases.is_empty() {
|
||||
self.phases.push(WorkflowPanelPhase::new("Work"));
|
||||
self.selected_phase = 0;
|
||||
}
|
||||
let phase_idx = self.selected_phase.min(self.phases.len().saturating_sub(1));
|
||||
// Tasks join the runtime's newest phase. `selected_phase` is
|
||||
// the render cursor and never routes an arriving task.
|
||||
let phase_idx = self.phases.len().saturating_sub(1);
|
||||
let display_model = resolved_model.or(model);
|
||||
let row = WorkflowPanelRow {
|
||||
let mut row = WorkflowPanelRow {
|
||||
task_id: task_id.clone(),
|
||||
label: label
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
@@ -1466,12 +1479,20 @@ impl WorkflowPanel {
|
||||
route: *route,
|
||||
usage: None,
|
||||
};
|
||||
if self.lifecycle == WorkflowPanelLifecycle::Cancelled {
|
||||
// Same finalization the cancel applied to running rows.
|
||||
row.status = WorkflowRowStatus::Cancelled;
|
||||
row.completed_at_ms = Some(at_ms);
|
||||
row.usage = Some(WorkflowRowUsage::default());
|
||||
}
|
||||
if let Some(existing) = self.find_row_mut(&task_id) {
|
||||
*existing = row;
|
||||
} else if let Some(phase) = self.phases.get_mut(phase_idx) {
|
||||
phase.rows.push(row);
|
||||
}
|
||||
self.lifecycle = WorkflowPanelLifecycle::Running;
|
||||
if !settled {
|
||||
self.lifecycle = WorkflowPanelLifecycle::Running;
|
||||
}
|
||||
}
|
||||
WorkflowPanelEvent::TaskCompleted {
|
||||
task_id,
|
||||
@@ -1525,7 +1546,7 @@ impl WorkflowPanel {
|
||||
if self.phases.is_empty() {
|
||||
self.phases.push(WorkflowPanelPhase::new("Work"));
|
||||
}
|
||||
let phase_idx = self.selected_phase.min(self.phases.len().saturating_sub(1));
|
||||
let phase_idx = self.phases.len().saturating_sub(1);
|
||||
if let Some(phase) = self.phases.get_mut(phase_idx) {
|
||||
phase.rows.push(WorkflowPanelRow {
|
||||
task_id,
|
||||
@@ -2707,6 +2728,77 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// U05-01: a child launch that raced the cancel (or any late same-run
|
||||
/// `task_started`) lands after the run settled. It is recorded, but it
|
||||
/// never reopens the run, and a settled row is never reset to running.
|
||||
#[test]
|
||||
fn late_task_started_never_reopens_a_settled_run() {
|
||||
let mut panel = started_panel();
|
||||
panel.apply_json_event(&json!({
|
||||
"type": "run_cancelled", "run_id": "workflow_abc",
|
||||
"reason": "stopped by you", "at_ms": 2_000,
|
||||
}));
|
||||
panel.apply_json_event(&task_started_json("t2", "deepseek", "flash"));
|
||||
panel.apply_json_event(&task_started_json("t1", "deepseek", "flash"));
|
||||
assert_eq!(panel.lifecycle, WorkflowPanelLifecycle::Cancelled);
|
||||
assert_eq!(
|
||||
panel.find_row_mut("t1").expect("t1").status,
|
||||
WorkflowRowStatus::Cancelled
|
||||
);
|
||||
assert_eq!(
|
||||
panel
|
||||
.find_row_mut("t2")
|
||||
.expect("late t2 is recorded")
|
||||
.status,
|
||||
WorkflowRowStatus::Cancelled
|
||||
);
|
||||
|
||||
let mut panel = started_panel();
|
||||
panel.apply_json_event(&json!({
|
||||
"type": "task_completed", "run_id": "workflow_abc",
|
||||
"task_id": "t1", "status": "succeeded", "at_ms": 1_500,
|
||||
}));
|
||||
panel.apply_json_event(&json!({
|
||||
"type": "run_completed", "run_id": "workflow_abc",
|
||||
"status": "completed", "at_ms": 1_600,
|
||||
}));
|
||||
panel.apply_json_event(&task_started_json("t1", "deepseek", "flash"));
|
||||
assert_eq!(panel.lifecycle, WorkflowPanelLifecycle::Succeeded);
|
||||
assert_eq!(
|
||||
panel.find_row_mut("t1").expect("t1").status,
|
||||
WorkflowRowStatus::Succeeded
|
||||
);
|
||||
}
|
||||
|
||||
/// U05-02: a terminal receipt with a missing or contradictory status is
|
||||
/// not evidence of success; it fails closed like an unknown status.
|
||||
#[test]
|
||||
fn malformed_completion_status_never_reads_as_success() {
|
||||
for status in [None, Some("running"), Some("pending")] {
|
||||
let mut panel = started_panel();
|
||||
let mut completed = json!({
|
||||
"type": "run_completed", "run_id": "workflow_abc", "at_ms": 2_000,
|
||||
});
|
||||
if let Some(status) = status {
|
||||
completed["status"] = json!(status);
|
||||
}
|
||||
panel.apply_json_event(&json!({
|
||||
"type": "task_completed", "run_id": "workflow_abc",
|
||||
"task_id": "t1", "at_ms": 1_900,
|
||||
}));
|
||||
panel.apply_json_event(&completed);
|
||||
assert_eq!(
|
||||
panel.lifecycle,
|
||||
WorkflowPanelLifecycle::Failed,
|
||||
"{status:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
panel.find_row_mut("t1").expect("row").status,
|
||||
WorkflowRowStatus::Failed
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// #4208: every decorative glyph the run map emits — expand marks, role
|
||||
/// marks, lane glyphs, gates, status marks across running, waiting,
|
||||
/// failed, cancelled, and completed members — must narrow to an
|
||||
|
||||
@@ -116,9 +116,15 @@ pub fn handle_key(app: &mut App, key: KeyEvent) -> Option<Option<SidebarRowActio
|
||||
.iter()
|
||||
.position(|panel| *panel == app.work_surface.panel)
|
||||
.unwrap_or(0);
|
||||
// Only a bare ←/→ walks the dock tabs. Ctrl/Alt/Super+←/→ are the
|
||||
// composer's word-navigation chords; they fall through below and
|
||||
// return ownership to the composer like printable input.
|
||||
let bare = !key
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER);
|
||||
let next = match key.code {
|
||||
KeyCode::Left => Some((current + tabs.len() - 1) % tabs.len()),
|
||||
KeyCode::Right => Some((current + 1) % tabs.len()),
|
||||
KeyCode::Left if bare => Some((current + tabs.len() - 1) % tabs.len()),
|
||||
KeyCode::Right if bare => Some((current + 1) % tabs.len()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(next) = next {
|
||||
@@ -128,10 +134,13 @@ pub fn handle_key(app: &mut App, key: KeyEvent) -> Option<Option<SidebarRowActio
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(key.code, KeyCode::Char(_))
|
||||
&& !key
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
|
||||
let navigation_chord = key
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER);
|
||||
if (matches!(key.code, KeyCode::Char(_)) && !navigation_chord)
|
||||
|| (app.work_surface.focused
|
||||
&& navigation_chord
|
||||
&& matches!(key.code, KeyCode::Left | KeyCode::Right))
|
||||
{
|
||||
release_focus(app);
|
||||
return None;
|
||||
|
||||
Reference in new issue
Block a user