Compare commits

...
7 Commits
Author SHA1 Message Date
Miguel Palhas 4738233580 Merge remote-tracking branch 'origin/main' into lento
ci / web (push) Failing after 27s
ci / rust (push) Failing after 50s
ci / flake (push) Has been cancelled
2026-09-10 21:55:19 +01:00
Miguel PalhasandClaude Opus 4.6 cb25320ea0 feat(maestro): keep agent text visible, collapse only tool calls
Agent text and thought rows stay outside the collapsed tool group as
standalone transcript items instead of being absorbed into it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 21:55:06 +01:00
Miguel Palhas ff52dd9812 Merge branch 'lyre'
ci / web (push) Successful in 51s
ci / rust (push) Failing after 1m2s
ci / flake (push) Successful in 3m24s
2026-09-10 21:53:34 +01:00
Miguel PalhasandClaude Opus 4.6 f3a5cad789 feat(maestro): widen sidebar activity rail from 5px to 7px
Makes the coloured state bars more visible at a glance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 21:53:18 +01:00
Miguel PalhasandClaude Opus 4.6 563a35d2ec fix(maestro): restore sidebar fill, pane title, and focus dimming after React port
ci / web (push) Failing after 6s
ci / rust (push) Failing after 48s
ci / flake (push) Has been cancelled
The React port replaced CSS class names with Tailwind utilities, breaking
three visual contracts:

- SessionCard used inline bg-* classes instead of .is-active/.is-open/
  .is-previewing that the styles.css cascade targets.
- PaneTitle used className "at-title" instead of "attach-title", so font,
  overflow, and child layout rules never applied.
- MultiView's Pane never applied .is-focused or .is-preview, so every
  pane matched :not(.is-focused) and was dimmed to 0.45 opacity.

Closes #256

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 21:51:53 +01:00
Miguel Palhas cd2184b128 Merge remote-tracking branch 'origin/main' into modulation-2
ci / web (push) Failing after 7s
ci / rust (push) Failing after 1m3s
ci / flake (push) Successful in 3m30s
2026-09-10 17:29:49 +01:00
Miguel PalhasandClaude Opus 4.6 1b2e5f5f05 feat(maestro): force kill deletes dirty worktrees
Previously --force left the worktree on disk so the operator could
inspect it. Now it removes the worktree unconditionally — the
confirmation prompt already shows the breakdown, so the decision
happens before the delete, not after.

