Compare commits

..
13 Commits
Author SHA1 Message Date
Miguel Palhas fffd2a55f3 Merge remote-tracking branch 'origin/main' into maestoso 2026-09-10 21:52:26 +01:00
Miguel PalhasandClaude Opus 4.6 563a35d2ec fix(maestro): restore sidebar fill, pane title, and focus dimming after React port
ci / web (push) Failing after 6s
ci / rust (push) Failing after 48s
ci / flake (push) Has been cancelled
The React port replaced CSS class names with Tailwind utilities, breaking
three visual contracts:

- SessionCard used inline bg-* classes instead of .is-active/.is-open/
  .is-previewing that the styles.css cascade targets.
- PaneTitle used className "at-title" instead of "attach-title", so font,
  overflow, and child layout rules never applied.
- MultiView's Pane never applied .is-focused or .is-preview, so every
  pane matched :not(.is-focused) and was dimmed to 0.45 opacity.

Closes #256

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 21:51:53 +01:00
Miguel PalhasandClaude Opus 4.6 9dead5652e fix(maestro): reconnect attach/structured sockets on Android resume
The visibility recovery only reconnected the catalog and daemon-link
sockets. Per-pane attach and structured WebSockets relied on backoff
timers that Android suspends alongside the sockets, leaving the
terminal disconnected after backgrounding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 21:50:29 +01:00
Miguel Palhas cd2184b128 Merge remote-tracking branch 'origin/main' into modulation-2
ci / web (push) Failing after 7s
ci / rust (push) Failing after 1m3s
ci / flake (push) Successful in 3m30s
2026-09-10 17:29:49 +01:00
Miguel PalhasandClaude Opus 4.6 1b2e5f5f05 feat(maestro): force kill deletes dirty worktrees
Previously --force left the worktree on disk so the operator could
inspect it. Now it removes the worktree unconditionally — the
confirmation prompt already shows the breakdown, so the decision
happens before the delete, not after.

