Compare commits

..
4 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 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
7 changed files with 40 additions and 92 deletions
+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,
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
+1 -1
View File
@@ -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();
});
+4 -6
View File
@@ -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} />
+2 -2
View File
@@ -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">
+12 -9
View File
@@ -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 no-underline ${rowFill(props)} ${
className={`session-card tree-leaf no-underline ${rowClasses(props)} ${
running ? "" : "opacity-60"
}`}
href={`#/attach/${encodeURIComponent(s.id)}`}
+5 -23
View File
@@ -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", () => {
+9 -38
View File
@@ -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;
}