Compare commits

..
Author SHA1 Message Date
Miguel Palhas 60655a1843 docs(maestro): describe the TUI's resume keybinding 2026-09-04 09:43:33 +01:00
Miguel Palhas 2c0b96411c fix(maestro): gate the sidebar preview attach on Running
The TUI's preview attached without checking status, so after a host reboot —
every session dormant — moving the sidebar highlight tried a live attach on
each one in turn; the daemon has no bridge to answer, so it 404/409s.

Gate every attach (preview, committed AttachHighlighted, and the reconciling
poll after a resume completes) on status == Running, and show a metadata +
resume placeholder in the pane instead, per docs/PREVIEW.md.
2026-09-04 09:43:31 +01:00
Miguel Palhas 8f1b234a11 feat(maestro): bind a TUI key to resume a dormant session
Adds DaemonClient::resume_session (POST /api/sessions/:id/resume) and a new
ResumeHighlighted action, bound to `u` by default (#166, #169).
2026-09-04 09:43:27 +01:00
Miguel Palhas ec7728afb1 feat(maestro): render dormant sidebar cards muted and sorted last
docs/SIDEBAR.md's dormant card contract (#168) applied to the TUI: name and
glyph render in the muted role, the activity rail carries no colour, and a
dormant session sorts after every live one in its folder, name notwithstanding.
2026-09-04 09:43:25 +01:00
Miguel Palhas 0fd107be77 merge: dormant cards and resume in the web sidebar (#168)
Closes #168
2026-09-04 09:26:02 +01:00
Miguel Palhas aebd10c2da merge: guard resume against kill and second resume (#175)
Closes #175
2026-09-04 09:24:35 +01:00
Miguel Palhas ae3091e2a2 fix(maestro): guard resume against kill and second resume
The respawn runs with the session map unlocked, so two takes of the
lock straddle it. A kill landing in that window deleted the sidecar
before resume wrote it back, leaving a file for a session that was
gone — the next recovery registered it as a dormant ghost. Resume now
deletes the sidecar in the branch where it finds its own record gone.

Two resumes both passed the `Running` check for the same reason. The
record is marked in-flight under the first lock, so the second request
is refused with `already_running` instead of losing a race for the
FIFO and reporting a tmux failure. Every path out of resume clears the
marker.

Both races are driven deterministically in the tests: the manager's
tmux spawn is parked mid-resume while the racing kill or resume runs.

Closes #175
2026-09-04 09:19:59 +01:00
Miguel Palhas 915e408c16 docs(maestro): describe the dormant card, pane and ordering
docs/SIDEBAR.md: the dormant card's muted rendering, its resume
action, and its lowest-precedence ordering.
docs/PREVIEW.md: a session that isn't Running never gets a live
attach through the preview path either — its pane reads the same as
its card.
2026-09-04 09:19:23 +01:00
Miguel Palhas 577434dc43 style(maestro): style the dormant card and pane
Muted card foreground and resume-button hover to match spawn-similar
(#168); the content pane's metadata-and-resume placeholder in place of
a live terminal.
2026-09-04 09:19:23 +01:00
Miguel Palhas cc1e7b5f98 fix(maestro): never open a live pane for a session that isn't running
Integration review (#168): the content pane opened a pane for
whatever the sidebar highlighted with no status check. After a host
reboot every session is dormant, so arrowing down the sidebar opened
a pane per session that immediately died with WS 1006 (the daemon
answers a bridgeless attach with 404/409, never a completed
handshake).

Gate every path that fills a pane (preview, open, mobile's single
pane) on the session's own status: a session that isn't Running gets
the same metadata-and-resume view its sidebar card shows
(docs/SIDEBAR.md, docs/PREVIEW.md) instead of an attach attempt. The
shell also reacts to onNeverAttached (previous commit) by dropping the
dead connection and re-rendering, and to an open session's
Running-ness flipping — most commonly a resume completing — so a
placeholder and a live pane swap in step with the catalog.
2026-09-04 09:19:19 +01:00
Miguel Palhas c29d6f4339 feat(maestro): resume a dormant session from its sidebar card
Two integration-review fixes plus the card's own resume action
(#168):

- dataset.status and the aria-label read a two-way running/dead test,
  so a dormant card announced itself as "exited" while its visible
  dormant flag disagreed. Both now read the session's own three-way
  status.
- A dormant card is a muted variant (docs/SIDEBAR.md): spawn-similar
  is replaced by a resume action (button, context menu) that calls
  POST /api/sessions/:id/resume — there is nothing running to clone
  from a dormant session, but resuming it in place makes sense. Kill
  is unchanged: it already dismisses a dormant session by removing
  its sidecar.
2026-09-04 09:19:13 +01:00
Miguel Palhas 938dd62648 fix(maestro): stop retrying a WS attach that never opened
Integration review (#168): a rejected attach handshake — a dormant
session's 409 not_attachable, most likely — looks identical to a
dropped stream from the WebSocket API (close code 1006, no reason).
shouldRetryClose kept retrying it on the catalog's Running/Dead check
alone, which loops for as long as the catalog hasn't caught up with
the session going dormant.

Track whether the socket ever reached `open`; a handshake that never
did is a rejection, not a blip, so it is never retried regardless of
catalog status. The caller (wired up in the next commit) gets a
distinct onNeverAttached callback instead of onClosed, so it can offer
the resume affordance rather than assuming the session is gone.
2026-09-04 09:19:09 +01:00
Miguel Palhas 8269bcabcc feat(maestro): sort dormant sessions after live ones in the sidebar
docs/SIDEBAR.md: a dormant session takes the lowest precedence in the
sidebar tree, so a rebooted host's live work never sorts under a pile
of dormant cards.
2026-09-04 09:19:03 +01:00
Miguel Palhas 1366c2d430 merge: always probe tmux during recovery (#174)
Closes #174
2026-09-04 09:15:45 +01:00
25 changed files with 1197 additions and 70 deletions
+41 -4
View File
@@ -87,6 +87,11 @@ pub struct SessionRecord {
/// (`POST /.../focus`), `None` while unfocused. Daemon-local; never rides
/// the wire.
pub focused_since: Option<std::time::Instant>,
/// Set for as long as a [`SessionManager::resume`] is between its two
/// takes of the map lock (#175). The spawn happens with the lock released,
/// so the `Running` check alone lets a second request through; this is the
/// state the second one is refused on. Daemon-local; never rides the wire.
pub resuming: bool,
/// Sticky acknowledgement applied by [`crate::activity::apply_focus_suppression`]:
/// once the pane has been focused long enough to clear an `AwaitingInput`
/// glow, this holds through focus loss until the session does new work.
@@ -595,6 +600,7 @@ impl SessionManager {
naming: false,
focused_since: None,
awaiting_suppressed: false,
resuming: false,
},
);
}
@@ -646,15 +652,23 @@ impl SessionManager {
id: Uuid,
presets: &[maestro_protocol::SpawnPreset],
) -> Result<Session, SessionError> {
// The spawn below runs with the map lock released, so the `Running`
// check alone is not enough to keep two resumes apart (#175): both
// would pass it, both would open a `PtyBridge` on the same FIFO, and
// the loser would report a tmux failure instead of the conflict it
// is. The record is marked in-flight under this first lock, and every
// path out of here clears it again.
let mut session = {
let sessions = self.inner.sessions.lock().await;
let rec = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
if rec.session.status == SessionStatus::Running {
let mut sessions = self.inner.sessions.lock().await;
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
if rec.session.status == SessionStatus::Running || rec.resuming {
return Err(SessionError::AlreadyRunning(id));
}
rec.resuming = true;
rec.session.clone()
};
if !session.cwd.exists() {
self.clear_resuming(id).await;
return Err(SessionError::CwdMissing(session.cwd.clone()));
}
let command = crate::resume::resume_command(&session, presets);
@@ -686,6 +700,7 @@ impl SessionManager {
if let Err(e) = spawned {
// `bridge` drops here, closing the FIFO reader. Nothing else was
// touched, so the session stays exactly as dormant as it was.
self.clear_resuming(id).await;
return Err(SessionError::Tmux(e));
}
@@ -705,6 +720,7 @@ impl SessionManager {
// sidecar does not describe.
let tmux = self.inner.tmux.clone();
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
self.clear_resuming(id).await;
return Err(SessionError::Sidecar(e));
}
@@ -712,9 +728,17 @@ impl SessionManager {
let mut sessions = self.inner.sessions.lock().await;
let Some(rec) = sessions.get_mut(&id) else {
// Killed while we were spawning. Undo the pane instead of
// leaving one behind with no record of it.
// leaving one behind with no record of it — and delete the
// sidecar the write above just put back (#175). The kill
// deleted it before that write landed, so leaving it would
// have the next recovery register a dormant ghost for a
// session that is gone and whose worktree is already removed.
drop(sessions);
let tmux = self.inner.tmux.clone();
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
if let Err(e) = self.inner.sidecar.delete(id) {
tracing::warn!(error = ?e, %id, "resume: failed to delete the sidecar of a session killed mid-resume");
}
return Err(SessionError::NotFound(id));
};
rec.session = session.clone();
@@ -724,6 +748,7 @@ impl SessionManager {
rec.settled = false;
rec.focused_since = None;
rec.awaiting_suppressed = false;
rec.resuming = false;
}
let _ = self.inner.events.send(CatalogEvent::SessionUpdated {
session: session.clone(),
@@ -731,6 +756,16 @@ impl SessionManager {
Ok(session)
}
/// Drop the in-flight marker [`Self::resume`] set under its first lock
/// (#175), so a refused or failed resume can be retried. A no-op when the
/// record is already gone: a kill that landed mid-resume took the marker
/// with it.
async fn clear_resuming(&self, id: Uuid) {
if let Some(rec) = self.inner.sessions.lock().await.get_mut(&id) {
rec.resuming = false;
}
}
/// Best-effort removal of a worktree created earlier in `create` when a
/// later step fails. No-op when the request had no worktree.
fn rollback_worktree(&self, base: &Option<PathBuf>, path: &Option<PathBuf>) {
@@ -922,6 +957,7 @@ impl SessionManager {
naming: false,
focused_since: None,
awaiting_suppressed: false,
resuming: false,
},
);
let _ = self
@@ -1048,6 +1084,7 @@ impl SessionManager {
naming: false,
focused_since: None,
awaiting_suppressed: false,
resuming: false,
},
);
registered.push(session);
+234
View File
@@ -357,3 +357,237 @@ async fn post_resume_on_an_unknown_session_is_404() {
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}
// ---------------------------------------------------------------------------
// Races (#175)
// ---------------------------------------------------------------------------
/// Real tmux, with the *first* `new_session_piped` held inside the call until
/// the test releases it. Later calls pass straight through, so a second resume
/// racing the first never blocks on a gate the first one owns.
///
/// This is what makes the two races below deterministic rather than timing
/// dependent: the resume under test is parked in exactly the window the bug
/// lives in, and the racing operation runs to completion before it moves.
#[derive(Debug)]
struct GatedSpawn {
inner: maestro_daemon::tmux::Tmux,
entered: tokio::sync::mpsc::UnboundedSender<()>,
release: std::sync::Mutex<std::sync::mpsc::Receiver<()>>,
gated: std::sync::atomic::AtomicBool,
}
/// The test's end of a [`GatedSpawn`].
struct Gate {
entered: tokio::sync::mpsc::UnboundedReceiver<()>,
release: std::sync::mpsc::SyncSender<()>,
}
impl Gate {
/// Wait until the gated spawn is parked mid-resume.
async fn wait_entered(&mut self) {
tokio::time::timeout(Duration::from_secs(10), self.entered.recv())
.await
.expect("resume reached the tmux spawn")
.expect("gate alive");
}
fn release(&self) {
self.release.send(()).expect("gate receiver alive");
}
}
fn gated_panes(inner: maestro_daemon::tmux::Tmux) -> (Arc<GatedSpawn>, Gate) {
let (entered_tx, entered_rx) = tokio::sync::mpsc::unbounded_channel();
let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1);
(
Arc::new(GatedSpawn {
inner,
entered: entered_tx,
release: std::sync::Mutex::new(release_rx),
gated: std::sync::atomic::AtomicBool::new(true),
}),
Gate {
entered: entered_rx,
release: release_tx,
},
)
}
impl maestro_daemon::panes::Panes for GatedSpawn {
fn new_session_piped(
&self,
id: Uuid,
cwd: &std::path::Path,
command: &str,
session_env: &std::collections::BTreeMap<String, String>,
sink_command: &str,
) -> Result<(), maestro_daemon::tmux::TmuxError> {
if self.gated.swap(false, std::sync::atomic::Ordering::SeqCst) {
let _ = self.entered.send(());
let _ = self.release.lock().expect("gate mutex").recv();
}
self.inner
.new_session_piped(id, cwd, command, session_env, sink_command)
}
fn kill_session(&self, id: Uuid) -> Result<(), maestro_daemon::tmux::TmuxError> {
self.inner.kill_session(id)
}
fn list_sessions(&self) -> Result<Vec<Uuid>, maestro_daemon::tmux::TmuxError> {
self.inner.list_sessions()
}
fn pane_info(
&self,
id: Uuid,
) -> Result<Option<maestro_daemon::tmux::PaneInfo>, maestro_daemon::tmux::TmuxError> {
self.inner.pane_info(id)
}
fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), maestro_daemon::tmux::TmuxError> {
self.inner.send_keys(id, bytes)
}
fn pipe_pane(&self, id: Uuid, sink: &str) -> Result<(), maestro_daemon::tmux::TmuxError> {
self.inner.pipe_pane(id, sink)
}
fn resize_window(
&self,
id: Uuid,
cols: u16,
rows: u16,
) -> Result<(), maestro_daemon::tmux::TmuxError> {
self.inner.resize_window(id, cols, rows)
}
fn capture_pane(&self, id: Uuid) -> Result<Option<Vec<u8>>, maestro_daemon::tmux::TmuxError> {
self.inner.capture_pane(id)
}
}
/// A dormant session whose manager's tmux spawn can be parked by the test.
async fn dormant_gated(fx: &DaemonFixture, cwd: &TempDir) -> (SessionManager, Session, Gate) {
let session = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
fx.tmux.tmux.kill_server().expect("kill tmux server");
let (panes, gate) = gated_panes(fx.tmux.tmux.clone());
let manager = SessionManager::new(
panes,
maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir()),
maestro_daemon::worktree::WorktreeManager::new(None),
fx.fifo_dir(),
);
manager.recover().await.expect("recover");
assert_eq!(
manager.status(session.id).await,
Some(SessionStatus::Dormant)
);
(manager, session, gate)
}
/// A kill that lands while a resume is inside the tmux spawn must not leave
/// the sidecar behind. The kill deletes it; the resume writes it back a moment
/// later and then discovers its own record is gone. The sidecar it resurrected
/// would otherwise make the next recovery register a ghost dormant session for
/// a session that was killed.
#[tokio::test]
async fn a_kill_during_resume_leaves_no_sidecar_behind() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let (manager, session, mut gate) = dormant_gated(&fx, &cwd).await;
let id = session.id;
let resuming = tokio::spawn({
let manager = manager.clone();
async move { manager.resume(id, &[]).await }
});
gate.wait_entered().await;
manager.kill(id, false).await.expect("kill mid-resume");
gate.release();
let err = resuming.await.unwrap().expect_err("record is gone");
assert!(matches!(err, SessionError::NotFound(got) if got == id));
assert!(
maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir())
.read(id)
.expect("read sidecar")
.is_none(),
"the killed session's sidecar must not be resurrected by the losing resume"
);
// And so the next recovery registers no ghost.
let after = revive(&fx);
after.recover().await.expect("recover");
assert!(
after.snapshot().await.is_empty(),
"a killed session must not come back dormant"
);
}
/// Two resumes of the same session must not both proceed. The second is
/// refused on the state — `already_running` — not on its tmux spawn colliding
/// with the first one's.
#[tokio::test]
async fn a_second_resume_is_refused_while_the_first_is_in_flight() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let (manager, session, mut gate) = dormant_gated(&fx, &cwd).await;
let id = session.id;
let resuming = tokio::spawn({
let manager = manager.clone();
async move { manager.resume(id, &[]).await }
});
gate.wait_entered().await;
let err = manager
.resume(id, &[])
.await
.expect_err("a resume is already in flight");
assert!(matches!(err, SessionError::AlreadyRunning(got) if got == id));
assert_eq!(err.to_error_info().code, "already_running");
gate.release();
let first = resuming.await.unwrap().expect("the first resume wins");
assert_eq!(first.status, SessionStatus::Running);
assert_eq!(
fx.tmux.tmux.list_sessions().expect("list"),
vec![id],
"exactly one pane for the session"
);
}
/// A refused resume must not leave the session marked in-flight: once the
/// reason for the refusal is gone, resuming works.
#[tokio::test]
async fn a_refused_resume_can_be_retried() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let (manager, session) = dormant_session(&fx, &cwd, "sleep 30", "retried").await;
std::fs::remove_dir_all(cwd.path()).unwrap();
let err = manager
.resume(session.id, &[])
.await
.expect_err("cwd is gone");
assert_eq!(err.to_error_info().code, "cwd_missing");
std::fs::create_dir_all(cwd.path()).unwrap();
let resumed = manager.resume(session.id, &[]).await.expect("resume");
assert_eq!(resumed.status, SessionStatus::Running);
}
+10
View File
@@ -141,6 +141,16 @@ impl DaemonClient {
json(check(resp).await?).await
}
/// `POST /api/sessions/:id/resume` (#166): respawn a dormant session
/// under its own uuid and sidecar. Refused with `409` (`already_running`
/// / `cwd_missing`) when there is nothing a respawn could fix.
pub async fn resume_session(&self, id: Uuid) -> Result<Session, ClientError> {
let resp = self
.send(Method::POST, &routes::session_resume(id), &[], None::<&()>)
.await?;
json(check(resp).await?).await
}
/// `POST /api/sessions/:id/activity` (#105): report an agent-observed
/// state. The daemon holds it above its own inference for a TTL, then
/// resumes inference. Answers `204 No Content`.
+15
View File
@@ -51,6 +51,9 @@ pub enum Action {
/// Rename the highlighted session (#125): opens a prompt pre-filled with
/// its current name.
RenameHighlighted,
/// Resume the highlighted dormant session (#166, #169): respawns it
/// under its own uuid and sidecar via `POST /api/sessions/:id/resume`.
ResumeHighlighted,
// Chrome
/// Open the `ctrl-k` command bar (`docs/COMMAND-BAR.md`) — a query line
@@ -105,6 +108,7 @@ impl Action {
Action::FastForwardSpawn => "Create session with defaults".into(),
Action::KillHighlighted => "Kill selected session".into(),
Action::RenameHighlighted => "Rename selected session".into(),
Action::ResumeHighlighted => "Resume dormant session".into(),
Action::OpenCommandBar => "Go to a session".into(),
Action::SendLiteralCtrlK => "Send literal Ctrl-K to session".into(),
Action::SendLiteralLeader => "Send literal leader key to session".into(),
@@ -134,6 +138,7 @@ impl Action {
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
"KillHighlighted" => return Ok(Action::KillHighlighted),
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
"ResumeHighlighted" => return Ok(Action::ResumeHighlighted),
"OpenCommandBar" => return Ok(Action::OpenCommandBar),
"SendLiteralCtrlK" => return Ok(Action::SendLiteralCtrlK),
"SendLiteralLeader" => return Ok(Action::SendLiteralLeader),
@@ -192,6 +197,16 @@ mod tests {
assert_eq!(Action::parse(" Noop ").unwrap(), Action::Noop);
}
/// #169: resume is its own verb, distinct from rename/kill, so a dormant
/// session's sidebar card has a keyboard-reachable resume.
#[test]
fn parses_resume_highlighted() {
assert_eq!(
Action::parse("ResumeHighlighted").unwrap(),
Action::ResumeHighlighted
);
}
#[test]
fn help_labels_are_plain_language() {
assert_eq!(Action::EnterInsert.help_label(), "Send keys to session");
+163 -20
View File
@@ -34,7 +34,7 @@ use crate::input::{
use crate::keymap::Mode;
use crate::overlays::HelpOverlay;
use crate::pane::{Pane, PaneStatus};
use crate::render::{draw, RegionMap, RenderInputs};
use crate::render::{draw, PanePlaceholder, RegionMap, RenderInputs};
use crate::session_task::{SessionInput, SessionTask};
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
@@ -190,6 +190,10 @@ pub enum Effect {
id: Uuid,
name: String,
},
/// Resume a dormant session (#166, #169): respawns it under its own
/// uuid and sidecar. The catalog picks up the resulting `Running` status
/// from the daemon's own `SessionUpdated` broadcast.
Resume(Uuid),
}
impl App {
@@ -263,6 +267,11 @@ impl App {
}
_ = self.catalog.changed() => {
// Picked up by the frame key's catalog version.
let effects = self.poll_resumed_attach();
if !effects.is_empty() {
self.epoch = self.epoch.wrapping_add(1);
}
self.perform(effects).await;
}
_ = self.link.changed() => {
// The status indicator and the restart overlay both read
@@ -414,6 +423,7 @@ impl App {
Effect::ProbeGitRoot { seq, path } => self.spawn_probe_git_root(seq, path),
Effect::LoadBranches => self.spawn_load_branches(),
Effect::Rename { id, name } => self.submit_rename(id, &name).await,
Effect::Resume(id) => self.submit_resume(id).await,
Effect::Forward { id, bytes } => {
if let Some(handle) = self.emulators.get(id) {
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
@@ -587,7 +597,10 @@ impl App {
}
// A previewed session is already attached: committing
// to it keeps that stream rather than reopening one.
if self.emulators.get(sid).is_none() {
// A session that isn't Running has no bridge to
// attach to (`docs/PREVIEW.md`) — the pane falls back
// to its dormant/exited placeholder instead.
if self.emulators.get(sid).is_none() && self.session_running(sid) {
effects.push(Effect::Attach(sid));
}
}
@@ -607,6 +620,11 @@ impl App {
self.prompt_rename(sid);
}
}
Action::ResumeHighlighted => {
if let Some(sid) = self.pick_sidebar_target() {
effects.push(Effect::Resume(sid));
}
}
}
// Every action can move the highlight or the focus, and the preview is
// a function of both (`docs/PREVIEW.md`).
@@ -663,13 +681,41 @@ impl App {
self.state.preview = Some(sid);
// Set outside the input path, so nothing else marks the frame stale.
self.epoch = self.epoch.wrapping_add(1);
let fresh = self.emulators.get(sid).is_none();
if fresh {
// A session that isn't Running has no bridge to preview-attach to
// (`docs/PREVIEW.md`): the pane falls back to its dormant/exited
// placeholder, and the preview budget has nothing of its own to track.
let attach = self.emulators.get(sid).is_none() && self.session_running(sid);
if attach {
self.preview_lru.retain(|s| *s != sid);
self.preview_lru.push(sid);
}
self.trim_preview_budget();
if fresh {
if attach {
vec![Effect::Attach(sid)]
} else {
Vec::new()
}
}
/// Whether `sid` is currently `Running`, per the catalog — the only
/// status a live pane attach is allowed for (`docs/PREVIEW.md`).
fn session_running(&self, sid: Uuid) -> bool {
self.catalog
.snapshot()
.sessions
.iter()
.any(|s| s.id == sid && s.status == maestro_protocol::SessionStatus::Running)
}
/// A previewed or committed session gates its attach on `Running`
/// (`docs/PREVIEW.md`) and may only become `Running` later — most
/// commonly a resume completing. Catch that up on a catalog change
/// rather than waiting on an unrelated highlight move.
fn poll_resumed_attach(&mut self) -> Vec<Effect> {
let Some(sid) = self.state.displayed_session() else {
return Vec::new();
};
if self.emulators.get(sid).is_none() && self.session_running(sid) {
vec![Effect::Attach(sid)]
} else {
Vec::new()
@@ -1327,6 +1373,15 @@ impl App {
}
}
/// Resume a dormant session (#166, #169). The catalog's own
/// `SessionUpdated` broadcast carries the `Running` status back; nothing
/// is patched locally here.
async fn submit_resume(&mut self, sid: Uuid) {
if let Err(e) = self.daemon.resume_session(sid).await {
self.state.toast(format!("resume failed: {e}"));
}
}
/// Report a pane-focus change to the daemon (docs/SIDEBAR.md's
/// `AwaitingInput` auto-clear). Only `Focus::Pane` on the active tab's
/// session counts — the sidebar highlight is never a "focused pane".
@@ -1416,21 +1471,32 @@ impl App {
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
})
});
let pane_title = match shown_sid {
Some(sid) => {
let name = snap
.sessions
.iter()
.find(|session| session.id == sid)
.map(crate::sidebar::name_for)
.unwrap_or_else(|| "session".into());
if previewing {
format!("{name} · preview")
} else {
name
}
}
None => "session".into(),
let shown_session = shown_sid.and_then(|sid| snap.sessions.iter().find(|s| s.id == sid));
let shown_name = shown_session
.map(crate::sidebar::name_for)
.unwrap_or_else(|| "session".into());
let pane_title = if previewing && shown_sid.is_some() {
format!("{shown_name} · preview")
} else {
shown_name.clone()
};
// docs/PREVIEW.md: a session that isn't Running never gets a live
// attach, so an empty pane here means "nothing to attach to", not
// merely "not yet connected" — the pane names it and offers resume
// instead of a terminal that would die on the daemon's 409.
let shown_cwd = shown_session
.map(|s| s.cwd.display().to_string())
.unwrap_or_default();
let pane_placeholder = if pane.is_none() {
shown_session
.filter(|s| s.status != maestro_protocol::SessionStatus::Running)
.map(|s| PanePlaceholder {
name: &shown_name,
cwd: &shown_cwd,
dormant: s.status == maestro_protocol::SessionStatus::Dormant,
})
} else {
None
};
let link_status = self.link.status();
@@ -1439,6 +1505,7 @@ impl App {
home_dir: &self.home_dir,
pane_title: &pane_title,
pane: pane.as_ref(),
pane_placeholder,
state: &self.state,
draft: self.draft.as_ref(),
help: &self.help,
@@ -2124,6 +2191,82 @@ mod tests {
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(sid)));
}
/// #169, docs/PREVIEW.md: a session that isn't `Running` never gets a
/// live attach — the daemon has nothing to answer a 409 with. Committing
/// to it still moves the tab, so the pane can show its placeholder.
#[test]
fn attach_highlighted_never_attaches_a_dormant_session() {
use maestro_protocol::{CatalogEvent, SessionStatus};
let sid = Uuid::from_u128(11);
let mut app = make_app();
let mut s = test_session(sid, "/repo");
s.status = SessionStatus::Dormant;
app.catalog
.apply_event(CatalogEvent::Snapshot { sessions: vec![s] });
app.dispatch(Action::SidebarNext);
assert_eq!(app.dispatch(Action::AttachHighlighted), Vec::new());
assert_eq!(app.state.active_tab().session(), Some(sid));
}
/// #169, docs/PREVIEW.md: moving the highlight across dormant sessions —
/// the sharp case after a reboot — must never try a live preview attach.
#[test]
fn preview_never_attaches_a_dormant_session() {
use maestro_protocol::{CatalogEvent, SessionStatus};
let sid = Uuid::from_u128(12);
let mut app = make_app();
let mut s = test_session(sid, "/repo");
s.status = SessionStatus::Dormant;
app.catalog
.apply_event(CatalogEvent::Snapshot { sessions: vec![s] });
app.dispatch(Action::SidebarNext);
assert_eq!(app.poll_due_preview_at(after_gap()), Vec::new());
assert_eq!(app.state.preview, Some(sid));
}
/// #169: a session that becomes `Running` while already shown — a resume
/// completing, most commonly — attaches without waiting on an unrelated
/// highlight move.
#[test]
fn a_shown_session_attaches_once_it_turns_running() {
use maestro_protocol::{CatalogEvent, SessionStatus};
let sid = Uuid::from_u128(13);
let mut app = make_app();
let mut dormant = test_session(sid, "/repo");
dormant.status = SessionStatus::Dormant;
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![dormant.clone()],
});
app.state.attach_to_focused(sid);
assert_eq!(app.poll_resumed_attach(), Vec::new());
let mut running = dormant;
running.status = SessionStatus::Running;
app.catalog
.apply_event(CatalogEvent::SessionUpdated { session: running });
assert_eq!(app.poll_resumed_attach(), vec![Effect::Attach(sid)]);
}
// -- resume (#166, #169) -------------------------------------------------
#[test]
fn resume_highlighted_asks_for_a_resume() {
use maestro_protocol::SessionStatus;
let mut app = make_app();
let sid = Uuid::from_u128(14);
let mut s = test_session(sid, "/repo");
s.status = SessionStatus::Dormant;
app.catalog
.apply_event(maestro_protocol::CatalogEvent::Snapshot { sessions: vec![s] });
assert_eq!(
app.dispatch(Action::ResumeHighlighted),
vec![Effect::Resume(sid)]
);
}
#[test]
fn enter_on_a_group_toggles_it_without_targeting_a_session() {
use maestro_protocol::CatalogEvent;
+6
View File
@@ -59,6 +59,8 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("N", "FastForwardSpawn"),
("x", "KillHighlighted"),
("r", "RenameHighlighted"),
// Resume a dormant session (#166, #169).
("u", "ResumeHighlighted"),
// Focus
("ctrl-w h", "FocusSidebar"),
// Session scrollback
@@ -257,6 +259,10 @@ mod tests {
cfg.keymap.resolve(Mode::Normal, "N"),
Some(Action::FastForwardSpawn),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "u"),
Some(Action::ResumeHighlighted),
);
}
/// AoE-style defaults (#shortcuts): `q` and `tab` are bare because Normal
+107
View File
@@ -72,6 +72,18 @@ fn inside(r: Rect, col: u16, row: u16) -> bool {
col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height
}
/// What the pane shows in place of a live attach when the session behind it
/// isn't `Running` (`docs/PREVIEW.md`): a session that is not `Running` never
/// gets a live attach, since the daemon has no bridge to answer one.
#[derive(Debug, Clone, Copy)]
pub struct PanePlaceholder<'a> {
pub name: &'a str,
pub cwd: &'a str,
/// `true` for a dormant session (offers resume), `false` for a dead one
/// (`docs/SIDEBAR.md`'s `exited`/`dormant` fragments never both show).
pub dormant: bool,
}
/// Bundle of references the renderer reads from. Built by `App::redraw` each
/// frame.
#[derive(Debug)]
@@ -80,6 +92,9 @@ pub struct RenderInputs<'a> {
pub home_dir: &'a str,
pub pane_title: &'a str,
pub pane: Option<&'a Pane>,
/// What to show instead, when `pane` is `None` because the shown session
/// isn't `Running` rather than merely not yet connected.
pub pane_placeholder: Option<PanePlaceholder<'a>>,
/// The spawn form behind [`OverlayKind::Spawn`], when one is up.
pub draft: Option<&'a crate::draft::DraftPane>,
pub state: &'a AppState,
@@ -147,6 +162,8 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
render_title_bar(f, title_area, inputs.pane_title, pane_focused);
if let Some(p) = inputs.pane {
p.render(f, pane_area);
} else if let Some(ph) = inputs.pane_placeholder {
render_pane_placeholder(f, pane_area, ph);
}
// `RegionMap::pane` is also the PTY resize source. The title bar is
// chrome; every remaining cell belongs to the emulator.
@@ -239,6 +256,44 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
map
}
/// docs/PREVIEW.md / docs/SIDEBAR.md: what the pane can honestly show for a
/// session with no bridge behind it — its metadata and a resume affordance
/// for a dormant one, never a terminal that would die on the daemon's 409.
fn render_pane_placeholder(f: &mut Frame, area: Rect, ph: PanePlaceholder<'_>) {
let palette = palette::active();
let surface = palette.surface(false);
let glyph = if ph.dormant { "◌" } else { "✗" };
let status = if ph.dormant {
"dormant — resume from the sidebar"
} else {
"exited"
};
let mid = area.y + area.height / 2;
let lines = [
(format!("{glyph} {}", ph.name), surface.fg),
(ph.cwd.to_string(), surface.muted),
(status.to_string(), surface.muted),
];
for (i, (text, colour)) in lines.iter().enumerate() {
let Some(y) = mid.checked_sub(1).map(|y| y + i as u16) else {
continue;
};
if y >= area.y + area.height {
continue;
}
let x = area.x + (area.width.saturating_sub(text.chars().count() as u16)) / 2;
f.render_widget(
Paragraph::new(text.as_str()).style(Style::default().bg(surface.bg).fg(*colour)),
Rect::new(
x,
y,
text.chars().count().min(area.width as usize) as u16,
1,
),
);
}
}
fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) {
let palette = palette::active();
let style = if focused {
@@ -435,6 +490,7 @@ mod tests {
home_dir: "",
pane_title: "session",
pane: None,
pane_placeholder: None,
draft: None,
state: &state,
help: &help,
@@ -532,6 +588,7 @@ mod tests {
home_dir: "",
pane_title: "session",
pane: None,
pane_placeholder: None,
draft: Some(&draft),
state: &state,
help: &help,
@@ -587,6 +644,7 @@ mod tests {
home_dir: "",
pane_title: "session",
pane: None,
pane_placeholder: None,
draft: None,
state: &state,
help: &help,
@@ -629,6 +687,7 @@ mod tests {
home_dir: "",
pane_title: "session",
pane: None,
pane_placeholder: None,
draft: None,
state: &state,
help: &help,
@@ -656,4 +715,52 @@ mod tests {
let title: String = (28..80).map(|x| pane_focused[(x, 0)].symbol()).collect();
assert!(title.contains("session"), "missing title: {title:?}");
}
/// docs/PREVIEW.md: a session that isn't `Running` never gets a live
/// attach — the pane names it and offers resume instead of a terminal
/// that would die on the daemon's 409.
#[test]
fn a_dormant_pane_placeholder_names_the_session_and_offers_resume() {
use crate::overlays::HelpOverlay;
use std::collections::HashSet;
let sidebar = Sidebar {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let state = AppState::new();
let inputs = RenderInputs {
sidebar: &sidebar,
home_dir: "",
pane_title: "worker",
pane: None,
pane_placeholder: Some(PanePlaceholder {
name: "worker",
cwd: "/home/n/work",
dormant: true,
}),
draft: None,
state: &state,
help: &help,
rename_input: &crate::text_input::TextInput::default(),
command_bar: None,
link: &online_link(),
};
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| {
draw(f, inputs);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let text: String = buf.content().iter().map(|c| c.symbol()).collect();
assert!(text.contains("worker"), "{text}");
assert!(text.contains("/home/n/work"), "{text}");
assert!(text.contains("dormant"), "{text}");
assert!(text.contains("resume"), "{text}");
}
}
+143 -12
View File
@@ -184,8 +184,16 @@ impl Sidebar {
grouped
.into_iter()
.map(|(key, mut sessions)| {
sessions
.sort_by(|a, b| name_for(a).cmp(&name_for(b)).then_with(|| a.id.cmp(&b.id)));
// docs/SIDEBAR.md, "Dormant cards": a dormant session takes
// the lowest precedence, name notwithstanding, so a reboot
// that leaves a tree dormant never buries live work spawned
// into it.
sessions.sort_by(|a, b| {
dormant_rank(a)
.cmp(&dormant_rank(b))
.then_with(|| name_for(a).cmp(&name_for(b)))
.then_with(|| a.id.cmp(&b.id))
});
SidebarGroup {
collapsed: collapsed.contains(&key),
key,
@@ -276,11 +284,28 @@ impl Sidebar {
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let fill = self.card_fill(s, focused);
let base = card_base(fill, focused);
let palette = palette::active();
let surface = palette.surface(fill.filled());
let prefix = 1 + INDENT.chars().count() + 2;
let name_budget = width.saturating_sub(prefix);
// docs/SIDEBAR.md, "Dormant cards": recovery resets a dormant
// session's activity to Idle and there is nothing running to report
// otherwise, so the rail carries no colour — the muted role, not the
// idle one.
let dormant = s.status == SessionStatus::Dormant;
let muted_style = base.patch(
surface
.muted_style()
.bg(base.bg.unwrap_or(palette::active().chrome_bg)),
);
let edge = highlighted && !focused;
let rail_style = base.fg(activity_color(s.activity, fill));
let rail_style = if dormant {
base.fg(surface.muted)
} else {
base.fg(activity_color(s.activity, fill))
};
let mut spans: Vec<Span<'static>> = vec![
Span::styled(RAIL, rail_style),
Span::styled(
@@ -291,18 +316,19 @@ impl Sidebar {
base
},
),
Span::styled(format!("{glyph} "), base),
Span::styled(
format!("{glyph} "),
if dormant { muted_style } else { base },
),
];
// #131: a name the daemon generated is provisional — the session's
// first prompt may replace it — so it reads muted, like the meta line,
// rather than as a name someone chose. See docs/SIDEBAR.md.
let name_style = if maestro_protocol::display::is_provisional_name(s) {
base.patch(
palette::active()
.surface(fill.filled())
.muted_style()
.bg(base.bg.unwrap_or(palette::active().chrome_bg)),
)
// A dormant card (#168, #169) is a muted variant of the whole card —
// the same muted foreground `exited` already uses — regardless of
// whether the name itself is provisional.
let name_style = if dormant || maestro_protocol::display::is_provisional_name(s) {
muted_style
} else {
base
};
@@ -328,11 +354,18 @@ impl Sidebar {
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let fill = self.card_fill(s, focused);
let base = card_base(fill, focused);
let rail_style = base.patch(Style::default().fg(activity_color(s.activity, fill)));
let muted = palette::active()
.surface(fill.filled())
.muted_style()
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
// docs/SIDEBAR.md, "Dormant cards": the rail carries no colour, so the
// duration that follows it (rendered in the rail's own colour) is
// muted rather than the idle role.
let rail_style = if dormant {
base.patch(muted)
} else {
base.patch(Style::default().fg(activity_color(s.activity, fill)))
};
let edge = highlighted && !focused;
let mut spans = vec![
Span::styled(RAIL, rail_style),
@@ -458,6 +491,10 @@ fn pad_to_width(mut spans: Vec<Span<'static>>, width: usize, base: Style) -> Vec
spans
}
fn dormant_rank(s: &Session) -> u8 {
u8::from(s.status == SessionStatus::Dormant)
}
/// `metadata.name` if present, else the command line.
pub(crate) fn name_for(s: &Session) -> String {
custom_name_for(s).unwrap_or_else(|| s.command.clone())
@@ -1108,6 +1145,100 @@ mod tests {
);
}
/// docs/SIDEBAR.md, "Dormant cards" (#169): a dormant session sorts
/// after every live one in its folder, name notwithstanding.
#[test]
fn dormant_sessions_sort_after_live_ones_in_their_folder() {
let mut dormant = session_named(1, "aaa-dormant");
dormant.status = SessionStatus::Dormant;
let live = session_named(2, "zzz-live");
let sb = Sidebar {
sessions: vec![dormant, live],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let names: Vec<String> = sb.groups(&HashSet::new())[0]
.sessions
.iter()
.map(name_for)
.collect();
assert_eq!(names, vec!["zzz-live", "aaa-dormant"]);
}
/// Dormant sessions still sort by name against each other.
#[test]
fn dormant_sessions_still_sort_by_name_against_each_other() {
let mut b = session_named(1, "b");
b.status = SessionStatus::Dormant;
let mut a = session_named(2, "a");
a.status = SessionStatus::Dormant;
let sb = Sidebar {
sessions: vec![b, a],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let names: Vec<String> = sb.groups(&HashSet::new())[0]
.sessions
.iter()
.map(name_for)
.collect();
assert_eq!(names, vec!["a", "b"]);
}
/// docs/SIDEBAR.md, "Dormant cards": a dormant card is a muted variant of
/// the normal card — the same muted foreground `exited` already uses —
/// covering the glyph and the name, not only the meta line.
#[test]
fn dormant_card_renders_its_name_and_glyph_muted() {
let mut s = session_named(1, "claude");
s.status = SessionStatus::Dormant;
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 44, 8);
let palette = palette::active();
let surface = palette.surface(false);
let colour_of = |needle: char| {
(0..buf.area.height)
.flat_map(|y| (0..buf.area.width).map(move |x| (x, y)))
.find(|&(x, y)| buf[(x, y)].symbol() == needle.to_string())
.map(|(x, y)| buf[(x, y)].fg)
.expect("must be drawn")
};
assert_eq!(colour_of('c'), surface.muted, "the dormant name");
assert_eq!(colour_of('\u{25cc}'), surface.muted, "the dormant glyph");
}
/// docs/SIDEBAR.md: recovery resets a dormant session's activity to Idle
/// and there is nothing running to report, so the rail is driven by the
/// muted role directly rather than `activity_color`.
#[test]
fn dormant_card_rail_carries_no_colour() {
let mut s = session_named(1, "claude");
s.status = SessionStatus::Dormant;
s.activity = ActivityState::Idle;
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 44, 8);
let palette = palette::active();
let surface = palette.surface(false);
let rail_colour = (0..buf.area.height)
.flat_map(|y| (0..buf.area.width).map(move |x| (x, y)))
.find(|&(x, y)| buf[(x, y)].symbol() == RAIL)
.map(|(x, y)| buf[(x, y)].fg)
.expect("rail must be drawn");
assert_eq!(rail_colour, surface.muted);
}
/// docs/SIDEBAR.md: line 2 names the program, never its arguments.
#[test]
fn meta_line_drops_the_commands_arguments() {
+6
View File
@@ -28,6 +28,12 @@ A preview is a view, not a state. Nothing about it is durable:
| Already in a pane, in another tab | That tab is brought forward. The active tab is restored when the preview ends. |
| Not in any pane | A throwaway tab carries it. It is removed when the preview ends. |
A session that is not `Running` — a dormant one, most often — never gets a
live attach here or anywhere else a pane is filled: the daemon has nothing to
answer a `409 not_attachable` and the socket would just die (WS close 1006).
Its pane shows the same metadata-and-resume view `docs/SIDEBAR.md` describes
for its card, honest about the fact that there is nothing to preview yet.
The throwaway tab never counts as an open session: the tab bar does not appear
because of it, the mobile shell does not switch away from the session list for
it, and the sidebar does not mark the session as open.
+7 -1
View File
@@ -98,7 +98,13 @@ conversation id is respawned on the command it was created with. `Session::comma
always keeps that original command, so a second resume resolves the same preset.
Resume is refused with `409` for a session that is already running
(`already_running`) and for one whose `cwd` has disappeared (`cwd_missing`) —
neither is something a respawn could fix. The other thing to do with a dormant
neither is something a respawn could fix. A session with a resume already in
flight is refused as `already_running` too (#175): the respawn happens with
the session map unlocked, so the record is marked in-flight before it starts
and the second request is answered on that state rather than on its tmux spawn
colliding with the first one's. A resume that loses a race with a kill takes
its own sidecar back off disk, so the killed session does not return as a
dormant ghost on the next recovery. The other thing to do with a dormant
session is dismiss it, which removes the sidecar so the next recovery does not
bring it back.
+32
View File
@@ -113,6 +113,33 @@ pane, no scrollback, and nothing to attach to. It is not an exit — nothing was
observed ending — so the two fragments never both show. The TUI marks the same
state on line 1 with `◌`, next to `✗` for exited and `●`/`○` for running.
### Dormant cards (#168)
A dormant card is a muted variant of the normal card (the web client's
`is-dormant` class, the same muted foreground `exited` already uses): name,
cwd and last-known age render as usual, but the activity rail carries no
colour, since recovery resets a dormant session's activity to `Idle` and
there is nothing running to report otherwise.
The row's right-hand actions swap **spawn similar** for **resume** (↻) — there
is no live state to clone from — and call `POST /api/sessions/:id/resume`
(#166). **Kill** stays: it is how a dormant session is dismissed, since the
daemon already treats a kill of a dormant session as removing its sidecar
rather than anything it needs to be running to do.
A dormant session takes the **lowest precedence in the ordering**: within a
folder, every live (`Running` or `Dead`) session sorts before every dormant
one, name notwithstanding, and dormant sessions sort among themselves by name.
So a reboot that leaves a whole tree dormant never buries the live work a
session spawns into it.
Selecting a dormant session — by click, by keyboard, or by preview
(`docs/PREVIEW.md`) — never opens a live attach: the content pane shows the
same metadata and resume affordance the card does, rather than a terminal
that would die on the daemon's `409 not_attachable` (WS close 1006). Resuming
turns the session `Running`; the pane and the card pick that up on their own
next update, no navigation required.
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only
while something would block an unforced kill, and reads:
@@ -331,6 +358,11 @@ The TUI binds these through its keymap, so a user can rebind any of them; the
web client's are fixed. A binding added to one client belongs in this table and
in both help overlays.
The TUI additionally binds `u` to resume the highlighted dormant session
(#166, #169). The web client has no keystroke for it — its own resume
affordance is the card's ↻ button and the context menu's **Resume** entry, per
"Dormant cards" above — so this one has no web-side pair.
## Killing a session
**Every kill confirms first, in both clients** (#160). A kill is destructive
+12
View File
@@ -91,6 +91,18 @@ export async function killSession(id: Uuid, force = false): Promise<void> {
);
}
/** Respawn a dormant session under its own uuid and sidecar (#166/#168).
* Refused with `409` (`already_running` / `cwd_missing`) when there is
* nothing a respawn could fix. */
export async function resumeSession(id: Uuid): Promise<Session> {
return jsonOrError(
await fetch(routes.sessionResume(id), {
method: "POST",
credentials: "same-origin",
}),
);
}
/** Report whether this client's pane is the one currently showing `id` — not
* merely highlighted in a sidebar. The daemon uses this to clear an
* `AwaitingInput` glow once the pane has been looked at long enough
+22 -3
View File
@@ -65,9 +65,17 @@ export interface AttachOptions {
* dropped — but NOT on an intentional `dispose()` (eviction / user close). The
* shell uses this to auto-close the pane rather than leave a dead one. */
onClosed?: (sessionId: Uuid) => void;
/** Invoked when the handshake itself never completed — no retry was
* attempted (see `shouldReconnect`'s `everOpened`). Unlike `onClosed`, this
* is not "the session is gone": it is at least as likely a dormant
* session's `409 not_attachable` caught mid-status-update (#168 review).
* The shell re-renders rather than tearing the pane's leaf out, so a
* dormant session's resume affordance can take over in place. */
onNeverAttached?: (sessionId: Uuid) => void;
/** Asked on every close: should the socket be retried? The shell answers
* from the catalog — see `shouldRetryClose`. */
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
* from the catalog — see `shouldRetryClose`. `everOpened` is false when
* this attempt never reached `open`. */
shouldReconnect?: (sessionId: Uuid, code: number, everOpened: boolean) => boolean;
}
/** What the header shows about the session (name / command). Optional — falls
@@ -379,6 +387,10 @@ export function createAttachConnection(
let ws: WebSocket;
let reconnectMs = INITIAL_RECONNECT_MS;
let reconnectTimer: number | null = null;
// #168 review: a fresh attempt that never reaches `open` is a rejected
// handshake (dormant/unknown session), not a dropped stream — see
// `shouldRetryClose`'s `everOpened` param.
let everOpened = false;
const sendControl = (msg: ClientControl) => {
if (ws.readyState !== WebSocket.OPEN) return;
@@ -402,6 +414,7 @@ export function createAttachConnection(
ws.binaryType = "arraybuffer";
ws.onopen = () => {
everOpened = true;
reconnectMs = INITIAL_RECONNECT_MS;
setState("connected", "open");
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
@@ -444,7 +457,7 @@ export function createAttachConnection(
// close handle their own pane teardown.
if (disposed) return;
if (opts.shouldReconnect?.(sessionId, ev.code)) {
if (opts.shouldReconnect?.(sessionId, ev.code, everOpened)) {
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
setState("reconnecting…", "connecting");
reconnectTimer = window.setTimeout(() => {
@@ -455,6 +468,12 @@ export function createAttachConnection(
return;
}
if (!everOpened) {
setState("not attached", "closed");
opts.onNeverAttached?.(sessionId);
return;
}
setState(`closed (${ev.code})`, "closed");
opts.onClosed?.(sessionId);
};
+38
View File
@@ -427,6 +427,44 @@ describe("session cards (#107)", () => {
expect(line2?.textContent).not.toContain("exited");
});
// #168: a dormant card is a muted variant with a resume action in place of
// "spawn similar", since there is nothing running to clone from live state.
it("renders a dormant card muted, with a resume action instead of spawn-similar", async () => {
const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
return Promise.resolve(
new Response(JSON.stringify({ id: "aaaaaaaa-0000-0000-0000-000000000120", status: "Running" }), {
status: 200,
}),
);
}) as typeof fetch;
const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000120", status: "Dormant" });
const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id) as HTMLElement;
expect(row.classList.contains("is-dormant")).toBe(true);
expect(row.querySelector(".leaf-clone")).toBeNull();
const resumeBtn = row.querySelector<HTMLButtonElement>(".leaf-resume");
expect(resumeBtn).toBeTruthy();
resumeBtn?.click();
await flush();
expect(calls).toContain(`POST /api/sessions/${s.id}/resume`);
});
// #168: a two-way running/dead test made a dormant card lie to screen
// readers — the visible flag says dormant, the aria-label said "exited".
it("marks a dormant row's status and aria-label distinctly from dead", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000119",
status: "Dormant",
});
const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id) as HTMLElement;
expect(row.dataset.status).toBe("dormant");
expect(row.getAttribute("aria-label")).toContain("dormant");
expect(row.getAttribute("aria-label")).not.toContain("exited");
});
it("carries the state word only in tooltips and aria labels, never as text", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000019",
+53 -19
View File
@@ -1,5 +1,5 @@
import { escapeHtml } from "./html.js";
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
import { ApiRequestError, getDaemonInfo, killSession, renameSession, resumeSession } from "./api.js";
import { openForceKillDialog, openKillConfirmDialog } from "./killDialog.js";
import { openContextMenu } from "./contextMenu.js";
import { toggleHelp } from "./help.js";
@@ -104,6 +104,21 @@ const STATE_WORDS: Record<string, string> = {
error: "Error",
};
// #168: dormancy is not an exit (docs/SIDEBAR.md), so `dataset.status` and
// the aria-label read the session's own three-way `status` rather than a
// two-way running/dead test that folded `Dormant` into "dead"/"exited".
function statusSlug(status: Session["status"]): string {
if (status === "Running") return "running";
if (status === "Dormant") return "dormant";
return "dead";
}
function statusWord(status: Session["status"]): string {
if (status === "Running") return "running";
if (status === "Dormant") return "dormant";
return "exited";
}
/** docs/SIDEBAR.md: the activity rail is pinned to the row's own left edge,
* full height. The row's own padding (`renderNode`) shifts outward with
* nesting depth, carrying the rail with it. */
@@ -203,33 +218,46 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
row.style.paddingLeft = `${depth * 16 + 8}px`;
row.dataset.sessionId = s.id;
row.dataset.navKey = `session:${s.id}`;
const running = s.status === "Running";
row.dataset.status = running ? "running" : "dead";
row.dataset.status = statusSlug(s.status);
// docs/SIDEBAR.md: a dormant card is a muted variant — nothing running
// behind it, so it earns no activity colour or spawn-similar affordance.
const dormant = s.status === "Dormant";
row.classList.toggle("is-dormant", dormant);
const name = sessionName(s);
const slug = activityColorSlug(s.activity ?? "Idle");
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
row.setAttribute(
"aria-label",
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
);
row.setAttribute("aria-label", `${name}, ${statusWord(s.status)}, ${stateWord}`);
const cloneOrResumeAction = dormant
? `<button class="leaf-act leaf-resume" type="button" title="Resume session" aria-label="Resume ${escapeHtml(name)}">↻</button>`
: `<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>`;
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
row.innerHTML = `
${activityRailMarkup(slug, stateWord)}
<span class="card-line1">
<span class="leaf-name${isProvisionalName(s) ? " is-provisional" : ""}">${escapeHtml(name)}</span>
<span class="leaf-actions">
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
${cloneOrResumeAction}
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
</span>
</span>
${metaLine(s, now)}
`;
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
similarBtn.addEventListener("click", (ev) => {
const similarBtn = row.querySelector<HTMLButtonElement>(".leaf-clone");
similarBtn?.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur }));
});
const resumeBtn = row.querySelector<HTMLButtonElement>(".leaf-resume");
resumeBtn?.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
resumeBtn.disabled = true;
void resumeSession(cur.id).catch((e: Error) => {
resumeBtn.disabled = false;
alert(`Failed to resume: ${e.message}`);
});
});
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
killBtn.addEventListener("click", (ev) => {
ev.preventDefault();
@@ -244,11 +272,19 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
ev.stopPropagation();
openContextMenu(ev.clientX, ev.clientY, [
{ label: "Rename…", onSelect: () => beginRename(row, cur) },
{
label: "Spawn similar",
onSelect: () =>
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
},
cur.status === "Dormant"
? {
label: "Resume",
onSelect: () =>
void resumeSession(cur.id).catch((e: Error) =>
alert(`Failed to resume: ${e.message}`),
),
}
: {
label: "Spawn similar",
onSelect: () =>
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
},
{
label: "Kill session",
danger: true,
@@ -260,10 +296,8 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
cur = next;
const nextSlug = activityColorSlug(next.activity ?? "Idle");
const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle;
row.setAttribute(
"aria-label",
`${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`,
);
row.dataset.status = statusSlug(next.status);
row.setAttribute("aria-label", `${sessionName(next)}, ${statusWord(next.status)}, ${nextWord}`);
const rail = row.querySelector(".activity-rail");
if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord);
const meta = row.querySelector(".card-line2");
+9 -2
View File
@@ -25,8 +25,13 @@ export interface PoolOptions {
/** Fired when a connection's socket closes on its own (session ended/dropped),
* not via an intentional close/eviction. The embedder closes the pane. */
onClosed?: (id: Uuid) => void;
/** Fired when a connection's handshake never completed and so was never
* retried (see `AttachOptions.onNeverAttached`). Unlike `onClosed`, the
* embedder should not assume the session is gone — a dormant session's
* pane wants its resume affordance, not teardown. */
onNeverAttached?: (id: Uuid) => void;
/** Forwarded to each connection: should a dropped socket be retried? */
shouldReconnect?: (id: Uuid, code: number) => boolean;
shouldReconnect?: (id: Uuid, code: number, everOpened: boolean) => boolean;
}
/**
@@ -85,7 +90,9 @@ export class ConnectionPool {
{
onRequestClose: (sid) => this.opts.onRequestClose?.(sid),
onClosed: (sid) => this.opts.onClosed?.(sid),
shouldReconnect: (sid, code) => this.opts.shouldReconnect?.(sid, code) ?? false,
onNeverAttached: (sid) => this.opts.onNeverAttached?.(sid),
shouldReconnect: (sid, code, everOpened) =>
this.opts.shouldReconnect?.(sid, code, everOpened) ?? false,
},
info,
);
+29 -1
View File
@@ -19,6 +19,7 @@ import {
getDaemonInfo,
gitRoot,
projects as fetchProjects,
resumeSession,
} from "./api.js";
import {
buildFastForwardRequest,
@@ -172,17 +173,42 @@ const pool = new ConnectionPool({
// pane is never left lying around.
onRequestClose: (id) => closePane(id),
onClosed: (id) => closePane(id),
shouldReconnect: (id, code) => shouldRetryClose(id, code, store.snapshot().sessions),
// #168 review: a handshake that never opened (a dormant session's `409
// not_attachable`, most likely) isn't "the session is gone" — drop the dead
// connection and re-render so the leaf gets a fresh attempt if the catalog
// still says Running, or the resume affordance if it doesn't.
onNeverAttached: (id) => {
pool.close(id);
mv?.render();
},
shouldReconnect: (id, code, everOpened) =>
shouldRetryClose(id, code, store.snapshot().sessions, everOpened),
});
// Session death used to reach the UI on two uncoordinated channels — the row
// from the events socket, the pane from `ws.onclose` — so either could happen
// without the other. The catalog is the authority: a pane for a session that is
// no longer listed gets closed here.
//
// A pane can also be showing the dormant placeholder (or, briefly, a dead
// socket) rather than a live attach (#168): when an *open* session's
// Running-ness flips — most commonly a resume completing — the pane and the
// placeholder need to swap without waiting on an unrelated layout change.
let lastOpenRunning = new Map<string, boolean>();
store.subscribe((state) => {
for (const id of mv?.openSessions() ?? []) {
if (!state.sessions.has(id)) closePane(id);
}
const open = mv?.openSessions() ?? new Set<string>();
const nextRunning = new Map<string, boolean>();
let runningChanged = false;
for (const id of open) {
const running = state.sessions.get(id)?.status === "Running";
nextRunning.set(id, running);
if (lastOpenRunning.get(id) !== running) runningChanged = true;
}
lastOpenRunning = nextRunning;
if (runningChanged) mv?.render();
});
function closePane(id: string): void {
@@ -275,6 +301,8 @@ mv = createMultiView({
// catalog (no sessions) to attach.
onOpenSessionsChange: updateShellRoute,
onLayoutChange: scheduleSave,
sessionFor: (id) => store.snapshot().sessions.get(id),
onResume: (id) => resumeSession(id),
});
let contentShowsMv = false;
/** Put the multi-view in the content pane, without rendering it. A preview
+98 -1
View File
@@ -45,7 +45,7 @@ function fakeSidebar(): SidebarHandle {
};
}
function makeMv(layout: Layout): MultiView {
function makeMv(layout: Layout, overrides: Partial<Parameters<typeof createMultiView>[0]> = {}): MultiView {
return createMultiView({
pool: fakePool(),
layout,
@@ -53,6 +53,7 @@ function makeMv(layout: Layout): MultiView {
infoFor: () => ({ name: "sess", command: "bash" }),
onDisconnect: () => {},
renderWelcome: () => document.createElement("div"),
...overrides,
});
}
@@ -87,6 +88,102 @@ describe("multiview empty panes", () => {
});
});
// #168 review: highlighting a dormant session used to open a pane and attempt
// a live attach, which the daemon answers with 404/409 and the socket dies
// with WS 1006. A dormant leaf must never reach the pool at all — it gets the
// resume affordance instead.
describe("multiview dormant gating (#168)", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
});
function dormantSession(id: string) {
return {
id,
cwd: "/home/n/work",
command: "claude",
env: {},
metadata: { name: "worker" },
status: "Dormant" as const,
created_at: "2026-08-24T10:00:00Z",
};
}
it("never hands a dormant session's leaf to the pool, and shows a resume affordance", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "d1", "row", true);
const setVisible = vi.fn();
const pool = { setVisible, setFocus() {} } as unknown as ConnectionPool;
const mv = createMultiView({
pool,
layout,
sidebar: fakeSidebar(),
infoFor: () => ({ name: "worker", command: "claude" }),
onDisconnect: () => {},
renderWelcome: () => document.createElement("div"),
sessionFor: (id) => (id === "d1" ? dormantSession("d1") : undefined),
});
document.body.appendChild(mv.el);
mv.render();
await flush();
expect(setVisible).toHaveBeenCalledWith([], expect.anything());
const pane = mv.el.querySelector('[data-leaf-id]') as HTMLElement;
expect(pane.querySelector(".mv-pane-dormant")).toBeTruthy();
expect(pane.textContent).toContain("worker");
expect(pane.textContent).toContain("/home/n/work");
expect(pane.querySelector(".mv-dormant-resume")).toBeTruthy();
});
it("still attaches a Running session's leaf normally", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "r1", "row", true);
const setVisible = vi.fn();
const pool = { setVisible, setFocus() {} } as unknown as ConnectionPool;
const mv = createMultiView({
pool,
layout,
sidebar: fakeSidebar(),
infoFor: () => ({ name: "worker", command: "claude" }),
onDisconnect: () => {},
renderWelcome: () => document.createElement("div"),
sessionFor: (id) => ({ ...dormantSession(id), status: "Running" }),
});
document.body.appendChild(mv.el);
mv.render();
await flush();
expect(setVisible).toHaveBeenCalledWith(
[expect.objectContaining({ id: "r1" })],
expect.anything(),
);
});
it("clicking resume calls back with the session id", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "d1", "row", true);
const onResume = vi.fn().mockResolvedValue(undefined);
const mv = makeMv(layout, {
sessionFor: (id) => (id === "d1" ? dormantSession("d1") : undefined),
onResume,
});
document.body.appendChild(mv.el);
mv.render();
await flush();
mv.el.querySelector<HTMLButtonElement>(".mv-dormant-resume")?.click();
expect(onResume).toHaveBeenCalledWith("d1");
});
});
describe("multiview pane-drag swap", () => {
beforeEach(() => {
stubFetch();
+61 -2
View File
@@ -6,6 +6,10 @@
import type { SidebarHandle } from "./catalog.js";
import type { ConnectionPool } from "./connections.js";
import { activityDurationLabel, activityHeldMs } from "./activity.js";
import { escapeHtml } from "./html.js";
import { sessionName } from "./types.js";
import type { Session } from "./types.js";
import {
activeTab,
addTab,
@@ -34,6 +38,14 @@ export interface MultiViewOptions {
onDisconnect: (sessionId: string) => void;
/** Welcome content for an empty tab. */
renderWelcome: () => HTMLElement;
/** The session behind a leaf, when known — used to gate a live attach on
* `status === "Running"` (#168 review) and to fill in the dormant pane's
* resume affordance. A leaf whose session this returns `undefined` for
* (unknown to the caller) attaches as before, so callers that don't wire
* this up see no change in behaviour. */
sessionFor?: (sessionId: string) => Session | undefined;
/** Resume a dormant session (#166/#168), from its pane's own affordance. */
onResume?: (sessionId: string) => Promise<unknown>;
/** Called when a spawn fills a pane with a new session, so the shell can
* re-evaluate its route (sidebar vs attach layout). */
onOpenSessionsChange?: () => void;
@@ -262,6 +274,44 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
persist();
}
// ---- dormant panes (#168 review) ----
/** The session behind `sessionId` when it is known and not `Running` —
* what gates a leaf away from a live attach and towards the resume
* affordance instead. */
function dormantSessionFor(sessionId: string): Session | undefined {
const s = opts.sessionFor?.(sessionId);
return s && s.status !== "Running" ? s : undefined;
}
/** docs/PREVIEW.md / docs/SIDEBAR.md: what the content pane can honestly
* show for a session with no pane behind it — its metadata and a resume
* affordance, never a live terminal that would just die with WS 1006. */
function renderDormantPane(host: HTMLElement, session: Session): void {
const heldMs = activityHeldMs(session.last_activity_at ?? session.created_at, Date.now());
const age = heldMs !== null ? activityDurationLabel(heldMs) : null;
const wrap = document.createElement("div");
wrap.className = "mv-pane-dormant";
wrap.innerHTML = `
<div class="mv-dormant-glyph" aria-hidden="true">◌</div>
<div class="mv-dormant-name">${escapeHtml(sessionName(session))}</div>
<div class="mv-dormant-cwd" title="${escapeHtml(session.cwd)}">${escapeHtml(session.cwd)}</div>
<div class="mv-dormant-detail">dormant${age ? ` for ${escapeHtml(age)}` : ""} — nothing running behind it</div>
<button type="button" class="btn btn-primary mv-dormant-resume">Resume</button>
`;
const btn = wrap.querySelector(".mv-dormant-resume") as HTMLButtonElement;
btn.addEventListener("click", () => {
btn.disabled = true;
btn.textContent = "Resuming…";
opts.onResume?.(session.id)?.catch((e: Error) => {
btn.disabled = false;
btn.textContent = "Resume";
alert(`Failed to resume: ${e.message}`);
});
});
host.replaceChildren(wrap);
}
// ---- pane + split DOM ----
function buildPane(leaf: Extract<Node, { kind: "leaf" }>): {
@@ -378,7 +428,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (node.kind === "leaf") {
const pane = buildPane(node);
if (node.sessionId) {
hosts.push({ id: node.sessionId, host: pane.host, info: infoFor(node.sessionId) });
const dormant = dormantSessionFor(node.sessionId);
if (dormant) {
renderDormantPane(pane.host, dormant);
} else {
hosts.push({ id: node.sessionId, host: pane.host, info: infoFor(node.sessionId) });
}
}
return pane.el;
}
@@ -616,9 +671,13 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
wrap.appendChild(pane.el);
canvas.appendChild(wrap);
const dormant = focused.sessionId ? dormantSessionFor(focused.sessionId) : undefined;
if (dormant) renderDormantPane(pane.host, dormant);
// Only the focused leaf's session is live on mobile; the rest park.
pool.setVisible(
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
focused.sessionId && !dormant
? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }]
: [],
{ focus: !preview && !quietRender },
);
if (!preview) pool.setFocus(focused.sessionId ?? null);
+7
View File
@@ -47,6 +47,13 @@ describe("shouldRetryClose", () => {
it("never retries a clean close", () => {
expect(shouldRetryClose("a", 1000, running)).toBe(false);
});
// #168 review: a handshake that never opened is a rejection (e.g. a
// dormant session's 409 not_attachable), not a dropped stream. Retrying it
// loops forever if the catalog status hasn't caught up yet.
it("never retries a socket that never opened, even if the catalog still says running", () => {
expect(shouldRetryClose("a", 1006, running, false)).toBe(false);
});
});
describe("backoff bounds", () => {
+8
View File
@@ -26,7 +26,15 @@ export function shouldRetryClose(
sessionId: Uuid,
code: number,
sessions: ReadonlyMap<Uuid, Session>,
/** Did this socket ever reach `open`? A handshake that never completed is
* the daemon rejecting the attach (dormant/unknown) rather than a dropped
* stream — retrying it spins in place until the catalog updates, which for
* a dormant session it never will (#168 review). Defaults to `true` so
* existing callers that only track drops of an established link are
* unaffected. */
everOpened = true,
): boolean {
if (code >= APPLICATION_CLOSE || code === CLEAN_CLOSE) return false;
if (!everOpened) return false;
return sessions.get(sessionId)?.status === "Running";
}
+6
View File
@@ -37,6 +37,12 @@ export function sessionFocus(id: Uuid): string {
return `${session(id)}/focus`;
}
/** `POST /api/sessions/:id/resume` (#166/#168): respawn a dormant session
* under its own uuid and sidecar. */
export function sessionResume(id: Uuid): string {
return `${session(id)}/resume`;
}
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
export function withPathQuery(route: string, value: string): string {
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
+45 -4
View File
@@ -1210,12 +1210,16 @@ textarea {
}
/* Exited sessions recede: dimmed name + an "exited" badge carry status, so it's
never the running tint's absence alone doing the work. */
.tree-leaf[data-status="dead"] {
never the running tint's absence alone doing the work. A dormant session
(#168, docs/SIDEBAR.md) reads the same muted way — it is known, not gone,
but there is nothing running behind it either. */
.tree-leaf[data-status="dead"],
.tree-leaf.is-dormant {
color: var(--muted);
}
.tree-leaf[data-status="dead"] .leaf-name {
.tree-leaf[data-status="dead"] .leaf-name,
.tree-leaf.is-dormant .leaf-name {
color: var(--muted);
font-weight: 400;
}
@@ -1754,7 +1758,8 @@ input.leaf-rename:focus {
}
}
.leaf-clone:hover {
.leaf-clone:hover,
.leaf-resume:hover {
color: var(--accent);
background: var(--accent-tint);
border-color: color-mix(in oklab, var(--accent) 40%, transparent);
@@ -3265,6 +3270,42 @@ input.leaf-rename:focus {
font-size: var(--fs-13);
}
/* #168: what a pane shows for a dormant session — its metadata and a resume
action, in place of the live terminal there is nothing to attach to. */
.mv-pane-dormant {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--sp-2);
padding: var(--sp-4);
text-align: center;
color: var(--muted);
}
.mv-dormant-glyph {
font-size: 28px;
color: var(--faint);
}
.mv-dormant-name {
font-size: var(--fs-15);
font-weight: 500;
color: var(--ink);
}
.mv-dormant-cwd {
font-size: var(--fs-12);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mv-dormant-detail {
font-size: var(--fs-12);
}
.mv-dormant-resume {
margin-top: var(--sp-2);
}
/* pane tools cluster (disconnect / split / close), top-right corner. The attach
header's own disconnect button is hidden — ⏻ lives here as the leftmost tool. */
.mv-pane-tools {
+37 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildTree, mergeLinear, type TreeFolder, type TreeNode } from "./tree.js";
import { buildTree, compareNodes, mergeLinear, type TreeFolder, type TreeNode } from "./tree.js";
import type { Session } from "./types.js";
function session(id: string, cwd: string, command = "bash"): Session {
@@ -67,6 +67,42 @@ describe("buildTree", () => {
expect(root.children[0].kind).toBe("folder");
expect(root.children[1].kind).toBe("leaf");
});
// #168, docs/SIDEBAR.md: a dormant session takes the lowest precedence in
// the ordering, so live work always sorts above it, even a name that would
// otherwise sort first.
it("sorts a dormant session after live ones in the same folder, name notwithstanding", () => {
const dormant = { ...session("aaa-dormant", "/home/n/work"), status: "Dormant" } as Session;
const live = session("zzz-live", "/home/n/work");
const root = buildTree([dormant, live], "/home/n") as TreeFolder;
const folder = root.children[0] as TreeFolder;
const ids = folder.children.map((c) => (c.kind === "leaf" ? c.session.id : c.name));
expect(ids).toEqual(["zzz-live", "aaa-dormant"]);
});
it("still sorts dormant sessions by name against each other", () => {
const a = {
...session("a", "/home/n/work"),
status: "Dormant",
metadata: { name: "a" },
} as Session;
const b = {
...session("b", "/home/n/work"),
status: "Dormant",
metadata: { name: "b" },
} as Session;
const root = buildTree([b, a], "/home/n") as TreeFolder;
const folder = root.children[0] as TreeFolder;
const ids = folder.children.map((c) => (c.kind === "leaf" ? c.session.id : c.name));
expect(ids).toEqual(["a", "b"]);
});
it("exposes the leaf/name precedence directly via compareNodes", () => {
const dormant: TreeNode = { kind: "leaf", session: { ...session("a", "/x"), status: "Dormant" } };
const live: TreeNode = { kind: "leaf", session: session("b", "/x") };
expect(compareNodes(dormant, live)).toBeGreaterThan(0);
expect(compareNodes(live, dormant)).toBeLessThan(0);
});
});
describe("mergeLinear", () => {
+8
View File
@@ -18,8 +18,16 @@ export interface TreeLeaf {
export type TreeNode = TreeFolder | TreeLeaf;
/** docs/SIDEBAR.md: a dormant session takes the lowest precedence in the
* ordering, so live work always sorts above it regardless of name. */
function dormantRank(node: TreeNode): number {
return node.kind === "leaf" && node.session.status === "Dormant" ? 1 : 0;
}
export function compareNodes(a: TreeNode, b: TreeNode): number {
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
const rank = dormantRank(a) - dormantRank(b);
if (rank !== 0) return rank;
const an = a.kind === "folder" ? a.name : sessionName(a.session);
const bn = b.kind === "folder" ? b.name : sessionName(b.session);
return an.localeCompare(bn);