Compare commits

...
9 Commits
Author SHA1 Message Date
Miguel Palhas cd5bbcfcb1 Merge remote-tracking branch 'origin/main' into timbre-2
ci / web (push) Failing after 1m7s
ci / flake (push) Successful in 3m55s
ci / rust (push) Has been cancelled
2026-09-12 10:59:49 +01:00
Miguel PalhasandClaude Opus 4.6 cbbf526fc4 fix(maestro): persist auto_approve across daemon restarts
Structured sessions spawned with auto_approve lost the flag on daemon
restart because it lived only in the in-memory SessionRecord. Add it to
the Session struct (sidecar JSON) so recovery reads it back instead of
hardcoding false.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 10:59:44 +01:00
Miguel Palhas 9f5b71d563 Merge remote-tracking branch 'origin/main' into aeolian
ci / web (push) Failing after 29s
ci / rust (push) Failing after 51s
ci / flake (push) Successful in 3m9s
2026-09-12 10:42:10 +01:00
Miguel PalhasandClaude Opus 4.6 de603ccd20 feat(maestro): split tool groups on text boundaries
Tool rows interleaved with agent text now produce separate
collapsed groups instead of one per turn. The timeline reads
text → group → text → group, matching chronological order.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 10:42:06 +01:00
Miguel Palhas c6af78e9dd feat(maestro): concatenate queued structured prompts into one turn
ci / web (push) Failing after 6s
ci / rust (push) Failing after 54s
ci / flake (push) Successful in 2m59s
Closes #257
2026-09-12 10:12:36 +01:00
Miguel PalhasandClaude Opus 4.6 7ab503e4f5 fix(maestro): update e2e test for force-kill worktree removal
ci / web (push) Failing after 6s
ci / rust (push) Failing after 53s
ci / flake (push) Successful in 2m47s
1b2e5f5 changed force kill to remove dirty worktrees but left the
test asserting the old keep-on-disk behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-11 08:38:51 +01:00
Miguel Palhas eaf11a7696 Merge branch 'maestoso'
ci / web (push) Failing after 27s
ci / rust (push) Failing after 1m1s
ci / flake (push) Successful in 3m35s
2026-09-10 23:15:45 +01:00
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
18 changed files with 197 additions and 82 deletions
+1
View File
@@ -345,6 +345,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
} }
} }
+6 -11
View File
@@ -1404,6 +1404,7 @@ impl SessionManager {
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
view: req.view, view: req.view,
auto_approve: req.auto_approve,
}; };
if let Err(e) = self.inner.sidecar.write(&session) { if let Err(e) = self.inner.sidecar.write(&session) {
@@ -1678,6 +1679,7 @@ impl SessionManager {
// Unknown on adoption without a sidecar; matches every // Unknown on adoption without a sidecar; matches every
// other field's forward-compat default. // other field's forward-compat default.
view: maestro_protocol::SessionView::Terminal, view: maestro_protocol::SessionView::Terminal,
auto_approve: false,
}, },
}; };
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo")); let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
@@ -1715,7 +1717,7 @@ impl SessionManager {
acp_events: None, acp_events: None,
structured_activity_watermark: None, structured_activity_watermark: None,
acp_event_pump: None, acp_event_pump: None,
auto_approve: false, auto_approve: session.auto_approve,
pending_prompt: None, pending_prompt: None,
}, },
); );
@@ -1894,15 +1896,13 @@ impl SessionManager {
acp_events, acp_events,
structured_activity_watermark: None, structured_activity_watermark: None,
acp_event_pump: None, acp_event_pump: None,
// Recovery cannot know the original preset's flag; the auto_approve: session.auto_approve,
// operator re-enables it by restarting with the preset.
auto_approve: false,
pending_prompt: None, pending_prompt: None,
}, },
); );
if let Some((acp, events)) = event_pump { if let Some((acp, events)) = event_pump {
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone()); let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
start_acp_permission_pump(&self.inner, id, &acp, events, false); start_acp_permission_pump(&self.inner, id, &acp, events, session.auto_approve);
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) { if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
record.acp_event_pump = Some(done); record.acp_event_pump = Some(done);
} }
@@ -3023,12 +3023,7 @@ 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).
async fn cleanup_record( async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kill_tmux: bool) {
inner: &Arc<Inner>,
id: Uuid,
record: SessionRecord,
kill_tmux: bool,
) {
if kill_tmux { if kill_tmux {
let tmux = inner.tmux.clone(); let tmux = inner.tmux.clone();
let _ = tmux_blocking(move || tmux.kill_session(id)).await; let _ = tmux_blocking(move || tmux.kill_session(id)).await;
+1
View File
@@ -34,6 +34,7 @@ fn sample(id: Uuid) -> Session {
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
view: SessionView::Terminal, view: SessionView::Terminal,
auto_approve: false,
} }
} }
+4 -4
View File
@@ -777,7 +777,7 @@ async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
other => panic!("expected a kill_guarded refusal, got {other:?}"), other => panic!("expected a kill_guarded refusal, got {other:?}"),
} }
// Force overrides the refusal and nothing else: session gone, tree kept. // Force overrides the refusal: session gone, tree removed.
client.kill_session_forced(s.id).await.expect("forced kill"); client.kill_session_forced(s.id).await.expect("forced kill");
assert!( assert!(
@@ -789,12 +789,12 @@ async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
.any(|x| x.id == s.id), .any(|x| x.id == s.id),
"the forced kill ends the session" "the forced kill ends the session"
); );
assert!(wt.is_dir(), "a guarded worktree must stay on disk"); assert!(!wt.is_dir(), "a forced kill removes the worktree");
assert!( assert!(
git_worktree_list(base.path()) !git_worktree_list(base.path())
.iter() .iter()
.any(|l| l.contains("feature/local")), .any(|l| l.contains("feature/local")),
"the kept tree stays registered in git" "the removed tree is unregistered from git"
); );
} }
+1
View File
@@ -744,6 +744,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
} }
} }
+7
View File
@@ -132,6 +132,12 @@ pub struct Session {
/// the two are minted by different processes with different lifetimes. /// the two are minted by different processes with different lifetimes.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub acp_session_id: Option<String>, pub acp_session_id: Option<String>,
/// When `true` the daemon auto-approves every ACP permission request for
/// this session instead of surfacing them to the client. Set at spawn time
/// from the preset or the `CreateSessionRequest`; persisted so it survives
/// a daemon restart.
#[serde(default)]
pub auto_approve: bool,
} }
/// A session's process shape (#133): a plain terminal, or a structured agent /// A session's process shape (#133): a plain terminal, or a structured agent
@@ -1054,6 +1060,7 @@ mod tests {
last_focused_at: None, last_focused_at: None,
view: SessionView::Terminal, view: SessionView::Terminal,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
} }
} }
+1
View File
@@ -50,6 +50,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
} }
} }
+6
View File
@@ -2216,6 +2216,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
}) })
.collect(), .collect(),
}); });
@@ -2399,6 +2400,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
}], }],
}); });
app.dispatch(Action::SidebarNext); app.dispatch(Action::SidebarNext);
@@ -2428,6 +2430,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
} }
} }
@@ -3590,6 +3593,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: focused, last_focused_at: focused,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
}; };
let older = Utc::now() - chrono::Duration::seconds(60); let older = Utc::now() - chrono::Duration::seconds(60);
app.catalog.apply_event(CatalogEvent::Snapshot { app.catalog.apply_event(CatalogEvent::Snapshot {
@@ -3870,6 +3874,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
}], }],
}); });
app.dispatch(Action::KillHighlighted); app.dispatch(Action::KillHighlighted);
@@ -3907,6 +3912,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
}], }],
}); });
} }
+1
View File
@@ -225,6 +225,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
} }
} }
+1
View File
@@ -811,6 +811,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
} }
} }
+2
View File
@@ -502,6 +502,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
} }
} }
@@ -1272,6 +1273,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
}; };
let sb = Sidebar { let sb = Sidebar {
sessions: vec![s], sessions: vec![s],
+11 -8
View File
@@ -189,14 +189,17 @@ choices, not daemon ones.
presentation for `Notice`. `Plan` is the exception: both clients omit it presentation for `Notice`. `Plan` is the exception: both clients omit it
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 each contiguous run of two or more tool rows into a
one collapsed turn activity block. `AgentText` and `AgentThought` rows stay collapsed activity block. An `AgentText` or `AgentThought` row between
outside the group as standalone transcript items — the operator sees all tools splits them into separate groups, so the timeline reads
reasoning without expanding. Resolved approval rows between tools are text → group → text → group rather than lumping every tool in a turn into
absorbed into the group. The collapsed header reads "N tool calls · Label one box. Text and thought rows stay outside groups as standalone transcript
'arg'" where the label and argument come from the last tool row's items — the operator sees all reasoning without expanding. Resolved
`splitToolTitle` — the operator sees which command is current at a glance. approval rows between tools within a run are absorbed into the group. The
Opening the group reveals tool rows in chronological order. 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 - 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
@@ -323,6 +323,26 @@ describe("prompt and cancel", () => {
await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "next up")); await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "next up"));
}); });
it("concatenates multiple queued messages into a single prompt", async () => {
const { socket } = mount();
snapshot(socket, [], busy);
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
const form = document.querySelector("form.structured-composer")!;
fireEvent.change(input, { target: { value: "first" } });
fireEvent.submit(form);
fireEvent.change(input, { target: { value: "second" } });
fireEvent.submit(form);
expect(sendStructuredPrompt).not.toHaveBeenCalled();
const queued = document.querySelectorAll(".st-row-queued");
expect(queued).toHaveLength(2);
socket.emit({ type: "reduced_state", state: idle });
await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "first\n\nsecond"));
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
});
it("hands a failed send's draft back to the operator", async () => { it("hands a failed send's draft back to the operator", async () => {
vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom")); vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom"));
const { socket } = mount(); const { socket } = mount();
@@ -251,14 +251,16 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
[sessionId, socketGeneration, session], [sessionId, socketGeneration, session],
); );
// A queued message goes out as soon as the projected turn is idle — the // Queued messages are concatenated into a single prompt so the agent sees
// daemon answers a second prompt `409 turn_in_flight`, so the queue is // one user turn instead of N back-to-back turns. The daemon rejects a
// client-local (docs/STRUCTURED-VIEW.md). // second prompt while a turn is in flight (`409 turn_in_flight`), so the
// queue is client-local (docs/STRUCTURED-VIEW.md).
useEffect(() => { useEffect(() => {
if (active || promptPending.current || queue.length === 0) return; if (active || promptPending.current || queue.length === 0) return;
const [next, ...rest] = queue; const text = queue.map((q) => q.text).join("\n\n");
setQueue(rest); const files = queue.flatMap((q) => q.attachments);
dispatchPrompt(next.text, next.attachments); setQueue([]);
dispatchPrompt(text, files);
}, [active, queue, dispatchPrompt]); }, [active, queue, dispatchPrompt]);
const submit = (text: string, files: File[]) => { const submit = (text: string, files: File[]) => {
+44 -10
View File
@@ -115,8 +115,8 @@ describe("activity grouping", () => {
it("groups a turn's tools once there are two of them", () => { it("groups a turn's tools once there are two of them", () => {
const rows = [text("user:1", "user", "go"), tool("tool:1"), tool("tool:2")]; const rows = [text("user:1", "user", "go"), tool("tool:1"), tool("tool:2")];
const groups = activityGroupsByTurn(rows, null, false); const groups = activityGroupsByTurn(rows, null, false);
expect([...groups.keys()]).toEqual(["turn:user:1"]); expect([...groups.keys()]).toEqual(["turn:user:1:0"]);
expect(groups.get("turn:user:1")).toMatchObject({ toolCount: 2 }); expect(groups.get("turn:user:1:0")).toMatchObject({ toolCount: 2 });
}); });
it("leaves a single tool call standalone", () => { it("leaves a single tool call standalone", () => {
@@ -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("splits tools into separate runs when text appears between them", () => {
const rows = [ const rows = [
text("user:1", "user", "go"), text("user:1", "user", "go"),
tool("tool:1"), tool("tool:1"),
@@ -132,9 +132,23 @@ describe("activity grouping", () => {
tool("tool:2"), tool("tool:2"),
text("agent:2", "agent_text", "done"), text("agent:2", "agent_text", "done"),
]; ];
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!; // Each run has only one tool, so neither qualifies for grouping.
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2"]); expect(activityGroupsByTurn(rows, null, false).size).toBe(0);
expect(group.toolCount).toBe(2); });
it("groups contiguous tools separated by text into distinct groups", () => {
const rows = [
text("user:1", "user", "go"),
tool("tool:1"),
tool("tool:2"),
text("agent:1", "agent_text", "working"),
tool("tool:3"),
tool("tool:4"),
];
const groups = activityGroupsByTurn(rows, null, false);
expect([...groups.keys()]).toEqual(["turn:user:1:0", "turn:user:1:1"]);
expect(groups.get("turn:user:1:0")!.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2"]);
expect(groups.get("turn:user:1:1")!.rows.map((r) => r.id)).toEqual(["tool:3", "tool:4"]);
}); });
it("keeps thoughts outside the group as standalone items", () => { it("keeps thoughts outside the group as standalone items", () => {
@@ -145,7 +159,7 @@ 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 group = activityGroupsByTurn(rows, null, false).get("turn:user:1:0")!;
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2"]); expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2"]);
}); });
@@ -155,7 +169,7 @@ describe("activity grouping", () => {
row("tool:1", "tool_call", { title: "Read(src/main.ts)", status: "completed" }), 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" }), row("tool:2", "tool_call", { title: "Execute(git status)", kind: "execute", status: "completed" }),
]; ];
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!; const group = activityGroupsByTurn(rows, null, false).get("turn:user:1:0")!;
expect(group.lastToolLabel).toBe("Execute"); expect(group.lastToolLabel).toBe("Execute");
expect(group.lastToolArg).toBe("git status"); expect(group.lastToolArg).toBe("git status");
}); });
@@ -169,7 +183,7 @@ describe("activity grouping", () => {
row("approval:2", "approval"), row("approval:2", "approval"),
tool("tool:3"), tool("tool:3"),
]; ];
const group = activityGroupsByTurn(rows, "approval:2", false).get("turn:user:1")!; const group = activityGroupsByTurn(rows, "approval:2", false).get("turn:user:1:0")!;
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "approval:1", "tool:2", "tool:3"]); expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "approval:1", "tool:2", "tool:3"]);
}); });
}); });
@@ -180,11 +194,31 @@ describe("transcript render list", () => {
const items = transcriptItems(rows, null, false); const items = transcriptItems(rows, null, false);
expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual([ expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual([
"user:1", "user:1",
"turn:user:1", "turn:user:1:0",
"agent:1", "agent:1",
]); ]);
}); });
it("interleaves groups and text in timeline order", () => {
const rows = [
text("user:1", "user", "go"),
tool("tool:1"),
tool("tool:2"),
text("agent:1", "agent_text", "working"),
tool("tool:3"),
tool("tool:4"),
text("agent:2", "agent_text", "done"),
];
const items = transcriptItems(rows, null, false);
expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual([
"user:1",
"turn:user:1:0",
"agent:1",
"turn:user:1:1",
"agent:2",
]);
});
it("omits plan rows, state-only notices and standalone resolved approvals", () => { it("omits plan rows, state-only notices and standalone resolved approvals", () => {
const rows = [ const rows = [
text("user:1", "user", "go"), text("user:1", "user", "go"),
+52 -43
View File
@@ -155,57 +155,68 @@ export interface ActivityGroup {
} }
/** /**
* Group tool rows (and resolved approvals between them) by turn. Text and * Group contiguous tool rows (and resolved approvals between them) into
* thought rows stay outside the group as standalone transcript items — the * collapsible blocks. An `AgentText` or `AgentThought` row between tools
* operator sees all reasoning without expanding anything. * splits the run — each contiguous stretch of tools becomes its own group,
* so the timeline reads text → group → text → group rather than lumping
* every tool in a turn into one box.
* *
* Returns groups keyed by `turn:<userId>`. * A group must contain at least two tools to qualify; a single tool stays
* standalone. Returns groups keyed by `turn:<userId>:<seq>`.
*/ */
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.
const toolCounts = new Map<string, number>();
let turnId = "before-first-message";
for (const row of rows) {
if (row.kind === "user") turnId = row.id;
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
const gid = `turn:${turnId}`;
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
}
// Second: build groups for turns with 2+ tools.
const groups = new Map<string, ActivityGroup>(); const groups = new Map<string, ActivityGroup>();
turnId = "before-first-message"; let turnId = "before-first-message";
let pending: TranscriptRow[] = []; let runSeq = 0;
let currentGid = `turn:${turnId}`; let runTools: TranscriptRow[] = [];
let runPending: TranscriptRow[] = [];
const flushRun = () => {
if (runTools.length >= 2) {
const gid = `turn:${turnId}:${runSeq}`;
const tool = (runTools[runTools.length - 1].data ?? {}) as { title?: string; name?: string | null; kind?: string };
const { label, arg } = splitToolTitle(tool.title, tool.name ?? undefined, tool.kind);
groups.set(gid, {
toolCount: runTools.length,
rows: [...runTools],
lastToolLabel: label,
lastToolArg: arg,
});
runSeq++;
}
runTools = [];
runPending = [];
};
for (const row of rows) { for (const row of rows) {
if (row.kind === "user") { if (row.kind === "user") {
flushRun();
turnId = row.id; turnId = row.id;
currentGid = `turn:${turnId}`; runSeq = 0;
pending = []; continue;
}
// Text and thought rows break the current tool run.
if (row.kind === "agent_text" || row.kind === "agent_thought") {
flushRun();
continue; continue;
} }
const count = toolCounts.get(currentGid) ?? 0;
if (count < 2) continue;
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId; const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId;
if (isResolvedApproval) { if (isResolvedApproval) {
pending.push(row); runPending.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: "" }; // Flush any pending resolved approvals into the run before this tool.
group.rows.push(...pending); runTools.push(...runPending);
pending = []; runPending = [];
group.rows.push(row); runTools.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);
} }
} }
flushRun();
return groups; return groups;
} }
@@ -217,10 +228,10 @@ export type TranscriptItem =
| { type: "group"; id: string; group: ActivityGroup }; | { type: "group"; id: string; group: ActivityGroup };
/** /**
* The transcript in render order: rows, with each turn's tool activity folded * The transcript in render order: rows, with each contiguous tool stretch
* into a single group item where one exists. Resolved approvals not absorbed * folded into a single group item where one exists. Resolved approvals not
* into a group are dropped — the tool row itself already identifies the * absorbed into a group are dropped — the tool row itself already identifies
* action. `plan` rows and state-only notices are dropped too (both clients * the action. `plan` rows and state-only notices are dropped too (both clients
* omit `Plan` from the transcript). A turn footer only survives as the final * omit `Plan` from the transcript). A turn footer only survives as the final
* item, so old footers stop reading like transcript content. * item, so old footers stop reading like transcript content.
*/ */
@@ -230,21 +241,19 @@ export function transcriptItems(
turnActive: boolean, turnActive: boolean,
): TranscriptItem[] { ): TranscriptItem[] {
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive); const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive);
const grouped = new Set<string>(); // Reverse index: row id → group id that contains it.
for (const group of groups.values()) for (const row of group.rows) grouped.add(row.id); const rowToGroup = new Map<string, string>();
for (const [groupId, group] of groups) for (const r of group.rows) rowToGroup.set(r.id, groupId);
const items: TranscriptItem[] = []; const items: TranscriptItem[] = [];
let turnId = "before-first-message";
const emitted = new Set<string>(); const emitted = new Set<string>();
for (const row of rows) { for (const row of rows) {
if (row.kind === "user") turnId = row.id; const groupId = rowToGroup.get(row.id);
const groupId = `turn:${turnId}`; if (groupId !== undefined) {
const group = groups.get(groupId);
if (group && grouped.has(row.id)) {
// The group renders once, at its first member's position. // The group renders once, at its first member's position.
if (!emitted.has(groupId)) { if (!emitted.has(groupId)) {
emitted.add(groupId); emitted.add(groupId);
items.push({ type: "group", id: groupId, group }); items.push({ type: "group", id: groupId, group: groups.get(groupId)! });
} }
continue; continue;
} }
@@ -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);