Compare commits

...
7 Commits
Author SHA1 Message Date
Miguel PalhasandClaude Opus 4.6 f36b7808a7 feat(maestro): show image previews in user message bubbles
ci / rust (push) Waiting to run
ci / web (push) Waiting to run
ci / flake (push) Waiting to run
Uploaded images now render as thumbnails in the user bubble instead of
showing only the server file path as text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 14:32:42 +01:00
Miguel Palhas 50b862b1fa Merge branch 'vivace'
ci / web (push) Failing after 29s
ci / rust (push) Failing after 50s
ci / flake (push) Successful in 3m30s
2026-09-08 13:47:19 +01:00
Miguel Palhas 4668cf8ece Merge remote-tracking branch 'origin/main' into recorder-4
ci / rust (push) Failing after 42s
ci / web (push) Failing after 20s
ci / flake (push) Successful in 3m39s
2026-09-08 13:46:03 +01:00
Miguel PalhasandClaude Opus 4.6 638155de03 feat(maestro): move activity indicator into scroll area
The "Running 1m ..." status now sits at the tail of the chat
history instead of pinned above the input, matching Claude Code's
placement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 13:45:56 +01:00
Miguel Palhas 1c0fff5507 Merge remote-tracking branch 'origin/main' into vivace 2026-09-08 13:45:48 +01:00
Miguel PalhasandClaude Opus 4.6 10c9db6d87 feat(web): show last agent text outside collapsed group during active turn
Intermediate narration was hidden inside the collapsed tool group
while the agent was still working. The last AgentText/AgentThought
now stays visible as current status, absorbed only when the turn ends
or newer text replaces it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 13:45:45 +01:00
Miguel PalhasandClaude Opus 4.6 b339fecad3 feat(maestro): route maestro send through ACP for structured sessions
ci / rust (push) Waiting to run
ci / web (push) Waiting to run
ci / flake (push) Waiting to run
Structured sessions were rejected by `send_input` with NotTerminal.
Now `run_send` checks the session view and routes to
`send_structured_prompt` for structured sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 13:43:01 +01:00
8 changed files with 224 additions and 32 deletions
+2 -2
View File
@@ -41,8 +41,8 @@ pub enum Command {
Attach(AttachArgs), Attach(AttachArgs),
/// Kill a session by ID. /// Kill a session by ID.
Kill(KillArgs), Kill(KillArgs),
/// Type text into a running session, as if it had been typed on an /// Send text to a running session. Terminal sessions receive raw
/// attached terminal. /// keystrokes; structured sessions receive an ACP prompt.
Send(SendArgs), Send(SendArgs),
/// Install or update the agent activity hook (#106). /// Install or update the agent activity hook (#106).
Hooks(HooksArgs), Hooks(HooksArgs),
+27 -15
View File
@@ -130,9 +130,12 @@ pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid,
/// `maestro send`. Answers with the session that was typed into, so the /// `maestro send`. Answers with the session that was typed into, so the
/// caller can name it once the daemon has accepted the text. /// caller can name it once the daemon has accepted the text.
/// ///
/// A bare ID skips the catalog fetch; anything else needs the list to match /// Terminal sessions get raw keystrokes via `send_input`; structured sessions
/// against. Text comes from the arguments, or from stdin when there is none — /// get an ACP prompt via `send_structured_prompt` (`--no-enter` is ignored
/// the shape a heredoc or a pipe wants. /// there — ACP prompts are always complete messages).
///
/// Text comes from the arguments, or from stdin when there is none — the
/// shape a heredoc or a pipe wants.
#[allow(clippy::result_large_err)] #[allow(clippy::result_large_err)]
pub async fn run_send( pub async fn run_send(
client: &DaemonClient, client: &DaemonClient,
@@ -155,19 +158,28 @@ pub async fn run_send(
return Err(CliError::NothingToSend); return Err(CliError::NothingToSend);
} }
let id = match uuid::Uuid::parse_str(args.session.trim()) { // We always need the session list: even a bare UUID needs the view field
Ok(id) => id, // to decide which endpoint to hit.
Err(_) => { let sessions = client.list_sessions().await?;
let sessions = client.list_sessions().await?; let id = resolve_session(&sessions, &args.session)?;
resolve_session(&sessions, &args.session)? let view = sessions
} .iter()
}; .find(|s| s.id == id)
.map(|s| s.view)
.unwrap_or(SessionView::Terminal);
let req = SendInputRequest { match view {
text, SessionView::Structured => {
submit: !args.no_enter, client.send_structured_prompt(id, &text).await?;
}; }
client.send_input(id, &req).await?; SessionView::Terminal => {
let req = SendInputRequest {
text,
submit: !args.no_enter,
};
client.send_input(id, &req).await?;
}
}
Ok(id) Ok(id)
} }
@@ -2,7 +2,7 @@
//! was unit-tested, but nothing exercised the client, the error decoding, or //! was unit-tested, but nothing exercised the client, the error decoding, or
//! the unreachable-daemon path without compiling and forking a real daemon. //! the unreachable-daemon path without compiling and forking a real daemon.
use maestro_protocol::{DaemonClient, DaemonInfo}; use maestro_protocol::{DaemonClient, DaemonInfo, SessionView};
use maestro_testing::{session, MockDaemon}; use maestro_testing::{session, MockDaemon};
use uuid::Uuid; use uuid::Uuid;
@@ -202,6 +202,35 @@ async fn send_resolves_a_session_by_name() {
assert_eq!(sent, id); assert_eq!(sent, id);
} }
/// A structured session routes through the ACP prompt endpoint, not the
/// terminal input one — `--no-enter` is irrelevant (ACP prompts are
/// complete messages).
#[tokio::test]
async fn send_to_structured_session_uses_acp_prompt() {
let mock = MockDaemon::start().await;
let id = Uuid::from_u128(13);
let mut s = session(id, "/tmp", "claude");
s.view = SessionView::Structured;
mock.add_session(s);
let args = maestro_cli::cli::SendArgs {
session: id.to_string(),
text: vec!["fix".into(), "the".into(), "tests".into()],
no_enter: false,
};
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
.await
.expect("send");
assert_eq!(sent, id);
// Terminal input must stay empty — the message went through ACP.
assert!(mock.inputs().is_empty());
let prompts = mock.structured_prompts();
assert_eq!(prompts.len(), 1);
assert_eq!(prompts[0].0, id);
assert_eq!(prompts[0].1.prompt, "fix the tests");
}
#[tokio::test] #[tokio::test]
async fn send_to_an_unknown_session_fails_without_typing_anything() { async fn send_to_an_unknown_session_fails_without_typing_anything() {
let mock = MockDaemon::start().await; let mock = MockDaemon::start().await;
+38 -1
View File
@@ -22,7 +22,8 @@ use chrono::Utc;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{ use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing, routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION, GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, StructuredPromptRequest,
PROTOCOL_VERSION,
}; };
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::{broadcast, oneshot}; use tokio::sync::{broadcast, oneshot};
@@ -60,6 +61,8 @@ struct Inner {
forced_error: Option<(u16, ApiError)>, forced_error: Option<(u16, ApiError)>,
/// Everything `POST /api/sessions/:id/input` was asked to type, in order. /// Everything `POST /api/sessions/:id/input` was asked to type, in order.
inputs: Vec<(Uuid, SendInputRequest)>, inputs: Vec<(Uuid, SendInputRequest)>,
/// Every ACP prompt sent to a structured session, in order.
structured_prompts: Vec<(Uuid, StructuredPromptRequest)>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -92,6 +95,10 @@ impl MockDaemon {
.route(routes::SESSIONS, get(list_sessions).post(create_session)) .route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION, delete(kill_session)) .route(routes::patterns::SESSION, delete(kill_session))
.route(routes::patterns::SESSION_INPUT, post(send_input)) .route(routes::patterns::SESSION_INPUT, post(send_input))
.route(
routes::patterns::STRUCTURED_PROMPT,
post(structured_prompt),
)
.route(routes::INFO, get(daemon_info)) .route(routes::INFO, get(daemon_info))
.route(routes::DIRS, get(list_dir)) .route(routes::DIRS, get(list_dir))
.route(routes::RECENT_DIRS, get(recent_dirs)) .route(routes::RECENT_DIRS, get(recent_dirs))
@@ -178,6 +185,16 @@ impl MockDaemon {
self.state.inner.lock().expect("poisoned").inputs.clone() self.state.inner.lock().expect("poisoned").inputs.clone()
} }
/// Every ACP prompt this daemon received, oldest first.
pub fn structured_prompts(&self) -> Vec<(Uuid, StructuredPromptRequest)> {
self.state
.inner
.lock()
.expect("poisoned")
.structured_prompts
.clone()
}
pub fn session_count(&self) -> usize { pub fn session_count(&self) -> usize {
self.state.inner.lock().expect("poisoned").sessions.len() self.state.inner.lock().expect("poisoned").sessions.len()
} }
@@ -266,6 +283,26 @@ async fn send_input(
StatusCode::NO_CONTENT.into_response() StatusCode::NO_CONTENT.into_response()
} }
async fn structured_prompt(
State(s): State<AppState>,
Path(id): Path<Uuid>,
Json(req): Json<StructuredPromptRequest>,
) -> Response {
if let Some(r) = forced(&s) {
return r;
}
let mut guard = s.inner.lock().expect("poisoned");
if !guard.sessions.iter().any(|x| x.id == id) {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("not_found", "no such session on this daemon")),
)
.into_response();
}
guard.structured_prompts.push((id, req));
StatusCode::NO_CONTENT.into_response()
}
async fn daemon_info(State(s): State<AppState>) -> Response { async fn daemon_info(State(s): State<AppState>) -> Response {
if let Some(r) = forced(&s) { if let Some(r) = forced(&s) {
return r; return r;
+7 -4
View File
@@ -193,10 +193,13 @@ choices, not daemon ones.
one collapsed turn activity block. `AgentText` and `AgentThought` rows that one collapsed turn activity block. `AgentText` and `AgentThought` rows that
appear *between* tool rows in the same turn are absorbed into the group appear *between* tool rows in the same turn are absorbed into the group
(intermediate narration); the last `AgentText` with no subsequent tool stays (intermediate narration); the last `AgentText` with no subsequent tool stays
outside as the prominent final answer. The header still reads outside as the prominent final answer. While a turn is still active
"N tool calls" (counting only tool rows). Opening the group reveals tools (responding or thinking), the most recent intermediate `AgentText` or
and intermediate text in chronological order, with the text rendered in a `AgentThought` in the group is kept outside as visible narration — replaced
subdued style. by the next text row when one arrives, and absorbed into the group when the
turn ends. The header still reads "N tool calls" (counting only tool rows).
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
+34
View File
@@ -446,6 +446,40 @@ describe("structured connection: data flow", () => {
conn.dispose(); conn.dispose();
}); });
it("keeps last intermediate text outside group while turn is active", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
const respondingState: ReducedState = {
turn: { responding: true, thinking: false, active_tool_calls: [] },
};
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 6,
rows: [
row("user:1", "user", { content: { type: "text", text: "do it" } }),
toolRow("tool:1", "one"),
row("agent:1", "agent_text", { content: { type: "text", text: "Now fixing" } }),
toolRow("tool:2", "two"),
row("agent:2", "agent_text", { content: { type: "text", text: "Found more" } }),
toolRow("tool:3", "three"),
],
},
});
ws.emit({ type: "reduced_state", state: respondingState });
// While the turn is active, the last intermediate text (agent:2) stays
// outside the collapsed group so it's visible as current narration.
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).toBeNull();
// Earlier intermediate text (agent:1) is still inside the group.
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull();
// When the turn ends, all intermediate text is absorbed.
ws.emit({ type: "reduced_state", state: idleState });
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull();
conn.dispose();
});
it("keeps an expanded running tool open without emitting a click when patched", () => { it("keeps an expanded running tool open without emitting a click when patched", () => {
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
+77 -7
View File
@@ -85,10 +85,10 @@ export function createStructuredConnection(
<div class="structured-main"> <div class="structured-main">
<div class="structured-scroll"> <div class="structured-scroll">
<div class="structured-rows"></div> <div class="structured-rows"></div>
</div> <div class="structured-activity" role="status" aria-live="polite" hidden>
<div class="structured-activity" role="status" aria-live="polite" hidden> <span class="structured-activity-pulse" aria-hidden="true"></span>
<span class="structured-activity-pulse" aria-hidden="true"></span> <span class="structured-activity-text">Thinking</span>
<span class="structured-activity-text">Thinking</span> </div>
</div> </div>
<form class="structured-composer"> <form class="structured-composer">
<button type="button" class="btn btn-ghost btn-icon structured-attach" aria-label="Attach files" title="Attach files (up to 20 MiB each)"> <button type="button" class="btn btn-ghost btn-icon structured-attach" aria-label="Attach files" title="Attach files (up to 20 MiB each)">
@@ -253,6 +253,8 @@ export function createStructuredConnection(
let activityTimer: number | null = null; let activityTimer: number | null = null;
const promptQueue: QueuedPrompt[] = []; const promptQueue: QueuedPrompt[] = [];
let promptRequestPending = false; let promptRequestPending = false;
/** Server path → object URL for uploaded images, so user bubbles can show previews. */
const imageObjectUrls = new Map<string, string>();
// Within 64px, wheel and touch imprecision should still count as following. // Within 64px, wheel and touch imprecision should still count as following.
const FOLLOW_BOTTOM_THRESHOLD_PX = 64; const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
@@ -383,6 +385,9 @@ export function createStructuredConnection(
} }
} }
/** Pattern matching `Attached file "name": "path"` references appended by dispatchPrompt. */
const attachedFileRe = /Attached file "(?:[^"\\]|\\.)*": "((?:[^"\\]|\\.)*)"/g;
function buildRowElement(row: TranscriptRow): HTMLElement | null { function buildRowElement(row: TranscriptRow): HTMLElement | null {
const el = buildTranscriptRow( const el = buildTranscriptRow(
row, row,
@@ -399,6 +404,39 @@ export function createStructuredConnection(
if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") { if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") {
expandedRowIds.delete(row.id); expandedRowIds.delete(row.id);
} }
// Inject image previews into user message bubbles and strip the path text.
if (row.kind === "user" && imageObjectUrls.size > 0) {
const data = row.data as { content?: { type: string; text?: string } } | undefined;
const text = data?.content?.type === "text" ? data.content.text ?? "" : "";
const body = el.querySelector(".st-body");
if (body && text) {
const images: string[] = [];
attachedFileRe.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = attachedFileRe.exec(text)) !== null) {
const serverPath = JSON.parse(`"${match[1]}"`);
const objectUrl = imageObjectUrls.get(serverPath);
if (objectUrl) images.push(objectUrl);
}
if (images.length > 0) {
// Strip attachment reference lines from the displayed text.
const userText = body.querySelector(".st-user-text");
if (userText) {
const cleaned = text.replace(/\n\nAttached file "[^"]*": "[^"]*"/g, "").trim();
userText.textContent = cleaned;
if (!cleaned) userText.remove();
}
for (const src of images) {
const img = document.createElement("img");
img.className = "st-user-image";
img.src = src;
img.alt = "Uploaded image";
img.loading = "lazy";
body.appendChild(img);
}
}
}
}
return el; return el;
} }
@@ -453,6 +491,25 @@ export function createStructuredConnection(
} }
} }
// pending at end of a turn = final text/approvals, stays outside (not flushed). // pending at end of a turn = final text/approvals, stays outside (not flushed).
// While the turn is still active, keep the last intermediate text/thought
// row outside the collapsed group so it's visible as the agent's current
// narration. It gets absorbed on the next rebuild once newer content
// arrives or the turn ends.
const turnActive = state.turn.responding || state.turn.thinking;
if (turnActive) {
const lastGroup = groups.get(currentGid);
if (lastGroup) {
for (let i = lastGroup.rows.length - 1; i >= 0; i--) {
const kind = lastGroup.rows[i].kind;
if (kind === "agent_text" || kind === "agent_thought") {
lastGroup.rows.splice(i, 1);
break;
}
}
}
}
return groups; return groups;
} }
@@ -698,6 +755,7 @@ export function createStructuredConnection(
function renderActivity(): void { function renderActivity(): void {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null; const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const wasHidden = activityEl.hidden;
activityEl.hidden = !active; activityEl.hidden = !active;
if (!active) { if (!active) {
optimisticTurnStartedAt = null; optimisticTurnStartedAt = null;
@@ -720,6 +778,11 @@ export function createStructuredConnection(
} }
activityText.textContent = parts.join(" · "); activityText.textContent = parts.join(" · ");
if (activityTimer === null) activityTimer = window.setInterval(renderActivity, 1_000); if (activityTimer === null) activityTimer = window.setInterval(renderActivity, 1_000);
// Activity lives inside the scroll area; nudge scroll when it first appears.
if (wasHidden && followingBottom) {
beginTranscriptLayout(true);
finishTranscriptLayout();
}
} }
function reconcileAwaitingBegin(): void { function reconcileAwaitingBegin(): void {
@@ -735,15 +798,17 @@ export function createStructuredConnection(
} }
function applyState(next: ReducedState, connectionGeneration: number): void { function applyState(next: ReducedState, connectionGeneration: number): void {
const wasTurnActive = state.turn.responding || state.turn.thinking;
state = next; state = next;
stateSocketGeneration = connectionGeneration; stateSocketGeneration = connectionGeneration;
reconcileAwaitingBegin(); reconcileAwaitingBegin();
const isTurnActive = state.turn.responding || state.turn.thinking;
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null; const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
if (newPendingId !== pendingApprovalRowId) { const needsRebuild = newPendingId !== pendingApprovalRowId || wasTurnActive !== isTurnActive;
if (needsRebuild) {
updateTranscriptLayout(() => { updateTranscriptLayout(() => {
const prev = pendingApprovalRowId;
pendingApprovalRowId = newPendingId; pendingApprovalRowId = newPendingId;
if (prev !== null || newPendingId !== null) rebuildTranscript(); rebuildTranscript();
}); });
} }
renderConfig(); renderConfig();
@@ -964,6 +1029,9 @@ export function createStructuredConnection(
for (const attachment of submittedAttachments) { for (const attachment of submittedAttachments) {
if (disposed) return; if (disposed) return;
if (!attachment.path) attachment.path = (await uploadStructuredFile(sessionId, attachment.file)).path; if (!attachment.path) attachment.path = (await uploadStructuredFile(sessionId, attachment.file)).path;
if (attachment.path && attachment.file.type.startsWith("image/")) {
imageObjectUrls.set(attachment.path, URL.createObjectURL(attachment.file));
}
} }
if (disposed) return; if (disposed) return;
const references = submittedAttachments.map(({ file, path }) => const references = submittedAttachments.map(({ file, path }) =>
@@ -1203,6 +1271,8 @@ export function createStructuredConnection(
closeCurrentSocket(); closeCurrentSocket();
expandedRowIds.clear(); expandedRowIds.clear();
expandedGroupIds.clear(); expandedGroupIds.clear();
for (const url of imageObjectUrls.values()) URL.revokeObjectURL(url);
imageObjectUrls.clear();
scrollEl.removeEventListener("scroll", onTranscriptScroll); scrollEl.removeEventListener("scroll", onTranscriptScroll);
scrollEl.removeEventListener("click", onTranscriptClick, true); scrollEl.removeEventListener("click", onTranscriptClick, true);
scrollEl.removeEventListener("wheel", stopSmoothFollow); scrollEl.removeEventListener("wheel", stopSmoothFollow);
+9 -2
View File
@@ -2318,9 +2318,7 @@ input.leaf-rename:focus {
gap: var(--sp-2); gap: var(--sp-2);
min-height: 30px; min-height: 30px;
padding: var(--sp-1) var(--sp-3); padding: var(--sp-1) var(--sp-3);
border-top: 1px solid var(--border);
color: var(--muted); color: var(--muted);
background: var(--surface-2);
font-size: var(--fs-11); font-size: var(--fs-11);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -2382,6 +2380,15 @@ input.leaf-rename:focus {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.st-user-image {
display: block;
max-width: min(100%, 480px);
max-height: 360px;
margin-top: var(--sp-2);
border-radius: var(--r-1);
object-fit: contain;
}
/* agent prose: no card, no background — plain text is the point */ /* agent prose: no card, no background — plain text is the point */
.st-row-agent .st-body p { .st-row-agent .st-body p {
margin: 0 0 var(--sp-2); margin: 0 0 var(--sp-2);