Compare commits

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:38:30 +01:00
6 changed files with 80 additions and 20 deletions
+9 -6
View File
@@ -194,12 +194,15 @@ choices, not daemon ones.
appear *between* tool rows in the same turn are absorbed into the group 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
@@ -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)}
> >
+12 -4
View File
@@ -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"),
+29 -9
View File
@@ -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;