Compare commits

..
Author SHA1 Message Date
Miguel PalhasandClaude Opus 4.6 4727c7dc20 feat(maestro): auto-approve ACP permissions via preset config
Structured sessions ignored the --dangerously-skip-permissions flags in
preset commands because the daemon discards CLI flags when spawning the
ACP adapter binary. This adds an `auto_approve` bool to SpawnPreset and
CreateSessionRequest so the operator can declare it in config.toml:

  { id = "claude", command = "claude", auto_approve = true }

When set, the daemon's permission pump immediately responds AllowAlways
to every ACP permission request without surfacing it to clients.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 11:19:09 +01:00
45 changed files with 617 additions and 3190 deletions
-18
View File
@@ -1,18 +0,0 @@
{
"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:*)",
"Bash(cargo test:*)"
]
}
}
+2 -10
View File
@@ -41,8 +41,8 @@ pub enum Command {
Attach(AttachArgs), Attach(AttachArgs),
/// Kill a session by ID. /// Kill a session by ID.
Kill(KillArgs), Kill(KillArgs),
/// Send text to a running session. Terminal sessions receive raw /// Type text into a running session, as if it had been typed on an
/// keystrokes; structured sessions receive an ACP prompt. /// attached terminal.
Send(SendArgs), Send(SendArgs),
/// Install or update the agent activity hook (#106). /// Install or update the agent activity hook (#106).
Hooks(HooksArgs), Hooks(HooksArgs),
@@ -92,14 +92,6 @@ pub struct SpawnArgs {
/// there. /// there.
#[arg(long)] #[arg(long)]
pub existing_branch: bool, pub existing_branch: bool,
/// Run a Claude Code or Codex session in a plain terminal instead of the
/// structured (ACP) view it otherwise gets by default.
#[arg(long, conflicts_with = "structured")]
pub terminal: bool,
/// Ask for the structured (ACP) view explicitly. Only Claude Code and
/// Codex commands have an adapter; the daemon refuses anything else.
#[arg(long)]
pub structured: bool,
/// After spawning, attach immediately. /// After spawning, attach immediately.
#[arg(long, short = 'a')] #[arg(long, short = 'a')]
pub attach: bool, pub attach: bool,
+17 -102
View File
@@ -3,7 +3,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::io::Write; use std::io::Write;
use maestro_protocol::display::{abbreviate_dir, supports_structured_view}; use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::{ use maestro_protocol::{
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest, ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
Session, SessionStatus, SessionView, WorktreeSpec, Session, SessionStatus, SessionView, WorktreeSpec,
@@ -51,7 +51,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
}; };
let body = CreateSessionRequest { let body = CreateSessionRequest {
view: spawn_view(&args), view: Default::default(),
cwd: args.cwd.clone(), cwd: args.cwd.clone(),
command: args.command.clone(), command: args.command.clone(),
env, env,
@@ -62,22 +62,6 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
client.create_session(&body).await client.create_session(&body).await
} }
/// The view a `maestro spawn` asks for: structured whenever the command
/// resolves to an agent with an ACP adapter, since that is the richer session
/// and the one both other clients now default to. `--terminal` opts out;
/// `--structured` forces the request through even for a command this side
/// does not recognise, leaving the refusal to the daemon.
fn spawn_view(args: &SpawnArgs) -> SessionView {
if args.terminal {
return SessionView::Terminal;
}
if args.structured || supports_structured_view(&args.command) {
SessionView::Structured
} else {
SessionView::Terminal
}
}
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead /// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
/// of removing it; the outcome names what was kept so dispatch can say where /// of removing it; the outcome names what was kept so dispatch can say where
/// the work went. /// the work went.
@@ -130,12 +114,9 @@ pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid,
/// `maestro send`. Answers with the session that was typed into, so the /// `maestro send`. Answers with the session that was typed into, so the
/// caller can name it once the daemon has accepted the text. /// caller can name it once the daemon has accepted the text.
/// ///
/// Terminal sessions get raw keystrokes via `send_input`; structured sessions /// A bare ID skips the catalog fetch; anything else needs the list to match
/// get an ACP prompt via `send_structured_prompt` (`--no-enter` is ignored /// against. Text comes from the arguments, or from stdin when there is none —
/// there — ACP prompts are always complete messages). /// the shape a heredoc or a pipe wants.
///
/// Text comes from the arguments, or from stdin when there is none — the
/// shape a heredoc or a pipe wants.
#[allow(clippy::result_large_err)] #[allow(clippy::result_large_err)]
pub async fn run_send( pub async fn run_send(
client: &DaemonClient, client: &DaemonClient,
@@ -158,28 +139,19 @@ pub async fn run_send(
return Err(CliError::NothingToSend); return Err(CliError::NothingToSend);
} }
// We always need the session list: even a bare UUID needs the view field let id = match uuid::Uuid::parse_str(args.session.trim()) {
// to decide which endpoint to hit. Ok(id) => id,
let sessions = client.list_sessions().await?; Err(_) => {
let id = resolve_session(&sessions, &args.session)?; let sessions = client.list_sessions().await?;
let view = sessions resolve_session(&sessions, &args.session)?
.iter() }
.find(|s| s.id == id) };
.map(|s| s.view)
.unwrap_or(SessionView::Terminal);
match view { let req = SendInputRequest {
SessionView::Structured => { text,
client.send_structured_prompt(id, &text).await?; submit: !args.no_enter,
} };
SessionView::Terminal => { client.send_input(id, &req).await?;
let req = SendInputRequest {
text,
submit: !args.no_enter,
};
client.send_input(id, &req).await?;
}
}
Ok(id) Ok(id)
} }
@@ -228,7 +200,6 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
ActivityState::Idle => "idle", ActivityState::Idle => "idle",
ActivityState::AwaitingInput => "awaiting", ActivityState::AwaitingInput => "awaiting",
ActivityState::Error => "error", ActivityState::Error => "error",
ActivityState::Starting => "starting",
}; };
// Show the live foreground command alongside the spawn command when // Show the live foreground command alongside the spawn command when
// the daemon has observed one that differs (NAP-340). // the daemon has observed one that differs (NAP-340).
@@ -269,62 +240,6 @@ mod tests {
use super::DaemonInfo; use super::DaemonInfo;
use clap::Parser;
use crate::cli::{Cli, Command};
fn spawn_args(extra: &[&str]) -> crate::cli::SpawnArgs {
let mut argv = vec!["maestro", "spawn", "--cwd", "/tmp", "--command"];
argv.extend_from_slice(extra);
let cli = Cli::try_parse_from(argv).expect("parses");
let Command::Spawn(args) = cli.command else {
panic!("expected spawn")
};
args
}
/// The CLI follows the two interactive clients: a command with an ACP
/// adapter spawns structured unless `--terminal` says otherwise, and
/// `--structured` still forces the request for a command this side does
/// not recognise.
#[test]
fn spawn_defaults_to_structured_for_supported_agents() {
use super::spawn_view;
assert_eq!(
spawn_view(&spawn_args(&["claude"])),
SessionView::Structured
);
assert_eq!(
spawn_view(&spawn_args(&["npx codex exec"])),
SessionView::Structured
);
assert_eq!(spawn_view(&spawn_args(&["bash"])), SessionView::Terminal);
assert_eq!(
spawn_view(&spawn_args(&["claude", "--terminal"])),
SessionView::Terminal
);
assert_eq!(
spawn_view(&spawn_args(&["opencode", "--structured"])),
SessionView::Structured
);
}
#[test]
fn spawn_refuses_both_view_flags_at_once() {
let err = Cli::try_parse_from([
"maestro",
"spawn",
"--cwd",
"/tmp",
"--command",
"claude",
"--structured",
"--terminal",
])
.unwrap_err();
assert!(err.to_string().contains("--terminal"), "{err}");
}
fn session(cwd: &str, command: &str, id: u128) -> Session { fn session(cwd: &str, command: &str, id: u128) -> Session {
Session { Session {
view: Default::default(), view: Default::default(),
@@ -2,7 +2,7 @@
//! was unit-tested, but nothing exercised the client, the error decoding, or //! was unit-tested, but nothing exercised the client, the error decoding, or
//! the unreachable-daemon path without compiling and forking a real daemon. //! the unreachable-daemon path without compiling and forking a real daemon.
use maestro_protocol::{DaemonClient, DaemonInfo, SessionView}; use maestro_protocol::{DaemonClient, DaemonInfo};
use maestro_testing::{session, MockDaemon}; use maestro_testing::{session, MockDaemon};
use uuid::Uuid; use uuid::Uuid;
@@ -202,35 +202,6 @@ async fn send_resolves_a_session_by_name() {
assert_eq!(sent, id); assert_eq!(sent, id);
} }
/// A structured session routes through the ACP prompt endpoint, not the
/// terminal input one — `--no-enter` is irrelevant (ACP prompts are
/// complete messages).
#[tokio::test]
async fn send_to_structured_session_uses_acp_prompt() {
let mock = MockDaemon::start().await;
let id = Uuid::from_u128(13);
let mut s = session(id, "/tmp", "claude");
s.view = SessionView::Structured;
mock.add_session(s);
let args = maestro_cli::cli::SendArgs {
session: id.to_string(),
text: vec!["fix".into(), "the".into(), "tests".into()],
no_enter: false,
};
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
.await
.expect("send");
assert_eq!(sent, id);
// Terminal input must stay empty — the message went through ACP.
assert!(mock.inputs().is_empty());
let prompts = mock.structured_prompts();
assert_eq!(prompts.len(), 1);
assert_eq!(prompts[0].0, id);
assert_eq!(prompts[0].1.prompt, "fix the tests");
}
#[tokio::test] #[tokio::test]
async fn send_to_an_unknown_session_fails_without_typing_anything() { async fn send_to_an_unknown_session_fails_without_typing_anything() {
let mock = MockDaemon::start().await; let mock = MockDaemon::start().await;
+4 -13
View File
@@ -80,22 +80,13 @@ mod tests {
} }
#[test] #[test]
fn non_repo_dir_does_not_find_root_inside_fixture() { fn non_repo_dir_is_none() {
// The fixture has no `.git`, so `find_root` must not return a path // A temp dir with no `.git` anywhere up the chain. Use the temp root so
// inside it. On hosts where an ancestor of the temp dir *is* a repo // the walk can't accidentally hit a real repo above the test's cwd.
// (e.g. `/tmp` lives inside a checkout), the function legitimately
// returns that ancestor — the test only asserts the fixture itself
// was not mistaken for a repo.
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let sub = tmp.path().join("plain/dir"); let sub = tmp.path().join("plain/dir");
fs::create_dir_all(&sub).unwrap(); fs::create_dir_all(&sub).unwrap();
match find_root(&sub) { assert_eq!(find_root(&sub), None);
None => {} // expected on most hosts
Some(root) => assert!(
!root.starts_with(tmp.path()),
"found .git inside the fixture at {root:?}, but none was created"
),
}
} }
#[test] #[test]
+3 -50
View File
@@ -12,8 +12,7 @@
use std::sync::Arc; use std::sync::Arc;
use axum::body::Bytes; use axum::extract::{Path, Query, State};
use axum::extract::{DefaultBodyLimit, Path, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::routing::{any, delete, get, post}; use axum::routing::{any, delete, get, post};
@@ -71,10 +70,6 @@ pub fn router(state: LocalApiState) -> Router {
.route(routes::patterns::STRUCTURED, any(structured_ws)) .route(routes::patterns::STRUCTURED, any(structured_ws))
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay)) .route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt)) .route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
.route(
routes::patterns::STRUCTURED_ATTACHMENT,
post(structured_attachment).layer(DefaultBodyLimit::max(20 * 1024 * 1024)),
)
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel)) .route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
.route(routes::patterns::STRUCTURED_CONFIG, post(structured_config)) .route(routes::patterns::STRUCTURED_CONFIG, post(structured_config))
.route( .route(
@@ -180,7 +175,7 @@ fn dir_error_status(code: &str) -> StatusCode {
fn session_error_status(code: &str) -> StatusCode { fn session_error_status(code: &str) -> StatusCode {
match code { match code {
"not_found" => StatusCode::NOT_FOUND, "not_found" => StatusCode::NOT_FOUND,
"invalid_env" | "bad_request" => StatusCode::BAD_REQUEST, "invalid_env" => StatusCode::BAD_REQUEST,
// A guard refusal is the daemon doing its job, not a failure (#79). // A guard refusal is the daemon doing its job, not a failure (#79).
"kill_guarded" => StatusCode::CONFLICT, "kill_guarded" => StatusCode::CONFLICT,
// The session outlived its pane: nothing to type into, and nothing // The session outlived its pane: nothing to type into, and nothing
@@ -437,55 +432,13 @@ async fn structured_replay(
} }
} }
#[derive(Deserialize)]
struct AttachmentQuery {
name: String,
}
async fn structured_attachment(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Query(query): Query<AttachmentQuery>,
bytes: Bytes,
) -> Response {
let name = query.name;
if name.is_empty()
|| name.len() > 180
|| name == "."
|| name == ".."
|| name
.chars()
.any(|c| c.is_control() || c == '/' || c == '\\')
{
return error_response(
StatusCode::BAD_REQUEST,
"invalid_filename",
"use a filename without directory separators, up to 180 bytes",
);
}
match s.manager.store_acp_attachment(id, &name, &bytes).await {
Ok(Some(path)) => Json(serde_json::json!({ "path": path })).into_response(),
Ok(None) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
async fn structured_prompt( async fn structured_prompt(
State(s): State<LocalApiState>, State(s): State<LocalApiState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(body): Json<StructuredPromptRequest>, Json(body): Json<StructuredPromptRequest>,
) -> Response { ) -> Response {
match s.manager.queue_acp_prompt(id, body.prompt).await { match s.manager.queue_acp_prompt(id, body.prompt).await {
Ok(QueueAcpPrompt::Queued) | Ok(QueueAcpPrompt::Pending) => { Ok(QueueAcpPrompt::Queued) => StatusCode::NO_CONTENT.into_response(),
StatusCode::NO_CONTENT.into_response()
}
Ok(QueueAcpPrompt::InFlight) => error_response( Ok(QueueAcpPrompt::InFlight) => error_response(
StatusCode::CONFLICT, StatusCode::CONFLICT,
"turn_in_flight", "turn_in_flight",
+161 -243
View File
@@ -6,9 +6,12 @@
//! handler interact with tmux. //! handler interact with tmux.
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::path::PathBuf; use std::path::PathBuf;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Weak}; use std::sync::{Arc, Weak};
use std::task::{Context, Poll};
use std::time::Duration; use std::time::Duration;
use chrono::Utc; use chrono::Utc;
@@ -122,9 +125,6 @@ pub struct SessionRecord {
/// When `true`, the permission pump auto-responds `AllowAlways` to every /// When `true`, the permission pump auto-responds `AllowAlways` to every
/// ACP permission request without surfacing it to clients. /// ACP permission request without surfacing it to clients.
pub auto_approve: bool, 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 { impl SessionRecord {
@@ -134,13 +134,6 @@ impl SessionRecord {
&& self.acp.is_some() && self.acp.is_some()
&& self.acp_events.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)] #[derive(Debug)]
@@ -215,8 +208,6 @@ pub enum SessionError {
NotTerminal(Uuid), NotTerminal(Uuid),
#[error("daemon is shutting down")] #[error("daemon is shutting down")]
ShuttingDown, ShuttingDown,
#[error("bad request: {0}")]
BadRequest(String),
/// The kill was refused (#79): the session's worktree holds unsaved work. /// The kill was refused (#79): the session's worktree holds unsaved work.
/// Nothing happened — the session keeps running and the tree is untouched, /// Nothing happened — the session keeps running and the tree is untouched,
/// so it stays the operator's handle on their own unfinished work. /// so it stays the operator's handle on their own unfinished work.
@@ -236,8 +227,6 @@ pub enum BeginAcpTurn {
#[derive(Debug)] #[derive(Debug)]
pub enum QueueAcpPrompt { pub enum QueueAcpPrompt {
Queued, Queued,
/// The adapter is still booting; the prompt is held until it is ready.
Pending,
InFlight, InFlight,
Unavailable, Unavailable,
Failed(String), Failed(String),
@@ -264,7 +253,6 @@ impl SessionError {
SessionError::NotRunning(_) => "not_running", SessionError::NotRunning(_) => "not_running",
SessionError::NotTerminal(_) => "not_terminal", SessionError::NotTerminal(_) => "not_terminal",
SessionError::ShuttingDown => "shutting_down", SessionError::ShuttingDown => "shutting_down",
SessionError::BadRequest(_) => "bad_request",
SessionError::KillGuarded(_) => "kill_guarded", SessionError::KillGuarded(_) => "kill_guarded",
}; };
let unsaved_work = match self { let unsaved_work = match self {
@@ -332,6 +320,18 @@ struct StructuredLifecycle {
} }
impl 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) { async fn wait_until_settled(&self) {
loop { loop {
let settled = self.settled.notified(); let settled = self.settled.notified();
@@ -343,18 +343,30 @@ impl StructuredLifecycle {
} }
} }
/// Release the structured-lifecycle in-flight counter that `create` acquires struct StructuredCreate<'a> {
/// before spawning the background ACP handshake task. Called when the task lifecycle: &'a StructuredLifecycle,
/// finishes (success or failure) or when `create_inner` fails before the task }
/// is spawned.
fn drop_structured_lifecycle_permit(inner: &Inner) { impl Drop for StructuredCreate<'_> {
if inner fn drop(&mut self) {
.structured_lifecycle if self.lifecycle.in_flight.fetch_sub(1, Ordering::AcqRel) == 1 {
.in_flight self.lifecycle.settled.notify_one();
.fetch_sub(1, Ordering::AcqRel) }
== 1 }
{ }
inner.structured_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)
} }
} }
@@ -595,36 +607,6 @@ impl SessionManager {
Ok(Some(replay)) Ok(Some(replay))
} }
/// Store attachment bytes for a live structured session.
pub async fn store_acp_attachment(
&self,
id: Uuid,
name: &str,
bytes: &[u8],
) -> Result<Option<std::path::PathBuf>, SessionError> {
{
let sessions = self.inner.sessions.lock().await;
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
if !record.is_live_structured() {
return Ok(None);
}
}
let dir = self.inner.sidecar.dir().join(format!("{id}.attachments"));
tokio::fs::create_dir_all(&dir).await?;
let dir = tokio::fs::canonicalize(dir).await?;
let path = dir.join(format!("{}-{name}", Uuid::new_v4()));
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
.await?;
use tokio::io::AsyncWriteExt;
file.write_all(bytes).await?;
file.sync_all().await?;
Ok(Some(path))
}
/// Subscribe to the two projections of a structured session's ACP log. /// Subscribe to the two projections of a structured session's ACP log.
/// ///
/// The transcript snapshot and whole reduced state are folded once by the /// The transcript snapshot and whole reduced state are folded once by the
@@ -648,9 +630,7 @@ impl SessionManager {
let stream = { let stream = {
let sessions = self.inner.sessions.lock().await; let sessions = self.inner.sessions.lock().await;
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?; let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
// Allow connecting while the adapter is still booting if !record.is_live_structured() {
// (`Starting`) — the event stream exists, it is just empty.
if !record.is_live_structured() && !record.is_starting_structured() {
return Ok(None); return Ok(None);
} }
let Some(stream) = &record.acp_events else { let Some(stream) = &record.acp_events else {
@@ -735,14 +715,8 @@ impl SessionManager {
text: String, text: String,
) -> Result<QueueAcpPrompt, SessionError> { ) -> Result<QueueAcpPrompt, SessionError> {
let (acp, stream) = { let (acp, stream) = {
let mut sessions = self.inner.sessions.lock().await; let sessions = self.inner.sessions.lock().await;
let record = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?; let record = sessions.get(&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() { if !record.is_live_structured() {
return Ok(QueueAcpPrompt::Unavailable); return Ok(QueueAcpPrompt::Unavailable);
} }
@@ -805,10 +779,6 @@ impl SessionManager {
}); });
drop(stream); drop(stream);
sync_structured_activity(&self.inner, id).await; sync_structured_activity(&self.inner, id).await;
#[cfg(feature = "haiku-autoname")]
if let Ok(true) = self.claim_autoname(id).await {
crate::autoname::spawn_naming(self.clone(), id, text);
}
return Ok(QueueAcpPrompt::Queued); return Ok(QueueAcpPrompt::Queued);
} }
Err(message) => { Err(message) => {
@@ -1217,42 +1187,17 @@ impl SessionManager {
} }
/// Spawn a new session. On success returns the freshly-created `Session`. /// 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> { pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
if req.view == SessionView::Structured { if req.view == SessionView::Structured {
// Acquire the lifecycle gate synchronously (rejects if shutting let permit = self
// 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 .inner
.structured_lifecycle .structured_lifecycle
.gate .admit(&self.inner.shutting_down)?;
.lock() return AdmittedStructuredCreate {
.expect("structured lifecycle gate"); create: Box::pin(self.create_inner(req)),
if self.inner.shutting_down.load(Ordering::Acquire) { _permit: permit,
return Err(SessionError::ShuttingDown);
} }
self.inner .await;
.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 self.create_inner(req).await
} }
@@ -1314,39 +1259,32 @@ impl SessionManager {
{ {
session_env.insert("MAESTRO_URL".into(), url); session_env.insert("MAESTRO_URL".into(), url);
} }
// ── Structured path: validate adapter, open event log, register // A structured session (#134) gets no tmux pane: the agent is a
// the session immediately with `Starting`, then handshake in the // child of the daemon, driven over ACP. An adapter that cannot come
// background so the HTTP caller gets an instant response. // up still yields a session — Dead, with the reason on it — so the
// // operator sees what to fix instead of a silently missing session.
// ── Terminal path: unchanged — tmux spawn is fast enough to stay let (acp, acp_failure) = match req.view {
// synchronous. SessionView::Structured => match structured_adapter(&req.command) {
// Structured: validate adapter, open event log eagerly. Terminal: Err(error) => (None, Some(error)),
// spawn the tmux pane synchronously (it is fast). Ok(adapter) => match maestro_acp::spawn(
let structured_spawn = match req.view { SpawnConfig::new(
SessionView::Structured => { &effective_cwd,
// Fail fast on an unsupported command before touching disk. session_env
let adapter = structured_adapter(&req.command).map_err(SessionError::BadRequest)?; .iter()
// Open the event log eagerly so the structured WS can .map(|(k, v)| (k.clone(), v.clone()))
// connect before the adapter is up. .collect(),
let acp_events = match self.inner.sidecar.open_events(id) { )
Ok(log) => { .adapter(adapter),
let (tx, _) = broadcast::channel(EVENTS_CAPACITY); )
let (projected_tx, _) = broadcast::channel(EVENTS_CAPACITY); .await
Arc::new(Mutex::new(AcpEventStream { {
log, Ok(acp) => (Some(Arc::new(acp)), None),
live: tx, Err(e) => {
projection: Projection::default(), tracing::warn!(%id, error = %e, "structured session: adapter failed to start");
projected_live: projected_tx, (None, Some(e.to_string()))
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 => { SessionView::Terminal => {
let spawned = { let spawned = {
let tmux = self.inner.tmux.clone(); let tmux = self.inner.tmux.clone();
@@ -1360,10 +1298,53 @@ impl SessionManager {
if let Err(e) = spawned { if let Err(e) = spawned {
return Err(SessionError::Tmux(e)); return Err(SessionError::Tmux(e));
} }
None (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 // Capture spawn-history inputs before `req` is partially moved into
// `session`. `history_cwd` is the *chosen* working dir (the request // `session`. `history_cwd` is the *chosen* working dir (the request
// `cwd`), not a generated worktree checkout path — that's what the // `cwd`), not a generated worktree checkout path — that's what the
@@ -1373,13 +1354,10 @@ impl SessionManager {
resolve_name(&mut metadata); resolve_name(&mut metadata);
let worktree_branch = req.worktree.as_ref().map(|w| w.branch.clone()); 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 { let session = Session {
id, id,
cwd: effective_cwd.clone(), cwd: effective_cwd,
command: req.command.clone(), command: req.command,
env: req.env, env: req.env,
metadata, metadata,
worktree: worktree_path.as_ref().zip(worktree_base_repo.as_ref()).map( worktree: worktree_path.as_ref().zip(worktree_base_repo.as_ref()).map(
@@ -1388,41 +1366,67 @@ impl SessionManager {
base_repo: base_repo.clone(), base_repo: base_repo.clone(),
}, },
), ),
status: SessionStatus::Running, status: if acp_failure.is_some() {
created_at: Utc::now(), SessionStatus::Dead
foreground: None,
activity: if is_structured {
ActivityState::Starting
} else { } else {
ActivityState::Active SessionStatus::Running
}, },
activity_detail: None, 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_detail: acp_failure,
last_activity_at: Utc::now(), last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(), activity_since: chrono::Utc::now(),
current_cwd: None, 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, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: acp.as_ref().map(|a| a.session_id().to_string()),
view: req.view, view: req.view,
}; };
if let Err(e) = self.inner.sidecar.write(&session) { if let Err(e) = self.inner.sidecar.write(&session) {
if !is_structured { // The process already started; end it so no ghost survives a
let tmux = self.inner.tmux.clone(); // failed sidecar write, then unwind the FIFO + worktree.
let _ = tmux_blocking(move || tmux.kill_session(id)).await; match &acp {
Some(acp) => {
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await
}
None => {
let tmux = self.inner.tmux.clone();
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
}
} }
return Err(SessionError::Sidecar(e)); 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 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( sessions.insert(
id, id,
SessionRecord { SessionRecord {
session: session.clone(), session: session.clone(),
fifo_path, fifo_path,
worktree_path: worktree_path.clone(), worktree_path,
worktree_base_repo: worktree_base_repo.clone(), worktree_base_repo,
bridge, bridge,
reported_activity: None, reported_activity: None,
settled: false, settled: false,
@@ -1430,12 +1434,11 @@ impl SessionManager {
naming: false, naming: false,
focused_since: None, focused_since: None,
awaiting_suppressed: false, awaiting_suppressed: false,
acp: None, acp,
acp_events: events_arc, acp_events,
structured_activity_watermark: None, structured_activity_watermark: None,
acp_event_pump: None, acp_event_pump: event_pump,
auto_approve: req.auto_approve, auto_approve: req.auto_approve,
pending_prompt: None,
}, },
); );
} }
@@ -1464,78 +1467,6 @@ impl SessionManager {
} }
pending.commit(); 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) Ok(session)
} }
@@ -1721,7 +1652,6 @@ impl SessionManager {
structured_activity_watermark: None, structured_activity_watermark: None,
acp_event_pump: None, acp_event_pump: None,
auto_approve: false, auto_approve: false,
pending_prompt: None,
}, },
); );
let _ = self let _ = self
@@ -1902,7 +1832,6 @@ impl SessionManager {
// Recovery cannot know the original preset's flag; the // Recovery cannot know the original preset's flag; the
// operator re-enables it by restarting with the preset. // operator re-enables it by restarting with the preset.
auto_approve: false, auto_approve: false,
pending_prompt: None,
}, },
); );
if let Some((acp, events)) = event_pump { if let Some((acp, events)) = event_pump {
@@ -2362,11 +2291,6 @@ async fn apply_structured_activity(
if record.session.view != SessionView::Structured { if record.session.view != SessionView::Structured {
return; 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(); let current_events = record.acp_events.as_ref();
match (expected_events, current_events) { match (expected_events, current_events) {
(Some((expected, sequence)), Some(current)) if Arc::ptr_eq(expected, current) => { (Some((expected, sequence)), Some(current)) if Arc::ptr_eq(expected, current) => {
@@ -2632,12 +2556,6 @@ async fn run_liveness_pass(inner: &Arc<Inner>) {
let mut tmux_backed = Vec::new(); let mut tmux_backed = Vec::new();
let mut acp_backed = Vec::new(); let mut acp_backed = Vec::new();
for (id, record) in running { 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 { match &record.acp {
Some(acp) => acp_backed.push((*id, acp.clone())), Some(acp) => acp_backed.push((*id, acp.clone())),
None => tmux_backed.push(*id), None => tmux_backed.push(*id),
+81 -135
View File
@@ -130,25 +130,6 @@ async fn wait_for_exit(pid: u32) {
assert!(!pid_alive(pid), "pid {pid} still alive"); 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] #[tokio::test]
async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() { async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
let fx = DaemonFixture::new(); let fx = DaemonFixture::new();
@@ -157,7 +138,7 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
let mut env = fake.env(); let mut env = fake.env();
env.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into()); env.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
let created = fx let session = fx
.manager .manager
.create(structured_command( .create(structured_command(
cwd.path(), cwd.path(),
@@ -167,12 +148,8 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
.await .await
.expect("create"); .expect("create");
assert_eq!(created.view, SessionView::Structured); assert_eq!(session.view, SessionView::Structured);
assert_eq!(created.status, SessionStatus::Running); assert_eq!(session.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); assert_eq!(session.activity, ActivityState::Idle);
let acp_id = session.acp_session_id.clone().expect("acp session id"); let acp_id = session.acp_session_id.clone().expect("acp session id");
assert!(acp_id.starts_with("fake-"), "{acp_id}"); assert!(acp_id.starts_with("fake-"), "{acp_id}");
@@ -187,7 +164,7 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
"{log}" "{log}"
); );
assert!( assert!(
log.contains(&format!("env MAESTRO_SESSION_ID={}", created.id)), log.contains(&format!("env MAESTRO_SESSION_ID={}", session.id)),
"{log}" "{log}"
); );
@@ -255,7 +232,6 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
.create(structured_command(cwd.path(), env, "codex")) .create(structured_command(cwd.path(), env, "codex"))
.await .await
.unwrap(); .unwrap();
let session = wait_for_ready(&fx.manager, session.id).await;
assert!(matches!( assert!(matches!(
fx.manager fx.manager
@@ -319,12 +295,11 @@ async fn projection_snapshot_and_live_stream_share_one_fold() {
let fx = DaemonFixture::new(); let fx = DaemonFixture::new();
let fake = Fake::new(); let fake = Fake::new();
let cwd = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap();
let created = fx let session = fx
.manager .manager
.create(structured(cwd.path(), fake.env())) .create(structured(cwd.path(), fake.env()))
.await .await
.expect("create"); .expect("create");
let session = wait_for_ready(&fx.manager, created.id).await;
wait_for_async(Duration::from_secs(5), || async { wait_for_async(Duration::from_secs(5), || async {
fx.manager fx.manager
@@ -422,7 +397,6 @@ async fn queued_prompt_is_a_durable_user_transcript_row() {
.create(structured(cwd.path(), fake.env())) .create(structured(cwd.path(), fake.env()))
.await .await
.expect("create"); .expect("create");
let session = wait_for_ready(&fx.manager, session.id).await;
assert!(matches!( assert!(matches!(
fx.manager fx.manager
@@ -468,7 +442,6 @@ async fn stale_turn_completion_cannot_end_the_next_turn() {
.create(structured(cwd.path(), fake.env())) .create(structured(cwd.path(), fake.env()))
.await .await
.unwrap(); .unwrap();
wait_for_ready(&fx.manager, session.id).await;
let maestro_daemon::session::BeginAcpTurn::Started(_, first) = let maestro_daemon::session::BeginAcpTurn::Started(_, first) =
fx.manager.begin_acp_turn(session.id).await.unwrap() fx.manager.begin_acp_turn(session.id).await.unwrap()
@@ -512,7 +485,6 @@ async fn cancelled_prompt_that_never_settles_fails_the_session() {
.create(structured(cwd.path(), env)) .create(structured(cwd.path(), env))
.await .await
.unwrap(); .unwrap();
wait_for_ready(&fx.manager, session.id).await;
assert!(matches!( assert!(matches!(
fx.manager fx.manager
@@ -551,7 +523,6 @@ async fn cancellation_settlement_allows_the_next_prompt_permission() {
.create(structured(cwd.path(), env)) .create(structured(cwd.path(), env))
.await .await
.unwrap(); .unwrap();
wait_for_ready(&fx.manager, session.id).await;
assert!(matches!( assert!(matches!(
fx.manager fx.manager
@@ -611,12 +582,11 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
let mut env = fake.env(); let mut env = fake.env();
env.insert("FAKE_ACP_LOAD_REPLAY".into(), "2".into()); env.insert("FAKE_ACP_LOAD_REPLAY".into(), "2".into());
let created = fx let session = fx
.manager .manager
.create(structured_command(cwd.path(), env, "codex")) .create(structured_command(cwd.path(), env, "codex"))
.await .await
.expect("create"); .expect("create");
let session = wait_for_ready(&fx.manager, created.id).await;
let acp_id = session.acp_session_id.clone().expect("acp id"); let acp_id = session.acp_session_id.clone().expect("acp id");
wait_for_async(Duration::from_secs(5), || async { wait_for_async(Duration::from_secs(5), || async {
fx.manager fx.manager
@@ -741,7 +711,6 @@ async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
.await .await
.expect("create"); .expect("create");
assert_eq!(session.cwd, worktree); assert_eq!(session.cwd, worktree);
wait_for_ready(&fx.manager, session.id).await;
push_all_to_origin(repo.path(), origin.path()); push_all_to_origin(repo.path(), origin.path());
fx.manager.shutdown().await; fx.manager.shutdown().await;
@@ -790,7 +759,6 @@ async fn failed_recovery_cleans_its_fifo() {
.create(structured(cwd.path(), fake.env())) .create(structured(cwd.path(), fake.env()))
.await .await
.expect("create"); .expect("create");
wait_for_ready(&fx.manager, session.id).await;
let old_pid = fake.pid(); let old_pid = fake.pid();
fx.manager.shutdown().await; fx.manager.shutdown().await;
wait_for_exit(old_pid).await; wait_for_exit(old_pid).await;
@@ -824,15 +792,12 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
empty.path().to_string_lossy().into_owned(), empty.path().to_string_lossy().into_owned(),
)]); )]);
let created = fx let session = fx
.manager .manager
.create(structured_command(cwd.path(), env, "codex")) .create(structured_command(cwd.path(), env, "codex"))
.await .await
.expect("create registers a Starting session"); .expect("a failed session, not an error");
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.status, SessionStatus::Dead);
assert_eq!(session.activity, ActivityState::Error); assert_eq!(session.activity, ActivityState::Error);
let detail = session.activity_detail.clone().expect("actionable detail"); let detail = session.activity_detail.clone().expect("actionable detail");
@@ -845,13 +810,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 // It is a real session: listed, on disk, and dismissable like any other
// dead one. // dead one.
assert!(fx.manager.has(created.id).await); assert!(fx.manager.has(session.id).await);
assert!(fx assert!(fx
.sidecar_dir() .sidecar_dir()
.join(format!("{}.json", created.id)) .join(format!("{}.json", session.id))
.exists()); .exists());
fx.manager.kill(created.id, false).await.expect("dismiss"); fx.manager.kill(session.id, false).await.expect("dismiss");
assert!(!fx.manager.has(created.id).await); assert!(!fx.manager.has(session.id).await);
} }
#[tokio::test] #[tokio::test]
@@ -860,20 +825,20 @@ async fn unsupported_structured_command_fails_without_launching_claude() {
let fake = Fake::new(); let fake = Fake::new();
let cwd = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap();
let err = fx let session = fx
.manager .manager
.create(structured_command(cwd.path(), fake.env(), "opencode")) .create(structured_command(cwd.path(), fake.env(), "opencode"))
.await .await
.expect_err("unsupported command is rejected at request validation"); .expect("failed structured session is persisted");
assert!( assert_eq!(session.status, SessionStatus::Dead);
matches!(err, maestro_daemon::SessionError::BadRequest(ref msg) if msg.contains("Claude Code or Codex")), let detail = session.activity_detail.expect("selection error");
"{err:?}" assert!(detail.contains("Claude Code or Codex"), "{detail}");
);
assert!( assert!(
fake.log().is_empty(), fake.log().is_empty(),
"unsupported command launched an adapter" "unsupported command launched an adapter"
); );
fx.manager.kill(session.id, false).await.expect("dismiss");
} }
#[tokio::test] #[tokio::test]
@@ -888,7 +853,6 @@ async fn kill_sends_cancel_then_sigterm_and_removes_the_sidecar() {
.create(structured(cwd.path(), fake.env())) .create(structured(cwd.path(), fake.env()))
.await .await
.expect("create"); .expect("create");
wait_for_ready(&fx.manager, session.id).await;
let pid = fake.pid(); let pid = fake.pid();
assert!(pid_alive(pid)); assert!(pid_alive(pid));
@@ -931,8 +895,6 @@ async fn shutdown_reaps_every_adapter_child() {
.create(structured(cwd.path(), b.env())) .create(structured(cwd.path(), b.env()))
.await .await
.expect("create b"); .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()); let (pa, pb) = (a.pid(), b.pid());
assert!(pid_alive(pa) && pid_alive(pb)); assert!(pid_alive(pa) && pid_alive(pb));
@@ -967,13 +929,10 @@ async fn dismissal_during_shutdown_does_not_restore_recovery_artifacts() {
.create(structured(cwd.path(), env)) .create(structured(cwd.path(), env))
.await .await
.expect("create"); .expect("create");
let blocker_session = fx fx.manager
.manager
.create(structured(cwd.path(), blocker.env())) .create(structured(cwd.path(), blocker.env()))
.await .await
.expect("create blocker"); .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 manager = fx.manager.clone();
let shutdown = tokio::spawn(async move { manager.shutdown().await }); let shutdown = tokio::spawn(async move { manager.shutdown().await });
@@ -1025,7 +984,6 @@ async fn shutdown_does_not_recover_an_adapter_that_already_exited() {
.create(structured(cwd.path(), env)) .create(structured(cwd.path(), env))
.await .await
.expect("create"); .expect("create");
wait_for_ready(&fx.manager, session.id).await;
wait_for_exit(fake.pid()).await; wait_for_exit(fake.pid()).await;
fx.manager.shutdown().await; fx.manager.shutdown().await;
@@ -1059,31 +1017,23 @@ async fn shutdown_waits_for_structured_creation_and_rejects_new_creates() {
let mut env = fake.env(); let mut env = fake.env();
env.insert("FAKE_ACP_DELAY_SESSION_NEW_MS".into(), "500".into()); env.insert("FAKE_ACP_DELAY_SESSION_NEW_MS".into(), "500".into());
// create() returns immediately; the handshake runs in the background let create_cwd = cwd.path().to_path_buf();
// and holds a lifecycle permit. let manager = fx.manager.clone();
let session = fx let create = tokio::spawn(async move { manager.create(structured(&create_cwd, env)).await });
.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 { wait_for_async(Duration::from_secs(5), || async {
fake.log().contains("recv session/new") fake.log().contains("recv session/new")
}) })
.await; .await;
// Shutdown must wait for the in-flight background handshake.
let manager = fx.manager.clone(); let manager = fx.manager.clone();
let shutdown = tokio::spawn(async move { manager.shutdown().await }); let shutdown = tokio::spawn(async move { manager.shutdown().await });
tokio::time::sleep(Duration::from_millis(50)).await; tokio::time::sleep(Duration::from_millis(50)).await;
assert!( assert!(
!shutdown.is_finished(), !shutdown.is_finished(),
"shutdown returned before handshake finished" "shutdown returned before create finished"
); );
// The handshake completes in the background (500ms delay). let session = create.await.expect("create task").expect("create");
wait_for_ready(&fx.manager, session.id).await;
let pid = fake.pid(); let pid = fake.pid();
shutdown.await.expect("shutdown task"); shutdown.await.expect("shutdown task");
@@ -1116,37 +1066,27 @@ async fn structured_handshakes_run_independently() {
gate.to_string_lossy().into_owned(), gate.to_string_lossy().into_owned(),
); );
// Both create() calls return immediately with Starting; the first let first_cwd = cwd.path().to_path_buf();
// adapter's handshake is gated while the second runs freely. let manager = fx.manager.clone();
let first = fx let first =
.manager tokio::spawn(async move { manager.create(structured(&first_cwd, blocked_env)).await });
.create(structured(cwd.path(), blocked_env)) wait_for_async(Duration::from_secs(5), || async {
.await blocked.log().contains("recv session/new")
.expect("first create"); })
let second = fx .await;
.manager
.create(structured(cwd.path(), ready.env()))
.await
.expect("second create");
// The second handshake completes independently of the first. let second = tokio::time::timeout(
let second_ready = wait_for_ready(&fx.manager, second.id).await; Duration::from_secs(5),
assert_eq!(second_ready.activity, ActivityState::Idle); fx.manager.create(structured(cwd.path(), ready.env())),
)
// The first handshake is still blocked. .await
let first_snap = fx .expect("second handshake was blocked by the first")
.manager .expect("second create");
.snapshot() assert_eq!(second.status, SessionStatus::Running);
.await assert!(!first.is_finished(), "the gate must still block the first");
.into_iter()
.find(|s| s.id == first.id)
.unwrap();
assert_eq!(first_snap.activity, ActivityState::Starting);
std::fs::write(&gate, b"release").unwrap(); std::fs::write(&gate, b"release").unwrap();
let first_ready = wait_for_ready(&fx.manager, first.id).await; let first = first.await.expect("first task").expect("first create");
assert_eq!(first_ready.activity, ActivityState::Idle);
fx.manager.kill(first.id, false).await.expect("kill first"); fx.manager.kill(first.id, false).await.expect("kill first");
fx.manager fx.manager
.kill(second.id, false) .kill(second.id, false)
@@ -1174,18 +1114,16 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
gate_b.to_string_lossy().into_owned(), gate_b.to_string_lossy().into_owned(),
); );
// Both create() calls return immediately; the background handshake let create_a = {
// tasks hold lifecycle permits and are blocked on their gates. let manager = fx.manager.clone();
let session_a = fx let cwd = cwd.path().to_path_buf();
.manager tokio::spawn(async move { manager.create(structured(&cwd, env_a)).await })
.create(structured(cwd.path(), env_a)) };
.await let create_b = {
.expect("create a"); let manager = fx.manager.clone();
let session_b = fx let cwd = cwd.path().to_path_buf();
.manager tokio::spawn(async move { manager.create(structured(&cwd, env_b)).await })
.create(structured(cwd.path(), env_b)) };
.await
.expect("create b");
wait_for_async(Duration::from_secs(5), || async { wait_for_async(Duration::from_secs(5), || async {
a.log().contains("recv session/new") && b.log().contains("recv session/new") a.log().contains("recv session/new") && b.log().contains("recv session/new")
}) })
@@ -1205,14 +1143,14 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
assert!(!shutdown.is_finished()); assert!(!shutdown.is_finished());
std::fs::write(&gate_a, b"release").unwrap(); std::fs::write(&gate_a, b"release").unwrap();
wait_for_ready(&fx.manager, session_a.id).await; let session_a = create_a.await.expect("create a task").expect("create a");
assert!( assert!(
!shutdown.is_finished(), !shutdown.is_finished(),
"shutdown ignored the second create" "shutdown ignored the second create"
); );
std::fs::write(&gate_b, b"release").unwrap(); std::fs::write(&gate_b, b"release").unwrap();
wait_for_ready(&fx.manager, session_b.id).await; let session_b = create_b.await.expect("create b task").expect("create b");
shutdown.await.expect("shutdown task"); shutdown.await.expect("shutdown task");
wait_for_exit(a.pid()).await; wait_for_exit(a.pid()).await;
@@ -1228,7 +1166,7 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
} }
#[tokio::test] #[tokio::test]
async fn killing_a_starting_session_rolls_back_and_settles_admission() { async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
let fx = DaemonFixture::new(); let fx = DaemonFixture::new();
let fake = Fake::new(); let fake = Fake::new();
let repo = TempDir::new().unwrap(); let repo = TempDir::new().unwrap();
@@ -1254,30 +1192,40 @@ async fn killing_a_starting_session_rolls_back_and_settles_admission() {
auto_approve: false, auto_approve: false,
}; };
// create() returns immediately with Starting; the handshake is gated. let create = {
let session = fx.manager.create(request).await.expect("create"); let manager = fx.manager.clone();
assert_eq!(session.activity, ActivityState::Starting); tokio::spawn(async move { manager.create(request).await })
};
wait_for_async(Duration::from_secs(5), || async { wait_for_async(Duration::from_secs(5), || async {
fake.log().contains("recv session/new") fake.log().contains("recv session/new")
}) })
.await; .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(); let pid = fake.pid();
// Kill the session while its handshake is still in progress. create.abort();
// force=true because the worktree has an unpushed branch commit. let shutdown = {
fx.manager.kill(session.id, true).await.expect("kill"); let manager = fx.manager.clone();
tokio::spawn(async move { manager.shutdown().await })
// Release the gate so the background task can finish and settle. };
std::fs::write(&gate, b"release").unwrap(); assert!(create.await.unwrap_err().is_cancelled());
tokio::time::timeout(Duration::from_secs(5), shutdown)
// Shutdown must not hang — the lifecycle permit is settled.
tokio::time::timeout(Duration::from_secs(5), fx.manager.shutdown())
.await .await
.expect("shutdown hung on a settled permit"); .expect("cancelled admission kept shutdown waiting")
.expect("shutdown task");
wait_for_exit(pid).await; 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()); assert!(fx.manager.snapshot().await.is_empty());
let id = session.id.to_string(); let id = id.to_string();
assert!( assert!(
std::fs::read_dir(fx.sidecar_dir()) std::fs::read_dir(fx.sidecar_dir())
.into_iter() .into_iter()
@@ -1287,7 +1235,7 @@ async fn killing_a_starting_session_rolls_back_and_settles_admission() {
.file_name() .file_name()
.to_string_lossy() .to_string_lossy()
.starts_with(&id)), .starts_with(&id)),
"killed Starting session left a sidecar artifact" "cancelled create left a sidecar artifact"
); );
} }
@@ -1304,7 +1252,6 @@ async fn shutdown_drains_final_notifications_to_the_log() {
.create(structured(cwd.path(), env)) .create(structured(cwd.path(), env))
.await .await
.expect("create"); .expect("create");
wait_for_ready(&fx.manager, session.id).await;
wait_for_async(Duration::from_secs(5), || async { wait_for_async(Duration::from_secs(5), || async {
fx.manager fx.manager
.read_acp_events(session.id, EventCursor::Since(0), 10) .read_acp_events(session.id, EventCursor::Since(0), 10)
@@ -1335,7 +1282,6 @@ async fn adapter_exit_marks_the_session_dead_and_keeps_it_listed() {
.create(structured(cwd.path(), fake.env())) .create(structured(cwd.path(), fake.env()))
.await .await
.expect("create"); .expect("create");
wait_for_ready(&fx.manager, session.id).await;
fx.manager.start_liveness_watcher(Duration::from_millis(50)); fx.manager.start_liveness_watcher(Duration::from_millis(50));
// The agent dies out from under the daemon: the liveness pass, not tmux, // The agent dies out from under the daemon: the liveness pass, not tmux,
+4 -78
View File
@@ -154,8 +154,7 @@ async fn structured_session(h: &Harness, fake: &FakeAcp) -> Uuid {
} }
async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>) -> Uuid { async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>) -> Uuid {
let id = h h.manager
.manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: SessionView::Structured, view: SessionView::Structured,
cwd: "/tmp".into(), cwd: "/tmp".into(),
@@ -167,9 +166,7 @@ async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>)
}) })
.await .await
.unwrap() .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) { async fn wait_for_session_activity(h: &Harness, id: Uuid, expected: ActivityState) {
@@ -238,60 +235,6 @@ async fn structured_ws_sends_snapshot_then_live_projection() {
); );
} }
#[tokio::test]
async fn structured_attachments_preserve_bytes_and_reject_paths() {
let h = spawn("attachments").await;
let fake = FakeAcp::new();
let id = structured_session(&h, &fake).await;
let client = reqwest::Client::new();
let endpoint = url(
h.addr,
&format!("/api/sessions/{id}/structured/attachments"),
);
let bytes = vec![0, 255, 10, 13];
let response = client
.post(&endpoint)
.query(&[("name", "sample.bin")])
.body(bytes.clone())
.send()
.await
.unwrap();
assert_eq!(response.status(), 200);
let body: serde_json::Value = response.json().await.unwrap();
let path = body["path"].as_str().unwrap();
assert!(Path::new(path).is_absolute());
assert_eq!(std::fs::read(path).unwrap(), bytes);
for name in ["../escape", "a/b", "a\\b", "..", ""] {
let response = client
.post(&endpoint)
.query(&[("name", name)])
.body("text")
.send()
.await
.unwrap();
assert_eq!(response.status(), 400, "{name}");
}
let response = client
.post(&endpoint)
.query(&[("name", "large.bin")])
.body(vec![0; 20 * 1024 * 1024 + 1])
.send()
.await
.unwrap();
assert_eq!(response.status(), 413);
let response = client
.post(url(
h.addr,
&format!("/api/sessions/{}/structured/attachments", Uuid::new_v4()),
))
.query(&[("name", "file.txt")])
.body("text")
.send()
.await
.unwrap();
assert_eq!(response.status(), 404);
}
#[tokio::test] #[tokio::test]
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() { async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() {
let h = spawn("naps-laptop").await; let h = spawn("naps-laptop").await;
@@ -1003,9 +946,7 @@ async fn names_honors_count_and_caps_it() {
#[tokio::test] #[tokio::test]
async fn gitroot_probes_path() { async fn gitroot_probes_path() {
// #26: resolves the query against the daemon home dir and walks up for a // #26: resolves the query against the daemon home dir and walks up for a
// `.git`. The tempdir itself has no `.git`, so the result must either be // `.git`. A `/tmp`-rooted temp dir is not in a repo, so the root is null.
// null (no repo anywhere above) or — in sandboxed environments where /tmp
// itself sits inside a repo — a path strictly above the tempdir.
let h = spawn("naps-laptop").await; let h = spawn("naps-laptop").await;
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let resp: serde_json::Value = reqwest::get(url( let resp: serde_json::Value = reqwest::get(url(
@@ -1020,16 +961,7 @@ async fn gitroot_probes_path() {
.json() .json()
.await .await
.unwrap(); .unwrap();
// On hosts where an ancestor of the temp dir is a repo (e.g. `/tmp` assert!(resp.get("root").is_none() || resp["root"].is_null());
// inside a checkout), the root legitimately points there. The test only
// asserts no root was found *inside* the temp dir.
match resp.get("root").and_then(|v| v.as_str()) {
None => {}
Some(root) => assert!(
!root.starts_with(tmp.path().to_string_lossy().as_ref()),
"found a repo root inside the fixture at {root:?}"
),
}
} }
#[tokio::test] #[tokio::test]
@@ -1508,12 +1440,6 @@ async fn events_ws_streams_structured_activity_without_hook_overrides() {
let maestro_protocol::CatalogEvent::SessionAdded { session } = next_event(&mut ws).await else { let maestro_protocol::CatalogEvent::SessionAdded { session } = next_event(&mut ws).await else {
panic!("expected structured SessionAdded") 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); assert_eq!(session.activity, ActivityState::Idle);
let client = reqwest::Client::new(); let client = reqwest::Client::new();
-19
View File
@@ -226,15 +226,6 @@ pub fn command_tool(command: &str) -> Option<&'static str> {
.map(|(_, tool)| *tool) .map(|(_, tool)| *tool)
} }
/// Whether a command line resolves to an agent Maestro can run as a
/// structured (ACP) session — Claude Code or Codex, the two adapters
/// `docs/STRUCTURED-VIEW.md` lists. Every client gates its structured-view
/// control on this, and the daemon rejects a `Structured` request for
/// anything else, so the answer has to come from one place.
pub fn supports_structured_view(command: &str) -> bool {
matches!(command_tool(command), Some("claude" | "codex"))
}
/// [`resolve`]'s outcome: the tokenized command line, and the index within it /// [`resolve`]'s outcome: the tokenized command line, and the index within it
/// of the word that is the actual program (past every assignment/wrapper it /// of the word that is the actual program (past every assignment/wrapper it
/// resolved through). /// resolved through).
@@ -1073,14 +1064,4 @@ mod tests {
assert_eq!(tokenize("\"FOO=1\" claude").unwrap()[0].plain_eq, None); assert_eq!(tokenize("\"FOO=1\" claude").unwrap()[0].plain_eq, None);
assert_eq!(tokenize("FOO\\=1 claude").unwrap()[0].plain_eq, None); assert_eq!(tokenize("FOO\\=1 claude").unwrap()[0].plain_eq, None);
} }
#[test]
fn structured_view_support_follows_the_resolved_program() {
assert!(supports_structured_view("claude --resume"));
assert!(supports_structured_view("FOO=1 /usr/local/bin/codex exec"));
assert!(supports_structured_view("npx claude-code"));
assert!(!supports_structured_view("opencode"));
assert!(!supports_structured_view("bash -l"));
assert!(!supports_structured_view("claude-code-acp"));
}
} }
+1 -7
View File
@@ -175,10 +175,6 @@ pub enum ActivityState {
AwaitingInput, AwaitingInput,
/// An agent reported a failure, or a structured session/turn failed. /// An agent reported a failure, or a structured session/turn failed.
Error, 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 { impl<'de> Deserialize<'de> for ActivityState {
@@ -191,7 +187,6 @@ impl<'de> Deserialize<'de> for ActivityState {
"Active" => Self::Active, "Active" => Self::Active,
"AwaitingInput" => Self::AwaitingInput, "AwaitingInput" => Self::AwaitingInput,
"Error" => Self::Error, "Error" => Self::Error,
"Starting" => Self::Starting,
// Known or not, any other spelling degrades to the neutral default // Known or not, any other spelling degrades to the neutral default
_ => Self::Idle, _ => Self::Idle,
}) })
@@ -230,10 +225,9 @@ where
"Idle" => Ok(ActivityState::Idle), "Idle" => Ok(ActivityState::Idle),
"AwaitingInput" => Ok(ActivityState::AwaitingInput), "AwaitingInput" => Ok(ActivityState::AwaitingInput),
"Error" => Ok(ActivityState::Error), "Error" => Ok(ActivityState::Error),
"Starting" => Ok(ActivityState::Starting),
other => Err(D::Error::unknown_variant( other => Err(D::Error::unknown_variant(
other, other,
&["Active", "Idle", "AwaitingInput", "Error", "Starting"], &["Active", "Idle", "AwaitingInput", "Error"],
)), )),
} }
} }
-6
View File
@@ -86,11 +86,6 @@ pub fn structured_prompt(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/prompt") format!("{SESSIONS}/{id}/structured/prompt")
} }
/// `POST /api/sessions/:id/structured/attachments` — upload a file.
pub fn structured_attachment(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/attachments")
}
/// `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. /// `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn.
pub fn structured_cancel(id: Uuid) -> String { pub fn structured_cancel(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/cancel") format!("{SESSIONS}/{id}/structured/cancel")
@@ -118,7 +113,6 @@ pub mod patterns {
pub const STRUCTURED: &str = "/api/structured/:id"; pub const STRUCTURED: &str = "/api/structured/:id";
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay"; pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt"; pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
pub const STRUCTURED_ATTACHMENT: &str = "/api/sessions/:id/structured/attachments";
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel"; pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config"; pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce"; pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
+1 -35
View File
@@ -22,8 +22,7 @@ use chrono::Utc;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{ use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing, routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, StructuredPromptRequest, GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION,
PROTOCOL_VERSION,
}; };
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::{broadcast, oneshot}; use tokio::sync::{broadcast, oneshot};
@@ -61,8 +60,6 @@ struct Inner {
forced_error: Option<(u16, ApiError)>, forced_error: Option<(u16, ApiError)>,
/// Everything `POST /api/sessions/:id/input` was asked to type, in order. /// Everything `POST /api/sessions/:id/input` was asked to type, in order.
inputs: Vec<(Uuid, SendInputRequest)>, inputs: Vec<(Uuid, SendInputRequest)>,
/// Every ACP prompt sent to a structured session, in order.
structured_prompts: Vec<(Uuid, StructuredPromptRequest)>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -95,7 +92,6 @@ impl MockDaemon {
.route(routes::SESSIONS, get(list_sessions).post(create_session)) .route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION, delete(kill_session)) .route(routes::patterns::SESSION, delete(kill_session))
.route(routes::patterns::SESSION_INPUT, post(send_input)) .route(routes::patterns::SESSION_INPUT, post(send_input))
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
.route(routes::INFO, get(daemon_info)) .route(routes::INFO, get(daemon_info))
.route(routes::DIRS, get(list_dir)) .route(routes::DIRS, get(list_dir))
.route(routes::RECENT_DIRS, get(recent_dirs)) .route(routes::RECENT_DIRS, get(recent_dirs))
@@ -182,16 +178,6 @@ impl MockDaemon {
self.state.inner.lock().expect("poisoned").inputs.clone() self.state.inner.lock().expect("poisoned").inputs.clone()
} }
/// Every ACP prompt this daemon received, oldest first.
pub fn structured_prompts(&self) -> Vec<(Uuid, StructuredPromptRequest)> {
self.state
.inner
.lock()
.expect("poisoned")
.structured_prompts
.clone()
}
pub fn session_count(&self) -> usize { pub fn session_count(&self) -> usize {
self.state.inner.lock().expect("poisoned").sessions.len() self.state.inner.lock().expect("poisoned").sessions.len()
} }
@@ -280,26 +266,6 @@ async fn send_input(
StatusCode::NO_CONTENT.into_response() StatusCode::NO_CONTENT.into_response()
} }
async fn structured_prompt(
State(s): State<AppState>,
Path(id): Path<Uuid>,
Json(req): Json<StructuredPromptRequest>,
) -> Response {
if let Some(r) = forced(&s) {
return r;
}
let mut guard = s.inner.lock().expect("poisoned");
if !guard.sessions.iter().any(|x| x.id == id) {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("not_found", "no such session on this daemon")),
)
.into_response();
}
guard.structured_prompts.push((id, req));
StatusCode::NO_CONTENT.into_response()
}
async fn daemon_info(State(s): State<AppState>) -> Response { async fn daemon_info(State(s): State<AppState>) -> Response {
if let Some(r) = forced(&s) { if let Some(r) = forced(&s) {
return r; return r;
-96
View File
@@ -11,7 +11,6 @@
//! `dispatch` / the overlay routing. //! `dispatch` / the overlay routing.
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
@@ -55,36 +54,6 @@ enum OverlayDecision {
Pass, Pass,
} }
fn copy_to_terminal_clipboard(text: &str) -> std::io::Result<()> {
let encoded = base64_encode(text.as_bytes());
let mut output = std::io::stdout();
write!(output, "\x1b]52;c;{encoded}\x07")?;
output.flush()
}
fn base64_encode(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let bits = (u32::from(chunk[0]) << 16)
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
| u32::from(*chunk.get(2).unwrap_or(&0));
output.push(TABLE[((bits >> 18) & 0x3f) as usize] as char);
output.push(TABLE[((bits >> 12) & 0x3f) as usize] as char);
output.push(if chunk.len() > 1 {
TABLE[((bits >> 6) & 0x3f) as usize] as char
} else {
'='
});
output.push(if chunk.len() > 2 {
TABLE[(bits & 0x3f) as usize] as char
} else {
'='
});
}
output
}
pub struct App { pub struct App {
cfg: TuiConfig, cfg: TuiConfig,
home_dir: String, home_dir: String,
@@ -371,10 +340,6 @@ impl App {
return Ok(false); return Ok(false);
} }
if self.handle_structured_mouse(&key) {
return Ok(false);
}
if let Some(effects) = self.handle_structured_key(&key) { if let Some(effects) = self.handle_structured_key(&key) {
self.perform(effects).await; self.perform(effects).await;
return Ok(false); return Ok(false);
@@ -458,61 +423,6 @@ impl App {
true true
} }
/// Transcript blocks are pointer targets: click selects the whole block,
/// gives it the selection surface, and copies its complete text.
fn handle_structured_mouse(&mut self, key: &InputKey) -> bool {
let InputKey::Mouse {
kind,
region: Region::Pane,
row,
..
} = key
else {
return false;
};
if !matches!(kind, MouseKind::Click | MouseKind::Hover) {
return false;
}
let Some(sid) = self.state.active_session() else {
return false;
};
let Some(handle) = self.structured.get(&sid) else {
return false;
};
let Some(pane) = self.region.pane else {
return false;
};
let offset = usize::from(row.saturating_sub(pane.y));
let transcript_height = pane.height.saturating_sub(pane.height.min(3));
if offset >= usize::from(transcript_height) {
return true;
}
let mut state = handle.state.lock().expect("structured state mutex");
let rendered_line = state.scroll.saturating_add(offset);
if !state.select_rendered_line(rendered_line) {
return true;
}
let Some(id) = state.selected.as_deref() else {
return true;
};
let Some(transcript) = state.rows.iter().find(|item| item.id == id) else {
return true;
};
if *kind == MouseKind::Hover {
return true;
}
let text = crate::structured_pane::copy_text(transcript);
drop(state);
if text.is_empty() {
return true;
}
match copy_to_terminal_clipboard(&text) {
Ok(()) => self.state.toast("copied transcript block"),
Err(error) => self.state.toast(format!("copy failed: {error}")),
}
true
}
/// Carry out what `dispatch` decided. The only place in `App` that talks to /// Carry out what `dispatch` decided. The only place in `App` that talks to
/// the daemon. /// the daemon.
async fn perform(&mut self, effects: Vec<Effect>) { async fn perform(&mut self, effects: Vec<Effect>) {
@@ -1995,12 +1905,6 @@ async fn fetch_branches(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn clipboard_payload_uses_standard_base64() {
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
assert_eq!(base64_encode("✓".as_bytes()), "4pyT");
}
use crate::action::Action; use crate::action::Action;
use crate::draft::DraftField; use crate::draft::DraftField;
use crate::keymap::Mode; use crate::keymap::Mode;
+19 -77
View File
@@ -41,7 +41,7 @@ use std::time::{Duration, Instant};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use maestro_config::{builtin_presets, custom_preset}; use maestro_config::{builtin_presets, custom_preset};
use maestro_protocol::display::{abbreviate_dir, supports_structured_view}; use maestro_protocol::display::{abbreviate_dir, command_tool};
use maestro_protocol::{ use maestro_protocol::{
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec, BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
}; };
@@ -255,14 +255,9 @@ pub struct DraftPane {
/// "Advanced options" disclosure (#93). Closed by default — most spawns /// "Advanced options" disclosure (#93). Closed by default — most spawns
/// take the default target. /// take the default target.
pub advanced_collapsed: bool, pub advanced_collapsed: bool,
/// ACP-backed structured transcript. Supported only when the resolved /// ACP-backed structured transcript. Off by default and supported only
/// command is Claude Code or Codex, and on by default whenever it is — /// when the resolved command is Claude Code or Codex.
/// until the operator works the toggle themselves.
pub structured_view: bool, pub structured_view: bool,
/// Set once the operator toggles structured view by hand. From then on a
/// command change never re-enables what they turned off, the same guard
/// `name_touched` gives the name field.
pub structured_view_touched: bool,
/// Enclosing repository of the chosen target — from the project scan for /// Enclosing repository of the chosen target — from the project scan for
/// picked rows, `/api/gitroot` for raw paths. `None` until known. /// picked rows, `/api/gitroot` for raw paths. `None` until known.
pub base_repo: Option<PathBuf>, pub base_repo: Option<PathBuf>,
@@ -303,7 +298,7 @@ impl Default for DraftPane {
// while its data loads, and stay usable when the daemon reports none. // while its data loads, and stay usable when the daemon reports none.
let mut presets = builtin_presets(); let mut presets = builtin_presets();
presets.push(custom_preset()); presets.push(custom_preset());
let mut draft = Self { Self {
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
presets, presets,
selected_preset: 0, selected_preset: 0,
@@ -324,7 +319,6 @@ impl Default for DraftPane {
worktree_enabled: true, worktree_enabled: true,
advanced_collapsed: true, advanced_collapsed: true,
structured_view: false, structured_view: false,
structured_view_touched: false,
base_repo: None, base_repo: None,
probed_cwd: None, probed_cwd: None,
probe_seq: 0, probe_seq: 0,
@@ -343,11 +337,7 @@ impl Default for DraftPane {
// straight away, and Tab from here reaches the command tiles. // straight away, and Tab from here reaches the command tiles.
focused_field: DraftField::DirPicker, focused_field: DraftField::DirPicker,
home_dir: String::new(), home_dir: String::new(),
}; }
// The built-in tile the form opens on decides whether structured view
// starts ticked, exactly as a later preset list would.
draft.sync_structured_view();
draft
} }
} }
@@ -366,7 +356,7 @@ impl DraftPane {
.position(|p| !p.command.is_empty()) .position(|p| !p.command.is_empty())
.unwrap_or(0); .unwrap_or(0);
self.apply_seed_command(); self.apply_seed_command();
self.sync_structured_view(); self.clear_unsupported_structured_view();
} }
/// Land a seeded command on the tile that runs it: its own preset when the /// Land a seeded command on the tile that runs it: its own preset when the
@@ -683,24 +673,18 @@ impl DraftPane {
} }
fn supports_structured_view(&self) -> bool { fn supports_structured_view(&self) -> bool {
supports_structured_view(self.command()) matches!(command_tool(self.command()), Some("claude" | "codex"))
} }
/// Re-resolve the toggle after the command changed: an unsupported fn clear_unsupported_structured_view(&mut self) {
/// command forces it off, a supported one turns it back on unless the
/// operator has already made the choice themselves.
fn sync_structured_view(&mut self) {
if !self.supports_structured_view() { if !self.supports_structured_view() {
self.structured_view = false; self.structured_view = false;
} else if !self.structured_view_touched {
self.structured_view = true;
} }
} }
pub fn toggle_structured_view(&mut self) { pub fn toggle_structured_view(&mut self) {
if self.supports_structured_view() { if self.supports_structured_view() {
self.structured_view = !self.structured_view; self.structured_view = !self.structured_view;
self.structured_view_touched = true;
} }
} }
@@ -848,7 +832,7 @@ impl DraftPane {
} }
self.selected_preset = self.selected_preset =
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize; ((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
self.sync_structured_view(); self.clear_unsupported_structured_view();
} }
/// Step-one boxes surviving the type-to-filter, in display order. A /// Step-one boxes surviving the type-to-filter, in display order. A
@@ -1151,7 +1135,7 @@ impl DraftPane {
DraftField::Preset | DraftField::Submit => {} DraftField::Preset | DraftField::Submit => {}
DraftField::CustomCommand => { DraftField::CustomCommand => {
self.custom_command.handle_key(other); self.custom_command.handle_key(other);
self.sync_structured_view(); self.clear_unsupported_structured_view();
} }
// Typing filters the list; the highlight resets to the top // Typing filters the list; the highlight resets to the top
// so a narrowed search always starts at its best match. // so a narrowed search always starts at its best match.
@@ -1913,7 +1897,7 @@ mod tests {
} }
#[test] #[test]
fn structured_view_is_default_on_and_visible_for_supported_agents() { fn structured_view_is_default_off_and_visible_for_supported_agents() {
let mut draft = DraftPane { let mut draft = DraftPane {
advanced_collapsed: false, advanced_collapsed: false,
..DraftPane::default() ..DraftPane::default()
@@ -1938,11 +1922,13 @@ mod tests {
auto_approve: false, auto_approve: false,
}, },
]); ]);
assert!(draft.structured_view, "on by default for a supported agent"); assert!(!draft.structured_view);
assert!(draft.field_order().contains(&DraftField::StructuredToggle)); assert!(draft.field_order().contains(&DraftField::StructuredToggle));
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true)); let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(rendered.contains("[x] structured view"), "{rendered}"); assert!(rendered.contains("[ ] structured view"), "{rendered}");
draft.toggle_structured_view();
assert!(draft.structured_view);
draft.cycle_preset(1); draft.cycle_preset(1);
assert_eq!(draft.command(), "codex"); assert_eq!(draft.command(), "codex");
assert!( assert!(
@@ -2032,16 +2018,16 @@ mod tests {
}; };
draft.selected_preset = draft.presets.len() - 1; draft.selected_preset = draft.presets.len() - 1;
draft.custom_command = crate::text_input::TextInput::with_value(command); draft.custom_command = crate::text_input::TextInput::with_value(command);
draft.sync_structured_view();
assert_eq!( assert_eq!(
draft.field_order().contains(&DraftField::StructuredToggle), draft.field_order().contains(&DraftField::StructuredToggle),
supported, supported,
"toggle visibility for {command:?}" "toggle visibility for {command:?}"
); );
draft.toggle_structured_view();
assert_eq!( assert_eq!(
draft.structured_view, supported, draft.structured_view, supported,
"default selection for {command:?}" "toggle takes effect for {command:?}"
); );
let view = draft.build_request().unwrap().view; let view = draft.build_request().unwrap().view;
assert_eq!( assert_eq!(
@@ -2053,12 +2039,6 @@ mod tests {
}, },
"request view for {command:?}" "request view for {command:?}"
); );
draft.toggle_structured_view();
assert!(
!draft.structured_view,
"the toggle turns the default off for {command:?}"
);
} }
} }
@@ -2096,6 +2076,7 @@ mod tests {
assert_eq!(draft.command(), "npx claude-code --resume"); assert_eq!(draft.command(), "npx claude-code --resume");
assert!(draft.field_order().contains(&DraftField::StructuredToggle)); assert!(draft.field_order().contains(&DraftField::StructuredToggle));
draft.toggle_structured_view();
assert!(draft.structured_view); assert!(draft.structured_view);
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured); assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
@@ -2114,45 +2095,6 @@ mod tests {
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal); assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
} }
/// The default only applies until the operator has an opinion: once they
/// untick the box, moving between supported commands leaves it unticked
/// instead of turning it back on under them.
#[test]
fn an_unticked_structured_view_survives_a_command_change() {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/srv/work")),
worktree_enabled: false,
advanced_collapsed: false,
..DraftPane::default()
};
draft.set_presets(vec![
SpawnPreset {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
},
SpawnPreset {
id: "codex".into(),
label: "Codex".into(),
command: "codex".into(),
auto_approve: false,
},
]);
assert!(draft.structured_view);
draft.toggle_structured_view();
assert!(!draft.structured_view);
draft.cycle_preset(1);
assert_eq!(draft.command(), "codex");
assert!(
!draft.structured_view,
"the operator's choice outranks the default"
);
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
/// Editing the custom-command text away from a supported agent clears an armed /// Editing the custom-command text away from a supported agent clears an armed
/// selection immediately, matching the preset-switch case above — an /// selection immediately, matching the preset-switch case above — an
/// invalid `Structured` request can never reach `build_request`. /// invalid `Structured` request can never reach `build_request`.
@@ -2167,7 +2109,7 @@ mod tests {
}; };
draft.selected_preset = draft.presets.len() - 1; draft.selected_preset = draft.presets.len() - 1;
draft.custom_command = crate::text_input::TextInput::with_value("codex"); draft.custom_command = crate::text_input::TextInput::with_value("codex");
draft.sync_structured_view(); draft.toggle_structured_view();
assert!(draft.structured_view); assert!(draft.structured_view);
typed(&mut draft, " -p"); typed(&mut draft, " -p");
-7
View File
@@ -55,7 +55,6 @@ pub enum MouseKind {
Click, Click,
DblClick, DblClick,
Drag, Drag,
Hover,
ScrollUp, ScrollUp,
ScrollDown, ScrollDown,
} }
@@ -149,7 +148,6 @@ impl InputKey {
MouseKind::Click => "click", MouseKind::Click => "click",
MouseKind::DblClick => "dblclick", MouseKind::DblClick => "dblclick",
MouseKind::Drag => "drag", MouseKind::Drag => "drag",
MouseKind::Hover => "hover",
MouseKind::ScrollUp => "scrollup", MouseKind::ScrollUp => "scrollup",
MouseKind::ScrollDown => "scrolldown", MouseKind::ScrollDown => "scrolldown",
}; };
@@ -180,7 +178,6 @@ impl InputKey {
"click" => MouseKind::Click, "click" => MouseKind::Click,
"dblclick" => MouseKind::DblClick, "dblclick" => MouseKind::DblClick,
"drag" => MouseKind::Drag, "drag" => MouseKind::Drag,
"hover" => MouseKind::Hover,
"scrollup" => MouseKind::ScrollUp, "scrollup" => MouseKind::ScrollUp,
"scrolldown" => MouseKind::ScrollDown, "scrolldown" => MouseKind::ScrollDown,
other => return Err(InputParseError::UnknownMouseKind(other.into())), other => return Err(InputParseError::UnknownMouseKind(other.into())),
@@ -300,7 +297,6 @@ where
let mk = match kind { let mk = match kind {
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click, MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag, MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
MouseEventKind::Moved => MouseKind::Hover,
MouseEventKind::ScrollUp => MouseKind::ScrollUp, MouseEventKind::ScrollUp => MouseKind::ScrollUp,
MouseEventKind::ScrollDown => MouseKind::ScrollDown, MouseEventKind::ScrollDown => MouseKind::ScrollDown,
_ => return None, _ => return None,
@@ -685,9 +681,6 @@ mod tests {
let s = "mouse:dblclick(sidebar-row)"; let s = "mouse:dblclick(sidebar-row)";
let k = InputKey::parse(s).unwrap(); let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s); assert_eq!(k.canonical(), s);
let s = "mouse:hover(pane)";
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s);
} }
#[test] #[test]
+2 -3
View File
@@ -82,7 +82,7 @@ impl Surface {
pub fn activity(&self, state: ActivityState) -> Color { pub fn activity(&self, state: ActivityState) -> Color {
match state { match state {
ActivityState::Active => self.active, ActivityState::Active => self.active,
ActivityState::Idle | ActivityState::Starting => self.idle, ActivityState::Idle => self.idle,
ActivityState::AwaitingInput => self.awaiting, ActivityState::AwaitingInput => self.awaiting,
ActivityState::Error => self.error, ActivityState::Error => self.error,
} }
@@ -928,10 +928,9 @@ pub fn contrast_ratio(a: Color, b: Color) -> Option<f64> {
mod tests { mod tests {
use super::*; use super::*;
const STATES: [ActivityState; 5] = [ const STATES: [ActivityState; 4] = [
ActivityState::Active, ActivityState::Active,
ActivityState::Idle, ActivityState::Idle,
ActivityState::Starting,
ActivityState::AwaitingInput, ActivityState::AwaitingInput,
ActivityState::Error, ActivityState::Error,
]; ];
-2
View File
@@ -979,7 +979,6 @@ mod tests {
for state in [ for state in [
ActivityState::Active, ActivityState::Active,
ActivityState::Idle, ActivityState::Idle,
ActivityState::Starting,
ActivityState::AwaitingInput, ActivityState::AwaitingInput,
ActivityState::Error, ActivityState::Error,
] { ] {
@@ -1010,7 +1009,6 @@ mod tests {
for state in [ for state in [
ActivityState::Active, ActivityState::Active,
ActivityState::Idle, ActivityState::Idle,
ActivityState::Starting,
ActivityState::AwaitingInput, ActivityState::AwaitingInput,
ActivityState::Error, ActivityState::Error,
] { ] {
+6 -83
View File
@@ -308,12 +308,11 @@ fn fallback_after_removal(
fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> { fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
let mut out = Vec::new(); let mut out = Vec::new();
for (index, row) in state.rows.iter().enumerate() { for row in &state.rows {
if row.kind == TranscriptRowKind::Plan { if row.kind == TranscriptRowKind::Plan {
continue; continue;
} }
let row_width = width.saturating_sub(1).max(1); let row_width = width.saturating_sub(1).max(1);
let start = out.len();
match row.kind { match row.kind {
TranscriptRowKind::User => push_wrapped( TranscriptRowKind::User => push_wrapped(
&mut out, &mut out,
@@ -357,53 +356,10 @@ fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
), ),
TranscriptRowKind::Plan => {} TranscriptRowKind::Plan => {}
} }
if out.len() > start
&& state.rows[index + 1..]
.iter()
.any(|next| next.kind != TranscriptRowKind::Plan)
{
// A blank surface line is the vertical padding that keeps adjacent
// transcript blocks readable at a glance.
out.push(VisualLine {
text: String::new(),
row_id: None,
rail: false,
tone: Tone::Normal,
});
}
} }
out out
} }
/// Text supplied to the terminal clipboard when a transcript block is
/// clicked. It preserves the full tool detail rather than its compact preview.
pub fn copy_text(row: &TranscriptRow) -> String {
match row.kind {
TranscriptRowKind::User
| TranscriptRowKind::AgentText
| TranscriptRowKind::AgentThought => content_text(row),
TranscriptRowKind::ToolCall | TranscriptRowKind::ToolDiff => {
let (name, argument) = tool_title(row);
let mut lines = vec![if argument.is_empty() {
name
} else {
format!("{name} {argument}")
}];
lines.extend(tool_content_lines(row, None));
lines.join("\n")
}
TranscriptRowKind::Approval => row
.data
.get("toolCall")
.and_then(|tool| tool.get("title"))
.and_then(serde_json::Value::as_str)
.unwrap_or("permission requested")
.into(),
TranscriptRowKind::Notice => "[notice]".into(),
TranscriptRowKind::Plan => String::new(),
}
}
fn push_wrapped( fn push_wrapped(
out: &mut Vec<VisualLine>, out: &mut Vec<VisualLine>,
row: &TranscriptRow, row: &TranscriptRow,
@@ -1044,39 +1000,6 @@ mod tests {
); );
} }
#[test]
fn transcript_blocks_have_a_padding_line_between_them() {
let mut state = StructuredState::default();
state.rows = vec![
row(
"one",
TranscriptRowKind::User,
json!({"content":{"type":"text","text":"first"}}),
),
row(
"two",
TranscriptRowKind::AgentText,
json!({"content":{"type":"text","text":"second"}}),
),
];
let lines = build_lines(&state, 80);
assert_eq!(lines.len(), 3);
assert_eq!(lines[1].text, "");
assert!(lines[1].row_id.is_none());
}
#[test]
fn copying_a_tool_keeps_its_full_detail() {
let tool = row(
"t",
TranscriptRowKind::ToolCall,
json!({"title":"Bash(cargo test)","content":[{"type":"content","content":{"type":"text","text":"all green"}}]}),
);
assert_eq!(copy_text(&tool), "Bash cargo test\nall green");
}
#[test] #[test]
fn tool_is_compact_until_selected_row_expands() { fn tool_is_compact_until_selected_row_expands() {
let mut state = StructuredState::default(); let mut state = StructuredState::default();
@@ -1497,7 +1420,7 @@ mod tests {
let mut state = StructuredState::default(); let mut state = StructuredState::default();
state.follow_selection = false; state.follow_selection = false;
numbered_rows(&mut state, 20); numbered_rows(&mut state, 20);
state.scroll = 20; state.scroll = 10;
let before = render_transcript_lines(&pane, &mut state, 30, 5); let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}"); assert!(before[0].contains("line 10"), "{before:?}");
@@ -1516,7 +1439,7 @@ mod tests {
let mut state = StructuredState::default(); let mut state = StructuredState::default();
state.follow_selection = false; state.follow_selection = false;
numbered_rows(&mut state, 20); numbered_rows(&mut state, 20);
state.scroll = 20; state.scroll = 10;
let before = render_transcript_lines(&pane, &mut state, 30, 5); let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}"); assert!(before[0].contains("line 10"), "{before:?}");
@@ -1536,7 +1459,7 @@ mod tests {
let mut state = StructuredState::default(); let mut state = StructuredState::default();
state.follow_selection = false; state.follow_selection = false;
numbered_rows(&mut state, 20); numbered_rows(&mut state, 20);
state.scroll = 20; state.scroll = 10;
let before = render_transcript_lines(&pane, &mut state, 30, 5); let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}"); assert!(before[0].contains("line 10"), "{before:?}");
@@ -1555,7 +1478,7 @@ mod tests {
let mut state = StructuredState::default(); let mut state = StructuredState::default();
state.follow_selection = false; state.follow_selection = false;
numbered_rows(&mut state, 20); numbered_rows(&mut state, 20);
state.scroll = 20; state.scroll = 10;
let before = render_transcript_lines(&pane, &mut state, 30, 5); let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}"); assert!(before[0].contains("line 10"), "{before:?}");
@@ -1574,7 +1497,7 @@ mod tests {
let mut state = StructuredState::default(); let mut state = StructuredState::default();
state.follow_selection = false; state.follow_selection = false;
numbered_rows(&mut state, 20); numbered_rows(&mut state, 20);
state.scroll = 20; state.scroll = 10;
render_transcript_lines(&pane, &mut state, 30, 5); render_transcript_lines(&pane, &mut state, 30, 5);
// Simulate a `TranscriptDelta::Patch` on a collapsed tool row above the // Simulate a `TranscriptDelta::Patch` on a collapsed tool row above the
-31
View File
@@ -199,18 +199,6 @@ impl StructuredState {
self.follow_selection = true; self.follow_selection = true;
} }
/// Select the transcript block under a rendered transcript line. Pointer
/// input uses the same row identity map as scroll reconciliation, so a
/// wrapped block behaves as one target.
pub fn select_rendered_line(&mut self, line: usize) -> bool {
let Some(id) = self.line_rows.get(line).and_then(Clone::clone) else {
return false;
};
self.selected = Some(id);
self.follow_selection = false;
true
}
pub fn toggle_selected(&mut self) { pub fn toggle_selected(&mut self) {
let Some(id) = self.selected.clone() else { let Some(id) = self.selected.clone() else {
return; return;
@@ -669,25 +657,6 @@ mod tests {
assert_eq!(state.composer.value(), "hi"); assert_eq!(state.composer.value(), "hi");
} }
#[test]
fn selecting_a_rendered_line_selects_its_whole_block() {
let mut state = StructuredState {
line_rows: vec![
Some("one".into()),
Some("one".into()),
None,
Some("two".into()),
],
..Default::default()
};
assert!(state.select_rendered_line(1));
assert_eq!(state.selected.as_deref(), Some("one"));
assert!(!state.follow_selection);
assert!(!state.select_rendered_line(2));
assert_eq!(state.selected.as_deref(), Some("one"));
}
#[test] #[test]
fn authoritative_idle_snapshot_reconciles_a_wholly_missed_turn() { fn authoritative_idle_snapshot_reconciles_a_wholly_missed_turn() {
let mut state = StructuredState::default(); let mut state = StructuredState::default();
-1
View File
@@ -160,7 +160,6 @@ The state word is never spelled out — the colour carries it.
| ---------------- | --------- | ---------------- | | ---------------- | --------- | ---------------- |
| `Active` | `--live` | `active` | | `Active` | `--live` | `active` |
| `Idle` | `--muted` | `idle` | | `Idle` | `--muted` | `idle` |
| `Starting` | `--muted` | `idle` |
| `AwaitingInput` | `--warn` | `awaiting` | | `AwaitingInput` | `--warn` | `awaiting` |
| `Error` | `--danger`| `error` | | `Error` | `--danger`| `error` |
+5 -52
View File
@@ -35,22 +35,6 @@ session's worktree holds unsaved work — applies to a structured session
exactly as it does to a terminal one; only what gets terminated differs (an exactly as it does to a terminal one; only what gets terminated differs (an
ACP handle instead of a tmux pane). ACP handle instead of a tmux pane).
## Choosing the view
`view` is fixed at spawn and never changes. Every client picks it the same
way, from the resolved command alone
(`maestro_protocol::display::supports_structured_view`): a command that
resolves to Claude Code or Codex spawns **Structured** by default, anything
else spawns **Terminal**. The operator can override the default — the web and
TUI spawn forms show the checkbox ticked and let it be unticked, and
`maestro spawn` takes `--terminal` (or `--structured`, which sends the request
even for a command the CLI does not recognise and lets the daemon refuse it).
Once the operator works the toggle, a later command change never re-ticks it
under them; it can still be forced off when the new command has no adapter.
The daemon is the last word: a `Structured` request for a command with no
adapter is refused there, whichever client sent it.
## The adapter ## The adapter
The resolved structured command selects a provider adapter. Its binary is The resolved structured command selects a provider adapter. Its binary is
@@ -190,16 +174,9 @@ choices, not daemon ones.
from the transcript; the TUI may show the current plan in its status line, from the transcript; the TUI may show the current plan in its status line,
while the web client deliberately has no stats or plan rail. while the web client deliberately has no stats or plan rail.
- The web client groups two or more tool rows after the same `User` row into - The web client groups two or more tool rows after the same `User` row into
one collapsed turn activity block. `AgentText` and `AgentThought` rows that one collapsed turn activity block. Opening it reveals independently
appear *between* tool rows in the same turn are absorbed into the group expandable tool calls, including any raw command, input, output, and diff
(intermediate narration); the last `AgentText` with no subsequent tool stays content the adapter supplied.
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 - The web client's user, agent, thought, tool-detail, and notice text blocks
copy their complete source text when clicked or keyboard-activated. copy their complete source text when clicked or keyboard-activated.
- The web client consumes `available_commands_update` notices as the live - The web client consumes `available_commands_update` notices as the live
@@ -325,26 +302,6 @@ pending decision, entirely on the daemon side:
## Prompt and cancel: bounded liveness ## Prompt and cancel: bounded liveness
The web composer accepts up to ten files through its attachment picker, file
drop, or clipboard. Pasted text of at least 4,000 characters or 40 lines becomes
a UTF-8 `.txt` attachment. Shorter pastes stay in the composer. Attachments can
be removed before sending; failed sends retain the draft and attachments.
Pastes also stay inline when all ten attachment slots are occupied or the text
exceeds the 20 MiB file limit.
On send, `POST /api/sessions/:id/structured/attachments?name=<filename>` uploads
each file as raw bytes (20 MiB maximum) and returns its absolute `path`.
Only live structured sessions accept uploads. Filenames are limited to 180
bytes and cannot contain directory separators or control characters. Files get unique names and private
file permissions in `<id>.attachments` beside the session sidecar. They persist
across daemon restarts; removing a composer chip does not delete uploaded data.
There is currently no automatic attachment cleanup.
The prompt includes each filename and host path so the adapter can read it
through its own file tools, including images when the provider supports them.
These references are persisted in the ordinary user transcript row. Attachment
bytes are not embedded in ACP messages or transcript history.
`POST /api/sessions/:id/structured/prompt` and `.../cancel` are serialized `POST /api/sessions/:id/structured/prompt` and `.../cancel` are serialized
against each other through one per-session `operation` lock against each other through one per-session `operation` lock
(`AcpEventStream::operation`), because ACP permission callbacks carry no (`AcpEventStream::operation`), because ACP permission callbacks carry no
@@ -353,15 +310,11 @@ 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 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." allowed to claim the daemon's idea of "the active turn."
- Web clients may collect a visible FIFO of operator messages while a turn is - A prompt is appended as a durable `User` row before it claims the turn and
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 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 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 turn is active, a second prompt gets `409 turn_in_flight` rather than
queuing in the daemon — one active turn per session, never a hidden queuing — one active turn per session, never a hidden queue of prompts.
server-side queue of prompts.
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`, - Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
and ends the turn's client-visible lifecycle immediately. If the 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 turn was still *dispatched* to ACP (waiting on its response), the daemon
+2 -12
View File
@@ -17,22 +17,12 @@
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.19.0", "@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"marked": "^18.0.11", "marked": "^18.0.11"
"react": "^19.2.8",
"react-dom": "^19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^4.7.0",
"happy-dom": "^20.11.6", "happy-dom": "^20.11.6",
"tailwindcss": "^4.3.3",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^5.4.10", "vite": "^5.4.10",
"vitest": "^2.1.9", "vitest": "^2.1.9"
"zustand": "^5.0.15"
} }
} }
+11 -984
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
export function App() {
return <div id="react-root" />;
}
-9
View File
@@ -184,15 +184,6 @@ export async function sendStructuredPrompt(id: Uuid, prompt: string): Promise<vo
); );
} }
export async function uploadStructuredFile(id: Uuid, file: File): Promise<{ path: string }> {
return jsonOrError(await fetch(`${routes.structuredAttachment(id)}?name=${encodeURIComponent(file.name)}`, {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/octet-stream" },
body: file,
}));
}
/** Cancel a structured session's active ACP turn (#138). */ /** Cancel a structured session's active ACP turn (#138). */
export async function cancelStructuredTurn(id: Uuid): Promise<void> { export async function cancelStructuredTurn(id: Uuid): Promise<void> {
await jsonOrError( await jsonOrError(
+3 -4
View File
@@ -663,9 +663,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
applyNav(); applyNav();
}; };
// Pointer hover previews only while Ctrl is held — plain hover was too // Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
// disruptive when sweeping the sidebar. Keyboard walk (j/k) still previews // out: there is no hover there, and a tap already opens the session.
// unconditionally via applyNav.
let hovered: string | null = null; let hovered: string | null = null;
const setHovered = (id: string | null) => { const setHovered = (id: string | null) => {
if (hovered === id) return; if (hovered === id) return;
@@ -673,7 +672,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
opts.onPreview?.(id); opts.onPreview?.(id);
}; };
treeHost.addEventListener("pointerover", (e) => { treeHost.addEventListener("pointerover", (e) => {
if (e.pointerType === "touch" || !e.ctrlKey) return; if (e.pointerType === "touch") return;
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf"); const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
setHovered(row?.dataset.sessionId ?? null); setHovered(row?.dataset.sessionId ?? null);
}); });
-10
View File
@@ -1,10 +0,0 @@
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./main.css";
const container = document.getElementById("root");
if (!container) {
throw new Error("missing #root element");
}
createRoot(container).render(<App />);
-1
View File
@@ -1 +0,0 @@
@import "tailwindcss";
-4
View File
@@ -47,10 +47,6 @@ export function structuredPrompt(id: Uuid): string {
return `${session(id)}/structured/prompt`; return `${session(id)}/structured/prompt`;
} }
export function structuredAttachment(id: Uuid): string {
return `${session(id)}/structured/attachments`;
}
/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */ /** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */
export function structuredCancel(id: Uuid): string { export function structuredCancel(id: Uuid): string {
return `${session(id)}/structured/cancel`; return `${session(id)}/structured/cancel`;
+5 -11
View File
@@ -182,32 +182,26 @@ describe("sidebar preview requests (docs/PREVIEW.md)", () => {
expect(seen.at(-1)).toBe(null); expect(seen.at(-1)).toBe(null);
}); });
it("ctrl+hover previews a card, plain hover does not", () => { it("asks for a preview of the hovered card, and clears it on the way out", () => {
const seen: (string | null)[] = []; const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) }); const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0]; const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
// Plain hover — no preview.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" })); 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]); expect(seen).toEqual([A]);
// Moving within the same card is not a new preview.
// Deduplicated while still on the same card. card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
expect(seen).toHaveLength(1); expect(seen).toHaveLength(1);
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" })); tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
expect(seen.at(-1)).toBe(null); expect(seen.at(-1)).toBe(null);
}); });
it("ignores touch even with ctrl", () => { it("ignores touch, where a tap already opens the session", () => {
const seen: (string | null)[] = []; const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) }); const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0]; const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch", ctrlKey: true })); card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch" }));
expect(seen).toEqual([]); expect(seen).toEqual([]);
}); });
}); });
+57 -98
View File
@@ -11,7 +11,7 @@ import { attachDirAutocomplete } from "./dirAutocomplete.js";
import { parentDir } from "./paths.js"; import { parentDir } from "./paths.js";
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js"; import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
import type { ProjectGroup } from "./projectList.js"; import type { ProjectGroup } from "./projectList.js";
import { splitCommand, toolIconSvg, TOOL_LABELS } from "./toolIcon.js"; import { splitCommand } from "./toolIcon.js";
import { abbreviateCwd } from "./types.js"; import { abbreviateCwd } from "./types.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js"; import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
@@ -140,12 +140,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const form = document.createElement("form"); const form = document.createElement("form");
form.className = "spawn-form"; form.className = "spawn-form";
form.innerHTML = ` 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"> <div class="field">
<span class="field-label">Project</span> <span class="field-label">Project</span>
<input class="raw-input project-filter" type="text" placeholder="Filter projects…" <input class="raw-input project-filter" type="text" placeholder="Filter projects…"
@@ -157,20 +151,26 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
placeholder="/home/user/project" inputmode="text" autocomplete="off" /> placeholder="/home/user/project" inputmode="text" autocomplete="off" />
</div> </div>
</div> </div>
<div class="advanced"> <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="field">
<span class="field-label">Name</span>
<div class="name-row">
<input name="name" type="text" autocomplete="off"
placeholder="named after your first prompt" />
</div>
</div>
<fieldset class="worktree">
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false"> <button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
<span class="chevron">▾</span> <span class="chevron">▾</span>
<span class="folder-name">Advanced</span> <span class="folder-name">Advanced options</span>
<span class="wt-summary"></span> <span class="wt-summary"></span>
</button> </button>
<div class="wt-collapsible" hidden> <div class="wt-collapsible" hidden>
<div class="field">
<span class="field-label">Session name</span>
<div class="name-row">
<input name="name" type="text" autocomplete="off"
placeholder="named after your first prompt" />
</div>
</div>
<label class="row-inline structured-view" hidden> <label class="row-inline structured-view" hidden>
<input type="checkbox" name="structured_view" /> <input type="checkbox" name="structured_view" />
Structured view Structured view
@@ -179,36 +179,34 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
<span class="field-label">Worktree</span> <span class="field-label">Worktree</span>
<div class="picker wt-choices"></div> <div class="picker wt-choices"></div>
</div> </div>
<div class="adv-group"> <label class="row-inline">
<label class="row-inline"> <input type="checkbox" name="worktree_enabled" />
<input type="checkbox" name="worktree_enabled" /> Create git worktree
Create git worktree </label>
</label> <p class="wt-hint" hidden></p>
<p class="wt-hint" hidden></p> <div class="wt-body" hidden>
<div class="wt-body" hidden> <p class="wt-repo"></p>
<p class="wt-repo"></p> <div class="wt-mode" role="radiogroup" aria-label="Branch source">
<div class="wt-mode" role="radiogroup" aria-label="Branch source"> <label class="row-inline">
<label class="row-inline"> <input type="radio" name="worktree_branch_mode" value="new" />
<input type="radio" name="worktree_branch_mode" value="new" /> New branch
New branch
</label>
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="existing" />
Existing branch
</label>
</div>
<label class="wt-new-wrap">
New branch name
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
</label> </label>
<label class="wt-exist-wrap" hidden> <label class="row-inline">
Pick a branch <input type="radio" name="worktree_branch_mode" value="existing" />
<select class="branch-select"></select> Existing branch
</label> </label>
</div> </div>
<label class="wt-new-wrap">
New branch name
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
</label>
<label class="wt-exist-wrap" hidden>
Pick a branch
<select class="branch-select"></select>
</label>
</div> </div>
</div> </div>
</div> </fieldset>
<p class="form-error" role="alert" aria-live="polite"></p> <p class="form-error" role="alert" aria-live="polite"></p>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">Spawn</button> <button type="submit" class="btn btn-primary">Spawn</button>
@@ -348,92 +346,50 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
command = preset!.command; command = preset!.command;
} }
updateStructuredAvailability(); updateStructuredAvailability();
for (const card of cmdTiles.querySelectorAll(".harness-card")) { for (const tile of cmdTiles.querySelectorAll(".tile")) {
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === id); tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
} }
emit(); emit();
} }
/** Re-highlight the operator's already-picked card after `renderCommandTiles` /** Re-highlight the operator's already-picked tile after `renderCommandTiles`
* rebuilds the grid from scratch (new cards carry no `is-active` class) — * rebuilds the row from scratch (new tiles carry no `is-active` class) —
* without touching `command`, since that was already settled. */ * without touching `command`, since that was already settled. */
function reapplyActiveTile(): void { function reapplyActiveTile(): void {
for (const card of cmdTiles.querySelectorAll(".harness-card")) { for (const tile of cmdTiles.querySelectorAll(".tile")) {
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === selectedPresetId); tile.classList.toggle("is-active", (tile 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 { function renderCommandTiles(): void {
cmdTiles.replaceChildren(); cmdTiles.replaceChildren();
for (const p of presets) { for (const p of presets) {
const isCustom = p.command === ""; const tile = document.createElement("button");
const card = document.createElement("button"); tile.type = "button";
card.type = "button"; tile.className = "tile";
card.className = isCustom ? "harness-card harness-custom" : "harness-card"; tile.dataset.id = p.id;
card.dataset.id = p.id; tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
const toolId = presetToolId(p); tile.addEventListener("click", () => {
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; commandTouched = true;
selectPreset(p.id); selectPreset(p.id);
}); });
cmdTiles.appendChild(card); cmdTiles.appendChild(tile);
} }
} }
/** 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 // Set once the operator picks a tile or types a custom command — guards
// `applyInitialCommand()` the same way `nameTouched` guards a late name // `applyInitialCommand()` the same way `nameTouched` guards a late name
// batch: a late `/api/info` resolution must never overwrite a field the // batch: a late `/api/info` resolution must never overwrite a field the
// operator has already touched (#76). // operator has already touched (#76).
let commandTouched = false; let commandTouched = false;
// Set once the operator works the checkbox themselves. Until then every
// command change re-derives the default (on for Claude Code and Codex, off
// for everything else); afterwards their choice outranks it, the same guard
// `commandTouched` gives the command field.
let structuredTouched = false;
function updateStructuredAvailability(): void { function updateStructuredAvailability(): void {
const supported = supportsStructuredView(command); const supported = supportsStructuredView(command);
structuredWrap.hidden = !supported; structuredWrap.hidden = !supported;
if (!supported) structuredView.checked = false; if (!supported) structuredView.checked = false;
else if (!structuredTouched) structuredView.checked = true;
} }
structuredView.addEventListener("change", () => { structuredView.addEventListener("change", emit);
structuredTouched = true;
emit();
});
cmdRaw.addEventListener("input", () => { cmdRaw.addEventListener("input", () => {
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") { if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
@@ -680,13 +636,16 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
box.type = "button"; box.type = "button";
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`; box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
box.dataset.cwd = project.path; box.dataset.cwd = project.path;
const trees = worktreesOf(group).length;
// The parent directory only — the last segment is the name line above it. // The parent directory only — the last segment is the name line above it.
// Unabbreviated beyond `~`, since the stem is what tells two projects // Unabbreviated beyond `~`, since the stem is what tells two projects
// sharing a basename apart (#95). // sharing a basename apart (#95).
const parent = abbreviateCwd(parentDir(project.path), homeDir); const parent = abbreviateCwd(parentDir(project.path), homeDir);
const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "";
box.innerHTML = ` box.innerHTML = `
<span class="project-name">${escapeHtml(project.name)}</span> <span class="project-name">${escapeHtml(project.name)}</span>
<span class="project-path">${escapeHtml(parent)}</span>`; <span class="project-path">${escapeHtml(parent)}</span>
<span class="project-meta">${escapeHtml(meta)}</span>`;
box.addEventListener("click", () => selectProject(group)); box.addEventListener("click", () => selectProject(group));
return box; return box;
} }
+1 -1
View File
@@ -139,7 +139,7 @@ describe("spawnForm Enter-to-submit (#97)", () => {
const form = await mount([MAESTRO]); const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click(); box(form, MAESTRO.path).click();
await drain(); await drain();
form.querySelector<HTMLButtonElement>('.cmd-tiles .harness-card[data-id="custom"]')!.click(); form.querySelector<HTMLButtonElement>('.cmd-tiles .tile[data-id="custom"]')!.click();
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!; const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
raw.value = "bash -l"; raw.value = "bash -l";
raw.dispatchEvent(new Event("input")); raw.dispatchEvent(new Event("input"));
+3 -3
View File
@@ -63,7 +63,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
await drain(); await drain();
// Fallback presets are up; the operator picks "zsh" before /api/info lands. // Fallback presets are up; the operator picks "zsh" before /api/info lands.
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find( const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
(t) => t.dataset.id === "zsh", (t) => t.dataset.id === "zsh",
)!; )!;
zshTile.click(); zshTile.click();
@@ -88,7 +88,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
document.body.appendChild(form); document.body.appendChild(form);
await drain(); await drain();
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find( const customTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
(t) => t.dataset.id === "custom", (t) => t.dataset.id === "custom",
)!; )!;
customTile.click(); 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. // Untouched: the refined preset list is still free to settle the command.
expect(values?.command).toBe("claude"); expect(values?.command).toBe("claude");
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find( const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
(t) => t.dataset.id === "fish", (t) => t.dataset.id === "fish",
); );
expect(fishTile).toBeDefined(); expect(fishTile).toBeDefined();
+6 -42
View File
@@ -44,7 +44,7 @@ describe("spawn form structured view", () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it("is default-on and shown for resolved Claude Code and Codex commands", async () => { it("is default-off and shown for resolved Claude Code and Codex commands", async () => {
stubApi(); stubApi();
handle = createSpawnForm({ onSuccess() {} }); handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el); document.body.appendChild(handle.el);
@@ -57,7 +57,7 @@ describe("spawn form structured view", () => {
handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.click(); handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
expect(disclosure.hidden).toBe(false); expect(disclosure.hidden).toBe(false);
expect(wrap.hidden).toBe(false); expect(wrap.hidden).toBe(false);
expect(checkbox.checked).toBe(true); expect(checkbox.checked).toBe(false);
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!; const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
custom.click(); custom.click();
@@ -65,34 +65,13 @@ describe("spawn form structured view", () => {
command.value = "FOO=1 /usr/local/bin/codex"; command.value = "FOO=1 /usr/local/bin/codex";
command.dispatchEvent(new Event("input")); command.dispatchEvent(new Event("input"));
expect(wrap.hidden).toBe(false); expect(wrap.hidden).toBe(false);
expect(checkbox.checked).toBe(true);
command.value = "opencode"; command.value = "opencode";
command.dispatchEvent(new Event("input")); command.dispatchEvent(new Event("input"));
expect(wrap.hidden).toBe(true); expect(wrap.hidden).toBe(true);
expect(checkbox.checked).toBe(false);
}); });
it("keeps an unchecked box unchecked across a command change", async () => { it("clears a checked value when Codex changes to an unsupported command", async () => {
stubApi();
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
await drain();
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
expect(checkbox.checked).toBe(true);
checkbox.click();
expect(checkbox.checked).toBe(false);
handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "codex";
command.dispatchEvent(new Event("input"));
expect(checkbox.checked).toBe(false);
});
it("clears the checked default when Codex changes to an unsupported command", async () => {
stubApi(); stubApi();
handle = createSpawnForm({ onSuccess() {} }); handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el); document.body.appendChild(handle.el);
@@ -104,6 +83,7 @@ describe("spawn form structured view", () => {
command.dispatchEvent(new Event("input")); command.dispatchEvent(new Event("input"));
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!; const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
checkbox.click();
expect(checkbox.checked).toBe(true); expect(checkbox.checked).toBe(true);
command.value = "opencode"; command.value = "opencode";
@@ -113,7 +93,7 @@ describe("spawn form structured view", () => {
expect(checkbox.checked).toBe(false); expect(checkbox.checked).toBe(false);
}); });
it.each(["claude", "codex"])("emits Structured for a %s command by default", async (tool) => { it.each(["claude", "codex"])("emits Structured for a selected %s command", async (tool) => {
const requests = stubApi(); const requests = stubApi();
handle = createSpawnForm({ handle = createSpawnForm({
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false }, seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
@@ -129,6 +109,7 @@ describe("spawn form structured view", () => {
command.dispatchEvent(new Event("input")); command.dispatchEvent(new Event("input"));
} }
handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!.click();
handle.el.requestSubmit(); handle.el.requestSubmit();
await drain(); await drain();
@@ -138,21 +119,4 @@ describe("spawn form structured view", () => {
view: "Structured", view: "Structured",
}); });
}); });
it("emits no view once the operator unticks the box", async () => {
const requests = stubApi();
handle = createSpawnForm({
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
onSuccess() {},
});
document.body.appendChild(handle.el);
await drain();
handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!.click();
handle.el.requestSubmit();
await drain();
expect(requests).toHaveLength(1);
expect(JSON.parse(String(requests[0].body)).view).toBeUndefined();
});
}); });
+2 -2
View File
@@ -52,14 +52,14 @@ describe("spawn modal", () => {
expect(host.querySelector(".form-actions .cancel")).toBeTruthy(); expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
}); });
it("opens with the project filter focused, harness before project", async () => { it("opens with the project filter focused, ahead of the command tiles", async () => {
openSpawnModal({ onSuccess: () => {} }); openSpawnModal({ onSuccess: () => {} });
await flush(); await flush();
const form = modal()!.querySelector("form.spawn-form")!; const form = modal()!.querySelector("form.spawn-form")!;
expect(document.activeElement).toBe(form.querySelector(".project-filter")); expect(document.activeElement).toBe(form.querySelector(".project-filter"));
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent); const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
expect(fields.slice(0, 2)).toEqual(["Harness", "Project"]); expect(fields.slice(0, 2)).toEqual(["Project", "Command"]);
}); });
it("titles a seeded open as a clone", async () => { it("titles a seeded open as a clone", async () => {
+15 -247
View File
@@ -6,7 +6,6 @@ vi.mock("./api.js", async () => {
return { return {
...actual, ...actual,
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined), sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
uploadStructuredFile: vi.fn().mockResolvedValue({ path: "/uploads/pasted.txt" }),
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined), cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined), resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
setStructuredConfig: vi.fn().mockResolvedValue(undefined), setStructuredConfig: vi.fn().mockResolvedValue(undefined),
@@ -17,7 +16,6 @@ import {
cancelStructuredTurn, cancelStructuredTurn,
resolveStructuredApproval, resolveStructuredApproval,
sendStructuredPrompt, sendStructuredPrompt,
uploadStructuredFile,
setStructuredConfig, setStructuredConfig,
} from "./api.js"; } from "./api.js";
import { TOGGLE_DRAWER_EVENT } from "./drawer.js"; import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
@@ -97,53 +95,6 @@ function mount(opts: {
describe("structured connection: data flow", () => { describe("structured connection: data flow", () => {
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());
it("converts long pasted text into a removable file and sends it without reply text", async () => {
const conn = mount({ sockets: [new FakeSocket()] });
const input = conn.el.querySelector("textarea")!;
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: {
files: [], getData: () => "a".repeat(4000),
} });
input.dispatchEvent(paste);
expect(paste.defaultPrevented).toBe(true);
expect(input.value).toBe("");
expect(conn.el.querySelector(".structured-attachments")!.textContent).toContain("pasted-1.txt");
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
await vi.waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", expect.stringContaining("/uploads/pasted.txt")));
expect(uploadStructuredFile).toHaveBeenCalledWith("s1", expect.objectContaining({ name: "pasted-1.txt", size: 4000 }));
conn.dispose();
});
it("leaves short pastes alone", () => {
const conn = mount({ sockets: [new FakeSocket()] });
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: { files: [], getData: () => "hello" } });
conn.el.querySelector("textarea")!.dispatchEvent(paste);
expect(paste.defaultPrevented).toBe(false);
conn.dispose();
});
it("retains failed uploads for retry and lets the operator remove them", async () => {
vi.mocked(uploadStructuredFile).mockRejectedValueOnce(new Error("Upload failed"));
const conn = mount({ sockets: [new FakeSocket()] });
const input = conn.el.querySelector("textarea")!;
input.value = "Read this";
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: {
files: [new File(["content"], "notes.txt")], getData: () => "",
} });
input.dispatchEvent(paste);
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
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"]')!;
expect(remove).not.toBeNull();
remove.click();
expect(conn.el.querySelector<HTMLElement>(".structured-attachments")!.hidden).toBe(true);
conn.dispose();
});
it("renders every row from the initial snapshot", () => { it("renders every row from the initial snapshot", () => {
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
@@ -413,95 +364,6 @@ describe("structured connection: data flow", () => {
conn.dispose(); 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 });
// Intermediate updates stay inside the collapsed activity disclosure,
// rather than becoming a second visible transcript alongside its summary.
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();
// It remains collapsed when the turn ends.
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("collapses trailing thought commentary with a turn's tool activity", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 5,
rows: [
row("user:1", "user", { content: { type: "text", text: "do it" } }),
toolRow("tool:1", "one"),
toolRow("tool:2", "two"),
row("thought:1", "agent_thought", { content: { type: "text", text: "Checking the next step" } }),
row("thought:2", "agent_thought", { content: { type: "text", text: "Preparing the change" } }),
],
},
});
for (const id of ["thought:1", "thought:2"]) {
expect(conn.el.querySelector(`[data-row-id="${id}"]`)?.closest(".st-tool-group")).not.toBeNull();
}
conn.dispose();
});
it("keeps an expanded running tool open without emitting a click when patched", () => { it("keeps an expanded running tool open without emitting a click when patched", () => {
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
@@ -749,7 +611,7 @@ describe("structured connection: turn/composer gating", () => {
conn.dispose(); conn.dispose();
}); });
it("keeps the composer available while a turn is active", () => { it("disables the composer while a turn is active and re-enables when idle", () => {
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
ws.open(); ws.open();
@@ -757,13 +619,13 @@ describe("structured connection: turn/composer gating", () => {
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement; const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement;
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
expect(input.disabled).toBe(false); expect(input.disabled).toBe(true);
expect(cancel.hidden).toBe(false); expect(cancel.hidden).toBe(false);
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send"); expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(true);
ws.emit({ type: "reduced_state", state: idleState }); ws.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false); expect(input.disabled).toBe(false);
expect(cancel.hidden).toBe(true); expect(cancel.hidden).toBe(true);
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send"); expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(false);
conn.dispose(); conn.dispose();
}); });
@@ -826,7 +688,7 @@ describe("structured connection: turn/composer gating", () => {
conn.dispose(); conn.dispose();
}); });
it("queues a second message before begin-turn is projected", () => { it("disables immediately and dispatches only once before begin-turn is projected", () => {
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {})); vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
@@ -839,45 +701,12 @@ describe("structured connection: turn/composer gating", () => {
input.value = "only once"; input.value = "only once";
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true })); form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
input.value = "queued next";
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true })); form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
expect(input.disabled).toBe(false); expect(input.disabled).toBe(true);
expect(send.disabled).toBe(false); expect(send.disabled).toBe(true);
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1); expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once"); expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
const queued = conn.el.querySelectorAll(".st-row-queued");
expect(queued).toHaveLength(1);
expect(queued[0].textContent).toBe("queued next");
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.querySelectorAll(".st-row-queued")).toHaveLength(2);
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.querySelectorAll(".st-row-queued")).toHaveLength(0);
conn.dispose(); conn.dispose();
}); });
@@ -929,7 +758,7 @@ describe("structured connection: turn/composer gating", () => {
expect(sendStructuredPrompt).toHaveBeenCalledTimes(2); expect(sendStructuredPrompt).toHaveBeenCalledTimes(2);
expect(input.value).toBe(""); expect(input.value).toBe("");
expect(input.disabled).toBe(false); expect(input.disabled).toBe(true);
expect(conn.el.textContent).not.toContain("stale failure"); expect(conn.el.textContent).not.toContain("stale failure");
conn.dispose(); conn.dispose();
}); });
@@ -991,7 +820,7 @@ describe("structured connection: approvals", () => {
conn.dispose(); conn.dispose();
}); });
it("hides a standalone resolved approval entirely when no tool group absorbs it", () => { it("removes the buttons once the approval resolves, even with no accompanying transcript delta", () => {
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
ws.open(); ws.open();
@@ -1007,68 +836,7 @@ describe("structured connection: approvals", () => {
// Resolved on another client: state clears with no transcript delta at all. // Resolved on another client: state clears with no transcript delta at all.
ws.emit({ type: "reduced_state", state: idleState }); ws.emit({ type: "reduced_state", state: idleState });
expect(conn.el.querySelector(".st-approval-acts")).toBeNull(); expect(conn.el.querySelector(".st-approval-acts")).toBeNull();
// Standalone resolved approval is hidden — no card, no compact line. expect(conn.el.querySelector(".st-approval-resolved")).not.toBeNull();
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(); conn.dispose();
}); });
@@ -1309,7 +1077,7 @@ describe("structured connection: reconnection/error/loading", () => {
second.open(); second.open();
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
second.emit({ type: "reduced_state", state: idleState }); second.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false); expect(input.disabled).toBe(true);
second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
second.emit({ type: "reduced_state", state: idleState }); second.emit({ type: "reduced_state", state: idleState });
@@ -1361,12 +1129,12 @@ describe("structured connection: reconnection/error/loading", () => {
second.open(); second.open();
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
second.emit({ type: "reduced_state", state: idleState }); second.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false); expect(input.disabled).toBe(true);
accept(); accept();
await Promise.resolve().then(() => Promise.resolve()); await Promise.resolve().then(() => Promise.resolve());
expect(input.disabled).toBe(false); expect(input.disabled).toBe(true);
expect(second.closed).toBe(true); expect(second.closed).toBe(true);
third.open(); third.open();
@@ -1767,7 +1535,7 @@ describe("structured connection: pane lifecycle", () => {
conn.dispose(); conn.dispose();
}); });
it("focusTerminal keeps the composer reachable during an active turn", () => { it("focusTerminal focuses the composer only when it is enabled", () => {
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
ws.open(); ws.open();
@@ -1775,7 +1543,7 @@ describe("structured connection: pane lifecycle", () => {
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
conn.focusTerminal(); conn.focusTerminal();
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
expect(document.activeElement).toBe(input); expect(document.activeElement).not.toBe(input);
ws.emit({ type: "reduced_state", state: idleState }); ws.emit({ type: "reduced_state", state: idleState });
conn.focusTerminal(); conn.focusTerminal();
expect(document.activeElement).toBe(input); expect(document.activeElement).toBe(input);
+55 -358
View File
@@ -10,7 +10,6 @@ import {
cancelStructuredTurn, cancelStructuredTurn,
resolveStructuredApproval, resolveStructuredApproval,
sendStructuredPrompt, sendStructuredPrompt,
uploadStructuredFile,
setStructuredConfig, setStructuredConfig,
structuredWsUrl, structuredWsUrl,
} from "./api.js"; } from "./api.js";
@@ -85,18 +84,13 @@ export function createStructuredConnection(
<div class="structured-main"> <div class="structured-main">
<div class="structured-scroll"> <div class="structured-scroll">
<div class="structured-rows"></div> <div class="structured-rows"></div>
<div class="structured-activity" role="status" aria-live="polite" hidden> </div>
<span class="structured-activity-pulse" aria-hidden="true"></span> <div class="structured-activity" role="status" aria-live="polite" hidden>
<span class="structured-activity-text">Thinking</span> <span class="structured-activity-pulse" aria-hidden="true"></span>
</div> <span class="structured-activity-text">Thinking</span>
</div> </div>
<form class="structured-composer"> <form class="structured-composer">
<button type="button" class="btn btn-ghost btn-icon structured-attach" aria-label="Attach files" title="Attach files (up to 20 MiB each)">
<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>
</button>
<input class="structured-file-input" type="file" multiple hidden>
<div class="structured-input-wrap"> <div class="structured-input-wrap">
<div class="structured-attachments" aria-label="Attachments" aria-live="polite" hidden></div>
<div id="structured-command-menu-${sessionId}" class="structured-command-menu" <div id="structured-command-menu-${sessionId}" class="structured-command-menu"
role="listbox" hidden></div> role="listbox" hidden></div>
<textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…" <textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…"
@@ -113,10 +107,7 @@ export function createStructuredConnection(
<button type="submit" class="btn btn-primary structured-send"><span>Send</span> <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> <svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7 5 5 5-5 5M12 10H3"/></svg>
</button> </button>
<button type="button" class="btn btn-icon structured-cancel" hidden aria-label="Cancel turn" title="Cancel turn"> <button type="button" class="btn structured-cancel" hidden>Cancel turn</button>
<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>
</form> </form>
</div> </div>
</div> </div>
@@ -136,82 +127,7 @@ export function createStructuredConnection(
const commandMenu = el.querySelector(".structured-command-menu") as HTMLDivElement; const commandMenu = el.querySelector(".structured-command-menu") as HTMLDivElement;
const input = el.querySelector(".structured-input") as HTMLTextAreaElement; const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement; const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
const sendLabel = sendBtn.querySelector("span") as HTMLSpanElement;
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement; const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
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;
function renderAttachments(): void {
attachmentEl.replaceChildren();
attachmentEl.hidden = attachments.length === 0;
for (const attachment of attachments) {
const chip = document.createElement("span");
chip.className = "structured-attachment";
const label = document.createElement("span");
label.textContent = attachment.file.name;
label.title = `${attachment.file.name} (${attachment.file.size.toLocaleString()} bytes)`;
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "×";
remove.setAttribute("aria-label", `Remove ${attachment.file.name}`);
remove.disabled = uploading;
remove.addEventListener("click", () => {
attachments = attachments.filter((item) => item !== attachment);
renderAttachments();
});
chip.append(label, remove);
attachmentEl.append(chip);
}
}
function addFiles(files: File[]): void {
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.`);
} else if (attachments.length >= 10) {
showError("Attach up to 10 files per message.");
} else {
attachments.push({ file });
}
}
renderAttachments();
}
attachBtn.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => {
addFiles(Array.from(fileInput.files ?? []));
fileInput.value = "";
});
composerForm.addEventListener("dragover", (event) => {
if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
});
composerForm.addEventListener("drop", (event) => {
if (!event.dataTransfer?.files.length) return;
event.preventDefault();
addFiles(Array.from(event.dataTransfer.files));
});
input.addEventListener("paste", (event) => {
const clipboard = event.clipboardData;
if (!clipboard || uploading) return;
if (clipboard.files.length) {
event.preventDefault();
addFiles(Array.from(clipboard.files));
return;
}
const text = clipboard.getData("text/plain");
if (text.length < 4000 && text.split("\n").length < 40) return;
if (attachments.length >= 10 || new Blob([text]).size > 20 * 1024 * 1024) return;
event.preventDefault();
addFiles([new File([text], `pasted-${++pasteCount}.txt`, { type: "text/plain" })]);
input.setRangeText("", input.selectionStart, input.selectionEnd, "end");
autoResize();
});
disconnectBtn.addEventListener("click", () => opts.onRequestClose?.(sessionId)); disconnectBtn.addEventListener("click", () => opts.onRequestClose?.(sessionId));
// Mobile-only (CSS hides it on desktop, where the sidebar is always in // Mobile-only (CSS hides it on desktop, where the sidebar is always in
@@ -249,10 +165,6 @@ export function createStructuredConnection(
let disposed = false; let disposed = false;
let optimisticTurnStartedAt: number | null = null; let optimisticTurnStartedAt: number | null = null;
let activityTimer: number | null = null; let activityTimer: number | null = null;
const promptQueue: QueuedPrompt[] = [];
let promptRequestPending = false;
/** Server path → object URL for uploaded images, so user bubbles can show previews. */
const imageObjectUrls = new Map<string, string>();
// Within 64px, wheel and touch imprecision should still count as following. // Within 64px, wheel and touch imprecision should still count as following.
const FOLLOW_BOTTOM_THRESHOLD_PX = 64; const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
@@ -383,9 +295,6 @@ export function createStructuredConnection(
} }
} }
/** Pattern matching `Attached file "name": "path"` references appended by dispatchPrompt. */
const attachedFileRe = /Attached file "(?:[^"\\]|\\.)*": "((?:[^"\\]|\\.)*)"/g;
function buildRowElement(row: TranscriptRow): HTMLElement | null { function buildRowElement(row: TranscriptRow): HTMLElement | null {
const el = buildTranscriptRow( const el = buildTranscriptRow(
row, row,
@@ -402,102 +311,20 @@ export function createStructuredConnection(
if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") { if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") {
expandedRowIds.delete(row.id); expandedRowIds.delete(row.id);
} }
// Inject image previews into user message bubbles and strip the path text.
if (row.kind === "user" && imageObjectUrls.size > 0) {
const data = row.data as { content?: { type: string; text?: string } } | undefined;
const text = data?.content?.type === "text" ? data.content.text ?? "" : "";
const body = el.querySelector(".st-body");
if (body && text) {
const images: string[] = [];
attachedFileRe.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = attachedFileRe.exec(text)) !== null) {
const serverPath = JSON.parse(`"${match[1]}"`);
const objectUrl = imageObjectUrls.get(serverPath);
if (objectUrl) images.push(objectUrl);
}
if (images.length > 0) {
// Strip attachment reference lines from the displayed text.
const userText = body.querySelector(".st-user-text");
if (userText) {
const cleaned = text.replace(/\n\nAttached file "[^"]*": "[^"]*"/g, "").trim();
userText.textContent = cleaned;
if (!cleaned) userText.remove();
}
for (const src of images) {
const img = document.createElement("img");
img.className = "st-user-image";
img.src = src;
img.alt = "Uploaded image";
img.loading = "lazy";
body.appendChild(img);
}
}
}
}
return el; return el;
} }
/** Group tool rows (and intermediate agent text / resolved approvals between function toolGroupsByTurn(): Map<string, TranscriptRow[]> {
* them) by turn. An agent_text/agent_thought row is "intermediate" when a const groups = new Map<string, TranscriptRow[]>();
* 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"; let turnId = "before-first-message";
for (const row of rows) { for (const row of rows) {
if (row.kind === "user") turnId = row.id; if (row.kind === "user") turnId = row.id;
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue; if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
const gid = `turn:${turnId}`; const id = `turn:${turnId}`;
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1); const group = groups.get(id) ?? [];
group.push(row);
groups.set(id, group);
} }
// 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);
}
}
// A final agent text is the answer, so it stays outside. Thought rows are
// working commentary, though: when a turn already has a disclosure they
// belong with its activity even if the next tool call has not arrived yet.
// Leaving that tail loose made a busy agent look like it was publishing a
// long italic answer above a collapsed "N tool calls" summary.
const lastGroup = groups.get(currentGid);
if (lastGroup && pending.length > 0 && pending.every((row) => row.kind === "agent_thought")) {
lastGroup.rows.push(...pending);
}
return groups; return groups;
} }
@@ -507,12 +334,11 @@ export function createStructuredConnection(
const focusedGroupId = active?.closest<HTMLElement>("[data-tool-group-id]")?.dataset.toolGroupId; const focusedGroupId = active?.closest<HTMLElement>("[data-tool-group-id]")?.dataset.toolGroupId;
const restoreToolFocus = active?.classList.contains("st-tool-head") ?? false; const restoreToolFocus = active?.classList.contains("st-tool-head") ?? false;
const restoreGroupFocus = active?.classList.contains("st-tool-group-head") ?? false; const restoreGroupFocus = active?.classList.contains("st-tool-group-head") ?? false;
const groups = activityGroupsByTurn(); const groups = toolGroupsByTurn();
const groupedRowIds = new Set<string>(); const groupedRowIds = new Set<string>();
const liveGroupIds = new Set([...groups.keys()]); const liveGroupIds = new Set([...groups].filter(([, tools]) => tools.length > 1).map(([id]) => id));
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id); for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
const prevGroupEls = new Map(groupEls);
rowEls.clear(); rowEls.clear();
groupEls.clear(); groupEls.clear();
const children: HTMLElement[] = []; const children: HTMLElement[] = [];
@@ -520,54 +346,34 @@ export function createStructuredConnection(
for (const row of rows) { for (const row of rows) {
if (row.kind === "user") turnId = row.id; if (row.kind === "user") turnId = row.id;
const groupId = `turn:${turnId}`; const groupId = `turn:${turnId}`;
const group = groups.get(groupId); const groupRows = groups.get(groupId) ?? [];
if (group && groupedRowIds.has(row.id)) continue; if ((row.kind === "tool_call" || row.kind === "tool_diff") && groupRows.length > 1) {
if (group && (row.kind === "tool_call" || row.kind === "tool_diff") && !groupedRowIds.has(row.id)) { if (groupedRowIds.has(row.id)) continue;
// First ungrouped tool in this turn — render the whole activity group. const toolEls = groupRows.flatMap((toolRow) => {
const memberEls = group.rows.flatMap((memberRow) => { groupedRowIds.add(toolRow.id);
groupedRowIds.add(memberRow.id); const toolEl = buildRowElement(toolRow);
const memberEl = buildRowElement(memberRow); if (!toolEl) return [];
if (!memberEl) return []; rowEls.set(toolRow.id, toolEl);
rowEls.set(memberRow.id, memberEl); return [toolEl];
return [memberEl];
}); });
const groupEl = buildToolGroup(
// Reuse existing group element when possible — avoids tearing down groupId,
// and recreating the wrapper, which restarts CSS transitions (visible groupRows,
// flash) and destroys keyboard/focus state. toolEls,
const existing = prevGroupEls.get(groupId); expandedGroupIds.has(groupId),
if (existing) { (expanded) => {
const titleEl = existing.querySelector<HTMLElement>(".st-tool-group-title"); if (expanded) expandedGroupIds.add(groupId);
if (titleEl) titleEl.textContent = `${group.toolCount} tool calls`; else expandedGroupIds.delete(groupId);
const inner = existing.querySelector<HTMLElement>(".st-disclosure-inner"); settleDisclosureLayout();
if (inner) inner.replaceChildren(...memberEls); },
groupEls.set(groupId, existing); );
children.push(existing); if (groupRows.some((toolRow) => enteringRowIds.has(toolRow.id))) {
} else { groupEl.classList.add("st-row-enter");
const groupEl = buildToolGroup(
groupId,
group.rows,
memberEls,
expandedGroupIds.has(groupId),
(expanded) => {
if (expanded) expandedGroupIds.add(groupId);
else expandedGroupIds.delete(groupId);
settleDisclosureLayout();
},
group.toolCount,
);
if (group.rows.some((r) => enteringRowIds.has(r.id))) {
groupEl.classList.add("st-row-enter");
}
groupEls.set(groupId, groupEl);
children.push(groupEl);
} }
groupEls.set(groupId, groupEl);
children.push(groupEl);
continue; 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); const rowEl = buildRowElement(row);
if (!rowEl) continue; if (!rowEl) continue;
rowEls.set(row.id, rowEl); rowEls.set(row.id, rowEl);
@@ -619,24 +425,6 @@ export function createStructuredConnection(
rows.push(delta.row); rows.push(delta.row);
} else { } else {
rows[i] = delta.row; 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 active = document.activeElement as HTMLElement | null;
const hadToolFocus = active?.classList.contains("st-tool-head") && oldEl.contains(active);
const newEl = buildRowElement(delta.row);
if (newEl) {
oldEl.replaceWith(newEl);
rowEls.set(delta.row.id, newEl);
if (hadToolFocus) {
newEl.querySelector<HTMLButtonElement>(".st-tool-head")?.focus();
}
}
syncAvailableCommands();
return;
}
} }
} else { } else {
const i = rowIndex.get(delta.id); const i = rowIndex.get(delta.id);
@@ -681,14 +469,12 @@ export function createStructuredConnection(
} }
} }
let configDirty = false;
function renderConfig(): void { function renderConfig(): void {
const options = sessionConfigOptions(); const options = sessionConfigOptions();
configEl.hidden = options.length === 0; configEl.hidden = options.length === 0;
if (options.length === 0) { if (options.length === 0) {
configMenu.hidden = true; configMenu.hidden = true;
configTrigger.setAttribute("aria-expanded", "false"); configTrigger.setAttribute("aria-expanded", "false");
configDirty = false;
return; return;
} }
const ordered = [...options].sort((a, b) => { const ordered = [...options].sort((a, b) => {
@@ -702,15 +488,6 @@ export function createStructuredConnection(
configTrigger.title = summary; configTrigger.title = summary;
configTrigger.setAttribute("aria-label", `Session settings. ${summary.replaceAll("\n", ". ")}`); configTrigger.setAttribute("aria-label", `Session settings. ${summary.replaceAll("\n", ". ")}`);
configTrigger.disabled = configBusy; 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(); configMenu.replaceChildren();
for (const option of ordered) { for (const option of ordered) {
const row = document.createElement("label"); const row = document.createElement("label");
@@ -749,33 +526,14 @@ export function createStructuredConnection(
} }
} }
function renderQueuedPrompts(): void {
for (const old of rowsEl.querySelectorAll(".st-row-queued")) old.remove();
for (const queued of promptQueue) {
const wrap = document.createElement("div");
wrap.className = "st-row st-row-queued";
const body = document.createElement("div");
body.className = "st-body";
const line = document.createElement("div");
line.className = "st-user-text";
line.textContent = queued.text;
body.appendChild(line);
wrap.appendChild(body);
rowsEl.appendChild(wrap);
}
}
function renderComposerState(): void { function renderComposerState(): void {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const projectedActive = state.turn.responding || state.turn.thinking; const projectedActive = state.turn.responding || state.turn.thinking;
input.disabled = false; input.disabled = active;
attachBtn.disabled = uploading; sendBtn.disabled = active;
fileInput.disabled = uploading; sendBtn.hidden = projectedActive;
renderAttachments();
sendBtn.disabled = false;
sendBtn.hidden = false;
sendLabel.textContent = "Send";
cancelBtn.hidden = !projectedActive; cancelBtn.hidden = !projectedActive;
renderQueuedPrompts(); if (active) closeCommandMenu();
renderActivity(); renderActivity();
} }
@@ -787,7 +545,6 @@ export function createStructuredConnection(
function renderActivity(): void { function renderActivity(): void {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null; const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const wasHidden = activityEl.hidden;
activityEl.hidden = !active; activityEl.hidden = !active;
if (!active) { if (!active) {
optimisticTurnStartedAt = null; optimisticTurnStartedAt = null;
@@ -810,11 +567,6 @@ export function createStructuredConnection(
} }
activityText.textContent = parts.join(" · "); activityText.textContent = parts.join(" · ");
if (activityTimer === null) activityTimer = window.setInterval(renderActivity, 1_000); if (activityTimer === null) activityTimer = window.setInterval(renderActivity, 1_000);
// Activity lives inside the scroll area; nudge scroll when it first appears.
if (wasHidden && followingBottom) {
beginTranscriptLayout(true);
finishTranscriptLayout();
}
} }
function reconcileAwaitingBegin(): void { function reconcileAwaitingBegin(): void {
@@ -830,22 +582,19 @@ export function createStructuredConnection(
} }
function applyState(next: ReducedState, connectionGeneration: number): void { function applyState(next: ReducedState, connectionGeneration: number): void {
const wasTurnActive = state.turn.responding || state.turn.thinking;
state = next; state = next;
stateSocketGeneration = connectionGeneration; stateSocketGeneration = connectionGeneration;
reconcileAwaitingBegin(); reconcileAwaitingBegin();
const isTurnActive = state.turn.responding || state.turn.thinking;
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null; const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
const needsRebuild = newPendingId !== pendingApprovalRowId || wasTurnActive !== isTurnActive; if (newPendingId !== pendingApprovalRowId) {
if (needsRebuild) {
updateTranscriptLayout(() => { updateTranscriptLayout(() => {
const prev = pendingApprovalRowId;
pendingApprovalRowId = newPendingId; pendingApprovalRowId = newPendingId;
rebuildTranscript(); if (prev !== null || newPendingId !== null) rebuildTranscript();
}); });
} }
renderConfig(); renderConfig();
renderComposerState(); renderComposerState();
dispatchQueuedPrompt();
} }
function closeConfigMenu(): void { function closeConfigMenu(): void {
@@ -855,7 +604,6 @@ export function createStructuredConnection(
configTrigger.addEventListener("click", () => { configTrigger.addEventListener("click", () => {
const opening = configMenu.hidden; const opening = configMenu.hidden;
if (opening && configDirty) renderConfig();
configMenu.hidden = !opening; configMenu.hidden = !opening;
configTrigger.setAttribute("aria-expanded", String(opening)); configTrigger.setAttribute("aria-expanded", String(opening));
}); });
@@ -1046,58 +794,35 @@ export function createStructuredConnection(
if (!commandMenu.contains(document.activeElement)) closeCommandMenu(); if (!commandMenu.contains(document.activeElement)) closeCommandMenu();
}, 0); }, 0);
}); });
function dispatchPrompt(text: string, submittedAttachments: Attachment[] = []): void { composerForm.addEventListener("submit", (e) => {
uploading = submittedAttachments.length > 0; e.preventDefault();
const text = input.value;
if (!text.trim() || input.disabled) return;
const generation = ++submissionGeneration; const generation = ++submissionGeneration;
const submissionSocketGeneration = socketGeneration; const submissionSocketGeneration = socketGeneration;
awaitingBeginGeneration = generation; awaitingBeginGeneration = generation;
optimisticTurnStartedAt = Date.now(); optimisticTurnStartedAt = Date.now();
acceptedSubmission = null; acceptedSubmission = null;
promptRequestPending = true;
input.value = ""; input.value = "";
autoResize(); autoResize();
clearError(); clearError();
renderComposerState(); renderComposerState();
const send = async () => { sendStructuredPrompt(sessionId, text).then(
for (const attachment of submittedAttachments) {
if (disposed) return;
if (!attachment.path) attachment.path = (await uploadStructuredFile(sessionId, attachment.file)).path;
if (attachment.path && attachment.file.type.startsWith("image/")) {
imageObjectUrls.set(attachment.path, URL.createObjectURL(attachment.file));
}
}
if (disposed) return;
const references = submittedAttachments.map(({ file, path }) =>
`Attached file ${JSON.stringify(file.name)}: ${JSON.stringify(path)}`);
await sendStructuredPrompt(sessionId, [text, ...references].filter(Boolean).join("\n\n"));
};
void send().then(
() => { () => {
promptRequestPending = false; if (
if (disposed || generation !== submissionGeneration) return; disposed
uploading = false; || generation !== submissionGeneration
if (attachments === submittedAttachments) { || awaitingBeginGeneration !== generation
attachments = []; ) return;
renderAttachments();
}
renderComposerState();
if (awaitingBeginGeneration !== generation) return;
const acceptanceSocketGeneration = socketGeneration; const acceptanceSocketGeneration = socketGeneration;
acceptedSubmission = { generation, socketGeneration: acceptanceSocketGeneration }; acceptedSubmission = { generation, socketGeneration: acceptanceSocketGeneration };
// A replacement socket may have captured idle before the POST claimed // A replacement socket may have captured idle before the POST claimed
// the prompt. Reconnect again so its snapshot cannot predate the 204. // the prompt. Reconnect again so its snapshot cannot predate the 204.
if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation(); if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation();
renderComposerState();
dispatchQueuedPrompt();
}, },
(e: unknown) => { (e: unknown) => {
promptRequestPending = false;
uploading = false;
if (disposed || generation !== submissionGeneration) return; if (disposed || generation !== submissionGeneration) return;
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null; if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
if (attachments.length === 0 && submittedAttachments.length > 0) {
attachments = submittedAttachments;
}
if (input.value === "") { if (input.value === "") {
input.value = text; input.value = text;
autoResize(); autoResize();
@@ -1106,32 +831,6 @@ export function createStructuredConnection(
renderComposerState(); 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", () => { cancelBtn.addEventListener("click", () => {
clearError(); clearError();
@@ -1304,8 +1003,6 @@ export function createStructuredConnection(
closeCurrentSocket(); closeCurrentSocket();
expandedRowIds.clear(); expandedRowIds.clear();
expandedGroupIds.clear(); expandedGroupIds.clear();
for (const url of imageObjectUrls.values()) URL.revokeObjectURL(url);
imageObjectUrls.clear();
scrollEl.removeEventListener("scroll", onTranscriptScroll); scrollEl.removeEventListener("scroll", onTranscriptScroll);
scrollEl.removeEventListener("click", onTranscriptClick, true); scrollEl.removeEventListener("click", onTranscriptClick, true);
scrollEl.removeEventListener("wheel", stopSmoothFollow); scrollEl.removeEventListener("wheel", stopSmoothFollow);
+4 -5
View File
@@ -315,8 +315,8 @@ describe("buildTranscriptRow: tool diffs", () => {
(el.querySelector(".st-tool-head") as HTMLButtonElement).click(); (el.querySelector(".st-tool-head") as HTMLButtonElement).click();
const diff = el.querySelector(".st-diff") as HTMLElement; const diff = el.querySelector(".st-diff") as HTMLElement;
expect(diff).not.toBeNull(); expect(diff).not.toBeNull();
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("+ c"); expect(diff.querySelector(".st-ln-add")!.textContent).toBe("c");
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("- b"); expect(diff.querySelector(".st-ln-del")!.textContent).toBe("b");
}); });
it("gives the diff its own horizontally-scrolling container", () => { it("gives the diff its own horizontally-scrolling container", () => {
@@ -368,14 +368,13 @@ describe("buildTranscriptRow: approval", () => {
expect(el.querySelector(".st-approval-path")!.textContent).toBe("/etc/x"); expect(el.querySelector(".st-approval-path")!.textContent).toBe("/etc/x");
}); });
it("renders a compact one-liner once resolved", () => { it("hides the buttons and shows an answered note once resolved", () => {
const el = buildTranscriptRow(approvalRow(allThreeOptions), { const el = buildTranscriptRow(approvalRow(allThreeOptions), {
pending: false, pending: false,
onDecide: () => Promise.resolve(), onDecide: () => Promise.resolve(),
}); });
expect(el.querySelector(".st-approval-acts")).toBeNull(); expect(el.querySelector(".st-approval-acts")).toBeNull();
expect(el.classList.contains("st-approval-compact")).toBe(true); expect(el.querySelector(".st-approval-resolved")!.textContent).toBe("answered");
expect(el.textContent).toContain("approved");
}); });
it("invokes onDecide with the clicked decision and disables the buttons", () => { it("invokes onDecide with the clicked decision and disables the buttons", () => {
+50 -50
View File
@@ -223,16 +223,15 @@ function buildDiffBlock(diff: { path: string; oldText?: string | null; newText:
wrap.appendChild(label); wrap.appendChild(label);
const scroller = el("div", "st-diff"); const scroller = el("div", "st-diff");
const pre = document.createElement("pre"); const pre = document.createElement("pre");
const lines = computeDiffLines(diff.oldText, diff.newText); for (const line of computeDiffLines(diff.oldText, diff.newText)) {
const prefixes: Record<string, string> = { add: "+ ", del: "- ", ctx: " ", hunk: "", eof: "" };
for (const line of lines) {
const span = document.createElement("span"); const span = document.createElement("span");
span.className = `st-ln st-ln-${line.kind}`; span.className = `st-ln st-ln-${line.kind}`;
span.textContent = (prefixes[line.kind] ?? "") + line.text; span.textContent = line.text;
pre.appendChild(span); pre.appendChild(span);
pre.appendChild(document.createTextNode("\n"));
} }
scroller.appendChild(pre); scroller.appendChild(pre);
makeCopyable(scroller, lines.map((l) => (prefixes[l.kind] ?? "") + l.text).join("\n"), "diff"); makeCopyable(scroller, pre.textContent ?? "", "diff");
wrap.appendChild(scroller); wrap.appendChild(scroller);
return wrap; return wrap;
} }
@@ -582,9 +581,7 @@ export function buildToolGroup(
children: HTMLElement[], children: HTMLElement[],
expanded: boolean, expanded: boolean,
onExpandedChange?: (expanded: boolean) => void, onExpandedChange?: (expanded: boolean) => void,
toolCount?: number,
): HTMLElement { ): HTMLElement {
const count = toolCount ?? rows.length;
const wrap = el("div", "st-tool-group"); const wrap = el("div", "st-tool-group");
wrap.dataset.toolGroupId = id; wrap.dataset.toolGroupId = id;
const head = document.createElement("button"); const head = document.createElement("button");
@@ -596,8 +593,15 @@ export function buildToolGroup(
glyph.textContent = "›"; glyph.textContent = "›";
glyph.setAttribute("aria-hidden", "true"); glyph.setAttribute("aria-hidden", "true");
const title = el("span", "st-tool-group-title"); const title = el("span", "st-tool-group-title");
title.textContent = `${count} tool calls`; title.textContent = `${rows.length} tool calls`;
head.append(glyph, title); const names = el("span", "st-tool-group-names");
names.textContent = rows
.map((row) => {
const tool = (row.data ?? {}) as AcpToolCallLike;
return splitToolTitle(tool.title, tool.name, tool.kind).label;
})
.join(" · ");
head.append(glyph, title, names);
const body = el("div", "st-tool-group-body st-disclosure"); const body = el("div", "st-tool-group-body st-disclosure");
const bodyClip = el("div", "st-disclosure-clip"); const bodyClip = el("div", "st-disclosure-clip");
@@ -705,16 +709,6 @@ function offeredDecisions(options: AcpPermissionOption[]): StructuredApprovalDec
function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLElement { function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLElement {
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] }; const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
const toolCall = request.toolCall ?? {}; 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 wrap = el("div", "st-row st-row-approval");
const card = el("div", "st-approval"); const card = el("div", "st-approval");
wrap.appendChild(card); wrap.appendChild(card);
@@ -734,39 +728,45 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
card.appendChild(pathEl); card.appendChild(pathEl);
} }
const acts = el("div", "st-approval-acts"); if (approval.pending) {
const mk = (label: string, decision: StructuredApprovalDecision, cls: string) => { const acts = el("div", "st-approval-acts");
const btn = document.createElement("button"); const mk = (label: string, decision: StructuredApprovalDecision, cls: string) => {
btn.type = "button"; const btn = document.createElement("button");
btn.className = cls ? `act ${cls}` : "act"; btn.type = "button";
btn.textContent = label; btn.className = cls ? `act ${cls}` : "act";
btn.addEventListener("click", () => { btn.textContent = label;
const buttons = [...acts.querySelectorAll<HTMLButtonElement>("button")]; btn.addEventListener("click", () => {
buttons.forEach((b) => (b.disabled = true)); const buttons = [...acts.querySelectorAll<HTMLButtonElement>("button")];
approval.onDecide(decision).catch(() => { buttons.forEach((b) => (b.disabled = true));
// Rejects only when this approval is still pending (see approval.onDecide(decision).catch(() => {
// ApprovalRowState) — restore the controls so the operator can // Rejects only when this approval is still pending (see
// retry; the error itself is surfaced by the connection. // ApprovalRowState) — restore the controls so the operator can
buttons.forEach((b) => (b.disabled = false)); // retry; the error itself is surfaced by the connection.
buttons.forEach((b) => (b.disabled = false));
});
}); });
}); return btn;
return btn; };
}; const offered = offeredDecisions(request.options ?? []);
const offered = offeredDecisions(request.options ?? []); const specs: [StructuredApprovalDecision, string, string][] = [
const specs: [StructuredApprovalDecision, string, string][] = [ ["allow_once", "Allow once", "primary"],
["allow_once", "Allow once", "primary"], ["allow_for_session", "Allow for session", ""],
["allow_for_session", "Allow for session", ""], ["deny", "Deny", "deny"],
["deny", "Deny", "deny"], ];
]; for (const [decision, label, cls] of specs) {
for (const [decision, label, cls] of specs) { if (offered.includes(decision)) acts.appendChild(mk(label, decision, cls));
if (offered.includes(decision)) acts.appendChild(mk(label, decision, cls)); }
} if (acts.children.length > 0) {
if (acts.children.length > 0) { card.appendChild(acts);
card.appendChild(acts); } else {
const none = el("div", "st-approval-resolved");
none.textContent = "no decision offered";
card.appendChild(none);
}
} else { } else {
const none = el("div", "st-approval-resolved"); const resolved = el("div", "st-approval-resolved");
none.textContent = "no decision offered"; resolved.textContent = "answered";
card.appendChild(none); card.appendChild(resolved);
} }
return wrap; return wrap;
+95 -238
View File
@@ -2290,9 +2290,6 @@ input.leaf-rename:focus {
.structured-main { .structured-main {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
max-width: 820px;
margin-inline: auto;
width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1 1 auto; flex: 1 1 auto;
@@ -2318,7 +2315,9 @@ input.leaf-rename:focus {
gap: var(--sp-2); gap: var(--sp-2);
min-height: 30px; min-height: 30px;
padding: var(--sp-1) var(--sp-3); padding: var(--sp-1) var(--sp-3);
border-top: 1px solid var(--border);
color: var(--muted); color: var(--muted);
background: var(--surface-2);
font-size: var(--fs-11); font-size: var(--fs-11);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -2375,36 +2374,11 @@ input.leaf-rename:focus {
background: var(--surface-2); background: var(--surface-2);
} }
/* Queued messages: optimistic user bubbles, not yet sent. */
.st-row-queued {
justify-content: flex-end;
pointer-events: none;
}
.st-row-queued .st-body {
flex: 0 1 auto;
max-width: min(86%, 72ch);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--r-2);
background: var(--surface-3);
opacity: 0.45;
filter: blur(0.5px);
}
.st-user-text { .st-user-text {
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.st-user-image {
display: block;
max-width: min(100%, 480px);
max-height: 360px;
margin-top: var(--sp-2);
border-radius: var(--r-1);
object-fit: contain;
}
/* agent prose: no card, no background — plain text is the point */ /* agent prose: no card, no background — plain text is the point */
.st-row-agent .st-body p { .st-row-agent .st-body p {
margin: 0 0 var(--sp-2); margin: 0 0 var(--sp-2);
@@ -2507,8 +2481,7 @@ input.leaf-rename:focus {
.st-row-thought { .st-row-thought {
color: var(--faint); color: var(--faint);
font-size: var(--fs-12); font-style: italic;
line-height: 1.4;
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@@ -2855,12 +2828,12 @@ input.leaf-rename:focus {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--r-2); border-radius: var(--r-2);
background: color-mix(in oklab, var(--surface-2) 55%, transparent); background: color-mix(in oklab, var(--surface-2) 55%, transparent);
padding: var(--sp-1) var(--sp-2); padding: var(--sp-2) var(--sp-3);
transition: background var(--dur-fast) var(--ease-out); transition: background var(--dur-fast) var(--ease-out);
} }
.st-tool-group:hover { .st-tool-group:hover {
background: color-mix(in oklab, var(--surface-2) 70%, transparent); background: var(--surface-2);
} }
.st-tool-group-head { .st-tool-group-head {
@@ -2897,6 +2870,15 @@ input.leaf-rename:focus {
font-weight: 500; font-weight: 500;
} }
.st-tool-group-names {
min-width: 0;
overflow: hidden;
color: var(--muted);
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.st-tool-group-body { .st-tool-group-body {
padding-top: 0; padding-top: 0;
} }
@@ -2904,42 +2886,11 @@ input.leaf-rename:focus {
.st-tool-group-body .st-disclosure-inner { .st-tool-group-body .st-disclosure-inner {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-top: var(--sp-1); padding-top: var(--sp-2);
} }
.st-tool-group-body .st-disclosure-inner > .st-row-tool { .st-tool-group-body .st-disclosure-inner > .st-row-tool {
padding: 0; padding: var(--sp-1) 0;
}
/* inside a group, tool cards lose their border and background —
the group container already provides the visual boundary. */
.st-tool-group-body .st-tool {
border: 0;
}
.st-tool-group-body .st-tool-head {
padding: var(--sp-1) var(--sp-2);
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] { .st-tool-group-body[hidden] {
@@ -2991,7 +2942,6 @@ input.leaf-rename:focus {
display: block; display: block;
white-space: pre; white-space: pre;
padding: 0 var(--sp-3); padding: 0 var(--sp-3);
line-height: 1.4;
} }
.st-ln-add { .st-ln-add {
@@ -3065,16 +3015,6 @@ input.leaf-rename:focus {
color: var(--faint); 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 { .act {
appearance: none; appearance: none;
font: inherit; font: inherit;
@@ -3122,26 +3062,31 @@ input.leaf-rename:focus {
} }
/* The composer is a sibling of the scrolling transcript, not part of it: it /* The composer is a sibling of the scrolling transcript, not part of it: it
stays reachable at the pane edge without changing transcript scroll math. stays reachable at the pane edge without changing transcript scroll math. */
It is a flush band at the foot of the pane rather than a floating card: its
own `--surface` fill is what separates it from the transcript, over a
hairline that doubles as the focus indicator. Inner metrics line the draft
up with the transcript's own 12px gutter. */
.structured-composer { .structured-composer {
position: relative;
flex: 0 0 auto; flex: 0 0 auto;
padding: var(--sp-1) var(--sp-1) max(var(--sp-1), env(safe-area-inset-bottom)); margin: var(--sp-2) var(--sp-3) max(var(--sp-2), env(safe-area-inset-bottom));
border-top: 1px solid var(--border); padding: var(--sp-1);
border: 1px solid var(--border-strong);
border-radius: var(--r-3);
background: var(--surface); background: var(--surface);
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
gap: var(--sp-1); gap: var(--sp-1);
transition: border-color var(--dur-fast) var(--ease-out); box-shadow:
inset 0 1px 0 color-mix(in oklab, var(--ink) 6%, transparent),
var(--shadow-1);
transition:
border-color var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out);
} }
.structured-composer:focus-within { .structured-composer:focus-within {
border-top-color: var(--border); border-color: var(--accent);
box-shadow:
inset 0 1px 0 color-mix(in oklab, var(--ink) 8%, transparent),
0 0 0 3px var(--accent-tint),
var(--shadow-1);
} }
.structured-input-wrap { .structured-input-wrap {
@@ -3150,45 +3095,6 @@ input.leaf-rename:focus {
min-width: 0; min-width: 0;
} }
.structured-attachments {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1);
padding: var(--sp-1);
}
.structured-attachments[hidden] { display: none; }
.structured-attachment {
display: inline-flex;
align-items: center;
min-width: 0;
max-width: 100%;
padding-left: var(--sp-2);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-1);
font-size: var(--fs-11);
}
.structured-attachment > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.structured-attachment button {
flex-shrink: 0;
min-width: var(--tap);
min-height: var(--tap);
border: 0;
background: transparent;
color: var(--muted);
}
.structured-attachment button:hover { color: var(--ink); }
.structured-attachment button:disabled { opacity: 0.5; }
.structured-input { .structured-input {
display: block; display: block;
width: 100%; width: 100%;
@@ -3206,10 +3112,6 @@ input.leaf-rename:focus {
line-height: 20px; line-height: 20px;
} }
.structured-input:focus-visible {
outline: none;
}
.structured-composer .structured-send, .structured-composer .structured-send,
.structured-composer .structured-cancel { .structured-composer .structured-cancel {
height: 40px; height: 40px;
@@ -3257,9 +3159,7 @@ input.leaf-rename:focus {
} }
.structured-composer .structured-cancel { .structured-composer .structured-cancel {
position: relative; padding: 0 var(--sp-3);
width: 40px;
padding: 0;
border-color: color-mix(in oklab, var(--danger) 35%, var(--border)); border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
color: var(--danger); color: var(--danger);
} }
@@ -3268,47 +3168,18 @@ input.leaf-rename:focus {
background: var(--danger-tint); 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) { @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. */
.structured-composer { .structured-composer {
flex-wrap: wrap; margin: var(--sp-1) 0 0;
justify-content: flex-end; padding-bottom: calc(var(--sp-1) + env(safe-area-inset-bottom, 0px));
padding-left: var(--sp-2); border-right: 0;
padding-right: var(--sp-2); border-bottom: 0;
} border-left: 0;
border-radius: var(--r-2) var(--r-2) 0 0;
/* `order` keeps the draft on the first line: without it the attach button,
which precedes it in the DOM, wraps onto a line of its own. */
.structured-input-wrap {
flex: 1 1 100%;
order: -1;
}
.structured-composer .structured-config {
margin-right: auto;
} }
.structured-config-menu { .structured-config-menu {
left: 0; right: calc(-1 * (var(--control-h) + var(--sp-2)));
right: auto;
} }
} }
@@ -3522,9 +3393,14 @@ input.leaf-rename:focus {
overflow-y: visible; overflow-y: visible;
} }
/* Rides the bottom of the scroll box: reachable at any scroll position, and
`margin-top: auto` keeps it at the screen's foot when the form is short. */
.spawn-modal .form-actions { .spawn-modal .form-actions {
position: sticky;
bottom: 0;
margin-top: auto; margin-top: auto;
padding-bottom: calc(var(--sp-2) + env(safe-area-inset-bottom, 0px)); padding: var(--sp-2) 0 calc(var(--sp-2) + env(safe-area-inset-bottom, 0px));
background: var(--surface);
box-shadow: 0 -8px 12px -8px oklch(0 0 0 / 0.5); box-shadow: 0 -8px 12px -8px oklch(0 0 0 / 0.5);
} }
@@ -3606,16 +3482,20 @@ input.leaf-rename:focus {
flex: 0 0 auto; flex: 0 0 auto;
} }
.advanced { .worktree {
border: 1px solid var(--border);
border-radius: var(--r-2);
padding: 0 var(--sp-3);
margin: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: color-mix(in oklab, var(--surface-2) 50%, transparent);
} }
.advanced .wt-toggle { .worktree .wt-toggle {
border-top: 1px solid var(--border); margin: 0 calc(var(--sp-3) * -1);
border-radius: 0; padding-left: var(--sp-3);
padding-top: var(--sp-2); width: calc(100% + var(--sp-3) * 2);
padding-bottom: var(--sp-2);
} }
.wt-summary { .wt-summary {
@@ -3627,27 +3507,13 @@ input.leaf-rename:focus {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--sp-3); gap: var(--sp-3);
padding: var(--sp-2) 0 var(--sp-1); padding-bottom: var(--sp-3);
} }
.wt-collapsible[hidden] { .wt-collapsible[hidden] {
display: none; display: none;
} }
/* Worktree sub-group: left accent border when expanded, visually nested. */
.adv-group {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding-left: var(--sp-3);
border-left: 2px solid var(--border);
margin-top: var(--sp-1);
}
.adv-group:has(input[name="worktree_enabled"]:checked) {
border-left-color: var(--accent);
}
.form-error { .form-error {
color: var(--danger); color: var(--danger);
margin: 0; margin: 0;
@@ -3674,76 +3540,59 @@ input.leaf-rename:focus {
color: var(--ink); color: var(--ink);
} }
/* Harness cards: the first decision in the spawn form — which tool runs. */ /* One-click tiles: host row + command row. */
.harness-grid { .tile-row {
display: grid; display: flex;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); flex-wrap: wrap;
gap: var(--sp-2); gap: var(--sp-2);
} }
.harness-card { .tile {
flex: 1 1 auto;
min-width: 96px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; gap: 2px;
gap: var(--sp-2); align-items: flex-start;
padding: var(--sp-3) var(--sp-2); padding: var(--sp-2) var(--sp-3);
background: var(--surface-2); background: var(--surface-2);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--r-2); border-radius: var(--r-2);
color: var(--ink); color: var(--ink);
font-family: inherit; font-family: inherit;
cursor: pointer; cursor: pointer;
text-align: center; text-align: left;
transition: transition:
border-color var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out); background var(--dur-fast) var(--ease-out);
} }
.harness-card:hover { .tile:hover {
border-color: var(--border-strong); border-color: var(--border-strong);
background: var(--surface-3);
} }
.harness-card.is-active { .tile.is-active {
border-color: var(--accent); border-color: var(--accent);
background: var(--accent-tint); background: var(--accent-tint);
} }
.harness-icon { .tile-label {
display: flex; font-size: var(--fs-14);
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; 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 .tile-sub {
and subdued color separate it from the named harnesses. */ font-size: var(--fs-12);
.harness-custom {
border-style: dashed;
color: var(--muted); color: var(--muted);
font-family: var(--font-mono);
} }
.harness-custom .harness-label { .tile-more {
font-weight: 500; flex: 0 0 auto;
min-width: 0;
justify-content: center;
align-items: center;
color: var(--muted);
} }
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the /* Working-directory picker (#95). Two steps: a grid of project boxes, then the
@@ -3772,7 +3621,7 @@ input.leaf-rename:focus {
.picker.projects { .picker.projects {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
align-content: start; align-content: start;
gap: var(--sp-2); gap: var(--sp-2);
max-height: 300px; max-height: 300px;
@@ -3828,6 +3677,19 @@ input.leaf-rename:focus {
overflow-wrap: anywhere; 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 { .project-other {
align-self: flex-start; align-self: flex-start;
padding: 4px 0; padding: 4px 0;
@@ -3969,11 +3831,6 @@ input.leaf-rename:focus {
gap: var(--sp-2); gap: var(--sp-2);
justify-content: flex-end; justify-content: flex-end;
margin-top: var(--sp-2); margin-top: var(--sp-2);
position: sticky;
bottom: 0;
padding: var(--sp-2) 0;
background: var(--surface);
z-index: 1;
} }
.reroll { .reroll {
+1 -1
View File
@@ -11,7 +11,7 @@ export type SessionStatus = "Running" | "Dead";
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340). /** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
* Absent on payloads from daemons that predate the field. `Error` is only set * Absent on payloads from daemons that predate the field. `Error` is only set
* when an agent reports failure (#105); the daemon never infers it. */ * when an agent reports failure (#105); the daemon never infers it. */
export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error" | "Starting"; export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error";
export interface Session { export interface Session {
id: Uuid; id: Uuid;
-5
View File
@@ -1,5 +0,0 @@
import type { Config } from "tailwindcss";
export default {
content: ["src/**/*.{ts,tsx}"],
} satisfies Config;
-1
View File
@@ -4,7 +4,6 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
-3
View File
@@ -1,6 +1,4 @@
import { defineConfig, loadEnv } from "vite"; import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// Where the dev proxy forwards `/api/*` (and the events WS) to. Default to a // Where the dev proxy forwards `/api/*` (and the events WS) to. Default to a
// local daemon on 127.0.0.1:7070. Override with: // local daemon on 127.0.0.1:7070. Override with:
@@ -20,7 +18,6 @@ export default defineConfig(({ mode }) => {
const target = resolveProxyTarget(env.MAESTRO_DAEMON_URL); const target = resolveProxyTarget(env.MAESTRO_DAEMON_URL);
const secure = target.startsWith("https://"); const secure = target.startsWith("https://");
return { return {
plugins: [react(), tailwindcss()],
build: { build: {
target: "es2022", target: "es2022",
outDir: "dist", outDir: "dist",