Compare commits

..
Author SHA1 Message Date
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 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
6 changed files with 760 additions and 80 deletions
+1 -3
View File
@@ -1349,9 +1349,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() {
+339 -49
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)
@@ -1677,7 +1773,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 +1789,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 +2436,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 +2653,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(
@@ -2481,6 +2692,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!(
+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">
Branch
<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>
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);
});
});
});
+53
View File
@@ -180,6 +180,59 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
expect(modes.at(-1)).toBe("existing");
});
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 {