Compare commits

..
Author SHA1 Message Date
Miguel Palhas b646cc5adf Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/96-tui-picker 2026-08-24 15:25:29 +01:00
Miguel Palhas cfc125144f feat(maestro): two-step worktree picker in the TUI draft
Mirrors the web project-picker redesign (#95) in the TUI (#96): step one
lists one box per repository (name, full path); picking one reveals step
two — "+ new" then that repository's worktrees newest-first, defaulting
to "+ new" except on a box that is itself a linked worktree. "+ new"
creates (cwd is the repo, create-worktree on); an existing worktree runs
in it (cwd is that worktree, create-worktree off). Neither step probes
/api/gitroot — only the free-text "Other directory…" hatch does.

Replaces the flat indented project+worktree list from #91/#64 with
projects::group_projects-backed DraftGroup boxes; the filter still
matches a repository via its own name/path or any of its worktrees.
2026-08-24 15:25:27 +01:00
Miguel Palhas d3098a9400 merge: build flake packages in CI so a stale pnpmDeps hash fails loudly
Closes #98
2026-08-24 15:16:30 +01:00
Miguel Palhas 36cb03961c ci(maestro): build flake packages so a stale pnpmDeps hash fails loudly
A web/pnpm-lock.yaml change invalidates the pinned pnpmDeps.hash in
flake.nix silently; nix build .#maestro-web then stays broken until
someone notices by hand (#98). Add a flake job that installs nix and
builds the packaged outputs on every CI run.
2026-08-24 15:14:43 +01:00
Miguel Palhas 5e34ce7a61 merge: pick a project, then its worktree
Closes #95
2026-08-24 15:10:27 +01:00
Miguel Palhas 1fa9ce798e Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/95-project-picker
# Conflicts:
#	web/src/spawnFormWorktree.test.ts
2026-08-24 15:09:05 +01:00
Miguel Palhas 2c89902829 feat(maestro): pick a project, then its worktree
The working-directory picker was a flat list mixing repositories with
their worktrees, each row repeating the project name as an abbreviated
path. It is now two steps: a grid of project boxes carrying the full
unabbreviated path, then — only once a project is picked — that
repository's worktrees with "+ new" as the default.

"+ new" creates a worktree; picking an existing one runs in it and
leaves the create-worktree toggle off, since the daemon never adopts
(docs/WORKTREES.md). That is #91's rule, now an explicit choice rather
than an inference from which row was clicked.

Also fixes `.raw-wrap[hidden]`: `display: flex` outranked the UA sheet's
`[hidden]`, so the free-text cwd input showed even when hidden.

The interaction rules are listed in spawnForm.ts for the TUI mirror (#96)
to follow.

Refs #95
2026-08-24 15:08:05 +01:00
Miguel Palhas 8949fdfdf6 merge: debounce the gitroot probe in both clients
Closes #74
2026-08-24 15:06:50 +01:00
Miguel Palhas 9b0e9751ec merge: stop a late /api/info resolution from clobbering a picked preset
Closes #76
2026-08-24 14:58:53 +01:00
Miguel Palhas d1ed4f1fdc Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/76-preset-reset 2026-08-24 14:58:19 +01:00
Miguel Palhas 2c8758ed14 fix(maestro): stop a late /api/info resolution from clobbering a picked preset
A tile clicked, or a custom command typed, during the /api/info round trip
was discarded: applyInitialCommand() ran unconditionally a second time once
presets refined, re-selecting the first non-custom preset with no seed to
protect the operator's pick. Guard it with a commandTouched flag, mirroring
the nameTouched pattern already used for the name-batch race.

Fixes #76.
2026-08-24 14:58:07 +01:00
10 changed files with 1207 additions and 243 deletions
+21
View File
@@ -97,3 +97,24 @@ jobs:
- name: build
run: pnpm -C web build
# The flake pins pnpmDeps.hash against web/pnpm-lock.yaml (#98). A lockfile
# change invalidates the pin silently elsewhere, so the packaged build has to
# fail here too.
flake:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install nix
run: |
curl -fsSL https://nixos.org/nix/install | sh -s -- --daemon --yes
echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH"
- name: build flake packages
run: |
nix build \
.#webFrontend \
.#maestro \
.#maestro-web \
--no-link
+2 -2
View File
@@ -1553,8 +1553,8 @@ mod tests {
panic!("expected a draft pane");
};
assert_eq!(draft.command(), "aider");
assert_eq!(draft.rows.len(), 1);
assert_eq!(draft.rows[0].project.name, "maestro");
assert_eq!(draft.groups.len(), 1);
assert_eq!(draft.groups[0].main.name, "maestro");
assert_eq!(draft.home_dir, "/home/u");
assert_eq!(draft.name.value(), "crimson-otter");
}
+474 -100
View File
@@ -7,13 +7,31 @@
//!
//! Field groups (#64), mirroring the web form (`web/src/spawnForm.ts`):
//! command presets from `/api/info` (with an implicit `custom` free-text
//! entry), a project picker fed by [`crate::projects`] over `/api/projects`
//! plus an `Other directory…` escape hatch, a generated session name from
//! entry), a two-step project/worktree picker (#96, mirroring the web
//! redesign in #95) fed by [`crate::projects`] over `/api/projects` plus an
//! `Other directory…` escape hatch, a generated session name from
//! `/api/names` with a reroll key, and validation that reports which field is
//! missing instead of posting an empty request. A worktree section (#65)
//! mirrors `web/src/spawnForm.ts`: on by default over a git repository,
//! branch tracking the generated name until touched, with a new-or-existing
//! branch switch fed by `/api/branches`.
//!
//! The picker itself is two steps (#96), verbatim from the rules in
//! `web/src/spawnForm.ts`'s "working-directory picker (#95)" comment:
//! 1. Step one is one box per repository (name, then full path). A worktree
//! is a box only when its main checkout is missing from the scan.
//! 2. The filter keeps a repository when the query matches it or any of its
//! worktrees, so a worktree name still finds its box.
//! 3. Picking a box reveals step two: "+ new", then that repository's
//! worktrees newest-first. "Other directory…" clears both steps.
//! 4. "+ new" is the default selection — except on a box that is itself a
//! linked worktree, which defaults to that worktree.
//! 5. "+ new" means create: cwd is the repository, create-worktree on.
//! An existing worktree means run in it: cwd is that worktree,
//! create-worktree OFF. The daemon never adopts (docs/WORKTREES.md), so
//! these are separate acts.
//! 6. Neither step probes `/api/gitroot`; the scan already reports
//! repository membership. Only the free-text hatch pays for a probe.
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
@@ -52,10 +70,15 @@ pub enum DraftField {
Preset,
/// Free-text command, reachable only while a custom preset is selected.
CustomCommand,
/// Project picker: typing filters, Up / Down highlight, Enter picks.
/// Step one: repository picker. Typing filters, Up / Down highlight,
/// Enter picks and reveals step two (#96).
DirPicker,
/// Free-text path, revealed by picking `Other directory…`.
CwdRaw,
/// Step two, shown once a repository is picked: "+ new" then that
/// repository's worktrees newest-first (#96 rule 3). Up / Down move and
/// apply immediately, mirroring the web form's per-click choice.
WorktreeChoice,
Name,
/// Worktree on/off for this session (#65). Enter / Space toggles.
WorktreeToggle,
@@ -78,14 +101,50 @@ pub enum WorktreeBranchMode {
Existing,
}
/// One flattened project-picker entry: a discovered repo/worktree plus what
/// rendering needs to know about it. `indent` marks worktrees nested under
/// their main checkout.
/// "new" (create a worktree) or an existing worktree to run in — the TUI
/// mirror of the web form's `WorktreeChoice` (#96 rule 5).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DraftRow {
pub enum WorktreeChoice {
New,
Existing(PathBuf),
}
/// One repository box for step one of the picker (#96 rule 1): the main
/// checkout (or, when it's missing from the scan, its freshest worktree
/// standing in for it) plus every other worktree of the same repository.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DraftGroup {
pub main: Project,
pub main_stale: bool,
pub worktrees: Vec<DraftWorktreeRow>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DraftWorktreeRow {
pub project: Project,
pub stale: bool,
pub indent: bool,
}
/// The repository key a [`DraftGroup`] was formed under — same key
/// `projects::group_projects` groups by, kept separately because the main
/// box's own path only equals it when the main checkout itself was found.
fn group_key(main: &Project) -> PathBuf {
main.base_repo.clone().unwrap_or_else(|| main.path.clone())
}
/// Every worktree of a group, including the group's own box when the scan
/// found no main checkout to nest it under — the TUI mirror of the web
/// form's `worktreesOf`. Already newest-first: when the main box stands in
/// for a missing main checkout it is the freshest member by construction
/// ([`projects::group_projects`]), and `worktrees` itself is sorted
/// newest-first.
fn worktree_rows_of(group: &DraftGroup) -> Vec<Project> {
let mut rows = Vec::new();
if group.main.is_worktree {
rows.push(group.main.clone());
}
rows.extend(group.worktrees.iter().map(|w| w.project.clone()));
rows
}
/// Why a submit was refused. The message names the missing field so the form
@@ -130,15 +189,23 @@ pub struct DraftPane {
pub presets: Vec<SpawnPreset>,
pub selected_preset: usize,
pub custom_command: crate::text_input::TextInput,
/// Discovered projects, sorted fresh-first with worktrees nested.
pub rows: Vec<DraftRow>,
/// Discovered projects, sorted fresh-first and grouped by repository
/// (#96 step one).
pub groups: Vec<DraftGroup>,
pub filter: crate::text_input::TextInput,
/// Highlighted entry within the visible list; the implicit
/// `Other directory…` entry sits at index `visible_rows().len()`.
/// Highlighted entry within the visible step-one list; the implicit
/// `Other directory…` entry sits at index `visible_groups().len()`.
pub highlighted: usize,
pub cwd_raw_visible: bool,
pub cwd_raw: crate::text_input::TextInput,
pub picked_cwd: Option<PathBuf>,
/// The repository picked in step one ([`group_key`] of its main box),
/// `None` until a box is picked. Looked up fresh in `groups` rather than
/// cached, so a project-list refresh can't leave it stale.
pub selected_group_key: Option<PathBuf>,
/// Step two's current pick (#96 rule 4/5). Meaningless while
/// `selected_group_key` is `None`.
pub worktree_choice: WorktreeChoice,
pub name: crate::text_input::TextInput,
pub name_queue: VecDeque<String>,
/// Set once the operator types or rerolls a name; guards refills against
@@ -193,12 +260,14 @@ impl Default for DraftPane {
presets,
selected_preset: 0,
custom_command: crate::text_input::TextInput::default(),
rows: Vec::new(),
groups: Vec::new(),
filter: crate::text_input::TextInput::default(),
highlighted: 0,
cwd_raw_visible: false,
cwd_raw: crate::text_input::TextInput::default(),
picked_cwd: None,
selected_group_key: None,
worktree_choice: WorktreeChoice::New,
name: crate::text_input::TextInput::default(),
name_queue: VecDeque::new(),
name_touched: false,
@@ -240,24 +309,24 @@ impl DraftPane {
.unwrap_or(0);
}
/// Replace the project snapshot: sort fresh-first (#54), nest worktrees
/// under their main checkout, and precompute staleness for rendering.
/// Replace the project snapshot: sort fresh-first (#54), group by
/// repository (#96), and precompute staleness for rendering.
pub fn set_projects(&mut self, discovered: Vec<Project>, now: DateTime<Utc>) {
let sorted = projects::sort_projects(discovered, now);
let groups = projects::group_projects(&sorted);
self.rows = groups
self.groups = groups
.iter()
.flat_map(|g| {
std::iter::once(DraftRow {
stale: projects::is_stale(g.main, now),
indent: false,
project: g.main.clone(),
})
.chain(g.worktrees.iter().map(|wt| DraftRow {
stale: projects::is_stale(wt, now),
indent: true,
project: (*wt).clone(),
}))
.map(|g| DraftGroup {
main: g.main.clone(),
main_stale: projects::is_stale(g.main, now),
worktrees: g
.worktrees
.iter()
.map(|wt| DraftWorktreeRow {
project: (*wt).clone(),
stale: projects::is_stale(wt, now),
})
.collect(),
})
.collect();
self.highlighted = self.highlighted.min(self.entry_count().saturating_sub(1));
@@ -600,6 +669,8 @@ impl DraftPane {
order.push(DraftField::DirPicker);
if self.cwd_raw_visible {
order.push(DraftField::CwdRaw);
} else if self.selected_group_key.is_some() {
order.push(DraftField::WorktreeChoice);
}
order.push(DraftField::Name);
order.push(DraftField::WorktreeToggle);
@@ -641,18 +712,25 @@ impl DraftPane {
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
}
/// Rows surviving the type-to-filter, in display order.
pub fn visible_rows(&self) -> Vec<&DraftRow> {
/// Step-one boxes surviving the type-to-filter, in display order. A
/// group survives when the query matches its box or any of its
/// worktrees (#96 rule 2), so a worktree name still finds its box.
pub fn visible_groups(&self) -> Vec<&DraftGroup> {
let query = self.filter.value();
self.rows
self.groups
.iter()
.filter(|r| projects::matches_filter(&r.project, query))
.filter(|g| {
projects::matches_filter(&g.main, query)
|| g.worktrees
.iter()
.any(|w| projects::matches_filter(&w.project, query))
})
.collect()
}
/// Visible entries including the trailing implicit `Other directory…`.
pub fn entry_count(&self) -> usize {
self.visible_rows().len() + 1
self.visible_groups().len() + 1
}
/// Highlight clamped to the current entry count.
@@ -668,35 +746,47 @@ impl DraftPane {
self.highlighted = ((self.selection() as i32) + delta).rem_euclid(count) as usize;
}
/// Act on the highlighted picker entry: a project becomes the working
/// directory (and pre-selects the command it was last used with), while
/// the trailing entry reveals the free-text path input.
/// The step-one group currently picked, looked up fresh against the
/// latest snapshot so a project-list refresh can't leave a stale clone
/// behind.
pub fn selected_group(&self) -> Option<DraftGroup> {
let key = self.selected_group_key.as_ref()?;
self.groups
.iter()
.find(|g| &group_key(&g.main) == key)
.cloned()
}
/// Step two's rows: "+ new" first, then the repository's worktrees
/// newest-first (#96 rule 3). Empty while no group is picked.
pub fn worktree_choice_rows(&self) -> Vec<(WorktreeChoice, String, String)> {
let Some(group) = self.selected_group() else {
return Vec::new();
};
let mut rows = vec![(
WorktreeChoice::New,
"+ new".to_string(),
format!("new worktree of {}", group.main.name),
)];
rows.extend(worktree_rows_of(&group).into_iter().map(|p| {
(
WorktreeChoice::Existing(p.path.clone()),
p.name.clone(),
p.path.to_string_lossy().into_owned(),
)
}));
rows
}
/// 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).
pub fn select_entry(&mut self) {
let visible = self.visible_rows();
let visible = self.visible_groups();
let sel = self.selection();
if sel < visible.len() {
let project = visible[sel].project.clone();
self.picked_cwd = Some(project.path.clone());
self.cwd_raw_visible = false;
// A directory that *is* a linked worktree defaults to spawning in
// place (#91): creating a second worktree of its branch fails.
// Turning it back on by hand still tries (git's own message),
// which is deliberate (#91 non-goal).
self.worktree_enabled = !project.is_worktree;
// The scan already knows this directory's repo (#61): no probe
// round trip needed, exactly like the web form.
let repo = project
.base_repo
.clone()
.unwrap_or_else(|| project.path.clone());
if let Some(cmd) = project.last_command.as_deref() {
if !cmd.is_empty() {
if let Some(idx) = self.presets.iter().position(|p| p.command == cmd) {
self.selected_preset = idx;
}
}
}
self.note_picked_repo(repo);
let group = visible[sel].clone();
self.select_group(&group);
return;
}
let seed = self.cwd().unwrap_or_else(|| {
@@ -706,6 +796,7 @@ impl DraftPane {
PathBuf::from(&self.home_dir)
}
});
self.clear_project_selection();
self.picked_cwd = None;
self.cwd_raw_visible = true;
self.cwd_raw = crate::text_input::TextInput::with_value(seed.to_string_lossy());
@@ -713,6 +804,95 @@ impl DraftPane {
self.note_unknown_repo();
}
/// Pick a step-one box: remember it and default step two (#96 rule 4) —
/// "+ new", except a box that is itself a linked worktree (the scan
/// found no main checkout for it), which defaults to that worktree.
fn select_group(&mut self, group: &DraftGroup) {
self.selected_group_key = Some(group_key(&group.main));
let default = if group.main.is_worktree {
WorktreeChoice::Existing(group.main.path.clone())
} else {
WorktreeChoice::New
};
self.apply_worktree_choice(default);
}
/// Clear both picker steps (#96 rule 3), e.g. for `Other directory…`.
fn clear_project_selection(&mut self) {
self.selected_group_key = None;
self.worktree_choice = WorktreeChoice::New;
}
/// Apply a step-two pick (#96 rule 5): "+ new" targets the repository
/// itself with worktree creation on; an existing worktree targets it
/// directly with worktree creation off — the daemon never adopts
/// (docs/WORKTREES.md). Neither path probes `/api/gitroot` (rule 6): the
/// scan already reported this target's repository.
pub fn apply_worktree_choice(&mut self, choice: WorktreeChoice) {
let Some(group) = self.selected_group() else {
return;
};
let target = match &choice {
WorktreeChoice::New => group.main.clone(),
WorktreeChoice::Existing(path) => {
match worktree_rows_of(&group)
.into_iter()
.find(|p| &p.path == path)
{
Some(p) => p,
None => return,
}
}
};
self.worktree_choice = choice.clone();
self.picked_cwd = Some(target.path.clone());
self.cwd_raw_visible = false;
if let Some(cmd) = target.last_command.as_deref() {
if !cmd.is_empty() {
if let Some(idx) = self.presets.iter().position(|p| p.command == cmd) {
self.selected_preset = idx;
}
}
}
match choice {
WorktreeChoice::New => {
self.worktree_enabled = true;
let repo = target
.base_repo
.clone()
.unwrap_or_else(|| target.path.clone());
self.note_picked_repo(repo);
}
WorktreeChoice::Existing(_) => {
self.worktree_enabled = false;
self.base_repo = None;
self.repo_missing = false;
self.probe_seq += 1;
self.needs_probe = false;
self.probe_due_at = None;
self.probed_cwd = self.cwd();
self.branches_for = None;
}
}
}
/// Move step two's highlight and apply the newly-highlighted choice at
/// once, matching the web form's per-click pick — there is no separate
/// confirm step.
pub fn move_worktree_choice(&mut self, delta: i32) {
let rows = self.worktree_choice_rows();
let count = rows.len() as i32;
if count == 0 {
return;
}
let cur = rows
.iter()
.position(|(c, _, _)| *c == self.worktree_choice)
.unwrap_or(0) as i32;
let next = ((cur + delta).rem_euclid(count)) as usize;
self.apply_worktree_choice(rows[next].0.clone());
}
/// Handle one keystroke while this draft owns input (Insert mode, pane
/// focused). Returns [`DraftInputResult::Submit`] when the user accepts;
/// the caller validates, fires the POST and replaces the draft with the
@@ -760,6 +940,14 @@ impl DraftPane {
self.move_selection(1);
DraftInputResult::Consumed
}
InputKey::Up if self.focused_field == DraftField::WorktreeChoice => {
self.move_worktree_choice(-1);
DraftInputResult::Consumed
}
InputKey::Down if self.focused_field == DraftField::WorktreeChoice => {
self.move_worktree_choice(1);
DraftInputResult::Consumed
}
InputKey::Left if self.focused_field == DraftField::Preset => {
self.cycle_preset(-1);
DraftInputResult::Consumed
@@ -832,6 +1020,15 @@ impl DraftPane {
let show_custom = self.is_custom();
let show_raw = self.cwd_raw_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()
} else {
Vec::new()
};
// 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.
@@ -839,6 +1036,7 @@ impl DraftPane {
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* worktree */ + 1 /* spawn */
+ u16::from(show_custom)
+ u16::from(show_raw)
+ 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());
let list_capacity = (inner.height.saturating_sub(fixed)).max(1) as usize;
@@ -851,6 +1049,8 @@ impl DraftPane {
constraints.push(Constraint::Length(list_capacity as u16));
if show_raw {
constraints.push(Constraint::Length(1));
} else if show_wt_choice {
constraints.push(Constraint::Length(wt_choice_capacity));
}
constraints.push(Constraint::Length(1)); // name
constraints.push(Constraint::Length(1)); // worktree toggle
@@ -927,7 +1127,10 @@ impl DraftPane {
cursor = Some((col, filter_row.y));
}
let visible = self.visible_rows();
// Step one (#96 rule 1): one box per repository, name then the FULL
// path — not the abbreviated form other lists use — so two projects
// sharing a basename stay distinguishable.
let visible = self.visible_groups();
let sel = self.selection();
let count = visible.len() + 1;
let capacity = list_capacity.min(count);
@@ -940,7 +1143,7 @@ impl DraftPane {
.iter()
.take(start + capacity)
.skip(start)
.map(|row| row.project.name.chars().count() + usize::from(row.indent) * 2)
.map(|group| group.main.name.chars().count())
.max()
.unwrap_or(0)
.min(18);
@@ -953,31 +1156,19 @@ impl DraftPane {
}
let active = pane_focused && self.focused_field == DraftField::DirPicker && i == sel;
let line = if i < visible.len() {
let row = visible[i];
let group = visible[i];
let mut base = Style::default();
if row.stale {
if group.main_stale {
base = base.add_modifier(Modifier::DIM);
}
if active {
base = base.add_modifier(Modifier::REVERSED);
}
let name = if row.indent {
format!(" {}", row.project.name)
} else {
row.project.name.clone()
};
Line::from(vec![
Span::styled(if active { ">" } else { " " }, base),
Span::raw(" "),
Span::styled(format!("{name:<name_width$}"), base),
Span::styled(
format!(" {}", abbreviate_path(&row.project.path, &self.home_dir)),
base,
),
Span::styled(
format!(" {}", row.project.last_command.clone().unwrap_or_default()),
base,
),
Span::styled(format!("{:<name_width$}", group.main.name), base),
Span::styled(format!(" {}", group.main.path.display()), base),
])
} else {
let mut style = Style::default();
@@ -1005,6 +1196,53 @@ impl DraftPane {
if self.focused_field == DraftField::CwdRaw {
cursor = Some((col, row.y));
}
} else if show_wt_choice {
// Step two (#96 rule 3): "+ new" first, then the repository's
// worktrees newest-first.
let choice_rows = wt_choice_rows_data;
let csel = choice_rows
.iter()
.position(|(c, _, _)| *c == self.worktree_choice)
.unwrap_or(0);
let ccap = (wt_choice_capacity as usize).min(choice_rows.len().max(1));
let cstart = if csel >= ccap { csel + 1 - ccap } else { 0 };
let label_width = choice_rows
.iter()
.skip(cstart)
.take(ccap)
.map(|(_, label, _)| label.chars().count())
.max()
.unwrap_or(0)
.min(18);
let choice_row = next_row();
for i in cstart..cstart + ccap {
let y = choice_row.y + (i - cstart) as u16;
if y >= choice_row.y + choice_row.height || i >= choice_rows.len() {
break;
}
let (_, label, sub) = &choice_rows[i];
let active =
pane_focused && self.focused_field == DraftField::WorktreeChoice && i == csel;
let mut base = Style::default();
if active {
base = base.add_modifier(Modifier::REVERSED);
}
let line = Line::from(vec![
Span::styled(if active { ">" } else { " " }, base),
Span::raw(" "),
Span::styled(format!("{label:<label_width$}"), base),
Span::styled(format!(" {sub}"), base),
]);
f.render_widget(
Paragraph::new(line),
Rect {
x: choice_row.x + GUTTER as u16,
y,
width: choice_row.width.saturating_sub(GUTTER as u16),
height: 1,
},
);
}
}
// -- name + reroll ---------------------------------------------------------
@@ -1405,12 +1643,8 @@ mod tests {
let stale_pos = rendered.find("stale-repo").unwrap();
assert!(fresh_pos < stale_pos, "fresh before stale:\n{rendered}");
assert!(
rendered.contains("~/n/repo"),
"collapsed path stem:\n{rendered}"
);
assert!(
rendered.contains("claude"),
"last command shown:\n{rendered}"
rendered.contains("/home/u/new/repo"),
"the full path, not an abbreviation (#96 rule 1):\n{rendered}"
);
// Staleness dims the row's cells.
@@ -1439,7 +1673,7 @@ mod tests {
assert_eq!(draft.focused_field, DraftField::DirPicker);
typed(&mut draft, "maes");
assert_eq!(
draft.visible_rows().len(),
draft.visible_groups().len(),
1,
"type-to-filter narrows the list"
);
@@ -1723,38 +1957,139 @@ mod tests {
assert!(wt.create_branch);
}
#[test]
fn picking_a_worktree_project_defaults_the_toggle_off() {
let mut draft = DraftPane::default();
let mut wt = repo_project("wt-91", "/repos/wt91", "/repos/main", 1, None);
/// A worktree of `group.main` (matching a solo worktree, is_worktree true).
fn linked_worktree(name: &str, path: &str, base: &str, days_ago: i64) -> Project {
let mut wt = repo_project(name, path, base, days_ago, None);
wt.is_worktree = true;
wt
}
#[test]
fn a_query_matching_only_a_worktree_still_finds_its_repository_box() {
// #96 rule 2: the filter keeps a repository when the query matches
// it or any of its worktrees.
let mut draft = DraftPane::default();
draft.set_projects(
vec![
repo_project("main", "/repos/main", "/repos/main", 2, None),
wt,
linked_worktree("wt-91", "/repos/wt91", "/repos/main", 1),
],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
typed(&mut draft, "wt-91");
draft.on_input(&InputKey::Enter);
assert!(
!draft.worktree_enabled,
"the directory is already a worktree; spawning in place"
assert_eq!(
draft.visible_groups().len(),
1,
"the worktree's name matches its repository's box"
);
assert_eq!(draft.visible_groups()[0].main.name, "main");
}
#[test]
fn picking_a_repository_box_reveals_step_two_defaulting_to_new() {
// #96 rule 3 (reveal) + rule 4 (default): a repository box that is
// itself a real main checkout defaults step two to "+ new".
let mut draft = DraftPane::default();
draft.set_projects(
vec![
repo_project("main", "/repos/main", "/repos/main", 2, None),
linked_worktree("wt-91", "/repos/wt91", "/repos/main", 1),
],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter); // picks the only box: "main"
assert_eq!(draft.worktree_choice, WorktreeChoice::New);
assert_eq!(draft.picked_cwd.as_deref(), Some(Path::new("/repos/main")));
assert!(draft.worktree_enabled, "\"+ new\" means create-worktree on");
assert!(
draft.field_order().contains(&DraftField::WorktreeChoice),
"step two is now reachable"
);
assert!(
draft.take_gitroot_probe(probe_due()).is_none(),
"rule 6: the scan already knows this repository"
);
// Step two: move onto the worktree and pick it (rule 5 — existing
// worktree means run in it, create-worktree off).
draft.focused_field = DraftField::WorktreeChoice;
draft.on_input(&InputKey::Down);
assert_eq!(
draft.worktree_choice,
WorktreeChoice::Existing(PathBuf::from("/repos/wt91"))
);
assert_eq!(draft.picked_cwd.as_deref(), Some(Path::new("/repos/wt91")));
assert!(!draft.worktree_enabled);
assert!(
draft.build_request().unwrap().worktree.is_none(),
"no worktree record — the session just runs there (#91)"
"no worktree record — the session just runs there"
);
assert!(
draft.take_gitroot_probe(probe_due()).is_none(),
"rule 6: still no probe for a scan-known worktree"
);
}
// Deliberately re-enabling still works, and the scan-known base repo
// survives so the toggle-on path needs no new probe.
draft.focused_field = DraftField::WorktreeToggle;
#[test]
fn a_box_that_is_itself_a_linked_worktree_defaults_to_running_there() {
// #96 rule 4: when the scan found no main checkout, the box IS a
// worktree, and step two defaults to that worktree instead of
// "+ new".
let mut draft = DraftPane::default();
draft.set_projects(
vec![linked_worktree(
"solo-wt",
"/repos/solo-wt",
"/repos/solo",
1,
)],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter);
assert!(draft.worktree_enabled);
assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/repos/main")));
assert!(draft.take_gitroot_probe(probe_due()).is_none());
assert_eq!(
draft.worktree_choice,
WorktreeChoice::Existing(PathBuf::from("/repos/solo-wt"))
);
assert_eq!(
draft.picked_cwd.as_deref(),
Some(Path::new("/repos/solo-wt"))
);
assert!(!draft.worktree_enabled, "defaults to running in place");
}
#[test]
fn other_directory_clears_both_picker_steps() {
// #96 rule 3.
let mut draft = DraftPane::default();
draft.set_projects(
vec![
repo_project("main", "/repos/main", "/repos/main", 2, None),
linked_worktree("wt-91", "/repos/wt91", "/repos/main", 1),
],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter); // picks "main", reveals step two
assert!(draft.selected_group_key.is_some());
draft.focused_field = DraftField::DirPicker;
draft.move_selection(1); // onto Other directory…
draft.on_input(&InputKey::Enter);
assert!(draft.cwd_raw_visible);
assert!(
draft.selected_group_key.is_none(),
"step one's pick is cleared"
);
assert!(
draft.worktree_choice_rows().is_empty(),
"step two is cleared too"
);
}
#[test]
@@ -2065,6 +2400,45 @@ mod tests {
);
}
#[test]
fn render_shows_step_two_wording_full_paths_and_newest_first_order() {
let mut draft = DraftPane {
home_dir: "/home/u".into(),
..DraftPane::default()
};
draft.set_projects(
vec![
repo_project("main", "/home/u/repos/main", "/home/u/repos/main", 5, None),
linked_worktree("older-wt", "/home/u/repos/older", "/home/u/repos/main", 3),
linked_worktree("newer-wt", "/home/u/repos/newer", "/home/u/repos/main", 1),
],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter); // picks the "main" box
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(
rendered.contains("/home/u/repos/main"),
"step one shows the full repository path:\n{rendered}"
);
assert!(
rendered.contains("+ new"),
"step two's create choice:\n{rendered}"
);
assert!(
rendered.contains("/home/u/repos/newer"),
"step two shows worktrees' full paths:\n{rendered}"
);
let new_pos = rendered.find("+ new").unwrap();
let newer_pos = rendered.find("newer-wt").unwrap();
let older_pos = rendered.find("older-wt").unwrap();
assert!(
new_pos < newer_pos && newer_pos < older_pos,
"\"+ new\" first, then worktrees newest-first:\n{rendered}"
);
}
#[test]
fn render_shows_the_worktree_section() {
let mut draft = DraftPane {
+41 -25
View File
@@ -34,13 +34,13 @@ function stubFetch(info: unknown, projects: Project[]): void {
);
}
function rowPaths(form: HTMLFormElement): string[] {
return [...form.querySelectorAll<HTMLButtonElement>(".project-row:not(.project-other)")].map(
(r) => r.dataset.cwd ?? "",
function boxPaths(form: HTMLFormElement): string[] {
return [...form.querySelectorAll<HTMLButtonElement>(".project-box")].map(
(b) => b.dataset.cwd ?? "",
);
}
describe("spawnForm project picker", () => {
describe("spawnForm project picker (#95: boxes, full paths, mains only)", () => {
let handle: SpawnFormHandle | null = null;
beforeEach(() => {
@@ -65,7 +65,7 @@ describe("spawnForm project picker", () => {
return handle;
}
it("renders one row per project, fresh before stale", async () => {
it("renders one box per project, fresh before stale", async () => {
const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW });
const stale = project({
name: "stale",
@@ -73,40 +73,56 @@ describe("spawnForm project picker", () => {
last_activity: "2026-01-01T00:00:00Z",
});
const form = (await mount([stale, fresh])).el;
expect(rowPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
const rows = [...form.querySelectorAll<HTMLButtonElement>(".project-row")];
expect(rows[1].classList.contains("is-stale")).toBe(true);
expect(rows[0].classList.contains("is-stale")).toBe(false);
expect(boxPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
const boxes = [...form.querySelectorAll<HTMLButtonElement>(".project-box")];
expect(boxes[1].classList.contains("is-stale")).toBe(true);
expect(boxes[0].classList.contains("is-stale")).toBe(false);
});
it("nests worktrees under their base repository", async () => {
it("shows the name as the primary line and the full path unabbreviated", async () => {
const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" });
const form = (await mount([p])).el;
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config");
// Not `~/t/nixos-config`: the abbreviation hid what distinguishes two
// projects sharing a basename (#95).
expect(box.querySelector(".project-path")!.textContent).toBe("/home/user/tea/nixos-config");
});
it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
const main = project({ name: "maestro", path: "/home/user/maestro" });
const wt = project({
name: "wt",
path: "/elsewhere/wt",
path: "/home/user/maestro/worktrees/wt",
is_worktree: true,
base_repo: main.path,
});
const form = (await mount([wt, main])).el;
expect(rowPaths(form)).toEqual(["/home/user/maestro", "/elsewhere/wt"]);
const [mainRow, wtRow] = [
...form.querySelectorAll(".project-row:not(.project-other)"),
];
expect(wtRow.classList.contains("is-worktree")).toBe(true);
expect(mainRow.classList.contains("is-worktree")).toBe(false);
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
expect(form.querySelector(".project-box.is-worktree")).toBeNull();
});
it("filters rows as the operator types", async () => {
it("filters boxes as the operator types, keeping a repo its worktree matched", async () => {
const a = project({ name: "maestro", path: "/home/user/maestro" });
const b = project({ name: "tea", path: "/home/user/work/tea" });
const form = (await mount([a, b])).el;
const wt = project({
name: "bulgarians",
path: "/home/user/maestro/worktrees/bulgarians",
is_worktree: true,
base_repo: a.path,
});
const form = (await mount([a, b, wt])).el;
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
filter.value = "tea";
filter.dispatchEvent(new Event("input"));
expect(rowPaths(form)).toEqual(["/home/user/work/tea"]);
expect(boxPaths(form)).toEqual(["/home/user/work/tea"]);
// A query that only matches a worktree still surfaces its repository box.
filter.value = "bulgarians";
filter.dispatchEvent(new Event("input"));
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
filter.value = "zzz";
filter.dispatchEvent(new Event("input"));
expect(rowPaths(form)).toEqual([]);
expect(boxPaths(form)).toEqual([]);
expect(form.querySelector(".picker-empty")).toBeTruthy();
});
@@ -135,17 +151,17 @@ describe("spawnForm project picker", () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
onChange.mockClear();
const row = form.querySelector<HTMLButtonElement>(".project-row")!;
row.click();
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
box.click();
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
const last = onChange.mock.lastCall![0];
expect(last.cwd).toBe("/home/user/maestro");
expect(last.command).toBe("claude");
expect(values).toBeTruthy();
// The raw input stays hidden — the picked row is the source of truth.
// The raw input stays hidden — the picked box is the source of truth.
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
expect(row.classList.contains("is-active")).toBe(true);
expect(box.classList.contains("is-active")).toBe(true);
});
it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => {
+184 -69
View File
@@ -9,8 +9,8 @@ import {
} from "./api.js";
import { attachDirAutocomplete } from "./dirAutocomplete.js";
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
import type { ProjectGroup } from "./projectList.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
/** Where a worktree's branch comes from: freshly created (default) or picked
* from the repository's existing branches (#61). */
@@ -130,15 +130,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
placeholder="bash -l" autocomplete="off" />
</div>
<div class="field">
<span class="field-label">Working directory</span>
<span class="field-label">Project</span>
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
autocomplete="off" />
<div class="picker projects"></div>
<button type="button" class="project-other">Other directory…</button>
<div class="raw-wrap cwd-raw-wrap" hidden>
<input class="raw-input cwd-raw" name="cwd_raw" type="text"
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
</div>
</div>
<div class="field wt-step" hidden>
<span class="field-label">Worktree</span>
<div class="picker wt-choices"></div>
</div>
<div class="field">
<span class="field-label">Name</span>
<div class="name-row">
@@ -187,8 +192,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const errEl = form.querySelector(".form-error") as HTMLElement;
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
const cmdRaw = form.querySelector(".cmd-raw") as HTMLInputElement;
const recentsEl = form.querySelector(".projects") as HTMLElement;
const projectsEl = form.querySelector(".projects") as HTMLElement;
const filterInput = form.querySelector(".project-filter") as HTMLInputElement;
const otherBtn = form.querySelector(".project-other") as HTMLButtonElement;
const wtStep = form.querySelector(".wt-step") as HTMLElement;
const wtChoices = form.querySelector(".wt-choices") as HTMLElement;
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
@@ -342,6 +350,15 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
emit();
}
/** Re-highlight the operator's already-picked tile after `renderCommandTiles`
* rebuilds the row from scratch (new tiles carry no `is-active` class) —
* without touching `command`, since that was already settled. */
function reapplyActiveTile(): void {
for (const tile of cmdTiles.querySelectorAll(".tile")) {
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId);
}
}
function renderCommandTiles(): void {
cmdTiles.replaceChildren();
for (const p of presets) {
@@ -350,13 +367,23 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
tile.className = "tile";
tile.dataset.id = p.id;
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
tile.addEventListener("click", () => selectPreset(p.id));
tile.addEventListener("click", () => {
commandTouched = true;
selectPreset(p.id);
});
cmdTiles.appendChild(tile);
}
}
// Set once the operator picks a tile or types a custom command — guards
// `applyInitialCommand()` the same way `nameTouched` guards a late name
// batch: a late `/api/info` resolution must never overwrite a field the
// operator has already touched (#76).
let commandTouched = false;
cmdRaw.addEventListener("input", () => {
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
commandTouched = true;
command = cmdRaw.value.trim();
emit();
}
@@ -372,7 +399,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
if (info.presets?.length) {
presets = info.presets;
renderCommandTiles();
applyInitialCommand();
if (commandTouched) {
reapplyActiveTile();
} else {
applyInitialCommand();
}
}
})
.catch(() => {
@@ -399,12 +430,36 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
if (first) selectPreset(first.id, false);
}
// ---- working-directory picker ---------------------------------------
// ---- working-directory picker (#95) ----------------------------------
// Two steps, and the TUI mirror (#96) must match these rules verbatim:
// 1. Step one is one box per repository (name, then full path). A worktree
// is a box only when its main checkout is missing from the scan.
// 2. The filter keeps a repository when the query matches it or any of its
// worktrees, so a worktree name still finds its box.
// 3. Picking a box reveals step two: "+ new", then that repository's
// worktrees newest-first. "Other directory…" clears both steps.
// 4. "+ new" is the default selection — except on a box that is itself a
// linked worktree, which defaults to that worktree.
// 5. "+ new" means create: cwd is the repository, create-worktree on.
// An existing worktree means run in it: cwd is that worktree,
// create-worktree OFF. The daemon never adopts (docs/WORKTREES.md), so
// these are separate acts — #91's rule, now an explicit choice.
// 6. Neither step probes /api/gitroot; the scan already reports repository
// membership. Only the free-text hatch pays for a probe.
/** "new" (create a worktree) or the path of an existing worktree to run in. */
type WorktreeChoice = "new" | string;
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
let groups: ProjectGroup[] = []; // rebuilt on every render
let selectedGroup: ProjectGroup | null = null;
let worktreeChoice: WorktreeChoice = "new";
function showRawCwd(prefillValue?: string, focus = true): void {
cwdRawWrap.hidden = false;
if (prefillValue != null) cwd = prefillValue;
cwdRaw.value = cwd;
markActiveProject(null);
clearProjectSelection();
if (focus) {
cwdRaw.focus();
cwdRaw.select();
@@ -421,62 +476,89 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
if (wtEnabled.checked) scheduleGitRootProbe();
});
function markActiveProject(activeCwd: string | null): void {
for (const row of recentsEl.querySelectorAll(".project-row")) {
row.classList.toggle(
"is-active",
(row as HTMLElement).dataset.cwd === activeCwd,
);
/** Every worktree of a group, including the group's own box when the scan
* found no main checkout to nest it under. */
function worktreesOf(group: ProjectGroup): Project[] {
return [group.main, ...group.worktrees].filter((p) => p.is_worktree);
}
function markActiveBox(): void {
const active = selectedGroup?.main.path ?? null;
for (const box of projectsEl.querySelectorAll(".project-box")) {
box.classList.toggle("is-active", (box as HTMLElement).dataset.cwd === active);
}
}
function selectProject(project: Project): void {
cwd = project.path;
function markActiveChoice(): void {
for (const btn of wtChoices.querySelectorAll(".wt-choice")) {
const el = btn as HTMLElement;
const value = el.dataset.kind === "new" ? "new" : (el.dataset.cwd ?? "");
el.classList.toggle("is-active", value === worktreeChoice);
}
}
function clearProjectSelection(): void {
selectedGroup = null;
worktreeChoice = "new";
wtChoices.replaceChildren();
wtStep.hidden = true;
markActiveBox();
}
function selectProject(group: ProjectGroup): void {
selectedGroup = group;
cwdRawWrap.hidden = true;
markActiveProject(project.path);
// The scan already knows this directory is inside a repo (#61): the
// toggle defaults back on and no /api/gitroot round trip is needed —
// unless the directory *is* a linked worktree itself (#91): spawning
// in place is the only thing that works there, so default the toggle
// off. Turning it back on by hand still tries (and fails with git's
// own message), which is deliberate (#91 non-goal).
wtEnabled.checked = !project.is_worktree;
if (project.is_worktree) {
renderWorktreeChoices(group);
markActiveBox();
// Rule 4: a box that is itself a linked worktree defaults to running there.
applyWorktreeChoice(group.main.is_worktree ? group.main.path : "new");
}
function applyWorktreeChoice(choice: WorktreeChoice): void {
const group = selectedGroup;
if (!group) return;
const target =
choice === "new" ? group.main : worktreesOf(group).find((p) => p.path === choice);
if (!target) return;
worktreeChoice = choice;
cwd = target.path;
branchCache = null;
markActiveChoice();
if (choice === "new") {
wtEnabled.checked = true;
applyProjectRepo(group.main.base_repo ?? group.main.path);
} else {
wtEnabled.checked = false;
inferredBaseRepo = null;
wtBody.hidden = true;
wtHint.hidden = true;
} else {
applyProjectRepo(project.base_repo ?? project.path);
}
emit();
// Picking a project pre-selects the command it was last used with.
const match = presets.find((p) => p.command === project.last_command && p.command !== "");
// The chosen directory pre-selects the command it was last used with.
const match = presets.find((p) => p.command === target.last_command && p.command !== "");
if (match) selectPreset(match.id);
}
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
function renderProjects(): void {
recentsEl.replaceChildren();
const sorted = sortProjects(filterProjects(discovered, filterInput.value), new Date());
for (const group of groupProjects(sorted)) {
recentsEl.appendChild(projectRow(group.main));
for (const wt of group.worktrees) recentsEl.appendChild(projectRow(wt));
}
if (discovered.length > 0 && sorted.length === 0) {
projectsEl.replaceChildren();
groups = groupProjects(sortProjects(discovered, new Date()));
const query = filterInput.value;
const visible = groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0);
for (const group of visible) projectsEl.appendChild(projectBox(group));
if (discovered.length > 0 && visible.length === 0) {
const empty = document.createElement("p");
empty.className = "picker-empty";
empty.textContent = "No projects match.";
recentsEl.appendChild(empty);
projectsEl.appendChild(empty);
}
recentsEl.appendChild(otherDirRow());
markActiveProject(cwdRawWrap.hidden ? cwd : null);
markActiveBox();
}
filterInput.addEventListener("input", renderProjects);
otherBtn.addEventListener("click", () => showRawCwd(cwd || homeDir));
async function loadProjects(): Promise<void> {
recentsEl.replaceChildren(loadingRow());
projectsEl.replaceChildren(loadingRow());
try {
discovered = await fetchProjects();
} catch {
@@ -487,12 +569,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
if (seedCwd) {
// A seed sets the directory verbatim, regardless of what the scan found.
// `cwdRawVisible === false` marks a draft restore whose cwd came from a
// picked row: keep the picker and don't steal focus. Otherwise land
// picked box: keep the picker and don't steal focus. Otherwise land
// focused in the raw input (clone / no-match flows).
if (prefill.cwdRawVisible === false) {
cwd = seedCwd;
cwdRaw.value = seedCwd;
markActiveProject(cwd);
// Restoring must not touch the worktree toggle — the seed carries it.
if (!restoreSelection(seedCwd)) clearProjectSelection();
} else {
showRawCwd(seedCwd, false);
}
@@ -509,34 +592,66 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
emit();
}
function projectRow(project: Project): HTMLElement {
const row = document.createElement("button");
row.type = "button";
row.className = `project-row${project.is_worktree ? " is-worktree" : ""}${
isStale(project, new Date()) ? " is-stale" : ""
}`;
row.dataset.cwd = project.path;
// Abbreviate exactly like the sidebar: home → `~`, then collapse leading
// segments to initials (`~/g/n/maestro`). The full path stays in the tooltip
// and `dataset.cwd` (what's actually submitted).
const display = abbreviateFolderName(abbreviateCwd(project.path, homeDir));
row.innerHTML = `
<span class="project-info">
<span class="project-name">${escapeHtml(project.name)}</span>
<span class="recent-dir" title="${escapeHtml(project.path)}">${display}</span>
</span>
<span class="recent-cmd">${escapeHtml(project.last_command ?? "")}</span>`;
row.addEventListener("click", () => selectProject(project));
return row;
/** Put the two steps back on a restored draft's cwd, without re-running the
* side effects of a fresh pick. */
function restoreSelection(path: string): boolean {
for (const group of groups) {
const wt = worktreesOf(group).find((p) => p.path === path);
const choice: WorktreeChoice | null =
wt ? wt.path : group.main.path === path ? "new" : null;
if (choice === null) continue;
selectedGroup = group;
worktreeChoice = choice;
renderWorktreeChoices(group);
markActiveChoice();
markActiveBox();
return true;
}
return false;
}
function otherDirRow(): HTMLElement {
const row = document.createElement("button");
row.type = "button";
row.className = "project-row project-other";
row.innerHTML = `<span class="project-info"><span class="recent-dir">Other directory…</span></span>`;
row.addEventListener("click", () => showRawCwd(cwd || homeDir));
return row;
function projectBox(group: ProjectGroup): HTMLElement {
const project = group.main;
const box = document.createElement("button");
box.type = "button";
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
box.dataset.cwd = project.path;
const trees = worktreesOf(group).length;
// The full path, not the sidebar's `~/t/n/maestro` abbreviation: it is what
// tells two projects sharing a basename apart (#95).
const meta = [trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "", project.last_command ?? ""]
.filter(Boolean)
.join(" · ");
box.innerHTML = `
<span class="project-name">${escapeHtml(project.name)}</span>
<span class="project-path">${escapeHtml(project.path)}</span>
<span class="project-meta">${escapeHtml(meta)}</span>`;
box.addEventListener("click", () => selectProject(group));
return box;
}
function renderWorktreeChoices(group: ProjectGroup): void {
wtChoices.replaceChildren();
wtChoices.appendChild(
choiceButton("new", "+ new", `new worktree of ${group.main.name}`),
);
for (const wt of worktreesOf(group)) {
wtChoices.appendChild(choiceButton(wt.path, wt.name, wt.path));
}
wtStep.hidden = false;
}
function choiceButton(choice: WorktreeChoice, label: string, sub: string): HTMLElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `wt-choice${choice === "new" ? " wt-choice-new" : ""}`;
btn.dataset.kind = choice === "new" ? "new" : "existing";
if (choice !== "new") btn.dataset.cwd = choice;
btn.innerHTML = `
<span class="wt-choice-name">${escapeHtml(label)}</span>
<span class="wt-choice-sub">${escapeHtml(sub)}</span>`;
btn.addEventListener("click", () => applyWorktreeChoice(choice));
return btn;
}
function loadingRow(): HTMLElement {
+124
View File
@@ -0,0 +1,124 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
/** Stubs fetch so `/api/info` only resolves once `resolveInfo()` is called —
* everything else (names, projects, gitroot) answers immediately with an
* empty/default body. Lets a test pick a preset (or type a custom command)
* mid-flight, then observe what the late `/api/info` resolution does to it. */
function stubDeferredInfo(): { resolveInfo: () => void } {
let releaseInfo: () => void = () => {};
const infoGate = new Promise<void>((resolve) => {
releaseInfo = resolve;
});
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/info")) {
await infoGate;
return new Response(
JSON.stringify({
hostname: "h",
version: "0",
home_dir: "/home/user",
presets: [
{ id: "claude", label: "Claude", command: "claude" },
{ id: "fish", label: "Fish", command: "fish" },
{ id: "custom", label: "Custom…", command: "" },
],
}),
{ status: 200 },
);
}
if (url.includes("/api/names")) {
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
}
return new Response(JSON.stringify([]), { status: 200 });
}),
);
return { resolveInfo: releaseInfo };
}
describe("spawnForm preset selection vs. late /api/info (#76)", () => {
let handle: SpawnFormHandle | null = null;
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
});
async function drain(): Promise<void> {
for (let i = 0; i < 40; i++) await Promise.resolve();
}
it("keeps an operator-picked tile after /api/info resolves late", async () => {
const { resolveInfo } = stubDeferredInfo();
let values: SpawnFormValues | undefined;
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
const form = handle.el;
document.body.appendChild(form);
await drain();
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
(t) => t.dataset.id === "zsh",
)!;
zshTile.click();
expect(zshTile.classList.contains("is-active")).toBe(true);
// /api/info lands late with a preset list that doesn't even include "zsh".
resolveInfo();
await drain();
// The operator's pick must not be discarded back to the new list's first
// entry — `onChange` only starts firing once the initial load settles
// (`ready`), which happens right here, so this is the first place the
// published command is observable.
expect(values?.command).toBe("zsh");
});
it("keeps an operator-typed custom command after /api/info resolves late", async () => {
const { resolveInfo } = stubDeferredInfo();
let values: SpawnFormValues | undefined;
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
const form = handle.el;
document.body.appendChild(form);
await drain();
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
(t) => t.dataset.id === "custom",
)!;
customTile.click();
const cmdRaw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
cmdRaw.value = "htop";
cmdRaw.dispatchEvent(new Event("input"));
resolveInfo();
await drain();
expect(values?.command).toBe("htop");
expect(cmdRaw.value).toBe("htop");
});
it("still applies the real presets' first entry when the operator never touched the command", async () => {
const { resolveInfo } = stubDeferredInfo();
let values: SpawnFormValues | undefined;
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
const form = handle.el;
document.body.appendChild(form);
await drain();
resolveInfo();
await drain();
// Untouched: the refined preset list is still free to settle the command.
expect(values?.command).toBe("claude");
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
(t) => t.dataset.id === "fish",
);
expect(fishTile).toBeDefined();
});
});
+2 -2
View File
@@ -101,7 +101,7 @@ describe("spawnForm gitroot probe debounce (#74)", () => {
it("a keystroke burst fires exactly one probe after the quiet period", async () => {
const { form, stub } = await mount();
form.querySelector<HTMLElement>(".project-row.project-other")!.click();
form.querySelector<HTMLElement>(".project-other")!.click();
// Burst: every keystroke reschedules; nothing may hit /api/gitroot yet.
for (let i = 1; i <= "/opt/somewhere".length; i++) {
typeRaw(form, "/opt/somewhere".slice(0, i));
@@ -119,7 +119,7 @@ describe("spawnForm gitroot probe debounce (#74)", () => {
it("a stale probe answer cannot overwrite a newer input", async () => {
const { form, stub } = await mount({ holdGitRootFor: ["/repo/old"] });
form.querySelector<HTMLElement>(".project-row.project-other")!.click();
form.querySelector<HTMLElement>(".project-other")!.click();
// Probe for the old target, held in flight.
typeRaw(form, "/repo/old");
+6 -6
View File
@@ -80,10 +80,10 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
};
function pickProject(form: HTMLFormElement): void {
form.querySelector<HTMLButtonElement>(".project-row.project-other")!.click();
form.querySelector<HTMLButtonElement>(".project-other")!.click();
form
.querySelectorAll<HTMLButtonElement>(".project-row")
.forEach((row) => row.dataset.cwd === "/home/user/maestro" && row.click());
.querySelectorAll<HTMLButtonElement>(".project-box")
.forEach((box) => box.dataset.cwd === "/home/user/maestro" && box.click());
}
it("defaults the worktree toggle on for a picked project with no gitroot probe", async () => {
@@ -118,8 +118,8 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
handle.el
.querySelectorAll<HTMLButtonElement>(".project-row")
.forEach((row) => row.dataset.cwd === "/home/user/wt-91" && row.click());
.querySelectorAll<HTMLButtonElement>(".project-box")
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
await drain();
expect(handle.el.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked).toBe(
@@ -134,7 +134,7 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
// Escape hatch: switch to a free-text dir; only now does /api/gitroot
// fire — debounced, so the quiet period must elapse first (#74).
form.querySelector<HTMLElement>(".project-row.project-other")!.click();
form.querySelector<HTMLElement>(".project-other")!.click();
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.value = "/opt/somewhere";
raw.dispatchEvent(new Event("input"));
+120 -39
View File
@@ -727,10 +727,8 @@ textarea {
}
/* Earlier path segments collapse to their initial; the trailing segment (the
one that actually names the dir) stays full. Dim the collapsed prefix. Shared
by the sidebar tree and the spawn dialog's recent-directory picker. */
.folder-name .folder-stem,
.recent-dir .folder-stem {
one that actually names the dir) stays full. Dim the collapsed prefix. */
.folder-name .folder-stem {
color: var(--faint);
}
@@ -1381,8 +1379,9 @@ textarea {
color: var(--muted);
}
/* Project picker rows (#59). Worktree rows are indented under their base
repository; rows stale beyond 7 days are dimmed but still selectable. */
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
picked project's worktree choices. Stale beyond 7 days dims but stays
selectable. */
.picker {
display: flex;
flex-direction: column;
@@ -1397,73 +1396,155 @@ textarea {
width: 100%;
}
.project-row {
/* `display` from `.field`/`.raw-wrap` outranks the UA sheet's `[hidden]`, so
step two and the raw-cwd hatch stayed visible while hidden. */
.wt-step[hidden],
.raw-wrap[hidden] {
display: none;
}
.picker.projects {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
align-content: start;
gap: var(--sp-2);
max-height: 300px;
padding: 1px;
}
.project-box {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
padding: var(--sp-2) var(--sp-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-2);
color: var(--ink);
font-family: inherit;
text-align: left;
cursor: pointer;
transition:
border-color var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out);
}
.project-box:hover {
border-color: var(--border-strong);
}
.project-box.is-active {
border-color: var(--accent);
background: var(--accent-tint);
}
.project-box.is-stale {
opacity: 0.55;
}
.project-name {
font-size: var(--fs-13);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The whole path, never abbreviated — it is what tells two projects sharing a
basename apart. Wraps rather than truncating so none of it is hidden. */
.project-path {
font-family: var(--font-mono);
font-size: var(--fs-12);
line-height: 1.35;
color: var(--muted);
overflow-wrap: anywhere;
}
.project-meta {
font-family: var(--font-mono);
font-size: var(--fs-12);
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-meta:empty {
display: none;
}
.project-other {
align-self: flex-start;
padding: 4px 0;
min-height: 30px;
background: none;
border: 0;
color: var(--muted);
font-family: inherit;
font-size: var(--fs-13);
font-style: italic;
text-align: left;
cursor: pointer;
}
.project-other:hover {
color: var(--ink);
}
/* Step two: "+ new" (create one) or an existing worktree (run in it). */
.picker.wt-choices {
max-height: 200px;
}
.wt-choice {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-3);
min-height: 32px;
padding: 6px var(--sp-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-2);
color: var(--ink);
font-family: inherit;
cursor: pointer;
text-align: left;
cursor: pointer;
transition:
border-color var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out);
}
.project-row:hover {
.wt-choice:hover {
border-color: var(--border-strong);
}
.project-row.is-active {
.wt-choice.is-active {
border-color: var(--accent);
background: var(--accent-tint);
}
.project-row.is-worktree {
margin-left: var(--sp-4);
}
.project-row.is-stale {
opacity: 0.55;
}
.project-info {
display: flex;
align-items: baseline;
gap: var(--sp-2);
min-width: 0;
}
.project-name {
.wt-choice-name {
flex: 0 0 auto;
font-size: var(--fs-13);
font-weight: 600;
white-space: nowrap;
}
.recent-dir {
.wt-choice-new .wt-choice-name {
font-family: var(--font-mono);
font-size: var(--fs-12);
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--accent);
}
.recent-cmd {
flex: 0 0 auto;
.wt-choice-sub {
min-width: 0;
font-family: var(--font-mono);
font-size: var(--fs-12);
line-height: 1.35;
color: var(--muted);
}
.project-other .recent-dir {
font-family: inherit;
font-style: italic;
text-align: right;
overflow-wrap: anywhere;
}
.picker-empty {
+233
View File
@@ -0,0 +1,233 @@
// @vitest-environment happy-dom
// Second step of the working-directory picker (#95). See the interaction rules
// block in spawnForm.ts for what each choice means.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
import type { Project } from "./types.js";
const MAESTRO: Project = {
path: "/home/user/tea/maestro",
name: "maestro",
is_worktree: false,
last_activity: "2026-08-20T10:00:00Z",
last_command: "claude",
};
const WT_A: Project = {
path: "/home/user/tea/maestro/worktrees/bulgarians",
name: "bulgarians",
is_worktree: true,
base_repo: MAESTRO.path,
last_activity: "2026-08-22T10:00:00Z",
};
const WT_B: Project = {
path: "/home/user/tea/maestro/worktrees/spawn-v2",
name: "spawn-v2",
is_worktree: true,
base_repo: MAESTRO.path,
last_activity: "2026-08-21T10:00:00Z",
};
const OTHER: Project = {
path: "/home/user/tea/rev",
name: "rev",
is_worktree: false,
last_activity: "2026-08-19T10:00:00Z",
};
function stubFetch(projects: Project[]): string[] {
const urls: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
urls.push(url);
const body = url.includes("/api/projects")
? projects
: url.includes("/api/names")
? { names: ["amber-fox"] }
: url.includes("/api/branches")
? []
: { hostname: "h", version: "0", home_dir: "/home/user", presets: [] };
return new Response(JSON.stringify(body), { status: 200 });
}),
);
return urls;
}
describe("spawnForm worktree step (#95)", () => {
let handle: SpawnFormHandle | null = null;
let urls: string[] = [];
let changes: SpawnFormValues[] = [];
beforeEach(() => {
vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") });
changes = [];
});
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
vi.useRealTimers();
});
const drain = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
async function mount(projects: Project[]): Promise<HTMLFormElement> {
urls = stubFetch(projects);
handle = createSpawnForm({ onSuccess() {}, onChange: (v) => changes.push(v) });
document.body.appendChild(handle.el);
await drain();
return handle.el;
}
const step = (form: HTMLFormElement) => form.querySelector<HTMLElement>(".wt-step")!;
const choices = (form: HTMLFormElement) => [
...form.querySelectorAll<HTMLButtonElement>(".wt-choice"),
];
const activeChoice = (form: HTMLFormElement) =>
form.querySelector<HTMLButtonElement>(".wt-choice.is-active");
const box = (form: HTMLFormElement, path: string) =>
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
const toggle = (form: HTMLFormElement) =>
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
it("hides the worktree step until a project is picked", async () => {
const form = await mount([MAESTRO, WT_A]);
expect(step(form).hidden).toBe(true);
expect(choices(form)).toEqual([]);
box(form, MAESTRO.path).click();
await drain();
expect(step(form).hidden).toBe(false);
});
it("lists '+ new' first and selected by default, then the project's worktrees", async () => {
const form = await mount([MAESTRO, WT_A, WT_B]);
box(form, MAESTRO.path).click();
await drain();
const labels = choices(form).map((c) => c.querySelector(".wt-choice-name")!.textContent);
expect(labels).toEqual(["+ new", "bulgarians", "spawn-v2"]);
expect(activeChoice(form)!.dataset.kind).toBe("new");
});
it("taking the '+ new' default runs in the repo with the create toggle on", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
await drain();
expect(changes.at(-1)!.cwd).toBe(MAESTRO.path);
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
expect(toggle(form).checked).toBe(true);
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain(MAESTRO.path);
// The scan already knows the repo — no probe.
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
});
it("picking an existing worktree runs in it with the create toggle off (#91)", async () => {
const form = await mount([MAESTRO, WT_A, WT_B]);
box(form, MAESTRO.path).click();
await drain();
const existing = choices(form).find((c) => c.dataset.cwd === WT_A.path)!;
existing.click();
await drain();
expect(changes.at(-1)!.cwd).toBe(WT_A.path);
expect(changes.at(-1)!.worktreeEnabled).toBe(false);
expect(toggle(form).checked).toBe(false);
expect(form.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
expect(existing.classList.contains("is-active")).toBe(true);
// Still no probe: the scan reported this worktree's repo membership.
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
// The project box stays marked — it is what the worktree list belongs to.
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
});
it("switching back to '+ new' re-enables the create toggle", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
await drain();
choices(form)
.find((c) => c.dataset.cwd === WT_A.path)!
.click();
await drain();
choices(form)
.find((c) => c.dataset.kind === "new")!
.click();
await drain();
expect(changes.at(-1)!.cwd).toBe(MAESTRO.path);
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
expect(toggle(form).checked).toBe(true);
});
it("repopulates and resets to '+ new' when another project is picked", async () => {
const form = await mount([MAESTRO, WT_A, OTHER]);
box(form, MAESTRO.path).click();
await drain();
choices(form)
.find((c) => c.dataset.cwd === WT_A.path)!
.click();
await drain();
box(form, OTHER.path).click();
await drain();
expect(choices(form).map((c) => c.dataset.kind)).toEqual(["new"]);
expect(activeChoice(form)!.dataset.kind).toBe("new");
expect(changes.at(-1)!.cwd).toBe(OTHER.path);
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
});
it("defaults to the worktree itself when the box is a linked worktree (#91)", async () => {
// No main checkout in the scan, so the orphan worktree is its own box.
const form = await mount([WT_A]);
box(form, WT_A.path).click();
await drain();
expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path);
expect(changes.at(-1)!.cwd).toBe(WT_A.path);
expect(changes.at(-1)!.worktreeEnabled).toBe(false);
expect(toggle(form).checked).toBe(false);
expect(form.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
});
it("hides the worktree step again when the operator takes the raw-input hatch", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
await drain();
expect(step(form).hidden).toBe(false);
form.querySelector<HTMLButtonElement>(".project-other")!.click();
await drain();
expect(step(form).hidden).toBe(true);
expect(choices(form)).toEqual([]);
expect(form.querySelector(".project-box.is-active")).toBeNull();
});
it("restores the picked worktree from a draft seed", async () => {
urls = stubFetch([MAESTRO, WT_A, WT_B]);
handle = createSpawnForm({
onSuccess() {},
seed: { cwd: WT_A.path, cwdRawVisible: false, worktreeEnabled: false },
});
document.body.appendChild(handle.el);
await drain();
const form = handle.el;
expect(step(form).hidden).toBe(false);
expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path);
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
expect(toggle(form).checked).toBe(false);
});
});