Compare commits

...
27 Commits
Author SHA1 Message Date
Miguel PalhasandClaude Opus 4.6 c2d427691d fix(web): stop tool-group flashing and config-menu closure on updates
Reuse existing tool-group DOM elements in rebuildTranscript instead of
tearing them down — preserves CSS transition state and focus. Defer
config-menu DOM rebuild while the menu is open so <select> dropdowns
survive background state updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 14:26:14 +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
Miguel PalhasandClaude Opus 4.6 3c10a3a08a fix(maestro): skip Starting sessions in liveness watcher
ci / web (push) Failing after 21s
ci / flake (push) Successful in 3m30s
ci / rust (push) Successful in 6m8s
A structured session still booting its adapter has no ACP handle and
no tmux pane, so the liveness pass misclassified it as a vanished
terminal session and reaped it. Claude Code adapters take long enough
to boot that the 2s liveness tick fires before the handshake finishes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 07:25:48 +01:00
Miguel Palhas fd79c8116a Merge remote-tracking branch 'origin/main' into tuba
ci / web (push) Failing after 7s
ci / flake (push) Successful in 3m15s
ci / rust (push) Successful in 5m54s
2026-09-07 21:49:31 +01:00
Miguel PalhasandClaude Opus 4.6 cb8b93f178 test(maestro): update tests for async structured spawn
Tests now assert `Starting` after create and use `wait_for_ready`
before checking post-handshake state. Unsupported command test
expects `BadRequest` instead of a persisted Dead session.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 21:28:22 +01:00
Miguel PalhasandClaude Opus 4.6 60e57fde31 feat(web): collapse resolved approval rows
ci / web (push) Failing after 25s
ci / flake (push) Successful in 3m36s
ci / rust (push) Successful in 6m12s
Resolved permission boxes vanish from the transcript instead of
lingering as "answered" cards. When a turn has 2+ tools the resolved
approval is absorbed into the tool group as a compact italic line;
otherwise it is hidden entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 21:20:31 +01:00
Miguel PalhasandClaude Opus 4.6 8d35f7f8c8 feat(maestro): async structured session spawn
Register a structured session immediately with `activity: Starting`
and run the ACP handshake in a background task. The HTTP caller gets
an instant response; the user can begin typing a prompt while the
adapter boots.

- Add `ActivityState::Starting` to the protocol
- Open the event log eagerly so the structured WS can connect during
  boot (empty transcript + idle state)
- `queue_acp_prompt` stashes a prompt while `Starting`; the background
  task drains it on successful handshake
