Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7c5b2e0ec | ||
|
|
323ead5a83 | ||
|
|
bb28fe1d6e | ||
|
|
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 | ||
|
|
cd66dbf7d8 | ||
|
|
6bb812f6bd | ||
|
|
d3098a9400 | ||
|
|
5de04af36a | ||
|
|
5e34ce7a61 | ||
|
|
1fa9ce798e | ||
|
|
2c89902829 | ||
|
|
8949fdfdf6 | ||
|
|
596e7e2f49 | ||
|
|
16609eff9e |
+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
|
||||
@@ -568,6 +600,7 @@ impl SessionManager {
|
||||
foreground: None,
|
||||
activity: ActivityState::Idle,
|
||||
last_activity_at: Utc::now(),
|
||||
activity_since: chrono::Utc::now(),
|
||||
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
|
||||
@@ -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]
|
||||
@@ -396,3 +421,70 @@ fn attaching_spawn_history_after_cloning_panics() {
|
||||
7,
|
||||
));
|
||||
}
|
||||
|
||||
/// Every tmux-spawned session learns who it is (#103): its own UUID under
|
||||
/// `MAESTRO_SESSION_ID`, so the reporting hook in #106 can name the session
|
||||
/// it is running inside.
|
||||
#[tokio::test]
|
||||
async fn spawn_exports_maestro_session_id() {
|
||||
let fx = Fx::new();
|
||||
let s = fx.manager.create(fx.request("bash")).await.expect("create");
|
||||
|
||||
let env = fx.spawned_env();
|
||||
assert_eq!(
|
||||
env.get("MAESTRO_SESSION_ID").map(String::as_str),
|
||||
Some(&s.id.to_string()[..]),
|
||||
"MAESTRO_SESSION_ID must be the session's UUID, got {env:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The hook also needs to know where to report to: `MAESTRO_URL` carries the
|
||||
/// daemon's base URL derived from `[daemon].bind_addr` (#103).
|
||||
#[tokio::test]
|
||||
async fn spawn_exports_maestro_url_when_the_daemon_is_wired() {
|
||||
let fx = Fx::with_daemon_url("http://127.0.0.1:7070");
|
||||
fx.manager.create(fx.request("bash")).await.expect("create");
|
||||
|
||||
let env = fx.spawned_env();
|
||||
assert_eq!(
|
||||
env.get("MAESTRO_URL").map(String::as_str),
|
||||
Some("http://127.0.0.1:7070"),
|
||||
"MAESTRO_URL must be the daemon's base URL, got {env:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Without a wired URL (tests, or a caller that never set one) only the
|
||||
/// session id is injected — an empty or bogus `MAESTRO_URL` would send the
|
||||
/// #106 hook's reports nowhere useful.
|
||||
#[tokio::test]
|
||||
async fn spawn_omits_maestro_url_when_not_wired() {
|
||||
let fx = Fx::new();
|
||||
fx.manager.create(fx.request("bash")).await.expect("create");
|
||||
|
||||
let env = fx.spawned_env();
|
||||
assert!(
|
||||
!env.contains_key("MAESTRO_URL"),
|
||||
"MAESTRO_URL must be absent when no daemon URL is known, got {env:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The daemon-owned variables win over anything the spawner sent: a client
|
||||
/// cannot spoof another session's identity.
|
||||
#[tokio::test]
|
||||
async fn spawn_overrides_client_supplied_maestro_variables() {
|
||||
let fx = Fx::with_daemon_url("http://127.0.0.1:7070");
|
||||
let mut req = fx.request("bash");
|
||||
req.env.insert(
|
||||
"MAESTRO_SESSION_ID".into(),
|
||||
"00000000-0000-0000-0000-000000000000".into(),
|
||||
);
|
||||
req.env.insert("MAESTRO_URL".into(), "http://evil:1".into());
|
||||
let s = fx.manager.create(req).await.expect("create");
|
||||
|
||||
let env = fx.spawned_env();
|
||||
assert_eq!(env.get("MAESTRO_SESSION_ID"), Some(&s.id.to_string()));
|
||||
assert_eq!(
|
||||
env.get("MAESTRO_URL"),
|
||||
Some(&"http://127.0.0.1:7070".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -175,7 +175,8 @@ impl App {
|
||||
// Render on next loop iteration.
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_millis(33)) => {
|
||||
// 30 Hz heartbeat.
|
||||
let effects = self.poll_due_probes_at(std::time::Instant::now());
|
||||
self.perform(effects).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -439,7 +440,7 @@ impl App {
|
||||
if draft.take_name_refill() {
|
||||
effects.push(Effect::RefillNames(tab_idx));
|
||||
}
|
||||
if let Some((seq, path)) = draft.take_gitroot_probe() {
|
||||
if let Some((seq, path)) = draft.take_gitroot_probe(std::time::Instant::now()) {
|
||||
effects.push(Effect::ProbeGitRoot {
|
||||
tab: tab_idx,
|
||||
seq,
|
||||
@@ -452,6 +453,26 @@ impl App {
|
||||
effects
|
||||
}
|
||||
|
||||
/// Fire any draft gitroot probes whose debounce window has elapsed (#74).
|
||||
/// The 30 Hz heartbeat calls this so a burst of typing issues its one
|
||||
/// probe even after the last keystroke.
|
||||
fn poll_due_probes_at(&mut self, now: std::time::Instant) -> Vec<Effect> {
|
||||
let mut effects = Vec::new();
|
||||
for tab_idx in 0..self.state.tabs.len() {
|
||||
let Some(PaneRef::Draft(draft)) = self.state.tabs[tab_idx].pane.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
if let Some((seq, path)) = draft.take_gitroot_probe(now) {
|
||||
effects.push(Effect::ProbeGitRoot {
|
||||
tab: tab_idx,
|
||||
seq,
|
||||
path,
|
||||
});
|
||||
}
|
||||
}
|
||||
effects
|
||||
}
|
||||
|
||||
/// Populate a freshly opened draft: presets from `/api/info` (falling back
|
||||
/// to the built-ins already in place), projects from `/api/projects`, and
|
||||
/// a first name batch. Applies only if that tab still shows the draft —
|
||||
@@ -1106,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,
|
||||
}],
|
||||
@@ -1328,9 +1350,7 @@ mod tests {
|
||||
for c in "crimson-otter".chars() {
|
||||
app.handle_draft_input(&InputKey::char(c));
|
||||
}
|
||||
app.handle_draft_input(&InputKey::Tab); // → worktree toggle
|
||||
app.handle_draft_input(&InputKey::Tab); // → branch mode
|
||||
app.handle_draft_input(&InputKey::Tab); // → new-branch name
|
||||
app.handle_draft_input(&InputKey::Tab); // → advanced disclosure (collapsed, #93)
|
||||
app.handle_draft_input(&InputKey::Tab); // → submit
|
||||
|
||||
match app.handle_draft_input(&InputKey::Enter).as_slice() {
|
||||
@@ -1347,10 +1367,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Editing the raw path with the worktree toggle on schedules a
|
||||
/// `/api/gitroot` probe so the section learns its base repo (#65).
|
||||
/// Editing the raw path schedules a `/api/gitroot` probe only once the
|
||||
/// keystroke burst settles (#74): nothing fires inside the debounce
|
||||
/// window; the heartbeat picks the due probe up afterwards.
|
||||
#[test]
|
||||
fn typing_a_raw_path_schedules_a_gitroot_probe() {
|
||||
fn typing_a_raw_path_schedules_a_debounced_gitroot_probe() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
||||
@@ -1359,13 +1380,24 @@ mod tests {
|
||||
draft.cwd_raw_visible = true;
|
||||
draft.focused_field = DraftField::CwdRaw;
|
||||
|
||||
let effects = app.handle_draft_input(&InputKey::char('/'));
|
||||
// A burst of keystrokes fires nothing.
|
||||
for c in "/opt/tooling".chars() {
|
||||
assert!(app.handle_draft_input(&InputKey::char(c)).is_empty());
|
||||
assert!(app.poll_due_probes_at(std::time::Instant::now()).is_empty());
|
||||
}
|
||||
|
||||
// Once the quiet period passes, exactly one probe is due.
|
||||
let due = std::time::Instant::now()
|
||||
+ std::time::Duration::from_millis(crate::draft::GITROOT_PROBE_DEBOUNCE_MS) * 10;
|
||||
match app.poll_due_probes_at(due).as_slice() {
|
||||
[Effect::ProbeGitRoot { tab: 0, path, .. }] => {
|
||||
assert_eq!(path, &std::path::PathBuf::from("/opt/tooling"));
|
||||
}
|
||||
other => panic!("expected one ProbeGitRoot effect, got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
matches!(
|
||||
effects.as_slice(),
|
||||
[Effect::ProbeGitRoot { tab: 0, seq: 2, .. }]
|
||||
),
|
||||
"{effects:?}"
|
||||
app.poll_due_probes_at(due).is_empty(),
|
||||
"the probe is consumed once"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1520,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");
|
||||
}
|
||||
@@ -1613,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,
|
||||
}
|
||||
|
||||
+931
-162
File diff suppressed because it is too large
Load Diff
@@ -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 maestro_protocol::{Session, SessionStatus};
|
||||
use chrono::Utc;
|
||||
use maestro_protocol::{ActivityState, Session, SessionStatus};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
@@ -12,6 +16,26 @@ use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
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;
|
||||
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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Sidebar {
|
||||
@@ -41,36 +65,155 @@ impl Sidebar {
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let width = inner.width as usize;
|
||||
let mut lines: Vec<Line> = vec![Line::from(Span::styled(
|
||||
"sessions",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
))];
|
||||
for s in &self.sessions {
|
||||
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
||||
(SessionStatus::Dead, _) => "✗",
|
||||
(SessionStatus::Running, true) => "●",
|
||||
(SessionStatus::Running, false) => "○",
|
||||
};
|
||||
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),
|
||||
]));
|
||||
let highlighted = self.highlight == Some(s.id);
|
||||
let base = card_base(highlighted);
|
||||
lines.push(Line::from(self.name_row(s, width)));
|
||||
if let Some(meta) = self.meta_row(s, width) {
|
||||
lines.push(Line::from(pad_to_width(meta, width, base)));
|
||||
}
|
||||
}
|
||||
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_secs = (now - s.activity_since).num_seconds();
|
||||
// #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_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 duration = format_state_duration(now - s.activity_since);
|
||||
let mut spans = vec![
|
||||
Span::styled(META_INDENT, base),
|
||||
Span::styled("●", dot_style),
|
||||
Span::styled(" ", base),
|
||||
Span::styled(duration, 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.
|
||||
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.
|
||||
@@ -85,8 +228,10 @@ fn name_for(s: &Session) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use chrono::Duration;
|
||||
use maestro_protocol::InTheWay;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::Terminal;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -104,25 +249,43 @@ mod tests {
|
||||
created_at: Utc::now(),
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
last_activity_at: Utc::now(),
|
||||
activity_since: Utc::now(),
|
||||
current_cwd: 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 mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal.draw(|f| sidebar.render(f, f.area())).unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
let mut out = String::new();
|
||||
for y in 0..buf.area.height {
|
||||
for x in 0..buf.area.width {
|
||||
out.push_str(buf[(x, y)].symbol());
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
terminal.backend().buffer().clone()
|
||||
}
|
||||
|
||||
fn rows(buf: &Buffer) -> Vec<String> {
|
||||
(0..buf.area.height)
|
||||
.map(|y| {
|
||||
(0..buf.area.width)
|
||||
.map(|x| buf[(x, y)].symbol())
|
||||
.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]
|
||||
@@ -132,7 +295,7 @@ mod tests {
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let rendered = render_to_string(&sb, 28, 12);
|
||||
let rendered = render_to_string(&draw(&sb, 28, 12));
|
||||
assert!(
|
||||
rendered.contains("claude"),
|
||||
"missing session name:\n{rendered}"
|
||||
@@ -151,11 +314,8 @@ mod tests {
|
||||
highlight: None,
|
||||
attached: [sid].into_iter().collect(),
|
||||
};
|
||||
let rendered = render_to_string(&sb, 28, 6);
|
||||
let line = rendered
|
||||
.lines()
|
||||
.find(|l| l.contains("claude"))
|
||||
.expect("claude row");
|
||||
let all = rows(&draw(&sb, 28, 6));
|
||||
let line = row_containing(&all, "claude");
|
||||
assert!(line.contains('●'), "expected filled glyph in: {line:?}");
|
||||
assert!(
|
||||
!line.contains('○'),
|
||||
@@ -172,21 +332,18 @@ mod tests {
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let rendered = render_to_string(&sb, 28, 6);
|
||||
let line = rendered
|
||||
.lines()
|
||||
.find(|l| l.contains("claude"))
|
||||
.expect("claude row");
|
||||
let buf = draw(&sb, 28, 6);
|
||||
let all = rows(&buf);
|
||||
let line = row_containing(&all, "claude");
|
||||
assert!(line.contains('✗'), "expected dead glyph in: {line:?}");
|
||||
}
|
||||
|
||||
/// #83: a session whose worktree blocks a kill carries the warning glyph;
|
||||
/// a clear or unprobed one does not.
|
||||
/// #83 via docs/SIDEBAR.md: blocked work lives on the meta line now, not
|
||||
/// on the name row; a clear or unprobed worktree never warns.
|
||||
#[test]
|
||||
fn blocked_session_gets_a_warning_glyph() {
|
||||
use maestro_protocol::InTheWay;
|
||||
|
||||
fn blocked_session_warns_on_its_meta_line() {
|
||||
let mut blocked = session_named(1, "claude");
|
||||
blocked.activity_since = Utc::now() - Duration::seconds(5);
|
||||
blocked.blocked_work = Some(InTheWay {
|
||||
unpushed_commits: 4,
|
||||
..InTheWay::default()
|
||||
@@ -199,21 +356,158 @@ mod tests {
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let rendered = render_to_string(&sb, 40, 8);
|
||||
let line = rendered
|
||||
.lines()
|
||||
.find(|l| l.contains("claude"))
|
||||
.expect("blocked row");
|
||||
assert!(line.contains('⚠'), "expected warning glyph in: {line:?}");
|
||||
let all = rows(&draw(&sb, 40, 10));
|
||||
|
||||
let meta = all
|
||||
.iter()
|
||||
.find(|l| l.contains("⚠"))
|
||||
.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"] {
|
||||
let line = rendered
|
||||
.lines()
|
||||
.find(|l| l.contains(name))
|
||||
.expect("clear row");
|
||||
assert!(!line.contains('⚠'), "{name} must not warn: {line:?}");
|
||||
let idx = all.iter().position(|l| l.contains(name)).unwrap();
|
||||
assert!(
|
||||
!all[idx + 1].contains('⚠'),
|
||||
"{name} must not warn: {:?}",
|
||||
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]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_command_when_name_metadata_is_absent() {
|
||||
let s = Session {
|
||||
@@ -227,8 +521,8 @@ mod tests {
|
||||
created_at: Utc::now(),
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
|
||||
last_activity_at: Utc::now(),
|
||||
activity_since: Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
};
|
||||
@@ -237,10 +531,150 @@ mod tests {
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let rendered = render_to_string(&sb, 28, 6);
|
||||
let rendered = render_to_string(&draw(&sb, 28, 6));
|
||||
assert!(
|
||||
rendered.contains("vim"),
|
||||
"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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -34,13 +34,13 @@ function stubFetch(info: unknown, projects: Project[]): void {
|
||||
);
|
||||
}
|
||||
|
||||
function rowPaths(form: HTMLFormElement): string[] {
|
||||
return [...form.querySelectorAll<HTMLButtonElement>(".project-row:not(.project-other)")].map(
|
||||
(r) => r.dataset.cwd ?? "",
|
||||
function boxPaths(form: HTMLFormElement): string[] {
|
||||
return [...form.querySelectorAll<HTMLButtonElement>(".project-box")].map(
|
||||
(b) => b.dataset.cwd ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
describe("spawnForm project picker", () => {
|
||||
describe("spawnForm project picker (#95: boxes, full paths, mains only)", () => {
|
||||
let handle: SpawnFormHandle | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -65,7 +65,7 @@ describe("spawnForm project picker", () => {
|
||||
return handle;
|
||||
}
|
||||
|
||||
it("renders one row per project, fresh before stale", async () => {
|
||||
it("renders one box per project, fresh before stale", async () => {
|
||||
const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW });
|
||||
const stale = project({
|
||||
name: "stale",
|
||||
@@ -73,40 +73,56 @@ describe("spawnForm project picker", () => {
|
||||
last_activity: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
const form = (await mount([stale, fresh])).el;
|
||||
expect(rowPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
|
||||
const rows = [...form.querySelectorAll<HTMLButtonElement>(".project-row")];
|
||||
expect(rows[1].classList.contains("is-stale")).toBe(true);
|
||||
expect(rows[0].classList.contains("is-stale")).toBe(false);
|
||||
expect(boxPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
|
||||
const boxes = [...form.querySelectorAll<HTMLButtonElement>(".project-box")];
|
||||
expect(boxes[1].classList.contains("is-stale")).toBe(true);
|
||||
expect(boxes[0].classList.contains("is-stale")).toBe(false);
|
||||
});
|
||||
|
||||
it("nests worktrees under their base repository", async () => {
|
||||
it("shows the name as the primary line and the full path unabbreviated", async () => {
|
||||
const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" });
|
||||
const form = (await mount([p])).el;
|
||||
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
||||
expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config");
|
||||
// Not `~/t/nixos-config`: the abbreviation hid what distinguishes two
|
||||
// projects sharing a basename (#95).
|
||||
expect(box.querySelector(".project-path")!.textContent).toBe("/home/user/tea/nixos-config");
|
||||
});
|
||||
|
||||
it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
|
||||
const main = project({ name: "maestro", path: "/home/user/maestro" });
|
||||
const wt = project({
|
||||
name: "wt",
|
||||
path: "/elsewhere/wt",
|
||||
path: "/home/user/maestro/worktrees/wt",
|
||||
is_worktree: true,
|
||||
base_repo: main.path,
|
||||
});
|
||||
const form = (await mount([wt, main])).el;
|
||||
expect(rowPaths(form)).toEqual(["/home/user/maestro", "/elsewhere/wt"]);
|
||||
const [mainRow, wtRow] = [
|
||||
...form.querySelectorAll(".project-row:not(.project-other)"),
|
||||
];
|
||||
expect(wtRow.classList.contains("is-worktree")).toBe(true);
|
||||
expect(mainRow.classList.contains("is-worktree")).toBe(false);
|
||||
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
|
||||
expect(form.querySelector(".project-box.is-worktree")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters rows as the operator types", async () => {
|
||||
it("filters boxes as the operator types, keeping a repo its worktree matched", async () => {
|
||||
const a = project({ name: "maestro", path: "/home/user/maestro" });
|
||||
const b = project({ name: "tea", path: "/home/user/work/tea" });
|
||||
const form = (await mount([a, b])).el;
|
||||
const wt = project({
|
||||
name: "bulgarians",
|
||||
path: "/home/user/maestro/worktrees/bulgarians",
|
||||
is_worktree: true,
|
||||
base_repo: a.path,
|
||||
});
|
||||
const form = (await mount([a, b, wt])).el;
|
||||
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
||||
filter.value = "tea";
|
||||
filter.dispatchEvent(new Event("input"));
|
||||
expect(rowPaths(form)).toEqual(["/home/user/work/tea"]);
|
||||
expect(boxPaths(form)).toEqual(["/home/user/work/tea"]);
|
||||
// A query that only matches a worktree still surfaces its repository box.
|
||||
filter.value = "bulgarians";
|
||||
filter.dispatchEvent(new Event("input"));
|
||||
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
|
||||
filter.value = "zzz";
|
||||
filter.dispatchEvent(new Event("input"));
|
||||
expect(rowPaths(form)).toEqual([]);
|
||||
expect(boxPaths(form)).toEqual([]);
|
||||
expect(form.querySelector(".picker-empty")).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -135,17 +151,17 @@ describe("spawnForm project picker", () => {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
onChange.mockClear();
|
||||
|
||||
const row = form.querySelector<HTMLButtonElement>(".project-row")!;
|
||||
row.click();
|
||||
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
||||
box.click();
|
||||
|
||||
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
|
||||
const last = onChange.mock.lastCall![0];
|
||||
expect(last.cwd).toBe("/home/user/maestro");
|
||||
expect(last.command).toBe("claude");
|
||||
expect(values).toBeTruthy();
|
||||
// The raw input stays hidden — the picked row is the source of truth.
|
||||
// The raw input stays hidden — the picked box is the source of truth.
|
||||
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
|
||||
expect(row.classList.contains("is-active")).toBe(true);
|
||||
expect(box.classList.contains("is-active")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => {
|
||||
|
||||
+265
-88
@@ -9,8 +9,8 @@ import {
|
||||
} from "./api.js";
|
||||
import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||
import type { ProjectGroup } from "./projectList.js";
|
||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
|
||||
|
||||
/** Where a worktree's branch comes from: freshly created (default) or picked
|
||||
* from the repository's existing branches (#61). */
|
||||
@@ -94,6 +94,11 @@ export const FALLBACK_PRESETS: SpawnPreset[] = [
|
||||
{ id: "custom", label: "Custom…", command: "" },
|
||||
];
|
||||
|
||||
/** Quiet period after raw-path typing before `/api/gitroot` fires (#74).
|
||||
* Must stay equal to `GITROOT_PROBE_DEBOUNCE_MS` in
|
||||
* crates/maestro-tui/src/draft.rs so both clients debounce alike. */
|
||||
const GITROOT_PROBE_DEBOUNCE_MS = 250;
|
||||
|
||||
export interface SpawnFormHandle {
|
||||
el: HTMLFormElement;
|
||||
/** Remove global listeners (dir autocomplete) — call when unmounting. */
|
||||
@@ -125,15 +130,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
placeholder="bash -l" autocomplete="off" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Working directory</span>
|
||||
<span class="field-label">Project</span>
|
||||
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
||||
autocomplete="off" />
|
||||
<div class="picker projects"></div>
|
||||
<button type="button" class="project-other">Other directory…</button>
|
||||
<div class="raw-wrap cwd-raw-wrap" hidden>
|
||||
<input class="raw-input cwd-raw" name="cwd_raw" type="text"
|
||||
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field wt-step" hidden>
|
||||
<span class="field-label">Worktree</span>
|
||||
<div class="picker wt-choices"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Name</span>
|
||||
<div class="name-row">
|
||||
@@ -142,33 +152,38 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
</div>
|
||||
</div>
|
||||
<fieldset class="worktree">
|
||||
<legend>
|
||||
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
|
||||
<span class="chevron">▾</span>
|
||||
<span class="folder-name">Advanced options</span>
|
||||
<span class="wt-summary"></span>
|
||||
</button>
|
||||
<div class="wt-collapsible" hidden>
|
||||
<label class="row-inline">
|
||||
<input type="checkbox" name="worktree_enabled" />
|
||||
Create git worktree
|
||||
</label>
|
||||
</legend>
|
||||
<p class="wt-hint" hidden></p>
|
||||
<div class="wt-body" hidden>
|
||||
<p class="wt-repo"></p>
|
||||
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
||||
<label class="row-inline">
|
||||
<input type="radio" name="worktree_branch_mode" value="new" />
|
||||
New branch
|
||||
<p class="wt-hint" hidden></p>
|
||||
<div class="wt-body" hidden>
|
||||
<p class="wt-repo"></p>
|
||||
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
||||
<label class="row-inline">
|
||||
<input type="radio" name="worktree_branch_mode" value="new" />
|
||||
New branch
|
||||
</label>
|
||||
<label class="row-inline">
|
||||
<input type="radio" name="worktree_branch_mode" value="existing" />
|
||||
Existing branch
|
||||
</label>
|
||||
</div>
|
||||
<label class="wt-new-wrap">
|
||||
New branch name
|
||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||
</label>
|
||||
<label class="row-inline">
|
||||
<input type="radio" name="worktree_branch_mode" value="existing" />
|
||||
Existing branch
|
||||
<label class="wt-exist-wrap" hidden>
|
||||
Pick a branch
|
||||
<select class="branch-select"></select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="wt-new-wrap">
|
||||
Branch
|
||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||
</label>
|
||||
<label class="wt-exist-wrap" hidden>
|
||||
Branch
|
||||
<select class="branch-select"></select>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<p class="form-error" role="alert" aria-live="polite"></p>
|
||||
@@ -182,11 +197,17 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
const errEl = form.querySelector(".form-error") as HTMLElement;
|
||||
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
|
||||
const cmdRaw = form.querySelector(".cmd-raw") as HTMLInputElement;
|
||||
const recentsEl = form.querySelector(".projects") as HTMLElement;
|
||||
const projectsEl = form.querySelector(".projects") as HTMLElement;
|
||||
const filterInput = form.querySelector(".project-filter") as HTMLInputElement;
|
||||
const otherBtn = form.querySelector(".project-other") as HTMLButtonElement;
|
||||
const wtStep = form.querySelector(".wt-step") as HTMLElement;
|
||||
const wtChoices = form.querySelector(".wt-choices") as HTMLElement;
|
||||
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
|
||||
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
|
||||
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
|
||||
const wtToggle = form.querySelector(".wt-toggle") as HTMLButtonElement;
|
||||
const wtCollapsible = form.querySelector(".wt-collapsible") as HTMLElement;
|
||||
const wtSummary = form.querySelector(".wt-summary") as HTMLElement;
|
||||
const wtHint = form.querySelector(".wt-hint") as HTMLElement;
|
||||
const wtBody = form.querySelector(".wt-body") as HTMLElement;
|
||||
const wtRepo = form.querySelector(".wt-repo") as HTMLElement;
|
||||
@@ -417,12 +438,36 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
if (first) selectPreset(first.id, false);
|
||||
}
|
||||
|
||||
// ---- working-directory picker ---------------------------------------
|
||||
// ---- working-directory picker (#95) ----------------------------------
|
||||
// Two steps, and the TUI mirror (#96) must match these rules verbatim:
|
||||
// 1. Step one is one box per repository (name, then full path). A worktree
|
||||
// is a box only when its main checkout is missing from the scan.
|
||||
// 2. The filter keeps a repository when the query matches it or any of its
|
||||
// worktrees, so a worktree name still finds its box.
|
||||
// 3. Picking a box reveals step two: "+ new", then that repository's
|
||||
// worktrees newest-first. "Other directory…" clears both steps.
|
||||
// 4. "+ new" is the default selection — except on a box that is itself a
|
||||
// linked worktree, which defaults to that worktree.
|
||||
// 5. "+ new" means create: cwd is the repository, create-worktree on.
|
||||
// An existing worktree means run in it: cwd is that worktree,
|
||||
// create-worktree OFF. The daemon never adopts (docs/WORKTREES.md), so
|
||||
// these are separate acts — #91's rule, now an explicit choice.
|
||||
// 6. Neither step probes /api/gitroot; the scan already reports repository
|
||||
// membership. Only the free-text hatch pays for a probe.
|
||||
|
||||
/** "new" (create a worktree) or the path of an existing worktree to run in. */
|
||||
type WorktreeChoice = "new" | string;
|
||||
|
||||
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
|
||||
let groups: ProjectGroup[] = []; // rebuilt on every render
|
||||
let selectedGroup: ProjectGroup | null = null;
|
||||
let worktreeChoice: WorktreeChoice = "new";
|
||||
|
||||
function showRawCwd(prefillValue?: string, focus = true): void {
|
||||
cwdRawWrap.hidden = false;
|
||||
if (prefillValue != null) cwd = prefillValue;
|
||||
cwdRaw.value = cwd;
|
||||
markActiveProject(null);
|
||||
clearProjectSelection();
|
||||
if (focus) {
|
||||
cwdRaw.focus();
|
||||
cwdRaw.select();
|
||||
@@ -436,65 +481,98 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
cwd = cwdRaw.value.trim();
|
||||
branchCache = null;
|
||||
emit();
|
||||
if (wtEnabled.checked) probeGitRoot();
|
||||
if (wtEnabled.checked) scheduleGitRootProbe();
|
||||
});
|
||||
|
||||
function markActiveProject(activeCwd: string | null): void {
|
||||
for (const row of recentsEl.querySelectorAll(".project-row")) {
|
||||
row.classList.toggle(
|
||||
"is-active",
|
||||
(row as HTMLElement).dataset.cwd === activeCwd,
|
||||
);
|
||||
/** Every worktree of a group, including the group's own box when the scan
|
||||
* found no main checkout to nest it under. */
|
||||
function worktreesOf(group: ProjectGroup): Project[] {
|
||||
return [group.main, ...group.worktrees].filter((p) => p.is_worktree);
|
||||
}
|
||||
|
||||
function markActiveBox(): void {
|
||||
const active = selectedGroup?.main.path ?? null;
|
||||
for (const box of projectsEl.querySelectorAll(".project-box")) {
|
||||
box.classList.toggle("is-active", (box as HTMLElement).dataset.cwd === active);
|
||||
}
|
||||
}
|
||||
|
||||
function selectProject(project: Project): void {
|
||||
cwd = project.path;
|
||||
function markActiveChoice(): void {
|
||||
for (const btn of wtChoices.querySelectorAll(".wt-choice")) {
|
||||
const el = btn as HTMLElement;
|
||||
const value = el.dataset.kind === "new" ? "new" : (el.dataset.cwd ?? "");
|
||||
el.classList.toggle("is-active", value === worktreeChoice);
|
||||
}
|
||||
}
|
||||
|
||||
function clearProjectSelection(): void {
|
||||
selectedGroup = null;
|
||||
worktreeChoice = "new";
|
||||
wtChoices.replaceChildren();
|
||||
wtStep.hidden = true;
|
||||
markActiveBox();
|
||||
}
|
||||
|
||||
function selectProject(group: ProjectGroup): void {
|
||||
selectedGroup = group;
|
||||
cwdRawWrap.hidden = true;
|
||||
markActiveProject(project.path);
|
||||
// The scan already knows this directory is inside a repo (#61): the
|
||||
// toggle defaults back on and no /api/gitroot round trip is needed —
|
||||
// unless the directory *is* a linked worktree itself (#91): spawning
|
||||
// in place is the only thing that works there, so default the toggle
|
||||
// off. Turning it back on by hand still tries (and fails with git's
|
||||
// own message), which is deliberate (#91 non-goal).
|
||||
wtEnabled.checked = !project.is_worktree;
|
||||
if (project.is_worktree) {
|
||||
renderWorktreeChoices(group);
|
||||
markActiveBox();
|
||||
// Rule 4: a box that is itself a linked worktree defaults to running there.
|
||||
applyWorktreeChoice(group.main.is_worktree ? group.main.path : "new");
|
||||
}
|
||||
|
||||
function applyWorktreeChoice(choice: WorktreeChoice): void {
|
||||
const group = selectedGroup;
|
||||
if (!group) return;
|
||||
const target =
|
||||
choice === "new" ? group.main : worktreesOf(group).find((p) => p.path === choice);
|
||||
if (!target) return;
|
||||
worktreeChoice = choice;
|
||||
cwd = target.path;
|
||||
branchCache = null;
|
||||
markActiveChoice();
|
||||
if (choice === "new") {
|
||||
wtEnabled.checked = true;
|
||||
updateWtSummary();
|
||||
applyProjectRepo(group.main.base_repo ?? group.main.path);
|
||||
} else {
|
||||
wtEnabled.checked = false;
|
||||
updateWtSummary();
|
||||
inferredBaseRepo = null;
|
||||
wtBody.hidden = true;
|
||||
wtHint.hidden = true;
|
||||
} else {
|
||||
applyProjectRepo(project.base_repo ?? project.path);
|
||||
}
|
||||
emit();
|
||||
// Picking a project pre-selects the command it was last used with.
|
||||
const match = presets.find((p) => p.command === project.last_command && p.command !== "");
|
||||
// The chosen directory pre-selects the command it was last used with.
|
||||
const match = presets.find((p) => p.command === target.last_command && p.command !== "");
|
||||
if (match) selectPreset(match.id);
|
||||
}
|
||||
|
||||
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
|
||||
/** 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 {
|
||||
recentsEl.replaceChildren();
|
||||
const sorted = sortProjects(filterProjects(discovered, filterInput.value), new Date());
|
||||
for (const group of groupProjects(sorted)) {
|
||||
recentsEl.appendChild(projectRow(group.main));
|
||||
for (const wt of group.worktrees) recentsEl.appendChild(projectRow(wt));
|
||||
}
|
||||
if (discovered.length > 0 && sorted.length === 0) {
|
||||
projectsEl.replaceChildren();
|
||||
groups = groupProjects(sortProjects(discovered, new Date()));
|
||||
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");
|
||||
empty.className = "picker-empty";
|
||||
empty.textContent = "No projects match.";
|
||||
recentsEl.appendChild(empty);
|
||||
projectsEl.appendChild(empty);
|
||||
}
|
||||
recentsEl.appendChild(otherDirRow());
|
||||
markActiveProject(cwdRawWrap.hidden ? cwd : null);
|
||||
markActiveBox();
|
||||
}
|
||||
|
||||
filterInput.addEventListener("input", renderProjects);
|
||||
otherBtn.addEventListener("click", () => showRawCwd(cwd || homeDir));
|
||||
|
||||
async function loadProjects(): Promise<void> {
|
||||
recentsEl.replaceChildren(loadingRow());
|
||||
projectsEl.replaceChildren(loadingRow());
|
||||
try {
|
||||
discovered = await fetchProjects();
|
||||
} catch {
|
||||
@@ -505,12 +583,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
if (seedCwd) {
|
||||
// A seed sets the directory verbatim, regardless of what the scan found.
|
||||
// `cwdRawVisible === false` marks a draft restore whose cwd came from a
|
||||
// picked row: keep the picker and don't steal focus. Otherwise land
|
||||
// picked box: keep the picker and don't steal focus. Otherwise land
|
||||
// focused in the raw input (clone / no-match flows).
|
||||
if (prefill.cwdRawVisible === false) {
|
||||
cwd = seedCwd;
|
||||
cwdRaw.value = seedCwd;
|
||||
markActiveProject(cwd);
|
||||
// Restoring must not touch the worktree toggle — the seed carries it.
|
||||
if (!restoreSelection(seedCwd)) clearProjectSelection();
|
||||
} else {
|
||||
showRawCwd(seedCwd, false);
|
||||
}
|
||||
@@ -527,34 +606,66 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
emit();
|
||||
}
|
||||
|
||||
function projectRow(project: Project): HTMLElement {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = `project-row${project.is_worktree ? " is-worktree" : ""}${
|
||||
isStale(project, new Date()) ? " is-stale" : ""
|
||||
}`;
|
||||
row.dataset.cwd = project.path;
|
||||
// Abbreviate exactly like the sidebar: home → `~`, then collapse leading
|
||||
// segments to initials (`~/g/n/maestro`). The full path stays in the tooltip
|
||||
// and `dataset.cwd` (what's actually submitted).
|
||||
const display = abbreviateFolderName(abbreviateCwd(project.path, homeDir));
|
||||
row.innerHTML = `
|
||||
<span class="project-info">
|
||||
<span class="project-name">${escapeHtml(project.name)}</span>
|
||||
<span class="recent-dir" title="${escapeHtml(project.path)}">${display}</span>
|
||||
</span>
|
||||
<span class="recent-cmd">${escapeHtml(project.last_command ?? "")}</span>`;
|
||||
row.addEventListener("click", () => selectProject(project));
|
||||
return row;
|
||||
/** Put the two steps back on a restored draft's cwd, without re-running the
|
||||
* side effects of a fresh pick. */
|
||||
function restoreSelection(path: string): boolean {
|
||||
for (const group of groups) {
|
||||
const wt = worktreesOf(group).find((p) => p.path === path);
|
||||
const choice: WorktreeChoice | null =
|
||||
wt ? wt.path : group.main.path === path ? "new" : null;
|
||||
if (choice === null) continue;
|
||||
selectedGroup = group;
|
||||
worktreeChoice = choice;
|
||||
renderWorktreeChoices(group);
|
||||
markActiveChoice();
|
||||
markActiveBox();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function otherDirRow(): HTMLElement {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "project-row project-other";
|
||||
row.innerHTML = `<span class="project-info"><span class="recent-dir">Other directory…</span></span>`;
|
||||
row.addEventListener("click", () => showRawCwd(cwd || homeDir));
|
||||
return row;
|
||||
function projectBox(group: ProjectGroup): HTMLElement {
|
||||
const project = group.main;
|
||||
const box = document.createElement("button");
|
||||
box.type = "button";
|
||||
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
|
||||
box.dataset.cwd = project.path;
|
||||
const trees = worktreesOf(group).length;
|
||||
// The full path, not the sidebar's `~/t/n/maestro` abbreviation: it is what
|
||||
// tells two projects sharing a basename apart (#95).
|
||||
const meta = [trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "", project.last_command ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
box.innerHTML = `
|
||||
<span class="project-name">${escapeHtml(project.name)}</span>
|
||||
<span class="project-path">${escapeHtml(project.path)}</span>
|
||||
<span class="project-meta">${escapeHtml(meta)}</span>`;
|
||||
box.addEventListener("click", () => selectProject(group));
|
||||
return box;
|
||||
}
|
||||
|
||||
function renderWorktreeChoices(group: ProjectGroup): void {
|
||||
wtChoices.replaceChildren();
|
||||
wtChoices.appendChild(
|
||||
choiceButton("new", "+ new", `new worktree of ${group.main.name}`),
|
||||
);
|
||||
for (const wt of worktreesOf(group)) {
|
||||
wtChoices.appendChild(choiceButton(wt.path, wt.name, wt.path));
|
||||
}
|
||||
wtStep.hidden = false;
|
||||
}
|
||||
|
||||
function choiceButton(choice: WorktreeChoice, label: string, sub: string): HTMLElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `wt-choice${choice === "new" ? " wt-choice-new" : ""}`;
|
||||
btn.dataset.kind = choice === "new" ? "new" : "existing";
|
||||
if (choice !== "new") btn.dataset.cwd = choice;
|
||||
btn.innerHTML = `
|
||||
<span class="wt-choice-name">${escapeHtml(label)}</span>
|
||||
<span class="wt-choice-sub">${escapeHtml(sub)}</span>`;
|
||||
btn.addEventListener("click", () => applyWorktreeChoice(choice));
|
||||
return btn;
|
||||
}
|
||||
|
||||
function loadingRow(): HTMLElement {
|
||||
@@ -564,6 +675,18 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---- worktree: disclosure (#93), reuses the sidebar's folder-header ---
|
||||
let advancedCollapsed = true;
|
||||
function setAdvancedCollapsed(collapsed: boolean): void {
|
||||
advancedCollapsed = collapsed;
|
||||
wtToggle.setAttribute("aria-expanded", String(!collapsed));
|
||||
wtCollapsible.hidden = collapsed;
|
||||
}
|
||||
function updateWtSummary(): void {
|
||||
wtSummary.textContent = wtEnabled.checked ? "creates a worktree" : "no worktree";
|
||||
}
|
||||
wtToggle.addEventListener("click", () => setAdvancedCollapsed(!advancedCollapsed));
|
||||
|
||||
// ---- worktree (branch-only; base_repo inferred via scan or /gitroot) --
|
||||
// The project scan already reports repo membership, so a picked project
|
||||
// resolves its base repo locally (#61); only the free-text escape hatch
|
||||
@@ -602,7 +725,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
applyProjectRepo(project.base_repo ?? project.path);
|
||||
return;
|
||||
}
|
||||
void probeGitRoot();
|
||||
scheduleGitRootProbe();
|
||||
}
|
||||
|
||||
// Every keystroke resets this timer, so a burst of typing issues one
|
||||
// request once the operator pauses. `probeToken` inside probeGitRoot still
|
||||
// owns ordering: a scheduled probe that fires after a newer one started
|
||||
// loses the token race and is dropped.
|
||||
let probeTimer: number | undefined;
|
||||
function scheduleGitRootProbe(): void {
|
||||
window.clearTimeout(probeTimer);
|
||||
probeTimer = window.setTimeout(() => {
|
||||
probeTimer = undefined;
|
||||
void probeGitRoot();
|
||||
}, GITROOT_PROBE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function probeGitRoot(): Promise<void> {
|
||||
@@ -631,6 +767,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
// Publish the untick too, or the next render seeds the box checked with
|
||||
// no inferred repo and submitting fails.
|
||||
wtEnabled.checked = false;
|
||||
updateWtSummary();
|
||||
emit();
|
||||
wtBody.hidden = true;
|
||||
wtHint.hidden = false;
|
||||
@@ -704,6 +841,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
branchSelect.addEventListener("change", () => emit());
|
||||
|
||||
wtEnabled.addEventListener("change", () => {
|
||||
updateWtSummary();
|
||||
emit();
|
||||
if (wtEnabled.checked) {
|
||||
resolveBaseRepo();
|
||||
@@ -717,6 +855,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
// The toggle defaults ON (#61): the operator almost always wants a
|
||||
// worktree, and every non-repo target unticks it via the gitroot probe.
|
||||
wtEnabled.checked = prefill.worktreeEnabled ?? true;
|
||||
updateWtSummary();
|
||||
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
|
||||
setBranchMode(branchMode);
|
||||
|
||||
@@ -727,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();
|
||||
@@ -763,6 +939,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
el: form,
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
window.clearTimeout(probeTimer);
|
||||
autocomplete.destroy();
|
||||
form.remove();
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||
|
||||
/** Debounce window under test — must equal GITROOT_PROBE_DEBOUNCE_MS in
|
||||
* spawnForm.ts (#74). */
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
const PROJECTS = [
|
||||
{ path: "/home/user/maestro", name: "maestro", last_activity: "2026-08-20T10:00:00Z" },
|
||||
];
|
||||
|
||||
/** Stub the fetches the form makes. Gitroot responses for a given path can be
|
||||
* held until `releaseGitRoot(path)` is called, so tests can land answers out
|
||||
* of order. */
|
||||
function stubFetch(opts?: { holdGitRootFor?: string[] }): {
|
||||
urls: string[];
|
||||
gitRootCalls: string[];
|
||||
releaseGitRoot: (path: string) => void;
|
||||
} {
|
||||
const urls: string[] = [];
|
||||
const gitRootCalls: string[] = [];
|
||||
const releases = new Map<string, () => void>();
|
||||
const held = new Map<string, Promise<void>>();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
urls.push(url);
|
||||
if (url.includes("/api/dirs")) {
|
||||
return new Response(JSON.stringify({ entries: [] }), { status: 200 });
|
||||
}
|
||||
if (url.includes("/api/gitroot")) {
|
||||
const path = new URL(url, "http://localhost").searchParams.get("path") ?? "";
|
||||
gitRootCalls.push(path);
|
||||
if (opts?.holdGitRootFor?.includes(path)) {
|
||||
let release!: () => void;
|
||||
held.set(
|
||||
path,
|
||||
new Promise<void>((r) => {
|
||||
release = r;
|
||||
}),
|
||||
);
|
||||
releases.set(path, release);
|
||||
}
|
||||
await held.get(path);
|
||||
return new Response(JSON.stringify({ root: path }), { status: 200 });
|
||||
}
|
||||
if (url.includes("/api/names")) {
|
||||
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
|
||||
}
|
||||
if (url.includes("/api/projects")) {
|
||||
return new Response(JSON.stringify(PROJECTS), { status: 200 });
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
|
||||
{ status: 200 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
return {
|
||||
urls,
|
||||
gitRootCalls,
|
||||
releaseGitRoot: (path: string) => releases.get(path)?.(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("spawnForm gitroot probe debounce (#74)", () => {
|
||||
let handle: SpawnFormHandle | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function mount(opts?: Parameters<typeof stubFetch>[0]) {
|
||||
const stub = stubFetch(opts);
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
return { form: handle.el, stub };
|
||||
}
|
||||
|
||||
const drain = async () => {
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
function typeRaw(form: HTMLFormElement, value: string): void {
|
||||
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
||||
raw.value = value;
|
||||
raw.dispatchEvent(new Event("input"));
|
||||
}
|
||||
|
||||
it("a keystroke burst fires exactly one probe after the quiet period", async () => {
|
||||
const { form, stub } = await mount();
|
||||
|
||||
form.querySelector<HTMLElement>(".project-other")!.click();
|
||||
// Burst: every keystroke reschedules; nothing may hit /api/gitroot yet.
|
||||
for (let i = 1; i <= "/opt/somewhere".length; i++) {
|
||||
typeRaw(form, "/opt/somewhere".slice(0, i));
|
||||
await drain();
|
||||
expect(stub.gitRootCalls).toEqual([]);
|
||||
}
|
||||
|
||||
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
|
||||
await drain();
|
||||
|
||||
expect(stub.gitRootCalls).toEqual(["/opt/somewhere"]);
|
||||
expect(stub.urls.filter((u) => u.includes("/api/gitroot")).length).toBe(1);
|
||||
});
|
||||
|
||||
it("a stale probe answer cannot overwrite a newer input", async () => {
|
||||
const { form, stub } = await mount({ holdGitRootFor: ["/repo/old"] });
|
||||
|
||||
form.querySelector<HTMLElement>(".project-other")!.click();
|
||||
|
||||
// Probe for the old target, held in flight.
|
||||
typeRaw(form, "/repo/old");
|
||||
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
|
||||
|
||||
// Newer target supersedes it and answers immediately.
|
||||
typeRaw(form, "/repo/new");
|
||||
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
|
||||
await drain();
|
||||
|
||||
expect(stub.gitRootCalls).toEqual(["/repo/old", "/repo/new"]);
|
||||
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
|
||||
expect(
|
||||
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked,
|
||||
).toBe(true);
|
||||
|
||||
// The late answer to the old target lands last but must lose.
|
||||
stub.releaseGitRoot("/repo/old");
|
||||
await drain();
|
||||
|
||||
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
|
||||
expect(
|
||||
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -80,10 +80,10 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
||||
};
|
||||
|
||||
function pickProject(form: HTMLFormElement): void {
|
||||
form.querySelector<HTMLButtonElement>(".project-row.project-other")!.click();
|
||||
form.querySelector<HTMLButtonElement>(".project-other")!.click();
|
||||
form
|
||||
.querySelectorAll<HTMLButtonElement>(".project-row")
|
||||
.forEach((row) => row.dataset.cwd === "/home/user/maestro" && row.click());
|
||||
.querySelectorAll<HTMLButtonElement>(".project-box")
|
||||
.forEach((box) => box.dataset.cwd === "/home/user/maestro" && box.click());
|
||||
}
|
||||
|
||||
it("defaults the worktree toggle on for a picked project with no gitroot probe", async () => {
|
||||
@@ -118,8 +118,8 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
|
||||
handle.el
|
||||
.querySelectorAll<HTMLButtonElement>(".project-row")
|
||||
.forEach((row) => row.dataset.cwd === "/home/user/wt-91" && row.click());
|
||||
.querySelectorAll<HTMLButtonElement>(".project-box")
|
||||
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
|
||||
await drain();
|
||||
|
||||
expect(handle.el.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked).toBe(
|
||||
@@ -132,11 +132,13 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
||||
it("still probes the gitroot for a free-text directory", async () => {
|
||||
const form = await mount();
|
||||
|
||||
// Escape hatch: switch to a free-text dir; only now does /api/gitroot fire.
|
||||
form.querySelector<HTMLElement>(".project-row.project-other")!.click();
|
||||
// Escape hatch: switch to a free-text dir; only now does /api/gitroot
|
||||
// fire — debounced, so the quiet period must elapse first (#74).
|
||||
form.querySelector<HTMLElement>(".project-other")!.click();
|
||||
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
||||
raw.value = "/opt/somewhere";
|
||||
raw.dispatchEvent(new Event("input"));
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
await drain();
|
||||
|
||||
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(true);
|
||||
@@ -178,6 +180,74 @@ 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")!;
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("states in the collapsed header that a worktree will be created by default (#93)", async () => {
|
||||
const form = await mount();
|
||||
expect(form.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/creates? a worktree/i);
|
||||
});
|
||||
|
||||
it("states in the collapsed header that no worktree will be created for a worktree row (#91, #93)", async () => {
|
||||
urls = stubFetch({
|
||||
projects: [
|
||||
{
|
||||
path: "/home/user/wt-91",
|
||||
name: "wt-91",
|
||||
is_worktree: true,
|
||||
base_repo: "/home/user/maestro",
|
||||
last_activity: "2026-08-20T10:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
|
||||
handle.el
|
||||
.querySelectorAll<HTMLButtonElement>(".project-box")
|
||||
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
|
||||
await drain();
|
||||
|
||||
expect(handle.el.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/no worktree/i);
|
||||
// Still collapsed — picking a project must not force it open.
|
||||
expect(handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("expanding the advanced section does not reorder the fields above it (#93)", async () => {
|
||||
const form = await mount();
|
||||
const before = [...form.querySelectorAll(".field")];
|
||||
|
||||
form.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
|
||||
|
||||
expect(form.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(false);
|
||||
expect([...form.querySelectorAll(".field")]).toStrictEqual(before);
|
||||
});
|
||||
|
||||
it("does not repopulate a destroyed form when the branch list lands late", async () => {
|
||||
const gate: { release?: () => void } = {};
|
||||
urls = stubFetch({ gateBranches: (r) => (gate.release = r) });
|
||||
|
||||
+141
-45
@@ -727,10 +727,8 @@ textarea {
|
||||
}
|
||||
|
||||
/* Earlier path segments collapse to their initial; the trailing segment (the
|
||||
one that actually names the dir) stays full. Dim the collapsed prefix. Shared
|
||||
by the sidebar tree and the spawn dialog's recent-directory picker. */
|
||||
.folder-name .folder-stem,
|
||||
.recent-dir .folder-stem {
|
||||
one that actually names the dir) stays full. Dim the collapsed prefix. */
|
||||
.folder-name .folder-stem {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
@@ -1286,18 +1284,33 @@ textarea {
|
||||
.worktree {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
padding: var(--sp-3);
|
||||
padding: 0 var(--sp-3);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
background: color-mix(in oklab, var(--surface-2) 50%, transparent);
|
||||
}
|
||||
|
||||
.worktree legend {
|
||||
padding: 0 6px;
|
||||
color: var(--ink);
|
||||
font-size: var(--fs-13);
|
||||
.worktree .wt-toggle {
|
||||
margin: 0 calc(var(--sp-3) * -1);
|
||||
padding-left: var(--sp-3);
|
||||
width: calc(100% + var(--sp-3) * 2);
|
||||
}
|
||||
|
||||
.wt-summary {
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-12);
|
||||
}
|
||||
|
||||
.wt-collapsible {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
padding-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
.wt-collapsible[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
@@ -1381,8 +1394,9 @@ textarea {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Project picker rows (#59). Worktree rows are indented under their base
|
||||
repository; rows stale beyond 7 days are dimmed but still selectable. */
|
||||
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
|
||||
picked project's worktree choices. Stale beyond 7 days dims but stays
|
||||
selectable. */
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1397,73 +1411,155 @@ textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-row {
|
||||
/* `display` from `.field`/`.raw-wrap` outranks the UA sheet's `[hidden]`, so
|
||||
step two and the raw-cwd hatch stayed visible while hidden. */
|
||||
.wt-step[hidden],
|
||||
.raw-wrap[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.picker.projects {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
align-content: start;
|
||||
gap: var(--sp-2);
|
||||
max-height: 300px;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.project-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
color: var(--ink);
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.project-box:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.project-box.is-active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
|
||||
.project-box.is-stale {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-size: var(--fs-13);
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The whole path, never abbreviated — it is what tells two projects sharing a
|
||||
basename apart. Wraps rather than truncating so none of it is hidden. */
|
||||
.project-path {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-12);
|
||||
line-height: 1.35;
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-12);
|
||||
color: var(--faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-meta:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.project-other {
|
||||
align-self: flex-start;
|
||||
padding: 4px 0;
|
||||
min-height: 30px;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--muted);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-13);
|
||||
font-style: italic;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-other:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Step two: "+ new" (create one) or an existing worktree (run in it). */
|
||||
.picker.wt-choices {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.wt-choice {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
min-height: 32px;
|
||||
padding: 6px var(--sp-3);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
color: var(--ink);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.project-row:hover {
|
||||
.wt-choice:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.project-row.is-active {
|
||||
.wt-choice.is-active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
|
||||
.project-row.is-worktree {
|
||||
margin-left: var(--sp-4);
|
||||
}
|
||||
|
||||
.project-row.is-stale {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.project-info {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
.wt-choice-name {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--fs-13);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recent-dir {
|
||||
.wt-choice-new .wt-choice-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-12);
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.recent-cmd {
|
||||
flex: 0 0 auto;
|
||||
.wt-choice-sub {
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-12);
|
||||
line-height: 1.35;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.project-other .recent-dir {
|
||||
font-family: inherit;
|
||||
font-style: italic;
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.picker-empty {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
// @vitest-environment happy-dom
|
||||
// Second step of the working-directory picker (#95). See the interaction rules
|
||||
// block in spawnForm.ts for what each choice means.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } 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",
|
||||
last_command: "claude",
|
||||
};
|
||||
|
||||
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 WT_B: Project = {
|
||||
path: "/home/user/tea/maestro/worktrees/spawn-v2",
|
||||
name: "spawn-v2",
|
||||
is_worktree: true,
|
||||
base_repo: MAESTRO.path,
|
||||
last_activity: "2026-08-21T10: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[]): string[] {
|
||||
const urls: string[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
urls.push(url);
|
||||
const body = url.includes("/api/projects")
|
||||
? projects
|
||||
: url.includes("/api/names")
|
||||
? { names: ["amber-fox"] }
|
||||
: url.includes("/api/branches")
|
||||
? []
|
||||
: { hostname: "h", version: "0", home_dir: "/home/user", presets: [] };
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
);
|
||||
return urls;
|
||||
}
|
||||
|
||||
describe("spawnForm worktree step (#95)", () => {
|
||||
let handle: SpawnFormHandle | null = null;
|
||||
let urls: string[] = [];
|
||||
let changes: SpawnFormValues[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") });
|
||||
changes = [];
|
||||
});
|
||||
|
||||
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> {
|
||||
urls = stubFetch(projects);
|
||||
handle = createSpawnForm({ onSuccess() {}, onChange: (v) => changes.push(v) });
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
return handle.el;
|
||||
}
|
||||
|
||||
const step = (form: HTMLFormElement) => form.querySelector<HTMLElement>(".wt-step")!;
|
||||
const choices = (form: HTMLFormElement) => [
|
||||
...form.querySelectorAll<HTMLButtonElement>(".wt-choice"),
|
||||
];
|
||||
const activeChoice = (form: HTMLFormElement) =>
|
||||
form.querySelector<HTMLButtonElement>(".wt-choice.is-active");
|
||||
const box = (form: HTMLFormElement, path: string) =>
|
||||
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
|
||||
const toggle = (form: HTMLFormElement) =>
|
||||
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
|
||||
|
||||
it("hides the worktree step until a project is picked", async () => {
|
||||
const form = await mount([MAESTRO, WT_A]);
|
||||
expect(step(form).hidden).toBe(true);
|
||||
expect(choices(form)).toEqual([]);
|
||||
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
|
||||
expect(step(form).hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("lists '+ new' first and selected by default, then the project's worktrees", async () => {
|
||||
const form = await mount([MAESTRO, WT_A, WT_B]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
|
||||
const labels = choices(form).map((c) => c.querySelector(".wt-choice-name")!.textContent);
|
||||
expect(labels).toEqual(["+ new", "bulgarians", "spawn-v2"]);
|
||||
expect(activeChoice(form)!.dataset.kind).toBe("new");
|
||||
});
|
||||
|
||||
it("taking the '+ new' default runs in the repo with the create toggle on", async () => {
|
||||
const form = await mount([MAESTRO, WT_A]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
|
||||
expect(changes.at(-1)!.cwd).toBe(MAESTRO.path);
|
||||
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
|
||||
expect(toggle(form).checked).toBe(true);
|
||||
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain(MAESTRO.path);
|
||||
// The scan already knows the repo — no probe.
|
||||
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
|
||||
});
|
||||
|
||||
it("picking an existing worktree runs in it with the create toggle off (#91)", async () => {
|
||||
const form = await mount([MAESTRO, WT_A, WT_B]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
|
||||
const existing = choices(form).find((c) => c.dataset.cwd === WT_A.path)!;
|
||||
existing.click();
|
||||
await drain();
|
||||
|
||||
expect(changes.at(-1)!.cwd).toBe(WT_A.path);
|
||||
expect(changes.at(-1)!.worktreeEnabled).toBe(false);
|
||||
expect(toggle(form).checked).toBe(false);
|
||||
expect(form.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
|
||||
expect(existing.classList.contains("is-active")).toBe(true);
|
||||
// Still no probe: the scan reported this worktree's repo membership.
|
||||
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
|
||||
// The project box stays marked — it is what the worktree list belongs to.
|
||||
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
|
||||
});
|
||||
|
||||
it("switching back to '+ new' re-enables the create toggle", async () => {
|
||||
const form = await mount([MAESTRO, WT_A]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
choices(form)
|
||||
.find((c) => c.dataset.cwd === WT_A.path)!
|
||||
.click();
|
||||
await drain();
|
||||
|
||||
choices(form)
|
||||
.find((c) => c.dataset.kind === "new")!
|
||||
.click();
|
||||
await drain();
|
||||
|
||||
expect(changes.at(-1)!.cwd).toBe(MAESTRO.path);
|
||||
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
|
||||
expect(toggle(form).checked).toBe(true);
|
||||
});
|
||||
|
||||
it("repopulates and resets to '+ new' when another project is picked", async () => {
|
||||
const form = await mount([MAESTRO, WT_A, OTHER]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
choices(form)
|
||||
.find((c) => c.dataset.cwd === WT_A.path)!
|
||||
.click();
|
||||
await drain();
|
||||
|
||||
box(form, OTHER.path).click();
|
||||
await drain();
|
||||
|
||||
expect(choices(form).map((c) => c.dataset.kind)).toEqual(["new"]);
|
||||
expect(activeChoice(form)!.dataset.kind).toBe("new");
|
||||
expect(changes.at(-1)!.cwd).toBe(OTHER.path);
|
||||
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to the worktree itself when the box is a linked worktree (#91)", async () => {
|
||||
// No main checkout in the scan, so the orphan worktree is its own box.
|
||||
const form = await mount([WT_A]);
|
||||
box(form, WT_A.path).click();
|
||||
await drain();
|
||||
|
||||
expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path);
|
||||
expect(changes.at(-1)!.cwd).toBe(WT_A.path);
|
||||
expect(changes.at(-1)!.worktreeEnabled).toBe(false);
|
||||
expect(toggle(form).checked).toBe(false);
|
||||
expect(form.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the worktree step again when the operator takes the raw-input hatch", async () => {
|
||||
const form = await mount([MAESTRO, WT_A]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
expect(step(form).hidden).toBe(false);
|
||||
|
||||
form.querySelector<HTMLButtonElement>(".project-other")!.click();
|
||||
await drain();
|
||||
|
||||
expect(step(form).hidden).toBe(true);
|
||||
expect(choices(form)).toEqual([]);
|
||||
expect(form.querySelector(".project-box.is-active")).toBeNull();
|
||||
});
|
||||
|
||||
it("restores the picked worktree from a draft seed", async () => {
|
||||
urls = stubFetch([MAESTRO, WT_A, WT_B]);
|
||||
handle = createSpawnForm({
|
||||
onSuccess() {},
|
||||
seed: { cwd: WT_A.path, cwdRawVisible: false, worktreeEnabled: false },
|
||||
});
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
const form = handle.el;
|
||||
|
||||
expect(step(form).hidden).toBe(false);
|
||||
expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path);
|
||||
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
|
||||
expect(toggle(form).checked).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user