Compare commits

...
Author SHA1 Message Date
Miguel Palhas 1a0d1284e1 feat(maestro): mirror activity_since and Error state in web types 2026-08-24 19:22:47 +01:00
Miguel Palhas fc6f3469b0 feat(maestro): add Error activity state to the wire 2026-08-24 19:21:44 +01:00
Miguel Palhas b95b5c04c1 feat(maestro): add activity_since to the session wire 2026-08-24 19:19:33 +01:00
Miguel Palhas ad2e700391 fix(maestro): spawn opens a new tab instead of splitting
ci / web (push) Successful in 31s
ci / rust (push) Successful in 2m22s
ci / flake (push) Successful in 6m43s
Pressing Spawn while reading a session split that pane in half. It now
opens the draft in its own tab, leaving the current tab untouched.

Scoped to the explicit Spawn affordances (the header button and the
sidebar empty state, both via OPEN_SPAWN_EVENT). "Spawn similar" and the
fast-forward key deliberately keep splitting: both act on the session in
front of you, so a pane beside it is the point. Reusing an empty pane
(#72) is unchanged — that path never split anything.

Closes #100
2026-08-24 17:17:35 +01:00
Miguel Palhas 2dcef860a6 fix(maestro): group worktree sessions under their repository
The sidebar nested a worktree session under the worktree, so a repo grew
a <repo>/worktrees/<branch> folder holding one session of the same name,
and the repo stopped being where you look for the work.

The daemon recorded whatever base_repo the request named, which is the
worktree itself when spawning from inside one; it now records the
resolved main checkout. The web tree groups by that instead of the cwd.
The TUI sidebar is a flat list with no directory grouping, so it needs
no mirror.

Closes #99
2026-08-24 17:17:35 +01:00
Miguel Palhas 0bb5df7245 fix(maestro): write nix.conf before installing nix in CI
ci / web (push) Successful in 1m26s
ci / rust (push) Successful in 4m20s
ci / flake (push) Successful in 7m24s
The runner container is root with no `nixbld` group. The installer
writes `build-users-group = nixbld` regardless and then fails on it:

  error: the group 'nixbld' specified in 'build-users-group' does not exist

Writing the config first (and again after, in case the installer
replaced it) clears that and enables flakes in the same place.

Refs #98
2026-08-24 16:19:01 +01:00
Miguel Palhas cb070e4f84 fix(maestro): make the flake CI job work on the runner
ci / flake (push) Failing after 16s
ci / web (push) Successful in 37s
ci / rust (push) Successful in 5m9s
Two defects in the job as landed, both invisible locally:

- The multi-user installer needs systemd, which the act_runner container
  does not have. Single-user install instead.
- The installer does not enable flakes, so `nix build .#…` fails on a
  fresh install. A machine with them in nix.conf builds fine, which is
  why this passed locally and failed in CI.

Refs #98
2026-08-24 16:17:00 +01:00
Miguel Palhas 8fde211348 feat(maestro): spawn form v2 refinements and review fixes
ci / web (push) Successful in 26s
ci / flake (push) Failing after 21s
ci / rust (push) Successful in 2m30s
Closes the Spawn v2 milestone: six review findings from the first blitz
plus seven pieces of operator feedback on the draft pane, and a flake
build that had been silently broken.

Form:
- The draft form is capped and centred instead of stretching to the pane.
- The worktree section sits behind a collapsed "Advanced options"
  disclosure whose header says whether a worktree will be created.
- The two controls both labelled "Branch" are now "New branch name" and
  "Pick a branch", matching in both clients.
- Enter submits from any field; inside a picker it chooses first.

Directory picker, rebuilt in both clients from one written set of rules:
- Projects are boxes with full paths; worktrees are a second step that
  appears once a project is picked, with "+ new" as the default.
- Picking an existing worktree runs in it and leaves the create-worktree
  toggle off — the daemon never adopts, so running in a tree and owning
  it stay separate acts (docs/WORKTREES.md).

Fixes: the fast-forward spawn key never matched a shifted 'S'; openDraft
never reused an empty pane and dropped its seed; swapping draft panes
moved the data but not the forms; the gitroot probe fired per keystroke;
the projects rescan ignored its own TTL; a late /api/info reset a picked
preset; and CI now builds the flake packages, which had been failing
since the pnpmDeps hash went stale.
2026-08-24 16:13:17 +01:00
Miguel Palhas 31cdbd4f0e merge: Enter submits the draft form from any field
Closes #97
2026-08-24 15:52:28 +01:00
Miguel Palhas 0d06827b69 fix(maestro): keep Enter toggling the worktree checkbox, not submitting
Matches the TUI's WorktreeToggle, whose Enter also toggles rather than
submitting — otherwise the two clients would disagree on this one
field despite #97 requiring the same rule everywhere else.
2026-08-24 15:51:19 +01:00
Miguel Palhas 8a6cb81d3a feat(maestro): Enter submits the TUI draft pane from any field (#97)
DirPicker mirrors the web filter/picker split: typing narrows and the
next Enter submits with the highlighted project directly (nothing was
chosen yet); an explicit Up/Down move onto a different row is
mid-selection, so Enter there only chooses it and a further Enter (now
the active pick) submits. WorktreeChoice, BranchExisting, Preset and
BranchMode already apply their highlight eagerly on Up/Down/Left/Right,
so Enter always submits there. Advanced and WorktreeToggle keep
activating on Enter, matching the web form's own button controls.
2026-08-24 15:50:28 +01:00
Miguel Palhas 52f9d0679e merge: distinct labels for the worktree branch fields
Closes #94
2026-08-24 15:48:38 +01:00
Miguel Palhas e6ef236f89 fix(maestro): match the web form's distinct branch-field labels in the TUI (#94)
The draft pane's new-branch gutter was labelled "br:" in both branch
modes — since #94's web fix now says "New branch name" / "Pick a
branch", mirror the same distinction here: "new:" for the branch-name
input, "pick:" for the existing-branch list (which previously had no
label at all).
2026-08-24 15:44:06 +01:00
Miguel Palhas 5747c71800 fix(maestro): distinct labels for the worktree branch field (#94)
The new-branch name input and the existing-branch picker both had a
literal <label>Branch</label>, on top of "New branch"/"Existing branch"
mode radios — reading the form gave no clue what the field wanted.
Kept the mode radios (they name the choice, not the field) and gave
each field its own label: "New branch name" for the text input,
"Pick a branch" for the select.
2026-08-24 15:42:10 +01:00
Miguel Palhas 5b0a0c2001 feat(maestro): Enter submits the spawn form from any field (#97)
A project-box or worktree-choice picker button that isn't yet the
active pick takes the Enter to choose it instead; the next Enter, once
it's active, submits. The project filter resolves to the top filtered
project rather than sending whatever cwd was last set. The raw-cwd
autocomplete's own Enter (accept the highlight) keeps working via
e.defaultPrevented.
2026-08-24 15:38:44 +01:00
Miguel Palhas df9013ffdf merge: two-step worktree picker in the TUI draft
Keeps both #93 and #96 in the draft layout: the worktree toggle and body
stay behind the Advanced disclosure, while the worktree choice step
belongs to the directory picker (#95 rule 3) and stays visible once a
project is picked.

Closes #96
2026-08-24 15:30:02 +01:00
Miguel Palhas 39341b278c merge: collapse the worktree section by default in both clients
Closes #93
2026-08-24 15:27:38 +01:00
Miguel Palhas cd66dbf7d8 Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/93-collapse-worktree
# Conflicts:
#	web/src/spawnForm.ts
2026-08-24 15:22:16 +01:00
Miguel Palhas 6bb812f6bd feat(maestro): collapse the TUI draft's worktree section by default (#93)
Mirrors the web spawn form: worktree/branch controls sit behind a new
"Advanced options" disclosure line (bracket [+]/[-], toggled by Enter
or Space, closed by default) instead of always showing the checkbox
and branch rows. The header states whether a worktree will be created
so #91's off-by-default for a worktree row is visible without
expanding. Collapsed fields drop out of Tab order; expanding only adds
rows below the disclosure, never moving the ones above it.
2026-08-24 15:20:35 +01:00
Miguel Palhas 5de04af36a feat(maestro): collapse the spawn form worktree section by default (#93)
Closed by default behind a disclosure reusing the sidebar folder-header
affordance (catalog.ts), since most spawns take the default target and
the section was the largest block in the form. The collapsed header
states whether a worktree will be created, so #91's off-by-default for
a worktree row stays visible without expanding.

Fixes a latent [hidden] + display:flex bug for the new .wt-collapsible
element (author CSS beats the UA [hidden] rule at equal specificity);
.wt-body has the same bug pre-existing and is out of scope here.
2026-08-24 15:15:47 +01:00
22 changed files with 1086 additions and 95 deletions
+13 -3
View File
@@ -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 \
+2
View File
@@ -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,
}
+7 -1
View File
@@ -278,7 +278,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),
};
@@ -349,6 +353,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 +573,7 @@ impl SessionManager {
foreground: None,
activity: ActivityState::Idle,
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
+7
View File
@@ -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")
@@ -947,3 +947,60 @@ 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");
}
+1
View File
@@ -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,
}
+42
View File
@@ -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!(
+1
View File
@@ -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,
}
+3 -3
View File
@@ -1127,6 +1127,7 @@ mod tests {
foreground: None,
activity: Default::default(),
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
}],
@@ -1349,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() {
@@ -1646,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,
+1
View File
@@ -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,
}
+357 -51
View File
@@ -80,6 +80,9 @@ pub enum DraftField {
/// apply immediately, mirroring the web form's per-click choice.
WorktreeChoice,
Name,
/// Disclosure collapsing the worktree/branch controls below (#93).
/// Closed by default; Enter / Space toggles.
Advanced,
/// Worktree on/off for this session (#65). Enter / Space toggles.
WorktreeToggle,
/// New-vs-existing branch source; Left / Right switches.
@@ -196,6 +199,12 @@ pub struct DraftPane {
/// Highlighted entry within the visible step-one list; the implicit
/// `Other directory…` entry sits at index `visible_groups().len()`.
pub highlighted: usize,
/// Set by an explicit Up/Down move, cleared by typing (#97): typing
/// mirrors the web form's plain filter input, where Enter always
/// resolves and submits; an explicit arrow move mirrors Tab landing on
/// a not-yet-active picker button there, where a first Enter only
/// chooses. See `dir_picker_choice_active` at the Enter match arm.
dir_picker_navigated: bool,
pub cwd_raw_visible: bool,
pub cwd_raw: crate::text_input::TextInput,
pub picked_cwd: Option<PathBuf>,
@@ -215,6 +224,10 @@ pub struct DraftPane {
/// Worktree on for this spawn (#65). Defaults on; a failed gitroot probe
/// unticks it, like the web form.
pub worktree_enabled: bool,
/// Whether the worktree/branch controls are collapsed behind the
/// "Advanced options" disclosure (#93). Closed by default — most spawns
/// take the default target.
pub advanced_collapsed: bool,
/// Enclosing repository of the chosen target — from the project scan for
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
pub base_repo: Option<PathBuf>,
@@ -263,6 +276,7 @@ impl Default for DraftPane {
groups: Vec::new(),
filter: crate::text_input::TextInput::default(),
highlighted: 0,
dir_picker_navigated: false,
cwd_raw_visible: false,
cwd_raw: crate::text_input::TextInput::default(),
picked_cwd: None,
@@ -273,6 +287,7 @@ impl Default for DraftPane {
name_touched: false,
needs_name_refill: false,
worktree_enabled: true,
advanced_collapsed: true,
base_repo: None,
probed_cwd: None,
probe_seq: 0,
@@ -511,6 +526,21 @@ impl DraftPane {
self.set_branch_mode(next);
}
/// Enter / Space on the "Advanced options" header (#93).
pub fn toggle_advanced(&mut self) {
self.advanced_collapsed = !self.advanced_collapsed;
}
/// What the collapsed header says about this spawn's default, so it stays
/// visible without expanding (#93).
fn advanced_summary(&self) -> &'static str {
if self.worktree_enabled {
"creates a worktree"
} else {
"no worktree"
}
}
/// Enter / Space on the toggle. Re-enabling reuses a still-valid repo
/// answer instead of re-probing.
pub fn toggle_worktree(&mut self) {
@@ -673,13 +703,16 @@ impl DraftPane {
order.push(DraftField::WorktreeChoice);
}
order.push(DraftField::Name);
order.push(DraftField::WorktreeToggle);
if self.worktree_body_visible() {
order.push(DraftField::BranchMode);
order.push(match self.branch_mode {
WorktreeBranchMode::New => DraftField::BranchNew,
WorktreeBranchMode::Existing => DraftField::BranchExisting,
});
order.push(DraftField::Advanced);
if !self.advanced_collapsed {
order.push(DraftField::WorktreeToggle);
if self.worktree_body_visible() {
order.push(DraftField::BranchMode);
order.push(match self.branch_mode {
WorktreeBranchMode::New => DraftField::BranchNew,
WorktreeBranchMode::Existing => DraftField::BranchExisting,
});
}
}
order.extend([DraftField::Submit]);
order
@@ -743,6 +776,7 @@ impl DraftPane {
if count == 0 {
return;
}
self.dir_picker_navigated = true;
self.highlighted = ((self.selection() as i32) + delta).rem_euclid(count) as usize;
}
@@ -778,6 +812,17 @@ impl DraftPane {
rows
}
/// Whether the highlighted step-one row is already the picked group
/// (#97): once true, a further Enter submits instead of re-picking it.
/// Always false on the trailing "Other directory…" row — picking it is
/// never a no-op, so it always takes the Enter to reveal the raw field.
fn dir_picker_choice_active(&self) -> bool {
let visible = self.visible_groups();
let sel = self.selection();
sel < visible.len()
&& self.selected_group_key.as_ref() == Some(&group_key(&visible[sel].main))
}
/// Act on the highlighted step-one box: reveals step two, defaulting per
/// rule 4. The trailing entry reveals the free-text path input and
/// clears both steps (rule 3).
@@ -907,23 +952,41 @@ impl DraftPane {
self.prev_field();
DraftInputResult::Consumed
}
InputKey::Enter => match self.focused_field {
DraftField::Submit => DraftInputResult::Submit,
DraftField::DirPicker => {
self.select_entry();
// Enter submits from any field (#97), mirroring web/src/spawnForm.ts.
// Two fields keep their own Enter instead: `DirPicker`, whose
// highlighted row isn't the picked one until a first Enter chooses
// it (the next Enter, now that it's active, falls through to
// submit); and the disclosure/toggle pair (`Advanced`,
// `WorktreeToggle`), which activate on Enter like a button rather
// than submitting, same as the web form's own `<button>` controls.
InputKey::Enter if self.focused_field == DraftField::DirPicker => {
let is_other_row = self.selection() >= self.visible_groups().len();
let choose_only =
is_other_row || (self.dir_picker_navigated && !self.dir_picker_choice_active());
self.select_entry();
if choose_only {
DraftInputResult::Consumed
} else {
DraftInputResult::Submit
}
DraftField::WorktreeToggle => {
self.toggle_worktree();
DraftInputResult::Consumed
}
// The existing-branch selection is live; Enter just confirms.
DraftField::BranchExisting => DraftInputResult::Consumed,
_ => {
self.next_field();
DraftInputResult::Consumed
}
},
}
InputKey::Enter if self.focused_field == DraftField::Advanced => {
self.toggle_advanced();
DraftInputResult::Consumed
}
InputKey::Enter if self.focused_field == DraftField::WorktreeToggle => {
self.toggle_worktree();
DraftInputResult::Consumed
}
InputKey::Enter => DraftInputResult::Submit,
InputKey::Char {
c: ' ',
ctrl: false,
..
} if self.focused_field == DraftField::Advanced => {
self.toggle_advanced();
DraftInputResult::Consumed
}
InputKey::Char {
c: ' ',
ctrl: false,
@@ -987,6 +1050,7 @@ impl DraftPane {
DraftField::DirPicker => {
self.filter.handle_key(other);
self.highlighted = 0;
self.dir_picker_navigated = false;
}
DraftField::CwdRaw => {
self.cwd_raw.handle_key(other);
@@ -1020,6 +1084,13 @@ impl DraftPane {
let show_custom = self.is_custom();
let show_raw = self.cwd_raw_visible;
// The worktree toggle and its body stay behind the "Advanced options"
// disclosure (#93): collapsed hides both, regardless of what they'd
// otherwise show. The worktree *choice* step is not part of that —
// it belongs to the directory picker (#95 rule 3), so it stays
// visible once a project is picked.
let show_wt_toggle = !self.advanced_collapsed;
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
let show_wt_choice = self.selected_group_key.is_some() && !show_raw;
let wt_choice_rows_data = if show_wt_choice {
self.worktree_choice_rows()
@@ -1029,13 +1100,13 @@ impl DraftPane {
// Up to four choices (new + worktrees) stay visible around the
// selection.
let wt_choice_capacity = wt_choice_rows_data.len().clamp(1, 4) as u16;
let show_wt_body = self.worktree_body_visible();
// Up to three branch rows stay visible around the selection; held
// branches need their reason on screen, so they aren't collapsed.
let branch_rows = self.branches.len().clamp(1, 3) as u16;
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* worktree */ + 1 /* spawn */
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* advanced header */ + 1 /* spawn */
+ u16::from(show_custom)
+ u16::from(show_raw)
+ u16::from(show_wt_toggle)
+ u16::from(show_wt_choice) * wt_choice_capacity
+ u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows })
+ u16::from(self.error.is_some());
@@ -1053,7 +1124,10 @@ impl DraftPane {
constraints.push(Constraint::Length(wt_choice_capacity));
}
constraints.push(Constraint::Length(1)); // name
constraints.push(Constraint::Length(1)); // worktree toggle
constraints.push(Constraint::Length(1)); // advanced header
if show_wt_toggle {
constraints.push(Constraint::Length(1)); // worktree toggle
}
if show_wt_body {
constraints.push(Constraint::Length(1)); // branch mode
match self.branch_mode {
@@ -1268,33 +1342,55 @@ impl DraftPane {
},
);
// -- advanced disclosure (#93) ------------------------------------------
let advanced_row = next_row();
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(format!("{:<GUTTER$}", "adv:")),
Span::styled(
format!(
"[{}] Advanced options",
if self.advanced_collapsed { "+" } else { "-" }
),
field_style(DraftField::Advanced),
),
Span::styled(
format!(" — {}", self.advanced_summary()),
Style::default().add_modifier(Modifier::DIM),
),
])),
advanced_row,
);
// -- worktree (#65) -----------------------------------------------------
let wt_row = next_row();
let hint = if self.cwd().is_none() {
Some("choose a directory first")
} else if self.repo_missing {
Some("not inside a git repository")
} else if !self.worktree_enabled {
Some("off — spawns in the checkout itself")
} else {
None
};
let mut tiles = vec![Span::raw(format!("{:<GUTTER$}", "wt:"))];
tiles.push(Span::styled(
if self.worktree_enabled {
"[x] worktree"
if show_wt_toggle {
let wt_row = next_row();
let hint = if self.cwd().is_none() {
Some("choose a directory first")
} else if self.repo_missing {
Some("not inside a git repository")
} else if !self.worktree_enabled {
Some("off — spawns in the checkout itself")
} else {
"[ ] worktree"
},
field_style(DraftField::WorktreeToggle),
));
if let Some(hint) = hint {
None
};
let mut tiles = vec![Span::raw(format!("{:<GUTTER$}", "wt:"))];
tiles.push(Span::styled(
format!(" {hint}"),
Style::default().add_modifier(Modifier::DIM),
if self.worktree_enabled {
"[x] worktree"
} else {
"[ ] worktree"
},
field_style(DraftField::WorktreeToggle),
));
if let Some(hint) = hint {
tiles.push(Span::styled(
format!(" {hint}"),
Style::default().add_modifier(Modifier::DIM),
));
}
f.render_widget(Paragraph::new(Line::from(tiles)), wt_row);
}
f.render_widget(Paragraph::new(Line::from(tiles)), wt_row);
if show_wt_body {
// branch source: new (created) vs existing (checked out as-is)
@@ -1322,7 +1418,7 @@ impl DraftPane {
match self.branch_mode {
WorktreeBranchMode::New => {
let row = next_row();
draw_gutter(f, row, "br:");
draw_gutter(f, row, "new:");
let col =
draw_text_field(f, &self.branch, row, field_style(DraftField::BranchNew));
if self.focused_field == DraftField::BranchNew {
@@ -1346,6 +1442,7 @@ impl DraftPane {
.unwrap_or(0)
.min(20);
let list_row = next_row();
draw_gutter(f, list_row, "pick:");
for i in start..(start + capacity).min(len.max(1)) {
let y = list_row.y + (i - start) as u16;
if y >= list_row.y + list_row.height {
@@ -1677,7 +1774,11 @@ mod tests {
1,
"type-to-filter narrows the list"
);
draft.on_input(&InputKey::Enter);
assert_eq!(
draft.on_input(&InputKey::Enter),
DraftInputResult::Submit,
"#97: the project filter submits with the highlighted project in one Enter"
);
assert_eq!(
draft.picked_cwd.as_deref(),
Some(Path::new("/repos/maestro"))
@@ -1689,6 +1790,113 @@ mod tests {
);
}
#[test]
fn enter_submits_immediately_from_ordinary_fields_97() {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/repos/maestro")),
base_repo: Some(PathBuf::from("/repos/maestro")),
probed_cwd: Some(PathBuf::from("/repos/maestro")),
..DraftPane::default()
};
for field in [
DraftField::Preset,
DraftField::Name,
DraftField::BranchNew,
DraftField::BranchMode,
] {
draft.focused_field = field;
assert_eq!(
draft.on_input(&InputKey::Enter),
DraftInputResult::Submit,
"{field:?} should submit on Enter"
);
}
draft.selected_preset = draft.presets.len() - 1; // custom
draft.focused_field = DraftField::CustomCommand;
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
}
#[test]
fn enter_submits_immediately_from_eager_apply_pickers_97() {
// WorktreeChoice and BranchExisting apply their highlight the moment
// Up/Down moves it (#96 rule 3/#54) — there is no separate "chosen
// vs highlighted" state left to confirm, so Enter always submits.
let mut draft = DraftPane::default();
draft.set_projects(
vec![repo_project(
"maestro",
"/repos/maestro",
"/repos/maestro",
1,
None,
)],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter); // picks "maestro", reveals step two
draft.focused_field = DraftField::WorktreeChoice;
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
draft.focused_field = DraftField::BranchExisting;
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
}
#[test]
fn dir_picker_enter_chooses_then_submits_once_the_highlight_is_the_active_pick_97() {
let mut draft = DraftPane::default();
draft.set_projects(
vec![
project("maestro", "/repos/maestro", 1, Some("claude")),
project("other", "/repos/other", 2, Some("zsh")),
],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter); // picks "maestro" (nothing chosen yet, submits)
assert_eq!(
draft.picked_cwd.as_deref(),
Some(Path::new("/repos/maestro"))
);
// Arrow onto the other row: an explicit navigation away from the
// active pick is "mid-selection" — Enter here only chooses it.
draft.move_selection(1);
assert_eq!(
draft.on_input(&InputKey::Enter),
DraftInputResult::Consumed,
"navigating to a different row must not submit the old pick"
);
assert_eq!(
draft.picked_cwd.as_deref(),
Some(Path::new("/repos/other")),
"the new row is chosen"
);
// The highlight is now the active pick: the next Enter submits.
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
}
#[test]
fn dir_picker_enter_on_other_directory_never_submits_directly_97() {
let mut draft = DraftPane::default();
draft.set_projects(
vec![project("maestro", "/repos/maestro", 1, None)],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
draft.move_selection(1); // onto "Other directory…"
assert_eq!(
draft.on_input(&InputKey::Enter),
DraftInputResult::Consumed,
"reveals the raw field instead of submitting, like the web form's own button"
);
assert_eq!(draft.focused_field, DraftField::CwdRaw);
}
#[test]
fn up_down_moves_the_highlight_within_the_list() {
let mut draft = DraftPane::default();
@@ -2229,7 +2437,10 @@ mod tests {
#[test]
fn held_branches_render_unavailable_with_their_holder_as_the_reason() {
let mut draft = DraftPane::default();
let mut draft = DraftPane {
advanced_collapsed: false,
..DraftPane::default()
};
draft.set_projects(
vec![repo_project(
"maestro",
@@ -2443,6 +2654,7 @@ mod tests {
fn render_shows_the_worktree_section() {
let mut draft = DraftPane {
home_dir: "/home/u".into(),
advanced_collapsed: false,
..DraftPane::default()
};
draft.set_projects(
@@ -2467,10 +2679,25 @@ mod tests {
draft.base_repo = Some(PathBuf::from("/home/u/tea/maestro"));
draft.probed_cwd = Some(PathBuf::from("/home/u/tea/maestro"));
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
for s in ["[new] [existing]", "br:", "crimson-otter"] {
for s in ["[new] [existing]", "new:", "crimson-otter"] {
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
}
// The new-branch input and existing-branch picker must not share a
// label — matching the web form's "New branch name" / "Pick a
// branch" (#94).
draft.set_branch_mode(WorktreeBranchMode::Existing);
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(
rendered.contains("pick:"),
"missing pick: label:\n{rendered}"
);
assert!(
!rendered.contains("br:"),
"stale ambiguous label:\n{rendered}"
);
draft.set_branch_mode(WorktreeBranchMode::New);
draft.focused_field = DraftField::WorktreeToggle;
draft.on_input(&InputKey::Enter);
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
@@ -2481,6 +2708,85 @@ mod tests {
);
}
#[test]
fn advanced_section_is_collapsed_by_default_and_states_the_worktree_default() {
let mut draft = DraftPane {
home_dir: "/home/u".into(),
..DraftPane::default()
};
draft.set_projects(
vec![project("maestro", "/home/u/tea/maestro", 1, Some("claude"))],
Utc::now(),
);
assert!(draft.advanced_collapsed, "closed by default");
assert_eq!(
draft
.field_order()
.iter()
.filter(|f| matches!(f, DraftField::WorktreeToggle | DraftField::BranchMode))
.count(),
0,
"collapsed fields drop out of tab order"
);
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(
rendered.contains("Advanced options") && rendered.contains("creates a worktree"),
"header states the on-by-default worktree:\n{rendered}"
);
assert!(
!rendered.contains("[x] worktree"),
"checkbox stays hidden while collapsed:\n{rendered}"
);
}
#[test]
fn advanced_header_states_no_worktree_for_a_worktree_row_91() {
let mut draft = DraftPane::default();
let mut wt = repo_project("wt-91", "/repos/wt91", "/repos/main", 1, None);
wt.is_worktree = true;
draft.set_projects(vec![wt], Utc::now());
draft.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter);
assert!(!draft.worktree_enabled);
assert!(
draft.advanced_collapsed,
"picking a row must not force it open"
);
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(
rendered.contains("no worktree"),
"header reflects #91's off default:\n{rendered}"
);
}
#[test]
fn enter_on_the_advanced_header_expands_it_without_moving_fields_above() {
let mut draft = DraftPane {
home_dir: "/home/u".into(),
focused_field: DraftField::Advanced,
..DraftPane::default()
};
draft.set_projects(
vec![project("maestro", "/home/u/tea/maestro", 1, Some("claude"))],
Utc::now(),
);
let before = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
let before_head: Vec<&str> = before.lines().take(6).collect();
draft.on_input(&InputKey::Enter);
assert!(!draft.advanced_collapsed);
let after = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
let after_head: Vec<&str> = after.lines().take(6).collect();
assert_eq!(
before_head, after_head,
"expanding must not move the fields above the disclosure"
);
assert!(after.contains("[x] worktree"), "body now visible:\n{after}");
}
#[test]
fn abbreviate_path_matches_the_web_helpers() {
assert_eq!(
+2
View File
@@ -105,6 +105,7 @@ mod tests {
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
}
@@ -228,6 +229,7 @@ mod tests {
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
+5 -1
View File
@@ -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) => {
+51
View File
@@ -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
View File
@@ -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);
+88 -22
View File
@@ -152,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>
@@ -200,6 +205,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
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;
@@ -526,9 +534,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
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;
@@ -539,11 +549,15 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
if (match) selectPreset(match.id);
}
/** Groups with at least one member (itself or a worktree) matching `query`. */
function visibleGroups(query: string): ProjectGroup[] {
return groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0);
}
function renderProjects(): void {
projectsEl.replaceChildren();
groups = groupProjects(sortProjects(discovered, new Date()));
const query = filterInput.value;
const visible = groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0);
const visible = visibleGroups(filterInput.value);
for (const group of visible) projectsEl.appendChild(projectBox(group));
if (discovered.length > 0 && visible.length === 0) {
const empty = document.createElement("p");
@@ -661,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
@@ -741,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;
@@ -814,6 +841,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
branchSelect.addEventListener("change", () => emit());
wtEnabled.addEventListener("change", () => {
updateWtSummary();
emit();
if (wtEnabled.checked) {
resolveBaseRepo();
@@ -827,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);
@@ -837,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();
+258
View File
@@ -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);
});
});
});
+68
View File
@@ -180,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) });
+21 -6
View File
@@ -1284,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 {
+56
View File
@@ -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
View File
@@ -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
View File
@@ -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 {