Compare commits

..
Author SHA1 Message Date
Miguel Palhas 8e9ed580ea Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/73-draft-pane-swap 2026-08-24 14:54:26 +01:00
Miguel Palhas 2d5e2f1718 fix(maestro): move draft forms along with swapped pane data
Center-zone pane drag swapped dragged.draft/target.draft but left
draftForms cached under the old leaf id, so both panes kept showing
their pre-swap form values while writing into the swapped leaf.

Tear down both leaves' cached forms at swap time (same pattern as
openDraft's stale-form teardown, #72) so render() remounts fresh
forms seeded from the swapped data.

Fixes #73
2026-08-24 14:54:18 +01:00
Miguel Palhas 56d1afe9bd merge: cap and centre the spawn form's width
Closes #92
2026-08-24 14:48:45 +01:00
Miguel Palhas 73e9ecfc85 fix(maestro): cap and centre the spawn form's width
The form filled the full pane width, so on a 4K display every input
stretched thousands of pixels wide. Bound it to 60ch (in line with the
48ch/42ch precedents already in the type scale) and centre it with
margin auto; panes narrower than the cap still fill edge to edge.
2026-08-24 14:48:19 +01:00
Miguel Palhas 095f810176 merge: make shift-S resolve to the fast-forward spawn binding
Closes #70
2026-08-24 14:45:53 +01:00
Miguel Palhas 3e809db1bc merge: honour the project cache TTL in the background rescan
Closes #75
2026-08-24 14:42:54 +01:00
Miguel Palhas e8bd8a4952 merge: reuse an empty draft pane and keep its seed
Closes #72
2026-08-24 14:41:42 +01:00
Miguel PalhasandClaude Sonnet 5 a85773cef0 fix(maestro): make shift-S resolve to the fast-forward spawn binding
crossterm attaches KeyModifiers::SHIFT to every uppercase char, so a real
Shift+S delivered Char { c: 'S', shift: true }. canonical() turned that
into "shift-S", which the default "S" binding never matched, so
FastForwardSpawn was unreachable from the keyboard.

