Compare commits

..
13 Commits
Author SHA1 Message Date
Miguel Palhas 2e7c5f7f58 fix: recover PWA after backgrounding
ci / web (push) Successful in 48s
ci / flake (push) Successful in 2m56s
ci / rust (push) Successful in 6m4s
2026-09-05 18:10:21 +01:00
Miguel Palhas 3529947497 Merge branch 'fix/codex-attach-scroll'
ci / web (push) Successful in 1m3s
ci / flake (push) Successful in 3m3s
ci / rust (push) Successful in 7m35s
2026-09-05 03:49:35 +01:00
Miguel Palhas 4851ad9101 fix(maestro): follow initial attach replay 2026-09-05 03:48:35 +01:00
Miguel Palhas 3cb4bebd1e fix(maestro): update second stale worktree test for branch suffixing
ci / web (push) Successful in 54s
ci / flake (push) Successful in 3m8s
ci / rust (push) Successful in 8m30s
second_spawn_on_same_branch_is_rejected asserted the same pre-#77
outright-rejection contract as the unit test fixed in 0e20baf, just at
the e2e/session level — also broken by 49ab7e9's branch-collision
suffixing and also already failing on main.

Renamed to second_spawn_on_same_branch_gets_suffixed and rewritten to
assert the actual behavior: the second spawn succeeds on a `-2`
suffixed branch and directory, and the first worktree is untouched.
2026-09-04 20:08:16 +01:00
Miguel Palhas 0e20baf7c2 fix(maestro): update stale worktree test for branch-collision suffixing
ci / web (push) Failing after 38s
ci / flake (push) Successful in 4m17s
ci / rust (push) Failing after 7m40s
add_fails_when_branch_already_exists asserted the pre-#77 behavior:
that a branch collision errors outright. Since 49ab7e9, add_new
sidesteps a branch collision with a numeric suffix instead, so the
test was failing CI on main regardless of any given change.

Replaces it with a test asserting the actual (and intended) suffixing
behavior.
2026-09-04 19:57:33 +01:00
Miguel Palhas af8acbfbf2 test(maestro): verify umbrella binary routes each subcommand to its crate
ci / web (push) Successful in 1m10s
ci / rust (push) Failing after 3m17s
ci / flake (push) Successful in 7m6s
Closes #10

The compile_error guard and per-feature build already had CI coverage
(the "feature-gate matrix" job). What was missing was proof that a
subcommand actually reaches its own crate's dispatch at runtime, not
just that clap parses it.