Natural exit still leaves dirty worktrees on disk (no operator
confirmation in that path).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 17:26:04 +01:00
13 changed files with 62 additions and 149 deletions
+1 -1
View File
@@ -133,7 +133,7 @@ pub struct KillArgs {
/// Session ID to kill. /// Session ID to kill.
pub session_id: Uuid, pub session_id: Uuid,
/// End the session even when its worktree holds unsaved work (#81). The /// 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)] #[arg(long)]
pub force: bool, pub force: bool,
} }
+7 -27
View File
@@ -1545,15 +1545,10 @@ impl SessionManager {
/// uncommitted tracked changes, untracked non-ignored files, or commits /// uncommitted tracked changes, untracked non-ignored files, or commits
/// reachable from no remote-tracking ref. Nothing is killed and nothing /// 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 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 /// is.
/// work, so ending a session that still matters becomes a deliberate
/// `--force`, which leaves the tree alone rather than destroying it
/// (WORKTREES.md).
/// ///
/// With `force` (#81) the refusal — and nothing else — is overridden: /// With `force` (#81) the refusal is overridden: the session ends and
/// the session ends and the guarded worktree is left on disk, never /// the worktree is removed, even when it holds unsaved work.
/// removed. A tree whose guard is clear never needed the override; force
/// removes it as usual.
/// ///
/// This is also how an already-dead session is dismissed. tmux is only /// 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 /// asked to kill something it still has — calling `kill-session` on a
@@ -1588,7 +1583,7 @@ impl SessionManager {
sessions.remove(&id) sessions.remove(&id)
}; };
let record = removed.ok_or(SessionError::NotFound(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(()) 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 /// 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 /// 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 /// tree. Anything holding unsaved work is left on disk — there is no
/// same thing a forced kill does with a guarded tree (#81). /// operator confirmation on the natural-exit path.
/// ///
/// Returns whether the worktree was fully accounted for. When it wasn't, /// Returns whether the worktree was fully accounted for. When it wasn't,
/// nothing was destroyed and the caller keeps the reference on the record. /// 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 /// 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 /// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the
/// liveness reaper (tmux already lost the session). /// 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( async fn cleanup_record(
inner: &Arc<Inner>, inner: &Arc<Inner>,
id: Uuid, id: Uuid,
record: SessionRecord, record: SessionRecord,
kill_tmux: bool, kill_tmux: bool,
keep_worktree: bool,
) { ) {
if kill_tmux { if kill_tmux {
let tmux = inner.tmux.clone(); let tmux = inner.tmux.clone();
@@ -3059,17 +3049,7 @@ async fn cleanup_record(
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar"); tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
} }
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) { if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
if keep_worktree { if let Err(e) = inner.worktree_mgr.remove(base, path) {
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.
tracing::warn!( tracing::warn!(
%id, %id,
path = %path.display(), path = %path.display(),
+1 -1
View File
@@ -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 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. 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 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 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 it is positioned relative to the row. It carries motion only for states that
+7 -13
View File
@@ -190,19 +190,13 @@ choices, not daemon ones.
from the transcript; the TUI may show the current plan in its status line, 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. 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 - 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 one collapsed turn activity block. `AgentText` and `AgentThought` rows stay
appear *between* tool rows in the same turn are absorbed into the group outside the group as standalone transcript items — the operator sees all
(intermediate narration); the last `AgentText` with no subsequent tool stays reasoning without expanding. Resolved approval rows between tools are
outside as the prominent final answer. While a turn is still active absorbed into the group. The collapsed header reads "N tool calls · Label
(responding or thinking), the most recent trailing text or thought row is 'arg'" where the label and argument come from the last tool row's
kept outside as visible narration — the operator always sees the agent's `splitToolTitle` — the operator sees which command is current at a glance.
latest reasoning without opening the group. It is replaced by the next text Opening the group reveals tool rows in chronological order.
row when one arrives, and absorbed into the group when the turn ends (unless
it is `AgentText`, which stays outside as the final answer). The collapsed
header reads "N tool calls · Label 'arg'" where the label and argument come
from the last tool row's `splitToolTitle` — the operator sees which command
is current at a glance. Opening the group reveals tools and intermediate
text in chronological order, with the text rendered in a subdued style.
- The web client's user, agent, thought, tool-detail, and notice text blocks - 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
+10 -25
View File
@@ -75,27 +75,17 @@ The point of refusing rather than cleaning up is that the session stays as the
operator's handle on their own unfinished work. A session that is killed while 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. 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`
`--force` overrides the refusal and nothing else. It ends the session and `--force` overrides the refusal and **removes the worktree**, even when it holds
**leaves the worktree on disk**, untouched. Force never removes a tree that unsaved work. The operator sees the breakdown in the confirmation prompt and
would have blocked. A tree whose guard is clear never needed the override, so a decides; once confirmed, the tree is gone.
forced kill removes it as usual — force changes what happens to blocked work,
not ordinary cleanup.
In the web client the kill button sends the unforced `DELETE` straight away — 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 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 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`. "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 ### Exiting on its own
A session that ends by itself — the command finished, the operator typed `exit` — 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. 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 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 later goes through `--force`, which removes it. The same holds when the probe
the probe itself fails: nothing is destroyed. 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 A forced kill always removes the worktree. A natural exit with unsaved work
reclaim command. A tree left behind by a forced kill or a dirty exit is an leaves the tree on disk — there is no operator confirmation in that path. Those
ordinary git worktree on disk; `git worktree list` finds it and trees are ordinary git worktrees; `git worktree list` finds them and
`git worktree remove` removes it. `git worktree remove` removes them.
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.
+1 -1
View File
@@ -105,7 +105,7 @@ describe("MultiView", () => {
expect(screen.getByText("Tab 1 · 1")).toBeTruthy(); expect(screen.getByText("Tab 1 · 1")).toBeTruthy();
const pane = document.querySelector('[data-session-id="s9"]'); const pane = document.querySelector('[data-session-id="s9"]');
expect(pane).toBeTruthy(); expect(pane).toBeTruthy();
expect(pane!.querySelector(".bg-preview-tint")).toBeTruthy(); expect(pane!.classList.contains("is-preview")).toBe(true);
expect(screen.getByText("preview")).toBeTruthy(); expect(screen.getByText("preview")).toBeTruthy();
}); });
+4 -6
View File
@@ -188,8 +188,9 @@ function Pane({
return ( return (
<div <div
className={ className={
"mv-pane flex h-full min-h-0 min-w-0 flex-col border " + "mv-pane flex h-full min-h-0 min-w-0 flex-col" +
(isFocused && !isPreview ? "border-accent" : "border-border-strong") (isFocused ? " is-focused" : "") +
(isPreview ? " is-preview" : "")
} }
data-leaf-id={leaf.id} data-leaf-id={leaf.id}
data-session-id={leaf.sessionId ?? undefined} data-session-id={leaf.sessionId ?? undefined}
@@ -201,10 +202,7 @@ function Pane({
}} }}
> >
<div <div
className={ 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"
"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")
}
> >
{leaf.sessionId ? ( {leaf.sessionId ? (
<PaneTitle sessionId={leaf.sessionId} session={session} /> <PaneTitle sessionId={leaf.sessionId} session={session} />
+2 -2
View File
@@ -15,7 +15,7 @@ export interface PaneTitleProps {
export function PaneTitle({ sessionId, session, className }: PaneTitleProps) { export function PaneTitle({ sessionId, session, className }: PaneTitleProps) {
if (!session) { if (!session) {
return ( return (
<span className={className ?? "at-title"} title={sessionId}> <span className={className ?? "attach-title"} title={sessionId}>
session <span className="sid">{sessionId.slice(0, 8)}</span> session <span className="sid">{sessionId.slice(0, 8)}</span>
</span> </span>
); );
@@ -27,7 +27,7 @@ export function PaneTitle({ sessionId, session, className }: PaneTitleProps) {
const icon = tool ? toolIconSvg(tool) : null; const icon = tool ? toolIconSvg(tool) : null;
return ( 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> <span className="at-name">{name}</span>
{showCommand && ( {showCommand && (
<span className="at-cmd"> <span className="at-cmd">
+2 -2
View File
@@ -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 // 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. // 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; const word = STATE_WORDS[slug] ?? STATE_WORDS.idle;
return ( return (
<span <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} data-activity={slug}
role="img" role="img"
title={word} title={word}
+12 -9
View File
@@ -44,14 +44,17 @@ export interface SessionCardProps {
onRenameCancel(): void; onRenameCancel(): void;
} }
/** The fill ladder from docs/SIDEBAR.md "Which row is filled", furthest down /** The fill ladder from docs/SIDEBAR.md "Which row is filled" — the CSS
* wins. Written as one ordered run so the precedence is the order it reads in. */ * cascade in styles.css carries the actual fills via `.is-active`, `.is-open`,
function rowFill(p: SessionCardProps): string { * `.is-previewing` (all inside `:where()` so source order is the precedence).
if (p.active) return "bg-accent-fill"; * We just apply the right class names here. */
if (p.previewing) return "bg-preview-tint"; function rowClasses(p: SessionCardProps): string {
if (p.open) return "bg-accent/[0.06]"; const cls: string[] = [];
if (p.highlighted) return "bg-preview-tint"; if (p.active) cls.push("is-active");
return ""; 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) { export function SessionCard(props: SessionCardProps) {
@@ -62,7 +65,7 @@ export function SessionCard(props: SessionCardProps) {
return ( return (
<a <a
className={`session-card tree-leaf no-underline ${rowFill(props)} ${ className={`session-card tree-leaf no-underline ${rowClasses(props)} ${
running ? "" : "opacity-60" running ? "" : "opacity-60"
}`} }`}
href={`#/attach/${encodeURIComponent(s.id)}`} href={`#/attach/${encodeURIComponent(s.id)}`}
+5 -23
View File
@@ -124,7 +124,7 @@ describe("activity grouping", () => {
expect(activityGroupsByTurn(rows, null, false).size).toBe(0); 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 = [ const rows = [
text("user:1", "user", "go"), text("user:1", "user", "go"),
tool("tool:1"), tool("tool:1"),
@@ -133,11 +133,11 @@ describe("activity grouping", () => {
text("agent:2", "agent_text", "done"), text("agent:2", "agent_text", "done"),
]; ];
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!; 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); 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 = [ const rows = [
text("user:1", "user", "go"), text("user:1", "user", "go"),
tool("tool:1"), tool("tool:1"),
@@ -145,26 +145,8 @@ describe("activity grouping", () => {
text("thought:1", "agent_thought", "hmm"), text("thought:1", "agent_thought", "hmm"),
text("thought:2", "agent_thought", "aha"), text("thought:2", "agent_thought", "aha"),
]; ];
const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!; const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
expect(live.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1"]); expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2"]);
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"]);
}); });
it("tracks the last tool's label and argument for the collapsed header", () => { it("tracks the last tool's label and argument for the collapsed header", () => {
+9 -38
View File
@@ -144,9 +144,10 @@ export function turnIsActive(state: ReducedState): boolean {
} }
export interface ActivityGroup { export interface ActivityGroup {
/** Header label count — tool rows only, never absorbed text. */ /** Header label count — tool rows only. */
toolCount: number; 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[]; rows: TranscriptRow[];
/** `splitToolTitle` of the last tool row — the collapsed header shows it. */ /** `splitToolTitle` of the last tool row — the collapsed header shows it. */
lastToolLabel: string; lastToolLabel: string;
@@ -154,23 +155,16 @@ export interface ActivityGroup {
} }
/** /**
* Group tool rows (and intermediate agent text / resolved approvals between * Group tool rows (and resolved approvals between them) by turn. Text and
* them) by turn. An agent_text/agent_thought row is "intermediate" when a tool * thought rows stay outside the group as standalone transcript items — the
* row follows it in the same turn — it gets absorbed into the group so the * operator sees all reasoning without expanding anything.
* 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.
* *
* Returns groups keyed by `turn:<userId>`. * Returns groups keyed by `turn:<userId>`.
*/ */
export function activityGroupsByTurn( export function activityGroupsByTurn(
rows: TranscriptRow[], rows: TranscriptRow[],
pendingApprovalId: string | null, pendingApprovalId: string | null,
turnActive: boolean, _turnActive: boolean,
): Map<string, ActivityGroup> { ): Map<string, ActivityGroup> {
// First: count tools per turn to know which turns qualify for grouping. // First: count tools per turn to know which turns qualify for grouping.
const toolCounts = new Map<string, number>(); const toolCounts = new Map<string, number>();
@@ -181,11 +175,9 @@ export function activityGroupsByTurn(
const gid = `turn:${turnId}`; const gid = `turn:${turnId}`;
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1); 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>(); const groups = new Map<string, ActivityGroup>();
turnId = "before-first-message"; 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 pending: TranscriptRow[] = [];
let currentGid = `turn:${turnId}`; let currentGid = `turn:${turnId}`;
for (const row of rows) { for (const row of rows) {
@@ -197,15 +189,13 @@ export function activityGroupsByTurn(
} }
const count = toolCounts.get(currentGid) ?? 0; const count = toolCounts.get(currentGid) ?? 0;
if (count < 2) continue; if (count < 2) continue;
const isIntermediateText = row.kind === "agent_text" || row.kind === "agent_thought";
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId; const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId;
if (isIntermediateText || isResolvedApproval) { if (isResolvedApproval) {
pending.push(row); pending.push(row);
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: [], lastToolLabel: "", lastToolArg: "" }; 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); group.rows.push(...pending);
pending = []; pending = [];
group.rows.push(row); group.rows.push(row);
@@ -216,25 +206,6 @@ export function activityGroupsByTurn(
groups.set(currentGid, group); 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; return groups;
} }
+1 -1
View File
@@ -1623,7 +1623,7 @@ input.leaf-rename:focus {
top: 0; top: 0;
bottom: 0; bottom: 0;
left: 0; left: 0;
width: 5px; width: 7px;
background: currentColor; background: currentColor;
} }