Compare commits

...
Author SHA1 Message Date
Miguel Palhas f6a97c4695 docs(maestro): settle line-2 rulings — command fragment, pre-#102 payloads, unconditional dot 2026-08-24 19:51:06 +01:00
Miguel Palhas 5be3e33c94 feat(maestro): render sidebar sessions as two-line cards per docs/SIDEBAR.md 2026-08-24 19:50:24 +01:00
Miguel Palhas d7b15a131f feat(maestro): activity duration + colour helpers per the sidebar card contract 2026-08-24 19:42:33 +01:00
Miguel Palhas 581ec29492 docs(maestro): name the activity dot in the card contract 2026-08-24 19:32:08 +01:00
Miguel Palhas 90b7953b17 merge: sidebar-cards/103-session-env 2026-08-24 19:31:35 +01:00
Miguel Palhas 26e7992c8d merge: sidebar-cards/102-activity-wire 2026-08-24 19:31:35 +01:00
Miguel Palhas 60515dd8c6 merge: sidebar-cards/101-sidebar-contract 2026-08-24 19:31:35 +01:00
Miguel Palhas b3c73fd2cb test(maestro): assert the exported session env end to end through real tmux 2026-08-24 19:30:11 +01:00
Miguel Palhas 7d06bec21e feat(maestro): export MAESTRO_SESSION_ID and MAESTRO_URL into spawned sessions
Every tmux-spawned session now gets its UUID under MAESTRO_SESSION_ID and,
when the runner has wired its base URL from [daemon].bind_addr, that URL
under MAESTRO_URL (#103). The daemon-owned values override anything the
spawn request carried. Adopted sessions stay without them by design.
2026-08-24 19:30:11 +01:00
Miguel Palhas 1a0d1284e1 feat(maestro): mirror activity_since and Error state in web types 2026-08-24 19:22:47 +01:00
Miguel Palhas fc6f3469b0 feat(maestro): add Error activity state to the wire 2026-08-24 19:21:44 +01:00
Miguel Palhas b95b5c04c1 feat(maestro): add activity_since to the session wire 2026-08-24 19:19:33 +01:00
Miguel Palhas 6af8abccd3 docs(maestro): add SIDEBAR.md session card contract 2026-08-24 19:17:04 +01:00
Miguel Palhas ad2e700391 fix(maestro): spawn opens a new tab instead of splitting
ci / web (push) Successful in 31s
ci / rust (push) Successful in 2m22s
ci / flake (push) Successful in 6m43s
Pressing Spawn while reading a session split that pane in half. It now
opens the draft in its own tab, leaving the current tab untouched.

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes a latent [hidden] + display:flex bug for the new .wt-collapsible
element (author CSS beats the UA [hidden] rule at equal specificity);
.wt-body has the same bug pre-existing and is out of scope here.
2026-08-24 15:15:47 +01:00
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 596e7e2f49 feat(maestro): debounce the gitroot probe in the TUI draft
Typing in the raw path field fired one /api/gitroot probe per
keystroke (#74). The draft now schedules the probe 250ms out — matching
GITROOT_PROBE_DEBOUNCE_MS in web/src/spawnForm.ts — and every keystroke
resets it; the 30Hz heartbeat polls for due probes so the burst's one
request fires after typing stops. The seq guard in resolve_gitroot is
untouched, so a late answer still cannot overwrite a newer input.
2026-08-24 15:04:47 +01:00
Miguel Palhas 16609eff9e feat(maestro): debounce the gitroot probe in the spawn form
Typing in the raw cwd field fired one /api/gitroot request per
keystroke (#74). Every keystroke now resets a 250ms window shared with
the TUI draft (GITROOT_PROBE_DEBOUNCE_MS there); probeToken still owns
ordering so a late answer can't overwrite a newer input.
2026-08-24 14:59:56 +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
Miguel Palhas 2a2da7f779 merge: move draft forms along with swapped pane data
Closes #73
2026-08-24 14:55:55 +01:00
Miguel Palhas 5eab434647 merge: default the worktree toggle off in an existing worktree
Closes #91
2026-08-24 14:54:46 +01:00
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 b80d604408 fix(maestro): default the worktree toggle off when the directory is itself a worktree (#91)
Picking a project row that is itself a linked git worktree used to
default 'Create git worktree' on, so spawning asked for a second
worktree of an already-checked-out branch and git refused. Both clients
now default the toggle off there: the session just runs in that existing
worktree with no worktree record (not adoption). Turning it back on by
hand still tries, per the issue's non-goal.
2026-08-24 14:52:17 +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
Miguel Palhas fb87a00906 merge: surface blocked work before the kill is attempted
Closes #83
2026-08-24 13:22:50 +01:00
40 changed files with 3632 additions and 420 deletions
+31
View File
@@ -97,3 +97,34 @@ 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
# The runner container is root with no `nixbld` group, but the installer
# writes `build-users-group = nixbld` anyway and then fails on it, so the
# config goes in first. Flakes are off by default and `nix build .#…`
# needs them. Written again afterwards in case the installer replaced it.
- name: Install nix
run: |
mkdir -p /etc/nix
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf
curl -fsSL https://nixos.org/nix/install | sh -s -- --no-daemon --yes
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf
echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
# The installer does not enable flakes, so `nix build .#…` needs the
# features passed explicitly — a local machine that has them in
# nix.conf will build fine while CI fails without this.
- name: build flake packages
run: |
nix --extra-experimental-features "nix-command flakes" build \
.#webFrontend \
.#maestro \
.#maestro-web \
--no-link
+4
View File
@@ -66,6 +66,10 @@ See `docs/INSTALL.md` for end-user install instructions.
collisions, what blocks a kill, what `--force` retains, and what reclaim
touches. Read it before changing anything that creates or removes one.
`docs/SIDEBAR.md` is the contract for session cards in the web and TUI
sidebars — line layout, activity colours, duration format, state precedence.
Read it before changing sidebar rendering.
`nix run .#dev` (or `just dev`) brings up the whole local stack under
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
+2
View File
@@ -108,6 +108,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
ActivityState::Active => "active",
ActivityState::Idle => "idle",
ActivityState::AwaitingInput => "awaiting",
ActivityState::Error => "error",
};
// Show the live foreground command alongside the spawn command when
// the daemon has observed one that differs (NAP-340).
@@ -176,6 +177,7 @@ mod tests {
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
}
+3
View File
@@ -73,6 +73,9 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
// One store instance shared with the API handlers: forced kills (#81)
// record here, `GET /api/worktrees` (#80) reads back from here.
.with_retained_store((*retained).clone());
// Spawned sessions learn where to report back (#103). Derived from the
// configured bind address; set before any spawn can happen.
manager.set_daemon_url(format!("http://{}", config.bind_addr));
let recovered = manager.recover().await?;
tracing::info!(recovered, "adopted existing tmux sessions");
+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();
+35 -2
View File
@@ -131,6 +131,11 @@ struct Inner {
/// Retained-worktree store (#78). `None` when not wired (tests); a forced
/// kill (#81) without one fails rather than destroying unrecorded work.
retained: Option<crate::retained_store::RetainedStore>,
/// The daemon's own HTTP base URL (#103), exported to spawned sessions as
/// `MAESTRO_URL`. `None` when unknown — those sessions simply don't get
/// the variable. A mutex because the runner sets it after construction,
/// possibly while clones are already serving.
daemon_url: std::sync::Mutex<Option<String>>,
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
events: broadcast::Sender<CatalogEvent>,
}
@@ -170,6 +175,7 @@ impl SessionManager {
replay_bytes,
spawn_history: None,
retained: None,
daemon_url: std::sync::Mutex::new(None),
sessions: Mutex::new(HashMap::new()),
events,
}),
@@ -202,6 +208,14 @@ impl SessionManager {
self
}
/// Set the daemon's own HTTP base URL (#103), exported to every session
/// spawned afterwards as `MAESTRO_URL`. Unlike the `with_*` builders this
/// is a plain setter: the daemon runner learns its bound address after
/// construction, and clones may already exist by then.
pub fn set_daemon_url(&self, url: String) {
*self.inner.daemon_url.lock().expect("daemon_url mutex") = Some(url);
}
/// Recent working dirs for the spawn form's picker (#24), newest-first and
/// deduped by dir. Empty when no history is wired or it's disabled.
pub fn recent_dirs(&self) -> Vec<RecentDir> {
@@ -278,7 +292,11 @@ impl SessionManager {
self.inner.worktree_mgr.add_existing(base_repo, branch)
}
.map_err(SessionError::Worktree)?;
(path.clone(), Some(path), Some(base_repo.clone()))
// Record the repository, not whatever the request named: a
// spawn from inside a linked worktree passes that worktree as
// `base_repo`, and clients group sessions by this path (#99).
let repo = crate::worktree::main_checkout_of(base_repo);
(path.clone(), Some(path), Some(repo))
}
None => (req.cwd.clone(), None, None),
};
@@ -300,7 +318,7 @@ impl SessionManager {
return Err(e.into());
}
};
let session_env = match sanitize_env(&req.env) {
let mut session_env = match sanitize_env(&req.env) {
Ok(env) => env,
Err(e) => {
let _ = std::fs::remove_file(&fifo_path);
@@ -308,6 +326,19 @@ impl SessionManager {
return Err(e);
}
};
// Identity + reporting target for the in-session hook (#106). Ours
// win over whatever the request carried, so a client cannot spoof
// another session's identity (#103).
session_env.insert("MAESTRO_SESSION_ID".into(), id.to_string());
if let Some(url) = self
.inner
.daemon_url
.lock()
.expect("daemon_url mutex")
.clone()
{
session_env.insert("MAESTRO_URL".into(), url);
}
let spawned = {
let tmux = self.inner.tmux.clone();
let cwd = effective_cwd.clone();
@@ -349,6 +380,7 @@ impl SessionManager {
foreground: None,
activity: ActivityState::Active,
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
// The watcher fills this in on its first blocked-work pass; a
@@ -568,6 +600,7 @@ impl SessionManager {
foreground: None,
activity: ActivityState::Idle,
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
+19
View File
@@ -264,6 +264,25 @@ impl Tmux {
check(out)
}
/// `tmux show-environment -t maestro-<id>`, parsed into KEY=VALUE pairs.
/// Test-facing view of what a spawned session actually inherits.
pub fn show_environment(&self, id: Uuid) -> Result<BTreeMap<String, String>, TmuxError> {
let name = session_name(id);
let out = self
.cmd()
.args(["show-environment", "-t", &name])
.output()?;
check(out.clone())?;
let stdout = String::from_utf8_lossy(&out.stdout);
let mut env = BTreeMap::new();
for line in stdout.lines() {
if let Some((k, v)) = line.split_once('=') {
env.insert(k.to_string(), v.to_string());
}
}
Ok(env)
}
/// `tmux kill-server`. Best-effort cleanup used mostly by tests.
pub fn kill_server(&self) -> Result<(), TmuxError> {
let out = self.cmd().arg("kill-server").output()?;
+7
View File
@@ -174,6 +174,13 @@ impl WorktreeManager {
/// The repository's main checkout. `--git-common-dir` points at the shared
/// `.git` for every linked worktree, so its parent is the main tree. Falls
/// back to the input when git can't answer.
/// The repository a path belongs to: the main checkout, even when `path` is a
/// linked worktree. Clients group sessions by this (#99), and placement uses it
/// so a worktree is never nested inside another.
pub fn main_checkout_of(path: &Path) -> PathBuf {
main_checkout(path)
}
fn main_checkout(path: &Path) -> PathBuf {
let out = Command::new("git")
.arg("-C")
@@ -30,6 +30,24 @@ impl Fx {
Self::with_sidecar_dir(|tmp| tmp.join("sidecars"))
}
/// Variant whose manager knows the daemon's own base URL (#103), the way
/// the production runner wires it from `[daemon].bind_addr`.
fn with_daemon_url(url: &str) -> Self {
let fx = Self::new();
fx.manager.set_daemon_url(url.to_string());
fx
}
/// The env map of the (single, expected) spawn call.
fn spawned_env(&self) -> BTreeMap<String, String> {
let calls = self.fake.calls();
let Some(Call::NewSessionPiped { env, .. }) = calls.first() else {
panic!("expected a spawn call, got {calls:?}");
};
assert_eq!(calls.len(), 1, "expected exactly one spawn: {calls:?}");
env.clone()
}
fn with_sidecar_dir(pick: impl Fn(&std::path::Path) -> PathBuf) -> Self {
let tmp = TempDir::new().expect("tempdir");
let fake = Arc::new(FakePanes::new());
@@ -261,16 +279,23 @@ async fn requested_env_reaches_tmux() {
fx.manager.create(req).await.expect("create");
let spawned = match fx.fake.calls().first() {
Some(Call::NewSessionPiped { env, .. }) => env.clone(),
other => panic!("expected a spawn call, got {other:?}"),
};
let id = spawned
.get("MAESTRO_SESSION_ID")
.expect("spawn must export MAESTRO_SESSION_ID (#103)")
.clone();
// The daemon-owned variable rides alongside whatever was requested.
let want: BTreeMap<String, String> = [
("EMPTY".to_string(), String::new()),
("FOO".to_string(), "bar".to_string()),
("MAESTRO_SESSION_ID".to_string(), id),
]
.into_iter()
.collect();
match fx.fake.calls().first() {
Some(Call::NewSessionPiped { env, .. }) => assert_eq!(env, &want),
other => panic!("expected a spawn call, got {other:?}"),
}
assert_eq!(&spawned, &want);
}
#[tokio::test]
@@ -396,3 +421,70 @@ fn attaching_spawn_history_after_cloning_panics() {
7,
));
}
/// Every tmux-spawned session learns who it is (#103): its own UUID under
/// `MAESTRO_SESSION_ID`, so the reporting hook in #106 can name the session
/// it is running inside.
#[tokio::test]
async fn spawn_exports_maestro_session_id() {
let fx = Fx::new();
let s = fx.manager.create(fx.request("bash")).await.expect("create");
let env = fx.spawned_env();
assert_eq!(
env.get("MAESTRO_SESSION_ID").map(String::as_str),
Some(&s.id.to_string()[..]),
"MAESTRO_SESSION_ID must be the session's UUID, got {env:?}"
);
}
/// The hook also needs to know where to report to: `MAESTRO_URL` carries the
/// daemon's base URL derived from `[daemon].bind_addr` (#103).
#[tokio::test]
async fn spawn_exports_maestro_url_when_the_daemon_is_wired() {
let fx = Fx::with_daemon_url("http://127.0.0.1:7070");
fx.manager.create(fx.request("bash")).await.expect("create");
let env = fx.spawned_env();
assert_eq!(
env.get("MAESTRO_URL").map(String::as_str),
Some("http://127.0.0.1:7070"),
"MAESTRO_URL must be the daemon's base URL, got {env:?}"
);
}
/// Without a wired URL (tests, or a caller that never set one) only the
/// session id is injected — an empty or bogus `MAESTRO_URL` would send the
/// #106 hook's reports nowhere useful.
#[tokio::test]
async fn spawn_omits_maestro_url_when_not_wired() {
let fx = Fx::new();
fx.manager.create(fx.request("bash")).await.expect("create");
let env = fx.spawned_env();
assert!(
!env.contains_key("MAESTRO_URL"),
"MAESTRO_URL must be absent when no daemon URL is known, got {env:?}"
);
}
/// The daemon-owned variables win over anything the spawner sent: a client
/// cannot spoof another session's identity.
#[tokio::test]
async fn spawn_overrides_client_supplied_maestro_variables() {
let fx = Fx::with_daemon_url("http://127.0.0.1:7070");
let mut req = fx.request("bash");
req.env.insert(
"MAESTRO_SESSION_ID".into(),
"00000000-0000-0000-0000-000000000000".into(),
);
req.env.insert("MAESTRO_URL".into(), "http://evil:1".into());
let s = fx.manager.create(req).await.expect("create");
let env = fx.spawned_env();
assert_eq!(env.get("MAESTRO_SESSION_ID"), Some(&s.id.to_string()));
assert_eq!(
env.get("MAESTRO_URL"),
Some(&"http://127.0.0.1:7070".to_string())
);
}
@@ -947,3 +947,101 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
fx.manager.kill(s.id, false).await.unwrap();
}
/// #99: clients group a session under `worktree.base_repo`, so that field has
/// to name the repository even when the spawn request pointed at a linked
/// worktree. Recording the request verbatim put the session in a folder of its
/// own — `<repo>/worktrees/<branch>` — instead of under the repo.
#[tokio::test]
async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
// A linked worktree of that repo, then a spawn whose `base_repo` names it
// rather than the main checkout — what happens when the operator spawns
// from inside a worktree.
let first = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/one".into(),
create_branch: true,
}),
})
.await
.expect("create first");
let inner_worktree = first.worktree.expect("first has a worktree").path;
let second = fx
.manager
.create(CreateSessionRequest {
cwd: inner_worktree.clone(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: inner_worktree.clone(),
branch: "feat/two".into(),
create_branch: true,
}),
})
.await
.expect("create second");
let wt = second.worktree.expect("second has a worktree");
assert_eq!(
wt.base_repo,
repo.path()
.canonicalize()
.unwrap_or_else(|_| repo.path().to_path_buf()),
"base_repo must be the repository, not the worktree the request named",
);
assert_ne!(wt.base_repo, inner_worktree, "the worktree is not the repo");
}
/// #103: every tmux-spawned session carries the daemon's identity variables in
/// its environment — its UUID under `MAESTRO_SESSION_ID` and the daemon's base
/// URL under `MAESTRO_URL` — so the in-session reporting hook (#106) can find
/// both without any out-of-band channel.
#[tokio::test]
async fn spawned_sessions_export_maestro_session_id_and_url() {
let fx = DaemonFixture::new();
fx.manager
.set_daemon_url("http://127.0.0.1:7071".to_string());
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
let env = fx
.tmux
.tmux
.show_environment(s.id)
.expect("show-environment");
assert_eq!(
env.get("MAESTRO_SESSION_ID"),
Some(&s.id.to_string()),
"session env: {env:?}"
);
assert_eq!(
env.get("MAESTRO_URL"),
Some(&"http://127.0.0.1:7071".to_string())
);
fx.manager.kill(s.id, false).await.expect("kill");
}
+1
View File
@@ -27,6 +27,7 @@ fn sample(id: Uuid) -> Session {
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
blocked_work: None,
}
+42
View File
@@ -80,6 +80,13 @@ pub struct Session {
/// from daemons that predate the field.
#[serde(default = "epoch")]
pub last_activity_at: DateTime<Utc>,
/// Wall-clock time at which the session entered its current
/// [`ActivityState`] (#102) — how long it has been `Active`, `Idle` or
/// `AwaitingInput`. Distinct from [`Self::last_activity_at`], which only
/// tracks the last observed PTY output. Populated by #104; defaults to the
/// Unix epoch on payloads from daemons that predate the field.
#[serde(default = "epoch")]
pub activity_since: DateTime<Utc>,
/// The pane's *current* working directory (`#{pane_current_path}`) — distinct
/// from [`Session::cwd`], the directory it was spawned in. The activity
/// watcher refreshes it as the user `cd`s around; it drives the web UI's
@@ -119,6 +126,9 @@ pub enum ActivityState {
Idle,
/// Blocked on a `read()` from the pty — waiting on the user.
AwaitingInput,
/// The agent running in the session reported a failure (#102). Never
/// inferred by the daemon; only set when an agent reports it (#105).
Error,
}
/// Optional worktree request attached to a `CreateSessionRequest`.
@@ -537,6 +547,9 @@ mod tests {
last_activity_at: DateTime::parse_from_rfc3339("2026-05-24T12:05:00Z")
.unwrap()
.with_timezone(&Utc),
activity_since: DateTime::parse_from_rfc3339("2026-05-24T12:04:30Z")
.unwrap()
.with_timezone(&Utc),
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
blocked_work: None,
}
@@ -588,6 +601,35 @@ mod tests {
);
}
#[test]
fn activity_since_defaults_to_epoch_on_legacy_payload() {
// A daemon predating #102 omits `activity_since`; it must deserialize
// to the forward-compat default rather than failing, and a present
// value round-trips.
let json = serde_json::json!({
"id": Uuid::from_u128(1),
"cwd": "/tmp",
"command": "bash",
"status": "Running",
"created_at": "2026-05-24T12:00:00Z"
});
let s: Session = serde_json::from_value(json).unwrap();
assert_eq!(s.activity_since, epoch());
let full = sample_session();
assert_eq!(full, round_trip(&full));
}
// #102: nothing infers `Error`; only #105's agent-reported failure sets it.
#[test]
fn error_activity_state_serializes_pascal_case_and_idle_stays_default() {
assert_eq!(
serde_json::to_value(ActivityState::Error).unwrap(),
serde_json::json!("Error")
);
assert_eq!(ActivityState::default(), ActivityState::Idle);
}
#[test]
fn activity_state_serializes_pascal_case_and_defaults_idle() {
assert_eq!(
+1
View File
@@ -42,6 +42,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
foreground: None,
activity: Default::default(),
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
}
+49 -16
View File
@@ -175,7 +175,8 @@ impl App {
// Render on next loop iteration.
}
_ = tokio::time::sleep(Duration::from_millis(33)) => {
// 30 Hz heartbeat.
let effects = self.poll_due_probes_at(std::time::Instant::now());
self.perform(effects).await;
}
}
}
@@ -439,7 +440,7 @@ impl App {
if draft.take_name_refill() {
effects.push(Effect::RefillNames(tab_idx));
}
if let Some((seq, path)) = draft.take_gitroot_probe() {
if let Some((seq, path)) = draft.take_gitroot_probe(std::time::Instant::now()) {
effects.push(Effect::ProbeGitRoot {
tab: tab_idx,
seq,
@@ -452,6 +453,26 @@ impl App {
effects
}
/// Fire any draft gitroot probes whose debounce window has elapsed (#74).
/// The 30 Hz heartbeat calls this so a burst of typing issues its one
/// probe even after the last keystroke.
fn poll_due_probes_at(&mut self, now: std::time::Instant) -> Vec<Effect> {
let mut effects = Vec::new();
for tab_idx in 0..self.state.tabs.len() {
let Some(PaneRef::Draft(draft)) = self.state.tabs[tab_idx].pane.as_mut() else {
continue;
};
if let Some((seq, path)) = draft.take_gitroot_probe(now) {
effects.push(Effect::ProbeGitRoot {
tab: tab_idx,
seq,
path,
});
}
}
effects
}
/// Populate a freshly opened draft: presets from `/api/info` (falling back
/// to the built-ins already in place), projects from `/api/projects`, and
/// a first name batch. Applies only if that tab still shows the draft —
@@ -1106,6 +1127,7 @@ mod tests {
foreground: None,
activity: Default::default(),
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
}],
@@ -1328,9 +1350,7 @@ mod tests {
for c in "crimson-otter".chars() {
app.handle_draft_input(&InputKey::char(c));
}
app.handle_draft_input(&InputKey::Tab); // → worktree toggle
app.handle_draft_input(&InputKey::Tab); // → branch mode
app.handle_draft_input(&InputKey::Tab); // → new-branch name
app.handle_draft_input(&InputKey::Tab); // → advanced disclosure (collapsed, #93)
app.handle_draft_input(&InputKey::Tab); // → submit
match app.handle_draft_input(&InputKey::Enter).as_slice() {
@@ -1347,10 +1367,11 @@ mod tests {
}
}
/// Editing the raw path with the worktree toggle on schedules a
/// `/api/gitroot` probe so the section learns its base repo (#65).
/// Editing the raw path schedules a `/api/gitroot` probe only once the
/// keystroke burst settles (#74): nothing fires inside the debounce
/// window; the heartbeat picks the due probe up afterwards.
#[test]
fn typing_a_raw_path_schedules_a_gitroot_probe() {
fn typing_a_raw_path_schedules_a_debounced_gitroot_probe() {
let mut app = make_app();
app.dispatch(Action::NewDraftPane);
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
@@ -1359,13 +1380,24 @@ mod tests {
draft.cwd_raw_visible = true;
draft.focused_field = DraftField::CwdRaw;
let effects = app.handle_draft_input(&InputKey::char('/'));
// A burst of keystrokes fires nothing.
for c in "/opt/tooling".chars() {
assert!(app.handle_draft_input(&InputKey::char(c)).is_empty());
assert!(app.poll_due_probes_at(std::time::Instant::now()).is_empty());
}
// Once the quiet period passes, exactly one probe is due.
let due = std::time::Instant::now()
+ std::time::Duration::from_millis(crate::draft::GITROOT_PROBE_DEBOUNCE_MS) * 10;
match app.poll_due_probes_at(due).as_slice() {
[Effect::ProbeGitRoot { tab: 0, path, .. }] => {
assert_eq!(path, &std::path::PathBuf::from("/opt/tooling"));
}
other => panic!("expected one ProbeGitRoot effect, got {other:?}"),
}
assert!(
matches!(
effects.as_slice(),
[Effect::ProbeGitRoot { tab: 0, seq: 2, .. }]
),
"{effects:?}"
app.poll_due_probes_at(due).is_empty(),
"the probe is consumed once"
);
}
@@ -1520,8 +1552,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");
}
@@ -1613,6 +1645,7 @@ mod tests {
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
+1
View File
@@ -194,6 +194,7 @@ mod tests {
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
}
+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();
File diff suppressed because it is too large Load Diff
+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 [
+2
View File
@@ -105,6 +105,7 @@ mod tests {
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
}
@@ -228,6 +229,7 @@ mod tests {
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
+104
View File
@@ -0,0 +1,104 @@
# SIDEBAR.md — the session card contract
One rule for how a session is rendered as a card in the sidebar, so the web
sidebar (#107) and the TUI sidebar (#108) describe a session the same way.
Read this before changing either. Both clients implement exactly what is
written here; if it needs to change, change it here first.
A card has **line 1** always and **line 2** only when line 2 has something to
say.
## Line 1 — name + actions
- Left: the session name — `metadata.name` if present, else the command line
(same fallback both clients already use).
- Right: two actions anchored to the top-right corner: **spawn similar**
(`⧉`) and **kill** (`✕`). In the TUI these are keyboard affordances shown on
the highlighted row; in the web they are buttons. The glyphs are shared so
muscle memory transfers.
- Actions reserve their fixed width; the name truncates with `…` before it can
ever reach them. **Overlap between the name and the actions is a defect** —
that overlap is what started this milestone.
Line 1 carries no badges. The `⚠ blocked work` badge (#83) and the `exited`
badge move off this row into line 2.
## Line 2 — meta line (adaptive)
Rendered only when at least one of these holds:
1. The session has been held in its current activity state longer than
**30 seconds** (`activity_since` from #102). A freshly-active session
stays one line.
2. Its worktree holds blocked work: `blocked_work` is `Some` and not clear
(`InTheWay::is_clear()`), rendered in `formatInTheWay`'s phrasing on web
and as `⚠ <summary>` on the TUI.
3. It has exited: `status == Dead`.
4. An agent-reported state (#105) of `Error` is in force (see Precedence).
Otherwise line 2 is absent — never an empty placeholder row.
When present, line 2 reads left to right:
```
<dot> <duration> [<blocked work> | exited] [<command>]
```
`<dot>` is a single filled glyph (`●` on both clients) carrying the activity
state's colour, and `<duration>` follows in the same colour. There is no state
word and no second badge: the dot exists so the state is still legible when a
row is dimmed or the terminal renders the duration in the default fg. The dot
and duration are unconditional parts of line 2; the bracketed fragments appear
only when their trigger holds. A payload that predates #102 (no usable
`activity_since`) contributes no duration — the dot still renders.
`<command>` is optional and muted: the session's command line, shown only when
the rendered name came from `metadata.name` and differs from the command (the
web has always surfaced this; the TUI may omit it).
## Colour per activity state
The state word is never spelled out — the colour carries it.
| Activity state | Web token | ratatui colour |
| ---------------- | --------- | ----------------- |
| `Active` | `--live` | `Color::Green` |
| `Idle` | `--muted` | `Color::DarkGray` |
| `AwaitingInput` | `--warn` | `Color::Yellow` |
| `Error` | `--danger`| `Color::Red` |
Web tokens resolve per theme automatically (`styles.css` defines dark, light,
and `prefers-color-scheme` variants for each). The TUI uses the plain ratatui
named colours above; do not introduce RGB values or a new palette module for
this.
The duration text inherits its state's colour; the blocked-work, exited and
command fragments use the muted colour (`--faint` / default style), since the
⚠ glyph already signals severity.
## Duration format
Time spent in the **current activity state**, sourced from `activity_since`
(#102) — *not* time since spawn, and *not* time since last PTY output
(`last_activity_at`). Both clients compute it identically:
- `< 60s` → whole seconds, floored: `30s`
- `< 60m` → whole minutes, floored: `4m`
- `≥ 60m` → whole hours, floored: `2h`
No days unit, no seconds inside minutes, no rounding up. Floor everywhere so
the counter never shows a duration the state hasn't actually reached yet.
Clock granularity: re-render at least once every 30s while visible so the
cutover points land within one tick.
## Precedence
An agent-reported `ActivityState` (#105) overrides the daemon's inference for
the TTL the report carries; when the TTL expires, inference resumes. Clients
never blend the two: whichever source is in force supplies both the colour and
the clock for `activity_since`.
If a client receives an `ActivityState` variant it does not know, it must not
fail rendering: treat it as `Idle` (neutral/dim, line-2 rules apply as usual)
until the daemon sends a variant it does know. Rust clients deserialize
activity leniently for the same reason.
+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 = ''
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import {
activityColorSlug,
activityDurationLabel,
activityHeldMs,
showsMetaLine,
} from "./activity.js";
import type { Session } from "./types.js";
describe("activityHeldMs", () => {
it("measures time spent in the current state", () => {
const now = Date.now();
const since = new Date(now - 90_000).toISOString();
expect(activityHeldMs(since, now)).toBe(90_000);
});
it("returns null for payloads that predate #102 (epoch default)", () => {
expect(activityHeldMs(undefined, Date.now())).toBeNull();
expect(activityHeldMs(new Date(0).toISOString(), Date.now())).toBeNull();
});
it("returns null for unparseable timestamps", () => {
expect(activityHeldMs("not-a-time", Date.now())).toBeNull();
});
it("never goes negative", () => {
const now = Date.now();
expect(activityHeldMs(new Date(now + 5_000).toISOString(), now)).toBe(0);
});
});
describe("activityDurationLabel (#102)", () => {
it("floors whole seconds under a minute", () => {
expect(activityDurationLabel(30_400)).toBe("30s");
expect(activityDurationLabel(59_900)).toBe("59s");
});
it("floors whole minutes under an hour", () => {
expect(activityDurationLabel(60_000)).toBe("1m");
expect(activityDurationLabel(4 * 60_000 + 59_999)).toBe("4m");
});
it("floors whole hours from an hour up, with no days unit", () => {
expect(activityDurationLabel(60 * 60_000)).toBe("1h");
expect(activityDurationLabel(2 * 3_600_000 + 59 * 60_000 + 59_999)).toBe("2h");
expect(activityDurationLabel(26 * 3_600_000)).toBe("26h");
});
});
describe("activityColorSlug", () => {
it("maps each known state to its contract slug", () => {
expect(activityColorSlug("Active")).toBe("active");
expect(activityColorSlug("Idle")).toBe("idle");
expect(activityColorSlug("AwaitingInput")).toBe("awaiting-input");
expect(activityColorSlug("Error")).toBe("error");
});
it("treats an unknown variant as idle so rendering never fails", () => {
expect(activityColorSlug("Teleported")).toBe("idle");
});
});
function session(overrides: Partial<Session>): Session {
return {
id: "11111111-2222-3333-4444-555555555555",
cwd: "/home/naps/tea/maestro",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
activity: "Active",
...overrides,
};
}
describe("showsMetaLine", () => {
const fresh = new Date(Date.now() - 10_000).toISOString();
it("stays hidden for a freshly-active session", () => {
expect(showsMetaLine(session({ activity_since: fresh }))).toBe(false);
});
it("appears once the state has been held past 30 seconds", () => {
const held = new Date(Date.now() - 31_000).toISOString();
expect(showsMetaLine(session({ activity_since: held }))).toBe(true);
});
it("appears for blocked work, exited sessions and reported errors", () => {
expect(
showsMetaLine(session({ activity_since: fresh, blocked_work: { uncommitted_files: 1 } })),
).toBe(true);
expect(showsMetaLine(session({ activity_since: fresh, status: "Dead" }))).toBe(true);
expect(showsMetaLine(session({ activity_since: fresh, activity: "Error" }))).toBe(true);
});
it("needs no activity timestamp when another trigger holds", () => {
expect(showsMetaLine(session({ activity_since: undefined, status: "Dead" }))).toBe(true);
});
});
+56
View File
@@ -0,0 +1,56 @@
// docs/SIDEBAR.md: the session card's meta line — activity colour mapping and
// the shared duration format. Both clients compute it identically; the TUI
// mirrors this in crates/maestro-tui.
import type { ActivityState, IsoTimestamp, Session } from "./types.js";
import { sessionBlockers } from "./types.js";
/** A state held shorter than this keeps the card to one line (contract rule 1). */
const META_LINE_MIN_HOLD_MS = 30_000;
// Payloads predating #102 default `activity_since` to the Unix epoch; anything
// implausibly old is treated as "never observed" rather than a huge duration.
const PLAUSIBLE_EPOCH_MS = Date.UTC(2010, 0, 1);
export function activityHeldMs(since: IsoTimestamp | undefined, now: number): number | null {
if (!since) return null;
const t = Date.parse(since);
if (Number.isNaN(t) || t < PLAUSIBLE_EPOCH_MS) return null;
return Math.max(0, now - t);
}
/** `<60s` → whole seconds, `<60m` → whole minutes, else whole hours. Floored
* everywhere so the counter never shows a duration the state hasn't reached. */
export function activityDurationLabel(heldMs: number): string {
const s = Math.floor(heldMs / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
return `${Math.floor(m / 60)}h`;
}
/** CSS hook for the state's colour (`styles.css` maps slugs to tokens). An
* unknown variant degrades to idle so rendering never fails (Precedence). */
export function activityColorSlug(state: ActivityState | string): string {
switch (state) {
case "Active":
return "active";
case "AwaitingInput":
return "awaiting-input";
case "Error":
return "error";
default:
return "idle";
}
}
/** Line 2 renders only when at least one contract trigger holds. */
export function showsMetaLine(s: Session, now: number = Date.now()): boolean {
const held = activityHeldMs(s.activity_since, now);
return (
(held !== null && held >= META_LINE_MIN_HOLD_MS) ||
sessionBlockers(s) !== null ||
s.status === "Dead" ||
s.activity === "Error"
);
}
+119
View File
@@ -104,3 +104,122 @@ describe("blocked-work indicator (#83)", () => {
expect(confirmations).toEqual(['Kill session "claude"?']);
});
});
describe("session cards (#107)", () => {
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
it("keeps the name and the actions in the same flow line, actions reserving their width", () => {
const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000010" });
const el = renderSidebar(storeWith([s])).el;
const line1 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line1");
expect(line1).not.toBeNull();
// Same flex line ⇒ the name truncates before it can ever reach the buttons.
expect(line1?.querySelector(".leaf-name")).not.toBeNull();
expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild);
});
it("stays a single line while freshly active", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000011",
activity: "Active",
activity_since: ago(5_000),
});
const el = renderSidebar(storeWith([s])).el;
expect(rowFor(el, s.id)?.querySelector(".card-line2")).toBeNull();
});
it("shows dot + floored duration once the state has been held past 30s", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000012",
activity: "Active",
activity_since: ago(4 * 60_000 + 30_000),
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.dataset.activity).toBe("active");
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
});
it("maps each activity state to its contract slug", () => {
const mk = (id: string, activity: string) =>
session({
id,
activity: activity as Session["activity"],
status: "Running",
activity_since: ago(90_000),
});
const idle = mk("aaaaaaaa-0000-0000-0000-000000000013", "Idle");
const wait = mk("aaaaaaaa-0000-0000-0000-000000000014", "AwaitingInput");
const error = mk("aaaaaaaa-0000-0000-0000-000000000015", "Error");
const el = renderSidebar(storeWith([idle, wait, error])).el;
expect(rowFor(el, idle.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"idle",
);
expect(rowFor(el, wait.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
expect(rowFor(el, error.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"error",
);
});
it("treats an unknown activity variant as idle instead of failing to render", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000016",
activity: "Teleported" as Session["activity"],
activity_since: ago(120_000),
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector(".card-line2");
expect(line2?.getAttribute("data-activity")).toBe("idle");
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("2m");
});
it("moves blocked work onto line 2 in the shared phrasing", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000017",
blocked_work: { uncommitted_files: 3, unpushed_commits: 2 },
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.textContent).toContain("⚠");
expect(line2?.textContent).toContain("3 uncommitted files, 2 unpushed commits");
// Line 1 carries no badges.
expect(rowFor(el, s.id)?.querySelector(".leaf-badge")).toBeNull();
});
it("shows exited on line 2 for dead sessions", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000018",
status: "Dead",
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.textContent).toContain("exited");
});
it("carries the state word only in tooltips and aria labels, never as text", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000019",
command: "claude",
metadata: { name: "worker" },
activity: "AwaitingInput",
activity_since: ago(90_000),
});
const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id) as HTMLElement;
const dot = row.querySelector<HTMLElement>(".activity-dot");
expect(dot?.getAttribute("title")).toContain("Awaiting input");
expect(dot?.getAttribute("aria-label")).toContain("Awaiting input");
expect(row.textContent).not.toMatch(/awaiting.?input/i);
});
it("keeps the attach navigation and depth indentation", () => {
const s = session({ id: "aaaaaaaa-0000-0000-0000-00000000001a" });
const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id) as HTMLAnchorElement;
expect(row.getAttribute("href")).toBe("#/attach/aaaaaaaa-0000-0000-0000-00000000001a");
expect(row.style.paddingLeft).toMatch(/^\d+(\.\d+)?px$/);
});
});
+63 -15
View File
@@ -4,6 +4,7 @@ import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
import { CatalogStore } from "./events.js";
import type { Session } from "./types.js";
import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js";
import { activityColorSlug, activityDurationLabel, activityHeldMs, showsMetaLine } from "./activity.js";
import type { TreeNode } from "./tree.js";
import { buildTree } from "./tree.js";
import { createThemeToggle } from "./theme.js";
@@ -17,6 +18,41 @@ export function requestOpenSpawn(): void {
const collapsed = new Set<string>();
// docs/SIDEBAR.md: the state word is never rendered — the dot's colour carries
// it. It survives only in tooltips/aria labels so meaning is not colour-only.
const STATE_WORDS: Record<string, string> = {
active: "Active",
idle: "Idle",
"awaiting-input": "Awaiting input",
error: "Error",
};
function metaLine(s: Session, now: number): string {
const name = sessionName(s);
const dead = s.status === "Dead";
const blockers = sessionBlockers(s);
const slug = activityColorSlug(s.activity ?? "Idle");
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
const held = activityHeldMs(s.activity_since, now);
const duration = held !== null ? activityDurationLabel(held) : null;
// The directory is the enclosing folder, so only surface the command — and
// only when it isn't already what the name shows.
const showCmd = s.command !== name;
const fragments = [
`<span class="activity-dot" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`,
duration ? `<span class="activity-duration">${duration}</span>` : "",
blockers
? `<span class="card-flag leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>`
: "",
dead ? `<span class="card-flag">exited</span>` : "",
showCmd
? `<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</span>`
: "",
];
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
}
function renderNode(node: TreeNode, depth: number): HTMLElement {
if (node.kind === "folder") {
const wrap = document.createElement("div");
@@ -52,30 +88,34 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
}
const s = node.session;
const now = Date.now();
const row = document.createElement("a");
row.className = "tree-leaf";
row.className = "tree-leaf session-card";
row.href = `#/attach/${encodeURIComponent(s.id)}`;
row.style.paddingLeft = `${depth * 16 + 8}px`;
row.dataset.sessionId = s.id;
const running = s.status === "Running";
row.dataset.status = running ? "running" : "dead";
const name = sessionName(s);
row.setAttribute("aria-label", `${name}, ${running ? "running" : "exited"}`);
// The directory is the enclosing folder, so only surface the command — and
// only when it isn't already what the name shows.
const showCmd = s.command !== name;
// #83: warn on the row itself, in `formatInTheWay`'s shared phrasing, so
// "this will be refused" is visible before the kill is attempted.
const blockers = sessionBlockers(s);
const stateWord =
STATE_WORDS[activityColorSlug(s.activity ?? "Idle")] ?? STATE_WORDS.idle;
row.setAttribute(
"aria-label",
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
);
// docs/SIDEBAR.md line 1: name + top-right actions in the same flow line —
// the actions reserve their width, so the name truncates before it can ever
// reach them. Line 2 renders only when a contract trigger holds.
const showMeta = showsMetaLine(s, now);
row.innerHTML = `
<span class="leaf-name">${escapeHtml(name)}</span>
${showCmd ? `<span class="leaf-meta"><span class="leaf-cmd">${escapeHtml(s.command)}</span></span>` : ""}
${running ? "" : `<span class="leaf-badge">exited</span>`}
${blockers ? `<span class="leaf-badge leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>` : ""}
<span class="leaf-actions">
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
<span class="card-line1">
<span class="leaf-name">${escapeHtml(name)}</span>
<span class="leaf-actions">
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
</span>
</span>
${showMeta ? metaLine(s, now) : ""}
`;
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
similarBtn.addEventListener("click", (ev) => {
@@ -395,6 +435,13 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const unsubscribe = store.subscribe(refresh);
// Contract granularity: re-render at least every 30s while visible so the
// duration cutover points land within one tick.
const clock = setInterval(() => {
if (document.visibilityState !== "visible") return;
refresh();
}, 30_000);
return {
el,
setActive(id: string | null) {
@@ -410,6 +457,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
applyHighlights();
},
dispose() {
clearInterval(clock);
document.removeEventListener("catalog-rerender", rerenderHandler);
unsubscribe();
},
+66 -18
View File
@@ -59,20 +59,28 @@ function leafRow(opts: {
status: "running" | "dead";
active?: boolean;
indent?: number;
activity?: "active" | "idle" | "awaiting-input" | "error";
duration?: string;
}): HTMLElement {
const row = h("a", "tree-leaf");
const row = h("a", "tree-leaf session-card");
(row as HTMLAnchorElement).href = "#/kit";
row.dataset.status = opts.status;
if (opts.active) row.classList.add("is-active");
row.style.paddingLeft = `${(opts.indent ?? 0) * 16 + 8}px`;
const flags = [
opts.status === "dead" ? `<span class="card-flag">exited</span>` : "",
opts.cmd !== opts.name ? `<span class="card-flag leaf-cmd">${opts.cmd}</span>` : "",
].join("");
row.innerHTML = `
<span class="leaf-name">${opts.name}</span>
${opts.cmd !== opts.name ? `<span class="leaf-meta"><span class="leaf-cmd">${opts.cmd}</span></span>` : ""}
${opts.status === "dead" ? `<span class="leaf-badge">exited</span>` : ""}
<span class="leaf-actions">
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${opts.name}">⧉</button>
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${opts.name}">✕</button>
</span>`;
<span class="card-line1">
<span class="leaf-name">${opts.name}</span>
<span class="leaf-actions">
<button class="leaf-act leaf-clone" type="button" title="Spawn similar">⧉</button>
<button class="leaf-act leaf-kill" type="button" title="Kill session">✕</button>
</span>
</span>
${opts.activity ? `<span class="card-line2" data-activity="${opts.activity}"><span class="activity-dot" title="${opts.activity}"></span><span class="activity-duration">${opts.duration ?? ""}</span>${flags}</span>` : ""}
`;
return row;
}
@@ -190,14 +198,17 @@ export function renderKit(): HTMLElement {
// --- chips & badges ---
{
const { el, body } = section("Chips & badges", "Status-bearing pills and counts.");
const activeLine = h(
"span",
"card-line2",
`<span class="activity-dot"></span><span class="activity-duration">4m</span>`,
);
activeLine.setAttribute("data-activity", "active");
body.append(
specimen("session-tally", h("span", "session-tally", "6 sessions · 5 live")),
specimen("folder-count", h("span", "folder-count", "4")),
specimen("leaf-badge", h("span", "leaf-badge", "exited")),
specimen(
"attach-state · open",
h("span", "attach-state", "live"),
),
specimen("card-line2 · active", activeLine),
specimen("attach-state · open", h("span", "attach-state", "live")),
);
(body.lastElementChild?.querySelector(".attach-state") as HTMLElement)?.setAttribute(
"data-state",
@@ -215,19 +226,56 @@ export function renderKit(): HTMLElement {
{
const { el, body } = section(
"Session rows — nested by host, then directory",
"The working directory (home collapsed to ~) is the nesting, and single-child chains fold into one node like ~/git/maestro. “Alive” is a faint row wash; exited rows recede with a badge; the open session is highlighted.",
"docs/SIDEBAR.md cards: line 1 is name + top-right actions (the name truncates before it can reach them); line 2 only when there's something to say — dot + duration in the state's colour, then blocked-work / exited fragments.",
);
const list = h("div", "kit-tree");
list.append(
folderHeader("~/git/maestro", 3, 0),
folderHeader("web", 2, 1),
leafRow({ name: "redesign", cmd: "claude", status: "running", indent: 2 }),
leafRow({ name: "dev-server", cmd: "vite dev", status: "running", indent: 2, active: true }),
leafRow({
name: "dev-server",
cmd: "vite dev",
status: "running",
indent: 2,
active: true,
activity: "active",
duration: "4m",
}),
folderHeader("crates/daemon", 1, 1),
leafRow({ name: "daemon-work", cmd: "claude", status: "running", indent: 2 }),
leafRow({
name: "daemon-work",
cmd: "claude",
status: "running",
indent: 2,
activity: "awaiting-input",
duration: "12s",
}),
leafRow({
name: "flaky-agent",
cmd: "claude",
status: "running",
indent: 2,
activity: "error",
duration: "2h",
}),
folderHeader("~/work/acme", 2, 0),
leafRow({ name: "billing-api", cmd: "claude", status: "running", indent: 1 }),
leafRow({ name: "scratch", cmd: "bash", status: "dead", indent: 1 }),
leafRow({
name: "billing-api",
cmd: "claude",
status: "running",
indent: 1,
activity: "idle",
duration: "26m",
}),
leafRow({
name: "scratch",
cmd: "bash",
status: "dead",
indent: 1,
activity: "idle",
duration: "5h",
}),
leafRow({ name: "monitor", cmd: "btop", status: "running", indent: 0 }),
);
body.classList.add("kit-tree-host");
+5 -1
View File
@@ -272,7 +272,11 @@ function showMultiView(): void {
// cloned session's dir + command.
function requestOpenSpawn(): void {
showMultiView();
mv.openDraft();
// A new tab, not a split of the pane you were reading (#100). "Spawn
// similar" and the fast-forward key below deliberately keep the old
// behaviour: both act on the session in front of you, so a split beside it
// is the point.
mv.openDraft(undefined, { newTab: true });
}
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
document.addEventListener("catalog-spawn-similar", (ev) => {
+173
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,134 @@ 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");
});
});
describe("openDraft newTab (#100)", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("opens its own tab instead of splitting the focused session", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
tab.focusedLeafId = first;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.openDraft(undefined, { newTab: true });
await flush();
expect(layout.tabs).toHaveLength(2);
// The tab you were reading is untouched: still one pane, still its session.
const original = leaves(layout.tabs[0].root);
expect(original).toHaveLength(1);
expect(original[0].sessionId).toBe("s1");
// The draft is alone in the new, now-active tab.
const fresh = leaves(layout.tabs[1].root);
expect(fresh).toHaveLength(1);
expect(fresh[0].sessionId).toBeNull();
expect(fresh[0].draft).not.toBeNull();
expect(layout.activeTabId).toBe(layout.tabs[1].id);
});
it("still splits when newTab is not asked for", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.openDraft();
await flush();
expect(layout.tabs).toHaveLength(1);
expect(leaves(tab.root)).toHaveLength(2);
});
});
+30 -5
View File
@@ -57,7 +57,7 @@ export interface MultiView {
/** Open a draft pane (issue #62): reuse the focused pane if it is already
* empty, otherwise split the focused pane. `seed` pre-fills the form
* ("Spawn similar" clones a session's cwd + command). */
openDraft: (seed?: Partial<Draft>) => void;
openDraft: (seed?: Partial<Draft>, opts?: { newTab?: boolean }) => void;
/** Start dragging a sidebar session into the canvas (issue #32). The
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
* drag shows drop zones to place it. */
@@ -412,9 +412,18 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
opts.onOpenSessionsChange?.();
}
function openDraft(seed?: Partial<Draft>): void {
const tab = activeTab(layout);
function openDraft(seed?: Partial<Draft>, opts?: { newTab?: boolean }): void {
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
if (opts?.newTab) {
// Spawn opens its own tab rather than halving whatever you were reading
// (#100). The empty-pane reuse below still applies within a tab.
const fresh = addTab(layout);
fresh.focusedLeafId = splitLeaf(fresh, "", null, "row", true);
findLeaf(fresh.root!, fresh.focusedLeafId)!.draft = draft();
render();
return;
}
const tab = activeTab(layout);
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
// Empty tab (or stale focus): the draft becomes the first pane.
tab.focusedLeafId = splitLeaf(tab, "", null, "row", true);
@@ -423,9 +432,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 +710,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);
+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 () => {
+294 -84
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). */
@@ -94,6 +94,11 @@ export const FALLBACK_PRESETS: SpawnPreset[] = [
{ id: "custom", label: "Custom…", command: "" },
];
/** Quiet period after raw-path typing before `/api/gitroot` fires (#74).
* Must stay equal to `GITROOT_PROBE_DEBOUNCE_MS` in
* crates/maestro-tui/src/draft.rs so both clients debounce alike. */
const GITROOT_PROBE_DEBOUNCE_MS = 250;
export interface SpawnFormHandle {
el: HTMLFormElement;
/** Remove global listeners (dir autocomplete) — call when unmounting. */
@@ -125,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">
@@ -142,33 +152,38 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
</div>
</div>
<fieldset class="worktree">
<legend>
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
<span class="chevron">▾</span>
<span class="folder-name">Advanced options</span>
<span class="wt-summary"></span>
</button>
<div class="wt-collapsible" hidden>
<label class="row-inline">
<input type="checkbox" name="worktree_enabled" />
Create git worktree
</label>
</legend>
<p class="wt-hint" hidden></p>
<div class="wt-body" hidden>
<p class="wt-repo"></p>
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="new" />
New branch
<p class="wt-hint" hidden></p>
<div class="wt-body" hidden>
<p class="wt-repo"></p>
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="new" />
New branch
</label>
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="existing" />
Existing branch
</label>
</div>
<label class="wt-new-wrap">
New branch name
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
</label>
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="existing" />
Existing branch
<label class="wt-exist-wrap" hidden>
Pick a branch
<select class="branch-select"></select>
</label>
</div>
<label class="wt-new-wrap">
Branch
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
</label>
<label class="wt-exist-wrap" hidden>
Branch
<select class="branch-select"></select>
</label>
</div>
</fieldset>
<p class="form-error" role="alert" aria-live="polite"></p>
@@ -182,11 +197,17 @@ 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;
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;
@@ -337,6 +358,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) {
@@ -345,13 +375,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();
}
@@ -367,7 +407,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
if (info.presets?.length) {
presets = info.presets;
renderCommandTiles();
applyInitialCommand();
if (commandTouched) {
reapplyActiveTile();
} else {
applyInitialCommand();
}
}
})
.catch(() => {
@@ -394,12 +438,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();
@@ -413,55 +481,98 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwd = cwdRaw.value.trim();
branchCache = null;
emit();
if (wtEnabled.checked) probeGitRoot();
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.
wtEnabled.checked = true;
applyProjectRepo(project.base_repo ?? project.path);
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;
updateWtSummary();
applyProjectRepo(group.main.base_repo ?? group.main.path);
} else {
wtEnabled.checked = false;
updateWtSummary();
inferredBaseRepo = null;
wtBody.hidden = true;
wtHint.hidden = true;
}
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
/** Groups with at least one member (itself or a worktree) matching `query`. */
function visibleGroups(query: string): ProjectGroup[] {
return groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0);
}
function renderProjects(): void {
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 visible = visibleGroups(filterInput.value);
for (const group of visible) projectsEl.appendChild(projectBox(group));
if (discovered.length > 0 && visible.length === 0) {
const empty = document.createElement("p");
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 {
@@ -472,12 +583,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);
}
@@ -494,34 +606,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 {
@@ -531,6 +675,18 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
return p;
}
// ---- worktree: disclosure (#93), reuses the sidebar's folder-header ---
let advancedCollapsed = true;
function setAdvancedCollapsed(collapsed: boolean): void {
advancedCollapsed = collapsed;
wtToggle.setAttribute("aria-expanded", String(!collapsed));
wtCollapsible.hidden = collapsed;
}
function updateWtSummary(): void {
wtSummary.textContent = wtEnabled.checked ? "creates a worktree" : "no worktree";
}
wtToggle.addEventListener("click", () => setAdvancedCollapsed(!advancedCollapsed));
// ---- worktree (branch-only; base_repo inferred via scan or /gitroot) --
// The project scan already reports repo membership, so a picked project
// resolves its base repo locally (#61); only the free-text escape hatch
@@ -569,7 +725,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
applyProjectRepo(project.base_repo ?? project.path);
return;
}
void probeGitRoot();
scheduleGitRootProbe();
}
// Every keystroke resets this timer, so a burst of typing issues one
// request once the operator pauses. `probeToken` inside probeGitRoot still
// owns ordering: a scheduled probe that fires after a newer one started
// loses the token race and is dropped.
let probeTimer: number | undefined;
function scheduleGitRootProbe(): void {
window.clearTimeout(probeTimer);
probeTimer = window.setTimeout(() => {
probeTimer = undefined;
void probeGitRoot();
}, GITROOT_PROBE_DEBOUNCE_MS);
}
async function probeGitRoot(): Promise<void> {
@@ -598,6 +767,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// Publish the untick too, or the next render seeds the box checked with
// no inferred repo and submitting fails.
wtEnabled.checked = false;
updateWtSummary();
emit();
wtBody.hidden = true;
wtHint.hidden = false;
@@ -671,6 +841,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
branchSelect.addEventListener("change", () => emit());
wtEnabled.addEventListener("change", () => {
updateWtSummary();
emit();
if (wtEnabled.checked) {
resolveBaseRepo();
@@ -684,6 +855,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// The toggle defaults ON (#61): the operator almost always wants a
// worktree, and every non-repo target unticks it via the gitroot probe.
wtEnabled.checked = prefill.worktreeEnabled ?? true;
updateWtSummary();
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
setBranchMode(branchMode);
@@ -694,6 +866,43 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
const autocomplete = attachDirAutocomplete(cwdRaw);
// ---- Enter submits from anywhere (#97) --------------------------------
// A project-box/wt-choice that isn't yet the active pick takes the Enter to
// choose it instead; the next Enter, now that it's active, submits.
// dirAutocomplete's own Enter (accept the highlight) already preventDefaults
// when it handles the key, which the `e.defaultPrevented` guard defers to.
// The worktree checkbox keeps Enter as a toggle (TUI parity: `WorktreeToggle`
// does the same) rather than folding it into the blanket-submit rule below.
form.addEventListener("keydown", (e) => {
if (e.key !== "Enter" || e.defaultPrevented) return;
const target = e.target as HTMLElement;
if (target === wtEnabled) {
e.preventDefault();
wtEnabled.checked = !wtEnabled.checked;
wtEnabled.dispatchEvent(new Event("change"));
return;
}
if (target instanceof HTMLButtonElement) {
if (!target.classList.contains("project-box") && !target.classList.contains("wt-choice")) return;
e.preventDefault();
if (!target.classList.contains("is-active")) {
target.click();
return;
}
form.requestSubmit();
return;
}
e.preventDefault();
if (target === filterInput) {
const top = visibleGroups(filterInput.value)[0];
if (top && top.main.path !== selectedGroup?.main.path) selectProject(top);
}
form.requestSubmit();
});
// ---- submit ----------------------------------------------------------
form.addEventListener("submit", async (e) => {
e.preventDefault();
@@ -730,6 +939,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
el: form,
destroy() {
destroyed = true;
window.clearTimeout(probeTimer);
autocomplete.destroy();
form.remove();
},
+258
View File
@@ -0,0 +1,258 @@
// @vitest-environment happy-dom
// Enter submits the draft form from any field, at any point (#97). The two
// exceptions: a picker button that isn't yet the active choice takes the
// Enter to *choose* it instead (the next Enter submits), and the project
// filter must resolve to the highlighted project rather than sending
// whatever cwd was already set.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
import type { Project } from "./types.js";
const MAESTRO: Project = {
path: "/home/user/tea/maestro",
name: "maestro",
is_worktree: false,
last_activity: "2026-08-20T10:00:00Z",
};
const WT_A: Project = {
path: "/home/user/tea/maestro/worktrees/bulgarians",
name: "bulgarians",
is_worktree: true,
base_repo: MAESTRO.path,
last_activity: "2026-08-22T10:00:00Z",
};
const OTHER: Project = {
path: "/home/user/tea/rev",
name: "rev",
is_worktree: false,
last_activity: "2026-08-19T10:00:00Z",
};
function stubFetch(projects: Project[]): void {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const body = url.includes("/api/projects")
? projects
: url.includes("/api/names")
? { names: ["amber-fox"] }
: url.includes("/api/branches")
? []
: url.includes("/api/dirs")
? { path: "/opt", entries: ["nomatch-zzz"], truncated: false }
: { hostname: "h", version: "0", home_dir: "/home/user", presets: [] };
return new Response(JSON.stringify(body), { status: 200 });
}),
);
}
describe("spawnForm Enter-to-submit (#97)", () => {
let handle: SpawnFormHandle | null = null;
beforeEach(() => {
vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") });
});
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
vi.useRealTimers();
});
const drain = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
async function mount(projects: Project[]): Promise<HTMLFormElement> {
stubFetch(projects);
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
await drain();
return handle.el;
}
function submitSpy(): ReturnType<typeof vi.fn> {
return vi.fn();
}
function pressEnter(el: HTMLElement): { defaultPrevented: boolean } {
const evt = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
el.dispatchEvent(evt);
return { defaultPrevented: evt.defaultPrevented };
}
const box = (form: HTMLFormElement, path: string) =>
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
it("toggles the worktree checkbox on Enter instead of submitting (TUI parity)", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
await drain();
const toggle = form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
expect(toggle.checked).toBe(true);
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(toggle);
expect(toggle.checked).toBe(false);
expect(spy).not.toHaveBeenCalled();
pressEnter(toggle);
expect(toggle.checked).toBe(true);
expect(spy).not.toHaveBeenCalled();
});
describe("submits immediately from ordinary fields", () => {
it("name field", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
await drain();
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(form.querySelector<HTMLInputElement>("input[name=name]")!);
expect(spy).toHaveBeenCalledTimes(1);
});
it("worktree branch field", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
await drain();
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(form.querySelector<HTMLInputElement>("input[name=worktree_branch]")!);
expect(spy).toHaveBeenCalledTimes(1);
});
it("custom command field", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
await drain();
form.querySelector<HTMLButtonElement>('.cmd-tiles .tile[data-id="custom"]')!.click();
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
raw.value = "bash -l";
raw.dispatchEvent(new Event("input"));
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(raw);
expect(spy).toHaveBeenCalledTimes(1);
});
it("raw cwd field once the autocomplete dropdown is closed", async () => {
const form = await mount([MAESTRO]);
form.querySelector<HTMLButtonElement>(".project-other")!.click();
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.value = "/opt/xyz-nothing-here";
raw.dispatchEvent(new Event("input"));
await vi.advanceTimersByTimeAsync(300);
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(raw);
expect(spy).toHaveBeenCalledTimes(1);
});
it("does not submit while the raw cwd autocomplete has a highlighted suggestion", async () => {
const form = await mount([MAESTRO]);
form.querySelector<HTMLButtonElement>(".project-other")!.click();
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.focus();
raw.value = "/opt/";
raw.dispatchEvent(new Event("input", { bubbles: true }));
await vi.advanceTimersByTimeAsync(150);
const spy = submitSpy();
form.addEventListener("submit", spy);
const { defaultPrevented } = pressEnter(raw);
expect(defaultPrevented).toBe(true);
expect(spy).not.toHaveBeenCalled();
expect(raw.value).toBe("/opt/nomatch-zzz/");
});
});
describe("two-step picker: first Enter chooses, second submits", () => {
it("project box", async () => {
const form = await mount([MAESTRO]);
const b = box(form, MAESTRO.path);
b.focus();
const spy = submitSpy();
form.addEventListener("submit", spy);
const first = pressEnter(b);
expect(first.defaultPrevented).toBe(true);
expect(b.classList.contains("is-active")).toBe(true);
expect(spy).not.toHaveBeenCalled();
pressEnter(b);
expect(spy).toHaveBeenCalledTimes(1);
});
it("worktree choice", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
await drain();
const choice = form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!;
expect(choice.classList.contains("is-active")).toBe(false); // "+ new" is the default
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(choice);
expect(choice.classList.contains("is-active")).toBe(true);
expect(spy).not.toHaveBeenCalled();
pressEnter(choice);
expect(spy).toHaveBeenCalledTimes(1);
});
});
describe("project filter", () => {
it("submits with the highlighted (first matching) project, not the filter text", async () => {
const form = await mount([MAESTRO, OTHER]);
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
filter.value = "rev";
filter.dispatchEvent(new Event("input"));
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(filter);
expect(spy).toHaveBeenCalledTimes(1);
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
});
it("does not clobber an already-chosen worktree when the filter still matches it", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
await drain();
form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.click();
await drain();
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
filter.value = "maestro";
filter.dispatchEvent(new Event("input"));
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(filter);
expect(spy).toHaveBeenCalledTimes(1);
expect(
form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.classList.contains(
"is-active",
),
).toBe(true);
});
});
});
+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();
});
});
+148
View File
@@ -0,0 +1,148 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
/** Debounce window under test — must equal GITROOT_PROBE_DEBOUNCE_MS in
* spawnForm.ts (#74). */
const DEBOUNCE_MS = 250;
const PROJECTS = [
{ path: "/home/user/maestro", name: "maestro", last_activity: "2026-08-20T10:00:00Z" },
];
/** Stub the fetches the form makes. Gitroot responses for a given path can be
* held until `releaseGitRoot(path)` is called, so tests can land answers out
* of order. */
function stubFetch(opts?: { holdGitRootFor?: string[] }): {
urls: string[];
gitRootCalls: string[];
releaseGitRoot: (path: string) => void;
} {
const urls: string[] = [];
const gitRootCalls: string[] = [];
const releases = new Map<string, () => void>();
const held = new Map<string, Promise<void>>();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
urls.push(url);
if (url.includes("/api/dirs")) {
return new Response(JSON.stringify({ entries: [] }), { status: 200 });
}
if (url.includes("/api/gitroot")) {
const path = new URL(url, "http://localhost").searchParams.get("path") ?? "";
gitRootCalls.push(path);
if (opts?.holdGitRootFor?.includes(path)) {
let release!: () => void;
held.set(
path,
new Promise<void>((r) => {
release = r;
}),
);
releases.set(path, release);
}
await held.get(path);
return new Response(JSON.stringify({ root: path }), { status: 200 });
}
if (url.includes("/api/names")) {
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
}
if (url.includes("/api/projects")) {
return new Response(JSON.stringify(PROJECTS), { status: 200 });
}
return new Response(
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
{ status: 200 },
);
}),
);
return {
urls,
gitRootCalls,
releaseGitRoot: (path: string) => releases.get(path)?.(),
};
}
describe("spawnForm gitroot probe debounce (#74)", () => {
let handle: SpawnFormHandle | null = null;
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
vi.useRealTimers();
});
async function mount(opts?: Parameters<typeof stubFetch>[0]) {
const stub = stubFetch(opts);
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
return { form: handle.el, stub };
}
const drain = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
function typeRaw(form: HTMLFormElement, value: string): void {
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.value = value;
raw.dispatchEvent(new Event("input"));
}
it("a keystroke burst fires exactly one probe after the quiet period", async () => {
const { form, stub } = await mount();
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));
await drain();
expect(stub.gitRootCalls).toEqual([]);
}
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
await drain();
expect(stub.gitRootCalls).toEqual(["/opt/somewhere"]);
expect(stub.urls.filter((u) => u.includes("/api/gitroot")).length).toBe(1);
});
it("a stale probe answer cannot overwrite a newer input", async () => {
const { form, stub } = await mount({ holdGitRootFor: ["/repo/old"] });
form.querySelector<HTMLElement>(".project-other")!.click();
// Probe for the old target, held in flight.
typeRaw(form, "/repo/old");
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
// Newer target supersedes it and answers immediately.
typeRaw(form, "/repo/new");
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
await drain();
expect(stub.gitRootCalls).toEqual(["/repo/old", "/repo/new"]);
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
expect(
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked,
).toBe(true);
// The late answer to the old target lands last but must lose.
stub.releaseGitRoot("/repo/old");
await drain();
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
expect(
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked,
).toBe(true);
});
});
+105 -6
View File
@@ -18,6 +18,7 @@ const BRANCHES = [
* its branch list is still in flight. */
function stubFetch(opts?: {
gateBranches?: (release: () => void) => void;
projects?: unknown[];
}): string[] {
const urls: string[] = [];
const gated = new Promise<void>((resolve) => {
@@ -37,7 +38,7 @@ function stubFetch(opts?: {
return new Response(JSON.stringify(BRANCHES), { status: 200 });
}
if (url.includes("/api/projects")) {
return new Response(JSON.stringify(PROJECTS), { status: 200 });
return new Response(JSON.stringify(opts?.projects ?? PROJECTS), { status: 200 });
}
return new Response(
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
@@ -79,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 () => {
@@ -100,14 +101,44 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
);
});
it("defaults the worktree toggle off for a row that is itself a worktree (#91)", async () => {
urls = stubFetch({
projects: [
{
path: "/home/user/wt-91",
name: "wt-91",
is_worktree: true,
base_repo: "/home/user/maestro",
last_activity: "2026-08-20T10:00:00Z",
},
],
});
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
handle.el
.querySelectorAll<HTMLButtonElement>(".project-box")
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
await drain();
expect(handle.el.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked).toBe(
false,
);
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
expect(handle.el.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
});
it("still probes the gitroot for a free-text directory", async () => {
const form = await mount();
// Escape hatch: switch to a free-text dir; only now does /api/gitroot fire.
form.querySelector<HTMLElement>(".project-row.project-other")!.click();
// 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-other")!.click();
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.value = "/opt/somewhere";
raw.dispatchEvent(new Event("input"));
await vi.advanceTimersByTimeAsync(250);
await drain();
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(true);
@@ -149,6 +180,74 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
expect(modes.at(-1)).toBe("existing");
});
it("labels the new-branch input and existing-branch picker distinctly (#94)", async () => {
const form = await mount();
pickProject(form);
await drain();
const newLabel = form.querySelector<HTMLElement>(".wt-new-wrap")!.textContent!.trim();
const existLabel = form.querySelector<HTMLElement>(".wt-exist-wrap")!.textContent!.trim();
expect(newLabel).not.toBe("Branch");
expect(existLabel).not.toBe("Branch");
expect(newLabel).not.toBe(existLabel);
expect(newLabel).toMatch(/name/i);
expect(existLabel).toMatch(/pick|choose|select/i);
});
it("collapses the advanced (worktree) section by default (#93)", async () => {
const form = await mount();
const toggle = form.querySelector<HTMLButtonElement>(".wt-toggle")!;
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(true);
});
it("states in the collapsed header that a worktree will be created by default (#93)", async () => {
const form = await mount();
expect(form.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/creates? a worktree/i);
});
it("states in the collapsed header that no worktree will be created for a worktree row (#91, #93)", async () => {
urls = stubFetch({
projects: [
{
path: "/home/user/wt-91",
name: "wt-91",
is_worktree: true,
base_repo: "/home/user/maestro",
last_activity: "2026-08-20T10:00:00Z",
},
],
});
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
handle.el
.querySelectorAll<HTMLButtonElement>(".project-box")
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
await drain();
expect(handle.el.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/no worktree/i);
// Still collapsed — picking a project must not force it open.
expect(handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
"false",
);
});
it("expanding the advanced section does not reorder the fields above it (#93)", async () => {
const form = await mount();
const before = [...form.querySelectorAll(".field")];
form.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
expect(form.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
"true",
);
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(false);
expect([...form.querySelectorAll(".field")]).toStrictEqual(before);
});
it("does not repopulate a destroyed form when the branch list lands late", async () => {
const gate: { release?: () => void } = {};
urls = stubFetch({ gateBranches: (r) => (gate.release = r) });
+207 -83
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);
}
@@ -789,60 +787,85 @@ textarea {
font-weight: 400;
}
/* #107: session cards per docs/SIDEBAR.md — line 1 (name + top-right actions)
always, line 2 (meta) only when it has something to say. */
.tree-leaf.session-card {
flex-direction: column;
align-items: stretch;
gap: 1px;
}
.card-line1 {
display: flex;
align-items: center;
gap: var(--sp-2);
min-width: 0;
}
.leaf-name {
font-weight: 500;
font-size: var(--fs-13);
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leaf-meta {
font-size: var(--fs-11);
color: var(--muted);
margin-left: auto;
padding-left: var(--sp-2);
/* Grows to push the actions to the right edge but shrinks first, so the name
truncates before it can ever reach them — overlap is a defect (#107). */
flex: 1 1 auto;
min-width: 0;
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-flex;
align-items: baseline;
}
/* Line 2 — dot + duration in the state's colour, then muted fragments. The
state word itself is never spelled out here; the colour carries it. */
.card-line2 {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.leaf-cmd {
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leaf-badge {
font-size: var(--fs-11);
letter-spacing: 0.02em;
text-transform: uppercase;
min-width: 0;
color: var(--muted);
border: 1px solid var(--border);
border-radius: var(--r-pill);
padding: 1px 8px;
}
.card-line2[data-activity="active"] {
color: var(--live);
}
.card-line2[data-activity="idle"] {
color: var(--muted);
}
.card-line2[data-activity="awaiting-input"] {
color: var(--warn);
}
.card-line2[data-activity="error"] {
color: var(--danger);
}
.activity-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
flex: 0 0 auto;
}
/* #83: the session row's blocked-work warning — an unforced kill will be
refused, and the operator should see that before pressing the button. */
.leaf-badge.leaf-blocked {
color: var(--warn, #b45309);
border-color: var(--warn, #b45309);
.activity-duration {
color: inherit;
flex: 0 0 auto;
}
/* Blocked-work / exited / command fragments recede: the ⚠ glyph already
signals severity and "exited" dims with the row. */
.card-flag {
color: var(--faint);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Actions live in line 1's flow at its right edge — never overlaid on the
name (#107). They reserve their width even while hover-faded. */
.leaf-actions {
position: absolute;
right: var(--sp-1);
top: 50%;
transform: translateY(-50%);
flex: 0 0 auto;
margin-left: auto;
display: inline-flex;
gap: 2px;
}
@@ -1215,6 +1238,9 @@ textarea {
/* ---------- forms ---------- */
.spawn-form {
width: 100%;
max-width: 60ch;
margin: 0 auto;
padding: var(--sp-3);
overflow: auto;
display: flex;
@@ -1283,18 +1309,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 {
@@ -1378,8 +1419,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;
@@ -1394,73 +1436,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 {
+56
View File
@@ -95,3 +95,59 @@ describe("mergeLinear", () => {
expect(merged.path).toBe("a/b");
});
});
describe("worktree sessions group under their repository (#99)", () => {
/** A session the daemon spawned into a worktree of `repo`. */
function worktreeSession(id: string, repo: string, wt: string): Session {
return { ...session(id, wt), worktree: { path: wt, base_repo: repo } } as Session;
}
/** Top-level folder names, after linear chains are collapsed. */
function topFolders(root: TreeFolder): string[] {
return root.children.filter((c) => c.kind === "folder").map((c) => (c as TreeFolder).name);
}
it("nests under the repo, not the worktree directory", () => {
const root = buildTree(
[
worktreeSession(
"a",
"/home/n/tea/agent-skills",
"/home/n/tea/agent-skills/worktrees/largo-clarinet",
),
],
"/home/n",
"",
) as TreeFolder;
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
const repo = root.children[0] as TreeFolder;
expect(repo.children).toHaveLength(1);
expect(repo.children[0].kind).toBe("leaf");
});
it("puts a repo's plain and worktree sessions side by side", () => {
const root = buildTree(
[
session("plain", "/home/n/tea/agent-skills"),
worktreeSession(
"wt",
"/home/n/tea/agent-skills",
"/home/n/tea/agent-skills/worktrees/largo-clarinet",
),
],
"/home/n",
"",
) as TreeFolder;
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
const repo = root.children[0] as TreeFolder;
expect(repo.children).toHaveLength(2);
expect(repo.children.every((c) => c.kind === "leaf")).toBe(true);
});
it("leaves a session without a worktree where it is", () => {
const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n", "") as TreeFolder;
expect(topFolders(root)).toEqual(["~/tea/arr"]);
});
});
+4 -2
View File
@@ -2,7 +2,7 @@
// so it can be tested directly.
import type { Session } from "./types.js";
import { abbreviateCwd, sessionCwd, sessionName } from "./types.js";
import { abbreviateCwd, sessionName, sessionTreePath } from "./types.js";
export interface TreeFolder {
kind: "folder";
@@ -30,7 +30,9 @@ export function buildTree(sessions: Session[], home: string | undefined, filter:
const lc = filter.trim().toLowerCase();
for (const s of sessions) {
const cwd = sessionCwd(s);
// Grouped by repository, so a worktree session lands under its repo
// rather than in a folder of its own (#99).
const cwd = sessionTreePath(s);
const dir = cwd ? abbreviateCwd(cwd, home) : "";
if (lc) {
const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
+30 -3
View File
@@ -9,8 +9,9 @@ export type IsoTimestamp = string;
export type SessionStatus = "Running" | "Dead";
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
* Absent on payloads from daemons that predate the field. */
export type ActivityState = "Active" | "Idle" | "AwaitingInput";
* Absent on payloads from daemons that predate the field. `Error` is only set
* when an agent reports failure (#105); the daemon never infers it. */
export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error";
export interface Session {
id: Uuid;
@@ -27,6 +28,11 @@ export interface Session {
activity?: ActivityState;
/** Wall-clock of the most recent observed PTY output (NAP-340). */
last_activity_at?: IsoTimestamp;
/** Wall-clock of when the session entered its current `activity` state
* (#102) — distinct from `last_activity_at`, which only tracks PTY output.
* Defaults to the Unix epoch on payloads from daemons that predate the
* field. */
activity_since?: IsoTimestamp;
/** The pane's *current* working directory, as the daemon's activity watcher
* observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir).
* Drives the directory-based session tree. Absent until first observed. */
@@ -36,6 +42,16 @@ export interface Session {
* without a worktree; all counts default to 0 on the wire for older
* daemons. */
blocked_work?: InTheWay | null;
/** The worktree the daemon created for this session, absent when it did not
* create one. `base_repo` is the repository's main checkout, which is what
* the session tree groups by (#99). */
worktree?: SessionWorktree | null;
}
/** A session's daemon-created worktree (`SessionWorktree` on the wire). */
export interface SessionWorktree {
path: string;
base_repo: string;
}
/** One directory's immediate subdirectories (NAP-329). */
@@ -231,12 +247,23 @@ export function sessionName(s: Session): string {
}
/** The directory a session lives in: its live `current_cwd` if the daemon has
* observed one, else the spawn `cwd`. Drives the directory-based session tree. */
* observed one, else the spawn `cwd`. */
export function sessionCwd(s: Session): string | undefined {
if (typeof s.current_cwd === "string" && s.current_cwd) return s.current_cwd;
return s.cwd || undefined;
}
/** Where a session sits in the directory tree. A worktree session belongs to
* its repository, not to the worktree (#99): otherwise `~/tea/agent-skills`
* grows a `~/tea/agent-skills/worktrees/largo-clarinet` folder holding one
* session of the same name, and the repository stops being where you look for
* the work. `base_repo` is the main checkout — the daemon resolves it. */
export function sessionTreePath(s: Session): string | undefined {
const repo = s.worktree?.base_repo;
if (typeof repo === "string" && repo) return repo;
return sessionCwd(s);
}
/** Collapse a daemon's home dir to `~`. `home` comes from `DaemonInfo.home_dir`;
* absent/empty leaves the path untouched. Mirrors the CLI's `abbreviate_dir`. */
export function abbreviateCwd(cwd: string, home?: string): string {
+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);
});
});