A malformed --config file makes cli/daemon/tui fail identically at
config load, before touching tmux, the network, or a terminal. That
turns "clap parsed it but the handler never ran" (a usage error, exit
2) into a distinguishable outcome from "it dispatched into the real
crate" (a `toml decode` runtime error via main's error printer, exit
1). The absence-side tests are feature-gated to only exist in a
single-feature build, so they run under CI's existing per-feature
matrix rather than the default all-features build.
2026-09-04 19:47:32 +01:00
Miguel Palhas 1ad7845f51 fix(maestro): refresh attach header when a session is renamed
Header name/command were baked into innerHTML at connection creation
and never revisited. ConnectionPool.mount() reused the cached
connection on later calls but dropped the fresh info, so a header
never picked up haiku autoname's rename.
2026-09-04 19:38:09 +01:00
Miguel PalhasandClaude Sonnet 5 f0c272e0b1 feat(maestro): copy terminal selection to clipboard
ci / web (push) Successful in 28s
ci / rust (push) Failing after 2m6s
ci / flake (push) Successful in 2m14s
xterm.js has no built-in copy-on-select; mirror onSelectionChange to
navigator.clipboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 14:33:24 +01:00
Miguel Palhas 624f6f19c8 Merge remote-tracking branch 'origin/fermata'
ci / web (push) Failing after 13s
ci / flake (push) Successful in 3m12s
ci / rust (push) Failing after 3m26s
2026-09-04 13:32:05 +01:00
Miguel Palhas 08161ae042 Merge remote-tracking branch 'origin/main' into fermata 2026-09-04 13:30:19 +01:00
Miguel PalhasandClaude Sonnet 5 1e3c7b294d fix(maestro): send plain Escape to the pane, move focus.toggle behind the leader
Escape unfocusing the pane made it impossible to send a literal Esc into
the attached session. Removes pane.focusList and its Escape binding, and
rebinds focus.toggle from ctrl-shift-h (claimed by the browser's History
shortcut) to leader-h, consistent with the other leader escape hatches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:30:14 +01:00
Miguel PalhasandClaude Sonnet 5 49ab7e9c81 fix(maestro): suffix branch name on new-branch worktree collision
ci / rust (push) Failing after 3m26s
ci / web (push) Successful in 43s
ci / flake (push) Successful in 3m54s
git worktree add -b fails outright when the branch already exists, even
if the target directory is free. A random fast-forward handle commonly
collides with a branch an earlier, already-removed worktree left behind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:29:49 +01:00
Miguel PalhasandClaude Sonnet 5 0cc25d7e1c fix(maestro): equalize split widths after spawning a session
ci / rust (push) Successful in 9m12s
ci / web (push) Successful in 33s
ci / flake (push) Successful in 2m37s
splitLeaf always halved just the target pane, so repeated splits left
one pane at 50% and the rest squeezed into shrinking fractions instead
of equal columns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:29:03 +01:00
20 changed files with 337 additions and 97 deletions
Generated
+1
View File
@@ -1051,6 +1051,7 @@ dependencies = [
"maestro-cli", "maestro-cli",
"maestro-daemon", "maestro-daemon",
"maestro-tui", "maestro-tui",
"tempfile",
"tokio", "tokio",
"tracing-subscriber", "tracing-subscriber",
] ]
+17 -5
View File
@@ -405,13 +405,17 @@ fn run_git(dir: &std::path::Path, args: &[&str]) {
); );
} }
/// `git worktree add -b` alone fails outright when the branch already exists,
/// even with the target directory free — a random handle commonly collides
/// with one an earlier, already-removed worktree left behind. `add_new`
/// sidesteps it on the branch name itself instead of erroring (#77).
#[test] #[test]
fn add_fails_when_branch_already_exists() { fn add_new_suffixes_branch_name_on_branch_collision() {
let repo_dir = TempDir::new().unwrap(); let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf(); let repo = repo_dir.path().to_path_buf();
init_repo(&repo); init_repo(&repo);
// pre-create the branch // pre-create the branch, unused by any worktree.
let status = Command::new("git") let status = Command::new("git")
.arg("-C") .arg("-C")
.arg(&repo) .arg(&repo)
@@ -423,9 +427,17 @@ fn add_fails_when_branch_already_exists() {
let wt_root = TempDir::new().unwrap(); let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf())); let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let err = mgr.add_new(&repo, "feat/dup").unwrap_err(); let path = mgr
let msg = format!("{err}"); .add_new(&repo, "feat/dup")
assert!(msg.contains("git exited"), "{msg}"); .expect("sidesteps the branch collision");
assert!(path.ends_with("feat/dup-2"), "{}", path.display());
assert!(path.exists());
// The pre-existing branch is untouched; the new worktree checked out the
// suffixed one instead of erroring or adopting `feat/dup`.
let branches = worktree_branches(&repo);
assert!(branches.contains(&"feat/dup-2".to_string()), "{branches:?}");
assert!(!branches.contains(&"feat/dup".to_string()), "{branches:?}");
} }
/// The project picker lists linked worktrees as pickable rows, so `base_repo` /// The project picker lists linked worktrees as pickable rows, so `base_repo`
+31 -24
View File
@@ -323,10 +323,11 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
client.kill_session(s.id).await.expect("kill"); client.kill_session(s.id).await.expect("kill");
} }
/// #66: a second spawn onto a branch that already has a worktree is rejected /// #66/#77: a second spawn onto the same branch used to be rejected outright;
/// with a structured worktree error; the first worktree stays intact. /// since the branch-collision suffixing landed, it succeeds instead — the
/// second worktree lands on `<branch>-2` and the first stays untouched.
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn second_spawn_on_same_branch_is_rejected() { async fn second_spawn_on_same_branch_gets_suffixed() {
let (_daemon, client) = bring_up().await; let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap(); let base = TempDir::new().unwrap();
@@ -336,28 +337,33 @@ async fn second_spawn_on_same_branch_is_rejected() {
.spawn_session(&worktree_req(base.path(), "feature/dup", true)) .spawn_session(&worktree_req(base.path(), "feature/dup", true))
.await .await
.expect("first spawn"); .expect("first spawn");
let dup = client let second = client
.spawn_session(&worktree_req(base.path(), "feature/dup", true)) .spawn_session(&worktree_req(base.path(), "feature/dup", true))
.await; .await
match dup { .expect("second spawn suffixes instead of rejecting");
Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => {
assert_eq!(
info.code, "worktree_failed",
"expected worktree error: {info:?}"
);
}
other => panic!("expected rejection for duplicate branch, got {other:?}"),
}
// Exactly one registration, one directory, still usable. let suffixed = base.path().join("worktrees").join("feature").join("dup-2");
// Count branch records only: the worktree path now contains the branch assert_eq!(
// name too (`worktrees/feature/dup`), so a substring match over every line second.cwd, suffixed,
// would count the same tree twice. "second worktree must land on the suffixed path"
let holders = git_worktree_list(base.path()) );
.iter() assert!(suffixed.is_dir());
.filter(|w| w.as_str() == "branch refs/heads/feature/dup")
.count(); // Both registrations exist: the original branch, untouched, and the
assert_eq!(holders, 1, "duplicate spawn must not register another tree"); // suffixed one the second spawn created.
let registrations = git_worktree_list(base.path());
assert!(
registrations
.iter()
.any(|l| l.as_str() == "branch refs/heads/feature/dup"),
"original branch must stay registered: {registrations:?}"
);
assert!(
registrations
.iter()
.any(|l| l.as_str() == "branch refs/heads/feature/dup-2"),
"suffixed branch must be registered too: {registrations:?}"
);
assert!( assert!(
base.path() base.path()
.join("worktrees") .join("worktrees")
@@ -368,7 +374,8 @@ async fn second_spawn_on_same_branch_is_rejected() {
); );
let _origin = throwaway_origin(base.path()); let _origin = throwaway_origin(base.path());
client.kill_session(first.id).await.expect("kill"); client.kill_session(first.id).await.expect("kill first");
client.kill_session(second.id).await.expect("kill second");
} }
/// #66/#56: when the client picks a *linked worktree* as base_repo, placement /// #66/#56: when the client picks a *linked worktree* as base_repo, placement
+3
View File
@@ -29,3 +29,6 @@ tracing-subscriber.workspace = true
maestro-cli = { path = "../maestro-cli", optional = true, default-features = false } maestro-cli = { path = "../maestro-cli", optional = true, default-features = false }
maestro-daemon = { path = "../maestro-daemon", optional = true, default-features = false } maestro-daemon = { path = "../maestro-daemon", optional = true, default-features = false }
maestro-tui = { path = "../maestro-tui", optional = true } maestro-tui = { path = "../maestro-tui", optional = true }
[dev-dependencies]
tempfile.workspace = true
+102
View File
@@ -0,0 +1,102 @@
//! Process-level routing for the umbrella `maestro` binary (#10): each
//! enabled top-level subcommand must reach its own crate's runtime dispatch,
//! not just parse successfully.
//!
//! A malformed `--config` file makes every crate's dispatch fail identically
//! at config load, before any subcommand touches tmux, the network, or a
//! terminal. That lets the assertions tell "clap parsed it but the handler
//! never ran" (a usage error, exit 2) apart from "it dispatched into the real
//! crate" (a `toml decode` runtime error via `main`'s `maestro: {e}` printer,
//! exit 1).
//!
//! The absence tests below only compile when the matching feature is off, so
//! they run under CI's per-feature-subset matrix (`--no-default-features
//! --features cli|daemon|tui`) rather than the default all-features build.
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Output};
fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_maestro"))
}
fn bad_config() -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().expect("create temp config file");
write!(f, "this is not valid toml {{{{{{").expect("write temp config");
f
}
fn run(args: &[&str]) -> Output {
Command::new(bin())
.args(args)
.output()
.unwrap_or_else(|e| panic!("spawn `maestro {args:?}`: {e}"))
}
/// Real dispatch happened: exit 1, `main`'s `"maestro: {e}"` prefix, and the
/// config crate's error surfacing through — never clap's own usage-error
/// page, which would mean the subcommand never reached its handler.
fn assert_dispatched(out: &Output) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "stderr:\n{stderr}");
assert!(stderr.starts_with("maestro: "), "stderr:\n{stderr}");
assert!(stderr.contains("toml decode"), "stderr:\n{stderr}");
assert!(!stderr.contains("Usage:"), "stderr:\n{stderr}");
}
/// Never reached a handler: clap rejected the subcommand outright because the
/// feature that defines it is off. Only used by the `not(feature = "...")`
/// tests below, which themselves only exist in a single-feature build.
#[cfg(not(all(feature = "cli", feature = "daemon", feature = "tui")))]
fn assert_unrecognized(out: &Output, name: &str) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "stderr:\n{stderr}");
assert!(
stderr.contains("unrecognized subcommand"),
"stderr:\n{stderr}"
);
assert!(stderr.contains(name), "stderr:\n{stderr}");
}
#[cfg(feature = "cli")]
#[test]
fn list_dispatches_into_maestro_cli() {
let cfg = bad_config();
let out = run(&["list", "--config", cfg.path().to_str().unwrap()]);
assert_dispatched(&out);
}
#[cfg(not(feature = "cli"))]
#[test]
fn list_is_unrecognized_without_cli_feature() {
assert_unrecognized(&run(&["list"]), "list");
}
#[cfg(feature = "daemon")]
#[test]
fn daemon_dispatches_into_maestro_daemon() {
let cfg = bad_config();
let out = run(&["daemon", "--config", cfg.path().to_str().unwrap()]);
assert_dispatched(&out);
}
#[cfg(not(feature = "daemon"))]
#[test]
fn daemon_is_unrecognized_without_daemon_feature() {
assert_unrecognized(&run(&["daemon"]), "daemon");
}
#[cfg(feature = "tui")]
#[test]
fn tui_dispatches_into_maestro_tui() {
let cfg = bad_config();
let out = run(&["tui", "--config", cfg.path().to_str().unwrap()]);
assert_dispatched(&out);
}
#[cfg(not(feature = "tui"))]
#[test]
fn tui_is_unrecognized_without_tui_feature() {
assert_unrecognized(&run(&["tui"]), "tui");
}
+4 -3
View File
@@ -307,9 +307,10 @@ lands on the first row it was hiding. A highlight parked on a skipped row —
reachable by clicking a folder header — still steps to its neighbour rather reachable by clicking a folder header — still steps to its neighbour rather
than jumping to the end of the list. than jumping to the end of the list.
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the The web client needs a chord to reach the list at all (leader-`h` — `Ctrl+A h`
same chord back): an attached terminal owns every plain keystroke. The TUI uses by default — and the same chord back): an attached terminal owns every plain
`ctrl-w h`. keystroke, and Escape is left to reach the pane's shell/app instead. The TUI
uses `ctrl-w h`.
Everything else is the same key on both clients: Everything else is the same key on both clients:
+42 -28
View File
@@ -4,8 +4,6 @@ import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { attachWsUrl } from "./routes.js"; import { attachWsUrl } from "./routes.js";
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
import { CommandSet, defineCommands } from "./commands.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, tildeSeq } from "./keys.js"; import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
@@ -14,15 +12,6 @@ import { splitCommand, toolIconSvg } from "./toolIcon.js";
import type { ClientControl, Uuid } from "./types.js"; import type { ClientControl, Uuid } from "./types.js";
import { WebglPolicy } from "./webglRenderer.js"; import { WebglPolicy } from "./webglRenderer.js";
// The pane's half of the command registry (#148): plain Escape hands the
// keyboard back to the session list, the pair to the list's Tab.
const paneKeys = new CommandSet(
defineCommands({
"pane.focusList": () => document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)),
}),
{ preventDefault: false },
);
/** One budget for the whole page — see webglRenderer.ts for why contexts are /** One budget for the whole page — see webglRenderer.ts for why contexts are
* rationed rather than handed to every connection. */ * rationed rather than handed to every connection. */
const webglPolicy = new WebglPolicy(); const webglPolicy = new WebglPolicy();
@@ -54,6 +43,10 @@ export interface AttachConnection {
* handling — the leader's literal Ctrl-K escape hatch * handling — the leader's literal Ctrl-K escape hatch
* (docs/COMMAND-BAR.md). */ * (docs/COMMAND-BAR.md). */
sendInput(data: string): void; sendInput(data: string): void;
/** Refresh the header's name/command (e.g. after autoname renames the
* session) — the connection outlives mount/unmount, so this is the only
* way an already-open header picks up a new name. */
updateInfo(info: AttachInfo): void;
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */ /** Fully disconnect: close the WS, dispose the terminal, remove the element. */
dispose(): void; dispose(): void;
} }
@@ -96,21 +89,25 @@ export function createAttachConnection(
el.className = "attach"; el.className = "attach";
// Useful identity over a bare UUID: name · command. The full id stays in the // Useful identity over a bare UUID: name · command. The full id stays in the
// tooltip for when you really need it. // tooltip for when you really need it.
const titleHtml = info const titleMarkup = (i?: AttachInfo): { html: string; attr: string } => ({
? `<span class="at-name">${escapeHtml(info.name)}</span>` + html: i
(info.command && info.command !== info.name ? commandFragment(info.command) : "") ? `<span class="at-name">${escapeHtml(i.name)}</span>` +
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`; (i.command && i.command !== i.name ? commandFragment(i.command) : "")
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId; : `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`,
attr: i ? `${i.name} · ${i.command} (${sessionId})` : sessionId,
});
const initialTitle = titleMarkup(info);
el.innerHTML = ` el.innerHTML = `
<header class="attach-header"> <header class="attach-header">
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button> <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(initialTitle.attr)}">${initialTitle.html}</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>
</header> </header>
<div class="attach-body"></div> <div class="attach-body"></div>
`; `;
const titleEl = el.querySelector(".attach-title") as HTMLSpanElement;
const body = el.querySelector(".attach-body") as HTMLDivElement; const body = el.querySelector(".attach-body") as HTMLDivElement;
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement; const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement; const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
@@ -221,15 +218,6 @@ export function createAttachConnection(
} }
} }
// Plain Escape hands the keyboard back to the session list instead of the
// shell/app. `false` tells xterm to skip its own handling of this event —
// which is also why the set doesn't `preventDefault`: swallowing here is
// xterm's return value, not the browser's.
term.attachCustomKeyEventHandler((e) => {
if (e.type !== "keydown") return true;
return !paneKeys.handle(e);
});
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a // Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
// line at a time and stall), so map finger drags ourselves. Two routes: // line at a time and stall), so map finger drags ourselves. Two routes:
// • normal buffer → term.scrollLines walks the scrollback. // • normal buffer → term.scrollLines walks the scrollback.
@@ -379,6 +367,9 @@ export function createAttachConnection(
let ws: WebSocket; let ws: WebSocket;
let reconnectMs = INITIAL_RECONNECT_MS; let reconnectMs = INITIAL_RECONNECT_MS;
let reconnectTimer: number | null = null; let reconnectTimer: number | null = null;
// Snapshot replay starts at row zero, which can leave Codex at its oldest line.
// Follow it once per socket; later scrolling remains under the operator's control.
let awaitingReplay = true;
const sendControl = (msg: ClientControl) => { const sendControl = (msg: ClientControl) => {
if (ws.readyState !== WebSocket.OPEN) return; if (ws.readyState !== WebSocket.OPEN) return;
@@ -398,6 +389,7 @@ export function createAttachConnection(
}; };
function connect(): void { function connect(): void {
awaitingReplay = true;
ws = new WebSocket(attachWsUrl(sessionId)); ws = new WebSocket(attachWsUrl(sessionId));
ws.binaryType = "arraybuffer"; ws.binaryType = "arraybuffer";
@@ -418,10 +410,20 @@ export function createAttachConnection(
return; return;
} }
if (ev.data instanceof ArrayBuffer) { if (ev.data instanceof ArrayBuffer) {
term.write(new Uint8Array(ev.data)); const replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(ev.data), () => {
if (replay) term.scrollToBottom();
});
} else if (ev.data instanceof Blob) { } else if (ev.data instanceof Blob) {
// Some browsers may default to Blob despite binaryType="arraybuffer". // Some browsers may default to Blob despite binaryType="arraybuffer".
ev.data.arrayBuffer().then((b) => term.write(new Uint8Array(b))); void ev.data.arrayBuffer().then((b) => {
const replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(b), () => {
if (replay) term.scrollToBottom();
});
});
} }
}; };
@@ -470,6 +472,12 @@ export function createAttachConnection(
const resizeDisp = term.onResize(({ cols, rows }) => { const resizeDisp = term.onResize(({ cols, rows }) => {
sendControl({ type: "Resize", cols, rows }); sendControl({ type: "Resize", cols, rows });
}); });
// xterm has no built-in copy-on-select; mirror the selection to the
// clipboard ourselves (best-effort — insecure contexts lack the API).
const selectionDisp = term.onSelectionChange(() => {
const text = term.getSelection();
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
});
window.addEventListener("resize", onResize); window.addEventListener("resize", onResize);
const visual = window.visualViewport ?? null; const visual = window.visualViewport ?? null;
@@ -513,6 +521,11 @@ export function createAttachConnection(
// The closure above: the same bytes-out path keystrokes and the mobile key // The closure above: the same bytes-out path keystrokes and the mobile key
// bar already take. // bar already take.
sendInput, sendInput,
updateInfo(next: AttachInfo) {
const { html, attr } = titleMarkup(next);
titleEl.innerHTML = html;
titleEl.title = attr;
},
dispose() { dispose() {
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;
@@ -525,6 +538,7 @@ export function createAttachConnection(
visual?.removeEventListener("resize", onResize); visual?.removeEventListener("resize", onResize);
dataDisp.dispose(); dataDisp.dispose();
resizeDisp.dispose(); resizeDisp.dispose();
selectionDisp.dispose();
releaseWebgl(); releaseWebgl();
try { try {
ws.close(); ws.close();
-4
View File
@@ -53,10 +53,6 @@ export function requestOpenCommandBar(): 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 hand the keyboard back to the session list — what Escape
* does when the pane's terminal is focused. Handled in main.ts. */
export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar";
/** Ask the shell to open the spawn modal seeded from an existing session /** Ask the shell to open the spawn modal seeded from an existing session
* ("Spawn similar"). Detail is the session being cloned. */ * ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar"; export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
+12 -14
View File
@@ -133,31 +133,28 @@ describe("CommandSet", () => {
expect(run).toHaveBeenCalledTimes(2); expect(run).toHaveBeenCalledTimes(2);
}); });
it("keeps plain keys deaf inside a form field, and chorded ones live", () => { it("keeps plain keys deaf inside a form field, and leader chords live from the pane", () => {
const plain = vi.fn(); const plain = vi.fn();
const chorded = vi.fn(); const chorded = vi.fn();
const set = new CommandSet( const set = new CommandSet(
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }), defineCommands({ "spawn.open": plain, "focus.toggle": chorded }, DEFAULT_LEADER),
{ leader: DEFAULT_LEADER },
); );
const input = el("<input>"); const input = el("<input>");
expect(pressOn(set, input, "n")).toBe(false); expect(pressOn(set, input, "n")).toBe(false);
expect(plain).not.toHaveBeenCalled(); expect(plain).not.toHaveBeenCalled();
expect(pressOn(set, input, "H", { ctrlKey: true, shiftKey: true })).toBe(true); // focus.toggle hangs off the leader (#166): a plain, unmodified Escape is
// left for the pane's shell/app instead.
const terminal = el('<textarea class="xterm-helper-textarea"></textarea>');
pressOn(set, terminal, "a", { ctrlKey: true });
expect(pressOn(set, terminal, "h")).toBe(true);
expect(chorded).toHaveBeenCalledTimes(1); expect(chorded).toHaveBeenCalledTimes(1);
}); });
it("keeps pane commands live inside the terminal's textarea", () => { it("leaves a plain Escape for the pane instead of binding it", () => {
const run = vi.fn(); const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }));
const set = new CommandSet(defineCommands({ "pane.focusList": run }), {
preventDefault: false,
});
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>'); const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
const e = key("Escape"); expect(pressOn(set, textarea, "Escape")).toBe(false);
Object.defineProperty(e, "target", { value: textarea });
expect(set.handle(e)).toBe(true);
expect(run).toHaveBeenCalledTimes(1);
// xterm is told to swallow by the return value, not by the browser default.
expect(e.defaultPrevented).toBe(false);
}); });
}); });
@@ -173,6 +170,7 @@ describe("the leader", () => {
const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s])); const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s]));
expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]); expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]);
expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]); expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]);
expect(byId.get("focus.toggle")?.keys).toEqual(["ctrl-b h"]);
// Commands that do not hang off the leader are untouched. // Commands that do not hang off the leader are untouched.
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]); expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
}); });
+2 -8
View File
@@ -93,7 +93,8 @@ const SPECS: readonly SpecSource[] = [
id: "focus.toggle", id: "focus.toggle",
label: "Move the keyboard between the session list and the pane", label: "Move the keyboard between the session list and the pane",
group: "Session list", group: "Session list",
keys: ["ctrl-shift-h"], keys: [`${DEFAULT_LEADER} h`],
leaderKeys: (leader) => [`${leader} h`],
scope: "global", scope: "global",
}, },
{ {
@@ -145,13 +146,6 @@ const SPECS: readonly SpecSource[] = [
keys: ["tab"], keys: ["tab"],
scope: "list", scope: "list",
}, },
{
id: "pane.focusList",
label: "Hand the keyboard back to the session list, from the pane",
group: "Session list",
keys: ["esc"],
scope: "pane",
},
{ {
id: "help.toggle", id: "help.toggle",
label: "Show or hide this help", label: "Show or hide this help",
+1
View File
@@ -16,6 +16,7 @@ vi.mock("./attach.js", () => ({
sendInput(data: string) { sendInput(data: string) {
sent.push({ id, data }); sent.push({ id, data });
}, },
updateInfo() {},
dispose() {}, dispose() {},
}), }),
})); }));
+1
View File
@@ -79,6 +79,7 @@ export class ConnectionPool {
if (conn) { if (conn) {
this.cache.delete(id); this.cache.delete(id);
this.cache.set(id, conn); this.cache.set(id, conn);
if (info) conn.updateInfo(info);
} else { } else {
conn = createAttachConnection( conn = createAttachConnection(
id, id,
+25
View File
@@ -130,6 +130,31 @@ describe("DaemonLink", () => {
expect(link.status().state).toBe("online"); expect(link.status().state).toBe("online");
}); });
it("rechecks immediately when the browser returns from the background", async () => {
const sched = manual();
let reply: DaemonInfo | null = info("aaa", "i1");
const link = new DaemonLink({
fetchInfo: async () => {
if (!reply) throw new Error("down");
return reply;
},
scheduler: sched,
reload: () => {},
});
await link.start();
reply = null;
link.noteDisconnected();
sched.run();
await Promise.resolve();
expect(link.status().state).toBe("down");
reply = info("aaa", "i1");
link.recover();
await Promise.resolve();
expect(link.status().state).toBe("online");
});
it("reloads the page when the daemon comes back on a new commit", async () => { it("reloads the page when the daemon comes back on a new commit", async () => {
const sched = manual(); const sched = manual();
const reload = vi.fn(); const reload = vi.fn();
+11
View File
@@ -148,6 +148,17 @@ export class DaemonLink {
void this.probe(); void this.probe();
} }
/**
* Recheck liveness after the browser resumes from the background. Mobile
* browsers may suspend both WebSocket events and timers while a PWA is
* hidden, leaving a stale `down` state after the daemon is already back.
*/
recover(): void {
if (this.state === "updating") return;
this.noteDisconnected();
void this.probe();
}
/** /**
* One `/api/info` round-trip. Resolves `true` when the daemon answered. * One `/api/info` round-trip. Resolves `true` when the daemon answered.
* *
+15
View File
@@ -132,6 +132,21 @@ describe("CatalogStore reducer", () => {
}); });
describe("CatalogStore reconnect", () => { describe("CatalogStore reconnect", () => {
it("replaces a suspended socket immediately", () => {
const first = new FakeSocket();
const second = new FakeSocket();
const { store } = storeWith([first, second]);
store.start();
store.reconnect();
expect(first.closed).toBe(true);
second.open();
// A late close from the old socket is deliberately ignored.
first.drop();
expect(store.backoff()).toBe(INITIAL_BACKOFF_MS);
});
it("doubles the delay per attempt and caps it", () => { it("doubles the delay per attempt and caps it", () => {
const sockets = Array.from({ length: 8 }, () => new FakeSocket()); const sockets = Array.from({ length: 8 }, () => new FakeSocket());
const { store, scheduler } = storeWith(sockets); const { store, scheduler } = storeWith(sockets);
+17
View File
@@ -59,6 +59,23 @@ export class CatalogStore {
this.connect(); this.connect();
} }
/** Replace a socket that may have gone stale while the browser was suspended. */
reconnect(): void {
if (this.reconnectTimer !== null) {
this.scheduler.clearTimer(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
// A close event from the superseded socket must not schedule a second
// connection alongside the new one.
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.close();
this.ws = null;
}
this.connect();
}
/** Current reconnect delay, for tests asserting the backoff schedule. */ /** Current reconnect delay, for tests asserting the backoff schedule. */
backoff(): number { backoff(): number {
return this.backoffMs; return this.backoffMs;
+18
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
emptyLayout, emptyLayout,
equalizeRatios,
leaves, leaves,
locateSession, locateSession,
openSessions, openSessions,
@@ -44,6 +45,23 @@ describe("splitLeaf", () => {
}); });
}); });
describe("equalizeRatios", () => {
it("resets nested splits to equal width, not just the newest one", () => {
const tab = tabWithOneLeaf("s1");
const first = leaves(tab.root)[0].id;
splitLeaf(tab, first, "s2", "row", true); // A|B, 0.5/0.5
const second = leaves(tab.root)[1].id;
splitLeaf(tab, second, "s3", "row", true); // A(0.5) | (B|C, 0.5/0.5) -> B,C at 0.25 each
equalizeRatios(tab.root);
const root = tab.root as Extract<typeof tab.root, { kind: "split" }>;
expect(root.ratio).toBeCloseTo(1 / 3);
const inner = root.b as Extract<typeof root.b, { kind: "split" }>;
expect(inner.ratio).toBeCloseTo(1 / 2);
});
});
describe("removeLeaf", () => { describe("removeLeaf", () => {
it("collapses the parent split into the surviving sibling", () => { it("collapses the parent split into the surviving sibling", () => {
const tab = tabWithOneLeaf("s1"); const tab = tabWithOneLeaf("s1");
+11
View File
@@ -118,6 +118,17 @@ export function removeLeaf(tab: Tab, leafId: string): void {
tab.root = tab.root ? rec(tab.root) : null; tab.root = tab.root ? rec(tab.root) : null;
} }
/** Reset every split ratio under `node` so all leaves get equal width,
* regardless of split direction or nesting order. Returns the leaf count. */
export function equalizeRatios(node: Node | null): number {
if (!node) return 0;
if (node.kind === "leaf") return 1;
const na = equalizeRatios(node.a);
const nb = equalizeRatios(node.b);
node.ratio = na / (na + nb);
return na + nb;
}
export function addTab(layout: Layout): Tab { export function addTab(layout: Layout): Tab {
const tab: Tab = { const tab: Tab = {
id: freshId("t"), id: freshId("t"),
+20 -11
View File
@@ -35,7 +35,6 @@ import { loadLayout, saveLayout } from "./persistence.js";
import { createPreviewSchedule } from "./previewSchedule.js"; import { createPreviewSchedule } from "./previewSchedule.js";
import { import {
FOCUS_PANE_EVENT, FOCUS_PANE_EVENT,
FOCUS_SIDEBAR_EVENT,
OPEN_COMMAND_BAR_EVENT, OPEN_COMMAND_BAR_EVENT,
OPEN_SPAWN_EVENT, OPEN_SPAWN_EVENT,
SESSION_SELECT_EVENT, SESSION_SELECT_EVENT,
@@ -108,6 +107,19 @@ void link.start();
store.start(); store.start();
// Installed mobile browsers suspend timers and WebSockets in the background.
// Replacing the socket on return avoids leaving the restart overlay up after a
// transient lost connection has already recovered.
function recoverFromBackground(): void {
if (document.visibilityState !== "visible") return;
link.recover();
store.reconnect();
}
document.addEventListener("visibilitychange", recoverFromBackground);
window.addEventListener("pageshow", recoverFromBackground);
window.addEventListener("online", recoverFromBackground);
// Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed // Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed
// without a running daemon. Stripped from production builds by Vite. // without a running daemon. Stripped from production builds by Vite.
if (import.meta.env.DEV && location.search.includes("mock")) { if (import.meta.env.DEV && location.search.includes("mock")) {
@@ -440,10 +452,11 @@ const spawnKeys = new CommandSet(
}), }),
); );
// Ctrl+Shift+H moves the keyboard between the session list and the pane, the // Leader-h moves the keyboard between the session list and the pane, the web
// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached // counterpart of the TUI's `ctrl-w h`. It has to be a chord behind the
// terminal owns every plain keystroke, so nothing unmodified could ever get // leader: an attached terminal owns every plain keystroke, and Ctrl+Shift+H
// the focus back out of it. // is already claimed by the browser (history), so nothing unmodified — or
// modified with a common browser chord — could get the focus back out of it.
// //
// This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served // This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
// on `/api/info`): it holds the key as a partial chord and takes select-all off // on `/api/info`): it holds the key as a partial chord and takes select-all off
@@ -528,8 +541,8 @@ window.addEventListener(
); );
// Tab in the session list hands the keyboard to the pane; Escape in the pane // Tab in the session list hands the keyboard to the pane; leader-h (focus.toggle,
// hands it back — the pair the TUI gets from `tab` and `esc`. // above) hands it back the other way.
document.addEventListener(FOCUS_PANE_EVENT, () => { document.addEventListener(FOCUS_PANE_EVENT, () => {
// Handing the keyboard to a previewed pane is choosing it: the preview stops // Handing the keyboard to a previewed pane is choosing it: the preview stops
// being provisional rather than snapping back the moment the list blurs. // being provisional rather than snapping back the moment the list blurs.
@@ -537,10 +550,6 @@ document.addEventListener(FOCUS_PANE_EVENT, () => {
if (previewed) focusSession(previewed); if (previewed) focusSession(previewed);
pool.focusTerminal(); pool.focusTerminal();
}); });
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList();
});
/** Open/focus a session and reflect it in the shell chrome. Shared by the /** Open/focus a session and reflect it in the shell chrome. Shared by the
* hash router and the sidebar's click-driven path (#133), which can't rely * hash router and the sidebar's click-driven path (#133), which can't rely
+4
View File
@@ -10,6 +10,7 @@ import {
activeTab, activeTab,
addTab, addTab,
closeTab, closeTab,
equalizeRatios,
findLeaf, findLeaf,
freshId, freshId,
type Dir, type Dir,
@@ -457,6 +458,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
dropPreview(); dropPreview();
const tab = activeTab(layout); const tab = activeTab(layout);
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true); tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
equalizeRatios(tab.root);
render(); render();
} }
@@ -542,6 +544,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
target.sessionId = sessionId; target.sessionId = sessionId;
} else { } else {
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true); tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
equalizeRatios(tab.root);
} }
render(); render();
opts.onOpenSessionsChange?.(); opts.onOpenSessionsChange?.();
@@ -815,6 +818,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (source.kind === "pane" && self) removeLeaf(tab, self); if (source.kind === "pane" && self) removeLeaf(tab, self);
else if (loc) removeLeaf(loc.tab, loc.leaf.id); else if (loc) removeLeaf(loc.tab, loc.leaf.id);
tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after); tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after);
equalizeRatios(tab.root);
render(); render();
} }