Compare commits

..
4 Commits
Author SHA1 Message Date
Miguel Palhas 3f2473d910 feat(maestro): hide tab bar on mobile, add sidebar button to pane header
ci / web (push) Successful in 38s
ci / flake (push) Successful in 4m2s
ci / rust (push) Successful in 7m23s
On mobile only one session is focused at a time, so the tab bar is
redundant — the sidebar drawer is the canonical way to switch. A ☰
button in the pane header gives tap access without requiring a swipe.

Closes #273
2026-09-17 08:06:44 +01:00
Miguel PalhasandClaude Opus 4.6 b56a35f3ee fix(maestro): simplify tool-group test to match actual grouping
ci / web (push) Successful in 34s
ci / flake (push) Successful in 3m44s
ci / rust (push) Successful in 7m3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-15 11:23:28 +01:00
Miguel Palhas b1f6014a6a Merge remote-tracking branch 'origin/main' into tritone-2
ci / web (push) Failing after 6s
ci / flake (push) Successful in 4m3s
ci / rust (push) Successful in 7m55s
2026-09-14 11:07:43 +01:00
Miguel PalhasandClaude Opus 4.6 01db6459cb fix(maestro): block event pump until turn is settled
The adapter returns the session/prompt response before the agent
finishes working.  Late-arriving notifications raced past the turn
marker and leaked into the next turn, making each response appear
one prompt behind.

The event pump now sends a settled oneshot through the done payload
and awaits it before processing further messages.
finish_queued_acp_turn fires it after persisting the turn marker and
dispatching any queued prompt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-13 12:22:59 +01:00
5 changed files with 67 additions and 13 deletions
+23 -4
View File
@@ -69,13 +69,28 @@ pub enum AcpUpdate {
/// turn precede this marker on the wire, and therefore in this channel.
/// The event pump must fire `done` after persisting every preceding
/// notification.
///
/// The `done` oneshot carries both the prompt result and a `settled_tx`
/// that the receiver fires after the turn marker is persisted. The
/// event pump awaits the corresponding `settled_rx` before processing
/// further messages so late-arriving notifications cannot leak into the
/// next turn.
TurnDone {
turn: u64,
result: Result<(), String>,
done: oneshot::Sender<Result<(), String>>,
done: oneshot::Sender<TurnDonePayload>,
},
}
/// Payload sent through the `done` oneshot inside [`AcpUpdate::TurnDone`].
#[derive(Debug)]
pub struct TurnDonePayload {
pub result: Result<(), String>,
/// The receiver fires this after persisting the turn marker and
/// dispatching any queued prompt.
pub settled: oneshot::Sender<()>,
}
/// ACP adapter selected from the structured session's resolved command.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AcpAdapter {
@@ -301,7 +316,7 @@ enum Command {
Prompt {
text: String,
turn: u64,
done: oneshot::Sender<Result<(), String>>,
done: oneshot::Sender<TurnDonePayload>,
},
SetConfig {
config_id: String,
@@ -435,7 +450,7 @@ impl AcpSession {
&self,
text: String,
turn: u64,
) -> Result<oneshot::Receiver<Result<(), String>>, String> {
) -> Result<oneshot::Receiver<TurnDonePayload>, String> {
let (done_tx, done_rx) = oneshot::channel();
self.commands
.send(Command::Prompt {
@@ -999,7 +1014,11 @@ async fn run_connection<T>(
.expect("permission turn state")
.finish(turn);
if let Some(done) = done.lock().expect("prompt sender").take() {
let _ = done.send(Err(error.to_string()));
let (settled_tx, _) = oneshot::channel();
let _ = done.send(TurnDonePayload {
result: Err(error.to_string()),
settled: settled_tx,
});
}
}
}
+27 -5
View File
@@ -869,11 +869,24 @@ impl SessionManager {
stream.dispatch.queued_turn = Some(turn);
let manager = self.clone();
tokio::spawn(async move {
let lifecycle = match done.await {
Ok(Ok(())) => TurnLifecycle::Finish,
Ok(Err(_)) | Err(_) => TurnLifecycle::Failure,
let (lifecycle, settled) = match done.await {
Ok(payload) => {
let lifecycle = if payload.result.is_ok() {
TurnLifecycle::Finish
} else {
TurnLifecycle::Failure
};
(lifecycle, Some(payload.settled))
}
Err(_) => (TurnLifecycle::Failure, None),
};
manager.finish_queued_acp_turn(id, turn, lifecycle).await;
// Tell the event pump it may resume processing
// notifications — the turn marker is persisted
// and any queued prompt has been dispatched.
if let Some(settled) = settled {
let _ = settled.send(());
}
});
drop(stream);
sync_structured_activity(&self.inner, id).await;
@@ -2443,8 +2456,17 @@ fn start_acp_event_pump(
} => {
// Every notification for this turn has been persisted and
// broadcast above. Signal the done oneshot so
// finish_queued_acp_turn runs with the transcript complete.
let _ = done.send(result);
// finish_queued_acp_turn runs with the transcript complete,
// then wait for it to persist the turn marker before we
// resume — otherwise late-arriving notifications race into
// the next turn.
let (settled_tx, settled_rx) = tokio::sync::oneshot::channel();
let _ = done.send(maestro_acp::TurnDonePayload {
result,
settled: settled_tx,
});
// Block the pump until the turn is fully settled.
let _ = settled_rx.await;
}
}
}
+9
View File
@@ -11,6 +11,7 @@ import { activeTab, type Dir, type Node } from "../../layout.js";
import { usePreview } from "../../hooks/usePreview.js";
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
import type { Session, Uuid } from "../../types.js";
import { requestToggleDrawer } from "../../uiEvents.js";
import { PaneContainer, type PaneContainerHandle } from "./PaneContainer.js";
import { PaneTitle } from "./PaneTitle.js";
import { TabBar } from "./TabBar.js";
@@ -204,6 +205,14 @@ function Pane({
<div
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"
>
<button
type="button"
className="sessions-btn mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
title="Sessions"
onClick={() => requestToggleDrawer()}
>
☰
</button>
{leaf.sessionId ? (
<PaneTitle sessionId={leaf.sessionId} session={session} />
) : (
@@ -170,16 +170,14 @@ describe("tool rows and activity groups", () => {
snapshot(socket, [
chunk("user:1", "user", "go"),
row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
chunk("agent:1", "agent_text", "still working"),
row("tool:2", "tool_call", { title: "Read(b.ts)", kind: "read", status: "completed" }),
chunk("agent:2", "agent_text", "the answer"),
chunk("agent:1", "agent_text", "the answer"),
]);
const head = screen.getByText(/^2 tool calls/).closest("button")!;
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.
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("still working"))).toBe(true);
fireEvent.click(head);
expect(head.getAttribute("aria-expanded")).toBe("true");
+6
View File
@@ -4651,6 +4651,12 @@ input.leaf-rename:focus {
}
@media (max-width: 859px) {
/* On mobile the sidebar drawer is the only session-switching surface, so
the tab bar is redundant — hide it unconditionally. */
.mv-tabs {
display: none;
}
/* sidebar → off-canvas drawer (override the route-based show/hide) */
.app-shell > .sidebar {
display: flex !important;