Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f36b7808a7 | ||
|
|
50b862b1fa | ||
|
|
4668cf8ece | ||
|
|
638155de03 | ||
|
|
1c0fff5507 | ||
|
|
10c9db6d87 | ||
|
|
b339fecad3 | ||
|
|
3c10a3a08a | ||
|
|
fd79c8116a | ||
|
|
cb8b93f178 | ||
|
|
60e57fde31 | ||
|
|
8d35f7f8c8 | ||
|
|
bc0fd6ee60 | ||
|
|
ed5a088299 | ||
|
|
9c77965c51 | ||
|
|
49df4c2e9e | ||
|
|
65d7186e1c | ||
|
|
47654aa434 | ||
|
|
6ca2ab1707 | ||
|
|
b0b0160ccb | ||
|
|
c4cd8a691e | ||
|
|
0381a571f2 | ||
|
|
8f3d47c49e | ||
|
|
ba0c54759b | ||
|
|
5387b29049 | ||
|
|
df51aa7be9 | ||
|
|
43bfda5a60 |
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(git fetch:*)",
|
||||||
|
"Bash(git merge:*)",
|
||||||
|
"Bash(npx tsc:*)",
|
||||||
|
"Bash(npm run build:*)",
|
||||||
|
"Bash(npm install:*)",
|
||||||
|
"Bash(./node_modules/.bin/tsc:*)",
|
||||||
|
"Bash(npm test:*)",
|
||||||
|
"Bash(cargo check:*)",
|
||||||
|
"Bash(cargo clippy:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,8 +41,8 @@ pub enum Command {
|
|||||||
Attach(AttachArgs),
|
Attach(AttachArgs),
|
||||||
/// Kill a session by ID.
|
/// Kill a session by ID.
|
||||||
Kill(KillArgs),
|
Kill(KillArgs),
|
||||||
/// Type text into a running session, as if it had been typed on an
|
/// Send text to a running session. Terminal sessions receive raw
|
||||||
/// attached terminal.
|
/// keystrokes; structured sessions receive an ACP prompt.
|
||||||
Send(SendArgs),
|
Send(SendArgs),
|
||||||
/// Install or update the agent activity hook (#106).
|
/// Install or update the agent activity hook (#106).
|
||||||
Hooks(HooksArgs),
|
Hooks(HooksArgs),
|
||||||
|
|||||||
@@ -130,9 +130,12 @@ pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid,
|
|||||||
/// `maestro send`. Answers with the session that was typed into, so the
|
/// `maestro send`. Answers with the session that was typed into, so the
|
||||||
/// caller can name it once the daemon has accepted the text.
|
/// caller can name it once the daemon has accepted the text.
|
||||||
///
|
///
|
||||||
/// A bare ID skips the catalog fetch; anything else needs the list to match
|
/// Terminal sessions get raw keystrokes via `send_input`; structured sessions
|
||||||
/// against. Text comes from the arguments, or from stdin when there is none —
|
/// get an ACP prompt via `send_structured_prompt` (`--no-enter` is ignored
|
||||||
/// the shape a heredoc or a pipe wants.
|
/// there — ACP prompts are always complete messages).
|
||||||
|
///
|
||||||
|
/// Text comes from the arguments, or from stdin when there is none — the
|
||||||
|
/// shape a heredoc or a pipe wants.
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
pub async fn run_send(
|
pub async fn run_send(
|
||||||
client: &DaemonClient,
|
client: &DaemonClient,
|
||||||
@@ -155,19 +158,28 @@ pub async fn run_send(
|
|||||||
return Err(CliError::NothingToSend);
|
return Err(CliError::NothingToSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
let id = match uuid::Uuid::parse_str(args.session.trim()) {
|
// We always need the session list: even a bare UUID needs the view field
|
||||||
Ok(id) => id,
|
// to decide which endpoint to hit.
|
||||||
Err(_) => {
|
let sessions = client.list_sessions().await?;
|
||||||
let sessions = client.list_sessions().await?;
|
let id = resolve_session(&sessions, &args.session)?;
|
||||||
resolve_session(&sessions, &args.session)?
|
let view = sessions
|
||||||
}
|
.iter()
|
||||||
};
|
.find(|s| s.id == id)
|
||||||
|
.map(|s| s.view)
|
||||||
|
.unwrap_or(SessionView::Terminal);
|
||||||
|
|
||||||
let req = SendInputRequest {
|
match view {
|
||||||
text,
|
SessionView::Structured => {
|
||||||
submit: !args.no_enter,
|
client.send_structured_prompt(id, &text).await?;
|
||||||
};
|
}
|
||||||
client.send_input(id, &req).await?;
|
SessionView::Terminal => {
|
||||||
|
let req = SendInputRequest {
|
||||||
|
text,
|
||||||
|
submit: !args.no_enter,
|
||||||
|
};
|
||||||
|
client.send_input(id, &req).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +228,7 @@ 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).
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! was unit-tested, but nothing exercised the client, the error decoding, or
|
//! was unit-tested, but nothing exercised the client, the error decoding, or
|
||||||
//! the unreachable-daemon path without compiling and forking a real daemon.
|
//! the unreachable-daemon path without compiling and forking a real daemon.
|
||||||
|
|
||||||
use maestro_protocol::{DaemonClient, DaemonInfo};
|
use maestro_protocol::{DaemonClient, DaemonInfo, SessionView};
|
||||||
use maestro_testing::{session, MockDaemon};
|
use maestro_testing::{session, MockDaemon};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -202,6 +202,35 @@ async fn send_resolves_a_session_by_name() {
|
|||||||
assert_eq!(sent, id);
|
assert_eq!(sent, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A structured session routes through the ACP prompt endpoint, not the
|
||||||
|
/// terminal input one — `--no-enter` is irrelevant (ACP prompts are
|
||||||
|
/// complete messages).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_to_structured_session_uses_acp_prompt() {
|
||||||
|
let mock = MockDaemon::start().await;
|
||||||
|
let id = Uuid::from_u128(13);
|
||||||
|
let mut s = session(id, "/tmp", "claude");
|
||||||
|
s.view = SessionView::Structured;
|
||||||
|
mock.add_session(s);
|
||||||
|
|
||||||
|
let args = maestro_cli::cli::SendArgs {
|
||||||
|
session: id.to_string(),
|
||||||
|
text: vec!["fix".into(), "the".into(), "tests".into()],
|
||||||
|
no_enter: false,
|
||||||
|
};
|
||||||
|
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
||||||
|
.await
|
||||||
|
.expect("send");
|
||||||
|
|
||||||
|
assert_eq!(sent, id);
|
||||||
|
// Terminal input must stay empty — the message went through ACP.
|
||||||
|
assert!(mock.inputs().is_empty());
|
||||||
|
let prompts = mock.structured_prompts();
|
||||||
|
assert_eq!(prompts.len(), 1);
|
||||||
|
assert_eq!(prompts[0].0, id);
|
||||||
|
assert_eq!(prompts[0].1.prompt, "fix the tests");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
||||||
let mock = MockDaemon::start().await;
|
let mock = MockDaemon::start().await;
|
||||||
|
|||||||
@@ -180,7 +180,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" => StatusCode::BAD_REQUEST,
|
"invalid_env" | "bad_request" => 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
|
||||||
@@ -483,7 +483,9 @@ async fn structured_prompt(
|
|||||||
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) => StatusCode::NO_CONTENT.into_response(),
|
Ok(QueueAcpPrompt::Queued) | Ok(QueueAcpPrompt::Pending) => {
|
||||||
|
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",
|
||||||
|
|||||||
@@ -6,12 +6,9 @@
|
|||||||
//! 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;
|
||||||
@@ -125,6 +122,9 @@ 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,6 +134,13 @@ 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)]
|
||||||
@@ -208,6 +215,8 @@ 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.
|
||||||
@@ -227,6 +236,8 @@ 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),
|
||||||
@@ -253,6 +264,7 @@ 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 {
|
||||||
@@ -320,18 +332,6 @@ 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,30 +343,18 @@ impl StructuredLifecycle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StructuredCreate<'a> {
|
/// Release the structured-lifecycle in-flight counter that `create` acquires
|
||||||
lifecycle: &'a StructuredLifecycle,
|
/// before spawning the background ACP handshake task. Called when the task
|
||||||
}
|
/// finishes (success or failure) or when `create_inner` fails before the task
|
||||||
|
/// is spawned.
|
||||||
impl Drop for StructuredCreate<'_> {
|
fn drop_structured_lifecycle_permit(inner: &Inner) {
|
||||||
fn drop(&mut self) {
|
if inner
|
||||||
if self.lifecycle.in_flight.fetch_sub(1, Ordering::AcqRel) == 1 {
|
.structured_lifecycle
|
||||||
self.lifecycle.settled.notify_one();
|
.in_flight
|
||||||
}
|
.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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,7 +648,9 @@ 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))?;
|
||||||
if !record.is_live_structured() {
|
// Allow connecting while the adapter is still booting
|
||||||
|
// (`Starting`) — the event stream exists, it is just empty.
|
||||||
|
if !record.is_live_structured() && !record.is_starting_structured() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let Some(stream) = &record.acp_events else {
|
let Some(stream) = &record.acp_events else {
|
||||||
@@ -745,8 +735,14 @@ impl SessionManager {
|
|||||||
text: String,
|
text: String,
|
||||||
) -> Result<QueueAcpPrompt, SessionError> {
|
) -> Result<QueueAcpPrompt, SessionError> {
|
||||||
let (acp, stream) = {
|
let (acp, stream) = {
|
||||||
let sessions = self.inner.sessions.lock().await;
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
|
let record = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
|
||||||
|
// While the adapter is booting, stash the prompt for the
|
||||||
|
// background handshake task to drain on success.
|
||||||
|
if record.is_starting_structured() {
|
||||||
|
record.pending_prompt = Some(text);
|
||||||
|
return Ok(QueueAcpPrompt::Pending);
|
||||||
|
}
|
||||||
if !record.is_live_structured() {
|
if !record.is_live_structured() {
|
||||||
return Ok(QueueAcpPrompt::Unavailable);
|
return Ok(QueueAcpPrompt::Unavailable);
|
||||||
}
|
}
|
||||||
@@ -1217,17 +1213,42 @@ 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 {
|
||||||
let permit = self
|
// Acquire the lifecycle gate synchronously (rejects if shutting
|
||||||
|
// down). The background handshake task calls
|
||||||
|
// `drop_structured_lifecycle_permit` when it finishes; we must
|
||||||
|
// not use the RAII `StructuredCreate` because it would drop at
|
||||||
|
// the end of `create_inner`, before the handshake completes.
|
||||||
|
let _gate = self
|
||||||
.inner
|
.inner
|
||||||
.structured_lifecycle
|
.structured_lifecycle
|
||||||
.admit(&self.inner.shutting_down)?;
|
.gate
|
||||||
return AdmittedStructuredCreate {
|
.lock()
|
||||||
create: Box::pin(self.create_inner(req)),
|
.expect("structured lifecycle gate");
|
||||||
_permit: permit,
|
if self.inner.shutting_down.load(Ordering::Acquire) {
|
||||||
|
return Err(SessionError::ShuttingDown);
|
||||||
}
|
}
|
||||||
.await;
|
self.inner
|
||||||
|
.structured_lifecycle
|
||||||
|
.in_flight
|
||||||
|
.fetch_add(1, Ordering::AcqRel);
|
||||||
|
drop(_gate);
|
||||||
|
|
||||||
|
return match self.create_inner(req).await {
|
||||||
|
Ok(session) => Ok(session),
|
||||||
|
Err(e) => {
|
||||||
|
// create_inner failed before spawning the background
|
||||||
|
// task, so release the permit here.
|
||||||
|
drop_structured_lifecycle_permit(&self.inner);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
self.create_inner(req).await
|
self.create_inner(req).await
|
||||||
}
|
}
|
||||||
@@ -1289,32 +1310,39 @@ impl SessionManager {
|
|||||||
{
|
{
|
||||||
session_env.insert("MAESTRO_URL".into(), url);
|
session_env.insert("MAESTRO_URL".into(), url);
|
||||||
}
|
}
|
||||||
// A structured session (#134) gets no tmux pane: the agent is a
|
// ── Structured path: validate adapter, open event log, register
|
||||||
// child of the daemon, driven over ACP. An adapter that cannot come
|
// the session immediately with `Starting`, then handshake in the
|
||||||
// up still yields a session — Dead, with the reason on it — so the
|
// background so the HTTP caller gets an instant response.
|
||||||
// operator sees what to fix instead of a silently missing session.
|
//
|
||||||
let (acp, acp_failure) = match req.view {
|
// ── Terminal path: unchanged — tmux spawn is fast enough to stay
|
||||||
SessionView::Structured => match structured_adapter(&req.command) {
|
// synchronous.
|
||||||
Err(error) => (None, Some(error)),
|
// Structured: validate adapter, open event log eagerly. Terminal:
|
||||||
Ok(adapter) => match maestro_acp::spawn(
|
// spawn the tmux pane synchronously (it is fast).
|
||||||
SpawnConfig::new(
|
let structured_spawn = match req.view {
|
||||||
&effective_cwd,
|
SessionView::Structured => {
|
||||||
session_env
|
// Fail fast on an unsupported command before touching disk.
|
||||||
.iter()
|
let adapter = structured_adapter(&req.command).map_err(SessionError::BadRequest)?;
|
||||||
.map(|(k, v)| (k.clone(), v.clone()))
|
// Open the event log eagerly so the structured WS can
|
||||||
.collect(),
|
// connect before the adapter is up.
|
||||||
)
|
let acp_events = match self.inner.sidecar.open_events(id) {
|
||||||
.adapter(adapter),
|
Ok(log) => {
|
||||||
)
|
let (tx, _) = broadcast::channel(EVENTS_CAPACITY);
|
||||||
.await
|
let (projected_tx, _) = broadcast::channel(EVENTS_CAPACITY);
|
||||||
{
|
Arc::new(Mutex::new(AcpEventStream {
|
||||||
Ok(acp) => (Some(Arc::new(acp)), None),
|
log,
|
||||||
Err(e) => {
|
live: tx,
|
||||||
tracing::warn!(%id, error = %e, "structured session: adapter failed to start");
|
projection: Projection::default(),
|
||||||
(None, Some(e.to_string()))
|
projected_live: projected_tx,
|
||||||
|
pending_approvals: HashMap::new(),
|
||||||
|
operation: Arc::new(Mutex::new(())),
|
||||||
|
dispatch: AcpDispatchState::default(),
|
||||||
|
activity_sequence: 0,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
},
|
Err(e) => return Err(SessionError::Sidecar(e)),
|
||||||
},
|
};
|
||||||
|
Some((adapter, acp_events))
|
||||||
|
}
|
||||||
SessionView::Terminal => {
|
SessionView::Terminal => {
|
||||||
let spawned = {
|
let spawned = {
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
@@ -1328,53 +1356,10 @@ 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
|
||||||
@@ -1384,10 +1369,13 @@ 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,
|
cwd: effective_cwd.clone(),
|
||||||
command: req.command,
|
command: req.command.clone(),
|
||||||
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(
|
||||||
@@ -1396,67 +1384,41 @@ impl SessionManager {
|
|||||||
base_repo: base_repo.clone(),
|
base_repo: base_repo.clone(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
status: if acp_failure.is_some() {
|
status: SessionStatus::Running,
|
||||||
SessionStatus::Dead
|
|
||||||
} else {
|
|
||||||
SessionStatus::Running
|
|
||||||
},
|
|
||||||
created_at: Utc::now(),
|
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,
|
foreground: None,
|
||||||
activity: match (req.view, acp_failure.is_some()) {
|
activity: if is_structured {
|
||||||
(_, true) => ActivityState::Error,
|
ActivityState::Starting
|
||||||
(SessionView::Structured, false) => ActivityState::Idle,
|
} else {
|
||||||
(SessionView::Terminal, false) => ActivityState::Active,
|
ActivityState::Active
|
||||||
},
|
},
|
||||||
activity_detail: acp_failure,
|
activity_detail: None,
|
||||||
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: acp.as_ref().map(|a| a.session_id().to_string()),
|
acp_session_id: None,
|
||||||
view: req.view,
|
view: req.view,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = self.inner.sidecar.write(&session) {
|
if let Err(e) = self.inner.sidecar.write(&session) {
|
||||||
// The process already started; end it so no ghost survives a
|
if !is_structured {
|
||||||
// failed sidecar write, then unwind the FIFO + worktree.
|
let tmux = self.inner.tmux.clone();
|
||||||
match &acp {
|
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
|
||||||
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: worktree_path.clone(),
|
||||||
worktree_base_repo,
|
worktree_base_repo: worktree_base_repo.clone(),
|
||||||
bridge,
|
bridge,
|
||||||
reported_activity: None,
|
reported_activity: None,
|
||||||
settled: false,
|
settled: false,
|
||||||
@@ -1464,11 +1426,12 @@ impl SessionManager {
|
|||||||
naming: false,
|
naming: false,
|
||||||
focused_since: None,
|
focused_since: None,
|
||||||
awaiting_suppressed: false,
|
awaiting_suppressed: false,
|
||||||
acp,
|
acp: None,
|
||||||
acp_events,
|
acp_events: events_arc,
|
||||||
structured_activity_watermark: None,
|
structured_activity_watermark: None,
|
||||||
acp_event_pump: event_pump,
|
acp_event_pump: None,
|
||||||
auto_approve: req.auto_approve,
|
auto_approve: req.auto_approve,
|
||||||
|
pending_prompt: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1497,6 +1460,78 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1682,6 +1717,7 @@ 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
|
||||||
@@ -1862,6 +1898,7 @@ 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 {
|
||||||
@@ -2321,6 +2358,11 @@ 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) => {
|
||||||
@@ -2586,6 +2628,12 @@ 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),
|
||||||
|
|||||||
@@ -130,6 +130,25 @@ 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();
|
||||||
@@ -138,7 +157,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 session = fx
|
let created = fx
|
||||||
.manager
|
.manager
|
||||||
.create(structured_command(
|
.create(structured_command(
|
||||||
cwd.path(),
|
cwd.path(),
|
||||||
@@ -148,8 +167,12 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
|||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
|
||||||
assert_eq!(session.view, SessionView::Structured);
|
assert_eq!(created.view, SessionView::Structured);
|
||||||
assert_eq!(session.status, SessionStatus::Running);
|
assert_eq!(created.status, SessionStatus::Running);
|
||||||
|
assert_eq!(created.activity, ActivityState::Starting);
|
||||||
|
assert!(created.acp_session_id.is_none());
|
||||||
|
|
||||||
|
let session = wait_for_ready(&fx.manager, created.id).await;
|
||||||
assert_eq!(session.activity, ActivityState::Idle);
|
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}");
|
||||||
@@ -164,7 +187,7 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
|||||||
"{log}"
|
"{log}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
log.contains(&format!("env MAESTRO_SESSION_ID={}", session.id)),
|
log.contains(&format!("env MAESTRO_SESSION_ID={}", created.id)),
|
||||||
"{log}"
|
"{log}"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -232,6 +255,7 @@ 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
|
||||||
@@ -295,11 +319,12 @@ 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 session = fx
|
let created = 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
|
||||||
@@ -397,6 +422,7 @@ 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
|
||||||
@@ -442,6 +468,7 @@ 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()
|
||||||
@@ -485,6 +512,7 @@ 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
|
||||||
@@ -523,6 +551,7 @@ 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
|
||||||
@@ -582,11 +611,12 @@ 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 session = fx
|
let created = 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
|
||||||
@@ -711,6 +741,7 @@ 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;
|
||||||
@@ -759,6 +790,7 @@ 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;
|
||||||
@@ -792,12 +824,15 @@ 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 session = fx
|
let created = fx
|
||||||
.manager
|
.manager
|
||||||
.create(structured_command(cwd.path(), env, "codex"))
|
.create(structured_command(cwd.path(), env, "codex"))
|
||||||
.await
|
.await
|
||||||
.expect("a failed session, not an error");
|
.expect("create registers a Starting session");
|
||||||
|
assert_eq!(created.activity, ActivityState::Starting);
|
||||||
|
|
||||||
|
// The background handshake fails because the adapter binary is missing.
|
||||||
|
let session = wait_for_ready(&fx.manager, created.id).await;
|
||||||
assert_eq!(session.status, SessionStatus::Dead);
|
assert_eq!(session.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");
|
||||||
@@ -810,13 +845,13 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
|
|||||||
|
|
||||||
// It is a real session: listed, on disk, and dismissable like any other
|
// It is a real session: listed, on disk, and dismissable like any other
|
||||||
// dead one.
|
// dead one.
|
||||||
assert!(fx.manager.has(session.id).await);
|
assert!(fx.manager.has(created.id).await);
|
||||||
assert!(fx
|
assert!(fx
|
||||||
.sidecar_dir()
|
.sidecar_dir()
|
||||||
.join(format!("{}.json", session.id))
|
.join(format!("{}.json", created.id))
|
||||||
.exists());
|
.exists());
|
||||||
fx.manager.kill(session.id, false).await.expect("dismiss");
|
fx.manager.kill(created.id, false).await.expect("dismiss");
|
||||||
assert!(!fx.manager.has(session.id).await);
|
assert!(!fx.manager.has(created.id).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -825,20 +860,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 session = fx
|
let err = fx
|
||||||
.manager
|
.manager
|
||||||
.create(structured_command(cwd.path(), fake.env(), "opencode"))
|
.create(structured_command(cwd.path(), fake.env(), "opencode"))
|
||||||
.await
|
.await
|
||||||
.expect("failed structured session is persisted");
|
.expect_err("unsupported command is rejected at request validation");
|
||||||
|
|
||||||
assert_eq!(session.status, SessionStatus::Dead);
|
assert!(
|
||||||
let detail = session.activity_detail.expect("selection error");
|
matches!(err, maestro_daemon::SessionError::BadRequest(ref msg) if msg.contains("Claude Code or Codex")),
|
||||||
assert!(detail.contains("Claude Code or Codex"), "{detail}");
|
"{err:?}"
|
||||||
|
);
|
||||||
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]
|
||||||
@@ -853,6 +888,7 @@ 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));
|
||||||
|
|
||||||
@@ -895,6 +931,8 @@ 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));
|
||||||
|
|
||||||
@@ -929,10 +967,13 @@ 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");
|
||||||
fx.manager
|
let blocker_session = fx
|
||||||
|
.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 });
|
||||||
@@ -984,6 +1025,7 @@ 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;
|
||||||
@@ -1017,23 +1059,31 @@ 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());
|
||||||
|
|
||||||
let create_cwd = cwd.path().to_path_buf();
|
// create() returns immediately; the handshake runs in the background
|
||||||
let manager = fx.manager.clone();
|
// and holds a lifecycle permit.
|
||||||
let create = tokio::spawn(async move { manager.create(structured(&create_cwd, env)).await });
|
let session = fx
|
||||||
|
.manager
|
||||||
|
.create(structured(cwd.path(), env))
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
|
||||||
|
// Wait until the adapter has received session/new (handshake in progress).
|
||||||
wait_for_async(Duration::from_secs(5), || async {
|
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 create finished"
|
"shutdown returned before handshake finished"
|
||||||
);
|
);
|
||||||
|
|
||||||
let session = create.await.expect("create task").expect("create");
|
// The handshake completes in the background (500ms delay).
|
||||||
|
wait_for_ready(&fx.manager, session.id).await;
|
||||||
let pid = fake.pid();
|
let pid = fake.pid();
|
||||||
shutdown.await.expect("shutdown task");
|
shutdown.await.expect("shutdown task");
|
||||||
|
|
||||||
@@ -1066,27 +1116,37 @@ async fn structured_handshakes_run_independently() {
|
|||||||
gate.to_string_lossy().into_owned(),
|
gate.to_string_lossy().into_owned(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let first_cwd = cwd.path().to_path_buf();
|
// Both create() calls return immediately with Starting; the first
|
||||||
let manager = fx.manager.clone();
|
// adapter's handshake is gated while the second runs freely.
|
||||||
let first =
|
let first = fx
|
||||||
tokio::spawn(async move { manager.create(structured(&first_cwd, blocked_env)).await });
|
.manager
|
||||||
wait_for_async(Duration::from_secs(5), || async {
|
.create(structured(cwd.path(), blocked_env))
|
||||||
blocked.log().contains("recv session/new")
|
.await
|
||||||
})
|
.expect("first create");
|
||||||
.await;
|
let second = fx
|
||||||
|
.manager
|
||||||
|
.create(structured(cwd.path(), ready.env()))
|
||||||
|
.await
|
||||||
|
.expect("second create");
|
||||||
|
|
||||||
let second = tokio::time::timeout(
|
// The second handshake completes independently of the first.
|
||||||
Duration::from_secs(5),
|
let second_ready = wait_for_ready(&fx.manager, second.id).await;
|
||||||
fx.manager.create(structured(cwd.path(), ready.env())),
|
assert_eq!(second_ready.activity, ActivityState::Idle);
|
||||||
)
|
|
||||||
.await
|
// The first handshake is still blocked.
|
||||||
.expect("second handshake was blocked by the first")
|
let first_snap = fx
|
||||||
.expect("second create");
|
.manager
|
||||||
assert_eq!(second.status, SessionStatus::Running);
|
.snapshot()
|
||||||
assert!(!first.is_finished(), "the gate must still block the first");
|
.await
|
||||||
|
.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 = first.await.expect("first task").expect("first create");
|
let first_ready = wait_for_ready(&fx.manager, first.id).await;
|
||||||
|
assert_eq!(first_ready.activity, ActivityState::Idle);
|
||||||
|
|
||||||
fx.manager.kill(first.id, false).await.expect("kill first");
|
fx.manager.kill(first.id, false).await.expect("kill first");
|
||||||
fx.manager
|
fx.manager
|
||||||
.kill(second.id, false)
|
.kill(second.id, false)
|
||||||
@@ -1114,16 +1174,18 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
|
|||||||
gate_b.to_string_lossy().into_owned(),
|
gate_b.to_string_lossy().into_owned(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let create_a = {
|
// Both create() calls return immediately; the background handshake
|
||||||
let manager = fx.manager.clone();
|
// tasks hold lifecycle permits and are blocked on their gates.
|
||||||
let cwd = cwd.path().to_path_buf();
|
let session_a = fx
|
||||||
tokio::spawn(async move { manager.create(structured(&cwd, env_a)).await })
|
.manager
|
||||||
};
|
.create(structured(cwd.path(), env_a))
|
||||||
let create_b = {
|
.await
|
||||||
let manager = fx.manager.clone();
|
.expect("create a");
|
||||||
let cwd = cwd.path().to_path_buf();
|
let session_b = fx
|
||||||
tokio::spawn(async move { manager.create(structured(&cwd, env_b)).await })
|
.manager
|
||||||
};
|
.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")
|
||||||
})
|
})
|
||||||
@@ -1143,14 +1205,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();
|
||||||
let session_a = create_a.await.expect("create a task").expect("create a");
|
wait_for_ready(&fx.manager, session_a.id).await;
|
||||||
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();
|
||||||
let session_b = create_b.await.expect("create b task").expect("create b");
|
wait_for_ready(&fx.manager, session_b.id).await;
|
||||||
shutdown.await.expect("shutdown task");
|
shutdown.await.expect("shutdown task");
|
||||||
|
|
||||||
wait_for_exit(a.pid()).await;
|
wait_for_exit(a.pid()).await;
|
||||||
@@ -1166,7 +1228,7 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
|
async fn killing_a_starting_session_rolls_back_and_settles_admission() {
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
let fake = Fake::new();
|
let fake = Fake::new();
|
||||||
let repo = TempDir::new().unwrap();
|
let repo = TempDir::new().unwrap();
|
||||||
@@ -1192,40 +1254,30 @@ async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
|
|||||||
auto_approve: false,
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let create = {
|
// create() returns immediately with Starting; the handshake is gated.
|
||||||
let manager = fx.manager.clone();
|
let session = fx.manager.create(request).await.expect("create");
|
||||||
tokio::spawn(async move { manager.create(request).await })
|
assert_eq!(session.activity, ActivityState::Starting);
|
||||||
};
|
|
||||||
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();
|
||||||
|
|
||||||
create.abort();
|
// Kill the session while its handshake is still in progress.
|
||||||
let shutdown = {
|
// force=true because the worktree has an unpushed branch commit.
|
||||||
let manager = fx.manager.clone();
|
fx.manager.kill(session.id, true).await.expect("kill");
|
||||||
tokio::spawn(async move { manager.shutdown().await })
|
|
||||||
};
|
// Release the gate so the background task can finish and settle.
|
||||||
assert!(create.await.unwrap_err().is_cancelled());
|
std::fs::write(&gate, b"release").unwrap();
|
||||||
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("cancelled admission kept shutdown waiting")
|
.expect("shutdown hung on a settled permit");
|
||||||
.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 = id.to_string();
|
let id = session.id.to_string();
|
||||||
assert!(
|
assert!(
|
||||||
std::fs::read_dir(fx.sidecar_dir())
|
std::fs::read_dir(fx.sidecar_dir())
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -1235,7 +1287,7 @@ async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
|
|||||||
.file_name()
|
.file_name()
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.starts_with(&id)),
|
.starts_with(&id)),
|
||||||
"cancelled create left a sidecar artifact"
|
"killed Starting session left a sidecar artifact"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1252,6 +1304,7 @@ async fn shutdown_drains_final_notifications_to_the_log() {
|
|||||||
.create(structured(cwd.path(), env))
|
.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)
|
||||||
@@ -1282,6 +1335,7 @@ 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,
|
||||||
|
|||||||
@@ -154,7 +154,8 @@ async fn structured_session(h: &Harness, fake: &FakeAcp) -> Uuid {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>) -> Uuid {
|
async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>) -> Uuid {
|
||||||
h.manager
|
let id = h
|
||||||
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: SessionView::Structured,
|
view: SessionView::Structured,
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
@@ -166,7 +167,9 @@ 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) {
|
||||||
@@ -1494,6 +1497,12 @@ async fn events_ws_streams_structured_activity_without_hook_overrides() {
|
|||||||
let maestro_protocol::CatalogEvent::SessionAdded { session } = next_event(&mut ws).await else {
|
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();
|
||||||
|
|||||||
@@ -175,6 +175,10 @@ 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 {
|
||||||
@@ -187,6 +191,7 @@ 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,
|
||||||
})
|
})
|
||||||
@@ -225,9 +230,10 @@ 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"],
|
&["Active", "Idle", "AwaitingInput", "Error", "Starting"],
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ use chrono::Utc;
|
|||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
|
||||||
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION,
|
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, StructuredPromptRequest,
|
||||||
|
PROTOCOL_VERSION,
|
||||||
};
|
};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::{broadcast, oneshot};
|
use tokio::sync::{broadcast, oneshot};
|
||||||
@@ -60,6 +61,8 @@ struct Inner {
|
|||||||
forced_error: Option<(u16, ApiError)>,
|
forced_error: Option<(u16, ApiError)>,
|
||||||
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
|
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
|
||||||
inputs: Vec<(Uuid, SendInputRequest)>,
|
inputs: Vec<(Uuid, SendInputRequest)>,
|
||||||
|
/// Every ACP prompt sent to a structured session, in order.
|
||||||
|
structured_prompts: Vec<(Uuid, StructuredPromptRequest)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -92,6 +95,10 @@ impl MockDaemon {
|
|||||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||||
.route(routes::patterns::SESSION, delete(kill_session))
|
.route(routes::patterns::SESSION, delete(kill_session))
|
||||||
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
||||||
|
.route(
|
||||||
|
routes::patterns::STRUCTURED_PROMPT,
|
||||||
|
post(structured_prompt),
|
||||||
|
)
|
||||||
.route(routes::INFO, get(daemon_info))
|
.route(routes::INFO, get(daemon_info))
|
||||||
.route(routes::DIRS, get(list_dir))
|
.route(routes::DIRS, get(list_dir))
|
||||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||||
@@ -178,6 +185,16 @@ impl MockDaemon {
|
|||||||
self.state.inner.lock().expect("poisoned").inputs.clone()
|
self.state.inner.lock().expect("poisoned").inputs.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every ACP prompt this daemon received, oldest first.
|
||||||
|
pub fn structured_prompts(&self) -> Vec<(Uuid, StructuredPromptRequest)> {
|
||||||
|
self.state
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.expect("poisoned")
|
||||||
|
.structured_prompts
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn session_count(&self) -> usize {
|
pub fn session_count(&self) -> usize {
|
||||||
self.state.inner.lock().expect("poisoned").sessions.len()
|
self.state.inner.lock().expect("poisoned").sessions.len()
|
||||||
}
|
}
|
||||||
@@ -266,6 +283,26 @@ async fn send_input(
|
|||||||
StatusCode::NO_CONTENT.into_response()
|
StatusCode::NO_CONTENT.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn structured_prompt(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<StructuredPromptRequest>,
|
||||||
|
) -> Response {
|
||||||
|
if let Some(r) = forced(&s) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
let mut guard = s.inner.lock().expect("poisoned");
|
||||||
|
if !guard.sessions.iter().any(|x| x.id == id) {
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ApiError::new("not_found", "no such session on this daemon")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
guard.structured_prompts.push((id, req));
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
async fn daemon_info(State(s): State<AppState>) -> Response {
|
async fn daemon_info(State(s): State<AppState>) -> Response {
|
||||||
if let Some(r) = forced(&s) {
|
if let Some(r) = forced(&s) {
|
||||||
return r;
|
return r;
|
||||||
|
|||||||
@@ -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 => self.idle,
|
ActivityState::Idle | ActivityState::Starting => self.idle,
|
||||||
ActivityState::AwaitingInput => self.awaiting,
|
ActivityState::AwaitingInput => self.awaiting,
|
||||||
ActivityState::Error => self.error,
|
ActivityState::Error => self.error,
|
||||||
}
|
}
|
||||||
@@ -928,9 +928,10 @@ pub fn contrast_ratio(a: Color, b: Color) -> Option<f64> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
const STATES: [ActivityState; 4] = [
|
const STATES: [ActivityState; 5] = [
|
||||||
ActivityState::Active,
|
ActivityState::Active,
|
||||||
ActivityState::Idle,
|
ActivityState::Idle,
|
||||||
|
ActivityState::Starting,
|
||||||
ActivityState::AwaitingInput,
|
ActivityState::AwaitingInput,
|
||||||
ActivityState::Error,
|
ActivityState::Error,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -979,6 +979,7 @@ 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,
|
||||||
] {
|
] {
|
||||||
@@ -1009,6 +1010,7 @@ 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,
|
||||||
] {
|
] {
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ 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` |
|
||||||
|
|
||||||
|
|||||||
+16
-5
@@ -190,9 +190,16 @@ 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. Opening it reveals independently
|
one collapsed turn activity block. `AgentText` and `AgentThought` rows that
|
||||||
expandable tool calls, including any raw command, input, output, and diff
|
appear *between* tool rows in the same turn are absorbed into the group
|
||||||
content the adapter supplied.
|
(intermediate narration); the last `AgentText` with no subsequent tool stays
|
||||||
|
outside as the prominent final answer. While a turn is still active
|
||||||
|
(responding or thinking), the most recent intermediate `AgentText` or
|
||||||
|
`AgentThought` in the group is kept outside as visible narration — replaced
|
||||||
|
by the next text row when one arrives, and absorbed into the group when the
|
||||||
|
turn ends. The header still reads "N tool calls" (counting only tool rows).
|
||||||
|
Opening the group reveals tools and intermediate text in chronological
|
||||||
|
order, with the text rendered in a subdued style.
|
||||||
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
- 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
|
||||||
@@ -346,11 +353,15 @@ thing a callback can be attributed to, so at most one turn may ever be
|
|||||||
in flight, and a cancelled turn must fully settle before the next prompt is
|
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."
|
||||||
|
|
||||||
- A prompt is appended as a durable `User` row before it claims the turn and
|
- Web clients may collect a visible FIFO of operator messages while a turn is
|
||||||
|
active. They submit each queued message only after the projected turn becomes
|
||||||
|
idle; the queue is client-local and unsent messages do not survive a reload.
|
||||||
|
- A submitted prompt is appended as a durable `User` row before it claims the turn and
|
||||||
dispatches to ACP, so the transcript never depends on an adapter echoing the
|
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 — one active turn per session, never a hidden queue of prompts.
|
queuing in the daemon — one active turn per session, never a hidden
|
||||||
|
server-side queue of prompts.
|
||||||
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
|
- 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
|
||||||
|
|||||||
+4
-3
@@ -663,8 +663,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
applyNav();
|
applyNav();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
|
// Pointer hover previews only while Ctrl is held — plain hover was too
|
||||||
// out: there is no hover there, and a tap already opens the session.
|
// disruptive when sweeping the sidebar. Keyboard walk (j/k) still previews
|
||||||
|
// 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;
|
||||||
@@ -672,7 +673,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") return;
|
if (e.pointerType === "touch" || !e.ctrlKey) 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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -182,26 +182,32 @@ describe("sidebar preview requests (docs/PREVIEW.md)", () => {
|
|||||||
expect(seen.at(-1)).toBe(null);
|
expect(seen.at(-1)).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("asks for a preview of the hovered card, and clears it on the way out", () => {
|
it("ctrl+hover previews a card, plain hover does not", () => {
|
||||||
const seen: (string | null)[] = [];
|
const 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.
|
|
||||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
// Deduplicated while still on the same card.
|
||||||
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
|
||||||
expect(seen).toHaveLength(1);
|
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, where a tap already opens the session", () => {
|
it("ignores touch even with ctrl", () => {
|
||||||
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" }));
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch", ctrlKey: true }));
|
||||||
expect(seen).toEqual([]);
|
expect(seen).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+53
-24
@@ -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 } from "./toolIcon.js";
|
import { splitCommand, toolIconSvg, TOOL_LABELS } 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,6 +140,12 @@ 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…"
|
||||||
@@ -151,12 +157,6 @@ 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="field">
|
|
||||||
<span class="field-label">Command</span>
|
|
||||||
<div class="tile-row cmd-tiles"></div>
|
|
||||||
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
|
|
||||||
placeholder="bash -l" autocomplete="off" />
|
|
||||||
</div>
|
|
||||||
<div class="advanced">
|
<div class="advanced">
|
||||||
<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>
|
||||||
@@ -348,37 +348,69 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
command = preset!.command;
|
command = preset!.command;
|
||||||
}
|
}
|
||||||
updateStructuredAvailability();
|
updateStructuredAvailability();
|
||||||
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
|
||||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
|
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === id);
|
||||||
}
|
}
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-highlight the operator's already-picked tile after `renderCommandTiles`
|
/** Re-highlight the operator's already-picked card after `renderCommandTiles`
|
||||||
* rebuilds the row from scratch (new tiles carry no `is-active` class) —
|
* rebuilds the grid from scratch (new cards 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 tile of cmdTiles.querySelectorAll(".tile")) {
|
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
|
||||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId);
|
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === selectedPresetId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a preset id to a tool icon id. The preset's command is the
|
||||||
|
* source of truth — `splitCommand` extracts the tool the same way the
|
||||||
|
* sidebar does. For the "Custom…" preset (empty command) there is no
|
||||||
|
* tool to show, and a terminal prompt stands in. */
|
||||||
|
function presetToolId(p: SpawnPreset): string | null {
|
||||||
|
if (!p.command) return null;
|
||||||
|
return splitCommand(p.command).tool;
|
||||||
|
}
|
||||||
|
|
||||||
function renderCommandTiles(): void {
|
function renderCommandTiles(): void {
|
||||||
cmdTiles.replaceChildren();
|
cmdTiles.replaceChildren();
|
||||||
for (const p of presets) {
|
for (const p of presets) {
|
||||||
const tile = document.createElement("button");
|
const isCustom = p.command === "";
|
||||||
tile.type = "button";
|
const card = document.createElement("button");
|
||||||
tile.className = "tile";
|
card.type = "button";
|
||||||
tile.dataset.id = p.id;
|
card.className = isCustom ? "harness-card harness-custom" : "harness-card";
|
||||||
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
|
card.dataset.id = p.id;
|
||||||
tile.addEventListener("click", () => {
|
const toolId = presetToolId(p);
|
||||||
|
const iconHtml = isCustom ? customIcon() : toolId ? harnessIcon(toolId) : harnessIcon("shell");
|
||||||
|
const label = TOOL_LABELS[toolId ?? ""] ?? p.label;
|
||||||
|
card.innerHTML =
|
||||||
|
`<span class="harness-icon">${iconHtml}</span>` +
|
||||||
|
`<span class="harness-label">${escapeHtml(isCustom ? "Custom\u2026" : label)}</span>`;
|
||||||
|
card.addEventListener("click", () => {
|
||||||
commandTouched = true;
|
commandTouched = true;
|
||||||
selectPreset(p.id);
|
selectPreset(p.id);
|
||||||
});
|
});
|
||||||
cmdTiles.appendChild(tile);
|
cmdTiles.appendChild(card);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Render a harness card icon at card size (24x24). Re-uses the SVG bodies
|
||||||
|
* from toolIcon.ts but at a larger viewport so the marks breathe. */
|
||||||
|
function harnessIcon(toolId: string): string {
|
||||||
|
// toolIconSvg renders at 13x13 with viewBox 0 0 16 16 — we want the
|
||||||
|
// same shape at 24x24 for the card context.
|
||||||
|
const small = toolIconSvg(toolId);
|
||||||
|
if (!small) return "";
|
||||||
|
return small
|
||||||
|
.replace(/width="13"/g, 'width="24"')
|
||||||
|
.replace(/height="13"/g, 'height="24"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An ellipsis mark for the "Custom…" escape hatch. */
|
||||||
|
function customIcon(): string {
|
||||||
|
return `<svg class="tool-icon" viewBox="0 0 16 16" width="24" height="24" role="img" aria-label="Custom command"><title>Custom command</title><g fill="currentColor"><circle cx="4" cy="8" r="1.3"/><circle cx="8" cy="8" r="1.3"/><circle cx="12" cy="8" r="1.3"/></g></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Set once the operator picks a tile or types a custom command — guards
|
// 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
|
||||||
@@ -648,16 +680,13 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 .tile[data-id="custom"]')!.click();
|
form.querySelector<HTMLButtonElement>('.cmd-tiles .harness-card[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"));
|
||||||
|
|||||||
@@ -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>(".tile")].find(
|
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].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>(".tile")].find(
|
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].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>(".tile")].find(
|
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
|
||||||
(t) => t.dataset.id === "fish",
|
(t) => t.dataset.id === "fish",
|
||||||
);
|
);
|
||||||
expect(fishTile).toBeDefined();
|
expect(fishTile).toBeDefined();
|
||||||
|
|||||||
@@ -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, ahead of the command tiles", async () => {
|
it("opens with the project filter focused, harness before project", 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(["Project", "Command"]);
|
expect(fields.slice(0, 2)).toEqual(["Harness", "Project"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("titles a seeded open as a clone", async () => {
|
it("titles a seeded open as a clone", async () => {
|
||||||
|
|||||||
+175
-16
@@ -134,7 +134,7 @@ describe("structured connection: data flow", () => {
|
|||||||
} });
|
} });
|
||||||
input.dispatchEvent(paste);
|
input.dispatchEvent(paste);
|
||||||
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
|
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
|
||||||
await vi.waitFor(() => expect(input.disabled).toBe(false));
|
await vi.waitFor(() => expect(conn.el.textContent).toContain("Upload failed"));
|
||||||
expect(input.value).toBe("Read this");
|
expect(input.value).toBe("Read this");
|
||||||
expect(sendStructuredPrompt).not.toHaveBeenCalled();
|
expect(sendStructuredPrompt).not.toHaveBeenCalled();
|
||||||
const remove = conn.el.querySelector<HTMLButtonElement>('[aria-label="Remove notes.txt"]')!;
|
const remove = conn.el.querySelector<HTMLButtonElement>('[aria-label="Remove notes.txt"]')!;
|
||||||
@@ -413,6 +413,73 @@ 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 });
|
||||||
|
// While the turn is active, the last intermediate text (agent:2) stays
|
||||||
|
// outside the collapsed group so it's visible as current narration.
|
||||||
|
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).toBeNull();
|
||||||
|
// Earlier intermediate text (agent:1) is still inside the group.
|
||||||
|
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull();
|
||||||
|
// When the turn ends, all intermediate text is absorbed.
|
||||||
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
|
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
|
||||||
|
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull();
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps an expanded running tool open without emitting a click when patched", () => {
|
it("keeps an expanded running tool open without emitting a click when patched", () => {
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
@@ -660,7 +727,7 @@ describe("structured connection: turn/composer gating", () => {
|
|||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables the composer while a turn is active and re-enables when idle", () => {
|
it("keeps the composer available while a turn is active", () => {
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
ws.open();
|
ws.open();
|
||||||
@@ -668,13 +735,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(true);
|
expect(input.disabled).toBe(false);
|
||||||
expect(cancel.hidden).toBe(false);
|
expect(cancel.hidden).toBe(false);
|
||||||
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(true);
|
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
|
||||||
ws.emit({ type: "reduced_state", state: idleState });
|
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") as HTMLButtonElement).hidden).toBe(false);
|
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
|
||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -737,7 +804,7 @@ describe("structured connection: turn/composer gating", () => {
|
|||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables immediately and dispatches only once before begin-turn is projected", () => {
|
it("queues a second message before begin-turn is projected", () => {
|
||||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
|
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
@@ -750,12 +817,43 @@ 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(true);
|
expect(input.disabled).toBe(false);
|
||||||
expect(send.disabled).toBe(true);
|
expect(send.disabled).toBe(false);
|
||||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
||||||
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
|
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
|
||||||
|
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("1 message queued");
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches queued messages in order as turns become idle", async () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||||
|
const submit = (text: string) => {
|
||||||
|
input.value = text;
|
||||||
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
submit("first");
|
||||||
|
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||||
|
submit("second");
|
||||||
|
submit("third");
|
||||||
|
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("2 messages queued");
|
||||||
|
|
||||||
|
await Promise.resolve().then(() => Promise.resolve());
|
||||||
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
|
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(2, "s1", "second");
|
||||||
|
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||||
|
await Promise.resolve().then(() => Promise.resolve());
|
||||||
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
|
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(3, "s1", "third");
|
||||||
|
expect((conn.el.querySelector(".structured-queue") as HTMLElement).hidden).toBe(true);
|
||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -807,7 +905,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(true);
|
expect(input.disabled).toBe(false);
|
||||||
expect(conn.el.textContent).not.toContain("stale failure");
|
expect(conn.el.textContent).not.toContain("stale failure");
|
||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
@@ -869,7 +967,7 @@ describe("structured connection: approvals", () => {
|
|||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes the buttons once the approval resolves, even with no accompanying transcript delta", () => {
|
it("hides a standalone resolved approval entirely when no tool group absorbs it", () => {
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
ws.open();
|
ws.open();
|
||||||
@@ -885,7 +983,68 @@ 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();
|
||||||
expect(conn.el.querySelector(".st-approval-resolved")).not.toBeNull();
|
// Standalone resolved approval is hidden — no card, no compact line.
|
||||||
|
expect(conn.el.querySelector(".st-row-approval")).toBeNull();
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("absorbs a resolved approval into the tool group as a compact line", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 0,
|
||||||
|
rows: [
|
||||||
|
row("user:1", "user", { content: { text: "go" } }),
|
||||||
|
toolRow("tool:1", "first"),
|
||||||
|
row("approval:1", "approval", { toolCall: { title: "Write file.ts" }, options: allApprovalOptions }),
|
||||||
|
toolRow("tool:2", "second"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
|
// 2+ tools in the turn → tool group exists; resolved approval is inside it.
|
||||||
|
const group = conn.el.querySelector(".st-tool-group");
|
||||||
|
expect(group).not.toBeNull();
|
||||||
|
const compact = group!.querySelector(".st-approval-compact");
|
||||||
|
expect(compact).not.toBeNull();
|
||||||
|
expect(compact!.textContent).toContain("Write file.ts");
|
||||||
|
expect(compact!.textContent).toContain("approved");
|
||||||
|
// No full-card approval anywhere on the transcript.
|
||||||
|
expect(conn.el.querySelector(".st-approval")).toBeNull();
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a pending approval standalone even when a tool group exists in the same turn", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 0,
|
||||||
|
rows: [
|
||||||
|
row("user:1", "user", { content: { text: "go" } }),
|
||||||
|
toolRow("tool:1", "first"),
|
||||||
|
row("approval:1", "approval", { toolCall: { title: "Write file.ts" }, options: allApprovalOptions }),
|
||||||
|
toolRow("tool:2", "second"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws.emit({
|
||||||
|
type: "reduced_state",
|
||||||
|
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
||||||
|
});
|
||||||
|
// Pending approval stays outside the tool group as a full card.
|
||||||
|
const approvalCard = conn.el.querySelector(".st-approval");
|
||||||
|
expect(approvalCard).not.toBeNull();
|
||||||
|
expect(conn.el.querySelectorAll(".st-approval-acts button").length).toBeGreaterThan(0);
|
||||||
|
// It should not be inside the tool group.
|
||||||
|
const group = conn.el.querySelector(".st-tool-group");
|
||||||
|
expect(group).not.toBeNull();
|
||||||
|
expect(group!.querySelector(".st-approval")).toBeNull();
|
||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1126,7 +1285,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(true);
|
expect(input.disabled).toBe(false);
|
||||||
|
|
||||||
second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||||
second.emit({ type: "reduced_state", state: idleState });
|
second.emit({ type: "reduced_state", state: idleState });
|
||||||
@@ -1178,12 +1337,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(true);
|
expect(input.disabled).toBe(false);
|
||||||
|
|
||||||
accept();
|
accept();
|
||||||
await Promise.resolve().then(() => Promise.resolve());
|
await Promise.resolve().then(() => Promise.resolve());
|
||||||
|
|
||||||
expect(input.disabled).toBe(true);
|
expect(input.disabled).toBe(false);
|
||||||
expect(second.closed).toBe(true);
|
expect(second.closed).toBe(true);
|
||||||
|
|
||||||
third.open();
|
third.open();
|
||||||
@@ -1584,7 +1743,7 @@ describe("structured connection: pane lifecycle", () => {
|
|||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("focusTerminal focuses the composer only when it is enabled", () => {
|
it("focusTerminal keeps the composer reachable during an active turn", () => {
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
ws.open();
|
ws.open();
|
||||||
@@ -1592,7 +1751,7 @@ describe("structured connection: pane lifecycle", () => {
|
|||||||
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
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).not.toBe(input);
|
expect(document.activeElement).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);
|
||||||
|
|||||||
+215
-43
@@ -85,10 +85,10 @@ export function createStructuredConnection(
|
|||||||
<div class="structured-main">
|
<div class="structured-main">
|
||||||
<div class="structured-scroll">
|
<div class="structured-scroll">
|
||||||
<div class="structured-rows"></div>
|
<div class="structured-rows"></div>
|
||||||
</div>
|
<div class="structured-activity" role="status" aria-live="polite" hidden>
|
||||||
<div class="structured-activity" role="status" aria-live="polite" hidden>
|
<span class="structured-activity-pulse" aria-hidden="true"></span>
|
||||||
<span class="structured-activity-pulse" aria-hidden="true"></span>
|
<span class="structured-activity-text">Thinking</span>
|
||||||
<span class="structured-activity-text">Thinking</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form class="structured-composer">
|
<form class="structured-composer">
|
||||||
<button type="button" class="btn btn-ghost btn-icon structured-attach" aria-label="Attach files" title="Attach files (up to 20 MiB each)">
|
<button type="button" class="btn btn-ghost btn-icon structured-attach" aria-label="Attach files" title="Attach files (up to 20 MiB each)">
|
||||||
@@ -113,7 +113,11 @@ 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 structured-cancel" hidden>Cancel turn</button>
|
<button type="button" class="btn btn-icon structured-cancel" hidden aria-label="Cancel turn" title="Cancel turn">
|
||||||
|
<svg class="structured-cancel-spinner" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="50 14" stroke-linecap="round"/></svg>
|
||||||
|
<svg class="structured-cancel-stop" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class="structured-queue" role="status" aria-live="polite" hidden></span>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,11 +137,14 @@ 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 queueEl = el.querySelector(".structured-queue") as HTMLSpanElement;
|
||||||
const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement;
|
const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement;
|
||||||
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
|
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
|
||||||
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
|
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
|
||||||
type Attachment = { file: File; path?: string };
|
type Attachment = { file: File; path?: string };
|
||||||
|
type QueuedPrompt = { text: string; attachments: Attachment[] };
|
||||||
let attachments: Attachment[] = [];
|
let attachments: Attachment[] = [];
|
||||||
let uploading = false;
|
let uploading = false;
|
||||||
let pasteCount = 0;
|
let pasteCount = 0;
|
||||||
@@ -155,7 +162,7 @@ export function createStructuredConnection(
|
|||||||
remove.type = "button";
|
remove.type = "button";
|
||||||
remove.textContent = "×";
|
remove.textContent = "×";
|
||||||
remove.setAttribute("aria-label", `Remove ${attachment.file.name}`);
|
remove.setAttribute("aria-label", `Remove ${attachment.file.name}`);
|
||||||
remove.disabled = input.disabled;
|
remove.disabled = uploading;
|
||||||
remove.addEventListener("click", () => {
|
remove.addEventListener("click", () => {
|
||||||
attachments = attachments.filter((item) => item !== attachment);
|
attachments = attachments.filter((item) => item !== attachment);
|
||||||
renderAttachments();
|
renderAttachments();
|
||||||
@@ -166,7 +173,7 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addFiles(files: File[]): void {
|
function addFiles(files: File[]): void {
|
||||||
if (input.disabled || disposed) return;
|
if (uploading || disposed) return;
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.size > 20 * 1024 * 1024) {
|
if (file.size > 20 * 1024 * 1024) {
|
||||||
showError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
|
showError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
|
||||||
@@ -193,7 +200,7 @@ export function createStructuredConnection(
|
|||||||
});
|
});
|
||||||
input.addEventListener("paste", (event) => {
|
input.addEventListener("paste", (event) => {
|
||||||
const clipboard = event.clipboardData;
|
const clipboard = event.clipboardData;
|
||||||
if (!clipboard || input.disabled) return;
|
if (!clipboard || uploading) return;
|
||||||
if (clipboard.files.length) {
|
if (clipboard.files.length) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
addFiles(Array.from(clipboard.files));
|
addFiles(Array.from(clipboard.files));
|
||||||
@@ -244,6 +251,10 @@ 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;
|
||||||
@@ -374,6 +385,9 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pattern matching `Attached file "name": "path"` references appended by dispatchPrompt. */
|
||||||
|
const attachedFileRe = /Attached file "(?:[^"\\]|\\.)*": "((?:[^"\\]|\\.)*)"/g;
|
||||||
|
|
||||||
function buildRowElement(row: TranscriptRow): HTMLElement | null {
|
function buildRowElement(row: TranscriptRow): HTMLElement | null {
|
||||||
const el = buildTranscriptRow(
|
const el = buildTranscriptRow(
|
||||||
row,
|
row,
|
||||||
@@ -390,20 +404,112 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolGroupsByTurn(): Map<string, TranscriptRow[]> {
|
/** Group tool rows (and intermediate agent text / resolved approvals between
|
||||||
const groups = new Map<string, TranscriptRow[]>();
|
* them) by turn. An agent_text/agent_thought row is "intermediate" when a
|
||||||
|
* tool row follows it in the same turn — it gets absorbed into the group so
|
||||||
|
* the collapsed block preserves chronological order. Resolved approval rows
|
||||||
|
* (not the currently pending one) are absorbed the same way. The *last*
|
||||||
|
* agent text with no subsequent tool stays standalone (the prominent final
|
||||||
|
* answer). Returns groups keyed by `turn:<userId>`, each with `toolCount`
|
||||||
|
* (for the header label) and `rows` (tools + intermediate text/approvals,
|
||||||
|
* in order). */
|
||||||
|
function activityGroupsByTurn(): Map<string, { toolCount: number; rows: TranscriptRow[] }> {
|
||||||
|
// First: count tools per turn to know which turns qualify for grouping.
|
||||||
|
const toolCounts = new Map<string, number>();
|
||||||
let turnId = "before-first-message";
|
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 id = `turn:${turnId}`;
|
const gid = `turn:${turnId}`;
|
||||||
const group = groups.get(id) ?? [];
|
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// pending at end of a turn = final text/approvals, stays outside (not flushed).
|
||||||
|
|
||||||
|
// While the turn is still active, keep the last intermediate text/thought
|
||||||
|
// row outside the collapsed group so it's visible as the agent's current
|
||||||
|
// narration. It gets absorbed on the next rebuild once newer content
|
||||||
|
// arrives or the turn ends.
|
||||||
|
const turnActive = state.turn.responding || state.turn.thinking;
|
||||||
|
if (turnActive) {
|
||||||
|
const lastGroup = groups.get(currentGid);
|
||||||
|
if (lastGroup) {
|
||||||
|
for (let i = lastGroup.rows.length - 1; i >= 0; i--) {
|
||||||
|
const kind = lastGroup.rows[i].kind;
|
||||||
|
if (kind === "agent_text" || kind === "agent_thought") {
|
||||||
|
lastGroup.rows.splice(i, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,9 +519,9 @@ 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 = toolGroupsByTurn();
|
const groups = activityGroupsByTurn();
|
||||||
const groupedRowIds = new Set<string>();
|
const groupedRowIds = new Set<string>();
|
||||||
const liveGroupIds = new Set([...groups].filter(([, tools]) => tools.length > 1).map(([id]) => id));
|
const liveGroupIds = new Set([...groups.keys()]);
|
||||||
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
|
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
|
||||||
|
|
||||||
rowEls.clear();
|
rowEls.clear();
|
||||||
@@ -425,34 +531,40 @@ 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 groupRows = groups.get(groupId) ?? [];
|
const group = groups.get(groupId);
|
||||||
if ((row.kind === "tool_call" || row.kind === "tool_diff") && groupRows.length > 1) {
|
if (group && groupedRowIds.has(row.id)) continue;
|
||||||
if (groupedRowIds.has(row.id)) continue;
|
if (group && (row.kind === "tool_call" || row.kind === "tool_diff") && !groupedRowIds.has(row.id)) {
|
||||||
const toolEls = groupRows.flatMap((toolRow) => {
|
// First ungrouped tool in this turn — render the whole activity group.
|
||||||
groupedRowIds.add(toolRow.id);
|
const memberEls = group.rows.flatMap((memberRow) => {
|
||||||
const toolEl = buildRowElement(toolRow);
|
groupedRowIds.add(memberRow.id);
|
||||||
if (!toolEl) return [];
|
const memberEl = buildRowElement(memberRow);
|
||||||
rowEls.set(toolRow.id, toolEl);
|
if (!memberEl) return [];
|
||||||
return [toolEl];
|
rowEls.set(memberRow.id, memberEl);
|
||||||
|
return [memberEl];
|
||||||
});
|
});
|
||||||
const groupEl = buildToolGroup(
|
const groupEl = buildToolGroup(
|
||||||
groupId,
|
groupId,
|
||||||
groupRows,
|
group.rows,
|
||||||
toolEls,
|
memberEls,
|
||||||
expandedGroupIds.has(groupId),
|
expandedGroupIds.has(groupId),
|
||||||
(expanded) => {
|
(expanded) => {
|
||||||
if (expanded) expandedGroupIds.add(groupId);
|
if (expanded) expandedGroupIds.add(groupId);
|
||||||
else expandedGroupIds.delete(groupId);
|
else expandedGroupIds.delete(groupId);
|
||||||
settleDisclosureLayout();
|
settleDisclosureLayout();
|
||||||
},
|
},
|
||||||
|
group.toolCount,
|
||||||
);
|
);
|
||||||
if (groupRows.some((toolRow) => enteringRowIds.has(toolRow.id))) {
|
if (group.rows.some((r) => enteringRowIds.has(r.id))) {
|
||||||
groupEl.classList.add("st-row-enter");
|
groupEl.classList.add("st-row-enter");
|
||||||
}
|
}
|
||||||
groupEls.set(groupId, groupEl);
|
groupEls.set(groupId, groupEl);
|
||||||
children.push(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);
|
||||||
@@ -504,6 +616,19 @@ 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 newEl = buildRowElement(delta.row);
|
||||||
|
if (newEl) {
|
||||||
|
oldEl.replaceWith(newEl);
|
||||||
|
rowEls.set(delta.row.id, newEl);
|
||||||
|
}
|
||||||
|
syncAvailableCommands();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const i = rowIndex.get(delta.id);
|
const i = rowIndex.get(delta.id);
|
||||||
@@ -606,16 +731,19 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderComposerState(): void {
|
function renderComposerState(): void {
|
||||||
const active = uploading || 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 = active;
|
input.disabled = false;
|
||||||
attachBtn.disabled = active;
|
attachBtn.disabled = uploading;
|
||||||
fileInput.disabled = active;
|
fileInput.disabled = uploading;
|
||||||
renderAttachments();
|
renderAttachments();
|
||||||
sendBtn.disabled = active;
|
sendBtn.disabled = false;
|
||||||
sendBtn.hidden = projectedActive;
|
sendBtn.hidden = false;
|
||||||
|
sendLabel.textContent = "Send";
|
||||||
cancelBtn.hidden = !projectedActive;
|
cancelBtn.hidden = !projectedActive;
|
||||||
if (active) closeCommandMenu();
|
queueEl.hidden = promptQueue.length === 0;
|
||||||
|
queueEl.textContent = promptQueue.length === 1
|
||||||
|
? "1 message queued"
|
||||||
|
: `${promptQueue.length} messages queued`;
|
||||||
renderActivity();
|
renderActivity();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,6 +755,7 @@ export function createStructuredConnection(
|
|||||||
|
|
||||||
function renderActivity(): void {
|
function renderActivity(): void {
|
||||||
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
||||||
|
const wasHidden = activityEl.hidden;
|
||||||
activityEl.hidden = !active;
|
activityEl.hidden = !active;
|
||||||
if (!active) {
|
if (!active) {
|
||||||
optimisticTurnStartedAt = null;
|
optimisticTurnStartedAt = null;
|
||||||
@@ -649,6 +778,11 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
activityText.textContent = parts.join(" · ");
|
activityText.textContent = parts.join(" · ");
|
||||||
if (activityTimer === null) activityTimer = window.setInterval(renderActivity, 1_000);
|
if (activityTimer === null) activityTimer = window.setInterval(renderActivity, 1_000);
|
||||||
|
// Activity lives inside the scroll area; nudge scroll when it first appears.
|
||||||
|
if (wasHidden && followingBottom) {
|
||||||
|
beginTranscriptLayout(true);
|
||||||
|
finishTranscriptLayout();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function reconcileAwaitingBegin(): void {
|
function reconcileAwaitingBegin(): void {
|
||||||
@@ -664,19 +798,22 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyState(next: ReducedState, connectionGeneration: number): void {
|
function applyState(next: ReducedState, connectionGeneration: number): void {
|
||||||
|
const wasTurnActive = state.turn.responding || state.turn.thinking;
|
||||||
state = next;
|
state = next;
|
||||||
stateSocketGeneration = connectionGeneration;
|
stateSocketGeneration = connectionGeneration;
|
||||||
reconcileAwaitingBegin();
|
reconcileAwaitingBegin();
|
||||||
|
const isTurnActive = state.turn.responding || state.turn.thinking;
|
||||||
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
|
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
|
||||||
if (newPendingId !== pendingApprovalRowId) {
|
const needsRebuild = newPendingId !== pendingApprovalRowId || wasTurnActive !== isTurnActive;
|
||||||
|
if (needsRebuild) {
|
||||||
updateTranscriptLayout(() => {
|
updateTranscriptLayout(() => {
|
||||||
const prev = pendingApprovalRowId;
|
|
||||||
pendingApprovalRowId = newPendingId;
|
pendingApprovalRowId = newPendingId;
|
||||||
if (prev !== null || newPendingId !== null) rebuildTranscript();
|
rebuildTranscript();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
renderConfig();
|
renderConfig();
|
||||||
renderComposerState();
|
renderComposerState();
|
||||||
|
dispatchQueuedPrompt();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeConfigMenu(): void {
|
function closeConfigMenu(): void {
|
||||||
@@ -876,17 +1013,14 @@ export function createStructuredConnection(
|
|||||||
if (!commandMenu.contains(document.activeElement)) closeCommandMenu();
|
if (!commandMenu.contains(document.activeElement)) closeCommandMenu();
|
||||||
}, 0);
|
}, 0);
|
||||||
});
|
});
|
||||||
composerForm.addEventListener("submit", (e) => {
|
function dispatchPrompt(text: string, submittedAttachments: Attachment[] = []): void {
|
||||||
e.preventDefault();
|
|
||||||
const text = input.value;
|
|
||||||
if ((!text.trim() && attachments.length === 0) || input.disabled) return;
|
|
||||||
const submittedAttachments = attachments;
|
|
||||||
uploading = submittedAttachments.length > 0;
|
uploading = submittedAttachments.length > 0;
|
||||||
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();
|
||||||
@@ -895,6 +1029,9 @@ export function createStructuredConnection(
|
|||||||
for (const attachment of submittedAttachments) {
|
for (const attachment of submittedAttachments) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
if (!attachment.path) attachment.path = (await uploadStructuredFile(sessionId, attachment.file)).path;
|
if (!attachment.path) attachment.path = (await uploadStructuredFile(sessionId, attachment.file)).path;
|
||||||
|
if (attachment.path && attachment.file.type.startsWith("image/")) {
|
||||||
|
imageObjectUrls.set(attachment.path, URL.createObjectURL(attachment.file));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
const references = submittedAttachments.map(({ file, path }) =>
|
const references = submittedAttachments.map(({ file, path }) =>
|
||||||
@@ -903,6 +1040,7 @@ export function createStructuredConnection(
|
|||||||
};
|
};
|
||||||
void send().then(
|
void send().then(
|
||||||
() => {
|
() => {
|
||||||
|
promptRequestPending = false;
|
||||||
if (disposed || generation !== submissionGeneration) return;
|
if (disposed || generation !== submissionGeneration) return;
|
||||||
uploading = false;
|
uploading = false;
|
||||||
if (attachments === submittedAttachments) {
|
if (attachments === submittedAttachments) {
|
||||||
@@ -916,11 +1054,17 @@ export function createStructuredConnection(
|
|||||||
// 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;
|
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();
|
||||||
@@ -929,6 +1073,32 @@ 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();
|
||||||
@@ -1101,6 +1271,8 @@ export function createStructuredConnection(
|
|||||||
closeCurrentSocket();
|
closeCurrentSocket();
|
||||||
expandedRowIds.clear();
|
expandedRowIds.clear();
|
||||||
expandedGroupIds.clear();
|
expandedGroupIds.clear();
|
||||||
|
for (const url of imageObjectUrls.values()) URL.revokeObjectURL(url);
|
||||||
|
imageObjectUrls.clear();
|
||||||
scrollEl.removeEventListener("scroll", onTranscriptScroll);
|
scrollEl.removeEventListener("scroll", onTranscriptScroll);
|
||||||
scrollEl.removeEventListener("click", onTranscriptClick, true);
|
scrollEl.removeEventListener("click", onTranscriptClick, true);
|
||||||
scrollEl.removeEventListener("wheel", stopSmoothFollow);
|
scrollEl.removeEventListener("wheel", stopSmoothFollow);
|
||||||
|
|||||||
@@ -368,13 +368,14 @@ describe("buildTranscriptRow: approval", () => {
|
|||||||
expect(el.querySelector(".st-approval-path")!.textContent).toBe("/etc/x");
|
expect(el.querySelector(".st-approval-path")!.textContent).toBe("/etc/x");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides the buttons and shows an answered note once resolved", () => {
|
it("renders a compact one-liner once resolved", () => {
|
||||||
const el = buildTranscriptRow(approvalRow(allThreeOptions), {
|
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.querySelector(".st-approval-resolved")!.textContent).toBe("answered");
|
expect(el.classList.contains("st-approval-compact")).toBe(true);
|
||||||
|
expect(el.textContent).toContain("approved");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invokes onDecide with the clicked decision and disables the buttons", () => {
|
it("invokes onDecide with the clicked decision and disables the buttons", () => {
|
||||||
|
|||||||
+44
-38
@@ -581,7 +581,9 @@ 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");
|
||||||
@@ -593,7 +595,7 @@ 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 = `${rows.length} tool calls`;
|
title.textContent = `${count} tool calls`;
|
||||||
head.append(glyph, title);
|
head.append(glyph, title);
|
||||||
|
|
||||||
const body = el("div", "st-tool-group-body st-disclosure");
|
const body = el("div", "st-tool-group-body st-disclosure");
|
||||||
@@ -702,6 +704,16 @@ function offeredDecisions(options: AcpPermissionOption[]): StructuredApprovalDec
|
|||||||
function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLElement {
|
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);
|
||||||
@@ -721,45 +733,39 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
|
|||||||
card.appendChild(pathEl);
|
card.appendChild(pathEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (approval.pending) {
|
const acts = el("div", "st-approval-acts");
|
||||||
const acts = el("div", "st-approval-acts");
|
const mk = (label: string, decision: StructuredApprovalDecision, cls: string) => {
|
||||||
const mk = (label: string, decision: StructuredApprovalDecision, cls: string) => {
|
const btn = document.createElement("button");
|
||||||
const btn = document.createElement("button");
|
btn.type = "button";
|
||||||
btn.type = "button";
|
btn.className = cls ? `act ${cls}` : "act";
|
||||||
btn.className = cls ? `act ${cls}` : "act";
|
btn.textContent = label;
|
||||||
btn.textContent = label;
|
btn.addEventListener("click", () => {
|
||||||
btn.addEventListener("click", () => {
|
const buttons = [...acts.querySelectorAll<HTMLButtonElement>("button")];
|
||||||
const buttons = [...acts.querySelectorAll<HTMLButtonElement>("button")];
|
buttons.forEach((b) => (b.disabled = true));
|
||||||
buttons.forEach((b) => (b.disabled = true));
|
approval.onDecide(decision).catch(() => {
|
||||||
approval.onDecide(decision).catch(() => {
|
// Rejects only when this approval is still pending (see
|
||||||
// Rejects only when this approval is still pending (see
|
// ApprovalRowState) — restore the controls so the operator can
|
||||||
// ApprovalRowState) — restore the controls so the operator can
|
// retry; the error itself is surfaced by the connection.
|
||||||
// retry; the error itself is surfaced by the connection.
|
buttons.forEach((b) => (b.disabled = false));
|
||||||
buttons.forEach((b) => (b.disabled = false));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
return btn;
|
});
|
||||||
};
|
return btn;
|
||||||
const offered = offeredDecisions(request.options ?? []);
|
};
|
||||||
const specs: [StructuredApprovalDecision, string, string][] = [
|
const offered = offeredDecisions(request.options ?? []);
|
||||||
["allow_once", "Allow once", "primary"],
|
const specs: [StructuredApprovalDecision, string, string][] = [
|
||||||
["allow_for_session", "Allow for session", ""],
|
["allow_once", "Allow once", "primary"],
|
||||||
["deny", "Deny", "deny"],
|
["allow_for_session", "Allow for session", ""],
|
||||||
];
|
["deny", "Deny", "deny"],
|
||||||
for (const [decision, label, cls] of specs) {
|
];
|
||||||
if (offered.includes(decision)) acts.appendChild(mk(label, decision, cls));
|
for (const [decision, label, cls] of specs) {
|
||||||
}
|
if (offered.includes(decision)) acts.appendChild(mk(label, decision, cls));
|
||||||
if (acts.children.length > 0) {
|
}
|
||||||
card.appendChild(acts);
|
if (acts.children.length > 0) {
|
||||||
} else {
|
card.appendChild(acts);
|
||||||
const none = el("div", "st-approval-resolved");
|
|
||||||
none.textContent = "no decision offered";
|
|
||||||
card.appendChild(none);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const resolved = el("div", "st-approval-resolved");
|
const none = el("div", "st-approval-resolved");
|
||||||
resolved.textContent = "answered";
|
none.textContent = "no decision offered";
|
||||||
card.appendChild(resolved);
|
card.appendChild(none);
|
||||||
}
|
}
|
||||||
|
|
||||||
return wrap;
|
return wrap;
|
||||||
|
|||||||
+123
-49
@@ -2290,6 +2290,9 @@ 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;
|
||||||
@@ -2306,9 +2309,6 @@ input.leaf-rename:focus {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 820px;
|
|
||||||
margin-inline: auto;
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.structured-activity {
|
.structured-activity {
|
||||||
@@ -2318,9 +2318,7 @@ input.leaf-rename:focus {
|
|||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
padding: var(--sp-1) var(--sp-3);
|
padding: var(--sp-1) var(--sp-3);
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
background: var(--surface-2);
|
|
||||||
font-size: var(--fs-11);
|
font-size: var(--fs-11);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
@@ -2382,6 +2380,15 @@ input.leaf-rename:focus {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.st-user-image {
|
||||||
|
display: block;
|
||||||
|
max-width: min(100%, 480px);
|
||||||
|
max-height: 360px;
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
border-radius: var(--r-1);
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
/* agent prose: no card, no background — plain text is the point */
|
/* agent prose: no card, no background — plain text is the point */
|
||||||
.st-row-agent .st-body p {
|
.st-row-agent .st-body p {
|
||||||
margin: 0 0 var(--sp-2);
|
margin: 0 0 var(--sp-2);
|
||||||
@@ -2898,6 +2905,26 @@ input.leaf-rename:focus {
|
|||||||
border-radius: var(--r-1);
|
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] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -3020,6 +3047,16 @@ 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;
|
||||||
@@ -3073,6 +3110,7 @@ input.leaf-rename:focus {
|
|||||||
hairline that doubles as the focus indicator. Inner metrics line the draft
|
hairline that doubles as the focus indicator. Inner metrics line the draft
|
||||||
up with the transcript's own 12px gutter. */
|
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));
|
padding: var(--sp-1) var(--sp-1) max(var(--sp-1), env(safe-area-inset-bottom));
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
@@ -3083,8 +3121,21 @@ input.leaf-rename:focus {
|
|||||||
transition: border-color var(--dur-fast) var(--ease-out);
|
transition: border-color var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.structured-queue {
|
||||||
|
position: absolute;
|
||||||
|
right: var(--sp-2);
|
||||||
|
bottom: calc(100% + var(--sp-1));
|
||||||
|
padding: 3px var(--sp-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--fs-11);
|
||||||
|
box-shadow: var(--shadow-1);
|
||||||
|
}
|
||||||
|
|
||||||
.structured-composer:focus-within {
|
.structured-composer:focus-within {
|
||||||
border-top-color: var(--accent);
|
border-top-color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.structured-input-wrap {
|
.structured-input-wrap {
|
||||||
@@ -3200,7 +3251,9 @@ input.leaf-rename:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.structured-composer .structured-cancel {
|
.structured-composer .structured-cancel {
|
||||||
padding: 0 var(--sp-3);
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
padding: 0;
|
||||||
border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
|
border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
@@ -3209,6 +3262,23 @@ 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
|
/* 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. */
|
draft gets a line of its own and the controls drop below it. */
|
||||||
@@ -3598,61 +3668,78 @@ input.leaf-rename:focus {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* One-click tiles: host row + command row. */
|
/* Harness cards: the first decision in the spawn form — which tool runs. */
|
||||||
.tile-row {
|
.harness-grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile {
|
.harness-card {
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 96px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
align-items: center;
|
||||||
align-items: flex-start;
|
gap: var(--sp-2);
|
||||||
padding: var(--sp-2) var(--sp-3);
|
padding: var(--sp-3) var(--sp-2);
|
||||||
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: left;
|
text-align: center;
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile:hover {
|
.harness-card:hover {
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
|
background: var(--surface-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile.is-active {
|
.harness-card.is-active {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--accent-tint);
|
background: var(--accent-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-label {
|
.harness-icon {
|
||||||
font-size: var(--fs-14);
|
display: flex;
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-sub {
|
|
||||||
font-size: var(--fs-12);
|
|
||||||
color: var(--muted);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-more {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
min-width: 0;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.harness-card.is-active .harness-icon {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.harness-icon .tool-icon {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harness-label {
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The "Custom…" card is an escape hatch, not a peer tool — dashed border
|
||||||
|
and subdued color separate it from the named harnesses. */
|
||||||
|
.harness-custom {
|
||||||
|
border-style: dashed;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.harness-custom .harness-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
|
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
|
||||||
picked project's worktree choices. Stale beyond 7 days dims but stays
|
picked project's worktree choices. Stale beyond 7 days dims but stays
|
||||||
selectable. */
|
selectable. */
|
||||||
@@ -3679,7 +3766,7 @@ input.leaf-rename:focus {
|
|||||||
|
|
||||||
.picker.projects {
|
.picker.projects {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
grid-template-columns: repeat(2, 1fr);
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
@@ -3735,19 +3822,6 @@ 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;
|
||||||
|
|||||||
+1
-1
@@ -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";
|
export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error" | "Starting";
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: Uuid;
|
id: Uuid;
|
||||||
|
|||||||
Reference in New Issue
Block a user