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>
This commit is contained in:
Miguel Palhas
2026-09-10 17:17:22 +01:00
co-authored by Claude Opus 4.6
parent 3a2513c78d
commit c170cd9d60
10 changed files with 202 additions and 61 deletions
+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;
+6 -6
View File
@@ -62,7 +62,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 ${rowFill(props)} ${
running ? "" : "opacity-60"
}`}
href={`#/attach/${encodeURIComponent(s.id)}`}
@@ -84,7 +84,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 +93,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 +116,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>,