Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
002c128ac8 | ||
|
|
581ec29492 | ||
|
|
90b7953b17 | ||
|
|
26e7992c8d | ||
|
|
60515dd8c6 | ||
|
|
b3c73fd2cb | ||
|
|
7d06bec21e | ||
|
|
1a0d1284e1 | ||
|
|
fc6f3469b0 | ||
|
|
b95b5c04c1 | ||
|
|
6af8abccd3 | ||
|
|
ad2e700391 | ||
|
|
2dcef860a6 | ||
|
|
0bb5df7245 | ||
|
|
cb070e4f84 | ||
|
|
8fde211348 | ||
|
|
31cdbd4f0e | ||
|
|
0d06827b69 | ||
|
|
8a6cb81d3a | ||
|
|
52f9d0679e | ||
|
|
e6ef236f89 | ||
|
|
5747c71800 | ||
|
|
5b0a0c2001 | ||
|
|
df9013ffdf | ||
|
|
39341b278c | ||
|
|
b646cc5adf | ||
|
|
cfc125144f |
+13
-3
@@ -106,14 +106,24 @@ jobs:
|
||||
steps:
|
||||
- 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
|
||||
run: |
|
||||
curl -fsSL https://nixos.org/nix/install | sh -s -- --daemon --yes
|
||||
echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH"
|
||||
mkdir -p /etc/nix
|
||||
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
|
||||
run: |
|
||||
nix build \
|
||||
nix --extra-experimental-features "nix-command flakes" build \
|
||||
.#webFrontend \
|
||||
.#maestro \
|
||||
.#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
|
||||
touches. Read it before changing anything that creates or removes one.
|
||||
|
||||
`docs/SIDEBAR.md` is the contract for session cards in the web and TUI
|
||||
sidebars — line layout, activity colours, duration format, state precedence.
|
||||
Read it before changing sidebar rendering.
|
||||
|
||||
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
||||
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
||||
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
||||
|
||||
@@ -108,6 +108,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
ActivityState::Active => "active",
|
||||
ActivityState::Idle => "idle",
|
||||
ActivityState::AwaitingInput => "awaiting",
|
||||
ActivityState::Error => "error",
|
||||
};
|
||||
// Show the live foreground command alongside the spawn command when
|
||||
// the daemon has observed one that differs (NAP-340).
|
||||
@@ -176,6 +177,7 @@ mod tests {
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
activity_since: chrono::Utc::now(),
|
||||
current_cwd: 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)
|
||||
// record here, `GET /api/worktrees` (#80) reads back from here.
|
||||
.with_retained_store((*retained).clone());
|
||||
// Spawned sessions learn where to report back (#103). Derived from the
|
||||
// configured bind address; set before any spawn can happen.
|
||||
manager.set_daemon_url(format!("http://{}", config.bind_addr));
|
||||
|
||||
let recovered = manager.recover().await?;
|
||||
tracing::info!(recovered, "adopted existing tmux sessions");
|
||||
|
||||
@@ -131,6 +131,11 @@ struct Inner {
|
||||
/// Retained-worktree store (#78). `None` when not wired (tests); a forced
|
||||
/// kill (#81) without one fails rather than destroying unrecorded work.
|
||||
retained: Option<crate::retained_store::RetainedStore>,
|
||||
/// The daemon's own HTTP base URL (#103), exported to spawned sessions as
|
||||
/// `MAESTRO_URL`. `None` when unknown — those sessions simply don't get
|
||||
/// the variable. A mutex because the runner sets it after construction,
|
||||
/// possibly while clones are already serving.
|
||||
daemon_url: std::sync::Mutex<Option<String>>,
|
||||
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
|
||||
events: broadcast::Sender<CatalogEvent>,
|
||||
}
|
||||
@@ -170,6 +175,7 @@ impl SessionManager {
|
||||
replay_bytes,
|
||||
spawn_history: None,
|
||||
retained: None,
|
||||
daemon_url: std::sync::Mutex::new(None),
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
events,
|
||||
}),
|
||||
@@ -202,6 +208,14 @@ impl SessionManager {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the daemon's own HTTP base URL (#103), exported to every session
|
||||
/// spawned afterwards as `MAESTRO_URL`. Unlike the `with_*` builders this
|
||||
/// is a plain setter: the daemon runner learns its bound address after
|
||||
/// construction, and clones may already exist by then.
|
||||
pub fn set_daemon_url(&self, url: String) {
|
||||
*self.inner.daemon_url.lock().expect("daemon_url mutex") = Some(url);
|
||||
}
|
||||
|
||||
/// Recent working dirs for the spawn form's picker (#24), newest-first and
|
||||
/// deduped by dir. Empty when no history is wired or it's disabled.
|
||||
pub fn recent_dirs(&self) -> Vec<RecentDir> {
|
||||
@@ -278,7 +292,11 @@ impl SessionManager {
|
||||
self.inner.worktree_mgr.add_existing(base_repo, branch)
|
||||
}
|
||||
.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),
|
||||
};
|
||||
@@ -300,7 +318,7 @@ impl SessionManager {
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
let session_env = match sanitize_env(&req.env) {
|
||||
let mut session_env = match sanitize_env(&req.env) {
|
||||
Ok(env) => env,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&fifo_path);
|
||||
@@ -308,6 +326,19 @@ impl SessionManager {
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
// Identity + reporting target for the in-session hook (#106). Ours
|
||||
// win over whatever the request carried, so a client cannot spoof
|
||||
// another session's identity (#103).
|
||||
session_env.insert("MAESTRO_SESSION_ID".into(), id.to_string());
|
||||
if let Some(url) = self
|
||||
.inner
|
||||
.daemon_url
|
||||
.lock()
|
||||
.expect("daemon_url mutex")
|
||||
.clone()
|
||||
{
|
||||
session_env.insert("MAESTRO_URL".into(), url);
|
||||
}
|
||||
let spawned = {
|
||||
let tmux = self.inner.tmux.clone();
|
||||
let cwd = effective_cwd.clone();
|
||||
@@ -349,6 +380,7 @@ impl SessionManager {
|
||||
foreground: None,
|
||||
activity: ActivityState::Active,
|
||||
last_activity_at: Utc::now(),
|
||||
activity_since: chrono::Utc::now(),
|
||||
|
||||
current_cwd: None,
|
||||
// The watcher fills this in on its first blocked-work pass; a
|
||||
@@ -565,9 +597,13 @@ impl SessionManager {
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
// 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,
|
||||
activity: ActivityState::Idle,
|
||||
last_activity_at: Utc::now(),
|
||||
activity_since: chrono::Utc::now(),
|
||||
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
@@ -824,6 +860,12 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
|
||||
{
|
||||
None
|
||||
} 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.foreground = foreground;
|
||||
rec.session.current_cwd = current_cwd;
|
||||
|
||||
@@ -264,6 +264,25 @@ impl Tmux {
|
||||
check(out)
|
||||
}
|
||||
|
||||
/// `tmux show-environment -t maestro-<id>`, parsed into KEY=VALUE pairs.
|
||||
/// Test-facing view of what a spawned session actually inherits.
|
||||
pub fn show_environment(&self, id: Uuid) -> Result<BTreeMap<String, String>, TmuxError> {
|
||||
let name = session_name(id);
|
||||
let out = self
|
||||
.cmd()
|
||||
.args(["show-environment", "-t", &name])
|
||||
.output()?;
|
||||
check(out.clone())?;
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let mut env = BTreeMap::new();
|
||||
for line in stdout.lines() {
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
env.insert(k.to_string(), v.to_string());
|
||||
}
|
||||
}
|
||||
Ok(env)
|
||||
}
|
||||
|
||||
/// `tmux kill-server`. Best-effort cleanup used mostly by tests.
|
||||
pub fn kill_server(&self) -> Result<(), TmuxError> {
|
||||
let out = self.cmd().arg("kill-server").output()?;
|
||||
|
||||
@@ -174,6 +174,13 @@ impl WorktreeManager {
|
||||
/// 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
|
||||
/// 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 {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
|
||||
@@ -30,6 +30,24 @@ impl Fx {
|
||||
Self::with_sidecar_dir(|tmp| tmp.join("sidecars"))
|
||||
}
|
||||
|
||||
/// Variant whose manager knows the daemon's own base URL (#103), the way
|
||||
/// the production runner wires it from `[daemon].bind_addr`.
|
||||
fn with_daemon_url(url: &str) -> Self {
|
||||
let fx = Self::new();
|
||||
fx.manager.set_daemon_url(url.to_string());
|
||||
fx
|
||||
}
|
||||
|
||||
/// The env map of the (single, expected) spawn call.
|
||||
fn spawned_env(&self) -> BTreeMap<String, String> {
|
||||
let calls = self.fake.calls();
|
||||
let Some(Call::NewSessionPiped { env, .. }) = calls.first() else {
|
||||
panic!("expected a spawn call, got {calls:?}");
|
||||
};
|
||||
assert_eq!(calls.len(), 1, "expected exactly one spawn: {calls:?}");
|
||||
env.clone()
|
||||
}
|
||||
|
||||
fn with_sidecar_dir(pick: impl Fn(&std::path::Path) -> PathBuf) -> Self {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let fake = Arc::new(FakePanes::new());
|
||||
@@ -261,16 +279,23 @@ async fn requested_env_reaches_tmux() {
|
||||
|
||||
fx.manager.create(req).await.expect("create");
|
||||
|
||||
let spawned = match fx.fake.calls().first() {
|
||||
Some(Call::NewSessionPiped { env, .. }) => env.clone(),
|
||||
other => panic!("expected a spawn call, got {other:?}"),
|
||||
};
|
||||
let id = spawned
|
||||
.get("MAESTRO_SESSION_ID")
|
||||
.expect("spawn must export MAESTRO_SESSION_ID (#103)")
|
||||
.clone();
|
||||
// The daemon-owned variable rides alongside whatever was requested.
|
||||
let want: BTreeMap<String, String> = [
|
||||
("EMPTY".to_string(), String::new()),
|
||||
("FOO".to_string(), "bar".to_string()),
|
||||
("MAESTRO_SESSION_ID".to_string(), id),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
match fx.fake.calls().first() {
|
||||
Some(Call::NewSessionPiped { env, .. }) => assert_eq!(env, &want),
|
||||
other => panic!("expected a spawn call, got {other:?}"),
|
||||
}
|
||||
assert_eq!(&spawned, &want);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -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
|
||||
// change here is `foreground`, not the activity state.
|
||||
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.
|
||||
// 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(
|
||||
events: &mut tokio::sync::broadcast::Receiver<CatalogEvent>,
|
||||
) -> maestro_protocol::Session {
|
||||
@@ -396,3 +488,70 @@ fn attaching_spawn_history_after_cloning_panics() {
|
||||
7,
|
||||
));
|
||||
}
|
||||
|
||||
/// Every tmux-spawned session learns who it is (#103): its own UUID under
|
||||
/// `MAESTRO_SESSION_ID`, so the reporting hook in #106 can name the session
|
||||
/// it is running inside.
|
||||
#[tokio::test]
|
||||
async fn spawn_exports_maestro_session_id() {
|
||||
let fx = Fx::new();
|
||||
let s = fx.manager.create(fx.request("bash")).await.expect("create");
|
||||
|
||||
let env = fx.spawned_env();
|
||||
assert_eq!(
|
||||
env.get("MAESTRO_SESSION_ID").map(String::as_str),
|
||||
Some(&s.id.to_string()[..]),
|
||||
"MAESTRO_SESSION_ID must be the session's UUID, got {env:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The hook also needs to know where to report to: `MAESTRO_URL` carries the
|
||||
/// daemon's base URL derived from `[daemon].bind_addr` (#103).
|
||||
#[tokio::test]
|
||||
async fn spawn_exports_maestro_url_when_the_daemon_is_wired() {
|
||||
let fx = Fx::with_daemon_url("http://127.0.0.1:7070");
|
||||
fx.manager.create(fx.request("bash")).await.expect("create");
|
||||
|
||||
let env = fx.spawned_env();
|
||||
assert_eq!(
|
||||
env.get("MAESTRO_URL").map(String::as_str),
|
||||
Some("http://127.0.0.1:7070"),
|
||||
"MAESTRO_URL must be the daemon's base URL, got {env:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Without a wired URL (tests, or a caller that never set one) only the
|
||||
/// session id is injected — an empty or bogus `MAESTRO_URL` would send the
|
||||
/// #106 hook's reports nowhere useful.
|
||||
#[tokio::test]
|
||||
async fn spawn_omits_maestro_url_when_not_wired() {
|
||||
let fx = Fx::new();
|
||||
fx.manager.create(fx.request("bash")).await.expect("create");
|
||||
|
||||
let env = fx.spawned_env();
|
||||
assert!(
|
||||
!env.contains_key("MAESTRO_URL"),
|
||||
"MAESTRO_URL must be absent when no daemon URL is known, got {env:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The daemon-owned variables win over anything the spawner sent: a client
|
||||
/// cannot spoof another session's identity.
|
||||
#[tokio::test]
|
||||
async fn spawn_overrides_client_supplied_maestro_variables() {
|
||||
let fx = Fx::with_daemon_url("http://127.0.0.1:7070");
|
||||
let mut req = fx.request("bash");
|
||||
req.env.insert(
|
||||
"MAESTRO_SESSION_ID".into(),
|
||||
"00000000-0000-0000-0000-000000000000".into(),
|
||||
);
|
||||
req.env.insert("MAESTRO_URL".into(), "http://evil:1".into());
|
||||
let s = fx.manager.create(req).await.expect("create");
|
||||
|
||||
let env = fx.spawned_env();
|
||||
assert_eq!(env.get("MAESTRO_SESSION_ID"), Some(&s.id.to_string()));
|
||||
assert_eq!(
|
||||
env.get("MAESTRO_URL"),
|
||||
Some(&"http://127.0.0.1:7070".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -947,3 +947,101 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
|
||||
|
||||
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,
|
||||
activity: Default::default(),
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
activity_since: chrono::Utc::now(),
|
||||
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
||||
blocked_work: None,
|
||||
}
|
||||
|
||||
@@ -80,6 +80,13 @@ pub struct Session {
|
||||
/// from daemons that predate the field.
|
||||
#[serde(default = "epoch")]
|
||||
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
|
||||
/// 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
|
||||
@@ -119,6 +126,9 @@ pub enum ActivityState {
|
||||
Idle,
|
||||
/// Blocked on a `read()` from the pty — waiting on the user.
|
||||
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,
|
||||
}
|
||||
|
||||
/// Optional worktree request attached to a `CreateSessionRequest`.
|
||||
@@ -537,6 +547,9 @@ mod tests {
|
||||
last_activity_at: DateTime::parse_from_rfc3339("2026-05-24T12:05:00Z")
|
||||
.unwrap()
|
||||
.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")),
|
||||
blocked_work: None,
|
||||
}
|
||||
@@ -588,6 +601,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]
|
||||
fn activity_state_serializes_pascal_case_and_defaults_idle() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -42,6 +42,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
last_activity_at: Utc::now(),
|
||||
activity_since: chrono::Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
}
|
||||
|
||||
@@ -1127,6 +1127,7 @@ mod tests {
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
last_activity_at: Utc::now(),
|
||||
activity_since: chrono::Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
}],
|
||||
@@ -1551,8 +1552,8 @@ mod tests {
|
||||
panic!("expected a draft pane");
|
||||
};
|
||||
assert_eq!(draft.command(), "aider");
|
||||
assert_eq!(draft.rows.len(), 1);
|
||||
assert_eq!(draft.rows[0].project.name, "maestro");
|
||||
assert_eq!(draft.groups.len(), 1);
|
||||
assert_eq!(draft.groups[0].main.name, "maestro");
|
||||
assert_eq!(draft.home_dir, "/home/u");
|
||||
assert_eq!(draft.name.value(), "crimson-otter");
|
||||
}
|
||||
@@ -1644,6 +1645,7 @@ mod tests {
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
activity_since: chrono::Utc::now(),
|
||||
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
|
||||
@@ -194,6 +194,7 @@ mod tests {
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
activity_since: chrono::Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
}
|
||||
|
||||
+652
-123
File diff suppressed because it is too large
Load Diff
@@ -105,6 +105,7 @@ mod tests {
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
activity_since: chrono::Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
}
|
||||
@@ -228,6 +229,7 @@ mod tests {
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
activity_since: chrono::Utc::now(),
|
||||
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# 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]
|
||||
```
|
||||
|
||||
`<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.
|
||||
|
||||
## 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 and exited
|
||||
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.
|
||||
+5
-1
@@ -272,7 +272,11 @@ function showMultiView(): void {
|
||||
// cloned session's dir + command.
|
||||
function requestOpenSpawn(): void {
|
||||
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("catalog-spawn-similar", (ev) => {
|
||||
|
||||
@@ -282,3 +282,54 @@ describe("multiview pane-drag swap (#73)", () => {
|
||||
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
|
||||
* empty, otherwise split the focused pane. `seed` pre-fills the form
|
||||
* ("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
|
||||
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
|
||||
* drag shows drop zones to place it. */
|
||||
@@ -412,9 +412,18 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
opts.onOpenSessionsChange?.();
|
||||
}
|
||||
|
||||
function openDraft(seed?: Partial<Draft>): void {
|
||||
const tab = activeTab(layout);
|
||||
function openDraft(seed?: Partial<Draft>, opts?: { newTab?: boolean }): void {
|
||||
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)) {
|
||||
// Empty tab (or stale focus): the draft becomes the first pane.
|
||||
tab.focusedLeafId = splitLeaf(tab, "", null, "row", true);
|
||||
|
||||
+45
-4
@@ -176,11 +176,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
</label>
|
||||
</div>
|
||||
<label class="wt-new-wrap">
|
||||
Branch
|
||||
New branch name
|
||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||
</label>
|
||||
<label class="wt-exist-wrap" hidden>
|
||||
Branch
|
||||
Pick a branch
|
||||
<select class="branch-select"></select>
|
||||
</label>
|
||||
</div>
|
||||
@@ -549,11 +549,15 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
if (match) selectPreset(match.id);
|
||||
}
|
||||
|
||||
/** Groups with at least one member (itself or a worktree) matching `query`. */
|
||||
function visibleGroups(query: string): ProjectGroup[] {
|
||||
return groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0);
|
||||
}
|
||||
|
||||
function renderProjects(): void {
|
||||
projectsEl.replaceChildren();
|
||||
groups = groupProjects(sortProjects(discovered, new Date()));
|
||||
const query = filterInput.value;
|
||||
const visible = groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0);
|
||||
const visible = visibleGroups(filterInput.value);
|
||||
for (const group of visible) projectsEl.appendChild(projectBox(group));
|
||||
if (discovered.length > 0 && visible.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
@@ -862,6 +866,43 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
|
||||
const autocomplete = attachDirAutocomplete(cwdRaw);
|
||||
|
||||
// ---- Enter submits from anywhere (#97) --------------------------------
|
||||
// A project-box/wt-choice that isn't yet the active pick takes the Enter to
|
||||
// choose it instead; the next Enter, now that it's active, submits.
|
||||
// dirAutocomplete's own Enter (accept the highlight) already preventDefaults
|
||||
// when it handles the key, which the `e.defaultPrevented` guard defers to.
|
||||
// The worktree checkbox keeps Enter as a toggle (TUI parity: `WorktreeToggle`
|
||||
// does the same) rather than folding it into the blanket-submit rule below.
|
||||
form.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Enter" || e.defaultPrevented) return;
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
if (target === wtEnabled) {
|
||||
e.preventDefault();
|
||||
wtEnabled.checked = !wtEnabled.checked;
|
||||
wtEnabled.dispatchEvent(new Event("change"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (target instanceof HTMLButtonElement) {
|
||||
if (!target.classList.contains("project-box") && !target.classList.contains("wt-choice")) return;
|
||||
e.preventDefault();
|
||||
if (!target.classList.contains("is-active")) {
|
||||
target.click();
|
||||
return;
|
||||
}
|
||||
form.requestSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (target === filterInput) {
|
||||
const top = visibleGroups(filterInput.value)[0];
|
||||
if (top && top.main.path !== selectedGroup?.main.path) selectProject(top);
|
||||
}
|
||||
form.requestSubmit();
|
||||
});
|
||||
|
||||
// ---- submit ----------------------------------------------------------
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// @vitest-environment happy-dom
|
||||
// Enter submits the draft form from any field, at any point (#97). The two
|
||||
// exceptions: a picker button that isn't yet the active choice takes the
|
||||
// Enter to *choose* it instead (the next Enter submits), and the project
|
||||
// filter must resolve to the highlighted project rather than sending
|
||||
// whatever cwd was already set.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||
import type { Project } from "./types.js";
|
||||
|
||||
const MAESTRO: Project = {
|
||||
path: "/home/user/tea/maestro",
|
||||
name: "maestro",
|
||||
is_worktree: false,
|
||||
last_activity: "2026-08-20T10:00:00Z",
|
||||
};
|
||||
|
||||
const WT_A: Project = {
|
||||
path: "/home/user/tea/maestro/worktrees/bulgarians",
|
||||
name: "bulgarians",
|
||||
is_worktree: true,
|
||||
base_repo: MAESTRO.path,
|
||||
last_activity: "2026-08-22T10:00:00Z",
|
||||
};
|
||||
|
||||
const OTHER: Project = {
|
||||
path: "/home/user/tea/rev",
|
||||
name: "rev",
|
||||
is_worktree: false,
|
||||
last_activity: "2026-08-19T10:00:00Z",
|
||||
};
|
||||
|
||||
function stubFetch(projects: Project[]): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const body = url.includes("/api/projects")
|
||||
? projects
|
||||
: url.includes("/api/names")
|
||||
? { names: ["amber-fox"] }
|
||||
: url.includes("/api/branches")
|
||||
? []
|
||||
: url.includes("/api/dirs")
|
||||
? { path: "/opt", entries: ["nomatch-zzz"], truncated: false }
|
||||
: { hostname: "h", version: "0", home_dir: "/home/user", presets: [] };
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("spawnForm Enter-to-submit (#97)", () => {
|
||||
let handle: SpawnFormHandle | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const drain = async () => {
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
async function mount(projects: Project[]): Promise<HTMLFormElement> {
|
||||
stubFetch(projects);
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
return handle.el;
|
||||
}
|
||||
|
||||
function submitSpy(): ReturnType<typeof vi.fn> {
|
||||
return vi.fn();
|
||||
}
|
||||
|
||||
function pressEnter(el: HTMLElement): { defaultPrevented: boolean } {
|
||||
const evt = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
|
||||
el.dispatchEvent(evt);
|
||||
return { defaultPrevented: evt.defaultPrevented };
|
||||
}
|
||||
|
||||
const box = (form: HTMLFormElement, path: string) =>
|
||||
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
|
||||
|
||||
it("toggles the worktree checkbox on Enter instead of submitting (TUI parity)", async () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
const toggle = form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
|
||||
expect(toggle.checked).toBe(true);
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(toggle);
|
||||
expect(toggle.checked).toBe(false);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
|
||||
pressEnter(toggle);
|
||||
expect(toggle.checked).toBe(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("submits immediately from ordinary fields", () => {
|
||||
it("name field", async () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(form.querySelector<HTMLInputElement>("input[name=name]")!);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("worktree branch field", async () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(form.querySelector<HTMLInputElement>("input[name=worktree_branch]")!);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("custom command field", async () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
form.querySelector<HTMLButtonElement>('.cmd-tiles .tile[data-id="custom"]')!.click();
|
||||
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||
raw.value = "bash -l";
|
||||
raw.dispatchEvent(new Event("input"));
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(raw);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("raw cwd field once the autocomplete dropdown is closed", async () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
form.querySelector<HTMLButtonElement>(".project-other")!.click();
|
||||
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
||||
raw.value = "/opt/xyz-nothing-here";
|
||||
raw.dispatchEvent(new Event("input"));
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(raw);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not submit while the raw cwd autocomplete has a highlighted suggestion", async () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
form.querySelector<HTMLButtonElement>(".project-other")!.click();
|
||||
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
||||
raw.focus();
|
||||
raw.value = "/opt/";
|
||||
raw.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
const { defaultPrevented } = pressEnter(raw);
|
||||
|
||||
expect(defaultPrevented).toBe(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(raw.value).toBe("/opt/nomatch-zzz/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("two-step picker: first Enter chooses, second submits", () => {
|
||||
it("project box", async () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
const b = box(form, MAESTRO.path);
|
||||
b.focus();
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
const first = pressEnter(b);
|
||||
expect(first.defaultPrevented).toBe(true);
|
||||
expect(b.classList.contains("is-active")).toBe(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
|
||||
pressEnter(b);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("worktree choice", async () => {
|
||||
const form = await mount([MAESTRO, WT_A]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
const choice = form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!;
|
||||
expect(choice.classList.contains("is-active")).toBe(false); // "+ new" is the default
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(choice);
|
||||
expect(choice.classList.contains("is-active")).toBe(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
|
||||
pressEnter(choice);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("project filter", () => {
|
||||
it("submits with the highlighted (first matching) project, not the filter text", async () => {
|
||||
const form = await mount([MAESTRO, OTHER]);
|
||||
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
||||
filter.value = "rev";
|
||||
filter.dispatchEvent(new Event("input"));
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(filter);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not clobber an already-chosen worktree when the filter still matches it", async () => {
|
||||
const form = await mount([MAESTRO, WT_A]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.click();
|
||||
await drain();
|
||||
|
||||
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
||||
filter.value = "maestro";
|
||||
filter.dispatchEvent(new Event("input"));
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(filter);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.classList.contains(
|
||||
"is-active",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -180,6 +180,21 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
||||
expect(modes.at(-1)).toBe("existing");
|
||||
});
|
||||
|
||||
it("labels the new-branch input and existing-branch picker distinctly (#94)", async () => {
|
||||
const form = await mount();
|
||||
pickProject(form);
|
||||
await drain();
|
||||
|
||||
const newLabel = form.querySelector<HTMLElement>(".wt-new-wrap")!.textContent!.trim();
|
||||
const existLabel = form.querySelector<HTMLElement>(".wt-exist-wrap")!.textContent!.trim();
|
||||
|
||||
expect(newLabel).not.toBe("Branch");
|
||||
expect(existLabel).not.toBe("Branch");
|
||||
expect(newLabel).not.toBe(existLabel);
|
||||
expect(newLabel).toMatch(/name/i);
|
||||
expect(existLabel).toMatch(/pick|choose|select/i);
|
||||
});
|
||||
|
||||
it("collapses the advanced (worktree) section by default (#93)", async () => {
|
||||
const form = await mount();
|
||||
const toggle = form.querySelector<HTMLButtonElement>(".wt-toggle")!;
|
||||
|
||||
@@ -95,3 +95,59 @@ describe("mergeLinear", () => {
|
||||
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.
|
||||
|
||||
import type { Session } from "./types.js";
|
||||
import { abbreviateCwd, sessionCwd, sessionName } from "./types.js";
|
||||
import { abbreviateCwd, sessionName, sessionTreePath } from "./types.js";
|
||||
|
||||
export interface TreeFolder {
|
||||
kind: "folder";
|
||||
@@ -30,7 +30,9 @@ export function buildTree(sessions: Session[], home: string | undefined, filter:
|
||||
const lc = filter.trim().toLowerCase();
|
||||
|
||||
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) : "";
|
||||
if (lc) {
|
||||
const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
|
||||
|
||||
+30
-3
@@ -9,8 +9,9 @@ export type IsoTimestamp = string;
|
||||
export type SessionStatus = "Running" | "Dead";
|
||||
|
||||
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
|
||||
* Absent on payloads from daemons that predate the field. */
|
||||
export type ActivityState = "Active" | "Idle" | "AwaitingInput";
|
||||
* Absent on payloads from daemons that predate the field. `Error` is only set
|
||||
* when an agent reports failure (#105); the daemon never infers it. */
|
||||
export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error";
|
||||
|
||||
export interface Session {
|
||||
id: Uuid;
|
||||
@@ -27,6 +28,11 @@ export interface Session {
|
||||
activity?: ActivityState;
|
||||
/** Wall-clock of the most recent observed PTY output (NAP-340). */
|
||||
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
|
||||
* observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir).
|
||||
* 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
|
||||
* daemons. */
|
||||
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). */
|
||||
@@ -231,12 +247,23 @@ export function sessionName(s: Session): string {
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
if (typeof s.current_cwd === "string" && s.current_cwd) return s.current_cwd;
|
||||
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`;
|
||||
* absent/empty leaves the path untouched. Mirrors the CLI's `abbreviate_dir`. */
|
||||
export function abbreviateCwd(cwd: string, home?: string): string {
|
||||
|
||||
Reference in New Issue
Block a user