Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f2473d910 | ||
|
|
b56a35f3ee | ||
|
|
b1f6014a6a | ||
|
|
01db6459cb | ||
|
|
d332c43a9f |
@@ -69,13 +69,28 @@ pub enum AcpUpdate {
|
|||||||
/// turn precede this marker on the wire, and therefore in this channel.
|
/// turn precede this marker on the wire, and therefore in this channel.
|
||||||
/// The event pump must fire `done` after persisting every preceding
|
/// The event pump must fire `done` after persisting every preceding
|
||||||
/// notification.
|
/// 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 {
|
TurnDone {
|
||||||
turn: u64,
|
turn: u64,
|
||||||
result: Result<(), String>,
|
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.
|
/// ACP adapter selected from the structured session's resolved command.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AcpAdapter {
|
pub enum AcpAdapter {
|
||||||
@@ -301,7 +316,7 @@ enum Command {
|
|||||||
Prompt {
|
Prompt {
|
||||||
text: String,
|
text: String,
|
||||||
turn: u64,
|
turn: u64,
|
||||||
done: oneshot::Sender<Result<(), String>>,
|
done: oneshot::Sender<TurnDonePayload>,
|
||||||
},
|
},
|
||||||
SetConfig {
|
SetConfig {
|
||||||
config_id: String,
|
config_id: String,
|
||||||
@@ -435,7 +450,7 @@ impl AcpSession {
|
|||||||
&self,
|
&self,
|
||||||
text: String,
|
text: String,
|
||||||
turn: u64,
|
turn: u64,
|
||||||
) -> Result<oneshot::Receiver<Result<(), String>>, String> {
|
) -> Result<oneshot::Receiver<TurnDonePayload>, String> {
|
||||||
let (done_tx, done_rx) = oneshot::channel();
|
let (done_tx, done_rx) = oneshot::channel();
|
||||||
self.commands
|
self.commands
|
||||||
.send(Command::Prompt {
|
.send(Command::Prompt {
|
||||||
@@ -999,7 +1014,11 @@ async fn run_connection<T>(
|
|||||||
.expect("permission turn state")
|
.expect("permission turn state")
|
||||||
.finish(turn);
|
.finish(turn);
|
||||||
if let Some(done) = done.lock().expect("prompt sender").take() {
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -869,11 +869,24 @@ impl SessionManager {
|
|||||||
stream.dispatch.queued_turn = Some(turn);
|
stream.dispatch.queued_turn = Some(turn);
|
||||||
let manager = self.clone();
|
let manager = self.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let lifecycle = match done.await {
|
let (lifecycle, settled) = match done.await {
|
||||||
Ok(Ok(())) => TurnLifecycle::Finish,
|
Ok(payload) => {
|
||||||
Ok(Err(_)) | Err(_) => TurnLifecycle::Failure,
|
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;
|
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);
|
drop(stream);
|
||||||
sync_structured_activity(&self.inner, id).await;
|
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
|
// Every notification for this turn has been persisted and
|
||||||
// broadcast above. Signal the done oneshot so
|
// broadcast above. Signal the done oneshot so
|
||||||
// finish_queued_acp_turn runs with the transcript complete.
|
// finish_queued_acp_turn runs with the transcript complete,
|
||||||
let _ = done.send(result);
|
// 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ export class ApiRequestError extends Error {
|
|||||||
|
|
||||||
async function jsonOrError<T>(res: Response): Promise<T> {
|
async function jsonOrError<T>(res: Response): Promise<T> {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
if (res.status === 204) return undefined as unknown as T;
|
if (res.status === 204 || res.status === 202) return undefined as unknown as T;
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { activeTab, type Dir, type Node } from "../../layout.js";
|
|||||||
import { usePreview } from "../../hooks/usePreview.js";
|
import { usePreview } from "../../hooks/usePreview.js";
|
||||||
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
||||||
import type { Session, Uuid } from "../../types.js";
|
import type { Session, Uuid } from "../../types.js";
|
||||||
|
import { requestToggleDrawer } from "../../uiEvents.js";
|
||||||
import { PaneContainer, type PaneContainerHandle } from "./PaneContainer.js";
|
import { PaneContainer, type PaneContainerHandle } from "./PaneContainer.js";
|
||||||
import { PaneTitle } from "./PaneTitle.js";
|
import { PaneTitle } from "./PaneTitle.js";
|
||||||
import { TabBar } from "./TabBar.js";
|
import { TabBar } from "./TabBar.js";
|
||||||
@@ -204,6 +205,14 @@ function Pane({
|
|||||||
<div
|
<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"
|
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 ? (
|
{leaf.sessionId ? (
|
||||||
<PaneTitle sessionId={leaf.sessionId} session={session} />
|
<PaneTitle sessionId={leaf.sessionId} session={session} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -170,16 +170,14 @@ describe("tool rows and activity groups", () => {
|
|||||||
snapshot(socket, [
|
snapshot(socket, [
|
||||||
chunk("user:1", "user", "go"),
|
chunk("user:1", "user", "go"),
|
||||||
row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
|
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" }),
|
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")!;
|
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.
|
||||||
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);
|
||||||
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("still working"))).toBe(true);
|
|
||||||
|
|
||||||
fireEvent.click(head);
|
fireEvent.click(head);
|
||||||
expect(head.getAttribute("aria-expanded")).toBe("true");
|
expect(head.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
|||||||
@@ -4651,6 +4651,12 @@ input.leaf-rename:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 859px) {
|
@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) */
|
/* sidebar → off-canvas drawer (override the route-based show/hide) */
|
||||||
.app-shell > .sidebar {
|
.app-shell > .sidebar {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
|
|||||||
Reference in New Issue
Block a user