Compare commits
19
Commits
@@ -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:*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ on:
|
|||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
|||||||
@@ -15,3 +15,6 @@ worktrees/
|
|||||||
|
|
||||||
# `nix run .#dev` scratch state (tmux socket)
|
# `nix run .#dev` scratch state (tmux socket)
|
||||||
/.dev/
|
/.dev/
|
||||||
|
|
||||||
|
# Per-machine Claude Code tool permissions
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
@@ -2916,17 +2916,16 @@ async fn mark_session_dead_with_worktree_settlement(
|
|||||||
// Release anyone attached; the scrollback survives for a later
|
// Release anyone attached; the scrollback survives for a later
|
||||||
// attach.
|
// attach.
|
||||||
record.bridge.seal();
|
record.bridge.seal();
|
||||||
let session = record.session.clone();
|
|
||||||
let worktree = record
|
let worktree = record
|
||||||
.worktree_base_repo
|
.worktree_base_repo
|
||||||
.clone()
|
.clone()
|
||||||
.zip(record.worktree_path.clone());
|
.zip(record.worktree_path.clone());
|
||||||
Some((session, worktree))
|
Some(worktree)
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let Some((mut session, worktree)) = flipped else {
|
let Some(worktree) = flipped else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let stream = inner
|
let stream = inner
|
||||||
@@ -2943,9 +2942,6 @@ async fn mark_session_dead_with_worktree_settlement(
|
|||||||
if settle_worktree {
|
if settle_worktree {
|
||||||
if let Some(wt) = &worktree {
|
if let Some(wt) = &worktree {
|
||||||
if settle_worktree_on_exit(inner, id, wt).await {
|
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;
|
let mut sessions = inner.sessions.lock().await;
|
||||||
if let Some(record) = sessions.get_mut(&id) {
|
if let Some(record) = sessions.get_mut(&id) {
|
||||||
record.session.worktree = None;
|
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
|
// 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;
|
let mut sessions = inner.sessions.lock().await;
|
||||||
if let Some(record) = sessions.get_mut(&id) {
|
let Some(record) = sessions.get_mut(&id) else {
|
||||||
record.settled = true;
|
return false;
|
||||||
}
|
};
|
||||||
}
|
record.settled = true;
|
||||||
|
record.session.clone()
|
||||||
|
};
|
||||||
|
|
||||||
let _ = inner.sidecar.write(&session);
|
let _ = inner.sidecar.write(&session);
|
||||||
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ use common::TmuxFixture;
|
|||||||
use maestro_daemon::sidecar::SidecarStore;
|
use maestro_daemon::sidecar::SidecarStore;
|
||||||
use maestro_daemon::worktree::WorktreeManager;
|
use maestro_daemon::worktree::WorktreeManager;
|
||||||
use maestro_daemon::SessionManager;
|
use maestro_daemon::SessionManager;
|
||||||
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource};
|
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource, SessionStatus};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
struct Fixture {
|
struct Fixture {
|
||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
sidecar: SidecarStore,
|
sidecar: SidecarStore,
|
||||||
_tmux: TmuxFixture,
|
tmux: TmuxFixture,
|
||||||
_state_dir: TempDir,
|
_state_dir: TempDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ fn fixture() -> Fixture {
|
|||||||
Fixture {
|
Fixture {
|
||||||
manager,
|
manager,
|
||||||
sidecar,
|
sidecar,
|
||||||
_tmux: tmux,
|
tmux,
|
||||||
_state_dir: state_dir,
|
_state_dir: state_dir,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,3 +171,37 @@ async fn a_blank_name_changes_nothing() {
|
|||||||
let live = fx.sidecar.read(id).unwrap().unwrap();
|
let live = fx.sidecar.read(id).unwrap().unwrap();
|
||||||
assert_eq!(display::name_source(&live), NameSource::Generated);
|
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();
|
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
html.contains(r#"id="app""#),
|
html.contains(r#"id="root""#),
|
||||||
"served index is missing the SPA mount point; got:\n{html}"
|
"served index is missing the SPA mount point; got:\n{html}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!html.contains("maestro is running"),
|
!html.contains("maestro is running"),
|
||||||
"served index is the placeholder, not the built bundle"
|
"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>`
|
// 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
|
// bundle, whereas the un-built source references `/src/main.ts`. This catches
|
||||||
// both an empty/placeholder embed and the staleness case.
|
// both an empty/placeholder embed and the staleness case.
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ async fn root_serves_the_spa_shell() {
|
|||||||
.map(|v| v.starts_with("text/html")),
|
.map(|v| v.starts_with("text/html")),
|
||||||
Some(true)
|
Some(true)
|
||||||
);
|
);
|
||||||
assert!(resp.text().await.unwrap().contains(r#"id="app""#));
|
assert!(resp.text().await.unwrap().contains(r#"id="root""#));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -69,7 +69,7 @@ async fn client_side_route_falls_back_to_the_shell() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), 200);
|
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:
|
/// An unmatched API path must 404 rather than fall through to the SPA shell:
|
||||||
|
|||||||
@@ -194,12 +194,15 @@ choices, not daemon ones.
|
|||||||
appear *between* tool rows in the same turn are absorbed into the group
|
appear *between* tool rows in the same turn are absorbed into the group
|
||||||
(intermediate narration); the last `AgentText` with no subsequent tool stays
|
(intermediate narration); the last `AgentText` with no subsequent tool stays
|
||||||
outside as the prominent final answer. While a turn is still active
|
outside as the prominent final answer. While a turn is still active
|
||||||
(responding or thinking), the most recent intermediate `AgentText` or
|
(responding or thinking), the most recent trailing text or thought row is
|
||||||
`AgentThought` in the group is kept outside as visible narration — replaced
|
kept outside as visible narration — the operator always sees the agent's
|
||||||
by the next text row when one arrives, and absorbed into the group when the
|
latest reasoning without opening the group. It is replaced by the next text
|
||||||
turn ends. The header still reads "N tool calls" (counting only tool rows).
|
row when one arrives, and absorbed into the group when the turn ends (unless
|
||||||
Opening the group reveals tools and intermediate text in chronological
|
it is `AgentText`, which stays outside as the final answer). The collapsed
|
||||||
order, with the text rendered in a subdued style.
|
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
|
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
||||||
copy their complete source text when clicked or keyboard-activated.
|
copy their complete source text when clicked or keyboard-activated.
|
||||||
- The web client consumes `available_commands_update` notices as the live
|
- The web client consumes `available_commands_update` notices as the live
|
||||||
|
|||||||
@@ -148,7 +148,7 @@
|
|||||||
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
||||||
inherit (finalAttrs) pname version src;
|
inherit (finalAttrs) pname version src;
|
||||||
fetcherVersion = 3;
|
fetcherVersion = 3;
|
||||||
hash = "sha256-AzF/rrhqbvl613hNsC18YSylBSVxtatg7fEiUEF1u2o=";
|
hash = "sha256-FEWOAtSFvOWHeCNR0RK8JQ/ssW31qmShu1YD2GuqioQ=";
|
||||||
};
|
};
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ function Shell() {
|
|||||||
activeId={focusedSessionId}
|
activeId={focusedSessionId}
|
||||||
openIds={openIds}
|
openIds={openIds}
|
||||||
previewId={previewedSessionId}
|
previewId={previewedSessionId}
|
||||||
|
linkStore={linkStore}
|
||||||
onSelect={(id) => focusSession(id)}
|
onSelect={(id) => focusSession(id)}
|
||||||
onPreview={preview}
|
onPreview={preview}
|
||||||
onSpawn={openPlainSpawn}
|
onSpawn={openPlainSpawn}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
import { CommandSet, defineCommands, keyCaps } from "../../commands.js";
|
import { CommandSet, defineCommands, keyCaps } from "../../commands.js";
|
||||||
|
import { TOGGLE_HELP_EVENT } from "../../uiEvents.js";
|
||||||
import { groupedCommands } from "./model.js";
|
import { groupedCommands } from "./model.js";
|
||||||
|
|
||||||
export function HelpOverlay() {
|
export function HelpOverlay() {
|
||||||
@@ -18,8 +19,13 @@ export function HelpOverlay() {
|
|||||||
if (open && overlayKeys.handle(e)) return;
|
if (open && overlayKeys.handle(e)) return;
|
||||||
helpKeys.handle(e);
|
helpKeys.handle(e);
|
||||||
};
|
};
|
||||||
|
const onToggle = () => setOpen((o) => !o);
|
||||||
window.addEventListener("keydown", onKeydown);
|
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]);
|
}, [open]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|||||||
@@ -71,4 +71,33 @@ describe("KillDialog", () => {
|
|||||||
await waitFor(() => expect(kill.disabled).toBe(false));
|
await waitFor(() => expect(kill.disabled).toBe(false));
|
||||||
expect(screen.getByText("Failed to kill: daemon unreachable")).not.toBeNull();
|
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.
|
// asked for the kill, so Enter confirms it.
|
||||||
useEffect(() => confirmRef.current?.focus(), []);
|
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 = () => {
|
const handleConfirm = () => {
|
||||||
setPending(true);
|
setPending(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function SessionCard(props: SessionCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<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"
|
running ? "" : "opacity-60"
|
||||||
}`}
|
}`}
|
||||||
href={`#/attach/${encodeURIComponent(s.id)}`}
|
href={`#/attach/${encodeURIComponent(s.id)}`}
|
||||||
@@ -84,7 +84,7 @@ export function SessionCard(props: SessionCardProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ActivityRail activity={s.activity} />
|
<ActivityRail activity={s.activity} />
|
||||||
<span className="flex min-w-0 items-center gap-sp-2">
|
<span className="card-line1">
|
||||||
{renaming ? (
|
{renaming ? (
|
||||||
<RenameField
|
<RenameField
|
||||||
session={s}
|
session={s}
|
||||||
@@ -93,17 +93,17 @@ export function SessionCard(props: SessionCardProps) {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<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"
|
isProvisionalName(s) ? "text-muted" : "text-ink"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="flex flex-none items-center gap-[2px]">
|
<span className="leaf-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="leaf-clone text-faint hover:text-ink"
|
className="leaf-act leaf-clone"
|
||||||
title="Spawn similar"
|
title="Spawn similar"
|
||||||
aria-label={`Spawn similar to ${name}`}
|
aria-label={`Spawn similar to ${name}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -116,7 +116,7 @@ export function SessionCard(props: SessionCardProps) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="leaf-kill text-faint hover:text-danger"
|
className="leaf-act leaf-kill"
|
||||||
title="Kill session"
|
title="Kill session"
|
||||||
aria-label={`Kill ${name}`}
|
aria-label={`Kill ${name}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -2,9 +2,15 @@
|
|||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { ThemeProvider } from "../../contexts/ThemeContext.js";
|
||||||
import type { Session } from "../../types.js";
|
import type { Session } from "../../types.js";
|
||||||
import { Sidebar } from "./Sidebar.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(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
@@ -36,7 +42,7 @@ function tree(): HTMLElement {
|
|||||||
|
|
||||||
describe("SessionCard layout", () => {
|
describe("SessionCard layout", () => {
|
||||||
it("renders two lines: the name, then a meta line naming the program", () => {
|
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");
|
const row = card("alpha");
|
||||||
expect(row.textContent).toContain("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 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" });
|
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("fresh").querySelector(".activity-duration")).toBeNull();
|
||||||
expect(card("held").querySelector(".activity-duration")!.textContent).toBe("4m");
|
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", () => {
|
it("paints a rail for every state, blinking on AwaitingInput and flashing on Error", () => {
|
||||||
render(
|
render(
|
||||||
<Sidebar
|
<Wrapper>
|
||||||
sessions={[
|
<Sidebar
|
||||||
session("idle"),
|
sessions={[
|
||||||
session("await", { activity: "AwaitingInput" }),
|
session("idle"),
|
||||||
session("err", { activity: "Error" }),
|
session("await", { activity: "AwaitingInput" }),
|
||||||
]}
|
session("err", { activity: "Error" }),
|
||||||
/>,
|
]}
|
||||||
|
/>
|
||||||
|
</Wrapper>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const rail = (id: string) => card(id).querySelector<HTMLElement>("[data-activity]")!;
|
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", () => {
|
it("renders blocked work as diff notation with the spelled-out tooltip", () => {
|
||||||
render(
|
render(
|
||||||
<Sidebar
|
<Wrapper>
|
||||||
sessions={[
|
<Sidebar
|
||||||
session("alpha", {
|
sessions={[
|
||||||
blocked_work: { uncommitted_files: 2, insertions: 124, deletions: 18, untracked_files: 3 },
|
session("alpha", {
|
||||||
}),
|
blocked_work: { uncommitted_files: 2, insertions: 124, deletions: 18, untracked_files: 3 },
|
||||||
]}
|
}),
|
||||||
/>,
|
]}
|
||||||
|
/>
|
||||||
|
</Wrapper>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const blocked = card("alpha").querySelector<HTMLElement>(".leaf-blocked")!;
|
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", () => {
|
it("dims a provisional name and leaves a settled one alone", () => {
|
||||||
render(
|
render(
|
||||||
<Sidebar
|
<Wrapper>
|
||||||
sessions={[
|
<Sidebar
|
||||||
session("gen", { metadata: { name: "gen", name_source: "Generated" } }),
|
sessions={[
|
||||||
session("user", { metadata: { name: "user", name_source: "User" } }),
|
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();
|
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" })];
|
const two = [session("a", { cwd: "/one" }), session("b", { cwd: "/two" })];
|
||||||
|
|
||||||
it("j and k step the highlight through the cards", () => {
|
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" });
|
fireEvent.keyDown(tree(), { key: "j" });
|
||||||
expect(card("a").dataset.highlighted).toBe("true");
|
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", () => {
|
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"]')!;
|
const header = document.querySelector<HTMLElement>('[data-nav-key="folder:one"]')!;
|
||||||
fireEvent.click(header);
|
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", () => {
|
it("Enter on a card opens it, and Tab hands the keyboard to the pane", () => {
|
||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
const onFocusPane = 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: "j" });
|
||||||
fireEvent.keyDown(tree(), { key: "Enter" });
|
fireEvent.keyDown(tree(), { key: "Enter" });
|
||||||
@@ -154,7 +166,7 @@ describe("keyboard walk", () => {
|
|||||||
|
|
||||||
it("previews the session the walk lands on", () => {
|
it("previews the session the walk lands on", () => {
|
||||||
const onPreview = vi.fn();
|
const onPreview = vi.fn();
|
||||||
render(<Sidebar sessions={two} onPreview={onPreview} />);
|
render(<Wrapper><Sidebar sessions={two} onPreview={onPreview} /></Wrapper>);
|
||||||
|
|
||||||
fireEvent.keyDown(tree(), { key: "j" });
|
fireEvent.keyDown(tree(), { key: "j" });
|
||||||
expect(onPreview).toHaveBeenCalledWith("a");
|
expect(onPreview).toHaveBeenCalledWith("a");
|
||||||
@@ -162,7 +174,7 @@ describe("keyboard walk", () => {
|
|||||||
|
|
||||||
it("x asks to kill the highlighted session and r renames it", () => {
|
it("x asks to kill the highlighted session and r renames it", () => {
|
||||||
const onKill = vi.fn();
|
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: "j" });
|
||||||
fireEvent.keyDown(tree(), { key: "x" });
|
fireEvent.keyDown(tree(), { key: "x" });
|
||||||
@@ -178,7 +190,9 @@ describe("context menu", () => {
|
|||||||
const onSpawnSimilar = vi.fn();
|
const onSpawnSimilar = vi.fn();
|
||||||
const onKill = vi.fn();
|
const onKill = vi.fn();
|
||||||
render(
|
render(
|
||||||
<Sidebar sessions={[session("alpha")]} onSpawnSimilar={onSpawnSimilar} onKill={onKill} />,
|
<Wrapper>
|
||||||
|
<Sidebar sessions={[session("alpha")]} onSpawnSimilar={onSpawnSimilar} onKill={onKill} />
|
||||||
|
</Wrapper>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.contextMenu(card("alpha"));
|
fireEvent.contextMenu(card("alpha"));
|
||||||
@@ -191,7 +205,7 @@ describe("context menu", () => {
|
|||||||
|
|
||||||
it("renames in place: Enter commits the field, Escape cancels", () => {
|
it("renames in place: Enter commits the field, Escape cancels", () => {
|
||||||
const onRename = vi.fn();
|
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.contextMenu(card("alpha"));
|
||||||
fireEvent.click(screen.getByText("Rename…"));
|
fireEvent.click(screen.getByText("Rename…"));
|
||||||
@@ -207,7 +221,7 @@ describe("context menu", () => {
|
|||||||
|
|
||||||
describe("search", () => {
|
describe("search", () => {
|
||||||
it("filters the tree down to matching sessions", () => {
|
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" } });
|
fireEvent.change(screen.getByLabelText("Search sessions"), { target: { value: "beta" } });
|
||||||
|
|
||||||
@@ -219,7 +233,7 @@ describe("search", () => {
|
|||||||
describe("empty state", () => {
|
describe("empty state", () => {
|
||||||
it("offers a spawn when there are no sessions at all", () => {
|
it("offers a spawn when there are no sessions at all", () => {
|
||||||
const onSpawn = vi.fn();
|
const onSpawn = vi.fn();
|
||||||
render(<Sidebar sessions={[]} onSpawn={onSpawn} />);
|
render(<Wrapper><Sidebar sessions={[]} onSpawn={onSpawn} /></Wrapper>);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("+ Spawn session"));
|
fireEvent.click(screen.getByText("+ Spawn session"));
|
||||||
expect(onSpawn).toHaveBeenCalled();
|
expect(onSpawn).toHaveBeenCalled();
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import type { CatalogStore, CatalogStoreApi } from "../../stores/catalogStore.js
|
|||||||
import type { InTheWay, Session } from "../../types.js";
|
import type { InTheWay, Session } from "../../types.js";
|
||||||
import { sessionBlockers, sessionName } from "../../types.js";
|
import { sessionBlockers, sessionName } from "../../types.js";
|
||||||
import { buildTree, type TreeNode } from "../../tree.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 { 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 { FolderNode } from "./FolderNode.js";
|
||||||
import { KillDialog } from "../dialogs/KillDialog.js";
|
import { KillDialog } from "../dialogs/KillDialog.js";
|
||||||
import { SessionCard } from "./SessionCard.js";
|
import { SessionCard } from "./SessionCard.js";
|
||||||
@@ -43,6 +46,8 @@ export interface SidebarProps {
|
|||||||
/** Tab: hand the keyboard back to the pane. */
|
/** Tab: hand the keyboard back to the pane. */
|
||||||
onFocusPane?(): void;
|
onFocusPane?(): void;
|
||||||
onHelp?(): 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). */
|
/** Overrides the built-in `DELETE /api/sessions/:id` flow (tests, kit). */
|
||||||
onKill?(session: Session): void;
|
onKill?(session: Session): void;
|
||||||
/** Overrides the built-in `PATCH /api/sessions/:id` (tests, kit). */
|
/** 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>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -396,3 +405,84 @@ const EMPTY_STORE = {
|
|||||||
setState: () => {},
|
setState: () => {},
|
||||||
subscribe: () => () => {},
|
subscribe: () => () => {},
|
||||||
} as unknown as CatalogStoreApi;
|
} 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,9 +104,11 @@ export interface SpawnFormProps {
|
|||||||
/** Fired whenever any value changes, once the form's initial seed settles. */
|
/** Fired whenever any value changes, once the form's initial seed settles. */
|
||||||
onChange?(values: SpawnFormValues): void;
|
onChange?(values: SpawnFormValues): void;
|
||||||
onSuccess(session: Session): 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 prefill = useRef(seed ?? {}).current;
|
||||||
|
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
@@ -240,7 +242,7 @@ export function SpawnForm({ seed, onChange, onSuccess }: SpawnFormProps) {
|
|||||||
|
|
||||||
const [advancedCollapsed, setAdvancedCollapsed] = useState(true);
|
const [advancedCollapsed, setAdvancedCollapsed] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [, setSubmitting] = useState(false);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
const probeTokenRef = useRef(0);
|
const probeTokenRef = useRef(0);
|
||||||
@@ -620,6 +622,7 @@ export function SpawnForm({ seed, onChange, onSuccess }: SpawnFormProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
|
id="spawn-form"
|
||||||
ref={formRef}
|
ref={formRef}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onKeyDown={onFormKeyDown}
|
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]"}`}>
|
<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}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end">
|
{!externalSubmit && (
|
||||||
<button
|
<div className="flex justify-end">
|
||||||
type="submit"
|
<button
|
||||||
disabled={submitting}
|
type="submit"
|
||||||
className="rounded-r-1 bg-accent px-sp-4 py-sp-2 text-fs-13 font-semibold text-accent-ink disabled:opacity-60"
|
className="btn btn-primary rounded-r-1 px-sp-4 py-sp-1 text-fs-13 font-semibold"
|
||||||
>
|
>
|
||||||
Spawn
|
Spawn
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,16 +64,23 @@ export function SpawnModal({ seed, onSuccess, onClose }: SpawnModalProps) {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div ref={filterInputRef} className="min-h-0 flex-1 overflow-auto">
|
<div ref={filterInputRef} className="min-h-0 flex-1 overflow-auto">
|
||||||
<SpawnForm seed={seed} onSuccess={handleSuccess} />
|
<SpawnForm seed={seed} onSuccess={handleSuccess} externalSubmit />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-sp-2 border-t border-border p-sp-3">
|
<div className="flex justify-end gap-sp-2 border-t border-border p-sp-3">
|
||||||
<button
|
<button
|
||||||
type="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}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ describe("tool rows and activity groups", () => {
|
|||||||
chunk("agent:2", "agent_text", "the answer"),
|
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");
|
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||||||
// The final answer stays outside the group; the intermediate text is in it.
|
// 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);
|
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}
|
key={item.id}
|
||||||
id={item.id}
|
id={item.id}
|
||||||
toolCount={item.group.toolCount}
|
toolCount={item.group.toolCount}
|
||||||
|
lastToolLabel={item.group.lastToolLabel}
|
||||||
|
lastToolArg={item.group.lastToolArg}
|
||||||
expanded={expandedGroups.has(item.id)}
|
expanded={expandedGroups.has(item.id)}
|
||||||
onExpandedChange={(expanded) => setGroupExpanded(item.id, expanded)}
|
onExpandedChange={(expanded) => setGroupExpanded(item.id, expanded)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
// A turn's collapsed activity block: two or more tool rows after the same
|
// 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
|
// `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
|
// rows only (docs/STRUCTURED-VIEW.md) and shows the last/current tool label so
|
||||||
// chronological order.
|
// the operator sees activity at a glance without expanding. Opening it reveals
|
||||||
|
// tools and text in chronological order.
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export interface ToolGroupProps {
|
export interface ToolGroupProps {
|
||||||
id: string;
|
id: string;
|
||||||
toolCount: number;
|
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;
|
expanded: boolean;
|
||||||
onExpandedChange(expanded: boolean): void;
|
onExpandedChange(expanded: boolean): void;
|
||||||
children: ReactNode;
|
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 (
|
return (
|
||||||
<div className="st-tool-group" data-tool-group-id={id}>
|
<div className="st-tool-group" data-tool-group-id={id}>
|
||||||
<button
|
<button
|
||||||
@@ -25,7 +33,7 @@ export function ToolGroup({ id, toolCount, expanded, onExpandedChange, children
|
|||||||
<span className="st-tool-group-glyph" aria-hidden="true">
|
<span className="st-tool-group-glyph" aria-hidden="true">
|
||||||
›
|
›
|
||||||
</span>
|
</span>
|
||||||
<span className="st-tool-group-title">{`${toolCount} tool calls`}</span>
|
<span className="st-tool-group-title">{summary}</span>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
|
|||||||
@@ -151,6 +151,33 @@ describe("activity grouping", () => {
|
|||||||
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1", "thought:2"]);
|
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", () => {
|
it("absorbs a resolved approval, never the pending one", () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
text("user:1", "user", "go"),
|
text("user:1", "user", "go"),
|
||||||
|
|||||||
@@ -148,6 +148,9 @@ export interface ActivityGroup {
|
|||||||
toolCount: number;
|
toolCount: number;
|
||||||
/** Tools plus absorbed intermediate text/approvals, in transcript order. */
|
/** Tools plus absorbed intermediate text/approvals, in transcript order. */
|
||||||
rows: TranscriptRow[];
|
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
|
* the currently pending one) are absorbed the same way. The *last* agent text
|
||||||
* with no subsequent tool stays standalone (the prominent final answer).
|
* 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>`.
|
* Returns groups keyed by `turn:<userId>`.
|
||||||
*/
|
*/
|
||||||
export function activityGroupsByTurn(
|
export function activityGroupsByTurn(
|
||||||
@@ -197,23 +204,36 @@ export function activityGroupsByTurn(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (row.kind === "tool_call" || row.kind === "tool_diff") {
|
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.
|
// Flush any pending rows — a tool follows, so they're intermediate.
|
||||||
group.rows.push(...pending);
|
group.rows.push(...pending);
|
||||||
pending = [];
|
pending = [];
|
||||||
group.rows.push(row);
|
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);
|
groups.set(currentGid, group);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A final agent text is the answer, so it stays outside. Thought rows are
|
// Trailing text/thought rows after the last tool in this turn.
|
||||||
// working commentary, though: when a turn already has a disclosure they
|
// While the turn is still live, the newest one stays outside as visible
|
||||||
// belong with its activity even if the next tool call has not arrived yet.
|
// narration — the operator always sees the agent's latest reasoning.
|
||||||
// While the turn is still live the most recent one stays outside as visible
|
// A final agent_text (the "answer") stays outside regardless.
|
||||||
// narration (docs/STRUCTURED-VIEW.md), and is absorbed when the turn ends.
|
|
||||||
const lastGroup = groups.get(currentGid);
|
const lastGroup = groups.get(currentGid);
|
||||||
if (lastGroup && pending.length > 0 && pending.every((row) => row.kind === "agent_thought")) {
|
if (lastGroup && pending.length > 0) {
|
||||||
const absorb = turnActive ? pending.slice(0, -1) : pending;
|
const allThoughts = pending.every((row) => row.kind === "agent_thought");
|
||||||
lastGroup.rows.push(...absorb);
|
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;
|
return groups;
|
||||||
|
|||||||
@@ -1093,7 +1093,9 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1287,6 +1289,7 @@
|
|||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.structured-attach:hover {
|
.structured-attach:hover {
|
||||||
|
|||||||
@@ -26,15 +26,23 @@ export type Palette = (typeof PALETTES)[number];
|
|||||||
const PALETTE_KEY = "maestro-palette";
|
const PALETTE_KEY = "maestro-palette";
|
||||||
|
|
||||||
function storedTheme(): Theme | null {
|
function storedTheme(): Theme | null {
|
||||||
const v = localStorage.getItem(THEME_KEY);
|
try {
|
||||||
return v === "light" || v === "dark" ? v : null;
|
const v = localStorage.getItem(THEME_KEY);
|
||||||
|
return v === "light" || v === "dark" ? v : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function storedPalette(): Palette | null {
|
function storedPalette(): Palette | null {
|
||||||
const v = localStorage.getItem(PALETTE_KEY);
|
try {
|
||||||
return (PALETTES as readonly string[]).includes(v ?? "")
|
const v = localStorage.getItem(PALETTE_KEY);
|
||||||
? (v as Palette)
|
return (PALETTES as readonly string[]).includes(v ?? "")
|
||||||
: null;
|
? (v as Palette)
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function systemPrefersLight(): boolean {
|
function systemPrefersLight(): boolean {
|
||||||
@@ -89,7 +97,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [palette]);
|
}, [palette]);
|
||||||
|
|
||||||
const setTheme = useCallback((t: Theme) => {
|
const setTheme = useCallback((t: Theme) => {
|
||||||
localStorage.setItem(THEME_KEY, t);
|
try { localStorage.setItem(THEME_KEY, t); } catch { /* test env */ }
|
||||||
setPinnedTheme(t);
|
setPinnedTheme(t);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -98,7 +106,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [theme, setTheme]);
|
}, [theme, setTheme]);
|
||||||
|
|
||||||
const setPalette = useCallback((p: Palette) => {
|
const setPalette = useCallback((p: Palette) => {
|
||||||
localStorage.setItem(PALETTE_KEY, p);
|
try { localStorage.setItem(PALETTE_KEY, p); } catch { /* test env */ }
|
||||||
setPaletteState(p);
|
setPaletteState(p);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -56,3 +56,11 @@ export const TOGGLE_DRAWER_EVENT = "maestro-toggle-drawer";
|
|||||||
export function requestToggleDrawer(): void {
|
export function requestToggleDrawer(): void {
|
||||||
document.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT));
|
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));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user