From 5de04af36acdc27ba6f1fca2ea0a7c424d91d882 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 15:15:47 +0100 Subject: [PATCH 1/2] 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. --- web/src/spawnForm.ts | 64 +++++++++++++++++++++---------- web/src/spawnFormWorktree.test.ts | 53 +++++++++++++++++++++++++ web/src/styles.css | 27 ++++++++++--- 3 files changed, 118 insertions(+), 26 deletions(-) diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index c18b2db..714796b 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -147,33 +147,38 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
- + +
@@ -192,6 +197,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; @@ -464,6 +472,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { // 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; + updateWtSummary(); if (project.is_worktree) { inferredBaseRepo = null; wtBody.hidden = true; @@ -569,6 +578,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 @@ -649,6 +670,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; @@ -722,6 +744,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { branchSelect.addEventListener("change", () => emit()); wtEnabled.addEventListener("change", () => { + updateWtSummary(); emit(); if (wtEnabled.checked) { resolveBaseRepo(); @@ -735,6 +758,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); diff --git a/web/src/spawnFormWorktree.test.ts b/web/src/spawnFormWorktree.test.ts index bb04b96..ff5ca1e 100644 --- a/web/src/spawnFormWorktree.test.ts +++ b/web/src/spawnFormWorktree.test.ts @@ -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(".wt-toggle")!; + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + expect(form.querySelector(".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(".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(".project-row") + .forEach((row) => row.dataset.cwd === "/home/user/wt-91" && row.click()); + await drain(); + + expect(handle.el.querySelector(".wt-summary")!.textContent).toMatch(/no worktree/i); + // Still collapsed — picking a project must not force it open. + expect(handle.el.querySelector(".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(".wt-toggle")!.click(); + + expect(form.querySelector(".wt-toggle")!.getAttribute("aria-expanded")).toBe( + "true", + ); + expect(form.querySelector(".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) }); diff --git a/web/src/styles.css b/web/src/styles.css index afe24c3..5aef4a8 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1286,18 +1286,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 { From 6bb812f6bdd5155738eea507c3496bacc2162c94 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 15:20:35 +0100 Subject: [PATCH 2/2] 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. --- crates/maestro-tui/src/app.rs | 4 +- crates/maestro-tui/src/draft.rs | 217 +++++++++++++++++++++++++++----- 2 files changed, 185 insertions(+), 36 deletions(-) diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 1894082..3dc11af 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -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() { diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index 0224662..34f7d66 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -57,6 +57,9 @@ pub enum DraftField { /// Free-text path, revealed by picking `Other directory…`. CwdRaw, 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. @@ -148,6 +151,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, @@ -204,6 +211,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, @@ -442,6 +450,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) { @@ -602,13 +625,16 @@ impl DraftPane { order.push(DraftField::CwdRaw); } 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 @@ -733,6 +759,10 @@ impl DraftPane { self.select_entry(); DraftInputResult::Consumed } + DraftField::Advanced => { + self.toggle_advanced(); + DraftInputResult::Consumed + } DraftField::WorktreeToggle => { self.toggle_worktree(); DraftInputResult::Consumed @@ -744,6 +774,14 @@ impl DraftPane { DraftInputResult::Consumed } }, + InputKey::Char { + c: ' ', + ctrl: false, + .. + } if self.focused_field == DraftField::Advanced => { + self.toggle_advanced(); + DraftInputResult::Consumed + } InputKey::Char { c: ' ', ctrl: false, @@ -832,13 +870,18 @@ impl DraftPane { let show_custom = self.is_custom(); let show_raw = self.cwd_raw_visible; - let show_wt_body = self.worktree_body_visible(); + // The worktree toggle and its body stay behind the "Advanced options" + // disclosure (#93): collapsed hides both, regardless of what they'd + // otherwise show. + let show_wt_toggle = !self.advanced_collapsed; + let show_wt_body = show_wt_toggle && 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_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; @@ -853,7 +896,10 @@ impl DraftPane { constraints.push(Constraint::Length(1)); } 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 { @@ -1030,33 +1076,55 @@ impl DraftPane { }, ); + // -- advanced disclosure (#93) ------------------------------------------ + let advanced_row = next_row(); + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw(format!("{: