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 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
24 changed files with 927 additions and 106 deletions
+8 -10
View File
@@ -1305,12 +1305,10 @@ impl SessionManager {
/// and stays silent on the event stream at steady state.
///
/// `agent_session_id` (#161) is the agent's own conversation id, parsed
/// by the caller from the hook payload's `session_id` field. The
/// sidecar is rewritten only when the reported id differs from the
/// stored one — a repeated report of the same id is pure churn from the
/// hook's `PreToolUse` cadence and writes nothing, but a resume (#176)
/// issues a new conversation id and must replace the stale one, or
/// every later resume reattaches the pre-resume conversation.
/// by the caller from the hook payload's `session_id` field. It is
/// written once — the first report that carries one wins — and the
/// sidecar is only rewritten on that transition, never on every report,
/// since an agent's session id never changes mid-session.
pub async fn report_activity(
&self,
id: Uuid,
@@ -1332,16 +1330,16 @@ impl SessionManager {
state,
self.inner.report_ttl,
));
let id_changed =
agent_session_id.is_some() && rec.session.agent_session_id != agent_session_id;
if id_changed {
let newly_identified =
rec.session.agent_session_id.is_none() && agent_session_id.is_some();
if newly_identified {
rec.session.agent_session_id = agent_session_id;
self.inner
.sidecar
.write(&rec.session)
.map_err(SessionError::Sidecar)?;
}
(changed || id_changed).then(|| rec.session.clone())
(changed || newly_identified).then(|| rec.session.clone())
};
if let Some(session) = updated {
let _ = self
+4 -31
View File
@@ -187,30 +187,10 @@ async fn a_reported_session_id_is_recorded_on_the_session() {
assert_eq!(s.agent_session_id.as_deref(), Some("conv-abc123"));
}
/// #161: repeated reports of the same id are pure churn from the hook's
/// `PreToolUse` cadence — the sidecar must not be rewritten for them.
/// #161: a session id does not change mid-session, so once set it is never
/// overwritten by a later report — even one that names a different id.
#[tokio::test]
async fn a_repeated_report_of_the_same_session_id_writes_nothing() {
let fx = fixture(Duration::from_secs(300)).await;
let id = spawn_sleeping(&fx.manager).await;
fx.manager
.report_activity(id, ActivityState::Active, None, Some("conv-first".into()))
.await
.unwrap();
fx.manager
.report_activity(id, ActivityState::Active, None, Some("conv-first".into()))
.await
.unwrap();
let s = observed(&fx.manager, id).await;
assert_eq!(s.agent_session_id.as_deref(), Some("conv-first"));
}
/// #176: a resume issues a new conversation id. The stored id must move to
/// it, or every later resume reattaches the stale pre-resume conversation.
#[tokio::test]
async fn a_reported_session_id_is_replaced_when_it_differs() {
async fn a_reported_session_id_is_written_once_and_never_replaced() {
let fx = fixture(Duration::from_secs(300)).await;
let id = spawn_sleeping(&fx.manager).await;
@@ -224,14 +204,7 @@ async fn a_reported_session_id_is_replaced_when_it_differs() {
.unwrap();
let s = observed(&fx.manager, id).await;
assert_eq!(s.agent_session_id.as_deref(), Some("conv-second"));
let stored = fx
.sidecar
.read(id)
.expect("sidecar read")
.expect("sidecar present");
assert_eq!(stored.agent_session_id.as_deref(), Some("conv-second"));
assert_eq!(s.agent_session_id.as_deref(), Some("conv-first"));
}
#[tokio::test]
+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.
+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);