Compare commits

...
Author SHA1 Message Date
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 Palhas 7cb52927f2 Merge remote-tracking branch 'origin/main' into tuba-2 2026-09-10 16:38:37 +01:00
Miguel PalhasandClaude Opus 4.6 2724447a51 feat(maestro): show last tool and live narration in collapsed group header
The collapsed tool group now reads "N tool calls · Label 'arg'" so the
operator sees the current command at a glance. While a turn is active,
the most recent agent text stays outside the group as visible narration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:38:30 +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
Miguel Palhas 979bfe2fbe chore(maestro): update local tool permissions
ci / web (push) Successful in 47s
ci / flake (push) Failing after 1m5s
ci / rust (push) Successful in 6m44s
2026-09-10 16:25:37 +01:00
Miguel PalhasandClaude Opus 4.6 8f96027878 ci(maestro): cancel in-progress builds on all branches
ci / web (push) Failing after 7s
ci / flake (push) Failing after 1m6s
ci / rust (push) Has been cancelled
Closes #255

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:24:38 +01:00
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
30 changed files with 790 additions and 174 deletions
-18
View File
@@ -1,18 +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:*)"
]
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ on:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
+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,
}
+19 -39
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(())
}
@@ -2916,17 +2911,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 +2937,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 +2948,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 });
@@ -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(),
+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:
+9 -6
View File
@@ -194,12 +194,15 @@ choices, not daemon ones.
appear *between* tool rows in the same turn are absorbed into the group
(intermediate narration); the last `AgentText` with no subsequent tool stays
outside as the prominent final answer. While a turn is still active
(responding or thinking), the most recent intermediate `AgentText` or
`AgentThought` in the group is kept outside as visible narration — replaced
by the next text row when one arrives, and absorbed into the group when the
turn ends. The header still reads "N tool calls" (counting only tool rows).
Opening the group reveals tools and intermediate text in chronological
order, with the text rendered in a subdued style.
(responding or thinking), the most recent trailing text or thought row is
kept outside as visible narration — the operator always sees the agent's
latest reasoning without opening the group. It is replaced by the next text
row when one arrives, and absorbed into the group when the turn ends (unless
it is `AgentText`, which stays outside as the final answer). The collapsed
header reads "N tool calls · Label 'arg'" where the label and argument come
from the last tool row's `splitToolTitle` — the operator sees which command
is current at a glance. Opening the group reveals tools and intermediate
text in chronological order, with the text rendered in a subdued style.
- The web client's user, agent, thought, tool-detail, and notice text blocks
copy their complete source text when clicked or keyboard-activated.
- The web client consumes `available_commands_update` notices as the live
+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);
+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>
);
}
+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>,
@@ -175,7 +175,7 @@ describe("tool rows and activity groups", () => {
chunk("agent:2", "agent_text", "the answer"),
]);
const head = screen.getByText("2 tool calls").closest("button")!;
const head = screen.getByText(/^2 tool calls/).closest("button")!;
expect(head.getAttribute("aria-expanded")).toBe("false");
// The final answer stays outside the group; the intermediate text is in it.
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
@@ -354,6 +354,8 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
key={item.id}
id={item.id}
toolCount={item.group.toolCount}
lastToolLabel={item.group.lastToolLabel}
lastToolArg={item.group.lastToolArg}
expanded={expandedGroups.has(item.id)}
onExpandedChange={(expanded) => setGroupExpanded(item.id, expanded)}
>
+12 -4
View File
@@ -1,19 +1,27 @@
// A turn's collapsed activity block: two or more tool rows after the same
// `User` row, plus the narration absorbed between them. The header counts tool
// rows only (docs/STRUCTURED-VIEW.md); opening it reveals tools and text in
// chronological order.
// rows only (docs/STRUCTURED-VIEW.md) and shows the last/current tool label so
// the operator sees activity at a glance without expanding. Opening it reveals
// tools and text in chronological order.
import type { ReactNode } from "react";
export interface ToolGroupProps {
id: string;
toolCount: number;
/** Label of the last (or running) tool, e.g. "Execute". */
lastToolLabel: string;
/** Argument of the last tool, e.g. "`git status`". */
lastToolArg: string;
expanded: boolean;
onExpandedChange(expanded: boolean): void;
children: ReactNode;
}
export function ToolGroup({ id, toolCount, expanded, onExpandedChange, children }: ToolGroupProps) {
export function ToolGroup({ id, toolCount, lastToolLabel, lastToolArg, expanded, onExpandedChange, children }: ToolGroupProps) {
const summary = lastToolArg
? `${toolCount} tool calls · ${lastToolLabel} \u2018${lastToolArg}\u2019`
: `${toolCount} tool calls`;
return (
<div className="st-tool-group" data-tool-group-id={id}>
<button
@@ -25,7 +33,7 @@ export function ToolGroup({ id, toolCount, expanded, onExpandedChange, children
<span className="st-tool-group-glyph" aria-hidden="true">
›
</span>
<span className="st-tool-group-title">{`${toolCount} tool calls`}</span>
<span className="st-tool-group-title">{summary}</span>
</button>
<div
className={
@@ -151,6 +151,33 @@ describe("activity grouping", () => {
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1", "thought:2"]);
});
it("keeps the newest agent_text outside while the turn is still live", () => {
const rows = [
text("user:1", "user", "go"),
tool("tool:1"),
text("agent:1", "agent_text", "working on it"),
tool("tool:2"),
text("agent:2", "agent_text", "almost done"),
];
const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!;
// "almost done" stays outside as visible narration while the turn is live
expect(live.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
const ended = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
// Once ended, the final agent_text stays outside as the answer
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
});
it("tracks the last tool's label and argument for the collapsed header", () => {
const rows = [
text("user:1", "user", "go"),
row("tool:1", "tool_call", { title: "Read(src/main.ts)", status: "completed" }),
row("tool:2", "tool_call", { title: "Execute(git status)", kind: "execute", status: "completed" }),
];
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
expect(group.lastToolLabel).toBe("Execute");
expect(group.lastToolArg).toBe("git status");
});
it("absorbs a resolved approval, never the pending one", () => {
const rows = [
text("user:1", "user", "go"),
+29 -9
View File
@@ -148,6 +148,9 @@ export interface ActivityGroup {
toolCount: number;
/** Tools plus absorbed intermediate text/approvals, in transcript order. */
rows: TranscriptRow[];
/** `splitToolTitle` of the last tool row — the collapsed header shows it. */
lastToolLabel: string;
lastToolArg: string;
}
/**
@@ -158,6 +161,10 @@ export interface ActivityGroup {
* the currently pending one) are absorbed the same way. The *last* agent text
* with no subsequent tool stays standalone (the prominent final answer).
*
* While a turn is still live, the most recent text or thought row trailing the
* group also stays outside as visible narration — the operator always sees the
* agent's latest reasoning without opening the group.
*
* Returns groups keyed by `turn:<userId>`.
*/
export function activityGroupsByTurn(
@@ -197,23 +204,36 @@ export function activityGroupsByTurn(
continue;
}
if (row.kind === "tool_call" || row.kind === "tool_diff") {
const group = groups.get(currentGid) ?? { toolCount: count, rows: [] };
const group = groups.get(currentGid) ?? { toolCount: count, rows: [], lastToolLabel: "", lastToolArg: "" };
// Flush any pending rows — a tool follows, so they're intermediate.
group.rows.push(...pending);
pending = [];
group.rows.push(row);
const tool = (row.data ?? {}) as { title?: string; name?: string | null; kind?: string };
const { label, arg } = splitToolTitle(tool.title, tool.name ?? undefined, tool.kind);
group.lastToolLabel = label;
group.lastToolArg = arg;
groups.set(currentGid, group);
}
}
// 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.
// While the turn is still live the most recent one stays outside as visible
// narration (docs/STRUCTURED-VIEW.md), and is absorbed when the turn ends.
// Trailing text/thought rows after the last tool in this turn.
// While the turn is still live, the newest one stays outside as visible
// narration — the operator always sees the agent's latest reasoning.
// A final agent_text (the "answer") stays outside regardless.
const lastGroup = groups.get(currentGid);
if (lastGroup && pending.length > 0 && pending.every((row) => row.kind === "agent_thought")) {
const absorb = turnActive ? pending.slice(0, -1) : pending;
lastGroup.rows.push(...absorb);
if (lastGroup && pending.length > 0) {
const allThoughts = pending.every((row) => row.kind === "agent_thought");
if (allThoughts) {
// Pure thoughts: absorb all when done, keep the newest outside while live.
const absorb = turnActive ? pending.slice(0, -1) : pending;
lastGroup.rows.push(...absorb);
} else if (turnActive) {
// Mixed or agent_text trailing: keep the very last row outside while live,
// absorb the rest.
lastGroup.rows.push(...pending.slice(0, -1));
}
// When the turn ends with agent_text last, nothing extra is absorbed — the
// text stays outside as the final answer.
}
return groups;
+4 -1
View File
@@ -1093,7 +1093,9 @@
position: relative;
width: 40px;
padding: 0;
border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
border-color: transparent;
background: transparent;
box-shadow: none;
color: var(--danger);
}
@@ -1287,6 +1289,7 @@
border-color: transparent;
background: transparent;
color: var(--muted);
align-self: center;
}
.structured-attach:hover {
+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);
}, []);
+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 />);
}
+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));
}