Compare commits

..
2 Commits
Author SHA1 Message Date
Miguel Palhas fffd2a55f3 Merge remote-tracking branch 'origin/main' into maestoso 2026-09-10 21:52:26 +01:00
Miguel PalhasandClaude Opus 4.6 9dead5652e fix(maestro): reconnect attach/structured sockets on Android resume
The visibility recovery only reconnected the catalog and daemon-link
sockets. Per-pane attach and structured WebSockets relied on backoff
timers that Android suspends alongside the sockets, leaving the
terminal disconnected after backgrounding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 21:50:29 +01:00
8 changed files with 109 additions and 25 deletions
+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 7px bar pinned to the row's own left In the **web client** the rail is a 5px 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
+13 -7
View File
@@ -190,13 +190,19 @@ 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 stay one collapsed turn activity block. `AgentText` and `AgentThought` rows that
outside the group as standalone transcript items — the operator sees all appear *between* tool rows in the same turn are absorbed into the group
reasoning without expanding. Resolved approval rows between tools are (intermediate narration); the last `AgentText` with no subsequent tool stays
absorbed into the group. The collapsed header reads "N tool calls · Label outside as the prominent final answer. While a turn is still active
'arg'" where the label and argument come from the last tool row's (responding or thinking), the most recent trailing text or thought row is
`splitToolTitle` — the operator sees which command is current at a glance. kept outside as visible narration — the operator always sees the agent's
Opening the group reveals tool rows in chronological order. 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.
- 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
+2 -2
View File
@@ -1,4 +1,4 @@
// docs/SIDEBAR.md: a 7px bar pinned to the row's own left edge, square // docs/SIDEBAR.md: a 5px 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-[7px] ${RAIL_CLASS[slug] ?? RAIL_CLASS.idle}`} className={`absolute left-0 top-0 bottom-0 w-[5px] ${RAIL_CLASS[slug] ?? RAIL_CLASS.idle}`}
data-activity={slug} data-activity={slug}
role="img" role="img"
title={word} title={word}
+23 -5
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("keeps intermediate text outside the group as standalone items", () => { it("absorbs intermediate narration but keeps the final answer outside", () => {
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", "tool:2"]); expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
expect(group.toolCount).toBe(2); expect(group.toolCount).toBe(2);
}); });
it("keeps thoughts outside the group as standalone items", () => { it("keeps the newest thought outside while the turn is still live", () => {
const rows = [ const rows = [
text("user:1", "user", "go"), text("user:1", "user", "go"),
tool("tool:1"), tool("tool:1"),
@@ -145,8 +145,26 @@ 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 group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!; const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!;
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2"]); 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"]);
}); });
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", () => {
+38 -9
View File
@@ -144,10 +144,9 @@ export function turnIsActive(state: ReducedState): boolean {
} }
export interface ActivityGroup { export interface ActivityGroup {
/** Header label count — tool rows only. */ /** Header label count — tool rows only, never absorbed text. */
toolCount: number; toolCount: number;
/** Tool rows and resolved approvals, in transcript order. Text/thought /** Tools plus absorbed intermediate text/approvals, in transcript order. */
* 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;
@@ -155,16 +154,23 @@ export interface ActivityGroup {
} }
/** /**
* Group tool rows (and resolved approvals between them) by turn. Text and * Group tool rows (and intermediate agent text / resolved approvals between
* thought rows stay outside the group as standalone transcript items — the * them) by turn. An agent_text/agent_thought row is "intermediate" when a tool
* operator sees all reasoning without expanding anything. * 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.
* *
* 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>();
@@ -175,9 +181,11 @@ 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 for turns with 2+ tools. // Second: build groups with interleaved text 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) {
@@ -189,13 +197,15 @@ 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 (isResolvedApproval) { if (isIntermediateText || 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);
@@ -206,6 +216,25 @@ 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;
} }
@@ -196,12 +196,24 @@ export function useStructuredSession(
connect(); connect();
}; };
// Mobile browsers suspend timers and silently kill WebSockets in the
// background. Kick a fresh connect when the page becomes visible again.
const onVisibility = () => {
if (document.visibilityState !== "visible" || disposed) return;
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return;
cancelTimer();
setStatus("reconnecting");
connect();
};
document.addEventListener("visibilitychange", onVisibility);
setStatus("connecting"); setStatus("connecting");
connect(); connect();
return () => { return () => {
disposed = true; disposed = true;
reconnectRef.current = () => {}; reconnectRef.current = () => {};
document.removeEventListener("visibilitychange", onVisibility);
cancelTimer(); cancelTimer();
closeSocket(); closeSocket();
}; };
@@ -302,6 +302,24 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
ta?.addEventListener("focus", onTaFocus); ta?.addEventListener("focus", onTaFocus);
ta?.addEventListener("blur", onTaBlur); ta?.addEventListener("blur", onTaBlur);
// Mobile browsers (Android especially) suspend timers and silently kill
// WebSockets when the app goes to background. The backoff timer set by
// onclose is frozen too, so recovery stalls until visibility returns. Kick
// a fresh connect immediately when the page becomes visible again.
const onVisibility = () => {
if (document.visibilityState !== "visible" || disposed) return;
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) return;
// Cancel any pending backoff — we're reconnecting right now.
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
reconnectMs = INITIAL_RECONNECT_MS;
onStatusChange?.("reconnecting");
connect();
};
document.addEventListener("visibilitychange", onVisibility);
window.addEventListener("resize", onResize); window.addEventListener("resize", onResize);
const visual = window.visualViewport ?? null; const visual = window.visualViewport ?? null;
visual?.addEventListener("resize", onResize); visual?.addEventListener("resize", onResize);
@@ -326,6 +344,7 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
reconnectTimer = null; reconnectTimer = null;
} }
ro.disconnect(); ro.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("resize", onResize); window.removeEventListener("resize", onResize);
visual?.removeEventListener("resize", onResize); visual?.removeEventListener("resize", onResize);
ta?.removeEventListener("focus", onTaFocus); ta?.removeEventListener("focus", onTaFocus);
+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: 7px; width: 5px;
background: currentColor; background: currentColor;
} }