Compare commits

..
11 Commits
Author SHA1 Message Date
Miguel Palhas 2ad2858fd5 Merge remote-tracking branch 'origin/main' into dorian
ci / web (push) Successful in 2m7s
ci / rust (push) Successful in 5m32s
ci / flake (push) Successful in 10m35s
2026-09-02 17:39:39 +01:00
Miguel Palhas fa2a26faa9 Merge remote-tracking branch 'origin/main' into dorian 2026-09-02 17:37:17 +01:00
Miguel Palhas b5feb3c39a fix(maestro): re-focus a sidebar session already open in a split
Sidebar rows only navigated via location.hash + hashchange. Moving
pane focus by split, drag, or direct pane click never touched the
hash, so a later click back onto a session whose row href matched the
stale hash fired no hashchange and was silently ignored — the pane
had to be clicked directly to regain focus.

Row clicks now also dispatch a session-select event handled the same
way as a hash deep-link, independent of whether the hash text changes.
2026-09-02 17:37:15 +01:00
Miguel Palhas e12baa23c6 fix(maestro): fast-forward spawn prefers the focused session's project
ci / flake (push) Successful in 10m50s
ci / web (push) Successful in 1m6s
ci / rust (push) Successful in 5m17s
N picked the globally freshest project by git-mtime, ignoring the session
the operator was actually on. Plain n's spawn form already seeds from the
focused session (spawnSimilarProjectSeed); fast-forward now matches that,
falling back to recency only when nothing is focused.
2026-09-02 17:36:11 +01:00
Miguel Palhas 63191c7be1 Merge remote-tracking branch 'origin/main' into rubato
ci / web (push) Successful in 1m50s
ci / rust (push) Successful in 5m7s
ci / flake (push) Successful in 8m45s
2026-09-02 17:19:26 +01:00
Miguel Palhas acb84b91ef fix(maestro): pin activity rail to left edge, drop active-state pulse
Rail now spans full row height flush to the left edge with square
corners, instead of a depth-shifted inset bar. Active state stays
solid green since colour alone already signals it; blink/flash stay
for awaiting-input/error.
2026-09-02 17:18:00 +01:00
Miguel PalhasandClaude Sonnet 5 c8ab4a531e fix(maestro): tool icon left of diff stat on sidebar meta line
ci / flake (push) Successful in 9m17s
ci / web (push) Successful in 1m9s
ci / rust (push) Successful in 3m55s
Meta line fragment order was command last, after the blocked-work diff
stat, so the tool icon sat right of the diff count instead of left.
Reorder to duration/detail/command/diff-stat in both web and TUI
sidebars per docs/SIDEBAR.md, and update the contract doc + TUI tests
to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 17:17:44 +01:00
naps62 21fc20b30a Merge pull request 'docs(maestro): replace the sidebar's activity dot with a rail' (#130) from indicators into main
ci / web (push) Successful in 3m40s
ci / rust (push) Successful in 5m49s
ci / flake (push) Successful in 6m12s
Reviewed-on: #130
2026-09-02 17:00:58 +01:00
Miguel Palhas abbd15a8b2 feat(maestro): TUI sidebar activity rail
ci / web (pull_request) Successful in 1m20s
ci / rust (pull_request) Successful in 5m47s
ci / flake (pull_request) Successful in 9m14s
Leads the existing indent gutter on both the name row and the meta
row with a one-column block glyph in the state's palette role,
replacing the meta line's inline dot. Folder headers gain a matching
blank column so labels stay aligned under session names.
2026-09-02 16:47:51 +01:00
Miguel Palhas 1d18e21420 feat(maestro): web sidebar activity rail
Replaces the inline activity dot with a coloured bar positioned inside
each row's own indent gutter, per the updated docs/SIDEBAR.md contract.
Active breathes, awaiting-input blinks, error flashes; idle stays
solid. prefers-reduced-motion leaves every state static.
2026-09-02 16:47:47 +01:00
Miguel Palhas 38a65e585f docs(maestro): replace the sidebar's activity dot with a rail
The inline dot competed with the meta line's other fragments and left
the indent gutter unused. A rail moves the state signal into that
gutter instead, on both clients.
2026-09-02 16:47:42 +01:00
12 changed files with 370 additions and 101 deletions
+28 -9
View File
@@ -608,7 +608,19 @@ impl App {
/// daemon never adopts an existing worktree (docs/WORKTREES.md), same
/// rule as web/src/spawnSimilar.ts.
fn open_spawn_modal(&mut self) {
let seed_cwd = self.state.active_session().and_then(|sid| {
let mut draft = DraftPane::default();
draft.seed_cwd = self.focused_seed_cwd();
self.draft = Some(draft);
self.state.open_spawn_modal();
}
/// The project of the session the operator was last on — its base
/// repository for a worktree session, its cwd otherwise. Shared by the
/// plain spawn modal seed and fast-forward's project pick (#67 review):
/// both should land on "where I currently am", not just "whatever's
/// freshest".
fn focused_seed_cwd(&self) -> Option<PathBuf> {
self.state.active_session().and_then(|sid| {
self.catalog
.snapshot()
.sessions
@@ -618,11 +630,7 @@ impl App {
Some(wt) => wt.base_repo.clone(),
None => s.cwd.clone(),
})
});
let mut draft = DraftPane::default();
draft.seed_cwd = seed_cwd;
self.draft = Some(draft);
self.state.open_spawn_modal();
})
}
/// Close the form and drop its half-filled state — reopening starts clean,
@@ -767,9 +775,11 @@ impl App {
info,
projects,
names,
focused_cwd,
} = update
{
self.fast_forward_spawn(info, projects, names).await;
self.fast_forward_spawn(info, projects, names, focused_cwd)
.await;
return;
}
let want = match &update {
@@ -836,6 +846,7 @@ impl App {
/// `/api/projects` walks all of `$HOME`, and pressing the key must not
/// freeze keys, frames and attached panes until it returns.
fn spawn_fast_forward(&self) {
let focused_cwd = self.focused_seed_cwd();
let (daemon, http, base, tx) = self.loader_handles();
tokio::spawn(async move {
let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects());
@@ -844,6 +855,7 @@ impl App {
info: info.ok(),
projects: projects.ok(),
names,
focused_cwd,
});
});
}
@@ -853,6 +865,7 @@ impl App {
info: Option<maestro_protocol::DaemonInfo>,
projects: Option<Vec<maestro_protocol::Project>>,
names: Vec<String>,
focused_cwd: Option<PathBuf>,
) {
// Keep the home dir: without it the pre-filled draft renders
// unabbreviated paths and "Other directory…" seeds `/` instead of
@@ -866,8 +879,13 @@ impl App {
_ => Vec::new(),
};
let discovered = projects.unwrap_or_default();
let resolved =
crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now());
let resolved = crate::fast_forward::resolve(
&discovered,
&presets,
names.first().cloned(),
Utc::now(),
focused_cwd.as_deref(),
);
if let Some(req) = resolved.build_request() {
self.submit_spawn(req).await;
return;
@@ -1166,6 +1184,7 @@ enum DraftUpdate {
info: Option<maestro_protocol::DaemonInfo>,
projects: Option<Vec<maestro_protocol::Project>>,
names: Vec<String>,
focused_cwd: Option<PathBuf>,
},
}
+93 -11
View File
@@ -26,16 +26,25 @@ pub struct FastForwardDefaults {
/// Resolve the fast-forward defaults from the daemon's answers. `name` is
/// `None` when `/api/names` yielded nothing; `presets` may be empty (the
/// caller falls back to the built-ins it already has).
/// caller falls back to the built-ins it already has). `focused_cwd` is the
/// project of the session the operator was last on ([`crate::app`]'s
/// `focused_seed_cwd`) — when it matches a known project, that project wins
/// over the global recency ranking, so `N` lands where the operator
/// currently is rather than wherever was freshest.
pub fn resolve(
projects: &[Project],
presets: &[SpawnPreset],
name: Option<String>,
now: DateTime<Utc>,
focused_cwd: Option<&std::path::Path>,
) -> FastForwardDefaults {
let project = crate::projects::sort_projects(projects.to_vec(), now)
.into_iter()
.next();
let project = focused_cwd
.and_then(|cwd| projects.iter().find(|p| p.path == cwd).cloned())
.or_else(|| {
crate::projects::sort_projects(projects.to_vec(), now)
.into_iter()
.next()
});
let command = project
.as_ref()
.and_then(|p| p.last_command.clone())
@@ -155,7 +164,13 @@ mod tests {
repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")),
];
let presets = vec![preset("aider", "aider"), preset("custom", "")];
let r = resolve(&projects, &presets, Some("amber-falcon".into()), Utc::now());
let r = resolve(
&projects,
&presets,
Some("amber-falcon".into()),
Utc::now(),
None,
);
assert_eq!(
r.project.as_ref().unwrap().path,
PathBuf::from("/repos/fresh")
@@ -176,6 +191,46 @@ mod tests {
assert!(wt.create_branch, "fresh branch named after the session");
}
#[test]
fn the_focused_sessions_project_wins_over_the_freshest_one() {
let projects = vec![
repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")),
project("/repos/current", 5, Some("zsh")),
];
let presets = vec![preset("claude", "claude")];
let r = resolve(
&projects,
&presets,
Some("n".into()),
Utc::now(),
Some(std::path::Path::new("/repos/current")),
);
assert_eq!(
r.project.as_ref().unwrap().path,
PathBuf::from("/repos/current")
);
assert_eq!(r.command.as_deref(), Some("zsh"));
}
#[test]
fn an_unknown_focused_cwd_falls_back_to_the_freshest_project() {
let projects = vec![
project("/repos/old", 30, Some("zsh")),
repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")),
];
let r = resolve(
&projects,
&[preset("claude", "claude")],
Some("n".into()),
Utc::now(),
Some(std::path::Path::new("/repos/gone")),
);
assert_eq!(
r.project.as_ref().unwrap().path,
PathBuf::from("/repos/fresh")
);
}
#[test]
fn worktree_of_the_most_recent_project_targets_its_base_repo() {
let projects = vec![repo_project("/repos/main/wt-x", "/repos/main", 0, None)];
@@ -184,6 +239,7 @@ mod tests {
&[preset("zsh", "zsh")],
Some("s".into()),
Utc::now(),
None,
);
let wt = r.build_request().unwrap().worktree.unwrap();
assert_eq!(wt.base_repo, PathBuf::from("/repos/main"));
@@ -193,7 +249,7 @@ mod tests {
fn missing_last_command_falls_back_to_the_first_non_custom_preset() {
let projects = vec![project("/repos/p", 0, None)];
let presets = vec![preset("custom", ""), preset("claude", "claude")];
let r = resolve(&projects, &presets, Some("n".into()), Utc::now());
let r = resolve(&projects, &presets, Some("n".into()), Utc::now(), None);
assert_eq!(r.command.as_deref(), Some("claude"));
}
@@ -201,7 +257,7 @@ mod tests {
fn an_empty_last_command_is_no_command_at_all() {
let projects = vec![project("/repos/p", 0, Some(""))];
let presets = vec![preset("custom", "")];
let r = resolve(&projects, &presets, Some("n".into()), Utc::now());
let r = resolve(&projects, &presets, Some("n".into()), Utc::now(), None);
assert_eq!(r.command, None);
assert!(r.build_request().is_none());
}
@@ -213,6 +269,7 @@ mod tests {
&[preset("claude", "claude")],
Some("n".into()),
Utc::now(),
None,
);
assert_eq!(r.project, None);
// No project → no directory → no request, even with command + name.
@@ -222,14 +279,26 @@ mod tests {
#[test]
fn a_missing_generated_name_downgrades_to_a_draft() {
let projects = vec![project("/repos/p", 0, Some("claude"))];
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
let r = resolve(
&projects,
&[preset("claude", "claude")],
None,
Utc::now(),
None,
);
assert!(r.build_request().is_none(), "no name → no immediate spawn");
}
#[test]
fn unresolvable_defaults_still_seed_the_draft_with_what_resolved() {
let projects = vec![repo_project("/repos/p", "/repos/p", 0, Some("aider"))];
let r = resolve(&projects, &[preset("aider", "aider")], None, Utc::now());
let r = resolve(
&projects,
&[preset("aider", "aider")],
None,
Utc::now(),
None,
);
let mut draft = DraftPane::default();
r.apply_to_draft(&mut draft);
@@ -251,6 +320,7 @@ mod tests {
&[preset("claude", "claude")],
Some("amber-falcon".into()),
Utc::now(),
None,
);
let mut draft = DraftPane::default();
named.apply_to_draft(&mut draft);
@@ -263,7 +333,13 @@ mod tests {
#[test]
fn an_unresolved_name_still_gets_generated_by_a_later_batch() {
let projects = vec![project("/repos/p", 0, Some("claude"))];
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
let r = resolve(
&projects,
&[preset("claude", "claude")],
None,
Utc::now(),
None,
);
let mut draft = DraftPane::default();
r.apply_to_draft(&mut draft);
draft.seed_names(vec!["late-name".into()]);
@@ -273,7 +349,13 @@ mod tests {
#[test]
fn a_last_command_no_preset_advertises_lands_in_custom_text() {
let projects = vec![project("/repos/p", 0, Some("nvim -d"))];
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
let r = resolve(
&projects,
&[preset("claude", "claude")],
None,
Utc::now(),
None,
);
let mut draft = DraftPane::default();
r.apply_to_draft(&mut draft);
assert_eq!(draft.command(), "nvim -d");
+2 -2
View File
@@ -13,8 +13,8 @@
//!
//! Each surface carries its own activity colours. A state colour picked for
//! the dark card background is not legible on the selection background, so
//! the selected card uses the selection surface's own state colours; the dot
//! keeps carrying the state either way (docs/SIDEBAR.md).
//! the selected card uses the selection surface's own state colours; the
//! rail keeps carrying the state either way (docs/SIDEBAR.md).
//!
//! [`Palette::DARK`] — graphite, no accent hue — is the default.
//! [`Palette::LIGHT`] is its light peer and [`Palette::ANSI`] uses
+48 -32
View File
@@ -1,7 +1,7 @@
//! Sidebar widget — session cards per `docs/SIDEBAR.md`.
//!
//! Line 1: status glyph (● attached / ○ live / ✗ dead) + name.
//! Line 2: activity dot + duration in the state's colour, plus the
//! Line 1: activity rail + status glyph (● attached / ○ live / ✗ dead) + name.
//! Line 2: activity rail + duration in the state's colour, plus the
//! adaptive detail, blocked-work, exited, and command fragments.
use std::collections::{BTreeMap, HashSet};
@@ -33,6 +33,9 @@ const META_LINE_AFTER_SECS: i64 = 30;
const PLAUSIBLE_SINCE_YEAR: i32 = 2010;
const INDENT: &str = " ";
const META_INDENT: &str = " ";
/// docs/SIDEBAR.md: a one-column activity rail leads the indent gutter on
/// both card rows, replacing the old inline dot glyph on the meta line.
const RAIL: &str = "▌";
/// Cards use the selection surface only while the sidebar owns focus. An
/// unfocused highlight remains an edge-marked cursor without claiming the
@@ -203,7 +206,9 @@ impl Sidebar {
);
let chevron = Span::styled(chevron, base);
let count = Span::styled(count, surface.muted_style().bg(row_bg));
let label_budget = width.saturating_sub(prefix.width() + chevron.width() + count.width());
// "1 +" is the blank leading column matching the session rows' rail.
let label_budget =
width.saturating_sub(1 + prefix.width() + chevron.width() + count.width());
let mut label = Vec::new();
if let Some(stem) = name.stem {
label.push(Span::styled(
@@ -213,7 +218,9 @@ impl Sidebar {
}
label.push(Span::styled(name.last, base));
let mut spans = vec![prefix, chevron];
// A blank leading column, matching the width the activity rail adds
// to session rows, so folder labels keep aligning under names.
let mut spans = vec![Span::styled(" ", base), prefix, chevron];
spans.extend(fit_to_width(label, label_budget));
spans.push(count);
pad_to_width(spans, width, base)
@@ -227,11 +234,13 @@ impl Sidebar {
};
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused);
let prefix = INDENT.chars().count() + 2;
let prefix = 1 + INDENT.chars().count() + 2;
let name_budget = width.saturating_sub(prefix);
let edge = highlighted && !focused;
let rail_style = base.fg(activity_color(s.activity, highlighted, focused));
let mut spans: Vec<Span<'static>> = vec![
Span::styled(RAIL, rail_style),
Span::styled(
if edge { "▏ " } else { INDENT },
if edge {
@@ -250,7 +259,8 @@ impl Sidebar {
pad_to_width(spans, width, base)
}
/// The adaptive second line: `<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>`.
/// The adaptive second line: `[<duration>] [<detail>] <command> [<blocked work> | exited]`,
/// led by the activity rail (docs/SIDEBAR.md).
fn meta_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
let now = Utc::now();
let held = held_for(s, now);
@@ -261,7 +271,7 @@ impl Sidebar {
let dead = s.status == SessionStatus::Dead;
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused);
let dot_style =
let rail_style =
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
let muted = palette::active()
.surface(highlighted && focused)
@@ -269,6 +279,7 @@ impl Sidebar {
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
let edge = highlighted && !focused;
let mut spans = vec![
Span::styled(RAIL, rail_style),
Span::styled(
if edge { " ▏ " } else { META_INDENT },
if edge {
@@ -277,11 +288,9 @@ impl Sidebar {
base
},
),
Span::styled("●", dot_style),
];
if let Some(d) = held.filter(|_| held_secs >= META_LINE_AFTER_SECS) {
spans.push(Span::styled(" ", base));
spans.push(Span::styled(format_state_duration(d), dot_style));
spans.push(Span::styled(format_state_duration(d), rail_style));
}
let mut tail: Vec<Span<'static>> = Vec::new();
if let Some(b) = blocked {
@@ -320,8 +329,8 @@ impl Sidebar {
budget,
));
}
spans.extend(tail);
spans.extend(fit_to_width(vec![command], command_w));
spans.extend(tail);
spans
}
}
@@ -689,7 +698,7 @@ mod tests {
/// The additions and deletions are the only coloured fragments: they are
/// what a reader scans for, and colouring the rest would make the line
/// compete with the activity dot.
/// compete with the activity rail.
#[test]
fn the_diff_colours_only_its_additions_and_deletions() {
let mut s = session_named(1, "claude");
@@ -735,8 +744,8 @@ mod tests {
assert_eq!(content(&all[0]), "sessions");
assert!(row_containing(&all, "claude").contains('○'));
assert!(
all[3].contains('●'),
"line 2 must carry the dot: {:?}",
all[3].contains(RAIL),
"line 2 must carry the rail: {:?}",
all[3]
);
assert!(
@@ -752,7 +761,7 @@ mod tests {
}
/// Rule 1 of docs/SIDEBAR.md: held in state longer than 30s earns the
/// meta line, with the floored duration next to the dot.
/// meta line, with the floored duration next to the rail.
#[test]
fn long_held_session_gets_a_meta_line_with_duration() {
let mut s = session_named(1, "claude");
@@ -765,7 +774,7 @@ mod tests {
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
let meta = &all[idx + 1];
assert!(meta.contains('●'), "meta line carries the dot: {meta:?}");
assert!(meta.contains(RAIL), "meta line carries the rail: {meta:?}");
assert!(meta.contains("1m"), "expected 1m in: {meta:?}");
}
@@ -782,10 +791,10 @@ mod tests {
assert_eq!(fmt(7200), "2h");
}
/// The dot and duration carry the activity state's colour; the state word
/// is never spelled out (docs/SIDEBAR.md colour table).
/// The rail and duration carry the activity state's colour; the state
/// word is never spelled out (docs/SIDEBAR.md colour table).
#[test]
fn meta_line_dot_uses_the_contract_colour_per_state() {
fn meta_line_rail_uses_the_contract_colour_per_state() {
for state in [
ActivityState::Active,
ActivityState::Idle,
@@ -808,7 +817,7 @@ mod tests {
}
}
/// The selected card keeps carrying its state on the dot, but in the
/// The selected card keeps carrying its state on the rail, but in the
/// selection surface's own colours. A state colour picked for the dark
/// card background painted onto the selection background is the contrast
/// bug this palette fixes — this test fails if it comes back.
@@ -845,7 +854,7 @@ mod tests {
);
assert!(
on_selection(p.selection.activity(state)),
"{state:?}: the dot must still carry the state on the selected card"
"{state:?}: the rail must still carry the state on the selected card"
);
let ratio = palette::contrast_ratio(p.selection.bg, p.selection.activity(state))
.expect("palette roles are RGB");
@@ -918,9 +927,11 @@ mod tests {
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
let meta = content(&all[idx + 1]);
let ellipsis = meta.find('…').expect("truncated command must contain …");
let tail = meta.find("~120").expect("diff stat must survive: {meta:?}");
assert!(
meta.trim_end().ends_with('…'),
"truncated command must end with …: {meta:?}"
ellipsis < tail,
"command must truncate before the diff stat: {meta:?}"
);
assert!(
content(&all[idx + 2]).trim().is_empty(),
@@ -945,7 +956,7 @@ mod tests {
let all = rows(&draw(&sb, 40, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
let meta = &all[idx + 1];
assert!(meta.contains('●'), "meta line carries the dot: {meta:?}");
assert!(meta.contains(RAIL), "meta line carries the rail: {meta:?}");
assert!(
meta.contains("1m"),
"duration precedes the detail: {meta:?}"
@@ -1018,7 +1029,7 @@ mod tests {
/// docs/SIDEBAR.md: a payload predating #102 carries an epoch
/// `activity_since` and so contributes no duration — a card must not claim
/// the session has been idle since 1970. The dot still renders when some
/// the session has been idle since 1970. The rail still renders when some
/// other trigger puts line 2 on screen.
#[test]
fn epoch_activity_since_renders_no_duration() {
@@ -1035,10 +1046,10 @@ mod tests {
rendered.contains("exited"),
"dead session must still show line 2:\n{rendered}"
);
for line in rendered.lines().filter(|l| l.contains('●')) {
for line in rendered.lines().filter(|l| l.contains(RAIL)) {
assert!(
!line
.split('●')
.split(RAIL)
.nth(1)
.unwrap_or("")
.trim_start()
@@ -1101,7 +1112,7 @@ mod tests {
}
#[test]
fn narrow_meta_line_keeps_dot_and_truncates_command() {
fn narrow_meta_line_keeps_rail_and_truncates_command() {
let s = session_named(1, "name");
let sb = Sidebar {
sessions: vec![s],
@@ -1110,8 +1121,8 @@ mod tests {
};
let all = rows(&draw(&sb, 9, 6));
assert!(
all[3].contains('●'),
"dot must survive narrow cards: {:?}",
all[3].contains(RAIL),
"rail must survive narrow cards: {:?}",
all[3]
);
assert!(
@@ -1122,7 +1133,7 @@ mod tests {
}
#[test]
fn blocked_warning_survives_before_a_truncated_command() {
fn blocked_warning_survives_after_a_truncated_command() {
let mut s = session_named(1, "name");
s.command = "a-program-name-that-cannot-fit".into();
s.blocked_work = Some(InTheWay {
@@ -1137,7 +1148,12 @@ mod tests {
let all = rows(&draw(&sb, 29, 6));
assert!(all[3].contains("~1"), "{:?}", all[3]);
let meta = content(&all[3]);
assert!(meta.ends_with('…'), "command must truncate: {meta:?}");
let ellipsis = meta.find('…').expect("command must truncate");
let tail = meta.find("~1").unwrap();
assert!(
ellipsis < tail,
"diff stat must follow the truncated command: {meta:?}"
);
}
#[test]
+36 -12
View File
@@ -37,23 +37,47 @@ itself is not.
Line 2 reads left to right:
```
<dot> [<duration>] [<detail>] [<diff stat> | exited] <command>
[<duration>] [<detail>] <command> [<diff stat> | exited]
```
Its minimum content is therefore the dot and the command.
Its minimum content is therefore just the command.
`<dot>` is a single filled glyph (`●` on both clients) carrying the activity
state's colour, and `<duration>` follows in the same colour when shown. There
is no state word and no second badge: the dot exists so the state is still
legible when a row is dimmed or the terminal renders the duration in the
default fg. The dot is an unconditional part of line 2; the bracketed
fragments appear only when their trigger holds.
The activity state does not render as an inline glyph on line 2 — a **rail**
carries it instead: a coloured bar in the gutter to the left of the card, the
space nesting already reserves for indentation. The rail paints for every
card, `Idle` included, and is independent of whatever line 2 has to say; a
card with nothing else to report still shows its rail next to the bare
command. `<duration>` follows in the rail's colour when shown. There is no
state word and no second badge: the rail exists so the state is still legible
when a row is dimmed or the terminal renders the duration in the default fg.
In the **web client** the rail is a 5px bar pinned to the row's own left
edge, square corners, spanning the full card height edge to edge — it
shifts outward with nesting depth along with the row's own padding, since
it is positioned relative to the row. It carries motion only for states that
need attention (DESIGN.md's "living signals" rule): a hard blink while
`AwaitingInput`, a fast flash while `Error`. `Active` and `Idle` are both
solid and still — colour alone (green vs. the idle grey) is the signal for
`Active`, so it does not also pulse. `prefers-reduced-motion` leaves every
state as a solid bar.
In the **TUI** the rail is a one-column-wide block glyph (`▌`) in the state's
palette role, leading the existing indent gutter (`INDENT`/`META_INDENT`) on
both the name row and the meta row, so the two lines read as one continuous
bar. Folder headers gain a matching blank leading column so labels still
align under session names. The TUI has no render cadence to animate against,
so its rail is always static — colour is the whole signal there, as it
always was.
Either client's rail carries the accessibility duty the old inline dot did:
it is the element whose `title`/`aria-label` names the state, so status still
never relies on colour alone.
`<duration>` renders only once the session has been held in its current
activity state longer than **30 seconds** (`activity_since` from #102) — under
that threshold the duration is absent and the dot renders alone. A payload
that predates #102 (no usable `activity_since`) likewise contributes no
duration — the dot still renders. This rule now governs only the duration
that threshold the duration is absent and the rail carries the state alone. A
payload that predates #102 (no usable `activity_since`) likewise contributes
no duration — the rail still renders. This rule now governs only the duration
fragment; it no longer decides whether line 2 exists.
`<detail>` is an agent-reported string (#105) — e.g. why a session is in
@@ -61,7 +85,7 @@ fragment; it no longer decides whether line 2 exists.
while an agent-reported state is in force (see Precedence): when the report
lapses at its TTL or is replaced, the detail goes with it. The detail
truncates with `…` ahead of the trailing fragments — a long detail never
pushes `<blocked work>`, `exited`, or `<command>` off the line, and neither
pushes `<command>`, `<blocked work>`, or `exited` off the line, and neither
client ever lets it wrap.
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
+9 -7
View File
@@ -224,9 +224,10 @@ describe("session cards (#107)", () => {
activity_since: ago(5_000),
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
const row = rowFor(el, s.id);
const line2 = row?.querySelector<HTMLElement>(".card-line2");
expect(line2).not.toBeNull();
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
expect(row?.querySelector(".activity-rail")).not.toBeNull();
expect(line2?.querySelector(".activity-duration")).toBeNull();
expect(line2?.querySelector(".leaf-cmd")).not.toBeNull();
});
@@ -238,9 +239,10 @@ describe("session cards (#107)", () => {
activity_since: ago(4 * 60_000 + 30_000),
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
const row = rowFor(el, s.id);
const line2 = row?.querySelector<HTMLElement>(".card-line2");
expect(line2?.dataset.activity).toBe("active");
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
expect(row?.querySelector(".activity-rail")).not.toBeNull();
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
});
@@ -355,9 +357,9 @@ describe("session cards (#107)", () => {
});
const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id) as HTMLElement;
const dot = row.querySelector<HTMLElement>(".activity-dot");
expect(dot?.getAttribute("title")).toContain("Awaiting input");
expect(dot?.getAttribute("aria-label")).toContain("Awaiting input");
const rail = row.querySelector<HTMLElement>(".activity-rail");
expect(rail?.getAttribute("title")).toContain("Awaiting input");
expect(rail?.getAttribute("aria-label")).toContain("Awaiting input");
expect(row.textContent).not.toMatch(/awaiting.?input/i);
});
+25 -7
View File
@@ -44,6 +44,14 @@ export const FOCUS_PANE_EVENT = "catalog-focus-pane";
* ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
/** A session row was clicked (issue #133). The row's `href` drives normal
* hash navigation, but a split/drag can move focus without ever touching
* `location.hash` — so a click back onto a session whose hash still
* matches fires no `hashchange` and the app never hears about it. This
* event is the click-driven path that doesn't depend on the hash actually
* changing. */
export const SESSION_SELECT_EVENT = "catalog-session-select";
/** The rows a keyboard walk stops on, in visual (document) order. Expanded
* folder headers are skipped: a directory is not something to focus, and the
* cards under it are one row away anyway. A *collapsed* folder still counts —
@@ -58,8 +66,9 @@ const collapsed = new Set<string>();
* would otherwise replace the field the user is typing into. */
let renamingId: string | null = null;
// docs/SIDEBAR.md: the state word is never rendered — the dot's colour carries
// it. It survives only in tooltips/aria labels so meaning is not colour-only.
// docs/SIDEBAR.md: the state word is never rendered — the rail's colour
// carries it. It survives only in tooltips/aria labels so meaning is not
// colour-only.
const STATE_WORDS: Record<string, string> = {
active: "Active",
idle: "Idle",
@@ -67,24 +76,29 @@ const STATE_WORDS: Record<string, string> = {
error: "Error",
};
/** 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. */
function activityRailMarkup(slug: string, stateWord: string): string {
return `<span class="activity-rail" data-activity="${slug}" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`;
}
function metaLine(s: Session, now: number): string {
const dead = s.status === "Dead";
const blockers = sessionBlockers(s);
const slug = activityColorSlug(s.activity ?? "Idle");
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
const held = activityHeldMs(s.activity_since, now);
const duration =
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
const fragments = [
`<span class="activity-dot" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`,
duration ? `<span class="activity-duration">${duration}</span>` : "",
s.activity_detail
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
: "",
commandFragment(s.command),
blockers ? blockedFragment(blockers) : "",
dead ? `<span class="card-flag">exited</span>` : "",
commandFragment(s.command),
];
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
}
@@ -156,14 +170,15 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
const running = s.status === "Running";
row.dataset.status = running ? "running" : "dead";
const name = sessionName(s);
const stateWord =
STATE_WORDS[activityColorSlug(s.activity ?? "Idle")] ?? STATE_WORDS.idle;
const slug = activityColorSlug(s.activity ?? "Idle");
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
row.setAttribute(
"aria-label",
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
);
// 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">${escapeHtml(name)}</span>
<span class="leaf-actions">
@@ -185,6 +200,9 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
ev.stopPropagation();
void requestKill(s, name);
});
row.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: s.id }));
});
row.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
ev.stopPropagation();
+24
View File
@@ -47,6 +47,30 @@ describe("resolveFastForward (#67)", () => {
});
});
it("prefers the focused session's project over the freshest one", () => {
const projects = [
project({ path: "/repos/fresh", name: "fresh", last_command: "claude" }),
project({
path: "/repos/current",
name: "current",
last_activity: "2020-01-01T00:00:00Z",
last_command: "zsh",
}),
];
const res = resolveFastForward(projects, presets, "n", "/repos/current");
expect(res.project?.path).toBe("/repos/current");
expect(res.command).toBe("zsh");
});
it("falls back to the freshest project when the focused cwd is unknown", () => {
const projects = [
project({ path: "/repos/old", name: "old", last_activity: "2020-01-01T00:00:00Z" }),
project({ path: "/repos/fresh", name: "fresh" }),
];
const res = resolveFastForward(projects, presets, "n", "/repos/gone");
expect(res.project?.path).toBe("/repos/fresh");
});
it("falls back to the first non-custom preset when there is no last command", () => {
const res = resolveFastForward([project({ last_command: undefined })], presets, "n");
expect(res.command).toBe("claude");
+9 -4
View File
@@ -13,15 +13,20 @@ export interface FastForwardResolution {
name?: string;
}
/** The defaults: most recent project (fresh-first, same ordering the picker
* shows), its last command falling back to the first non-custom preset, and
* a freshly generated name when the daemon supplied one. */
/** The defaults: the currently focused session's project when it's still a
* known project, else the most recent one (fresh-first, same ordering the
* picker shows) — so `N` lands where the operator currently is, matching
* what plain `n` seeds from `spawnSimilarProjectSeed`. Its last command
* falls back to the first non-custom preset, and the name is freshly
* generated when the daemon supplied one. */
export function resolveFastForward(
projects: Project[],
presets: SpawnPreset[],
name?: string,
focusedCwd?: string,
): FastForwardResolution {
const project = sortProjects(projects, new Date())[0];
const project =
projects.find((p) => p.path === focusedCwd) ?? sortProjects(projects, new Date())[0];
const lastCommand = project?.last_command?.trim();
const command =
lastCommand || presets.find((p) => p.command !== "")?.command || undefined;
+11 -4
View File
@@ -66,12 +66,15 @@ function leafRow(opts: {
(row as HTMLAnchorElement).href = "#/kit";
row.dataset.status = opts.status;
if (opts.active) row.classList.add("is-active");
row.style.paddingLeft = `${(opts.indent ?? 0) * 16 + 8}px`;
const depth = opts.indent ?? 0;
row.style.paddingLeft = `${depth * 16 + 8}px`;
const flags = [
opts.status === "dead" ? `<span class="card-flag">exited</span>` : "",
opts.cmd !== opts.name ? `<span class="card-flag leaf-cmd">${opts.cmd}</span>` : "",
].join("");
const activity = opts.activity ?? "idle";
row.innerHTML = `
<span class="activity-rail" data-activity="${activity}" style="left:${depth * 16 + 2}px" title="${activity}"></span>
<span class="card-line1">
<span class="leaf-name">${opts.name}</span>
<span class="leaf-actions">
@@ -79,7 +82,7 @@ function leafRow(opts: {
<button class="leaf-act leaf-kill" type="button" title="Kill session">✕</button>
</span>
</span>
${opts.activity ? `<span class="card-line2" data-activity="${opts.activity}"><span class="activity-dot" title="${opts.activity}"></span><span class="activity-duration">${opts.duration ?? ""}</span>${flags}</span>` : ""}
${opts.activity ? `<span class="card-line2" data-activity="${opts.activity}"><span class="activity-duration">${opts.duration ?? ""}</span>${flags}</span>` : ""}
`;
return row;
}
@@ -201,11 +204,15 @@ export function renderKit(): HTMLElement {
const activeLine = h(
"span",
"card-line2",
`<span class="activity-dot"></span><span class="activity-duration">4m</span>`,
`<span class="activity-duration">4m</span>`,
);
activeLine.setAttribute("data-activity", "active");
const rail = h("span", "activity-rail");
rail.dataset.activity = "active";
rail.style.cssText = "position:static;display:inline-block;height:14px;";
body.append(
specimen("folder-count", h("span", "folder-count", "4")),
specimen("activity-rail · active", rail),
specimen("card-line2 · active", activeLine),
specimen("attach-state · open", h("span", "attach-state", "live")),
);
@@ -225,7 +232,7 @@ export function renderKit(): HTMLElement {
{
const { el, body } = section(
"Session rows — nested by host, then directory",
"docs/SIDEBAR.md cards: line 1 is name + top-right actions (the name truncates before it can reach them); line 2 only when there's something to say — dot + duration in the state's colour, then blocked-work / exited fragments.",
"docs/SIDEBAR.md cards: line 1 is name + top-right actions (the name truncates before it can reach them); the activity rail sits in the indent gutter to the left; line 2 carries duration in the state's colour, then blocked-work / exited fragments, whenever there's something to say.",
);
const list = h("div", "kit-tree");
list.append(
+39 -7
View File
@@ -29,7 +29,12 @@ import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js";
import { loadLayout, saveLayout } from "./persistence.js";
import { FOCUS_PANE_EVENT, OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
import {
FOCUS_PANE_EVENT,
OPEN_SPAWN_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
} from "./catalog.js";
import { initTheme } from "./theme.js";
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
import { installViewportSizing } from "./viewport.js";
@@ -257,13 +262,28 @@ function requestOpenSpawn(): void {
// beside it is the point.
// The session you were last on preselects its project: `n` then Enter spawns
// another session there without touching the picker.
const focused = mv?.focusedSessionId() ?? null;
const current = focused ? store.snapshot().sessions.get(focused) : undefined;
const current = focusedSession();
openSpawnModal({
seed: current ? spawnSimilarProjectSeed(current) : undefined,
onSuccess: (session) => mountSpawned(session, { newTab: true }),
});
}
/** The session the operator was last on, if any. Shared by the plain spawn
* modal seed and fast-forward's project pick: both should land on "where I
* currently am". */
function focusedSession(): Session | undefined {
const focused = mv?.focusedSessionId() ?? null;
return focused ? store.snapshot().sessions.get(focused) : undefined;
}
/** The focused session's project — its base repo for a worktree session, its
* cwd otherwise (same rule as spawnSimilarProjectSeed). */
function focusedProjectCwd(): string | undefined {
const s = focusedSession();
if (!s) return undefined;
return s.worktree ? s.worktree.base_repo : s.cwd;
}
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
const s = (ev as CustomEvent<Session>).detail;
@@ -298,7 +318,7 @@ async function requestFastForward(): Promise<void> {
} catch {
/* keep fallbacks */
}
const resolved = resolveFastForward(discovered, presets, name);
const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
const req = buildFastForwardRequest(resolved);
if (req) {
try {
@@ -355,6 +375,20 @@ window.addEventListener("keydown", (e) => {
// Escape in the session list hands the keyboard back to the pane.
document.addEventListener(FOCUS_PANE_EVENT, () => pool.focusTerminal());
/** Open/focus a session and reflect it in the shell chrome. Shared by the
* hash router and the sidebar's click-driven path (#133), which can't rely
* on `hashchange` alone — a split or drag can move focus without the hash
* changing, leaving a click back onto that session's row silently ignored. */
function focusSession(sessionId: string): void {
showMultiView();
mv.openSession(sessionId);
updateShellRoute();
}
document.addEventListener(SESSION_SELECT_EVENT, (e) => {
focusSession((e as CustomEvent<string>).detail);
});
type Route =
| { kind: "catalog" }
| { kind: "attach"; sessionId: string }
@@ -373,9 +407,7 @@ function dispatch(): void {
switch (route.kind) {
case "attach":
// Deep-link: open/focus the session, then show the layout.
showMultiView();
mv.openSession(route.sessionId);
updateShellRoute();
focusSession(route.sessionId);
return;
case "not-found":
shellEl.dataset.route = "notfound";
+46 -6
View File
@@ -1393,12 +1393,32 @@ input.leaf-rename:focus {
color: var(--danger);
}
.activity-dot {
width: 7px;
height: 7px;
border-radius: 50%;
/* docs/SIDEBAR.md: the activity rail replaces the old inline dot. It is
pinned to the row's own left edge, full height, square corners — see
catalog.ts. Idle stays solid and still on purpose: motion is reserved for
states that need attention (DESIGN.md's "living signals" rule). */
.activity-rail {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 5px;
background: currentColor;
flex: 0 0 auto;
}
.activity-rail[data-activity="active"] {
color: var(--live);
}
.activity-rail[data-activity="idle"] {
color: var(--border-strong);
}
.activity-rail[data-activity="awaiting-input"] {
color: var(--warn);
animation: blink 1.2s steps(1) infinite;
}
.activity-rail[data-activity="error"] {
color: var(--danger);
animation: rail-flash 1.4s ease-out infinite;
}
.activity-duration {
@@ -2593,6 +2613,25 @@ input.leaf-rename:focus {
}
}
@keyframes rail-flash {
0%,
100% {
opacity: 1;
}
15% {
opacity: 0.25;
}
30% {
opacity: 1;
}
45% {
opacity: 0.25;
}
60% {
opacity: 1;
}
}
/* ---------- responsive ---------- */
@media (max-width: 520px) {
@@ -2642,7 +2681,8 @@ input.leaf-rename:focus {
}
.brand-dot,
[data-status="running"] .leaf-status::before,
.tally-live::before {
.tally-live::before,
.activity-rail {
animation: none;
}
}