Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4738233580 | ||
|
|
cb25320ea0 | ||
|
|
ff52dd9812 | ||
|
|
f3a5cad789 | ||
|
|
563a35d2ec | ||
|
|
cd2184b128 | ||
|
|
1b2e5f5f05 | ||
|
|
2c8956e634 | ||
|
|
f2c0882c36 | ||
|
|
c170cd9d60 | ||
|
|
ebf943bf30 | ||
|
|
645aa2dea0 | ||
|
|
3a2513c78d | ||
|
|
142e9a4870 | ||
|
|
e5c17606d0 |
@@ -1,25 +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:*)",
|
||||
"Bash(source:*)",
|
||||
"Bash(git remote get-url:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git -C /home/naps62/tea/maestro merge grazioso-2 --no-edit)",
|
||||
"Bash(git -C /home/naps62/tea/maestro push)",
|
||||
"Bash(curl:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -15,3 +15,6 @@ worktrees/
|
||||
|
||||
# `nix run .#dev` scratch state (tmux socket)
|
||||
/.dev/
|
||||
|
||||
# Per-machine Claude Code tool permissions
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ command. `<duration>` follows in the rail's colour when shown. There is no
|
||||
state word and no second badge: the rail exists so the state is still legible
|
||||
when a row is dimmed or the terminal renders the duration in the default fg.
|
||||
|
||||
In the **web client** the rail is a 5px bar pinned to the row's own left
|
||||
In the **web client** the rail is a 7px bar pinned to the row's own left
|
||||
edge, square corners, spanning the full card height edge to edge — it
|
||||
shifts outward with nesting depth along with the row's own padding, since
|
||||
it is positioned relative to the row. It carries motion only for states that
|
||||
|
||||
+7
-13
@@ -190,19 +190,13 @@ choices, not daemon ones.
|
||||
from the transcript; the TUI may show the current plan in its status line,
|
||||
while the web client deliberately has no stats or plan rail.
|
||||
- The web client groups two or more tool rows after the same `User` row into
|
||||
one collapsed turn activity block. `AgentText` and `AgentThought` rows that
|
||||
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 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.
|
||||
one collapsed turn activity block. `AgentText` and `AgentThought` rows stay
|
||||
outside the group as standalone transcript items — the operator sees all
|
||||
reasoning without expanding. Resolved approval rows between tools are
|
||||
absorbed into the group. 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 tool rows in chronological order.
|
||||
- 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
@@ -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.
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -314,6 +314,7 @@ function Shell() {
|
||||
activeId={focusedSessionId}
|
||||
openIds={openIds}
|
||||
previewId={previewedSessionId}
|
||||
linkStore={linkStore}
|
||||
onSelect={(id) => focusSession(id)}
|
||||
onPreview={preview}
|
||||
onSpawn={openPlainSpawn}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -105,7 +105,7 @@ describe("MultiView", () => {
|
||||
expect(screen.getByText("Tab 1 · 1")).toBeTruthy();
|
||||
const pane = document.querySelector('[data-session-id="s9"]');
|
||||
expect(pane).toBeTruthy();
|
||||
expect(pane!.querySelector(".bg-preview-tint")).toBeTruthy();
|
||||
expect(pane!.classList.contains("is-preview")).toBe(true);
|
||||
expect(screen.getByText("preview")).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -188,8 +188,9 @@ function Pane({
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"mv-pane flex h-full min-h-0 min-w-0 flex-col border " +
|
||||
(isFocused && !isPreview ? "border-accent" : "border-border-strong")
|
||||
"mv-pane flex h-full min-h-0 min-w-0 flex-col" +
|
||||
(isFocused ? " is-focused" : "") +
|
||||
(isPreview ? " is-preview" : "")
|
||||
}
|
||||
data-leaf-id={leaf.id}
|
||||
data-session-id={leaf.sessionId ?? undefined}
|
||||
@@ -201,10 +202,7 @@ function Pane({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"mv-pane-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12 " +
|
||||
(isPreview ? "bg-preview-tint text-preview" : "bg-surface")
|
||||
}
|
||||
className="mv-pane-header attach-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12"
|
||||
>
|
||||
{leaf.sessionId ? (
|
||||
<PaneTitle sessionId={leaf.sessionId} session={session} />
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface PaneTitleProps {
|
||||
export function PaneTitle({ sessionId, session, className }: PaneTitleProps) {
|
||||
if (!session) {
|
||||
return (
|
||||
<span className={className ?? "at-title"} title={sessionId}>
|
||||
<span className={className ?? "attach-title"} title={sessionId}>
|
||||
session <span className="sid">{sessionId.slice(0, 8)}</span>
|
||||
</span>
|
||||
);
|
||||
@@ -27,7 +27,7 @@ export function PaneTitle({ sessionId, session, className }: PaneTitleProps) {
|
||||
const icon = tool ? toolIconSvg(tool) : null;
|
||||
|
||||
return (
|
||||
<span className={className ?? "at-title"} title={`${name} · ${session.command} (${sessionId})`}>
|
||||
<span className={className ?? "attach-title"} title={`${name} · ${session.command} (${sessionId})`}>
|
||||
<span className="at-name">{name}</span>
|
||||
{showCommand && (
|
||||
<span className="at-cmd">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// docs/SIDEBAR.md: a 5px bar pinned to the row's own left edge, square
|
||||
// docs/SIDEBAR.md: a 7px bar pinned to the row's own left edge, square
|
||||
// corners, full card height. It paints for every card, Idle included, and it
|
||||
// carries the accessible name of the state so status is never colour-only.
|
||||
|
||||
@@ -37,7 +37,7 @@ export function ActivityRail({ activity }: { activity: ActivityState | string |
|
||||
const word = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
||||
return (
|
||||
<span
|
||||
className={`absolute left-0 top-0 bottom-0 w-[5px] ${RAIL_CLASS[slug] ?? RAIL_CLASS.idle}`}
|
||||
className={`absolute left-0 top-0 bottom-0 w-[7px] ${RAIL_CLASS[slug] ?? RAIL_CLASS.idle}`}
|
||||
data-activity={slug}
|
||||
role="img"
|
||||
title={word}
|
||||
|
||||
@@ -44,14 +44,17 @@ export interface SessionCardProps {
|
||||
onRenameCancel(): void;
|
||||
}
|
||||
|
||||
/** The fill ladder from docs/SIDEBAR.md "Which row is filled", furthest down
|
||||
* wins. Written as one ordered run so the precedence is the order it reads in. */
|
||||
function rowFill(p: SessionCardProps): string {
|
||||
if (p.active) return "bg-accent-fill";
|
||||
if (p.previewing) return "bg-preview-tint";
|
||||
if (p.open) return "bg-accent/[0.06]";
|
||||
if (p.highlighted) return "bg-preview-tint";
|
||||
return "";
|
||||
/** The fill ladder from docs/SIDEBAR.md "Which row is filled" — the CSS
|
||||
* cascade in styles.css carries the actual fills via `.is-active`, `.is-open`,
|
||||
* `.is-previewing` (all inside `:where()` so source order is the precedence).
|
||||
* We just apply the right class names here. */
|
||||
function rowClasses(p: SessionCardProps): string {
|
||||
const cls: string[] = [];
|
||||
if (p.active) cls.push("is-active");
|
||||
if (p.open) cls.push("is-open");
|
||||
if (p.previewing) cls.push("is-previewing");
|
||||
if (p.highlighted) cls.push("is-highlighted");
|
||||
return cls.join(" ");
|
||||
}
|
||||
|
||||
export function SessionCard(props: SessionCardProps) {
|
||||
@@ -62,7 +65,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 ${rowClasses(props)} ${
|
||||
running ? "" : "opacity-60"
|
||||
}`}
|
||||
href={`#/attach/${encodeURIComponent(s.id)}`}
|
||||
@@ -84,7 +87,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 +96,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 +119,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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -124,7 +124,7 @@ describe("activity grouping", () => {
|
||||
expect(activityGroupsByTurn(rows, null, false).size).toBe(0);
|
||||
});
|
||||
|
||||
it("absorbs intermediate narration but keeps the final answer outside", () => {
|
||||
it("keeps intermediate text outside the group as standalone items", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
tool("tool:1"),
|
||||
@@ -133,11 +133,11 @@ describe("activity grouping", () => {
|
||||
text("agent:2", "agent_text", "done"),
|
||||
];
|
||||
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
|
||||
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2"]);
|
||||
expect(group.toolCount).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps the newest thought outside while the turn is still live", () => {
|
||||
it("keeps thoughts outside the group as standalone items", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
tool("tool:1"),
|
||||
@@ -145,26 +145,8 @@ describe("activity grouping", () => {
|
||||
text("thought:1", "agent_thought", "hmm"),
|
||||
text("thought:2", "agent_thought", "aha"),
|
||||
];
|
||||
const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!;
|
||||
expect(live.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1"]);
|
||||
const ended = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||
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"]);
|
||||
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2"]);
|
||||
});
|
||||
|
||||
it("tracks the last tool's label and argument for the collapsed header", () => {
|
||||
|
||||
@@ -144,9 +144,10 @@ export function turnIsActive(state: ReducedState): boolean {
|
||||
}
|
||||
|
||||
export interface ActivityGroup {
|
||||
/** Header label count — tool rows only, never absorbed text. */
|
||||
/** Header label count — tool rows only. */
|
||||
toolCount: number;
|
||||
/** Tools plus absorbed intermediate text/approvals, in transcript order. */
|
||||
/** Tool rows and resolved approvals, in transcript order. Text/thought
|
||||
* rows stay outside the group as standalone transcript items. */
|
||||
rows: TranscriptRow[];
|
||||
/** `splitToolTitle` of the last tool row — the collapsed header shows it. */
|
||||
lastToolLabel: string;
|
||||
@@ -154,23 +155,16 @@ export interface ActivityGroup {
|
||||
}
|
||||
|
||||
/**
|
||||
* Group tool rows (and intermediate agent text / resolved approvals between
|
||||
* them) by turn. An agent_text/agent_thought row is "intermediate" when a tool
|
||||
* row follows it in the same turn — it gets absorbed into the group so the
|
||||
* collapsed block preserves chronological order. Resolved approval rows (not
|
||||
* 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.
|
||||
* Group tool rows (and resolved approvals between them) by turn. Text and
|
||||
* thought rows stay outside the group as standalone transcript items — the
|
||||
* operator sees all reasoning without expanding anything.
|
||||
*
|
||||
* Returns groups keyed by `turn:<userId>`.
|
||||
*/
|
||||
export function activityGroupsByTurn(
|
||||
rows: TranscriptRow[],
|
||||
pendingApprovalId: string | null,
|
||||
turnActive: boolean,
|
||||
_turnActive: boolean,
|
||||
): Map<string, ActivityGroup> {
|
||||
// First: count tools per turn to know which turns qualify for grouping.
|
||||
const toolCounts = new Map<string, number>();
|
||||
@@ -181,11 +175,9 @@ export function activityGroupsByTurn(
|
||||
const gid = `turn:${turnId}`;
|
||||
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
|
||||
}
|
||||
// Second: build groups with interleaved text for turns with 2+ tools.
|
||||
// Second: build groups for turns with 2+ tools.
|
||||
const groups = new Map<string, ActivityGroup>();
|
||||
turnId = "before-first-message";
|
||||
// Pending rows that might be intermediate (not yet confirmed by a following
|
||||
// tool). If no tool follows, they stay standalone.
|
||||
let pending: TranscriptRow[] = [];
|
||||
let currentGid = `turn:${turnId}`;
|
||||
for (const row of rows) {
|
||||
@@ -197,15 +189,13 @@ export function activityGroupsByTurn(
|
||||
}
|
||||
const count = toolCounts.get(currentGid) ?? 0;
|
||||
if (count < 2) continue;
|
||||
const isIntermediateText = row.kind === "agent_text" || row.kind === "agent_thought";
|
||||
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId;
|
||||
if (isIntermediateText || isResolvedApproval) {
|
||||
if (isResolvedApproval) {
|
||||
pending.push(row);
|
||||
continue;
|
||||
}
|
||||
if (row.kind === "tool_call" || row.kind === "tool_diff") {
|
||||
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);
|
||||
@@ -216,25 +206,6 @@ export function activityGroupsByTurn(
|
||||
groups.set(currentGid, group);
|
||||
}
|
||||
}
|
||||
// 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) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1094,6 +1094,8 @@
|
||||
width: 40px;
|
||||
padding: 0;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
+1
-1
@@ -1623,7 +1623,7 @@ input.leaf-rename:focus {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 5px;
|
||||
width: 7px;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user