Compare commits

...
9 Commits
Author SHA1 Message Date
Miguel PalhasandClaude Opus 5 1c349e5858 ci(maestro): cancel superseded runs
ci / web (push) Successful in 1m30s
ci / rust (push) Successful in 3m42s
ci / flake (push) Successful in 5m50s
Pushes queued a new run without stopping the old one. Group by ref and
cancel in-progress, except on main where runs finish.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 10:35:31 +01:00
Miguel Palhas 9fc21b4306 Merge remote-tracking branch 'origin/main' into moderato-motif
ci / flake (push) Has been cancelled
ci / web (push) Has been cancelled
ci / rust (push) Has been cancelled
2026-09-01 10:00:32 +01:00
naps62 19162f9952 Merge pull request 'feat(maestro): hide tab bar when a single tab' (#129) from marcato-lute into main
ci / web (push) Has been cancelled
ci / flake (push) Has been cancelled
ci / rust (push) Has been cancelled
Reviewed-on: #129
2026-09-01 09:54:58 +01:00
Miguel PalhasandClaude Opus 5 2fcfc06c3c feat(maestro): drop the global app header
The header carried only the brand and a session tally; app actions had
already moved to the sidebar footer. The mobile ☰ it hosted moves to the
pane header (replacing the redundant back arrow) and to the welcome
state, so the session drawer stays reachable without a swipe. The DEV
`?kit` link moves to the sidebar footer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 09:51:22 +01:00
Miguel Palhas f82ab45766 Merge remote-tracking branch 'origin/main' into marcato-lute
ci / web (pull_request) Successful in 1m16s
ci / rust (pull_request) Successful in 7m39s
ci / flake (pull_request) Has been cancelled
2026-09-01 09:49:07 +01:00
Miguel Palhas d304171d7c feat(maestro): hide tab bar when a single tab
The bar has nothing to switch between with one tab, so it is pure chrome. It reappears as soon as a second tab exists; spawn already opens its own tab, so the "+" button stays reachable.
2026-09-01 09:48:15 +01:00
Miguel Palhas 95b459bd65 Merge remote-tracking branch 'origin/main' into largo-piano
ci / web (push) Successful in 40m34s
ci / rust (push) Successful in 44m56s
ci / flake (push) Has been cancelled
2026-08-31 22:40:53 +01:00
Miguel PalhasandClaude Opus 5 c9cfdb5668 feat(maestro): one-word handles, quieter project boxes
Session handles are one word from a single 218-entry list — instruments,
forms, notation and theory, tempo and dynamics — replacing the
adjective-noun pair. A batch is shuffled, so it repeats only once the
wordlist runs out.

Project boxes show the parent directory instead of the full path: the
last segment is already the name line above it. The last command is gone
from the box, and picking a project no longer pre-selects it — the
directory and what runs in it are independent choices. Both changes land
in the TUI picker too, which mirrors the web form's rules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 22:40:49 +01:00
Miguel PalhasandClaude Opus 5 31158cdb1a fix(maestro): close mobile drawer on spawn
ci / web (push) Successful in 36s
ci / rust (push) Successful in 4m11s
ci / flake (push) Successful in 6m35s
Spawn and Spawn similar open a draft pane behind the open sidebar on
mobile, so the tap read as dead. Extract the drawer into its own module
and close it on both spawn events as well as a session tap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 22:31:05 +01:00
19 changed files with 551 additions and 321 deletions
+4
View File
@@ -7,6 +7,10 @@ on:
- 'linear/**' - 'linear/**'
pull_request: pull_request:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs: jobs:
rust: rust:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+4 -7
View File
@@ -232,17 +232,14 @@ async fn projects_returns_empty_for_a_disabled_scanner() {
assert!(projects.is_empty()); assert!(projects.is_empty());
} }
/// #53: every returned handle splits into a known adjective and noun. /// #53: every returned handle is one word from the shared list.
fn assert_valid_names(batch: &maestro_protocol::NameBatch) { fn assert_valid_names(batch: &maestro_protocol::NameBatch) {
for name in &batch.names { for name in &batch.names {
let (adj, noun) = name.split_once('-').unwrap_or_else(|| { assert!(!name.contains('-'), "handle {name:?} is not one word");
panic!("handle {name:?} is not adjective-noun");
});
assert!( assert!(
maestro_names::ADJECTIVES.contains(&adj), maestro_names::WORDS.contains(&name.as_str()),
"{adj} not in ADJECTIVES" "{name} not in WORDS"
); );
assert!(maestro_names::NOUNS.contains(&noun), "{noun} not in NOUNS");
} }
} }
+233 -29
View File
@@ -1,10 +1,195 @@
//! The one home of the session-handle wordlists (#53). //! The one home of the session-handle wordlist (#53).
//! //!
//! Previously duplicated in `web/src/names.ts`; every client now gets handles //! Previously duplicated in `web/src/names.ts`; every client now gets handles
//! from here, served by `GET /api/names`. //! from here, served by `GET /api/names`.
/// Musical adjectives — tempo, dynamics, and articulation markings. /// Every word a handle can be: instruments, forms, and the vocabulary of
pub const ADJECTIVES: &[&str] = &[ /// tempo, dynamics, notation and theory. One word per handle — the list is
/// wide enough that a single word still reads as distinct.
pub const WORDS: &[&str] = &[
// Instruments
"cello",
"violin",
"viola",
"oboe",
"flute",
"piccolo",
"clarinet",
"bassoon",
"trumpet",
"trombone",
"tuba",
"horn",
"cornet",
"bugle",
"flugelhorn",
"euphonium",
"saxophone",
"recorder",
"timpani",
"harp",
"piano",
"organ",
"lyre",
"lute",
"mandolin",
"banjo",
"ukulele",
"guitar",
"sitar",
"koto",
"shamisen",
"balalaika",
"zither",
"dulcimer",
"kantele",
"erhu",
"guzheng",
"pipa",
"duduk",
"ocarina",
"kalimba",
"harmonica",
"accordion",
"bandoneon",
"bagpipe",
"harpsichord",
"clavichord",
"celesta",
"theremin",
"marimba",
"xylophone",
"vibraphone",
"glockenspiel",
"snare",
"cymbal",
"tambourine",
"castanet",
"djembe",
"tabla",
"bodhran",
"gong",
"chimes",
"contrabass",
"shawm",
"crumhorn",
"sackbut",
"didgeridoo",
// Forms and pieces
"sonata",
"fugue",
"canon",
"prelude",
"overture",
"rondo",
"nocturne",
"scherzo",
"etude",
"ballade",
"barcarolle",
"bagatelle",
"caprice",
"chaconne",
"passacaglia",
"toccata",
"intermezzo",
"interlude",
"minuet",
"gavotte",
"sarabande",
"gigue",
"allemande",
"courante",
"mazurka",
"polonaise",
"waltz",
"bolero",
"habanera",
"tarantella",
"fandango",
"serenade",
"aubade",
"requiem",
"cantata",
"oratorio",
"madrigal",
"motet",
"chorale",
"hymn",
"anthem",
"lullaby",
"elegy",
"rhapsody",
"symphony",
"concerto",
"fantasia",
"divertimento",
"quartet",
"quintet",
"sextet",
"septet",
"octet",
"trio",
// Structure, notation and theory
"octave",
"chord",
"cadence",
"cadenza",
"coda",
"motif",
"refrain",
"verse",
"chorus",
"bridge",
"finale",
"arpeggio",
"glissando",
"ostinato",
"counterpoint",
"descant",
"harmony",
"melody",
"rhythm",
"tempo",
"timbre",
"treble",
"clef",
"staff",
"quaver",
"crotchet",
"semibreve",
"fermata",
"trill",
"mordent",
"appoggiatura",
"portamento",
"vibrato",
"triplet",
"tuplet",
"downbeat",
"upbeat",
"anacrusis",
"syncopation",
"modulation",
"transposition",
"unison",
"semitone",
"tritone",
"diapason",
"tonic",
"dominant",
"mediant",
"ionian",
"dorian",
"phrygian",
"lydian",
"mixolydian",
"aeolian",
"locrian",
"pentatonic",
"chromatic",
"diatonic",
// Tempo, dynamics and articulation
"allegro", "allegro",
"andante", "andante",
"adagio", "adagio",
@@ -15,6 +200,7 @@ pub const ADJECTIVES: &[&str] = &[
"lento", "lento",
"moderato", "moderato",
"crescendo", "crescendo",
"diminuendo",
"staccato", "staccato",
"legato", "legato",
"forte", "forte",
@@ -35,60 +221,78 @@ pub const ADJECTIVES: &[&str] = &[
"sotto", "sotto",
"vivo", "vivo",
"feroce", "feroce",
]; "giocoso",
"affettuoso",
/// Musical nouns — instruments and forms from the concert hall. "risoluto",
pub const NOUNS: &[&str] = &[ "leggiero",
"cello", "violin", "viola", "oboe", "flute", "clarinet", "bassoon", "trumpet", "trombone", "pesante",
"tuba", "horn", "timpani", "harp", "piano", "organ", "lyre", "lute", "fugue", "sonata", "morendo",
"octave", "chord", "cadence", "prelude", "rondo", "nocturne", "scherzo", "refrain", "coda", "accelerando",
"motif", "overture", "ritardando",
]; ];
/// Upper bound for a `GET /api/names?count=N` batch. /// Upper bound for a `GET /api/names?count=N` batch.
pub const MAX_BATCH: usize = 100; pub const MAX_BATCH: usize = 100;
/// A fresh `adjective-noun` handle, e.g. `allegro-cello`. /// A fresh one-word handle, e.g. `nocturne`.
pub fn generate() -> String { pub fn generate() -> String {
use rand::seq::SliceRandom; use rand::seq::SliceRandom;
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
format!( (*WORDS.choose(&mut rng).unwrap()).to_string()
"{}-{}",
ADJECTIVES.choose(&mut rng).unwrap(),
NOUNS.choose(&mut rng).unwrap()
)
} }
/// `count` fresh handles (the caller clamps; this only trusts its input). /// `count` fresh handles, distinct while the wordlist lasts (the caller
/// clamps; this only trusts its input).
pub fn generate_batch(count: usize) -> Vec<String> { pub fn generate_batch(count: usize) -> Vec<String> {
(0..count).map(|_| generate()).collect() use rand::seq::SliceRandom;
let mut rng = rand::thread_rng();
let mut pool: Vec<&str> = WORDS.to_vec();
pool.shuffle(&mut rng);
(0..count)
.map(|i| match pool.get(i) {
// Past the wordlist a batch has to repeat itself.
Some(word) => (*word).to_string(),
None => generate(),
})
.collect()
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::collections::HashSet;
#[test] #[test]
fn generated_name_is_adjective_dash_noun() { fn generated_name_is_a_single_known_word() {
let name = generate(); let name = generate();
let (adj, noun) = name.split_once('-').expect("name contains a dash"); assert!(!name.contains('-'), "{name} is not one word");
assert!(ADJECTIVES.contains(&adj), "{adj} not in ADJECTIVES"); assert!(WORDS.contains(&name.as_str()), "{name} not in WORDS");
assert!(NOUNS.contains(&noun), "{noun} not in NOUNS");
} }
#[test] #[test]
fn batch_returns_the_requested_count() { fn the_wordlist_has_no_duplicates_and_covers_a_full_batch() {
let unique: HashSet<&&str> = WORDS.iter().collect();
assert_eq!(unique.len(), WORDS.len(), "WORDS has duplicates");
assert!(
WORDS.len() >= MAX_BATCH,
"a full batch would have to repeat"
);
}
#[test]
fn batch_returns_the_requested_count_without_repeats() {
assert_eq!(generate_batch(1).len(), 1); assert_eq!(generate_batch(1).len(), 1);
assert_eq!(generate_batch(50).len(), 50); let batch = generate_batch(MAX_BATCH);
for name in generate_batch(10) { assert_eq!(batch.len(), MAX_BATCH);
let (adj, noun) = name.split_once('-').unwrap(); for name in &batch {
assert!(ADJECTIVES.contains(&adj) && NOUNS.contains(&noun)); assert!(WORDS.contains(&name.as_str()), "{name} not in WORDS");
} }
let unique: HashSet<&String> = batch.iter().collect();
assert_eq!(unique.len(), batch.len(), "batch repeated a handle");
} }
#[test] #[test]
fn rerolls_can_differ() { fn rerolls_can_differ() {
// 30x30=900 combinations; 20 draws all matching has probability ~1e-40.
let first = generate(); let first = generate();
let differs = (0..20).any(|_| generate() != first); let differs = (0..20).any(|_| generate() != first);
assert!(differs); assert!(differs);
+1 -1
View File
@@ -506,7 +506,7 @@ pub struct DaemonInfo {
} }
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh /// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
/// `adjective-noun` session handles generated by the `maestro-names` crate. /// one-word session handles generated by the `maestro-names` crate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NameBatch { pub struct NameBatch {
pub names: Vec<String>, pub names: Vec<String>,
+28 -18
View File
@@ -18,8 +18,9 @@
//! //!
//! The picker itself is two steps (#96), verbatim from the rules in //! The picker itself is two steps (#96), verbatim from the rules in
//! `web/src/spawnForm.ts`'s "working-directory picker (#95)" comment: //! `web/src/spawnForm.ts`'s "working-directory picker (#95)" comment:
//! 1. Step one is one box per repository (name, then full path). A worktree //! 1. Step one is one box per repository (name, then its parent directory).
//! is a box only when its main checkout is missing from the scan. //! 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 //! 2. The filter keeps a repository when the query matches it or any of its
//! worktrees, so a worktree name still finds its box. //! worktrees, so a worktree name still finds its box.
//! 3. Picking a box reveals step two: "+ new", then that repository's //! 3. Picking a box reveals step two: "+ new", then that repository's
@@ -32,6 +33,8 @@
//! these are separate acts. //! these are separate acts.
//! 6. Neither step probes `/api/gitroot`; the scan already reports //! 6. Neither step probes `/api/gitroot`; the scan already reports
//! repository membership. Only the free-text hatch pays for a probe. //! repository membership. Only the free-text hatch pays for a probe.
//! 7. Neither step touches the command: the directory and what runs in it
//! are independent choices.
use std::collections::VecDeque; use std::collections::VecDeque;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -39,6 +42,7 @@ use std::time::{Duration, Instant};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use maestro_config::{builtin_presets, custom_preset}; use maestro_config::{builtin_presets, custom_preset};
use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec}; use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style}; use ratatui::style::{Modifier, Style};
@@ -128,6 +132,15 @@ pub struct DraftWorktreeRow {
pub stale: bool, pub stale: bool,
} }
/// A box's second column: the directory holding the project, `~`-abbreviated.
/// Only the stem — the last segment is the name column beside it.
fn parent_dir_label(path: &Path, home: &str) -> String {
match path.parent() {
Some(parent) => abbreviate_dir(parent, home),
None => String::new(),
}
}
/// The repository key a [`DraftGroup`] was formed under — same key /// The repository key a [`DraftGroup`] was formed under — same key
/// `projects::group_projects` groups by, kept separately because the main /// `projects::group_projects` groups by, kept separately because the main
/// box's own path only equals it when the main checkout itself was found. /// box's own path only equals it when the main checkout itself was found.
@@ -893,13 +906,6 @@ impl DraftPane {
self.worktree_choice = choice.clone(); self.worktree_choice = choice.clone();
self.picked_cwd = Some(target.path.clone()); self.picked_cwd = Some(target.path.clone());
self.cwd_raw_visible = false; self.cwd_raw_visible = false;
if let Some(cmd) = target.last_command.as_deref() {
if !cmd.is_empty() {
if let Some(idx) = self.presets.iter().position(|p| p.command == cmd) {
self.selected_preset = idx;
}
}
}
match choice { match choice {
WorktreeChoice::New => { WorktreeChoice::New => {
self.worktree_enabled = true; self.worktree_enabled = true;
@@ -1200,9 +1206,9 @@ impl DraftPane {
cursor = Some((col, filter_row.y)); cursor = Some((col, filter_row.y));
} }
// Step one (#96 rule 1): one box per repository, name then the FULL // Step one (#96 rule 1): one box per repository, name then the parent
// path — not the abbreviated form other lists use — so two projects // directory it sits in. The last segment is the name column already;
// sharing a basename stay distinguishable. // the stem is what keeps two projects sharing a basename apart.
let visible = self.visible_groups(); let visible = self.visible_groups();
let sel = self.selection(); let sel = self.selection();
let count = visible.len() + 1; let count = visible.len() + 1;
@@ -1241,7 +1247,10 @@ impl DraftPane {
Span::styled(if active { ">" } else { " " }, base), Span::styled(if active { ">" } else { " " }, base),
Span::raw(" "), Span::raw(" "),
Span::styled(format!("{:<name_width$}", group.main.name), base), Span::styled(format!("{:<name_width$}", group.main.name), base),
Span::styled(format!(" {}", group.main.path.display()), base), Span::styled(
format!(" {}", parent_dir_label(&group.main.path, &self.home_dir)),
base,
),
]) ])
} else { } else {
let mut style = Style::default(); let mut style = Style::default();
@@ -1741,8 +1750,9 @@ mod tests {
let stale_pos = rendered.find("stale-repo").unwrap(); let stale_pos = rendered.find("stale-repo").unwrap();
assert!(fresh_pos < stale_pos, "fresh before stale:\n{rendered}"); assert!(fresh_pos < stale_pos, "fresh before stale:\n{rendered}");
assert!( assert!(
rendered.contains("/home/u/new/repo"), rendered.contains("~/new") && !rendered.contains("~/new/repo"),
"the full path, not an abbreviation (#96 rule 1):\n{rendered}" "the parent directory only — the name column is the last segment \
(#96 rule 1):\n{rendered}"
); );
// Staleness dims the row's cells. // Staleness dims the row's cells.
@@ -1787,7 +1797,7 @@ mod tests {
assert_eq!( assert_eq!(
draft.command(), draft.command(),
"claude", "claude",
"pick pre-selects the last command" "the preset is the operator's own choice — picking a project leaves it alone"
); );
} }
@@ -2640,8 +2650,8 @@ mod tests {
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true)); let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!( assert!(
rendered.contains("/home/u/repos/main"), rendered.contains("~/repos"),
"step one shows the full repository path:\n{rendered}" "step one shows the directory holding the repository:\n{rendered}"
); );
assert!( assert!(
rendered.contains("+ new"), rendered.contains("+ new"),
+8 -1
View File
@@ -6,6 +6,7 @@ import { attachWsUrl } from "./routes.js";
import { escapeHtml } from "./html.js"; import { escapeHtml } from "./html.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js"; import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { applyModsToChar, cursorSeq } from "./keys.js"; import { applyModsToChar, cursorSeq } from "./keys.js";
import { requestToggleDrawer } from "./drawer.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js"; import { splitCommand, toolIconSvg } from "./toolIcon.js";
import type { ClientControl, Uuid } from "./types.js"; import type { ClientControl, Uuid } from "./types.js";
@@ -80,7 +81,7 @@ export function createAttachConnection(
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId; const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
el.innerHTML = ` el.innerHTML = `
<header class="attach-header"> <header class="attach-header">
<a class="btn btn-ghost back" href="#/" aria-label="Back to catalog" title="Back to catalog">←</a> <button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
<span class="attach-title" title="${escapeHtml(titleAttr)}">${titleHtml}</span> <span class="attach-title" title="${escapeHtml(titleAttr)}">${titleHtml}</span>
<span class="attach-state" data-state="connecting">connecting…</span> <span class="attach-state" data-state="connecting">connecting…</span>
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button> <button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
@@ -100,6 +101,12 @@ export function createAttachConnection(
stateEl.dataset.state = kind; stateEl.dataset.state = kind;
}; };
// Mobile-only (CSS hides it on desktop, where the sidebar is always in view):
// the session list is an off-canvas drawer and this is what opens it.
(el.querySelector(".sessions-btn") as HTMLElement).addEventListener("click", () =>
requestToggleDrawer(),
);
disconnectBtn.addEventListener("click", () => { disconnectBtn.addEventListener("click", () => {
opts.onRequestClose?.(sessionId); opts.onRequestClose?.(sessionId);
}); });
+16 -59
View File
@@ -37,6 +37,10 @@ export function requestOpenSpawn(): void {
* connection pool. */ * connection pool. */
export const FOCUS_PANE_EVENT = "catalog-focus-pane"; export const FOCUS_PANE_EVENT = "catalog-focus-pane";
/** Ask the shell to open a draft pane seeded from an existing session
* ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
/** The rows a keyboard walk stops on, in visual (document) order. Expanded /** The rows a keyboard walk stops on, in visual (document) order. Expanded
* folder headers are skipped: a directory is not something to focus, and the * folder headers are skipped: a directory is not something to focus, and the
* cards under it are one row away anyway. A *collapsed* folder still counts — * cards under it are one row away anyway. A *collapsed* folder still counts —
@@ -169,7 +173,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
similarBtn.addEventListener("click", (ev) => { similarBtn.addEventListener("click", (ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s })); document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s }));
}); });
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement; const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
killBtn.addEventListener("click", (ev) => { killBtn.addEventListener("click", (ev) => {
@@ -185,7 +189,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
{ {
label: "Spawn similar", label: "Spawn similar",
onSelect: () => onSelect: () =>
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s })), document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })),
}, },
{ label: "Kill session", danger: true, onSelect: () => void confirmAndKill(s, name) }, { label: "Kill session", danger: true, onSelect: () => void confirmAndKill(s, name) },
]); ]);
@@ -262,18 +266,6 @@ function beginRename(row: HTMLElement, s: Session): void {
input.select(); input.select();
} }
function renderStatusbar(sessions: Session[]): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "statusbar";
const live = sessions.filter((s) => s.status === "Running").length;
const tally = document.createElement("span");
tally.className = "session-tally";
tally.textContent = `${sessions.length} session${sessions.length === 1 ? "" : "s"} · ${live} live`;
wrap.appendChild(tally);
return wrap;
}
function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement { function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
const el = document.createElement("div"); const el = document.createElement("div");
el.className = "empty"; el.className = "empty";
@@ -298,51 +290,6 @@ function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
return el; return el;
} }
/**
* The global app header (full width, above the sidebar/content split): brand +
* session tally. App-level actions (help, theme, spawn) live pinned to the
* sidebar's foot instead — see {@link renderSidebar}.
*/
export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): HTMLElement {
const header = document.createElement("header");
header.className = "app-header";
header.innerHTML = `
<button type="button" class="app-menu" title="Sessions" aria-label="Toggle session list">☰</button>
<a class="brand" href="#/" aria-label="maestro — clear selection">
<span class="brand-dot" aria-hidden="true"></span>
<h1>maestro<span class="blink" aria-hidden="true">_</span></h1>
</a>
<div class="app-status"></div>
<div class="app-actions"></div>
`;
// Mobile-only ☰ (CSS hides it on desktop) — opens the off-canvas session drawer.
(header.querySelector(".app-menu") as HTMLElement).addEventListener("click", () =>
onToggleSidebar?.(),
);
// Session tally lives centered in the header (not the sidebar).
const statusHost = header.querySelector(".app-status") as HTMLElement;
const renderStatus = () => {
const state = store.snapshot();
statusHost.replaceChildren(renderStatusbar([...state.sessions.values()]));
};
store.subscribe(renderStatus);
renderStatus();
const actions = header.querySelector(".app-actions") as HTMLElement;
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
if (import.meta.env.DEV) {
const kit = document.createElement("a");
kit.className = "btn btn-ghost";
kit.href = "?kit";
kit.textContent = "kit";
kit.title = "Open the design kit (DEV)";
actions.appendChild(kit);
}
return header;
}
/** Two-tier sidebar highlight state (issue #33): which sessions are open in some /** Two-tier sidebar highlight state (issue #33): which sessions are open in some
* pane (quiet), and which one is focused (loud). */ * pane (quiet), and which one is focused (loud). */
export interface SidebarHighlights { export interface SidebarHighlights {
@@ -422,6 +369,16 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
helpBtn.addEventListener("click", () => toggleHelp()); helpBtn.addEventListener("click", () => toggleHelp());
footer.appendChild(helpBtn); footer.appendChild(helpBtn);
footer.appendChild(createThemeToggle()); footer.appendChild(createThemeToggle());
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
if (import.meta.env.DEV) {
const kit = document.createElement("a");
kit.className = "btn btn-ghost";
kit.href = "?kit";
kit.textContent = "kit";
kit.title = "Open the design kit (DEV)";
footer.appendChild(kit);
}
const spawnBtn = document.createElement("button"); const spawnBtn = document.createElement("button");
spawnBtn.type = "button"; spawnBtn.type = "button";
spawnBtn.className = "btn btn-primary spawn-btn"; spawnBtn.className = "btn btn-primary spawn-btn";
+47
View File
@@ -0,0 +1,47 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
import { installDrawer, type Drawer } from "./drawer.js";
function mount(): { shell: HTMLElement; sidebar: HTMLElement; drawer: Drawer } {
const shell = document.createElement("div");
const sidebar = document.createElement("aside");
const backdrop = document.createElement("div");
const row = document.createElement("div");
row.className = "tree-leaf";
sidebar.appendChild(row);
shell.append(sidebar, backdrop);
document.body.replaceChildren(shell);
const drawer = installDrawer(shell, sidebar, backdrop);
drawer.open();
return { shell, sidebar, drawer };
}
describe("mobile drawer", () => {
beforeEach(() => document.body.replaceChildren());
it("closes when a session row is tapped", () => {
const { shell, sidebar } = mount();
(sidebar.querySelector(".tree-leaf") as HTMLElement).click();
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("closes when a spawn is requested", () => {
const { shell } = mount();
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("closes when spawn similar is requested", () => {
const { shell } = mount();
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: {} }));
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("leaves the backdrop click closing it", () => {
const { shell } = mount();
(shell.lastElementChild as HTMLElement).click();
expect(shell.classList.contains("drawer-open")).toBe(false);
});
});
+83
View File
@@ -0,0 +1,83 @@
// Mobile off-canvas sidebar (issue #35); on desktop the `drawer-open` class is
// inert (CSS gates the drawer styling).
//
// Anything that puts a new view in the content area must close the drawer, or
// it renders behind the sidebar and the tap reads as a dead button. "Spawn
// similar" sits inside a session row but stops propagation, so it never reaches
// the row's own close — both spawn paths arrive here via document events.
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
/** Toggling the drawer from outside the shell (the pane header's ☰ on mobile),
* without threading the controller through every view. */
export const TOGGLE_DRAWER_EVENT = "maestro-toggle-drawer";
export function requestToggleDrawer(): void {
document.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT));
}
export interface Drawer {
open(): void;
close(): void;
toggle(): void;
isOpen(): boolean;
}
export function installDrawer(
shellEl: HTMLElement,
sidebarEl: HTMLElement,
backdropEl: HTMLElement,
): Drawer {
const drawer: Drawer = {
open: () => shellEl.classList.add("drawer-open"),
close: () => shellEl.classList.remove("drawer-open"),
toggle: () => shellEl.classList.toggle("drawer-open"),
isOpen: () => shellEl.classList.contains("drawer-open"),
};
backdropEl.addEventListener("click", () => drawer.close());
sidebarEl.addEventListener("click", (e) => {
if ((e.target as HTMLElement).closest(".tree-leaf")) drawer.close();
});
document.addEventListener(TOGGLE_DRAWER_EVENT, () => drawer.toggle());
document.addEventListener(OPEN_SPAWN_EVENT, () => drawer.close());
document.addEventListener(SPAWN_SIMILAR_EVENT, () => drawer.close());
return drawer;
}
/** Horizontal swipes drive the drawer (right opens, left closes) rather than
* the browser's back/forward navigation. Only a clearly horizontal gesture past
* a threshold counts, so vertical terminal scrolling (which owns its own touch
* handler) is untouched. */
export function installDrawerSwipe(drawer: Drawer): void {
const mobileMq = window.matchMedia("(max-width: 859px)");
let sx = 0;
let sy = 0;
let tracking = false;
document.addEventListener(
"touchstart",
(e) => {
tracking = mobileMq.matches && e.touches.length === 1;
if (tracking) {
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
}
},
{ passive: true },
);
document.addEventListener(
"touchend",
(e) => {
if (!tracking) return;
tracking = false;
const t = e.changedTouches[0];
const dx = t.clientX - sx;
const dy = t.clientY - sy;
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
if (dx > 0 && !drawer.isOpen()) drawer.open();
else if (dx < 0 && drawer.isOpen()) drawer.close();
},
{ passive: true },
);
}
-1
View File
@@ -205,7 +205,6 @@ export function renderKit(): HTMLElement {
); );
activeLine.setAttribute("data-activity", "active"); activeLine.setAttribute("data-activity", "active");
body.append( body.append(
specimen("session-tally", h("span", "session-tally", "6 sessions · 5 live")),
specimen("folder-count", h("span", "folder-count", "4")), specimen("folder-count", h("span", "folder-count", "4")),
specimen("card-line2 · active", activeLine), specimen("card-line2 · active", activeLine),
specimen("attach-state · open", h("span", "attach-state", "live")), specimen("attach-state · open", h("span", "attach-state", "live")),
+21 -59
View File
@@ -2,8 +2,9 @@ import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/500.css"; import "@fontsource/jetbrains-mono/500.css";
import "@fontsource/jetbrains-mono/600.css"; import "@fontsource/jetbrains-mono/600.css";
import "@fontsource/jetbrains-mono/700.css"; import "@fontsource/jetbrains-mono/700.css";
import { renderSidebar, renderTopbar } from "./catalog.js"; import { renderSidebar } from "./catalog.js";
import { ConnectionPool } from "./connections.js"; import { ConnectionPool } from "./connections.js";
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
import { installHudKeybind } from "./debug.js"; import { installHudKeybind } from "./debug.js";
import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js"; import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
import { CatalogStore } from "./events.js"; import { CatalogStore } from "./events.js";
@@ -25,7 +26,7 @@ import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js"; import { emptyLayout, type Layout, openSessions } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js"; import { createMultiView, type MultiView } from "./multiview.js";
import { loadLayout, saveLayout } from "./persistence.js"; import { loadLayout, saveLayout } from "./persistence.js";
import { FOCUS_PANE_EVENT, OPEN_SPAWN_EVENT } from "./catalog.js"; import { FOCUS_PANE_EVENT, OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
import { initTheme } from "./theme.js"; import { initTheme } from "./theme.js";
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js"; import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
import { installViewportSizing } from "./viewport.js"; import { installViewportSizing } from "./viewport.js";
@@ -92,63 +93,15 @@ const content = document.createElement("section");
content.className = "content"; content.className = "content";
// Mobile (issue #35): the sidebar is an off-canvas drawer toggled by the ☰ in // Mobile (issue #35): the sidebar is an off-canvas drawer toggled by the ☰ in
// the header; a backdrop covers the content while it's open, and tapping a // the pane header (and the welcome state). `installDrawer` owns the close rules (session tap, Spawn, Spawn
// session closes it. On desktop these are inert (CSS gates the drawer styling). // similar, backdrop); on desktop the class it toggles is inert.
const backdrop = document.createElement("div"); const backdrop = document.createElement("div");
backdrop.className = "mv-backdrop"; backdrop.className = "mv-backdrop";
function closeDrawer(): void { const drawer = installDrawer(shellEl, sidebar.el, backdrop);
shellEl.classList.remove("drawer-open"); installDrawerSwipe(drawer);
}
function openDrawer(): void {
shellEl.classList.add("drawer-open");
}
function toggleDrawer(): void {
shellEl.classList.toggle("drawer-open");
}
backdrop.addEventListener("click", closeDrawer);
sidebar.el.addEventListener("click", (e) => {
if ((e.target as HTMLElement).closest(".tree-leaf")) closeDrawer();
});
// Mobile: horizontal swipes drive the drawer (right opens, left closes) rather
// than the browser's back/forward navigation. We only act on a clearly
// horizontal gesture past a threshold, so vertical terminal scrolling (which
// owns its own touch handler) is untouched.
{
const mobileMq = window.matchMedia("(max-width: 859px)");
let sx = 0;
let sy = 0;
let tracking = false;
document.addEventListener(
"touchstart",
(e) => {
tracking = mobileMq.matches && e.touches.length === 1;
if (tracking) {
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
}
},
{ passive: true },
);
document.addEventListener(
"touchend",
(e) => {
if (!tracking) return;
tracking = false;
const t = e.changedTouches[0];
const dx = t.clientX - sx;
const dy = t.clientY - sy;
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
const open = shellEl.classList.contains("drawer-open");
if (dx > 0 && !open) openDrawer();
else if (dx < 0 && open) closeDrawer();
},
{ passive: true },
);
}
shellEl.append(sidebar.el, content, backdrop); shellEl.append(sidebar.el, content, backdrop);
root.replaceChildren(renderTopbar(store, toggleDrawer), shellEl); root.replaceChildren(shellEl);
// The pool owns attach connections independently of the route: navigating away // The pool owns attach connections independently of the route: navigating away
// parks a session (keeps it streaming); navigating back re-mounts it instantly. // parks a session (keeps it streaming); navigating back re-mounts it instantly.
@@ -228,13 +181,22 @@ function renderWelcome(): HTMLElement {
</p> </p>
<p class="empty-hint">Press <kbd>?</kbd> for keyboard shortcuts.</p> <p class="empty-hint">Press <kbd>?</kbd> for keyboard shortcuts.</p>
<div class="empty-actions"></div>`; <div class="empty-actions"></div>`;
const actions = el.querySelector(".empty-actions") as HTMLElement;
// Mobile-only: with no pane open there is no pane header to carry the ☰, so
// the drawer would be swipe-only. CSS hides this on desktop.
const sessions = document.createElement("button");
sessions.type = "button";
sessions.className = "btn btn-ghost welcome-sessions";
sessions.textContent = "☰ Sessions";
sessions.addEventListener("click", () => requestToggleDrawer());
actions.appendChild(sessions);
const spawn = document.createElement("button"); const spawn = document.createElement("button");
spawn.type = "button"; spawn.type = "button";
spawn.className = "btn btn-primary"; spawn.className = "btn btn-primary";
spawn.textContent = "+ Spawn session"; spawn.textContent = "+ Spawn session";
spawn.title = "Draft pane: n — fast-forward spawn: N"; spawn.title = "Draft pane: n — fast-forward spawn: N";
spawn.addEventListener("click", () => requestOpenSpawn()); spawn.addEventListener("click", () => requestOpenSpawn());
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn); actions.appendChild(spawn);
return el; return el;
} }
@@ -272,7 +234,7 @@ function showMultiView(): void {
updateShellRoute(); updateShellRoute();
} }
// Spawn affordances (#62): the header button, the sidebar's empty state, and // Spawn affordances (#62): the sidebar's footer button, its empty state, and
// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one from the // `?mock&spawn` all open a draft pane; "Spawn similar" seeds one from the
// cloned session (see spawnSimilar.ts). // cloned session (see spawnSimilar.ts).
function requestOpenSpawn(): void { function requestOpenSpawn(): void {
@@ -284,7 +246,7 @@ function requestOpenSpawn(): void {
mv.openDraft(undefined, { newTab: true }); mv.openDraft(undefined, { newTab: true });
} }
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn()); document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
document.addEventListener("catalog-spawn-similar", (ev) => { document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
const s = (ev as CustomEvent<Session>).detail; const s = (ev as CustomEvent<Session>).detail;
showMultiView(); showMultiView();
mv.openDraft(spawnSimilarSeed(s)); mv.openDraft(spawnSimilarSeed(s));
@@ -358,7 +320,7 @@ window.addEventListener("keydown", (e) => {
if (sidebar.hasListFocus()) { if (sidebar.hasListFocus()) {
pool.focusTerminal(); pool.focusTerminal();
} else { } else {
openDrawer(); // no-op on desktop; on mobile the list is off-canvas drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList(); sidebar.focusList();
} }
}); });
+36 -1
View File
@@ -17,7 +17,7 @@ function stubFetch(): void {
: url.includes("/api/projects") : url.includes("/api/projects")
? [] ? []
: url.includes("/api/names") : url.includes("/api/names")
? { names: ["allegro-cello"] } ? { names: ["cello"] }
: undefined; : undefined;
return new Response(JSON.stringify(body), { status: 200 }); return new Response(JSON.stringify(body), { status: 200 });
}), }),
@@ -523,3 +523,38 @@ describe("multiview mobile single pane", () => {
expect(mv.el.classList.contains("mv-mobile-solo")).toBe(true); expect(mv.el.classList.contains("mv-mobile-solo")).toBe(true);
}); });
}); });
describe("multiview solo tab bar", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("hides the tab bar while only one tab exists", () => {
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();
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
});
it("shows the tab bar again once a second tab appears", () => {
const layout = emptyLayout();
const first = layout.tabs[0];
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
const second = addTab(layout);
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
expect(mv.el.classList.contains("mv-solo-tab")).toBe(false);
});
});
+5 -1
View File
@@ -470,8 +470,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
canvas.replaceChildren(); canvas.replaceChildren();
const tab = activeTab(layout); const tab = activeTab(layout);
// Mobile: the tab bar is a second navigation surface we don't want, so CSS // Mobile: the tab bar is a second navigation surface we don't want, so CSS
// collapses it; the header ☰ keeps the sidebar drawer reachable. // collapses it; the pane header's ☰ keeps the sidebar drawer reachable.
el.classList.toggle("mv-mobile-solo", isMobile); el.classList.toggle("mv-mobile-solo", isMobile);
// A lone tab has nothing to switch between, so the bar is only a strip of
// chrome; CSS hides it until a second tab exists. Spawning still opens its
// own tab (#100), which brings the bar back with the "+" button on it.
el.classList.toggle("mv-solo-tab", layout.tabs.length === 1);
if (!tab.root) { if (!tab.root) {
pruneForms(tab); pruneForms(tab);
+17 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { longestCommonPrefix, splitPath } from "./paths.js"; import { longestCommonPrefix, parentDir, splitPath } from "./paths.js";
describe("splitPath", () => { describe("splitPath", () => {
it("splits on the last slash, keeping it with the directory", () => { it("splits on the last slash, keeping it with the directory", () => {
@@ -29,3 +29,19 @@ describe("longestCommonPrefix", () => {
expect(longestCommonPrefix(["Alpha", "alpha"])).toBe(""); expect(longestCommonPrefix(["Alpha", "alpha"])).toBe("");
}); });
}); });
describe("parentDir", () => {
it("drops the last segment", () => {
expect(parentDir("/home/user/tea/maestro")).toBe("/home/user/tea");
expect(parentDir("/home/user/tea/maestro/")).toBe("/home/user/tea");
});
it("keeps the root for a top-level path", () => {
expect(parentDir("/maestro")).toBe("/");
expect(parentDir("/")).toBe("/");
});
it("is empty for a bare name", () => {
expect(parentDir("maestro")).toBe("");
});
});
+9
View File
@@ -19,3 +19,12 @@ export function longestCommonPrefix(items: string[]): string {
} }
return lcp; return lcp;
} }
/** Everything above the last segment of an absolute path: `/a/b/c` → `/a/b`.
* A top-level path keeps `/`, and a trailing slash is ignored. */
export function parentDir(path: string): string {
const trimmed = path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
const slash = trimmed.lastIndexOf("/");
if (slash <= 0) return slash === 0 ? "/" : "";
return trimmed.slice(0, slash);
}
+17 -7
View File
@@ -27,7 +27,7 @@ function stubFetch(info: unknown, projects: Project[]): void {
: url.includes("/api/projects") : url.includes("/api/projects")
? projects ? projects
: url.includes("/api/names") : url.includes("/api/names")
? { names: ["allegro-cello", "presto-fugue", "adagio-lyre"] } ? { names: ["cello", "fugue", "lyre"] }
: undefined; : undefined;
return new Response(JSON.stringify(body), { status: 200 }); return new Response(JSON.stringify(body), { status: 200 });
}), }),
@@ -79,14 +79,22 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
expect(boxes[0].classList.contains("is-stale")).toBe(false); expect(boxes[0].classList.contains("is-stale")).toBe(false);
}); });
it("shows the name as the primary line and the full path unabbreviated", async () => { it("shows the name as the primary line and its parent directory below", async () => {
const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" }); const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" });
const form = (await mount([p])).el; const form = (await mount([p])).el;
const box = form.querySelector<HTMLButtonElement>(".project-box")!; const box = form.querySelector<HTMLButtonElement>(".project-box")!;
expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config"); expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config");
// Not `~/t/nixos-config`: the abbreviation hid what distinguishes two // Only the stem: repeating the last segment would just echo the name. Not
// projects sharing a basename (#95). // `~/t`, since the stem is what distinguishes two projects sharing a
expect(box.querySelector(".project-path")!.textContent).toBe("/home/user/tea/nixos-config"); // basename (#95).
expect(box.querySelector(".project-path")!.textContent).toBe("~/tea");
});
it("keeps the last command out of the box", async () => {
const p = project({ name: "maestro", path: "/home/user/maestro", last_command: "claude" });
const form = (await mount([p])).el;
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
expect(box.textContent).not.toContain("claude");
}); });
it("lists only main checkouts as boxes — worktrees belong to step two", async () => { it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
@@ -126,11 +134,11 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
expect(form.querySelector(".picker-empty")).toBeTruthy(); expect(form.querySelector(".picker-empty")).toBeTruthy();
}); });
it("picking a project selects its cwd and pre-selects its last command", async () => { it("picking a project selects its cwd and leaves the command alone", async () => {
const p = project({ const p = project({
name: "maestro", name: "maestro",
path: "/home/user/maestro", path: "/home/user/maestro",
last_command: "claude", last_command: "aider",
}); });
const onChange = vi.fn(); const onChange = vi.fn();
stubFetch( stubFetch(
@@ -157,6 +165,8 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value; const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
const last = onChange.mock.lastCall![0]; const last = onChange.mock.lastCall![0];
expect(last.cwd).toBe("/home/user/maestro"); expect(last.cwd).toBe("/home/user/maestro");
// The directory and the command are independent choices: the preset stays
// where it was, whatever the project was last run with.
expect(last.command).toBe("claude"); expect(last.command).toBe("claude");
expect(values).toBeTruthy(); expect(values).toBeTruthy();
// The raw input stays hidden — the picked box is the source of truth. // The raw input stays hidden — the picked box is the source of truth.
+13 -11
View File
@@ -8,8 +8,10 @@ import {
gitRoot, gitRoot,
} from "./api.js"; } from "./api.js";
import { attachDirAutocomplete } from "./dirAutocomplete.js"; import { attachDirAutocomplete } from "./dirAutocomplete.js";
import { parentDir } from "./paths.js";
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js"; import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
import type { ProjectGroup } from "./projectList.js"; import type { ProjectGroup } from "./projectList.js";
import { abbreviateCwd } from "./types.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js"; import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
/** Where a worktree's branch comes from: freshly created (default) or picked /** Where a worktree's branch comes from: freshly created (default) or picked
@@ -440,8 +442,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// ---- working-directory picker (#95) ---------------------------------- // ---- working-directory picker (#95) ----------------------------------
// Two steps, and the TUI mirror (#96) must match these rules verbatim: // 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 // 1. Step one is one box per repository (name, then its parent directory).
// is a box only when its main checkout is missing from the scan. // 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 // 2. The filter keeps a repository when the query matches it or any of its
// worktrees, so a worktree name still finds its box. // worktrees, so a worktree name still finds its box.
// 3. Picking a box reveals step two: "+ new", then that repository's // 3. Picking a box reveals step two: "+ new", then that repository's
@@ -454,6 +457,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// these are separate acts — #91's rule, now an explicit choice. // these are separate acts — #91's rule, now an explicit choice.
// 6. Neither step probes /api/gitroot; the scan already reports repository // 6. Neither step probes /api/gitroot; the scan already reports repository
// membership. Only the free-text hatch pays for a probe. // membership. Only the free-text hatch pays for a probe.
// 7. Neither step touches the command: the directory and what runs in it
// are independent choices.
/** "new" (create a worktree) or the path of an existing worktree to run in. */ /** "new" (create a worktree) or the path of an existing worktree to run in. */
type WorktreeChoice = "new" | string; type WorktreeChoice = "new" | string;
@@ -544,9 +549,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
wtHint.hidden = true; wtHint.hidden = true;
} }
emit(); emit();
// 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);
} }
/** Groups with at least one member (itself or a worktree) matching `query`. */ /** Groups with at least one member (itself or a worktree) matching `query`. */
@@ -634,14 +636,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`; box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
box.dataset.cwd = project.path; box.dataset.cwd = project.path;
const trees = worktreesOf(group).length; const trees = worktreesOf(group).length;
// The full path, not the sidebar's `~/t/n/maestro` abbreviation: it is what // The parent directory only — the last segment is the name line above it.
// tells two projects sharing a basename apart (#95). // Unabbreviated beyond `~`, since the stem is what tells two projects
const meta = [trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "", project.last_command ?? ""] // sharing a basename apart (#95).
.filter(Boolean) const parent = abbreviateCwd(parentDir(project.path), homeDir);
.join(" · "); const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "";
box.innerHTML = ` box.innerHTML = `
<span class="project-name">${escapeHtml(project.name)}</span> <span class="project-name">${escapeHtml(project.name)}</span>
<span class="project-path">${escapeHtml(project.path)}</span> <span class="project-path">${escapeHtml(parent)}</span>
<span class="project-meta">${escapeHtml(meta)}</span>`; <span class="project-meta">${escapeHtml(meta)}</span>`;
box.addEventListener("click", () => selectProject(group)); box.addEventListener("click", () => selectProject(group));
return box; return box;
+2 -2
View File
@@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js"; import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
const BATCH_A = ["allegro-cello", "presto-fugue", "adagio-lyre"]; const BATCH_A = ["cello", "fugue", "lyre"];
const BATCH_B = ["forte-oboe", "largo-tuba", "vivace-harp"]; const BATCH_B = ["oboe", "tuba", "harp"];
/** Stub the three fetches the form's initial load makes. Records every /** Stub the three fetches the form's initial load makes. Records every
* /api/names URL so tests can assert how many batches were fetched. */ * /api/names URL so tests can assert how many batches were fetched. */
+7 -123
View File
@@ -365,73 +365,6 @@ textarea {
/* ---------- app shell: sidebar + content ---------- */ /* ---------- app shell: sidebar + content ---------- */
/* Global app header: brand + app-level actions (spawn, theme toggle), spanning
the full width above the split. */
.app-header {
display: flex;
align-items: center;
gap: var(--sp-3);
flex: 0 0 auto;
padding: var(--sp-1) var(--sp-3);
border-bottom: 1px solid var(--border);
background: color-mix(in oklab, var(--surface) 82%, transparent);
backdrop-filter: blur(8px);
}
/* Hamburger to the left of the brand — toggles the off-canvas session drawer.
Mobile only (desktop keeps the sidebar permanently in view). */
.app-menu {
display: none;
place-items: center;
flex: none;
width: 32px;
height: 32px;
border: none;
border-radius: var(--r-1);
background: transparent;
color: var(--ink);
font-size: var(--fs-17);
cursor: pointer;
}
.app-menu:hover {
background: var(--surface-2);
}
/* Three-part header: brand (left), status (centered), actions (right). Equal
flex on the outer two keeps the status group truly centered. */
.app-header .brand {
flex: 1 1 0;
min-width: 0;
margin-right: 0;
}
.app-status {
flex: 0 1 auto;
display: flex;
justify-content: center;
min-width: 0;
overflow: hidden;
}
.app-status .statusbar {
border-bottom: 0;
background: transparent;
padding: 0;
flex-wrap: nowrap;
}
.app-status .statusbar .spacer {
display: none;
}
.app-actions {
display: flex;
align-items: center;
gap: var(--sp-2);
flex: 1 1 0;
justify-content: flex-end;
}
.app-shell { .app-shell {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
@@ -479,9 +412,6 @@ textarea {
.sidebar-resize { .sidebar-resize {
display: none; display: none;
} }
.app-status {
display: none;
}
} }
/* Sidebar's own slim header: just the session search. */ /* Sidebar's own slim header: just the session search. */
@@ -638,42 +568,6 @@ textarea {
box-shadow: 0 0 0 3px var(--accent-tint); box-shadow: 0 0 0 3px var(--accent-tint);
} }
/* ---------- status bar ---------- */
.statusbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-3);
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border);
background: var(--surface);
font-size: var(--fs-12);
color: var(--muted);
}
.statusbar .spacer {
/* zero basis: claim leftover space without inflating the bar's min width */
flex: 1 1 0;
min-width: var(--sp-2);
}
.session-tally {
display: inline-flex;
align-items: center;
gap: 7px;
font-variant-numeric: tabular-nums;
}
.session-tally::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--live);
box-shadow: 0 0 5px var(--live-glow);
}
/* ---------- session tree ---------- */ /* ---------- session tree ---------- */
.tree { .tree {
@@ -1247,16 +1141,17 @@ input.leaf-rename:focus {
width: 26px; width: 26px;
} }
.attach-header .back { /* Opens the off-canvas session drawer. Desktop keeps the sidebar permanently in
view, so it is mobile-only — same rule as the welcome state's ☰. */
.attach-header .sessions-btn {
font-size: var(--fs-15); font-size: var(--fs-15);
width: 28px; width: 28px;
height: 26px; height: 26px;
} }
/* Desktop keeps the session list permanently in view, so the in-header "back"
arrow is redundant — the sidebar is the nav. Mobile (single-pane) keeps it. */
@media (min-width: 860px) { @media (min-width: 860px) {
.attach-header .back { .attach-header .sessions-btn,
.welcome-sessions {
display: none; display: none;
} }
} }
@@ -2087,9 +1982,6 @@ input.leaf-rename:focus {
max-width: none; max-width: none;
order: -1; order: -1;
} }
.statusbar .spacer {
display: none;
}
.tree-leaf .leaf-actions { .tree-leaf .leaf-actions {
opacity: 1; opacity: 1;
} }
@@ -2204,7 +2096,8 @@ input.leaf-rename:focus {
} }
/* Mobile: the tab bar is a second navigation surface — collapse it (a floating /* Mobile: the tab bar is a second navigation surface — collapse it (a floating
☰ inside the pane keeps the sidebar drawer reachable). */ ☰ inside the pane keeps the sidebar drawer reachable). */
.mv-mobile-solo > .mv-tabs { .mv-mobile-solo > .mv-tabs,
.mv-solo-tab > .mv-tabs {
display: none; display: none;
} }
@@ -2383,11 +2276,6 @@ input.leaf-rename:focus {
.mv-pane:not(.is-focused) .attach-header { .mv-pane:not(.is-focused) .attach-header {
opacity: 0.7; opacity: 0.7;
} }
/* per-pane back button is meaningless in multi-view (use Close pane instead) */
.mv-pane .attach-header .back {
display: none;
}
/* Draft pane (#57): an empty pane renders the shared spawn form inline. /* Draft pane (#57): an empty pane renders the shared spawn form inline.
The host is already a flex column; the form fills and scrolls within it. */ The host is already a flex column; the form fills and scrolls within it. */
.mv-pane-host > .mv-draft { .mv-pane-host > .mv-draft {
@@ -2509,10 +2397,6 @@ input.leaf-rename:focus {
} }
@media (max-width: 859px) { @media (max-width: 859px) {
.app-menu {
display: inline-grid;
}
/* sidebar → off-canvas drawer (override the route-based show/hide) */ /* sidebar → off-canvas drawer (override the route-based show/hide) */
.app-shell > .sidebar { .app-shell > .sidebar {
display: flex !important; display: flex !important;