- `apply_structured_activity` skips overwriting `Starting`
- `subscribe_acp_projection` allows connecting during `Starting`
- TUI/CLI map `Starting` to idle colour / "starting" label

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 21:08:08 +01:00
Miguel Palhas bc0fd6ee60 Merge branch 'etude'
ci / web (push) Failing after 20s
ci / flake (push) Successful in 3m32s
ci / rust (push) Successful in 6m3s
2026-09-07 20:56:24 +01:00
Miguel PalhasandClaude Opus 4.6 ed5a088299 feat(web): equal-width project cards, drop worktree counters
Project boxes now use a fixed two-column grid so every card is the
same width. The worktree count line is removed — the information
lives in the worktree picker that follows a project selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 20:48:43 +01:00
Miguel Palhas 9c77965c51 Merge remote-tracking branch 'origin/main' into arpeggio
ci / web (push) Failing after 29s
ci / rust (push) Successful in 6m48s
ci / flake (push) Successful in 4m8s
2026-09-07 20:33:38 +01:00
Miguel PalhasandClaude Opus 4.6 49df4c2e9e fix(web): surgical DOM patch for tool rows to stop flashing
Patch deltas on existing rows now swap just the affected element via
replaceWith() instead of tearing down and rebuilding the entire
transcript. Stops the visible flash and preserves text selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 20:33:34 +01:00
Miguel PalhasandClaude Opus 4.6 65d7186e1c feat(web): harness cards with tool logos lead the spawn dialog
ci / rust (push) Successful in 8m28s
ci / web (push) Successful in 25s
ci / flake (push) Successful in 3m27s
Move the harness/command choice above the project picker so the
operator decides what runs before where. Replace the flat command
tiles with larger centered cards that render each tool's icon from
toolIcon.ts at 24x24. The Custom escape hatch gets a three-dot
ellipsis icon and a dashed border to read as secondary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 20:29:35 +01:00
Miguel PalhasandClaude Opus 4.6 47654aa434 feat(web): gate sidebar hover preview on ctrl
ci / rust (push) Failing after 5s
ci / web (push) Successful in 26s
ci / flake (push) Successful in 3m0s
Plain hover no longer previews — ctrl+hover does.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 15:22:58 +01:00
Miguel PalhasandClaude Opus 4.6 6ca2ab1707 merge: resolve conflict with main's prompt queue
ci / web (push) Successful in 30s
ci / flake (push) Successful in 3m38s
ci / rust (push) Successful in 6m18s
Keep icon cancel button, always-"Send" label, and queue status span
from main. Drop unused `active` local after label simplification.
Update test expectation to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 14:35:47 +01:00
Miguel PalhasandClaude Opus 4.6 b0b0160ccb feat(web): align composer and activity to content width, icon cancel button
Move max-width constraint from .structured-rows up to .structured-main
so the activity banner and composer match the transcript width. Replace
text cancel button with a stop-sign icon inside a spinning ring. Drop
the accent border-top on composer focus.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 14:32:56 +01:00
naps62 c4cd8a691e Merge pull request 'fix(web): queue structured prompts' (#204) from quaver into main
ci / web (push) Successful in 30s
ci / flake (push) Successful in 3m46s
ci / rust (push) Successful in 6m33s
Reviewed-on: #204
2026-09-07 12:28:49 +01:00
Miguel Palhas 0381a571f2 Merge remote-tracking branch 'origin/main' into brillante
ci / web (push) Failing after 6s
ci / flake (push) Successful in 4m23s
ci / rust (push) Successful in 7m8s
2026-09-07 11:42:28 +01:00
Miguel PalhasandClaude Opus 4.6 8f3d47c49e feat(web): absorb intermediate agent text into tool groups
Intermediate agent text/thought rows between tool calls in the same
turn now render inside the collapsed tool group instead of after it,
preserving chronological order. The last agent text (no subsequent
tool) stays outside as the prominent final answer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 11:42:25 +01:00
Miguel Palhas ba0c54759b fix(tui): complete test presets
ci / web (pull_request) Successful in 27s
ci / flake (pull_request) Successful in 47s
ci / rust (pull_request) Successful in 7m3s
2026-09-07 11:39:51 +01:00
Miguel Palhas 5387b29049 Merge remote-tracking branch 'origin/main' into quaver
ci / web (pull_request) Successful in 30s
ci / flake (pull_request) Successful in 49s
ci / rust (pull_request) Failing after 52s
2026-09-07 11:25:33 +01:00
Miguel Palhas df51aa7be9 Merge remote-tracking branch 'origin/main' into quaver
ci / web (pull_request) Successful in 27s
ci / flake (pull_request) Successful in 46s
ci / rust (pull_request) Successful in 5m16s
# Conflicts:
#	web/src/structured.ts
2026-09-07 11:06:13 +01:00
Miguel Palhas 43bfda5a60 fix(web): queue structured prompts
ci / web (pull_request) Successful in 47s
ci / flake (pull_request) Successful in 1m17s
ci / rust (pull_request) Successful in 9m35s
2026-09-07 10:42:49 +01:00
26 changed files with 1124 additions and 471 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git fetch:*)",
"Bash(git merge:*)",
"Bash(npx tsc:*)",
"Bash(npm run build:*)",
"Bash(npm install:*)",
"Bash(./node_modules/.bin/tsc:*)",
"Bash(npm test:*)",
"Bash(cargo check:*)",
"Bash(cargo clippy:*)"
]
}
}
+2 -2
View File
@@ -41,8 +41,8 @@ pub enum Command {
Attach(AttachArgs),
/// Kill a session by ID.
Kill(KillArgs),
/// Type text into a running session, as if it had been typed on an
/// attached terminal.
/// Send text to a running session. Terminal sessions receive raw
/// keystrokes; structured sessions receive an ACP prompt.
Send(SendArgs),
/// Install or update the agent activity hook (#106).
Hooks(HooksArgs),
+22 -9
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
/// 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
/// against. Text comes from the arguments, or from stdin when there is none —
/// the shape a heredoc or a pipe wants.
/// Terminal sessions get raw keystrokes via `send_input`; structured sessions
/// get an ACP prompt via `send_structured_prompt` (`--no-enter` is ignored
/// 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)]
pub async fn run_send(
client: &DaemonClient,
@@ -155,19 +158,28 @@ pub async fn run_send(
return Err(CliError::NothingToSend);
}
let id = match uuid::Uuid::parse_str(args.session.trim()) {
Ok(id) => id,
Err(_) => {
// We always need the session list: even a bare UUID needs the view field
// to decide which endpoint to hit.
let sessions = client.list_sessions().await?;
resolve_session(&sessions, &args.session)?
}
};
let id = resolve_session(&sessions, &args.session)?;
let view = sessions
.iter()
.find(|s| s.id == id)
.map(|s| s.view)
.unwrap_or(SessionView::Terminal);
match view {
SessionView::Structured => {
client.send_structured_prompt(id, &text).await?;
}
SessionView::Terminal => {
let req = SendInputRequest {
text,
submit: !args.no_enter,
};
client.send_input(id, &req).await?;
}
}
Ok(id)
}
@@ -216,6 +228,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
ActivityState::Idle => "idle",
ActivityState::AwaitingInput => "awaiting",
ActivityState::Error => "error",
ActivityState::Starting => "starting",
};
// Show the live foreground command alongside the spawn command when
// the daemon has observed one that differs (NAP-340).
@@ -2,7 +2,7 @@
//! was unit-tested, but nothing exercised the client, the error decoding, or
//! 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 uuid::Uuid;
@@ -202,6 +202,35 @@ async fn send_resolves_a_session_by_name() {
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]
async fn send_to_an_unknown_session_fails_without_typing_anything() {
let mock = MockDaemon::start().await;
+4 -2
View File
@@ -180,7 +180,7 @@ fn dir_error_status(code: &str) -> StatusCode {
fn session_error_status(code: &str) -> StatusCode {
match code {
"not_found" => StatusCode::NOT_FOUND,
"invalid_env" => StatusCode::BAD_REQUEST,
"invalid_env" | "bad_request" => StatusCode::BAD_REQUEST,
// A guard refusal is the daemon doing its job, not a failure (#79).
"kill_guarded" => StatusCode::CONFLICT,
// The session outlived its pane: nothing to type into, and nothing
@@ -483,7 +483,9 @@ async fn structured_prompt(
Json(body): Json<StructuredPromptRequest>,
) -> Response {
match s.manager.queue_acp_prompt(id, body.prompt).await {
Ok(QueueAcpPrompt::Queued) => StatusCode::NO_CONTENT.into_response(),
Ok(QueueAcpPrompt::Queued) | Ok(QueueAcpPrompt::Pending) => {
StatusCode::NO_CONTENT.into_response()
}
Ok(QueueAcpPrompt::InFlight) => error_response(
StatusCode::CONFLICT,
"turn_in_flight",
+206 -158
View File
@@ -6,12 +6,9 @@
//! handler interact with tmux.
use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Weak};
use std::task::{Context, Poll};
use std::time::Duration;
use chrono::Utc;
@@ -125,6 +122,9 @@ pub struct SessionRecord {
/// When `true`, the permission pump auto-responds `AllowAlways` to every
/// ACP permission request without surfacing it to clients.
pub auto_approve: bool,
/// A prompt submitted while the adapter was still booting (`Starting`).
/// The background handshake task drains this on success.
pub pending_prompt: Option<String>,
}
impl SessionRecord {
@@ -134,6 +134,13 @@ impl SessionRecord {
&& self.acp.is_some()
&& self.acp_events.is_some()
}
/// The session is structured and still booting its adapter.
fn is_starting_structured(&self) -> bool {
self.session.view == SessionView::Structured
&& self.session.activity == ActivityState::Starting
&& self.acp.is_none()
}
}
#[derive(Debug)]
@@ -208,6 +215,8 @@ pub enum SessionError {
NotTerminal(Uuid),
#[error("daemon is shutting down")]
ShuttingDown,
#[error("bad request: {0}")]
BadRequest(String),
/// The kill was refused (#79): the session's worktree holds unsaved work.
/// Nothing happened — the session keeps running and the tree is untouched,
/// so it stays the operator's handle on their own unfinished work.
@@ -227,6 +236,8 @@ pub enum BeginAcpTurn {
#[derive(Debug)]
pub enum QueueAcpPrompt {
Queued,
/// The adapter is still booting; the prompt is held until it is ready.
Pending,
InFlight,
Unavailable,
Failed(String),
@@ -253,6 +264,7 @@ impl SessionError {
SessionError::NotRunning(_) => "not_running",
SessionError::NotTerminal(_) => "not_terminal",
SessionError::ShuttingDown => "shutting_down",
SessionError::BadRequest(_) => "bad_request",
SessionError::KillGuarded(_) => "kill_guarded",
};
let unsaved_work = match self {
@@ -320,18 +332,6 @@ struct StructuredLifecycle {
}
impl StructuredLifecycle {
fn admit<'a>(
&'a self,
shutting_down: &AtomicBool,
) -> Result<StructuredCreate<'a>, SessionError> {
let _gate = self.gate.lock().expect("structured lifecycle gate");
if shutting_down.load(Ordering::Acquire) {
return Err(SessionError::ShuttingDown);
}
self.in_flight.fetch_add(1, Ordering::AcqRel);
Ok(StructuredCreate { lifecycle: self })
}
async fn wait_until_settled(&self) {
loop {
let settled = self.settled.notified();
@@ -343,30 +343,18 @@ impl StructuredLifecycle {
}
}
struct StructuredCreate<'a> {
lifecycle: &'a StructuredLifecycle,
}
impl Drop for StructuredCreate<'_> {
fn drop(&mut self) {
if self.lifecycle.in_flight.fetch_sub(1, Ordering::AcqRel) == 1 {
self.lifecycle.settled.notify_one();
}
}
}
struct AdmittedStructuredCreate<'a> {
create: Pin<Box<dyn Future<Output = Result<Session, SessionError>> + Send + 'a>>,
/// Field order matters: dropping a cancelled create rolls back its inner
/// future before this permit releases shutdown's in-flight barrier.
_permit: StructuredCreate<'a>,
}
impl Future for AdmittedStructuredCreate<'_> {
type Output = Result<Session, SessionError>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
self.create.as_mut().poll(cx)
/// Release the structured-lifecycle in-flight counter that `create` acquires
/// before spawning the background ACP handshake task. Called when the task
/// finishes (success or failure) or when `create_inner` fails before the task
/// is spawned.
fn drop_structured_lifecycle_permit(inner: &Inner) {
if inner
.structured_lifecycle
.in_flight
.fetch_sub(1, Ordering::AcqRel)
== 1
{
inner.structured_lifecycle.settled.notify_one();
}
}
@@ -660,7 +648,9 @@ impl SessionManager {
let stream = {
let sessions = self.inner.sessions.lock().await;
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
if !record.is_live_structured() {
// Allow connecting while the adapter is still booting
// (`Starting`) — the event stream exists, it is just empty.
if !record.is_live_structured() && !record.is_starting_structured() {
return Ok(None);
}
let Some(stream) = &record.acp_events else {
@@ -745,8 +735,14 @@ impl SessionManager {
text: String,
) -> Result<QueueAcpPrompt, SessionError> {
let (acp, stream) = {
let sessions = self.inner.sessions.lock().await;
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
let mut sessions = self.inner.sessions.lock().await;
let record = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
// While the adapter is booting, stash the prompt for the
// background handshake task to drain on success.
if record.is_starting_structured() {
record.pending_prompt = Some(text);
return Ok(QueueAcpPrompt::Pending);
}
if !record.is_live_structured() {
return Ok(QueueAcpPrompt::Unavailable);
}
@@ -1217,17 +1213,42 @@ impl SessionManager {
}
/// Spawn a new session. On success returns the freshly-created `Session`.
///
/// For structured sessions, the ACP handshake runs in a background task
/// after the session is registered — the HTTP caller gets an instant
/// response with `activity: Starting`. The `StructuredLifecycle` gate is
/// held across the background task so `shutdown()` waits for it.
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
if req.view == SessionView::Structured {
let permit = self
// Acquire the lifecycle gate synchronously (rejects if shutting
// down). The background handshake task calls
// `drop_structured_lifecycle_permit` when it finishes; we must
// not use the RAII `StructuredCreate` because it would drop at
// the end of `create_inner`, before the handshake completes.
let _gate = self
.inner
.structured_lifecycle
.admit(&self.inner.shutting_down)?;
return AdmittedStructuredCreate {
create: Box::pin(self.create_inner(req)),
_permit: permit,
.gate
.lock()
.expect("structured lifecycle gate");
if self.inner.shutting_down.load(Ordering::Acquire) {
return Err(SessionError::ShuttingDown);
}
.await;
self.inner
.structured_lifecycle
.in_flight
.fetch_add(1, Ordering::AcqRel);
drop(_gate);
return match self.create_inner(req).await {
Ok(session) => Ok(session),
Err(e) => {
// create_inner failed before spawning the background
// task, so release the permit here.
drop_structured_lifecycle_permit(&self.inner);
Err(e)
}
};
}
self.create_inner(req).await
}
@@ -1289,32 +1310,39 @@ impl SessionManager {
{
session_env.insert("MAESTRO_URL".into(), url);
}
// A structured session (#134) gets no tmux pane: the agent is a
// child of the daemon, driven over ACP. An adapter that cannot come
// up still yields a session — Dead, with the reason on it — so the
// operator sees what to fix instead of a silently missing session.
let (acp, acp_failure) = match req.view {
SessionView::Structured => match structured_adapter(&req.command) {
Err(error) => (None, Some(error)),
Ok(adapter) => match maestro_acp::spawn(
SpawnConfig::new(
&effective_cwd,
session_env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
)
.adapter(adapter),
)
.await
{
Ok(acp) => (Some(Arc::new(acp)), None),
Err(e) => {
tracing::warn!(%id, error = %e, "structured session: adapter failed to start");
(None, Some(e.to_string()))
// ── Structured path: validate adapter, open event log, register
// the session immediately with `Starting`, then handshake in the
// background so the HTTP caller gets an instant response.
//
// ── Terminal path: unchanged — tmux spawn is fast enough to stay
// synchronous.
// Structured: validate adapter, open event log eagerly. Terminal:
// spawn the tmux pane synchronously (it is fast).
let structured_spawn = match req.view {
SessionView::Structured => {
// Fail fast on an unsupported command before touching disk.
let adapter = structured_adapter(&req.command).map_err(SessionError::BadRequest)?;
// Open the event log eagerly so the structured WS can
// connect before the adapter is up.
let acp_events = match self.inner.sidecar.open_events(id) {
Ok(log) => {
let (tx, _) = broadcast::channel(EVENTS_CAPACITY);
let (projected_tx, _) = broadcast::channel(EVENTS_CAPACITY);
Arc::new(Mutex::new(AcpEventStream {
log,
live: tx,
projection: Projection::default(),
projected_live: projected_tx,
pending_approvals: HashMap::new(),
operation: Arc::new(Mutex::new(())),
dispatch: AcpDispatchState::default(),
activity_sequence: 0,
}))
}
Err(e) => return Err(SessionError::Sidecar(e)),
};
Some((adapter, acp_events))
}
},
},
SessionView::Terminal => {
let spawned = {
let tmux = self.inner.tmux.clone();
@@ -1328,51 +1356,8 @@ impl SessionManager {
if let Err(e) = spawned {
return Err(SessionError::Tmux(e));
}
(None, None)
}
};
let acp_events = if let Some(acp) = &acp {
let persisted =
match self
.inner
.sidecar
.read_events(id, EventCursor::Since(0), usize::MAX)
{
Ok(events) => events,
Err(error) => {
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
return Err(SessionError::Sidecar(error));
}
};
match self.inner.sidecar.open_events(id) {
Ok(log) => {
let (tx, _) = broadcast::channel(EVENTS_CAPACITY);
let (projected_tx, _) = broadcast::channel(EVENTS_CAPACITY);
let mut projection = Projection::default();
for event in &persisted {
apply_persisted_event(&mut projection, event);
}
Some(Arc::new(Mutex::new(AcpEventStream {
log,
live: tx,
projection,
projected_live: projected_tx,
pending_approvals: HashMap::new(),
operation: Arc::new(Mutex::new(())),
dispatch: AcpDispatchState::default(),
activity_sequence: 0,
})))
}
Err(e) => {
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
return Err(SessionError::Sidecar(e));
}
}
} else {
None
}
};
// Capture spawn-history inputs before `req` is partially moved into
@@ -1384,10 +1369,13 @@ impl SessionManager {
resolve_name(&mut metadata);
let worktree_branch = req.worktree.as_ref().map(|w| w.branch.clone());
let is_structured = structured_spawn.is_some();
let events_arc = structured_spawn.as_ref().map(|(_, e)| e.clone());
let session = Session {
id,
cwd: effective_cwd,
command: req.command,
cwd: effective_cwd.clone(),
command: req.command.clone(),
env: req.env,
metadata,
worktree: worktree_path.as_ref().zip(worktree_base_repo.as_ref()).map(
@@ -1396,67 +1384,41 @@ impl SessionManager {
base_repo: base_repo.clone(),
},
),
status: if acp_failure.is_some() {
SessionStatus::Dead
} else {
SessionStatus::Running
},
status: SessionStatus::Running,
created_at: Utc::now(),
// A terminal spawn begins active until its watcher sees otherwise;
// a structured spawn has no turn yet and begins idle.
foreground: None,
activity: match (req.view, acp_failure.is_some()) {
(_, true) => ActivityState::Error,
(SessionView::Structured, false) => ActivityState::Idle,
(SessionView::Terminal, false) => ActivityState::Active,
activity: if is_structured {
ActivityState::Starting
} else {
ActivityState::Active
},
activity_detail: acp_failure,
activity_detail: None,
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
// The watcher fills this in on its first blocked-work pass; a
// fresh tree has nothing in the way anyway.
blocked_work: None,
last_focused_at: None,
acp_session_id: acp.as_ref().map(|a| a.session_id().to_string()),
acp_session_id: None,
view: req.view,
};
if let Err(e) = self.inner.sidecar.write(&session) {
// The process already started; end it so no ghost survives a
// failed sidecar write, then unwind the FIFO + worktree.
match &acp {
Some(acp) => {
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await
}
None => {
if !is_structured {
let tmux = self.inner.tmux.clone();
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
}
}
return Err(SessionError::Sidecar(e));
}
let event_pump = acp
.as_ref()
.zip(acp_events.as_ref())
.map(|(acp, events)| (acp.clone(), events.clone()));
{
let mut sessions = self.inner.sessions.lock().await;
let event_pump = event_pump.map(|(acp, events)| {
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
start_acp_permission_pump(&self.inner, id, &acp, events, req.auto_approve);
done
});
sessions.insert(
id,
SessionRecord {
session: session.clone(),
fifo_path,
worktree_path,
worktree_base_repo,
worktree_path: worktree_path.clone(),
worktree_base_repo: worktree_base_repo.clone(),
bridge,
reported_activity: None,
settled: false,
@@ -1464,11 +1426,12 @@ impl SessionManager {
naming: false,
focused_since: None,
awaiting_suppressed: false,
acp,
acp_events,
acp: None,
acp_events: events_arc,
structured_activity_watermark: None,
acp_event_pump: event_pump,
acp_event_pump: None,
auto_approve: req.auto_approve,
pending_prompt: None,
},
);
}
@@ -1497,6 +1460,78 @@ impl SessionManager {
}
pending.commit();
// ── Background ACP handshake for structured sessions ──
//
// The session is registered and visible to clients. The adapter
// spawn + handshake runs in a background task; on completion it
// fills in `acp`, starts the event/permission pumps, and
// transitions activity from `Starting` to `Idle` (or to `Error`
// on failure). The `StructuredLifecycle` permit is held until the
// task finishes so `shutdown()` waits for it.
if let Some((adapter, events)) = structured_spawn {
let inner = self.inner.clone();
let env: HashMap<String, String> = session_env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let cwd = effective_cwd;
let auto_approve = req.auto_approve;
tokio::spawn(async move {
let result = maestro_acp::spawn(SpawnConfig::new(&cwd, env).adapter(adapter)).await;
match result {
Ok(acp_session) => {
let acp = Arc::new(acp_session);
let done = start_acp_event_pump(&inner, id, &acp, events.clone());
start_acp_permission_pump(&inner, id, &acp, events.clone(), auto_approve);
// Drain any prompt submitted while Starting.
let pending_prompt = {
let mut sessions = inner.sessions.lock().await;
let Some(record) = sessions.get_mut(&id) else {
// Session was killed while we were booting.
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
drop_structured_lifecycle_permit(&inner);
return;
};
record.acp = Some(acp.clone());
record.acp_event_pump = Some(done);
record.session.acp_session_id = Some(acp.session_id().to_string());
record.session.activity = ActivityState::Idle;
record.session.activity_since = Utc::now();
let _ = inner.sidecar.write(&record.session);
let _ = inner.events.send(CatalogEvent::SessionUpdated {
session: record.session.clone(),
});
record.pending_prompt.take()
};
if let Some(prompt) = pending_prompt {
let manager = SessionManager {
inner: inner.clone(),
};
let _ = manager.queue_acp_prompt(id, prompt).await;
}
}
Err(e) => {
tracing::warn!(%id, error = %e, "structured session: adapter failed to start");
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.session.status = SessionStatus::Dead;
record.session.activity = ActivityState::Error;
record.session.activity_detail = Some(e.to_string());
record.session.activity_since = Utc::now();
let _ = inner.sidecar.write(&record.session);
let _ = inner.events.send(CatalogEvent::SessionUpdated {
session: record.session.clone(),
});
}
}
}
drop_structured_lifecycle_permit(&inner);
});
}
Ok(session)
}
@@ -1682,6 +1717,7 @@ impl SessionManager {
structured_activity_watermark: None,
acp_event_pump: None,
auto_approve: false,
pending_prompt: None,
},
);
let _ = self
@@ -1862,6 +1898,7 @@ impl SessionManager {
// Recovery cannot know the original preset's flag; the
// operator re-enables it by restarting with the preset.
auto_approve: false,
pending_prompt: None,
},
);
if let Some((acp, events)) = event_pump {
@@ -2321,6 +2358,11 @@ async fn apply_structured_activity(
if record.session.view != SessionView::Structured {
return;
}
// While the adapter is still booting, the background handshake task
// owns the activity state — the activity pass must not overwrite it.
if record.session.activity == ActivityState::Starting {
return;
}
let current_events = record.acp_events.as_ref();
match (expected_events, current_events) {
(Some((expected, sequence)), Some(current)) if Arc::ptr_eq(expected, current) => {
@@ -2586,6 +2628,12 @@ async fn run_liveness_pass(inner: &Arc<Inner>) {
let mut tmux_backed = Vec::new();
let mut acp_backed = Vec::new();
for (id, record) in running {
if record.is_starting_structured() {
// The adapter hasn't finished its handshake yet — no ACP
// handle and no tmux pane. Skip; the background task owns
// this session's lifecycle until the handshake settles.
continue;
}
match &record.acp {
Some(acp) => acp_backed.push((*id, acp.clone())),
None => tmux_backed.push(*id),
+134 -80
View File
@@ -130,6 +130,25 @@ async fn wait_for_exit(pid: u32) {
assert!(!pid_alive(pid), "pid {pid} still alive");
}
/// Wait for a session's background ACP handshake to complete (activity
/// transitions out of `Starting`). Returns the refreshed session.
async fn wait_for_ready(manager: &SessionManager, id: uuid::Uuid) -> maestro_protocol::Session {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let session = manager
.snapshot()
.await
.into_iter()
.find(|s| s.id == id)
.expect("session exists");
if session.activity != ActivityState::Starting {
return session;
}
assert!(Instant::now() < deadline, "handshake timed out");
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
#[tokio::test]
async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
let fx = DaemonFixture::new();
@@ -138,7 +157,7 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
let mut env = fake.env();
env.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
let session = fx
let created = fx
.manager
.create(structured_command(
cwd.path(),
@@ -148,8 +167,12 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
.await
.expect("create");
assert_eq!(session.view, SessionView::Structured);
assert_eq!(session.status, SessionStatus::Running);
assert_eq!(created.view, SessionView::Structured);
assert_eq!(created.status, SessionStatus::Running);
assert_eq!(created.activity, ActivityState::Starting);
assert!(created.acp_session_id.is_none());
let session = wait_for_ready(&fx.manager, created.id).await;
assert_eq!(session.activity, ActivityState::Idle);
let acp_id = session.acp_session_id.clone().expect("acp session id");
assert!(acp_id.starts_with("fake-"), "{acp_id}");
@@ -164,7 +187,7 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
"{log}"
);
assert!(
log.contains(&format!("env MAESTRO_SESSION_ID={}", session.id)),
log.contains(&format!("env MAESTRO_SESSION_ID={}", created.id)),
"{log}"
);
@@ -232,6 +255,7 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
.create(structured_command(cwd.path(), env, "codex"))
.await
.unwrap();
let session = wait_for_ready(&fx.manager, session.id).await;
assert!(matches!(
fx.manager
@@ -295,11 +319,12 @@ async fn projection_snapshot_and_live_stream_share_one_fold() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = fx
let created = fx
.manager
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
let session = wait_for_ready(&fx.manager, created.id).await;
wait_for_async(Duration::from_secs(5), || async {
fx.manager
@@ -397,6 +422,7 @@ async fn queued_prompt_is_a_durable_user_transcript_row() {
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
let session = wait_for_ready(&fx.manager, session.id).await;
assert!(matches!(
fx.manager
@@ -442,6 +468,7 @@ async fn stale_turn_completion_cannot_end_the_next_turn() {
.create(structured(cwd.path(), fake.env()))
.await
.unwrap();
wait_for_ready(&fx.manager, session.id).await;
let maestro_daemon::session::BeginAcpTurn::Started(_, first) =
fx.manager.begin_acp_turn(session.id).await.unwrap()
@@ -485,6 +512,7 @@ async fn cancelled_prompt_that_never_settles_fails_the_session() {
.create(structured(cwd.path(), env))
.await
.unwrap();
wait_for_ready(&fx.manager, session.id).await;
assert!(matches!(
fx.manager
@@ -523,6 +551,7 @@ async fn cancellation_settlement_allows_the_next_prompt_permission() {
.create(structured(cwd.path(), env))
.await
.unwrap();
wait_for_ready(&fx.manager, session.id).await;
assert!(matches!(
fx.manager
@@ -582,11 +611,12 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
let mut env = fake.env();
env.insert("FAKE_ACP_LOAD_REPLAY".into(), "2".into());
let session = fx
let created = fx
.manager
.create(structured_command(cwd.path(), env, "codex"))
.await
.expect("create");
let session = wait_for_ready(&fx.manager, created.id).await;
let acp_id = session.acp_session_id.clone().expect("acp id");
wait_for_async(Duration::from_secs(5), || async {
fx.manager
@@ -711,6 +741,7 @@ async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
.await
.expect("create");
assert_eq!(session.cwd, worktree);
wait_for_ready(&fx.manager, session.id).await;
push_all_to_origin(repo.path(), origin.path());
fx.manager.shutdown().await;
@@ -759,6 +790,7 @@ async fn failed_recovery_cleans_its_fifo() {
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
let old_pid = fake.pid();
fx.manager.shutdown().await;
wait_for_exit(old_pid).await;
@@ -792,12 +824,15 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
empty.path().to_string_lossy().into_owned(),
)]);
let session = fx
let created = fx
.manager
.create(structured_command(cwd.path(), env, "codex"))
.await
.expect("a failed session, not an error");
.expect("create registers a Starting session");
assert_eq!(created.activity, ActivityState::Starting);
// The background handshake fails because the adapter binary is missing.
let session = wait_for_ready(&fx.manager, created.id).await;
assert_eq!(session.status, SessionStatus::Dead);
assert_eq!(session.activity, ActivityState::Error);
let detail = session.activity_detail.clone().expect("actionable detail");
@@ -810,13 +845,13 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
// It is a real session: listed, on disk, and dismissable like any other
// dead one.
assert!(fx.manager.has(session.id).await);
assert!(fx.manager.has(created.id).await);
assert!(fx
.sidecar_dir()
.join(format!("{}.json", session.id))
.join(format!("{}.json", created.id))
.exists());
fx.manager.kill(session.id, false).await.expect("dismiss");
assert!(!fx.manager.has(session.id).await);
fx.manager.kill(created.id, false).await.expect("dismiss");
assert!(!fx.manager.has(created.id).await);
}
#[tokio::test]
@@ -825,20 +860,20 @@ async fn unsupported_structured_command_fails_without_launching_claude() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = fx
let err = fx
.manager
.create(structured_command(cwd.path(), fake.env(), "opencode"))
.await
.expect("failed structured session is persisted");
.expect_err("unsupported command is rejected at request validation");
assert_eq!(session.status, SessionStatus::Dead);
let detail = session.activity_detail.expect("selection error");
assert!(detail.contains("Claude Code or Codex"), "{detail}");
assert!(
matches!(err, maestro_daemon::SessionError::BadRequest(ref msg) if msg.contains("Claude Code or Codex")),
"{err:?}"
);
assert!(
fake.log().is_empty(),
"unsupported command launched an adapter"
);
fx.manager.kill(session.id, false).await.expect("dismiss");
}
#[tokio::test]
@@ -853,6 +888,7 @@ async fn kill_sends_cancel_then_sigterm_and_removes_the_sidecar() {
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
let pid = fake.pid();
assert!(pid_alive(pid));
@@ -895,6 +931,8 @@ async fn shutdown_reaps_every_adapter_child() {
.create(structured(cwd.path(), b.env()))
.await
.expect("create b");
wait_for_ready(&fx.manager, sa.id).await;
wait_for_ready(&fx.manager, sb.id).await;
let (pa, pb) = (a.pid(), b.pid());
assert!(pid_alive(pa) && pid_alive(pb));
@@ -929,10 +967,13 @@ async fn dismissal_during_shutdown_does_not_restore_recovery_artifacts() {
.create(structured(cwd.path(), env))
.await
.expect("create");
fx.manager
let blocker_session = fx
.manager
.create(structured(cwd.path(), blocker.env()))
.await
.expect("create blocker");
wait_for_ready(&fx.manager, session.id).await;
wait_for_ready(&fx.manager, blocker_session.id).await;
let manager = fx.manager.clone();
let shutdown = tokio::spawn(async move { manager.shutdown().await });
@@ -984,6 +1025,7 @@ async fn shutdown_does_not_recover_an_adapter_that_already_exited() {
.create(structured(cwd.path(), env))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
wait_for_exit(fake.pid()).await;
fx.manager.shutdown().await;
@@ -1017,23 +1059,31 @@ async fn shutdown_waits_for_structured_creation_and_rejects_new_creates() {
let mut env = fake.env();
env.insert("FAKE_ACP_DELAY_SESSION_NEW_MS".into(), "500".into());
let create_cwd = cwd.path().to_path_buf();
let manager = fx.manager.clone();
let create = tokio::spawn(async move { manager.create(structured(&create_cwd, env)).await });
// create() returns immediately; the handshake runs in the background
// and holds a lifecycle permit.
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.expect("create");
// Wait until the adapter has received session/new (handshake in progress).
wait_for_async(Duration::from_secs(5), || async {
fake.log().contains("recv session/new")
})
.await;
// Shutdown must wait for the in-flight background handshake.
let manager = fx.manager.clone();
let shutdown = tokio::spawn(async move { manager.shutdown().await });
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
!shutdown.is_finished(),
"shutdown returned before create finished"
"shutdown returned before handshake finished"
);
let session = create.await.expect("create task").expect("create");
// The handshake completes in the background (500ms delay).
wait_for_ready(&fx.manager, session.id).await;
let pid = fake.pid();
shutdown.await.expect("shutdown task");
@@ -1066,27 +1116,37 @@ async fn structured_handshakes_run_independently() {
gate.to_string_lossy().into_owned(),
);
let first_cwd = cwd.path().to_path_buf();
let manager = fx.manager.clone();
let first =
tokio::spawn(async move { manager.create(structured(&first_cwd, blocked_env)).await });
wait_for_async(Duration::from_secs(5), || async {
blocked.log().contains("recv session/new")
})
.await;
let second = tokio::time::timeout(
Duration::from_secs(5),
fx.manager.create(structured(cwd.path(), ready.env())),
)
// Both create() calls return immediately with Starting; the first
// adapter's handshake is gated while the second runs freely.
let first = fx
.manager
.create(structured(cwd.path(), blocked_env))
.await
.expect("first create");
let second = fx
.manager
.create(structured(cwd.path(), ready.env()))
.await
.expect("second handshake was blocked by the first")
.expect("second create");
assert_eq!(second.status, SessionStatus::Running);
assert!(!first.is_finished(), "the gate must still block the first");
// The second handshake completes independently of the first.
let second_ready = wait_for_ready(&fx.manager, second.id).await;
assert_eq!(second_ready.activity, ActivityState::Idle);
// The first handshake is still blocked.
let first_snap = fx
.manager
.snapshot()
.await
.into_iter()
.find(|s| s.id == first.id)
.unwrap();
assert_eq!(first_snap.activity, ActivityState::Starting);
std::fs::write(&gate, b"release").unwrap();
let first = first.await.expect("first task").expect("first create");
let first_ready = wait_for_ready(&fx.manager, first.id).await;
assert_eq!(first_ready.activity, ActivityState::Idle);
fx.manager.kill(first.id, false).await.expect("kill first");
fx.manager
.kill(second.id, false)
@@ -1114,16 +1174,18 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
gate_b.to_string_lossy().into_owned(),
);
let create_a = {
let manager = fx.manager.clone();
let cwd = cwd.path().to_path_buf();
tokio::spawn(async move { manager.create(structured(&cwd, env_a)).await })
};
let create_b = {
let manager = fx.manager.clone();
let cwd = cwd.path().to_path_buf();
tokio::spawn(async move { manager.create(structured(&cwd, env_b)).await })
};
// Both create() calls return immediately; the background handshake
// tasks hold lifecycle permits and are blocked on their gates.
let session_a = fx
.manager
.create(structured(cwd.path(), env_a))
.await
.expect("create a");
let session_b = fx
.manager
.create(structured(cwd.path(), env_b))
.await
.expect("create b");
wait_for_async(Duration::from_secs(5), || async {
a.log().contains("recv session/new") && b.log().contains("recv session/new")
})
@@ -1143,14 +1205,14 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
assert!(!shutdown.is_finished());
std::fs::write(&gate_a, b"release").unwrap();
let session_a = create_a.await.expect("create a task").expect("create a");
wait_for_ready(&fx.manager, session_a.id).await;
assert!(
!shutdown.is_finished(),
"shutdown ignored the second create"
);
std::fs::write(&gate_b, b"release").unwrap();
let session_b = create_b.await.expect("create b task").expect("create b");
wait_for_ready(&fx.manager, session_b.id).await;
shutdown.await.expect("shutdown task");
wait_for_exit(a.pid()).await;
@@ -1166,7 +1228,7 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
}
#[tokio::test]
async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
async fn killing_a_starting_session_rolls_back_and_settles_admission() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let repo = TempDir::new().unwrap();
@@ -1192,40 +1254,30 @@ async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
auto_approve: false,
};
let create = {
let manager = fx.manager.clone();
tokio::spawn(async move { manager.create(request).await })
};
// create() returns immediately with Starting; the handshake is gated.
let session = fx.manager.create(request).await.expect("create");
assert_eq!(session.activity, ActivityState::Starting);
wait_for_async(Duration::from_secs(5), || async {
fake.log().contains("recv session/new")
})
.await;
let id = fake
.log()
.lines()
.find_map(|line| line.strip_prefix("env MAESTRO_SESSION_ID="))
.and_then(|id| uuid::Uuid::parse_str(id).ok())
.expect("session id in adapter log");
let pid = fake.pid();
create.abort();
let shutdown = {
let manager = fx.manager.clone();
tokio::spawn(async move { manager.shutdown().await })
};
assert!(create.await.unwrap_err().is_cancelled());
tokio::time::timeout(Duration::from_secs(5), shutdown)
// Kill the session while its handshake is still in progress.
// force=true because the worktree has an unpushed branch commit.
fx.manager.kill(session.id, true).await.expect("kill");
// Release the gate so the background task can finish and settle.
std::fs::write(&gate, b"release").unwrap();
// Shutdown must not hang — the lifecycle permit is settled.
tokio::time::timeout(Duration::from_secs(5), fx.manager.shutdown())
.await
.expect("cancelled admission kept shutdown waiting")
.expect("shutdown task");
.expect("shutdown hung on a settled permit");
wait_for_exit(pid).await;
wait_for_async(Duration::from_secs(5), || async {
!fx.fifo_dir().join(format!("{id}.fifo")).exists()
&& !repo.path().join("cancelled-create").exists()
})
.await;
assert!(fx.manager.snapshot().await.is_empty());
let id = id.to_string();
let id = session.id.to_string();
assert!(
std::fs::read_dir(fx.sidecar_dir())
.into_iter()
@@ -1235,7 +1287,7 @@ async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
.file_name()
.to_string_lossy()
.starts_with(&id)),
"cancelled create left a sidecar artifact"
"killed Starting session left a sidecar artifact"
);
}
@@ -1252,6 +1304,7 @@ async fn shutdown_drains_final_notifications_to_the_log() {
.create(structured(cwd.path(), env))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
@@ -1282,6 +1335,7 @@ async fn adapter_exit_marks_the_session_dead_and_keeps_it_listed() {
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
fx.manager.start_liveness_watcher(Duration::from_millis(50));
// The agent dies out from under the daemon: the liveness pass, not tmux,
+11 -2
View File
@@ -154,7 +154,8 @@ async fn structured_session(h: &Harness, fake: &FakeAcp) -> Uuid {
}
async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>) -> Uuid {
h.manager
let id = h
.manager
.create(CreateSessionRequest {
view: SessionView::Structured,
cwd: "/tmp".into(),
@@ -166,7 +167,9 @@ async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>)
})
.await
.unwrap()
.id
.id;
wait_for_session_activity(h, id, ActivityState::Idle).await;
id
}
async fn wait_for_session_activity(h: &Harness, id: Uuid, expected: ActivityState) {
@@ -1494,6 +1497,12 @@ async fn events_ws_streams_structured_activity_without_hook_overrides() {
let maestro_protocol::CatalogEvent::SessionAdded { session } = next_event(&mut ws).await else {
panic!("expected structured SessionAdded")
};
assert_eq!(session.activity, ActivityState::Starting);
// The background handshake completes and sends SessionUpdated → Idle.
let maestro_protocol::CatalogEvent::SessionUpdated { session } = next_event(&mut ws).await
else {
panic!("expected SessionUpdated after handshake")
};
assert_eq!(session.activity, ActivityState::Idle);
let client = reqwest::Client::new();
+7 -1
View File
@@ -175,6 +175,10 @@ pub enum ActivityState {
AwaitingInput,
/// An agent reported a failure, or a structured session/turn failed.
Error,
/// The ACP adapter is booting; the session is registered but cannot accept
/// prompts yet. Older clients that do not know this variant degrade it to
/// `Idle` via the lenient deserializer — acceptable.
Starting,
}
impl<'de> Deserialize<'de> for ActivityState {
@@ -187,6 +191,7 @@ impl<'de> Deserialize<'de> for ActivityState {
"Active" => Self::Active,
"AwaitingInput" => Self::AwaitingInput,
"Error" => Self::Error,
"Starting" => Self::Starting,
// Known or not, any other spelling degrades to the neutral default
_ => Self::Idle,
})
@@ -225,9 +230,10 @@ where
"Idle" => Ok(ActivityState::Idle),
"AwaitingInput" => Ok(ActivityState::AwaitingInput),
"Error" => Ok(ActivityState::Error),
"Starting" => Ok(ActivityState::Starting),
other => Err(D::Error::unknown_variant(
other,
&["Active", "Idle", "AwaitingInput", "Error"],
&["Active", "Idle", "AwaitingInput", "Error", "Starting"],
)),
}
}
+38 -1
View File
@@ -22,7 +22,8 @@ use chrono::Utc;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{
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::sync::{broadcast, oneshot};
@@ -60,6 +61,8 @@ struct Inner {
forced_error: Option<(u16, ApiError)>,
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
inputs: Vec<(Uuid, SendInputRequest)>,
/// Every ACP prompt sent to a structured session, in order.
structured_prompts: Vec<(Uuid, StructuredPromptRequest)>,
}
#[derive(Clone)]
@@ -92,6 +95,10 @@ impl MockDaemon {
.route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION, delete(kill_session))
.route(routes::patterns::SESSION_INPUT, post(send_input))
.route(
routes::patterns::STRUCTURED_PROMPT,
post(structured_prompt),
)
.route(routes::INFO, get(daemon_info))
.route(routes::DIRS, get(list_dir))
.route(routes::RECENT_DIRS, get(recent_dirs))
@@ -178,6 +185,16 @@ impl MockDaemon {
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 {
self.state.inner.lock().expect("poisoned").sessions.len()
}
@@ -266,6 +283,26 @@ async fn send_input(
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 {
if let Some(r) = forced(&s) {
return r;
+3 -2
View File
@@ -82,7 +82,7 @@ impl Surface {
pub fn activity(&self, state: ActivityState) -> Color {
match state {
ActivityState::Active => self.active,
ActivityState::Idle => self.idle,
ActivityState::Idle | ActivityState::Starting => self.idle,
ActivityState::AwaitingInput => self.awaiting,
ActivityState::Error => self.error,
}
@@ -928,9 +928,10 @@ pub fn contrast_ratio(a: Color, b: Color) -> Option<f64> {
mod tests {
use super::*;
const STATES: [ActivityState; 4] = [
const STATES: [ActivityState; 5] = [
ActivityState::Active,
ActivityState::Idle,
ActivityState::Starting,
ActivityState::AwaitingInput,
ActivityState::Error,
];
+2
View File
@@ -979,6 +979,7 @@ mod tests {
for state in [
ActivityState::Active,
ActivityState::Idle,
ActivityState::Starting,
ActivityState::AwaitingInput,
ActivityState::Error,
] {
@@ -1009,6 +1010,7 @@ mod tests {
for state in [
ActivityState::Active,
ActivityState::Idle,
ActivityState::Starting,
ActivityState::AwaitingInput,
ActivityState::Error,
] {
+1
View File
@@ -160,6 +160,7 @@ The state word is never spelled out — the colour carries it.
| ---------------- | --------- | ---------------- |
| `Active` | `--live` | `active` |
| `Idle` | `--muted` | `idle` |
| `Starting` | `--muted` | `idle` |
| `AwaitingInput` | `--warn` | `awaiting` |
| `Error` | `--danger`| `error` |
+16 -5
View File
@@ -190,9 +190,16 @@ choices, not daemon ones.
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.
- The web client groups two or more tool rows after the same `User` row into
one collapsed turn activity block. Opening it reveals independently
expandable tool calls, including any raw command, input, output, and diff
content the adapter supplied.
one collapsed turn activity block. `AgentText` and `AgentThought` rows that
appear *between* tool rows in the same turn are absorbed into the group
(intermediate narration); the last `AgentText` with no subsequent tool stays
outside as the prominent final answer. While a turn is still active
(responding or thinking), the most recent intermediate `AgentText` or
`AgentThought` in the group is kept outside as visible narration — replaced
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
copy their complete source text when clicked or keyboard-activated.
- The web client consumes `available_commands_update` notices as the live
@@ -346,11 +353,15 @@ thing a callback can be attributed to, so at most one turn may ever be
in flight, and a cancelled turn must fully settle before the next prompt is
allowed to claim the daemon's idea of "the active turn."
- A prompt is appended as a durable `User` row before it claims the turn and
- Web clients may collect a visible FIFO of operator messages while a turn is
active. They submit each queued message only after the projected turn becomes
idle; the queue is client-local and unsent messages do not survive a reload.
- A submitted prompt is appended as a durable `User` row before it claims the turn and
dispatches to ACP, so the transcript never depends on an adapter echoing the
operator's input. If that append fails, the prompt is not dispatched. While a
turn is active, a second prompt gets `409 turn_in_flight` rather than
queuing — one active turn per session, never a hidden queue of prompts.
queuing in the daemon — one active turn per session, never a hidden
server-side queue of prompts.
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
and ends the turn's client-visible lifecycle immediately. If the cancelled
turn was still *dispatched* to ACP (waiting on its response), the daemon
+4 -3
View File
@@ -663,8 +663,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
applyNav();
};
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
// out: there is no hover there, and a tap already opens the session.
// Pointer hover previews only while Ctrl is held — plain hover was too
// disruptive when sweeping the sidebar. Keyboard walk (j/k) still previews
// unconditionally via applyNav.
let hovered: string | null = null;
const setHovered = (id: string | null) => {
if (hovered === id) return;
@@ -672,7 +673,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
opts.onPreview?.(id);
};
treeHost.addEventListener("pointerover", (e) => {
if (e.pointerType === "touch") return;
if (e.pointerType === "touch" || !e.ctrlKey) return;
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
setHovered(row?.dataset.sessionId ?? null);
});
+11 -5
View File
@@ -182,26 +182,32 @@ describe("sidebar preview requests (docs/PREVIEW.md)", () => {
expect(seen.at(-1)).toBe(null);
});
it("asks for a preview of the hovered card, and clears it on the way out", () => {
it("ctrl+hover previews a card, plain hover does not", () => {
const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
// Plain hover — no preview.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
expect(seen).toEqual([]);
// Ctrl+hover — preview fires.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
expect(seen).toEqual([A]);
// Moving within the same card is not a new preview.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
// Deduplicated while still on the same card.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
expect(seen).toHaveLength(1);
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
expect(seen.at(-1)).toBe(null);
});
it("ignores touch, where a tap already opens the session", () => {
it("ignores touch even with ctrl", () => {
const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch" }));
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch", ctrlKey: true }));
expect(seen).toEqual([]);
});
});
+53 -24
View File
@@ -11,7 +11,7 @@ import { attachDirAutocomplete } from "./dirAutocomplete.js";
import { parentDir } from "./paths.js";
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
import type { ProjectGroup } from "./projectList.js";
import { splitCommand } from "./toolIcon.js";
import { splitCommand, toolIconSvg, TOOL_LABELS } from "./toolIcon.js";
import { abbreviateCwd } from "./types.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
@@ -140,6 +140,12 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const form = document.createElement("form");
form.className = "spawn-form";
form.innerHTML = `
<div class="field">
<span class="field-label">Harness</span>
<div class="harness-grid cmd-tiles"></div>
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
placeholder="bash -l" autocomplete="off" />
</div>
<div class="field">
<span class="field-label">Project</span>
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
@@ -151,12 +157,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
</div>
</div>
<div class="field">
<span class="field-label">Command</span>
<div class="tile-row cmd-tiles"></div>
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
placeholder="bash -l" autocomplete="off" />
</div>
<div class="advanced">
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
<span class="chevron">▾</span>
@@ -348,37 +348,69 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
command = preset!.command;
}
updateStructuredAvailability();
for (const tile of cmdTiles.querySelectorAll(".tile")) {
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === id);
}
emit();
}
/** Re-highlight the operator's already-picked tile after `renderCommandTiles`
* rebuilds the row from scratch (new tiles carry no `is-active` class) —
/** Re-highlight the operator's already-picked card after `renderCommandTiles`
* rebuilds the grid from scratch (new cards carry no `is-active` class) —
* without touching `command`, since that was already settled. */
function reapplyActiveTile(): void {
for (const tile of cmdTiles.querySelectorAll(".tile")) {
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId);
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === selectedPresetId);
}
}
/** Resolve a preset id to a tool icon id. The preset's command is the
* source of truth — `splitCommand` extracts the tool the same way the
* sidebar does. For the "Custom…" preset (empty command) there is no
* tool to show, and a terminal prompt stands in. */
function presetToolId(p: SpawnPreset): string | null {
if (!p.command) return null;
return splitCommand(p.command).tool;
}
function renderCommandTiles(): void {
cmdTiles.replaceChildren();
for (const p of presets) {
const tile = document.createElement("button");
tile.type = "button";
tile.className = "tile";
tile.dataset.id = p.id;
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
tile.addEventListener("click", () => {
const isCustom = p.command === "";
const card = document.createElement("button");
card.type = "button";
card.className = isCustom ? "harness-card harness-custom" : "harness-card";
card.dataset.id = p.id;
const toolId = presetToolId(p);
const iconHtml = isCustom ? customIcon() : toolId ? harnessIcon(toolId) : harnessIcon("shell");
const label = TOOL_LABELS[toolId ?? ""] ?? p.label;
card.innerHTML =
`<span class="harness-icon">${iconHtml}</span>` +
`<span class="harness-label">${escapeHtml(isCustom ? "Custom\u2026" : label)}</span>`;
card.addEventListener("click", () => {
commandTouched = true;
selectPreset(p.id);
});
cmdTiles.appendChild(tile);
cmdTiles.appendChild(card);
}
}
/** Render a harness card icon at card size (24x24). Re-uses the SVG bodies
* from toolIcon.ts but at a larger viewport so the marks breathe. */
function harnessIcon(toolId: string): string {
// toolIconSvg renders at 13x13 with viewBox 0 0 16 16 — we want the
// same shape at 24x24 for the card context.
const small = toolIconSvg(toolId);
if (!small) return "";
return small
.replace(/width="13"/g, 'width="24"')
.replace(/height="13"/g, 'height="24"');
}
/** An ellipsis mark for the "Custom…" escape hatch. */
function customIcon(): string {
return `<svg class="tool-icon" viewBox="0 0 16 16" width="24" height="24" role="img" aria-label="Custom command"><title>Custom command</title><g fill="currentColor"><circle cx="4" cy="8" r="1.3"/><circle cx="8" cy="8" r="1.3"/><circle cx="12" cy="8" r="1.3"/></g></svg>`;
}
// Set once the operator picks a tile or types a custom command — guards
// `applyInitialCommand()` the same way `nameTouched` guards a late name
// batch: a late `/api/info` resolution must never overwrite a field the
@@ -648,16 +680,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
box.type = "button";
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
box.dataset.cwd = project.path;
const trees = worktreesOf(group).length;
// The parent directory only — the last segment is the name line above it.
// Unabbreviated beyond `~`, since the stem is what tells two projects
// sharing a basename apart (#95).
const parent = abbreviateCwd(parentDir(project.path), homeDir);
const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "";
box.innerHTML = `
<span class="project-name">${escapeHtml(project.name)}</span>
<span class="project-path">${escapeHtml(parent)}</span>
<span class="project-meta">${escapeHtml(meta)}</span>`;
<span class="project-path">${escapeHtml(parent)}</span>`;
box.addEventListener("click", () => selectProject(group));
return box;
}
+1 -1
View File
@@ -139,7 +139,7 @@ describe("spawnForm Enter-to-submit (#97)", () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
await drain();
form.querySelector<HTMLButtonElement>('.cmd-tiles .tile[data-id="custom"]')!.click();
form.querySelector<HTMLButtonElement>('.cmd-tiles .harness-card[data-id="custom"]')!.click();
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
raw.value = "bash -l";
raw.dispatchEvent(new Event("input"));
+3 -3
View File
@@ -63,7 +63,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
await drain();
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
(t) => t.dataset.id === "zsh",
)!;
zshTile.click();
@@ -88,7 +88,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
document.body.appendChild(form);
await drain();
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
(t) => t.dataset.id === "custom",
)!;
customTile.click();
@@ -116,7 +116,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
// Untouched: the refined preset list is still free to settle the command.
expect(values?.command).toBe("claude");
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
(t) => t.dataset.id === "fish",
);
expect(fishTile).toBeDefined();
+2 -2
View File
@@ -52,14 +52,14 @@ describe("spawn modal", () => {
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
});
it("opens with the project filter focused, ahead of the command tiles", async () => {
it("opens with the project filter focused, harness before project", async () => {
openSpawnModal({ onSuccess: () => {} });
await flush();
const form = modal()!.querySelector("form.spawn-form")!;
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
expect(fields.slice(0, 2)).toEqual(["Project", "Command"]);
expect(fields.slice(0, 2)).toEqual(["Harness", "Project"]);
});
it("titles a seeded open as a clone", async () => {
+175 -16
View File
@@ -134,7 +134,7 @@ describe("structured connection: data flow", () => {
} });
input.dispatchEvent(paste);
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
await vi.waitFor(() => expect(input.disabled).toBe(false));
await vi.waitFor(() => expect(conn.el.textContent).toContain("Upload failed"));
expect(input.value).toBe("Read this");
expect(sendStructuredPrompt).not.toHaveBeenCalled();
const remove = conn.el.querySelector<HTMLButtonElement>('[aria-label="Remove notes.txt"]')!;
@@ -413,6 +413,73 @@ describe("structured connection: data flow", () => {
conn.dispose();
});
it("absorbs intermediate agent text into tool group, keeps final text outside", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 8,
rows: [
row("user:1", "user", { content: { type: "text", text: "do it" } }),
row("agent:1", "agent_text", { content: { type: "text", text: "Let me check" } }),
toolRow("tool:1", "one"),
toolRow("tool:2", "two"),
row("agent:2", "agent_text", { content: { type: "text", text: "Found issues, fixing" } }),
toolRow("tool:3", "three"),
toolRow("tool:4", "four"),
row("agent:3", "agent_text", { content: { type: "text", text: "All done" } }),
],
},
});
const groups = conn.el.querySelectorAll<HTMLElement>(".st-tool-group");
expect(groups).toHaveLength(1);
// Header counts only tool rows, not intermediate text.
expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("4 tool calls");
// Intermediate text (agent:2) is inside the group.
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
// First text (agent:1) is outside — it precedes all tools.
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).toBeNull();
// Final text (agent:3) is outside — no tool follows it.
expect(conn.el.querySelector('[data-row-id="agent:3"]')?.closest(".st-tool-group")).toBeNull();
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", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
@@ -660,7 +727,7 @@ describe("structured connection: turn/composer gating", () => {
conn.dispose();
});
it("disables the composer while a turn is active and re-enables when idle", () => {
it("keeps the composer available while a turn is active", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
@@ -668,13 +735,13 @@ describe("structured connection: turn/composer gating", () => {
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement;
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
expect(input.disabled).toBe(true);
expect(input.disabled).toBe(false);
expect(cancel.hidden).toBe(false);
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(true);
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
ws.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(cancel.hidden).toBe(true);
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(false);
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
conn.dispose();
});
@@ -737,7 +804,7 @@ describe("structured connection: turn/composer gating", () => {
conn.dispose();
});
it("disables immediately and dispatches only once before begin-turn is projected", () => {
it("queues a second message before begin-turn is projected", () => {
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
@@ -750,12 +817,43 @@ describe("structured connection: turn/composer gating", () => {
input.value = "only once";
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
input.value = "queued next";
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
expect(input.disabled).toBe(true);
expect(send.disabled).toBe(true);
expect(input.disabled).toBe(false);
expect(send.disabled).toBe(false);
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("1 message queued");
conn.dispose();
});
it("dispatches queued messages in order as turns become idle", async () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const submit = (text: string) => {
input.value = text;
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
};
submit("first");
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
submit("second");
submit("third");
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("2 messages queued");
await Promise.resolve().then(() => Promise.resolve());
ws.emit({ type: "reduced_state", state: idleState });
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(2, "s1", "second");
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
await Promise.resolve().then(() => Promise.resolve());
ws.emit({ type: "reduced_state", state: idleState });
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(3, "s1", "third");
expect((conn.el.querySelector(".structured-queue") as HTMLElement).hidden).toBe(true);
conn.dispose();
});
@@ -807,7 +905,7 @@ describe("structured connection: turn/composer gating", () => {
expect(sendStructuredPrompt).toHaveBeenCalledTimes(2);
expect(input.value).toBe("");
expect(input.disabled).toBe(true);
expect(input.disabled).toBe(false);
expect(conn.el.textContent).not.toContain("stale failure");
conn.dispose();
});
@@ -869,7 +967,7 @@ describe("structured connection: approvals", () => {
conn.dispose();
});
it("removes the buttons once the approval resolves, even with no accompanying transcript delta", () => {
it("hides a standalone resolved approval entirely when no tool group absorbs it", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
@@ -885,7 +983,68 @@ describe("structured connection: approvals", () => {
// Resolved on another client: state clears with no transcript delta at all.
ws.emit({ type: "reduced_state", state: idleState });
expect(conn.el.querySelector(".st-approval-acts")).toBeNull();
expect(conn.el.querySelector(".st-approval-resolved")).not.toBeNull();
// Standalone resolved approval is hidden — no card, no compact line.
expect(conn.el.querySelector(".st-row-approval")).toBeNull();
conn.dispose();
});
it("absorbs a resolved approval into the tool group as a compact line", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 0,
rows: [
row("user:1", "user", { content: { text: "go" } }),
toolRow("tool:1", "first"),
row("approval:1", "approval", { toolCall: { title: "Write file.ts" }, options: allApprovalOptions }),
toolRow("tool:2", "second"),
],
},
});
ws.emit({ type: "reduced_state", state: idleState });
// 2+ tools in the turn → tool group exists; resolved approval is inside it.
const group = conn.el.querySelector(".st-tool-group");
expect(group).not.toBeNull();
const compact = group!.querySelector(".st-approval-compact");
expect(compact).not.toBeNull();
expect(compact!.textContent).toContain("Write file.ts");
expect(compact!.textContent).toContain("approved");
// No full-card approval anywhere on the transcript.
expect(conn.el.querySelector(".st-approval")).toBeNull();
conn.dispose();
});
it("keeps a pending approval standalone even when a tool group exists in the same turn", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 0,
rows: [
row("user:1", "user", { content: { text: "go" } }),
toolRow("tool:1", "first"),
row("approval:1", "approval", { toolCall: { title: "Write file.ts" }, options: allApprovalOptions }),
toolRow("tool:2", "second"),
],
},
});
ws.emit({
type: "reduced_state",
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
});
// Pending approval stays outside the tool group as a full card.
const approvalCard = conn.el.querySelector(".st-approval");
expect(approvalCard).not.toBeNull();
expect(conn.el.querySelectorAll(".st-approval-acts button").length).toBeGreaterThan(0);
// It should not be inside the tool group.
const group = conn.el.querySelector(".st-tool-group");
expect(group).not.toBeNull();
expect(group!.querySelector(".st-approval")).toBeNull();
conn.dispose();
});
@@ -1126,7 +1285,7 @@ describe("structured connection: reconnection/error/loading", () => {
second.open();
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
second.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(true);
expect(input.disabled).toBe(false);
second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
second.emit({ type: "reduced_state", state: idleState });
@@ -1178,12 +1337,12 @@ describe("structured connection: reconnection/error/loading", () => {
second.open();
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
second.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(true);
expect(input.disabled).toBe(false);
accept();
await Promise.resolve().then(() => Promise.resolve());
expect(input.disabled).toBe(true);
expect(input.disabled).toBe(false);
expect(second.closed).toBe(true);
third.open();
@@ -1584,7 +1743,7 @@ describe("structured connection: pane lifecycle", () => {
conn.dispose();
});
it("focusTerminal focuses the composer only when it is enabled", () => {
it("focusTerminal keeps the composer reachable during an active turn", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
@@ -1592,7 +1751,7 @@ describe("structured connection: pane lifecycle", () => {
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
conn.focusTerminal();
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
expect(document.activeElement).not.toBe(input);
expect(document.activeElement).toBe(input);
ws.emit({ type: "reduced_state", state: idleState });
conn.focusTerminal();
expect(document.activeElement).toBe(input);
+196 -40
View File
@@ -85,11 +85,11 @@ export function createStructuredConnection(
<div class="structured-main">
<div class="structured-scroll">
<div class="structured-rows"></div>
</div>
<div class="structured-activity" role="status" aria-live="polite" hidden>
<span class="structured-activity-pulse" aria-hidden="true"></span>
<span class="structured-activity-text">Thinking</span>
</div>
</div>
<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)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="m8 12 7-7a3 3 0 0 1 4 4L9 19a5 5 0 0 1-7-7L12 2M5 14l9-9"/></svg>
@@ -113,7 +113,11 @@ export function createStructuredConnection(
<button type="submit" class="btn btn-primary structured-send"><span>Send</span>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7 5 5 5-5 5M12 10H3"/></svg>
</button>
<button type="button" class="btn structured-cancel" hidden>Cancel turn</button>
<button type="button" class="btn btn-icon structured-cancel" hidden aria-label="Cancel turn" title="Cancel turn">
<svg class="structured-cancel-spinner" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="50 14" stroke-linecap="round"/></svg>
<svg class="structured-cancel-stop" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/></svg>
</button>
<span class="structured-queue" role="status" aria-live="polite" hidden></span>
</form>
</div>
</div>
@@ -133,11 +137,14 @@ export function createStructuredConnection(
const commandMenu = el.querySelector(".structured-command-menu") as HTMLDivElement;
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
const sendLabel = sendBtn.querySelector("span") as HTMLSpanElement;
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
const queueEl = el.querySelector(".structured-queue") as HTMLSpanElement;
const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement;
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
type Attachment = { file: File; path?: string };
type QueuedPrompt = { text: string; attachments: Attachment[] };
let attachments: Attachment[] = [];
let uploading = false;
let pasteCount = 0;
@@ -155,7 +162,7 @@ export function createStructuredConnection(
remove.type = "button";
remove.textContent = "×";
remove.setAttribute("aria-label", `Remove ${attachment.file.name}`);
remove.disabled = input.disabled;
remove.disabled = uploading;
remove.addEventListener("click", () => {
attachments = attachments.filter((item) => item !== attachment);
renderAttachments();
@@ -166,7 +173,7 @@ export function createStructuredConnection(
}
function addFiles(files: File[]): void {
if (input.disabled || disposed) return;
if (uploading || disposed) return;
for (const file of files) {
if (file.size > 20 * 1024 * 1024) {
showError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
@@ -193,7 +200,7 @@ export function createStructuredConnection(
});
input.addEventListener("paste", (event) => {
const clipboard = event.clipboardData;
if (!clipboard || input.disabled) return;
if (!clipboard || uploading) return;
if (clipboard.files.length) {
event.preventDefault();
addFiles(Array.from(clipboard.files));
@@ -244,6 +251,8 @@ export function createStructuredConnection(
let disposed = false;
let optimisticTurnStartedAt: number | null = null;
let activityTimer: number | null = null;
const promptQueue: QueuedPrompt[] = [];
let promptRequestPending = false;
// Within 64px, wheel and touch imprecision should still count as following.
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
@@ -393,17 +402,76 @@ export function createStructuredConnection(
return el;
}
function toolGroupsByTurn(): Map<string, TranscriptRow[]> {
const groups = new Map<string, TranscriptRow[]>();
/** Group tool rows (and intermediate agent text / resolved approvals between
* them) by turn. An agent_text/agent_thought row is "intermediate" when a
* tool 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). Returns groups keyed by `turn:<userId>`, each with `toolCount`
* (for the header label) and `rows` (tools + intermediate text/approvals,
* in order). */
function activityGroupsByTurn(): Map<string, { toolCount: number; rows: TranscriptRow[] }> {
// 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 id = `turn:${turnId}`;
const group = groups.get(id) ?? [];
group.push(row);
groups.set(id, group);
const gid = `turn:${turnId}`;
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
}
// Second: build groups with interleaved text for turns with 2+ tools.
const groups = new Map<string, { toolCount: number; rows: TranscriptRow[] }>();
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 currentGid = `turn:${turnId}`;
for (const row of rows) {
if (row.kind === "user") {
turnId = row.id;
currentGid = `turn:${turnId}`;
pending = [];
continue;
}
const count = toolCounts.get(currentGid) ?? 0;
if (count < 2) continue;
const isIntermediateText = row.kind === "agent_text" || row.kind === "agent_thought";
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalRowId;
if (isIntermediateText || isResolvedApproval) {
pending.push(row);
continue;
}
if (row.kind === "tool_call" || row.kind === "tool_diff") {
const group = groups.get(currentGid) ?? { toolCount: count, rows: [] };
// Flush any pending rows — a tool follows, so they're intermediate.
group.rows.push(...pending);
pending = [];
group.rows.push(row);
groups.set(currentGid, group);
}
}
// 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;
}
@@ -413,11 +481,12 @@ export function createStructuredConnection(
const focusedGroupId = active?.closest<HTMLElement>("[data-tool-group-id]")?.dataset.toolGroupId;
const restoreToolFocus = active?.classList.contains("st-tool-head") ?? false;
const restoreGroupFocus = active?.classList.contains("st-tool-group-head") ?? false;
const groups = toolGroupsByTurn();
const groups = activityGroupsByTurn();
const groupedRowIds = new Set<string>();
const liveGroupIds = new Set([...groups].filter(([, tools]) => tools.length > 1).map(([id]) => id));
const liveGroupIds = new Set([...groups.keys()]);
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
const prevGroupEls = new Map(groupEls);
rowEls.clear();
groupEls.clear();
const children: HTMLElement[] = [];
@@ -425,34 +494,54 @@ export function createStructuredConnection(
for (const row of rows) {
if (row.kind === "user") turnId = row.id;
const groupId = `turn:${turnId}`;
const groupRows = groups.get(groupId) ?? [];
if ((row.kind === "tool_call" || row.kind === "tool_diff") && groupRows.length > 1) {
if (groupedRowIds.has(row.id)) continue;
const toolEls = groupRows.flatMap((toolRow) => {
groupedRowIds.add(toolRow.id);
const toolEl = buildRowElement(toolRow);
if (!toolEl) return [];
rowEls.set(toolRow.id, toolEl);
return [toolEl];
const group = groups.get(groupId);
if (group && groupedRowIds.has(row.id)) continue;
if (group && (row.kind === "tool_call" || row.kind === "tool_diff") && !groupedRowIds.has(row.id)) {
// First ungrouped tool in this turn — render the whole activity group.
const memberEls = group.rows.flatMap((memberRow) => {
groupedRowIds.add(memberRow.id);
const memberEl = buildRowElement(memberRow);
if (!memberEl) return [];
rowEls.set(memberRow.id, memberEl);
return [memberEl];
});
// Reuse existing group element when possible — avoids tearing down
// and recreating the wrapper, which restarts CSS transitions (visible
// flash) and destroys keyboard/focus state.
const existing = prevGroupEls.get(groupId);
if (existing) {
const titleEl = existing.querySelector<HTMLElement>(".st-tool-group-title");
if (titleEl) titleEl.textContent = `${group.toolCount} tool calls`;
const inner = existing.querySelector<HTMLElement>(".st-disclosure-inner");
if (inner) inner.replaceChildren(...memberEls);
groupEls.set(groupId, existing);
children.push(existing);
} else {
const groupEl = buildToolGroup(
groupId,
groupRows,
toolEls,
group.rows,
memberEls,
expandedGroupIds.has(groupId),
(expanded) => {
if (expanded) expandedGroupIds.add(groupId);
else expandedGroupIds.delete(groupId);
settleDisclosureLayout();
},
group.toolCount,
);
if (groupRows.some((toolRow) => enteringRowIds.has(toolRow.id))) {
if (group.rows.some((r) => enteringRowIds.has(r.id))) {
groupEl.classList.add("st-row-enter");
}
groupEls.set(groupId, groupEl);
children.push(groupEl);
}
continue;
}
// Resolved approvals not absorbed into a tool group are hidden — the
// tool row itself already identifies the action, so a standalone
// "answered" card adds nothing. Pending approvals always render.
if (row.kind === "approval" && row.id !== pendingApprovalRowId) continue;
const rowEl = buildRowElement(row);
if (!rowEl) continue;
rowEls.set(row.id, rowEl);
@@ -504,6 +593,19 @@ export function createStructuredConnection(
rows.push(delta.row);
} else {
rows[i] = delta.row;
// Surgical DOM swap: replace just this row's element instead of
// tearing down and rebuilding the entire transcript, which causes
// visible flashing and breaks text selection.
const oldEl = rowEls.get(delta.row.id);
if (oldEl) {
const newEl = buildRowElement(delta.row);
if (newEl) {
oldEl.replaceWith(newEl);
rowEls.set(delta.row.id, newEl);
}
syncAvailableCommands();
return;
}
}
} else {
const i = rowIndex.get(delta.id);
@@ -548,12 +650,14 @@ export function createStructuredConnection(
}
}
let configDirty = false;
function renderConfig(): void {
const options = sessionConfigOptions();
configEl.hidden = options.length === 0;
if (options.length === 0) {
configMenu.hidden = true;
configTrigger.setAttribute("aria-expanded", "false");
configDirty = false;
return;
}
const ordered = [...options].sort((a, b) => {
@@ -567,6 +671,15 @@ export function createStructuredConnection(
configTrigger.title = summary;
configTrigger.setAttribute("aria-label", `Session settings. ${summary.replaceAll("\n", ". ")}`);
configTrigger.disabled = configBusy;
// When the menu is open and the user is interacting (not mid-change),
// skip the DOM rebuild so open <select> dropdowns aren't destroyed.
// Mark dirty so the next open rebuilds with fresh data.
if (!configMenu.hidden && !configBusy) {
configDirty = true;
return;
}
configDirty = false;
configMenu.replaceChildren();
for (const option of ordered) {
const row = document.createElement("label");
@@ -606,16 +719,19 @@ export function createStructuredConnection(
}
function renderComposerState(): void {
const active = uploading || state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const projectedActive = state.turn.responding || state.turn.thinking;
input.disabled = active;
attachBtn.disabled = active;
fileInput.disabled = active;
input.disabled = false;
attachBtn.disabled = uploading;
fileInput.disabled = uploading;
renderAttachments();
sendBtn.disabled = active;
sendBtn.hidden = projectedActive;
sendBtn.disabled = false;
sendBtn.hidden = false;
sendLabel.textContent = "Send";
cancelBtn.hidden = !projectedActive;
if (active) closeCommandMenu();
queueEl.hidden = promptQueue.length === 0;
queueEl.textContent = promptQueue.length === 1
? "1 message queued"
: `${promptQueue.length} messages queued`;
renderActivity();
}
@@ -627,6 +743,7 @@ export function createStructuredConnection(
function renderActivity(): void {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const wasHidden = activityEl.hidden;
activityEl.hidden = !active;
if (!active) {
optimisticTurnStartedAt = null;
@@ -649,6 +766,11 @@ export function createStructuredConnection(
}
activityText.textContent = parts.join(" · ");
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 {
@@ -664,19 +786,22 @@ export function createStructuredConnection(
}
function applyState(next: ReducedState, connectionGeneration: number): void {
const wasTurnActive = state.turn.responding || state.turn.thinking;
state = next;
stateSocketGeneration = connectionGeneration;
reconcileAwaitingBegin();
const isTurnActive = state.turn.responding || state.turn.thinking;
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
if (newPendingId !== pendingApprovalRowId) {
const needsRebuild = newPendingId !== pendingApprovalRowId || wasTurnActive !== isTurnActive;
if (needsRebuild) {
updateTranscriptLayout(() => {
const prev = pendingApprovalRowId;
pendingApprovalRowId = newPendingId;
if (prev !== null || newPendingId !== null) rebuildTranscript();
rebuildTranscript();
});
}
renderConfig();
renderComposerState();
dispatchQueuedPrompt();
}
function closeConfigMenu(): void {
@@ -686,6 +811,7 @@ export function createStructuredConnection(
configTrigger.addEventListener("click", () => {
const opening = configMenu.hidden;
if (opening && configDirty) renderConfig();
configMenu.hidden = !opening;
configTrigger.setAttribute("aria-expanded", String(opening));
});
@@ -876,17 +1002,14 @@ export function createStructuredConnection(
if (!commandMenu.contains(document.activeElement)) closeCommandMenu();
}, 0);
});
composerForm.addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value;
if ((!text.trim() && attachments.length === 0) || input.disabled) return;
const submittedAttachments = attachments;
function dispatchPrompt(text: string, submittedAttachments: Attachment[] = []): void {
uploading = submittedAttachments.length > 0;
const generation = ++submissionGeneration;
const submissionSocketGeneration = socketGeneration;
awaitingBeginGeneration = generation;
optimisticTurnStartedAt = Date.now();
acceptedSubmission = null;
promptRequestPending = true;
input.value = "";
autoResize();
clearError();
@@ -903,6 +1026,7 @@ export function createStructuredConnection(
};
void send().then(
() => {
promptRequestPending = false;
if (disposed || generation !== submissionGeneration) return;
uploading = false;
if (attachments === submittedAttachments) {
@@ -916,11 +1040,17 @@ export function createStructuredConnection(
// A replacement socket may have captured idle before the POST claimed
// the prompt. Reconnect again so its snapshot cannot predate the 204.
if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation();
renderComposerState();
dispatchQueuedPrompt();
},
(e: unknown) => {
promptRequestPending = false;
uploading = false;
if (disposed || generation !== submissionGeneration) return;
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
if (attachments.length === 0 && submittedAttachments.length > 0) {
attachments = submittedAttachments;
}
if (input.value === "") {
input.value = text;
autoResize();
@@ -929,6 +1059,32 @@ export function createStructuredConnection(
renderComposerState();
},
);
}
function dispatchQueuedPrompt(): void {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
if (disposed || active || promptRequestPending || promptQueue.length === 0) return;
const next = promptQueue.shift()!;
dispatchPrompt(next.text, next.attachments);
}
composerForm.addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value;
if ((!text.trim() && attachments.length === 0) || uploading) return;
const submittedAttachments = attachments;
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
if (active || promptQueue.length > 0) {
promptQueue.push({ text, attachments: submittedAttachments });
attachments = [];
input.value = "";
autoResize();
clearError();
renderComposerState();
dispatchQueuedPrompt();
return;
}
dispatchPrompt(text, submittedAttachments);
});
cancelBtn.addEventListener("click", () => {
clearError();
+3 -2
View File
@@ -368,13 +368,14 @@ describe("buildTranscriptRow: approval", () => {
expect(el.querySelector(".st-approval-path")!.textContent).toBe("/etc/x");
});
it("hides the buttons and shows an answered note once resolved", () => {
it("renders a compact one-liner once resolved", () => {
const el = buildTranscriptRow(approvalRow(allThreeOptions), {
pending: false,
onDecide: () => Promise.resolve(),
});
expect(el.querySelector(".st-approval-acts")).toBeNull();
expect(el.querySelector(".st-approval-resolved")!.textContent).toBe("answered");
expect(el.classList.contains("st-approval-compact")).toBe(true);
expect(el.textContent).toContain("approved");
});
it("invokes onDecide with the clicked decision and disables the buttons", () => {
+13 -7
View File
@@ -581,7 +581,9 @@ export function buildToolGroup(
children: HTMLElement[],
expanded: boolean,
onExpandedChange?: (expanded: boolean) => void,
toolCount?: number,
): HTMLElement {
const count = toolCount ?? rows.length;
const wrap = el("div", "st-tool-group");
wrap.dataset.toolGroupId = id;
const head = document.createElement("button");
@@ -593,7 +595,7 @@ export function buildToolGroup(
glyph.textContent = "›";
glyph.setAttribute("aria-hidden", "true");
const title = el("span", "st-tool-group-title");
title.textContent = `${rows.length} tool calls`;
title.textContent = `${count} tool calls`;
head.append(glyph, title);
const body = el("div", "st-tool-group-body st-disclosure");
@@ -702,6 +704,16 @@ function offeredDecisions(options: AcpPermissionOption[]): StructuredApprovalDec
function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLElement {
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
const toolCall = request.toolCall ?? {};
// Resolved approvals render as a compact one-liner inside a tool group
// (or are hidden entirely when standalone — see rebuildTranscript).
if (!approval.pending) {
const wrap = el("div", "st-row st-row-approval st-approval-compact");
const label = toolCall.title ?? toolKindLabel(toolCall.kind);
wrap.textContent = `${label} — approved`;
return wrap;
}
const wrap = el("div", "st-row st-row-approval");
const card = el("div", "st-approval");
wrap.appendChild(card);
@@ -721,7 +733,6 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
card.appendChild(pathEl);
}
if (approval.pending) {
const acts = el("div", "st-approval-acts");
const mk = (label: string, decision: StructuredApprovalDecision, cls: string) => {
const btn = document.createElement("button");
@@ -756,11 +767,6 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
none.textContent = "no decision offered";
card.appendChild(none);
}
} else {
const resolved = el("div", "st-approval-resolved");
resolved.textContent = "answered";
card.appendChild(resolved);
}
return wrap;
}
+114 -49
View File
@@ -2290,6 +2290,9 @@ input.leaf-rename:focus {
.structured-main {
min-width: 0;
min-height: 0;
max-width: 820px;
margin-inline: auto;
width: 100%;
display: flex;
flex-direction: column;
flex: 1 1 auto;
@@ -2306,9 +2309,6 @@ input.leaf-rename:focus {
display: flex;
flex-direction: column;
min-width: 0;
max-width: 820px;
margin-inline: auto;
width: 100%;
}
.structured-activity {
@@ -2318,9 +2318,7 @@ input.leaf-rename:focus {
gap: var(--sp-2);
min-height: 30px;
padding: var(--sp-1) var(--sp-3);
border-top: 1px solid var(--border);
color: var(--muted);
background: var(--surface-2);
font-size: var(--fs-11);
font-variant-numeric: tabular-nums;
}
@@ -2898,6 +2896,26 @@ input.leaf-rename:focus {
border-radius: var(--r-1);
}
/* intermediate agent text inside a tool group — subdued so the final
answer outside the group stays the prominent one. */
.st-tool-group-body .st-row-agent {
color: var(--muted);
font-size: var(--fs-12);
padding: var(--sp-1) var(--sp-2);
}
.st-tool-group-body .st-row-agent .st-body > :first-child {
margin-top: 0;
}
.st-tool-group-body .st-row-agent .st-body > :last-child {
margin-bottom: 0;
}
.st-tool-group-body .st-row-thought {
padding: var(--sp-1) var(--sp-2);
}
.st-tool-group-body[hidden] {
display: none;
}
@@ -3020,6 +3038,16 @@ input.leaf-rename:focus {
color: var(--faint);
}
/* compact resolved approval inside a tool group — same muted treatment
as intermediate agent text so the audit trail is preserved without
drawing attention to itself. */
.st-approval-compact {
color: var(--muted);
font-size: var(--fs-12);
padding: var(--sp-1) var(--sp-2);
font-style: italic;
}
.act {
appearance: none;
font: inherit;
@@ -3073,6 +3101,7 @@ input.leaf-rename:focus {
hairline that doubles as the focus indicator. Inner metrics line the draft
up with the transcript's own 12px gutter. */
.structured-composer {
position: relative;
flex: 0 0 auto;
padding: var(--sp-1) var(--sp-1) max(var(--sp-1), env(safe-area-inset-bottom));
border-top: 1px solid var(--border);
@@ -3083,8 +3112,21 @@ input.leaf-rename:focus {
transition: border-color var(--dur-fast) var(--ease-out);
}
.structured-queue {
position: absolute;
right: var(--sp-2);
bottom: calc(100% + var(--sp-1));
padding: 3px var(--sp-2);
border: 1px solid var(--border);
border-radius: var(--r-pill);
background: var(--surface);
color: var(--muted);
font-size: var(--fs-11);
box-shadow: var(--shadow-1);
}
.structured-composer:focus-within {
border-top-color: var(--accent);
border-top-color: var(--border);
}
.structured-input-wrap {
@@ -3200,7 +3242,9 @@ input.leaf-rename:focus {
}
.structured-composer .structured-cancel {
padding: 0 var(--sp-3);
position: relative;
width: 40px;
padding: 0;
border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
color: var(--danger);
}
@@ -3209,6 +3253,23 @@ input.leaf-rename:focus {
background: var(--danger-tint);
}
.structured-cancel-stop {
width: 16px;
height: 16px;
}
.structured-cancel-spinner {
position: absolute;
inset: 4px;
width: auto;
height: auto;
animation: cancel-spin 0.8s linear infinite;
}
@keyframes cancel-spin {
to { transform: rotate(360deg); }
}
@media (max-width: 520px) {
/* One row cannot hold a draft plus three controls at phone width — the
draft gets a line of its own and the controls drop below it. */
@@ -3598,61 +3659,78 @@ input.leaf-rename:focus {
color: var(--ink);
}
/* One-click tiles: host row + command row. */
.tile-row {
display: flex;
flex-wrap: wrap;
/* Harness cards: the first decision in the spawn form — which tool runs. */
.harness-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: var(--sp-2);
}
.tile {
flex: 1 1 auto;
min-width: 96px;
.harness-card {
display: flex;
flex-direction: column;
gap: 2px;
align-items: flex-start;
padding: var(--sp-2) var(--sp-3);
align-items: center;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-2);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-2);
color: var(--ink);
font-family: inherit;
cursor: pointer;
text-align: left;
text-align: center;
transition:
border-color var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out);
}
.tile:hover {
.harness-card:hover {
border-color: var(--border-strong);
background: var(--surface-3);
}
.tile.is-active {
.harness-card.is-active {
border-color: var(--accent);
background: var(--accent-tint);
}
.tile-label {
font-size: var(--fs-14);
font-weight: 600;
}
.tile-sub {
font-size: var(--fs-12);
color: var(--muted);
font-family: var(--font-mono);
}
.tile-more {
flex: 0 0 auto;
min-width: 0;
justify-content: center;
.harness-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: var(--muted);
}
.harness-card.is-active .harness-icon {
color: var(--ink);
}
.harness-icon .tool-icon {
display: block;
}
.harness-label {
font-size: var(--fs-13);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* The "Custom…" card is an escape hatch, not a peer tool — dashed border
and subdued color separate it from the named harnesses. */
.harness-custom {
border-style: dashed;
color: var(--muted);
}
.harness-custom .harness-label {
font-weight: 500;
}
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
picked project's worktree choices. Stale beyond 7 days dims but stays
selectable. */
@@ -3679,7 +3757,7 @@ input.leaf-rename:focus {
.picker.projects {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
grid-template-columns: repeat(2, 1fr);
align-content: start;
gap: var(--sp-2);
max-height: 300px;
@@ -3735,19 +3813,6 @@ input.leaf-rename:focus {
overflow-wrap: anywhere;
}
.project-meta {
font-family: var(--font-mono);
font-size: var(--fs-12);
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-meta:empty {
display: none;
}
.project-other {
align-self: flex-start;
padding: 4px 0;
+1 -1
View File
@@ -11,7 +11,7 @@ export type SessionStatus = "Running" | "Dead";
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
* Absent on payloads from daemons that predate the field. `Error` is only set
* when an agent reports failure (#105); the daemon never infers it. */
export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error";
export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error" | "Starting";
export interface Session {
id: Uuid;