Compare commits

...
2 Commits
Author SHA1 Message Date
Miguel PalhasandClaude Opus 4.6 1ea463f6cf fix(maestro): collapse mobile drawer after spawning a session
ci / web (push) Failing after 28s
ci / flake (push) Successful in 4m6s
ci / rust (push) Successful in 7m37s
mountSpawned now fires a CLOSE_DRAWER_EVENT so the sidebar gets out of
the way once the new session lands. Covers fast-forward, modal, and
command-bar spawn paths.

Closes #261

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 23:31:52 +01:00
Miguel PalhasandClaude Opus 4.6 7c27a897ac feat(maestro): optimistic session kill in sidebar
ci / web (push) Failing after 6s
ci / flake (push) Successful in 4m13s
ci / rust (push) Successful in 7m25s
Close the kill dialog and remove the session card immediately on
confirm instead of waiting for the DELETE round-trip. Restores the
session if the daemon refuses with kill_guarded and re-opens the
dialog in force mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 21:21:36 +01:00
5 changed files with 46 additions and 4 deletions
+2
View File
@@ -47,6 +47,7 @@ import {
OPEN_SPAWN_EVENT, OPEN_SPAWN_EVENT,
SESSION_SELECT_EVENT, SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT, SPAWN_SIMILAR_EVENT,
requestCloseDrawer,
requestOpenSpawn, requestOpenSpawn,
requestToggleDrawer, requestToggleDrawer,
} from "./uiEvents.js"; } from "./uiEvents.js";
@@ -182,6 +183,7 @@ function Shell() {
(session: Session, opts?: { newTab?: boolean }) => { (session: Session, opts?: { newTab?: boolean }) => {
layoutStore.getState().openSessionBeside(session.id, opts); layoutStore.getState().openSessionBeside(session.id, opts);
navigate({ kind: "attach", sessionId: session.id }); navigate({ kind: "attach", sessionId: session.id });
requestCloseDrawer();
}, },
[layoutStore, navigate], [layoutStore, navigate],
); );
+14 -1
View File
@@ -2,7 +2,7 @@
import { act, cleanup, fireEvent, render } from "@testing-library/react"; import { act, cleanup, fireEvent, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "../../uiEvents.js"; import { CLOSE_DRAWER_EVENT, OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "../../uiEvents.js";
import { TOGGLE_DRAWER_EVENT } from "../../uiEvents.js"; import { TOGGLE_DRAWER_EVENT } from "../../uiEvents.js";
import { Drawer } from "./Drawer.js"; import { Drawer } from "./Drawer.js";
@@ -103,6 +103,19 @@ describe("Drawer (#35)", () => {
expect(shell().classList.contains("drawer-open")).toBe(false); expect(shell().classList.contains("drawer-open")).toBe(false);
}); });
it("closes on the close-drawer event (after spawn lands)", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
openViaToggle();
act(() => {
document.dispatchEvent(new CustomEvent(CLOSE_DRAWER_EVENT));
});
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("closes on a backdrop click", () => { it("closes on a backdrop click", () => {
render( render(
<Drawer sidebar={<div className="tree-leaf">row</div>}> <Drawer sidebar={<div className="tree-leaf">row</div>}>
+3 -1
View File
@@ -10,7 +10,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT, TOGGLE_DRAWER_EVENT } from "../../uiEvents.js"; import { CLOSE_DRAWER_EVENT, OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT, TOGGLE_DRAWER_EVENT } from "../../uiEvents.js";
export interface DrawerProps { export interface DrawerProps {
sidebar: ReactNode; sidebar: ReactNode;
@@ -28,10 +28,12 @@ export function Drawer({ sidebar, children, className }: DrawerProps) {
document.addEventListener(TOGGLE_DRAWER_EVENT, onToggle); document.addEventListener(TOGGLE_DRAWER_EVENT, onToggle);
document.addEventListener(OPEN_SPAWN_EVENT, onClose); document.addEventListener(OPEN_SPAWN_EVENT, onClose);
document.addEventListener(SPAWN_SIMILAR_EVENT, onClose); document.addEventListener(SPAWN_SIMILAR_EVENT, onClose);
document.addEventListener(CLOSE_DRAWER_EVENT, onClose);
return () => { return () => {
document.removeEventListener(TOGGLE_DRAWER_EVENT, onToggle); document.removeEventListener(TOGGLE_DRAWER_EVENT, onToggle);
document.removeEventListener(OPEN_SPAWN_EVENT, onClose); document.removeEventListener(OPEN_SPAWN_EVENT, onClose);
document.removeEventListener(SPAWN_SIMILAR_EVENT, onClose); document.removeEventListener(SPAWN_SIMILAR_EVENT, onClose);
document.removeEventListener(CLOSE_DRAWER_EVENT, onClose);
}; };
}, []); }, []);
+18 -2
View File
@@ -333,10 +333,25 @@ export function Sidebar(props: SidebarProps) {
onCancel={() => setKill(null)} onCancel={() => setKill(null)}
onConfirm={async () => { onConfirm={async () => {
const { session: s, force } = kill; const { session: s, force } = kill;
// Close the dialog immediately and optimistically remove the
// session from the sidebar so the operator isn't staring at a
// spinner while the daemon tears down the tmux session.
setKill(null);
const store = props.store;
if (store) {
const next = new Map(store.getState().sessions);
next.delete(s.id);
store.setState({ sessions: next });
}
try { try {
await killSession(s.id, force); await killSession(s.id, force);
setKill(null);
} catch (e: unknown) { } catch (e: unknown) {
// Restore the session so it reappears in the sidebar.
if (store) {
const restored = new Map(store.getState().sessions);
restored.set(s.id, s);
store.setState({ sessions: restored });
}
// The daemon refuses an unforced kill over a worktree holding // The daemon refuses an unforced kill over a worktree holding
// unsaved work; that refusal needs its own prompt // unsaved work; that refusal needs its own prompt
// (docs/WORKTREES.md). // (docs/WORKTREES.md).
@@ -349,7 +364,8 @@ export function Sidebar(props: SidebarProps) {
}); });
return; return;
} }
throw e; // Nothing else we can usefully show — the session is back in
// the sidebar and the operator can retry.
} }
}} }}
/> />
+9
View File
@@ -57,6 +57,15 @@ export function requestToggleDrawer(): void {
document.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT)); document.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT));
} }
/** Close the mobile drawer unconditionally — used after a spawn lands a new
* session so the content pane is visible. Unlike TOGGLE, this is idempotent:
* firing it when the drawer is already closed is a no-op. */
export const CLOSE_DRAWER_EVENT = "maestro-close-drawer";
export function requestCloseDrawer(): void {
document.dispatchEvent(new CustomEvent(CLOSE_DRAWER_EVENT));
}
/** Toggle the help overlay from a button click (the `?` key is handled by /** Toggle the help overlay from a button click (the `?` key is handled by
* HelpOverlay's own command set). */ * HelpOverlay's own command set). */
export const TOGGLE_HELP_EVENT = "maestro-toggle-help"; export const TOGGLE_HELP_EVENT = "maestro-toggle-help";