Compare commits

..
Author SHA1 Message Date
Miguel Palhas b6f60bfbae Merge remote-tracking branch 'origin/main' into grazioso
ci / web (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / flake (push) Has been cancelled
2026-09-10 16:19:28 +01:00
Miguel PalhasandClaude Opus 4.6 a8a335eadb fix(maestro): remove cancel button border, center attach button
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:19:24 +01:00
Miguel Palhas 048e273ff9 ci: retry after runner cache clear
ci / web (push) Successful in 1m0s
ci / flake (push) Failing after 2m19s
ci / rust (push) Has been cancelled
2026-09-10 16:18:05 +01:00
Miguel PalhasandClaude Opus 4.6 beef2049ed fix(maestro): update remaining id="app" references in web tests
ci / rust (push) Failing after 6s
ci / web (push) Successful in 37s
ci / flake (push) Failing after 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:02:02 +01:00
Miguel PalhasandClaude Opus 4.6 c30592e6b6 fix(maestro): update embedded_bundle test for React mount point
ci / web (push) Failing after 6s
ci / flake (push) Failing after 1m4s
ci / rust (push) Failing after 1m51s
The React migration changed the SPA root from id="app" to id="root".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 17:07:21 +01:00
Miguel PalhasandClaude Opus 4.6 abae81a0ce feat(web): React + Tailwind migration
ci / web (push) Successful in 37s
ci / flake (push) Failing after 1m6s
ci / rust (push) Failing after 1m56s
Replaces the 12.7k-line vanilla TypeScript SPA with React + Zustand +
Tailwind. 16 issues across milestone 48.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 17:01:49 +01:00
Miguel PalhasandClaude Opus 4.6 66a568818a fix(maestro): re-read session before sidecar write in mark_session_dead
ci / web (push) Failing after 6s
ci / flake (push) Failing after 55s
ci / rust (push) Successful in 6m7s
mark_session_dead_with_worktree_settlement cloned the session under the
lock, dropped it, did async work, then wrote the stale clone to the
sidecar. A haiku rename (or any other metadata update) that landed
between the clone and the write was silently overwritten, so the sidecar
reverted to name_source: Generated and the next daemon recovery
re-triggered autoname.

Re-read record.session under the lock at write time so the sidecar
always reflects the latest state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 15:14:34 +01:00
Miguel Palhas f3d6146884 Closes #254: port design kit specimen page to React 2026-09-09 15:13:48 +01:00
Miguel Palhas 35ae01db93 feat(maestro): port design kit specimen page to React
Replaces the deleted kit.ts + kit.css with DesignKit.tsx, gated behind
import.meta.env.DEV and loaded on ?kit. Shows the full tailwind.config.ts
token mapping (colours across all 6 palettes, typography, spacing,
radii, z-index) plus buttons, ActivityRail states, SessionCard states,
and terminal chrome, all switchable live through ThemeContext.

Closes #254
2026-09-09 15:11:48 +01:00
7 changed files with 453 additions and 21 deletions
+12 -12
View File
@@ -2916,17 +2916,16 @@ async fn mark_session_dead_with_worktree_settlement(
// Release anyone attached; the scrollback survives for a later
// attach.
record.bridge.seal();
let session = record.session.clone();
let worktree = record
.worktree_base_repo
.clone()
.zip(record.worktree_path.clone());
Some((session, worktree))
Some(worktree)
}
_ => None,
}
};
let Some((mut session, worktree)) = flipped else {
let Some(worktree) = flipped else {
return false;
};
let stream = inner
@@ -2943,9 +2942,6 @@ async fn mark_session_dead_with_worktree_settlement(
if settle_worktree {
if let Some(wt) = &worktree {
if settle_worktree_on_exit(inner, id, wt).await {
// The tree is settled (removed); a stale "in the way" would
// describe something the session no longer holds.
session.blocked_work = None;
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.session.worktree = None;
@@ -2957,13 +2953,17 @@ async fn mark_session_dead_with_worktree_settlement(
}
}
// Settle has now been applied or refused either way (#115): from here on
// its outcomes are safe to observe.
{
// its outcomes are safe to observe. Re-read the session from the record
// so the sidecar write includes any updates (e.g. a haiku rename) that
// landed between the initial clone and now.
let session = {
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.settled = true;
}
}
let Some(record) = sessions.get_mut(&id) else {
return false;
};
record.settled = true;
record.session.clone()
};
let _ = inner.sidecar.write(&session);
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
+37 -3
View File
@@ -11,14 +11,14 @@ use common::TmuxFixture;
use maestro_daemon::sidecar::SidecarStore;
use maestro_daemon::worktree::WorktreeManager;
use maestro_daemon::SessionManager;
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource};
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource, SessionStatus};
use tempfile::TempDir;
use uuid::Uuid;
struct Fixture {
manager: SessionManager,
sidecar: SidecarStore,
_tmux: TmuxFixture,
tmux: TmuxFixture,
_state_dir: TempDir,
}
@@ -35,7 +35,7 @@ fn fixture() -> Fixture {
Fixture {
manager,
sidecar,
_tmux: tmux,
tmux,
_state_dir: state_dir,
}
}
@@ -171,3 +171,37 @@ async fn a_blank_name_changes_nothing() {
let live = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(display::name_source(&live), NameSource::Generated);
}
/// A session that dies naturally (liveness watcher marks it dead) must not
/// overwrite a haiku rename that landed before the death. Regression test for
/// the stale-clone sidecar write in `mark_session_dead`.
#[tokio::test]
async fn session_death_preserves_the_haiku_name() {
let fx = fixture();
let id = spawn(&fx.manager, None).await;
fx.manager.claim_autoname(id).await.unwrap();
fx.manager.apply_autoname(id, "haiku-name").await.unwrap();
// Verify the sidecar has Haiku before the session dies.
let before = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(display::name_source(&before), NameSource::Haiku);
// Kill the tmux session directly so the liveness watcher picks it up.
fx.tmux.tmux.kill_session(id).expect("kill tmux directly");
fx.manager
.start_liveness_watcher(std::time::Duration::from_millis(25));
common::wait_for_async(std::time::Duration::from_secs(3), || async {
fx.manager.status(id).await == Some(SessionStatus::Dead)
})
.await;
let after = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(
display::name_source(&after),
NameSource::Haiku,
"mark_session_dead must not overwrite a haiku rename in the sidecar"
);
assert_eq!(display::custom_name(&after), Some("haiku-name"));
}
@@ -13,14 +13,14 @@ async fn embedded_index_is_real_spa_not_placeholder() {
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(
html.contains(r#"id="app""#),
html.contains(r#"id="root""#),
"served index is missing the SPA mount point; got:\n{html}"
);
assert!(
!html.contains("maestro is running"),
"served index is the placeholder, not the built bundle"
);
// Stronger signal than `id="app"` (which appears in both source and built
// Stronger signal than `id="root"` (which appears in both source and built
// HTML): the vite build rewrites the entry to a hashed `/assets/index-<hash>`
// bundle, whereas the un-built source references `/src/main.ts`. This catches
// both an empty/placeholder embed and the staleness case.
+2 -2
View File
@@ -59,7 +59,7 @@ async fn root_serves_the_spa_shell() {
.map(|v| v.starts_with("text/html")),
Some(true)
);
assert!(resp.text().await.unwrap().contains(r#"id="app""#));
assert!(resp.text().await.unwrap().contains(r#"id="root""#));
}
#[tokio::test]
@@ -69,7 +69,7 @@ async fn client_side_route_falls_back_to_the_shell() {
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert!(resp.text().await.unwrap().contains(r#"id="app""#));
assert!(resp.text().await.unwrap().contains(r#"id="root""#));
}
/// An unmatched API path must 404 rather than fall through to the SPA shell:
+382
View File
@@ -0,0 +1,382 @@
// Dev-only design kit (#254), React port of the deleted kit.ts + kit.css.
// Specimen page for the Tailwind token mapping (tailwind.config.ts ->
// src/styles.css) so the visual system can be reviewed as a whole. Loads on
// `?kit` (see index.tsx) and never ships to a production bundle.
import { useState, type ReactNode } from "react";
import { ActivityRail, STATE_WORDS } from "../sidebar/ActivityRail.js";
import { SessionCard, type SessionCardProps } from "../sidebar/SessionCard.js";
import { PaneTitle } from "../layout/PaneTitle.js";
import { PALETTES, useTheme, type Palette } from "../../contexts/ThemeContext.js";
import type { Session } from "../../types.js";
/** The accent each palette lands on, read from styles.css — shown so all six
* are comparable at a glance without switching the live one one at a time. */
const PALETTE_ACCENT: Record<Palette, string> = {
graphite: "oklch(0.92 0.005 256)",
"ice-cyan": "oklch(0.85 0.115 205)",
periwinkle: "oklch(0.75 0.125 275)",
"amber-phosphor": "oklch(0.85 0.16 85)",
ember: "oklch(0.78 0.16 40)",
orchid: "oklch(0.78 0.13 320)",
};
const COLOR_TOKENS = [
"--bg",
"--bg-grid",
"--surface",
"--surface-2",
"--surface-3",
"--border",
"--border-strong",
"--ink",
"--muted",
"--faint",
"--accent",
"--accent-strong",
"--accent-ink",
"--accent-tint",
"--accent-fill",
"--live",
"--danger",
"--danger-tint",
"--warn",
"--term-bg",
] as const;
// Tailwind's content scanner needs each utility to appear as a literal,
// contiguous string in source — a template-built `w-${token}` is invisible
// to it, so every scale below pairs the token label with its whole class name.
const SPACING_TOKENS = [
{ token: "sp-1", widthClass: "w-sp-1" },
{ token: "sp-2", widthClass: "w-sp-2" },
{ token: "sp-3", widthClass: "w-sp-3" },
{ token: "sp-4", widthClass: "w-sp-4" },
{ token: "sp-5", widthClass: "w-sp-5" },
{ token: "sp-6", widthClass: "w-sp-6" },
{ token: "sp-8", widthClass: "w-sp-8" },
] as const;
const RADIUS_TOKENS = [
{ token: "r-1", radiusClass: "rounded-r-1" },
{ token: "r-2", radiusClass: "rounded-r-2" },
{ token: "r-3", radiusClass: "rounded-r-3" },
{ token: "r-pill", radiusClass: "rounded-r-pill" },
] as const;
const FONT_SIZE_TOKENS = [
{ token: "fs-11", sizeClass: "text-fs-11" },
{ token: "fs-12", sizeClass: "text-fs-12" },
{ token: "fs-13", sizeClass: "text-fs-13" },
{ token: "fs-14", sizeClass: "text-fs-14" },
{ token: "fs-15", sizeClass: "text-fs-15" },
{ token: "fs-17", sizeClass: "text-fs-17" },
{ token: "fs-20", sizeClass: "text-fs-20" },
] as const;
const Z_INDEX_TOKENS = [
{ name: "z-sticky", value: 10 },
{ name: "z-backdrop", value: 40 },
{ name: "z-modal", value: 50 },
{ name: "z-toast", value: 60 },
] as const;
function Section({
title,
note,
children,
}: {
title: string;
note?: string;
children: ReactNode;
}) {
return (
<section className="border-t border-border py-sp-6 first:border-t-0 first:pt-0">
<h2 className="text-fs-17 font-semibold text-ink">{title}</h2>
{note && <p className="mt-sp-1 max-w-[70ch] text-fs-12 text-muted">{note}</p>}
<div className="mt-sp-4 flex flex-wrap gap-sp-4">{children}</div>
</section>
);
}
function Specimen({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col items-start gap-sp-2">
<div className="flex min-h-[48px] items-center rounded-r-2 border border-border bg-surface p-sp-3">
{children}
</div>
<span className="font-mono text-fs-11 text-faint">{label}</span>
</div>
);
}
function fakeSession(overrides: Partial<Session>): Session {
return {
id: "00000000-0000-0000-0000-000000000000",
cwd: "/home/dev/maestro",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: new Date().toISOString(),
activity_since: new Date(Date.now() - 4 * 60_000).toISOString(),
...overrides,
};
}
const NOOP_CARD_HANDLERS: Omit<SessionCardProps, "session" | "depth" | "now" | "highlighted" | "active" | "open" | "previewing" | "renaming"> = {
onSelect: () => {},
onOpenMenu: () => {},
onSpawnSimilar: () => {},
onKill: () => {},
onRenameCommit: () => {},
onRenameCancel: () => {},
};
const CARD_SPECIMENS: Array<{ label: string; props: Omit<SessionCardProps, keyof typeof NOOP_CARD_HANDLERS> }> = [
{
label: "idle",
props: {
session: fakeSession({ command: "bash" }),
depth: 0,
now: Date.now(),
highlighted: false,
active: false,
open: false,
previewing: false,
renaming: false,
},
},
{
label: "active, focused",
props: {
session: fakeSession({ activity: "Active", command: "vite dev" }),
depth: 0,
now: Date.now(),
highlighted: false,
active: true,
open: true,
previewing: false,
renaming: false,
},
},
{
label: "awaiting-input",
props: {
session: fakeSession({ activity: "AwaitingInput", activity_since: new Date(Date.now() - 12_000).toISOString() }),
depth: 0,
now: Date.now(),
highlighted: false,
active: false,
open: false,
previewing: false,
renaming: false,
},
},
{
label: "error",
props: {
session: fakeSession({ activity: "Error", activity_detail: "tests failing", activity_since: new Date(Date.now() - 7_200_000).toISOString() }),
depth: 0,
now: Date.now(),
highlighted: false,
active: false,
open: false,
previewing: false,
renaming: false,
},
},
{
label: "previewing",
props: {
session: fakeSession({ command: "opencode" }),
depth: 0,
now: Date.now(),
highlighted: false,
active: false,
open: false,
previewing: true,
renaming: false,
},
},
{
label: "exited",
props: {
session: fakeSession({ status: "Dead", command: "btop" }),
depth: 0,
now: Date.now(),
highlighted: false,
active: false,
open: false,
previewing: false,
renaming: false,
},
},
];
const ACTIVITY_STATES = Object.keys(STATE_WORDS) as Array<keyof typeof STATE_WORDS>;
export function DesignKit() {
const { theme, palette, toggleTheme, setPalette } = useTheme();
// "open" is deliberately display:none in the real app (.mv-pane's rule,
// styles.css) — a streaming pane already shows it's connected — so start on
// a state the kit page can actually show.
const [attachState, setAttachState] = useState<"connecting" | "open" | "closed">("connecting");
return (
// #root is a fixed-height, overflow-hidden flex column (main.css) built for
// the app shell's own internal scrolling — this page needs its own scroll
// container to match, or everything past the first viewport is clipped.
<div className="h-full overflow-y-auto bg-bg text-ink">
<header className="flex items-center justify-between border-b border-border px-sp-4 py-sp-3">
<h1 className="text-fs-20 font-semibold">
maestro<span className="text-accent">_</span> · design kit
</h1>
<div className="flex items-center gap-sp-2">
<button type="button" className="btn btn-ghost" onClick={toggleTheme}>
{theme === "dark" ? "☀ light" : "☾ dark"}
</button>
</div>
</header>
<main className="mx-auto max-w-5xl px-sp-4 py-sp-4">
<Section
title="Colour palettes"
note="6 palettes, picked via data-palette on <html> through ThemeContext. Switching one recolours every token below live."
>
{PALETTES.map((p) => (
<button
key={p}
type="button"
onClick={() => setPalette(p)}
className={`flex flex-col items-center gap-sp-1 rounded-r-2 border p-sp-2 ${
p === palette ? "border-accent" : "border-border"
}`}
>
<span
className="h-sp-6 w-sp-8 rounded-r-1"
style={{ background: PALETTE_ACCENT[p] }}
/>
<span className="font-mono text-fs-11 text-muted">{p}</span>
</button>
))}
</Section>
<Section title="Colour tokens" note="Current palette + theme, applied through var(--token).">
{COLOR_TOKENS.map((t) => (
<div key={t} className="flex flex-col items-start gap-sp-1">
<div
className="h-sp-8 w-sp-8 rounded-r-2 border border-border"
style={{ background: `var(${t})` }}
/>
<span className="font-mono text-fs-11 text-faint">{t}</span>
</div>
))}
</Section>
<Section title="Typography" note="fontSize -> var(--fs-N), from tailwind.config.ts.">
{FONT_SIZE_TOKENS.map(({ token, sizeClass }) => (
<Specimen key={token} label={token}>
<span className={`font-mono ${sizeClass}`}>Sessions</span>
</Specimen>
))}
</Section>
<Section title="Spacing scale" note="spacing -> var(--sp-N).">
{SPACING_TOKENS.map(({ token, widthClass }) => (
<div key={token} className="flex flex-col items-start gap-sp-1">
<div className={`h-sp-3 bg-accent ${widthClass}`} />
<span className="font-mono text-fs-11 text-faint">{token}</span>
</div>
))}
</Section>
<Section title="Border radii" note="borderRadius -> var(--r-N).">
{RADIUS_TOKENS.map(({ token, radiusClass }) => (
<div key={token} className="flex flex-col items-start gap-sp-1">
<div className={`h-sp-6 w-sp-8 border-2 border-accent ${radiusClass}`} />
<span className="font-mono text-fs-11 text-faint">{token}</span>
</div>
))}
</Section>
<Section title="Z-index scale" note="zIndex -> var(--z-*).">
{Z_INDEX_TOKENS.map((z) => (
<div key={z.name} className="flex items-baseline gap-sp-2 font-mono text-fs-12">
<span className="text-muted">{z.name}</span>
<span className="text-ink">{z.value}</span>
</div>
))}
</Section>
<Section title="Buttons" note="Terminal-native bracket chrome — .btn / .btn-primary / .btn-ghost / .btn-icon / .btn-danger.">
<Specimen label="btn">
<button type="button" className="btn">Cancel</button>
</Specimen>
<Specimen label="btn-primary">
<button type="button" className="btn btn-primary">+ Spawn</button>
</Specimen>
<Specimen label="btn-ghost">
<button type="button" className="btn btn-ghost">Dismiss</button>
</Specimen>
<Specimen label="btn-icon">
<button type="button" className="btn btn-icon">✕</button>
</Specimen>
<Specimen label="btn-danger">
<button type="button" className="btn btn-danger">Kill</button>
</Specimen>
<Specimen label="btn:disabled">
<button type="button" className="btn btn-primary" disabled>+ Spawn</button>
</Specimen>
</Section>
<Section title="Activity rail" note="ActivityRail.tsx — one state per session card, colour-coded and never colour-only (title/aria-label carry the word).">
{ACTIVITY_STATES.map((slug) => (
<Specimen key={slug} label={`${slug} · ${STATE_WORDS[slug]}`}>
<div className="relative h-[32px] w-[24px] overflow-hidden rounded-r-1 bg-surface-2">
<ActivityRail activity={slug} />
</div>
</Specimen>
))}
</Section>
<Section title="Session cards" note="SessionCard.tsx, docs/SIDEBAR.md states.">
<div className="w-full max-w-sm divide-y divide-border rounded-r-2 border border-border bg-surface-2">
{CARD_SPECIMENS.map(({ label, props }) => (
<div key={label} className="relative">
<SessionCard {...props} {...NOOP_CARD_HANDLERS} />
</div>
))}
</div>
</Section>
<Section title="Terminal chrome" note="attach-header + attach-body, as worn by TerminalPane/StructuredPane.">
<div className="w-full max-w-lg overflow-hidden rounded-r-2 border border-border">
<div className="attach-header">
<PaneTitle
sessionId="11111111-2222-3333-4444-555555555555"
session={fakeSession({ command: "claude" })}
/>
<span className="flex-1" />
<button
type="button"
className="attach-state"
data-state={attachState}
onClick={() =>
setAttachState((s) => (s === "open" ? "connecting" : s === "connecting" ? "closed" : "open"))
}
title="Click to cycle"
>
{attachState}
</button>
<button type="button" className="btn btn-icon disconnect" title="Disconnect">
⏻
</button>
</div>
<div className="attach-body h-[120px] items-start">
<span className="font-mono text-fs-13 text-muted">$ pnpm test</span>
</div>
</div>
</Section>
</main>
</div>
);
}
+2 -1
View File
@@ -1093,7 +1093,7 @@
position: relative;
width: 40px;
padding: 0;
border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
border-color: transparent;
color: var(--danger);
}
@@ -1287,6 +1287,7 @@
border-color: transparent;
background: transparent;
color: var(--muted);
align-self: center;
}
.structured-attach:hover {
+16 -1
View File
@@ -1,5 +1,6 @@
import { createRoot } from "react-dom/client";
import { App } from "./App.js";
import { ThemeProvider } from "./contexts/ThemeContext.js";
import "./main.css";
const container = document.getElementById("root");
@@ -7,4 +8,18 @@ if (!container) {
throw new Error("missing #root element");
}
createRoot(container).render(<App />);
const root = createRoot(container);
// Dev-only: `?kit` renders the standalone design kit instead of the app
// shell (#254). Dynamic import keeps it out of the production bundle.
if (import.meta.env.DEV && new URLSearchParams(location.search).has("kit")) {
void import("./components/kit/DesignKit.js").then(({ DesignKit }) => {
root.render(
<ThemeProvider>
<DesignKit />
</ThemeProvider>,
);
});
} else {
root.render(<App />);
}