Natural exit still leaves dirty worktrees on disk (no operator
confirmation in that path).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 17:26:04 +01:00
Miguel PalhasandClaude Opus 4.6 2c8956e634 fix(maestro): reset pending state when kill dialog transitions to force mode
ci / web (push) Successful in 38s
ci / flake (push) Successful in 3m51s
ci / rust (push) Has been cancelled
The onConfirm handler in Sidebar resolves (not rejects) when the daemon
refuses an unforced kill — it mutates state to switch to force mode and
returns. That left `pending` stuck at true in the KillDialog, so the
"Force kill" button rendered disabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 17:24:34 +01:00
Miguel Palhas f2c0882c36 Merge remote-tracking branch 'origin/main' into xylophone-2
ci / web (push) Failing after 6s
ci / flake (push) Successful in 3m40s
ci / rust (push) Has been cancelled
# Conflicts:
#	.claude/settings.local.json
2026-09-10 17:17:32 +01:00
Miguel PalhasandClaude Opus 4.6 c170cd9d60 fix(maestro): restore sidebar footer, fix spawn modal and session card regressions
Post-React-migration regressions: spawn submit button moved to modal
footer, sidebar footer restored (help/theme/palette/status/spawn),
session card stripped of redundant Tailwind, action buttons use proper
leaf-act/leaf-actions CSS structure for hover-only visibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 17:17:22 +01:00
Miguel PalhasandClaude Opus 4.6 ebf943bf30 chore(maestro): stop tracking settings.local.json
ci / web (push) Successful in 1m3s
ci / flake (push) Successful in 4m2s
ci / rust (push) Successful in 7m14s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:50:31 +01:00
Miguel PalhasandClaude Opus 5 645aa2dea0 fix(maestro): drop border and background on composer stop button
ci / web (push) Failing after 6s
ci / rust (push) Has been cancelled
ci / flake (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:48:02 +01:00
Miguel Palhas 3a2513c78d Merge branch 'tuba-2'
ci / web (push) Successful in 34s
ci / flake (push) Successful in 5m32s
ci / rust (push) Successful in 8m1s
2026-09-10 16:39:27 +01:00
Miguel PalhasandClaude Opus 4.6 142e9a4870 chore(maestro): update local tool permissions
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / flake (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:38:24 +01:00
Miguel PalhasandClaude Opus 4.6 e5c17606d0 fix(maestro): update pnpmDeps hash for tailwindcss 4.3.3
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:35:37 +01:00
23 changed files with 305 additions and 156 deletions
-25
View File
@@ -1,25 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git fetch:*)",
"Bash(git merge:*)",
"Bash(npx tsc:*)",
"Bash(npm run build:*)",
"Bash(npm install:*)",
"Bash(./node_modules/.bin/tsc:*)",
"Bash(npm test:*)",
"Bash(cargo check:*)",
"Bash(cargo clippy:*)",
"Bash(cargo test:*)",
"Bash(source:*)",
"Bash(git remote get-url:*)",
"Bash(git push:*)",
"Bash(git checkout:*)",
"Bash(git -C /home/naps62/tea/maestro merge grazioso-2 --no-edit)",
"Bash(git -C /home/naps62/tea/maestro push)",
"Bash(curl:*)"
]
}
}
+3
View File
@@ -15,3 +15,6 @@ worktrees/
# `nix run .#dev` scratch state (tmux socket)
/.dev/
# Per-machine Claude Code tool permissions
.claude/settings.local.json
+1 -1
View File
@@ -133,7 +133,7 @@ pub struct KillArgs {
/// Session ID to kill.
pub session_id: Uuid,
/// End the session even when its worktree holds unsaved work (#81). The
/// worktree is left on disk — never removed.
/// worktree is removed along with the session.
#[arg(long)]
pub force: bool,
}
+7 -27
View File
@@ -1545,15 +1545,10 @@ impl SessionManager {
/// uncommitted tracked changes, untracked non-ignored files, or commits
/// reachable from no remote-tracking ref. Nothing is killed and nothing
/// is removed; the error names what blocked it and how much of it there
/// is. The session stays as the operator's handle on their own unfinished
/// work, so ending a session that still matters becomes a deliberate
/// `--force`, which leaves the tree alone rather than destroying it
/// (WORKTREES.md).
/// is.
///
/// With `force` (#81) the refusal — and nothing else — is overridden:
/// the session ends and the guarded worktree is left on disk, never
/// removed. A tree whose guard is clear never needed the override; force
/// removes it as usual.
/// With `force` (#81) the refusal is overridden: the session ends and
/// the worktree is removed, even when it holds unsaved work.
///
/// This is also how an already-dead session is dismissed. tmux is only
/// asked to kill something it still has — calling `kill-session` on a
@@ -1588,7 +1583,7 @@ impl SessionManager {
sessions.remove(&id)
};
let record = removed.ok_or(SessionError::NotFound(id))?;
cleanup_record(&self.inner, id, record, alive, guarded).await;
cleanup_record(&self.inner, id, record, alive).await;
Ok(())
}
@@ -2972,8 +2967,8 @@ async fn mark_session_dead_with_worktree_settlement(
/// Apply the exit rule (#82) to a vanished session's worktree: probe what
/// would be lost through the kill's own predicate, then remove a pristine
/// tree. Anything holding unsaved work is left exactly where it is — the
/// same thing a forced kill does with a guarded tree (#81).
/// tree. Anything holding unsaved work is left on disk — there is no
/// operator confirmation on the natural-exit path.
///
/// Returns whether the worktree was fully accounted for. When it wasn't,
/// nothing was destroyed and the caller keeps the reference on the record.
@@ -3028,16 +3023,11 @@ async fn settle_worktree_on_exit(
/// gone from the authoritative in-memory map, so a stray FIFO or sidecar can't
/// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the
/// liveness reaper (tmux already lost the session).
///
/// `keep_worktree` is `true` for a forced kill (#81) over a guarded tree: the
/// session ends but the worktree is left on disk, so only the removal is
/// skipped.
async fn cleanup_record(
inner: &Arc<Inner>,
id: Uuid,
record: SessionRecord,
kill_tmux: bool,
keep_worktree: bool,
) {
if kill_tmux {
let tmux = inner.tmux.clone();
@@ -3059,17 +3049,7 @@ async fn cleanup_record(
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
}
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
if keep_worktree {
tracing::info!(
%id,
path = %path.display(),
"forced kill: worktree left on disk"
);
} else if let Err(e) = inner.worktree_mgr.remove(base, path) {
// The kill already passed the unsaved-work guard (#79), so this
// removal is unconditional; the manager's own nested-worktree
// refusal is all that can still leave a tree on disk, and it
// says where.
if let Err(e) = inner.worktree_mgr.remove(base, path) {
tracing::warn!(
%id,
path = %path.display(),
+10 -25
View File
@@ -75,27 +75,17 @@ The point of refusing rather than cleaning up is that the session stays as the
operator's handle on their own unfinished work. A session that is killed while
its worktree still matters leaves a directory nobody is looking at.
A consequence, accepted deliberately: a feature branch almost always has unpushed
commits, so `--force` is the ordinary way to end a session, not an exception.
That is only safe because force never removes what blocked.
### `--force`
`--force` overrides the refusal and nothing else. It ends the session and
**leaves the worktree on disk**, untouched. Force never removes a tree that
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,
not ordinary cleanup.
`--force` overrides the refusal and **removes the worktree**, even when it holds
unsaved work. The operator sees the breakdown in the confirmation prompt and
decides; once confirmed, the tree is gone.
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
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.
### Exiting on its own
A session that ends by itself — the command finished, the operator typed `exit` —
@@ -113,17 +103,12 @@ once. A removed tree drops off the record, so dismissing the session afterwards
does not refuse: there is no guard left to trip.
A tree that was left alone stays attached to the dead record, where dismissing it
later goes through `--force`, which leaves it on disk too. The same holds when
the probe itself fails: nothing is destroyed.
later goes through `--force`, which removes it. The same holds when the probe
itself fails: nothing is destroyed.
## Nothing is removed on the operator's behalf
## Trees left behind by natural exit
There is no store of worktrees the daemon is holding, no list endpoint, and no
reclaim command. A tree left behind by a forced kill or a dirty exit is an
ordinary git worktree on disk; `git worktree list` finds it and
`git worktree remove` removes it.
That is deliberate. A tracked list of trees pending deletion is a second place
the truth can live, and it goes stale the moment somebody removes a directory by
hand. Git already knows which worktrees exist, and removing one is not a
decision Maestro is better placed to make.
A forced kill always removes the worktree. A natural exit with unsaved work
leaves the tree on disk — there is no operator confirmation in that path. Those
trees are ordinary git worktrees; `git worktree list` finds them and
`git worktree remove` removes them.
+1 -1
View File
@@ -148,7 +148,7 @@
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
inherit (finalAttrs) pname version src;
fetcherVersion = 3;
hash = "sha256-AzF/rrhqbvl613hNsC18YSylBSVxtatg7fEiUEF1u2o=";
hash = "sha256-FEWOAtSFvOWHeCNR0RK8JQ/ssW31qmShu1YD2GuqioQ=";
};
buildPhase = ''
+1
View File
@@ -314,6 +314,7 @@ function Shell() {
activeId={focusedSessionId}
openIds={openIds}
previewId={previewedSessionId}
linkStore={linkStore}
onSelect={(id) => focusSession(id)}
onPreview={preview}
onSpawn={openPlainSpawn}
+7 -1
View File
@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { CommandSet, defineCommands, keyCaps } from "../../commands.js";
import { TOGGLE_HELP_EVENT } from "../../uiEvents.js";
import { groupedCommands } from "./model.js";
export function HelpOverlay() {
@@ -18,8 +19,13 @@ export function HelpOverlay() {
if (open && overlayKeys.handle(e)) return;
helpKeys.handle(e);
};
const onToggle = () => setOpen((o) => !o);
window.addEventListener("keydown", onKeydown);
return () => window.removeEventListener("keydown", onKeydown);
document.addEventListener(TOGGLE_HELP_EVENT, onToggle);
return () => {
window.removeEventListener("keydown", onKeydown);
document.removeEventListener(TOGGLE_HELP_EVENT, onToggle);
};
}, [open]);
if (!open) return null;
@@ -71,4 +71,33 @@ describe("KillDialog", () => {
await waitFor(() => expect(kill.disabled).toBe(false));
expect(screen.getByText("Failed to kill: daemon unreachable")).not.toBeNull();
});
it("resets pending when force changes, so the force button is clickable", async () => {
// Simulate the Sidebar flow: onConfirm resolves (parent switches to
// force mode via rerender), but pending was set to true before the call.
const onConfirm = vi.fn(() => Promise.resolve());
const { rerender } = render(
<KillDialog name="alpha" blockers={null} onConfirm={onConfirm} onCancel={vi.fn()} />,
);
const kill = screen.getByRole("button", { name: "Kill" }) as HTMLButtonElement;
fireEvent.click(kill);
expect(kill.disabled).toBe(true);
// Parent re-renders with force=true (the daemon refused the unforced kill).
await waitFor(() => expect(onConfirm).toHaveBeenCalledOnce());
rerender(
<KillDialog
name="alpha"
blockers={{ uncommitted_files: 1, untracked_files: 0, unpushed_commits: 0 }}
force
reason="worktree is dirty"
onConfirm={onConfirm}
onCancel={vi.fn()}
/>,
);
const forceBtn = screen.getByRole("button", { name: "Force kill" }) as HTMLButtonElement;
expect(forceBtn.disabled).toBe(false);
});
});
@@ -34,6 +34,11 @@ export function KillDialog(props: KillDialogProps) {
// asked for the kill, so Enter confirms it.
useEffect(() => confirmRef.current?.focus(), []);
// When the dialog transitions from normal → force mode, the previous
// onConfirm resolved without error (it mutated parent state and returned),
// so `pending` is still true. Reset it so the force button is clickable.
useEffect(() => setPending(false), [force]);
const handleConfirm = () => {
setPending(true);
setError(null);
+1 -1
View File
@@ -105,7 +105,7 @@ describe("MultiView", () => {
expect(screen.getByText("Tab 1 · 1")).toBeTruthy();
const pane = document.querySelector('[data-session-id="s9"]');
expect(pane).toBeTruthy();
expect(pane!.querySelector(".bg-preview-tint")).toBeTruthy();
expect(pane!.classList.contains("is-preview")).toBe(true);
expect(screen.getByText("preview")).toBeTruthy();
});
+4 -6
View File
@@ -188,8 +188,9 @@ function Pane({
return (
<div
className={
"mv-pane flex h-full min-h-0 min-w-0 flex-col border " +
(isFocused && !isPreview ? "border-accent" : "border-border-strong")
"mv-pane flex h-full min-h-0 min-w-0 flex-col" +
(isFocused ? " is-focused" : "") +
(isPreview ? " is-preview" : "")
}
data-leaf-id={leaf.id}
data-session-id={leaf.sessionId ?? undefined}
@@ -201,10 +202,7 @@ function Pane({
}}
>
<div
className={
"mv-pane-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12 " +
(isPreview ? "bg-preview-tint text-preview" : "bg-surface")
}
className="mv-pane-header attach-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12"
>
{leaf.sessionId ? (
<PaneTitle sessionId={leaf.sessionId} session={session} />
+2 -2
View File
@@ -15,7 +15,7 @@ export interface PaneTitleProps {
export function PaneTitle({ sessionId, session, className }: PaneTitleProps) {
if (!session) {
return (
<span className={className ?? "at-title"} title={sessionId}>
<span className={className ?? "attach-title"} title={sessionId}>
session <span className="sid">{sessionId.slice(0, 8)}</span>
</span>
);
@@ -27,7 +27,7 @@ export function PaneTitle({ sessionId, session, className }: PaneTitleProps) {
const icon = tool ? toolIconSvg(tool) : null;
return (
<span className={className ?? "at-title"} title={`${name} · ${session.command} (${sessionId})`}>
<span className={className ?? "attach-title"} title={`${name} · ${session.command} (${sessionId})`}>
<span className="at-name">{name}</span>
{showCommand && (
<span className="at-cmd">
+17 -14
View File
@@ -44,14 +44,17 @@ export interface SessionCardProps {
onRenameCancel(): void;
}
/** The fill ladder from docs/SIDEBAR.md "Which row is filled", furthest down
* wins. Written as one ordered run so the precedence is the order it reads in. */
function rowFill(p: SessionCardProps): string {
if (p.active) return "bg-accent-fill";
if (p.previewing) return "bg-preview-tint";
if (p.open) return "bg-accent/[0.06]";
if (p.highlighted) return "bg-preview-tint";
return "";
/** The fill ladder from docs/SIDEBAR.md "Which row is filled" — the CSS
* cascade in styles.css carries the actual fills via `.is-active`, `.is-open`,
* `.is-previewing` (all inside `:where()` so source order is the precedence).
* We just apply the right class names here. */
function rowClasses(p: SessionCardProps): string {
const cls: string[] = [];
if (p.active) cls.push("is-active");
if (p.open) cls.push("is-open");
if (p.previewing) cls.push("is-previewing");
if (p.highlighted) cls.push("is-highlighted");
return cls.join(" ");
}
export function SessionCard(props: SessionCardProps) {
@@ -62,7 +65,7 @@ export function SessionCard(props: SessionCardProps) {
return (
<a
className={`session-card tree-leaf relative flex flex-col gap-[1px] py-sp-1 pr-sp-2 no-underline ${rowFill(props)} ${
className={`session-card tree-leaf no-underline ${rowClasses(props)} ${
running ? "" : "opacity-60"
}`}
href={`#/attach/${encodeURIComponent(s.id)}`}
@@ -84,7 +87,7 @@ export function SessionCard(props: SessionCardProps) {
}}
>
<ActivityRail activity={s.activity} />
<span className="flex min-w-0 items-center gap-sp-2">
<span className="card-line1">
{renaming ? (
<RenameField
session={s}
@@ -93,17 +96,17 @@ export function SessionCard(props: SessionCardProps) {
/>
) : (
<span
className={`min-w-0 flex-1 truncate text-fs-13 ${
className={`leaf-name min-w-0 flex-1 truncate text-fs-13 ${
isProvisionalName(s) ? "text-muted" : "text-ink"
}`}
>
{name}
</span>
)}
<span className="flex flex-none items-center gap-[2px]">
<span className="leaf-actions">
<button
type="button"
className="leaf-clone text-faint hover:text-ink"
className="leaf-act leaf-clone"
title="Spawn similar"
aria-label={`Spawn similar to ${name}`}
onClick={(e) => {
@@ -116,7 +119,7 @@ export function SessionCard(props: SessionCardProps) {
</button>
<button
type="button"
className="leaf-kill text-faint hover:text-danger"
className="leaf-act leaf-kill"
title="Kill session"
aria-label={`Kill ${name}`}
onClick={(e) => {
+45 -31
View File
@@ -2,9 +2,15 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ThemeProvider } from "../../contexts/ThemeContext.js";
import type { Session } from "../../types.js";
import { Sidebar } from "./Sidebar.js";
/** Wrap children in ThemeProvider so useTheme works in the footer. */
function Wrapper({ children }: { children: React.ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>;
}
afterEach(() => {
cleanup();
vi.useRealTimers();
@@ -36,7 +42,7 @@ function tree(): HTMLElement {
describe("SessionCard layout", () => {
it("renders two lines: the name, then a meta line naming the program", () => {
render(<Sidebar sessions={[session("alpha")]} />);
render(<Wrapper><Sidebar sessions={[session("alpha")]} /></Wrapper>);
const row = card("alpha");
expect(row.textContent).toContain("alpha");
@@ -52,7 +58,7 @@ describe("SessionCard layout", () => {
const fresh = session("fresh", { activity: "Active", activity_since: "2026-08-24T09:59:50Z" });
const held = session("held", { activity: "Active", activity_since: "2026-08-24T09:56:00Z" });
render(<Sidebar sessions={[fresh, held]} />);
render(<Wrapper><Sidebar sessions={[fresh, held]} /></Wrapper>);
expect(card("fresh").querySelector(".activity-duration")).toBeNull();
expect(card("held").querySelector(".activity-duration")!.textContent).toBe("4m");
@@ -60,13 +66,15 @@ describe("SessionCard layout", () => {
it("paints a rail for every state, blinking on AwaitingInput and flashing on Error", () => {
render(
<Sidebar
sessions={[
session("idle"),
session("await", { activity: "AwaitingInput" }),
session("err", { activity: "Error" }),
]}
/>,
<Wrapper>
<Sidebar
sessions={[
session("idle"),
session("await", { activity: "AwaitingInput" }),
session("err", { activity: "Error" }),
]}
/>
</Wrapper>,
);
const rail = (id: string) => card(id).querySelector<HTMLElement>("[data-activity]")!;
@@ -80,13 +88,15 @@ describe("SessionCard layout", () => {
it("renders blocked work as diff notation with the spelled-out tooltip", () => {
render(
<Sidebar
sessions={[
session("alpha", {
blocked_work: { uncommitted_files: 2, insertions: 124, deletions: 18, untracked_files: 3 },
}),
]}
/>,
<Wrapper>
<Sidebar
sessions={[
session("alpha", {
blocked_work: { uncommitted_files: 2, insertions: 124, deletions: 18, untracked_files: 3 },
}),
]}
/>
</Wrapper>,
);
const blocked = card("alpha").querySelector<HTMLElement>(".leaf-blocked")!;
@@ -96,12 +106,14 @@ describe("SessionCard layout", () => {
it("dims a provisional name and leaves a settled one alone", () => {
render(
<Sidebar
sessions={[
session("gen", { metadata: { name: "gen", name_source: "Generated" } }),
session("user", { metadata: { name: "user", name_source: "User" } }),
]}
/>,
<Wrapper>
<Sidebar
sessions={[
session("gen", { metadata: { name: "gen", name_source: "Generated" } }),
session("user", { metadata: { name: "user", name_source: "User" } }),
]}
/>
</Wrapper>,
);
expect(card("gen").querySelector(".text-muted")).not.toBeNull();
@@ -113,7 +125,7 @@ describe("keyboard walk", () => {
const two = [session("a", { cwd: "/one" }), session("b", { cwd: "/two" })];
it("j and k step the highlight through the cards", () => {
render(<Sidebar sessions={two} />);
render(<Wrapper><Sidebar sessions={two} /></Wrapper>);
fireEvent.keyDown(tree(), { key: "j" });
expect(card("a").dataset.highlighted).toBe("true");
@@ -124,7 +136,7 @@ describe("keyboard walk", () => {
});
it("stops on a collapsed folder header and Enter expands onto its first card", () => {
render(<Sidebar sessions={two} />);
render(<Wrapper><Sidebar sessions={two} /></Wrapper>);
const header = document.querySelector<HTMLElement>('[data-nav-key="folder:one"]')!;
fireEvent.click(header);
@@ -142,7 +154,7 @@ describe("keyboard walk", () => {
it("Enter on a card opens it, and Tab hands the keyboard to the pane", () => {
const onSelect = vi.fn();
const onFocusPane = vi.fn();
render(<Sidebar sessions={two} onSelect={onSelect} onFocusPane={onFocusPane} />);
render(<Wrapper><Sidebar sessions={two} onSelect={onSelect} onFocusPane={onFocusPane} /></Wrapper>);
fireEvent.keyDown(tree(), { key: "j" });
fireEvent.keyDown(tree(), { key: "Enter" });
@@ -154,7 +166,7 @@ describe("keyboard walk", () => {
it("previews the session the walk lands on", () => {
const onPreview = vi.fn();
render(<Sidebar sessions={two} onPreview={onPreview} />);
render(<Wrapper><Sidebar sessions={two} onPreview={onPreview} /></Wrapper>);
fireEvent.keyDown(tree(), { key: "j" });
expect(onPreview).toHaveBeenCalledWith("a");
@@ -162,7 +174,7 @@ describe("keyboard walk", () => {
it("x asks to kill the highlighted session and r renames it", () => {
const onKill = vi.fn();
render(<Sidebar sessions={two} onKill={onKill} />);
render(<Wrapper><Sidebar sessions={two} onKill={onKill} /></Wrapper>);
fireEvent.keyDown(tree(), { key: "j" });
fireEvent.keyDown(tree(), { key: "x" });
@@ -178,7 +190,9 @@ describe("context menu", () => {
const onSpawnSimilar = vi.fn();
const onKill = vi.fn();
render(
<Sidebar sessions={[session("alpha")]} onSpawnSimilar={onSpawnSimilar} onKill={onKill} />,
<Wrapper>
<Sidebar sessions={[session("alpha")]} onSpawnSimilar={onSpawnSimilar} onKill={onKill} />
</Wrapper>,
);
fireEvent.contextMenu(card("alpha"));
@@ -191,7 +205,7 @@ describe("context menu", () => {
it("renames in place: Enter commits the field, Escape cancels", () => {
const onRename = vi.fn();
render(<Sidebar sessions={[session("alpha")]} onRename={onRename} />);
render(<Wrapper><Sidebar sessions={[session("alpha")]} onRename={onRename} /></Wrapper>);
fireEvent.contextMenu(card("alpha"));
fireEvent.click(screen.getByText("Rename…"));
@@ -207,7 +221,7 @@ describe("context menu", () => {
describe("search", () => {
it("filters the tree down to matching sessions", () => {
render(<Sidebar sessions={[session("alpha"), session("beta", { cwd: "/home/n/beta" })]} />);
render(<Wrapper><Sidebar sessions={[session("alpha"), session("beta", { cwd: "/home/n/beta" })]} /></Wrapper>);
fireEvent.change(screen.getByLabelText("Search sessions"), { target: { value: "beta" } });
@@ -219,7 +233,7 @@ describe("search", () => {
describe("empty state", () => {
it("offers a spawn when there are no sessions at all", () => {
const onSpawn = vi.fn();
render(<Sidebar sessions={[]} onSpawn={onSpawn} />);
render(<Wrapper><Sidebar sessions={[]} onSpawn={onSpawn} /></Wrapper>);
fireEvent.click(screen.getByText("+ Spawn session"));
expect(onSpawn).toHaveBeenCalled();
+91 -1
View File
@@ -10,8 +10,11 @@ import type { CatalogStore, CatalogStoreApi } from "../../stores/catalogStore.js
import type { InTheWay, Session } from "../../types.js";
import { sessionBlockers, sessionName } from "../../types.js";
import { buildTree, type TreeNode } from "../../tree.js";
import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT } from "../../uiEvents.js";
import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, requestToggleHelp } from "../../uiEvents.js";
import { ContextMenu, type ContextMenuItem } from "../common/ContextMenu.js";
import type { DaemonLinkStoreApi, DaemonLinkState } from "../daemon/DaemonLink.js";
import { formatRelease } from "../daemon/DaemonLink.js";
import { useTheme, PALETTES, type Palette } from "../../contexts/ThemeContext.js";
import { FolderNode } from "./FolderNode.js";
import { KillDialog } from "../dialogs/KillDialog.js";
import { SessionCard } from "./SessionCard.js";
@@ -43,6 +46,8 @@ export interface SidebarProps {
/** Tab: hand the keyboard back to the pane. */
onFocusPane?(): void;
onHelp?(): void;
/** The daemon-link store, for the status indicator in the footer. */
linkStore?: DaemonLinkStoreApi;
/** Overrides the built-in `DELETE /api/sessions/:id` flow (tests, kit). */
onKill?(session: Session): void;
/** Overrides the built-in `PATCH /api/sessions/:id` (tests, kit). */
@@ -349,6 +354,10 @@ export function Sidebar(props: SidebarProps) {
}}
/>
)}
<SidebarFooter
onSpawn={() => props.onSpawn?.()}
linkStore={props.linkStore}
/>
</aside>
);
}
@@ -396,3 +405,84 @@ const EMPTY_STORE = {
setState: () => {},
subscribe: () => () => {},
} as unknown as CatalogStoreApi;
// ---------------------------------------------------------------------------
// Sidebar footer: help, theme toggle, palette picker, status dot, spawn.
// Mirrors the vanilla catalog.ts footer that the React migration dropped.
// ---------------------------------------------------------------------------
function SidebarFooter({
onSpawn,
linkStore,
}: {
onSpawn(): void;
linkStore?: DaemonLinkStoreApi;
}) {
const { theme, toggleTheme, palette, setPalette } = useTheme();
return (
<div className="sidebar-footer">
<button
type="button"
className="btn btn-ghost btn-icon help-btn"
title="Keyboard shortcuts (?)"
aria-label="Keyboard shortcuts"
onClick={requestToggleHelp}
>
?
</button>
<button
type="button"
className="btn btn-ghost btn-icon"
title={`Switch to ${theme === "dark" ? "light" : "dark"} theme`}
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} theme`}
onClick={toggleTheme}
>
{theme === "dark" ? "☀" : "☾"}
</button>
<select
className="palette-select"
value={palette}
aria-label="Colour palette"
onChange={(e) => setPalette(e.currentTarget.value as Palette)}
>
{PALETTES.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
{linkStore && <StatusDot store={linkStore} />}
<button
type="button"
className="btn btn-primary spawn-btn"
title="Spawn form: n — fast-forward spawn: N"
onClick={onSpawn}
>
+ Spawn
</button>
</div>
);
}
/** Daemon status indicator: a coloured dot whose tooltip names the release. */
function StatusDot({ store }: { store: DaemonLinkStoreApi }) {
const state = useStore(store, (s: DaemonLinkState) => s.state);
const build = useStore(store, (s: DaemonLinkState) => s.build);
const hostname = useStore(store, (s: DaemonLinkState) => s.hostname);
return (
<button
type="button"
className="status-chip btn btn-ghost btn-icon"
data-state={state}
aria-label={`Daemon ${state}`}
>
<span className="status-dot" />
<span className="status-tip">
<span>{hostname ?? "maestro"}</span>
<span className="text-faint">{formatRelease(build)}</span>
</span>
</button>
);
}
+15 -11
View File
@@ -104,9 +104,11 @@ export interface SpawnFormProps {
/** Fired whenever any value changes, once the form's initial seed settles. */
onChange?(values: SpawnFormValues): void;
onSuccess(session: Session): void;
/** When true, hides the built-in submit button (the modal provides its own). */
externalSubmit?: boolean;
}
export function SpawnForm({ seed, onChange, onSuccess }: SpawnFormProps) {
export function SpawnForm({ seed, onChange, onSuccess, externalSubmit }: SpawnFormProps) {
const prefill = useRef(seed ?? {}).current;
const formRef = useRef<HTMLFormElement>(null);
@@ -240,7 +242,7 @@ export function SpawnForm({ seed, onChange, onSuccess }: SpawnFormProps) {
const [advancedCollapsed, setAdvancedCollapsed] = useState(true);
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [, setSubmitting] = useState(false);
const [ready, setReady] = useState(false);
const probeTokenRef = useRef(0);
@@ -620,6 +622,7 @@ export function SpawnForm({ seed, onChange, onSuccess }: SpawnFormProps) {
return (
<form
id="spawn-form"
ref={formRef}
onSubmit={onSubmit}
onKeyDown={onFormKeyDown}
@@ -839,15 +842,16 @@ export function SpawnForm({ seed, onChange, onSuccess }: SpawnFormProps) {
<p role="alert" aria-live="polite" className={`m-0 text-fs-13 text-danger ${error ? "rounded-r-2 bg-danger-tint px-sp-3 py-sp-2" : "min-h-[1.2em]"}`}>
{error}
</p>
<div className="flex justify-end">
<button
type="submit"
disabled={submitting}
className="rounded-r-1 bg-accent px-sp-4 py-sp-2 text-fs-13 font-semibold text-accent-ink disabled:opacity-60"
>
Spawn
</button>
</div>
{!externalSubmit && (
<div className="flex justify-end">
<button
type="submit"
className="btn btn-primary rounded-r-1 px-sp-4 py-sp-1 text-fs-13 font-semibold"
>
Spawn
</button>
</div>
)}
</form>
);
}
+9 -2
View File
@@ -64,16 +64,23 @@ export function SpawnModal({ seed, onSuccess, onClose }: SpawnModalProps) {
</button>
</header>
<div ref={filterInputRef} className="min-h-0 flex-1 overflow-auto">
<SpawnForm seed={seed} onSuccess={handleSuccess} />
<SpawnForm seed={seed} onSuccess={handleSuccess} externalSubmit />
</div>
<div className="flex justify-end gap-sp-2 border-t border-border p-sp-3">
<button
type="button"
className="rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
className="rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-13 text-ink"
onClick={onClose}
>
Cancel
</button>
<button
type="submit"
form="spawn-form"
className="btn btn-primary rounded-r-1 px-sp-4 py-sp-1 text-fs-13 font-semibold"
>
Spawn
</button>
</div>
</div>
</div>,
@@ -1094,6 +1094,8 @@
width: 40px;
padding: 0;
border-color: transparent;
background: transparent;
box-shadow: none;
color: var(--danger);
}
@@ -196,12 +196,24 @@ export function useStructuredSession(
connect();
};
// Mobile browsers suspend timers and silently kill WebSockets in the
// background. Kick a fresh connect when the page becomes visible again.
const onVisibility = () => {
if (document.visibilityState !== "visible" || disposed) return;
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return;
cancelTimer();
setStatus("reconnecting");
connect();
};
document.addEventListener("visibilitychange", onVisibility);
setStatus("connecting");
connect();
return () => {
disposed = true;
reconnectRef.current = () => {};
document.removeEventListener("visibilitychange", onVisibility);
cancelTimer();
closeSocket();
};
@@ -302,6 +302,24 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
ta?.addEventListener("focus", onTaFocus);
ta?.addEventListener("blur", onTaBlur);
// Mobile browsers (Android especially) suspend timers and silently kill
// WebSockets when the app goes to background. The backoff timer set by
// onclose is frozen too, so recovery stalls until visibility returns. Kick
// a fresh connect immediately when the page becomes visible again.
const onVisibility = () => {
if (document.visibilityState !== "visible" || disposed) return;
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) return;
// Cancel any pending backoff — we're reconnecting right now.
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
reconnectMs = INITIAL_RECONNECT_MS;
onStatusChange?.("reconnecting");
connect();
};
document.addEventListener("visibilitychange", onVisibility);
window.addEventListener("resize", onResize);
const visual = window.visualViewport ?? null;
visual?.addEventListener("resize", onResize);
@@ -326,6 +344,7 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
reconnectTimer = null;
}
ro.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("resize", onResize);
visual?.removeEventListener("resize", onResize);
ta?.removeEventListener("focus", onTaFocus);
+16 -8
View File
@@ -26,15 +26,23 @@ export type Palette = (typeof PALETTES)[number];
const PALETTE_KEY = "maestro-palette";
function storedTheme(): Theme | null {
const v = localStorage.getItem(THEME_KEY);
return v === "light" || v === "dark" ? v : null;
try {
const v = localStorage.getItem(THEME_KEY);
return v === "light" || v === "dark" ? v : null;
} catch {
return null;
}
}
function storedPalette(): Palette | null {
const v = localStorage.getItem(PALETTE_KEY);
return (PALETTES as readonly string[]).includes(v ?? "")
? (v as Palette)
: null;
try {
const v = localStorage.getItem(PALETTE_KEY);
return (PALETTES as readonly string[]).includes(v ?? "")
? (v as Palette)
: null;
} catch {
return null;
}
}
function systemPrefersLight(): boolean {
@@ -89,7 +97,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}, [palette]);
const setTheme = useCallback((t: Theme) => {
localStorage.setItem(THEME_KEY, t);
try { localStorage.setItem(THEME_KEY, t); } catch { /* test env */ }
setPinnedTheme(t);
}, []);
@@ -98,7 +106,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}, [theme, setTheme]);
const setPalette = useCallback((p: Palette) => {
localStorage.setItem(PALETTE_KEY, p);
try { localStorage.setItem(PALETTE_KEY, p); } catch { /* test env */ }
setPaletteState(p);
}, []);
+8
View File
@@ -56,3 +56,11 @@ export const TOGGLE_DRAWER_EVENT = "maestro-toggle-drawer";
export function requestToggleDrawer(): void {
document.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT));
}
/** Toggle the help overlay from a button click (the `?` key is handled by
* HelpOverlay's own command set). */
export const TOGGLE_HELP_EVENT = "maestro-toggle-help";
export function requestToggleHelp(): void {
document.dispatchEvent(new CustomEvent(TOGGLE_HELP_EVENT));
}