Compare commits

..
5 Commits
Author SHA1 Message Date
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
7 changed files with 168 additions and 70 deletions
+2 -2
View File
@@ -13,8 +13,8 @@
//! //!
//! Each surface carries its own activity colours. A state colour picked for //! 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 dark card background is not legible on the selection background, so
//! the selected card uses the selection surface's own state colours; the dot //! the selected card uses the selection surface's own state colours; the
//! keeps carrying the state either way (docs/SIDEBAR.md). //! rail keeps carrying the state either way (docs/SIDEBAR.md).
//! //!
//! [`Palette::DARK`] — graphite, no accent hue — is the default. //! [`Palette::DARK`] — graphite, no accent hue — is the default.
//! [`Palette::LIGHT`] is its light peer and [`Palette::ANSI`] uses //! [`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`. //! Sidebar widget — session cards per `docs/SIDEBAR.md`.
//! //!
//! Line 1: status glyph (● attached / ○ live / ✗ dead) + name. //! Line 1: activity rail + status glyph (● attached / ○ live / ✗ dead) + name.
//! Line 2: activity dot + duration in the state's colour, plus the //! Line 2: activity rail + duration in the state's colour, plus the
//! adaptive detail, blocked-work, exited, and command fragments. //! adaptive detail, blocked-work, exited, and command fragments.
use std::collections::{BTreeMap, HashSet}; use std::collections::{BTreeMap, HashSet};
@@ -33,6 +33,9 @@ const META_LINE_AFTER_SECS: i64 = 30;
const PLAUSIBLE_SINCE_YEAR: i32 = 2010; const PLAUSIBLE_SINCE_YEAR: i32 = 2010;
const INDENT: &str = " "; const INDENT: &str = " ";
const META_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 /// Cards use the selection surface only while the sidebar owns focus. An
/// unfocused highlight remains an edge-marked cursor without claiming the /// unfocused highlight remains an edge-marked cursor without claiming the
@@ -203,7 +206,9 @@ impl Sidebar {
); );
let chevron = Span::styled(chevron, base); let chevron = Span::styled(chevron, base);
let count = Span::styled(count, surface.muted_style().bg(row_bg)); 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(); let mut label = Vec::new();
if let Some(stem) = name.stem { if let Some(stem) = name.stem {
label.push(Span::styled( label.push(Span::styled(
@@ -213,7 +218,9 @@ impl Sidebar {
} }
label.push(Span::styled(name.last, base)); 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.extend(fit_to_width(label, label_budget));
spans.push(count); spans.push(count);
pad_to_width(spans, width, base) pad_to_width(spans, width, base)
@@ -227,11 +234,13 @@ impl Sidebar {
}; };
let highlighted = self.highlight == Some(SidebarItem::Session(s.id)); let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused); 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 name_budget = width.saturating_sub(prefix);
let edge = highlighted && !focused; let edge = highlighted && !focused;
let rail_style = base.fg(activity_color(s.activity, highlighted, focused));
let mut spans: Vec<Span<'static>> = vec![ let mut spans: Vec<Span<'static>> = vec![
Span::styled(RAIL, rail_style),
Span::styled( Span::styled(
if edge { "▏ " } else { INDENT }, if edge { "▏ " } else { INDENT },
if edge { if edge {
@@ -250,7 +259,8 @@ impl Sidebar {
pad_to_width(spans, width, base) 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>> { fn meta_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
let now = Utc::now(); let now = Utc::now();
let held = held_for(s, now); let held = held_for(s, now);
@@ -261,7 +271,7 @@ impl Sidebar {
let dead = s.status == SessionStatus::Dead; let dead = s.status == SessionStatus::Dead;
let highlighted = self.highlight == Some(SidebarItem::Session(s.id)); let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused); let base = card_base(highlighted, focused);
let dot_style = let rail_style =
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused))); base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
let muted = palette::active() let muted = palette::active()
.surface(highlighted && focused) .surface(highlighted && focused)
@@ -269,6 +279,7 @@ impl Sidebar {
.bg(base.bg.unwrap_or(palette::active().chrome_bg)); .bg(base.bg.unwrap_or(palette::active().chrome_bg));
let edge = highlighted && !focused; let edge = highlighted && !focused;
let mut spans = vec![ let mut spans = vec![
Span::styled(RAIL, rail_style),
Span::styled( Span::styled(
if edge { " ▏ " } else { META_INDENT }, if edge { " ▏ " } else { META_INDENT },
if edge { if edge {
@@ -277,11 +288,9 @@ impl Sidebar {
base base
}, },
), ),
Span::styled("●", dot_style),
]; ];
if let Some(d) = held.filter(|_| held_secs >= META_LINE_AFTER_SECS) { 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), rail_style));
spans.push(Span::styled(format_state_duration(d), dot_style));
} }
let mut tail: Vec<Span<'static>> = Vec::new(); let mut tail: Vec<Span<'static>> = Vec::new();
if let Some(b) = blocked { if let Some(b) = blocked {
@@ -320,8 +329,8 @@ impl Sidebar {
budget, budget,
)); ));
} }
spans.extend(tail);
spans.extend(fit_to_width(vec![command], command_w)); spans.extend(fit_to_width(vec![command], command_w));
spans.extend(tail);
spans spans
} }
} }
@@ -689,7 +698,7 @@ mod tests {
/// The additions and deletions are the only coloured fragments: they are /// The additions and deletions are the only coloured fragments: they are
/// what a reader scans for, and colouring the rest would make the line /// what a reader scans for, and colouring the rest would make the line
/// compete with the activity dot. /// compete with the activity rail.
#[test] #[test]
fn the_diff_colours_only_its_additions_and_deletions() { fn the_diff_colours_only_its_additions_and_deletions() {
let mut s = session_named(1, "claude"); let mut s = session_named(1, "claude");
@@ -735,8 +744,8 @@ mod tests {
assert_eq!(content(&all[0]), "sessions"); assert_eq!(content(&all[0]), "sessions");
assert!(row_containing(&all, "claude").contains('○')); assert!(row_containing(&all, "claude").contains('○'));
assert!( assert!(
all[3].contains('●'), all[3].contains(RAIL),
"line 2 must carry the dot: {:?}", "line 2 must carry the rail: {:?}",
all[3] all[3]
); );
assert!( assert!(
@@ -752,7 +761,7 @@ mod tests {
} }
/// Rule 1 of docs/SIDEBAR.md: held in state longer than 30s earns the /// 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] #[test]
fn long_held_session_gets_a_meta_line_with_duration() { fn long_held_session_gets_a_meta_line_with_duration() {
let mut s = session_named(1, "claude"); let mut s = session_named(1, "claude");
@@ -765,7 +774,7 @@ mod tests {
let all = rows(&draw(&sb, 28, 8)); let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap(); let idx = all.iter().position(|l| l.contains("claude")).unwrap();
let meta = &all[idx + 1]; 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:?}"); assert!(meta.contains("1m"), "expected 1m in: {meta:?}");
} }
@@ -782,10 +791,10 @@ mod tests {
assert_eq!(fmt(7200), "2h"); assert_eq!(fmt(7200), "2h");
} }
/// The dot and duration carry the activity state's colour; the state word /// The rail and duration carry the activity state's colour; the state
/// is never spelled out (docs/SIDEBAR.md colour table). /// word is never spelled out (docs/SIDEBAR.md colour table).
#[test] #[test]
fn meta_line_dot_uses_the_contract_colour_per_state() { fn meta_line_rail_uses_the_contract_colour_per_state() {
for state in [ for state in [
ActivityState::Active, ActivityState::Active,
ActivityState::Idle, 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 /// selection surface's own colours. A state colour picked for the dark
/// card background painted onto the selection background is the contrast /// card background painted onto the selection background is the contrast
/// bug this palette fixes — this test fails if it comes back. /// bug this palette fixes — this test fails if it comes back.
@@ -845,7 +854,7 @@ mod tests {
); );
assert!( assert!(
on_selection(p.selection.activity(state)), 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)) let ratio = palette::contrast_ratio(p.selection.bg, p.selection.activity(state))
.expect("palette roles are RGB"); .expect("palette roles are RGB");
@@ -918,9 +927,11 @@ mod tests {
let all = rows(&draw(&sb, 28, 8)); let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap(); let idx = all.iter().position(|l| l.contains("claude")).unwrap();
let meta = content(&all[idx + 1]); 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!( assert!(
meta.trim_end().ends_with('…'), ellipsis < tail,
"truncated command must end with …: {meta:?}" "command must truncate before the diff stat: {meta:?}"
); );
assert!( assert!(
content(&all[idx + 2]).trim().is_empty(), content(&all[idx + 2]).trim().is_empty(),
@@ -945,7 +956,7 @@ mod tests {
let all = rows(&draw(&sb, 40, 8)); let all = rows(&draw(&sb, 40, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap(); let idx = all.iter().position(|l| l.contains("claude")).unwrap();
let meta = &all[idx + 1]; 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!( assert!(
meta.contains("1m"), meta.contains("1m"),
"duration precedes the detail: {meta:?}" "duration precedes the detail: {meta:?}"
@@ -1018,7 +1029,7 @@ mod tests {
/// docs/SIDEBAR.md: a payload predating #102 carries an epoch /// docs/SIDEBAR.md: a payload predating #102 carries an epoch
/// `activity_since` and so contributes no duration — a card must not claim /// `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. /// other trigger puts line 2 on screen.
#[test] #[test]
fn epoch_activity_since_renders_no_duration() { fn epoch_activity_since_renders_no_duration() {
@@ -1035,10 +1046,10 @@ mod tests {
rendered.contains("exited"), rendered.contains("exited"),
"dead session must still show line 2:\n{rendered}" "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!( assert!(
!line !line
.split('●') .split(RAIL)
.nth(1) .nth(1)
.unwrap_or("") .unwrap_or("")
.trim_start() .trim_start()
@@ -1101,7 +1112,7 @@ mod tests {
} }
#[test] #[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 s = session_named(1, "name");
let sb = Sidebar { let sb = Sidebar {
sessions: vec![s], sessions: vec![s],
@@ -1110,8 +1121,8 @@ mod tests {
}; };
let all = rows(&draw(&sb, 9, 6)); let all = rows(&draw(&sb, 9, 6));
assert!( assert!(
all[3].contains('●'), all[3].contains(RAIL),
"dot must survive narrow cards: {:?}", "rail must survive narrow cards: {:?}",
all[3] all[3]
); );
assert!( assert!(
@@ -1122,7 +1133,7 @@ mod tests {
} }
#[test] #[test]
fn blocked_warning_survives_before_a_truncated_command() { fn blocked_warning_survives_after_a_truncated_command() {
let mut s = session_named(1, "name"); let mut s = session_named(1, "name");
s.command = "a-program-name-that-cannot-fit".into(); s.command = "a-program-name-that-cannot-fit".into();
s.blocked_work = Some(InTheWay { s.blocked_work = Some(InTheWay {
@@ -1137,7 +1148,12 @@ mod tests {
let all = rows(&draw(&sb, 29, 6)); let all = rows(&draw(&sb, 29, 6));
assert!(all[3].contains("~1"), "{:?}", all[3]); assert!(all[3].contains("~1"), "{:?}", all[3]);
let meta = content(&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] #[test]
+34 -12
View File
@@ -37,23 +37,45 @@ itself is not.
Line 2 reads left to right: 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 The activity state does not render as an inline glyph on line 2 — a **rail**
state's colour, and `<duration>` follows in the same colour when shown. There carries it instead: a coloured bar in the gutter to the left of the card, the
is no state word and no second badge: the dot exists so the state is still space nesting already reserves for indentation. The rail paints for every
legible when a row is dimmed or the terminal renders the duration in the card, `Idle` included, and is independent of whatever line 2 has to say; a
default fg. The dot is an unconditional part of line 2; the bracketed card with nothing else to report still shows its rail next to the bare
fragments appear only when their trigger holds. 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 positioned inside the row's own
indent gutter — it shifts outward with nesting depth exactly as the gutter
does — spanning the full card height. It carries motion, reserved for states
that need attention (DESIGN.md's "living signals" rule): a slow breathing
opacity while `Active`, a hard blink while `AwaitingInput`, a fast flash while
`Error`. `Idle` is solid and still, on purpose. `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 `<duration>` renders only once the session has been held in its current
activity state longer than **30 seconds** (`activity_since` from #102) — under 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 threshold the duration is absent and the rail carries the state alone. A
that predates #102 (no usable `activity_since`) likewise contributes no payload that predates #102 (no usable `activity_since`) likewise contributes
duration — the dot still renders. This rule now governs only the duration no duration — the rail still renders. This rule now governs only the duration
fragment; it no longer decides whether line 2 exists. fragment; it no longer decides whether line 2 exists.
`<detail>` is an agent-reported string (#105) — e.g. why a session is in `<detail>` is an agent-reported string (#105) — e.g. why a session is in
@@ -61,7 +83,7 @@ fragment; it no longer decides whether line 2 exists.
while an agent-reported state is in force (see Precedence): when the report 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 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 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. client ever lets it wrap.
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing `<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), activity_since: ago(5_000),
}); });
const el = renderSidebar(storeWith([s])).el; 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).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(".activity-duration")).toBeNull();
expect(line2?.querySelector(".leaf-cmd")).not.toBeNull(); expect(line2?.querySelector(".leaf-cmd")).not.toBeNull();
}); });
@@ -238,9 +239,10 @@ describe("session cards (#107)", () => {
activity_since: ago(4 * 60_000 + 30_000), activity_since: ago(4 * 60_000 + 30_000),
}); });
const el = renderSidebar(storeWith([s])).el; 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?.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"); expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
}); });
@@ -355,9 +357,9 @@ describe("session cards (#107)", () => {
}); });
const el = renderSidebar(storeWith([s])).el; const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id) as HTMLElement; const row = rowFor(el, s.id) as HTMLElement;
const dot = row.querySelector<HTMLElement>(".activity-dot"); const rail = row.querySelector<HTMLElement>(".activity-rail");
expect(dot?.getAttribute("title")).toContain("Awaiting input"); expect(rail?.getAttribute("title")).toContain("Awaiting input");
expect(dot?.getAttribute("aria-label")).toContain("Awaiting input"); expect(rail?.getAttribute("aria-label")).toContain("Awaiting input");
expect(row.textContent).not.toMatch(/awaiting.?input/i); expect(row.textContent).not.toMatch(/awaiting.?input/i);
}); });
+16 -7
View File
@@ -58,8 +58,9 @@ const collapsed = new Set<string>();
* would otherwise replace the field the user is typing into. */ * would otherwise replace the field the user is typing into. */
let renamingId: string | null = null; let renamingId: string | null = null;
// docs/SIDEBAR.md: the state word is never rendered — the dot's colour carries // docs/SIDEBAR.md: the state word is never rendered — the rail's colour
// it. It survives only in tooltips/aria labels so meaning is not colour-only. // carries it. It survives only in tooltips/aria labels so meaning is not
// colour-only.
const STATE_WORDS: Record<string, string> = { const STATE_WORDS: Record<string, string> = {
active: "Active", active: "Active",
idle: "Idle", idle: "Idle",
@@ -67,24 +68,31 @@ const STATE_WORDS: Record<string, string> = {
error: "Error", error: "Error",
}; };
/** docs/SIDEBAR.md: the activity rail sits inside the row's own indent
* gutter, so it shifts outward with nesting depth exactly as that gutter
* does. `depth * 16 + 8` is the same expression `renderNode` uses for the
* row's own padding — the rail sits just inside it. */
function activityRailMarkup(slug: string, stateWord: string, depth: number): string {
const left = depth * 16 + 2;
return `<span class="activity-rail" data-activity="${slug}" style="left:${left}px" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`;
}
function metaLine(s: Session, now: number): string { function metaLine(s: Session, now: number): string {
const dead = s.status === "Dead"; const dead = s.status === "Dead";
const blockers = sessionBlockers(s); const blockers = sessionBlockers(s);
const slug = activityColorSlug(s.activity ?? "Idle"); const slug = activityColorSlug(s.activity ?? "Idle");
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
const held = activityHeldMs(s.activity_since, now); const held = activityHeldMs(s.activity_since, now);
const duration = const duration =
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null; held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
const fragments = [ const fragments = [
`<span class="activity-dot" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`,
duration ? `<span class="activity-duration">${duration}</span>` : "", duration ? `<span class="activity-duration">${duration}</span>` : "",
s.activity_detail s.activity_detail
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>` ? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
: "", : "",
commandFragment(s.command),
blockers ? blockedFragment(blockers) : "", blockers ? blockedFragment(blockers) : "",
dead ? `<span class="card-flag">exited</span>` : "", dead ? `<span class="card-flag">exited</span>` : "",
commandFragment(s.command),
]; ];
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`; return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
} }
@@ -156,14 +164,15 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
const running = s.status === "Running"; const running = s.status === "Running";
row.dataset.status = running ? "running" : "dead"; row.dataset.status = running ? "running" : "dead";
const name = sessionName(s); const name = sessionName(s);
const stateWord = const slug = activityColorSlug(s.activity ?? "Idle");
STATE_WORDS[activityColorSlug(s.activity ?? "Idle")] ?? STATE_WORDS.idle; const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
row.setAttribute( row.setAttribute(
"aria-label", "aria-label",
`${name}, ${running ? "running" : "exited"}, ${stateWord}`, `${name}, ${running ? "running" : "exited"}, ${stateWord}`,
); );
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional. // docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
row.innerHTML = ` row.innerHTML = `
${activityRailMarkup(slug, stateWord, depth)}
<span class="card-line1"> <span class="card-line1">
<span class="leaf-name">${escapeHtml(name)}</span> <span class="leaf-name">${escapeHtml(name)}</span>
<span class="leaf-actions"> <span class="leaf-actions">
+11 -4
View File
@@ -66,12 +66,15 @@ function leafRow(opts: {
(row as HTMLAnchorElement).href = "#/kit"; (row as HTMLAnchorElement).href = "#/kit";
row.dataset.status = opts.status; row.dataset.status = opts.status;
if (opts.active) row.classList.add("is-active"); 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 = [ const flags = [
opts.status === "dead" ? `<span class="card-flag">exited</span>` : "", opts.status === "dead" ? `<span class="card-flag">exited</span>` : "",
opts.cmd !== opts.name ? `<span class="card-flag leaf-cmd">${opts.cmd}</span>` : "", opts.cmd !== opts.name ? `<span class="card-flag leaf-cmd">${opts.cmd}</span>` : "",
].join(""); ].join("");
const activity = opts.activity ?? "idle";
row.innerHTML = ` row.innerHTML = `
<span class="activity-rail" data-activity="${activity}" style="left:${depth * 16 + 2}px" title="${activity}"></span>
<span class="card-line1"> <span class="card-line1">
<span class="leaf-name">${opts.name}</span> <span class="leaf-name">${opts.name}</span>
<span class="leaf-actions"> <span class="leaf-actions">
@@ -79,7 +82,7 @@ function leafRow(opts: {
<button class="leaf-act leaf-kill" type="button" title="Kill session">✕</button> <button class="leaf-act leaf-kill" type="button" title="Kill session">✕</button>
</span> </span>
</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; return row;
} }
@@ -201,11 +204,15 @@ export function renderKit(): HTMLElement {
const activeLine = h( const activeLine = h(
"span", "span",
"card-line2", "card-line2",
`<span class="activity-dot"></span><span class="activity-duration">4m</span>`, `<span class="activity-duration">4m</span>`,
); );
activeLine.setAttribute("data-activity", "active"); 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( body.append(
specimen("folder-count", h("span", "folder-count", "4")), specimen("folder-count", h("span", "folder-count", "4")),
specimen("activity-rail · active", rail),
specimen("card-line2 · active", activeLine), specimen("card-line2 · active", activeLine),
specimen("attach-state · open", h("span", "attach-state", "live")), specimen("attach-state · open", h("span", "attach-state", "live")),
); );
@@ -225,7 +232,7 @@ export function renderKit(): HTMLElement {
{ {
const { el, body } = section( const { el, body } = section(
"Session rows — nested by host, then directory", "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"); const list = h("div", "kit-tree");
list.append( list.append(
+48 -6
View File
@@ -1393,12 +1393,34 @@ input.leaf-rename:focus {
color: var(--danger); color: var(--danger);
} }
.activity-dot { /* docs/SIDEBAR.md: the activity rail replaces the old inline dot. It sits
width: 7px; inside the row's own indent gutter (`.tree-leaf` is already positioned),
height: 7px; at a `left` set inline per nesting depth — see `activityRailMarkup` in
border-radius: 50%; 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: 4px;
bottom: 4px;
width: 5px;
border-radius: 2px;
background: currentColor; background: currentColor;
flex: 0 0 auto; }
.activity-rail[data-activity="active"] {
color: var(--live);
animation: pulse 2.4s var(--ease-out) infinite;
}
.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 { .activity-duration {
@@ -2593,6 +2615,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 ---------- */ /* ---------- responsive ---------- */
@media (max-width: 520px) { @media (max-width: 520px) {
@@ -2642,7 +2683,8 @@ input.leaf-rename:focus {
} }
.brand-dot, .brand-dot,
[data-status="running"] .leaf-status::before, [data-status="running"] .leaf-status::before,
.tally-live::before { .tally-live::before,
.activity-rail {
animation: none; animation: none;
} }
} }