Compare commits

..
Author SHA1 Message Date
Miguel PalhasandClaude Opus 4.6 19087ec613 fix(maestro): add missing auto_approve field in TUI tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 11:31:27 +01:00
69 changed files with 493 additions and 8193 deletions
-18
View File
@@ -1,18 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git fetch:*)",
"Bash(git merge:*)",
"Bash(npx tsc:*)",
"Bash(npm run build:*)",
"Bash(npm install:*)",
"Bash(./node_modules/.bin/tsc:*)",
"Bash(npm test:*)",
"Bash(cargo check:*)",
"Bash(cargo clippy:*)",
"Bash(cargo test:*)"
]
}
}
+2 -2
View File
@@ -41,8 +41,8 @@ pub enum Command {
Attach(AttachArgs),
/// Kill a session by ID.
Kill(KillArgs),
/// Send text to a running session. Terminal sessions receive raw
/// keystrokes; structured sessions receive an ACP prompt.
/// Type text into a running session, as if it had been typed on an
/// attached terminal.
Send(SendArgs),
/// Install or update the agent activity hook (#106).
Hooks(HooksArgs),
+15 -28
View File
@@ -130,12 +130,9 @@ pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid,
/// `maestro send`. Answers with the session that was typed into, so the
/// caller can name it once the daemon has accepted the text.
///
/// Terminal sessions get raw keystrokes via `send_input`; structured sessions
/// get an ACP prompt via `send_structured_prompt` (`--no-enter` is ignored
/// there — ACP prompts are always complete messages).
///
/// Text comes from the arguments, or from stdin when there is none — the
/// shape a heredoc or a pipe wants.
/// A bare ID skips the catalog fetch; anything else needs the list to match
/// against. Text comes from the arguments, or from stdin when there is none —
/// the shape a heredoc or a pipe wants.
#[allow(clippy::result_large_err)]
pub async fn run_send(
client: &DaemonClient,
@@ -158,28 +155,19 @@ pub async fn run_send(
return Err(CliError::NothingToSend);
}
// We always need the session list: even a bare UUID needs the view field
// to decide which endpoint to hit.
let sessions = client.list_sessions().await?;
let id = resolve_session(&sessions, &args.session)?;
let view = sessions
.iter()
.find(|s| s.id == id)
.map(|s| s.view)
.unwrap_or(SessionView::Terminal);
let id = match uuid::Uuid::parse_str(args.session.trim()) {
Ok(id) => id,
Err(_) => {
let sessions = client.list_sessions().await?;
resolve_session(&sessions, &args.session)?
}
};
match view {
SessionView::Structured => {
client.send_structured_prompt(id, &text).await?;
}
SessionView::Terminal => {
let req = SendInputRequest {
text,
submit: !args.no_enter,
};
client.send_input(id, &req).await?;
}
}
let req = SendInputRequest {
text,
submit: !args.no_enter,
};
client.send_input(id, &req).await?;
Ok(id)
}
@@ -228,7 +216,6 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
ActivityState::Idle => "idle",
ActivityState::AwaitingInput => "awaiting",
ActivityState::Error => "error",
ActivityState::Starting => "starting",
};
// Show the live foreground command alongside the spawn command when
// the daemon has observed one that differs (NAP-340).
@@ -2,7 +2,7 @@
//! was unit-tested, but nothing exercised the client, the error decoding, or
//! the unreachable-daemon path without compiling and forking a real daemon.
use maestro_protocol::{DaemonClient, DaemonInfo, SessionView};
use maestro_protocol::{DaemonClient, DaemonInfo};
use maestro_testing::{session, MockDaemon};
use uuid::Uuid;
@@ -202,35 +202,6 @@ async fn send_resolves_a_session_by_name() {
assert_eq!(sent, id);
}
/// A structured session routes through the ACP prompt endpoint, not the
/// terminal input one — `--no-enter` is irrelevant (ACP prompts are
/// complete messages).
#[tokio::test]
async fn send_to_structured_session_uses_acp_prompt() {
let mock = MockDaemon::start().await;
let id = Uuid::from_u128(13);
let mut s = session(id, "/tmp", "claude");
s.view = SessionView::Structured;
mock.add_session(s);
let args = maestro_cli::cli::SendArgs {
session: id.to_string(),
text: vec!["fix".into(), "the".into(), "tests".into()],
no_enter: false,
};
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
.await
.expect("send");
assert_eq!(sent, id);
// Terminal input must stay empty — the message went through ACP.
assert!(mock.inputs().is_empty());
let prompts = mock.structured_prompts();
assert_eq!(prompts.len(), 1);
assert_eq!(prompts[0].0, id);
assert_eq!(prompts[0].1.prompt, "fix the tests");
}
#[tokio::test]
async fn send_to_an_unknown_session_fails_without_typing_anything() {
let mock = MockDaemon::start().await;
+4 -13
View File
@@ -80,22 +80,13 @@ mod tests {
}
#[test]
fn non_repo_dir_does_not_find_root_inside_fixture() {
// The fixture has no `.git`, so `find_root` must not return a path
// inside it. On hosts where an ancestor of the temp dir *is* a repo
// (e.g. `/tmp` lives inside a checkout), the function legitimately
// returns that ancestor — the test only asserts the fixture itself
// was not mistaken for a repo.
fn non_repo_dir_is_none() {
// A temp dir with no `.git` anywhere up the chain. Use the temp root so
// the walk can't accidentally hit a real repo above the test's cwd.
let tmp = tempfile::tempdir().unwrap();
let sub = tmp.path().join("plain/dir");
fs::create_dir_all(&sub).unwrap();
match find_root(&sub) {
None => {} // expected on most hosts
Some(root) => assert!(
!root.starts_with(tmp.path()),
"found .git inside the fixture at {root:?}, but none was created"
),
}
assert_eq!(find_root(&sub), None);
}
#[test]
+2 -4
View File
@@ -180,7 +180,7 @@ fn dir_error_status(code: &str) -> StatusCode {
fn session_error_status(code: &str) -> StatusCode {
match code {
"not_found" => StatusCode::NOT_FOUND,
"invalid_env" | "bad_request" => StatusCode::BAD_REQUEST,
"invalid_env" => StatusCode::BAD_REQUEST,
// A guard refusal is the daemon doing its job, not a failure (#79).
"kill_guarded" => StatusCode::CONFLICT,
// The session outlived its pane: nothing to type into, and nothing
@@ -483,9 +483,7 @@ async fn structured_prompt(
Json(body): Json<StructuredPromptRequest>,
) -> Response {
match s.manager.queue_acp_prompt(id, body.prompt).await {
Ok(QueueAcpPrompt::Queued) | Ok(QueueAcpPrompt::Pending) => {
StatusCode::NO_CONTENT.into_response()
}
Ok(QueueAcpPrompt::Queued) => StatusCode::NO_CONTENT.into_response(),
Ok(QueueAcpPrompt::InFlight) => error_response(
StatusCode::CONFLICT,
"turn_in_flight",
+161 -213
View File
@@ -6,9 +6,12 @@
//! handler interact with tmux.
use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Weak};
use std::task::{Context, Poll};
use std::time::Duration;
use chrono::Utc;
@@ -122,9 +125,6 @@ pub struct SessionRecord {
/// When `true`, the permission pump auto-responds `AllowAlways` to every
/// ACP permission request without surfacing it to clients.
pub auto_approve: bool,
/// A prompt submitted while the adapter was still booting (`Starting`).
/// The background handshake task drains this on success.
pub pending_prompt: Option<String>,
}
impl SessionRecord {
@@ -134,13 +134,6 @@ impl SessionRecord {
&& self.acp.is_some()
&& self.acp_events.is_some()
}
/// The session is structured and still booting its adapter.
fn is_starting_structured(&self) -> bool {
self.session.view == SessionView::Structured
&& self.session.activity == ActivityState::Starting
&& self.acp.is_none()
}
}
#[derive(Debug)]
@@ -215,8 +208,6 @@ pub enum SessionError {
NotTerminal(Uuid),
#[error("daemon is shutting down")]
ShuttingDown,
#[error("bad request: {0}")]
BadRequest(String),
/// The kill was refused (#79): the session's worktree holds unsaved work.
/// Nothing happened — the session keeps running and the tree is untouched,
/// so it stays the operator's handle on their own unfinished work.
@@ -236,8 +227,6 @@ pub enum BeginAcpTurn {
#[derive(Debug)]
pub enum QueueAcpPrompt {
Queued,
/// The adapter is still booting; the prompt is held until it is ready.
Pending,
InFlight,
Unavailable,
Failed(String),
@@ -264,7 +253,6 @@ impl SessionError {
SessionError::NotRunning(_) => "not_running",
SessionError::NotTerminal(_) => "not_terminal",
SessionError::ShuttingDown => "shutting_down",
SessionError::BadRequest(_) => "bad_request",
SessionError::KillGuarded(_) => "kill_guarded",
};
let unsaved_work = match self {
@@ -332,6 +320,18 @@ struct StructuredLifecycle {
}
impl StructuredLifecycle {
fn admit<'a>(
&'a self,
shutting_down: &AtomicBool,
) -> Result<StructuredCreate<'a>, SessionError> {
let _gate = self.gate.lock().expect("structured lifecycle gate");
if shutting_down.load(Ordering::Acquire) {
return Err(SessionError::ShuttingDown);
}
self.in_flight.fetch_add(1, Ordering::AcqRel);
Ok(StructuredCreate { lifecycle: self })
}
async fn wait_until_settled(&self) {
loop {
let settled = self.settled.notified();
@@ -343,18 +343,30 @@ impl StructuredLifecycle {
}
}
/// Release the structured-lifecycle in-flight counter that `create` acquires
/// before spawning the background ACP handshake task. Called when the task
/// finishes (success or failure) or when `create_inner` fails before the task
/// is spawned.
fn drop_structured_lifecycle_permit(inner: &Inner) {
if inner
.structured_lifecycle
.in_flight
.fetch_sub(1, Ordering::AcqRel)
== 1
{
inner.structured_lifecycle.settled.notify_one();
struct StructuredCreate<'a> {
lifecycle: &'a StructuredLifecycle,
}
impl Drop for StructuredCreate<'_> {
fn drop(&mut self) {
if self.lifecycle.in_flight.fetch_sub(1, Ordering::AcqRel) == 1 {
self.lifecycle.settled.notify_one();
}
}
}
struct AdmittedStructuredCreate<'a> {
create: Pin<Box<dyn Future<Output = Result<Session, SessionError>> + Send + 'a>>,
/// Field order matters: dropping a cancelled create rolls back its inner
/// future before this permit releases shutdown's in-flight barrier.
_permit: StructuredCreate<'a>,
}
impl Future for AdmittedStructuredCreate<'_> {
type Output = Result<Session, SessionError>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
self.create.as_mut().poll(cx)
}
}
@@ -648,9 +660,7 @@ impl SessionManager {
let stream = {
let sessions = self.inner.sessions.lock().await;
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
// 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() {
if !record.is_live_structured() {
return Ok(None);
}
let Some(stream) = &record.acp_events else {
@@ -735,14 +745,8 @@ impl SessionManager {
text: String,
) -> Result<QueueAcpPrompt, SessionError> {
let (acp, stream) = {
let mut sessions = self.inner.sessions.lock().await;
let record = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
// While the adapter is booting, stash the prompt for the
// background handshake task to drain on success.
if record.is_starting_structured() {
record.pending_prompt = Some(text);
return Ok(QueueAcpPrompt::Pending);
}
let sessions = self.inner.sessions.lock().await;
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
if !record.is_live_structured() {
return Ok(QueueAcpPrompt::Unavailable);
}
@@ -805,10 +809,6 @@ impl SessionManager {
});
drop(stream);
sync_structured_activity(&self.inner, id).await;
#[cfg(feature = "haiku-autoname")]
if let Ok(true) = self.claim_autoname(id).await {
crate::autoname::spawn_naming(self.clone(), id, text);
}
return Ok(QueueAcpPrompt::Queued);
}
Err(message) => {
@@ -1217,42 +1217,17 @@ impl SessionManager {
}
/// Spawn a new session. On success returns the freshly-created `Session`.
///
/// For structured sessions, the ACP handshake runs in a background task
/// after the session is registered — the HTTP caller gets an instant
/// response with `activity: Starting`. The `StructuredLifecycle` gate is
/// held across the background task so `shutdown()` waits for it.
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
if req.view == SessionView::Structured {
// 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
let permit = self
.inner
.structured_lifecycle
.gate
.lock()
.expect("structured lifecycle gate");
if self.inner.shutting_down.load(Ordering::Acquire) {
return Err(SessionError::ShuttingDown);
.admit(&self.inner.shutting_down)?;
return AdmittedStructuredCreate {
create: Box::pin(self.create_inner(req)),
_permit: permit,
}
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)
}
};
.await;
}
self.create_inner(req).await
}
@@ -1314,39 +1289,32 @@ impl SessionManager {
{
session_env.insert("MAESTRO_URL".into(), url);
}
// ── Structured path: validate adapter, open event log, register
// the session immediately with `Starting`, then handshake in the
// background so the HTTP caller gets an instant response.
//
// ── Terminal path: unchanged — tmux spawn is fast enough to stay
// synchronous.
// Structured: validate adapter, open event log eagerly. Terminal:
// spawn the tmux pane synchronously (it is fast).
let structured_spawn = match req.view {
SessionView::Structured => {
// Fail fast on an unsupported command before touching disk.
let adapter = structured_adapter(&req.command).map_err(SessionError::BadRequest)?;
// Open the event log eagerly so the structured WS can
// connect before the adapter is up.
let acp_events = match self.inner.sidecar.open_events(id) {
Ok(log) => {
let (tx, _) = broadcast::channel(EVENTS_CAPACITY);
let (projected_tx, _) = broadcast::channel(EVENTS_CAPACITY);
Arc::new(Mutex::new(AcpEventStream {
log,
live: tx,
projection: Projection::default(),
projected_live: projected_tx,
pending_approvals: HashMap::new(),
operation: Arc::new(Mutex::new(())),
dispatch: AcpDispatchState::default(),
activity_sequence: 0,
}))
// A structured session (#134) gets no tmux pane: the agent is a
// child of the daemon, driven over ACP. An adapter that cannot come
// up still yields a session — Dead, with the reason on it — so the
// operator sees what to fix instead of a silently missing session.
let (acp, acp_failure) = match req.view {
SessionView::Structured => match structured_adapter(&req.command) {
Err(error) => (None, Some(error)),
Ok(adapter) => match maestro_acp::spawn(
SpawnConfig::new(
&effective_cwd,
session_env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
)
.adapter(adapter),
)
.await
{
Ok(acp) => (Some(Arc::new(acp)), None),
Err(e) => {
tracing::warn!(%id, error = %e, "structured session: adapter failed to start");
(None, Some(e.to_string()))
}
Err(e) => return Err(SessionError::Sidecar(e)),
};
Some((adapter, acp_events))
}
},
},
SessionView::Terminal => {
let spawned = {
let tmux = self.inner.tmux.clone();
@@ -1360,10 +1328,53 @@ impl SessionManager {
if let Err(e) = spawned {
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
// `session`. `history_cwd` is the *chosen* working dir (the request
// `cwd`), not a generated worktree checkout path — that's what the
@@ -1373,13 +1384,10 @@ impl SessionManager {
resolve_name(&mut metadata);
let worktree_branch = req.worktree.as_ref().map(|w| w.branch.clone());
let is_structured = structured_spawn.is_some();
let events_arc = structured_spawn.as_ref().map(|(_, e)| e.clone());
let session = Session {
id,
cwd: effective_cwd.clone(),
command: req.command.clone(),
cwd: effective_cwd,
command: req.command,
env: req.env,
metadata,
worktree: worktree_path.as_ref().zip(worktree_base_repo.as_ref()).map(
@@ -1388,41 +1396,67 @@ impl SessionManager {
base_repo: base_repo.clone(),
},
),
status: SessionStatus::Running,
created_at: Utc::now(),
foreground: None,
activity: if is_structured {
ActivityState::Starting
status: if acp_failure.is_some() {
SessionStatus::Dead
} else {
ActivityState::Active
SessionStatus::Running
},
activity_detail: None,
created_at: Utc::now(),
// A terminal spawn begins active until its watcher sees otherwise;
// a structured spawn has no turn yet and begins idle.
foreground: None,
activity: match (req.view, acp_failure.is_some()) {
(_, true) => ActivityState::Error,
(SessionView::Structured, false) => ActivityState::Idle,
(SessionView::Terminal, false) => ActivityState::Active,
},
activity_detail: acp_failure,
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
// The watcher fills this in on its first blocked-work pass; a
// fresh tree has nothing in the way anyway.
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
acp_session_id: acp.as_ref().map(|a| a.session_id().to_string()),
view: req.view,
};
if let Err(e) = self.inner.sidecar.write(&session) {
if !is_structured {
let tmux = self.inner.tmux.clone();
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
// The process already started; end it so no ghost survives a
// failed sidecar write, then unwind the FIFO + worktree.
match &acp {
Some(acp) => {
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await
}
None => {
let tmux = self.inner.tmux.clone();
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
}
}
return Err(SessionError::Sidecar(e));
}
let event_pump = acp
.as_ref()
.zip(acp_events.as_ref())
.map(|(acp, events)| (acp.clone(), events.clone()));
{
let mut sessions = self.inner.sessions.lock().await;
let event_pump = event_pump.map(|(acp, events)| {
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
start_acp_permission_pump(&self.inner, id, &acp, events, req.auto_approve);
done
});
sessions.insert(
id,
SessionRecord {
session: session.clone(),
fifo_path,
worktree_path: worktree_path.clone(),
worktree_base_repo: worktree_base_repo.clone(),
worktree_path,
worktree_base_repo,
bridge,
reported_activity: None,
settled: false,
@@ -1430,12 +1464,11 @@ impl SessionManager {
naming: false,
focused_since: None,
awaiting_suppressed: false,
acp: None,
acp_events: events_arc,
acp,
acp_events,
structured_activity_watermark: None,
acp_event_pump: None,
acp_event_pump: event_pump,
auto_approve: req.auto_approve,
pending_prompt: None,
},
);
}
@@ -1464,78 +1497,6 @@ impl SessionManager {
}
pending.commit();
// ── Background ACP handshake for structured sessions ──
//
// The session is registered and visible to clients. The adapter
// spawn + handshake runs in a background task; on completion it
// fills in `acp`, starts the event/permission pumps, and
// transitions activity from `Starting` to `Idle` (or to `Error`
// on failure). The `StructuredLifecycle` permit is held until the
// task finishes so `shutdown()` waits for it.
if let Some((adapter, events)) = structured_spawn {
let inner = self.inner.clone();
let env: HashMap<String, String> = session_env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let cwd = effective_cwd;
let auto_approve = req.auto_approve;
tokio::spawn(async move {
let result = maestro_acp::spawn(SpawnConfig::new(&cwd, env).adapter(adapter)).await;
match result {
Ok(acp_session) => {
let acp = Arc::new(acp_session);
let done = start_acp_event_pump(&inner, id, &acp, events.clone());
start_acp_permission_pump(&inner, id, &acp, events.clone(), auto_approve);
// Drain any prompt submitted while Starting.
let pending_prompt = {
let mut sessions = inner.sessions.lock().await;
let Some(record) = sessions.get_mut(&id) else {
// Session was killed while we were booting.
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
drop_structured_lifecycle_permit(&inner);
return;
};
record.acp = Some(acp.clone());
record.acp_event_pump = Some(done);
record.session.acp_session_id = Some(acp.session_id().to_string());
record.session.activity = ActivityState::Idle;
record.session.activity_since = Utc::now();
let _ = inner.sidecar.write(&record.session);
let _ = inner.events.send(CatalogEvent::SessionUpdated {
session: record.session.clone(),
});
record.pending_prompt.take()
};
if let Some(prompt) = pending_prompt {
let manager = SessionManager {
inner: inner.clone(),
};
let _ = manager.queue_acp_prompt(id, prompt).await;
}
}
Err(e) => {
tracing::warn!(%id, error = %e, "structured session: adapter failed to start");
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.session.status = SessionStatus::Dead;
record.session.activity = ActivityState::Error;
record.session.activity_detail = Some(e.to_string());
record.session.activity_since = Utc::now();
let _ = inner.sidecar.write(&record.session);
let _ = inner.events.send(CatalogEvent::SessionUpdated {
session: record.session.clone(),
});
}
}
}
drop_structured_lifecycle_permit(&inner);
});
}
Ok(session)
}
@@ -1721,7 +1682,6 @@ impl SessionManager {
structured_activity_watermark: None,
acp_event_pump: None,
auto_approve: false,
pending_prompt: None,
},
);
let _ = self
@@ -1902,7 +1862,6 @@ impl SessionManager {
// Recovery cannot know the original preset's flag; the
// operator re-enables it by restarting with the preset.
auto_approve: false,
pending_prompt: None,
},
);
if let Some((acp, events)) = event_pump {
@@ -2362,11 +2321,6 @@ async fn apply_structured_activity(
if record.session.view != SessionView::Structured {
return;
}
// While the adapter is still booting, the background handshake task
// owns the activity state — the activity pass must not overwrite it.
if record.session.activity == ActivityState::Starting {
return;
}
let current_events = record.acp_events.as_ref();
match (expected_events, current_events) {
(Some((expected, sequence)), Some(current)) if Arc::ptr_eq(expected, current) => {
@@ -2632,12 +2586,6 @@ async fn run_liveness_pass(inner: &Arc<Inner>) {
let mut tmux_backed = Vec::new();
let mut acp_backed = Vec::new();
for (id, record) in running {
if record.is_starting_structured() {
// The adapter hasn't finished its handshake yet — no ACP
// handle and no tmux pane. Skip; the background task owns
// this session's lifecycle until the handshake settles.
continue;
}
match &record.acp {
Some(acp) => acp_backed.push((*id, acp.clone())),
None => tmux_backed.push(*id),
+81 -135
View File
@@ -130,25 +130,6 @@ async fn wait_for_exit(pid: u32) {
assert!(!pid_alive(pid), "pid {pid} still alive");
}
/// Wait for a session's background ACP handshake to complete (activity
/// transitions out of `Starting`). Returns the refreshed session.
async fn wait_for_ready(manager: &SessionManager, id: uuid::Uuid) -> maestro_protocol::Session {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let session = manager
.snapshot()
.await
.into_iter()
.find(|s| s.id == id)
.expect("session exists");
if session.activity != ActivityState::Starting {
return session;
}
assert!(Instant::now() < deadline, "handshake timed out");
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
#[tokio::test]
async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
let fx = DaemonFixture::new();
@@ -157,7 +138,7 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
let mut env = fake.env();
env.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
let created = fx
let session = fx
.manager
.create(structured_command(
cwd.path(),
@@ -167,12 +148,8 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
.await
.expect("create");
assert_eq!(created.view, SessionView::Structured);
assert_eq!(created.status, SessionStatus::Running);
assert_eq!(created.activity, ActivityState::Starting);
assert!(created.acp_session_id.is_none());
let session = wait_for_ready(&fx.manager, created.id).await;
assert_eq!(session.view, SessionView::Structured);
assert_eq!(session.status, SessionStatus::Running);
assert_eq!(session.activity, ActivityState::Idle);
let acp_id = session.acp_session_id.clone().expect("acp session id");
assert!(acp_id.starts_with("fake-"), "{acp_id}");
@@ -187,7 +164,7 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
"{log}"
);
assert!(
log.contains(&format!("env MAESTRO_SESSION_ID={}", created.id)),
log.contains(&format!("env MAESTRO_SESSION_ID={}", session.id)),
"{log}"
);
@@ -255,7 +232,6 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
.create(structured_command(cwd.path(), env, "codex"))
.await
.unwrap();
let session = wait_for_ready(&fx.manager, session.id).await;
assert!(matches!(
fx.manager
@@ -319,12 +295,11 @@ async fn projection_snapshot_and_live_stream_share_one_fold() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let created = fx
let session = fx
.manager
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
let session = wait_for_ready(&fx.manager, created.id).await;
wait_for_async(Duration::from_secs(5), || async {
fx.manager
@@ -422,7 +397,6 @@ async fn queued_prompt_is_a_durable_user_transcript_row() {
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
let session = wait_for_ready(&fx.manager, session.id).await;
assert!(matches!(
fx.manager
@@ -468,7 +442,6 @@ async fn stale_turn_completion_cannot_end_the_next_turn() {
.create(structured(cwd.path(), fake.env()))
.await
.unwrap();
wait_for_ready(&fx.manager, session.id).await;
let maestro_daemon::session::BeginAcpTurn::Started(_, first) =
fx.manager.begin_acp_turn(session.id).await.unwrap()
@@ -512,7 +485,6 @@ async fn cancelled_prompt_that_never_settles_fails_the_session() {
.create(structured(cwd.path(), env))
.await
.unwrap();
wait_for_ready(&fx.manager, session.id).await;
assert!(matches!(
fx.manager
@@ -551,7 +523,6 @@ async fn cancellation_settlement_allows_the_next_prompt_permission() {
.create(structured(cwd.path(), env))
.await
.unwrap();
wait_for_ready(&fx.manager, session.id).await;
assert!(matches!(
fx.manager
@@ -611,12 +582,11 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
let mut env = fake.env();
env.insert("FAKE_ACP_LOAD_REPLAY".into(), "2".into());
let created = fx
let session = fx
.manager
.create(structured_command(cwd.path(), env, "codex"))
.await
.expect("create");
let session = wait_for_ready(&fx.manager, created.id).await;
let acp_id = session.acp_session_id.clone().expect("acp id");
wait_for_async(Duration::from_secs(5), || async {
fx.manager
@@ -741,7 +711,6 @@ async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
.await
.expect("create");
assert_eq!(session.cwd, worktree);
wait_for_ready(&fx.manager, session.id).await;
push_all_to_origin(repo.path(), origin.path());
fx.manager.shutdown().await;
@@ -790,7 +759,6 @@ async fn failed_recovery_cleans_its_fifo() {
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
let old_pid = fake.pid();
fx.manager.shutdown().await;
wait_for_exit(old_pid).await;
@@ -824,15 +792,12 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
empty.path().to_string_lossy().into_owned(),
)]);
let created = fx
let session = fx
.manager
.create(structured_command(cwd.path(), env, "codex"))
.await
.expect("create registers a Starting session");
assert_eq!(created.activity, ActivityState::Starting);
.expect("a failed session, not an error");
// The background handshake fails because the adapter binary is missing.
let session = wait_for_ready(&fx.manager, created.id).await;
assert_eq!(session.status, SessionStatus::Dead);
assert_eq!(session.activity, ActivityState::Error);
let detail = session.activity_detail.clone().expect("actionable detail");
@@ -845,13 +810,13 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
// It is a real session: listed, on disk, and dismissable like any other
// dead one.
assert!(fx.manager.has(created.id).await);
assert!(fx.manager.has(session.id).await);
assert!(fx
.sidecar_dir()
.join(format!("{}.json", created.id))
.join(format!("{}.json", session.id))
.exists());
fx.manager.kill(created.id, false).await.expect("dismiss");
assert!(!fx.manager.has(created.id).await);
fx.manager.kill(session.id, false).await.expect("dismiss");
assert!(!fx.manager.has(session.id).await);
}
#[tokio::test]
@@ -860,20 +825,20 @@ async fn unsupported_structured_command_fails_without_launching_claude() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let err = fx
let session = fx
.manager
.create(structured_command(cwd.path(), fake.env(), "opencode"))
.await
.expect_err("unsupported command is rejected at request validation");
.expect("failed structured session is persisted");
assert!(
matches!(err, maestro_daemon::SessionError::BadRequest(ref msg) if msg.contains("Claude Code or Codex")),
"{err:?}"
);
assert_eq!(session.status, SessionStatus::Dead);
let detail = session.activity_detail.expect("selection error");
assert!(detail.contains("Claude Code or Codex"), "{detail}");
assert!(
fake.log().is_empty(),
"unsupported command launched an adapter"
);
fx.manager.kill(session.id, false).await.expect("dismiss");
}
#[tokio::test]
@@ -888,7 +853,6 @@ async fn kill_sends_cancel_then_sigterm_and_removes_the_sidecar() {
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
let pid = fake.pid();
assert!(pid_alive(pid));
@@ -931,8 +895,6 @@ async fn shutdown_reaps_every_adapter_child() {
.create(structured(cwd.path(), b.env()))
.await
.expect("create b");
wait_for_ready(&fx.manager, sa.id).await;
wait_for_ready(&fx.manager, sb.id).await;
let (pa, pb) = (a.pid(), b.pid());
assert!(pid_alive(pa) && pid_alive(pb));
@@ -967,13 +929,10 @@ async fn dismissal_during_shutdown_does_not_restore_recovery_artifacts() {
.create(structured(cwd.path(), env))
.await
.expect("create");
let blocker_session = fx
.manager
fx.manager
.create(structured(cwd.path(), blocker.env()))
.await
.expect("create blocker");
wait_for_ready(&fx.manager, session.id).await;
wait_for_ready(&fx.manager, blocker_session.id).await;
let manager = fx.manager.clone();
let shutdown = tokio::spawn(async move { manager.shutdown().await });
@@ -1025,7 +984,6 @@ async fn shutdown_does_not_recover_an_adapter_that_already_exited() {
.create(structured(cwd.path(), env))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
wait_for_exit(fake.pid()).await;
fx.manager.shutdown().await;
@@ -1059,31 +1017,23 @@ async fn shutdown_waits_for_structured_creation_and_rejects_new_creates() {
let mut env = fake.env();
env.insert("FAKE_ACP_DELAY_SESSION_NEW_MS".into(), "500".into());
// create() returns immediately; the handshake runs in the background
// and holds a lifecycle permit.
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.expect("create");
// Wait until the adapter has received session/new (handshake in progress).
let create_cwd = cwd.path().to_path_buf();
let manager = fx.manager.clone();
let create = tokio::spawn(async move { manager.create(structured(&create_cwd, env)).await });
wait_for_async(Duration::from_secs(5), || async {
fake.log().contains("recv session/new")
})
.await;
// Shutdown must wait for the in-flight background handshake.
let manager = fx.manager.clone();
let shutdown = tokio::spawn(async move { manager.shutdown().await });
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
!shutdown.is_finished(),
"shutdown returned before handshake finished"
"shutdown returned before create finished"
);
// The handshake completes in the background (500ms delay).
wait_for_ready(&fx.manager, session.id).await;
let session = create.await.expect("create task").expect("create");
let pid = fake.pid();
shutdown.await.expect("shutdown task");
@@ -1116,37 +1066,27 @@ async fn structured_handshakes_run_independently() {
gate.to_string_lossy().into_owned(),
);
// Both create() calls return immediately with Starting; the first
// adapter's handshake is gated while the second runs freely.
let first = fx
.manager
.create(structured(cwd.path(), blocked_env))
.await
.expect("first create");
let second = fx
.manager
.create(structured(cwd.path(), ready.env()))
.await
.expect("second create");
let first_cwd = cwd.path().to_path_buf();
let manager = fx.manager.clone();
let first =
tokio::spawn(async move { manager.create(structured(&first_cwd, blocked_env)).await });
wait_for_async(Duration::from_secs(5), || async {
blocked.log().contains("recv session/new")
})
.await;
// The second handshake completes independently of the first.
let second_ready = wait_for_ready(&fx.manager, second.id).await;
assert_eq!(second_ready.activity, ActivityState::Idle);
// The first handshake is still blocked.
let first_snap = fx
.manager
.snapshot()
.await
.into_iter()
.find(|s| s.id == first.id)
.unwrap();
assert_eq!(first_snap.activity, ActivityState::Starting);
let second = tokio::time::timeout(
Duration::from_secs(5),
fx.manager.create(structured(cwd.path(), ready.env())),
)
.await
.expect("second handshake was blocked by the first")
.expect("second create");
assert_eq!(second.status, SessionStatus::Running);
assert!(!first.is_finished(), "the gate must still block the first");
std::fs::write(&gate, b"release").unwrap();
let first_ready = wait_for_ready(&fx.manager, first.id).await;
assert_eq!(first_ready.activity, ActivityState::Idle);
let first = first.await.expect("first task").expect("first create");
fx.manager.kill(first.id, false).await.expect("kill first");
fx.manager
.kill(second.id, false)
@@ -1174,18 +1114,16 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
gate_b.to_string_lossy().into_owned(),
);
// Both create() calls return immediately; the background handshake
// tasks hold lifecycle permits and are blocked on their gates.
let session_a = fx
.manager
.create(structured(cwd.path(), env_a))
.await
.expect("create a");
let session_b = fx
.manager
.create(structured(cwd.path(), env_b))
.await
.expect("create b");
let create_a = {
let manager = fx.manager.clone();
let cwd = cwd.path().to_path_buf();
tokio::spawn(async move { manager.create(structured(&cwd, env_a)).await })
};
let create_b = {
let manager = fx.manager.clone();
let cwd = cwd.path().to_path_buf();
tokio::spawn(async move { manager.create(structured(&cwd, env_b)).await })
};
wait_for_async(Duration::from_secs(5), || async {
a.log().contains("recv session/new") && b.log().contains("recv session/new")
})
@@ -1205,14 +1143,14 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
assert!(!shutdown.is_finished());
std::fs::write(&gate_a, b"release").unwrap();
wait_for_ready(&fx.manager, session_a.id).await;
let session_a = create_a.await.expect("create a task").expect("create a");
assert!(
!shutdown.is_finished(),
"shutdown ignored the second create"
);
std::fs::write(&gate_b, b"release").unwrap();
wait_for_ready(&fx.manager, session_b.id).await;
let session_b = create_b.await.expect("create b task").expect("create b");
shutdown.await.expect("shutdown task");
wait_for_exit(a.pid()).await;
@@ -1228,7 +1166,7 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
}
#[tokio::test]
async fn killing_a_starting_session_rolls_back_and_settles_admission() {
async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let repo = TempDir::new().unwrap();
@@ -1254,30 +1192,40 @@ async fn killing_a_starting_session_rolls_back_and_settles_admission() {
auto_approve: false,
};
// create() returns immediately with Starting; the handshake is gated.
let session = fx.manager.create(request).await.expect("create");
assert_eq!(session.activity, ActivityState::Starting);
let create = {
let manager = fx.manager.clone();
tokio::spawn(async move { manager.create(request).await })
};
wait_for_async(Duration::from_secs(5), || async {
fake.log().contains("recv session/new")
})
.await;
let id = fake
.log()
.lines()
.find_map(|line| line.strip_prefix("env MAESTRO_SESSION_ID="))
.and_then(|id| uuid::Uuid::parse_str(id).ok())
.expect("session id in adapter log");
let pid = fake.pid();
// Kill the session while its handshake is still in progress.
// force=true because the worktree has an unpushed branch commit.
fx.manager.kill(session.id, true).await.expect("kill");
// Release the gate so the background task can finish and settle.
std::fs::write(&gate, b"release").unwrap();
// Shutdown must not hang — the lifecycle permit is settled.
tokio::time::timeout(Duration::from_secs(5), fx.manager.shutdown())
create.abort();
let shutdown = {
let manager = fx.manager.clone();
tokio::spawn(async move { manager.shutdown().await })
};
assert!(create.await.unwrap_err().is_cancelled());
tokio::time::timeout(Duration::from_secs(5), shutdown)
.await
.expect("shutdown hung on a settled permit");
.expect("cancelled admission kept shutdown waiting")
.expect("shutdown task");
wait_for_exit(pid).await;
wait_for_async(Duration::from_secs(5), || async {
!fx.fifo_dir().join(format!("{id}.fifo")).exists()
&& !repo.path().join("cancelled-create").exists()
})
.await;
assert!(fx.manager.snapshot().await.is_empty());
let id = session.id.to_string();
let id = id.to_string();
assert!(
std::fs::read_dir(fx.sidecar_dir())
.into_iter()
@@ -1287,7 +1235,7 @@ async fn killing_a_starting_session_rolls_back_and_settles_admission() {
.file_name()
.to_string_lossy()
.starts_with(&id)),
"killed Starting session left a sidecar artifact"
"cancelled create left a sidecar artifact"
);
}
@@ -1304,7 +1252,6 @@ async fn shutdown_drains_final_notifications_to_the_log() {
.create(structured(cwd.path(), env))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
@@ -1335,7 +1282,6 @@ async fn adapter_exit_marks_the_session_dead_and_keeps_it_listed() {
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
fx.manager.start_liveness_watcher(Duration::from_millis(50));
// The agent dies out from under the daemon: the liveness pass, not tmux,
+4 -24
View File
@@ -154,8 +154,7 @@ async fn structured_session(h: &Harness, fake: &FakeAcp) -> Uuid {
}
async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>) -> Uuid {
let id = h
.manager
h.manager
.create(CreateSessionRequest {
view: SessionView::Structured,
cwd: "/tmp".into(),
@@ -167,9 +166,7 @@ async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>)
})
.await
.unwrap()
.id;
wait_for_session_activity(h, id, ActivityState::Idle).await;
id
.id
}
async fn wait_for_session_activity(h: &Harness, id: Uuid, expected: ActivityState) {
@@ -1003,9 +1000,7 @@ async fn names_honors_count_and_caps_it() {
#[tokio::test]
async fn gitroot_probes_path() {
// #26: resolves the query against the daemon home dir and walks up for a
// `.git`. The tempdir itself has no `.git`, so the result must either be
// null (no repo anywhere above) or — in sandboxed environments where /tmp
// itself sits inside a repo — a path strictly above the tempdir.
// `.git`. A `/tmp`-rooted temp dir is not in a repo, so the root is null.
let h = spawn("naps-laptop").await;
let tmp = tempfile::tempdir().unwrap();
let resp: serde_json::Value = reqwest::get(url(
@@ -1020,16 +1015,7 @@ async fn gitroot_probes_path() {
.json()
.await
.unwrap();
// On hosts where an ancestor of the temp dir is a repo (e.g. `/tmp`
// inside a checkout), the root legitimately points there. The test only
// asserts no root was found *inside* the temp dir.
match resp.get("root").and_then(|v| v.as_str()) {
None => {}
Some(root) => assert!(
!root.starts_with(tmp.path().to_string_lossy().as_ref()),
"found a repo root inside the fixture at {root:?}"
),
}
assert!(resp.get("root").is_none() || resp["root"].is_null());
}
#[tokio::test]
@@ -1508,12 +1494,6 @@ async fn events_ws_streams_structured_activity_without_hook_overrides() {
let maestro_protocol::CatalogEvent::SessionAdded { session } = next_event(&mut ws).await else {
panic!("expected structured SessionAdded")
};
assert_eq!(session.activity, ActivityState::Starting);
// The background handshake completes and sends SessionUpdated → Idle.
let maestro_protocol::CatalogEvent::SessionUpdated { session } = next_event(&mut ws).await
else {
panic!("expected SessionUpdated after handshake")
};
assert_eq!(session.activity, ActivityState::Idle);
let client = reqwest::Client::new();
+1 -7
View File
@@ -175,10 +175,6 @@ pub enum ActivityState {
AwaitingInput,
/// An agent reported a failure, or a structured session/turn failed.
Error,
/// The ACP adapter is booting; the session is registered but cannot accept
/// prompts yet. Older clients that do not know this variant degrade it to
/// `Idle` via the lenient deserializer — acceptable.
Starting,
}
impl<'de> Deserialize<'de> for ActivityState {
@@ -191,7 +187,6 @@ impl<'de> Deserialize<'de> for ActivityState {
"Active" => Self::Active,
"AwaitingInput" => Self::AwaitingInput,
"Error" => Self::Error,
"Starting" => Self::Starting,
// Known or not, any other spelling degrades to the neutral default
_ => Self::Idle,
})
@@ -230,10 +225,9 @@ where
"Idle" => Ok(ActivityState::Idle),
"AwaitingInput" => Ok(ActivityState::AwaitingInput),
"Error" => Ok(ActivityState::Error),
"Starting" => Ok(ActivityState::Starting),
other => Err(D::Error::unknown_variant(
other,
&["Active", "Idle", "AwaitingInput", "Error", "Starting"],
&["Active", "Idle", "AwaitingInput", "Error"],
)),
}
}
+1 -35
View File
@@ -22,8 +22,7 @@ use chrono::Utc;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, StructuredPromptRequest,
PROTOCOL_VERSION,
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION,
};
use tokio::net::TcpListener;
use tokio::sync::{broadcast, oneshot};
@@ -61,8 +60,6 @@ struct Inner {
forced_error: Option<(u16, ApiError)>,
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
inputs: Vec<(Uuid, SendInputRequest)>,
/// Every ACP prompt sent to a structured session, in order.
structured_prompts: Vec<(Uuid, StructuredPromptRequest)>,
}
#[derive(Clone)]
@@ -95,7 +92,6 @@ impl MockDaemon {
.route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION, delete(kill_session))
.route(routes::patterns::SESSION_INPUT, post(send_input))
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
.route(routes::INFO, get(daemon_info))
.route(routes::DIRS, get(list_dir))
.route(routes::RECENT_DIRS, get(recent_dirs))
@@ -182,16 +178,6 @@ impl MockDaemon {
self.state.inner.lock().expect("poisoned").inputs.clone()
}
/// Every ACP prompt this daemon received, oldest first.
pub fn structured_prompts(&self) -> Vec<(Uuid, StructuredPromptRequest)> {
self.state
.inner
.lock()
.expect("poisoned")
.structured_prompts
.clone()
}
pub fn session_count(&self) -> usize {
self.state.inner.lock().expect("poisoned").sessions.len()
}
@@ -280,26 +266,6 @@ async fn send_input(
StatusCode::NO_CONTENT.into_response()
}
async fn structured_prompt(
State(s): State<AppState>,
Path(id): Path<Uuid>,
Json(req): Json<StructuredPromptRequest>,
) -> Response {
if let Some(r) = forced(&s) {
return r;
}
let mut guard = s.inner.lock().expect("poisoned");
if !guard.sessions.iter().any(|x| x.id == id) {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("not_found", "no such session on this daemon")),
)
.into_response();
}
guard.structured_prompts.push((id, req));
StatusCode::NO_CONTENT.into_response()
}
async fn daemon_info(State(s): State<AppState>) -> Response {
if let Some(r) = forced(&s) {
return r;
+2 -3
View File
@@ -82,7 +82,7 @@ impl Surface {
pub fn activity(&self, state: ActivityState) -> Color {
match state {
ActivityState::Active => self.active,
ActivityState::Idle | ActivityState::Starting => self.idle,
ActivityState::Idle => self.idle,
ActivityState::AwaitingInput => self.awaiting,
ActivityState::Error => self.error,
}
@@ -928,10 +928,9 @@ pub fn contrast_ratio(a: Color, b: Color) -> Option<f64> {
mod tests {
use super::*;
const STATES: [ActivityState; 5] = [
const STATES: [ActivityState; 4] = [
ActivityState::Active,
ActivityState::Idle,
ActivityState::Starting,
ActivityState::AwaitingInput,
ActivityState::Error,
];
-2
View File
@@ -979,7 +979,6 @@ mod tests {
for state in [
ActivityState::Active,
ActivityState::Idle,
ActivityState::Starting,
ActivityState::AwaitingInput,
ActivityState::Error,
] {
@@ -1010,7 +1009,6 @@ mod tests {
for state in [
ActivityState::Active,
ActivityState::Idle,
ActivityState::Starting,
ActivityState::AwaitingInput,
ActivityState::Error,
] {
-1
View File
@@ -160,7 +160,6 @@ The state word is never spelled out — the colour carries it.
| ---------------- | --------- | ---------------- |
| `Active` | `--live` | `active` |
| `Idle` | `--muted` | `idle` |
| `Starting` | `--muted` | `idle` |
| `AwaitingInput` | `--warn` | `awaiting` |
| `Error` | `--danger`| `error` |
+5 -16
View File
@@ -190,16 +190,9 @@ choices, not daemon ones.
from the transcript; the TUI may show the current plan in its status line,
while the web client deliberately has no stats or plan rail.
- The web client groups two or more tool rows after the same `User` row into
one collapsed turn activity block. `AgentText` and `AgentThought` rows that
appear *between* tool rows in the same turn are absorbed into the group
(intermediate narration); the last `AgentText` with no subsequent tool stays
outside as the prominent final answer. While a turn is still active
(responding or thinking), the most recent intermediate `AgentText` or
`AgentThought` in the group is kept outside as visible narration — replaced
by the next text row when one arrives, and absorbed into the group when the
turn ends. The header still reads "N tool calls" (counting only tool rows).
Opening the group reveals tools and intermediate text in chronological
order, with the text rendered in a subdued style.
one collapsed turn activity block. Opening it reveals independently
expandable tool calls, including any raw command, input, output, and diff
content the adapter supplied.
- The web client's user, agent, thought, tool-detail, and notice text blocks
copy their complete source text when clicked or keyboard-activated.
- The web client consumes `available_commands_update` notices as the live
@@ -353,15 +346,11 @@ thing a callback can be attributed to, so at most one turn may ever be
in flight, and a cancelled turn must fully settle before the next prompt is
allowed to claim the daemon's idea of "the active turn."
- Web clients may collect a visible FIFO of operator messages while a turn is
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
- A prompt is appended as a durable `User` row before it claims the turn and
dispatches to ACP, so the transcript never depends on an adapter echoing the
operator's input. If that append fails, the prompt is not dispatched. While a
turn is active, a second prompt gets `409 turn_in_flight` rather than
queuing in the daemon — one active turn per session, never a hidden
server-side queue of prompts.
queuing — one active turn per session, never a hidden queue of prompts.
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
and ends the turn's client-visible lifecycle immediately. If the cancelled
turn was still *dispatched* to ACP (waiting on its response), the daemon
-1
View File
@@ -24,7 +24,6 @@
</head>
<body>
<div id="app"></div>
<div id="root"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2 -12
View File
@@ -17,22 +17,12 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^5.5.0",
"marked": "^18.0.11",
"react": "^19.2.8",
"react-dom": "^19.2.8"
"marked": "^18.0.11"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^4.7.0",
"happy-dom": "^20.11.6",
"tailwindcss": "^4.3.3",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vitest": "^2.1.9",
"zustand": "^5.0.15"
"vitest": "^2.1.9"
}
}
+11 -984
View File
File diff suppressed because it is too large Load Diff
-9
View File
@@ -1,9 +0,0 @@
import { ThemeProvider } from "./contexts/ThemeContext";
export function App() {
return (
<ThemeProvider>
<div id="react-root" />
</ThemeProvider>
);
}
+3 -4
View File
@@ -663,9 +663,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
applyNav();
};
// Pointer hover previews only while Ctrl is held — plain hover was too
// disruptive when sweeping the sidebar. Keyboard walk (j/k) still previews
// unconditionally via applyNav.
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
// out: there is no hover there, and a tap already opens the session.
let hovered: string | null = null;
const setHovered = (id: string | null) => {
if (hovered === id) return;
@@ -673,7 +672,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
opts.onPreview?.(id);
};
treeHost.addEventListener("pointerover", (e) => {
if (e.pointerType === "touch" || !e.ctrlKey) return;
if (e.pointerType === "touch") return;
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
setHovered(row?.dataset.sessionId ?? null);
});
@@ -1,47 +0,0 @@
// docs/SIDEBAR.md: a 5px bar pinned to the row's own left edge, square
// corners, full card height. It paints for every card, Idle included, and it
// carries the accessible name of the state so status is never colour-only.
import { activityColorSlug } from "../../activity.js";
import type { ActivityState } from "../../types.js";
export const STATE_WORDS: Record<string, string> = {
active: "Active",
idle: "Idle",
"awaiting-input": "Awaiting input",
error: "Error",
};
/** Rail fill per state, and the motion the state earns. */
const RAIL_CLASS: Record<string, string> = {
active: "bg-live",
idle: "bg-border-strong",
"awaiting-input": "bg-warn rail-blink",
error: "bg-danger rail-flash",
};
/** Duration text inherits the rail's colour (docs/SIDEBAR.md). */
export const DURATION_CLASS: Record<string, string> = {
active: "text-live",
idle: "text-muted",
"awaiting-input": "text-warn",
error: "text-danger",
};
export function stateWordFor(activity: ActivityState | string | undefined): string {
return STATE_WORDS[activityColorSlug(activity ?? "Idle")] ?? STATE_WORDS.idle;
}
export function ActivityRail({ activity }: { activity: ActivityState | string | undefined }) {
const slug = activityColorSlug(activity ?? "Idle");
const word = STATE_WORDS[slug] ?? STATE_WORDS.idle;
return (
<span
className={`absolute left-0 top-0 bottom-0 w-[5px] ${RAIL_CLASS[slug] ?? RAIL_CLASS.idle}`}
data-activity={slug}
role="img"
title={word}
aria-label={word}
/>
);
}
@@ -1,64 +0,0 @@
// Right-click menu on a session card (docs/SIDEBAR.md): rename, spawn similar,
// kill. Rendered into <body> so a scrolling sidebar cannot clip it.
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
export interface ContextMenuItem {
label: string;
danger?: boolean;
onSelect(): void;
}
export interface ContextMenuProps {
x: number;
y: number;
items: ContextMenuItem[];
onClose(): void;
}
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
const onDown = (e: Event) => {
if (!ref.current?.contains(e.target as Node)) onClose();
};
document.addEventListener("keydown", onKey);
document.addEventListener("pointerdown", onDown, true);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onDown, true);
};
}, [onClose]);
return createPortal(
<div
ref={ref}
role="menu"
className="context-menu fixed z-modal min-w-[160px] rounded-r-2 border border-border-strong bg-surface py-sp-1 shadow-2"
style={{ left: x, top: y }}
>
{items.map((item) => (
<button
key={item.label}
type="button"
role="menuitem"
className={`block w-full px-sp-3 py-sp-1 text-left text-fs-12 hover:bg-surface-2 ${
item.danger ? "text-danger" : "text-ink"
}`}
onClick={() => {
onClose();
item.onSelect();
}}
>
{item.label}
</button>
))}
</div>,
document.body,
);
}
-51
View File
@@ -1,51 +0,0 @@
// A collapsible directory in the sidebar tree. Only a *collapsed* header is a
// keyboard-walk stop (docs/SIDEBAR.md); an expanded one is decoration in front
// of the cards it holds, so it carries no highlight of its own.
import type { ReactNode } from "react";
import { splitFolderLabel } from "./model.js";
export interface FolderNodeProps {
name: string;
path: string;
depth: number;
collapsed: boolean;
highlighted: boolean;
onToggle(): void;
children: ReactNode;
}
export function FolderNode(props: FolderNodeProps) {
const { stem, last } = splitFolderLabel(props.name);
return (
<div className="tree-folder">
<button
type="button"
className={`tree-folder-header flex w-full items-center gap-sp-1 py-[2px] text-fs-11 text-muted ${
props.highlighted ? "ring-1 ring-accent-ring" : ""
}`}
style={{ paddingLeft: props.depth * 16 }}
aria-expanded={!props.collapsed}
data-nav-key={`folder:${props.path}`}
data-highlighted={props.highlighted ? "true" : undefined}
title={props.name}
onClick={props.onToggle}
>
{/* One glyph, rotated when collapsed, so the mark and the rotation
never disagree about which way the folder points. */}
<span
aria-hidden="true"
className={`inline-block transition-transform ${props.collapsed ? "-rotate-90" : ""}`}
>
▾
</span>
<span className="truncate">
{stem && <span className="text-faint">{stem}/</span>}
{last}
</span>
</button>
{!props.collapsed && props.children}
</div>
);
}
@@ -1,63 +0,0 @@
// docs/SIDEBAR.md "Killing a session": every kill confirms first, and the
// confirmation names what the card last saw in the way — the daemon refuses an
// unforced kill over such a tree, and saying so beforehand is cheaper than the
// refusal. Cancel holds the focus so a stray Enter kills nothing.
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import type { InTheWay } from "../../types.js";
import { formatInTheWay } from "../../types.js";
export interface KillConfirmProps {
name: string;
blockers: InTheWay | null;
/** Set when the daemon already refused an unforced kill: this pass forces. */
force?: boolean;
onConfirm(): void;
onCancel(): void;
}
export function KillConfirm(props: KillConfirmProps) {
const cancel = useRef<HTMLButtonElement>(null);
useEffect(() => cancel.current?.focus(), []);
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={props.force ? "Force kill session" : "Kill session"}
className="kill-confirm fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
onKeyDown={(e) => {
if (e.key === "Escape") props.onCancel();
}}
>
<div className="min-w-[280px] rounded-r-2 border border-border-strong bg-surface p-sp-4 shadow-2">
<p className="text-fs-13 text-ink">
{props.force ? "Force kill" : "Kill"} <strong>{props.name}</strong>?
</p>
{props.blockers && (
<p className="mt-sp-2 text-fs-12 text-warn">{formatInTheWay(props.blockers)}</p>
)}
<div className="mt-sp-3 flex justify-end gap-sp-2">
<button
ref={cancel}
type="button"
className="rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
onClick={props.onCancel}
>
Cancel
</button>
<button
type="button"
className="rounded-r-1 bg-danger px-sp-3 py-sp-1 text-fs-12 text-accent-ink"
onClick={props.onConfirm}
>
{props.force ? "Force kill" : "Kill"}
</button>
</div>
</div>
</div>,
document.body,
);
}
-264
View File
@@ -1,264 +0,0 @@
// docs/SIDEBAR.md: a session card is always two lines — the name, then the
// adaptive meta line — with the activity rail in the gutter beside them.
import { useEffect, useRef, type MouseEvent } from "react";
import {
activityDurationLabel,
activityHeldMs,
META_LINE_MIN_HOLD_MS,
} from "../../activity.js";
import { splitCommand, toolIconSvg } from "../../toolIcon.js";
import type { InTheWay, Session } from "../../types.js";
import {
blockerStats,
customSessionName,
formatInTheWay,
isProvisionalName,
MAX_SESSION_NAME_LEN,
normalizeSessionName,
sessionBlockers,
sessionName,
} from "../../types.js";
import { ActivityRail, DURATION_CLASS, stateWordFor } from "./ActivityRail.js";
import { activityColorSlug } from "../../activity.js";
export interface SessionCardProps {
session: Session;
/** Nesting depth; the row's padding (and so the rail) shifts outward with it. */
depth: number;
/** Clock the duration is measured against — the sidebar ticks it. */
now: number;
highlighted: boolean;
/** Showing in the focused pane. */
active: boolean;
/** Open in some pane, not the focused one. */
open: boolean;
previewing: boolean;
renaming: boolean;
onSelect(): void;
onOpenMenu(x: number, y: number): void;
onSpawnSimilar(): void;
onKill(): void;
onRenameCommit(name: string): void;
onRenameCancel(): void;
}
/** The fill ladder from docs/SIDEBAR.md "Which row is filled", furthest down
* wins. Written as one ordered run so the precedence is the order it reads in. */
function rowFill(p: SessionCardProps): string {
if (p.active) return "bg-accent-fill";
if (p.previewing) return "bg-preview-tint";
if (p.open) return "bg-accent/[0.06]";
if (p.highlighted) return "bg-preview-tint";
return "";
}
export function SessionCard(props: SessionCardProps) {
const { session: s, depth, now, renaming } = props;
const name = sessionName(s);
const running = s.status === "Running";
const word = stateWordFor(s.activity);
return (
<a
className={`session-card tree-leaf relative flex flex-col gap-[1px] py-sp-1 pr-sp-2 no-underline ${rowFill(props)} ${
running ? "" : "opacity-60"
}`}
href={`#/attach/${encodeURIComponent(s.id)}`}
style={{ paddingLeft: depth * 16 + 8 }}
data-session-id={s.id}
data-nav-key={`session:${s.id}`}
data-status={running ? "running" : "dead"}
data-highlighted={props.highlighted ? "true" : undefined}
aria-label={`${name}, ${running ? "running" : "exited"}, ${word}`}
onClick={(e) => {
if (renaming) return;
e.preventDefault();
props.onSelect();
}}
onContextMenu={(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
props.onOpenMenu(e.clientX, e.clientY);
}}
>
<ActivityRail activity={s.activity} />
<span className="flex min-w-0 items-center gap-sp-2">
{renaming ? (
<RenameField
session={s}
onCommit={props.onRenameCommit}
onCancel={props.onRenameCancel}
/>
) : (
<span
className={`min-w-0 flex-1 truncate text-fs-13 ${
isProvisionalName(s) ? "text-muted" : "text-ink"
}`}
>
{name}
</span>
)}
<span className="flex flex-none items-center gap-[2px]">
<button
type="button"
className="leaf-clone text-faint hover:text-ink"
title="Spawn similar"
aria-label={`Spawn similar to ${name}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
props.onSpawnSimilar();
}}
>
⧉
</button>
<button
type="button"
className="leaf-kill text-faint hover:text-danger"
title="Kill session"
aria-label={`Kill ${name}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
props.onKill();
}}
>
✕
</button>
</span>
</span>
<MetaLine session={s} now={now} />
</a>
);
}
/** Line 2, left to right: `[duration] [detail] command [diff stat | exited]`.
* The line itself is unconditional; only its fragments are adaptive. */
export function MetaLine({ session: s, now }: { session: Session; now: number }) {
const slug = activityColorSlug(s.activity ?? "Idle");
const held = activityHeldMs(s.activity_since, now);
const duration =
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
const blockers = sessionBlockers(s);
return (
<span
className="card-line2 flex min-w-0 items-center gap-sp-2 text-fs-11"
data-activity={slug}
>
{duration && (
<span className={`activity-duration flex-none ${DURATION_CLASS[slug] ?? "text-muted"}`}>
{duration}
</span>
)}
{s.activity_detail && (
<span className="activity-detail min-w-0 flex-1 truncate text-faint">
{s.activity_detail}
</span>
)}
<CommandFragment command={s.command} />
{blockers && <BlockedFragment blockers={blockers} />}
{s.status === "Dead" && <span className="flex-none text-faint">exited</span>}
</span>
);
}
/** docs/SIDEBAR.md: the card names the program only, as its tool mark when we
* have one. The full command line stays in the tooltip either way. */
function CommandFragment({ command }: { command: string }) {
const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null;
return (
<span
className="leaf-cmd flex min-w-0 flex-none items-center gap-[4px] truncate text-faint"
title={command}
>
{icon ? (
<span className="contents" dangerouslySetInnerHTML={{ __html: icon }} />
) : (
<span className="truncate">{program}</span>
)}
</span>
);
}
/** Blocked work in diff notation — `+124 -18 ?3 ↑2`. Only `+` and `-` take
* colour; the spelled-out breakdown survives as the tooltip. */
function BlockedFragment({ blockers }: { blockers: InTheWay }) {
const stats = blockerStats(blockers);
return (
<span
className="leaf-blocked flex flex-none items-center gap-[4px] text-faint"
title={`Kill will be refused unless forced — ${formatInTheWay(blockers)}`}
>
{stats.map((st) => (
<span
key={st.kind}
data-stat={st.kind}
className={
st.kind === "add" ? "text-live" : st.kind === "del" ? "text-danger" : "text-muted"
}
>
{st.text}
</span>
))}
</span>
);
}
/** Inline rename (#125): Enter commits, Escape and blur cancel, an emptied
* field clears the name so the command fallback resumes. It starts from the
* session's own name, never from the command it falls back to. */
function RenameField({
session: s,
onCommit,
onCancel,
}: {
session: Session;
onCommit(name: string): void;
onCancel(): void;
}) {
const ref = useRef<HTMLInputElement>(null);
const settled = useRef(false);
useEffect(() => {
ref.current?.focus();
ref.current?.select();
}, []);
return (
<input
ref={ref}
type="text"
className="leaf-rename min-w-0 flex-1 bg-surface-2 text-fs-13 text-ink"
defaultValue={customSessionName(s) ?? ""}
maxLength={MAX_SESSION_NAME_LEN}
aria-label={`Rename ${sessionName(s)}`}
// The card is an <a> the tree also drags from: without these, clicking
// into the field navigates away or starts a drag.
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
if (settled.current) return;
settled.current = true;
onCommit(normalizeSessionName(e.currentTarget.value));
} else if (e.key === "Escape") {
e.preventDefault();
if (settled.current) return;
settled.current = true;
onCancel();
}
}}
onBlur={() => {
if (settled.current) return;
settled.current = true;
onCancel();
}}
/>
);
}
-227
View File
@@ -1,227 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Session } from "../../types.js";
import { Sidebar } from "./Sidebar.js";
afterEach(() => {
cleanup();
vi.useRealTimers();
});
function session(id: string, over: Partial<Session> = {}): Session {
return {
id,
cwd: "/home/n/alpha",
command: "hx src/",
env: {},
metadata: { name: id },
status: "Running",
created_at: "2026-08-24T10:00:00Z",
...over,
} as Session;
}
/** The card element for a session, by the `data-session-id` the row carries. */
function card(id: string): HTMLElement {
const el = document.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
if (!el) throw new Error(`no card for ${id}`);
return el;
}
function tree(): HTMLElement {
return document.querySelector<HTMLElement>(".tree")!;
}
describe("SessionCard layout", () => {
it("renders two lines: the name, then a meta line naming the program", () => {
render(<Sidebar sessions={[session("alpha")]} />);
const row = card("alpha");
expect(row.textContent).toContain("alpha");
const meta = row.querySelector(".card-line2")!;
// The program only — never its arguments.
expect(meta.textContent).toContain("hx");
expect(meta.textContent).not.toContain("src/");
});
it("shows no duration under the 30s hold, and one above it", () => {
const now = Date.parse("2026-08-24T10:00:00Z");
vi.setSystemTime(now);
const fresh = session("fresh", { activity: "Active", activity_since: "2026-08-24T09:59:50Z" });
const held = session("held", { activity: "Active", activity_since: "2026-08-24T09:56:00Z" });
render(<Sidebar sessions={[fresh, held]} />);
expect(card("fresh").querySelector(".activity-duration")).toBeNull();
expect(card("held").querySelector(".activity-duration")!.textContent).toBe("4m");
});
it("paints a rail for every state, blinking on AwaitingInput and flashing on Error", () => {
render(
<Sidebar
sessions={[
session("idle"),
session("await", { activity: "AwaitingInput" }),
session("err", { activity: "Error" }),
]}
/>,
);
const rail = (id: string) => card(id).querySelector<HTMLElement>("[data-activity]")!;
expect(rail("idle").dataset.activity).toBe("idle");
expect(rail("idle").className).not.toContain("rail-");
expect(rail("await").className).toContain("rail-blink");
expect(rail("err").className).toContain("rail-flash");
// Status never rests on colour alone.
expect(rail("await").getAttribute("aria-label")).toBe("Awaiting input");
});
it("renders blocked work as diff notation with the spelled-out tooltip", () => {
render(
<Sidebar
sessions={[
session("alpha", {
blocked_work: { uncommitted_files: 2, insertions: 124, deletions: 18, untracked_files: 3 },
}),
]}
/>,
);
const blocked = card("alpha").querySelector<HTMLElement>(".leaf-blocked")!;
expect(blocked.textContent).toBe("+124-18?3");
expect(blocked.title).toContain("2 uncommitted files");
});
it("dims a provisional name and leaves a settled one alone", () => {
render(
<Sidebar
sessions={[
session("gen", { metadata: { name: "gen", name_source: "Generated" } }),
session("user", { metadata: { name: "user", name_source: "User" } }),
]}
/>,
);
expect(card("gen").querySelector(".text-muted")).not.toBeNull();
expect(card("user").textContent).toContain("user");
});
});
describe("keyboard walk", () => {
const two = [session("a", { cwd: "/one" }), session("b", { cwd: "/two" })];
it("j and k step the highlight through the cards", () => {
render(<Sidebar sessions={two} />);
fireEvent.keyDown(tree(), { key: "j" });
expect(card("a").dataset.highlighted).toBe("true");
fireEvent.keyDown(tree(), { key: "j" });
expect(card("b").dataset.highlighted).toBe("true");
fireEvent.keyDown(tree(), { key: "k" });
expect(card("a").dataset.highlighted).toBe("true");
});
it("stops on a collapsed folder header and Enter expands onto its first card", () => {
render(<Sidebar sessions={two} />);
const header = document.querySelector<HTMLElement>('[data-nav-key="folder:one"]')!;
fireEvent.click(header);
expect(header.getAttribute("aria-expanded")).toBe("false");
fireEvent.keyDown(tree(), { key: "j" });
expect(
document.querySelector<HTMLElement>('[data-nav-key="folder:one"]')!.dataset.highlighted,
).toBe("true");
fireEvent.keyDown(tree(), { key: "Enter" });
expect(card("a").dataset.highlighted).toBe("true");
});
it("Enter on a card opens it, and Tab hands the keyboard to the pane", () => {
const onSelect = vi.fn();
const onFocusPane = vi.fn();
render(<Sidebar sessions={two} onSelect={onSelect} onFocusPane={onFocusPane} />);
fireEvent.keyDown(tree(), { key: "j" });
fireEvent.keyDown(tree(), { key: "Enter" });
expect(onSelect).toHaveBeenCalledWith("a");
fireEvent.keyDown(tree(), { key: "Tab" });
expect(onFocusPane).toHaveBeenCalled();
});
it("previews the session the walk lands on", () => {
const onPreview = vi.fn();
render(<Sidebar sessions={two} onPreview={onPreview} />);
fireEvent.keyDown(tree(), { key: "j" });
expect(onPreview).toHaveBeenCalledWith("a");
});
it("x asks to kill the highlighted session and r renames it", () => {
const onKill = vi.fn();
render(<Sidebar sessions={two} onKill={onKill} />);
fireEvent.keyDown(tree(), { key: "j" });
fireEvent.keyDown(tree(), { key: "x" });
expect(onKill).toHaveBeenCalledWith(expect.objectContaining({ id: "a" }));
fireEvent.keyDown(tree(), { key: "r" });
expect(card("a").querySelector(".leaf-rename")).not.toBeNull();
});
});
describe("context menu", () => {
it("offers rename, spawn similar and kill", () => {
const onSpawnSimilar = vi.fn();
const onKill = vi.fn();
render(
<Sidebar sessions={[session("alpha")]} onSpawnSimilar={onSpawnSimilar} onKill={onKill} />,
);
fireEvent.contextMenu(card("alpha"));
const labels = [...document.querySelectorAll('[role="menuitem"]')].map((n) => n.textContent);
expect(labels).toEqual(["Rename…", "Spawn similar", "Kill session"]);
fireEvent.click(screen.getByText("Spawn similar"));
expect(onSpawnSimilar).toHaveBeenCalledWith(expect.objectContaining({ id: "alpha" }));
});
it("renames in place: Enter commits the field, Escape cancels", () => {
const onRename = vi.fn();
render(<Sidebar sessions={[session("alpha")]} onRename={onRename} />);
fireEvent.contextMenu(card("alpha"));
fireEvent.click(screen.getByText("Rename…"));
const input = card("alpha").querySelector<HTMLInputElement>(".leaf-rename")!;
expect(input.value).toBe("alpha");
fireEvent.change(input, { target: { value: " renamed " } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onRename).toHaveBeenCalledWith("alpha", "renamed");
expect(card("alpha").querySelector(".leaf-rename")).toBeNull();
});
});
describe("search", () => {
it("filters the tree down to matching sessions", () => {
render(<Sidebar sessions={[session("alpha"), session("beta", { cwd: "/home/n/beta" })]} />);
fireEvent.change(screen.getByLabelText("Search sessions"), { target: { value: "beta" } });
expect(document.querySelector('[data-session-id="alpha"]')).toBeNull();
expect(document.querySelector('[data-session-id="beta"]')).not.toBeNull();
});
});
describe("empty state", () => {
it("offers a spawn when there are no sessions at all", () => {
const onSpawn = vi.fn();
render(<Sidebar sessions={[]} onSpawn={onSpawn} />);
fireEvent.click(screen.getByText("+ Spawn session"));
expect(onSpawn).toHaveBeenCalled();
});
});
-368
View File
@@ -1,368 +0,0 @@
// React port of the sidebar tree (catalog.ts). The contract it renders is
// docs/SIDEBAR.md: two-line cards behind an activity rail, folder-grouped by
// repository, walked with j/k.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStore } from "zustand";
import { ApiRequestError, killSession, renameSession } from "../../api.js";
import type { CatalogStore, CatalogStoreApi } from "../../stores/catalogStore.js";
import type { InTheWay, Session } from "../../types.js";
import { sessionBlockers, sessionName } from "../../types.js";
import { buildTree, type TreeNode } from "../../tree.js";
import { ContextMenu, type ContextMenuItem } from "./ContextMenu.js";
import { FolderNode } from "./FolderNode.js";
import { KillConfirm } from "./KillConfirm.js";
import { SessionCard } from "./SessionCard.js";
import { SidebarSearch } from "./SidebarSearch.js";
import { filterSessions, firstRowUnder, navRows, sessionKey } from "./model.js";
import { useNow } from "./useNow.js";
import "./sidebar.css";
export interface SidebarProps {
/** Live catalog. Either this or `sessions` supplies the tree. */
store?: CatalogStoreApi;
/** Static session list — what tests and the design kit render from. */
sessions?: Session[];
/** The daemon's home dir, so cwds abbreviate to `~/…`. */
homeDir?: string;
/** Showing in the focused pane. */
activeId?: string | null;
/** Open in some pane, focused one included. */
openIds?: ReadonlySet<string>;
/** Previewing (docs/PREVIEW.md) — a view, never durable state. */
previewId?: string | null;
onSelect?(id: string): void;
/** The highlight moved onto a session, or off every session. */
onPreview?(id: string | null): void;
onSpawnSimilar?(session: Session): void;
onSpawn?(): void;
/** Fast-forward spawn (`N`). */
onSpawnDefaults?(): void;
/** Tab: hand the keyboard back to the pane. */
onFocusPane?(): void;
onHelp?(): void;
/** Overrides the built-in `DELETE /api/sessions/:id` flow (tests, kit). */
onKill?(session: Session): void;
/** Overrides the built-in `PATCH /api/sessions/:id` (tests, kit). */
onRename?(id: string, name: string): void;
}
interface MenuState {
x: number;
y: number;
session: Session;
}
interface KillState {
session: Session;
blockers: InTheWay | null;
force: boolean;
}
export function Sidebar(props: SidebarProps) {
const sessions = useSessions(props);
const now = useNow();
const [query, setQuery] = useState("");
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set<string>());
const [navKey, setNavKey] = useState<string | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [menu, setMenu] = useState<MenuState | null>(null);
const [kill, setKill] = useState<KillState | null>(null);
const treeRef = useRef<HTMLDivElement>(null);
const tree = useMemo(
() => buildTree(filterSessions(sessions, query), props.homeDir),
[sessions, query, props.homeDir],
);
const rows = useMemo(() => navRows(tree, collapsed), [tree, collapsed]);
// A row that went away — session killed, folder collapsed over it — drops
// the highlight rather than leaving it pointing at nothing.
useEffect(() => {
if (navKey !== null && !rows.some((r) => r.key === navKey)) setNavKey(null);
}, [rows, navKey]);
const preview = props.onPreview;
const previewOf = useCallback(
(key: string | null) => {
preview?.(key?.startsWith("session:") ? key.slice("session:".length) : null);
},
[preview],
);
const moveNav = useCallback(
(delta: number) => {
if (rows.length === 0) return;
const cur = rows.findIndex((r) => r.key === navKey);
// Nothing highlighted: start one off the end being walked from, so the
// first step lands on the first (or last) row.
const from = cur === -1 ? (delta > 0 ? -1 : 0) : cur;
const next = rows[(((from + delta) % rows.length) + rows.length) % rows.length];
setNavKey(next.key);
previewOf(next.key);
},
[rows, navKey, previewOf],
);
const toggleFolder = useCallback((path: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
}, []);
/** Enter: open the highlighted session, or expand the highlighted folder and
* land on the first row it was hiding. */
const activateNav = useCallback(() => {
const row = rows.find((r) => r.key === navKey);
if (!row) return;
if (row.kind === "session") {
props.onSelect?.(row.id);
return;
}
const expanded = new Set(collapsed);
expanded.delete(row.path);
setCollapsed(expanded);
const revealed = firstRowUnder(tree, expanded, row.path);
setNavKey(revealed?.key ?? null);
}, [rows, navKey, collapsed, tree, props]);
const beginKill = useCallback(
(s: Session) => {
if (props.onKill) {
props.onKill(s);
return;
}
setKill({ session: s, blockers: sessionBlockers(s), force: false });
},
[props],
);
const commitRename = useCallback(
(id: string, name: string) => {
setRenamingId(null);
if (props.onRename) props.onRename(id, name);
else void renameSession(id, name).catch(() => {});
},
[props],
);
/** The session behind the highlighted row, when the highlight is on a card. */
const highlightedSession = useCallback((): Session | undefined => {
const row = rows.find((r) => r.key === navKey);
if (row?.kind !== "session") return undefined;
return sessions.find((s) => s.id === row.id);
}, [rows, navKey, sessions]);
const onKeyDown = (e: React.KeyboardEvent) => {
// The inline rename field owns its own keys; the walk stays out of it.
if (e.target instanceof HTMLInputElement) return;
switch (e.key) {
case "j":
case "ArrowDown":
e.preventDefault();
moveNav(1);
return;
case "k":
case "ArrowUp":
e.preventDefault();
moveNav(-1);
return;
case "Enter":
e.preventDefault();
activateNav();
return;
case "Tab":
e.preventDefault();
props.onFocusPane?.();
return;
case "c": {
const row = rows.find((r) => r.key === navKey);
if (row?.kind !== "folder") return;
e.preventDefault();
toggleFolder(row.path);
return;
}
case "r": {
const s = highlightedSession();
if (!s) return;
e.preventDefault();
setRenamingId(s.id);
return;
}
case "x": {
const s = highlightedSession();
if (!s) return;
e.preventDefault();
beginKill(s);
return;
}
case "n":
e.preventDefault();
props.onSpawn?.();
return;
case "N":
e.preventDefault();
props.onSpawnDefaults?.();
return;
case "?":
e.preventDefault();
props.onHelp?.();
return;
}
};
const menuItems = (s: Session): ContextMenuItem[] => [
{ label: "Rename…", onSelect: () => setRenamingId(s.id) },
{ label: "Spawn similar", onSelect: () => props.onSpawnSimilar?.(s) },
{ label: "Kill session", danger: true, onSelect: () => beginKill(s) },
];
const renderNode = (node: TreeNode, depth: number): React.ReactNode => {
if (node.kind === "leaf") {
const s = node.session;
return (
<SessionCard
key={s.id}
session={s}
depth={depth}
now={now}
highlighted={navKey === sessionKey(s.id)}
active={props.activeId === s.id}
open={(props.openIds?.has(s.id) ?? false) && props.activeId !== s.id}
previewing={props.previewId === s.id && props.activeId !== s.id}
renaming={renamingId === s.id}
onSelect={() => props.onSelect?.(s.id)}
onOpenMenu={(x, y) => setMenu({ x, y, session: s })}
onSpawnSimilar={() => props.onSpawnSimilar?.(s)}
onKill={() => beginKill(s)}
onRenameCommit={(name) => commitRename(s.id, name)}
onRenameCancel={() => setRenamingId(null)}
/>
);
}
if (!node.name) {
return node.children.map((c) => renderNode(c, depth));
}
return (
<FolderNode
key={node.path}
name={node.name}
path={node.path}
depth={depth}
collapsed={collapsed.has(node.path)}
highlighted={navKey === `folder:${node.path}`}
onToggle={() => toggleFolder(node.path)}
>
{node.children.map((c) => renderNode(c, depth + 1))}
</FolderNode>
);
};
return (
<aside className="sidebar flex h-full min-h-0 flex-col bg-surface">
<SidebarSearch
value={query}
onChange={setQuery}
onDismiss={() => treeRef.current?.focus()}
/>
<div
ref={treeRef}
className="tree min-h-0 flex-1 overflow-y-auto outline-none"
tabIndex={-1}
role="tree"
aria-label="Sessions"
onKeyDown={onKeyDown}
// The keyboard walk's preview belongs to the list holding the
// keyboard: once it doesn't, the pane goes back to what was opened.
onBlur={() => preview?.(null)}
>
{sessions.length === 0 ? (
<EmptyState onSpawn={() => props.onSpawn?.()} />
) : (
renderNode(tree, 0)
)}
</div>
{menu && (
<ContextMenu
x={menu.x}
y={menu.y}
items={menuItems(menu.session)}
onClose={() => setMenu(null)}
/>
)}
{kill && (
<KillConfirm
name={sessionName(kill.session)}
blockers={kill.blockers}
force={kill.force}
onCancel={() => setKill(null)}
onConfirm={() => {
const { session: s, force } = kill;
setKill(null);
void killSession(s.id, force).catch((e: unknown) => {
// The daemon refuses an unforced kill over a worktree holding
// unsaved work; that refusal needs its own prompt
// (docs/WORKTREES.md).
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
setKill({
session: s,
blockers: e.unsavedWork ?? sessionBlockers(s),
force: true,
});
}
});
}}
/>
)}
</aside>
);
}
/** The tree's one empty state: no sessions at all. */
function EmptyState({ onSpawn }: { onSpawn(): void }) {
return (
<div className="empty flex flex-col items-center gap-sp-2 p-sp-6 text-center">
<div aria-hidden="true" className="text-fs-20 text-faint">
+
</div>
<div className="text-fs-13 text-ink">No sessions yet</div>
<p className="text-fs-12 text-muted">Start your first session — it'll show up here.</p>
<button
type="button"
className="rounded-r-1 bg-accent px-sp-3 py-sp-1 text-fs-12 text-accent-ink"
onClick={onSpawn}
>
+ Spawn session
</button>
</div>
);
}
/** Sessions from the store when one is wired, else the static prop. */
function useSessions(props: SidebarProps): Session[] {
const fromStore = useStore(
// `useStore` needs a store every render; the no-store case is served by a
// stable empty stand-in whose result is thrown away below.
props.store ?? EMPTY_STORE,
(s: CatalogStore) => s.sessions,
);
return useMemo(
() => (props.store ? [...fromStore.values()] : (props.sessions ?? [])),
[props.store, props.sessions, fromStore],
);
}
// One frozen state object, not a fresh one per call: `useStore` reads the
// snapshot on every render and a new Map each time would never settle.
const EMPTY_STATE = { sessions: new Map<string, Session>() };
const EMPTY_STORE = {
getState: () => EMPTY_STATE,
getInitialState: () => EMPTY_STATE,
setState: () => {},
subscribe: () => () => {},
} as unknown as CatalogStoreApi;
@@ -1,34 +0,0 @@
// The sidebar's search field: a substring filter over the tree (name, command
// and cwd). Escape clears it and hands the keyboard back to the list.
export interface SidebarSearchProps {
value: string;
onChange(value: string): void;
/** Escape on an already-empty field — the list wants the keyboard back. */
onDismiss?(): void;
}
export function SidebarSearch({ value, onChange, onDismiss }: SidebarSearchProps) {
return (
<header className="sidebar-search p-sp-2">
<input
type="search"
className="search w-full rounded-r-1 border border-border bg-surface-2 px-sp-2 py-sp-1 text-fs-12 text-ink"
placeholder="Search sessions…"
aria-label="Search sessions"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
value={value}
onChange={(e) => onChange(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key !== "Escape") return;
e.preventDefault();
if (value) onChange("");
else onDismiss?.();
}}
/>
</header>
);
}
-8
View File
@@ -1,8 +0,0 @@
export { Sidebar, type SidebarProps } from "./Sidebar.js";
export { SessionCard, type SessionCardProps } from "./SessionCard.js";
export { FolderNode, type FolderNodeProps } from "./FolderNode.js";
export { SidebarSearch, type SidebarSearchProps } from "./SidebarSearch.js";
export { ActivityRail } from "./ActivityRail.js";
export { ContextMenu, type ContextMenuItem } from "./ContextMenu.js";
export { KillConfirm } from "./KillConfirm.js";
export * from "./model.js";
-62
View File
@@ -1,62 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildTree } from "../../tree.js";
import type { Session } from "../../types.js";
import { filterSessions, firstRowUnder, navRows, splitFolderLabel } from "./model.js";
function session(id: string, cwd: string, command = "claude"): Session {
return {
id,
cwd,
command,
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
} as Session;
}
describe("navRows", () => {
const tree = buildTree([session("a", "/one"), session("b", "/two")], undefined);
it("stops on every card and skips expanded folder headers", () => {
expect(navRows(tree, new Set()).map((r) => r.key)).toEqual(["session:a", "session:b"]);
});
it("stops on a collapsed folder header instead of the cards it hides", () => {
expect(navRows(tree, new Set(["one"])).map((r) => r.key)).toEqual([
"folder:one",
"session:b",
]);
});
it("names the first row an expanded folder reveals", () => {
expect(firstRowUnder(tree, new Set(), "one")?.key).toBe("session:a");
});
});
describe("splitFolderLabel", () => {
it("keeps a shallow path whole", () => {
expect(splitFolderLabel("~/maestro")).toEqual({ stem: null, last: "~/maestro" });
});
it("initials the leading segments of a deep path", () => {
expect(splitFolderLabel("~/git.naps.pt/naps62/maestro")).toEqual({
stem: "~/g/n",
last: "maestro",
});
});
});
describe("filterSessions", () => {
const all = [session("a", "/home/n/alpha", "claude"), session("b", "/home/n/beta", "bash")];
it("returns everything for a blank query", () => {
expect(filterSessions(all, " ")).toHaveLength(2);
});
it("matches the cwd and the command, case-insensitively", () => {
expect(filterSessions(all, "ALPHA").map((s) => s.id)).toEqual(["a"]);
expect(filterSessions(all, "bash").map((s) => s.id)).toEqual(["b"]);
});
});
-90
View File
@@ -1,90 +0,0 @@
// Pure model helpers behind the React sidebar. Kept out of the components so
// the walk order and the folder label can be tested without a DOM.
import type { Session } from "../../types.js";
import { sessionCwd, sessionName } from "../../types.js";
import type { TreeNode } from "../../tree.js";
/** A row the keyboard walk can land on (docs/SIDEBAR.md "Keyboard walk"):
* every session card, and a folder header only while it is collapsed. */
export type NavRow =
| { key: string; kind: "folder"; path: string }
| { key: string; kind: "session"; id: string };
export function folderKey(path: string): string {
return `folder:${path}`;
}
export function sessionKey(id: string): string {
return `session:${id}`;
}
/** The walkable rows in visual order. An expanded folder contributes nothing
* itself — its cards are one row away — while a collapsed one is the only row
* it has, so skipping it would leave the folder unreachable. */
export function navRows(root: TreeNode, collapsed: ReadonlySet<string>): NavRow[] {
const out: NavRow[] = [];
const walk = (node: TreeNode): void => {
if (node.kind === "leaf") {
out.push({ key: sessionKey(node.session.id), kind: "session", id: node.session.id });
return;
}
if (node.name && collapsed.has(node.path)) {
out.push({ key: folderKey(node.path), kind: "folder", path: node.path });
return;
}
for (const child of node.children) walk(child);
};
for (const child of root.kind === "folder" ? root.children : [root]) walk(child);
return out;
}
/** The first walkable row inside `path`, i.e. where the highlight lands when
* Enter expands that folder. */
export function firstRowUnder(
root: TreeNode,
collapsed: ReadonlySet<string>,
path: string,
): NavRow | undefined {
const found = findFolder(root, path);
if (!found) return undefined;
return navRows({ ...found, name: "" }, collapsed)[0];
}
function findFolder(node: TreeNode, path: string): Extract<TreeNode, { kind: "folder" }> | null {
if (node.kind === "leaf") return null;
if (node.path === path && node.name) return node;
for (const child of node.children) {
const hit = findFolder(child, path);
if (hit) return hit;
}
return null;
}
/** Structured form of `abbreviateFolderName`: a deep path shows its leading
* segments as initials so the tail stays readable in a narrow sidebar. */
export function splitFolderLabel(name: string): { stem: string | null; last: string } {
const parts = name.split("/");
if (parts.length <= 2) return { stem: null, last: name };
const last = parts[parts.length - 1];
const stem = parts
.slice(0, -1)
.map((p) => (p === "~" ? "~" : (Array.from(p)[0] ?? "")))
.join("/");
return { stem, last };
}
/** Case-insensitive substring filter over a session's name, command and cwd —
* what the sidebar's own search field narrows the tree with. */
export function filterSessions(sessions: Session[], query: string): Session[] {
const q = query.trim().toLowerCase();
if (!q) return sessions;
return sessions.filter((s) => {
const cwd = sessionCwd(s) ?? "";
return (
sessionName(s).toLowerCase().includes(q) ||
s.command.toLowerCase().includes(q) ||
cwd.toLowerCase().includes(q)
);
});
}
-49
View File
@@ -1,49 +0,0 @@
/* Motion for the activity rail (docs/SIDEBAR.md): a hard blink while
AwaitingInput, a fast flash while Error. Active and Idle are solid and
still — colour alone is the signal. Kept as real CSS rather than Tailwind
utilities because both need @keyframes of their own. */
@keyframes maestro-rail-blink {
0%,
50% {
opacity: 1;
}
50.01%,
100% {
opacity: 0;
}
}
@keyframes maestro-rail-flash {
0%,
100% {
opacity: 1;
}
15% {
opacity: 0.25;
}
30% {
opacity: 1;
}
45% {
opacity: 0.25;
}
60% {
opacity: 1;
}
}
.rail-blink {
animation: maestro-rail-blink 1.2s steps(1) infinite;
}
.rail-flash {
animation: maestro-rail-flash 1.4s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.rail-blink,
.rail-flash {
animation: none;
}
}
-16
View File
@@ -1,16 +0,0 @@
import { useEffect, useState } from "react";
/** A clock that ticks at least every `intervalMs` while the page is visible.
* docs/SIDEBAR.md asks for a re-render at least every 30s so the duration's
* cutover points land within one tick. */
export function useNow(intervalMs = 30_000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => {
if (document.visibilityState !== "visible") return;
setNow(Date.now());
}, intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return now;
}
@@ -1,79 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { BranchInfo } from "../../types.js";
import { BranchPicker, type BranchPickerProps } from "./BranchPicker.js";
afterEach(cleanup);
function props(over: Partial<BranchPickerProps> = {}): BranchPickerProps {
return {
enabled: true,
onToggleEnabled: vi.fn(),
hint: null,
baseRepo: "/repo",
mode: "new",
onModeChange: vi.fn(),
newBranch: "",
onNewBranchChange: vi.fn(),
branches: undefined,
existingBranch: "",
onExistingBranchChange: vi.fn(),
...over,
};
}
describe("BranchPicker", () => {
it("shows the hint instead of the body while unresolved", () => {
render(<BranchPicker {...props({ hint: "Choose a working directory first." })} />);
expect(screen.getByText("Choose a working directory first.")).not.toBeNull();
expect(screen.queryByText("New branch name")).toBeNull();
});
it("shows the new-branch field by default and the repo line once resolved", () => {
render(<BranchPicker {...props()} />);
expect(screen.getByText("repo: /repo")).not.toBeNull();
expect(screen.getByPlaceholderText("feat/foo")).not.toBeNull();
expect(screen.queryByText("Pick a branch")).toBeNull();
});
it("switches to the existing-branch select and reports the mode change", () => {
const onModeChange = vi.fn();
render(<BranchPicker {...props({ onModeChange })} />);
fireEvent.click(screen.getByLabelText("Existing branch"));
expect(onModeChange).toHaveBeenCalledWith("existing");
});
it("disables a branch already held by another worktree, and the main checkout's HEAD", () => {
const branches: BranchInfo[] = [
{ name: "main", is_head: true },
{ name: "feat/a", is_head: false, worktree: "/repos/a" },
{ name: "feat/b", is_head: false },
];
render(<BranchPicker {...props({ mode: "existing", branches })} />);
const main = screen.getByRole("option", { name: /main — held by the main checkout/ }) as HTMLOptionElement;
const a = screen.getByRole("option", { name: /feat\/a — held by \/repos\/a/ }) as HTMLOptionElement;
const b = screen.getByRole("option", { name: "feat/b" }) as HTMLOptionElement;
expect(main.disabled).toBe(true);
expect(a.disabled).toBe(true);
expect(b.disabled).toBe(false);
});
it("shows a placeholder option when no local branches were found", () => {
render(<BranchPicker {...props({ mode: "existing", branches: [] })} />);
expect(screen.getByText("No local branches found.")).not.toBeNull();
});
it("toggles the worktree checkbox", () => {
const onToggleEnabled = vi.fn();
render(<BranchPicker {...props({ onToggleEnabled })} />);
fireEvent.click(screen.getByLabelText("Create git worktree"));
expect(onToggleEnabled).toHaveBeenCalledWith(false);
});
});
-119
View File
@@ -1,119 +0,0 @@
// The spawn form's worktree section (#61): create-worktree toggle, then
// (once a base repo is known) new-branch-vs-existing-branch, mirroring
// spawnForm.ts's `.adv-group`/`.wt-body`/`.wt-mode` markup.
import type { BranchInfo } from "../../types.js";
import type { WorktreeBranchMode } from "../../spawnForm.js";
export interface BranchPickerProps {
enabled: boolean;
onToggleEnabled(enabled: boolean): void;
/** Status line shown in place of the body — "Checking…", "not a repo",
* "choose a directory first". `null` once the body itself can render. */
hint: string | null;
/** The repo a worktree would branch from, once resolved. */
baseRepo: string | null;
mode: WorktreeBranchMode;
onModeChange(mode: WorktreeBranchMode): void;
newBranch: string;
onNewBranchChange(value: string): void;
/** This cwd's local branches, or `undefined` while unknown/not yet loaded. */
branches: BranchInfo[] | undefined;
existingBranch: string;
onExistingBranchChange(value: string): void;
}
export function BranchPicker(props: BranchPickerProps) {
return (
<div
className={`flex flex-col gap-sp-3 border-l-2 pl-sp-3 ${
props.enabled ? "border-accent" : "border-border"
}`}
>
<label className="flex cursor-pointer items-center gap-sp-2 text-fs-14 text-ink">
<input
type="checkbox"
data-role="toggle"
className="size-[18px] accent-accent"
checked={props.enabled}
onChange={(e) => props.onToggleEnabled(e.currentTarget.checked)}
/>
Create git worktree
</label>
{props.hint && <p className="m-0 text-fs-12 text-muted">{props.hint}</p>}
{props.enabled && !props.hint && (
<div className="flex flex-col gap-sp-3">
{props.baseRepo && (
<p className="m-0 font-mono text-fs-12 text-muted">repo: {props.baseRepo}</p>
)}
<div role="radiogroup" aria-label="Branch source" className="flex gap-sp-4">
<label className="flex cursor-pointer items-center gap-sp-2 text-fs-14 text-ink">
<input
type="radio"
name="worktree_branch_mode"
checked={props.mode === "new"}
onChange={() => props.onModeChange("new")}
/>
New branch
</label>
<label className="flex cursor-pointer items-center gap-sp-2 text-fs-14 text-ink">
<input
type="radio"
name="worktree_branch_mode"
checked={props.mode === "existing"}
onChange={() => props.onModeChange("existing")}
/>
Existing branch
</label>
</div>
{props.mode === "new" ? (
<label className="flex flex-col gap-1 text-fs-12 text-muted">
New branch name
<input
name="worktree_branch"
type="text"
autoComplete="off"
placeholder="feat/foo"
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2 font-mono text-fs-15 text-ink"
value={props.newBranch}
onChange={(e) => props.onNewBranchChange(e.currentTarget.value)}
/>
</label>
) : (
<label className="flex flex-col gap-1 text-fs-12 text-muted">
Pick a branch
<select
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2 font-mono text-fs-15 text-ink"
value={props.existingBranch}
onChange={(e) => props.onExistingBranchChange(e.currentTarget.value)}
>
{branchOptions(props.branches)}
</select>
</label>
)}
</div>
)}
</div>
);
}
function branchOptions(branches: BranchInfo[] | undefined) {
if (!branches || branches.length === 0) {
return <option value="">No local branches found.</option>;
}
return branches.map((b) => {
// A branch already checked out anywhere stays visible but unpickable —
// git refuses a second checkout. That includes the main checkout's own
// HEAD, which is commonly `main` and would otherwise be the default pick,
// making every existing-branch spawn fail (#54 review).
const held = b.worktree ?? (b.is_head ? "the main checkout" : null);
return (
<option key={b.name} value={b.name} disabled={held !== null} title={held ?? ""}>
{held ? `${b.name} — held by ${held}` : b.name}
</option>
);
});
}
@@ -1,109 +0,0 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DirAutocomplete } from "./DirAutocomplete.js";
vi.mock("../../api.js", () => ({
listDir: vi.fn(),
}));
import { listDir } from "../../api.js";
const mockListDir = vi.mocked(listDir);
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
function Harness({ initial = "" }: { initial?: string }) {
const [value, setValue] = useState(initial);
return <DirAutocomplete value={value} onChange={setValue} placeholder="cwd" />;
}
// `fireEvent.focus`/`.blur` dispatch the event without moving the browser's
// real focus, so `document.activeElement` (which the dropdown's fetch guards
// against) never changes — calling the DOM method directly does both.
function focus(el: HTMLElement): void {
act(() => el.focus());
}
function blur(el: HTMLElement): void {
act(() => el.blur());
}
describe("DirAutocomplete", () => {
it("lists entries matching the trailing segment on focus", async () => {
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha", "beta"] });
render(<Harness initial="/home/n/al" />);
focus(screen.getByPlaceholderText("cwd"));
expect(await screen.findByRole("option", { name: "alpha/" })).not.toBeNull();
expect(screen.queryByRole("option", { name: "beta/" })).toBeNull();
});
it("Tab commits the highlighted entry and does not close the field", async () => {
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha"] });
render(<Harness initial="/home/n/a" />);
const input = screen.getByPlaceholderText("cwd") as HTMLInputElement;
focus(input);
await screen.findByRole("option", { name: "alpha/" });
fireEvent.keyDown(input, { key: "Tab" });
expect(input.value).toBe("/home/n/alpha/");
});
it("arrows move the highlight and Enter accepts it without submitting", async () => {
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha", "beta"] });
render(<Harness initial="/home/n/" />);
const input = screen.getByPlaceholderText("cwd") as HTMLInputElement;
focus(input);
await screen.findByRole("option", { name: "alpha/" });
fireEvent.keyDown(input, { key: "ArrowDown" });
const notPrevented = fireEvent.keyDown(input, { key: "Enter" });
expect(input.value).toBe("/home/n/beta/");
expect(notPrevented).toBe(false); // preventDefault called -> event "processed"
});
it("Escape closes the dropdown and stops the key from bubbling", async () => {
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha"] });
const onKeyDown = vi.fn();
render(
<div onKeyDown={onKeyDown}>
<Harness initial="/home/n/a" />
</div>,
);
const input = screen.getByPlaceholderText("cwd");
focus(input);
await screen.findByRole("option", { name: "alpha/" });
fireEvent.keyDown(input, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("option")).toBeNull());
expect(onKeyDown).not.toHaveBeenCalled();
});
it("does not reopen on refocus until the operator types again", async () => {
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha"] });
render(<Harness initial="/home/n/a" />);
const input = screen.getByPlaceholderText("cwd");
focus(input);
await screen.findByRole("option", { name: "alpha/" });
fireEvent.keyDown(input, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("option")).toBeNull());
blur(input);
focus(input);
await new Promise((r) => setTimeout(r, 10));
expect(screen.queryByRole("option")).toBeNull();
fireEvent.change(input, { target: { value: "/home/n/al" } });
expect(await screen.findByRole("option", { name: "alpha/" })).not.toBeNull();
});
});
@@ -1,256 +0,0 @@
// React port of ../../dirAutocomplete.ts: shell-style directory autocomplete
// on a path <input>. Lists one directory at a time over `GET /api/dirs`,
// caches per directory, and filters the trailing path segment locally.
//
// Keyboard model mirrors `cd dir/sub<tab>`:
// Tab commit the highlighted entry and let focus move on; with nothing
// highlighted, plain tab navigation
// ↑/↓ move the highlight
// Enter accept the highlighted entry (and suppress form submit); with
// nothing highlighted, bubble to the form unprevented
// Esc close the dropdown, refocus the input, and stop the key from
// bubbling — so dismissing the popup can't also close a parent modal
//
// Once dismissed (Esc or blur) the popup stays closed until the operator
// types again — refocusing alone doesn't reopen it.
import { useCallback, useEffect, useRef, useState, type InputHTMLAttributes, type Ref } from "react";
import { createPortal } from "react-dom";
import { listDir } from "../../api.js";
import { splitPath } from "../../paths.js";
/** Most entries rendered at once — a long list is unhelpful and slow. */
const MAX_VISIBLE = 60;
/** Debounce before a network fetch when the input crosses into a new directory. */
const FETCH_DEBOUNCE_MS = 90;
export interface DirAutocompleteProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "onChange"> {
value: string;
onChange(value: string): void;
/** Access to the underlying `<input>` — e.g. to focus/select it. */
inputRef?: Ref<HTMLInputElement>;
}
function assignRef(ref: Ref<HTMLInputElement> | undefined, el: HTMLInputElement | null): void {
if (!ref) return;
if (typeof ref === "function") ref(el);
else (ref as { current: HTMLInputElement | null }).current = el;
}
export function DirAutocomplete({ value, onChange, inputRef, ...inputProps }: DirAutocompleteProps) {
const localRef = useRef<HTMLInputElement | null>(null);
const cache = useRef(new Map<string, string[]>());
const fetchSeq = useRef(0);
const debounceTimer = useRef<number | undefined>(undefined);
const dismissedRef = useRef(false);
const [filtered, setFiltered] = useState<string[]>([]);
const [highlight, setHighlight] = useState(-1);
const [open, setOpen] = useState(false);
const [rect, setRect] = useState<{ left: number; top: number; width: number } | null>(null);
const setInputRef = useCallback(
(el: HTMLInputElement | null) => {
localRef.current = el;
assignRef(inputRef, el);
},
[inputRef],
);
const reposition = useCallback(() => {
const el = localRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setRect({ left: r.left, top: r.bottom + 4, width: r.width });
}, []);
const close = useCallback(() => {
setOpen(false);
setFiltered([]);
setHighlight(-1);
}, []);
const dismiss = useCallback(() => {
dismissedRef.current = true;
close();
}, [close]);
const applyFilter = useCallback(
(entries: string[], prefix: string) => {
// A fetch can outlive the dismissal it was meant to serve (Tab-commit
// re-arms a refresh, then blur dismisses 120ms later). Showing it now
// would anchor a dropdown under an unfocused input nothing closes.
if (dismissedRef.current || document.activeElement !== localRef.current) {
close();
return;
}
const lower = prefix.toLowerCase();
const next = entries.filter((e) => e.toLowerCase().startsWith(lower));
setFiltered(next);
setHighlight(next.length > 0 ? 0 : -1);
if (next.length === 0) {
close();
return;
}
setOpen(true);
reposition();
},
[close, reposition],
);
const refresh = useCallback(
(raw: string) => {
const { dir, prefix } = splitPath(raw);
const cached = cache.current.get(dir);
if (cached) {
applyFilter(cached, prefix);
return;
}
const seq = ++fetchSeq.current;
listDir(dir)
.then((listing) => {
cache.current.set(dir, listing.entries);
if (seq !== fetchSeq.current) return; // a newer fetch superseded this one
const cur = splitPath(localRef.current?.value ?? raw);
if (cur.dir === dir) applyFilter(listing.entries, cur.prefix);
})
.catch(() => {
if (seq === fetchSeq.current) close();
});
},
[applyFilter, close],
);
const scheduleRefresh = useCallback(
(raw: string) => {
window.clearTimeout(debounceTimer.current);
debounceTimer.current = window.setTimeout(() => refresh(raw), FETCH_DEBOUNCE_MS);
},
[refresh],
);
/** Complete `name` as the next path segment and append `/` so the child
* directory's listing loads on the next interaction — the drill-in
* gesture. Unlike `accept`, doesn't steal focus: Tab uses this to commit
* and move to the next field in one gesture. */
const commit = useCallback(
(name: string) => {
const { dir } = splitPath(value);
onChange(`${dir}${name}/`);
},
[value, onChange],
);
const accept = useCallback(
(name: string) => {
commit(name);
localRef.current?.focus();
},
[commit],
);
useEffect(() => {
if (!open) return;
window.addEventListener("scroll", reposition, true);
window.addEventListener("resize", reposition);
return () => {
window.removeEventListener("scroll", reposition, true);
window.removeEventListener("resize", reposition);
};
}, [open, reposition]);
useEffect(() => () => window.clearTimeout(debounceTimer.current), []);
return (
<>
<input
{...inputProps}
ref={setInputRef}
value={value}
autoComplete="off"
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
onChange={(e) => {
dismissedRef.current = false;
const next = e.currentTarget.value;
onChange(next);
scheduleRefresh(next);
}}
onFocus={(e) => {
inputProps.onFocus?.(e);
if (dismissedRef.current) return;
refresh(value);
}}
onBlur={(e) => {
inputProps.onBlur?.(e);
// Delay so a dropdown mousedown can complete first.
window.setTimeout(dismiss, 120);
}}
onKeyDown={(e) => {
inputProps.onKeyDown?.(e);
if (e.defaultPrevented) return;
if (e.key === "Escape") {
if (open) {
e.preventDefault();
e.stopPropagation();
dismiss();
localRef.current?.focus();
}
return;
}
if (e.key === "Tab") {
if (!open || highlight < 0 || highlight >= filtered.length) return; // plain tab navigation
commit(filtered[highlight]); // no preventDefault — focus moves to the next field
return;
}
if (!open) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlight((h) => Math.min(h + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlight((h) => Math.max(h - 1, 0));
} else if (e.key === "Enter" && highlight >= 0 && highlight < filtered.length) {
e.preventDefault(); // accept the entry instead of submitting the form
accept(filtered[highlight]);
}
}}
/>
{open &&
rect &&
createPortal(
<ul
role="listbox"
className="dir-ac fixed z-modal max-h-[240px] overflow-y-auto rounded-r-2 border border-border-strong bg-surface py-sp-1 font-mono shadow-2"
style={{ left: rect.left, top: rect.top, width: rect.width }}
>
{filtered.slice(0, MAX_VISIBLE).map((name, i) => (
<li
key={name}
role="option"
aria-selected={i === highlight}
className={`cursor-pointer truncate px-sp-3 py-1 text-fs-12 text-ink ${
i === highlight ? "bg-accent-tint" : ""
}`}
onMouseDown={(e) => {
e.preventDefault(); // accept before the input's blur fires
accept(name);
}}
>
{name}/
</li>
))}
{filtered.length > MAX_VISIBLE && (
<li className="px-sp-3 py-1 text-fs-11 text-faint">
+{filtered.length - MAX_VISIBLE} more — keep typing to narrow
</li>
)}
</ul>,
document.body,
)}
</>
);
}
-383
View File
@@ -1,383 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { BranchInfo, DaemonInfo, Project, Session, SpawnPreset } from "../../types.js";
import { SpawnForm } from "./SpawnForm.js";
vi.mock("../../api.js", () => ({
createSession: vi.fn(),
fetchBranches: vi.fn(),
fetchNames: vi.fn(),
getDaemonInfo: vi.fn(),
gitRoot: vi.fn(),
listDir: vi.fn(),
projects: vi.fn(),
}));
import {
createSession,
fetchBranches,
fetchNames,
getDaemonInfo,
gitRoot,
listDir,
projects as fetchProjects,
} from "../../api.js";
const mockCreateSession = vi.mocked(createSession);
const mockFetchBranches = vi.mocked(fetchBranches);
const mockFetchNames = vi.mocked(fetchNames);
const mockGetDaemonInfo = vi.mocked(getDaemonInfo);
const mockGitRoot = vi.mocked(gitRoot);
const mockListDir = vi.mocked(listDir);
const mockFetchProjects = vi.mocked(fetchProjects);
const presets: SpawnPreset[] = [
{ id: "claude", label: "Claude", command: "claude" },
{ id: "opencode", label: "opencode", command: "opencode" },
{ id: "zsh", label: "Shell", command: "zsh" },
{ id: "custom", label: "Custom…", command: "" },
];
function info(over: Partial<DaemonInfo> = {}): DaemonInfo {
return { hostname: "h", version: "1", home_dir: "/home/n", presets, ...over };
}
function project(over: Partial<Project> = {}): Project {
return { path: "/repos/p", name: "p", last_activity: new Date().toISOString(), ...over };
}
function session(over: Partial<Session> = {}): Session {
return {
id: "s1",
cwd: "/repos/p",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
...over,
};
}
/** Defaults every test starts from: one known project, no branches, a fresh
* generated handle. Individual tests override what they care about. */
function stub(over: { projects?: Project[]; info?: DaemonInfo; names?: string[]; branches?: BranchInfo[] } = {}) {
mockGetDaemonInfo.mockResolvedValue(over.info ?? info());
mockFetchProjects.mockResolvedValue(over.projects ?? [project()]);
mockFetchNames.mockResolvedValue({ names: over.names ?? ["amber-fox"] });
mockFetchBranches.mockResolvedValue(over.branches ?? []);
mockGitRoot.mockResolvedValue({});
mockListDir.mockResolvedValue({ path: "", entries: [] });
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
async function settle() {
await waitFor(() => expect(screen.queryByText("Loading projects…")).toBeNull());
}
/** Everything worktree/name/structured-related lives behind the "Advanced"
* disclosure, collapsed by default (#93) — mirrors the vanilla form exactly. */
function openAdvanced() {
fireEvent.click(screen.getByRole("button", { name: /Advanced/ }));
}
describe("SpawnForm harness picker", () => {
it("selects the first non-custom preset by default and reveals the custom input on Custom…", async () => {
stub();
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
expect(screen.getByRole("button", { name: /^Claude/ }).className).toContain("border-accent");
fireEvent.click(screen.getByRole("button", { name: "Custom…" }));
expect(screen.getByPlaceholderText("bash -l").hidden).toBe(false);
});
it("resolves a seeded command to its matching preset", async () => {
stub();
render(<SpawnForm seed={{ command: "opencode" }} onSuccess={vi.fn()} />);
await settle();
const opencode = screen.getByRole("button", { name: /opencode/ });
expect(opencode.className).toContain("border-accent");
});
it("drops an unmatched seeded command into the custom field", async () => {
stub();
render(<SpawnForm seed={{ command: "hx src/" }} onSuccess={vi.fn()} />);
await settle();
const raw = screen.getByPlaceholderText("bash -l") as HTMLInputElement;
expect(raw.hidden).toBe(false);
expect(raw.value).toBe("hx src/");
});
});
describe("SpawnForm project picker", () => {
it("lists discovered projects and reveals the worktree step on pick", async () => {
stub({ projects: [project({ path: "/repos/a", name: "a" }), project({ path: "/repos/b", name: "b" })] });
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
openAdvanced();
expect(screen.queryByText("Worktree")).toBeNull();
fireEvent.click(screen.getByText("a"));
expect(screen.getByText("Worktree")).not.toBeNull();
expect(screen.getByText("+ new").closest("button")?.getAttribute("data-active")).toBe("true");
});
it("filters the project grid by name", async () => {
stub({ projects: [project({ path: "/repos/alpha", name: "alpha" }), project({ path: "/repos/beta", name: "beta" })] });
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
fireEvent.change(screen.getByPlaceholderText("Filter projects…"), { target: { value: "alp" } });
expect(screen.getByText("alpha")).not.toBeNull();
expect(screen.queryByText("beta")).toBeNull();
});
it("falls back to the raw directory input when no projects are discovered", async () => {
stub({ projects: [] });
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
expect((screen.getByPlaceholderText("/home/user/project") as HTMLInputElement).value).toBe("/home/n");
});
it("Other directory… clears the picker and opens the raw input", async () => {
stub({ projects: [project({ path: "/repos/a", name: "a" })] });
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
fireEvent.click(screen.getByText("a"));
expect(screen.getByText("a").closest("button")?.getAttribute("data-active")).toBe("true");
fireEvent.click(screen.getByText("Other directory…"));
expect(screen.getByText("a").closest("button")?.getAttribute("data-active")).toBeNull();
expect((screen.getByPlaceholderText("/home/user/project") as HTMLInputElement).value).toBe("/repos/a");
});
});
describe("SpawnForm worktree defaults", () => {
it("defaults the toggle on for a picked project and infers the repo without a gitroot probe", async () => {
stub({ projects: [project({ path: "/repos/a", name: "a" })] });
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
openAdvanced();
fireEvent.click(screen.getByText("a"));
expect(screen.getByText("repo: /repos/a")).not.toBeNull();
expect(mockGitRoot).not.toHaveBeenCalled();
});
it("defaults the toggle off for a project that is itself a worktree (#91)", async () => {
stub({
projects: [
project({ path: "/repos/main", name: "main" }),
project({ path: "/repos/wt", name: "wt", is_worktree: true, base_repo: "/repos/main" }),
],
});
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
openAdvanced();
fireEvent.click(screen.getByText("main"));
fireEvent.click(screen.getByText("wt"));
expect((screen.getByLabelText("Create git worktree") as HTMLInputElement).checked).toBe(false);
});
it("still probes gitroot for a free-text directory", async () => {
stub();
mockGitRoot.mockResolvedValue({ root: "/repos/found" });
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
openAdvanced();
fireEvent.click(screen.getByText("Other directory…"));
fireEvent.change(screen.getByPlaceholderText("/home/user/project"), {
target: { value: "/repos/found/sub" },
});
await waitFor(() => expect(mockGitRoot).toHaveBeenCalledWith("/repos/found/sub"));
expect(await screen.findByText("repo: /repos/found")).not.toBeNull();
});
it("populates existing branches, disabling the one held by another worktree", async () => {
stub({
projects: [project({ path: "/repos/a", name: "a" })],
branches: [
{ name: "main", is_head: true },
{ name: "feat/x", is_head: false, worktree: "/repos/other" },
{ name: "feat/y", is_head: false },
],
});
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
openAdvanced();
fireEvent.click(screen.getByText("a"));
fireEvent.click(screen.getByLabelText("Existing branch"));
const y = await screen.findByRole("option", { name: "feat/y" });
expect((y as HTMLOptionElement).disabled).toBe(false);
expect((screen.getByRole("option", { name: /main — held by the main checkout/ }) as HTMLOptionElement).disabled).toBe(
true,
);
});
it("submits the first pickable branch when the operator never opens the select", async () => {
stub({
projects: [project({ path: "/repos/a", name: "a" })],
branches: [
{ name: "main", is_head: true },
{ name: "feat/y", is_head: false },
],
});
mockCreateSession.mockResolvedValue(session());
const onSuccess = vi.fn();
render(<SpawnForm onSuccess={onSuccess} />);
await settle();
openAdvanced();
fireEvent.click(screen.getByText("a"));
fireEvent.click(screen.getByLabelText("Existing branch"));
await screen.findByRole("option", { name: "feat/y" });
fireEvent.click(screen.getByRole("button", { name: "Spawn" }));
await waitFor(() => expect(onSuccess).toHaveBeenCalled());
expect(mockCreateSession).toHaveBeenCalledWith(
expect.objectContaining({ worktree: expect.objectContaining({ branch: "feat/y", create_branch: false }) }),
);
});
});
describe("SpawnForm structured view", () => {
it("defaults on for a resolved Claude Code command and off for an unsupported one", async () => {
stub();
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
openAdvanced();
expect((screen.getByLabelText("Structured view") as HTMLInputElement).checked).toBe(true);
fireEvent.click(screen.getByRole("button", { name: /^Shell/ }));
expect(screen.queryByLabelText("Structured view")).toBeNull();
});
it("keeps an unchecked box unchecked across a command change between two supported tools", async () => {
stub();
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
openAdvanced();
fireEvent.click(screen.getByLabelText("Structured view"));
fireEvent.click(screen.getByRole("button", { name: /opencode/ }));
fireEvent.click(screen.getByRole("button", { name: /^Claude/ }));
expect((screen.getByLabelText("Structured view") as HTMLInputElement).checked).toBe(false);
});
});
describe("SpawnForm submit", () => {
it("builds and posts the request, reporting the created session", async () => {
stub({ projects: [project({ path: "/repos/a", name: "a" })] });
const created = session();
mockCreateSession.mockResolvedValue(created);
const onSuccess = vi.fn();
render(<SpawnForm onSuccess={onSuccess} />);
await settle();
fireEvent.click(screen.getByText("a"));
fireEvent.click(screen.getByRole("button", { name: "Spawn" }));
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(created));
expect(mockCreateSession).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/repos/a", command: "claude" }),
);
});
it("shows the daemon's rejection and leaves the form open", async () => {
stub({ projects: [project({ path: "/repos/a", name: "a" })] });
mockCreateSession.mockRejectedValue(new Error("cwd is not a directory"));
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
fireEvent.click(screen.getByText("a"));
fireEvent.click(screen.getByRole("button", { name: "Spawn" }));
expect(await screen.findByText("cwd is not a directory")).not.toBeNull();
});
it("rejects an existing-branch worktree with nothing picked", async () => {
stub({ projects: [project({ path: "/repos/a", name: "a" })], branches: [] });
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
openAdvanced();
fireEvent.click(screen.getByText("a"));
fireEvent.click(screen.getByLabelText("Existing branch"));
await screen.findByText("No local branches found.");
fireEvent.click(screen.getByRole("button", { name: "Spawn" }));
expect(await screen.findByText("Pick an existing branch.")).not.toBeNull();
expect(mockCreateSession).not.toHaveBeenCalled();
});
});
describe("SpawnForm Enter-to-submit (#97)", () => {
it("submits from the session name field", async () => {
stub({ projects: [project({ path: "/repos/a", name: "a" })] });
mockCreateSession.mockResolvedValue(session());
const onSuccess = vi.fn();
render(<SpawnForm onSuccess={onSuccess} />);
await settle();
fireEvent.click(screen.getByText("a"));
openAdvanced();
fireEvent.keyDown(screen.getByPlaceholderText("named after your first prompt"), { key: "Enter" });
await waitFor(() => expect(onSuccess).toHaveBeenCalled());
});
it("first Enter picks a not-yet-active project box, the second submits", async () => {
stub({ projects: [project({ path: "/repos/a", name: "a" }), project({ path: "/repos/b", name: "b" })] });
mockCreateSession.mockResolvedValue(session());
const onSuccess = vi.fn();
render(<SpawnForm onSuccess={onSuccess} />);
await settle();
const boxB = screen.getByText("b").closest("button")!;
fireEvent.keyDown(boxB, { key: "Enter" });
expect(onSuccess).not.toHaveBeenCalled();
expect(boxB.getAttribute("data-active")).toBe("true");
fireEvent.keyDown(boxB, { key: "Enter" });
await waitFor(() => expect(onSuccess).toHaveBeenCalled());
});
it("toggles the worktree checkbox on Enter instead of submitting", async () => {
stub({ projects: [project({ path: "/repos/a", name: "a" })] });
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
openAdvanced();
fireEvent.click(screen.getByText("a"));
const checkbox = screen.getByLabelText("Create git worktree") as HTMLInputElement;
expect(checkbox.checked).toBe(true);
fireEvent.keyDown(checkbox, { key: "Enter" });
expect(checkbox.checked).toBe(false);
expect(mockCreateSession).not.toHaveBeenCalled();
});
});
-853
View File
@@ -1,853 +0,0 @@
// React port of ../../spawnForm.ts: the spawn form itself — preset tiles,
// two-step project picker, name, structured-view checkbox, worktree. Mounted
// inside SpawnModal.
//
// Working-directory picker (#95), unchanged from the vanilla form:
// 1. Step one is one box per repository (name, then its parent directory).
// A worktree is a box only when its main checkout is missing from the
// scan.
// 2. The filter keeps a repository when the query matches it or any of its
// worktrees.
// 3. Picking a box reveals step two: "+ new", then that repository's
// worktrees newest-first. "Other directory…" clears both steps.
// 4. "+ new" is the default selection — except on a box that is itself a
// linked worktree, which defaults to that worktree.
// 5. "+ new" means create: cwd is the repository, create-worktree on. An
// existing worktree means run in it: cwd is that worktree, create-worktree
// off (the daemon never adopts, docs/WORKTREES.md).
// 6. Neither step probes /api/gitroot; only the free-text hatch does.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
createSession,
fetchBranches,
fetchNames,
getDaemonInfo,
gitRoot,
projects as fetchProjects,
} from "../../api.js";
import { parentDir } from "../../paths.js";
import {
filterProjects,
groupProjects,
isStale,
sortProjects,
type ProjectGroup,
} from "../../projectList.js";
import {
buildSpawnRequest,
FALLBACK_PRESETS,
supportsStructuredView,
type SpawnFormSeed,
type SpawnFormValues,
type WorktreeBranchMode,
} from "../../spawnForm.js";
import { splitCommand, toolIconSvg, TOOL_LABELS } from "../../toolIcon.js";
import { abbreviateCwd } from "../../types.js";
import type { BranchInfo, Project, Session, SpawnPreset } from "../../types.js";
import { BranchPicker } from "./BranchPicker.js";
import { DirAutocomplete } from "./DirAutocomplete.js";
/** Quiet period after raw-path typing before `/api/gitroot` fires (#74). Must
* stay equal to `GITROOT_PROBE_DEBOUNCE_MS` in crates/maestro-tui/src/draft.rs
* and to ../../spawnForm.ts's own constant, so every client debounces alike. */
const GITROOT_PROBE_DEBOUNCE_MS = 250;
/** "new" (create a worktree) or the path of an existing worktree to run in. */
type WorktreeChoice = "new" | string;
type BaseRepoResolution =
| { kind: "disabled" }
| { kind: "no-cwd" }
| { kind: "found"; repo: string }
| { kind: "probe" };
function resolveBaseRepoFor(cwd: string, wtEnabled: boolean, discovered: Project[]): BaseRepoResolution {
if (!wtEnabled) return { kind: "disabled" };
if (!cwd) return { kind: "no-cwd" };
const project = discovered.find((p) => p.path === cwd);
if (project) return { kind: "found", repo: project.base_repo ?? project.path };
return { kind: "probe" };
}
/** Every worktree of a group, including the group's own box when the scan
* found no main checkout to nest it under. */
function worktreesOf(group: ProjectGroup): Project[] {
return [group.main, ...group.worktrees].filter((p) => p.is_worktree);
}
/** Resolve a preset id to a tool icon id — the preset's command is the source
* of truth, the same way the sidebar derives one. The "Custom…" preset (empty
* command) has no tool to show. */
function presetToolId(p: SpawnPreset): string | null {
if (!p.command) return null;
return splitCommand(p.command).tool;
}
function harnessIcon(toolId: string): string | null {
const small = toolIconSvg(toolId);
if (!small) return null;
// toolIconSvg renders at 13x13 (viewBox 0 0 16 16) for the sidebar; the
// harness card wants the same mark at 24x24 so it breathes.
return small.replace(/width="13"/g, 'width="24"').replace(/height="13"/g, 'height="24"');
}
function pickerButtonClass(active: boolean): string {
return `flex min-h-[32px] items-baseline justify-between gap-sp-3 rounded-r-2 border px-sp-3 py-1.5 text-left font-mono ${
active ? "border-accent bg-accent-tint" : "border-border bg-surface-2 hover:border-border-strong"
}`;
}
export interface SpawnFormProps {
seed?: SpawnFormSeed;
/** Fired whenever any value changes, once the form's initial seed settles. */
onChange?(values: SpawnFormValues): void;
onSuccess(session: Session): void;
}
export function SpawnForm({ seed, onChange, onSuccess }: SpawnFormProps) {
const prefill = useRef(seed ?? {}).current;
const formRef = useRef<HTMLFormElement>(null);
const filterInputRef = useRef<HTMLInputElement>(null);
const customCommandRef = useRef<HTMLInputElement>(null);
const rawCwdRef = useRef<HTMLInputElement>(null);
const destroyedRef = useRef(false);
useEffect(() => () => {
destroyedRef.current = true;
}, []);
// ---- harness / command -------------------------------------------------
const [homeDir, setHomeDir] = useState("");
const [infoLoading, setInfoLoading] = useState(true);
const [presets, setPresets] = useState<SpawnPreset[]>(FALLBACK_PRESETS);
const [selectedPresetId, setSelectedPresetId] = useState("");
const [command, setCommand] = useState("");
const [customCommand, setCustomCommand] = useState("");
const [commandTouched, setCommandTouched] = useState(false);
const [structuredView, setStructuredView] = useState(false);
const [structuredTouched, setStructuredTouched] = useState(false);
const activePreset = useMemo(() => presets.find((p) => p.id === selectedPresetId), [presets, selectedPresetId]);
const isCustomSelected = !activePreset || activePreset.command === "";
useEffect(() => {
if (commandTouched && isCustomSelected) customCommandRef.current?.focus();
}, [isCustomSelected, commandTouched]);
useEffect(() => {
let cancelled = false;
getDaemonInfo()
.then((info) => {
if (cancelled) return;
setHomeDir(info.home_dir ?? "");
if (info.presets?.length) setPresets(info.presets);
})
.catch(() => {
// keep fallbacks
})
.finally(() => {
if (!cancelled) setInfoLoading(false);
});
return () => {
cancelled = true;
};
}, []);
// Pick a sensible starting command whenever the preset list changes and the
// operator hasn't touched it yet: a seed's command (matched to a preset, or
// dropped into custom), else the first non-custom preset.
useEffect(() => {
if (commandTouched) return;
const wanted = prefill.command;
if (wanted) {
const match = presets.find((p) => p.command === wanted && p.command !== "");
if (match) {
setSelectedPresetId(match.id);
setCommand(match.command);
return;
}
const custom = presets.find((p) => p.command === "");
setCustomCommand(wanted);
setSelectedPresetId(custom ? custom.id : "custom");
setCommand(wanted);
return;
}
const first = presets.find((p) => p.command !== "");
if (first) {
setSelectedPresetId(first.id);
setCommand(first.command);
}
// prefill is captured once at construction, not a reactive dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [presets, commandTouched]);
const structuredSupported = useMemo(() => supportsStructuredView(command), [command]);
useEffect(() => {
if (!structuredSupported) setStructuredView(false);
else if (!structuredTouched) setStructuredView(true);
}, [structuredSupported, structuredTouched]);
// ---- project picker ------------------------------------------------------
const [discovered, setDiscovered] = useState<Project[]>([]);
const [projectsLoading, setProjectsLoading] = useState(true);
const [filterQuery, setFilterQuery] = useState("");
const [cwd, setCwd] = useState("");
const [cwdRawVisible, setCwdRawVisible] = useState(false);
const [rawFocusToken, setRawFocusToken] = useState(0);
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(null);
const [worktreeChoice, setWorktreeChoice] = useState<WorktreeChoice>("new");
const groups = useMemo(() => groupProjects(sortProjects(discovered, new Date())), [discovered]);
const visibleGroups = useMemo(
() => groups.filter((g) => filterProjects([g.main, ...g.worktrees], filterQuery).length > 0),
[groups, filterQuery],
);
const selectedGroup = useMemo(
() => groups.find((g) => g.main.path === selectedGroupPath) ?? null,
[groups, selectedGroupPath],
);
useEffect(() => {
let cancelled = false;
fetchProjects()
.then((list) => {
if (!cancelled) setDiscovered(list);
})
.catch(() => {
if (!cancelled) setDiscovered([]);
})
.finally(() => {
if (!cancelled) setProjectsLoading(false);
});
return () => {
cancelled = true;
};
}, []);
// ---- worktree (branch-only; base_repo inferred via scan or /gitroot) ----
const [wtEnabled, setWtEnabled] = useState(prefill.worktreeEnabled ?? true);
const [inferredBaseRepo, setInferredBaseRepo] = useState<string | null>(null);
const [wtHint, setWtHint] = useState<string | null>(null);
const [name, setName] = useState(prefill.name ?? "");
const [branchEdited, setBranchEdited] = useState(Boolean(prefill.worktreeBranchEdited));
const [branchMode, setBranchMode] = useState<WorktreeBranchMode>(prefill.worktreeBranchMode ?? "new");
const [newBranch, setNewBranch] = useState(prefill.worktreeBranch ?? "");
const [existingBranch, setExistingBranch] = useState("");
const [branchCache, setBranchCache] = useState<{ cwd: string; branches: BranchInfo[] } | null>(null);
const [advancedCollapsed, setAdvancedCollapsed] = useState(true);
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [ready, setReady] = useState(false);
const probeTokenRef = useRef(0);
const probeTimerRef = useRef<number | undefined>(undefined);
const applyBaseRepoResolution = useCallback((res: BaseRepoResolution) => {
switch (res.kind) {
case "disabled":
setInferredBaseRepo(null);
setWtHint(null);
return;
case "no-cwd":
setInferredBaseRepo(null);
setWtHint("Choose a working directory first.");
return;
case "found":
setInferredBaseRepo(res.repo);
setWtHint(null);
setBranchCache(null);
setExistingBranch("");
return;
case "probe":
return; // caller schedules the probe
}
}, []);
const probeGitRoot = useCallback(async (path: string) => {
const token = ++probeTokenRef.current;
if (!path) {
setInferredBaseRepo(null);
setWtHint("Choose a working directory first.");
return;
}
setWtHint("Checking for a git repository…");
let root: string | undefined;
try {
root = (await gitRoot(path)).root;
} catch {
root = undefined;
}
if (token !== probeTokenRef.current || destroyedRef.current) return; // a newer probe superseded us
if (root) {
setInferredBaseRepo(root);
setWtHint(null);
setBranchCache(null);
setExistingBranch("");
} else {
setInferredBaseRepo(null);
// Publish the untick too, or a later submit fails with an inferred repo
// that no longer matches an unticked box.
setWtEnabled(false);
setWtHint("Not inside a git repository — pick a dir in a repo to use a worktree.");
}
}, []);
const scheduleGitRootProbe = useCallback(
(path: string) => {
window.clearTimeout(probeTimerRef.current);
probeTimerRef.current = window.setTimeout(() => {
probeTimerRef.current = undefined;
void probeGitRoot(path);
}, GITROOT_PROBE_DEBOUNCE_MS);
},
[probeGitRoot],
);
useEffect(() => () => window.clearTimeout(probeTimerRef.current), []);
// Seed the form once the initial project scan and daemon info have both
// settled — mirrors createSpawnForm's `loadProjects()`. Computed from local
// values rather than reading state back, since the setState calls below
// won't have committed yet within this same effect.
const seededRef = useRef(false);
useEffect(() => {
if (infoLoading || projectsLoading || seededRef.current) return;
seededRef.current = true;
const seedCwd = prefill.cwd ?? null;
let finalCwd = "";
let finalCwdRawVisible = false;
let finalGroupPath: string | null = null;
let finalChoice: WorktreeChoice = "new";
const restore = (path: string): boolean => {
for (const group of groups) {
const wt = worktreesOf(group).find((p) => p.path === path);
const choice: WorktreeChoice | null = wt ? wt.path : group.main.path === path ? "new" : null;
if (choice === null) continue;
finalGroupPath = group.main.path;
finalChoice = choice;
return true;
}
return false;
};
if (seedCwd) {
if (prefill.cwdRawVisible === false) {
finalCwd = seedCwd;
if (!restore(seedCwd)) finalCwdRawVisible = true;
} else {
finalCwd = seedCwd;
finalCwdRawVisible = true;
}
} else if (discovered.length === 0) {
finalCwd = homeDir;
finalCwdRawVisible = true;
}
setCwd(finalCwd);
setCwdRawVisible(finalCwdRawVisible);
setSelectedGroupPath(finalGroupPath);
setWorktreeChoice(finalChoice);
const res = resolveBaseRepoFor(finalCwd, wtEnabled, discovered);
applyBaseRepoResolution(res);
if (res.kind === "probe") scheduleGitRootProbe(finalCwd);
setReady(true);
// groups/wtEnabled/discovered/homeDir are read for this one settle pass;
// re-running on their later changes would re-seed a form the operator
// has since edited.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [infoLoading, projectsLoading]);
// ---- name (also seeds the worktree branch) ------------------------------
const skipInitialBranchSync = useRef(Boolean(prefill.worktreeBranch));
useEffect(() => {
// One-time construction seeding, mirroring spawnForm.ts precedence: an
// explicit seed wins outright; otherwise the prefilled name; otherwise a
// freshly generated handle (#53).
if (prefill.worktreeBranch) return;
if (!branchEdited && branchMode === "new" && prefill.name) return; // handled below, mount-only
if (!branchEdited && branchMode === "new") {
void fetchNames(1)
.then((batch) => {
const handle = batch.names[0];
if (destroyedRef.current || !handle) return;
setNewBranch((cur) => cur || handle);
})
.catch(() => {
// the operator types their own branch
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!skipInitialBranchSync.current && !branchEdited && branchMode === "new" && name) {
setNewBranch(name);
}
skipInitialBranchSync.current = false;
// A cleared name leaves the branch on its last value; only `name` changes
// (typed or the mount-time prefill) drive this sync.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [name]);
// ---- existing-branch picker (#61): fetch once per cwd -------------------
useEffect(() => {
if (branchMode !== "existing" || !cwd || !inferredBaseRepo) return;
if (branchCache?.cwd === cwd) return;
let cancelled = false;
const target = cwd;
fetchBranches(target)
.then((branches) => {
if (cancelled || destroyedRef.current) return;
setBranchCache({ cwd: target, branches });
})
.catch(() => {
// the picker just stays empty; "new" mode still works
});
return () => {
cancelled = true;
};
}, [branchMode, cwd, inferredBaseRepo, branchCache]);
const branchesForCwd = branchCache?.cwd === cwd ? branchCache.branches : undefined;
// A native <select> defaults its displayed (and reported) value to the
// first enabled option once nothing is explicitly selected. Derived at
// render/submit time rather than synced via an effect, so there is no
// window where the control shows one branch while state still holds "".
const effectiveExistingBranch =
existingBranch || branchesForCwd?.find((b) => !b.worktree && !b.is_head)?.name || "";
// ---- emit (#observability parity with spawnForm.ts's onChange) ---------
useEffect(() => {
if (!ready) return;
onChange?.({
command,
cwd,
name,
structuredView,
autoApprove: activePreset?.auto_approve ?? false,
worktreeEnabled: wtEnabled,
worktreeBranch: branchMode === "existing" ? effectiveExistingBranch : newBranch,
worktreeBranchEdited: branchEdited,
worktreeBranchMode: branchMode,
cwdRawVisible,
});
}, [
ready,
command,
cwd,
name,
structuredView,
activePreset,
wtEnabled,
branchMode,
effectiveExistingBranch,
newBranch,
branchEdited,
cwdRawVisible,
onChange,
]);
// ---- handlers -------------------------------------------------------------
const selectPreset = useCallback(
(id: string) => {
setCommandTouched(true);
setSelectedPresetId(id);
const preset = presets.find((p) => p.id === id);
if (!preset || preset.command === "") setCommand(customCommand.trim());
else setCommand(preset.command);
},
[presets, customCommand],
);
const clearProjectSelection = useCallback(() => {
setSelectedGroupPath(null);
setWorktreeChoice("new");
}, []);
const showRawCwd = useCallback(
(prefillValue?: string, focus = true) => {
setCwdRawVisible(true);
if (prefillValue != null) setCwd(prefillValue);
clearProjectSelection();
if (focus) setRawFocusToken((t) => t + 1);
},
[clearProjectSelection],
);
useEffect(() => {
if (rawFocusToken === 0) return;
rawCwdRef.current?.focus();
rawCwdRef.current?.select();
}, [rawFocusToken]);
const applyWorktreeChoice = useCallback(
(group: ProjectGroup, choice: WorktreeChoice) => {
const target = choice === "new" ? group.main : worktreesOf(group).find((p) => p.path === choice);
if (!target) return;
setWorktreeChoice(choice);
setCwd(target.path);
setBranchCache(null);
setExistingBranch("");
if (choice === "new") {
setWtEnabled(true);
applyBaseRepoResolution({ kind: "found", repo: group.main.base_repo ?? group.main.path });
} else {
setWtEnabled(false);
applyBaseRepoResolution({ kind: "disabled" });
}
},
[applyBaseRepoResolution],
);
const selectProject = useCallback(
(group: ProjectGroup) => {
setSelectedGroupPath(group.main.path);
setCwdRawVisible(false);
// Rule 4: a box that is itself a linked worktree defaults to running there.
applyWorktreeChoice(group, group.main.is_worktree ? group.main.path : "new");
},
[applyWorktreeChoice],
);
const onWtEnabledChange = useCallback(
(next: boolean) => {
setWtEnabled(next);
const res = resolveBaseRepoFor(cwd, next, discovered);
applyBaseRepoResolution(res);
if (res.kind === "probe") scheduleGitRootProbe(cwd);
},
[cwd, discovered, applyBaseRepoResolution, scheduleGitRootProbe],
);
const onRawCwdChange = useCallback(
(v: string) => {
setCwd(v);
setBranchCache(null);
setExistingBranch("");
if (wtEnabled) scheduleGitRootProbe(v);
},
[wtEnabled, scheduleGitRootProbe],
);
const onSubmit = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError("");
const values: SpawnFormValues = {
command,
cwd,
name,
structuredView,
autoApprove: activePreset?.auto_approve ?? false,
worktreeEnabled: wtEnabled,
worktreeBranch: branchMode === "existing" ? effectiveExistingBranch : newBranch,
worktreeBranchEdited: branchEdited,
worktreeBranchMode: branchMode,
};
const built = buildSpawnRequest(values, inferredBaseRepo);
if ("error" in built) {
setError(built.error);
return;
}
setSubmitting(true);
try {
const session = await createSession(built.req);
onSuccess(session);
} catch (err) {
setError((err as Error).message);
setSubmitting(false);
}
},
[
command,
cwd,
name,
structuredView,
activePreset,
wtEnabled,
branchMode,
effectiveExistingBranch,
newBranch,
branchEdited,
inferredBaseRepo,
onSuccess,
],
);
// ---- Enter submits from anywhere (#97) ------------------------------------
const onFormKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLFormElement>) => {
if (e.key !== "Enter" || e.defaultPrevented) return;
const target = e.target as HTMLElement;
if (target.dataset.role === "toggle") {
e.preventDefault();
(target as HTMLInputElement).click();
return;
}
if (target instanceof HTMLButtonElement && target.dataset.picker) {
e.preventDefault();
if (target.dataset.active === "true") {
formRef.current?.requestSubmit();
} else {
target.click();
}
return;
}
e.preventDefault();
if (target === filterInputRef.current) {
// Resolve the filter to a project only when the current pick isn't
// one of the matches: a filter typed past it means the operator
// moved on, while a pick that still matches keeps its worktree choice.
const stillMatches =
selectedGroup != null && visibleGroups.some((g) => g.main.path === selectedGroup.main.path);
if (!stillMatches && visibleGroups[0]) selectProject(visibleGroups[0]);
}
formRef.current?.requestSubmit();
},
[selectedGroup, visibleGroups, selectProject],
);
return (
<form
ref={formRef}
onSubmit={onSubmit}
onKeyDown={onFormKeyDown}
className="mx-auto flex w-full max-w-[60ch] flex-col gap-sp-2 overflow-auto p-sp-3"
>
<div className="flex flex-col gap-sp-2">
<span className="text-fs-13 text-ink">Harness</span>
<div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-sp-2">
{presets.map((p) => {
const isCustom = p.command === "";
const toolId = presetToolId(p);
const icon = isCustom ? null : toolId ? harnessIcon(toolId) : harnessIcon("shell");
const label = TOOL_LABELS[toolId ?? ""] ?? p.label;
const active = p.id === selectedPresetId;
return (
<button
key={p.id}
type="button"
className={`flex flex-col items-center gap-sp-2 rounded-r-2 border px-sp-2 py-sp-3 text-center ${
active
? "border-accent bg-accent-tint"
: isCustom
? "border-dashed border-border text-muted"
: "border-border bg-surface-2 hover:border-border-strong"
}`}
onClick={() => selectPreset(p.id)}
>
<span
className={`flex h-8 w-8 items-center justify-center ${active ? "text-ink" : "text-muted"}`}
aria-hidden={icon ? undefined : true}
dangerouslySetInnerHTML={icon ? { __html: icon } : undefined}
>
{icon ? undefined : "…"}
</span>
<span className="max-w-full truncate text-fs-13 font-semibold">
{isCustom ? "Custom…" : label}
</span>
</button>
);
})}
</div>
<input
ref={customCommandRef}
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2.5 font-mono text-fs-15 text-ink"
name="command_raw"
type="text"
hidden={!isCustomSelected}
placeholder="bash -l"
autoComplete="off"
value={customCommand}
onChange={(e) => {
const v = e.currentTarget.value;
setCustomCommand(v);
if (isCustomSelected) {
setCommandTouched(true);
setCommand(v.trim());
}
}}
/>
</div>
<div className="flex flex-col gap-sp-2">
<span className="text-fs-13 text-ink">Project</span>
<input
ref={filterInputRef}
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2.5 font-mono text-fs-15 text-ink"
type="text"
placeholder="Filter projects…"
autoComplete="off"
value={filterQuery}
onChange={(e) => setFilterQuery(e.currentTarget.value)}
/>
{!ready ? (
<p className="m-0 px-sp-3 py-1.5 text-fs-13 text-faint">Loading projects…</p>
) : (
<div className="grid max-h-[300px] grid-cols-2 gap-sp-2 overflow-y-auto p-px">
{visibleGroups.map((group) => {
const active = selectedGroup?.main.path === group.main.path;
return (
<button
key={group.main.path}
type="button"
data-picker="project"
data-active={active || undefined}
className={`flex min-w-0 flex-col gap-0.5 rounded-r-2 border px-sp-3 py-sp-2 text-left ${
active ? "border-accent bg-accent-tint" : "border-border bg-surface-2 hover:border-border-strong"
} ${isStale(group.main, new Date()) ? "opacity-55" : ""}`}
onClick={() => selectProject(group)}
>
<span className="truncate text-fs-13 font-semibold text-ink">{group.main.name}</span>
<span className="break-words font-mono text-fs-12 text-muted">
{abbreviateCwd(parentDir(group.main.path), homeDir)}
</span>
</button>
);
})}
{discovered.length > 0 && visibleGroups.length === 0 && (
<p className="col-span-2 m-0 px-sp-3 py-1.5 text-fs-13 text-faint">No projects match.</p>
)}
</div>
)}
<button
type="button"
className="self-start bg-transparent px-0 py-1 text-fs-13 italic text-muted hover:text-ink"
onClick={() => showRawCwd(cwd || homeDir)}
>
Other directory…
</button>
{cwdRawVisible && (
<DirAutocomplete
inputRef={rawCwdRef}
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2.5 font-mono text-fs-15 text-ink"
name="cwd_raw"
type="text"
placeholder="/home/user/project"
value={cwd}
onChange={onRawCwdChange}
/>
)}
</div>
<div className="flex flex-col border-t border-border pt-sp-2">
<button
type="button"
className="flex w-full items-center gap-sp-1 py-sp-2 text-left text-fs-11 text-muted"
aria-expanded={!advancedCollapsed}
onClick={() => setAdvancedCollapsed((c) => !c)}
>
<span className={`inline-block transition-transform ${advancedCollapsed ? "-rotate-90" : ""}`}>▾</span>
<span className="font-semibold">Advanced</span>
<span className="ml-auto text-fs-12 text-muted">{wtEnabled ? "creates a worktree" : "no worktree"}</span>
</button>
{!advancedCollapsed && (
<div className="flex flex-col gap-sp-3 py-sp-2">
<label className="flex flex-col gap-1 text-fs-12 text-muted">
Session name
<input
name="name"
type="text"
autoComplete="off"
placeholder="named after your first prompt"
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2.5 font-mono text-fs-15 text-ink"
value={name}
onChange={(e) => setName(e.currentTarget.value)}
/>
</label>
{structuredSupported && (
<label className="flex cursor-pointer items-center gap-sp-2 text-fs-14 text-ink">
<input
type="checkbox"
data-role="toggle"
className="size-[18px] accent-accent"
checked={structuredView}
onChange={(e) => {
setStructuredTouched(true);
setStructuredView(e.currentTarget.checked);
}}
/>
Structured view
</label>
)}
{selectedGroup && (
<div className="flex flex-col gap-sp-2">
<span className="text-fs-13 text-ink">Worktree</span>
<div className="flex max-h-[200px] flex-col gap-sp-1 overflow-y-auto">
<button
type="button"
data-picker="worktree"
data-active={worktreeChoice === "new" || undefined}
className={pickerButtonClass(worktreeChoice === "new")}
onClick={() => applyWorktreeChoice(selectedGroup, "new")}
>
<span className="text-fs-13 font-semibold text-accent">+ new</span>
<span className="text-right text-fs-12 text-muted">
new worktree of {selectedGroup.main.name}
</span>
</button>
{worktreesOf(selectedGroup).map((wt) => (
<button
key={wt.path}
type="button"
data-picker="worktree"
data-active={worktreeChoice === wt.path || undefined}
className={pickerButtonClass(worktreeChoice === wt.path)}
onClick={() => applyWorktreeChoice(selectedGroup, wt.path)}
>
<span className="text-fs-13 font-semibold text-ink">{wt.name}</span>
<span className="text-right text-fs-12 text-muted">{wt.path}</span>
</button>
))}
</div>
</div>
)}
<BranchPicker
enabled={wtEnabled}
onToggleEnabled={onWtEnabledChange}
hint={wtHint}
baseRepo={inferredBaseRepo}
mode={branchMode}
onModeChange={setBranchMode}
newBranch={newBranch}
onNewBranchChange={(v) => {
setBranchEdited(true);
setNewBranch(v);
}}
branches={branchesForCwd}
existingBranch={effectiveExistingBranch}
onExistingBranchChange={setExistingBranch}
/>
</div>
)}
</div>
<p role="alert" aria-live="polite" className={`m-0 text-fs-13 text-danger ${error ? "rounded-r-2 bg-danger-tint px-sp-3 py-sp-2" : "min-h-[1.2em]"}`}>
{error}
</p>
<div className="flex justify-end">
<button
type="submit"
disabled={submitting}
className="rounded-r-1 bg-accent px-sp-4 py-sp-2 text-fs-13 font-semibold text-accent-ink disabled:opacity-60"
>
Spawn
</button>
</div>
</form>
);
}
@@ -1,108 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonInfo, Project, Session } from "../../types.js";
import { SpawnModal } from "./SpawnModal.js";
vi.mock("../../api.js", () => ({
createSession: vi.fn(),
fetchBranches: vi.fn(),
fetchNames: vi.fn(),
getDaemonInfo: vi.fn(),
gitRoot: vi.fn(),
listDir: vi.fn(),
projects: vi.fn(),
}));
import { createSession, fetchNames, getDaemonInfo, listDir, projects as fetchProjects } from "../../api.js";
function info(): DaemonInfo {
return { hostname: "h", version: "1", home_dir: "/home/n", presets: [{ id: "claude", label: "Claude", command: "claude" }] };
}
function project(over: Partial<Project> = {}): Project {
return { path: "/repos/p", name: "p", ...over };
}
beforeEach(() => {
vi.mocked(getDaemonInfo).mockResolvedValue(info());
vi.mocked(fetchProjects).mockResolvedValue([project()]);
vi.mocked(fetchNames).mockResolvedValue({ names: ["amber-fox"] });
vi.mocked(listDir).mockResolvedValue({ path: "", entries: [] });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
async function settle() {
await waitFor(() => expect(screen.queryByText("Loading projects…")).toBeNull());
}
describe("SpawnModal", () => {
it("mounts the spawn form inside a dialog", async () => {
render(<SpawnModal onSuccess={vi.fn()} onClose={vi.fn()} />);
await settle();
expect(screen.getByRole("dialog")).not.toBeNull();
expect(screen.getByRole("button", { name: "Spawn" })).not.toBeNull();
});
it("titles a seeded open as a clone", async () => {
render(<SpawnModal seed={{ command: "claude" }} onSuccess={vi.fn()} onClose={vi.fn()} />);
await settle();
expect(screen.getByText("Spawn similar")).not.toBeNull();
});
it("titles a plain open as a new session", async () => {
render(<SpawnModal onSuccess={vi.fn()} onClose={vi.fn()} />);
await settle();
expect(screen.getByText("Spawn session")).not.toBeNull();
});
it("closes on Escape, on Cancel and on a backdrop click", async () => {
const onClose = vi.fn();
const { unmount } = render(<SpawnModal onSuccess={vi.fn()} onClose={onClose} />);
await settle();
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onClose).toHaveBeenCalledTimes(2);
unmount();
render(<SpawnModal onSuccess={vi.fn()} onClose={onClose} />);
await settle();
fireEvent.click(document.querySelector(".modal-backdrop")!);
expect(onClose).toHaveBeenCalledTimes(3);
});
it("closes before handing the created session to its caller", async () => {
const created: Session = {
id: "s1",
cwd: "/repos/p",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
};
vi.mocked(createSession).mockResolvedValue(created);
const calls: string[] = [];
const onClose = vi.fn(() => calls.push("close"));
const onSuccess = vi.fn(() => calls.push("success"));
render(<SpawnModal onSuccess={onSuccess} onClose={onClose} />);
await settle();
fireEvent.click(screen.getByText("p"));
fireEvent.click(screen.getByRole("button", { name: "Spawn" }));
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(created));
expect(calls).toEqual(["close", "success"]);
});
});
-82
View File
@@ -1,82 +0,0 @@
// React port of ../../spawnModal.ts: dialog chrome around SpawnForm. Escape,
// the ✕ and a click on the backdrop all close it; SpawnForm's own DirAutocomplete
// swallows Escape first, so dismissing a dropdown never also discards the form.
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import type { Session } from "../../types.js";
import { SpawnForm, type SpawnFormProps } from "./SpawnForm.js";
export interface SpawnModalProps {
seed?: SpawnFormProps["seed"];
/** Called with the created session after a successful submit — the modal
* has already closed by then, so the caller decides where it lands. */
onSuccess(session: Session): void;
onClose(): void;
}
export function SpawnModal({ seed, onSuccess, onClose }: SpawnModalProps) {
const filterInputRef = useRef<HTMLInputElement>(null);
const handleSuccess = (session: Session) => {
onClose();
onSuccess(session);
};
// The project filter owns the keyboard on open: typing narrows the list and
// Tab from here reaches the harness tiles, matching the TUI's draft pane.
useEffect(() => {
const host = filterInputRef.current;
host?.querySelector<HTMLInputElement>("input[placeholder='Filter projects…']")?.focus();
}, []);
return createPortal(
<div
className="modal-backdrop fixed inset-0 z-modal flex items-center justify-center bg-bg/70 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="spawn-title"
className="flex max-h-[92vh] w-full max-w-[560px] flex-col rounded-r-3 border border-border bg-surface shadow-2"
onKeyDown={(e) => {
if (e.key === "Escape" && !e.defaultPrevented) {
e.preventDefault();
onClose();
}
}}
>
<header className="flex items-center justify-between border-b border-border p-sp-3">
<h2 id="spawn-title" className="m-0 text-fs-15 font-semibold text-ink">
{seed ? "Spawn similar" : "Spawn session"}
</h2>
<button
type="button"
aria-label="Close"
className="rounded-r-1 px-sp-2 py-1 text-fs-15 text-muted hover:text-ink"
onClick={onClose}
>
✕
</button>
</header>
<div ref={filterInputRef} className="min-h-0 flex-1 overflow-auto">
<SpawnForm seed={seed} onSuccess={handleSuccess} />
</div>
<div className="flex justify-end gap-sp-2 border-t border-border p-sp-3">
<button
type="button"
className="rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
onClick={onClose}
>
Cancel
</button>
</div>
</div>
</div>,
document.body,
);
}
-8
View File
@@ -1,8 +0,0 @@
export { SpawnModal, type SpawnModalProps } from "./SpawnModal.js";
export { SpawnForm, type SpawnFormProps } from "./SpawnForm.js";
export { DirAutocomplete, type DirAutocompleteProps } from "./DirAutocomplete.js";
export { BranchPicker, type BranchPickerProps } from "./BranchPicker.js";
export { useFastForward, type UseFastForwardOptions } from "./useFastForward.js";
// spawnSimilar.ts is pure seed-building logic with no DOM dependency — reused
// as-is rather than re-implemented for React (#248).
export { spawnSimilarSeed, spawnSimilarProjectSeed } from "../../spawnSimilar.js";
@@ -1,114 +0,0 @@
// @vitest-environment happy-dom
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Project, Session, SpawnPreset } from "../../types.js";
import { useFastForward } from "./useFastForward.js";
vi.mock("../../api.js", () => ({
createSession: vi.fn(),
fetchNames: vi.fn(),
getDaemonInfo: vi.fn(),
projects: vi.fn(),
}));
import { createSession, fetchNames, getDaemonInfo, projects as fetchProjects } from "../../api.js";
const mockCreateSession = vi.mocked(createSession);
const mockFetchNames = vi.mocked(fetchNames);
const mockGetDaemonInfo = vi.mocked(getDaemonInfo);
const mockFetchProjects = vi.mocked(fetchProjects);
const presets: SpawnPreset[] = [{ id: "claude", label: "Claude", command: "claude" }];
function project(over: Partial<Project> = {}): Project {
return { path: "/repos/p", name: "p", base_repo: "/repos/p", last_command: "claude", ...over };
}
function session(): Session {
return {
id: "s1",
cwd: "/repos/p",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
};
}
afterEach(() => vi.clearAllMocks());
describe("useFastForward", () => {
it("spawns immediately once every default resolves", async () => {
mockFetchProjects.mockResolvedValue([project()]);
mockFetchNames.mockResolvedValue({ names: ["amber-fox"] });
mockGetDaemonInfo.mockResolvedValue({ hostname: "h", version: "1", presets });
const created = session();
mockCreateSession.mockResolvedValue(created);
const onSpawned = vi.fn();
const onFallback = vi.fn();
const { result } = renderHook(() => useFastForward({ onSpawned, onFallback }));
await act(() => result.current());
expect(mockCreateSession).toHaveBeenCalledWith(
expect.objectContaining({
cwd: "/repos/p",
command: "claude",
worktree: { base_repo: "/repos/p", branch: "amber-fox", create_branch: true },
}),
);
expect(onSpawned).toHaveBeenCalledWith(created);
expect(onFallback).not.toHaveBeenCalled();
});
it("falls back to a pre-filled seed when the daemon rejects the spawn", async () => {
mockFetchProjects.mockResolvedValue([project()]);
mockFetchNames.mockResolvedValue({ names: ["amber-fox"] });
mockGetDaemonInfo.mockResolvedValue({ hostname: "h", version: "1", presets });
mockCreateSession.mockRejectedValue(new Error("boom"));
const onSpawned = vi.fn();
const onFallback = vi.fn();
const { result } = renderHook(() => useFastForward({ onSpawned, onFallback }));
await act(() => result.current());
expect(onSpawned).not.toHaveBeenCalled();
expect(onFallback).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/repos/p", command: "claude", worktreeBranch: "amber-fox" }),
);
});
it("falls back when nothing is known yet (no projects, no handle)", async () => {
mockFetchProjects.mockResolvedValue([]);
mockFetchNames.mockRejectedValue(new Error("exhausted"));
mockGetDaemonInfo.mockResolvedValue({ hostname: "h", version: "1", presets });
const onSpawned = vi.fn();
const onFallback = vi.fn();
const { result } = renderHook(() => useFastForward({ onSpawned, onFallback }));
await act(() => result.current());
expect(mockCreateSession).not.toHaveBeenCalled();
expect(onFallback).toHaveBeenCalledWith(expect.objectContaining({ cwd: "" }));
});
it("prefers the focused session's project over the freshest one", async () => {
mockFetchProjects.mockResolvedValue([
project({ path: "/repos/fresh", name: "fresh" }),
project({ path: "/repos/current", name: "current", last_activity: "2020-01-01T00:00:00Z" }),
]);
mockFetchNames.mockResolvedValue({ names: ["h"] });
mockGetDaemonInfo.mockResolvedValue({ hostname: "h", version: "1", presets });
mockCreateSession.mockResolvedValue(session());
const onSpawned = vi.fn();
const { result } = renderHook(() =>
useFastForward({ focusedCwd: "/repos/current", onSpawned, onFallback: vi.fn() }),
);
await act(() => result.current());
expect(mockCreateSession).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/repos/current" }));
});
});
@@ -1,57 +0,0 @@
// React port of main.ts's `requestFastForward` (#67): one keystroke, every
// spawn default resolved — most recent project (or the one the operator is
// on), its last command, a fresh generated branch handle, worktree on with a
// new branch. A failed POST, or any missing default, falls back to the same
// pre-filled form `onFallback` opens, rather than a dead keystroke.
import { useCallback } from "react";
import { createSession, fetchNames, getDaemonInfo, projects as fetchProjects } from "../../api.js";
import { buildFastForwardRequest, fastForwardSeed, resolveFastForward } from "../../fastForward.js";
import { FALLBACK_PRESETS, type SpawnFormSeed } from "../../spawnForm.js";
import type { Project, Session, SpawnPreset } from "../../types.js";
export interface UseFastForwardOptions {
/** The project of the session the operator is currently on, if any — its
* base repo for a worktree session, else its cwd. Same rule the plain
* spawn modal's seed uses, so `N` lands where the operator currently is. */
focusedCwd?: string;
onSpawned(session: Session): void;
onFallback(seed: SpawnFormSeed): void;
}
async function resolvePresets(): Promise<SpawnPreset[]> {
try {
const info = await getDaemonInfo();
if (info.presets?.length) return info.presets;
} catch {
// keep fallbacks
}
return FALLBACK_PRESETS;
}
/** Returns a function to call on the fast-forward keystroke. */
export function useFastForward(opts: UseFastForwardOptions): () => Promise<void> {
const { focusedCwd, onSpawned, onFallback } = opts;
return useCallback(async () => {
const [discovered, handle] = await Promise.all([
fetchProjects().catch(() => [] as Project[]),
fetchNames(1)
.then((batch) => batch.names[0])
.catch(() => undefined),
]);
const presets = await resolvePresets();
const resolved = resolveFastForward(discovered, presets, handle, focusedCwd);
const req = buildFastForwardRequest(resolved);
if (req) {
try {
onSpawned(await createSession(req));
return;
} catch {
// degrades to the pre-filled form below, never a silent dead key
}
}
onFallback(fastForwardSeed(resolved));
}, [focusedCwd, onSpawned, onFallback]);
}
-134
View File
@@ -1,134 +0,0 @@
// React port of ../../attach.ts's `buildKeyBar` — the Termux-style special-key
// bar for the soft keyboard (Esc/Tab/Ctrl/Alt/cursor keys/symbols it hides
// behind a page switch). Ctrl/Alt are one-shot sticky modifiers: arming one
// folds into the very next key, tapped here or typed on the soft keyboard,
// then disarms.
import { useCallback, useRef, useState } from "react";
import { applyModsToChar, cursorSeq, tildeSeq } from "../../keys.js";
type Modifier = "ctrl" | "alt";
/** CSI final bytes: arrows A-D, Home `H`, End `F`. */
type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F";
type KeyDef =
| { label: string; mod: Modifier }
| { label: string; cursor: CursorFinal }
| { label: string; tilde: number }
| { label: string; char: string }
| { label: string; seq: string };
/**
* Row-major into an 8-column / 2-row grid (#7):
*
* Esc Tab Home ↑ End PgUp - /
* Ctrl Alt ← ↓ → PgDn | ~
*/
const KEY_DEFS: KeyDef[] = [
{ label: "Esc", seq: "\x1b" },
{ label: "Tab", seq: "\t" },
{ label: "Home", cursor: "H" },
{ label: "↑", cursor: "A" },
{ label: "End", cursor: "F" },
{ label: "PgUp", tilde: 5 },
{ label: "-", char: "-" },
{ label: "/", char: "/" },
{ label: "Ctrl", mod: "ctrl" },
{ label: "Alt", mod: "alt" },
{ label: "←", cursor: "D" },
{ label: "↓", cursor: "B" },
{ label: "→", cursor: "C" },
{ label: "PgDn", tilde: 6 },
{ label: "|", char: "|" },
{ label: "~", char: "~" },
];
/**
* Pending Ctrl/Alt state plus `consume`, which folds them into a soft-keyboard
* character before it reaches the PTY — used by the terminal's own `onData`
* handler, not by this component's own button clicks.
*/
export function useKeyBarModifiers() {
const armedRef = useRef<{ ctrl: boolean; alt: boolean }>({ ctrl: false, alt: false });
const [, setTick] = useState(0);
const rerender = useCallback(() => setTick((n) => n + 1), []);
const setMod = useCallback(
(mod: Modifier, on: boolean) => {
armedRef.current[mod] = on;
rerender();
},
[rerender],
);
const clearMods = useCallback(() => {
if (!armedRef.current.ctrl && !armedRef.current.alt) return;
armedRef.current = { ctrl: false, alt: false };
rerender();
}, [rerender]);
const consume = useCallback((data: string): string => {
const { ctrl, alt } = armedRef.current;
if ((ctrl || alt) && data.length === 1) {
const out = applyModsToChar(data, ctrl, alt);
armedRef.current = { ctrl: false, alt: false };
return out;
}
return data;
}, []);
return { armed: armedRef.current, setMod, clearMods, consume };
}
export interface KeyBarProps {
armed: { ctrl: boolean; alt: boolean };
onToggleMod(mod: Modifier): void;
onClearMods(): void;
onSend(data: string): void;
}
export function KeyBar({ armed, onToggleMod, onClearMods, onSend }: KeyBarProps) {
return (
<div
role="toolbar"
aria-label="Special keys"
className="flex flex-wrap gap-1 border-t border-white/10 bg-black/20 p-1"
>
{KEY_DEFS.map((def) => {
if ("mod" in def) {
return (
<button
key={def.label}
type="button"
aria-pressed={armed[def.mod]}
aria-label={`${def.label} modifier`}
className="min-w-8 rounded px-2 py-1 text-xs aria-pressed:bg-emerald-500/40"
onClick={() => onToggleMod(def.mod)}
>
{def.label}
</button>
);
}
const bytes = () => {
if ("cursor" in def) return cursorSeq(def.cursor, armed.ctrl, armed.alt);
if ("tilde" in def) return tildeSeq(def.tilde, armed.ctrl, armed.alt);
if ("char" in def) return applyModsToChar(def.char, armed.ctrl, armed.alt);
return def.seq;
};
return (
<button
key={def.label}
type="button"
className="min-w-8 rounded px-2 py-1 text-xs"
onClick={() => {
onSend(bytes());
onClearMods();
}}
>
{def.label}
</button>
);
})}
</div>
);
}
@@ -1,363 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, render } from "@testing-library/react";
import { createRef } from "react";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
import { attachWsUrl } from "../../routes.js";
import type { ClientControl } from "../../types.js";
class FakeTerminal {
static instances: FakeTerminal[] = [];
cols = 80;
rows = 24;
element: HTMLElement | undefined = undefined;
textarea: HTMLTextAreaElement | null = null;
disposed = false;
focus = vi.fn();
private dataCb: ((d: string) => void) | null = null;
constructor(public opts: unknown) {
FakeTerminal.instances.push(this);
}
loadAddon(): void {}
open(container: HTMLElement): void {
container.appendChild(document.createElement("div"));
}
onData(cb: (d: string) => void) {
this.dataCb = cb;
return { dispose: vi.fn() };
}
emitData(d: string): void {
this.dataCb?.(d);
}
onResize() {
return { dispose: vi.fn() };
}
onSelectionChange() {
return { dispose: vi.fn() };
}
write(_data: Uint8Array, cb?: () => void): void {
cb?.();
}
scrollToBottom(): void {}
resize(cols: number, rows: number): void {
this.cols = cols;
this.rows = rows;
}
clearTextureAtlas(): void {}
getSelection(): string {
return "";
}
dispose(): void {
this.disposed = true;
}
}
class FakeFitAddon {
fit = vi.fn();
}
class FakeWebglAddon {
static instances: FakeWebglAddon[] = [];
disposed = false;
private lossCb: (() => void) | null = null;
constructor() {
FakeWebglAddon.instances.push(this);
}
onContextLoss(cb: () => void): void {
this.lossCb = cb;
}
triggerLoss(): void {
this.lossCb?.();
}
dispose(): void {
this.disposed = true;
}
}
const policyEnabled = vi.fn(() => true);
const policyNoteLoss = vi.fn();
vi.mock("@xterm/xterm", () => ({ Terminal: FakeTerminal }));
vi.mock("@xterm/addon-fit", () => ({ FitAddon: FakeFitAddon }));
vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: FakeWebglAddon }));
vi.mock("../../webglRenderer.js", () => ({
WebglPolicy: vi.fn().mockImplementation(() => ({
enabled: policyEnabled,
noteContextLoss: policyNoteLoss,
})),
}));
class FakeWebSocket {
static OPEN = 1;
static CONNECTING = 0;
static CLOSING = 2;
static CLOSED = 3;
static instances: FakeWebSocket[] = [];
readyState = FakeWebSocket.CONNECTING;
binaryType = "";
sent: (string | ArrayBufferView)[] = [];
onopen: (() => void) | null = null;
onmessage: ((ev: { data: unknown }) => void) | null = null;
onerror: (() => void) | null = null;
onclose: ((ev: { code: number; reason: string }) => void) | null = null;
constructor(public url: string) {
FakeWebSocket.instances.push(this);
}
send(data: string | ArrayBufferView): void {
this.sent.push(data);
}
close(): void {
this.readyState = FakeWebSocket.CLOSED;
}
open(): void {
this.readyState = FakeWebSocket.OPEN;
this.onopen?.();
}
drop(code = 1006, reason = ""): void {
this.readyState = FakeWebSocket.CLOSED;
this.onclose?.({ code, reason });
}
lastControl(): ClientControl {
return JSON.parse(this.sent.at(-1) as string) as ClientControl;
}
}
beforeAll(() => {
vi.stubGlobal(
"ResizeObserver",
class {
observe(): void {}
disconnect(): void {}
unobserve(): void {}
},
);
});
beforeEach(() => {
FakeTerminal.instances = [];
FakeWebglAddon.instances = [];
FakeWebSocket.instances = [];
policyEnabled.mockReturnValue(true);
policyEnabled.mockClear();
policyNoteLoss.mockClear();
vi.stubGlobal("WebSocket", FakeWebSocket);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
// Imported after the mocks above so it picks up the mocked xterm/webgl modules.
const { TerminalPane } = await import("./TerminalPane.js");
describe("TerminalPane lifecycle", () => {
it("opens an xterm terminal and connects to the session's attach socket on mount", () => {
render(<TerminalPane sessionId="s1" />);
expect(FakeTerminal.instances).toHaveLength(1);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(FakeWebSocket.instances[0].url).toBe(attachWsUrl("s1"));
});
it("sends the terminal size once the socket opens", () => {
render(<TerminalPane sessionId="s1" />);
const sock = FakeWebSocket.instances[0];
sock.open();
expect(sock.lastControl()).toEqual({ type: "Resize", cols: 80, rows: 24 });
});
it("disposes the terminal and closes the socket on unmount", () => {
const { unmount } = render(<TerminalPane sessionId="s1" />);
const term = FakeTerminal.instances[0];
const sock = FakeWebSocket.instances[0];
unmount();
expect(term.disposed).toBe(true);
expect(sock.readyState).toBe(FakeWebSocket.CLOSED);
});
it("reconnects to a fresh socket for a new sessionId", () => {
const { rerender } = render(<TerminalPane sessionId="s1" />);
rerender(<TerminalPane sessionId="s2" />);
expect(FakeWebSocket.instances).toHaveLength(2);
expect(FakeWebSocket.instances[1].url).toBe(attachWsUrl("s2"));
expect(FakeTerminal.instances[0].disposed).toBe(true);
});
});
describe("TerminalPane reconnect backoff", () => {
it("retries a dropped socket, doubling the delay from 500ms up to a 10s cap", () => {
vi.useFakeTimers();
render(<TerminalPane sessionId="s1" />);
const drop = () => FakeWebSocket.instances.at(-1)!.drop(1006, "");
const expectStillWaiting = (ms: number, countBefore: number) => {
vi.advanceTimersByTime(ms - 1);
expect(FakeWebSocket.instances).toHaveLength(countBefore);
vi.advanceTimersByTime(1);
expect(FakeWebSocket.instances).toHaveLength(countBefore + 1);
};
expect(FakeWebSocket.instances).toHaveLength(1);
drop();
expectStillWaiting(INITIAL_RECONNECT_MS, 1); // 500ms -> #2
drop();
expectStillWaiting(INITIAL_RECONNECT_MS * 2, 2); // 1000ms -> #3
drop();
expectStillWaiting(INITIAL_RECONNECT_MS * 4, 3); // 2000ms -> #4
drop();
expectStillWaiting(INITIAL_RECONNECT_MS * 8, 4); // 4000ms -> #5
drop();
expectStillWaiting(INITIAL_RECONNECT_MS * 16, 5); // 8000ms -> #6
drop();
expectStillWaiting(MAX_RECONNECT_MS, 6); // capped at 10s -> #7
drop();
expectStillWaiting(MAX_RECONNECT_MS, 7); // stays capped -> #8
});
it("resets the backoff once a reconnected socket opens", () => {
vi.useFakeTimers();
render(<TerminalPane sessionId="s1" />);
FakeWebSocket.instances[0].drop(1006, "");
vi.advanceTimersByTime(INITIAL_RECONNECT_MS);
FakeWebSocket.instances[1].open();
FakeWebSocket.instances[1].drop(1006, "");
vi.advanceTimersByTime(INITIAL_RECONNECT_MS - 1);
expect(FakeWebSocket.instances).toHaveLength(2);
vi.advanceTimersByTime(1);
expect(FakeWebSocket.instances).toHaveLength(3);
});
it("does not retry a clean close, and reports it closed", () => {
vi.useFakeTimers();
const onClosed = vi.fn();
render(<TerminalPane sessionId="s1" onClosed={onClosed} />);
FakeWebSocket.instances[0].drop(1000, "");
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(onClosed).toHaveBeenCalledOnce();
});
it("does not retry an application-level rejection, and writes it into the terminal", () => {
vi.useFakeTimers();
const onClosed = vi.fn();
render(<TerminalPane sessionId="s1" onClosed={onClosed} />);
const term = FakeTerminal.instances[0];
const writeSpy = vi.spyOn(term, "write");
FakeWebSocket.instances[0].drop(4004, "unknown session");
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(onClosed).not.toHaveBeenCalled();
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("unknown session"));
});
it("honors a caller-supplied shouldReconnect policy", () => {
vi.useFakeTimers();
const onClosed = vi.fn();
render(<TerminalPane sessionId="s1" shouldReconnect={() => false} onClosed={onClosed} />);
FakeWebSocket.instances[0].drop(1006, "");
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(onClosed).toHaveBeenCalledOnce();
});
});
describe("TerminalPane WebGL context budget", () => {
it("acquires a WebGL context on mount, shared policy allowing", () => {
render(<TerminalPane sessionId="s1" />);
expect(policyEnabled).toHaveBeenCalled();
expect(FakeWebglAddon.instances).toHaveLength(1);
});
it("never creates a context once the shared budget is exhausted", () => {
policyEnabled.mockReturnValue(false);
render(<TerminalPane sessionId="s1" />);
expect(FakeWebglAddon.instances).toHaveLength(0);
});
it("releases its context on unmount, handing the budget back", () => {
const { unmount } = render(<TerminalPane sessionId="s1" />);
const addon = FakeWebglAddon.instances[0];
unmount();
expect(addon.disposed).toBe(true);
});
it("counts a context loss and re-acquires while still mounted", () => {
render(<TerminalPane sessionId="s1" />);
const first = FakeWebglAddon.instances[0];
first.triggerLoss();
expect(policyNoteLoss).toHaveBeenCalledOnce();
expect(first.disposed).toBe(true);
expect(FakeWebglAddon.instances).toHaveLength(2);
});
it("does not re-acquire a lost context once the budget is spent", () => {
render(<TerminalPane sessionId="s1" />);
const first = FakeWebglAddon.instances[0];
policyEnabled.mockReturnValue(false);
first.triggerLoss();
expect(FakeWebglAddon.instances).toHaveLength(1);
});
});
describe("TerminalPane imperative handle", () => {
it("sendInput writes bytes to the PTY only once the socket is open", () => {
const ref = createRef<import("./TerminalPane.js").TerminalPaneHandle>();
render(<TerminalPane sessionId="s1" ref={ref} />);
const sock = FakeWebSocket.instances[0];
ref.current!.sendInput("nope");
expect(sock.sent).toHaveLength(0);
sock.open();
sock.sent.length = 0; // drop the resize control frame from open()
ref.current!.sendInput("hi");
expect(sock.sent).toHaveLength(1);
expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("hi");
});
it("focusTerminal focuses the underlying xterm instance", () => {
const ref = createRef<import("./TerminalPane.js").TerminalPaneHandle>();
render(<TerminalPane sessionId="s1" ref={ref} />);
ref.current!.focusTerminal();
expect(FakeTerminal.instances[0].focus).toHaveBeenCalledOnce();
});
it("routes typed keystrokes through to the socket", () => {
render(<TerminalPane sessionId="s1" />);
const sock = FakeWebSocket.instances[0];
sock.open();
sock.sent.length = 0;
FakeTerminal.instances[0].emitData("q");
expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("q");
});
});
@@ -1,362 +0,0 @@
// React port of ../../attach.ts: an xterm terminal bound to the daemon's
// attach WebSocket, plus ../../webglRenderer.ts's shared GPU-context budget
// and ../../reconnect.ts's backoff schedule. Pane chrome (title, disconnect
// button, sessions drawer) stays out of scope here — this is the thin
// ref-based xterm wrapper the issue asked for; a header composes around it.
import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
import { attachWsUrl } from "../../routes.js";
import type { ClientControl, Uuid } from "../../types.js";
import { WebglPolicy } from "../../webglRenderer.js";
import { KeyBar, useKeyBarModifiers } from "./KeyBar.js";
/** One budget for the whole page — every mounted `TerminalPane` shares it, the
* same reasoning as attach.ts's own module-level policy. */
const webglPolicy = new WebglPolicy();
export type TerminalStatus = "connecting" | "open" | "reconnecting" | "closed" | "error";
/** Application-defined close codes (daemon rejection) and the clean-close
* code — retrying either would only loop. Mirrors reconnect.ts's
* `shouldRetryClose`, minus the catalog lookup a standalone pane doesn't have. */
function defaultShouldReconnect(code: number): boolean {
return code < 4000 && code !== 1000;
}
export interface TerminalPaneProps {
sessionId: Uuid;
/** Grabs the keyboard once the socket opens. A sidebar preview passes `false`. */
focus?: boolean;
/** Asked on every close: should the socket be retried? */
shouldReconnect?: (code: number) => boolean;
/** The socket closed for good (retry declined, or the daemon rejected the
* session) — not called on an ordinary unmount. */
onClosed?: () => void;
/** Connection status changes, for a header/status badge to render. */
onStatusChange?: (status: TerminalStatus, detail?: string) => void;
className?: string;
}
export interface TerminalPaneHandle {
focusTerminal(): void;
sendInput(data: string): void;
}
export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
function TerminalPane(
{ sessionId, focus = true, shouldReconnect = defaultShouldReconnect, onClosed, onStatusChange, className },
ref,
) {
const bodyRef = useRef<HTMLDivElement | null>(null);
const sendInputRef = useRef<(data: string) => void>(() => {});
const focusTerminalRef = useRef<() => void>(() => {});
const keyBar = useKeyBarModifiers();
const [keybarActive, setKeybarActive] = useState(false);
useImperativeHandle(
ref,
() => ({
focusTerminal: () => focusTerminalRef.current(),
sendInput: (data: string) => sendInputRef.current(data),
}),
[],
);
useEffect(() => {
const body = bodyRef.current;
if (!body) return;
const term = new Terminal({
cursorBlink: true,
fontFamily:
'"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: 13,
convertEol: false,
theme: {
background: "#121419",
foreground: "#e6e8ec",
cursor: "#5fe3a1",
cursorAccent: "#121419",
selectionBackground: "#2b6b4f",
},
scrollback: 5000,
allowProposedApi: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(body);
let webgl: WebglAddon | null = null;
let dprMedia: MediaQueryList | null = null;
const onDprChange = () => {
try {
term.clearTextureAtlas();
} catch {
// ignore — nothing to rebuild if the renderer already went away
}
watchDpr();
};
function watchDpr(): void {
dprMedia?.removeEventListener("change", onDprChange);
dprMedia = null;
try {
dprMedia = window.matchMedia?.(`(resolution: ${window.devicePixelRatio}dppx)`) ?? null;
dprMedia?.addEventListener("change", onDprChange);
} catch {
// ignore — a browser without `resolution` media queries just keeps
// the atlas it has
}
}
function releaseWebgl(): void {
dprMedia?.removeEventListener("change", onDprChange);
dprMedia = null;
if (!webgl) return;
const addon = webgl;
webgl = null;
try {
addon.dispose();
} catch {
// ignore — a lost context can throw on the way out
}
}
function acquireWebgl(): void {
if (webgl || disposed || !webglPolicy.enabled()) return;
try {
const addon = new WebglAddon();
addon.onContextLoss(() => {
webglPolicy.noteContextLoss();
releaseWebgl();
if (mounted) acquireWebgl();
});
term.loadAddon(addon);
webgl = addon;
watchDpr();
} catch {
webglPolicy.noteContextLoss();
webgl = null;
}
}
let mounted = false;
const tryFit = () => {
if (!mounted) return;
try {
const el2 = term.element;
const parent = el2?.parentElement;
const core = (
term as unknown as {
_core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } };
}
)._core;
const cell = core?._renderService?.dimensions?.css?.cell;
if (!el2 || !parent || !cell || !cell.width || !cell.height) {
fit.fit();
return;
}
const vp = el2.querySelector(".xterm-viewport") as HTMLElement | null;
let availW: number;
let availH: number;
if (vp && vp.clientWidth > 0) {
availW = vp.clientWidth;
availH = vp.clientHeight;
} else {
const ps = getComputedStyle(parent);
const ts = getComputedStyle(el2);
availW = parseInt(ps.width) - (parseInt(ts.paddingLeft) + parseInt(ts.paddingRight) || 0);
availH = parseInt(ps.height) - (parseInt(ts.paddingTop) + parseInt(ts.paddingBottom) || 0);
}
const cols = Math.max(2, Math.floor(availW / cell.width));
const rows = Math.max(1, Math.floor(availH / cell.height));
if (cols !== term.cols || rows !== term.rows) term.resize(cols, rows);
} catch {
try {
fit.fit();
} catch {
// ignore — fit fails if the container is detached or zero-sized
}
}
};
let ws: WebSocket;
let reconnectMs = INITIAL_RECONNECT_MS;
let reconnectTimer: number | null = null;
let awaitingReplay = true;
let disposed = false;
const sendControl = (msg: ClientControl) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify(msg));
};
const sendInput = (data: string) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(new TextEncoder().encode(data));
};
sendInputRef.current = sendInput;
focusTerminalRef.current = () => term.focus();
const onResize = () => {
tryFit();
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
};
function connect(): void {
awaitingReplay = true;
ws = new WebSocket(attachWsUrl(sessionId));
ws.binaryType = "arraybuffer";
ws.onopen = () => {
reconnectMs = INITIAL_RECONNECT_MS;
onStatusChange?.("open");
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
if (mounted && focus) term.focus();
};
ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
try {
JSON.parse(ev.data) as ClientControl;
} catch {
// ignore unknown control frames
}
return;
}
if (ev.data instanceof ArrayBuffer) {
const replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(ev.data), () => {
if (replay) term.scrollToBottom();
});
} else if (ev.data instanceof Blob) {
void ev.data.arrayBuffer().then((b) => {
const replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(b), () => {
if (replay) term.scrollToBottom();
});
});
}
};
ws.onerror = () => {
onStatusChange?.("error");
};
ws.onclose = (ev) => {
const reason = ev.reason?.trim();
if (ev.code >= 4000 && reason && !disposed) {
onStatusChange?.("error", reason);
term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`);
return;
}
if (disposed) return;
if (shouldReconnect(ev.code)) {
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
onStatusChange?.("reconnecting");
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
connect();
}, delay);
return;
}
onStatusChange?.("closed", String(ev.code));
onClosed?.();
};
}
onStatusChange?.("connecting");
connect();
const dataDisp = term.onData((data) => {
sendInput(keyBar.consume(data));
});
const resizeDisp = term.onResize(({ cols, rows }) => {
sendControl({ type: "Resize", cols, rows });
});
const selectionDisp = term.onSelectionChange(() => {
const text = term.getSelection();
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
});
const ta = term.textarea;
const onTaFocus = () => {
setKeybarActive(true);
requestAnimationFrame(tryFit);
};
const onTaBlur = () => {
setKeybarActive(false);
requestAnimationFrame(tryFit);
};
ta?.addEventListener("focus", onTaFocus);
ta?.addEventListener("blur", onTaBlur);
window.addEventListener("resize", onResize);
const visual = window.visualViewport ?? null;
visual?.addEventListener("resize", onResize);
const ro = new ResizeObserver(() => tryFit());
ro.observe(body);
document.fonts?.ready?.then(() => tryFit()).catch(() => {});
mounted = true;
acquireWebgl();
requestAnimationFrame(() => {
tryFit();
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
if (focus && ws.readyState === WebSocket.OPEN) term.focus();
});
return () => {
disposed = true;
mounted = false;
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
ro.disconnect();
window.removeEventListener("resize", onResize);
visual?.removeEventListener("resize", onResize);
ta?.removeEventListener("focus", onTaFocus);
ta?.removeEventListener("blur", onTaBlur);
dataDisp.dispose();
resizeDisp.dispose();
selectionDisp.dispose();
releaseWebgl();
try {
ws.close();
} catch {
// ignore
}
term.dispose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- reconnecting
// a session change is a fresh connection, not a prop-driven re-render
}, [sessionId]);
return (
<div className={className ? `flex h-full flex-col ${className}` : "flex h-full flex-col"}>
<div ref={bodyRef} className="min-h-0 flex-1" />
{keybarActive && (
<KeyBar
armed={keyBar.armed}
onToggleMod={(mod) => keyBar.setMod(mod, !keyBar.armed[mod])}
onClearMods={keyBar.clearMods}
onSend={(data) => sendInputRef.current(data)}
/>
)}
</div>
);
},
);
-163
View File
@@ -1,163 +0,0 @@
// @vitest-environment happy-dom
import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { PALETTES, ThemeProvider, useTheme } from "./ThemeContext";
// happy-dom's vitest environment ships neither localStorage nor matchMedia;
// back both with plain in-memory fakes so persistence and system-theme
// reactivity are observable (mirrors ../theme.test.ts's localStorage fake).
let store = new Map<string, string>();
let systemPrefersLight = false;
let mediaListeners: Array<(e: MediaQueryListEvent) => void> = [];
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => store.set(k, v),
removeItem: (k: string) => store.delete(k),
},
});
Object.defineProperty(window, "matchMedia", {
value: (query: string) => ({
matches: query.includes("light") ? systemPrefersLight : false,
media: query,
addEventListener: (_type: string, cb: (e: MediaQueryListEvent) => void) => {
mediaListeners.push(cb);
},
removeEventListener: (_type: string, cb: (e: MediaQueryListEvent) => void) => {
mediaListeners = mediaListeners.filter((l) => l !== cb);
},
}),
});
});
function fireSystemThemeChange(matches: boolean): void {
systemPrefersLight = matches;
for (const cb of mediaListeners) {
cb({ matches } as MediaQueryListEvent);
}
}
function Probe() {
const { theme, palette, setTheme, toggleTheme, setPalette } = useTheme();
return (
<div>
<span data-testid="theme">{theme}</span>
<span data-testid="palette">{palette}</span>
<button onClick={() => setTheme("light")}>pin-light</button>
<button onClick={toggleTheme}>toggle</button>
<button onClick={() => setPalette("ember")}>set-ember</button>
<button onClick={() => setPalette("graphite")}>set-graphite</button>
</div>
);
}
describe("ThemeProvider / useTheme", () => {
beforeEach(() => {
store = new Map();
mediaListeners = [];
systemPrefersLight = false;
delete document.documentElement.dataset.theme;
delete document.documentElement.dataset.palette;
});
afterEach(() => {
cleanup();
});
it("defaults to graphite and the system theme, no data-palette attribute", () => {
render(
<ThemeProvider>
<Probe />
</ThemeProvider>,
);
expect(screen.getByTestId("theme").textContent).toBe("dark");
expect(screen.getByTestId("palette").textContent).toBe("graphite");
expect(document.documentElement.dataset.palette).toBeUndefined();
});
it("follows a system theme change while unpinned", () => {
render(
<ThemeProvider>
<Probe />
</ThemeProvider>,
);
act(() => fireSystemThemeChange(true));
expect(screen.getByTestId("theme").textContent).toBe("light");
});
it("setTheme pins, persists, and stops following the system", () => {
render(
<ThemeProvider>
<Probe />
</ThemeProvider>,
);
act(() => screen.getByText("pin-light").click());
expect(screen.getByTestId("theme").textContent).toBe("light");
expect(document.documentElement.dataset.theme).toBe("light");
expect(localStorage.getItem("maestro-theme")).toBe("light");
act(() => fireSystemThemeChange(true));
expect(screen.getByTestId("theme").textContent).toBe("light");
});
it("toggleTheme flips the resolved theme", () => {
render(
<ThemeProvider>
<Probe />
</ThemeProvider>,
);
act(() => screen.getByText("toggle").click());
expect(screen.getByTestId("theme").textContent).toBe("light");
act(() => screen.getByText("toggle").click());
expect(screen.getByTestId("theme").textContent).toBe("dark");
});
it("setPalette stamps data-palette and persists; graphite clears it", () => {
render(
<ThemeProvider>
<Probe />
</ThemeProvider>,
);
act(() => screen.getByText("set-ember").click());
expect(document.documentElement.dataset.palette).toBe("ember");
expect(localStorage.getItem("maestro-palette")).toBe("ember");
act(() => screen.getByText("set-graphite").click());
expect(document.documentElement.dataset.palette).toBeUndefined();
});
it("a fresh mount re-applies a stored palette", () => {
localStorage.setItem("maestro-palette", "periwinkle");
render(
<ThemeProvider>
<Probe />
</ThemeProvider>,
);
expect(screen.getByTestId("palette").textContent).toBe("periwinkle");
expect(document.documentElement.dataset.palette).toBe("periwinkle");
});
it("exposes every palette in picker order", () => {
expect(PALETTES).toEqual([
"graphite",
"ice-cyan",
"periwinkle",
"amber-phosphor",
"ember",
"orchid",
]);
});
it("throws when used outside a provider", () => {
const Bare = () => {
useTheme();
return null;
};
expect(() => render(<Bare />)).toThrow(
"useTheme must be used within a ThemeProvider",
);
});
});
-118
View File
@@ -1,118 +0,0 @@
// React port of ../theme.ts. Same storage keys and `data-theme`/`data-palette`
// attributes on <html>, so this can sit alongside the vanilla shell (which
// still owns theme.ts) without either one fighting the other over state.
import {
createContext,
useCallback,
useContext,
useLayoutEffect,
useState,
type ReactNode,
} from "react";
type Theme = "light" | "dark";
const THEME_KEY = "maestro-theme";
/** Every `data-palette` value, in picker order. Graphite is the default. */
export const PALETTES = [
"graphite",
"ice-cyan",
"periwinkle",
"amber-phosphor",
"ember",
"orchid",
] as const;
export type Palette = (typeof PALETTES)[number];
const PALETTE_KEY = "maestro-palette";
function storedTheme(): Theme | null {
const v = localStorage.getItem(THEME_KEY);
return v === "light" || v === "dark" ? v : null;
}
function storedPalette(): Palette | null {
const v = localStorage.getItem(PALETTE_KEY);
return (PALETTES as readonly string[]).includes(v ?? "")
? (v as Palette)
: null;
}
function systemPrefersLight(): boolean {
return window.matchMedia("(prefers-color-scheme: light)").matches;
}
// Dev-only `?theme=` override for visual review, same as theme.ts's — never
// persisted, just seeds the initial pin for this session.
function devThemeOverride(): Theme | null {
if (!import.meta.env.DEV) return null;
const q = new URLSearchParams(location.search).get("theme");
return q === "light" || q === "dark" ? q : null;
}
interface ThemeContextValue {
theme: Theme;
palette: Palette;
setTheme: (t: Theme) => void;
toggleTheme: () => void;
setPalette: (p: Palette) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [pinnedTheme, setPinnedTheme] = useState<Theme | null>(
() => devThemeOverride() ?? storedTheme(),
);
const [systemIsLight, setSystemIsLight] = useState(systemPrefersLight);
const [palette, setPaletteState] = useState<Palette>(
() => storedPalette() ?? "graphite",
);
const theme: Theme = pinnedTheme ?? (systemIsLight ? "light" : "dark");
useLayoutEffect(() => {
const mql = window.matchMedia("(prefers-color-scheme: light)");
const onChange = (e: MediaQueryListEvent) => setSystemIsLight(e.matches);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
// Pinning is one-way (no UI un-pins back to "follow system"), so this only
// ever needs to set the attribute — never clear it — same as theme.ts.
useLayoutEffect(() => {
if (pinnedTheme) document.documentElement.dataset.theme = pinnedTheme;
}, [pinnedTheme]);
useLayoutEffect(() => {
if (palette === "graphite") delete document.documentElement.dataset.palette;
else document.documentElement.dataset.palette = palette;
}, [palette]);
const setTheme = useCallback((t: Theme) => {
localStorage.setItem(THEME_KEY, t);
setPinnedTheme(t);
}, []);
const toggleTheme = useCallback(() => {
setTheme(theme === "dark" ? "light" : "dark");
}, [theme, setTheme]);
const setPalette = useCallback((p: Palette) => {
localStorage.setItem(PALETTE_KEY, p);
setPaletteState(p);
}, []);
return (
<ThemeContext.Provider
value={{ theme, palette, setTheme, toggleTheme, setPalette }}
>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within a ThemeProvider");
return ctx;
}
-10
View File
@@ -1,10 +0,0 @@
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./main.css";
const container = document.getElementById("root");
if (!container) {
throw new Error("missing #root element");
}
createRoot(container).render(<App />);
-2
View File
@@ -1,2 +0,0 @@
@import "tailwindcss";
@config "../tailwind.config.ts";
+5 -11
View File
@@ -182,32 +182,26 @@ describe("sidebar preview requests (docs/PREVIEW.md)", () => {
expect(seen.at(-1)).toBe(null);
});
it("ctrl+hover previews a card, plain hover does not", () => {
it("asks for a preview of the hovered card, and clears it on the way out", () => {
const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
// Plain hover — no preview.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
expect(seen).toEqual([]);
// Ctrl+hover — preview fires.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
expect(seen).toEqual([A]);
// Deduplicated while still on the same card.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
// Moving within the same card is not a new preview.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
expect(seen).toHaveLength(1);
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
expect(seen.at(-1)).toBe(null);
});
it("ignores touch even with ctrl", () => {
it("ignores touch, where a tap already opens the session", () => {
const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch", ctrlKey: true }));
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch" }));
expect(seen).toEqual([]);
});
});
+26 -55
View File
@@ -11,13 +11,13 @@ import { attachDirAutocomplete } from "./dirAutocomplete.js";
import { parentDir } from "./paths.js";
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
import type { ProjectGroup } from "./projectList.js";
import { splitCommand, toolIconSvg, TOOL_LABELS } from "./toolIcon.js";
import { splitCommand } from "./toolIcon.js";
import { abbreviateCwd } from "./types.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
export const STRUCTURED_VIEW_TOOLS = new Set(["claude", "codex"]);
const STRUCTURED_VIEW_TOOLS = new Set(["claude", "codex"]);
export function supportsStructuredView(command: string): boolean {
function supportsStructuredView(command: string): boolean {
const tool = splitCommand(command).tool;
return tool !== null && STRUCTURED_VIEW_TOOLS.has(tool);
}
@@ -140,12 +140,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const form = document.createElement("form");
form.className = "spawn-form";
form.innerHTML = `
<div class="field">
<span class="field-label">Harness</span>
<div class="harness-grid cmd-tiles"></div>
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
placeholder="bash -l" autocomplete="off" />
</div>
<div class="field">
<span class="field-label">Project</span>
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
@@ -157,6 +151,12 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
</div>
</div>
<div class="field">
<span class="field-label">Command</span>
<div class="tile-row cmd-tiles"></div>
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
placeholder="bash -l" autocomplete="off" />
</div>
<div class="advanced">
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
<span class="chevron">▾</span>
@@ -348,69 +348,37 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
command = preset!.command;
}
updateStructuredAvailability();
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === id);
for (const tile of cmdTiles.querySelectorAll(".tile")) {
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
}
emit();
}
/** Re-highlight the operator's already-picked card after `renderCommandTiles`
* rebuilds the grid from scratch (new cards carry no `is-active` class) —
/** Re-highlight the operator's already-picked tile after `renderCommandTiles`
* rebuilds the row from scratch (new tiles carry no `is-active` class) —
* without touching `command`, since that was already settled. */
function reapplyActiveTile(): void {
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === selectedPresetId);
for (const tile of cmdTiles.querySelectorAll(".tile")) {
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId);
}
}
/** Resolve a preset id to a tool icon id. The preset's command is the
* source of truth — `splitCommand` extracts the tool the same way the
* sidebar does. For the "Custom…" preset (empty command) there is no
* tool to show, and a terminal prompt stands in. */
function presetToolId(p: SpawnPreset): string | null {
if (!p.command) return null;
return splitCommand(p.command).tool;
}
function renderCommandTiles(): void {
cmdTiles.replaceChildren();
for (const p of presets) {
const isCustom = p.command === "";
const card = document.createElement("button");
card.type = "button";
card.className = isCustom ? "harness-card harness-custom" : "harness-card";
card.dataset.id = p.id;
const toolId = presetToolId(p);
const iconHtml = isCustom ? customIcon() : toolId ? harnessIcon(toolId) : harnessIcon("shell");
const label = TOOL_LABELS[toolId ?? ""] ?? p.label;
card.innerHTML =
`<span class="harness-icon">${iconHtml}</span>` +
`<span class="harness-label">${escapeHtml(isCustom ? "Custom\u2026" : label)}</span>`;
card.addEventListener("click", () => {
const tile = document.createElement("button");
tile.type = "button";
tile.className = "tile";
tile.dataset.id = p.id;
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
tile.addEventListener("click", () => {
commandTouched = true;
selectPreset(p.id);
});
cmdTiles.appendChild(card);
cmdTiles.appendChild(tile);
}
}
/** Render a harness card icon at card size (24x24). Re-uses the SVG bodies
* from toolIcon.ts but at a larger viewport so the marks breathe. */
function harnessIcon(toolId: string): string {
// toolIconSvg renders at 13x13 with viewBox 0 0 16 16 — we want the
// same shape at 24x24 for the card context.
const small = toolIconSvg(toolId);
if (!small) return "";
return small
.replace(/width="13"/g, 'width="24"')
.replace(/height="13"/g, 'height="24"');
}
/** An ellipsis mark for the "Custom…" escape hatch. */
function customIcon(): string {
return `<svg class="tool-icon" viewBox="0 0 16 16" width="24" height="24" role="img" aria-label="Custom command"><title>Custom command</title><g fill="currentColor"><circle cx="4" cy="8" r="1.3"/><circle cx="8" cy="8" r="1.3"/><circle cx="12" cy="8" r="1.3"/></g></svg>`;
}
// Set once the operator picks a tile or types a custom command — guards
// `applyInitialCommand()` the same way `nameTouched` guards a late name
// batch: a late `/api/info` resolution must never overwrite a field the
@@ -680,13 +648,16 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
box.type = "button";
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
box.dataset.cwd = project.path;
const trees = worktreesOf(group).length;
// The parent directory only — the last segment is the name line above it.
// Unabbreviated beyond `~`, since the stem is what tells two projects
// sharing a basename apart (#95).
const parent = abbreviateCwd(parentDir(project.path), homeDir);
const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "";
box.innerHTML = `
<span class="project-name">${escapeHtml(project.name)}</span>
<span class="project-path">${escapeHtml(parent)}</span>`;
<span class="project-path">${escapeHtml(parent)}</span>
<span class="project-meta">${escapeHtml(meta)}</span>`;
box.addEventListener("click", () => selectProject(group));
return box;
}
+1 -1
View File
@@ -139,7 +139,7 @@ describe("spawnForm Enter-to-submit (#97)", () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
await drain();
form.querySelector<HTMLButtonElement>('.cmd-tiles .harness-card[data-id="custom"]')!.click();
form.querySelector<HTMLButtonElement>('.cmd-tiles .tile[data-id="custom"]')!.click();
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
raw.value = "bash -l";
raw.dispatchEvent(new Event("input"));
+3 -3
View File
@@ -63,7 +63,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
await drain();
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
(t) => t.dataset.id === "zsh",
)!;
zshTile.click();
@@ -88,7 +88,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
document.body.appendChild(form);
await drain();
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
(t) => t.dataset.id === "custom",
)!;
customTile.click();
@@ -116,7 +116,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
// Untouched: the refined preset list is still free to settle the command.
expect(values?.command).toBe("claude");
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
(t) => t.dataset.id === "fish",
);
expect(fishTile).toBeDefined();
+2 -2
View File
@@ -52,14 +52,14 @@ describe("spawn modal", () => {
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
});
it("opens with the project filter focused, harness before project", async () => {
it("opens with the project filter focused, ahead of the command tiles", async () => {
openSpawnModal({ onSuccess: () => {} });
await flush();
const form = modal()!.querySelector("form.spawn-form")!;
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
expect(fields.slice(0, 2)).toEqual(["Harness", "Project"]);
expect(fields.slice(0, 2)).toEqual(["Project", "Command"]);
});
it("titles a seeded open as a clone", async () => {
-206
View File
@@ -1,206 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
createCatalogStore,
INITIAL_BACKOFF_MS,
MAX_BACKOFF_MS,
type CatalogStore,
type Scheduler,
} from "./catalogStore.js";
import type { CatalogEvent, Session } from "../types.js";
function session(id: string): Session {
return {
id,
cwd: "/home/n",
command: "bash",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-22T00:00:00Z",
} as Session;
}
/** Just enough of a WebSocket for the store to drive, plus a way to push frames. */
class FakeSocket {
onmessage: ((ev: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
closed = false;
close(): void {
this.closed = true;
}
open(): void {
this.onopen?.();
}
emit(event: CatalogEvent): void {
this.onmessage?.({ data: JSON.stringify(event) });
}
drop(): void {
this.onclose?.();
}
}
/** Scheduler that records delays instead of waiting them out. */
function fakeScheduler(): Scheduler & { delays: number[]; runNext(): void } {
const queued: Array<() => void> = [];
return {
delays: [] as number[],
setTimer(fn, ms) {
this.delays.push(ms);
queued.push(fn);
return queued.length;
},
clearTimer() {},
runNext() {
queued.shift()?.();
},
};
}
function storeWith(sockets: FakeSocket[], refetch?: () => Promise<Session[]>) {
let i = 0;
const scheduler = fakeScheduler();
const store = createCatalogStore({
socket: () => sockets[i++] as unknown as WebSocket,
scheduler,
refetch,
});
return { store, scheduler };
}
describe("catalogStore reducer", () => {
it("replaces state on Snapshot and applies deltas", () => {
const ws = new FakeSocket();
const { store } = storeWith([ws]);
store.getState().start();
ws.emit({ type: "Snapshot", sessions: [session("a")] } as CatalogEvent);
expect([...store.getState().sessions.keys()]).toEqual(["a"]);
ws.emit({ type: "SessionAdded", session: session("b") } as CatalogEvent);
expect([...store.getState().sessions.keys()].sort()).toEqual(["a", "b"]);
ws.emit({ type: "SessionRemoved", id: "a" } as CatalogEvent);
expect([...store.getState().sessions.keys()]).toEqual(["b"]);
});
it("ignores a frame that is not JSON", () => {
const ws = new FakeSocket();
const { store } = storeWith([ws]);
store.getState().start();
ws.emit({ type: "Snapshot", sessions: [session("a")] } as CatalogEvent);
ws.onmessage?.({ data: "not json" });
expect([...store.getState().sessions.keys()]).toEqual(["a"]);
});
it("refetches over REST when the server reports dropped frames", async () => {
const ws = new FakeSocket();
const refetch = vi.fn(async () => [session("fresh")]);
const { store } = storeWith([ws], refetch);
store.getState().start();
ws.emit({ type: "Snapshot", sessions: [session("stale")] } as CatalogEvent);
ws.emit({ type: "Lagged", skipped: 4 } as CatalogEvent);
await vi.waitFor(() => expect(refetch).toHaveBeenCalledOnce());
expect([...store.getState().sessions.keys()]).toEqual(["fresh"]);
});
it("hands each subscriber a fresh sessions map it can diff against the previous one", () => {
const ws = new FakeSocket();
const { store } = storeWith([ws]);
const seen: CatalogStore[] = [];
store.subscribe((s) => seen.push(s));
store.getState().start();
ws.emit({ type: "Snapshot", sessions: [session("a")] } as CatalogEvent);
ws.emit({ type: "SessionAdded", session: session("b") } as CatalogEvent);
const [afterSnapshot, afterAdd] = seen;
expect(afterSnapshot.sessions).not.toBe(afterAdd.sessions);
expect(afterSnapshot.sessions.size).toBe(1);
expect(afterAdd.sessions.size).toBe(2);
});
});
describe("catalogStore reconnect", () => {
it("replaces a suspended socket immediately", () => {
const first = new FakeSocket();
const second = new FakeSocket();
const { store } = storeWith([first, second]);
store.getState().start();
store.getState().reconnect();
expect(first.closed).toBe(true);
second.open();
// A late close from the old socket is deliberately ignored.
first.drop();
expect(store.getState().backoff()).toBe(INITIAL_BACKOFF_MS);
});
it("doubles the delay per attempt and caps it", () => {
const sockets = Array.from({ length: 8 }, () => new FakeSocket());
const { store, scheduler } = storeWith(sockets);
store.getState().start();
for (let i = 0; i < 7; i++) {
sockets[i].drop();
scheduler.runNext();
}
expect(scheduler.delays[0]).toBe(INITIAL_BACKOFF_MS);
expect(scheduler.delays[1]).toBe(INITIAL_BACKOFF_MS * 2);
expect(scheduler.delays.at(-1)).toBe(MAX_BACKOFF_MS);
});
it("resets the delay once a connection opens", () => {
const sockets = [new FakeSocket(), new FakeSocket(), new FakeSocket()];
const { store, scheduler } = storeWith(sockets);
store.getState().start();
sockets[0].drop();
scheduler.runNext();
expect(store.getState().backoff()).toBe(INITIAL_BACKOFF_MS * 2);
sockets[1].open();
expect(store.getState().backoff()).toBe(INITIAL_BACKOFF_MS);
});
it("stop() tears the socket down without scheduling a reconnect", () => {
const ws = new FakeSocket();
const { store, scheduler } = storeWith([ws]);
store.getState().start();
store.getState().stop();
ws.drop();
expect(ws.closed).toBe(true);
expect(scheduler.delays).toEqual([]);
});
});
describe("catalogStore connected state", () => {
it("reports the socket opening and dropping", () => {
const sock = new FakeSocket();
const store = createCatalogStore({
socket: () => sock as unknown as WebSocket,
scheduler: fakeScheduler(),
});
const seen: boolean[] = [];
store.subscribe((s) => seen.push(s.connected));
store.getState().start();
sock.open();
sock.drop();
expect(seen).toEqual([true, false]);
});
});
-188
View File
@@ -1,188 +0,0 @@
import { create } from "zustand";
import { eventsWsUrl, listSessions } from "../api.js";
import type { CatalogEvent, Session, Uuid } from "../types.js";
export interface CatalogState {
sessions: Map<Uuid, Session>;
/** The events socket's up/down state. Drives the daemon link (daemonLink.ts)
* — the socket dying is the first sign the daemon went down or was
* upgraded under the page. */
connected: boolean;
}
/** Opens the events socket. Injectable so the reducer and the backoff schedule
* can be driven without a server. */
export type SocketFactory = () => WebSocket;
/** Schedules a reconnect attempt; returns a handle `clearTimer` understands. */
export interface Scheduler {
setTimer(fn: () => void, ms: number): number;
clearTimer(handle: number): void;
}
const browserScheduler: Scheduler = {
setTimer: (fn, ms) => window.setTimeout(fn, ms),
clearTimer: (h) => window.clearTimeout(h),
};
/** Initial reconnect delay, doubling to `MAX_BACKOFF_MS`. */
export const INITIAL_BACKOFF_MS = 500;
export const MAX_BACKOFF_MS = 10_000;
export interface CatalogStoreOptions {
socket?: SocketFactory;
scheduler?: Scheduler;
/** Full-state refetch used when the server reports it dropped frames. */
refetch?: () => Promise<Session[]>;
}
export interface CatalogActions {
start(): void;
/** Replace a socket that may have gone stale while the browser was suspended. */
reconnect(): void;
stop(): void;
/** Current reconnect delay, for tests asserting the backoff schedule. */
backoff(): number;
}
export type CatalogStore = CatalogState & CatalogActions;
/** Builds an isolated catalog store — a socket/scheduler/refetch can be
* injected (tests), or defaulted to the real browser/daemon ones. The
* returned value is both a React hook (`useCatalog(selector)`) and a plain
* object (`useCatalog.getState()`, `useCatalog.subscribe(fn)`), so the
* reconnect/backoff machinery works the same whether or not React is
* driving it. */
export function createCatalogStore(opts: CatalogStoreOptions = {}) {
const socketFactory = opts.socket ?? (() => new WebSocket(eventsWsUrl()));
const scheduler = opts.scheduler ?? browserScheduler;
const refetch = opts.refetch ?? listSessions;
let ws: WebSocket | null = null;
let reconnectTimer: number | null = null;
let backoffMs = INITIAL_BACKOFF_MS;
return create<CatalogStore>((set, get) => {
function apply(event: CatalogEvent): void {
switch (event.type) {
case "Snapshot":
set({ sessions: new Map(event.sessions.map((s) => [s.id, s])) });
return;
case "SessionAdded":
case "SessionUpdated": {
const next = new Map(get().sessions);
next.set(event.session.id, event.session);
set({ sessions: next });
return;
}
case "SessionRemoved": {
const next = new Map(get().sessions);
next.delete(event.id);
set({ sessions: next });
return;
}
case "Lagged":
// Re-derive from REST to avoid showing stale state.
void resync();
return;
}
}
async function resync(): Promise<void> {
try {
const sessions = await refetch();
set({ sessions: new Map(sessions.map((s) => [s.id, s])) });
} catch {
// surface in UI on next reconnect cycle; nothing else useful to do
}
}
function connect(): void {
const socket = socketFactory();
ws = socket;
socket.onmessage = (ev) => {
let event: CatalogEvent;
try {
event = JSON.parse(ev.data) as CatalogEvent;
} catch {
return;
}
apply(event);
};
socket.onopen = () => {
backoffMs = INITIAL_BACKOFF_MS;
set({ connected: true });
};
socket.onclose = () => {
ws = null;
set({ connected: false });
scheduleReconnect();
};
socket.onerror = () => {
try {
socket.close();
} catch {
// ignore
}
};
}
function scheduleReconnect(): void {
if (reconnectTimer !== null) return;
const delay = Math.min(backoffMs, MAX_BACKOFF_MS);
reconnectTimer = scheduler.setTimer(() => {
reconnectTimer = null;
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
connect();
}, delay);
}
return {
sessions: new Map(),
connected: false,
start() {
connect();
},
reconnect() {
if (reconnectTimer !== null) {
scheduler.clearTimer(reconnectTimer);
reconnectTimer = null;
}
if (ws) {
// A close event from the superseded socket must not schedule a
// second connection alongside the new one.
ws.onclose = null;
ws.onerror = null;
ws.close();
ws = null;
}
connect();
},
stop() {
if (reconnectTimer !== null) {
scheduler.clearTimer(reconnectTimer);
reconnectTimer = null;
}
if (ws) {
ws.onclose = null;
ws.close();
ws = null;
}
},
backoff() {
return backoffMs;
},
};
});
}
export type CatalogStoreApi = ReturnType<typeof createCatalogStore>;
-414
View File
@@ -1,414 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { createConnectionStore } from "./connectionStore.js";
import { createCatalogStore } from "./catalogStore.js";
import type { Session } from "../types.js";
// The pane's own bytes-out path, stubbed: the escape hatch only needs to know
// the pool reaches the focused connection with them.
const sent: { id: string; data: string }[] = [];
const disposed: string[] = [];
const mounts: { id: string; focus: boolean | undefined }[] = [];
function fakeConn(kind: string, id: string) {
const el = document.createElement("div");
el.tabIndex = -1; // focusable via .focus(), like a real input/terminal
el.dataset.kind = kind;
el.dataset.session = id;
return {
sessionId: id,
kind,
el,
onMount(opts?: { focus?: boolean }) {
mounts.push({ id, focus: opts?.focus });
},
onUnmount() {},
focusTerminal() {},
sendInput(data: string) {
sent.push({ id, data });
},
updateInfo() {},
dispose() {
disposed.push(`${kind}:${id}`);
},
};
}
vi.mock("../attach.js", () => ({
createAttachConnection: (id: string) => fakeConn("attach", id),
}));
vi.mock("../structured.js", () => ({
createStructuredConnection: (id: string) => fakeConn("structured", id),
}));
afterEach(() => {
vi.unstubAllGlobals();
sent.length = 0;
disposed.length = 0;
mounts.length = 0;
});
/** Minimal fixture for the sessions map a catalog store broadcasts. */
function session(id: string, view?: "Terminal" | "Structured"): Session {
return {
id,
cwd: "/repo",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
view,
};
}
/** A catalog store an id can be pushed into on demand, mirroring
* catalogStore.test.ts's own injectable-socket pattern. */
class FakeCatalogSocket {
onmessage: ((ev: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
close(): void {}
push(sessions: Session[]): void {
this.onmessage?.({ data: JSON.stringify({ type: "Snapshot", sessions }) });
}
}
function fakeCatalog() {
const socket = new FakeCatalogSocket();
const store = createCatalogStore({ socket: () => socket as unknown as WebSocket });
store.getState().start();
return { store, socket };
}
const A = "aaaaaaaa-0000-0000-0000-0000000000ff";
const B = "bbbbbbbb-0000-0000-0000-0000000000ff";
function mockFetch() {
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
describe("connectionStore setFocus", () => {
it("reports the new session focused, none unfocused, the first time", () => {
const fetchMock = mockFetch();
const pool = createConnectionStore();
pool.getState().setFocus(A);
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe(`/api/sessions/${A}/focus`);
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({ focused: true });
expect(pool.getState().focusedId).toBe(A);
});
it("unfocuses the previous session and focuses the new one on a switch", () => {
const fetchMock = mockFetch();
const pool = createConnectionStore();
pool.getState().setFocus(A);
fetchMock.mockClear();
pool.getState().setFocus(B);
expect(fetchMock).toHaveBeenCalledTimes(2);
const bodies = fetchMock.mock.calls.map(([url, init]: unknown[]) => [
url,
JSON.parse((init as RequestInit).body as string),
]);
expect(bodies).toContainEqual([`/api/sessions/${A}/focus`, { focused: false }]);
expect(bodies).toContainEqual([`/api/sessions/${B}/focus`, { focused: true }]);
});
it("reports unfocused when focus clears to null", () => {
const fetchMock = mockFetch();
const pool = createConnectionStore();
pool.getState().setFocus(A);
fetchMock.mockClear();
pool.getState().setFocus(null);
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe(`/api/sessions/${A}/focus`);
expect(JSON.parse(init.body as string)).toEqual({ focused: false });
});
it("is a no-op when the focused session doesn't change", () => {
const fetchMock = mockFetch();
const pool = createConnectionStore();
pool.getState().setFocus(A);
fetchMock.mockClear();
pool.getState().setFocus(A);
expect(fetchMock).not.toHaveBeenCalled();
});
it("does not let a fetch rejection break the pool", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const pool = createConnectionStore();
expect(() => pool.getState().setFocus(A)).not.toThrow();
// Let the rejected promise's `.catch` settle before the test ends.
await Promise.resolve();
await Promise.resolve();
});
});
describe("connectionStore sendToFocused", () => {
it("types the bytes into the focused session's PTY", () => {
mockFetch();
sent.length = 0;
const pool = createConnectionStore();
const pane = document.createElement("div");
pool.getState().mount(A, pane);
pool.getState().setFocus(A);
expect(pool.getState().sendToFocused("\x0b")).toBe(true);
expect(sent).toEqual([{ id: A, data: "\x0b" }]);
});
it("is a no-op with nothing focused", () => {
mockFetch();
sent.length = 0;
const pool = createConnectionStore();
expect(pool.getState().sendToFocused("\x0b")).toBe(false);
expect(sent).toEqual([]);
});
});
describe("connectionStore mount/unmount", () => {
it("tracks mounted and LRU order reactively", () => {
mockFetch();
const pool = createConnectionStore();
const paneA = document.createElement("div");
const paneB = document.createElement("div");
pool.getState().mount(A, paneA);
pool.getState().mount(B, paneB);
expect(pool.getState().cacheOrder).toEqual([A, B]);
expect([...pool.getState().mounted]).toEqual([A, B]);
pool.getState().unmount(A);
expect([...pool.getState().mounted]).toEqual([B]);
// Parked, not evicted — still in LRU order.
expect(pool.getState().cacheOrder).toEqual([A, B]);
pool.getState().close(A);
expect(pool.getState().cacheOrder).toEqual([B]);
expect(disposed).toEqual(["attach:" + A]);
});
it("evicts the least-recently-viewed parked connection beyond MAX_PARKED", () => {
mockFetch();
const pool = createConnectionStore();
const ids = Array.from({ length: 8 }, (_, i) => `id-${i}`);
for (const id of ids) {
pool.getState().mount(id, document.createElement("div"));
pool.getState().unmount(id); // park immediately
}
// Each mount only has itself visible (the rest already parked), so the
// cap-crossing eviction fires once, on the mount that pushes the parked
// count from 6 to 7: the single oldest (id-0) is dropped.
expect(disposed).toEqual(["attach:id-0"]);
expect(pool.getState().cacheOrder).toEqual(ids.slice(1));
});
});
// A deep link or restored layout can `mount()` a session before the catalog
// snapshot names its view — the daemon defaults an absent `view` to
// `"Terminal"`, but so does an *unknown* session, and only one of those is
// actually resolved (#140 review).
describe("connectionStore: view resolution races", () => {
it("replaces a wrongly-typed connection once the catalog resolves it, without waiting for another mount()", () => {
mockFetch();
const { store, socket } = fakeCatalog();
const pool = createConnectionStore({ store });
const pane = document.createElement("div");
pool.getState().mount(A, pane); // no info: deep-link-before-snapshot race
expect(disposed).toEqual([]);
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
// The fresh connection landed in the same pane nothing re-mounted it into.
expect(pane.children).toHaveLength(1);
});
it("also repairs a wrongly-typed connection that is parked, not mounted, without evicting its other parked siblings", () => {
mockFetch();
const { store, socket } = fakeCatalog();
const pool = createConnectionStore({ store });
const hidden = document.body.lastElementChild as HTMLElement; // this pool's own cache
pool.getState().mount(A, document.createElement("div"));
pool.getState().mount(B, document.createElement("div"));
pool.getState().unmount(A);
pool.getState().unmount(B);
disposed.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
// B's own element is still parked — A's repair didn't wipe its siblings
// out of the shared hidden cache.
expect(hidden.querySelector(`[data-session="${B}"]`)).not.toBeNull();
expect((hidden.querySelector(`[data-session="${A}"]`) as HTMLElement | null)?.dataset.kind).toBe("structured");
});
it("does not replace a connection whose resolved view already matches", () => {
mockFetch();
const { store, socket } = fakeCatalog();
const pool = createConnectionStore({ store });
const pane = document.createElement("div");
pool.getState().mount(A, pane, { name: "a", command: "claude", view: "Structured" });
disposed.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual([]);
});
it("does not downgrade an already-resolved connection when a later mount() call has no info", () => {
mockFetch();
const pool = createConnectionStore();
const pane = document.createElement("div");
pool.getState().mount(A, pane, { name: "a", command: "claude", view: "Structured" });
disposed.length = 0;
pool.getState().mount(A, pane); // e.g. a re-render before the catalog answers again
expect(disposed).toEqual([]);
});
it("does not treat a session missing from the catalog as Terminal and churn a live connection", () => {
mockFetch();
const { store, socket } = fakeCatalog();
const pool = createConnectionStore({ store });
const pane = document.createElement("div");
pool.getState().mount(A, pane, { name: "a", command: "claude", view: "Structured" });
disposed.length = 0;
// `A` is absent from this snapshot — the embedder's own subscription
// handles closing the pane; the pool must not race it by "fixing" the
// view first.
socket.push([session(B, "Terminal")]);
expect(disposed).toEqual([]);
});
it("re-focuses the fresh connection when the keyboard was actually inside the replaced pane", () => {
mockFetch();
const { store, socket } = fakeCatalog();
const pool = createConnectionStore({ store });
const pane = document.createElement("div");
document.body.appendChild(pane);
pool.getState().mount(A, pane);
pool.getState().setFocus(A);
(pane.firstElementChild as HTMLElement).focus();
expect(document.activeElement).toBe(pane.firstElementChild);
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
expect(mounts).toEqual([{ id: A, focus: true }]);
pane.remove();
});
it("does not steal focus from a same-tab sidebar preview even though focusedId still names the previous pane", () => {
mockFetch();
const { store, socket } = fakeCatalog();
const pool = createConnectionStore({ store });
const pane = document.createElement("div");
document.body.appendChild(pane);
pool.getState().mount(A, pane);
pool.getState().setFocus(A);
(pane.firstElementChild as HTMLElement).focus();
const sidebarInput = document.createElement("input");
document.body.appendChild(sidebarInput);
sidebarInput.focus();
expect(document.activeElement).toBe(sidebarInput);
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
expect(mounts).toEqual([{ id: A, focus: false }]);
pane.remove();
sidebarInput.remove();
});
it("does not focus a parked (unmounted) connection it repairs", () => {
mockFetch();
const { store, socket } = fakeCatalog();
const pool = createConnectionStore({ store });
const pane = document.createElement("div");
pool.getState().mount(A, pane);
pool.getState().setFocus(A);
pool.getState().unmount(A); // parked — no longer visible, `focusedId` still names it
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
expect(mounts).toEqual([]); // parked repair calls onUnmount, never onMount
});
});
describe("connectionStore setVisible/activate/parkActive", () => {
it("parks sessions not in the next visible set and mounts the rest", () => {
mockFetch();
const pool = createConnectionStore();
const paneA = document.createElement("div");
const paneB = document.createElement("div");
pool.getState().setVisible([
{ id: A, pane: paneA },
{ id: B, pane: paneB },
]);
expect([...pool.getState().mounted].sort()).toEqual([A, B]);
pool.getState().setVisible([{ id: B, pane: paneB }]);
expect([...pool.getState().mounted]).toEqual([B]);
});
it("activate mounts+focuses alone, parkActive parks all and clears focus", () => {
mockFetch();
const pool = createConnectionStore();
const pane = document.createElement("div");
pool.getState().activate(A, pane);
expect(pool.getState().focusedId).toBe(A);
expect(pool.getState().isMounted(A)).toBe(true);
pool.getState().parkActive();
expect(pool.getState().focusedId).toBeNull();
expect(pool.getState().isMounted(A)).toBe(false);
});
});
describe("connectionStore dispose", () => {
it("disposes every cached connection and resets state", () => {
mockFetch();
const pool = createConnectionStore();
pool.getState().mount(A, document.createElement("div"));
pool.getState().mount(B, document.createElement("div"));
disposed.length = 0;
pool.getState().dispose();
expect(disposed.sort()).toEqual(["attach:" + A, "attach:" + B]);
expect(pool.getState().mounted.size).toBe(0);
expect(pool.getState().cacheOrder).toEqual([]);
expect(pool.getState().focusedId).toBeNull();
});
});
-360
View File
@@ -1,360 +0,0 @@
import { create } from "zustand";
import { createAttachConnection } from "../attach.js";
import type { AttachInfo } from "../paneTitle.js";
import type { PaneConnection } from "../pane.js";
import { createStructuredConnection } from "../structured.js";
import { setSessionFocus } from "../api.js";
import { sessionName, type Session, type SessionView, type Uuid } from "../types.js";
import type { CatalogStoreApi } from "./catalogStore.js";
/**
* How many *parked* (off-screen but still-connected) sessions to keep warm
* beyond whatever is currently visible. Visible panes are never counted or
* evicted — only this background buffer is bounded, so the browser doesn't hold
* an unbounded fan of open sockets. Switching back to a recently-viewed session
* is instant while it's still parked; older ones are evicted (WS closed) once
* the buffer overflows.
*/
const MAX_PARKED = 6;
/** A session's view defaults to `"Terminal"` wherever it isn't carried —
* matching the wire default (`SessionView`'s `#[default]`, types.ts). Only
* meaningful once the session itself is known; an altogether-unknown
* session (no `AttachInfo`/`Session` at all) is a distinct, un-resolved
* state callers must check for separately, not "Terminal". */
function resolveView(view: SessionView | undefined): SessionView {
return view ?? "Terminal";
}
export interface PoolOptions {
/** Fired when the focused session changes (drives sidebar highlight + HUD). */
onFocusChange?: (id: Uuid | null) => void;
/** Fired when a view's explicit disconnect control is used; the embedder
* decides how to update the layout/route (then typically calls `close`). */
onRequestClose?: (id: Uuid) => void;
/** Fired when a connection's socket closes on its own (session ended/dropped),
* not via an intentional close/eviction. The embedder closes the pane. */
onClosed?: (id: Uuid) => void;
/** Forwarded to each connection: should a dropped socket be retried? */
shouldReconnect?: (id: Uuid, code: number) => boolean;
/** Threaded through to a structured connection so its rail can read live
* worktree/git-status data (#140). Terminal connections don't need it. */
store?: CatalogStoreApi;
}
export interface ConnectionState {
/** Sessions currently mounted into a visible pane. */
mounted: Set<Uuid>;
/** Every live (mounted or parked) connection, oldest first — LRU order. */
cacheOrder: Uuid[];
/** The focused session, or null when nothing is focused (welcome pane). */
focusedId: Uuid | null;
}
export interface ConnectionActions {
isMounted(id: Uuid): boolean;
/**
* Ensure `id` is live and shown inside `pane`, reusing its connection if one
* exists (refreshing LRU recency). Does not touch other mounted panes.
*/
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, opts?: { focus?: boolean }): void;
/** Park `id` back into the hidden cache (still connected) and stop showing it. */
unmount(id: Uuid): void;
/**
* Reconcile the visible set to exactly `entries` (in render order): park any
* mounted session not listed, mount/refresh each listed one in its pane.
* `focus: false` shows the panes without taking the keyboard (preview).
*/
setVisible(entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[], opts?: { focus?: boolean }): void;
/** Also tells the daemon which pane is focused (docs/SIDEBAR.md's
* `AwaitingInput` auto-clear) — the sidebar highlight never calls this,
* only a pane actually becoming the visible view. Best-effort: a failed
* report only delays the glow clearing. */
setFocus(id: Uuid | null): void;
/** Compat shim: show `id` alone in `pane` and focus it; park everything else. */
activate(id: Uuid, pane: HTMLElement, info?: AttachInfo): void;
/** Compat shim: park all visible panes and clear focus (e.g. welcome pane). */
parkActive(): void;
/** Put the keyboard back into the focused session's terminal. No-op when
* nothing is focused (welcome pane) or its connection is gone. */
focusTerminal(): void;
/** Type raw bytes into the focused session's PTY. Returns false when there is
* nothing focused, or its connection is gone — the leader's literal Ctrl-K
* is a no-op over the welcome pane rather than an error
* (docs/COMMAND-BAR.md). */
sendToFocused(data: string): boolean;
/** Fully disconnect + forget a session. */
close(id: Uuid): void;
dispose(): void;
}
export type ConnectionStore = ConnectionState & ConnectionActions;
/**
* Builds an isolated connection store — owns the set of live attach
* connections, decoupled from the router/UI. The returned value is both a
* React hook and a plain object (`getState`/`subscribe`), same shape as
* {@link createCatalogStore}.
*
* Unlike a single-active pool, several connections can be **mounted**
* (visible) at once — one per leaf of the active tab's layout tree. The
* caller reconciles the visible set with {@link ConnectionActions.setVisible}.
* Sessions no longer shown are **parked** in a hidden cache (still streaming)
* and bounded by an LRU. A connection only dies on eviction, an explicit
* {@link ConnectionActions.close}, or {@link ConnectionActions.dispose}.
*/
export function createConnectionStore(opts: PoolOptions = {}) {
/** All live connections. Insertion order doubles as LRU: oldest first. */
const cache = new Map<Uuid, PaneConnection>();
/** The view each cached connection was actually created for — a deep link
* or a restored layout can `mount()` a session before the catalog
* snapshot names its view, defaulting it to `"Terminal"`; this is what
* lets a later reconciliation notice the resolved view disagrees (#140
* review). */
const connViews = new Map<Uuid, SessionView>();
// Off-screen home for parked, still-connected terminals.
const hidden = document.createElement("div");
hidden.className = "conn-cache";
hidden.setAttribute("aria-hidden", "true");
document.body.appendChild(hidden);
let unsubscribeStore: (() => void) | undefined;
const store = create<ConnectionStore>((set, get) => {
function createConn(id: Uuid, info?: AttachInfo): PaneConnection {
const view = resolveView(info?.view);
connViews.set(id, view);
const connOpts = {
onRequestClose: (sid: Uuid) => opts.onRequestClose?.(sid),
onClosed: (sid: Uuid) => opts.onClosed?.(sid),
shouldReconnect: (sid: Uuid, code: number) => opts.shouldReconnect?.(sid, code) ?? false,
};
// structured.ts's own `store` param still expects the pre-Zustand
// `CatalogStore` class (events.ts) and doesn't use it yet (#140) — left
// unpassed here until that rail wiring is ported too.
return view === "Structured"
? createStructuredConnection(id, connOpts, info)
: createAttachConnection(id, connOpts, info);
}
/** Dispose whatever is cached for `id` and cache a freshly created
* connection in its place. Does not touch the DOM, `mounted`, or LRU
* order — callers place the new element themselves. */
function replace(id: Uuid, info?: AttachInfo): PaneConnection {
cache.get(id)?.dispose();
const fresh = createConn(id, info);
cache.set(id, fresh);
return fresh;
}
function insertOrder(id: Uuid): void {
set((s) => ({ cacheOrder: [...s.cacheOrder, id] }));
}
function touchOrder(id: Uuid): void {
set((s) => ({ cacheOrder: [...s.cacheOrder.filter((x) => x !== id), id] }));
}
function removeOrder(id: Uuid): void {
set((s) => ({ cacheOrder: s.cacheOrder.filter((x) => x !== id) }));
}
function setMounted(id: Uuid, isMounted: boolean): void {
set((s) => {
if (s.mounted.has(id) === isMounted) return {};
const next = new Set(s.mounted);
if (isMounted) next.add(id);
else next.delete(id);
return { mounted: next };
});
}
function setFocus(id: Uuid | null): void {
const previous = get().focusedId;
if (id === previous) return;
set({ focusedId: id });
if (previous !== null) void setSessionFocus(previous, false).catch(() => {});
if (id !== null) void setSessionFocus(id, true).catch(() => {});
opts.onFocusChange?.(id);
}
function close(id: Uuid): void {
const conn = cache.get(id);
if (!conn) return;
conn.dispose();
cache.delete(id);
connViews.delete(id);
removeOrder(id);
setMounted(id, false);
if (get().focusedId === id) setFocus(null);
}
/** Drop least-recently-viewed *parked* connections beyond the buffer cap. */
function evict(): void {
while (cache.size - get().mounted.size > MAX_PARKED) {
const mounted = get().mounted;
let victim: Uuid | null = null;
for (const id of cache.keys()) {
if (!mounted.has(id)) {
victim = id;
break;
}
}
if (victim === null) break;
close(victim);
}
}
function mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, mountOpts?: { focus?: boolean }): void {
let conn = cache.get(id);
if (conn) {
// `info === undefined` means "session not known yet", not "Terminal" —
// never downgrade a resolved connection just because a later caller
// couldn't resolve the session. Only a *resolved* view that disagrees
// with what this connection was built for forces a replace.
if (info !== undefined && resolveView(info.view) !== connViews.get(id)) {
conn = replace(id, info);
} else {
touchOrder(id);
if (info) conn.updateInfo(info);
}
} else {
conn = createConn(id, info);
cache.set(id, conn);
insertOrder(id);
}
if (conn.el.parentElement !== pane) {
pane.replaceChildren(conn.el);
conn.onMount(mountOpts);
} else if (!get().mounted.has(id)) {
conn.onMount(mountOpts);
}
setMounted(id, true);
evict();
}
function unmount(id: Uuid): void {
if (!get().mounted.has(id)) return;
setMounted(id, false);
const conn = cache.get(id);
if (conn) {
hidden.appendChild(conn.el); // stays alive, just off-screen
conn.onUnmount();
}
}
function setVisible(
entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[],
visOpts?: { focus?: boolean },
): void {
const next = new Set(entries.map((e) => e.id));
for (const id of [...get().mounted]) {
if (!next.has(id)) unmount(id);
}
for (const e of entries) mount(e.id, e.pane, e.info, visOpts);
}
/** Proactively fix any cached connection whose view no longer matches the
* catalog — the only path that repairs a pane nothing will `mount()`
* again on its own (parked, or mounted but not currently being
* re-rendered by anything else). A session absent from `sessions` (not
* yet known, or removed) is left alone — that's the embedder's own
* close/eviction concern, not a view mismatch. */
function reconcileViews(sessions: Map<Uuid, Session>): void {
for (const id of [...cache.keys()]) {
const session = sessions.get(id);
if (session === undefined || resolveView(session.view) === connViews.get(id)) continue;
const existing = cache.get(id)!;
const parent = existing.el.parentElement;
const wasMounted = get().mounted.has(id);
// Capture before anything below touches the DOM: dispose()/el.remove()
// detach `existing.el`, and once a focused descendant is detached the
// browser moves `document.activeElement` to `<body>` — too late to
// tell whether the keyboard was actually in this pane.
const hadDomFocus = existing.el.contains(document.activeElement);
const info: AttachInfo = { name: sessionName(session), command: session.command, view: session.view };
const fresh = replace(id, info);
existing.el.remove(); // don't rely on dispose() to clear the DOM
if (parent && parent !== hidden) {
parent.replaceChildren(fresh.el);
} else {
// Parked: `hidden` holds every parked connection's element as
// siblings — append, never replaceChildren, or they'd all vanish.
hidden.appendChild(fresh.el);
}
if (wasMounted) {
// `focusedId` names the session the pool was last told to focus —
// but a same-tab sidebar preview moves the keyboard into the
// sidebar without clearing it, so `focusedId` alone can point at a
// pane the keyboard already left. Only re-focus when the DOM
// agrees it never did.
fresh.onMount({ focus: get().focusedId === id && hadDomFocus });
} else {
fresh.onUnmount();
}
}
}
// A session `mount()`ed before its catalog entry exists gets a
// `"Terminal"` connection by default; once the snapshot actually names
// the view, fix any connection that guessed wrong — nothing else
// re-`mount()`s an already-open pane just because the catalog updated.
unsubscribeStore = opts.store?.subscribe((state) => reconcileViews(state.sessions));
return {
mounted: new Set<Uuid>(),
cacheOrder: [],
focusedId: null,
isMounted(id) {
return get().mounted.has(id);
},
mount,
unmount,
setVisible,
setFocus,
activate(id, pane, info) {
setVisible([{ id, pane, info }]);
setFocus(id);
},
parkActive() {
for (const id of [...get().mounted]) unmount(id);
setFocus(null);
},
focusTerminal() {
const id = get().focusedId;
if (id === null) return;
cache.get(id)?.focusTerminal();
},
sendToFocused(data) {
const id = get().focusedId;
if (id === null) return false;
const conn = cache.get(id);
if (!conn) return false;
conn.sendInput(data);
return true;
},
close,
dispose() {
unsubscribeStore?.();
for (const conn of cache.values()) conn.dispose();
cache.clear();
connViews.clear();
set({ mounted: new Set(), cacheOrder: [], focusedId: null });
hidden.remove();
},
};
});
return store;
}
export type ConnectionStoreApi = ReturnType<typeof createConnectionStore>;
+16 -199
View File
@@ -134,7 +134,7 @@ describe("structured connection: data flow", () => {
} });
input.dispatchEvent(paste);
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
await vi.waitFor(() => expect(conn.el.textContent).toContain("Upload failed"));
await vi.waitFor(() => expect(input.disabled).toBe(false));
expect(input.value).toBe("Read this");
expect(sendStructuredPrompt).not.toHaveBeenCalled();
const remove = conn.el.querySelector<HTMLButtonElement>('[aria-label="Remove notes.txt"]')!;
@@ -413,95 +413,6 @@ describe("structured connection: data flow", () => {
conn.dispose();
});
it("absorbs intermediate agent text into tool group, keeps final text outside", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 8,
rows: [
row("user:1", "user", { content: { type: "text", text: "do it" } }),
row("agent:1", "agent_text", { content: { type: "text", text: "Let me check" } }),
toolRow("tool:1", "one"),
toolRow("tool:2", "two"),
row("agent:2", "agent_text", { content: { type: "text", text: "Found issues, fixing" } }),
toolRow("tool:3", "three"),
toolRow("tool:4", "four"),
row("agent:3", "agent_text", { content: { type: "text", text: "All done" } }),
],
},
});
const groups = conn.el.querySelectorAll<HTMLElement>(".st-tool-group");
expect(groups).toHaveLength(1);
// Header counts only tool rows, not intermediate text.
expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("4 tool calls");
// Intermediate text (agent:2) is inside the group.
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
// First text (agent:1) is outside — it precedes all tools.
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).toBeNull();
// Final text (agent:3) is outside — no tool follows it.
expect(conn.el.querySelector('[data-row-id="agent:3"]')?.closest(".st-tool-group")).toBeNull();
conn.dispose();
});
it("keeps last intermediate text outside group while turn is active", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
const respondingState: ReducedState = {
turn: { responding: true, thinking: false, active_tool_calls: [] },
};
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 6,
rows: [
row("user:1", "user", { content: { type: "text", text: "do it" } }),
toolRow("tool:1", "one"),
row("agent:1", "agent_text", { content: { type: "text", text: "Now fixing" } }),
toolRow("tool:2", "two"),
row("agent:2", "agent_text", { content: { type: "text", text: "Found more" } }),
toolRow("tool:3", "three"),
],
},
});
ws.emit({ type: "reduced_state", state: respondingState });
// Intermediate updates stay inside the collapsed activity disclosure,
// rather than becoming a second visible transcript alongside its summary.
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull();
// It remains collapsed when the turn ends.
ws.emit({ type: "reduced_state", state: idleState });
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull();
conn.dispose();
});
it("collapses trailing thought commentary with a turn's tool activity", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 5,
rows: [
row("user:1", "user", { content: { type: "text", text: "do it" } }),
toolRow("tool:1", "one"),
toolRow("tool:2", "two"),
row("thought:1", "agent_thought", { content: { type: "text", text: "Checking the next step" } }),
row("thought:2", "agent_thought", { content: { type: "text", text: "Preparing the change" } }),
],
},
});
for (const id of ["thought:1", "thought:2"]) {
expect(conn.el.querySelector(`[data-row-id="${id}"]`)?.closest(".st-tool-group")).not.toBeNull();
}
conn.dispose();
});
it("keeps an expanded running tool open without emitting a click when patched", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
@@ -749,7 +660,7 @@ describe("structured connection: turn/composer gating", () => {
conn.dispose();
});
it("keeps the composer available while a turn is active", () => {
it("disables the composer while a turn is active and re-enables when idle", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
@@ -757,13 +668,13 @@ describe("structured connection: turn/composer gating", () => {
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement;
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
expect(input.disabled).toBe(false);
expect(input.disabled).toBe(true);
expect(cancel.hidden).toBe(false);
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(true);
ws.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(cancel.hidden).toBe(true);
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(false);
conn.dispose();
});
@@ -826,7 +737,7 @@ describe("structured connection: turn/composer gating", () => {
conn.dispose();
});
it("queues a second message before begin-turn is projected", () => {
it("disables immediately and dispatches only once before begin-turn is projected", () => {
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
@@ -839,45 +750,12 @@ describe("structured connection: turn/composer gating", () => {
input.value = "only once";
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
input.value = "queued next";
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
expect(input.disabled).toBe(false);
expect(send.disabled).toBe(false);
expect(input.disabled).toBe(true);
expect(send.disabled).toBe(true);
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
const queued = conn.el.querySelectorAll(".st-row-queued");
expect(queued).toHaveLength(1);
expect(queued[0].textContent).toBe("queued next");
conn.dispose();
});
it("dispatches queued messages in order as turns become idle", async () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const submit = (text: string) => {
input.value = text;
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
};
submit("first");
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
submit("second");
submit("third");
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
expect(conn.el.querySelectorAll(".st-row-queued")).toHaveLength(2);
await Promise.resolve().then(() => Promise.resolve());
ws.emit({ type: "reduced_state", state: idleState });
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(2, "s1", "second");
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
await Promise.resolve().then(() => Promise.resolve());
ws.emit({ type: "reduced_state", state: idleState });
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(3, "s1", "third");
expect(conn.el.querySelectorAll(".st-row-queued")).toHaveLength(0);
conn.dispose();
});
@@ -929,7 +807,7 @@ describe("structured connection: turn/composer gating", () => {
expect(sendStructuredPrompt).toHaveBeenCalledTimes(2);
expect(input.value).toBe("");
expect(input.disabled).toBe(false);
expect(input.disabled).toBe(true);
expect(conn.el.textContent).not.toContain("stale failure");
conn.dispose();
});
@@ -991,7 +869,7 @@ describe("structured connection: approvals", () => {
conn.dispose();
});
it("hides a standalone resolved approval entirely when no tool group absorbs it", () => {
it("removes the buttons once the approval resolves, even with no accompanying transcript delta", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
@@ -1007,68 +885,7 @@ describe("structured connection: approvals", () => {
// Resolved on another client: state clears with no transcript delta at all.
ws.emit({ type: "reduced_state", state: idleState });
expect(conn.el.querySelector(".st-approval-acts")).toBeNull();
// 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();
expect(conn.el.querySelector(".st-approval-resolved")).not.toBeNull();
conn.dispose();
});
@@ -1309,7 +1126,7 @@ describe("structured connection: reconnection/error/loading", () => {
second.open();
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
second.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(input.disabled).toBe(true);
second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
second.emit({ type: "reduced_state", state: idleState });
@@ -1361,12 +1178,12 @@ describe("structured connection: reconnection/error/loading", () => {
second.open();
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
second.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(input.disabled).toBe(true);
accept();
await Promise.resolve().then(() => Promise.resolve());
expect(input.disabled).toBe(false);
expect(input.disabled).toBe(true);
expect(second.closed).toBe(true);
third.open();
@@ -1767,7 +1584,7 @@ describe("structured connection: pane lifecycle", () => {
conn.dispose();
});
it("focusTerminal keeps the composer reachable during an active turn", () => {
it("focusTerminal focuses the composer only when it is enabled", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
@@ -1775,7 +1592,7 @@ describe("structured connection: pane lifecycle", () => {
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
conn.focusTerminal();
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
expect(document.activeElement).toBe(input);
expect(document.activeElement).not.toBe(input);
ws.emit({ type: "reduced_state", state: idleState });
conn.focusTerminal();
expect(document.activeElement).toBe(input);
+55 -260
View File
@@ -85,10 +85,10 @@ export function createStructuredConnection(
<div class="structured-main">
<div class="structured-scroll">
<div class="structured-rows"></div>
<div class="structured-activity" role="status" aria-live="polite" hidden>
<span class="structured-activity-pulse" aria-hidden="true"></span>
<span class="structured-activity-text">Thinking</span>
</div>
</div>
<div class="structured-activity" role="status" aria-live="polite" hidden>
<span class="structured-activity-pulse" aria-hidden="true"></span>
<span class="structured-activity-text">Thinking</span>
</div>
<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)">
@@ -113,10 +113,7 @@ export function createStructuredConnection(
<button type="submit" class="btn btn-primary structured-send"><span>Send</span>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7 5 5 5-5 5M12 10H3"/></svg>
</button>
<button type="button" class="btn 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>
<button type="button" class="btn structured-cancel" hidden>Cancel turn</button>
</form>
</div>
</div>
@@ -136,13 +133,11 @@ export function createStructuredConnection(
const commandMenu = el.querySelector(".structured-command-menu") as HTMLDivElement;
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
const sendLabel = sendBtn.querySelector("span") as HTMLSpanElement;
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement;
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
type Attachment = { file: File; path?: string };
type QueuedPrompt = { text: string; attachments: Attachment[] };
let attachments: Attachment[] = [];
let uploading = false;
let pasteCount = 0;
@@ -160,7 +155,7 @@ export function createStructuredConnection(
remove.type = "button";
remove.textContent = "×";
remove.setAttribute("aria-label", `Remove ${attachment.file.name}`);
remove.disabled = uploading;
remove.disabled = input.disabled;
remove.addEventListener("click", () => {
attachments = attachments.filter((item) => item !== attachment);
renderAttachments();
@@ -171,7 +166,7 @@ export function createStructuredConnection(
}
function addFiles(files: File[]): void {
if (uploading || disposed) return;
if (input.disabled || disposed) return;
for (const file of files) {
if (file.size > 20 * 1024 * 1024) {
showError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
@@ -198,7 +193,7 @@ export function createStructuredConnection(
});
input.addEventListener("paste", (event) => {
const clipboard = event.clipboardData;
if (!clipboard || uploading) return;
if (!clipboard || input.disabled) return;
if (clipboard.files.length) {
event.preventDefault();
addFiles(Array.from(clipboard.files));
@@ -249,10 +244,6 @@ export function createStructuredConnection(
let disposed = false;
let optimisticTurnStartedAt: 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.
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
@@ -383,9 +374,6 @@ export function createStructuredConnection(
}
}
/** Pattern matching `Attached file "name": "path"` references appended by dispatchPrompt. */
const attachedFileRe = /Attached file "(?:[^"\\]|\\.)*": "((?:[^"\\]|\\.)*)"/g;
function buildRowElement(row: TranscriptRow): HTMLElement | null {
const el = buildTranscriptRow(
row,
@@ -402,102 +390,20 @@ export function createStructuredConnection(
if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") {
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;
}
/** Group tool rows (and intermediate agent text / resolved approvals between
* them) by turn. An agent_text/agent_thought row is "intermediate" when a
* tool row follows it in the same turn — it gets absorbed into the group so
* the collapsed block preserves chronological order. Resolved approval rows
* (not the currently pending one) are absorbed the same way. The *last*
* agent text with no subsequent tool stays standalone (the prominent final
* answer). Returns groups keyed by `turn:<userId>`, each with `toolCount`
* (for the header label) and `rows` (tools + intermediate text/approvals,
* in order). */
function activityGroupsByTurn(): Map<string, { toolCount: number; rows: TranscriptRow[] }> {
// First: count tools per turn to know which turns qualify for grouping.
const toolCounts = new Map<string, number>();
function toolGroupsByTurn(): Map<string, TranscriptRow[]> {
const groups = new Map<string, TranscriptRow[]>();
let turnId = "before-first-message";
for (const row of rows) {
if (row.kind === "user") turnId = row.id;
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
const gid = `turn:${turnId}`;
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
const id = `turn:${turnId}`;
const group = groups.get(id) ?? [];
group.push(row);
groups.set(id, group);
}
// Second: build groups with interleaved text for turns with 2+ tools.
const groups = new Map<string, { toolCount: number; rows: TranscriptRow[] }>();
turnId = "before-first-message";
// Pending rows that might be intermediate (not yet confirmed by a
// following tool). If no tool follows, they stay standalone.
let pending: TranscriptRow[] = [];
let currentGid = `turn:${turnId}`;
for (const row of rows) {
if (row.kind === "user") {
turnId = row.id;
currentGid = `turn:${turnId}`;
pending = [];
continue;
}
const count = toolCounts.get(currentGid) ?? 0;
if (count < 2) continue;
const isIntermediateText = row.kind === "agent_text" || row.kind === "agent_thought";
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalRowId;
if (isIntermediateText || isResolvedApproval) {
pending.push(row);
continue;
}
if (row.kind === "tool_call" || row.kind === "tool_diff") {
const group = groups.get(currentGid) ?? { toolCount: count, rows: [] };
// Flush any pending rows — a tool follows, so they're intermediate.
group.rows.push(...pending);
pending = [];
group.rows.push(row);
groups.set(currentGid, group);
}
}
// A final agent text is the answer, so it stays outside. Thought rows are
// working commentary, though: when a turn already has a disclosure they
// belong with its activity even if the next tool call has not arrived yet.
// Leaving that tail loose made a busy agent look like it was publishing a
// long italic answer above a collapsed "N tool calls" summary.
const lastGroup = groups.get(currentGid);
if (lastGroup && pending.length > 0 && pending.every((row) => row.kind === "agent_thought")) {
lastGroup.rows.push(...pending);
}
return groups;
}
@@ -507,12 +413,11 @@ export function createStructuredConnection(
const focusedGroupId = active?.closest<HTMLElement>("[data-tool-group-id]")?.dataset.toolGroupId;
const restoreToolFocus = active?.classList.contains("st-tool-head") ?? false;
const restoreGroupFocus = active?.classList.contains("st-tool-group-head") ?? false;
const groups = activityGroupsByTurn();
const groups = toolGroupsByTurn();
const groupedRowIds = new Set<string>();
const liveGroupIds = new Set([...groups.keys()]);
const liveGroupIds = new Set([...groups].filter(([, tools]) => tools.length > 1).map(([id]) => id));
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
const prevGroupEls = new Map(groupEls);
rowEls.clear();
groupEls.clear();
const children: HTMLElement[] = [];
@@ -520,54 +425,34 @@ export function createStructuredConnection(
for (const row of rows) {
if (row.kind === "user") turnId = row.id;
const groupId = `turn:${turnId}`;
const group = groups.get(groupId);
if (group && groupedRowIds.has(row.id)) continue;
if (group && (row.kind === "tool_call" || row.kind === "tool_diff") && !groupedRowIds.has(row.id)) {
// First ungrouped tool in this turn — render the whole activity group.
const memberEls = group.rows.flatMap((memberRow) => {
groupedRowIds.add(memberRow.id);
const memberEl = buildRowElement(memberRow);
if (!memberEl) return [];
rowEls.set(memberRow.id, memberEl);
return [memberEl];
const groupRows = groups.get(groupId) ?? [];
if ((row.kind === "tool_call" || row.kind === "tool_diff") && groupRows.length > 1) {
if (groupedRowIds.has(row.id)) continue;
const toolEls = groupRows.flatMap((toolRow) => {
groupedRowIds.add(toolRow.id);
const toolEl = buildRowElement(toolRow);
if (!toolEl) return [];
rowEls.set(toolRow.id, toolEl);
return [toolEl];
});
// Reuse existing group element when possible — avoids tearing down
// and recreating the wrapper, which restarts CSS transitions (visible
// flash) and destroys keyboard/focus state.
const existing = prevGroupEls.get(groupId);
if (existing) {
const titleEl = existing.querySelector<HTMLElement>(".st-tool-group-title");
if (titleEl) titleEl.textContent = `${group.toolCount} tool calls`;
const inner = existing.querySelector<HTMLElement>(".st-disclosure-inner");
if (inner) inner.replaceChildren(...memberEls);
groupEls.set(groupId, existing);
children.push(existing);
} else {
const groupEl = buildToolGroup(
groupId,
group.rows,
memberEls,
expandedGroupIds.has(groupId),
(expanded) => {
if (expanded) expandedGroupIds.add(groupId);
else expandedGroupIds.delete(groupId);
settleDisclosureLayout();
},
group.toolCount,
);
if (group.rows.some((r) => enteringRowIds.has(r.id))) {
groupEl.classList.add("st-row-enter");
}
groupEls.set(groupId, groupEl);
children.push(groupEl);
const groupEl = buildToolGroup(
groupId,
groupRows,
toolEls,
expandedGroupIds.has(groupId),
(expanded) => {
if (expanded) expandedGroupIds.add(groupId);
else expandedGroupIds.delete(groupId);
settleDisclosureLayout();
},
);
if (groupRows.some((toolRow) => enteringRowIds.has(toolRow.id))) {
groupEl.classList.add("st-row-enter");
}
groupEls.set(groupId, groupEl);
children.push(groupEl);
continue;
}
// Resolved approvals not absorbed into a tool group are hidden — the
// tool row itself already identifies the action, so a standalone
// "answered" card adds nothing. Pending approvals always render.
if (row.kind === "approval" && row.id !== pendingApprovalRowId) continue;
const rowEl = buildRowElement(row);
if (!rowEl) continue;
rowEls.set(row.id, rowEl);
@@ -619,24 +504,6 @@ export function createStructuredConnection(
rows.push(delta.row);
} else {
rows[i] = delta.row;
// Surgical DOM swap: replace just this row's element instead of
// tearing down and rebuilding the entire transcript, which causes
// visible flashing and breaks text selection.
const oldEl = rowEls.get(delta.row.id);
if (oldEl) {
const active = document.activeElement as HTMLElement | null;
const hadToolFocus = active?.classList.contains("st-tool-head") && oldEl.contains(active);
const newEl = buildRowElement(delta.row);
if (newEl) {
oldEl.replaceWith(newEl);
rowEls.set(delta.row.id, newEl);
if (hadToolFocus) {
newEl.querySelector<HTMLButtonElement>(".st-tool-head")?.focus();
}
}
syncAvailableCommands();
return;
}
}
} else {
const i = rowIndex.get(delta.id);
@@ -681,14 +548,12 @@ export function createStructuredConnection(
}
}
let configDirty = false;
function renderConfig(): void {
const options = sessionConfigOptions();
configEl.hidden = options.length === 0;
if (options.length === 0) {
configMenu.hidden = true;
configTrigger.setAttribute("aria-expanded", "false");
configDirty = false;
return;
}
const ordered = [...options].sort((a, b) => {
@@ -702,15 +567,6 @@ export function createStructuredConnection(
configTrigger.title = summary;
configTrigger.setAttribute("aria-label", `Session settings. ${summary.replaceAll("\n", ". ")}`);
configTrigger.disabled = configBusy;
// When the menu is open and the user is interacting (not mid-change),
// skip the DOM rebuild so open <select> dropdowns aren't destroyed.
// Mark dirty so the next open rebuilds with fresh data.
if (!configMenu.hidden && !configBusy) {
configDirty = true;
return;
}
configDirty = false;
configMenu.replaceChildren();
for (const option of ordered) {
const row = document.createElement("label");
@@ -749,33 +605,17 @@ export function createStructuredConnection(
}
}
function renderQueuedPrompts(): void {
for (const old of rowsEl.querySelectorAll(".st-row-queued")) old.remove();
for (const queued of promptQueue) {
const wrap = document.createElement("div");
wrap.className = "st-row st-row-queued";
const body = document.createElement("div");
body.className = "st-body";
const line = document.createElement("div");
line.className = "st-user-text";
line.textContent = queued.text;
body.appendChild(line);
wrap.appendChild(body);
rowsEl.appendChild(wrap);
}
}
function renderComposerState(): void {
const active = uploading || state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const projectedActive = state.turn.responding || state.turn.thinking;
input.disabled = false;
attachBtn.disabled = uploading;
fileInput.disabled = uploading;
input.disabled = active;
attachBtn.disabled = active;
fileInput.disabled = active;
renderAttachments();
sendBtn.disabled = false;
sendBtn.hidden = false;
sendLabel.textContent = "Send";
sendBtn.disabled = active;
sendBtn.hidden = projectedActive;
cancelBtn.hidden = !projectedActive;
renderQueuedPrompts();
if (active) closeCommandMenu();
renderActivity();
}
@@ -787,7 +627,6 @@ export function createStructuredConnection(
function renderActivity(): void {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const wasHidden = activityEl.hidden;
activityEl.hidden = !active;
if (!active) {
optimisticTurnStartedAt = null;
@@ -810,11 +649,6 @@ export function createStructuredConnection(
}
activityText.textContent = parts.join(" · ");
if (activityTimer === null) activityTimer = window.setInterval(renderActivity, 1_000);
// Activity lives inside the scroll area; nudge scroll when it first appears.
if (wasHidden && followingBottom) {
beginTranscriptLayout(true);
finishTranscriptLayout();
}
}
function reconcileAwaitingBegin(): void {
@@ -830,22 +664,19 @@ export function createStructuredConnection(
}
function applyState(next: ReducedState, connectionGeneration: number): void {
const wasTurnActive = state.turn.responding || state.turn.thinking;
state = next;
stateSocketGeneration = connectionGeneration;
reconcileAwaitingBegin();
const isTurnActive = state.turn.responding || state.turn.thinking;
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
const needsRebuild = newPendingId !== pendingApprovalRowId || wasTurnActive !== isTurnActive;
if (needsRebuild) {
if (newPendingId !== pendingApprovalRowId) {
updateTranscriptLayout(() => {
const prev = pendingApprovalRowId;
pendingApprovalRowId = newPendingId;
rebuildTranscript();
if (prev !== null || newPendingId !== null) rebuildTranscript();
});
}
renderConfig();
renderComposerState();
dispatchQueuedPrompt();
}
function closeConfigMenu(): void {
@@ -855,7 +686,6 @@ export function createStructuredConnection(
configTrigger.addEventListener("click", () => {
const opening = configMenu.hidden;
if (opening && configDirty) renderConfig();
configMenu.hidden = !opening;
configTrigger.setAttribute("aria-expanded", String(opening));
});
@@ -1046,14 +876,17 @@ export function createStructuredConnection(
if (!commandMenu.contains(document.activeElement)) closeCommandMenu();
}, 0);
});
function dispatchPrompt(text: string, submittedAttachments: Attachment[] = []): void {
composerForm.addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value;
if ((!text.trim() && attachments.length === 0) || input.disabled) return;
const submittedAttachments = attachments;
uploading = submittedAttachments.length > 0;
const generation = ++submissionGeneration;
const submissionSocketGeneration = socketGeneration;
awaitingBeginGeneration = generation;
optimisticTurnStartedAt = Date.now();
acceptedSubmission = null;
promptRequestPending = true;
input.value = "";
autoResize();
clearError();
@@ -1062,9 +895,6 @@ export function createStructuredConnection(
for (const attachment of submittedAttachments) {
if (disposed) return;
if (!attachment.path) attachment.path = (await uploadStructuredFile(sessionId, attachment.file)).path;
if (attachment.path && attachment.file.type.startsWith("image/")) {
imageObjectUrls.set(attachment.path, URL.createObjectURL(attachment.file));
}
}
if (disposed) return;
const references = submittedAttachments.map(({ file, path }) =>
@@ -1073,7 +903,6 @@ export function createStructuredConnection(
};
void send().then(
() => {
promptRequestPending = false;
if (disposed || generation !== submissionGeneration) return;
uploading = false;
if (attachments === submittedAttachments) {
@@ -1087,17 +916,11 @@ export function createStructuredConnection(
// A replacement socket may have captured idle before the POST claimed
// the prompt. Reconnect again so its snapshot cannot predate the 204.
if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation();
renderComposerState();
dispatchQueuedPrompt();
},
(e: unknown) => {
promptRequestPending = false;
uploading = false;
if (disposed || generation !== submissionGeneration) return;
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
if (attachments.length === 0 && submittedAttachments.length > 0) {
attachments = submittedAttachments;
}
if (input.value === "") {
input.value = text;
autoResize();
@@ -1106,32 +929,6 @@ export function createStructuredConnection(
renderComposerState();
},
);
}
function dispatchQueuedPrompt(): void {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
if (disposed || active || promptRequestPending || promptQueue.length === 0) return;
const next = promptQueue.shift()!;
dispatchPrompt(next.text, next.attachments);
}
composerForm.addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value;
if ((!text.trim() && attachments.length === 0) || uploading) return;
const submittedAttachments = attachments;
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
if (active || promptQueue.length > 0) {
promptQueue.push({ text, attachments: submittedAttachments });
attachments = [];
input.value = "";
autoResize();
clearError();
renderComposerState();
dispatchQueuedPrompt();
return;
}
dispatchPrompt(text, submittedAttachments);
});
cancelBtn.addEventListener("click", () => {
clearError();
@@ -1304,8 +1101,6 @@ export function createStructuredConnection(
closeCurrentSocket();
expandedRowIds.clear();
expandedGroupIds.clear();
for (const url of imageObjectUrls.values()) URL.revokeObjectURL(url);
imageObjectUrls.clear();
scrollEl.removeEventListener("scroll", onTranscriptScroll);
scrollEl.removeEventListener("click", onTranscriptClick, true);
scrollEl.removeEventListener("wheel", stopSmoothFollow);
+4 -5
View File
@@ -315,8 +315,8 @@ describe("buildTranscriptRow: tool diffs", () => {
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
const diff = el.querySelector(".st-diff") as HTMLElement;
expect(diff).not.toBeNull();
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("+ c");
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("- b");
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("c");
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("b");
});
it("gives the diff its own horizontally-scrolling container", () => {
@@ -368,14 +368,13 @@ describe("buildTranscriptRow: approval", () => {
expect(el.querySelector(".st-approval-path")!.textContent).toBe("/etc/x");
});
it("renders a compact one-liner once resolved", () => {
it("hides the buttons and shows an answered note once resolved", () => {
const el = buildTranscriptRow(approvalRow(allThreeOptions), {
pending: false,
onDecide: () => Promise.resolve(),
});
expect(el.querySelector(".st-approval-acts")).toBeNull();
expect(el.classList.contains("st-approval-compact")).toBe(true);
expect(el.textContent).toContain("approved");
expect(el.querySelector(".st-approval-resolved")!.textContent).toBe("answered");
});
it("invokes onDecide with the clicked decision and disables the buttons", () => {
+42 -49
View File
@@ -223,16 +223,15 @@ function buildDiffBlock(diff: { path: string; oldText?: string | null; newText:
wrap.appendChild(label);
const scroller = el("div", "st-diff");
const pre = document.createElement("pre");
const lines = computeDiffLines(diff.oldText, diff.newText);
const prefixes: Record<string, string> = { add: "+ ", del: "- ", ctx: " ", hunk: "", eof: "" };
for (const line of lines) {
for (const line of computeDiffLines(diff.oldText, diff.newText)) {
const span = document.createElement("span");
span.className = `st-ln st-ln-${line.kind}`;
span.textContent = (prefixes[line.kind] ?? "") + line.text;
span.textContent = line.text;
pre.appendChild(span);
pre.appendChild(document.createTextNode("\n"));
}
scroller.appendChild(pre);
makeCopyable(scroller, lines.map((l) => (prefixes[l.kind] ?? "") + l.text).join("\n"), "diff");
makeCopyable(scroller, pre.textContent ?? "", "diff");
wrap.appendChild(scroller);
return wrap;
}
@@ -582,9 +581,7 @@ export function buildToolGroup(
children: HTMLElement[],
expanded: boolean,
onExpandedChange?: (expanded: boolean) => void,
toolCount?: number,
): HTMLElement {
const count = toolCount ?? rows.length;
const wrap = el("div", "st-tool-group");
wrap.dataset.toolGroupId = id;
const head = document.createElement("button");
@@ -596,7 +593,7 @@ export function buildToolGroup(
glyph.textContent = "›";
glyph.setAttribute("aria-hidden", "true");
const title = el("span", "st-tool-group-title");
title.textContent = `${count} tool calls`;
title.textContent = `${rows.length} tool calls`;
head.append(glyph, title);
const body = el("div", "st-tool-group-body st-disclosure");
@@ -705,16 +702,6 @@ function offeredDecisions(options: AcpPermissionOption[]): StructuredApprovalDec
function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLElement {
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
const toolCall = request.toolCall ?? {};
// Resolved approvals render as a compact one-liner inside a tool group
// (or are hidden entirely when standalone — see rebuildTranscript).
if (!approval.pending) {
const wrap = el("div", "st-row st-row-approval st-approval-compact");
const label = toolCall.title ?? toolKindLabel(toolCall.kind);
wrap.textContent = `${label} — approved`;
return wrap;
}
const wrap = el("div", "st-row st-row-approval");
const card = el("div", "st-approval");
wrap.appendChild(card);
@@ -734,39 +721,45 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
card.appendChild(pathEl);
}
const acts = el("div", "st-approval-acts");
const mk = (label: string, decision: StructuredApprovalDecision, cls: string) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = cls ? `act ${cls}` : "act";
btn.textContent = label;
btn.addEventListener("click", () => {
const buttons = [...acts.querySelectorAll<HTMLButtonElement>("button")];
buttons.forEach((b) => (b.disabled = true));
approval.onDecide(decision).catch(() => {
// Rejects only when this approval is still pending (see
// ApprovalRowState) — restore the controls so the operator can
// retry; the error itself is surfaced by the connection.
buttons.forEach((b) => (b.disabled = false));
if (approval.pending) {
const acts = el("div", "st-approval-acts");
const mk = (label: string, decision: StructuredApprovalDecision, cls: string) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = cls ? `act ${cls}` : "act";
btn.textContent = label;
btn.addEventListener("click", () => {
const buttons = [...acts.querySelectorAll<HTMLButtonElement>("button")];
buttons.forEach((b) => (b.disabled = true));
approval.onDecide(decision).catch(() => {
// Rejects only when this approval is still pending (see
// ApprovalRowState) — restore the controls so the operator can
// retry; the error itself is surfaced by the connection.
buttons.forEach((b) => (b.disabled = false));
});
});
});
return btn;
};
const offered = offeredDecisions(request.options ?? []);
const specs: [StructuredApprovalDecision, string, string][] = [
["allow_once", "Allow once", "primary"],
["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));
}
if (acts.children.length > 0) {
card.appendChild(acts);
return btn;
};
const offered = offeredDecisions(request.options ?? []);
const specs: [StructuredApprovalDecision, string, string][] = [
["allow_once", "Allow once", "primary"],
["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));
}
if (acts.children.length > 0) {
card.appendChild(acts);
} else {
const none = el("div", "st-approval-resolved");
none.textContent = "no decision offered";
card.appendChild(none);
}
} else {
const none = el("div", "st-approval-resolved");
none.textContent = "no decision offered";
card.appendChild(none);
const resolved = el("div", "st-approval-resolved");
resolved.textContent = "answered";
card.appendChild(resolved);
}
return wrap;
+43 -126
View File
@@ -2290,9 +2290,6 @@ input.leaf-rename:focus {
.structured-main {
min-width: 0;
min-height: 0;
max-width: 820px;
margin-inline: auto;
width: 100%;
display: flex;
flex-direction: column;
flex: 1 1 auto;
@@ -2318,7 +2315,9 @@ input.leaf-rename:focus {
gap: var(--sp-2);
min-height: 30px;
padding: var(--sp-1) var(--sp-3);
border-top: 1px solid var(--border);
color: var(--muted);
background: var(--surface-2);
font-size: var(--fs-11);
font-variant-numeric: tabular-nums;
}
@@ -2375,36 +2374,11 @@ input.leaf-rename:focus {
background: var(--surface-2);
}
/* Queued messages: optimistic user bubbles, not yet sent. */
.st-row-queued {
justify-content: flex-end;
pointer-events: none;
}
.st-row-queued .st-body {
flex: 0 1 auto;
max-width: min(86%, 72ch);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--r-2);
background: var(--surface-3);
opacity: 0.45;
filter: blur(0.5px);
}
.st-user-text {
white-space: pre-wrap;
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 */
.st-row-agent .st-body p {
margin: 0 0 var(--sp-2);
@@ -2507,8 +2481,7 @@ input.leaf-rename:focus {
.st-row-thought {
color: var(--faint);
font-size: var(--fs-12);
line-height: 1.4;
font-style: italic;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@@ -2922,26 +2895,6 @@ input.leaf-rename:focus {
border-radius: var(--r-1);
}
/* intermediate agent text inside a tool group — subdued so the final
answer outside the group stays the prominent one. */
.st-tool-group-body .st-row-agent {
color: var(--muted);
font-size: var(--fs-12);
padding: var(--sp-1) var(--sp-2);
}
.st-tool-group-body .st-row-agent .st-body > :first-child {
margin-top: 0;
}
.st-tool-group-body .st-row-agent .st-body > :last-child {
margin-bottom: 0;
}
.st-tool-group-body .st-row-thought {
padding: var(--sp-1) var(--sp-2);
}
.st-tool-group-body[hidden] {
display: none;
}
@@ -2991,7 +2944,6 @@ input.leaf-rename:focus {
display: block;
white-space: pre;
padding: 0 var(--sp-3);
line-height: 1.4;
}
.st-ln-add {
@@ -3065,16 +3017,6 @@ input.leaf-rename:focus {
color: var(--faint);
}
/* compact resolved approval inside a tool group — same muted treatment
as intermediate agent text so the audit trail is preserved without
drawing attention to itself. */
.st-approval-compact {
color: var(--muted);
font-size: var(--fs-12);
padding: var(--sp-1) var(--sp-2);
font-style: italic;
}
.act {
appearance: none;
font: inherit;
@@ -3128,7 +3070,6 @@ input.leaf-rename:focus {
hairline that doubles as the focus indicator. Inner metrics line the draft
up with the transcript's own 12px gutter. */
.structured-composer {
position: relative;
flex: 0 0 auto;
padding: var(--sp-1) var(--sp-1) max(var(--sp-1), env(safe-area-inset-bottom));
border-top: 1px solid var(--border);
@@ -3139,9 +3080,8 @@ input.leaf-rename:focus {
transition: border-color var(--dur-fast) var(--ease-out);
}
.structured-composer:focus-within {
border-top-color: var(--border);
border-top-color: var(--accent);
}
.structured-input-wrap {
@@ -3257,9 +3197,7 @@ input.leaf-rename:focus {
}
.structured-composer .structured-cancel {
position: relative;
width: 40px;
padding: 0;
padding: 0 var(--sp-3);
border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
color: var(--danger);
}
@@ -3268,23 +3206,6 @@ input.leaf-rename:focus {
background: var(--danger-tint);
}
.structured-cancel-stop {
width: 16px;
height: 16px;
}
.structured-cancel-spinner {
position: absolute;
inset: 4px;
width: auto;
height: auto;
animation: cancel-spin 0.8s linear infinite;
}
@keyframes cancel-spin {
to { transform: rotate(360deg); }
}
@media (max-width: 520px) {
/* One row cannot hold a draft plus three controls at phone width — the
draft gets a line of its own and the controls drop below it. */
@@ -3674,76 +3595,59 @@ input.leaf-rename:focus {
color: var(--ink);
}
/* Harness cards: the first decision in the spawn form — which tool runs. */
.harness-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
/* One-click tiles: host row + command row. */
.tile-row {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
.harness-card {
.tile {
flex: 1 1 auto;
min-width: 96px;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-2);
gap: 2px;
align-items: flex-start;
padding: var(--sp-2) var(--sp-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-2);
color: var(--ink);
font-family: inherit;
cursor: pointer;
text-align: center;
text-align: left;
transition:
border-color var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out);
}
.harness-card:hover {
.tile:hover {
border-color: var(--border-strong);
background: var(--surface-3);
}
.harness-card.is-active {
.tile.is-active {
border-color: var(--accent);
background: var(--accent-tint);
}
.harness-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: var(--muted);
}
.harness-card.is-active .harness-icon {
color: var(--ink);
}
.harness-icon .tool-icon {
display: block;
}
.harness-label {
font-size: var(--fs-13);
.tile-label {
font-size: var(--fs-14);
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;
.tile-sub {
font-size: var(--fs-12);
color: var(--muted);
font-family: var(--font-mono);
}
.harness-custom .harness-label {
font-weight: 500;
.tile-more {
flex: 0 0 auto;
min-width: 0;
justify-content: center;
align-items: center;
color: var(--muted);
}
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
@@ -3772,7 +3676,7 @@ input.leaf-rename:focus {
.picker.projects {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
align-content: start;
gap: var(--sp-2);
max-height: 300px;
@@ -3828,6 +3732,19 @@ input.leaf-rename:focus {
overflow-wrap: anywhere;
}
.project-meta {
font-family: var(--font-mono);
font-size: var(--fs-12);
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-meta:empty {
display: none;
}
.project-other {
align-self: flex-start;
padding: 4px 0;
+1 -1
View File
@@ -11,7 +11,7 @@ export type SessionStatus = "Running" | "Dead";
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
* Absent on payloads from daemons that predate the field. `Error` is only set
* when an agent reports failure (#105); the daemon never infers it. */
export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error" | "Starting";
export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error";
export interface Session {
id: Uuid;
-105
View File
@@ -1,105 +0,0 @@
import type { Config } from "tailwindcss";
// Bridges Tailwind utilities to the OKLCH custom-property tokens defined in
// src/styles.css. Every value here is `var(--token)`, never a literal, so
// theme/palette switching (data-theme, data-palette on <html>) keeps working
// through Tailwind classes exactly as it does through the vanilla CSS.
export default {
content: ["src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
bg: "var(--bg)",
"bg-grid": "var(--bg-grid)",
surface: "var(--surface)",
"surface-2": "var(--surface-2)",
"surface-3": "var(--surface-3)",
border: "var(--border)",
"border-strong": "var(--border-strong)",
ink: "var(--ink)",
muted: "var(--muted)",
faint: "var(--faint)",
accent: "var(--accent)",
"accent-strong": "var(--accent-strong)",
"accent-ink": "var(--accent-ink)",
"accent-tint": "var(--accent-tint)",
"accent-ring": "var(--accent-ring)",
"accent-fill": "var(--accent-fill)",
preview: "var(--preview)",
"preview-ink": "var(--preview-ink)",
"preview-tint": "var(--preview-tint)",
"preview-ring": "var(--preview-ring)",
live: "var(--live)",
"live-glow": "var(--live-glow)",
danger: "var(--danger)",
"danger-tint": "var(--danger-tint)",
warn: "var(--warn)",
"term-bg": "var(--term-bg)",
},
spacing: {
"sp-1": "var(--sp-1)",
"sp-2": "var(--sp-2)",
"sp-3": "var(--sp-3)",
"sp-4": "var(--sp-4)",
"sp-5": "var(--sp-5)",
"sp-6": "var(--sp-6)",
"sp-8": "var(--sp-8)",
},
borderRadius: {
"r-1": "var(--r-1)",
"r-2": "var(--r-2)",
"r-3": "var(--r-3)",
"r-pill": "var(--r-pill)",
},
fontSize: {
"fs-11": "var(--fs-11)",
"fs-12": "var(--fs-12)",
"fs-13": "var(--fs-13)",
"fs-14": "var(--fs-14)",
"fs-15": "var(--fs-15)",
"fs-17": "var(--fs-17)",
"fs-20": "var(--fs-20)",
},
fontFamily: {
mono: "var(--font-mono)",
sans: "var(--font-sans)",
},
height: {
"row-h": "var(--row-h)",
"control-h": "var(--control-h)",
tap: "var(--tap)",
"bar-h": "var(--bar-h)",
},
width: {
tap: "var(--tap)",
},
minHeight: {
"control-h": "var(--control-h)",
tap: "var(--tap)",
},
zIndex: {
sticky: "var(--z-sticky)",
backdrop: "var(--z-backdrop)",
modal: "var(--z-modal)",
toast: "var(--z-toast)",
},
transitionDuration: {
fast: "var(--dur-fast)",
DEFAULT: "var(--dur)",
slow: "var(--dur-slow)",
},
transitionTimingFunction: {
out: "var(--ease-out)",
},
boxShadow: {
1: "var(--shadow-1)",
2: "var(--shadow-2)",
},
},
},
} satisfies Config;
-1
View File
@@ -4,7 +4,6 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
-3
View File
@@ -1,6 +1,4 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// Where the dev proxy forwards `/api/*` (and the events WS) to. Default to a
// local daemon on 127.0.0.1:7070. Override with:
@@ -20,7 +18,6 @@ export default defineConfig(({ mode }) => {
const target = resolveProxyTarget(env.MAESTRO_DAEMON_URL);
const secure = target.startsWith("https://");
return {
plugins: [react(), tailwindcss()],
build: {
target: "es2022",
outDir: "dist",