Compare commits

..
Author SHA1 Message Date
Miguel Palhas 46bae13212 feat(maestro): map design tokens to Tailwind config, port theme to React
Wires tailwind.config.ts to every custom-property token in styles.css
(spacing, OKLCH colours, radii, z-index, sizing, durations) so Tailwind
utilities resolve to the same variables the vanilla CSS uses. Adds
ThemeContext/useTheme as a React port of theme.ts, keeping the same
localStorage keys and data-theme/data-palette attributes so it can run
alongside the vanilla shell.
2026-09-09 00:12:59 +01:00
Miguel Palhas 589e0144e2 Closes #239: scaffold React + Tailwind + Zustand 2026-09-09 00:03:04 +01:00
Miguel Palhas 0061378193 feat(maestro): scaffold React + Tailwind + Zustand
Adds react, react-dom, zustand, tailwindcss, @tailwindcss/vite as a new
skeleton alongside the existing vanilla SPA. New src/index.tsx entry
point (unused until wired into index.html in a later issue); main.ts
stays the active entry.
2026-09-09 00:01:33 +01:00
Miguel Palhas 1cf4825b1d fix(web): collapse agent commentary
ci / web (push) Failing after 6s
ci / flake (push) Successful in 3m31s
ci / rust (push) Successful in 6m18s
2026-09-08 17:21:08 +01:00
Miguel PalhasandClaude Opus 4.6 f799aaf564 fix(maestro): restore focus after surgical tool-row patch
ci / web (push) Successful in 41s
ci / flake (push) Successful in 4m36s
ci / rust (push) Successful in 8m42s
The patch path did a DOM replaceWith and returned early, skipping the
focus-restoration logic in rebuildTranscript. Now saves and restores
tool-head focus within the surgical swap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 15:37:58 +01:00
naps62-yolo 5af5ceb9a8 feat(maestro): trigger haiku naming for ACP sessions (#206)
ci / web (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / flake (push) Has been cancelled
2026-09-08 15:35:27 +01:00
16 changed files with 1473 additions and 57 deletions
+11 -14
View File
@@ -80,24 +80,21 @@ mod tests {
}
#[test]
fn non_repo_dir_is_none() {
// A temp dir with no `.git` anywhere up the chain. We need a path whose
// *entire* ancestor chain is `.git`-free, but `/tmp` itself may live
// inside a repo (CI containers, sandbox VMs). Work around this by
// creating a tmpfs-like isolated root via an in-memory mount when
// possible, or — more portably — by asserting the result is NOT inside
// the temp dir we control rather than asserting None outright.
fn non_repo_dir_does_not_find_root_inside_fixture() {
// The fixture has no `.git`, so `find_root` must not return a path
// inside it. On hosts where an ancestor of the temp dir *is* a repo
// (e.g. `/tmp` lives inside a checkout), the function legitimately
// returns that ancestor — the test only asserts the fixture itself
// was not mistaken for a repo.
let tmp = tempfile::tempdir().unwrap();
let sub = tmp.path().join("plain/dir");
fs::create_dir_all(&sub).unwrap();
// If an ancestor of /tmp has .git, find_root will return that — but it
// must never return anything at or below our tempdir.
let result = find_root(&sub);
if let Some(ref root) = result {
assert!(
match find_root(&sub) {
None => {} // expected on most hosts
Some(root) => assert!(
!root.starts_with(tmp.path()),
"find_root returned {root:?} which is inside our .git-free tempdir",
);
"found .git inside the fixture at {root:?}, but none was created"
),
}
}
+4
View File
@@ -805,6 +805,10 @@ impl SessionManager {
});
drop(stream);
sync_structured_activity(&self.inner, id).await;
#[cfg(feature = "haiku-autoname")]
if let Ok(true) = self.claim_autoname(id).await {
crate::autoname::spawn_naming(self.clone(), id, text);
}
return Ok(QueueAcpPrompt::Queued);
}
Err(message) => {
+9 -6
View File
@@ -1020,12 +1020,15 @@ async fn gitroot_probes_path() {
.json()
.await
.unwrap();
if let Some(serde_json::Value::String(root)) = resp.get("root") {
let root = std::path::Path::new(root);
assert!(
!root.starts_with(tmp.path()),
"gitroot returned {root:?} inside our .git-free tempdir",
);
// On hosts where an ancestor of the temp dir is a repo (e.g. `/tmp`
// inside a checkout), the root legitimately points there. The test only
// asserts no root was found *inside* the temp dir.
match resp.get("root").and_then(|v| v.as_str()) {
None => {}
Some(root) => assert!(
!root.starts_with(tmp.path().to_string_lossy().as_ref()),
"found a repo root inside the fixture at {root:?}"
),
}
}
+12 -2
View File
@@ -17,12 +17,22 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^5.5.0",
"marked": "^18.0.11"
"marked": "^18.0.11",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^4.7.0",
"happy-dom": "^20.11.6",
"tailwindcss": "^4.3.3",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vitest": "^2.1.9"
"vitest": "^2.1.9",
"zustand": "^5.0.15"
}
}
+984 -11
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
import { ThemeProvider } from "./contexts/ThemeContext";
export function App() {
return (
<ThemeProvider>
<div id="react-root" />
</ThemeProvider>
);
}
+163
View File
@@ -0,0 +1,163 @@
// @vitest-environment happy-dom
import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { PALETTES, ThemeProvider, useTheme } from "./ThemeContext";
// happy-dom's vitest environment ships neither localStorage nor matchMedia;
// back both with plain in-memory fakes so persistence and system-theme
// reactivity are observable (mirrors ../theme.test.ts's localStorage fake).
let store = new Map<string, string>();
let systemPrefersLight = false;
let mediaListeners: Array<(e: MediaQueryListEvent) => void> = [];
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => store.set(k, v),
removeItem: (k: string) => store.delete(k),
},
});
Object.defineProperty(window, "matchMedia", {
value: (query: string) => ({
matches: query.includes("light") ? systemPrefersLight : false,
media: query,
addEventListener: (_type: string, cb: (e: MediaQueryListEvent) => void) => {
mediaListeners.push(cb);
},
removeEventListener: (_type: string, cb: (e: MediaQueryListEvent) => void) => {
mediaListeners = mediaListeners.filter((l) => l !== cb);
},
}),
});
});
function fireSystemThemeChange(matches: boolean): void {
systemPrefersLight = matches;
for (const cb of mediaListeners) {
cb({ matches } as MediaQueryListEvent);
}
}
function Probe() {
const { theme, palette, setTheme, toggleTheme, setPalette } = useTheme();
return (
<div>
<span data-testid="theme">{theme}</span>
<span data-testid="palette">{palette}</span>
<button onClick={() => setTheme("light")}>pin-light</button>
<button onClick={toggleTheme}>toggle</button>
<button onClick={() => setPalette("ember")}>set-ember</button>
<button onClick={() => setPalette("graphite")}>set-graphite</button>
</div>
);
}
describe("ThemeProvider / useTheme", () => {
beforeEach(() => {
store = new Map();
mediaListeners = [];
systemPrefersLight = false;
delete document.documentElement.dataset.theme;
delete document.documentElement.dataset.palette;
});
afterEach(() => {
cleanup();
});
it("defaults to graphite and the system theme, no data-palette attribute", () => {
render(
<ThemeProvider>
<Probe />
</ThemeProvider>,
);
expect(screen.getByTestId("theme").textContent).toBe("dark");
expect(screen.getByTestId("palette").textContent).toBe("graphite");
expect(document.documentElement.dataset.palette).toBeUndefined();
});
it("follows a system theme change while unpinned", () => {
render(
<ThemeProvider>
<Probe />
</ThemeProvider>,
);
act(() => fireSystemThemeChange(true));
expect(screen.getByTestId("theme").textContent).toBe("light");
});
it("setTheme pins, persists, and stops following the system", () => {
render(
<ThemeProvider>
<Probe />
</ThemeProvider>,
);
act(() => screen.getByText("pin-light").click());
expect(screen.getByTestId("theme").textContent).toBe("light");
expect(document.documentElement.dataset.theme).toBe("light");
expect(localStorage.getItem("maestro-theme")).toBe("light");
act(() => fireSystemThemeChange(true));
expect(screen.getByTestId("theme").textContent).toBe("light");
});
it("toggleTheme flips the resolved theme", () => {
render(
<ThemeProvider>
<Probe />
</ThemeProvider>,
);
act(() => screen.getByText("toggle").click());
expect(screen.getByTestId("theme").textContent).toBe("light");
act(() => screen.getByText("toggle").click());
expect(screen.getByTestId("theme").textContent).toBe("dark");
});
it("setPalette stamps data-palette and persists; graphite clears it", () => {
render(
<ThemeProvider>
<Probe />
</ThemeProvider>,
);
act(() => screen.getByText("set-ember").click());
expect(document.documentElement.dataset.palette).toBe("ember");
expect(localStorage.getItem("maestro-palette")).toBe("ember");
act(() => screen.getByText("set-graphite").click());
expect(document.documentElement.dataset.palette).toBeUndefined();
});
it("a fresh mount re-applies a stored palette", () => {
localStorage.setItem("maestro-palette", "periwinkle");
render(
<ThemeProvider>
<Probe />
</ThemeProvider>,
);
expect(screen.getByTestId("palette").textContent).toBe("periwinkle");
expect(document.documentElement.dataset.palette).toBe("periwinkle");
});
it("exposes every palette in picker order", () => {
expect(PALETTES).toEqual([
"graphite",
"ice-cyan",
"periwinkle",
"amber-phosphor",
"ember",
"orchid",
]);
});
it("throws when used outside a provider", () => {
const Bare = () => {
useTheme();
return null;
};
expect(() => render(<Bare />)).toThrow(
"useTheme must be used within a ThemeProvider",
);
});
});
+118
View File
@@ -0,0 +1,118 @@
// React port of ../theme.ts. Same storage keys and `data-theme`/`data-palette`
// attributes on <html>, so this can sit alongside the vanilla shell (which
// still owns theme.ts) without either one fighting the other over state.
import {
createContext,
useCallback,
useContext,
useLayoutEffect,
useState,
type ReactNode,
} from "react";
type Theme = "light" | "dark";
const THEME_KEY = "maestro-theme";
/** Every `data-palette` value, in picker order. Graphite is the default. */
export const PALETTES = [
"graphite",
"ice-cyan",
"periwinkle",
"amber-phosphor",
"ember",
"orchid",
] as const;
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;
}
function storedPalette(): Palette | null {
const v = localStorage.getItem(PALETTE_KEY);
return (PALETTES as readonly string[]).includes(v ?? "")
? (v as Palette)
: null;
}
function systemPrefersLight(): boolean {
return window.matchMedia("(prefers-color-scheme: light)").matches;
}
// Dev-only `?theme=` override for visual review, same as theme.ts's — never
// persisted, just seeds the initial pin for this session.
function devThemeOverride(): Theme | null {
if (!import.meta.env.DEV) return null;
const q = new URLSearchParams(location.search).get("theme");
return q === "light" || q === "dark" ? q : null;
}
interface ThemeContextValue {
theme: Theme;
palette: Palette;
setTheme: (t: Theme) => void;
toggleTheme: () => void;
setPalette: (p: Palette) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [pinnedTheme, setPinnedTheme] = useState<Theme | null>(
() => devThemeOverride() ?? storedTheme(),
);
const [systemIsLight, setSystemIsLight] = useState(systemPrefersLight);
const [palette, setPaletteState] = useState<Palette>(
() => storedPalette() ?? "graphite",
);
const theme: Theme = pinnedTheme ?? (systemIsLight ? "light" : "dark");
useLayoutEffect(() => {
const mql = window.matchMedia("(prefers-color-scheme: light)");
const onChange = (e: MediaQueryListEvent) => setSystemIsLight(e.matches);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
// Pinning is one-way (no UI un-pins back to "follow system"), so this only
// ever needs to set the attribute — never clear it — same as theme.ts.
useLayoutEffect(() => {
if (pinnedTheme) document.documentElement.dataset.theme = pinnedTheme;
}, [pinnedTheme]);
useLayoutEffect(() => {
if (palette === "graphite") delete document.documentElement.dataset.palette;
else document.documentElement.dataset.palette = palette;
}, [palette]);
const setTheme = useCallback((t: Theme) => {
localStorage.setItem(THEME_KEY, t);
setPinnedTheme(t);
}, []);
const toggleTheme = useCallback(() => {
setTheme(theme === "dark" ? "light" : "dark");
}, [theme, setTheme]);
const setPalette = useCallback((p: Palette) => {
localStorage.setItem(PALETTE_KEY, p);
setPaletteState(p);
}, []);
return (
<ThemeContext.Provider
value={{ theme, palette, setTheme, toggleTheme, setPalette }}
>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within a ThemeProvider");
return ctx;
}
+10
View File
@@ -0,0 +1,10 @@
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./main.css";
const container = document.getElementById("root");
if (!container) {
throw new Error("missing #root element");
}
createRoot(container).render(<App />);
+2
View File
@@ -0,0 +1,2 @@
@import "tailwindcss";
@config "../tailwind.config.ts";
+27 -5
View File
@@ -468,18 +468,40 @@ describe("structured connection: data flow", () => {
},
});
ws.emit({ type: "reduced_state", state: respondingState });
// While the turn is active, the last intermediate text (agent:2) stays
// outside the collapsed group so it's visible as current narration.
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).toBeNull();
// Earlier intermediate text (agent:1) is still inside the group.
// Intermediate updates stay inside the collapsed activity disclosure,
// rather than becoming a second visible transcript alongside its summary.
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull();
// When the turn ends, all intermediate text is absorbed.
// It remains collapsed when the turn ends.
ws.emit({ type: "reduced_state", state: idleState });
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull();
conn.dispose();
});
it("collapses trailing thought commentary with a turn's tool activity", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 5,
rows: [
row("user:1", "user", { content: { type: "text", text: "do it" } }),
toolRow("tool:1", "one"),
toolRow("tool:2", "two"),
row("thought:1", "agent_thought", { content: { type: "text", text: "Checking the next step" } }),
row("thought:2", "agent_thought", { content: { type: "text", text: "Preparing the change" } }),
],
},
});
for (const id of ["thought:1", "thought:2"]) {
expect(conn.el.querySelector(`[data-row-id="${id}"]`)?.closest(".st-tool-group")).not.toBeNull();
}
conn.dispose();
});
it("keeps an expanded running tool open without emitting a click when patched", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
+13 -18
View File
@@ -488,24 +488,14 @@ export function createStructuredConnection(
groups.set(currentGid, group);
}
}
// pending at end of a turn = final text/approvals, stays outside (not flushed).
// While the turn is still active, keep the last intermediate text/thought
// row outside the collapsed group so it's visible as the agent's current
// narration. It gets absorbed on the next rebuild once newer content
// arrives or the turn ends.
const turnActive = state.turn.responding || state.turn.thinking;
if (turnActive) {
const lastGroup = groups.get(currentGid);
if (lastGroup) {
for (let i = lastGroup.rows.length - 1; i >= 0; i--) {
const kind = lastGroup.rows[i].kind;
if (kind === "agent_text" || kind === "agent_thought") {
lastGroup.rows.splice(i, 1);
break;
}
}
}
// A final agent text is the answer, so it stays outside. Thought rows are
// working commentary, though: when a turn already has a disclosure they
// belong with its activity even if the next tool call has not arrived yet.
// Leaving that tail loose made a busy agent look like it was publishing a
// long italic answer above a collapsed "N tool calls" summary.
const lastGroup = groups.get(currentGid);
if (lastGroup && pending.length > 0 && pending.every((row) => row.kind === "agent_thought")) {
lastGroup.rows.push(...pending);
}
return groups;
@@ -634,10 +624,15 @@ export function createStructuredConnection(
// visible flashing and breaks text selection.
const oldEl = rowEls.get(delta.row.id);
if (oldEl) {
const active = document.activeElement as HTMLElement | null;
const hadToolFocus = active?.classList.contains("st-tool-head") && oldEl.contains(active);
const newEl = buildRowElement(delta.row);
if (newEl) {
oldEl.replaceWith(newEl);
rowEls.set(delta.row.id, newEl);
if (hadToolFocus) {
newEl.querySelector<HTMLButtonElement>(".st-tool-head")?.focus();
}
}
syncAvailableCommands();
return;
+2 -1
View File
@@ -2507,7 +2507,8 @@ input.leaf-rename:focus {
.st-row-thought {
color: var(--faint);
font-style: italic;
font-size: var(--fs-12);
line-height: 1.4;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
+105
View File
@@ -0,0 +1,105 @@
import type { Config } from "tailwindcss";
// Bridges Tailwind utilities to the OKLCH custom-property tokens defined in
// src/styles.css. Every value here is `var(--token)`, never a literal, so
// theme/palette switching (data-theme, data-palette on <html>) keeps working
// through Tailwind classes exactly as it does through the vanilla CSS.
export default {
content: ["src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
bg: "var(--bg)",
"bg-grid": "var(--bg-grid)",
surface: "var(--surface)",
"surface-2": "var(--surface-2)",
"surface-3": "var(--surface-3)",
border: "var(--border)",
"border-strong": "var(--border-strong)",
ink: "var(--ink)",
muted: "var(--muted)",
faint: "var(--faint)",
accent: "var(--accent)",
"accent-strong": "var(--accent-strong)",
"accent-ink": "var(--accent-ink)",
"accent-tint": "var(--accent-tint)",
"accent-ring": "var(--accent-ring)",
"accent-fill": "var(--accent-fill)",
preview: "var(--preview)",
"preview-ink": "var(--preview-ink)",
"preview-tint": "var(--preview-tint)",
"preview-ring": "var(--preview-ring)",
live: "var(--live)",
"live-glow": "var(--live-glow)",
danger: "var(--danger)",
"danger-tint": "var(--danger-tint)",
warn: "var(--warn)",
"term-bg": "var(--term-bg)",
},
spacing: {
"sp-1": "var(--sp-1)",
"sp-2": "var(--sp-2)",
"sp-3": "var(--sp-3)",
"sp-4": "var(--sp-4)",
"sp-5": "var(--sp-5)",
"sp-6": "var(--sp-6)",
"sp-8": "var(--sp-8)",
},
borderRadius: {
"r-1": "var(--r-1)",
"r-2": "var(--r-2)",
"r-3": "var(--r-3)",
"r-pill": "var(--r-pill)",
},
fontSize: {
"fs-11": "var(--fs-11)",
"fs-12": "var(--fs-12)",
"fs-13": "var(--fs-13)",
"fs-14": "var(--fs-14)",
"fs-15": "var(--fs-15)",
"fs-17": "var(--fs-17)",
"fs-20": "var(--fs-20)",
},
fontFamily: {
mono: "var(--font-mono)",
sans: "var(--font-sans)",
},
height: {
"row-h": "var(--row-h)",
"control-h": "var(--control-h)",
tap: "var(--tap)",
"bar-h": "var(--bar-h)",
},
width: {
tap: "var(--tap)",
},
minHeight: {
"control-h": "var(--control-h)",
tap: "var(--tap)",
},
zIndex: {
sticky: "var(--z-sticky)",
backdrop: "var(--z-backdrop)",
modal: "var(--z-modal)",
toast: "var(--z-toast)",
},
transitionDuration: {
fast: "var(--dur-fast)",
DEFAULT: "var(--dur)",
slow: "var(--dur-slow)",
},
transitionTimingFunction: {
out: "var(--ease-out)",
},
boxShadow: {
1: "var(--shadow-1)",
2: "var(--shadow-2)",
},
},
},
} satisfies Config;
+1
View File
@@ -4,6 +4,7 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
+3
View File
@@ -1,4 +1,6 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// Where the dev proxy forwards `/api/*` (and the events WS) to. Default to a
// local daemon on 127.0.0.1:7070. Override with:
@@ -18,6 +20,7 @@ export default defineConfig(({ mode }) => {
const target = resolveProxyTarget(env.MAESTRO_DAEMON_URL);
const secure = target.startsWith("https://");
return {
plugins: [react(), tailwindcss()],
build: {
target: "es2022",
outDir: "dist",