Compare commits

...
11 Commits
Author SHA1 Message Date
Miguel Palhas c67fa2bd4e feat(maestro): force-kill prompt in the web client
ci / web (push) Successful in 35s
ci / rust (push) Successful in 4m1s
ci / flake (push) Successful in 6m55s
The kill button now sends the unforced DELETE immediately instead of
asking first. Only the daemon's guard refusal raises a prompt, and it is
a real dialog rather than confirm(), so it can offer the override the
daemon already implements.

The API error envelope is decoded into ApiRequestError, keeping the code
and the unsaved-work breakdown so the dialog names what is in the way.
2026-09-01 11:51:45 +01:00
Miguel Palhas 88b6bba9f2 feat(maestro): show only the program on session cards
ci / web (push) Successful in 34s
ci / rust (push) Successful in 3m56s
ci / flake (push) Successful in 7m8s
The command fragment named the full command line, so a session spawned
with `claude --dangerously-skip-permissions` spent the whole line on a
flag. Cards now name the program only — its icon in the web client, its
bare name in the TUI — and keep the full command line in the tooltip.

Adds `maestro_protocol::display::command_program`, the Rust counterpart
of the web's `splitCommand`.
2026-09-01 11:11:39 +01:00
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
30 changed files with 1023 additions and 419 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);
+81
View File
@@ -140,6 +140,63 @@ pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
cur cur
} }
/// Command wrappers that run another program: the program is what follows.
const WRAPPERS: [&str; 8] = [
"sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command",
];
/// The program a command line runs, basenamed and stripped of its arguments —
/// what a session card shows per `docs/SIDEBAR.md`, which names the program
/// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`,
/// `npx`, `pnpm dlx`, …) are looked through. Mirrors the web's `splitCommand`.
pub fn command_program(command: &str) -> String {
let words: Vec<&str> = command.split_whitespace().collect();
let mut i = 0;
while i < words.len() {
let word = words[i];
if is_env_assignment(word) {
i += 1;
continue;
}
let wrapper = basename(word).to_lowercase();
if WRAPPERS.contains(&wrapper.as_str()) {
i += 1;
if (wrapper == "pnpm" || wrapper == "yarn") && words.get(i) == Some(&"dlx") {
i += 1;
}
continue;
}
break;
}
match words.get(i) {
Some(word) => basename(word).to_string(),
// Nothing but wrappers and assignments: say what was there rather than
// rendering an empty fragment.
None => command.trim().to_string(),
}
}
/// `FOO=bar`, the shell's own prefix for one command's environment.
fn is_env_assignment(word: &str) -> bool {
let mut chars = word.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
let rest: String = chars.collect();
match rest.split_once('=') {
Some((name, _)) => name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
None => false,
}
}
fn basename(word: &str) -> &str {
match word.rsplit_once('/') {
Some((_, tail)) => tail,
None => word,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -318,4 +375,28 @@ mod tests {
}; };
assert_eq!(nested.name, "a/b"); assert_eq!(nested.name, "a/b");
} }
#[test]
fn command_program_drops_arguments() {
assert_eq!(
command_program("claude --dangerously-skip-permissions"),
"claude"
);
assert_eq!(command_program(" zsh -l "), "zsh");
assert_eq!(command_program("hx src/main.rs"), "hx");
}
#[test]
fn command_program_looks_through_paths_env_and_wrappers() {
assert_eq!(command_program("/usr/local/bin/claude -p hi"), "claude");
assert_eq!(command_program("FOO=1 BAR=2 claude"), "claude");
assert_eq!(command_program("npx opencode"), "opencode");
assert_eq!(command_program("pnpm dlx opencode run"), "opencode");
}
#[test]
fn command_program_keeps_a_line_that_is_only_wrappers() {
assert_eq!(command_program("sudo"), "sudo");
assert_eq!(command_program(""), "");
}
} }
+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"),
+29 -4
View File
@@ -8,7 +8,9 @@ use std::collections::{BTreeMap, HashSet};
use std::path::PathBuf; use std::path::PathBuf;
use chrono::Utc; use chrono::Utc;
use maestro_protocol::display::{abbreviate_dir, abbreviate_folder_name, session_tree_path}; use maestro_protocol::display::{
abbreviate_dir, abbreviate_folder_name, command_program, session_tree_path,
};
use maestro_protocol::{ActivityState, Session, SessionStatus, StatKind}; use maestro_protocol::{ActivityState, Session, SessionStatus, StatKind};
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
@@ -297,7 +299,8 @@ impl Sidebar {
if dead { if dead {
tail.push(Span::styled(" exited", muted)); tail.push(Span::styled(" exited", muted));
} }
let command = Span::styled(format!(" {}", s.command), muted); // docs/SIDEBAR.md: the card names the program, never its arguments.
let command = Span::styled(format!(" {}", command_program(&s.command)), muted);
// Reserve the triggered status fragments before detail and command. // Reserve the triggered status fragments before detail and command.
// If the row is exceptionally narrow, keep one column for the // If the row is exceptionally narrow, keep one column for the
@@ -874,12 +877,34 @@ mod tests {
); );
} }
/// docs/SIDEBAR.md: line 2 names the program, never its arguments.
#[test]
fn meta_line_drops_the_commands_arguments() {
let mut s = session_named(1, "handle");
s.command = "claude --dangerously-skip-permissions".into();
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 60, 6));
let meta = content(&all[3]);
assert!(
meta.contains("claude"),
"line 2 must name the program: {meta:?}"
);
assert!(
!meta.contains("dangerously"),
"line 2 must not carry arguments: {meta:?}"
);
}
/// The sidebar is narrow: the command truncates with `…` and never wraps /// The sidebar is narrow: the command truncates with `…` and never wraps
/// onto a third row (docs/SIDEBAR.md). /// onto a third row (docs/SIDEBAR.md).
#[test] #[test]
fn meta_line_command_truncates_instead_of_wrapping() { fn meta_line_command_truncates_instead_of_wrapping() {
let mut s = session_named(1, "claude"); let mut s = session_named(1, "claude");
s.command = "a command that cannot fit".into(); s.command = "a-program-name-that-cannot-fit".into();
s.activity_since = Utc::now() - Duration::seconds(5); s.activity_since = Utc::now() - Duration::seconds(5);
s.blocked_work = Some(InTheWay { s.blocked_work = Some(InTheWay {
uncommitted_files: 120, uncommitted_files: 120,
@@ -1099,7 +1124,7 @@ mod tests {
#[test] #[test]
fn blocked_warning_survives_before_a_truncated_command() { fn blocked_warning_survives_before_a_truncated_command() {
let mut s = session_named(1, "name"); let mut s = session_named(1, "name");
s.command = "a command that cannot fit".into(); s.command = "a-program-name-that-cannot-fit".into();
s.blocked_work = Some(InTheWay { s.blocked_work = Some(InTheWay {
uncommitted_files: 1, uncommitted_files: 1,
..Default::default() ..Default::default()
+19 -14
View File
@@ -88,21 +88,26 @@ of `maestro_protocol::InTheWay`'s `Display` does not disappear: it is the web
card's tooltip and the wording of the kill refusal, so no two clients describe card's tooltip and the wording of the kill refusal, so no two clients describe
the same tree differently. the same tree differently.
`<command>` is unconditional and muted: the session's command line, shown on `<command>` is unconditional and muted: it names the **program** the session
every card in both clients — no omission in the TUI, no name-vs-command runs — never its arguments. `claude --dangerously-skip-permissions` renders as
comparison gating it. A session named by its command line then shows the same `claude`. Flags say how a session was invoked, which is not what a card is for,
string on both lines; that is accepted, the price of a row that never changes and a long one crowds out every other fragment on the line. It shows on every
height. card in both clients — no omission in the TUI, no name-vs-command comparison
gating it. A session named by its program then shows the same string on both
lines; that is accepted, the price of a row that never changes height.
In the **web client** the command's leading program is replaced by that tool's The program is found by looking through a path, leading `FOO=bar` assignments
icon when it is one we have a mark for — Claude Code, Codex, opencode, Gemini and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …), then taking the basename:
CLI, Aider, Cursor, pi, and any shell — and only the remaining words of the `/usr/local/bin/claude -p hi` is `claude`. A line that is nothing but wrappers
command line follow as text. Recognition looks through a path, leading is shown as written rather than as an empty fragment.
`FOO=bar` assignments and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …); an
unrecognised program keeps its full text, so the card never shows a mark In the **web client** the program is replaced by that tool's icon when it is
standing for the wrong tool. The full command line stays in the card's tooltip one we have a mark for — Claude Code, Codex, opencode, Gemini CLI, Aider,
either way. The **TUI** keeps the command as text: a terminal cannot be assumed Cursor, pi, and any shell — and nothing follows it as text. An unrecognised
to have a font carrying tool glyphs. program keeps its name as text, so the card never shows a mark standing for the
wrong tool. The full command line, arguments and all, stays in the card's
tooltip either way. The **TUI** keeps the program as text: a terminal cannot be
assumed to have a font carrying tool glyphs.
## Colour per activity state ## Colour per activity state
+5
View File
@@ -79,6 +79,11 @@ would have blocked. A tree whose guard is clear never needed the override, so a
forced kill removes it as usual — force changes what happens to blocked work, forced kill removes it as usual — force changes what happens to blocked work,
not ordinary cleanup. not ordinary cleanup.
In the web client the kill button sends the unforced `DELETE` straight away —
there is no pre-confirmation, because the daemon is the one that decides. Only
the refusal raises a prompt, and that prompt names what is in the way and offers
"Force kill", which repeats the request with `force=true`.
Ending a session and destroying its work are two separate acts. Maestro only Ending a session and destroying its work are two separate acts. Maestro only
performs the first one. Removing a tree it left behind is the operator's own performs the first one. Removing a tree it left behind is the operator's own
`git worktree remove`, chosen while looking at exactly what would be lost. `git worktree remove`, chosen while looking at exactly what would be lost.
+32 -7
View File
@@ -1,6 +1,7 @@
import * as routes from "./routes.js"; import * as routes from "./routes.js";
import type { import type {
ApiError, ApiError,
InTheWay,
Branches, Branches,
CreateSessionRequest, CreateSessionRequest,
DaemonInfo, DaemonInfo,
@@ -13,19 +14,38 @@ import type {
Uuid, Uuid,
} from "./types.js"; } from "./types.js";
/** A `/api/*` failure with the daemon's own error envelope kept intact, so a
* caller can branch on `code` — the kill guard's `kill_guarded` (#79) — and
* render `unsavedWork` rather than re-parsing the message. */
export class ApiRequestError extends Error {
readonly status: number;
readonly code: string;
readonly detail: string;
readonly unsavedWork: InTheWay | null;
constructor(res: Response, info?: ApiError["error"], text?: string) {
const detail = info ? `${info.code}: ${info.message}` : (text ?? "");
super(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
this.name = "ApiRequestError";
this.status = res.status;
this.code = info?.code ?? "";
this.detail = info?.message ?? text ?? "";
this.unsavedWork = info?.unsaved_work ?? null;
}
}
async function jsonOrError<T>(res: Response): Promise<T> { async function jsonOrError<T>(res: Response): Promise<T> {
if (res.ok) { if (res.ok) {
if (res.status === 204) return undefined as unknown as T; if (res.status === 204) return undefined as unknown as T;
return (await res.json()) as T; return (await res.json()) as T;
} }
let detail = "";
try { try {
const body = (await res.json()) as ApiError; const body = (await res.json()) as ApiError;
detail = `${body.error.code}: ${body.error.message}`; throw new ApiRequestError(res, body.error);
} catch { } catch (e) {
detail = await res.text(); if (e instanceof ApiRequestError) throw e;
throw new ApiRequestError(res, undefined, await res.text().catch(() => ""));
} }
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
} }
export async function listSessions(): Promise<Session[]> { export async function listSessions(): Promise<Session[]> {
@@ -57,9 +77,14 @@ export async function renameSession(id: Uuid, name: string): Promise<Session> {
); );
} }
export async function killSession(id: Uuid): Promise<void> { /** Kill a session. With `force` (#81) the daemon overrides its refusal over a
* worktree holding unsaved work and leaves that worktree on disk. */
export async function killSession(id: Uuid, force = false): Promise<void> {
const url = force
? `${routes.session(id)}?${routes.FORCE_QUERY}=true`
: routes.session(id);
await jsonOrError( await jsonOrError(
await fetch(routes.session(id), { await fetch(url, {
method: "DELETE", method: "DELETE",
credentials: "same-origin", credentials: "same-origin",
}), }),
+13 -5
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";
@@ -55,12 +56,13 @@ export interface AttachInfo {
command: string; command: string;
} }
/** Header echoes the sidebar: the command's leading program becomes its tool /** Header echoes the sidebar: the program becomes its tool icon when we have a
* icon when we have a mark for it, remaining words stay as text. */ * mark for it and stays as its bare name when we do not. Arguments are
* dropped, as on the card. */
function commandFragment(command: string): string { function commandFragment(command: string): string {
const { tool, rest } = splitCommand(command); const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null; const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? rest : command; const text = icon ? "" : program;
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`; return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
} }
@@ -80,7 +82,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 +102,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);
}); });
+79 -28
View File
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest"; import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { renderSidebar } from "./catalog.js"; import { renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js"; import type { CatalogListener, CatalogStore } from "./events.js";
@@ -16,6 +16,10 @@ beforeAll(() => {
window.fetch = () => Promise.reject(new Error("offline")); window.fetch = () => Promise.reject(new Error("offline"));
}); });
afterEach(() => {
document.querySelector(".kill-dialog")?.remove();
});
function session(overrides: Partial<Session>): Session { function session(overrides: Partial<Session>): Session {
return { return {
id: "11111111-2222-3333-4444-555555555555", id: "11111111-2222-3333-4444-555555555555",
@@ -46,6 +50,11 @@ function rowFor(el: HTMLElement, id: string): HTMLElement | null {
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`); return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
} }
/** Let the kill chain's awaits (fetch, its json body) settle. */
async function flush(): Promise<void> {
for (let i = 0; i < 8; i++) await Promise.resolve();
}
describe("blocked-work indicator (#83)", () => { describe("blocked-work indicator (#83)", () => {
it("marks the row of a blocked session in diff notation", () => { it("marks the row of a blocked session in diff notation", () => {
const blocked = session({ const blocked = session({
@@ -89,37 +98,77 @@ describe("blocked-work indicator (#83)", () => {
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull(); expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
}); });
it("restates the warning at the kill confirm", async () => { it("kills straight away, without a first confirmation", async () => {
const confirmations: string[] = []; const calls: string[] = [];
window.confirm = (msg?: string) => { window.fetch = ((url: string, init?: RequestInit) => {
confirmations.push(msg ?? ""); calls.push(`${init?.method ?? "GET"} ${url}`);
return false; return Promise.resolve(new Response(null, { status: 204 }));
}; }) as typeof fetch;
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" });
const el = renderSidebar(storeWith([plain])).el;
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
expect(document.querySelector(".kill-dialog")).toBeNull();
});
it("offers the override when the daemon refuses a guarded kill", async () => {
const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
if (url.includes("force=true")) return Promise.resolve(new Response(null, { status: 204 }));
return Promise.resolve(
new Response(
JSON.stringify({
error: {
code: "kill_guarded",
message: "worktree has unsaved work",
unsaved_work: { untracked_files: 1 },
},
}),
{ status: 409, headers: { "content-type": "application/json" } },
),
);
}) as typeof fetch;
const blocked = session({ const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000004", id: "aaaaaaaa-0000-0000-0000-000000000004",
command: "bash", command: "bash",
blocked_work: { untracked_files: 1 }, blocked_work: { untracked_files: 1 },
}); });
const el = renderSidebar(storeWith([blocked])).el; const el = renderSidebar(storeWith([blocked])).el;
const kill = rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill"); rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
kill?.click(); await flush();
await Promise.resolve(); const dialog = document.querySelector(".kill-dialog");
expect(confirmations).toHaveLength(1); expect(dialog?.textContent).toContain("1 untracked file");
expect(confirmations[0]).toContain("1 untracked file"); dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
expect(confirmations[0]).toContain("refused unless forced"); await flush();
expect(calls).toContain(`DELETE /api/sessions/${blocked.id}?force=true`);
expect(document.querySelector(".kill-dialog")).toBeNull();
}); });
it("confirms plainly when nothing blocks", async () => { it("closes the override prompt without killing on cancel", async () => {
const confirmations: string[] = []; const calls: string[] = [];
window.confirm = (msg?: string) => { window.fetch = ((url: string, init?: RequestInit) => {
confirmations.push(msg ?? ""); calls.push(`${init?.method ?? "GET"} ${url}`);
return false; return Promise.resolve(
}; new Response(
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" }); JSON.stringify({
const el = renderSidebar(storeWith([plain])).el; error: { code: "kill_guarded", message: "worktree has unsaved work" },
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click(); }),
await Promise.resolve(); { status: 409, headers: { "content-type": "application/json" } },
expect(confirmations).toEqual(['Kill session "claude"?']); ),
);
}) as typeof fetch;
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000006",
blocked_work: { unpushed_commits: 2 },
});
const el = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
expect(document.querySelector(".kill-dialog")).toBeNull();
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
}); });
}); });
@@ -209,15 +258,16 @@ describe("session cards (#107)", () => {
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull(); expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
}); });
it("keeps the command's arguments as text beside the icon", () => { it("drops the command's arguments, keeping them in the tooltip", () => {
const s = session({ const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001c", id: "aaaaaaaa-0000-0000-0000-00000000001c",
command: "claude --resume", command: "claude --dangerously-skip-permissions",
}); });
const el = renderSidebar(storeWith([s])).el; const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd"); const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude"); expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("--resume"); expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
expect(cmd?.getAttribute("title")).toBe("claude --dangerously-skip-permissions");
}); });
it("keeps an unknown command as plain text", () => { it("keeps an unknown command as plain text", () => {
@@ -228,7 +278,8 @@ describe("session cards (#107)", () => {
const el = renderSidebar(storeWith([s])).el; const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd"); const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")).toBeNull(); expect(cmd?.querySelector(".tool-icon")).toBeNull();
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx src/main.rs"); expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx");
expect(cmd?.getAttribute("title")).toBe("hx src/main.rs");
}); });
it("maps each activity state to its contract slug", () => { it("maps each activity state to its contract slug", () => {
+42 -74
View File
@@ -1,5 +1,6 @@
import { escapeHtml } from "./html.js"; import { escapeHtml } from "./html.js";
import { getDaemonInfo, killSession, renameSession } from "./api.js"; import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
import { openForceKillDialog } from "./killDialog.js";
import { openContextMenu } from "./contextMenu.js"; import { openContextMenu } from "./contextMenu.js";
import { isTypingTarget, toggleHelp } from "./help.js"; import { isTypingTarget, toggleHelp } from "./help.js";
import { CatalogStore } from "./events.js"; import { CatalogStore } from "./events.js";
@@ -37,6 +38,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 —
@@ -92,13 +97,14 @@ function blockedFragment(blockers: InTheWay): string {
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`; return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`;
} }
/** docs/SIDEBAR.md: the command's leading program becomes its tool icon when /** docs/SIDEBAR.md: the card names the program only — arguments never reach
* we have a mark for it, and only the remaining words stay as text. The full * the card. The program becomes its tool icon when we have a mark for it, and
* command line stays in the tooltip either way. */ * stays as its bare name when we do not. The full command line, arguments and
* all, stays in the tooltip either way. */
function commandFragment(command: string): string { function commandFragment(command: string): string {
const { tool, rest } = splitCommand(command); const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null; const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? rest : command; const text = icon ? "" : program;
return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`; return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
} }
@@ -169,13 +175,13 @@ 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) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
void confirmAndKill(s, name); void requestKill(s, name);
}); });
row.addEventListener("contextmenu", (ev) => { row.addEventListener("contextmenu", (ev) => {
ev.preventDefault(); ev.preventDefault();
@@ -185,24 +191,33 @@ 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 requestKill(s, name) },
]); ]);
}); });
return row; return row;
} }
/** #83: the confirm restates the warning at the moment of decision. */ /**
async function confirmAndKill(s: Session, name: string): Promise<void> { * Kill on the first click (#81): the daemon is the one that decides whether a
const blockers = sessionBlockers(s); * session may go, and it refuses over a worktree holding unsaved work. Only
const question = blockers * that refusal is worth a prompt, and it needs one `confirm()` cannot give —
? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.` * a forced kill. Anything else surfaces as an alert.
: `Kill session "${name}"?`; */
if (!confirm(question)) return; async function requestKill(s: Session, name: string): Promise<void> {
try { try {
await killSession(s.id); await killSession(s.id);
} catch (e) { } catch (e) {
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
openForceKillDialog({
name,
blockers: e.unsavedWork ?? sessionBlockers(s),
reason: e.detail,
onForce: () => killSession(s.id, true),
});
return;
}
alert(`Failed to kill: ${(e as Error).message}`); alert(`Failed to kill: ${(e as Error).message}`);
} }
} }
@@ -262,18 +277,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 +301,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 +380,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 },
);
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Forced-kill prompt (#81).
*
* The daemon refuses to kill a session whose worktree still holds uncommitted
* or unpushed work. `confirm()` can only ask the same question twice, so the
* refusal gets its own dialog: it names what is in the way and offers the
* override the daemon's `force=true` already implements. A kill the daemon
* accepts never reaches here — there is nothing to warn about.
*/
import { formatInTheWay, type InTheWay } from "./types.js";
export interface ForceKillPrompt {
/** Session name, as the sidebar shows it. */
name: string;
/** What the daemon reported in the way, when it reported anything. */
blockers: InTheWay | null;
/** Message from the daemon's refusal, shown when there is no breakdown. */
reason: string;
/** Run the forced kill. A rejection is rendered in the dialog, which stays
* open so the override can be retried. */
onForce: () => Promise<void>;
}
let open: { el: HTMLElement; onKeydown: (ev: KeyboardEvent) => void } | null = null;
export function closeForceKillDialog(): void {
if (!open) return;
window.removeEventListener("keydown", open.onKeydown, true);
open.el.remove();
open = null;
}
/** Mount the prompt. Opening while one is up replaces it, so a second refusal
* can never stack two dialogs. */
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
closeForceKillDialog();
const el = document.createElement("div");
el.className = "kill-dialog";
el.setAttribute("role", "dialog");
el.setAttribute("aria-modal", "true");
el.setAttribute("aria-label", `Kill ${p.name}`);
const panel = document.createElement("div");
panel.className = "kill-dialog-panel";
const title = document.createElement("h2");
title.className = "kill-dialog-title";
title.textContent = `Kill "${p.name}"?`;
const body = document.createElement("p");
body.className = "kill-dialog-body";
body.textContent = p.blockers
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`;
const error = document.createElement("p");
error.className = "kill-dialog-error";
error.hidden = true;
const actions = document.createElement("div");
actions.className = "kill-dialog-actions";
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "btn btn-ghost kill-dialog-cancel";
cancel.textContent = "Cancel";
cancel.addEventListener("click", closeForceKillDialog);
const force = document.createElement("button");
force.type = "button";
force.className = "btn btn-danger kill-dialog-force";
force.textContent = "Force kill";
force.addEventListener("click", () => {
force.disabled = true;
void p
.onForce()
.then(closeForceKillDialog)
.catch((e: unknown) => {
force.disabled = false;
error.hidden = false;
error.textContent = `Failed to kill: ${(e as Error).message}`;
});
});
actions.append(cancel, force);
panel.append(title, body, error, actions);
// A press on the scrim cancels; one inside the panel is left alone.
el.addEventListener("click", (ev) => {
if (!panel.contains(ev.target as Node)) closeForceKillDialog();
});
el.appendChild(panel);
document.body.appendChild(el);
const onKeydown = (ev: KeyboardEvent): void => {
if (ev.key !== "Escape") return;
ev.preventDefault();
ev.stopPropagation();
closeForceKillDialog();
};
window.addEventListener("keydown", onKeydown, true);
open = { el, onKeydown };
cancel.focus();
return el;
}
-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.
+4
View File
@@ -18,6 +18,10 @@ export const EVENTS = "/api/events";
/** Name of the query parameter `DIRS` and `GITROOT` both take. */ /** Name of the query parameter `DIRS` and `GITROOT` both take. */
export const PATH_QUERY = "path"; export const PATH_QUERY = "path";
/** Name of the query parameter `DELETE /api/sessions/:id` takes (#81):
* `force=true` overrides the kill refusal over a guarded worktree. */
export const FORCE_QUERY = "force";
/** Name of the query parameter `NAMES` takes. */ /** Name of the query parameter `NAMES` takes. */
export const COUNT_QUERY = "count"; export const COUNT_QUERY = "count";
+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. */
+65 -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 {
@@ -1164,6 +1058,64 @@ input.leaf-rename:focus {
border-color: color-mix(in oklab, var(--danger) 40%, transparent); border-color: color-mix(in oklab, var(--danger) 40%, transparent);
} }
/* ---------- forced-kill prompt (#81) ---------- */
.kill-dialog {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: grid;
place-items: center;
padding: var(--sp-4);
background: oklch(0 0 0 / 0.5);
backdrop-filter: blur(4px);
}
.kill-dialog-panel {
width: min(400px, 100%);
padding: var(--sp-4);
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--r-3);
box-shadow: var(--shadow-2);
}
.kill-dialog-title {
margin: 0 0 var(--sp-2);
font-family: var(--font-mono);
font-size: var(--fs-15);
color: var(--ink);
}
.kill-dialog-body {
margin: 0;
color: var(--muted);
font-size: var(--fs-13);
line-height: 1.5;
}
.kill-dialog-error {
margin: var(--sp-2) 0 0;
color: var(--danger);
font-size: var(--fs-13);
}
.kill-dialog-actions {
display: flex;
justify-content: flex-end;
gap: var(--sp-2);
margin-top: var(--sp-4);
}
.btn-danger {
color: var(--danger);
border-color: color-mix(in oklab, var(--danger) 40%, transparent);
}
.btn-danger:hover {
background: var(--danger-tint);
border-color: var(--danger);
}
/* ---------- empty / loading ---------- */ /* ---------- empty / loading ---------- */
.empty { .empty {
@@ -1247,16 +1199,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 +2040,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 +2154,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 +2334,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 +2455,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;
+7 -7
View File
@@ -20,12 +20,12 @@ describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
} }
}); });
it("keeps everything after the program as text", () => { it("drops everything after the program", () => {
expect(splitCommand("claude --resume -p hi")).toEqual({ expect(splitCommand("claude --dangerously-skip-permissions")).toEqual({
tool: "claude", tool: "claude",
rest: "--resume -p hi", program: "claude",
}); });
expect(splitCommand(" zsh -l ")).toEqual({ tool: "shell", rest: "-l" }); expect(splitCommand(" zsh -l ")).toEqual({ tool: "shell", program: "zsh" });
}); });
it("looks through paths, env assignments and wrappers", () => { it("looks through paths, env assignments and wrappers", () => {
@@ -33,13 +33,13 @@ describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
expect(splitCommand("FOO=1 BAR=2 claude").tool).toBe("claude"); expect(splitCommand("FOO=1 BAR=2 claude").tool).toBe("claude");
expect(splitCommand("npx opencode").tool).toBe("opencode"); expect(splitCommand("npx opencode").tool).toBe("opencode");
expect(splitCommand("pnpm dlx opencode run").tool).toBe("opencode"); expect(splitCommand("pnpm dlx opencode run").tool).toBe("opencode");
expect(splitCommand("pnpm dlx opencode run").rest).toBe("run"); expect(splitCommand("pnpm dlx opencode run").program).toBe("opencode");
}); });
it("leaves an unrecognised program as text, mark-less", () => { it("leaves an unrecognised program as its bare name, mark-less", () => {
expect(splitCommand("hx src/main.rs")).toEqual({ expect(splitCommand("hx src/main.rs")).toEqual({
tool: null, tool: null,
rest: "hx src/main.rs", program: "hx",
}); });
// A wrapper with nothing after it is not a tool either. // A wrapper with nothing after it is not a tool either.
expect(splitCommand("sudo").tool).toBeNull(); expect(splitCommand("sudo").tool).toBeNull();
+17 -18
View File
@@ -2,11 +2,11 @@
* *
* A session's command line starts with the program that runs it, and that * A session's command line starts with the program that runs it, and that
* program is nearly always one of a handful of agents or a shell. Rendering it * program is nearly always one of a handful of agents or a shell. Rendering it
* as its own mark buys back most of the fragment's width, which the sidebar * as its own mark buys back the whole fragment's width — the arguments are
* spends on the rest of the command line instead. * dropped, so a card says which tool runs, not how it was invoked.
* *
* Recognition is deliberately narrow: an unrecognised program keeps its text, * Recognition is deliberately narrow: an unrecognised program keeps its name
* so the fragment never renders a mark that stands for the wrong tool. * as text, so the fragment never renders a mark that stands for the wrong tool.
*/ */
/** Command wrappers that run another program: the tool is what follows. */ /** Command wrappers that run another program: the tool is what follows. */
@@ -48,15 +48,15 @@ export const TOOL_LABELS: Record<string, string> = {
export interface CommandParts { export interface CommandParts {
/** Icon id, or `null` when the program is not one we have a mark for. */ /** Icon id, or `null` when the program is not one we have a mark for. */
tool: string | null; tool: string | null;
/** What is left of the command line once the icon stands for the program. /** The program the command runs, basenamed and stripped of its arguments —
* Empty when the icon says everything the command line said. */ * what a card shows when there is no icon to show instead. */
rest: string; program: string;
} }
/** Split a command line into the tool its icon stands for and the remainder. /** Split a command line into the tool its icon stands for and the program name.
* *
* `claude --resume` → `{tool: "claude", rest: "--resume"}`; an unrecognised * `claude --resume` → `{tool: "claude", program: "claude"}`; an unrecognised
* `hx src/` keeps the whole line as `rest` with no tool. */ * `hx src/` keeps `hx` as the program with no tool. */
export function splitCommand(command: string): CommandParts { export function splitCommand(command: string): CommandParts {
const words = command.trim().split(/\s+/).filter(Boolean); const words = command.trim().split(/\s+/).filter(Boolean);
let i = 0; let i = 0;
@@ -68,23 +68,22 @@ export function splitCommand(command: string): CommandParts {
i += 1; i += 1;
continue; continue;
} }
const program = basename(word); const wrapper = basename(word).toLowerCase();
if (WRAPPERS.has(program)) { if (WRAPPERS.has(wrapper)) {
i += 1; i += 1;
if ((program === "pnpm" || program === "yarn") && words[i] === "dlx") i += 1; if ((wrapper === "pnpm" || wrapper === "yarn") && words[i] === "dlx") i += 1;
continue; continue;
} }
break; break;
} }
if (i >= words.length) return { tool: null, rest: command.trim() }; if (i >= words.length) return { tool: null, program: command.trim() };
const tool = BY_PROGRAM[basename(words[i])] ?? null; const program = basename(words[i]);
if (!tool) return { tool: null, rest: command.trim() }; return { tool: BY_PROGRAM[program.toLowerCase()] ?? null, program };
return { tool, rest: words.slice(i + 1).join(" ") };
} }
function basename(word: string): string { function basename(word: string): string {
const cut = word.lastIndexOf("/"); const cut = word.lastIndexOf("/");
return (cut >= 0 ? word.slice(cut + 1) : word).toLowerCase(); return cut >= 0 ? word.slice(cut + 1) : word;
} }
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */ /** Anthropic's burst: tapered rays around a centre, long ones on the axes. */
+6 -1
View File
@@ -253,7 +253,12 @@ export type ClientControl =
| { type: "Error"; code: string; message: string }; | { type: "Error"; code: string; message: string };
export interface ApiError { export interface ApiError {
error: { code: string; message: string }; error: {
code: string;
message: string;
/** Set only by the kill guard (#79): what blocked the unforced kill. */
unsaved_work?: InTheWay;
};
} }
export function sessionName(s: Session): string { export function sessionName(s: Session): string {