canonical() now drops the shift- prefix when the char is already
uppercase, since the case already says "shifted". Regression test drives
an actual crossterm KeyEvent through canonicalise() and the keymap,
rather than asserting on the binding string, per #70.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 14:39:53 +01:00
Miguel Palhas 9c6e06ec4f fix(maestro): reuse empty draft pane and keep its seed (#72)
openDraft's reuse check was `!target.draft`, dead after first render:
createSpawnForm auto-syncs its picked defaults into the leaf's draft
once its project load settles, so every empty pane has a non-null
draft a second later. `n` and Spawn always split instead of reusing
the pane on screen.

Reuse now checks only `!target.sessionId`, matching the codebase's own
definition of "empty" pane elsewhere. Reusing also drops the leaf's
stale form instance so a new seed is read by the rebuilt form instead
of being ignored by the old one.
2026-08-24 14:39:51 +01:00
Miguel Palhas 3029de667e fix(maestro): honour the project cache TTL in the background rescan (#75) 2026-08-24 14:39:10 +01:00
Miguel Palhas a8d693c0a3 fix(web): bump pnpmDeps hash for lockfile change
ci / web (push) Successful in 26s
ci / rust (push) Successful in 3m8s
2026-08-24 13:57:01 +01:00
Miguel Palhas 6c1bcf042c feat(maestro): spawn v2 and the worktree lifecycle redesign
ci / web (push) Successful in 25s
ci / rust (push) Failing after 1m40s
Merges two milestones. Spawn v2 (#53-#68) replaced the spawn modal with
an inline draft pane, added project scanning, branch pickers and name
generation across the web and TUI clients.

Worktree lifecycle (#77-#89) then redesigned what the daemon does with a
worktree it created, which is what Spawn v2's reviews kept getting wrong:

- The daemon never adopts a directory it finds. A path collision is
  sidestepped with a numeric suffix instead of resolved.
- A kill is refused when the worktree holds unsaved work; ignored files
  never count. --force ends the session and RETAINS the tree.
- A session that exits on its own applies the same predicate, from the
  same function, so the two paths cannot drift.
- Retained trees are persisted in state_dir, listed by GET /api/worktrees,
  and reclaimed explicitly. Reclaim removes the directory, never the branch.
- cleanup_on_kill is gone from the wire; what survives is decided by what
  is in the tree, not by what the spawn asked for.

docs/WORKTREES.md is the written contract, with the reasoning for each
non-obvious rule.
2026-08-24 13:36:53 +01:00
7 changed files with 219 additions and 18 deletions
+35 -13
View File
@@ -261,17 +261,13 @@ impl ProjectScanner {
}
/// Cached snapshot, rescanning (off-thread) when the TTL has lapsed.
/// Also the background timer's entry point: a tick inside the TTL is a
/// no-op (#75).
pub async fn repos(&self) -> std::sync::Arc<Vec<DiscoveredRepo>> {
self.repos_inner(false).await
}
async fn repos_inner(&self, force: bool) -> std::sync::Arc<Vec<DiscoveredRepo>> {
let mut cache = self.cache.lock().await;
if !force {
if let Some((at, repos)) = cache.as_ref() {
if at.elapsed() < self.ttl {
return repos.clone();
}
if let Some((at, repos)) = cache.as_ref() {
if at.elapsed() < self.ttl {
return repos.clone();
}
}
let roots = self.roots.clone();
@@ -287,11 +283,11 @@ impl ProjectScanner {
repos
}
/// Rescan now, ignoring the TTL. Called by the background timer — going
/// through `repos()` would hit the cache and make most ticks no-ops,
/// leaving requests to pay for the rescan instead (#54 review).
/// One background-refresh tick: rescan only if the cached snapshot has
/// passed its TTL (#75) — the timer used to bypass it and re-walk the
/// roots every [`REFRESH_INTERVAL`].
async fn refresh(&self) {
self.repos_inner(true).await;
self.repos().await;
}
/// Spawn the slow background refresh task. Drop the returned handle to
@@ -544,6 +540,32 @@ mod tests {
assert_eq!(fresh[0].name, "late");
}
#[tokio::test]
async fn background_refresh_honours_the_ttl() {
let tmp = tempfile::tempdir().unwrap();
let mut scanner = ProjectScanner::new(
vec![tmp.path().to_path_buf()],
3,
PathBuf::from("/nonexistent"),
);
let empty = scanner.repos().await;
assert!(empty.is_empty());
make_repo(tmp.path(), "late");
// A background tick inside the TTL must not re-walk the filesystem:
// the new repo stays invisible until the cache expires.
scanner.refresh().await;
assert!(scanner.repos().await.is_empty());
// Once the TTL lapses, the next tick picks it up.
scanner.ttl = Duration::ZERO;
scanner.refresh().await;
let fresh = scanner.repos().await;
assert_eq!(fresh.len(), 1);
assert_eq!(fresh[0].name, "late");
}
#[test]
fn disabled_scanner_always_serves_empty() {
let scanner = ProjectScanner::disabled();
+20
View File
@@ -238,6 +238,26 @@ mod tests {
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
}
/// Regression for #70: the previous canonical form of a real Shift+S
/// keypress was `"shift-S"`, which the default `"S"` binding never
/// matched. Drive the actual crossterm event, not the binding string,
/// so the dead-key bug can't come back unnoticed.
#[test]
fn real_shift_s_keypress_resolves_to_fast_forward_spawn() {
use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
use crate::input::{canonicalise, Region};
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises");
assert_eq!(
cfg.keymap.resolve(Mode::Normal, &key.canonical()),
Some(Action::FastForwardSpawn),
);
}
#[test]
fn custom_chord_timeout_is_applied() {
let mut section = TuiSection::default();
+20 -2
View File
@@ -117,7 +117,9 @@ impl InputKey {
if *alt {
out.push_str("alt-");
}
if *shift {
// Uppercase already implies shift; crossterm sets the flag
// on every such char, so skip the redundant prefix (#70).
if *shift && !c.is_uppercase() {
out.push_str("shift-");
}
out.push(*c);
@@ -580,12 +582,28 @@ mod tests {
#[test]
fn canonical_round_trips_simple_chars() {
for s in ["a", "ctrl-a", "ctrl-w", "alt-x", "shift-A"] {
for s in ["a", "ctrl-a", "ctrl-w", "alt-x"] {
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s, "round-trip {s:?}");
}
}
/// crossterm 0.28 attaches `KeyModifiers::SHIFT` to any uppercase char,
/// so a real Shift+S delivers `Char { c: 'S', shift: true }`. Its
/// canonical form must be the bare `"S"` a keymap binds, not `"shift-S"` —
/// the uppercase letter already says "shifted"; a redundant prefix would
/// make the default `S` binding unreachable from the keyboard (#70).
#[test]
fn shift_flag_is_redundant_for_an_already_uppercase_char() {
let k = InputKey::Char {
c: 'S',
ctrl: false,
alt: false,
shift: true,
};
assert_eq!(k.canonical(), "S");
}
#[test]
fn canonical_round_trips_specials() {
for s in [
+1 -1
View File
@@ -125,7 +125,7 @@
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
inherit (finalAttrs) pname version src;
fetcherVersion = 3;
hash = "sha256-Lhi41AtCK69SM4+7zih19+Njf7o2hyXrAV0W6VO9YxU=";
hash = "sha256-D9JdgI92vzAY8en2zGEEPd4hwOLVi3sr1joT4b17Be4=";
};
buildPhase = ''
+122
View File
@@ -147,6 +147,48 @@ describe("multiview openDraft (#62)", () => {
expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip");
});
it("reuses an already-synced empty pane instead of splitting (#72)", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
const empty = splitLeaf(tab, first, null, "row", true);
tab.focusedLeafId = empty;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
// Let the mounted form's loadProjects() settle and auto-sync its picked
// defaults into leaf.draft before openDraft runs (#72's dead-branch window).
await flush();
expect(leaves(tab.root).find((l) => l.id === empty)?.draft).not.toBeNull();
mv.openDraft();
await flush();
expect(leaves(tab.root)).toHaveLength(2);
expect(tab.focusedLeafId).toBe(empty);
});
it("keeps a seed after reusing an already-synced empty pane (#72)", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
const empty = splitLeaf(tab, first, null, "row", true);
tab.focusedLeafId = empty;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
await flush();
mv.openDraft({ cwd: "/home/user/proj", command: "claude" });
await flush();
const leaf = leaves(tab.root).find((l) => l.id === empty)!;
expect(leaf.draft?.cwd).toBe("/home/user/proj");
expect(leaf.draft?.command).toBe("claude");
const cwdRaw = mv.el.querySelector(".mv-draft .cwd-raw") as HTMLInputElement;
expect(cwdRaw.value).toBe("/home/user/proj");
});
it("seeds a clone's cwd and command into the draft", async () => {
const layout = emptyLayout();
const mv = makeMv(layout);
@@ -160,3 +202,83 @@ describe("multiview openDraft (#62)", () => {
expect(leaf.draft?.command).toBe("claude");
});
});
describe("multiview pane-drag swap (#73)", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
/** Drag `sourcePane`'s title onto `targetPane`'s center, as the real pane
* drag handler does — modelled after the pointer sequence in runDrag()/
* updateDock() in multiview.ts, since neither is exposed on MultiView. */
async function dragPaneOntoCenter(sourcePane: HTMLElement, targetPane: HTMLElement): Promise<void> {
// The real drag-start gate requires the pointerdown target to sit inside
// an `.attach-header` (attach.ts's session title bar). Draft panes mount
// no such element, so stand one in for the gate check itself.
const marker = document.createElement("div");
marker.className = "attach-header";
sourcePane.appendChild(marker);
vi.spyOn(targetPane, "getBoundingClientRect").mockReturnValue({
left: 100,
top: 0,
width: 100,
height: 100,
right: 200,
bottom: 100,
x: 100,
y: 0,
toJSON() {},
} as DOMRect);
(document as unknown as { elementsFromPoint: (x: number, y: number) => Element[] }).elementsFromPoint = () => [
targetPane,
];
marker.dispatchEvent(
new PointerEvent("pointerdown", { clientX: 0, clientY: 0, bubbles: true, cancelable: true }),
);
window.dispatchEvent(
new PointerEvent("pointermove", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }),
);
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }));
await flush();
}
it("moves each draft's form along with its data when two draft panes are swapped", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const leaf1 = splitLeaf(tab, "", null, "row", true);
const leaf2 = splitLeaf(tab, leaf1, null, "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
await flush();
const paneOf = (leafId: string) => mv.el.querySelector(`[data-leaf-id="${leafId}"]`) as HTMLElement;
const nameInputOf = (leafId: string) => paneOf(leafId).querySelector("input[name=name]") as HTMLInputElement;
nameInputOf(leaf1).value = "west";
nameInputOf(leaf1).dispatchEvent(new Event("input"));
nameInputOf(leaf2).value = "east";
nameInputOf(leaf2).dispatchEvent(new Event("input"));
await flush();
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("west");
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("east");
await dragPaneOntoCenter(paneOf(leaf1), paneOf(leaf2));
// The leaf data swaps (already correct pre-fix)...
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("east");
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("west");
// ...and the mounted forms must follow, not stay behind showing the old
// pane's values (#73).
expect(nameInputOf(leaf1).value).toBe("east");
expect(nameInputOf(leaf2).value).toBe("west");
});
});
+18 -2
View File
@@ -423,9 +423,15 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
return;
}
const target = findLeaf(tab.root, tab.focusedLeafId)!;
if (!target.sessionId && !target.draft) {
// An already-empty pane becomes the draft in place.
if (!target.sessionId) {
// Empty = no session, not `!draft` (#72): the mounted form auto-syncs
// picked defaults into `draft` once ready, so that's never reliably null.
target.draft = draft();
const stale = draftForms.get(target.id);
if (stale) {
stale.destroy();
draftForms.delete(target.id);
}
} else {
// Split a fresh draft beside the focused pane.
tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true);
@@ -695,6 +701,16 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const dragged = findLeaf(tab.root, self)!;
[dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId];
[dragged.draft, target.draft] = [target.draft, dragged.draft];
// The leaf data moved but each pane's mounted form is still keyed to
// its old leaf (#73) — tear both down like openDraft's reuse case
// (#72) so render() remounts fresh forms seeded from the swapped data.
for (const id of [dragged.id, target.id]) {
const stale = draftForms.get(id);
if (stale) {
stale.destroy();
draftForms.delete(id);
}
}
tab.focusedLeafId = target.id;
} else if (sessionId) {
const loc = locateSession(layout, sessionId);
+3
View File
@@ -1215,6 +1215,9 @@ textarea {
/* ---------- forms ---------- */
.spawn-form {
width: 100%;
max-width: 60ch;
margin: 0 auto;
padding: var(--sp-3);
overflow: auto;
display: flex;