Compare commits

...
Author SHA1 Message Date
Miguel Palhas f6a97c4695 docs(maestro): settle line-2 rulings — command fragment, pre-#102 payloads, unconditional dot 2026-08-24 19:51:06 +01:00
Miguel Palhas 5be3e33c94 feat(maestro): render sidebar sessions as two-line cards per docs/SIDEBAR.md 2026-08-24 19:50:24 +01:00
Miguel Palhas d7b15a131f feat(maestro): activity duration + colour helpers per the sidebar card contract 2026-08-24 19:42:33 +01:00
Miguel Palhas 581ec29492 docs(maestro): name the activity dot in the card contract 2026-08-24 19:32:08 +01:00
Miguel Palhas 90b7953b17 merge: sidebar-cards/103-session-env 2026-08-24 19:31:35 +01:00
Miguel Palhas 26e7992c8d merge: sidebar-cards/102-activity-wire 2026-08-24 19:31:35 +01:00
Miguel Palhas 60515dd8c6 merge: sidebar-cards/101-sidebar-contract 2026-08-24 19:31:35 +01:00
Miguel Palhas b3c73fd2cb test(maestro): assert the exported session env end to end through real tmux 2026-08-24 19:30:11 +01:00
Miguel Palhas 7d06bec21e feat(maestro): export MAESTRO_SESSION_ID and MAESTRO_URL into spawned sessions
Every tmux-spawned session now gets its UUID under MAESTRO_SESSION_ID and,
when the runner has wired its base URL from [daemon].bind_addr, that URL
under MAESTRO_URL (#103). The daemon-owned values override anything the
spawn request carried. Adopted sessions stay without them by design.
2026-08-24 19:30:11 +01:00
Miguel Palhas 6af8abccd3 docs(maestro): add SIDEBAR.md session card contract 2026-08-24 19:17:04 +01:00
13 changed files with 763 additions and 76 deletions
+4
View File
@@ -66,6 +66,10 @@ See `docs/INSTALL.md` for end-user install instructions.
collisions, what blocks a kill, what `--force` retains, and what reclaim
touches. Read it before changing anything that creates or removes one.
`docs/SIDEBAR.md` is the contract for session cards in the web and TUI
sidebars — line layout, activity colours, duration format, state precedence.
Read it before changing sidebar rendering.
`nix run .#dev` (or `just dev`) brings up the whole local stack under
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
+3
View File
@@ -73,6 +73,9 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
// One store instance shared with the API handlers: forced kills (#81)
// record here, `GET /api/worktrees` (#80) reads back from here.
.with_retained_store((*retained).clone());
// Spawned sessions learn where to report back (#103). Derived from the
// configured bind address; set before any spawn can happen.
manager.set_daemon_url(format!("http://{}", config.bind_addr));
let recovered = manager.recover().await?;
tracing::info!(recovered, "adopted existing tmux sessions");
+28 -1
View File
@@ -131,6 +131,11 @@ struct Inner {
/// Retained-worktree store (#78). `None` when not wired (tests); a forced
/// kill (#81) without one fails rather than destroying unrecorded work.
retained: Option<crate::retained_store::RetainedStore>,
/// The daemon's own HTTP base URL (#103), exported to spawned sessions as
/// `MAESTRO_URL`. `None` when unknown — those sessions simply don't get
/// the variable. A mutex because the runner sets it after construction,
/// possibly while clones are already serving.
daemon_url: std::sync::Mutex<Option<String>>,
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
events: broadcast::Sender<CatalogEvent>,
}
@@ -170,6 +175,7 @@ impl SessionManager {
replay_bytes,
spawn_history: None,
retained: None,
daemon_url: std::sync::Mutex::new(None),
sessions: Mutex::new(HashMap::new()),
events,
}),
@@ -202,6 +208,14 @@ impl SessionManager {
self
}
/// Set the daemon's own HTTP base URL (#103), exported to every session
/// spawned afterwards as `MAESTRO_URL`. Unlike the `with_*` builders this
/// is a plain setter: the daemon runner learns its bound address after
/// construction, and clones may already exist by then.
pub fn set_daemon_url(&self, url: String) {
*self.inner.daemon_url.lock().expect("daemon_url mutex") = Some(url);
}
/// Recent working dirs for the spawn form's picker (#24), newest-first and
/// deduped by dir. Empty when no history is wired or it's disabled.
pub fn recent_dirs(&self) -> Vec<RecentDir> {
@@ -304,7 +318,7 @@ impl SessionManager {
return Err(e.into());
}
};
let session_env = match sanitize_env(&req.env) {
let mut session_env = match sanitize_env(&req.env) {
Ok(env) => env,
Err(e) => {
let _ = std::fs::remove_file(&fifo_path);
@@ -312,6 +326,19 @@ impl SessionManager {
return Err(e);
}
};
// Identity + reporting target for the in-session hook (#106). Ours
// win over whatever the request carried, so a client cannot spoof
// another session's identity (#103).
session_env.insert("MAESTRO_SESSION_ID".into(), id.to_string());
if let Some(url) = self
.inner
.daemon_url
.lock()
.expect("daemon_url mutex")
.clone()
{
session_env.insert("MAESTRO_URL".into(), url);
}
let spawned = {
let tmux = self.inner.tmux.clone();
let cwd = effective_cwd.clone();
+19
View File
@@ -264,6 +264,25 @@ impl Tmux {
check(out)
}
/// `tmux show-environment -t maestro-<id>`, parsed into KEY=VALUE pairs.
/// Test-facing view of what a spawned session actually inherits.
pub fn show_environment(&self, id: Uuid) -> Result<BTreeMap<String, String>, TmuxError> {
let name = session_name(id);
let out = self
.cmd()
.args(["show-environment", "-t", &name])
.output()?;
check(out.clone())?;
let stdout = String::from_utf8_lossy(&out.stdout);
let mut env = BTreeMap::new();
for line in stdout.lines() {
if let Some((k, v)) = line.split_once('=') {
env.insert(k.to_string(), v.to_string());
}
}
Ok(env)
}
/// `tmux kill-server`. Best-effort cleanup used mostly by tests.
pub fn kill_server(&self) -> Result<(), TmuxError> {
let out = self.cmd().arg("kill-server").output()?;
@@ -30,6 +30,24 @@ impl Fx {
Self::with_sidecar_dir(|tmp| tmp.join("sidecars"))
}
/// Variant whose manager knows the daemon's own base URL (#103), the way
/// the production runner wires it from `[daemon].bind_addr`.
fn with_daemon_url(url: &str) -> Self {
let fx = Self::new();
fx.manager.set_daemon_url(url.to_string());
fx
}
/// The env map of the (single, expected) spawn call.
fn spawned_env(&self) -> BTreeMap<String, String> {
let calls = self.fake.calls();
let Some(Call::NewSessionPiped { env, .. }) = calls.first() else {
panic!("expected a spawn call, got {calls:?}");
};
assert_eq!(calls.len(), 1, "expected exactly one spawn: {calls:?}");
env.clone()
}
fn with_sidecar_dir(pick: impl Fn(&std::path::Path) -> PathBuf) -> Self {
let tmp = TempDir::new().expect("tempdir");
let fake = Arc::new(FakePanes::new());
@@ -261,16 +279,23 @@ async fn requested_env_reaches_tmux() {
fx.manager.create(req).await.expect("create");
let spawned = match fx.fake.calls().first() {
Some(Call::NewSessionPiped { env, .. }) => env.clone(),
other => panic!("expected a spawn call, got {other:?}"),
};
let id = spawned
.get("MAESTRO_SESSION_ID")
.expect("spawn must export MAESTRO_SESSION_ID (#103)")
.clone();
// The daemon-owned variable rides alongside whatever was requested.
let want: BTreeMap<String, String> = [
("EMPTY".to_string(), String::new()),
("FOO".to_string(), "bar".to_string()),
("MAESTRO_SESSION_ID".to_string(), id),
]
.into_iter()
.collect();
match fx.fake.calls().first() {
Some(Call::NewSessionPiped { env, .. }) => assert_eq!(env, &want),
other => panic!("expected a spawn call, got {other:?}"),
}
assert_eq!(&spawned, &want);
}
#[tokio::test]
@@ -396,3 +421,70 @@ fn attaching_spawn_history_after_cloning_panics() {
7,
));
}
/// Every tmux-spawned session learns who it is (#103): its own UUID under
/// `MAESTRO_SESSION_ID`, so the reporting hook in #106 can name the session
/// it is running inside.
#[tokio::test]
async fn spawn_exports_maestro_session_id() {
let fx = Fx::new();
let s = fx.manager.create(fx.request("bash")).await.expect("create");
let env = fx.spawned_env();
assert_eq!(
env.get("MAESTRO_SESSION_ID").map(String::as_str),
Some(&s.id.to_string()[..]),
"MAESTRO_SESSION_ID must be the session's UUID, got {env:?}"
);
}
/// The hook also needs to know where to report to: `MAESTRO_URL` carries the
/// daemon's base URL derived from `[daemon].bind_addr` (#103).
#[tokio::test]
async fn spawn_exports_maestro_url_when_the_daemon_is_wired() {
let fx = Fx::with_daemon_url("http://127.0.0.1:7070");
fx.manager.create(fx.request("bash")).await.expect("create");
let env = fx.spawned_env();
assert_eq!(
env.get("MAESTRO_URL").map(String::as_str),
Some("http://127.0.0.1:7070"),
"MAESTRO_URL must be the daemon's base URL, got {env:?}"
);
}
/// Without a wired URL (tests, or a caller that never set one) only the
/// session id is injected — an empty or bogus `MAESTRO_URL` would send the
/// #106 hook's reports nowhere useful.
#[tokio::test]
async fn spawn_omits_maestro_url_when_not_wired() {
let fx = Fx::new();
fx.manager.create(fx.request("bash")).await.expect("create");
let env = fx.spawned_env();
assert!(
!env.contains_key("MAESTRO_URL"),
"MAESTRO_URL must be absent when no daemon URL is known, got {env:?}"
);
}
/// The daemon-owned variables win over anything the spawner sent: a client
/// cannot spoof another session's identity.
#[tokio::test]
async fn spawn_overrides_client_supplied_maestro_variables() {
let fx = Fx::with_daemon_url("http://127.0.0.1:7070");
let mut req = fx.request("bash");
req.env.insert(
"MAESTRO_SESSION_ID".into(),
"00000000-0000-0000-0000-000000000000".into(),
);
req.env.insert("MAESTRO_URL".into(), "http://evil:1".into());
let s = fx.manager.create(req).await.expect("create");
let env = fx.spawned_env();
assert_eq!(env.get("MAESTRO_SESSION_ID"), Some(&s.id.to_string()));
assert_eq!(
env.get("MAESTRO_URL"),
Some(&"http://127.0.0.1:7070".to_string())
);
}
@@ -1004,3 +1004,44 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
);
assert_ne!(wt.base_repo, inner_worktree, "the worktree is not the repo");
}
/// #103: every tmux-spawned session carries the daemon's identity variables in
/// its environment — its UUID under `MAESTRO_SESSION_ID` and the daemon's base
/// URL under `MAESTRO_URL` — so the in-session reporting hook (#106) can find
/// both without any out-of-band channel.
#[tokio::test]
async fn spawned_sessions_export_maestro_session_id_and_url() {
let fx = DaemonFixture::new();
fx.manager
.set_daemon_url("http://127.0.0.1:7071".to_string());
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
let env = fx
.tmux
.tmux
.show_environment(s.id)
.expect("show-environment");
assert_eq!(
env.get("MAESTRO_SESSION_ID"),
Some(&s.id.to_string()),
"session env: {env:?}"
);
assert_eq!(
env.get("MAESTRO_URL"),
Some(&"http://127.0.0.1:7071".to_string())
);
fx.manager.kill(s.id, false).await.expect("kill");
}
+104
View File
@@ -0,0 +1,104 @@
# SIDEBAR.md — the session card contract
One rule for how a session is rendered as a card in the sidebar, so the web
sidebar (#107) and the TUI sidebar (#108) describe a session the same way.
Read this before changing either. Both clients implement exactly what is
written here; if it needs to change, change it here first.
A card has **line 1** always and **line 2** only when line 2 has something to
say.
## Line 1 — name + actions
- Left: the session name — `metadata.name` if present, else the command line
(same fallback both clients already use).
- Right: two actions anchored to the top-right corner: **spawn similar**
(`⧉`) and **kill** (`✕`). In the TUI these are keyboard affordances shown on
the highlighted row; in the web they are buttons. The glyphs are shared so
muscle memory transfers.
- Actions reserve their fixed width; the name truncates with `…` before it can
ever reach them. **Overlap between the name and the actions is a defect** —
that overlap is what started this milestone.
Line 1 carries no badges. The `⚠ blocked work` badge (#83) and the `exited`
badge move off this row into line 2.
## Line 2 — meta line (adaptive)
Rendered only when at least one of these holds:
1. The session has been held in its current activity state longer than
**30 seconds** (`activity_since` from #102). A freshly-active session
stays one line.
2. Its worktree holds blocked work: `blocked_work` is `Some` and not clear
(`InTheWay::is_clear()`), rendered in `formatInTheWay`'s phrasing on web
and as `⚠ <summary>` on the TUI.
3. It has exited: `status == Dead`.
4. An agent-reported state (#105) of `Error` is in force (see Precedence).
Otherwise line 2 is absent — never an empty placeholder row.
When present, line 2 reads left to right:
```
<dot> <duration> [<blocked work> | exited] [<command>]
```
`<dot>` is a single filled glyph (`●` on both clients) carrying the activity
state's colour, and `<duration>` follows in the same colour. 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
and duration are unconditional parts of line 2; the bracketed fragments appear
only when their trigger holds. A payload that predates #102 (no usable
`activity_since`) contributes no duration — the dot still renders.
`<command>` is optional and muted: the session's command line, shown only when
the rendered name came from `metadata.name` and differs from the command (the
web has always surfaced this; the TUI may omit it).
## Colour per activity state
The state word is never spelled out — the colour carries it.
| Activity state | Web token | ratatui colour |
| ---------------- | --------- | ----------------- |
| `Active` | `--live` | `Color::Green` |
| `Idle` | `--muted` | `Color::DarkGray` |
| `AwaitingInput` | `--warn` | `Color::Yellow` |
| `Error` | `--danger`| `Color::Red` |
Web tokens resolve per theme automatically (`styles.css` defines dark, light,
and `prefers-color-scheme` variants for each). The TUI uses the plain ratatui
named colours above; do not introduce RGB values or a new palette module for
this.
The duration text inherits its state's colour; the blocked-work, exited and
command fragments use the muted colour (`--faint` / default style), since the
⚠ glyph already signals severity.
## Duration format
Time spent in the **current activity state**, sourced from `activity_since`
(#102) — *not* time since spawn, and *not* time since last PTY output
(`last_activity_at`). Both clients compute it identically:
- `< 60s` → whole seconds, floored: `30s`
- `< 60m` → whole minutes, floored: `4m`
- `≥ 60m` → whole hours, floored: `2h`
No days unit, no seconds inside minutes, no rounding up. Floor everywhere so
the counter never shows a duration the state hasn't actually reached yet.
Clock granularity: re-render at least once every 30s while visible so the
cutover points land within one tick.
## Precedence
An agent-reported `ActivityState` (#105) overrides the daemon's inference for
the TTL the report carries; when the TTL expires, inference resumes. Clients
never blend the two: whichever source is in force supplies both the colour and
the clock for `activity_since`.
If a client receives an `ActivityState` variant it does not know, it must not
fail rendering: treat it as `Idle` (neutral/dim, line-2 rules apply as usual)
until the daemon sends a variant it does know. Rust clients deserialize
activity leniently for the same reason.
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import {
activityColorSlug,
activityDurationLabel,
activityHeldMs,
showsMetaLine,
} from "./activity.js";
import type { Session } from "./types.js";
describe("activityHeldMs", () => {
it("measures time spent in the current state", () => {
const now = Date.now();
const since = new Date(now - 90_000).toISOString();
expect(activityHeldMs(since, now)).toBe(90_000);
});
it("returns null for payloads that predate #102 (epoch default)", () => {
expect(activityHeldMs(undefined, Date.now())).toBeNull();
expect(activityHeldMs(new Date(0).toISOString(), Date.now())).toBeNull();
});
it("returns null for unparseable timestamps", () => {
expect(activityHeldMs("not-a-time", Date.now())).toBeNull();
});
it("never goes negative", () => {
const now = Date.now();
expect(activityHeldMs(new Date(now + 5_000).toISOString(), now)).toBe(0);
});
});
describe("activityDurationLabel (#102)", () => {
it("floors whole seconds under a minute", () => {
expect(activityDurationLabel(30_400)).toBe("30s");
expect(activityDurationLabel(59_900)).toBe("59s");
});
it("floors whole minutes under an hour", () => {
expect(activityDurationLabel(60_000)).toBe("1m");
expect(activityDurationLabel(4 * 60_000 + 59_999)).toBe("4m");
});
it("floors whole hours from an hour up, with no days unit", () => {
expect(activityDurationLabel(60 * 60_000)).toBe("1h");
expect(activityDurationLabel(2 * 3_600_000 + 59 * 60_000 + 59_999)).toBe("2h");
expect(activityDurationLabel(26 * 3_600_000)).toBe("26h");
});
});
describe("activityColorSlug", () => {
it("maps each known state to its contract slug", () => {
expect(activityColorSlug("Active")).toBe("active");
expect(activityColorSlug("Idle")).toBe("idle");
expect(activityColorSlug("AwaitingInput")).toBe("awaiting-input");
expect(activityColorSlug("Error")).toBe("error");
});
it("treats an unknown variant as idle so rendering never fails", () => {
expect(activityColorSlug("Teleported")).toBe("idle");
});
});
function session(overrides: Partial<Session>): Session {
return {
id: "11111111-2222-3333-4444-555555555555",
cwd: "/home/naps/tea/maestro",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
activity: "Active",
...overrides,
};
}
describe("showsMetaLine", () => {
const fresh = new Date(Date.now() - 10_000).toISOString();
it("stays hidden for a freshly-active session", () => {
expect(showsMetaLine(session({ activity_since: fresh }))).toBe(false);
});
it("appears once the state has been held past 30 seconds", () => {
const held = new Date(Date.now() - 31_000).toISOString();
expect(showsMetaLine(session({ activity_since: held }))).toBe(true);
});
it("appears for blocked work, exited sessions and reported errors", () => {
expect(
showsMetaLine(session({ activity_since: fresh, blocked_work: { uncommitted_files: 1 } })),
).toBe(true);
expect(showsMetaLine(session({ activity_since: fresh, status: "Dead" }))).toBe(true);
expect(showsMetaLine(session({ activity_since: fresh, activity: "Error" }))).toBe(true);
});
it("needs no activity timestamp when another trigger holds", () => {
expect(showsMetaLine(session({ activity_since: undefined, status: "Dead" }))).toBe(true);
});
});
+56
View File
@@ -0,0 +1,56 @@
// docs/SIDEBAR.md: the session card's meta line — activity colour mapping and
// the shared duration format. Both clients compute it identically; the TUI
// mirrors this in crates/maestro-tui.
import type { ActivityState, IsoTimestamp, Session } from "./types.js";
import { sessionBlockers } from "./types.js";
/** A state held shorter than this keeps the card to one line (contract rule 1). */
const META_LINE_MIN_HOLD_MS = 30_000;
// Payloads predating #102 default `activity_since` to the Unix epoch; anything
// implausibly old is treated as "never observed" rather than a huge duration.
const PLAUSIBLE_EPOCH_MS = Date.UTC(2010, 0, 1);
export function activityHeldMs(since: IsoTimestamp | undefined, now: number): number | null {
if (!since) return null;
const t = Date.parse(since);
if (Number.isNaN(t) || t < PLAUSIBLE_EPOCH_MS) return null;
return Math.max(0, now - t);
}
/** `<60s` → whole seconds, `<60m` → whole minutes, else whole hours. Floored
* everywhere so the counter never shows a duration the state hasn't reached. */
export function activityDurationLabel(heldMs: number): string {
const s = Math.floor(heldMs / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
return `${Math.floor(m / 60)}h`;
}
/** CSS hook for the state's colour (`styles.css` maps slugs to tokens). An
* unknown variant degrades to idle so rendering never fails (Precedence). */
export function activityColorSlug(state: ActivityState | string): string {
switch (state) {
case "Active":
return "active";
case "AwaitingInput":
return "awaiting-input";
case "Error":
return "error";
default:
return "idle";
}
}
/** Line 2 renders only when at least one contract trigger holds. */
export function showsMetaLine(s: Session, now: number = Date.now()): boolean {
const held = activityHeldMs(s.activity_since, now);
return (
(held !== null && held >= META_LINE_MIN_HOLD_MS) ||
sessionBlockers(s) !== null ||
s.status === "Dead" ||
s.activity === "Error"
);
}
+119
View File
@@ -104,3 +104,122 @@ describe("blocked-work indicator (#83)", () => {
expect(confirmations).toEqual(['Kill session "claude"?']);
});
});
describe("session cards (#107)", () => {
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
it("keeps the name and the actions in the same flow line, actions reserving their width", () => {
const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000010" });
const el = renderSidebar(storeWith([s])).el;
const line1 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line1");
expect(line1).not.toBeNull();
// Same flex line ⇒ the name truncates before it can ever reach the buttons.
expect(line1?.querySelector(".leaf-name")).not.toBeNull();
expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild);
});
it("stays a single line while freshly active", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000011",
activity: "Active",
activity_since: ago(5_000),
});
const el = renderSidebar(storeWith([s])).el;
expect(rowFor(el, s.id)?.querySelector(".card-line2")).toBeNull();
});
it("shows dot + floored duration once the state has been held past 30s", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000012",
activity: "Active",
activity_since: ago(4 * 60_000 + 30_000),
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.dataset.activity).toBe("active");
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
});
it("maps each activity state to its contract slug", () => {
const mk = (id: string, activity: string) =>
session({
id,
activity: activity as Session["activity"],
status: "Running",
activity_since: ago(90_000),
});
const idle = mk("aaaaaaaa-0000-0000-0000-000000000013", "Idle");
const wait = mk("aaaaaaaa-0000-0000-0000-000000000014", "AwaitingInput");
const error = mk("aaaaaaaa-0000-0000-0000-000000000015", "Error");
const el = renderSidebar(storeWith([idle, wait, error])).el;
expect(rowFor(el, idle.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"idle",
);
expect(rowFor(el, wait.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
expect(rowFor(el, error.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"error",
);
});
it("treats an unknown activity variant as idle instead of failing to render", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000016",
activity: "Teleported" as Session["activity"],
activity_since: ago(120_000),
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector(".card-line2");
expect(line2?.getAttribute("data-activity")).toBe("idle");
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("2m");
});
it("moves blocked work onto line 2 in the shared phrasing", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000017",
blocked_work: { uncommitted_files: 3, unpushed_commits: 2 },
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.textContent).toContain("⚠");
expect(line2?.textContent).toContain("3 uncommitted files, 2 unpushed commits");
// Line 1 carries no badges.
expect(rowFor(el, s.id)?.querySelector(".leaf-badge")).toBeNull();
});
it("shows exited on line 2 for dead sessions", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000018",
status: "Dead",
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.textContent).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",
command: "claude",
metadata: { name: "worker" },
activity: "AwaitingInput",
activity_since: ago(90_000),
});
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");
expect(row.textContent).not.toMatch(/awaiting.?input/i);
});
it("keeps the attach navigation and depth indentation", () => {
const s = session({ id: "aaaaaaaa-0000-0000-0000-00000000001a" });
const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id) as HTMLAnchorElement;
expect(row.getAttribute("href")).toBe("#/attach/aaaaaaaa-0000-0000-0000-00000000001a");
expect(row.style.paddingLeft).toMatch(/^\d+(\.\d+)?px$/);
});
});
+63 -15
View File
@@ -4,6 +4,7 @@ import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
import { CatalogStore } from "./events.js";
import type { Session } from "./types.js";
import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js";
import { activityColorSlug, activityDurationLabel, activityHeldMs, showsMetaLine } from "./activity.js";
import type { TreeNode } from "./tree.js";
import { buildTree } from "./tree.js";
import { createThemeToggle } from "./theme.js";
@@ -17,6 +18,41 @@ export function requestOpenSpawn(): void {
const collapsed = new Set<string>();
// 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.
const STATE_WORDS: Record<string, string> = {
active: "Active",
idle: "Idle",
"awaiting-input": "Awaiting input",
error: "Error",
};
function metaLine(s: Session, now: number): string {
const name = sessionName(s);
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 ? activityDurationLabel(held) : null;
// The directory is the enclosing folder, so only surface the command — and
// only when it isn't already what the name shows.
const showCmd = s.command !== name;
const fragments = [
`<span class="activity-dot" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`,
duration ? `<span class="activity-duration">${duration}</span>` : "",
blockers
? `<span class="card-flag leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>`
: "",
dead ? `<span class="card-flag">exited</span>` : "",
showCmd
? `<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</span>`
: "",
];
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
}
function renderNode(node: TreeNode, depth: number): HTMLElement {
if (node.kind === "folder") {
const wrap = document.createElement("div");
@@ -52,30 +88,34 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
}
const s = node.session;
const now = Date.now();
const row = document.createElement("a");
row.className = "tree-leaf";
row.className = "tree-leaf session-card";
row.href = `#/attach/${encodeURIComponent(s.id)}`;
row.style.paddingLeft = `${depth * 16 + 8}px`;
row.dataset.sessionId = s.id;
const running = s.status === "Running";
row.dataset.status = running ? "running" : "dead";
const name = sessionName(s);
row.setAttribute("aria-label", `${name}, ${running ? "running" : "exited"}`);
// The directory is the enclosing folder, so only surface the command — and
// only when it isn't already what the name shows.
const showCmd = s.command !== name;
// #83: warn on the row itself, in `formatInTheWay`'s shared phrasing, so
// "this will be refused" is visible before the kill is attempted.
const blockers = sessionBlockers(s);
const stateWord =
STATE_WORDS[activityColorSlug(s.activity ?? "Idle")] ?? STATE_WORDS.idle;
row.setAttribute(
"aria-label",
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
);
// docs/SIDEBAR.md line 1: name + top-right actions in the same flow line —
// the actions reserve their width, so the name truncates before it can ever
// reach them. Line 2 renders only when a contract trigger holds.
const showMeta = showsMetaLine(s, now);
row.innerHTML = `
<span class="leaf-name">${escapeHtml(name)}</span>
${showCmd ? `<span class="leaf-meta"><span class="leaf-cmd">${escapeHtml(s.command)}</span></span>` : ""}
${running ? "" : `<span class="leaf-badge">exited</span>`}
${blockers ? `<span class="leaf-badge leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>` : ""}
<span class="leaf-actions">
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
<span class="card-line1">
<span class="leaf-name">${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>
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
</span>
</span>
${showMeta ? metaLine(s, now) : ""}
`;
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
similarBtn.addEventListener("click", (ev) => {
@@ -395,6 +435,13 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const unsubscribe = store.subscribe(refresh);
// Contract granularity: re-render at least every 30s while visible so the
// duration cutover points land within one tick.
const clock = setInterval(() => {
if (document.visibilityState !== "visible") return;
refresh();
}, 30_000);
return {
el,
setActive(id: string | null) {
@@ -410,6 +457,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
applyHighlights();
},
dispose() {
clearInterval(clock);
document.removeEventListener("catalog-rerender", rerenderHandler);
unsubscribe();
},
+66 -18
View File
@@ -59,20 +59,28 @@ function leafRow(opts: {
status: "running" | "dead";
active?: boolean;
indent?: number;
activity?: "active" | "idle" | "awaiting-input" | "error";
duration?: string;
}): HTMLElement {
const row = h("a", "tree-leaf");
const row = h("a", "tree-leaf session-card");
(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 flags = [
opts.status === "dead" ? `<span class="card-flag">exited</span>` : "",
opts.cmd !== opts.name ? `<span class="card-flag leaf-cmd">${opts.cmd}</span>` : "",
].join("");
row.innerHTML = `
<span class="leaf-name">${opts.name}</span>
${opts.cmd !== opts.name ? `<span class="leaf-meta"><span class="leaf-cmd">${opts.cmd}</span></span>` : ""}
${opts.status === "dead" ? `<span class="leaf-badge">exited</span>` : ""}
<span class="leaf-actions">
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${opts.name}">⧉</button>
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${opts.name}">✕</button>
</span>`;
<span class="card-line1">
<span class="leaf-name">${opts.name}</span>
<span class="leaf-actions">
<button class="leaf-act leaf-clone" type="button" title="Spawn similar">⧉</button>
<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>` : ""}
`;
return row;
}
@@ -190,14 +198,17 @@ export function renderKit(): HTMLElement {
// --- chips & badges ---
{
const { el, body } = section("Chips & badges", "Status-bearing pills and counts.");
const activeLine = h(
"span",
"card-line2",
`<span class="activity-dot"></span><span class="activity-duration">4m</span>`,
);
activeLine.setAttribute("data-activity", "active");
body.append(
specimen("session-tally", h("span", "session-tally", "6 sessions · 5 live")),
specimen("folder-count", h("span", "folder-count", "4")),
specimen("leaf-badge", h("span", "leaf-badge", "exited")),
specimen(
"attach-state · open",
h("span", "attach-state", "live"),
),
specimen("card-line2 · active", activeLine),
specimen("attach-state · open", h("span", "attach-state", "live")),
);
(body.lastElementChild?.querySelector(".attach-state") as HTMLElement)?.setAttribute(
"data-state",
@@ -215,19 +226,56 @@ export function renderKit(): HTMLElement {
{
const { el, body } = section(
"Session rows — nested by host, then directory",
"The working directory (home collapsed to ~) is the nesting, and single-child chains fold into one node like ~/git/maestro. “Alive” is a faint row wash; exited rows recede with a badge; the open session is highlighted.",
"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.",
);
const list = h("div", "kit-tree");
list.append(
folderHeader("~/git/maestro", 3, 0),
folderHeader("web", 2, 1),
leafRow({ name: "redesign", cmd: "claude", status: "running", indent: 2 }),
leafRow({ name: "dev-server", cmd: "vite dev", status: "running", indent: 2, active: true }),
leafRow({
name: "dev-server",
cmd: "vite dev",
status: "running",
indent: 2,
active: true,
activity: "active",
duration: "4m",
}),
folderHeader("crates/daemon", 1, 1),
leafRow({ name: "daemon-work", cmd: "claude", status: "running", indent: 2 }),
leafRow({
name: "daemon-work",
cmd: "claude",
status: "running",
indent: 2,
activity: "awaiting-input",
duration: "12s",
}),
leafRow({
name: "flaky-agent",
cmd: "claude",
status: "running",
indent: 2,
activity: "error",
duration: "2h",
}),
folderHeader("~/work/acme", 2, 0),
leafRow({ name: "billing-api", cmd: "claude", status: "running", indent: 1 }),
leafRow({ name: "scratch", cmd: "bash", status: "dead", indent: 1 }),
leafRow({
name: "billing-api",
cmd: "claude",
status: "running",
indent: 1,
activity: "idle",
duration: "26m",
}),
leafRow({
name: "scratch",
cmd: "bash",
status: "dead",
indent: 1,
activity: "idle",
duration: "5h",
}),
leafRow({ name: "monitor", cmd: "btop", status: "running", indent: 0 }),
);
body.classList.add("kit-tree-host");
+63 -38
View File
@@ -787,60 +787,85 @@ textarea {
font-weight: 400;
}
/* #107: session cards per docs/SIDEBAR.md — line 1 (name + top-right actions)
always, line 2 (meta) only when it has something to say. */
.tree-leaf.session-card {
flex-direction: column;
align-items: stretch;
gap: 1px;
}
.card-line1 {
display: flex;
align-items: center;
gap: var(--sp-2);
min-width: 0;
}
.leaf-name {
font-weight: 500;
font-size: var(--fs-13);
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leaf-meta {
font-size: var(--fs-11);
color: var(--muted);
margin-left: auto;
padding-left: var(--sp-2);
/* Grows to push the actions to the right edge but shrinks first, so the name
truncates before it can ever reach them — overlap is a defect (#107). */
flex: 1 1 auto;
min-width: 0;
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-flex;
align-items: baseline;
}
/* Line 2 — dot + duration in the state's colour, then muted fragments. The
state word itself is never spelled out here; the colour carries it. */
.card-line2 {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.leaf-cmd {
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leaf-badge {
font-size: var(--fs-11);
letter-spacing: 0.02em;
text-transform: uppercase;
min-width: 0;
color: var(--muted);
border: 1px solid var(--border);
border-radius: var(--r-pill);
padding: 1px 8px;
}
.card-line2[data-activity="active"] {
color: var(--live);
}
.card-line2[data-activity="idle"] {
color: var(--muted);
}
.card-line2[data-activity="awaiting-input"] {
color: var(--warn);
}
.card-line2[data-activity="error"] {
color: var(--danger);
}
.activity-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
flex: 0 0 auto;
}
/* #83: the session row's blocked-work warning — an unforced kill will be
refused, and the operator should see that before pressing the button. */
.leaf-badge.leaf-blocked {
color: var(--warn, #b45309);
border-color: var(--warn, #b45309);
.activity-duration {
color: inherit;
flex: 0 0 auto;
}
/* Blocked-work / exited / command fragments recede: the ⚠ glyph already
signals severity and "exited" dims with the row. */
.card-flag {
color: var(--faint);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Actions live in line 1's flow at its right edge — never overlaid on the
name (#107). They reserve their width even while hover-faded. */
.leaf-actions {
position: absolute;
right: var(--sp-1);
top: 50%;
transform: translateY(-50%);
flex: 0 0 auto;
margin-left: auto;
display: inline-flex;
gap: 2px;
}