Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71add0215a | ||
|
|
766d256970 | ||
|
|
78cddec730 | ||
|
|
4ff31a4f79 | ||
|
|
abab7dcb63 | ||
|
|
c7c5b2e0ec | ||
|
|
f6a97c4695 | ||
|
|
323ead5a83 | ||
|
|
5be3e33c94 | ||
|
|
002c128ac8 | ||
|
|
bb28fe1d6e | ||
|
|
d7b15a131f | ||
|
|
581ec29492 | ||
|
|
90b7953b17 | ||
|
|
26e7992c8d | ||
|
|
60515dd8c6 | ||
|
|
b3c73fd2cb | ||
|
|
7d06bec21e | ||
|
|
1a0d1284e1 | ||
|
|
fc6f3469b0 | ||
|
|
b95b5c04c1 | ||
|
|
6af8abccd3 | ||
|
|
ad2e700391 | ||
|
|
2dcef860a6 | ||
|
|
0bb5df7245 | ||
|
|
cb070e4f84 | ||
|
|
8fde211348 |
+13
-3
@@ -106,14 +106,24 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# The runner container is root with no `nixbld` group, but the installer
|
||||||
|
# writes `build-users-group = nixbld` anyway and then fails on it, so the
|
||||||
|
# config goes in first. Flakes are off by default and `nix build .#…`
|
||||||
|
# needs them. Written again afterwards in case the installer replaced it.
|
||||||
- name: Install nix
|
- name: Install nix
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL https://nixos.org/nix/install | sh -s -- --daemon --yes
|
mkdir -p /etc/nix
|
||||||
echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH"
|
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf
|
||||||
|
curl -fsSL https://nixos.org/nix/install | sh -s -- --no-daemon --yes
|
||||||
|
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf
|
||||||
|
echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
# The installer does not enable flakes, so `nix build .#…` needs the
|
||||||
|
# features passed explicitly — a local machine that has them in
|
||||||
|
# nix.conf will build fine while CI fails without this.
|
||||||
- name: build flake packages
|
- name: build flake packages
|
||||||
run: |
|
run: |
|
||||||
nix build \
|
nix --extra-experimental-features "nix-command flakes" build \
|
||||||
.#webFrontend \
|
.#webFrontend \
|
||||||
.#maestro \
|
.#maestro \
|
||||||
.#maestro-web \
|
.#maestro-web \
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ See `docs/INSTALL.md` for end-user install instructions.
|
|||||||
collisions, what blocks a kill, what `--force` retains, and what reclaim
|
collisions, what blocks a kill, what `--force` retains, and what reclaim
|
||||||
touches. Read it before changing anything that creates or removes one.
|
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
|
`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
|
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
|
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
ActivityState::Active => "active",
|
ActivityState::Active => "active",
|
||||||
ActivityState::Idle => "idle",
|
ActivityState::Idle => "idle",
|
||||||
ActivityState::AwaitingInput => "awaiting",
|
ActivityState::AwaitingInput => "awaiting",
|
||||||
|
ActivityState::Error => "error",
|
||||||
};
|
};
|
||||||
// Show the live foreground command alongside the spawn command when
|
// Show the live foreground command alongside the spawn command when
|
||||||
// the daemon has observed one that differs (NAP-340).
|
// the daemon has observed one that differs (NAP-340).
|
||||||
@@ -176,6 +177,7 @@ mod tests {
|
|||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: chrono::Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
// One store instance shared with the API handlers: forced kills (#81)
|
||||||
// record here, `GET /api/worktrees` (#80) reads back from here.
|
// record here, `GET /api/worktrees` (#80) reads back from here.
|
||||||
.with_retained_store((*retained).clone());
|
.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?;
|
let recovered = manager.recover().await?;
|
||||||
tracing::info!(recovered, "adopted existing tmux sessions");
|
tracing::info!(recovered, "adopted existing tmux sessions");
|
||||||
|
|||||||
@@ -131,6 +131,11 @@ struct Inner {
|
|||||||
/// Retained-worktree store (#78). `None` when not wired (tests); a forced
|
/// Retained-worktree store (#78). `None` when not wired (tests); a forced
|
||||||
/// kill (#81) without one fails rather than destroying unrecorded work.
|
/// kill (#81) without one fails rather than destroying unrecorded work.
|
||||||
retained: Option<crate::retained_store::RetainedStore>,
|
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>>,
|
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
|
||||||
events: broadcast::Sender<CatalogEvent>,
|
events: broadcast::Sender<CatalogEvent>,
|
||||||
}
|
}
|
||||||
@@ -170,6 +175,7 @@ impl SessionManager {
|
|||||||
replay_bytes,
|
replay_bytes,
|
||||||
spawn_history: None,
|
spawn_history: None,
|
||||||
retained: None,
|
retained: None,
|
||||||
|
daemon_url: std::sync::Mutex::new(None),
|
||||||
sessions: Mutex::new(HashMap::new()),
|
sessions: Mutex::new(HashMap::new()),
|
||||||
events,
|
events,
|
||||||
}),
|
}),
|
||||||
@@ -202,6 +208,14 @@ impl SessionManager {
|
|||||||
self
|
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
|
/// 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.
|
/// deduped by dir. Empty when no history is wired or it's disabled.
|
||||||
pub fn recent_dirs(&self) -> Vec<RecentDir> {
|
pub fn recent_dirs(&self) -> Vec<RecentDir> {
|
||||||
@@ -278,7 +292,11 @@ impl SessionManager {
|
|||||||
self.inner.worktree_mgr.add_existing(base_repo, branch)
|
self.inner.worktree_mgr.add_existing(base_repo, branch)
|
||||||
}
|
}
|
||||||
.map_err(SessionError::Worktree)?;
|
.map_err(SessionError::Worktree)?;
|
||||||
(path.clone(), Some(path), Some(base_repo.clone()))
|
// Record the repository, not whatever the request named: a
|
||||||
|
// spawn from inside a linked worktree passes that worktree as
|
||||||
|
// `base_repo`, and clients group sessions by this path (#99).
|
||||||
|
let repo = crate::worktree::main_checkout_of(base_repo);
|
||||||
|
(path.clone(), Some(path), Some(repo))
|
||||||
}
|
}
|
||||||
None => (req.cwd.clone(), None, None),
|
None => (req.cwd.clone(), None, None),
|
||||||
};
|
};
|
||||||
@@ -300,7 +318,7 @@ impl SessionManager {
|
|||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let session_env = match sanitize_env(&req.env) {
|
let mut session_env = match sanitize_env(&req.env) {
|
||||||
Ok(env) => env,
|
Ok(env) => env,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = std::fs::remove_file(&fifo_path);
|
let _ = std::fs::remove_file(&fifo_path);
|
||||||
@@ -308,6 +326,19 @@ impl SessionManager {
|
|||||||
return Err(e);
|
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 spawned = {
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
let cwd = effective_cwd.clone();
|
let cwd = effective_cwd.clone();
|
||||||
@@ -349,6 +380,7 @@ impl SessionManager {
|
|||||||
foreground: None,
|
foreground: None,
|
||||||
activity: ActivityState::Active,
|
activity: ActivityState::Active,
|
||||||
last_activity_at: Utc::now(),
|
last_activity_at: Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
|
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
// The watcher fills this in on its first blocked-work pass; a
|
// The watcher fills this in on its first blocked-work pass; a
|
||||||
@@ -565,9 +597,13 @@ impl SessionManager {
|
|||||||
status: SessionStatus::Running,
|
status: SessionStatus::Running,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
// Activity is unknown on adoption; the watcher fills it in.
|
// Activity is unknown on adoption; the watcher fills it in.
|
||||||
|
// #104: the restart also resets the activity clock to now —
|
||||||
|
// accepted, since the daemon cannot know how long a
|
||||||
|
// recovered session had already been sitting idle.
|
||||||
foreground: None,
|
foreground: None,
|
||||||
activity: ActivityState::Idle,
|
activity: ActivityState::Idle,
|
||||||
last_activity_at: Utc::now(),
|
last_activity_at: Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
|
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
@@ -824,6 +860,12 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
|
|||||||
{
|
{
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
|
// #104: stamp only when the state itself changed — never on
|
||||||
|
// foreground/cwd-only diffs, and never every tick, or the
|
||||||
|
// sidebar's duration would reset continuously and read zero.
|
||||||
|
if rec.session.activity != activity {
|
||||||
|
rec.session.activity_since = Utc::now();
|
||||||
|
}
|
||||||
rec.session.activity = activity;
|
rec.session.activity = activity;
|
||||||
rec.session.foreground = foreground;
|
rec.session.foreground = foreground;
|
||||||
rec.session.current_cwd = current_cwd;
|
rec.session.current_cwd = current_cwd;
|
||||||
|
|||||||
@@ -264,6 +264,25 @@ impl Tmux {
|
|||||||
check(out)
|
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.
|
/// `tmux kill-server`. Best-effort cleanup used mostly by tests.
|
||||||
pub fn kill_server(&self) -> Result<(), TmuxError> {
|
pub fn kill_server(&self) -> Result<(), TmuxError> {
|
||||||
let out = self.cmd().arg("kill-server").output()?;
|
let out = self.cmd().arg("kill-server").output()?;
|
||||||
|
|||||||
@@ -174,6 +174,13 @@ impl WorktreeManager {
|
|||||||
/// The repository's main checkout. `--git-common-dir` points at the shared
|
/// The repository's main checkout. `--git-common-dir` points at the shared
|
||||||
/// `.git` for every linked worktree, so its parent is the main tree. Falls
|
/// `.git` for every linked worktree, so its parent is the main tree. Falls
|
||||||
/// back to the input when git can't answer.
|
/// back to the input when git can't answer.
|
||||||
|
/// The repository a path belongs to: the main checkout, even when `path` is a
|
||||||
|
/// linked worktree. Clients group sessions by this (#99), and placement uses it
|
||||||
|
/// so a worktree is never nested inside another.
|
||||||
|
pub fn main_checkout_of(path: &Path) -> PathBuf {
|
||||||
|
main_checkout(path)
|
||||||
|
}
|
||||||
|
|
||||||
fn main_checkout(path: &Path) -> PathBuf {
|
fn main_checkout(path: &Path) -> PathBuf {
|
||||||
let out = Command::new("git")
|
let out = Command::new("git")
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
|
|||||||
@@ -30,6 +30,24 @@ impl Fx {
|
|||||||
Self::with_sidecar_dir(|tmp| tmp.join("sidecars"))
|
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 {
|
fn with_sidecar_dir(pick: impl Fn(&std::path::Path) -> PathBuf) -> Self {
|
||||||
let tmp = TempDir::new().expect("tempdir");
|
let tmp = TempDir::new().expect("tempdir");
|
||||||
let fake = Arc::new(FakePanes::new());
|
let fake = Arc::new(FakePanes::new());
|
||||||
@@ -261,16 +279,23 @@ async fn requested_env_reaches_tmux() {
|
|||||||
|
|
||||||
fx.manager.create(req).await.expect("create");
|
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> = [
|
let want: BTreeMap<String, String> = [
|
||||||
("EMPTY".to_string(), String::new()),
|
("EMPTY".to_string(), String::new()),
|
||||||
("FOO".to_string(), "bar".to_string()),
|
("FOO".to_string(), "bar".to_string()),
|
||||||
|
("MAESTRO_SESSION_ID".to_string(), id),
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect();
|
.collect();
|
||||||
match fx.fake.calls().first() {
|
assert_eq!(&spawned, &want);
|
||||||
Some(Call::NewSessionPiped { env, .. }) => assert_eq!(env, &want),
|
|
||||||
other => panic!("expected a spawn call, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -338,6 +363,9 @@ async fn activity_pass_emits_updated_only_when_something_changed() {
|
|||||||
// Still inside `activity::ACTIVE_WINDOW` (3s) since the spawn, so the
|
// Still inside `activity::ACTIVE_WINDOW` (3s) since the spawn, so the
|
||||||
// change here is `foreground`, not the activity state.
|
// change here is `foreground`, not the activity state.
|
||||||
assert_eq!(updated.activity, ActivityState::Active);
|
assert_eq!(updated.activity, ActivityState::Active);
|
||||||
|
// A foreground/cwd-only change is not a state transition (#104): the
|
||||||
|
// sidebar's duration must keep counting from the original stamp.
|
||||||
|
assert_eq!(updated.activity_since, session.activity_since);
|
||||||
|
|
||||||
// Drain anything already queued, then confirm a steady pane stays quiet.
|
// Drain anything already queued, then confirm a steady pane stays quiet.
|
||||||
// Stays well inside the 3s window, so no Active -> Idle transition either.
|
// Stays well inside the 3s window, so no Active -> Idle transition either.
|
||||||
@@ -349,6 +377,70 @@ async fn activity_pass_emits_updated_only_when_something_changed() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The sidebar's duration (#104) is time in the *current* state, so
|
||||||
|
/// `activity_since` is stamped exactly when the decided state differs from
|
||||||
|
/// the stored one — once per transition. Stamping it every tick would make
|
||||||
|
/// the duration permanently read zero.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn activity_transition_stamps_activity_since_once_per_transition() {
|
||||||
|
let fx = Fx::new();
|
||||||
|
let session = fx
|
||||||
|
.manager
|
||||||
|
.create(fx.request("sleep 300"))
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
fx.fake.set_pane_info(
|
||||||
|
session.id,
|
||||||
|
PaneInfo {
|
||||||
|
// Nothing reads from this pid: `awaiting_input` is deterministically
|
||||||
|
// false, so past `ACTIVE_WINDOW` the session decides as Idle.
|
||||||
|
pane_pid: i32::MAX,
|
||||||
|
command: "sleep".into(),
|
||||||
|
tty: "/dev/null".into(),
|
||||||
|
current_path: String::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut events = fx.manager.events();
|
||||||
|
fx.manager.start_liveness_watcher(Duration::from_millis(25));
|
||||||
|
|
||||||
|
let idle = wait_for_activity(&fx.manager, session.id, ActivityState::Idle).await;
|
||||||
|
assert!(
|
||||||
|
idle.activity_since >= session.activity_since + chrono::Duration::seconds(2),
|
||||||
|
"the Active -> Idle transition must stamp a fresh activity_since \
|
||||||
|
(spawn stamp {:?}, transition stamp {:?})",
|
||||||
|
session.activity_since,
|
||||||
|
idle.activity_since
|
||||||
|
);
|
||||||
|
|
||||||
|
// Steady Idle across many more ticks must not re-stamp — no further
|
||||||
|
// SessionUpdated at all, or the sidebar clock would keep resetting.
|
||||||
|
while events.try_recv().is_ok() {}
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
assert!(
|
||||||
|
matches!(events.try_recv(), Err(TryRecvError::Empty)),
|
||||||
|
"a steady state must not re-emit (and re-stamp) on every tick"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_activity(
|
||||||
|
manager: &SessionManager,
|
||||||
|
id: uuid::Uuid,
|
||||||
|
want: ActivityState,
|
||||||
|
) -> maestro_protocol::Session {
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(6);
|
||||||
|
loop {
|
||||||
|
if tokio::time::Instant::now() >= deadline {
|
||||||
|
panic!("timed out waiting for {id} to become {want:?}");
|
||||||
|
}
|
||||||
|
if let Some(s) = manager.snapshot().await.into_iter().find(|s| s.id == id) {
|
||||||
|
if s.activity == want {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn wait_for_updated(
|
async fn wait_for_updated(
|
||||||
events: &mut tokio::sync::broadcast::Receiver<CatalogEvent>,
|
events: &mut tokio::sync::broadcast::Receiver<CatalogEvent>,
|
||||||
) -> maestro_protocol::Session {
|
) -> maestro_protocol::Session {
|
||||||
@@ -396,3 +488,70 @@ fn attaching_spawn_history_after_cloning_panics() {
|
|||||||
7,
|
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())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -947,3 +947,101 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
|
|||||||
|
|
||||||
fx.manager.kill(s.id, false).await.unwrap();
|
fx.manager.kill(s.id, false).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #99: clients group a session under `worktree.base_repo`, so that field has
|
||||||
|
/// to name the repository even when the spawn request pointed at a linked
|
||||||
|
/// worktree. Recording the request verbatim put the session in a folder of its
|
||||||
|
/// own — `<repo>/worktrees/<branch>` — instead of under the repo.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let repo = TempDir::new().unwrap();
|
||||||
|
init_repo(repo.path());
|
||||||
|
|
||||||
|
// A linked worktree of that repo, then a spawn whose `base_repo` names it
|
||||||
|
// rather than the main checkout — what happens when the operator spawns
|
||||||
|
// from inside a worktree.
|
||||||
|
let first = fx
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: repo.path().to_path_buf(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: Some(WorktreeSpec {
|
||||||
|
base_repo: repo.path().to_path_buf(),
|
||||||
|
branch: "feat/one".into(),
|
||||||
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create first");
|
||||||
|
let inner_worktree = first.worktree.expect("first has a worktree").path;
|
||||||
|
|
||||||
|
let second = fx
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: inner_worktree.clone(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: Some(WorktreeSpec {
|
||||||
|
base_repo: inner_worktree.clone(),
|
||||||
|
branch: "feat/two".into(),
|
||||||
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create second");
|
||||||
|
|
||||||
|
let wt = second.worktree.expect("second has a worktree");
|
||||||
|
assert_eq!(
|
||||||
|
wt.base_repo,
|
||||||
|
repo.path()
|
||||||
|
.canonicalize()
|
||||||
|
.unwrap_or_else(|_| repo.path().to_path_buf()),
|
||||||
|
"base_repo must be the repository, not the worktree the request named",
|
||||||
|
);
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ fn sample(id: Uuid) -> Session {
|
|||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: chrono::Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ pub struct Session {
|
|||||||
/// from daemons that predate the field.
|
/// from daemons that predate the field.
|
||||||
#[serde(default = "epoch")]
|
#[serde(default = "epoch")]
|
||||||
pub last_activity_at: DateTime<Utc>,
|
pub last_activity_at: DateTime<Utc>,
|
||||||
|
/// Wall-clock time at which the session entered its current
|
||||||
|
/// [`ActivityState`] (#102) — how long it has been `Active`, `Idle` or
|
||||||
|
/// `AwaitingInput`. Distinct from [`Self::last_activity_at`], which only
|
||||||
|
/// tracks the last observed PTY output. Populated by #104; defaults to the
|
||||||
|
/// Unix epoch on payloads from daemons that predate the field.
|
||||||
|
#[serde(default = "epoch")]
|
||||||
|
pub activity_since: DateTime<Utc>,
|
||||||
/// The pane's *current* working directory (`#{pane_current_path}`) — distinct
|
/// The pane's *current* working directory (`#{pane_current_path}`) — distinct
|
||||||
/// from [`Session::cwd`], the directory it was spawned in. The activity
|
/// from [`Session::cwd`], the directory it was spawned in. The activity
|
||||||
/// watcher refreshes it as the user `cd`s around; it drives the web UI's
|
/// watcher refreshes it as the user `cd`s around; it drives the web UI's
|
||||||
@@ -110,7 +117,12 @@ pub enum SessionStatus {
|
|||||||
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
|
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
|
||||||
/// daemon derives this from tmux pane state, PTY quiescence, and `/proc`
|
/// daemon derives this from tmux pane state, PTY quiescence, and `/proc`
|
||||||
/// inspection — never by parsing the terminal byte stream.
|
/// inspection — never by parsing the terminal byte stream.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
///
|
||||||
|
/// Deserializes leniently (#110, SIDEBAR.md Precedence): an unknown variant
|
||||||
|
/// from a newer daemon becomes [`ActivityState::Idle`] instead of failing the
|
||||||
|
/// whole `Session`. Non-string values still error — only variant names are
|
||||||
|
/// forgiven.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
|
||||||
pub enum ActivityState {
|
pub enum ActivityState {
|
||||||
/// Producing output, or the foreground process is busy.
|
/// Producing output, or the foreground process is busy.
|
||||||
Active,
|
Active,
|
||||||
@@ -119,6 +131,25 @@ pub enum ActivityState {
|
|||||||
Idle,
|
Idle,
|
||||||
/// Blocked on a `read()` from the pty — waiting on the user.
|
/// Blocked on a `read()` from the pty — waiting on the user.
|
||||||
AwaitingInput,
|
AwaitingInput,
|
||||||
|
/// The agent running in the session reported a failure (#102). Never
|
||||||
|
/// inferred by the daemon; only set when an agent reports it (#105).
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for ActivityState {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
Ok(match s.as_str() {
|
||||||
|
"Active" => Self::Active,
|
||||||
|
"AwaitingInput" => Self::AwaitingInput,
|
||||||
|
"Error" => Self::Error,
|
||||||
|
// Known or not, any other spelling degrades to the neutral default
|
||||||
|
_ => Self::Idle,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Optional worktree request attached to a `CreateSessionRequest`.
|
/// Optional worktree request attached to a `CreateSessionRequest`.
|
||||||
@@ -537,6 +568,9 @@ mod tests {
|
|||||||
last_activity_at: DateTime::parse_from_rfc3339("2026-05-24T12:05:00Z")
|
last_activity_at: DateTime::parse_from_rfc3339("2026-05-24T12:05:00Z")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.with_timezone(&Utc),
|
.with_timezone(&Utc),
|
||||||
|
activity_since: DateTime::parse_from_rfc3339("2026-05-24T12:04:30Z")
|
||||||
|
.unwrap()
|
||||||
|
.with_timezone(&Utc),
|
||||||
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
@@ -588,6 +622,35 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn activity_since_defaults_to_epoch_on_legacy_payload() {
|
||||||
|
// A daemon predating #102 omits `activity_since`; it must deserialize
|
||||||
|
// to the forward-compat default rather than failing, and a present
|
||||||
|
// value round-trips.
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"id": Uuid::from_u128(1),
|
||||||
|
"cwd": "/tmp",
|
||||||
|
"command": "bash",
|
||||||
|
"status": "Running",
|
||||||
|
"created_at": "2026-05-24T12:00:00Z"
|
||||||
|
});
|
||||||
|
let s: Session = serde_json::from_value(json).unwrap();
|
||||||
|
assert_eq!(s.activity_since, epoch());
|
||||||
|
|
||||||
|
let full = sample_session();
|
||||||
|
assert_eq!(full, round_trip(&full));
|
||||||
|
}
|
||||||
|
|
||||||
|
// #102: nothing infers `Error`; only #105's agent-reported failure sets it.
|
||||||
|
#[test]
|
||||||
|
fn error_activity_state_serializes_pascal_case_and_idle_stays_default() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(ActivityState::Error).unwrap(),
|
||||||
|
serde_json::json!("Error")
|
||||||
|
);
|
||||||
|
assert_eq!(ActivityState::default(), ActivityState::Idle);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn activity_state_serializes_pascal_case_and_defaults_idle() {
|
fn activity_state_serializes_pascal_case_and_defaults_idle() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -597,6 +660,35 @@ mod tests {
|
|||||||
assert_eq!(ActivityState::default(), ActivityState::Idle);
|
assert_eq!(ActivityState::default(), ActivityState::Idle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #110 / SIDEBAR.md Precedence: a future daemon may send an
|
||||||
|
// `ActivityState` variant this client does not know; it must degrade to
|
||||||
|
// the neutral default instead of failing the whole `Session`.
|
||||||
|
#[test]
|
||||||
|
fn unknown_activity_state_variant_deserializes_to_idle() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<ActivityState>(serde_json::json!("Teleported")).unwrap(),
|
||||||
|
ActivityState::Idle
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut future = serde_json::to_value(sample_session()).unwrap();
|
||||||
|
future["activity"] = serde_json::json!("QuantumEntangled");
|
||||||
|
let s: Session = serde_json::from_value(future).unwrap();
|
||||||
|
assert_eq!(s.activity, ActivityState::Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_string_activity_payload_still_fails_deserialization() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"id": Uuid::from_u128(1),
|
||||||
|
"cwd": "/tmp",
|
||||||
|
"command": "bash",
|
||||||
|
"status": "Running",
|
||||||
|
"created_at": "2026-05-24T12:00:00Z",
|
||||||
|
"activity": 42,
|
||||||
|
});
|
||||||
|
assert!(serde_json::from_value::<Session>(json).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_round_trips() {
|
fn session_round_trips() {
|
||||||
let s = sample_session();
|
let s = sample_session();
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
|||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: Utc::now(),
|
last_activity_at: Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1127,6 +1127,7 @@ mod tests {
|
|||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: Utc::now(),
|
last_activity_at: Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
}],
|
}],
|
||||||
@@ -1644,6 +1645,7 @@ mod tests {
|
|||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: chrono::Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
|
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ mod tests {
|
|||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: chrono::Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
//! Sidebar widget — session list, navigation-only.
|
//! Sidebar widget — session cards per `docs/SIDEBAR.md`.
|
||||||
//!
|
//!
|
||||||
//! Status glyphs: ● attached / ○ live / ✗ dead.
|
//! Line 1: status glyph (● attached / ○ live / ✗ dead) + name.
|
||||||
|
//! Line 2: activity dot + duration in the state's colour, plus the
|
||||||
|
//! blocked-work and exited fragments when they apply. Absent entirely when
|
||||||
|
//! it has nothing to say.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use maestro_protocol::{Session, SessionStatus};
|
use chrono::Utc;
|
||||||
|
use maestro_protocol::{ActivityState, Session, SessionStatus};
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
@@ -12,6 +16,32 @@ use ratatui::widgets::{Block, Borders, Paragraph};
|
|||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Time-in-state alone earns a meta line only once the session has sat in
|
||||||
|
/// its current activity state this long (docs/SIDEBAR.md).
|
||||||
|
const META_LINE_AFTER_SECS: i64 = 30;
|
||||||
|
/// A payload predating #102 defaults `activity_since` to the Unix epoch. Per
|
||||||
|
/// docs/SIDEBAR.md such a session contributes no duration — the dot still
|
||||||
|
/// renders — so anything implausibly old counts as "never observed" rather
|
||||||
|
/// than a card announcing that the session has been idle for half a century.
|
||||||
|
/// Mirrors `PLAUSIBLE_EPOCH_MS` in `web/src/activity.ts`.
|
||||||
|
const PLAUSIBLE_SINCE_YEAR: i32 = 2010;
|
||||||
|
const INDENT: &str = " ";
|
||||||
|
const META_INDENT: &str = " ";
|
||||||
|
/// Spawn-similar and kill, shown on the highlighted card's top-right corner.
|
||||||
|
const ACTIONS: &str = "⧉ ✕";
|
||||||
|
|
||||||
|
/// Every card sits on this background so cards read as cards against the
|
||||||
|
/// panel; the highlighted card swaps it for the focus background instead
|
||||||
|
/// (`Modifier::REVERSED` made the card unreadable against a background).
|
||||||
|
const CARD_BG: Color = Color::Black;
|
||||||
|
fn card_base(highlighted: bool) -> Style {
|
||||||
|
if highlighted {
|
||||||
|
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||||
|
} else {
|
||||||
|
Style::default().bg(CARD_BG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Renderable sidebar state.
|
/// Renderable sidebar state.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Sidebar {
|
pub struct Sidebar {
|
||||||
@@ -41,36 +71,171 @@ impl Sidebar {
|
|||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
f.render_widget(block, area);
|
f.render_widget(block, area);
|
||||||
|
|
||||||
|
let width = inner.width as usize;
|
||||||
let mut lines: Vec<Line> = vec![Line::from(Span::styled(
|
let mut lines: Vec<Line> = vec![Line::from(Span::styled(
|
||||||
"sessions",
|
"sessions",
|
||||||
Style::default().add_modifier(Modifier::BOLD),
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
))];
|
))];
|
||||||
for s in &self.sessions {
|
for s in &self.sessions {
|
||||||
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
let highlighted = self.highlight == Some(s.id);
|
||||||
(SessionStatus::Dead, _) => "✗",
|
let base = card_base(highlighted);
|
||||||
(SessionStatus::Running, true) => "●",
|
lines.push(Line::from(self.name_row(s, width)));
|
||||||
(SessionStatus::Running, false) => "○",
|
if let Some(meta) = self.meta_row(s, width) {
|
||||||
};
|
lines.push(Line::from(pad_to_width(meta, width, base)));
|
||||||
let label = name_for(s);
|
}
|
||||||
// #83: a session whose worktree blocks a kill gets a warning
|
|
||||||
// glyph; the kill confirm names the specifics.
|
|
||||||
let blocked = s
|
|
||||||
.blocked_work
|
|
||||||
.filter(|b| !b.is_clear())
|
|
||||||
.map(|_| " ⚠")
|
|
||||||
.unwrap_or_default();
|
|
||||||
let style = if self.highlight == Some(s.id) {
|
|
||||||
Style::default().add_modifier(Modifier::REVERSED)
|
|
||||||
} else {
|
|
||||||
Style::default()
|
|
||||||
};
|
|
||||||
lines.push(Line::from(vec![
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(format!("{glyph} {label}{blocked}"), style),
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
f.render_widget(Paragraph::new(lines), inner);
|
f.render_widget(Paragraph::new(lines), inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn name_row(&self, s: &Session, width: usize) -> Vec<Span<'static>> {
|
||||||
|
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
||||||
|
(SessionStatus::Dead, _) => "✗",
|
||||||
|
(SessionStatus::Running, true) => "●",
|
||||||
|
(SessionStatus::Running, false) => "○",
|
||||||
|
};
|
||||||
|
let highlighted = self.highlight == Some(s.id);
|
||||||
|
let base = card_base(highlighted);
|
||||||
|
// Line-1 actions are keyboard affordances shown on the highlighted
|
||||||
|
// row only; they reserve their width so the name truncates with `…`
|
||||||
|
// before it can ever reach them (docs/SIDEBAR.md).
|
||||||
|
let actions_width = if highlighted {
|
||||||
|
ACTIONS.chars().count()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let usable = width.saturating_sub(actions_width);
|
||||||
|
let prefix = INDENT.chars().count() + 2;
|
||||||
|
// Keep one column of separation between a truncated name and the
|
||||||
|
// actions so the ellipsis never touches them.
|
||||||
|
let sep = if highlighted { 1 } else { 0 };
|
||||||
|
let name_budget = usable.saturating_sub(prefix + sep);
|
||||||
|
|
||||||
|
let mut spans: Vec<Span<'static>> = vec![
|
||||||
|
Span::styled(INDENT, base),
|
||||||
|
Span::styled(format!("{glyph} "), base),
|
||||||
|
];
|
||||||
|
spans.extend(
|
||||||
|
fit_to_width(vec![Span::raw(name_for(s))], name_budget)
|
||||||
|
.into_iter()
|
||||||
|
.map(|sp| Span::styled(sp.content, base.patch(sp.style))),
|
||||||
|
);
|
||||||
|
let used: usize = spans.iter().map(|sp| sp.width()).sum();
|
||||||
|
if let Some(fill) = usable.checked_sub(used).filter(|&n| n > 0) {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
" ".repeat(fill),
|
||||||
|
Style::default().bg(base.bg.unwrap_or(Color::Reset)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if highlighted {
|
||||||
|
spans.push(Span::styled(ACTIONS, base));
|
||||||
|
}
|
||||||
|
spans
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The adaptive second line: `<dot> <duration> [<blocked work> | exited]`.
|
||||||
|
fn meta_row(&self, s: &Session, width: usize) -> Option<Vec<Span<'static>>> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let held = held_for(s, now);
|
||||||
|
let held_secs = held.map(|d| d.num_seconds()).unwrap_or(0);
|
||||||
|
// #83: a worktree that blocks a kill is summarised here rather than
|
||||||
|
// on line 1; the kill confirm names the specifics.
|
||||||
|
let blocked = s.blocked_work.filter(|b| !b.is_clear());
|
||||||
|
let dead = s.status == SessionStatus::Dead;
|
||||||
|
let errored = s.activity == ActivityState::Error;
|
||||||
|
if (held.is_none() || held_secs < META_LINE_AFTER_SECS)
|
||||||
|
&& blocked.is_none()
|
||||||
|
&& !dead
|
||||||
|
&& !errored
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let base = card_base(self.highlight == Some(s.id));
|
||||||
|
let dot_style = base.patch(Style::default().fg(activity_color(s.activity)));
|
||||||
|
let muted = Style::default().bg(base.bg.unwrap_or(Color::Reset));
|
||||||
|
let mut spans = vec![
|
||||||
|
Span::styled(META_INDENT, base),
|
||||||
|
Span::styled("●", dot_style),
|
||||||
|
];
|
||||||
|
if let Some(d) = held {
|
||||||
|
spans.push(Span::styled(" ", base));
|
||||||
|
spans.push(Span::styled(format_state_duration(d), dot_style));
|
||||||
|
}
|
||||||
|
if let Some(b) = blocked {
|
||||||
|
spans.push(Span::styled(format!(" ⚠ {b}"), muted));
|
||||||
|
}
|
||||||
|
if dead {
|
||||||
|
spans.push(Span::styled(" exited", muted));
|
||||||
|
}
|
||||||
|
Some(fit_to_width(spans, width))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ratatui colour for an [`ActivityState`], per docs/SIDEBAR.md. The state
|
||||||
|
/// word is never spelled out — this colour carries it.
|
||||||
|
fn activity_color(state: ActivityState) -> Color {
|
||||||
|
match state {
|
||||||
|
ActivityState::Active => Color::Green,
|
||||||
|
ActivityState::Idle => Color::DarkGray,
|
||||||
|
ActivityState::AwaitingInput => Color::Yellow,
|
||||||
|
ActivityState::Error => Color::Red,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours,
|
||||||
|
/// measured in the current activity state. No days, no rounding up.
|
||||||
|
/// How long the session has held its current activity state, or `None` when
|
||||||
|
/// the daemon never reported one (see [`PLAUSIBLE_SINCE_YEAR`]).
|
||||||
|
fn held_for(s: &Session, now: chrono::DateTime<Utc>) -> Option<chrono::Duration> {
|
||||||
|
use chrono::Datelike;
|
||||||
|
if s.activity_since.year() < PLAUSIBLE_SINCE_YEAR {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(now - s.activity_since)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_state_duration(elapsed: chrono::Duration) -> String {
|
||||||
|
let secs = elapsed.num_seconds().max(0);
|
||||||
|
match secs {
|
||||||
|
0..=59 => format!("{secs}s"),
|
||||||
|
60..=3599 => format!("{}m", secs / 60),
|
||||||
|
_ => format!("{}h", secs / 3600),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Truncate a span list to `max` columns, ending it with `…` when cut. The
|
||||||
|
/// sidebar is narrow: card lines truncate, they never wrap.
|
||||||
|
fn fit_to_width(spans: Vec<Span<'static>>, max: usize) -> Vec<Span<'static>> {
|
||||||
|
let mut out: Vec<Span<'static>> = Vec::with_capacity(spans.len());
|
||||||
|
let mut used = 0usize;
|
||||||
|
for span in spans {
|
||||||
|
let w = span.width();
|
||||||
|
if used + w <= max {
|
||||||
|
used += w;
|
||||||
|
out.push(span);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let budget = max.saturating_sub(used + 1);
|
||||||
|
let ellipsis = "…";
|
||||||
|
if budget > 0 {
|
||||||
|
let text: String = span.content.chars().take(budget).collect();
|
||||||
|
out.push(Span::styled(format!("{text}{ellipsis}"), span.style));
|
||||||
|
} else if used < max {
|
||||||
|
out.push(Span::styled(ellipsis, span.style));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pad a card row out to the full sidebar width so the card background
|
||||||
|
/// paints edge to edge rather than stopping at the last glyph.
|
||||||
|
fn pad_to_width(mut spans: Vec<Span<'static>>, width: usize, base: Style) -> Vec<Span<'static>> {
|
||||||
|
let used: usize = spans.iter().map(|sp| sp.width()).sum();
|
||||||
|
if let Some(fill) = width.checked_sub(used).filter(|&n| n > 0) {
|
||||||
|
let pad_style = Style::default().bg(base.bg.unwrap_or(Color::Reset));
|
||||||
|
spans.push(Span::styled(" ".repeat(fill), pad_style));
|
||||||
|
}
|
||||||
|
spans
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `metadata.name` if present, else the command line.
|
/// `metadata.name` if present, else the command line.
|
||||||
@@ -85,8 +250,10 @@ fn name_for(s: &Session) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use chrono::Utc;
|
use chrono::{DateTime, Duration};
|
||||||
|
use maestro_protocol::InTheWay;
|
||||||
use ratatui::backend::TestBackend;
|
use ratatui::backend::TestBackend;
|
||||||
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -104,25 +271,43 @@ mod tests {
|
|||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: Utc::now(),
|
||||||
|
activity_since: Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_to_string(sidebar: &Sidebar, w: u16, h: u16) -> String {
|
fn draw(sidebar: &Sidebar, w: u16, h: u16) -> Buffer {
|
||||||
let backend = TestBackend::new(w, h);
|
let backend = TestBackend::new(w, h);
|
||||||
let mut terminal = Terminal::new(backend).unwrap();
|
let mut terminal = Terminal::new(backend).unwrap();
|
||||||
terminal.draw(|f| sidebar.render(f, f.area())).unwrap();
|
terminal.draw(|f| sidebar.render(f, f.area())).unwrap();
|
||||||
let buf = terminal.backend().buffer().clone();
|
terminal.backend().buffer().clone()
|
||||||
let mut out = String::new();
|
}
|
||||||
for y in 0..buf.area.height {
|
|
||||||
for x in 0..buf.area.width {
|
fn rows(buf: &Buffer) -> Vec<String> {
|
||||||
out.push_str(buf[(x, y)].symbol());
|
(0..buf.area.height)
|
||||||
}
|
.map(|y| {
|
||||||
out.push('\n');
|
(0..buf.area.width)
|
||||||
}
|
.map(|x| buf[(x, y)].symbol())
|
||||||
out
|
.collect::<String>()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_to_string(buf: &Buffer) -> String {
|
||||||
|
rows(buf).join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_containing<'a>(rows: &'a [String], needle: &str) -> &'a str {
|
||||||
|
rows.iter()
|
||||||
|
.find(|l| l.contains(needle))
|
||||||
|
.unwrap_or_else(|| panic!("no row containing {needle:?} in {rows:#?}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row text without the trailing space padding and right border.
|
||||||
|
fn content(row: &str) -> &str {
|
||||||
|
row.trim_end().trim_end_matches('│').trim_end()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -132,7 +317,7 @@ mod tests {
|
|||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
};
|
};
|
||||||
let rendered = render_to_string(&sb, 28, 12);
|
let rendered = render_to_string(&draw(&sb, 28, 12));
|
||||||
assert!(
|
assert!(
|
||||||
rendered.contains("claude"),
|
rendered.contains("claude"),
|
||||||
"missing session name:\n{rendered}"
|
"missing session name:\n{rendered}"
|
||||||
@@ -151,11 +336,8 @@ mod tests {
|
|||||||
highlight: None,
|
highlight: None,
|
||||||
attached: [sid].into_iter().collect(),
|
attached: [sid].into_iter().collect(),
|
||||||
};
|
};
|
||||||
let rendered = render_to_string(&sb, 28, 6);
|
let all = rows(&draw(&sb, 28, 6));
|
||||||
let line = rendered
|
let line = row_containing(&all, "claude");
|
||||||
.lines()
|
|
||||||
.find(|l| l.contains("claude"))
|
|
||||||
.expect("claude row");
|
|
||||||
assert!(line.contains('●'), "expected filled glyph in: {line:?}");
|
assert!(line.contains('●'), "expected filled glyph in: {line:?}");
|
||||||
assert!(
|
assert!(
|
||||||
!line.contains('○'),
|
!line.contains('○'),
|
||||||
@@ -172,21 +354,18 @@ mod tests {
|
|||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
};
|
};
|
||||||
let rendered = render_to_string(&sb, 28, 6);
|
let buf = draw(&sb, 28, 6);
|
||||||
let line = rendered
|
let all = rows(&buf);
|
||||||
.lines()
|
let line = row_containing(&all, "claude");
|
||||||
.find(|l| l.contains("claude"))
|
|
||||||
.expect("claude row");
|
|
||||||
assert!(line.contains('✗'), "expected dead glyph in: {line:?}");
|
assert!(line.contains('✗'), "expected dead glyph in: {line:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #83: a session whose worktree blocks a kill carries the warning glyph;
|
/// #83 via docs/SIDEBAR.md: blocked work lives on the meta line now, not
|
||||||
/// a clear or unprobed one does not.
|
/// on the name row; a clear or unprobed worktree never warns.
|
||||||
#[test]
|
#[test]
|
||||||
fn blocked_session_gets_a_warning_glyph() {
|
fn blocked_session_warns_on_its_meta_line() {
|
||||||
use maestro_protocol::InTheWay;
|
|
||||||
|
|
||||||
let mut blocked = session_named(1, "claude");
|
let mut blocked = session_named(1, "claude");
|
||||||
|
blocked.activity_since = Utc::now() - Duration::seconds(5);
|
||||||
blocked.blocked_work = Some(InTheWay {
|
blocked.blocked_work = Some(InTheWay {
|
||||||
unpushed_commits: 4,
|
unpushed_commits: 4,
|
||||||
..InTheWay::default()
|
..InTheWay::default()
|
||||||
@@ -199,18 +378,191 @@ mod tests {
|
|||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
};
|
};
|
||||||
let rendered = render_to_string(&sb, 40, 8);
|
let all = rows(&draw(&sb, 40, 10));
|
||||||
let line = rendered
|
|
||||||
.lines()
|
let meta = all
|
||||||
.find(|l| l.contains("claude"))
|
.iter()
|
||||||
.expect("blocked row");
|
.find(|l| l.contains("⚠"))
|
||||||
assert!(line.contains('⚠'), "expected warning glyph in: {line:?}");
|
.expect("blocked card must warn somewhere");
|
||||||
|
assert!(
|
||||||
|
meta.contains("4 unpushed commits"),
|
||||||
|
"warning must carry InTheWay's summary: {meta:?}"
|
||||||
|
);
|
||||||
|
let name_row = row_containing(&all, "claude");
|
||||||
|
assert!(
|
||||||
|
!name_row.contains('⚠'),
|
||||||
|
"warning moved off the name row: {name_row:?}"
|
||||||
|
);
|
||||||
for name in ["bash", "vim"] {
|
for name in ["bash", "vim"] {
|
||||||
let line = rendered
|
let idx = all.iter().position(|l| l.contains(name)).unwrap();
|
||||||
.lines()
|
assert!(
|
||||||
.find(|l| l.contains(name))
|
!all[idx + 1].contains('⚠'),
|
||||||
.expect("clear row");
|
"{name} must not warn: {:?}",
|
||||||
assert!(!line.contains('⚠'), "{name} must not warn: {line:?}");
|
all[idx + 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A freshly-active session stays one line — never an empty placeholder
|
||||||
|
/// row (docs/SIDEBAR.md).
|
||||||
|
#[test]
|
||||||
|
fn freshly_active_session_stays_one_line() {
|
||||||
|
let mut s = session_named(1, "claude");
|
||||||
|
s.activity_since = Utc::now() - Duration::seconds(10);
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![s],
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
|
assert_eq!(content(&all[0]), "sessions");
|
||||||
|
assert!(row_containing(&all, "claude").contains('○'));
|
||||||
|
assert!(
|
||||||
|
content(&all[2]).is_empty(),
|
||||||
|
"line 2 must be absent, got {:?}",
|
||||||
|
all[2]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 1 of docs/SIDEBAR.md: held in state longer than 30s earns the
|
||||||
|
/// meta line, with the floored duration next to the dot.
|
||||||
|
#[test]
|
||||||
|
fn long_held_session_gets_a_meta_line_with_duration() {
|
||||||
|
let mut s = session_named(1, "claude");
|
||||||
|
s.activity_since = Utc::now() - Duration::seconds(90);
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![s],
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
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("1m"), "expected 1m in: {meta:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duration_format_floors_whole_units() {
|
||||||
|
let fmt = |secs| format_state_duration(Duration::seconds(secs));
|
||||||
|
assert_eq!(fmt(0), "0s");
|
||||||
|
assert_eq!(fmt(45), "45s");
|
||||||
|
assert_eq!(fmt(59), "59s");
|
||||||
|
assert_eq!(fmt(60), "1m");
|
||||||
|
assert_eq!(fmt(240), "4m");
|
||||||
|
assert_eq!(fmt(3599), "59m");
|
||||||
|
assert_eq!(fmt(3600), "1h");
|
||||||
|
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).
|
||||||
|
#[test]
|
||||||
|
fn meta_line_dot_uses_the_contract_colour_per_state() {
|
||||||
|
for (state, expected) in [
|
||||||
|
(ActivityState::Active, Color::Green),
|
||||||
|
(ActivityState::Idle, Color::DarkGray),
|
||||||
|
(ActivityState::AwaitingInput, Color::Yellow),
|
||||||
|
(ActivityState::Error, Color::Red),
|
||||||
|
] {
|
||||||
|
let mut s = session_named(1, "claude");
|
||||||
|
s.activity = state;
|
||||||
|
s.activity_since = Utc::now() - Duration::seconds(120);
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![s],
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let buf = draw(&sb, 28, 8);
|
||||||
|
let found = (0..buf.area.height)
|
||||||
|
.any(|y| (0..buf.area.width).any(|x| buf[(x, y)].style().fg == Some(expected)));
|
||||||
|
assert!(found, "expected some cell in fg {expected:?} for {state:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 3 of docs/SIDEBAR.md: a dead session always gets a meta line,
|
||||||
|
/// even freshly dead, with the muted `exited` fragment.
|
||||||
|
#[test]
|
||||||
|
fn dead_session_shows_exited_on_its_meta_line() {
|
||||||
|
let mut s = session_named(1, "claude");
|
||||||
|
s.status = SessionStatus::Dead;
|
||||||
|
s.activity_since = Utc::now() - Duration::seconds(5);
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![s],
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
|
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||||
|
assert!(
|
||||||
|
all[idx + 1].contains("exited"),
|
||||||
|
"expected exited fragment in: {:?}",
|
||||||
|
all[idx + 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sidebar is narrow: the meta line truncates with `…` and never
|
||||||
|
/// wraps onto a third row (docs/SIDEBAR.md).
|
||||||
|
#[test]
|
||||||
|
fn meta_line_truncates_instead_of_wrapping() {
|
||||||
|
let mut s = session_named(1, "claude");
|
||||||
|
s.activity_since = Utc::now() - Duration::seconds(5);
|
||||||
|
s.blocked_work = Some(InTheWay {
|
||||||
|
uncommitted_files: 120,
|
||||||
|
..InTheWay::default()
|
||||||
|
});
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![s],
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
|
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||||
|
let meta = content(&all[idx + 1]);
|
||||||
|
assert!(
|
||||||
|
meta.trim_end().ends_with('…'),
|
||||||
|
"truncated meta line must end with …: {meta:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
content(&all[idx + 2]).trim().is_empty(),
|
||||||
|
"meta line must not wrap: {:?}",
|
||||||
|
all[idx + 2]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// other trigger puts line 2 on screen.
|
||||||
|
#[test]
|
||||||
|
fn epoch_activity_since_renders_no_duration() {
|
||||||
|
let mut legacy = session_named(1, "claude");
|
||||||
|
legacy.activity_since = DateTime::from_timestamp(0, 0).unwrap();
|
||||||
|
legacy.status = SessionStatus::Dead;
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![legacy],
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let rendered = render_to_string(&draw(&sb, 40, 8));
|
||||||
|
assert!(
|
||||||
|
rendered.contains("exited"),
|
||||||
|
"dead session must still show line 2:\n{rendered}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!rendered.contains('h') || !rendered.contains("● "),
|
||||||
|
"no duration may follow the dot for an epoch stamp:\n{rendered}"
|
||||||
|
);
|
||||||
|
for line in rendered.lines().filter(|l| l.contains('●')) {
|
||||||
|
assert!(
|
||||||
|
!line
|
||||||
|
.split('●')
|
||||||
|
.nth(1)
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim_start()
|
||||||
|
.starts_with(char::is_numeric),
|
||||||
|
"duration rendered from an epoch stamp: {line:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,8 +579,8 @@ mod tests {
|
|||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: Utc::now(),
|
||||||
|
activity_since: Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
};
|
};
|
||||||
@@ -237,10 +589,150 @@ mod tests {
|
|||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
};
|
};
|
||||||
let rendered = render_to_string(&sb, 28, 6);
|
let rendered = render_to_string(&draw(&sb, 28, 6));
|
||||||
assert!(
|
assert!(
|
||||||
rendered.contains("vim"),
|
rendered.contains("vim"),
|
||||||
"missing fallback command name:\n{rendered}"
|
"missing fallback command name:\n{rendered}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reversed video makes a card unreadable against a background; cards
|
||||||
|
/// use explicit backgrounds instead (docs/SIDEBAR.md, #108).
|
||||||
|
#[test]
|
||||||
|
fn no_cell_is_ever_reversed() {
|
||||||
|
let mut highlighted = session_named(1, "claude");
|
||||||
|
highlighted.activity_since = Utc::now() - Duration::seconds(120);
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![highlighted],
|
||||||
|
highlight: Some(Uuid::from_u128(1)),
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let buf = draw(&sb, 28, 8);
|
||||||
|
for y in 0..buf.area.height {
|
||||||
|
for x in 0..buf.area.width {
|
||||||
|
assert!(
|
||||||
|
!buf[(x, y)]
|
||||||
|
.style()
|
||||||
|
.add_modifier
|
||||||
|
.contains(Modifier::REVERSED),
|
||||||
|
"reversed video at ({x},{y})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The highlighted card gets the focus background; every other card gets
|
||||||
|
/// a card background distinct from the panel's default.
|
||||||
|
#[test]
|
||||||
|
fn cards_use_explicit_backgrounds_with_a_distinct_focus_background() {
|
||||||
|
let one = session_named(1, "claude");
|
||||||
|
let two = session_named(2, "bash");
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![one, two],
|
||||||
|
highlight: Some(Uuid::from_u128(1)),
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let buf = draw(&sb, 28, 8);
|
||||||
|
let all = rows(&buf);
|
||||||
|
let claude_y = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||||
|
let bash_y = all.iter().position(|l| l.contains("bash")).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
buf[(0, claude_y as u16)].style().bg,
|
||||||
|
Some(Color::Cyan),
|
||||||
|
"focused card must sit on the focus background"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
buf[(0, claude_y as u16)].style().fg,
|
||||||
|
Some(Color::Black),
|
||||||
|
"focused card text must stay readable on cyan"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
buf[(0, bash_y as u16)].style().bg,
|
||||||
|
Some(Color::Black),
|
||||||
|
"unfocused cards must still have a card background"
|
||||||
|
);
|
||||||
|
// The panel itself (header row) stays on the terminal default.
|
||||||
|
assert!(
|
||||||
|
matches!(buf[(0, 0)].style().bg, None | Some(Color::Reset)),
|
||||||
|
"panel must keep the terminal default background"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The card background paints the full card width, not just the glyphs.
|
||||||
|
#[test]
|
||||||
|
fn card_background_covers_the_full_card_width() {
|
||||||
|
let s = session_named(1, "claude");
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![s],
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let buf = draw(&sb, 28, 8);
|
||||||
|
let y = buf.content.iter().position(|c| c.symbol() == "c").unwrap() as u16 / buf.area.width;
|
||||||
|
for x in 0..27 {
|
||||||
|
assert_eq!(
|
||||||
|
buf[(x, y)].style().bg,
|
||||||
|
Some(Color::Black),
|
||||||
|
"gap in card background at col {x}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Line 1's actions live on the highlighted row's top-right corner.
|
||||||
|
#[test]
|
||||||
|
fn highlighted_row_anchors_the_actions_top_right() {
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![session_named(1, "claude")],
|
||||||
|
highlight: Some(Uuid::from_u128(1)),
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
|
let line = content(row_containing(&all, "claude"));
|
||||||
|
assert!(
|
||||||
|
line.ends_with("⧉ ✕"),
|
||||||
|
"actions must sit at the right edge: {line:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unhighlighted_rows_show_no_actions() {
|
||||||
|
let one = session_named(1, "claude");
|
||||||
|
let two = session_named(2, "bash");
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![one, two],
|
||||||
|
highlight: Some(Uuid::from_u128(1)),
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
|
let line = row_containing(&all, "bash");
|
||||||
|
assert!(
|
||||||
|
!line.contains('⧉') && !line.contains('✕'),
|
||||||
|
"actions are highlight-only: {line:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name truncates with `…` before it can ever reach the reserved
|
||||||
|
/// action width (docs/SIDEBAR.md — overlap is a defect).
|
||||||
|
#[test]
|
||||||
|
fn name_truncates_before_reaching_the_actions() {
|
||||||
|
let mut s = session_named(1, "claude");
|
||||||
|
if let Some(serde_json::Value::String(name)) = s.metadata.get_mut("name") {
|
||||||
|
*name = "an-unreasonably-long-session-name".into();
|
||||||
|
}
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![s],
|
||||||
|
highlight: Some(Uuid::from_u128(1)),
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
|
let line = row_containing(&all, "an-unreasonably");
|
||||||
|
let name_end = line.find('…').expect("name must truncate with …");
|
||||||
|
let actions_start = line.find('⧉').expect("actions must still render");
|
||||||
|
assert!(name_end < actions_start, "name overlaps actions: {line:?}");
|
||||||
|
assert!(
|
||||||
|
line.split_at(actions_start).0.ends_with(' '),
|
||||||
|
"no gap before the actions: {line:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+104
@@ -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.
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -104,3 +104,122 @@ describe("blocked-work indicator (#83)", () => {
|
|||||||
expect(confirmations).toEqual(['Kill session "claude"?']);
|
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
@@ -4,6 +4,7 @@ import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
|
|||||||
import { CatalogStore } from "./events.js";
|
import { CatalogStore } from "./events.js";
|
||||||
import type { Session } from "./types.js";
|
import type { Session } from "./types.js";
|
||||||
import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } 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 type { TreeNode } from "./tree.js";
|
||||||
import { buildTree } from "./tree.js";
|
import { buildTree } from "./tree.js";
|
||||||
import { createThemeToggle } from "./theme.js";
|
import { createThemeToggle } from "./theme.js";
|
||||||
@@ -17,6 +18,41 @@ export function requestOpenSpawn(): void {
|
|||||||
|
|
||||||
const collapsed = new Set<string>();
|
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 {
|
function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||||
if (node.kind === "folder") {
|
if (node.kind === "folder") {
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
@@ -52,30 +88,34 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const s = node.session;
|
const s = node.session;
|
||||||
|
const now = Date.now();
|
||||||
const row = document.createElement("a");
|
const row = document.createElement("a");
|
||||||
row.className = "tree-leaf";
|
row.className = "tree-leaf session-card";
|
||||||
row.href = `#/attach/${encodeURIComponent(s.id)}`;
|
row.href = `#/attach/${encodeURIComponent(s.id)}`;
|
||||||
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
||||||
row.dataset.sessionId = s.id;
|
row.dataset.sessionId = s.id;
|
||||||
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);
|
||||||
row.setAttribute("aria-label", `${name}, ${running ? "running" : "exited"}`);
|
const stateWord =
|
||||||
// The directory is the enclosing folder, so only surface the command — and
|
STATE_WORDS[activityColorSlug(s.activity ?? "Idle")] ?? STATE_WORDS.idle;
|
||||||
// only when it isn't already what the name shows.
|
row.setAttribute(
|
||||||
const showCmd = s.command !== name;
|
"aria-label",
|
||||||
// #83: warn on the row itself, in `formatInTheWay`'s shared phrasing, so
|
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
|
||||||
// "this will be refused" is visible before the kill is attempted.
|
);
|
||||||
const blockers = sessionBlockers(s);
|
// 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 = `
|
row.innerHTML = `
|
||||||
<span class="leaf-name">${escapeHtml(name)}</span>
|
<span class="card-line1">
|
||||||
${showCmd ? `<span class="leaf-meta"><span class="leaf-cmd">${escapeHtml(s.command)}</span></span>` : ""}
|
<span class="leaf-name">${escapeHtml(name)}</span>
|
||||||
${running ? "" : `<span class="leaf-badge">exited</span>`}
|
<span class="leaf-actions">
|
||||||
${blockers ? `<span class="leaf-badge leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>` : ""}
|
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
|
||||||
<span class="leaf-actions">
|
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
||||||
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
|
</span>
|
||||||
<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;
|
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
|
||||||
similarBtn.addEventListener("click", (ev) => {
|
similarBtn.addEventListener("click", (ev) => {
|
||||||
@@ -395,6 +435,13 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
|
|
||||||
const unsubscribe = store.subscribe(refresh);
|
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 {
|
return {
|
||||||
el,
|
el,
|
||||||
setActive(id: string | null) {
|
setActive(id: string | null) {
|
||||||
@@ -410,6 +457,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
applyHighlights();
|
applyHighlights();
|
||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
|
clearInterval(clock);
|
||||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
},
|
},
|
||||||
|
|||||||
+66
-18
@@ -59,20 +59,28 @@ function leafRow(opts: {
|
|||||||
status: "running" | "dead";
|
status: "running" | "dead";
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
indent?: number;
|
indent?: number;
|
||||||
|
activity?: "active" | "idle" | "awaiting-input" | "error";
|
||||||
|
duration?: string;
|
||||||
}): HTMLElement {
|
}): HTMLElement {
|
||||||
const row = h("a", "tree-leaf");
|
const row = h("a", "tree-leaf session-card");
|
||||||
(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`;
|
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 = `
|
row.innerHTML = `
|
||||||
<span class="leaf-name">${opts.name}</span>
|
<span class="card-line1">
|
||||||
${opts.cmd !== opts.name ? `<span class="leaf-meta"><span class="leaf-cmd">${opts.cmd}</span></span>` : ""}
|
<span class="leaf-name">${opts.name}</span>
|
||||||
${opts.status === "dead" ? `<span class="leaf-badge">exited</span>` : ""}
|
<span class="leaf-actions">
|
||||||
<span class="leaf-actions">
|
<button class="leaf-act leaf-clone" type="button" title="Spawn similar">⧉</button>
|
||||||
<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">✕</button>
|
||||||
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${opts.name}">✕</button>
|
</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>` : ""}
|
||||||
|
`;
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,14 +198,17 @@ export function renderKit(): HTMLElement {
|
|||||||
// --- chips & badges ---
|
// --- chips & badges ---
|
||||||
{
|
{
|
||||||
const { el, body } = section("Chips & badges", "Status-bearing pills and counts.");
|
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(
|
body.append(
|
||||||
specimen("session-tally", h("span", "session-tally", "6 sessions · 5 live")),
|
specimen("session-tally", h("span", "session-tally", "6 sessions · 5 live")),
|
||||||
specimen("folder-count", h("span", "folder-count", "4")),
|
specimen("folder-count", h("span", "folder-count", "4")),
|
||||||
specimen("leaf-badge", h("span", "leaf-badge", "exited")),
|
specimen("card-line2 · active", activeLine),
|
||||||
specimen(
|
specimen("attach-state · open", h("span", "attach-state", "live")),
|
||||||
"attach-state · open",
|
|
||||||
h("span", "attach-state", "live"),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
(body.lastElementChild?.querySelector(".attach-state") as HTMLElement)?.setAttribute(
|
(body.lastElementChild?.querySelector(".attach-state") as HTMLElement)?.setAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
@@ -215,19 +226,56 @@ 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",
|
||||||
"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");
|
const list = h("div", "kit-tree");
|
||||||
list.append(
|
list.append(
|
||||||
folderHeader("~/git/maestro", 3, 0),
|
folderHeader("~/git/maestro", 3, 0),
|
||||||
folderHeader("web", 2, 1),
|
folderHeader("web", 2, 1),
|
||||||
leafRow({ name: "redesign", cmd: "claude", status: "running", indent: 2 }),
|
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),
|
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),
|
folderHeader("~/work/acme", 2, 0),
|
||||||
leafRow({ name: "billing-api", cmd: "claude", status: "running", indent: 1 }),
|
leafRow({
|
||||||
leafRow({ name: "scratch", cmd: "bash", status: "dead", indent: 1 }),
|
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 }),
|
leafRow({ name: "monitor", cmd: "btop", status: "running", indent: 0 }),
|
||||||
);
|
);
|
||||||
body.classList.add("kit-tree-host");
|
body.classList.add("kit-tree-host");
|
||||||
|
|||||||
+5
-1
@@ -272,7 +272,11 @@ function showMultiView(): void {
|
|||||||
// cloned session's dir + command.
|
// cloned session's dir + command.
|
||||||
function requestOpenSpawn(): void {
|
function requestOpenSpawn(): void {
|
||||||
showMultiView();
|
showMultiView();
|
||||||
mv.openDraft();
|
// A new tab, not a split of the pane you were reading (#100). "Spawn
|
||||||
|
// similar" and the fast-forward key below deliberately keep the old
|
||||||
|
// behaviour: both act on the session in front of you, so a split beside it
|
||||||
|
// is the point.
|
||||||
|
mv.openDraft(undefined, { newTab: true });
|
||||||
}
|
}
|
||||||
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
||||||
document.addEventListener("catalog-spawn-similar", (ev) => {
|
document.addEventListener("catalog-spawn-similar", (ev) => {
|
||||||
|
|||||||
@@ -282,3 +282,54 @@ describe("multiview pane-drag swap (#73)", () => {
|
|||||||
expect(nameInputOf(leaf2).value).toBe("west");
|
expect(nameInputOf(leaf2).value).toBe("west");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("openDraft newTab (#100)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
stubFetch();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens its own tab instead of splitting the focused session", async () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||||
|
tab.focusedLeafId = first;
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
|
||||||
|
mv.openDraft(undefined, { newTab: true });
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(layout.tabs).toHaveLength(2);
|
||||||
|
// The tab you were reading is untouched: still one pane, still its session.
|
||||||
|
const original = leaves(layout.tabs[0].root);
|
||||||
|
expect(original).toHaveLength(1);
|
||||||
|
expect(original[0].sessionId).toBe("s1");
|
||||||
|
// The draft is alone in the new, now-active tab.
|
||||||
|
const fresh = leaves(layout.tabs[1].root);
|
||||||
|
expect(fresh).toHaveLength(1);
|
||||||
|
expect(fresh[0].sessionId).toBeNull();
|
||||||
|
expect(fresh[0].draft).not.toBeNull();
|
||||||
|
expect(layout.activeTabId).toBe(layout.tabs[1].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still splits when newTab is not asked for", async () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
|
||||||
|
mv.openDraft();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(layout.tabs).toHaveLength(1);
|
||||||
|
expect(leaves(tab.root)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+12
-3
@@ -57,7 +57,7 @@ export interface MultiView {
|
|||||||
/** Open a draft pane (issue #62): reuse the focused pane if it is already
|
/** Open a draft pane (issue #62): reuse the focused pane if it is already
|
||||||
* empty, otherwise split the focused pane. `seed` pre-fills the form
|
* empty, otherwise split the focused pane. `seed` pre-fills the form
|
||||||
* ("Spawn similar" clones a session's cwd + command). */
|
* ("Spawn similar" clones a session's cwd + command). */
|
||||||
openDraft: (seed?: Partial<Draft>) => void;
|
openDraft: (seed?: Partial<Draft>, opts?: { newTab?: boolean }) => void;
|
||||||
/** Start dragging a sidebar session into the canvas (issue #32). The
|
/** Start dragging a sidebar session into the canvas (issue #32). The
|
||||||
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
|
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
|
||||||
* drag shows drop zones to place it. */
|
* drag shows drop zones to place it. */
|
||||||
@@ -412,9 +412,18 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
opts.onOpenSessionsChange?.();
|
opts.onOpenSessionsChange?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDraft(seed?: Partial<Draft>): void {
|
function openDraft(seed?: Partial<Draft>, opts?: { newTab?: boolean }): void {
|
||||||
const tab = activeTab(layout);
|
|
||||||
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
|
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
|
||||||
|
if (opts?.newTab) {
|
||||||
|
// Spawn opens its own tab rather than halving whatever you were reading
|
||||||
|
// (#100). The empty-pane reuse below still applies within a tab.
|
||||||
|
const fresh = addTab(layout);
|
||||||
|
fresh.focusedLeafId = splitLeaf(fresh, "", null, "row", true);
|
||||||
|
findLeaf(fresh.root!, fresh.focusedLeafId)!.draft = draft();
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tab = activeTab(layout);
|
||||||
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
|
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
|
||||||
// Empty tab (or stale focus): the draft becomes the first pane.
|
// Empty tab (or stale focus): the draft becomes the first pane.
|
||||||
tab.focusedLeafId = splitLeaf(tab, "", null, "row", true);
|
tab.focusedLeafId = splitLeaf(tab, "", null, "row", true);
|
||||||
|
|||||||
+63
-38
@@ -787,60 +787,85 @@ textarea {
|
|||||||
font-weight: 400;
|
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 {
|
.leaf-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: var(--fs-13);
|
font-size: var(--fs-13);
|
||||||
flex: 0 1 auto;
|
/* Grows to push the actions to the right edge but shrinks first, so the name
|
||||||
overflow: hidden;
|
truncates before it can ever reach them — overlap is a defect (#107). */
|
||||||
text-overflow: ellipsis;
|
flex: 1 1 auto;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaf-meta {
|
|
||||||
font-size: var(--fs-11);
|
|
||||||
color: var(--muted);
|
|
||||||
margin-left: auto;
|
|
||||||
padding-left: var(--sp-2);
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 0 1 auto;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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);
|
gap: var(--sp-2);
|
||||||
}
|
|
||||||
|
|
||||||
.leaf-cmd {
|
|
||||||
color: var(--muted);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaf-badge {
|
|
||||||
font-size: var(--fs-11);
|
font-size: var(--fs-11);
|
||||||
letter-spacing: 0.02em;
|
min-width: 0;
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--muted);
|
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;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* #83: the session row's blocked-work warning — an unforced kill will be
|
.activity-duration {
|
||||||
refused, and the operator should see that before pressing the button. */
|
color: inherit;
|
||||||
.leaf-badge.leaf-blocked {
|
flex: 0 0 auto;
|
||||||
color: var(--warn, #b45309);
|
|
||||||
border-color: var(--warn, #b45309);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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 {
|
.leaf-actions {
|
||||||
position: absolute;
|
flex: 0 0 auto;
|
||||||
right: var(--sp-1);
|
margin-left: auto;
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,3 +95,59 @@ describe("mergeLinear", () => {
|
|||||||
expect(merged.path).toBe("a/b");
|
expect(merged.path).toBe("a/b");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("worktree sessions group under their repository (#99)", () => {
|
||||||
|
/** A session the daemon spawned into a worktree of `repo`. */
|
||||||
|
function worktreeSession(id: string, repo: string, wt: string): Session {
|
||||||
|
return { ...session(id, wt), worktree: { path: wt, base_repo: repo } } as Session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top-level folder names, after linear chains are collapsed. */
|
||||||
|
function topFolders(root: TreeFolder): string[] {
|
||||||
|
return root.children.filter((c) => c.kind === "folder").map((c) => (c as TreeFolder).name);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("nests under the repo, not the worktree directory", () => {
|
||||||
|
const root = buildTree(
|
||||||
|
[
|
||||||
|
worktreeSession(
|
||||||
|
"a",
|
||||||
|
"/home/n/tea/agent-skills",
|
||||||
|
"/home/n/tea/agent-skills/worktrees/largo-clarinet",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"/home/n",
|
||||||
|
"",
|
||||||
|
) as TreeFolder;
|
||||||
|
|
||||||
|
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
||||||
|
const repo = root.children[0] as TreeFolder;
|
||||||
|
expect(repo.children).toHaveLength(1);
|
||||||
|
expect(repo.children[0].kind).toBe("leaf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a repo's plain and worktree sessions side by side", () => {
|
||||||
|
const root = buildTree(
|
||||||
|
[
|
||||||
|
session("plain", "/home/n/tea/agent-skills"),
|
||||||
|
worktreeSession(
|
||||||
|
"wt",
|
||||||
|
"/home/n/tea/agent-skills",
|
||||||
|
"/home/n/tea/agent-skills/worktrees/largo-clarinet",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"/home/n",
|
||||||
|
"",
|
||||||
|
) as TreeFolder;
|
||||||
|
|
||||||
|
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
||||||
|
const repo = root.children[0] as TreeFolder;
|
||||||
|
expect(repo.children).toHaveLength(2);
|
||||||
|
expect(repo.children.every((c) => c.kind === "leaf")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a session without a worktree where it is", () => {
|
||||||
|
const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n", "") as TreeFolder;
|
||||||
|
expect(topFolders(root)).toEqual(["~/tea/arr"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+4
-2
@@ -2,7 +2,7 @@
|
|||||||
// so it can be tested directly.
|
// so it can be tested directly.
|
||||||
|
|
||||||
import type { Session } from "./types.js";
|
import type { Session } from "./types.js";
|
||||||
import { abbreviateCwd, sessionCwd, sessionName } from "./types.js";
|
import { abbreviateCwd, sessionName, sessionTreePath } from "./types.js";
|
||||||
|
|
||||||
export interface TreeFolder {
|
export interface TreeFolder {
|
||||||
kind: "folder";
|
kind: "folder";
|
||||||
@@ -30,7 +30,9 @@ export function buildTree(sessions: Session[], home: string | undefined, filter:
|
|||||||
const lc = filter.trim().toLowerCase();
|
const lc = filter.trim().toLowerCase();
|
||||||
|
|
||||||
for (const s of sessions) {
|
for (const s of sessions) {
|
||||||
const cwd = sessionCwd(s);
|
// Grouped by repository, so a worktree session lands under its repo
|
||||||
|
// rather than in a folder of its own (#99).
|
||||||
|
const cwd = sessionTreePath(s);
|
||||||
const dir = cwd ? abbreviateCwd(cwd, home) : "";
|
const dir = cwd ? abbreviateCwd(cwd, home) : "";
|
||||||
if (lc) {
|
if (lc) {
|
||||||
const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
|
const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
|
||||||
|
|||||||
+30
-3
@@ -9,8 +9,9 @@ export type IsoTimestamp = string;
|
|||||||
export type SessionStatus = "Running" | "Dead";
|
export type SessionStatus = "Running" | "Dead";
|
||||||
|
|
||||||
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
|
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
|
||||||
* Absent on payloads from daemons that predate the field. */
|
* Absent on payloads from daemons that predate the field. `Error` is only set
|
||||||
export type ActivityState = "Active" | "Idle" | "AwaitingInput";
|
* when an agent reports failure (#105); the daemon never infers it. */
|
||||||
|
export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error";
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: Uuid;
|
id: Uuid;
|
||||||
@@ -27,6 +28,11 @@ export interface Session {
|
|||||||
activity?: ActivityState;
|
activity?: ActivityState;
|
||||||
/** Wall-clock of the most recent observed PTY output (NAP-340). */
|
/** Wall-clock of the most recent observed PTY output (NAP-340). */
|
||||||
last_activity_at?: IsoTimestamp;
|
last_activity_at?: IsoTimestamp;
|
||||||
|
/** Wall-clock of when the session entered its current `activity` state
|
||||||
|
* (#102) — distinct from `last_activity_at`, which only tracks PTY output.
|
||||||
|
* Defaults to the Unix epoch on payloads from daemons that predate the
|
||||||
|
* field. */
|
||||||
|
activity_since?: IsoTimestamp;
|
||||||
/** The pane's *current* working directory, as the daemon's activity watcher
|
/** The pane's *current* working directory, as the daemon's activity watcher
|
||||||
* observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir).
|
* observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir).
|
||||||
* Drives the directory-based session tree. Absent until first observed. */
|
* Drives the directory-based session tree. Absent until first observed. */
|
||||||
@@ -36,6 +42,16 @@ export interface Session {
|
|||||||
* without a worktree; all counts default to 0 on the wire for older
|
* without a worktree; all counts default to 0 on the wire for older
|
||||||
* daemons. */
|
* daemons. */
|
||||||
blocked_work?: InTheWay | null;
|
blocked_work?: InTheWay | null;
|
||||||
|
/** The worktree the daemon created for this session, absent when it did not
|
||||||
|
* create one. `base_repo` is the repository's main checkout, which is what
|
||||||
|
* the session tree groups by (#99). */
|
||||||
|
worktree?: SessionWorktree | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A session's daemon-created worktree (`SessionWorktree` on the wire). */
|
||||||
|
export interface SessionWorktree {
|
||||||
|
path: string;
|
||||||
|
base_repo: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One directory's immediate subdirectories (NAP-329). */
|
/** One directory's immediate subdirectories (NAP-329). */
|
||||||
@@ -231,12 +247,23 @@ export function sessionName(s: Session): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The directory a session lives in: its live `current_cwd` if the daemon has
|
/** The directory a session lives in: its live `current_cwd` if the daemon has
|
||||||
* observed one, else the spawn `cwd`. Drives the directory-based session tree. */
|
* observed one, else the spawn `cwd`. */
|
||||||
export function sessionCwd(s: Session): string | undefined {
|
export function sessionCwd(s: Session): string | undefined {
|
||||||
if (typeof s.current_cwd === "string" && s.current_cwd) return s.current_cwd;
|
if (typeof s.current_cwd === "string" && s.current_cwd) return s.current_cwd;
|
||||||
return s.cwd || undefined;
|
return s.cwd || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where a session sits in the directory tree. A worktree session belongs to
|
||||||
|
* its repository, not to the worktree (#99): otherwise `~/tea/agent-skills`
|
||||||
|
* grows a `~/tea/agent-skills/worktrees/largo-clarinet` folder holding one
|
||||||
|
* session of the same name, and the repository stops being where you look for
|
||||||
|
* the work. `base_repo` is the main checkout — the daemon resolves it. */
|
||||||
|
export function sessionTreePath(s: Session): string | undefined {
|
||||||
|
const repo = s.worktree?.base_repo;
|
||||||
|
if (typeof repo === "string" && repo) return repo;
|
||||||
|
return sessionCwd(s);
|
||||||
|
}
|
||||||
|
|
||||||
/** Collapse a daemon's home dir to `~`. `home` comes from `DaemonInfo.home_dir`;
|
/** Collapse a daemon's home dir to `~`. `home` comes from `DaemonInfo.home_dir`;
|
||||||
* absent/empty leaves the path untouched. Mirrors the CLI's `abbreviate_dir`. */
|
* absent/empty leaves the path untouched. Mirrors the CLI's `abbreviate_dir`. */
|
||||||
export function abbreviateCwd(cwd: string, home?: string): string {
|
export function abbreviateCwd(cwd: string, home?: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user