Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee61cea64f | ||
|
|
7e21371e47 | ||
|
|
52ae636dbb | ||
|
|
9d93e6e60e | ||
|
|
babdc678e0 | ||
|
|
9ba1078386 | ||
|
|
f0a1774e4d | ||
|
|
ac720eb266 | ||
|
|
0ba83c3d8f | ||
|
|
e8e98baf4d | ||
|
|
b286323c9c | ||
|
|
71ec7e073c | ||
|
|
46bae13212 | ||
|
|
20d303278d | ||
|
|
589e0144e2 | ||
|
|
0061378193 | ||
|
|
1cf4825b1d | ||
|
|
f799aaf564 | ||
|
|
5af5ceb9a8 | ||
|
|
fbdcec6f69 | ||
|
|
6af4cda650 | ||
|
|
ab4beea2d9 | ||
|
|
44ef5619bf | ||
|
|
6adb5dabf0 | ||
|
|
e9c9e1c647 | ||
|
|
4167b21fe9 | ||
|
|
b9f8f02e11 | ||
|
|
f36b7808a7 | ||
|
|
c2d427691d | ||
|
|
50b862b1fa | ||
|
|
4668cf8ece | ||
|
|
638155de03 | ||
|
|
1c0fff5507 | ||
|
|
10c9db6d87 | ||
|
|
b339fecad3 | ||
|
|
3c10a3a08a | ||
|
|
fd79c8116a | ||
|
|
cb8b93f178 | ||
|
|
60e57fde31 | ||
|
|
8d35f7f8c8 | ||
|
|
bc0fd6ee60 | ||
|
|
ed5a088299 | ||
|
|
9c77965c51 | ||
|
|
49df4c2e9e | ||
|
|
65d7186e1c | ||
|
|
47654aa434 |
@@ -11,7 +11,8 @@
|
||||
"Bash(./node_modules/.bin/tsc:*)",
|
||||
"Bash(npm test:*)",
|
||||
"Bash(cargo check:*)",
|
||||
"Bash(cargo clippy:*)"
|
||||
"Bash(cargo clippy:*)",
|
||||
"Bash(cargo test:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@ pub enum Command {
|
||||
Attach(AttachArgs),
|
||||
/// Kill a session by ID.
|
||||
Kill(KillArgs),
|
||||
/// Type text into a running session, as if it had been typed on an
|
||||
/// attached terminal.
|
||||
/// Send text to a running session. Terminal sessions receive raw
|
||||
/// keystrokes; structured sessions receive an ACP prompt.
|
||||
Send(SendArgs),
|
||||
/// Install or update the agent activity hook (#106).
|
||||
Hooks(HooksArgs),
|
||||
|
||||
@@ -130,9 +130,12 @@ pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid,
|
||||
/// `maestro send`. Answers with the session that was typed into, so the
|
||||
/// caller can name it once the daemon has accepted the text.
|
||||
///
|
||||
/// A bare ID skips the catalog fetch; anything else needs the list to match
|
||||
/// against. Text comes from the arguments, or from stdin when there is none —
|
||||
/// the shape a heredoc or a pipe wants.
|
||||
/// Terminal sessions get raw keystrokes via `send_input`; structured sessions
|
||||
/// get an ACP prompt via `send_structured_prompt` (`--no-enter` is ignored
|
||||
/// there — ACP prompts are always complete messages).
|
||||
///
|
||||
/// Text comes from the arguments, or from stdin when there is none — the
|
||||
/// shape a heredoc or a pipe wants.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub async fn run_send(
|
||||
client: &DaemonClient,
|
||||
@@ -155,19 +158,28 @@ pub async fn run_send(
|
||||
return Err(CliError::NothingToSend);
|
||||
}
|
||||
|
||||
let id = match uuid::Uuid::parse_str(args.session.trim()) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
let sessions = client.list_sessions().await?;
|
||||
resolve_session(&sessions, &args.session)?
|
||||
}
|
||||
};
|
||||
// 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 req = SendInputRequest {
|
||||
text,
|
||||
submit: !args.no_enter,
|
||||
};
|
||||
client.send_input(id, &req).await?;
|
||||
match view {
|
||||
SessionView::Structured => {
|
||||
client.send_structured_prompt(id, &text).await?;
|
||||
}
|
||||
SessionView::Terminal => {
|
||||
let req = SendInputRequest {
|
||||
text,
|
||||
submit: !args.no_enter,
|
||||
};
|
||||
client.send_input(id, &req).await?;
|
||||
}
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
@@ -216,6 +228,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
ActivityState::Idle => "idle",
|
||||
ActivityState::AwaitingInput => "awaiting",
|
||||
ActivityState::Error => "error",
|
||||
ActivityState::Starting => "starting",
|
||||
};
|
||||
// Show the live foreground command alongside the spawn command when
|
||||
// the daemon has observed one that differs (NAP-340).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! was unit-tested, but nothing exercised the client, the error decoding, or
|
||||
//! the unreachable-daemon path without compiling and forking a real daemon.
|
||||
|
||||
use maestro_protocol::{DaemonClient, DaemonInfo};
|
||||
use maestro_protocol::{DaemonClient, DaemonInfo, SessionView};
|
||||
use maestro_testing::{session, MockDaemon};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -202,6 +202,35 @@ async fn send_resolves_a_session_by_name() {
|
||||
assert_eq!(sent, id);
|
||||
}
|
||||
|
||||
/// A structured session routes through the ACP prompt endpoint, not the
|
||||
/// terminal input one — `--no-enter` is irrelevant (ACP prompts are
|
||||
/// complete messages).
|
||||
#[tokio::test]
|
||||
async fn send_to_structured_session_uses_acp_prompt() {
|
||||
let mock = MockDaemon::start().await;
|
||||
let id = Uuid::from_u128(13);
|
||||
let mut s = session(id, "/tmp", "claude");
|
||||
s.view = SessionView::Structured;
|
||||
mock.add_session(s);
|
||||
|
||||
let args = maestro_cli::cli::SendArgs {
|
||||
session: id.to_string(),
|
||||
text: vec!["fix".into(), "the".into(), "tests".into()],
|
||||
no_enter: false,
|
||||
};
|
||||
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
||||
.await
|
||||
.expect("send");
|
||||
|
||||
assert_eq!(sent, id);
|
||||
// Terminal input must stay empty — the message went through ACP.
|
||||
assert!(mock.inputs().is_empty());
|
||||
let prompts = mock.structured_prompts();
|
||||
assert_eq!(prompts.len(), 1);
|
||||
assert_eq!(prompts[0].0, id);
|
||||
assert_eq!(prompts[0].1.prompt, "fix the tests");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
||||
let mock = MockDaemon::start().await;
|
||||
|
||||
@@ -80,13 +80,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
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.
|
||||
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.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sub = tmp.path().join("plain/dir");
|
||||
fs::create_dir_all(&sub).unwrap();
|
||||
assert_eq!(find_root(&sub), None);
|
||||
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"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -180,7 +180,7 @@ fn dir_error_status(code: &str) -> StatusCode {
|
||||
fn session_error_status(code: &str) -> StatusCode {
|
||||
match code {
|
||||
"not_found" => StatusCode::NOT_FOUND,
|
||||
"invalid_env" => StatusCode::BAD_REQUEST,
|
||||
"invalid_env" | "bad_request" => StatusCode::BAD_REQUEST,
|
||||
// A guard refusal is the daemon doing its job, not a failure (#79).
|
||||
"kill_guarded" => StatusCode::CONFLICT,
|
||||
// The session outlived its pane: nothing to type into, and nothing
|
||||
@@ -483,7 +483,9 @@ async fn structured_prompt(
|
||||
Json(body): Json<StructuredPromptRequest>,
|
||||
) -> Response {
|
||||
match s.manager.queue_acp_prompt(id, body.prompt).await {
|
||||
Ok(QueueAcpPrompt::Queued) => StatusCode::NO_CONTENT.into_response(),
|
||||
Ok(QueueAcpPrompt::Queued) | Ok(QueueAcpPrompt::Pending) => {
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
Ok(QueueAcpPrompt::InFlight) => error_response(
|
||||
StatusCode::CONFLICT,
|
||||
"turn_in_flight",
|
||||
|
||||
@@ -6,12 +6,9 @@
|
||||
//! handler interact with tmux.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Weak};
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
@@ -125,6 +122,9 @@ pub struct SessionRecord {
|
||||
/// When `true`, the permission pump auto-responds `AllowAlways` to every
|
||||
/// ACP permission request without surfacing it to clients.
|
||||
pub auto_approve: bool,
|
||||
/// A prompt submitted while the adapter was still booting (`Starting`).
|
||||
/// The background handshake task drains this on success.
|
||||
pub pending_prompt: Option<String>,
|
||||
}
|
||||
|
||||
impl SessionRecord {
|
||||
@@ -134,6 +134,13 @@ impl SessionRecord {
|
||||
&& self.acp.is_some()
|
||||
&& self.acp_events.is_some()
|
||||
}
|
||||
|
||||
/// The session is structured and still booting its adapter.
|
||||
fn is_starting_structured(&self) -> bool {
|
||||
self.session.view == SessionView::Structured
|
||||
&& self.session.activity == ActivityState::Starting
|
||||
&& self.acp.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -208,6 +215,8 @@ pub enum SessionError {
|
||||
NotTerminal(Uuid),
|
||||
#[error("daemon is shutting down")]
|
||||
ShuttingDown,
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
/// The kill was refused (#79): the session's worktree holds unsaved work.
|
||||
/// Nothing happened — the session keeps running and the tree is untouched,
|
||||
/// so it stays the operator's handle on their own unfinished work.
|
||||
@@ -227,6 +236,8 @@ pub enum BeginAcpTurn {
|
||||
#[derive(Debug)]
|
||||
pub enum QueueAcpPrompt {
|
||||
Queued,
|
||||
/// The adapter is still booting; the prompt is held until it is ready.
|
||||
Pending,
|
||||
InFlight,
|
||||
Unavailable,
|
||||
Failed(String),
|
||||
@@ -253,6 +264,7 @@ impl SessionError {
|
||||
SessionError::NotRunning(_) => "not_running",
|
||||
SessionError::NotTerminal(_) => "not_terminal",
|
||||
SessionError::ShuttingDown => "shutting_down",
|
||||
SessionError::BadRequest(_) => "bad_request",
|
||||
SessionError::KillGuarded(_) => "kill_guarded",
|
||||
};
|
||||
let unsaved_work = match self {
|
||||
@@ -320,18 +332,6 @@ struct StructuredLifecycle {
|
||||
}
|
||||
|
||||
impl StructuredLifecycle {
|
||||
fn admit<'a>(
|
||||
&'a self,
|
||||
shutting_down: &AtomicBool,
|
||||
) -> Result<StructuredCreate<'a>, SessionError> {
|
||||
let _gate = self.gate.lock().expect("structured lifecycle gate");
|
||||
if shutting_down.load(Ordering::Acquire) {
|
||||
return Err(SessionError::ShuttingDown);
|
||||
}
|
||||
self.in_flight.fetch_add(1, Ordering::AcqRel);
|
||||
Ok(StructuredCreate { lifecycle: self })
|
||||
}
|
||||
|
||||
async fn wait_until_settled(&self) {
|
||||
loop {
|
||||
let settled = self.settled.notified();
|
||||
@@ -343,30 +343,18 @@ impl StructuredLifecycle {
|
||||
}
|
||||
}
|
||||
|
||||
struct StructuredCreate<'a> {
|
||||
lifecycle: &'a StructuredLifecycle,
|
||||
}
|
||||
|
||||
impl Drop for StructuredCreate<'_> {
|
||||
fn drop(&mut self) {
|
||||
if self.lifecycle.in_flight.fetch_sub(1, Ordering::AcqRel) == 1 {
|
||||
self.lifecycle.settled.notify_one();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AdmittedStructuredCreate<'a> {
|
||||
create: Pin<Box<dyn Future<Output = Result<Session, SessionError>> + Send + 'a>>,
|
||||
/// Field order matters: dropping a cancelled create rolls back its inner
|
||||
/// future before this permit releases shutdown's in-flight barrier.
|
||||
_permit: StructuredCreate<'a>,
|
||||
}
|
||||
|
||||
impl Future for AdmittedStructuredCreate<'_> {
|
||||
type Output = Result<Session, SessionError>;
|
||||
|
||||
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
self.create.as_mut().poll(cx)
|
||||
/// Release the structured-lifecycle in-flight counter that `create` acquires
|
||||
/// before spawning the background ACP handshake task. Called when the task
|
||||
/// finishes (success or failure) or when `create_inner` fails before the task
|
||||
/// is spawned.
|
||||
fn drop_structured_lifecycle_permit(inner: &Inner) {
|
||||
if inner
|
||||
.structured_lifecycle
|
||||
.in_flight
|
||||
.fetch_sub(1, Ordering::AcqRel)
|
||||
== 1
|
||||
{
|
||||
inner.structured_lifecycle.settled.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,7 +648,9 @@ impl SessionManager {
|
||||
let stream = {
|
||||
let sessions = self.inner.sessions.lock().await;
|
||||
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
|
||||
if !record.is_live_structured() {
|
||||
// Allow connecting while the adapter is still booting
|
||||
// (`Starting`) — the event stream exists, it is just empty.
|
||||
if !record.is_live_structured() && !record.is_starting_structured() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(stream) = &record.acp_events else {
|
||||
@@ -745,8 +735,14 @@ impl SessionManager {
|
||||
text: String,
|
||||
) -> Result<QueueAcpPrompt, SessionError> {
|
||||
let (acp, stream) = {
|
||||
let sessions = self.inner.sessions.lock().await;
|
||||
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
|
||||
let mut sessions = self.inner.sessions.lock().await;
|
||||
let record = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
|
||||
// While the adapter is booting, stash the prompt for the
|
||||
// background handshake task to drain on success.
|
||||
if record.is_starting_structured() {
|
||||
record.pending_prompt = Some(text);
|
||||
return Ok(QueueAcpPrompt::Pending);
|
||||
}
|
||||
if !record.is_live_structured() {
|
||||
return Ok(QueueAcpPrompt::Unavailable);
|
||||
}
|
||||
@@ -809,6 +805,10 @@ 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,17 +1217,42 @@ impl SessionManager {
|
||||
}
|
||||
|
||||
/// Spawn a new session. On success returns the freshly-created `Session`.
|
||||
///
|
||||
/// For structured sessions, the ACP handshake runs in a background task
|
||||
/// after the session is registered — the HTTP caller gets an instant
|
||||
/// response with `activity: Starting`. The `StructuredLifecycle` gate is
|
||||
/// held across the background task so `shutdown()` waits for it.
|
||||
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
|
||||
if req.view == SessionView::Structured {
|
||||
let permit = self
|
||||
// Acquire the lifecycle gate synchronously (rejects if shutting
|
||||
// down). The background handshake task calls
|
||||
// `drop_structured_lifecycle_permit` when it finishes; we must
|
||||
// not use the RAII `StructuredCreate` because it would drop at
|
||||
// the end of `create_inner`, before the handshake completes.
|
||||
let _gate = self
|
||||
.inner
|
||||
.structured_lifecycle
|
||||
.admit(&self.inner.shutting_down)?;
|
||||
return AdmittedStructuredCreate {
|
||||
create: Box::pin(self.create_inner(req)),
|
||||
_permit: permit,
|
||||
.gate
|
||||
.lock()
|
||||
.expect("structured lifecycle gate");
|
||||
if self.inner.shutting_down.load(Ordering::Acquire) {
|
||||
return Err(SessionError::ShuttingDown);
|
||||
}
|
||||
.await;
|
||||
self.inner
|
||||
.structured_lifecycle
|
||||
.in_flight
|
||||
.fetch_add(1, Ordering::AcqRel);
|
||||
drop(_gate);
|
||||
|
||||
return match self.create_inner(req).await {
|
||||
Ok(session) => Ok(session),
|
||||
Err(e) => {
|
||||
// create_inner failed before spawning the background
|
||||
// task, so release the permit here.
|
||||
drop_structured_lifecycle_permit(&self.inner);
|
||||
Err(e)
|
||||
}
|
||||
};
|
||||
}
|
||||
self.create_inner(req).await
|
||||
}
|
||||
@@ -1289,32 +1314,39 @@ impl SessionManager {
|
||||
{
|
||||
session_env.insert("MAESTRO_URL".into(), url);
|
||||
}
|
||||
// A structured session (#134) gets no tmux pane: the agent is a
|
||||
// child of the daemon, driven over ACP. An adapter that cannot come
|
||||
// up still yields a session — Dead, with the reason on it — so the
|
||||
// operator sees what to fix instead of a silently missing session.
|
||||
let (acp, acp_failure) = match req.view {
|
||||
SessionView::Structured => match structured_adapter(&req.command) {
|
||||
Err(error) => (None, Some(error)),
|
||||
Ok(adapter) => match maestro_acp::spawn(
|
||||
SpawnConfig::new(
|
||||
&effective_cwd,
|
||||
session_env
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect(),
|
||||
)
|
||||
.adapter(adapter),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(acp) => (Some(Arc::new(acp)), None),
|
||||
Err(e) => {
|
||||
tracing::warn!(%id, error = %e, "structured session: adapter failed to start");
|
||||
(None, Some(e.to_string()))
|
||||
// ── Structured path: validate adapter, open event log, register
|
||||
// the session immediately with `Starting`, then handshake in the
|
||||
// background so the HTTP caller gets an instant response.
|
||||
//
|
||||
// ── Terminal path: unchanged — tmux spawn is fast enough to stay
|
||||
// synchronous.
|
||||
// Structured: validate adapter, open event log eagerly. Terminal:
|
||||
// spawn the tmux pane synchronously (it is fast).
|
||||
let structured_spawn = match req.view {
|
||||
SessionView::Structured => {
|
||||
// Fail fast on an unsupported command before touching disk.
|
||||
let adapter = structured_adapter(&req.command).map_err(SessionError::BadRequest)?;
|
||||
// Open the event log eagerly so the structured WS can
|
||||
// connect before the adapter is up.
|
||||
let acp_events = match self.inner.sidecar.open_events(id) {
|
||||
Ok(log) => {
|
||||
let (tx, _) = broadcast::channel(EVENTS_CAPACITY);
|
||||
let (projected_tx, _) = broadcast::channel(EVENTS_CAPACITY);
|
||||
Arc::new(Mutex::new(AcpEventStream {
|
||||
log,
|
||||
live: tx,
|
||||
projection: Projection::default(),
|
||||
projected_live: projected_tx,
|
||||
pending_approvals: HashMap::new(),
|
||||
operation: Arc::new(Mutex::new(())),
|
||||
dispatch: AcpDispatchState::default(),
|
||||
activity_sequence: 0,
|
||||
}))
|
||||
}
|
||||
},
|
||||
},
|
||||
Err(e) => return Err(SessionError::Sidecar(e)),
|
||||
};
|
||||
Some((adapter, acp_events))
|
||||
}
|
||||
SessionView::Terminal => {
|
||||
let spawned = {
|
||||
let tmux = self.inner.tmux.clone();
|
||||
@@ -1328,53 +1360,10 @@ 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
|
||||
@@ -1384,10 +1373,13 @@ impl SessionManager {
|
||||
resolve_name(&mut metadata);
|
||||
let worktree_branch = req.worktree.as_ref().map(|w| w.branch.clone());
|
||||
|
||||
let is_structured = structured_spawn.is_some();
|
||||
let events_arc = structured_spawn.as_ref().map(|(_, e)| e.clone());
|
||||
|
||||
let session = Session {
|
||||
id,
|
||||
cwd: effective_cwd,
|
||||
command: req.command,
|
||||
cwd: effective_cwd.clone(),
|
||||
command: req.command.clone(),
|
||||
env: req.env,
|
||||
metadata,
|
||||
worktree: worktree_path.as_ref().zip(worktree_base_repo.as_ref()).map(
|
||||
@@ -1396,67 +1388,41 @@ impl SessionManager {
|
||||
base_repo: base_repo.clone(),
|
||||
},
|
||||
),
|
||||
status: if acp_failure.is_some() {
|
||||
SessionStatus::Dead
|
||||
} else {
|
||||
SessionStatus::Running
|
||||
},
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
// A terminal spawn begins active until its watcher sees otherwise;
|
||||
// a structured spawn has no turn yet and begins idle.
|
||||
foreground: None,
|
||||
activity: match (req.view, acp_failure.is_some()) {
|
||||
(_, true) => ActivityState::Error,
|
||||
(SessionView::Structured, false) => ActivityState::Idle,
|
||||
(SessionView::Terminal, false) => ActivityState::Active,
|
||||
activity: if is_structured {
|
||||
ActivityState::Starting
|
||||
} else {
|
||||
ActivityState::Active
|
||||
},
|
||||
activity_detail: acp_failure,
|
||||
activity_detail: None,
|
||||
last_activity_at: Utc::now(),
|
||||
activity_since: chrono::Utc::now(),
|
||||
|
||||
current_cwd: None,
|
||||
// The watcher fills this in on its first blocked-work pass; a
|
||||
// fresh tree has nothing in the way anyway.
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: acp.as_ref().map(|a| a.session_id().to_string()),
|
||||
acp_session_id: None,
|
||||
view: req.view,
|
||||
};
|
||||
|
||||
if let Err(e) = self.inner.sidecar.write(&session) {
|
||||
// The process already started; end it so no ghost survives a
|
||||
// failed sidecar write, then unwind the FIFO + worktree.
|
||||
match &acp {
|
||||
Some(acp) => {
|
||||
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
let tmux = self.inner.tmux.clone();
|
||||
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
|
||||
}
|
||||
if !is_structured {
|
||||
let tmux = self.inner.tmux.clone();
|
||||
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
|
||||
}
|
||||
return Err(SessionError::Sidecar(e));
|
||||
}
|
||||
|
||||
let event_pump = acp
|
||||
.as_ref()
|
||||
.zip(acp_events.as_ref())
|
||||
.map(|(acp, events)| (acp.clone(), events.clone()));
|
||||
{
|
||||
let mut sessions = self.inner.sessions.lock().await;
|
||||
let event_pump = event_pump.map(|(acp, events)| {
|
||||
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
|
||||
start_acp_permission_pump(&self.inner, id, &acp, events, req.auto_approve);
|
||||
done
|
||||
});
|
||||
sessions.insert(
|
||||
id,
|
||||
SessionRecord {
|
||||
session: session.clone(),
|
||||
fifo_path,
|
||||
worktree_path,
|
||||
worktree_base_repo,
|
||||
worktree_path: worktree_path.clone(),
|
||||
worktree_base_repo: worktree_base_repo.clone(),
|
||||
bridge,
|
||||
reported_activity: None,
|
||||
settled: false,
|
||||
@@ -1464,11 +1430,12 @@ impl SessionManager {
|
||||
naming: false,
|
||||
focused_since: None,
|
||||
awaiting_suppressed: false,
|
||||
acp,
|
||||
acp_events,
|
||||
acp: None,
|
||||
acp_events: events_arc,
|
||||
structured_activity_watermark: None,
|
||||
acp_event_pump: event_pump,
|
||||
acp_event_pump: None,
|
||||
auto_approve: req.auto_approve,
|
||||
pending_prompt: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1497,6 +1464,78 @@ impl SessionManager {
|
||||
}
|
||||
|
||||
pending.commit();
|
||||
|
||||
// ── Background ACP handshake for structured sessions ──
|
||||
//
|
||||
// The session is registered and visible to clients. The adapter
|
||||
// spawn + handshake runs in a background task; on completion it
|
||||
// fills in `acp`, starts the event/permission pumps, and
|
||||
// transitions activity from `Starting` to `Idle` (or to `Error`
|
||||
// on failure). The `StructuredLifecycle` permit is held until the
|
||||
// task finishes so `shutdown()` waits for it.
|
||||
if let Some((adapter, events)) = structured_spawn {
|
||||
let inner = self.inner.clone();
|
||||
let env: HashMap<String, String> = session_env
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
let cwd = effective_cwd;
|
||||
let auto_approve = req.auto_approve;
|
||||
tokio::spawn(async move {
|
||||
let result = maestro_acp::spawn(SpawnConfig::new(&cwd, env).adapter(adapter)).await;
|
||||
|
||||
match result {
|
||||
Ok(acp_session) => {
|
||||
let acp = Arc::new(acp_session);
|
||||
let done = start_acp_event_pump(&inner, id, &acp, events.clone());
|
||||
start_acp_permission_pump(&inner, id, &acp, events.clone(), auto_approve);
|
||||
// Drain any prompt submitted while Starting.
|
||||
let pending_prompt = {
|
||||
let mut sessions = inner.sessions.lock().await;
|
||||
let Some(record) = sessions.get_mut(&id) else {
|
||||
// Session was killed while we were booting.
|
||||
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
||||
.await;
|
||||
drop_structured_lifecycle_permit(&inner);
|
||||
return;
|
||||
};
|
||||
record.acp = Some(acp.clone());
|
||||
record.acp_event_pump = Some(done);
|
||||
record.session.acp_session_id = Some(acp.session_id().to_string());
|
||||
record.session.activity = ActivityState::Idle;
|
||||
record.session.activity_since = Utc::now();
|
||||
let _ = inner.sidecar.write(&record.session);
|
||||
let _ = inner.events.send(CatalogEvent::SessionUpdated {
|
||||
session: record.session.clone(),
|
||||
});
|
||||
record.pending_prompt.take()
|
||||
};
|
||||
if let Some(prompt) = pending_prompt {
|
||||
let manager = SessionManager {
|
||||
inner: inner.clone(),
|
||||
};
|
||||
let _ = manager.queue_acp_prompt(id, prompt).await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(%id, error = %e, "structured session: adapter failed to start");
|
||||
let mut sessions = inner.sessions.lock().await;
|
||||
if let Some(record) = sessions.get_mut(&id) {
|
||||
record.session.status = SessionStatus::Dead;
|
||||
record.session.activity = ActivityState::Error;
|
||||
record.session.activity_detail = Some(e.to_string());
|
||||
record.session.activity_since = Utc::now();
|
||||
let _ = inner.sidecar.write(&record.session);
|
||||
let _ = inner.events.send(CatalogEvent::SessionUpdated {
|
||||
session: record.session.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
drop_structured_lifecycle_permit(&inner);
|
||||
});
|
||||
}
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
@@ -1682,6 +1721,7 @@ impl SessionManager {
|
||||
structured_activity_watermark: None,
|
||||
acp_event_pump: None,
|
||||
auto_approve: false,
|
||||
pending_prompt: None,
|
||||
},
|
||||
);
|
||||
let _ = self
|
||||
@@ -1862,6 +1902,7 @@ impl SessionManager {
|
||||
// Recovery cannot know the original preset's flag; the
|
||||
// operator re-enables it by restarting with the preset.
|
||||
auto_approve: false,
|
||||
pending_prompt: None,
|
||||
},
|
||||
);
|
||||
if let Some((acp, events)) = event_pump {
|
||||
@@ -2321,6 +2362,11 @@ async fn apply_structured_activity(
|
||||
if record.session.view != SessionView::Structured {
|
||||
return;
|
||||
}
|
||||
// While the adapter is still booting, the background handshake task
|
||||
// owns the activity state — the activity pass must not overwrite it.
|
||||
if record.session.activity == ActivityState::Starting {
|
||||
return;
|
||||
}
|
||||
let current_events = record.acp_events.as_ref();
|
||||
match (expected_events, current_events) {
|
||||
(Some((expected, sequence)), Some(current)) if Arc::ptr_eq(expected, current) => {
|
||||
@@ -2586,6 +2632,12 @@ async fn run_liveness_pass(inner: &Arc<Inner>) {
|
||||
let mut tmux_backed = Vec::new();
|
||||
let mut acp_backed = Vec::new();
|
||||
for (id, record) in running {
|
||||
if record.is_starting_structured() {
|
||||
// The adapter hasn't finished its handshake yet — no ACP
|
||||
// handle and no tmux pane. Skip; the background task owns
|
||||
// this session's lifecycle until the handshake settles.
|
||||
continue;
|
||||
}
|
||||
match &record.acp {
|
||||
Some(acp) => acp_backed.push((*id, acp.clone())),
|
||||
None => tmux_backed.push(*id),
|
||||
|
||||
@@ -130,6 +130,25 @@ async fn wait_for_exit(pid: u32) {
|
||||
assert!(!pid_alive(pid), "pid {pid} still alive");
|
||||
}
|
||||
|
||||
/// Wait for a session's background ACP handshake to complete (activity
|
||||
/// transitions out of `Starting`). Returns the refreshed session.
|
||||
async fn wait_for_ready(manager: &SessionManager, id: uuid::Uuid) -> maestro_protocol::Session {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let session = manager
|
||||
.snapshot()
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|s| s.id == id)
|
||||
.expect("session exists");
|
||||
if session.activity != ActivityState::Starting {
|
||||
return session;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "handshake timed out");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
||||
let fx = DaemonFixture::new();
|
||||
@@ -138,7 +157,7 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
||||
let mut env = fake.env();
|
||||
env.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
|
||||
|
||||
let session = fx
|
||||
let created = fx
|
||||
.manager
|
||||
.create(structured_command(
|
||||
cwd.path(),
|
||||
@@ -148,8 +167,12 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
assert_eq!(session.view, SessionView::Structured);
|
||||
assert_eq!(session.status, SessionStatus::Running);
|
||||
assert_eq!(created.view, SessionView::Structured);
|
||||
assert_eq!(created.status, SessionStatus::Running);
|
||||
assert_eq!(created.activity, ActivityState::Starting);
|
||||
assert!(created.acp_session_id.is_none());
|
||||
|
||||
let session = wait_for_ready(&fx.manager, created.id).await;
|
||||
assert_eq!(session.activity, ActivityState::Idle);
|
||||
let acp_id = session.acp_session_id.clone().expect("acp session id");
|
||||
assert!(acp_id.starts_with("fake-"), "{acp_id}");
|
||||
@@ -164,7 +187,7 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
||||
"{log}"
|
||||
);
|
||||
assert!(
|
||||
log.contains(&format!("env MAESTRO_SESSION_ID={}", session.id)),
|
||||
log.contains(&format!("env MAESTRO_SESSION_ID={}", created.id)),
|
||||
"{log}"
|
||||
);
|
||||
|
||||
@@ -232,6 +255,7 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
|
||||
.create(structured_command(cwd.path(), env, "codex"))
|
||||
.await
|
||||
.unwrap();
|
||||
let session = wait_for_ready(&fx.manager, session.id).await;
|
||||
|
||||
assert!(matches!(
|
||||
fx.manager
|
||||
@@ -295,11 +319,12 @@ async fn projection_snapshot_and_live_stream_share_one_fold() {
|
||||
let fx = DaemonFixture::new();
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let session = fx
|
||||
let created = fx
|
||||
.manager
|
||||
.create(structured(cwd.path(), fake.env()))
|
||||
.await
|
||||
.expect("create");
|
||||
let session = wait_for_ready(&fx.manager, created.id).await;
|
||||
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
fx.manager
|
||||
@@ -397,6 +422,7 @@ async fn queued_prompt_is_a_durable_user_transcript_row() {
|
||||
.create(structured(cwd.path(), fake.env()))
|
||||
.await
|
||||
.expect("create");
|
||||
let session = wait_for_ready(&fx.manager, session.id).await;
|
||||
|
||||
assert!(matches!(
|
||||
fx.manager
|
||||
@@ -442,6 +468,7 @@ async fn stale_turn_completion_cannot_end_the_next_turn() {
|
||||
.create(structured(cwd.path(), fake.env()))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
|
||||
let maestro_daemon::session::BeginAcpTurn::Started(_, first) =
|
||||
fx.manager.begin_acp_turn(session.id).await.unwrap()
|
||||
@@ -485,6 +512,7 @@ async fn cancelled_prompt_that_never_settles_fails_the_session() {
|
||||
.create(structured(cwd.path(), env))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
|
||||
assert!(matches!(
|
||||
fx.manager
|
||||
@@ -523,6 +551,7 @@ async fn cancellation_settlement_allows_the_next_prompt_permission() {
|
||||
.create(structured(cwd.path(), env))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
|
||||
assert!(matches!(
|
||||
fx.manager
|
||||
@@ -582,11 +611,12 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
|
||||
let mut env = fake.env();
|
||||
env.insert("FAKE_ACP_LOAD_REPLAY".into(), "2".into());
|
||||
|
||||
let session = fx
|
||||
let created = fx
|
||||
.manager
|
||||
.create(structured_command(cwd.path(), env, "codex"))
|
||||
.await
|
||||
.expect("create");
|
||||
let session = wait_for_ready(&fx.manager, created.id).await;
|
||||
let acp_id = session.acp_session_id.clone().expect("acp id");
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
fx.manager
|
||||
@@ -711,6 +741,7 @@ async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
|
||||
.await
|
||||
.expect("create");
|
||||
assert_eq!(session.cwd, worktree);
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
|
||||
fx.manager.shutdown().await;
|
||||
@@ -759,6 +790,7 @@ async fn failed_recovery_cleans_its_fifo() {
|
||||
.create(structured(cwd.path(), fake.env()))
|
||||
.await
|
||||
.expect("create");
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
let old_pid = fake.pid();
|
||||
fx.manager.shutdown().await;
|
||||
wait_for_exit(old_pid).await;
|
||||
@@ -792,12 +824,15 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
|
||||
empty.path().to_string_lossy().into_owned(),
|
||||
)]);
|
||||
|
||||
let session = fx
|
||||
let created = fx
|
||||
.manager
|
||||
.create(structured_command(cwd.path(), env, "codex"))
|
||||
.await
|
||||
.expect("a failed session, not an error");
|
||||
.expect("create registers a Starting session");
|
||||
assert_eq!(created.activity, ActivityState::Starting);
|
||||
|
||||
// The background handshake fails because the adapter binary is missing.
|
||||
let session = wait_for_ready(&fx.manager, created.id).await;
|
||||
assert_eq!(session.status, SessionStatus::Dead);
|
||||
assert_eq!(session.activity, ActivityState::Error);
|
||||
let detail = session.activity_detail.clone().expect("actionable detail");
|
||||
@@ -810,13 +845,13 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
|
||||
|
||||
// It is a real session: listed, on disk, and dismissable like any other
|
||||
// dead one.
|
||||
assert!(fx.manager.has(session.id).await);
|
||||
assert!(fx.manager.has(created.id).await);
|
||||
assert!(fx
|
||||
.sidecar_dir()
|
||||
.join(format!("{}.json", session.id))
|
||||
.join(format!("{}.json", created.id))
|
||||
.exists());
|
||||
fx.manager.kill(session.id, false).await.expect("dismiss");
|
||||
assert!(!fx.manager.has(session.id).await);
|
||||
fx.manager.kill(created.id, false).await.expect("dismiss");
|
||||
assert!(!fx.manager.has(created.id).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -825,20 +860,20 @@ async fn unsupported_structured_command_fails_without_launching_claude() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
let session = fx
|
||||
let err = fx
|
||||
.manager
|
||||
.create(structured_command(cwd.path(), fake.env(), "opencode"))
|
||||
.await
|
||||
.expect("failed structured session is persisted");
|
||||
.expect_err("unsupported command is rejected at request validation");
|
||||
|
||||
assert_eq!(session.status, SessionStatus::Dead);
|
||||
let detail = session.activity_detail.expect("selection error");
|
||||
assert!(detail.contains("Claude Code or Codex"), "{detail}");
|
||||
assert!(
|
||||
matches!(err, maestro_daemon::SessionError::BadRequest(ref msg) if msg.contains("Claude Code or Codex")),
|
||||
"{err:?}"
|
||||
);
|
||||
assert!(
|
||||
fake.log().is_empty(),
|
||||
"unsupported command launched an adapter"
|
||||
);
|
||||
fx.manager.kill(session.id, false).await.expect("dismiss");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -853,6 +888,7 @@ async fn kill_sends_cancel_then_sigterm_and_removes_the_sidecar() {
|
||||
.create(structured(cwd.path(), fake.env()))
|
||||
.await
|
||||
.expect("create");
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
let pid = fake.pid();
|
||||
assert!(pid_alive(pid));
|
||||
|
||||
@@ -895,6 +931,8 @@ async fn shutdown_reaps_every_adapter_child() {
|
||||
.create(structured(cwd.path(), b.env()))
|
||||
.await
|
||||
.expect("create b");
|
||||
wait_for_ready(&fx.manager, sa.id).await;
|
||||
wait_for_ready(&fx.manager, sb.id).await;
|
||||
let (pa, pb) = (a.pid(), b.pid());
|
||||
assert!(pid_alive(pa) && pid_alive(pb));
|
||||
|
||||
@@ -929,10 +967,13 @@ async fn dismissal_during_shutdown_does_not_restore_recovery_artifacts() {
|
||||
.create(structured(cwd.path(), env))
|
||||
.await
|
||||
.expect("create");
|
||||
fx.manager
|
||||
let blocker_session = fx
|
||||
.manager
|
||||
.create(structured(cwd.path(), blocker.env()))
|
||||
.await
|
||||
.expect("create blocker");
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
wait_for_ready(&fx.manager, blocker_session.id).await;
|
||||
|
||||
let manager = fx.manager.clone();
|
||||
let shutdown = tokio::spawn(async move { manager.shutdown().await });
|
||||
@@ -984,6 +1025,7 @@ async fn shutdown_does_not_recover_an_adapter_that_already_exited() {
|
||||
.create(structured(cwd.path(), env))
|
||||
.await
|
||||
.expect("create");
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
wait_for_exit(fake.pid()).await;
|
||||
|
||||
fx.manager.shutdown().await;
|
||||
@@ -1017,23 +1059,31 @@ async fn shutdown_waits_for_structured_creation_and_rejects_new_creates() {
|
||||
let mut env = fake.env();
|
||||
env.insert("FAKE_ACP_DELAY_SESSION_NEW_MS".into(), "500".into());
|
||||
|
||||
let create_cwd = cwd.path().to_path_buf();
|
||||
let manager = fx.manager.clone();
|
||||
let create = tokio::spawn(async move { manager.create(structured(&create_cwd, env)).await });
|
||||
// create() returns immediately; the handshake runs in the background
|
||||
// and holds a lifecycle permit.
|
||||
let session = fx
|
||||
.manager
|
||||
.create(structured(cwd.path(), env))
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
// Wait until the adapter has received session/new (handshake in progress).
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
fake.log().contains("recv session/new")
|
||||
})
|
||||
.await;
|
||||
|
||||
// Shutdown must wait for the in-flight background handshake.
|
||||
let manager = fx.manager.clone();
|
||||
let shutdown = tokio::spawn(async move { manager.shutdown().await });
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert!(
|
||||
!shutdown.is_finished(),
|
||||
"shutdown returned before create finished"
|
||||
"shutdown returned before handshake finished"
|
||||
);
|
||||
|
||||
let session = create.await.expect("create task").expect("create");
|
||||
// The handshake completes in the background (500ms delay).
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
let pid = fake.pid();
|
||||
shutdown.await.expect("shutdown task");
|
||||
|
||||
@@ -1066,27 +1116,37 @@ async fn structured_handshakes_run_independently() {
|
||||
gate.to_string_lossy().into_owned(),
|
||||
);
|
||||
|
||||
let first_cwd = cwd.path().to_path_buf();
|
||||
let manager = fx.manager.clone();
|
||||
let first =
|
||||
tokio::spawn(async move { manager.create(structured(&first_cwd, blocked_env)).await });
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
blocked.log().contains("recv session/new")
|
||||
})
|
||||
.await;
|
||||
// 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 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");
|
||||
// The second handshake completes independently of the first.
|
||||
let second_ready = wait_for_ready(&fx.manager, second.id).await;
|
||||
assert_eq!(second_ready.activity, ActivityState::Idle);
|
||||
|
||||
// The first handshake is still blocked.
|
||||
let first_snap = fx
|
||||
.manager
|
||||
.snapshot()
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|s| s.id == first.id)
|
||||
.unwrap();
|
||||
assert_eq!(first_snap.activity, ActivityState::Starting);
|
||||
|
||||
std::fs::write(&gate, b"release").unwrap();
|
||||
let first = first.await.expect("first task").expect("first create");
|
||||
let first_ready = wait_for_ready(&fx.manager, first.id).await;
|
||||
assert_eq!(first_ready.activity, ActivityState::Idle);
|
||||
|
||||
fx.manager.kill(first.id, false).await.expect("kill first");
|
||||
fx.manager
|
||||
.kill(second.id, false)
|
||||
@@ -1114,16 +1174,18 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
|
||||
gate_b.to_string_lossy().into_owned(),
|
||||
);
|
||||
|
||||
let create_a = {
|
||||
let manager = fx.manager.clone();
|
||||
let cwd = cwd.path().to_path_buf();
|
||||
tokio::spawn(async move { manager.create(structured(&cwd, env_a)).await })
|
||||
};
|
||||
let create_b = {
|
||||
let manager = fx.manager.clone();
|
||||
let cwd = cwd.path().to_path_buf();
|
||||
tokio::spawn(async move { manager.create(structured(&cwd, env_b)).await })
|
||||
};
|
||||
// Both create() calls return immediately; the background handshake
|
||||
// tasks hold lifecycle permits and are blocked on their gates.
|
||||
let session_a = fx
|
||||
.manager
|
||||
.create(structured(cwd.path(), env_a))
|
||||
.await
|
||||
.expect("create a");
|
||||
let session_b = fx
|
||||
.manager
|
||||
.create(structured(cwd.path(), env_b))
|
||||
.await
|
||||
.expect("create b");
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
a.log().contains("recv session/new") && b.log().contains("recv session/new")
|
||||
})
|
||||
@@ -1143,14 +1205,14 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
|
||||
assert!(!shutdown.is_finished());
|
||||
|
||||
std::fs::write(&gate_a, b"release").unwrap();
|
||||
let session_a = create_a.await.expect("create a task").expect("create a");
|
||||
wait_for_ready(&fx.manager, session_a.id).await;
|
||||
assert!(
|
||||
!shutdown.is_finished(),
|
||||
"shutdown ignored the second create"
|
||||
);
|
||||
|
||||
std::fs::write(&gate_b, b"release").unwrap();
|
||||
let session_b = create_b.await.expect("create b task").expect("create b");
|
||||
wait_for_ready(&fx.manager, session_b.id).await;
|
||||
shutdown.await.expect("shutdown task");
|
||||
|
||||
wait_for_exit(a.pid()).await;
|
||||
@@ -1166,7 +1228,7 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
|
||||
async fn killing_a_starting_session_rolls_back_and_settles_admission() {
|
||||
let fx = DaemonFixture::new();
|
||||
let fake = Fake::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
@@ -1192,40 +1254,30 @@ async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
|
||||
auto_approve: false,
|
||||
};
|
||||
|
||||
let create = {
|
||||
let manager = fx.manager.clone();
|
||||
tokio::spawn(async move { manager.create(request).await })
|
||||
};
|
||||
// create() returns immediately with Starting; the handshake is gated.
|
||||
let session = fx.manager.create(request).await.expect("create");
|
||||
assert_eq!(session.activity, ActivityState::Starting);
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
fake.log().contains("recv session/new")
|
||||
})
|
||||
.await;
|
||||
let id = fake
|
||||
.log()
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("env MAESTRO_SESSION_ID="))
|
||||
.and_then(|id| uuid::Uuid::parse_str(id).ok())
|
||||
.expect("session id in adapter log");
|
||||
let pid = fake.pid();
|
||||
|
||||
create.abort();
|
||||
let shutdown = {
|
||||
let manager = fx.manager.clone();
|
||||
tokio::spawn(async move { manager.shutdown().await })
|
||||
};
|
||||
assert!(create.await.unwrap_err().is_cancelled());
|
||||
tokio::time::timeout(Duration::from_secs(5), shutdown)
|
||||
// Kill the session while its handshake is still in progress.
|
||||
// force=true because the worktree has an unpushed branch commit.
|
||||
fx.manager.kill(session.id, true).await.expect("kill");
|
||||
|
||||
// Release the gate so the background task can finish and settle.
|
||||
std::fs::write(&gate, b"release").unwrap();
|
||||
|
||||
// Shutdown must not hang — the lifecycle permit is settled.
|
||||
tokio::time::timeout(Duration::from_secs(5), fx.manager.shutdown())
|
||||
.await
|
||||
.expect("cancelled admission kept shutdown waiting")
|
||||
.expect("shutdown task");
|
||||
.expect("shutdown hung on a settled permit");
|
||||
|
||||
wait_for_exit(pid).await;
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
!fx.fifo_dir().join(format!("{id}.fifo")).exists()
|
||||
&& !repo.path().join("cancelled-create").exists()
|
||||
})
|
||||
.await;
|
||||
assert!(fx.manager.snapshot().await.is_empty());
|
||||
let id = id.to_string();
|
||||
let id = session.id.to_string();
|
||||
assert!(
|
||||
std::fs::read_dir(fx.sidecar_dir())
|
||||
.into_iter()
|
||||
@@ -1235,7 +1287,7 @@ async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(&id)),
|
||||
"cancelled create left a sidecar artifact"
|
||||
"killed Starting session left a sidecar artifact"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1252,6 +1304,7 @@ async fn shutdown_drains_final_notifications_to_the_log() {
|
||||
.create(structured(cwd.path(), env))
|
||||
.await
|
||||
.expect("create");
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
fx.manager
|
||||
.read_acp_events(session.id, EventCursor::Since(0), 10)
|
||||
@@ -1282,6 +1335,7 @@ async fn adapter_exit_marks_the_session_dead_and_keeps_it_listed() {
|
||||
.create(structured(cwd.path(), fake.env()))
|
||||
.await
|
||||
.expect("create");
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
||||
|
||||
// The agent dies out from under the daemon: the liveness pass, not tmux,
|
||||
|
||||
@@ -154,7 +154,8 @@ async fn structured_session(h: &Harness, fake: &FakeAcp) -> Uuid {
|
||||
}
|
||||
|
||||
async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>) -> Uuid {
|
||||
h.manager
|
||||
let id = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: SessionView::Structured,
|
||||
cwd: "/tmp".into(),
|
||||
@@ -166,7 +167,9 @@ async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.id
|
||||
.id;
|
||||
wait_for_session_activity(h, id, ActivityState::Idle).await;
|
||||
id
|
||||
}
|
||||
|
||||
async fn wait_for_session_activity(h: &Harness, id: Uuid, expected: ActivityState) {
|
||||
@@ -1000,7 +1003,9 @@ 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`. A `/tmp`-rooted temp dir is not in a repo, so the root is null.
|
||||
// `.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.
|
||||
let h = spawn("naps-laptop").await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let resp: serde_json::Value = reqwest::get(url(
|
||||
@@ -1015,7 +1020,16 @@ async fn gitroot_probes_path() {
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.get("root").is_none() || resp["root"].is_null());
|
||||
// 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:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1494,6 +1508,12 @@ async fn events_ws_streams_structured_activity_without_hook_overrides() {
|
||||
let maestro_protocol::CatalogEvent::SessionAdded { session } = next_event(&mut ws).await else {
|
||||
panic!("expected structured SessionAdded")
|
||||
};
|
||||
assert_eq!(session.activity, ActivityState::Starting);
|
||||
// The background handshake completes and sends SessionUpdated → Idle.
|
||||
let maestro_protocol::CatalogEvent::SessionUpdated { session } = next_event(&mut ws).await
|
||||
else {
|
||||
panic!("expected SessionUpdated after handshake")
|
||||
};
|
||||
assert_eq!(session.activity, ActivityState::Idle);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
@@ -175,6 +175,10 @@ pub enum ActivityState {
|
||||
AwaitingInput,
|
||||
/// An agent reported a failure, or a structured session/turn failed.
|
||||
Error,
|
||||
/// The ACP adapter is booting; the session is registered but cannot accept
|
||||
/// prompts yet. Older clients that do not know this variant degrade it to
|
||||
/// `Idle` via the lenient deserializer — acceptable.
|
||||
Starting,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ActivityState {
|
||||
@@ -187,6 +191,7 @@ impl<'de> Deserialize<'de> for ActivityState {
|
||||
"Active" => Self::Active,
|
||||
"AwaitingInput" => Self::AwaitingInput,
|
||||
"Error" => Self::Error,
|
||||
"Starting" => Self::Starting,
|
||||
// Known or not, any other spelling degrades to the neutral default
|
||||
_ => Self::Idle,
|
||||
})
|
||||
@@ -225,9 +230,10 @@ where
|
||||
"Idle" => Ok(ActivityState::Idle),
|
||||
"AwaitingInput" => Ok(ActivityState::AwaitingInput),
|
||||
"Error" => Ok(ActivityState::Error),
|
||||
"Starting" => Ok(ActivityState::Starting),
|
||||
other => Err(D::Error::unknown_variant(
|
||||
other,
|
||||
&["Active", "Idle", "AwaitingInput", "Error"],
|
||||
&["Active", "Idle", "AwaitingInput", "Error", "Starting"],
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ use chrono::Utc;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use maestro_protocol::{
|
||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
|
||||
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION,
|
||||
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, StructuredPromptRequest,
|
||||
PROTOCOL_VERSION,
|
||||
};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{broadcast, oneshot};
|
||||
@@ -60,6 +61,8 @@ struct Inner {
|
||||
forced_error: Option<(u16, ApiError)>,
|
||||
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
|
||||
inputs: Vec<(Uuid, SendInputRequest)>,
|
||||
/// Every ACP prompt sent to a structured session, in order.
|
||||
structured_prompts: Vec<(Uuid, StructuredPromptRequest)>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -92,6 +95,7 @@ impl MockDaemon {
|
||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||
.route(routes::patterns::SESSION, delete(kill_session))
|
||||
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
||||
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
||||
.route(routes::INFO, get(daemon_info))
|
||||
.route(routes::DIRS, get(list_dir))
|
||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||
@@ -178,6 +182,16 @@ impl MockDaemon {
|
||||
self.state.inner.lock().expect("poisoned").inputs.clone()
|
||||
}
|
||||
|
||||
/// Every ACP prompt this daemon received, oldest first.
|
||||
pub fn structured_prompts(&self) -> Vec<(Uuid, StructuredPromptRequest)> {
|
||||
self.state
|
||||
.inner
|
||||
.lock()
|
||||
.expect("poisoned")
|
||||
.structured_prompts
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn session_count(&self) -> usize {
|
||||
self.state.inner.lock().expect("poisoned").sessions.len()
|
||||
}
|
||||
@@ -266,6 +280,26 @@ async fn send_input(
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
async fn structured_prompt(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<StructuredPromptRequest>,
|
||||
) -> Response {
|
||||
if let Some(r) = forced(&s) {
|
||||
return r;
|
||||
}
|
||||
let mut guard = s.inner.lock().expect("poisoned");
|
||||
if !guard.sessions.iter().any(|x| x.id == id) {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ApiError::new("not_found", "no such session on this daemon")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
guard.structured_prompts.push((id, req));
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
async fn daemon_info(State(s): State<AppState>) -> Response {
|
||||
if let Some(r) = forced(&s) {
|
||||
return r;
|
||||
|
||||
@@ -82,7 +82,7 @@ impl Surface {
|
||||
pub fn activity(&self, state: ActivityState) -> Color {
|
||||
match state {
|
||||
ActivityState::Active => self.active,
|
||||
ActivityState::Idle => self.idle,
|
||||
ActivityState::Idle | ActivityState::Starting => self.idle,
|
||||
ActivityState::AwaitingInput => self.awaiting,
|
||||
ActivityState::Error => self.error,
|
||||
}
|
||||
@@ -928,9 +928,10 @@ pub fn contrast_ratio(a: Color, b: Color) -> Option<f64> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const STATES: [ActivityState; 4] = [
|
||||
const STATES: [ActivityState; 5] = [
|
||||
ActivityState::Active,
|
||||
ActivityState::Idle,
|
||||
ActivityState::Starting,
|
||||
ActivityState::AwaitingInput,
|
||||
ActivityState::Error,
|
||||
];
|
||||
|
||||
@@ -979,6 +979,7 @@ mod tests {
|
||||
for state in [
|
||||
ActivityState::Active,
|
||||
ActivityState::Idle,
|
||||
ActivityState::Starting,
|
||||
ActivityState::AwaitingInput,
|
||||
ActivityState::Error,
|
||||
] {
|
||||
@@ -1009,6 +1010,7 @@ mod tests {
|
||||
for state in [
|
||||
ActivityState::Active,
|
||||
ActivityState::Idle,
|
||||
ActivityState::Starting,
|
||||
ActivityState::AwaitingInput,
|
||||
ActivityState::Error,
|
||||
] {
|
||||
|
||||
@@ -160,6 +160,7 @@ The state word is never spelled out — the colour carries it.
|
||||
| ---------------- | --------- | ---------------- |
|
||||
| `Active` | `--live` | `active` |
|
||||
| `Idle` | `--muted` | `idle` |
|
||||
| `Starting` | `--muted` | `idle` |
|
||||
| `AwaitingInput` | `--warn` | `awaiting` |
|
||||
| `Error` | `--danger`| `error` |
|
||||
|
||||
|
||||
@@ -193,10 +193,13 @@ choices, not daemon ones.
|
||||
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. 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.
|
||||
outside as the prominent final answer. While a turn is still active
|
||||
(responding or thinking), the most recent intermediate `AgentText` or
|
||||
`AgentThought` in the group is kept outside as visible narration — replaced
|
||||
by the next text row when one arrives, and absorbed into the group when the
|
||||
turn ends. The header still reads "N tool calls" (counting only tool rows).
|
||||
Opening the group reveals tools and intermediate text in chronological
|
||||
order, with the text rendered in a subdued style.
|
||||
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
||||
copy their complete source text when clicked or keyboard-activated.
|
||||
- The web client consumes `available_commands_update` notices as the live
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+12
-2
@@ -17,12 +17,22 @@
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"marked": "^18.0.11"
|
||||
"marked": "^18.0.11",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"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"
|
||||
"vitest": "^2.1.9",
|
||||
"zustand": "^5.0.15"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+984
-11
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<div id="react-root" />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
+4
-3
@@ -663,8 +663,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
applyNav();
|
||||
};
|
||||
|
||||
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
|
||||
// out: there is no hover there, and a tap already opens the session.
|
||||
// Pointer hover previews only while Ctrl is held — plain hover was too
|
||||
// disruptive when sweeping the sidebar. Keyboard walk (j/k) still previews
|
||||
// unconditionally via applyNav.
|
||||
let hovered: string | null = null;
|
||||
const setHovered = (id: string | null) => {
|
||||
if (hovered === id) return;
|
||||
@@ -672,7 +673,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
opts.onPreview?.(id);
|
||||
};
|
||||
treeHost.addEventListener("pointerover", (e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
if (e.pointerType === "touch" || !e.ctrlKey) return;
|
||||
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
|
||||
setHovered(row?.dataset.sessionId ?? null);
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
type MatchRange,
|
||||
type MatchResult,
|
||||
type SessionCandidate,
|
||||
} from "./matcher.js";
|
||||
} from "./utils/matcher.js";
|
||||
import { splitPath } from "./paths.js";
|
||||
import { splitCommand } from "./toolIcon.js";
|
||||
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js";
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// A session's action page (docs/COMMAND-BAR.md, "Drill-in"): the same overlay
|
||||
// over kill, rename and spawn similar, still fuzzy searchable. The page names
|
||||
// the session it acts on, so the target is never ambiguous.
|
||||
|
||||
import { sessionName, type Session } from "../../types.js";
|
||||
import { ResultList } from "./ResultList.js";
|
||||
import type { BarGroup } from "./model.js";
|
||||
|
||||
export interface ActionPageProps {
|
||||
session: Session;
|
||||
/** The three actions, ranked by the query line — one kind, so no headers. */
|
||||
groups: BarGroup[];
|
||||
highlighted: number;
|
||||
onActivate(index: number): void;
|
||||
}
|
||||
|
||||
export function ActionPage({ session, groups, highlighted, onActivate }: ActionPageProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="presentation"
|
||||
className="cmdbar-page-header border-b border-border px-sp-3 py-sp-1 text-fs-11 text-muted"
|
||||
>
|
||||
{`Actions for "${sessionName(session)}"`}
|
||||
</div>
|
||||
<ResultList
|
||||
groups={groups}
|
||||
showHeaders={false}
|
||||
highlighted={highlighted}
|
||||
onActivate={onActivate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
KILL_SESSION_EVENT,
|
||||
RENAME_SESSION_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
} from "../../catalog.js";
|
||||
import { COMMAND_SPECS, type Command } from "../../commands.js";
|
||||
import type { Session } from "../../types.js";
|
||||
import { CommandBar, SPAWN_DIRECTORY_EVENT, type CommandBarProps } from "./CommandBar.js";
|
||||
|
||||
vi.mock("../../api.js", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
listDir: vi.fn(async () => ({ path: "", entries: [] })),
|
||||
projects: vi.fn(async () => []),
|
||||
recentDirs: vi.fn(async () => ({ dirs: [] })),
|
||||
}));
|
||||
|
||||
function session(id: string, over: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id,
|
||||
cwd: "/home/me/dev/api",
|
||||
command: "bash",
|
||||
env: {},
|
||||
metadata: { name: id },
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function command(over: Partial<Command> = {}): Command {
|
||||
return {
|
||||
id: "cmd",
|
||||
label: "A command",
|
||||
group: "Test",
|
||||
keys: ["a"],
|
||||
scope: "global",
|
||||
run: () => {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const sessions = [
|
||||
session("api", { current_cwd: "/home/me/dev/api", last_focused_at: "2026-01-01T11:00:00Z" }),
|
||||
session("worker", {
|
||||
current_cwd: "/home/me/dev/worker",
|
||||
last_focused_at: "2026-01-01T10:00:00Z",
|
||||
}),
|
||||
session("notes", { current_cwd: "/home/me/notes" }),
|
||||
];
|
||||
|
||||
function open(over: Partial<CommandBarProps> = {}) {
|
||||
return render(
|
||||
<CommandBar
|
||||
sessions={sessions}
|
||||
homeDir="/home/me"
|
||||
commands={[]}
|
||||
onOpenSession={() => {}}
|
||||
onClose={() => {}}
|
||||
{...over}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function input(): HTMLInputElement {
|
||||
return screen.getByRole("combobox") as HTMLInputElement;
|
||||
}
|
||||
|
||||
function type(value: string): void {
|
||||
fireEvent.change(input(), { target: { value } });
|
||||
}
|
||||
|
||||
function press(key: string, init: Record<string, unknown> = {}): void {
|
||||
fireEvent.keyDown(input(), { key, ...init });
|
||||
}
|
||||
|
||||
function rowNames(): string[] {
|
||||
return [...document.querySelectorAll(".cmdbar-row-name")].map((r) => r.textContent ?? "");
|
||||
}
|
||||
|
||||
function activeName(): string | undefined {
|
||||
return document.querySelector(".cmdbar-row.is-active .cmdbar-row-name")?.textContent ?? undefined;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("the overlay", () => {
|
||||
it("is a registry command, so the help overlay lists it", () => {
|
||||
const spec = COMMAND_SPECS.find((s) => s.id === "bar.open");
|
||||
expect(spec?.keys).toEqual(["ctrl-k"]);
|
||||
expect(spec?.scope).toBe("global");
|
||||
});
|
||||
|
||||
it("lists sessions by recency under a kind header on an empty query", () => {
|
||||
open();
|
||||
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
|
||||
"Sessions",
|
||||
]);
|
||||
expect(rowNames()).toEqual(["api", "worker", "notes"]);
|
||||
expect(activeName()).toBe("api");
|
||||
});
|
||||
|
||||
it("ranks a typed query through the matcher and highlights the matched range", () => {
|
||||
open();
|
||||
type("wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
expect([...document.querySelectorAll(".cmdbar-row-name mark")].map((m) => m.textContent)).toEqual(
|
||||
["wor"],
|
||||
);
|
||||
});
|
||||
|
||||
it("highlights inside the directory when that is the field that matched", () => {
|
||||
open();
|
||||
type("dev/wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
expect(
|
||||
[...document.querySelectorAll(".cmdbar-row-meta mark")].map((m) => m.textContent).join(""),
|
||||
).toBe("dev/wor");
|
||||
});
|
||||
|
||||
it("renders a no-results state rather than an empty list", () => {
|
||||
open();
|
||||
type("zzz");
|
||||
expect(rowNames()).toEqual([]);
|
||||
expect(screen.getByText("No matches")).toBeDefined();
|
||||
});
|
||||
|
||||
it("moves the highlight with the arrows and ctrl-p/ctrl-n, without wrapping", () => {
|
||||
open();
|
||||
press("ArrowUp");
|
||||
expect(activeName()).toBe("api");
|
||||
press("ArrowDown");
|
||||
expect(activeName()).toBe("worker");
|
||||
press("n", { ctrlKey: true });
|
||||
expect(activeName()).toBe("notes");
|
||||
press("ArrowDown");
|
||||
expect(activeName()).toBe("notes");
|
||||
press("p", { ctrlKey: true });
|
||||
expect(activeName()).toBe("worker");
|
||||
});
|
||||
|
||||
it("puts the highlight back on the best row whenever the query changes", () => {
|
||||
open();
|
||||
press("ArrowDown");
|
||||
expect(activeName()).toBe("worker");
|
||||
type("o");
|
||||
expect(activeName()).toBe(rowNames()[0]);
|
||||
});
|
||||
|
||||
it("opens the highlighted session through the caller's open path on Enter", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
open({ onOpenSession, onClose });
|
||||
press("ArrowDown");
|
||||
press("Enter");
|
||||
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("raises the shared select event when the caller supplies no handler", () => {
|
||||
const listener = vi.fn();
|
||||
document.addEventListener(SESSION_SELECT_EVENT, listener);
|
||||
open({ onOpenSession: undefined });
|
||||
press("Enter");
|
||||
document.removeEventListener(SESSION_SELECT_EVENT, listener);
|
||||
expect((listener.mock.calls[0][0] as CustomEvent).detail).toBe("api");
|
||||
});
|
||||
|
||||
it("Enter on an empty result list does nothing", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
open({ onOpenSession, onClose });
|
||||
type("zzz");
|
||||
press("Enter");
|
||||
expect(onOpenSession).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes on Escape and on a press outside the panel", () => {
|
||||
const onClose = vi.fn();
|
||||
open({ onClose });
|
||||
press("Escape");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
fireEvent.mouseDown(document.querySelector(".cmdbar-overlay") as HTMLElement);
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("stays open for a press inside the panel", () => {
|
||||
const onClose = vi.fn();
|
||||
open({ onClose });
|
||||
fireEvent.mouseDown(document.querySelector(".cmdbar-panel") as HTMLElement);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the keyboard to whatever held it when it opened", () => {
|
||||
const before = document.createElement("input");
|
||||
document.body.appendChild(before);
|
||||
before.focus();
|
||||
const view = open();
|
||||
expect(document.activeElement).toBe(input());
|
||||
view.unmount();
|
||||
expect(document.activeElement).toBe(before);
|
||||
before.remove();
|
||||
});
|
||||
|
||||
it("clicking a row opens that session", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
open({ onOpenSession });
|
||||
fireEvent.click(document.querySelectorAll(".cmdbar-row")[1]);
|
||||
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sigils", () => {
|
||||
const commands = [
|
||||
command({ id: "help.toggle", label: "Show help", keys: ["?"] }),
|
||||
command({ id: "spawn.open", label: "Open the spawn form", keys: ["n"], run: vi.fn() }),
|
||||
];
|
||||
|
||||
it("lists every command in registry order under `>`, with its chord and no header", () => {
|
||||
open({ commands });
|
||||
type(">");
|
||||
expect(rowNames()).toEqual(["Show help", "Open the spawn form"]);
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
expect([...document.querySelectorAll(".cmdbar-row-keys kbd")].map((k) => k.textContent)).toEqual(
|
||||
["?", "n"],
|
||||
);
|
||||
});
|
||||
|
||||
it("matches a command on its label and never on its chord", () => {
|
||||
open({ commands });
|
||||
type(">help");
|
||||
expect(rowNames()).toEqual(["Show help"]);
|
||||
type(">?");
|
||||
expect(rowNames()).toEqual([]);
|
||||
});
|
||||
|
||||
it("runs the highlighted command on Enter", () => {
|
||||
const run = vi.fn();
|
||||
open({ commands: [command({ label: "Run me", run })] });
|
||||
type(">run");
|
||||
press("Enter");
|
||||
expect(run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the list mixed with no sigil, and returns to it when the sigil goes", () => {
|
||||
open({ commands });
|
||||
type(">show");
|
||||
expect(rowNames()).toEqual(["Show help"]);
|
||||
type("");
|
||||
expect(rowNames()).toEqual(["api", "worker", "notes"]);
|
||||
});
|
||||
|
||||
it("lists recent dirs then projects under `~`", async () => {
|
||||
open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/x"] });
|
||||
type("~");
|
||||
await waitFor(() => expect(rowNames()).toEqual(["~/dev/api", "/srv/x"]));
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fills the list in from /api/dirs as a path is typed", async () => {
|
||||
const listDirEntries = vi.fn(async () => ["maestro", "api"]);
|
||||
open({ loadDirectories: async () => ["/home/me/notes"], listDirEntries });
|
||||
// The second `~` is a literal query character and reaches the matcher.
|
||||
type("~~/dev/mae");
|
||||
await waitFor(() => expect(rowNames()).toEqual(["~/dev/maestro"]));
|
||||
expect(listDirEntries).toHaveBeenCalledWith("~/dev/");
|
||||
});
|
||||
|
||||
it("spawns in the highlighted directory on Enter", async () => {
|
||||
const onSpawnDirectory = vi.fn();
|
||||
open({ loadDirectories: async () => ["/srv/x"], onSpawnDirectory });
|
||||
type("~");
|
||||
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
|
||||
press("Enter");
|
||||
expect(onSpawnDirectory).toHaveBeenCalledWith("/srv/x");
|
||||
});
|
||||
|
||||
it("raises the spawn event when the caller supplies no handler", async () => {
|
||||
const listener = vi.fn();
|
||||
document.addEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
||||
open({ loadDirectories: async () => ["/srv/x"] });
|
||||
type("~");
|
||||
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
|
||||
press("Enter");
|
||||
document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
||||
expect((listener.mock.calls[0][0] as CustomEvent).detail).toBe("/srv/x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the session action page", () => {
|
||||
function drill(over: Partial<CommandBarProps> = {}) {
|
||||
const view = open(over);
|
||||
press("Tab");
|
||||
return view;
|
||||
}
|
||||
|
||||
it("lists kill, rename and spawn similar for the highlighted session", () => {
|
||||
drill();
|
||||
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
|
||||
});
|
||||
|
||||
it("names the session being acted on", () => {
|
||||
drill();
|
||||
expect(screen.getByText('Actions for "api"')).toBeDefined();
|
||||
});
|
||||
|
||||
it("drops the kind headers, since the page is one kind", () => {
|
||||
drill();
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("stays fuzzy-searchable over the three actions", () => {
|
||||
drill();
|
||||
type("ren");
|
||||
expect(rowNames()).toEqual(["Rename session"]);
|
||||
});
|
||||
|
||||
it("does nothing on a command row", () => {
|
||||
open({ commands: [command({ label: "Show help" })] });
|
||||
type(">show");
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["Show help"]);
|
||||
});
|
||||
|
||||
it("does nothing on a directory row", async () => {
|
||||
open({ loadDirectories: async () => ["/srv/x"] });
|
||||
type("~");
|
||||
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["/srv/x"]);
|
||||
});
|
||||
|
||||
it("returns to the result list on Escape, with the previous query intact", () => {
|
||||
const onClose = vi.fn();
|
||||
open({ onClose });
|
||||
type("wor");
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
|
||||
press("Escape");
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(input().value).toBe("wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
});
|
||||
|
||||
it("returns to the result list on backspace at an empty query", () => {
|
||||
drill();
|
||||
fireEvent.keyDown(input(), { key: "Backspace" });
|
||||
expect(rowNames()).toEqual(["api", "worker", "notes"]);
|
||||
});
|
||||
|
||||
it("keeps backspace ordinary while the page's query has text", () => {
|
||||
drill();
|
||||
type("ren");
|
||||
fireEvent.keyDown(input(), { key: "Backspace" });
|
||||
expect(rowNames()).toEqual(["Rename session"]);
|
||||
});
|
||||
|
||||
it("closes the bar when the page's Escape has already been used", () => {
|
||||
const onClose = vi.fn();
|
||||
drill({ onClose });
|
||||
press("Escape");
|
||||
press("Escape");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("raises kill, rename and spawn similar with the session, closing the bar", () => {
|
||||
for (const [query, key] of [
|
||||
["kill", "onKillSession"],
|
||||
["rename", "onRenameSession"],
|
||||
["spawn", "onSpawnSimilar"],
|
||||
] as const) {
|
||||
const handler = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
drill({ [key]: handler, onClose });
|
||||
type(query);
|
||||
press("Enter");
|
||||
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: "api" }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("hands each action to the flow it already has, by default", () => {
|
||||
for (const [query, event] of [
|
||||
["kill", KILL_SESSION_EVENT],
|
||||
["rename", RENAME_SESSION_EVENT],
|
||||
["spawn", SPAWN_SIMILAR_EVENT],
|
||||
] as const) {
|
||||
const listener = vi.fn();
|
||||
document.addEventListener(event, listener);
|
||||
drill();
|
||||
type(query);
|
||||
press("Enter");
|
||||
document.removeEventListener(event, listener);
|
||||
expect((listener.mock.calls[0][0] as CustomEvent).detail.id).toBe("api");
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
|
||||
// list. Sessions and commands rank together by default; `>` narrows to
|
||||
// commands, `~` to directories. `tab` on a session drills into its action page.
|
||||
//
|
||||
// The bar picks an action and hands off — it never reimplements one. Each
|
||||
// handler defaults to the event the flow that already implements it listens
|
||||
// on, so the React bar and the legacy shell raise the same flows.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import {
|
||||
KILL_SESSION_EVENT,
|
||||
RENAME_SESSION_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
} from "../../catalog.js";
|
||||
import { publishedCommands, type Command } from "../../commands.js";
|
||||
import { sessionName, type Session } from "../../types.js";
|
||||
import { ActionPage } from "./ActionPage.js";
|
||||
import { ResultList } from "./ResultList.js";
|
||||
import {
|
||||
barGroups,
|
||||
barRows,
|
||||
commandBarCandidates,
|
||||
dirEntryPaths,
|
||||
directoryBarCandidates,
|
||||
isActionCandidate,
|
||||
listDirEntries as defaultListDirEntries,
|
||||
loadBarDirectories,
|
||||
parseBarQuery,
|
||||
probeDir,
|
||||
sessionActionCandidates,
|
||||
sessionBarCandidates,
|
||||
PLACEHOLDERS,
|
||||
type BarCandidate,
|
||||
type BarResult,
|
||||
type DirectoryBarCandidate,
|
||||
type SessionAction,
|
||||
} from "./model.js";
|
||||
|
||||
/** Raised when Enter lands on a directory row and no handler was given. The
|
||||
* bar picks the directory; the shell owns the spawn, which is the same
|
||||
* fast-forward path `N` takes. */
|
||||
export const SPAWN_DIRECTORY_EVENT = "cmdbar-spawn-directory";
|
||||
|
||||
export interface CommandBarProps {
|
||||
/** Sessions to build candidates from, as the store has them right now. */
|
||||
sessions: readonly Session[];
|
||||
/** The daemon's home dir, for the directory field's `~/…` abbreviation. */
|
||||
homeDir?: string;
|
||||
/** The command registry to list under `>`. Defaults to every bound command,
|
||||
* in registry order. */
|
||||
commands?: readonly Command[];
|
||||
/** The directory list behind `~`, fetched once on first use. */
|
||||
loadDirectories?: () => Promise<string[]>;
|
||||
/** One directory's subdirectories, for filling the list in as a path is
|
||||
* typed. Defaults to `GET /api/dirs`. */
|
||||
listDirEntries?: (dir: string) => Promise<string[]>;
|
||||
onOpenSession?(sessionId: string): void;
|
||||
onSpawnDirectory?(path: string): void;
|
||||
onKillSession?(session: Session): void;
|
||||
onRenameSession?(session: Session): void;
|
||||
onSpawnSimilar?(session: Session): void;
|
||||
/** Closing returns the keyboard to whatever held it when the bar opened. */
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
function raise(name: string, detail: unknown): void {
|
||||
document.dispatchEvent(new CustomEvent(name, { detail }));
|
||||
}
|
||||
|
||||
export function CommandBar(props: CommandBarProps) {
|
||||
const {
|
||||
sessions,
|
||||
homeDir,
|
||||
commands,
|
||||
loadDirectories = loadBarDirectories,
|
||||
listDirEntries = defaultListDirEntries,
|
||||
onClose,
|
||||
} = props;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [raw, setRaw] = useState("");
|
||||
const [highlighted, setHighlighted] = useState(0);
|
||||
// The action page: `page` is the session being acted on, `stashed` the
|
||||
// result-list query it was drilled into from — backing out restores it, so
|
||||
// the round trip costs the operator nothing.
|
||||
const [page, setPage] = useState<Session | null>(null);
|
||||
const [stashed, setStashed] = useState("");
|
||||
|
||||
// `~`'s candidates: the recents-and-projects list, fetched once, plus
|
||||
// whatever `/api/dirs` has filled in for the directory being typed.
|
||||
const [directories, setDirectories] = useState<DirectoryBarCandidate[]>([]);
|
||||
const [probed, setProbed] = useState<DirectoryBarCandidate[]>([]);
|
||||
const requestedRef = useRef(false);
|
||||
const listingsRef = useRef(new Map<string, string[]>());
|
||||
|
||||
// On the action page the query line is over the three actions, so there is
|
||||
// no sigil to read: `>` and `~` are ordinary characters there.
|
||||
const parsed = useMemo(() => (page ? { sigil: null, query: raw } : parseBarQuery(raw)), [page, raw]);
|
||||
|
||||
const sessionCandidates = useMemo(
|
||||
() => sessionBarCandidates(sessions, homeDir),
|
||||
[sessions, homeDir],
|
||||
);
|
||||
const commandCandidates = useMemo(
|
||||
() => commandBarCandidates(commands ?? publishedCommands()),
|
||||
[commands],
|
||||
);
|
||||
|
||||
// Closing returns the keyboard to whatever held it when the bar opened: a
|
||||
// round trip through the bar never silently moves focus.
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
inputRef.current?.focus();
|
||||
return () => {
|
||||
if (previouslyFocused?.isConnected) previouslyFocused.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The recents-and-projects list, the first time `~` is used. No directories
|
||||
// is a shorter list, not an error state.
|
||||
useEffect(() => {
|
||||
if (parsed.sigil !== "~" || requestedRef.current) return;
|
||||
requestedRef.current = true;
|
||||
let live = true;
|
||||
loadDirectories()
|
||||
.then((paths) => {
|
||||
if (live) setDirectories(directoryBarCandidates(paths, homeDir));
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [parsed.sigil, loadDirectories, homeDir]);
|
||||
|
||||
// Fill the list in from `GET /api/dirs` once the query looks like a path: the
|
||||
// directory it names is listed and its children join the candidates, so `~`
|
||||
// reaches a directory that was never spawned in.
|
||||
const dir = parsed.sigil === "~" ? probeDir(parsed.query) : "";
|
||||
useEffect(() => {
|
||||
if (dir === "") {
|
||||
setProbed([]);
|
||||
return;
|
||||
}
|
||||
const cached = listingsRef.current.get(dir);
|
||||
if (cached) {
|
||||
setProbed(directoryBarCandidates(dirEntryPaths(dir, cached), homeDir));
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
setProbed([]);
|
||||
listDirEntries(dir)
|
||||
.then((entries) => {
|
||||
listingsRef.current.set(dir, entries);
|
||||
if (live) setProbed(directoryBarCandidates(dirEntryPaths(dir, entries), homeDir));
|
||||
})
|
||||
.catch(() => {
|
||||
/* an unreadable directory contributes nothing */
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [dir, listDirEntries, homeDir]);
|
||||
|
||||
/** What the matcher sees for the query line as it stands. With no sigil the
|
||||
* list is mixed — sessions and commands together — except on an empty query,
|
||||
* where the bar is a session switcher. */
|
||||
const candidates: BarCandidate[] = useMemo(() => {
|
||||
if (page) return sessionActionCandidates();
|
||||
switch (parsed.sigil) {
|
||||
case ">":
|
||||
return [...commandCandidates];
|
||||
case "~": {
|
||||
const seen = new Set<string>();
|
||||
return [...directories, ...probed].filter((c) =>
|
||||
seen.has(c.path) ? false : (seen.add(c.path), true),
|
||||
);
|
||||
}
|
||||
default:
|
||||
return parsed.query === ""
|
||||
? [...sessionCandidates]
|
||||
: [...sessionCandidates, ...commandCandidates];
|
||||
}
|
||||
}, [page, parsed.sigil, parsed.query, sessionCandidates, commandCandidates, directories, probed]);
|
||||
|
||||
const groups = useMemo(
|
||||
() => barGroups(barRows(parsed.query, candidates)),
|
||||
[parsed.query, candidates],
|
||||
);
|
||||
// Grouping is a rendering of the ranking, so the flattened groups are the
|
||||
// rows in the order they appear — which is the order the highlight walks and
|
||||
// Enter acts on.
|
||||
const rows: BarResult[] = useMemo(() => groups.flatMap((g) => g.results), [groups]);
|
||||
|
||||
// The highlight starts on the globally best row every time the list changes:
|
||||
// an old index would point at a different result.
|
||||
useEffect(() => {
|
||||
setHighlighted(0);
|
||||
}, [rows]);
|
||||
|
||||
/** The highlight does not wrap: it stops at the first and last row. */
|
||||
const move = useCallback(
|
||||
(delta: number) => {
|
||||
setHighlighted((at) => Math.min(rows.length - 1, Math.max(0, at + delta)));
|
||||
},
|
||||
[rows.length],
|
||||
);
|
||||
|
||||
const runAction = useCallback(
|
||||
(action: SessionAction, session: Session) => {
|
||||
onClose();
|
||||
switch (action.id) {
|
||||
case "kill":
|
||||
(props.onKillSession ?? ((s: Session) => raise(KILL_SESSION_EVENT, s)))(session);
|
||||
return;
|
||||
case "rename":
|
||||
(props.onRenameSession ?? ((s: Session) => raise(RENAME_SESSION_EVENT, s)))(session);
|
||||
return;
|
||||
case "spawn-similar":
|
||||
(props.onSpawnSimilar ?? ((s: Session) => raise(SPAWN_SIMILAR_EVENT, s)))(session);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[onClose, props.onKillSession, props.onRenameSession, props.onSpawnSimilar],
|
||||
);
|
||||
|
||||
const activate = useCallback(
|
||||
(index: number) => {
|
||||
const result = rows[index];
|
||||
if (!result) return;
|
||||
const candidate = result.candidate;
|
||||
if (isActionCandidate(candidate)) {
|
||||
// The page cannot be up without a session to act on.
|
||||
if (page) runAction(candidate.action, page);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
switch (candidate.kind) {
|
||||
case "session":
|
||||
(props.onOpenSession ?? ((id: string) => raise(SESSION_SELECT_EVENT, id)))(
|
||||
candidate.session.id,
|
||||
);
|
||||
return;
|
||||
case "command":
|
||||
candidate.command.run();
|
||||
return;
|
||||
case "directory":
|
||||
(props.onSpawnDirectory ?? ((p: string) => raise(SPAWN_DIRECTORY_EVENT, p)))(
|
||||
candidate.path,
|
||||
);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[rows, page, runAction, onClose, props.onOpenSession, props.onSpawnDirectory],
|
||||
);
|
||||
|
||||
/** `tab`: drill into the highlighted session's action page. No effect on any
|
||||
* other kind, and none on the page itself, which has no session rows. */
|
||||
const drillIn = useCallback(() => {
|
||||
const candidate = rows[highlighted]?.candidate;
|
||||
if (page || candidate?.kind !== "session") return;
|
||||
setPage(candidate.session);
|
||||
setStashed(raw);
|
||||
setRaw("");
|
||||
}, [rows, highlighted, page, raw]);
|
||||
|
||||
/** Escape, or backspace at an empty query: back to the result list with the
|
||||
* query the drill-in was made from. */
|
||||
const leavePage = useCallback(() => {
|
||||
setPage(null);
|
||||
setRaw(stashed);
|
||||
setStashed("");
|
||||
}, [stashed]);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
// On an action page Escape is one level up, not the way out.
|
||||
if (page) leavePage();
|
||||
else onClose();
|
||||
return;
|
||||
case "Tab":
|
||||
e.preventDefault();
|
||||
drillIn();
|
||||
return;
|
||||
case "Backspace":
|
||||
// Ordinary editing, except at an empty query on the action page.
|
||||
if (page && raw === "") {
|
||||
e.preventDefault();
|
||||
leavePage();
|
||||
}
|
||||
return;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
activate(highlighted);
|
||||
return;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
move(1);
|
||||
return;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
move(-1);
|
||||
return;
|
||||
case "n":
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
move(1);
|
||||
}
|
||||
return;
|
||||
case "p":
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
move(-1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const activeRowId = rows.length > 0 ? `cmdbar-row-${highlighted}` : undefined;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="cmdbar-overlay fixed inset-0 z-modal flex items-start justify-center bg-bg/70 pt-[12vh] backdrop-blur-sm max-[600px]:items-end max-[600px]:pt-0"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command bar"
|
||||
onMouseDown={(e) => {
|
||||
// A press on the scrim closes; one inside the panel is left alone.
|
||||
if (!panelRef.current?.contains(e.target as Node)) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="cmdbar-panel flex w-full max-w-[560px] flex-col overflow-hidden rounded-r-3 border border-border bg-surface shadow-2 max-[600px]:max-w-none max-[600px]:rounded-b-none"
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="cmdbar-results"
|
||||
aria-label="Search sessions, commands and directories"
|
||||
aria-activedescendant={activeRowId}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="cmdbar-input w-full border-b border-border bg-transparent px-sp-3 py-sp-2 text-fs-15 text-ink outline-none"
|
||||
placeholder={page ? `Act on ${sessionName(page)}…` : PLACEHOLDERS[parsed.sigil ?? "mixed"]}
|
||||
value={raw}
|
||||
onChange={(e) => setRaw(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{page ? (
|
||||
<ActionPage
|
||||
session={page}
|
||||
groups={groups}
|
||||
highlighted={highlighted}
|
||||
onActivate={activate}
|
||||
/>
|
||||
) : (
|
||||
<ResultList
|
||||
groups={groups}
|
||||
// While a sigil is in force there is one kind, so the header is
|
||||
// dropped.
|
||||
showHeaders={parsed.sigil === null}
|
||||
highlighted={highlighted}
|
||||
onActivate={activate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// The bar's result list: kind headers over rank-ordered rows
|
||||
// (docs/COMMAND-BAR.md, "Structure"). Headers are labels, never rows — the
|
||||
// highlight walks the rows alone, in the order they are rendered.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { keyCaps } from "../../commands.js";
|
||||
import {
|
||||
highlightSegments,
|
||||
isActionCandidate,
|
||||
metaText,
|
||||
rowName,
|
||||
type BarGroup,
|
||||
type BarResult,
|
||||
} from "./model.js";
|
||||
|
||||
export interface ResultListProps {
|
||||
groups: BarGroup[];
|
||||
/** Dropped while a sigil is in force or on the action page: one kind needs
|
||||
* no header. */
|
||||
showHeaders: boolean;
|
||||
/** Index into the flattened rows, in the order they render. */
|
||||
highlighted: number;
|
||||
onActivate(index: number): void;
|
||||
}
|
||||
|
||||
function Highlighted({ text, ranges }: { text: string; ranges: BarResult["ranges"] }) {
|
||||
return (
|
||||
<>
|
||||
{highlightSegments(text, ranges).map((segment, i) =>
|
||||
segment.hit ? (
|
||||
<mark key={i} className="cmdbar-hit bg-transparent font-semibold text-accent">
|
||||
{segment.text}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i}>{segment.text}</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
result,
|
||||
index,
|
||||
active,
|
||||
onActivate,
|
||||
}: {
|
||||
result: BarResult;
|
||||
index: number;
|
||||
active: boolean;
|
||||
onActivate(index: number): void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const candidate = result.candidate;
|
||||
|
||||
useEffect(() => {
|
||||
if (active) ref.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [active]);
|
||||
|
||||
const name = rowName(candidate);
|
||||
const nameMatched =
|
||||
result.field === "name" || result.field === "label" || result.field === "path";
|
||||
const meta = metaText(result);
|
||||
const dead = candidate.kind === "session" && candidate.session.status === "Dead";
|
||||
const chord =
|
||||
candidate.kind === "command" && !isActionCandidate(candidate)
|
||||
? candidate.command.keys[0]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="option"
|
||||
id={`cmdbar-row-${index}`}
|
||||
aria-selected={active}
|
||||
data-index={index}
|
||||
data-kind={candidate.kind}
|
||||
data-session-id={candidate.kind === "session" ? candidate.session.id : undefined}
|
||||
data-action-id={isActionCandidate(candidate) ? candidate.action.id : undefined}
|
||||
data-command-id={
|
||||
candidate.kind === "command" && !isActionCandidate(candidate)
|
||||
? candidate.command.id
|
||||
: undefined
|
||||
}
|
||||
data-path={candidate.kind === "directory" ? candidate.path : undefined}
|
||||
className={[
|
||||
"cmdbar-row flex cursor-pointer items-baseline gap-sp-2 rounded-r-1 px-sp-3 py-sp-1 text-fs-13",
|
||||
active ? "is-active bg-surface-2 text-ink" : "text-ink",
|
||||
dead ? "is-dead opacity-60" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onClick={() => onActivate(index)}
|
||||
>
|
||||
<span className="cmdbar-row-name truncate">
|
||||
{nameMatched ? <Highlighted text={name} ranges={result.ranges} /> : name}
|
||||
</span>
|
||||
{chord !== undefined ? (
|
||||
<span className="cmdbar-row-keys ml-auto flex gap-1">
|
||||
{keyCaps(chord).map((cap, i) => (
|
||||
<kbd
|
||||
key={i}
|
||||
className="rounded-r-1 border border-border px-1 text-fs-11 text-muted"
|
||||
>
|
||||
{cap}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
) : meta ? (
|
||||
<span className="cmdbar-row-meta ml-auto truncate text-fs-11 text-muted">
|
||||
{result.field !== undefined && result.field !== "name" ? (
|
||||
<Highlighted text={meta} ranges={result.ranges} />
|
||||
) : (
|
||||
meta
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResultList({ groups, showHeaders, highlighted, onActivate }: ResultListProps) {
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div
|
||||
id="cmdbar-results"
|
||||
role="listbox"
|
||||
aria-label="Results"
|
||||
className="cmdbar-results max-h-[50vh] overflow-auto p-sp-1"
|
||||
>
|
||||
<div className="cmdbar-empty px-sp-3 py-sp-2 text-fs-12 text-muted">No matches</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
return (
|
||||
<div
|
||||
id="cmdbar-results"
|
||||
role="listbox"
|
||||
aria-label="Results"
|
||||
className="cmdbar-results max-h-[50vh] overflow-auto p-sp-1"
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<div key={group.kind}>
|
||||
{showHeaders ? (
|
||||
<div
|
||||
role="presentation"
|
||||
className="cmdbar-group-title px-sp-3 pt-sp-2 pb-1 text-fs-11 uppercase tracking-wide text-muted"
|
||||
>
|
||||
{group.title}
|
||||
</div>
|
||||
) : null}
|
||||
{group.results.map((result) => {
|
||||
const at = index++;
|
||||
return (
|
||||
<Row
|
||||
key={at}
|
||||
result={result}
|
||||
index={at}
|
||||
active={at === highlighted}
|
||||
onActivate={onActivate}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { CommandBar, SPAWN_DIRECTORY_EVENT, type CommandBarProps } from "./CommandBar.js";
|
||||
export { ResultList, type ResultListProps } from "./ResultList.js";
|
||||
export { ActionPage, type ActionPageProps } from "./ActionPage.js";
|
||||
export { useCommandBar, type CommandBarControls, type UseCommandBarOptions } from "./useCommandBar.js";
|
||||
export * from "./model.js";
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Command } from "../../commands.js";
|
||||
import type { Session } from "../../types.js";
|
||||
import {
|
||||
barGroups,
|
||||
barRows,
|
||||
commandBarCandidates,
|
||||
dedupeCommands,
|
||||
directoryBarCandidates,
|
||||
directoryCandidate,
|
||||
highlightSegments,
|
||||
orderSessionsForBar,
|
||||
parseBarQuery,
|
||||
sessionActionCandidates,
|
||||
sessionBarCandidates,
|
||||
sessionCandidate,
|
||||
} from "./model.js";
|
||||
|
||||
function session(id: string, over: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id,
|
||||
cwd: "/home/me/dev/api",
|
||||
command: "bash",
|
||||
env: {},
|
||||
metadata: { name: id },
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function command(over: Partial<Command> = {}): Command {
|
||||
return {
|
||||
id: "a",
|
||||
label: "A command",
|
||||
group: "Test",
|
||||
keys: ["a"],
|
||||
scope: "global",
|
||||
run: () => {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("empty-query ordering", () => {
|
||||
it("sorts most recently focused first", () => {
|
||||
const older = session("older", { last_focused_at: "2026-01-01T10:00:00Z" });
|
||||
const newer = session("newer", { last_focused_at: "2026-01-01T11:00:00Z" });
|
||||
expect(orderSessionsForBar([older, newer]).map((s) => s.id)).toEqual(["newer", "older"]);
|
||||
});
|
||||
|
||||
it("sorts a never-focused session after every focused one, newest created first", () => {
|
||||
const focused = session("focused", { last_focused_at: "2026-01-01T09:00:00Z" });
|
||||
const old = session("old", { created_at: "2026-01-01T00:00:00Z" });
|
||||
const fresh = session("fresh", { created_at: "2026-01-02T00:00:00Z" });
|
||||
expect(orderSessionsForBar([old, fresh, focused]).map((s) => s.id)).toEqual([
|
||||
"focused",
|
||||
"fresh",
|
||||
"old",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts dead sessions after live ones regardless of recency", () => {
|
||||
const dead = session("dead", { status: "Dead", last_focused_at: "2026-01-01T12:00:00Z" });
|
||||
const live = session("live", { last_focused_at: "2026-01-01T10:00:00Z" });
|
||||
expect(orderSessionsForBar([dead, live]).map((s) => s.id)).toEqual(["live", "dead"]);
|
||||
});
|
||||
|
||||
it("treats an empty last_focused_at as never focused", () => {
|
||||
const blank = session("blank", { last_focused_at: "" });
|
||||
const focused = session("focused", { last_focused_at: "2026-01-01T10:00:00Z" });
|
||||
expect(orderSessionsForBar([blank, focused]).map((s) => s.id)).toEqual(["focused", "blank"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session candidates", () => {
|
||||
it("carries the candidate table's fields, with the directory abbreviated", () => {
|
||||
const s = session("api", {
|
||||
metadata: { name: "api" },
|
||||
current_cwd: "/home/me/dev/api",
|
||||
command: "claude --resume",
|
||||
});
|
||||
expect(sessionCandidate(s, "/home/me").fields).toEqual({
|
||||
name: "api",
|
||||
directory: "~/dev/api",
|
||||
command: "claude --resume",
|
||||
tool: "claude",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the tool out when the program has no mark, and groups a worktree by its repo", () => {
|
||||
const s = session("edit", {
|
||||
metadata: {},
|
||||
command: "hx src/",
|
||||
worktree: { path: "/home/me/dev/api/wt/x", base_repo: "/home/me/dev/api" },
|
||||
});
|
||||
const candidate = sessionCandidate(s, "/home/me");
|
||||
expect(candidate.fields.tool).toBeUndefined();
|
||||
expect(candidate.fields.directory).toBe("~/dev/api");
|
||||
// No `metadata.name`: the sidebar's fallback is the command line.
|
||||
expect(candidate.fields.name).toBe("hx src/");
|
||||
});
|
||||
|
||||
it("builds candidates in the empty-query order", () => {
|
||||
const a = session("a", { last_focused_at: "2026-01-01T10:00:00Z" });
|
||||
const b = session("b", { last_focused_at: "2026-01-01T11:00:00Z" });
|
||||
expect(sessionBarCandidates([a, b]).map((c) => c.session.id)).toEqual(["b", "a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rows and grouping", () => {
|
||||
it("an empty query keeps every candidate in input order", () => {
|
||||
const candidates = sessionBarCandidates([session("a"), session("b")]);
|
||||
expect(barRows("", candidates).map((r) => r.score)).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it("groups by kind without reordering the ranking", () => {
|
||||
const rows = barRows("kill", [
|
||||
...sessionBarCandidates([session("killer-app", { metadata: { name: "killer-app" } })]),
|
||||
...commandBarCandidates([command({ label: "Kill selected session" })]),
|
||||
]);
|
||||
const groups = barGroups(rows);
|
||||
expect(groups.map((g) => g.title)).toEqual(["Sessions", "Commands"]);
|
||||
expect(groups.flatMap((g) => g.results)).toEqual(rows);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsing the query line", () => {
|
||||
it("reads a sigil only in first position", () => {
|
||||
expect(parseBarQuery(">help")).toEqual({ sigil: ">", query: "help" });
|
||||
expect(parseBarQuery("~dev")).toEqual({ sigil: "~", query: "dev" });
|
||||
});
|
||||
|
||||
it("leaves a sigil character elsewhere in the query literal", () => {
|
||||
expect(parseBarQuery("~dev/~scratch")).toEqual({ sigil: "~", query: "dev/~scratch" });
|
||||
expect(parseBarQuery("a>b")).toEqual({ sigil: null, query: "a>b" });
|
||||
});
|
||||
|
||||
it("returns to the mixed list when the sigil is backspaced away", () => {
|
||||
expect(parseBarQuery("")).toEqual({ sigil: null, query: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("command candidates", () => {
|
||||
it("dedupes by action, not by chord", () => {
|
||||
const run = () => {};
|
||||
const cmds = [
|
||||
command({ id: "one", label: "Do it", keys: ["j"], run }),
|
||||
command({ id: "two", label: "Do it", keys: ["down"], run }),
|
||||
];
|
||||
expect(commandBarCandidates(cmds)).toHaveLength(1);
|
||||
expect(dedupeCommands(cmds)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("matches on the label only, never on the chord", () => {
|
||||
const candidates = commandBarCandidates([command({ label: "Show help", keys: ["ctrl-q"] })]);
|
||||
expect(barRows("ctrlq", candidates)).toEqual([]);
|
||||
expect(barRows("show", candidates)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("directory candidates", () => {
|
||||
it("abbreviates the path it shows and keeps the one it spawns in", () => {
|
||||
const candidate = directoryCandidate("/home/me/dev/api", "/home/me");
|
||||
expect(candidate.fields.path).toBe("~/dev/api");
|
||||
expect(candidate.path).toBe("/home/me/dev/api");
|
||||
});
|
||||
|
||||
it("expands a typed `~` and dedupes by the directory spawned in", () => {
|
||||
const candidates = directoryBarCandidates(
|
||||
["~/dev/api", "/home/me/dev/api", "", "/srv/x"],
|
||||
"/home/me",
|
||||
);
|
||||
expect(candidates.map((c) => c.path)).toEqual(["/home/me/dev/api", "/srv/x"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the action page's candidates", () => {
|
||||
it("offers no action beyond the three", () => {
|
||||
expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([
|
||||
"kill",
|
||||
"rename",
|
||||
"spawn-similar",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("highlight segments", () => {
|
||||
it("splits the text on the matcher's ranges", () => {
|
||||
expect(highlightSegments("web-search", [[0, 1], [4, 5]])).toEqual([
|
||||
{ text: "w", hit: true },
|
||||
{ text: "eb-", hit: false },
|
||||
{ text: "s", hit: true },
|
||||
{ text: "earch", hit: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
// The command bar's pure model (docs/COMMAND-BAR.md), lifted from
|
||||
// ../../commandBar.ts for the React port (#247): what a candidate is, the
|
||||
// order an empty query lists them in, how a query line splits into a sigil and
|
||||
// a query, and how a ranked list groups for rendering.
|
||||
//
|
||||
// Nothing here touches the DOM or the network apart from `loadBarDirectories`,
|
||||
// which is the one place the `~` list comes from.
|
||||
|
||||
import { listDir, projects as fetchProjects, recentDirs } from "../../api.js";
|
||||
import type { Command } from "../../commands.js";
|
||||
import { splitPath } from "../../paths.js";
|
||||
import { splitCommand } from "../../toolIcon.js";
|
||||
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "../../types.js";
|
||||
import {
|
||||
matchAndRank,
|
||||
type CandidateKind,
|
||||
type CommandCandidate,
|
||||
type DirectoryCandidate,
|
||||
type MatchRange,
|
||||
type MatchResult,
|
||||
type SessionCandidate,
|
||||
} from "../../utils/matcher.js";
|
||||
|
||||
/** A matcher candidate that remembers what it came from, so a result can be
|
||||
* acted on without a second lookup. The extra property is invisible to the
|
||||
* matcher, which only ever reads `kind` and `fields`. */
|
||||
export type SessionBarCandidate = SessionCandidate & { session: Session };
|
||||
export type CommandBarCandidate = CommandCandidate & { command: Command };
|
||||
/** `fields.path` is the abbreviated path the operator reads and the matcher
|
||||
* ranks; `path` is the one a spawn is made in. */
|
||||
export type DirectoryBarCandidate = DirectoryCandidate & { path: string };
|
||||
|
||||
/** One row of a session's action page. It is a `command` to the matcher — a
|
||||
* single `label` field, ranked exactly like a registry command — and carries
|
||||
* the action it stands for instead of a registry entry. */
|
||||
export type ActionBarCandidate = CommandCandidate & { action: SessionAction };
|
||||
|
||||
export type BarCandidate =
|
||||
| SessionBarCandidate
|
||||
| CommandBarCandidate
|
||||
| ActionBarCandidate
|
||||
| DirectoryBarCandidate;
|
||||
export type BarResult = MatchResult<BarCandidate>;
|
||||
|
||||
/** One kind's slice of the ranked list, in rank order. */
|
||||
export interface BarGroup<C extends BarCandidate = BarCandidate> {
|
||||
kind: CandidateKind;
|
||||
title: string;
|
||||
results: MatchResult<C>[];
|
||||
}
|
||||
|
||||
const KIND_TITLES: Record<CandidateKind, string> = {
|
||||
session: "Sessions",
|
||||
command: "Commands",
|
||||
directory: "Directories",
|
||||
};
|
||||
|
||||
// ---- the sigils ----
|
||||
|
||||
export type BarSigil = ">" | "~";
|
||||
|
||||
export interface BarQuery {
|
||||
/** The sigil in force, or null for the mixed list. */
|
||||
sigil: BarSigil | null;
|
||||
/** What the matcher sees: everything after a sigil, or the whole query. */
|
||||
query: string;
|
||||
}
|
||||
|
||||
export const PLACEHOLDERS: Record<"mixed" | BarSigil, string> = {
|
||||
mixed: "Go to a session…",
|
||||
">": "Run a command…",
|
||||
"~": "Spawn in a directory…",
|
||||
};
|
||||
|
||||
/** Split a raw query line into its sigil and the query proper.
|
||||
*
|
||||
* A sigil is only a sigil in first position: `>` or `~` anywhere else is a
|
||||
* literal query character, which is what a directory query with a second `~`
|
||||
* in it depends on. Backspacing past a sigil needs no special case either —
|
||||
* the first character goes and the query is a mixed one again. */
|
||||
export function parseBarQuery(raw: string): BarQuery {
|
||||
const first = raw[0];
|
||||
if (first === ">" || first === "~") return { sigil: first, query: raw.slice(1) };
|
||||
return { sigil: null, query: raw };
|
||||
}
|
||||
|
||||
// ---- the session action page ----
|
||||
|
||||
/** The actions a session's action page offers, and no others: the bar picks
|
||||
* one and hands off to the flow that already implements it
|
||||
* (docs/COMMAND-BAR.md, "Drill-in"). */
|
||||
export type SessionActionId = "kill" | "rename" | "spawn-similar";
|
||||
|
||||
export interface SessionAction {
|
||||
id: SessionActionId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const SESSION_ACTIONS: readonly SessionAction[] = [
|
||||
{ id: "kill", label: "Kill session" },
|
||||
{ id: "rename", label: "Rename session" },
|
||||
{ id: "spawn-similar", label: "Spawn similar" },
|
||||
];
|
||||
|
||||
/** The action page's candidates, in the order above — which is the order an
|
||||
* empty query lists them in, since the matcher keeps input order. */
|
||||
export function sessionActionCandidates(): ActionBarCandidate[] {
|
||||
return SESSION_ACTIONS.map((action) => ({
|
||||
kind: "command",
|
||||
action,
|
||||
fields: { label: action.label },
|
||||
}));
|
||||
}
|
||||
|
||||
/** Narrow a `command` candidate to an action row. Both share the matcher's
|
||||
* `command` kind, so what separates them is what they carry. */
|
||||
export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate {
|
||||
return candidate.kind === "command" && "action" in candidate;
|
||||
}
|
||||
|
||||
// ---- candidates ----
|
||||
|
||||
function focusedAt(s: Session): number | null {
|
||||
const stamp = s.last_focused_at;
|
||||
if (typeof stamp !== "string" || stamp === "") return null;
|
||||
const ms = Date.parse(stamp);
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
|
||||
function createdAt(s: Session): number {
|
||||
const ms = Date.parse(s.created_at ?? "");
|
||||
return Number.isNaN(ms) ? 0 : ms;
|
||||
}
|
||||
|
||||
/** The empty-query order (docs/COMMAND-BAR.md, "Empty query"): most recently
|
||||
* focused first; a session that has never been focused sorts after every
|
||||
* session that has, most recently created first among those; and a dead
|
||||
* session sorts after every live one regardless of recency, because a session
|
||||
* you cannot attach to is not where you just were.
|
||||
*
|
||||
* This is also the input order for a non-empty query, which is what the
|
||||
* matcher's last tie-break preserves. */
|
||||
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
|
||||
return [...sessions].sort((a, b) => {
|
||||
const deadA = a.status === "Dead" ? 1 : 0;
|
||||
const deadB = b.status === "Dead" ? 1 : 0;
|
||||
if (deadA !== deadB) return deadA - deadB;
|
||||
|
||||
const focusA = focusedAt(a);
|
||||
const focusB = focusedAt(b);
|
||||
if ((focusA === null) !== (focusB === null)) return focusA === null ? 1 : -1;
|
||||
if (focusA !== null && focusB !== null && focusA !== focusB) return focusB - focusA;
|
||||
|
||||
return createdAt(b) - createdAt(a);
|
||||
});
|
||||
}
|
||||
|
||||
/** Build one session's candidate: the fields docs/COMMAND-BAR.md's candidate
|
||||
* table names, with the directory abbreviated the way the sidebar shows it. */
|
||||
export function sessionCandidate(s: Session, homeDir?: string): SessionBarCandidate {
|
||||
const treePath = sessionTreePath(s);
|
||||
const directory = treePath ? abbreviateCwd(treePath, homeDir) : undefined;
|
||||
const tool = splitCommand(s.command).tool ?? undefined;
|
||||
return {
|
||||
kind: "session",
|
||||
session: s,
|
||||
fields: {
|
||||
name: sessionName(s),
|
||||
...(directory ? { directory } : {}),
|
||||
...(s.command ? { command: s.command } : {}),
|
||||
...(tool ? { tool } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Every session as a candidate, in the empty-query order. */
|
||||
export function sessionBarCandidates(
|
||||
sessions: readonly Session[],
|
||||
homeDir?: string,
|
||||
): SessionBarCandidate[] {
|
||||
return orderSessionsForBar(sessions).map((s) => sessionCandidate(s, homeDir));
|
||||
}
|
||||
|
||||
/** Drop the commands that would list twice. Several chords can be bound to one
|
||||
* action — the registry already carries its aliases on one entry, but nothing
|
||||
* stops two entries running the same handler, and listed naively that is two
|
||||
* identical rows. The dedupe is by **action**, never by chord: the chord is
|
||||
* displayed, so a command with two of them is still one row. */
|
||||
export function dedupeCommands(commands: readonly Command[]): Command[] {
|
||||
const seenRun = new Set<Command["run"]>();
|
||||
const seenLabel = new Set<string>();
|
||||
const kept: Command[] = [];
|
||||
for (const command of commands) {
|
||||
if (seenRun.has(command.run) || seenLabel.has(command.label)) continue;
|
||||
seenRun.add(command.run);
|
||||
seenLabel.add(command.label);
|
||||
kept.push(command);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** Every bound command as a candidate, in registry order. Only the label is a
|
||||
* field: the chord is displayed beside the row, never matched, because
|
||||
* matching it would make `>` results depend on a user's rebinds. */
|
||||
export function commandBarCandidates(commands: readonly Command[]): CommandBarCandidate[] {
|
||||
return dedupeCommands(commands).map((command) => ({
|
||||
kind: "command",
|
||||
command,
|
||||
fields: { label: command.label },
|
||||
}));
|
||||
}
|
||||
|
||||
/** `~/dev/api` back to `/home/me/dev/api` — the inverse of `abbreviateCwd`, for
|
||||
* a path the operator typed or `/api/dirs` completed. */
|
||||
export function expandHome(path: string, homeDir?: string): string {
|
||||
if (!homeDir) return path;
|
||||
if (path === "~") return homeDir;
|
||||
return path.startsWith("~/") ? `${homeDir}${path.slice(1)}` : path;
|
||||
}
|
||||
|
||||
export function directoryCandidate(path: string, homeDir?: string): DirectoryBarCandidate {
|
||||
const absolute = expandHome(path, homeDir);
|
||||
return { kind: "directory", path: absolute, fields: { path: abbreviateCwd(absolute, homeDir) } };
|
||||
}
|
||||
|
||||
/** Candidates for `~`, deduped by the directory they spawn in and keeping the
|
||||
* first mention of each — recent dirs (newest first) ahead of the project
|
||||
* list, which is the empty-query order the contract asks for. */
|
||||
export function directoryBarCandidates(
|
||||
paths: readonly string[],
|
||||
homeDir?: string,
|
||||
): DirectoryBarCandidate[] {
|
||||
const seen = new Set<string>();
|
||||
const out: DirectoryBarCandidate[] = [];
|
||||
for (const path of paths) {
|
||||
if (path === "") continue;
|
||||
const candidate = directoryCandidate(path, homeDir);
|
||||
if (seen.has(candidate.path)) continue;
|
||||
seen.add(candidate.path);
|
||||
out.push(candidate);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The directory list the bar starts from: what the spawn form already knows —
|
||||
* `GET /api/recent-dirs` and the project list — either alone if the other
|
||||
* fails. */
|
||||
export async function loadBarDirectories(): Promise<string[]> {
|
||||
const [recents, discovered] = await Promise.all([
|
||||
recentDirs()
|
||||
.then((r) => r.dirs.map((d) => d.cwd))
|
||||
.catch(() => [] as string[]),
|
||||
fetchProjects()
|
||||
.then((list) => list.map((p) => p.path))
|
||||
.catch(() => [] as string[]),
|
||||
]);
|
||||
return [...recents, ...discovered];
|
||||
}
|
||||
|
||||
/** One directory's children as candidate paths, for filling the `~` list in as
|
||||
* a path is typed. The directory being listed comes from the query itself. */
|
||||
export function dirEntryPaths(dir: string, entries: readonly string[]): string[] {
|
||||
return entries.map((entry) => `${dir}${entry}`);
|
||||
}
|
||||
|
||||
/** The directory a `~` query names, or `""` when it names none yet. */
|
||||
export function probeDir(query: string): string {
|
||||
return splitPath(query).dir;
|
||||
}
|
||||
|
||||
/** `GET /api/dirs`, in the shape the bar wants it. */
|
||||
export function listDirEntries(dir: string): Promise<string[]> {
|
||||
return listDir(dir).then((listing) => listing.entries);
|
||||
}
|
||||
|
||||
// ---- grouping ----
|
||||
|
||||
/** Bucket a ranked list by kind for rendering. Grouping is a rendering of the
|
||||
* ranking, never a reordering of it: a group holds its matches in rank order,
|
||||
* and groups appear in the order their best-scoring member does — so the first
|
||||
* row under the first header is the globally best result. */
|
||||
export function barGroups<C extends BarCandidate>(
|
||||
results: readonly MatchResult<C>[],
|
||||
): BarGroup<C>[] {
|
||||
const groups: BarGroup<C>[] = [];
|
||||
for (const result of results) {
|
||||
const kind = result.candidate.kind;
|
||||
let group = groups.find((g) => g.kind === kind);
|
||||
if (!group) {
|
||||
group = { kind, title: KIND_TITLES[kind], results: [] };
|
||||
groups.push(group);
|
||||
}
|
||||
group.results.push(result);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** The rows a query produces, flat and in rank order — what the highlight walks
|
||||
* and what Enter acts on. Headers are labels, not rows, so they are never in
|
||||
* this list. */
|
||||
export function barRows<C extends BarCandidate>(
|
||||
query: string,
|
||||
candidates: readonly C[],
|
||||
): MatchResult<C>[] {
|
||||
return matchAndRank(query, candidates);
|
||||
}
|
||||
|
||||
// ---- row text ----
|
||||
|
||||
/** The name line of a row: the session's name, the command's label, or the
|
||||
* directory's path. */
|
||||
export function rowName(candidate: BarCandidate): string {
|
||||
switch (candidate.kind) {
|
||||
case "session":
|
||||
return candidate.fields.name;
|
||||
case "command":
|
||||
return candidate.fields.label;
|
||||
case "directory":
|
||||
return candidate.fields.path;
|
||||
}
|
||||
}
|
||||
|
||||
/** The secondary line under a session row: the field the match was found in
|
||||
* when that was not the name, so a session found through its directory says
|
||||
* so. Only sessions have one — a command shows keycaps instead, and a
|
||||
* directory's path is already the name. */
|
||||
export function metaText(result: BarResult): string | undefined {
|
||||
if (result.candidate.kind !== "session") return undefined;
|
||||
const fields = result.candidate.fields;
|
||||
switch (result.field) {
|
||||
case "directory":
|
||||
return fields.directory;
|
||||
case "command":
|
||||
return fields.command;
|
||||
case "tool":
|
||||
return fields.tool;
|
||||
default:
|
||||
return fields.directory;
|
||||
}
|
||||
}
|
||||
|
||||
/** One run of a row's text, flagged when the matcher matched inside it. The
|
||||
* React rendering of `renderHighlighted`: ranges come from the matcher as
|
||||
* `[start, end)` character pairs over the field that won. */
|
||||
export interface HighlightSegment {
|
||||
text: string;
|
||||
hit: boolean;
|
||||
}
|
||||
|
||||
export function highlightSegments(
|
||||
text: string,
|
||||
ranges: readonly MatchRange[],
|
||||
): HighlightSegment[] {
|
||||
const out: HighlightSegment[] = [];
|
||||
let cursor = 0;
|
||||
for (const [start, end] of ranges) {
|
||||
if (start > cursor) out.push({ text: text.slice(cursor, start), hit: false });
|
||||
out.push({ text: text.slice(start, end), hit: true });
|
||||
cursor = end;
|
||||
}
|
||||
if (cursor < text.length) out.push({ text: text.slice(cursor), hit: false });
|
||||
return out;
|
||||
}
|
||||
|
||||
export { sessionName };
|
||||
export type { CandidateKind, MatchRange, MatchResult };
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { OPEN_COMMAND_BAR_EVENT } from "../../catalog.js";
|
||||
import { useCommandBar, type UseCommandBarOptions } from "./useCommandBar.js";
|
||||
|
||||
let controls: ReturnType<typeof useCommandBar>;
|
||||
|
||||
function Probe(opts: UseCommandBarOptions) {
|
||||
controls = useCommandBar(opts);
|
||||
return <div>{controls.open ? "open" : "closed"}</div>;
|
||||
}
|
||||
|
||||
function ctrlK(target: EventTarget = window): void {
|
||||
act(() => {
|
||||
target.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("useCommandBar", () => {
|
||||
it("opens on ctrl-k, from the capture phase so an attached xterm never sees it", () => {
|
||||
const view = render(<Probe />);
|
||||
ctrlK();
|
||||
expect(view.container.textContent).toBe("open");
|
||||
});
|
||||
|
||||
it("stays deaf while the bar is already up — its own input owns the keys", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(<Probe onOpen={onOpen} />);
|
||||
ctrlK();
|
||||
ctrlK();
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves a form field's own Ctrl-K alone", () => {
|
||||
const field = document.createElement("input");
|
||||
document.body.appendChild(field);
|
||||
const view = render(<Probe />);
|
||||
ctrlK(field);
|
||||
expect(view.container.textContent).toBe("closed");
|
||||
field.remove();
|
||||
});
|
||||
|
||||
it("opens from the sidebar's search field, the touch entry point", () => {
|
||||
const view = render(<Probe />);
|
||||
act(() => {
|
||||
document.dispatchEvent(new CustomEvent(OPEN_COMMAND_BAR_EVENT));
|
||||
});
|
||||
expect(view.container.textContent).toBe("open");
|
||||
});
|
||||
|
||||
it("runs the caller's hook before opening, so a preview is discarded first", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(<Probe onOpen={onOpen} />);
|
||||
ctrlK();
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closes on demand", () => {
|
||||
const view = render(<Probe />);
|
||||
ctrlK();
|
||||
act(() => controls.closeBar());
|
||||
expect(view.container.textContent).toBe("closed");
|
||||
});
|
||||
|
||||
it("binds nothing while disabled — another overlay owns the keyboard", () => {
|
||||
const view = render(<Probe enabled={false} />);
|
||||
ctrlK();
|
||||
expect(view.container.textContent).toBe("closed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// Opening and closing the bar (docs/COMMAND-BAR.md, "Opening"). `ctrl-k` is a
|
||||
// registry command like any other, so the `?` overlay lists it without a
|
||||
// second list; the touch entry point — the sidebar's search field — raises
|
||||
// `OPEN_COMMAND_BAR_EVENT` and lands on the same open.
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { OPEN_COMMAND_BAR_EVENT } from "../../catalog.js";
|
||||
import { CommandSet, defineCommands, isBarTypingTarget } from "../../commands.js";
|
||||
|
||||
export interface UseCommandBarOptions {
|
||||
/** Run before the bar opens — the shell discards a preview here, since the
|
||||
* bar is not previewed into (docs/PREVIEW.md). */
|
||||
onOpen?(): void;
|
||||
/** Off while another overlay owns the keyboard (the restart overlay, the
|
||||
* help dialog). Defaults to on. */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CommandBarControls {
|
||||
open: boolean;
|
||||
openBar(): void;
|
||||
closeBar(): void;
|
||||
}
|
||||
|
||||
export function useCommandBar(opts: UseCommandBarOptions = {}): CommandBarControls {
|
||||
const { onOpen, enabled = true } = opts;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const openBar = useCallback(() => {
|
||||
onOpen?.();
|
||||
setOpen(true);
|
||||
}, [onOpen]);
|
||||
|
||||
const closeBar = useCallback(() => setOpen(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const keys = new CommandSet(defineCommands({ "bar.open": openBar }));
|
||||
|
||||
// Capture phase: an attached xterm swallows every keystroke it is given, so
|
||||
// the bar's binding has to be seen ahead of the terminal.
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
// The bar owns the keyboard while it is up; its own input handles the keys.
|
||||
if (open) return;
|
||||
// The pane's hidden textarea is not a typing target for the bar: ctrl-k
|
||||
// is seen ahead of xterm and opens the bar. A real form field — the spawn
|
||||
// form's inputs — keeps its own Ctrl+K.
|
||||
if (isBarTypingTarget(e.target)) return;
|
||||
keys.handle(e);
|
||||
};
|
||||
const onTouchEntry = () => {
|
||||
if (!open) openBar();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeydown, true);
|
||||
document.addEventListener(OPEN_COMMAND_BAR_EVENT, onTouchEntry);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeydown, true);
|
||||
document.removeEventListener(OPEN_COMMAND_BAR_EVENT, onTouchEntry);
|
||||
};
|
||||
}, [enabled, open, openBar]);
|
||||
|
||||
return { open, openBar, closeBar };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// 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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,34 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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";
|
||||
@@ -0,0 +1,62 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// 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)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// @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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// 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>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
// 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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,853 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// @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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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";
|
||||
@@ -0,0 +1,114 @@
|
||||
// @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" }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// 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]);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ApprovalView } from "./ApprovalView.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const ALL_OPTIONS = [
|
||||
{ optionId: "a1", name: "Allow once", kind: "allow_once" as const },
|
||||
{ optionId: "a2", name: "Allow always", kind: "allow_always" as const },
|
||||
{ optionId: "r1", name: "Reject", kind: "reject_once" as const },
|
||||
];
|
||||
|
||||
function buttons(): string[] {
|
||||
return [...document.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent ?? "");
|
||||
}
|
||||
|
||||
describe("ApprovalView", () => {
|
||||
it("asks the operator with the three decisions the daemon exposes", () => {
|
||||
render(
|
||||
<ApprovalView
|
||||
request={{ toolCall: { title: "Write(a.ts)", locations: [{ path: "/repo/a.ts" }] }, options: ALL_OPTIONS }}
|
||||
pending
|
||||
onDecide={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
expect(buttons()).toEqual(["Allow once", "Allow for session", "Deny"]);
|
||||
expect(screen.getByText("Write(a.ts)")).toBeTruthy();
|
||||
expect(screen.getByText("/repo/a.ts")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("submits the decision the operator picked", async () => {
|
||||
const onDecide = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ApprovalView request={{ options: ALL_OPTIONS }} pending onDecide={onDecide} />);
|
||||
fireEvent.click(screen.getByText("Deny"));
|
||||
await waitFor(() => expect(onDecide).toHaveBeenCalledWith("deny"));
|
||||
});
|
||||
|
||||
it("disables every button once one is pressed, so a decision is sent once", () => {
|
||||
render(<ApprovalView request={{ options: ALL_OPTIONS }} pending onDecide={() => new Promise(() => {})} />);
|
||||
fireEvent.click(screen.getByText("Allow once"));
|
||||
const all = [...document.querySelectorAll<HTMLButtonElement>(".st-approval-acts button")];
|
||||
expect(all.every((b) => b.disabled)).toBe(true);
|
||||
});
|
||||
|
||||
it("restores the controls when the decision failed and this approval is still pending", async () => {
|
||||
const onDecide = vi.fn().mockRejectedValue(new Error("nope"));
|
||||
render(<ApprovalView request={{ options: ALL_OPTIONS }} pending onDecide={onDecide} />);
|
||||
fireEvent.click(screen.getByText("Allow once"));
|
||||
await waitFor(() => {
|
||||
const all = [...document.querySelectorAll<HTMLButtonElement>(".st-approval-acts button")];
|
||||
expect(all.every((b) => !b.disabled)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("offers no button for a decision the request cannot back", () => {
|
||||
render(
|
||||
<ApprovalView
|
||||
request={{ options: [{ optionId: "a1", name: "Allow once", kind: "allow_once" }] }}
|
||||
pending
|
||||
onDecide={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
expect(buttons()).toEqual(["Allow once"]);
|
||||
});
|
||||
|
||||
it("says so when the request offers no decision at all", () => {
|
||||
render(<ApprovalView request={{ options: [] }} pending onDecide={() => Promise.resolve()} />);
|
||||
expect(screen.getByText("no decision offered")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a resolved approval as a one-liner, with no controls", () => {
|
||||
render(
|
||||
<ApprovalView
|
||||
request={{ toolCall: { title: "Write(a.ts)" }, options: ALL_OPTIONS }}
|
||||
pending={false}
|
||||
onDecide={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Write(a.ts) — approved")).toBeTruthy();
|
||||
expect(document.querySelector(".st-approval-acts")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// An `Approval` row. The daemon exposes exactly three decisions
|
||||
// (docs/STRUCTURED-VIEW.md) and mints the nonce; this only solicits one of
|
||||
// them. A decision the request offers no matching ACP option for is not shown
|
||||
// — its button would "allow" something that silently resolves `Cancelled`.
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { AcpPermissionOption, AcpToolCallLike, StructuredApprovalDecision } from "../../types.js";
|
||||
import { offeredDecisions, toolKindLabel } from "./model.js";
|
||||
|
||||
const SPECS: [StructuredApprovalDecision, string, string][] = [
|
||||
["allow_once", "Allow once", "primary"],
|
||||
["allow_for_session", "Allow for session", ""],
|
||||
["deny", "Deny", "deny"],
|
||||
];
|
||||
|
||||
export interface ApprovalViewProps {
|
||||
/** The `RequestPermissionRequest` the row carries — serialized without its
|
||||
* nonce, which never leaves the daemon's live wire. */
|
||||
request: { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
|
||||
/** Whether this is the one live approval. A restored/answered row is not. */
|
||||
pending: boolean;
|
||||
/** Resolves once the decision reached the daemon; rejects only while this
|
||||
* approval is still the pending one, so the buttons can come back. */
|
||||
onDecide: (decision: StructuredApprovalDecision) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ApprovalView({ request, pending, onDecide }: ApprovalViewProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toolCall = request.toolCall ?? {};
|
||||
const what = toolCall.title ?? toolKindLabel(toolCall.kind);
|
||||
|
||||
// Resolved approvals render as a compact one-liner inside a tool group (or
|
||||
// are dropped entirely when standalone — see model.ts's transcriptItems).
|
||||
if (!pending) {
|
||||
return <div className="st-row st-row-approval st-approval-compact">{`${what} — approved`}</div>;
|
||||
}
|
||||
|
||||
const offered = offeredDecisions(request.options ?? []);
|
||||
const path = toolCall.locations?.[0]?.path;
|
||||
|
||||
const decide = (decision: StructuredApprovalDecision) => {
|
||||
setBusy(true);
|
||||
onDecide(decision).catch(() => {
|
||||
// Rejects only while this approval is still pending — restore the
|
||||
// controls so the operator can retry. The error itself is surfaced by
|
||||
// the pane's banner.
|
||||
setBusy(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="st-row st-row-approval">
|
||||
<div className="st-approval">
|
||||
<div className="st-approval-title">
|
||||
<span aria-hidden="true">⚠</span> permission requested
|
||||
</div>
|
||||
<div className="st-approval-what">{what}</div>
|
||||
{path && <div className="st-approval-path">{path}</div>}
|
||||
{offered.length > 0 ? (
|
||||
<div className="st-approval-acts">
|
||||
{SPECS.filter(([decision]) => offered.includes(decision)).map(([decision, label, cls]) => (
|
||||
<button
|
||||
key={decision}
|
||||
type="button"
|
||||
className={cls ? `act ${cls}` : "act"}
|
||||
disabled={busy}
|
||||
onClick={() => decide(decision)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="st-approval-resolved">no decision offered</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
// The composer: draft, attachments, `/` command autocomplete off the live
|
||||
// `available_commands_update` notices, the session-settings menu, and the
|
||||
// send/cancel pair. Sending is the pane's job — this only hands it a draft and
|
||||
// the files that go with it.
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type ClipboardEvent,
|
||||
type DragEvent,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
|
||||
import type { AcpSessionConfigChoice, AcpSessionConfigOption } from "../../types.js";
|
||||
import {
|
||||
applyCommand,
|
||||
commandTokenAt,
|
||||
filterCommands,
|
||||
orderConfigOptions,
|
||||
type AvailableCommand,
|
||||
} from "./model.js";
|
||||
|
||||
/** Per-message ceilings the daemon enforces on uploads
|
||||
* (docs/STRUCTURED-VIEW.md, "Prompt and cancel"). */
|
||||
export const MAX_ATTACHMENTS = 10;
|
||||
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
||||
/** Pasted text at or above either bound becomes a `.txt` attachment instead of
|
||||
* going inline. */
|
||||
const PASTE_TO_FILE_CHARS = 4_000;
|
||||
const PASTE_TO_FILE_LINES = 40;
|
||||
|
||||
export interface ComposerProps {
|
||||
/** Files chosen but not yet sent. Owned by the pane so a failed send can put
|
||||
* them back. */
|
||||
attachments: File[];
|
||||
onAttachmentsChange(files: File[]): void;
|
||||
commands: AvailableCommand[];
|
||||
configOptions: AcpSessionConfigOption[];
|
||||
/** A turn is in flight — the cancel control replaces nothing, it appears
|
||||
* beside send (a queued message is still allowed). */
|
||||
turnActive: boolean;
|
||||
/** An upload/POST is in flight: the attachment controls are frozen. */
|
||||
uploading: boolean;
|
||||
configBusy: boolean;
|
||||
/** Restored into the box when a send fails. */
|
||||
draft: string;
|
||||
onDraftChange(draft: string): void;
|
||||
onSubmit(text: string, attachments: File[]): void;
|
||||
onCancel(): void;
|
||||
onConfigChange(option: AcpSessionConfigOption, value: string | boolean): void;
|
||||
onError(message: string): void;
|
||||
menuId: string;
|
||||
}
|
||||
|
||||
function configChoices(option: AcpSessionConfigOption): AcpSessionConfigChoice[] {
|
||||
return (option.options ?? []).flatMap((choice) => ("options" in choice ? choice.options : [choice]));
|
||||
}
|
||||
|
||||
function currentConfigLabel(option: AcpSessionConfigOption): string {
|
||||
if (typeof option.currentValue === "boolean") return option.currentValue ? "On" : "Off";
|
||||
return configChoices(option).find((choice) => choice.value === option.currentValue)?.name
|
||||
?? String(option.currentValue);
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
attachments,
|
||||
onAttachmentsChange,
|
||||
commands,
|
||||
configOptions,
|
||||
turnActive,
|
||||
uploading,
|
||||
configBusy,
|
||||
draft,
|
||||
onDraftChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onConfigChange,
|
||||
onError,
|
||||
menuId,
|
||||
}: ComposerProps) {
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
const configRef = useRef<HTMLDivElement | null>(null);
|
||||
const [caret, setCaret] = useState(0);
|
||||
const [activeCommand, setActiveCommand] = useState(0);
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
/** Escape closes the menu until the draft or caret moves again. */
|
||||
const [menuDismissed, setMenuDismissed] = useState(false);
|
||||
const pasteCount = useRef(0);
|
||||
|
||||
// Grow with the draft, up to the CSS max-height; past that it scrolls.
|
||||
useLayoutEffect(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
input.style.height = "auto";
|
||||
input.style.overflowY = "hidden";
|
||||
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
|
||||
if (input.scrollHeight > 160) input.style.overflowY = "auto";
|
||||
}, [draft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!configOpen) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (!configRef.current?.contains(event.target as Node)) setConfigOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointerDown);
|
||||
return () => document.removeEventListener("pointerdown", onPointerDown);
|
||||
}, [configOpen]);
|
||||
|
||||
const token = commandTokenAt(draft, caret, caret);
|
||||
const matches = token && commands.length > 0 ? filterCommands(commands, token.query) : [];
|
||||
const menuOpen = matches.length > 0 && !menuDismissed;
|
||||
const activeIndex = menuOpen ? Math.min(Math.max(activeCommand, 0), matches.length - 1) : -1;
|
||||
|
||||
const addFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
if (uploading) return;
|
||||
const next = [...attachments];
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||
onError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
|
||||
} else if (next.length >= MAX_ATTACHMENTS) {
|
||||
onError(`Attach up to ${MAX_ATTACHMENTS} files per message.`);
|
||||
} else {
|
||||
next.push(file);
|
||||
}
|
||||
}
|
||||
onAttachmentsChange(next);
|
||||
},
|
||||
[attachments, onAttachmentsChange, onError, uploading],
|
||||
);
|
||||
|
||||
const syncCaret = () => {
|
||||
setMenuDismissed(false);
|
||||
setCaret(inputRef.current?.selectionStart ?? 0);
|
||||
};
|
||||
|
||||
const chooseCommand = (command: AvailableCommand) => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
const current = commandTokenAt(draft, input.selectionStart, input.selectionEnd);
|
||||
if (!current) return;
|
||||
const applied = applyCommand(draft, current, command.name);
|
||||
onDraftChange(applied.value);
|
||||
setActiveCommand(0);
|
||||
// The caret has to move after React has written the new value.
|
||||
requestAnimationFrame(() => {
|
||||
input.focus();
|
||||
input.setSelectionRange(applied.cursor, applied.cursor);
|
||||
setCaret(applied.cursor);
|
||||
});
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if ((!draft.trim() && attachments.length === 0) || uploading) return;
|
||||
onSubmit(draft, attachments);
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (menuOpen) {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
const direction = event.key === "ArrowDown" ? 1 : -1;
|
||||
setActiveCommand((index) => (index + direction + matches.length) % matches.length);
|
||||
return;
|
||||
}
|
||||
if ((event.key === "Enter" && !event.shiftKey) || event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
chooseCommand(matches[activeIndex]);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setMenuDismissed(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const clipboard = event.clipboardData;
|
||||
if (!clipboard || uploading) return;
|
||||
if (clipboard.files.length) {
|
||||
event.preventDefault();
|
||||
addFiles(Array.from(clipboard.files));
|
||||
return;
|
||||
}
|
||||
const text = clipboard.getData("text/plain");
|
||||
if (text.length < PASTE_TO_FILE_CHARS && text.split("\n").length < PASTE_TO_FILE_LINES) return;
|
||||
// No slot for it, or too big to upload: leave it inline rather than
|
||||
// dropping it on the floor.
|
||||
if (attachments.length >= MAX_ATTACHMENTS || new Blob([text]).size > MAX_ATTACHMENT_BYTES) return;
|
||||
event.preventDefault();
|
||||
addFiles([new File([text], `pasted-${++pasteCount.current}.txt`, { type: "text/plain" })]);
|
||||
};
|
||||
|
||||
const ordered = orderConfigOptions(configOptions);
|
||||
const summary = ordered.map((option) => `${option.name}: ${currentConfigLabel(option)}`).join("\n");
|
||||
|
||||
return (
|
||||
<form
|
||||
className="structured-composer"
|
||||
onSubmit={(event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
onDragOver={(event: DragEvent) => {
|
||||
if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
|
||||
}}
|
||||
onDrop={(event: DragEvent) => {
|
||||
if (!event.dataTransfer?.files.length) return;
|
||||
event.preventDefault();
|
||||
addFiles(Array.from(event.dataTransfer.files));
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="structured-attach"
|
||||
aria-label="Attach files"
|
||||
title="Attach files (up to 20 MiB each)"
|
||||
disabled={uploading}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
<path d="m8 12 7-7a3 3 0 0 1 4 4L9 19a5 5 0 0 1-7-7L12 2M5 14l9-9" />
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
className="structured-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
disabled={uploading}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
addFiles(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<div className="structured-input-wrap">
|
||||
<div className="structured-attachments" aria-label="Attachments" aria-live="polite" hidden={attachments.length === 0}>
|
||||
{attachments.map((file, index) => (
|
||||
<span key={`${file.name}-${index}`} className="structured-attachment">
|
||||
<span title={`${file.name} (${file.size.toLocaleString()} bytes)`}>{file.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
disabled={uploading}
|
||||
onClick={() => onAttachmentsChange(attachments.filter((_, i) => i !== index))}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div id={menuId} className="structured-command-menu" role="listbox" hidden={!menuOpen}>
|
||||
{matches.map((command, index) => (
|
||||
<div
|
||||
key={command.name}
|
||||
id={`${menuId}-option-${index}`}
|
||||
className="structured-command-option"
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => chooseCommand(command)}
|
||||
>
|
||||
<div className="structured-command-title">
|
||||
<span className="structured-command-name">{`/${command.name}`}</span>
|
||||
<span className="structured-command-hint">{command.input?.hint ?? ""}</span>
|
||||
</div>
|
||||
<div className="structured-command-description">
|
||||
{command.description.replace(/\s+/g, " ").trim()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="structured-input"
|
||||
rows={1}
|
||||
placeholder="Reply, or / for commands…"
|
||||
autoComplete="off"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls={menuId}
|
||||
aria-activedescendant={activeIndex >= 0 ? `${menuId}-option-${activeIndex}` : undefined}
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
onDraftChange(event.target.value);
|
||||
setActiveCommand(0);
|
||||
setMenuDismissed(false);
|
||||
setCaret(event.target.selectionStart ?? event.target.value.length);
|
||||
}}
|
||||
onClick={syncCaret}
|
||||
onSelect={syncCaret}
|
||||
onKeyUp={(event) => {
|
||||
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) syncCaret();
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
/>
|
||||
</div>
|
||||
<div className="structured-config" ref={configRef} hidden={ordered.length === 0}>
|
||||
<button
|
||||
type="button"
|
||||
className="structured-config-trigger"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={configOpen}
|
||||
aria-label={`Session settings. ${summary.replaceAll("\n", ". ")}`}
|
||||
title={summary}
|
||||
disabled={configBusy}
|
||||
onClick={() => setConfigOpen((open) => !open)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 7h10M18 7h2M4 17h2M10 17h10M14 4v6M6 14v6" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
className="structured-config-menu"
|
||||
role="group"
|
||||
aria-label="Session settings"
|
||||
hidden={!configOpen}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
setConfigOpen(false);
|
||||
configRef.current?.querySelector<HTMLButtonElement>(".structured-config-trigger")?.focus();
|
||||
}}
|
||||
>
|
||||
{ordered.map((option) => (
|
||||
<label key={option.id} className="structured-config-row" title={option.description ?? undefined}>
|
||||
<span className="structured-config-name">{option.name}</span>
|
||||
{option.type === "select" ? (
|
||||
<select
|
||||
className="structured-config-select"
|
||||
disabled={configBusy}
|
||||
value={String(option.currentValue)}
|
||||
onChange={(event) => onConfigChange(option, event.target.value)}
|
||||
>
|
||||
{configChoices(option).map((choice) => (
|
||||
<option key={choice.value} value={choice.value} title={choice.description ?? ""}>
|
||||
{choice.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : option.type === "boolean" ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="structured-config-toggle"
|
||||
disabled={configBusy}
|
||||
checked={option.currentValue === true}
|
||||
onChange={(event) => onConfigChange(option, event.target.checked)}
|
||||
/>
|
||||
) : null}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="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>
|
||||
{turnActive && (
|
||||
<button
|
||||
type="button"
|
||||
className="structured-cancel"
|
||||
aria-label="Cancel turn"
|
||||
title="Cancel turn"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<svg className="structured-cancel-spinner" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="2" strokeDasharray="50 14" strokeLinecap="round" />
|
||||
</svg>
|
||||
<svg className="structured-cancel-stop" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// docs/STRUCTURED-VIEW.md: the web client's user, agent, thought, tool-detail
|
||||
// and notice text blocks copy their complete source text when clicked or
|
||||
// keyboard-activated. The visible node is whatever it wraps; this only adds
|
||||
// the affordance and the "copied"/"error" state the CSS reads.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type ElementType, type ReactNode } from "react";
|
||||
|
||||
async function writeClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
} catch {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const copied = document.execCommand?.("copy") ?? false;
|
||||
textarea.remove();
|
||||
if (!copied) throw new Error("clipboard unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
export interface CopyableProps {
|
||||
/** The complete source text — not whatever the DOM happens to show. */
|
||||
text: string;
|
||||
/** Names the thing in the "Copy …" label ("message", "diff", "path", …). */
|
||||
label: string;
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
title?: string;
|
||||
children?: ReactNode;
|
||||
/** Extra attributes the row builders set (`data-row-id`, `lang`, …). */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Wrapping a node in a copy affordance. Empty `text` renders the node plain —
|
||||
* a control that copies nothing is worse than no control. */
|
||||
export function Copyable({
|
||||
text,
|
||||
label,
|
||||
as: Tag = "div",
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: CopyableProps) {
|
||||
const [copyState, setCopyState] = useState<"copied" | "error" | null>(null);
|
||||
const timer = useRef<number | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current !== null) window.clearTimeout(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const copy = useCallback(() => {
|
||||
void writeClipboard(text).then(
|
||||
() => {
|
||||
setCopyState("copied");
|
||||
if (timer.current !== null) window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(() => setCopyState(null), 1200);
|
||||
},
|
||||
() => setCopyState("error"),
|
||||
);
|
||||
}, [text]);
|
||||
|
||||
if (!text) {
|
||||
return (
|
||||
<Tag className={className} {...rest}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const ariaLabel = copyState === "copied" ? `${label} copied` : `Copy ${label}`;
|
||||
return (
|
||||
<Tag
|
||||
className={className ? `${className} st-copyable` : "st-copyable"}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={ariaLabel}
|
||||
title={`Copy ${label}`}
|
||||
data-copy-state={copyState ?? undefined}
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
// A link inside the block is a link, not a copy target.
|
||||
if ((event.target as Element | null)?.closest("a")) return;
|
||||
copy();
|
||||
}}
|
||||
onKeyDown={(event: React.KeyboardEvent) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
copy();
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { DiffView } from "./DiffView.js";
|
||||
import { toolDiffMeta } from "./model.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function lines(kind: string): string[] {
|
||||
return [...document.querySelectorAll(`.st-ln-${kind}`)].map((el) => el.textContent ?? "");
|
||||
}
|
||||
|
||||
describe("DiffView", () => {
|
||||
it("marks insertions and deletions with their column prefixes", () => {
|
||||
render(<DiffView diff={{ path: "src/a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }} />);
|
||||
expect(lines("del")).toEqual(["- two"]);
|
||||
expect(lines("add")).toEqual(["+ TWO", "+ three"]);
|
||||
expect(lines("ctx")).toEqual([" one"]);
|
||||
expect(screen.getByText("src/a.ts")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("carries the +added −removed counts the tool head shows", () => {
|
||||
render(<DiffView diff={{ path: "a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }} />);
|
||||
const body = document.querySelector<HTMLElement>(".st-diff")!;
|
||||
expect(body.dataset.added).toBe("2");
|
||||
expect(body.dataset.removed).toBe("1");
|
||||
expect(toolDiffMeta([{ type: "diff", path: "a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }]))
|
||||
.toBe("+2 −1");
|
||||
});
|
||||
|
||||
it("renders every line of a new file as added", () => {
|
||||
render(<DiffView diff={{ path: "new.ts", newText: "alpha\nbeta\n" }} />);
|
||||
expect(lines("add")).toEqual(["+ alpha", "+ beta"]);
|
||||
expect(lines("del")).toEqual([]);
|
||||
});
|
||||
|
||||
it("collapses a long unchanged run to a hunk summary", () => {
|
||||
const filler = Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n");
|
||||
render(<DiffView diff={{ path: "a.ts", oldText: `head\n${filler}\n`, newText: `HEAD\n${filler}\n` }} />);
|
||||
expect(lines("hunk")).toEqual(["⋯ 17 unchanged lines ⋯"]);
|
||||
});
|
||||
|
||||
it("copies the whole diff, prefixes included", () => {
|
||||
render(<DiffView diff={{ path: "a.ts", oldText: "one\n", newText: "two\n" }} />);
|
||||
const body = document.querySelector<HTMLElement>(".st-diff")!;
|
||||
expect(body.getAttribute("aria-label")).toBe("Copy diff");
|
||||
expect(body.textContent).toBe("- one+ two");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// A `Diff` content block, rendered as coloured hunks. ACP hands over whole
|
||||
// `oldText`/`newText`, so the lines and their `+added −removed` counts are
|
||||
// computed here (structuredDiff.ts) rather than parsed out of a patch.
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { computeDiffLines, diffStats, type DiffLine } from "../../structuredDiff.js";
|
||||
import type { AcpDiff } from "../../types.js";
|
||||
import { Copyable } from "./Copyable.js";
|
||||
|
||||
/** Column prefix by line kind. A `hunk` summary and an `eof` marker are their
|
||||
* own complete text, so neither takes one. */
|
||||
const PREFIX: Record<DiffLine["kind"], string> = {
|
||||
add: "+ ",
|
||||
del: "- ",
|
||||
ctx: " ",
|
||||
hunk: "",
|
||||
eof: "",
|
||||
};
|
||||
|
||||
export function diffLineText(line: DiffLine): string {
|
||||
return PREFIX[line.kind] + line.text;
|
||||
}
|
||||
|
||||
export interface DiffViewProps {
|
||||
diff: AcpDiff;
|
||||
}
|
||||
|
||||
export function DiffView({ diff }: DiffViewProps) {
|
||||
const lines = useMemo(() => computeDiffLines(diff.oldText, diff.newText), [diff.oldText, diff.newText]);
|
||||
const stats = useMemo(() => diffStats(lines), [lines]);
|
||||
const plain = useMemo(() => lines.map(diffLineText).join("\n"), [lines]);
|
||||
|
||||
return (
|
||||
<div className="st-diff-block">
|
||||
<Copyable className="st-diff-path" text={diff.path} label="path" title={diff.path}>
|
||||
{diff.path}
|
||||
</Copyable>
|
||||
<Copyable
|
||||
className="st-diff"
|
||||
text={plain}
|
||||
label="diff"
|
||||
data-added={stats.added}
|
||||
data-removed={stats.removed}
|
||||
>
|
||||
<pre>
|
||||
{lines.map((line, index) => (
|
||||
<span key={index} className={`st-ln st-ln-${line.kind}`}>
|
||||
{diffLineText(line)}
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
</Copyable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../api.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../api.js")>("../../api.js");
|
||||
return {
|
||||
...actual,
|
||||
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
|
||||
uploadStructuredFile: vi.fn().mockResolvedValue({ path: "/uploads/pasted.txt" }),
|
||||
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
|
||||
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
|
||||
setStructuredConfig: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
cancelStructuredTurn,
|
||||
resolveStructuredApproval,
|
||||
sendStructuredPrompt,
|
||||
uploadStructuredFile,
|
||||
} from "../../api.js";
|
||||
import { INITIAL_RECONNECT_MS } from "../../reconnect.js";
|
||||
import type { ReducedState, StructuredEvent, TranscriptRow } from "../../types.js";
|
||||
import { StructuredPane } from "./StructuredPane.js";
|
||||
|
||||
/** Just enough of a WebSocket for the pane to drive (mirrors structured.test.ts's
|
||||
* own fake, which the pre-React connection used). */
|
||||
class FakeSocket {
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onclose: ((ev: { code: number; reason: string }) => void) | null = null;
|
||||
closed = false;
|
||||
|
||||
open(): void {
|
||||
act(() => this.onopen?.());
|
||||
}
|
||||
emit(event: StructuredEvent): void {
|
||||
act(() => this.onmessage?.({ data: JSON.stringify(event) }));
|
||||
}
|
||||
drop(code = 1006, reason = ""): void {
|
||||
act(() => this.onclose?.({ code, reason }));
|
||||
}
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const idle: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
|
||||
const busy: ReducedState = { turn: { responding: true, thinking: false, active_tool_calls: [] } };
|
||||
|
||||
function row(id: string, kind: TranscriptRow["kind"], data: unknown = {}): TranscriptRow {
|
||||
return { id, kind, data };
|
||||
}
|
||||
|
||||
function chunk(id: string, kind: "user" | "agent_text" | "agent_thought", text: string): TranscriptRow {
|
||||
return row(id, kind, { content: { type: "text", text } });
|
||||
}
|
||||
|
||||
const APPROVAL_REQUEST = {
|
||||
toolCall: { title: "Write(secrets.txt)", kind: "edit", locations: [{ path: "/repo/secrets.txt" }] },
|
||||
options: [
|
||||
{ optionId: "a1", name: "Allow once", kind: "allow_once" },
|
||||
{ optionId: "a2", name: "Allow always", kind: "allow_always" },
|
||||
{ optionId: "r1", name: "Reject", kind: "reject_once" },
|
||||
],
|
||||
};
|
||||
|
||||
function mount(): { socket: FakeSocket; sockets: FakeSocket[] } {
|
||||
const sockets: FakeSocket[] = [];
|
||||
render(
|
||||
<StructuredPane
|
||||
sessionId="s1"
|
||||
socketFactory={() => {
|
||||
const socket = new FakeSocket();
|
||||
sockets.push(socket);
|
||||
return socket as unknown as WebSocket;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
sockets[0].open();
|
||||
return { socket: sockets[0], sockets };
|
||||
}
|
||||
|
||||
function snapshot(socket: FakeSocket, rows: TranscriptRow[], state: ReducedState = idle): void {
|
||||
socket.emit({ type: "transcript_snapshot", snapshot: { cursor: rows.length, rows } });
|
||||
socket.emit({ type: "reduced_state", state });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("the eight row kinds", () => {
|
||||
it("renders user, agent, thought, tool, diff, approval and notice rows; never a plan", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(
|
||||
socket,
|
||||
[
|
||||
chunk("user:1", "user", "please edit it"),
|
||||
chunk("agent:1", "agent_text", "**bold** answer"),
|
||||
chunk("thought:1", "agent_thought", "considering"),
|
||||
row("tool:1", "tool_call", { title: "Read(src/main.ts)", kind: "read", status: "completed" }),
|
||||
row("tool:2", "tool_diff", {
|
||||
title: "Edit(a.ts)",
|
||||
kind: "edit",
|
||||
status: "completed",
|
||||
content: [{ type: "diff", path: "a.ts", oldText: "one\n", newText: "one\ntwo\n" }],
|
||||
}),
|
||||
row("approval:1", "approval", APPROVAL_REQUEST),
|
||||
row("plan", "plan", { entries: [{ content: "step", priority: "high", status: "pending" }] }),
|
||||
row("notice:1", "notice", { sessionUpdate: "mystery_update" }),
|
||||
],
|
||||
{ ...idle, pending_approval: { nonce: "nonce-1", request: APPROVAL_REQUEST as never } },
|
||||
);
|
||||
|
||||
expect(screen.getByText("please edit it")).toBeTruthy();
|
||||
expect(document.querySelector(".st-row-agent strong")?.textContent).toBe("bold");
|
||||
expect(document.querySelector(".st-row-thought")?.textContent).toContain("considering");
|
||||
expect(screen.getByText("src/main.ts")).toBeTruthy();
|
||||
// The diff row's head carries the line counts, not a fabricated duration.
|
||||
expect(document.querySelector('[data-row-id="tool:2"] .st-tool-meta')?.textContent).toBe("+1 −0");
|
||||
expect(screen.getByText("permission requested")).toBeTruthy();
|
||||
expect(screen.getByText("[notice]")).toBeTruthy();
|
||||
expect(document.querySelector('[data-row-id="plan"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("omits the state-only notices it consumes for `/` autocomplete", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [
|
||||
row("notice:1", "notice", {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [{ name: "compact", description: "Compact the conversation" }],
|
||||
}),
|
||||
row("notice:2", "notice", { sessionUpdate: "session_info_update" }),
|
||||
]);
|
||||
expect(screen.queryByText("[notice]")).toBeNull();
|
||||
|
||||
// …and offers them in the composer instead.
|
||||
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
||||
fireEvent.change(input, { target: { value: "/comp" } });
|
||||
expect(screen.getByText("/compact")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a completed turn's footer inline, and retires it once the next turn starts", () => {
|
||||
const { socket } = mount();
|
||||
const footer = row("turn_meta:1", "notice", {
|
||||
maestroTurnMeta: { elapsedMs: 65_000, outcome: "completed", model: "sonnet", contextDelta: 1200 },
|
||||
});
|
||||
snapshot(socket, [chunk("user:1", "user", "go"), footer]);
|
||||
expect(screen.getByText("1m 5s · +1.2k context tokens · sonnet")).toBeTruthy();
|
||||
|
||||
socket.emit({
|
||||
type: "projection_update",
|
||||
transcript: { type: "append", row: chunk("user:2", "user", "again") },
|
||||
state: idle,
|
||||
});
|
||||
expect(screen.queryByText(/1m 5s/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool rows and activity groups", () => {
|
||||
it("collapses two or more tools in a turn into one group, opened on click", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [
|
||||
chunk("user:1", "user", "go"),
|
||||
row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
|
||||
chunk("agent:1", "agent_text", "still working"),
|
||||
row("tool:2", "tool_call", { title: "Read(b.ts)", kind: "read", status: "completed" }),
|
||||
chunk("agent:2", "agent_text", "the answer"),
|
||||
]);
|
||||
|
||||
const head = screen.getByText("2 tool calls").closest("button")!;
|
||||
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||||
// The final answer stays outside the group; the intermediate text is in it.
|
||||
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
|
||||
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("still working"))).toBe(true);
|
||||
|
||||
fireEvent.click(head);
|
||||
expect(head.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("discloses a tool's output only once its head is opened", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [
|
||||
row("tool:1", "tool_call", {
|
||||
title: "Execute(ls)",
|
||||
kind: "execute",
|
||||
status: "completed",
|
||||
content: [{ type: "content", content: { type: "text", text: "a.ts\nb.ts" } }],
|
||||
rawInput: { command: "ls" },
|
||||
rawOutput: { exit_code: 0 },
|
||||
}),
|
||||
]);
|
||||
expect(document.querySelector(".st-tool-text")).toBeNull();
|
||||
|
||||
fireEvent.click(document.querySelector<HTMLButtonElement>(".st-tool-head")!);
|
||||
expect(document.querySelector(".st-tool-text")?.textContent).toBe("a.ts\nb.ts");
|
||||
expect(screen.getByText("Exit code")).toBeTruthy();
|
||||
expect(document.querySelector(".st-tool-exit-ok")?.textContent).toBe("0");
|
||||
});
|
||||
|
||||
it("patches a tool row in place rather than appending a second one", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "in_progress" })]);
|
||||
expect(document.querySelector(".st-tool-meta")?.textContent).toBe("running");
|
||||
|
||||
socket.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "patch",
|
||||
row: row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
|
||||
},
|
||||
state: idle,
|
||||
});
|
||||
expect(document.querySelectorAll('[data-row-id="tool:1"]')).toHaveLength(1);
|
||||
expect(document.querySelector(".st-tool-meta")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("approvals", () => {
|
||||
it("answers with the live nonce, offering only the decisions the request backs", async () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [row("approval:1", "approval", APPROVAL_REQUEST)], {
|
||||
...idle,
|
||||
pending_approval: { nonce: "nonce-1", request: APPROVAL_REQUEST as never },
|
||||
});
|
||||
|
||||
expect(screen.getByText("/repo/secrets.txt")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("Allow for session"));
|
||||
await waitFor(() =>
|
||||
expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "nonce-1", "allow_for_session"),
|
||||
);
|
||||
});
|
||||
|
||||
it("hides a decision the request offers no ACP option for", () => {
|
||||
const { socket } = mount();
|
||||
const request = { toolCall: { title: "Read(a.ts)" }, options: [{ optionId: "r", name: "No", kind: "reject_once" }] };
|
||||
snapshot(socket, [row("approval:1", "approval", request)], {
|
||||
...idle,
|
||||
pending_approval: { nonce: "nonce-2", request: request as never },
|
||||
});
|
||||
expect(screen.getByText("Deny")).toBeTruthy();
|
||||
expect(screen.queryByText("Allow once")).toBeNull();
|
||||
});
|
||||
|
||||
it("treats only the newest approval as pending, and hides resolved standalone ones", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(
|
||||
socket,
|
||||
[row("approval:1", "approval", APPROVAL_REQUEST), row("approval:2", "approval", APPROVAL_REQUEST)],
|
||||
{ ...idle, pending_approval: { nonce: "nonce-3", request: APPROVAL_REQUEST as never } },
|
||||
);
|
||||
expect(screen.getAllByText("permission requested")).toHaveLength(1);
|
||||
expect(screen.queryByText(/approved/)).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the controls once the approval is no longer pending", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [row("approval:1", "approval", APPROVAL_REQUEST)], {
|
||||
...idle,
|
||||
pending_approval: { nonce: "nonce-4", request: APPROVAL_REQUEST as never },
|
||||
});
|
||||
expect(screen.getByText("Allow once")).toBeTruthy();
|
||||
|
||||
socket.emit({ type: "reduced_state", state: idle });
|
||||
expect(screen.queryByText("Allow once")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("prompt and cancel", () => {
|
||||
it("sends a draft, clearing the composer", async () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, []);
|
||||
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
||||
fireEvent.change(input, { target: { value: "do the thing" } });
|
||||
fireEvent.submit(document.querySelector("form.structured-composer")!);
|
||||
|
||||
await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "do the thing"));
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
it("uploads attachments and names them in the prompt", async () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, []);
|
||||
const file = new File(["hello"], "notes.txt", { type: "text/plain" });
|
||||
fireEvent.change(document.querySelector<HTMLInputElement>(".structured-file-input")!, {
|
||||
target: { files: [file] },
|
||||
});
|
||||
expect(screen.getByText("notes.txt")).toBeTruthy();
|
||||
|
||||
fireEvent.change(document.querySelector<HTMLTextAreaElement>(".structured-input")!, {
|
||||
target: { value: "read this" },
|
||||
});
|
||||
fireEvent.submit(document.querySelector("form.structured-composer")!);
|
||||
|
||||
await waitFor(() => expect(uploadStructuredFile).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledWith(
|
||||
"s1",
|
||||
'read this\n\nAttached file "notes.txt": "/uploads/pasted.txt"',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("queues a second message while a turn is in flight, and sends it when the turn ends", async () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [], busy);
|
||||
fireEvent.change(document.querySelector<HTMLTextAreaElement>(".structured-input")!, {
|
||||
target: { value: "next up" },
|
||||
});
|
||||
fireEvent.submit(document.querySelector("form.structured-composer")!);
|
||||
|
||||
expect(sendStructuredPrompt).not.toHaveBeenCalled();
|
||||
expect(document.querySelector(".st-row-queued")?.textContent).toBe("next up");
|
||||
|
||||
socket.emit({ type: "reduced_state", state: idle });
|
||||
await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "next up"));
|
||||
});
|
||||
|
||||
it("hands a failed send's draft back to the operator", async () => {
|
||||
vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom"));
|
||||
const { socket } = mount();
|
||||
snapshot(socket, []);
|
||||
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
||||
fireEvent.change(input, { target: { value: "keep me" } });
|
||||
fireEvent.submit(document.querySelector("form.structured-composer")!);
|
||||
|
||||
await waitFor(() => expect(document.querySelector(".structured-error")?.textContent).toContain("boom"));
|
||||
expect(input.value).toBe("keep me");
|
||||
});
|
||||
|
||||
it("shows the cancel control only while a turn is live", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, []);
|
||||
expect(document.querySelector(".structured-cancel")).toBeNull();
|
||||
|
||||
socket.emit({ type: "reduced_state", state: busy });
|
||||
fireEvent.click(document.querySelector<HTMLButtonElement>(".structured-cancel")!);
|
||||
expect(cancelStructuredTurn).toHaveBeenCalledWith("s1");
|
||||
});
|
||||
|
||||
it("reports live turn activity above the composer", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [], {
|
||||
turn: {
|
||||
responding: true,
|
||||
thinking: false,
|
||||
active_tool_calls: ["t1", "t2"],
|
||||
started_at: new Date(Date.now() - 5_000).toISOString(),
|
||||
},
|
||||
usage: { used: 12_000, size: 200_000 },
|
||||
});
|
||||
const activity = document.querySelector<HTMLElement>(".structured-activity")!;
|
||||
expect(activity.hidden).toBe(false);
|
||||
expect(activity.textContent).toContain("Running 2 tools");
|
||||
expect(activity.textContent).toContain("12k context / 200k context");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the socket", () => {
|
||||
it("replaces the transcript wholesale on every snapshot, never merging", async () => {
|
||||
const { socket, sockets } = mount();
|
||||
snapshot(socket, [chunk("user:1", "user", "first")]);
|
||||
socket.drop(1006);
|
||||
|
||||
// The retry is on reconnect.ts's backoff schedule, not immediate.
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, INITIAL_RECONNECT_MS + 20));
|
||||
});
|
||||
const replacement = sockets[1];
|
||||
expect(replacement).toBeDefined();
|
||||
replacement.open();
|
||||
snapshot(replacement, [chunk("user:2", "user", "second")]);
|
||||
expect(screen.queryByText("first")).toBeNull();
|
||||
expect(screen.getByText("second")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reports a daemon rejection instead of retrying it", () => {
|
||||
const status = vi.fn();
|
||||
const sockets: FakeSocket[] = [];
|
||||
render(
|
||||
<StructuredPane
|
||||
sessionId="s1"
|
||||
onStatusChange={status}
|
||||
socketFactory={() => {
|
||||
const socket = new FakeSocket();
|
||||
sockets.push(socket);
|
||||
return socket as unknown as WebSocket;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
sockets[0].open();
|
||||
sockets[0].drop(4004, "not a structured session");
|
||||
expect(sockets).toHaveLength(1);
|
||||
expect(status).toHaveBeenCalledWith("error", "not a structured session");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
// React port of structured.ts (#246): a live structured session — the
|
||||
// transcript from `WS /api/structured/:id`, the composer that drives
|
||||
// `.../prompt` and `.../cancel`, and the approval controls that answer
|
||||
// `.../approvals/:nonce`. docs/STRUCTURED-VIEW.md is the contract.
|
||||
//
|
||||
// Like TerminalPane, the pane draws the body only: pane chrome (title,
|
||||
// disconnect, sessions drawer) belongs to whatever composes it, which reads
|
||||
// the connection state through `onStatusChange`.
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
cancelStructuredTurn,
|
||||
resolveStructuredApproval,
|
||||
sendStructuredPrompt,
|
||||
setStructuredConfig,
|
||||
uploadStructuredFile,
|
||||
} from "../../api.js";
|
||||
import { formatContextTokens, formatElapsed } from "../../structuredRender.js";
|
||||
import type {
|
||||
AcpSessionConfigOption,
|
||||
StructuredApprovalDecision,
|
||||
TranscriptRow as Row,
|
||||
Uuid,
|
||||
} from "../../types.js";
|
||||
import { Composer } from "./Composer.js";
|
||||
import {
|
||||
commandsFromTranscript,
|
||||
pendingApprovalRowId as pendingApprovalRowIdOf,
|
||||
transcriptItems,
|
||||
turnIsActive,
|
||||
} from "./model.js";
|
||||
import { ToolGroup } from "./ToolGroup.js";
|
||||
import { TranscriptRow } from "./TranscriptRow.js";
|
||||
import { useStructuredSession, type StructuredStatus, type StructuredSocketFactory } from "./useStructuredSession.js";
|
||||
import "./structured.css";
|
||||
|
||||
/** Within this much of the bottom, wheel and touch imprecision should still
|
||||
* count as following. */
|
||||
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
|
||||
|
||||
interface QueuedPrompt {
|
||||
text: string;
|
||||
attachments: File[];
|
||||
}
|
||||
|
||||
export interface StructuredPaneProps {
|
||||
sessionId: Uuid;
|
||||
/** Grabs the composer once mounted. A sidebar preview passes `false`. */
|
||||
focus?: boolean;
|
||||
shouldReconnect?: (code: number) => boolean;
|
||||
onClosed?: () => void;
|
||||
onStatusChange?: (status: StructuredStatus, detail?: string) => void;
|
||||
/** Test seam: drive the socket without a daemon. */
|
||||
socketFactory?: StructuredSocketFactory;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface StructuredPaneHandle {
|
||||
/** Put the keyboard in the composer — the structured pane's terminal. */
|
||||
focusComposer(): void;
|
||||
}
|
||||
|
||||
export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPaneProps>(function StructuredPane(
|
||||
{ sessionId, focus = true, shouldReconnect, onClosed, onStatusChange, socketFactory, className },
|
||||
ref,
|
||||
) {
|
||||
const session = useStructuredSession(sessionId, { shouldReconnect, onClosed, socketFactory });
|
||||
const { rows, state, status, detail, socketGeneration } = session;
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const composerRef = useRef<HTMLDivElement | null>(null);
|
||||
const followingBottom = useRef(true);
|
||||
|
||||
const [expandedRows, setExpandedRows] = useState<ReadonlySet<string>>(new Set());
|
||||
const [expandedGroups, setExpandedGroups] = useState<ReadonlySet<string>>(new Set());
|
||||
const [draft, setDraft] = useState("");
|
||||
const [attachments, setAttachments] = useState<File[]>([]);
|
||||
const [queue, setQueue] = useState<QueuedPrompt[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [configBusy, setConfigBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
/** Server path → object URL, so a user row can preview an image it uploaded. */
|
||||
const imageUrls = useRef(new Map<string, string>());
|
||||
|
||||
// Submission bookkeeping, ported from structured.ts: a prompt is "sending"
|
||||
// from the POST until the projection shows the turn it claimed, and an
|
||||
// acceptance that straddles a reconnect forces one more reconnect so the
|
||||
// snapshot cannot predate the 204.
|
||||
const submission = useRef(0);
|
||||
const accepted = useRef<{ generation: number; socketGeneration: number } | null>(null);
|
||||
const optimisticStart = useRef<number | null>(null);
|
||||
const promptPending = useRef(false);
|
||||
const [awaitingBegin, setAwaitingBegin] = useState<number | null>(null);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focusComposer: () => composerRef.current?.querySelector<HTMLTextAreaElement>(".structured-input")?.focus(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onStatusChange?.(status, detail ?? undefined);
|
||||
}, [status, detail, onStatusChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focus) return;
|
||||
composerRef.current?.querySelector<HTMLTextAreaElement>(".structured-input")?.focus();
|
||||
}, [focus]);
|
||||
|
||||
const urls = imageUrls.current;
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const url of urls.values()) URL.revokeObjectURL(url);
|
||||
urls.clear();
|
||||
},
|
||||
[urls],
|
||||
);
|
||||
|
||||
const turnActive = turnIsActive(state);
|
||||
const pendingRowId = useMemo(() => pendingApprovalRowIdOf(rows, state), [rows, state]);
|
||||
const items = useMemo(
|
||||
() => transcriptItems(rows, pendingRowId, turnActive),
|
||||
[rows, pendingRowId, turnActive],
|
||||
);
|
||||
const commands = useMemo(() => commandsFromTranscript(rows), [rows]);
|
||||
const configOptions: AcpSessionConfigOption[] = state.config_options?.configOptions ?? [];
|
||||
|
||||
const active = turnActive || awaitingBegin !== null;
|
||||
|
||||
// Stop saying "Sending" once the turn the submission was waiting for has
|
||||
// begun — or, when it was accepted but a replacement socket has
|
||||
// re-snapshotted since, once that newer snapshot has landed. Without the
|
||||
// second case a prompt accepted across a reconnect would say "Sending"
|
||||
// forever.
|
||||
useEffect(() => {
|
||||
if (turnActive) {
|
||||
setAwaitingBegin(null);
|
||||
} else if (
|
||||
awaitingBegin !== null &&
|
||||
accepted.current?.generation === awaitingBegin &&
|
||||
socketGeneration > accepted.current.socketGeneration
|
||||
) {
|
||||
setAwaitingBegin(null);
|
||||
}
|
||||
}, [turnActive, awaitingBegin, socketGeneration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) optimisticStart.current = null;
|
||||
}, [active]);
|
||||
|
||||
// Keep the pane pinned to the newest row unless the operator scrolled away.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || !followingBottom.current) return;
|
||||
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
|
||||
}, [items, active, queue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active]);
|
||||
|
||||
const onScroll = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
followingBottom.current = el.scrollHeight - el.clientHeight - el.scrollTop <= FOLLOW_BOTTOM_THRESHOLD_PX;
|
||||
};
|
||||
|
||||
const decide = useCallback(
|
||||
async (rowId: string, decision: StructuredApprovalDecision): Promise<void> => {
|
||||
const nonce = state.pending_approval?.nonce;
|
||||
// Already resolved elsewhere between the click and here. A stale nonce
|
||||
// is a no-op, not an error (docs/STRUCTURED-VIEW.md: any nonce answers
|
||||
// 204), and the row's buttons are already gone — resolve quietly.
|
||||
if (!nonce || rowId !== pendingRowId) return;
|
||||
try {
|
||||
await resolveStructuredApproval(sessionId, nonce, decision);
|
||||
} catch (e: unknown) {
|
||||
// Propagate — so the row re-enables its buttons — only while this is
|
||||
// still the pending approval. If it moved on in flight, reviving the
|
||||
// buttons would revive a decision that no longer applies.
|
||||
setError(String(e));
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[sessionId, state.pending_approval?.nonce, pendingRowId],
|
||||
);
|
||||
|
||||
const dispatchPrompt = useCallback(
|
||||
(text: string, files: File[]) => {
|
||||
const generation = ++submission.current;
|
||||
const submissionSocket = socketGeneration;
|
||||
setAwaitingBegin(generation);
|
||||
optimisticStart.current = Date.now();
|
||||
accepted.current = null;
|
||||
promptPending.current = true;
|
||||
setUploading(files.length > 0);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
|
||||
const send = async () => {
|
||||
const references: string[] = [];
|
||||
for (const file of files) {
|
||||
const { path } = await uploadStructuredFile(sessionId, file);
|
||||
if (file.type.startsWith("image/")) imageUrls.current.set(path, URL.createObjectURL(file));
|
||||
references.push(`Attached file ${JSON.stringify(file.name)}: ${JSON.stringify(path)}`);
|
||||
}
|
||||
await sendStructuredPrompt(sessionId, [text, ...references].filter(Boolean).join("\n\n"));
|
||||
};
|
||||
|
||||
void send().then(
|
||||
() => {
|
||||
promptPending.current = false;
|
||||
if (generation !== submission.current) return;
|
||||
setUploading(false);
|
||||
setAttachments((current) => (current === files ? [] : current));
|
||||
accepted.current = { generation, socketGeneration };
|
||||
// A replacement socket may have captured idle before the POST
|
||||
// claimed the prompt. Reconnect so its snapshot cannot predate the
|
||||
// 204.
|
||||
if (socketGeneration > submissionSocket) session.reconnect();
|
||||
},
|
||||
(e: unknown) => {
|
||||
promptPending.current = false;
|
||||
if (generation !== submission.current) return;
|
||||
setAwaitingBegin((current) => (current === generation ? null : current));
|
||||
optimisticStart.current = null;
|
||||
setUploading(false);
|
||||
// The draft and its attachments are the operator's work — a failed
|
||||
// send hands both back rather than eating them.
|
||||
setAttachments((current) => (current.length === 0 ? files : current));
|
||||
setDraft((current) => (current === "" ? text : current));
|
||||
setError(String(e));
|
||||
},
|
||||
);
|
||||
},
|
||||
[sessionId, socketGeneration, session],
|
||||
);
|
||||
|
||||
// A queued message goes out as soon as the projected turn is idle — the
|
||||
// daemon answers a second prompt `409 turn_in_flight`, so the queue is
|
||||
// client-local (docs/STRUCTURED-VIEW.md).
|
||||
useEffect(() => {
|
||||
if (active || promptPending.current || queue.length === 0) return;
|
||||
const [next, ...rest] = queue;
|
||||
setQueue(rest);
|
||||
dispatchPrompt(next.text, next.attachments);
|
||||
}, [active, queue, dispatchPrompt]);
|
||||
|
||||
const submit = (text: string, files: File[]) => {
|
||||
if (active || queue.length > 0) {
|
||||
setQueue((current) => [...current, { text, attachments: files }]);
|
||||
setAttachments([]);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
dispatchPrompt(text, files);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setError(null);
|
||||
cancelStructuredTurn(sessionId).catch((e: unknown) => setError(String(e)));
|
||||
};
|
||||
|
||||
const changeConfig = (option: AcpSessionConfigOption, value: string | boolean) => {
|
||||
setConfigBusy(true);
|
||||
setError(null);
|
||||
setStructuredConfig(sessionId, { config_id: option.id, value })
|
||||
.catch((e: unknown) =>
|
||||
setError(e instanceof Error ? e.message : "Could not change the session setting"),
|
||||
)
|
||||
.finally(() => setConfigBusy(false));
|
||||
};
|
||||
|
||||
const elapsedMs = () => {
|
||||
const authoritative = state.turn.started_at ? Date.parse(state.turn.started_at) : Number.NaN;
|
||||
const startedAt = Number.isFinite(authoritative) ? authoritative : optimisticStart.current;
|
||||
return startedAt === null ? 0 : Math.max(0, now - startedAt);
|
||||
};
|
||||
|
||||
const activityLabel = () => {
|
||||
const tools = state.turn.active_tool_calls.length;
|
||||
const label = awaitingBegin !== null
|
||||
? "Sending"
|
||||
: state.turn.thinking
|
||||
? "Thinking"
|
||||
: tools > 0
|
||||
? `Running ${tools} ${tools === 1 ? "tool" : "tools"}`
|
||||
: "Responding";
|
||||
const parts = [label, formatElapsed(elapsedMs())];
|
||||
if (typeof state.usage?.used === "number") {
|
||||
const usage = formatContextTokens(state.usage.used);
|
||||
parts.push(
|
||||
typeof state.usage.size === "number" ? `${usage} / ${formatContextTokens(state.usage.size)}` : usage,
|
||||
);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
};
|
||||
|
||||
const setRowExpanded = (id: string, expanded: boolean) =>
|
||||
setExpandedRows((current) => {
|
||||
const next = new Set(current);
|
||||
if (expanded) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const setGroupExpanded = (id: string, expanded: boolean) =>
|
||||
setExpandedGroups((current) => {
|
||||
const next = new Set(current);
|
||||
if (expanded) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const renderRow = (row: Row) => (
|
||||
<TranscriptRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pendingApproval={row.id === pendingRowId}
|
||||
onDecide={(decision) => decide(row.id, decision)}
|
||||
expanded={expandedRows.has(row.id)}
|
||||
onExpandedChange={(expanded) => setRowExpanded(row.id, expanded)}
|
||||
imageUrls={imageUrls.current}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className ? `structured-pane ${className}` : "structured-pane"}>
|
||||
<div className="structured-body">
|
||||
<div className="structured-main">
|
||||
<div className="structured-scroll" ref={scrollRef} onScroll={onScroll}>
|
||||
<div className="structured-rows">
|
||||
{items.map((item) =>
|
||||
item.type === "row" ? (
|
||||
renderRow(item.row)
|
||||
) : (
|
||||
<ToolGroup
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
toolCount={item.group.toolCount}
|
||||
expanded={expandedGroups.has(item.id)}
|
||||
onExpandedChange={(expanded) => setGroupExpanded(item.id, expanded)}
|
||||
>
|
||||
{item.group.rows.map(renderRow)}
|
||||
</ToolGroup>
|
||||
),
|
||||
)}
|
||||
{queue.map((queued, index) => (
|
||||
<div key={`queued-${index}`} className="st-row st-row-queued">
|
||||
<div className="st-body">
|
||||
<div className="st-user-text">{queued.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="structured-activity" role="status" aria-live="polite" hidden={!active}>
|
||||
<span className="structured-activity-pulse" aria-hidden="true" />
|
||||
<span className="structured-activity-text">{active ? activityLabel() : ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
{error !== null && <div className="structured-error">{error}</div>}
|
||||
<div ref={composerRef}>
|
||||
<Composer
|
||||
attachments={attachments}
|
||||
onAttachmentsChange={setAttachments}
|
||||
commands={commands}
|
||||
configOptions={configOptions}
|
||||
turnActive={active}
|
||||
uploading={uploading}
|
||||
configBusy={configBusy}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onSubmit={submit}
|
||||
onCancel={cancel}
|
||||
onConfigChange={changeConfig}
|
||||
onError={setError}
|
||||
menuId={`structured-command-menu-${sessionId}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// A turn's collapsed activity block: two or more tool rows after the same
|
||||
// `User` row, plus the narration absorbed between them. The header counts tool
|
||||
// rows only (docs/STRUCTURED-VIEW.md); opening it reveals tools and text in
|
||||
// chronological order.
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface ToolGroupProps {
|
||||
id: string;
|
||||
toolCount: number;
|
||||
expanded: boolean;
|
||||
onExpandedChange(expanded: boolean): void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ToolGroup({ id, toolCount, expanded, onExpandedChange, children }: ToolGroupProps) {
|
||||
return (
|
||||
<div className="st-tool-group" data-tool-group-id={id}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool-group-head"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => onExpandedChange(!expanded)}
|
||||
>
|
||||
<span className="st-tool-group-glyph" aria-hidden="true">
|
||||
›
|
||||
</span>
|
||||
<span className="st-tool-group-title">{`${toolCount} tool calls`}</span>
|
||||
</button>
|
||||
<div
|
||||
className={
|
||||
expanded ? "st-tool-group-body st-disclosure is-expanded" : "st-tool-group-body st-disclosure"
|
||||
}
|
||||
aria-hidden={!expanded}
|
||||
inert={!expanded}
|
||||
>
|
||||
<div className="st-disclosure-clip">
|
||||
<div className="st-disclosure-inner">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// The glyph column of a tool row's head. Recognition is narrow on purpose: an
|
||||
// unrecognised or absent `ToolKind` gets the generic bullet, never a blank
|
||||
// column and never a mark that stands for the wrong kind of work.
|
||||
|
||||
import type { AcpToolKind } from "../../types.js";
|
||||
|
||||
/** Glyph shown in a tool row's head, by `ToolKind`. */
|
||||
const KIND_GLYPH: Record<string, string> = {
|
||||
read: "≡",
|
||||
edit: "±",
|
||||
delete: "✕",
|
||||
move: "→",
|
||||
search: "⌕",
|
||||
execute: "$",
|
||||
think: "…",
|
||||
fetch: "⇣",
|
||||
switch_mode: "⇄",
|
||||
};
|
||||
|
||||
export function toolGlyph(kind: string | undefined): string {
|
||||
return (kind && KIND_GLYPH[kind]) || "•";
|
||||
}
|
||||
|
||||
export interface ToolIconProps {
|
||||
kind: AcpToolKind | string | undefined;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToolIcon({ kind, className = "st-tool-glyph" }: ToolIconProps) {
|
||||
return (
|
||||
<span className={className} aria-hidden="true">
|
||||
{toolGlyph(kind)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
// One transcript row, by kind. docs/STRUCTURED-VIEW.md fixes the eight kinds
|
||||
// and leaves every rendering decision here: markdown for AgentText, an inline
|
||||
// italic stream for AgentThought, a tool card for ToolCall/ToolDiff, a diff
|
||||
// view for ToolDiff's content, an approval card for Approval, and an opaque
|
||||
// collapsed line for a Notice nobody has taught the client about. `Plan` is
|
||||
// deliberately not drawn — the web client has no plan rail.
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
|
||||
import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "../../structuredRender.js";
|
||||
import type {
|
||||
AcpContentBlock,
|
||||
AcpPermissionOption,
|
||||
AcpToolCallContent,
|
||||
AcpToolCallLike,
|
||||
StructuredApprovalDecision,
|
||||
TranscriptRow as Row,
|
||||
TurnMeta,
|
||||
} from "../../types.js";
|
||||
import { ApprovalView } from "./ApprovalView.js";
|
||||
import { Copyable } from "./Copyable.js";
|
||||
import { DiffView } from "./DiffView.js";
|
||||
import {
|
||||
contentBlockText,
|
||||
isStateOnlyNotice,
|
||||
splitToolTitle,
|
||||
statusMeta,
|
||||
toolDiffMeta,
|
||||
turnMeta,
|
||||
} from "./model.js";
|
||||
import { safeMediaSource, toolPayload } from "./toolPayload.js";
|
||||
import { ToolIcon } from "./ToolIcon.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// content rows: user / agent / thought
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Model-authored markdown, sanitized by structuredRender.ts before it enters
|
||||
* the DOM. Tables get their own scroller and links open out of the app. */
|
||||
function Prose({ html }: { html: string }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const container = ref.current;
|
||||
if (!container) return;
|
||||
for (const table of Array.from(container.querySelectorAll("table"))) {
|
||||
if (table.parentElement?.classList.contains("st-markdown-table-wrap")) continue;
|
||||
const scroller = document.createElement("div");
|
||||
scroller.className = "st-markdown-table-wrap";
|
||||
table.replaceWith(scroller);
|
||||
scroller.appendChild(table);
|
||||
}
|
||||
for (const link of container.querySelectorAll("a")) {
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
}
|
||||
}, [html]);
|
||||
return <div ref={ref} className="st-body" dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
|
||||
export interface ContentRowProps {
|
||||
row: Row;
|
||||
variant: "user" | "agent" | "thought";
|
||||
/** Object URLs for images this prompt uploaded, keyed by the server path the
|
||||
* row's text names. */
|
||||
imageUrls?: Map<string, string>;
|
||||
}
|
||||
|
||||
/** `Attached file "name": "path"` references the composer appends on send. */
|
||||
const ATTACHED_FILE_RE = /Attached file "(?:[^"\\]|\\.)*": "((?:[^"\\]|\\.)*)"/g;
|
||||
|
||||
function attachedImages(text: string, imageUrls: Map<string, string>): string[] {
|
||||
const urls: string[] = [];
|
||||
ATTACHED_FILE_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = ATTACHED_FILE_RE.exec(text)) !== null) {
|
||||
let serverPath: string;
|
||||
try {
|
||||
serverPath = JSON.parse(`"${match[1]}"`) as string;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const url = imageUrls.get(serverPath);
|
||||
if (url) urls.push(url);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
export function ContentRow({ row, variant, imageUrls }: ContentRowProps) {
|
||||
const data = row.data as { content?: AcpContentBlock } | undefined;
|
||||
const text = contentBlockText(data?.content);
|
||||
const displayText = variant === "thought" ? text.trim() : text;
|
||||
const label = variant === "user" ? "message" : variant === "agent" ? "response" : "thought";
|
||||
|
||||
let body: ReactNode;
|
||||
if (variant === "user") {
|
||||
const images = imageUrls && imageUrls.size > 0 ? attachedImages(displayText, imageUrls) : [];
|
||||
// With previews in hand the path references are noise — the image is the
|
||||
// attachment. Without them the text is all the operator has, so it stays.
|
||||
const shown =
|
||||
images.length > 0
|
||||
? displayText.replace(/\n\nAttached file "[^"]*": "[^"]*"/g, "").trim()
|
||||
: displayText;
|
||||
body = (
|
||||
<div className="st-body">
|
||||
{shown && <div className="st-user-text">{shown}</div>}
|
||||
{images.map((src) => (
|
||||
<img key={src} className="st-user-image" src={src} alt="Uploaded image" loading="lazy" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else if (variant === "agent") {
|
||||
body = <Prose html={renderMarkdown(displayText)} />;
|
||||
} else {
|
||||
body = <div className="st-body" dangerouslySetInnerHTML={{ __html: renderInlineMarkdown(displayText) }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Copyable className={`st-row st-row-${variant}`} text={displayText} label={label} data-row-id={row.id}>
|
||||
{body}
|
||||
</Copyable>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tool rows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ToolDetailBlock({ label, text }: { label: string; text: string }) {
|
||||
return (
|
||||
<div className="st-tool-detail">
|
||||
<div className="st-tool-detail-label">{label}</div>
|
||||
<Copyable as="pre" text={text} label={label.toLowerCase()}>
|
||||
{text}
|
||||
</Copyable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentBlockView({ block }: { block: AcpContentBlock }) {
|
||||
if (block.type === "text") {
|
||||
if (!block.text) return null;
|
||||
return (
|
||||
<Copyable className="st-tool-text" text={block.text} label="output">
|
||||
{block.text}
|
||||
</Copyable>
|
||||
);
|
||||
}
|
||||
if (block.type === "image") {
|
||||
const source = safeMediaSource(block.mimeType, block.data, "image");
|
||||
if (source) return <img className="st-tool-image" src={source} alt="Tool output" loading="lazy" />;
|
||||
return <ToolDetailBlock label="Image" text={block.uri ?? block.mimeType ?? "Unavailable"} />;
|
||||
}
|
||||
if (block.type === "audio") {
|
||||
const source = safeMediaSource(block.mimeType, block.data, "audio");
|
||||
if (source) return <audio className="st-tool-audio" controls src={source} />;
|
||||
return <ToolDetailBlock label="Audio" text={block.mimeType ?? "Unavailable"} />;
|
||||
}
|
||||
if (block.type === "resource_link") {
|
||||
return (
|
||||
<div className="st-tool-resource">
|
||||
<span className="st-tool-resource-name">{block.title ?? block.name ?? "Resource"}</span>
|
||||
{block.uri && (
|
||||
<Copyable as="code" className="st-tool-resource-uri" text={block.uri} label="resource URI">
|
||||
{block.uri}
|
||||
</Copyable>
|
||||
)}
|
||||
{block.description && <span className="st-tool-resource-description">{block.description}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const resource = block.resource;
|
||||
if (typeof resource?.text === "string") {
|
||||
return (
|
||||
<ToolDetailBlock label={resource.uri ? `Resource · ${resource.uri}` : "Resource"} text={resource.text} />
|
||||
);
|
||||
}
|
||||
const printed = resource === undefined ? "" : JSON.stringify(resource, null, 2);
|
||||
return printed ? <ToolDetailBlock label="Resource" text={printed} /> : null;
|
||||
}
|
||||
|
||||
function ToolBody({ tool, content }: { tool: AcpToolCallLike; content: AcpToolCallContent[] }) {
|
||||
const payload = toolPayload(tool, content);
|
||||
const hasRaw = payload.raw.input !== null || payload.raw.output !== null;
|
||||
return (
|
||||
<>
|
||||
{content.map((block, index) => {
|
||||
if (block.type === "content") return <ContentBlockView key={index} block={block.content} />;
|
||||
if (block.type === "diff") return <DiffView key={index} diff={block} />;
|
||||
return (
|
||||
<div key={index} className="st-tool-terminal">
|
||||
{block.terminalId ? `Terminal · ${block.terminalId}` : "Embedded terminal"}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{payload.details.map((detail, index) => (
|
||||
<ToolDetailBlock key={`${detail.label}-${index}`} label={detail.label} text={detail.text} />
|
||||
))}
|
||||
{payload.exitCode !== null && (
|
||||
<div className="st-tool-fact">
|
||||
<span className="st-tool-fact-label">Exit code</span>
|
||||
<code className={payload.exitCode === 0 ? "st-tool-exit-ok" : "st-tool-exit-failed"}>
|
||||
{payload.exitCode}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{hasRaw && (
|
||||
<details className="st-tool-raw">
|
||||
<summary>Raw JSON</summary>
|
||||
<div className="st-tool-raw-body">
|
||||
{payload.raw.input !== null && <ToolDetailBlock label="Input" text={payload.raw.input} />}
|
||||
{payload.raw.output !== null && <ToolDetailBlock label="Output" text={payload.raw.output} />}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** True when a tool call has anything at all to disclose — a head with nothing
|
||||
* behind it stays a plain line rather than an empty disclosure. */
|
||||
function hasToolBody(tool: AcpToolCallLike, content: AcpToolCallContent[]): boolean {
|
||||
if (content.length > 0) return true;
|
||||
const payload = toolPayload(tool, content);
|
||||
return payload.details.length > 0 || payload.exitCode !== null || payload.raw.input !== null || payload.raw.output !== null;
|
||||
}
|
||||
|
||||
export interface ToolRowProps {
|
||||
row: Row;
|
||||
expanded: boolean;
|
||||
onExpandedChange(expanded: boolean): void;
|
||||
}
|
||||
|
||||
export function ToolRow({ row, expanded, onExpandedChange }: ToolRowProps) {
|
||||
const tool = (row.data ?? {}) as AcpToolCallLike;
|
||||
const content = tool.content ?? [];
|
||||
const hasDiff = content.some((c) => c.type === "diff");
|
||||
const { label, arg } = splitToolTitle(tool.title, tool.name, tool.kind);
|
||||
const meta = statusMeta(tool.status, hasDiff);
|
||||
const metaParts: string[] = [];
|
||||
if (hasDiff) metaParts.push(toolDiffMeta(content));
|
||||
if (meta) metaParts.push(meta.text);
|
||||
const disclosable = hasToolBody(tool, content);
|
||||
const open = expanded && disclosable;
|
||||
|
||||
return (
|
||||
<div className="st-row st-row-tool" data-row-id={row.id}>
|
||||
<div className="st-tool">
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool-head"
|
||||
aria-expanded={open}
|
||||
onClick={() => disclosable && onExpandedChange(!open)}
|
||||
>
|
||||
<ToolIcon kind={tool.kind} />
|
||||
<span className="st-tool-name">{label}</span>
|
||||
<span className="st-tool-arg" title={arg}>
|
||||
{arg}
|
||||
</span>
|
||||
{metaParts.length > 0 && (
|
||||
<span className={meta ? `st-tool-meta st-tool-meta-${meta.kind}` : "st-tool-meta"}>
|
||||
{metaParts.join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{disclosable && (
|
||||
<div
|
||||
className={open ? "st-tool-out st-disclosure is-expanded" : "st-tool-out st-disclosure"}
|
||||
aria-hidden={!open}
|
||||
inert={!open}
|
||||
>
|
||||
<div className="st-disclosure-clip">
|
||||
<div className="st-disclosure-inner">
|
||||
{/* Built only once opened: a long transcript would otherwise
|
||||
render every tool's whole output up front. */}
|
||||
{open && <ToolBody tool={tool} content={content} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// notices and turn footers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function compactNumber(value: number): string {
|
||||
if (Math.abs(value) < 1_000) return String(value);
|
||||
if (Math.abs(value) < 1_000_000) return `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)}k`;
|
||||
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}m`;
|
||||
}
|
||||
|
||||
export function TurnMetaRow({ meta }: { meta: TurnMeta }) {
|
||||
const parts = [formatElapsed(meta.elapsedMs)];
|
||||
if (typeof meta.contextDelta === "number") {
|
||||
const sign = meta.contextDelta > 0 ? "+" : "";
|
||||
parts.push(`${sign}${compactNumber(meta.contextDelta)} context tokens`);
|
||||
} else if (typeof meta.contextTokens === "number") {
|
||||
parts.push(`${compactNumber(meta.contextTokens)} context`);
|
||||
}
|
||||
if (meta.model) parts.push(meta.model);
|
||||
if (meta.outcome !== "completed") parts.push(meta.outcome);
|
||||
const text = parts.join(" · ");
|
||||
return (
|
||||
<Copyable
|
||||
className={`st-turn-meta st-turn-meta-${meta.outcome}`}
|
||||
text={text}
|
||||
label="turn details"
|
||||
aria-label={`Turn ${meta.outcome}`}
|
||||
>
|
||||
<span className="st-turn-meta-dot" aria-hidden="true" />
|
||||
<span>{text}</span>
|
||||
</Copyable>
|
||||
);
|
||||
}
|
||||
|
||||
/** The floor presentation docs/STRUCTURED-VIEW.md allows for a `Notice` the
|
||||
* client has nothing better to do with: an opaque, collapsed line. */
|
||||
export function NoticeRow({ row }: { row: Row }) {
|
||||
const text = `[${row.kind}]`;
|
||||
return (
|
||||
<Copyable className="st-row st-row-notice" text={text} label="notice" data-row-id={row.id}>
|
||||
<div className="st-body">{text}</div>
|
||||
</Copyable>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TranscriptRowProps {
|
||||
row: Row;
|
||||
/** This row is the one live approval (docs/STRUCTURED-VIEW.md's nonce
|
||||
* rules). Every other approval row is history. */
|
||||
pendingApproval?: boolean;
|
||||
onDecide?: (decision: StructuredApprovalDecision) => Promise<void>;
|
||||
expanded?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
imageUrls?: Map<string, string>;
|
||||
}
|
||||
|
||||
/** One transcript row, or `null` for the rows the web client omits (`plan`,
|
||||
* and the state-only notices it consumes for `/` autocomplete). */
|
||||
export function TranscriptRow({
|
||||
row,
|
||||
pendingApproval = false,
|
||||
onDecide,
|
||||
expanded = false,
|
||||
onExpandedChange,
|
||||
imageUrls,
|
||||
}: TranscriptRowProps) {
|
||||
switch (row.kind) {
|
||||
case "user":
|
||||
return <ContentRow row={row} variant="user" imageUrls={imageUrls} />;
|
||||
case "agent_text":
|
||||
return <ContentRow row={row} variant="agent" />;
|
||||
case "agent_thought":
|
||||
return <ContentRow row={row} variant="thought" />;
|
||||
case "tool_call":
|
||||
case "tool_diff":
|
||||
return (
|
||||
<ToolRow row={row} expanded={expanded} onExpandedChange={(next) => onExpandedChange?.(next)} />
|
||||
);
|
||||
case "approval": {
|
||||
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
|
||||
return (
|
||||
<ApprovalView
|
||||
request={request}
|
||||
pending={pendingApproval}
|
||||
onDecide={onDecide ?? (() => Promise.resolve())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "plan":
|
||||
return null;
|
||||
case "notice": {
|
||||
if (isStateOnlyNotice(row)) return null;
|
||||
const meta = turnMeta(row);
|
||||
return meta ? <TurnMetaRow meta={meta} /> : <NoticeRow row={row} />;
|
||||
}
|
||||
default:
|
||||
return <NoticeRow row={row} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export { StructuredPane, type StructuredPaneHandle, type StructuredPaneProps } from "./StructuredPane.js";
|
||||
export { TranscriptRow, type TranscriptRowProps } from "./TranscriptRow.js";
|
||||
export { DiffView, type DiffViewProps } from "./DiffView.js";
|
||||
export { ApprovalView, type ApprovalViewProps } from "./ApprovalView.js";
|
||||
export { ToolGroup, type ToolGroupProps } from "./ToolGroup.js";
|
||||
export { ToolIcon, toolGlyph, type ToolIconProps } from "./ToolIcon.js";
|
||||
export { Composer, type ComposerProps } from "./Composer.js";
|
||||
export {
|
||||
useStructuredSession,
|
||||
type StructuredSession,
|
||||
type StructuredSocketFactory,
|
||||
type StructuredStatus,
|
||||
} from "./useStructuredSession.js";
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ReducedState, TranscriptRow } from "../../types.js";
|
||||
import {
|
||||
activityGroupsByTurn,
|
||||
applyCommand,
|
||||
commandsFromTranscript,
|
||||
commandTokenAt,
|
||||
contentBlockText,
|
||||
filterCommands,
|
||||
isStateOnlyNotice,
|
||||
offeredDecisions,
|
||||
orderConfigOptions,
|
||||
pendingApprovalRowId,
|
||||
splitToolTitle,
|
||||
statusMeta,
|
||||
toolDiffMeta,
|
||||
toolKindLabel,
|
||||
transcriptItems,
|
||||
turnMeta,
|
||||
} from "./model.js";
|
||||
|
||||
function row(id: string, kind: TranscriptRow["kind"], data: unknown = {}): TranscriptRow {
|
||||
return { id, kind, data };
|
||||
}
|
||||
|
||||
function tool(id: string): TranscriptRow {
|
||||
return row(id, "tool_call", { title: `Read(${id})`, status: "completed" });
|
||||
}
|
||||
|
||||
function text(id: string, kind: "user" | "agent_text" | "agent_thought", body: string): TranscriptRow {
|
||||
return row(id, kind, { content: { type: "text", text: body } });
|
||||
}
|
||||
|
||||
const idle: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
|
||||
|
||||
describe("row taxonomy", () => {
|
||||
it("splits an adapter's Name(argument) title, and falls back to the kind", () => {
|
||||
expect(splitToolTitle("Read(src/main.ts)", null, "read")).toEqual({ label: "Read", arg: "src/main.ts" });
|
||||
expect(splitToolTitle("something opaque", null, "search")).toEqual({
|
||||
label: "Search",
|
||||
arg: "something opaque",
|
||||
});
|
||||
expect(splitToolTitle(undefined, null, undefined)).toEqual({ label: "Tool", arg: "" });
|
||||
});
|
||||
|
||||
it("labels an unknown tool kind rather than leaving it blank", () => {
|
||||
expect(toolKindLabel("execute")).toBe("Execute");
|
||||
expect(toolKindLabel("no_such_kind")).toBe("Tool");
|
||||
expect(toolKindLabel(undefined)).toBe("Tool");
|
||||
});
|
||||
|
||||
it("names non-text content blocks instead of dropping them", () => {
|
||||
expect(contentBlockText({ type: "text", text: "hi" })).toBe("hi");
|
||||
expect(contentBlockText({ type: "image" })).toBe("[image]");
|
||||
expect(contentBlockText({ type: "resource_link", name: "spec.md" })).toBe("[resource: spec.md]");
|
||||
expect(contentBlockText(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("counts a tool call's diff lines", () => {
|
||||
expect(toolDiffMeta([{ type: "diff", path: "a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }]))
|
||||
.toBe("+2 −1");
|
||||
expect(toolDiffMeta([])).toBe("+0 −0");
|
||||
});
|
||||
|
||||
it("shows a status only while it is worth showing", () => {
|
||||
expect(statusMeta("in_progress", false)).toEqual({ text: "running", kind: "running" });
|
||||
expect(statusMeta("failed", true)).toEqual({ text: "failed", kind: "failed" });
|
||||
expect(statusMeta("pending", true)).toBeNull(); // the diff counts already say it
|
||||
expect(statusMeta("completed", false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notices", () => {
|
||||
it("reads a maestroTurnMeta footer, and only a well-formed one", () => {
|
||||
const meta = { elapsedMs: 4200, outcome: "completed", model: "sonnet" };
|
||||
expect(turnMeta(row("n1", "notice", { maestroTurnMeta: meta }))).toEqual(meta);
|
||||
expect(turnMeta(row("n2", "notice", { maestroTurnMeta: { outcome: "completed" } }))).toBeNull();
|
||||
expect(turnMeta(row("n3", "notice", { sessionUpdate: "session_info_update" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("marks the state-only notices the client consumes rather than shows", () => {
|
||||
expect(isStateOnlyNotice(row("n1", "notice", { sessionUpdate: "available_commands_update" }))).toBe(true);
|
||||
expect(isStateOnlyNotice(row("n2", "notice", { sessionUpdate: "session_info_update" }))).toBe(true);
|
||||
expect(isStateOnlyNotice(row("n3", "notice", { sessionUpdate: "something_else" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("approvals", () => {
|
||||
it("offers only decisions the request actually backs", () => {
|
||||
expect(
|
||||
offeredDecisions([
|
||||
{ optionId: "a", name: "Allow once", kind: "allow_once" },
|
||||
{ optionId: "r", name: "Reject", kind: "reject_once" },
|
||||
]),
|
||||
).toEqual(["allow_once", "deny"]);
|
||||
expect(offeredDecisions([{ optionId: "a", name: "Always", kind: "allow_always" }])).toEqual([
|
||||
"allow_for_session",
|
||||
]);
|
||||
expect(offeredDecisions([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("points a live pending approval at the newest approval row", () => {
|
||||
const rows = [row("approval:1", "approval"), tool("tool:1"), row("approval:2", "approval")];
|
||||
const pending: ReducedState = {
|
||||
...idle,
|
||||
pending_approval: { nonce: "n", request: {} as never },
|
||||
};
|
||||
expect(pendingApprovalRowId(rows, pending)).toBe("approval:2");
|
||||
expect(pendingApprovalRowId(rows, idle)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("activity grouping", () => {
|
||||
it("groups a turn's tools once there are two of them", () => {
|
||||
const rows = [text("user:1", "user", "go"), tool("tool:1"), tool("tool:2")];
|
||||
const groups = activityGroupsByTurn(rows, null, false);
|
||||
expect([...groups.keys()]).toEqual(["turn:user:1"]);
|
||||
expect(groups.get("turn:user:1")).toMatchObject({ toolCount: 2 });
|
||||
});
|
||||
|
||||
it("leaves a single tool call standalone", () => {
|
||||
const rows = [text("user:1", "user", "go"), tool("tool:1")];
|
||||
expect(activityGroupsByTurn(rows, null, false).size).toBe(0);
|
||||
});
|
||||
|
||||
it("absorbs intermediate narration but keeps the final answer outside", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
tool("tool:1"),
|
||||
text("agent:1", "agent_text", "working"),
|
||||
tool("tool:2"),
|
||||
text("agent:2", "agent_text", "done"),
|
||||
];
|
||||
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
|
||||
expect(group.toolCount).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps the newest thought outside while the turn is still live", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
tool("tool:1"),
|
||||
tool("tool:2"),
|
||||
text("thought:1", "agent_thought", "hmm"),
|
||||
text("thought:2", "agent_thought", "aha"),
|
||||
];
|
||||
const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!;
|
||||
expect(live.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1"]);
|
||||
const ended = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1", "thought:2"]);
|
||||
});
|
||||
|
||||
it("absorbs a resolved approval, never the pending one", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
tool("tool:1"),
|
||||
row("approval:1", "approval"),
|
||||
tool("tool:2"),
|
||||
row("approval:2", "approval"),
|
||||
tool("tool:3"),
|
||||
];
|
||||
const group = activityGroupsByTurn(rows, "approval:2", false).get("turn:user:1")!;
|
||||
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "approval:1", "tool:2", "tool:3"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transcript render list", () => {
|
||||
it("renders a group once, at its first member's position", () => {
|
||||
const rows = [text("user:1", "user", "go"), tool("tool:1"), tool("tool:2"), text("agent:1", "agent_text", "done")];
|
||||
const items = transcriptItems(rows, null, false);
|
||||
expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual([
|
||||
"user:1",
|
||||
"turn:user:1",
|
||||
"agent:1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits plan rows, state-only notices and standalone resolved approvals", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
row("plan", "plan", { entries: [] }),
|
||||
row("notice:1", "notice", { sessionUpdate: "available_commands_update", availableCommands: [] }),
|
||||
row("approval:1", "approval"),
|
||||
text("agent:1", "agent_text", "done"),
|
||||
];
|
||||
const items = transcriptItems(rows, null, false);
|
||||
expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual(["user:1", "agent:1"]);
|
||||
});
|
||||
|
||||
it("keeps a turn footer only while it is the final item", () => {
|
||||
const footer = row("turn_meta:1", "notice", { maestroTurnMeta: { elapsedMs: 10, outcome: "completed" } });
|
||||
expect(transcriptItems([text("user:1", "user", "go"), footer], null, false)).toHaveLength(2);
|
||||
const later = [text("user:1", "user", "go"), footer, text("user:2", "user", "again")];
|
||||
expect(transcriptItems(later, null, false).map((i) => (i.type === "row" ? i.row.id : i.id))).toEqual([
|
||||
"user:1",
|
||||
"user:2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composer commands", () => {
|
||||
const notice = row("notice:9", "notice", {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{ name: "compact", description: "Compact the\n conversation" },
|
||||
{ name: "cost", description: "Show cost", input: { hint: "[period]" } },
|
||||
{ name: 42 },
|
||||
],
|
||||
});
|
||||
|
||||
it("takes the newest available_commands_update, skipping malformed entries", () => {
|
||||
const stale = row("notice:1", "notice", {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [{ name: "old", description: "gone" }],
|
||||
});
|
||||
expect(commandsFromTranscript([stale, notice]).map((c) => c.name)).toEqual(["compact", "cost"]);
|
||||
expect(commandsFromTranscript([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("only matches a /token under the caret", () => {
|
||||
expect(commandTokenAt("/comp", 5, 5)).toEqual({ start: 0, end: 5, query: "comp" });
|
||||
expect(commandTokenAt("hello /co there", 9, 9)).toEqual({ start: 6, end: 9, query: "co" });
|
||||
expect(commandTokenAt("no slash", 3, 3)).toBeNull();
|
||||
expect(commandTokenAt("/comp", 2, 5)).toBeNull(); // a selection is not a caret
|
||||
});
|
||||
|
||||
it("ranks prefix matches ahead of substring matches", () => {
|
||||
const commands = commandsFromTranscript([notice]);
|
||||
expect(filterCommands(commands, "co").map((c) => c.name)).toEqual(["compact", "cost"]);
|
||||
expect(filterCommands(commands, "act").map((c) => c.name)).toEqual(["compact"]);
|
||||
});
|
||||
|
||||
it("replaces only the token under the caret", () => {
|
||||
const token = commandTokenAt("say /co now", 7, 7)!;
|
||||
expect(applyCommand("say /co now", token, "compact")).toEqual({ value: "say /compact now", cursor: 13 });
|
||||
const trailing = commandTokenAt("/co", 3, 3)!;
|
||||
expect(applyCommand("/co", trailing, "compact")).toEqual({ value: "/compact ", cursor: 9 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("session config", () => {
|
||||
it("orders the menu by category, unknown categories last", () => {
|
||||
const options = [
|
||||
{ id: "d", category: null },
|
||||
{ id: "b", category: "thought_level" },
|
||||
{ id: "a", category: "model" },
|
||||
{ id: "c", category: "mode" },
|
||||
];
|
||||
expect(orderConfigOptions(options).map((o) => o.id)).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
// Pure transcript logic for the structured pane (#246) — the decisions
|
||||
// docs/STRUCTURED-VIEW.md leaves to a client, kept out of the components so
|
||||
// they can be tested without a DOM or a socket. Ported from structured.ts's
|
||||
// private helpers and the parts of structuredRender.ts that were data, not
|
||||
// markup.
|
||||
|
||||
import type {
|
||||
AcpContentBlock,
|
||||
AcpPermissionOption,
|
||||
AcpToolCallContent,
|
||||
ReducedState,
|
||||
StructuredApprovalDecision,
|
||||
TranscriptRow,
|
||||
TranscriptRowKind,
|
||||
TurnMeta,
|
||||
} from "../../types.js";
|
||||
import { computeDiffLines, diffStats } from "../../structuredDiff.js";
|
||||
|
||||
/** Human label behind a `ToolKind`. An unrecognised/absent kind is "Tool" —
|
||||
* never a blank label. */
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
read: "Read",
|
||||
edit: "Edit",
|
||||
delete: "Delete",
|
||||
move: "Move",
|
||||
search: "Search",
|
||||
execute: "Execute",
|
||||
think: "Think",
|
||||
fetch: "Fetch",
|
||||
switch_mode: "Switch mode",
|
||||
};
|
||||
|
||||
export function toolKindLabel(kind: string | undefined): string {
|
||||
return (kind && KIND_LABEL[kind]) || "Tool";
|
||||
}
|
||||
|
||||
/** Split a title into `{label, arg}` when it follows the adapter's own
|
||||
* `Name(argument)` convention; otherwise the whole title is the arg and the
|
||||
* tool's `kind` supplies a generic label. Never guesses at a shape the data
|
||||
* doesn't actually show. */
|
||||
export function splitToolTitle(
|
||||
title: string | undefined,
|
||||
name: string | null | undefined,
|
||||
kind: string | undefined,
|
||||
): { label: string; arg: string } {
|
||||
const label = name ?? toolKindLabel(kind);
|
||||
if (!title) return { label, arg: "" };
|
||||
const m = /^(\S+)\(([\s\S]*)\)$/.exec(title);
|
||||
if (m) return { label: name ?? m[1], arg: m[2] };
|
||||
return { label, arg: title };
|
||||
}
|
||||
|
||||
/** Content-block text, or a labelled placeholder for anything that isn't
|
||||
* text — never dropped silently, never fabricated. */
|
||||
export function contentBlockText(block: AcpContentBlock | undefined): string {
|
||||
if (!block) return "";
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
return block.text;
|
||||
case "image":
|
||||
return "[image]";
|
||||
case "audio":
|
||||
return "[audio]";
|
||||
case "resource_link":
|
||||
return `[resource: ${block.name ?? block.uri ?? "linked"}]`;
|
||||
case "resource":
|
||||
return "[resource]";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** `+added −removed` across every diff block in a tool call's content — real
|
||||
* derived data, never a fabricated duration. */
|
||||
export function toolDiffMeta(content: AcpToolCallContent[]): string {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const block of content) {
|
||||
if (block.type !== "diff") continue;
|
||||
const stats = diffStats(computeDiffLines(block.oldText, block.newText));
|
||||
added += stats.added;
|
||||
removed += stats.removed;
|
||||
}
|
||||
return `+${added} −${removed}`;
|
||||
}
|
||||
|
||||
export function statusMeta(
|
||||
status: string | undefined,
|
||||
hasDiff: boolean,
|
||||
): { text: string; kind: string } | null {
|
||||
if (status === "in_progress") return { text: "running", kind: "running" };
|
||||
if (status === "failed") return { text: "failed", kind: "failed" };
|
||||
if (status === "pending" && !hasDiff) return { text: "pending", kind: "pending" };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The `maestroTurnMeta` footer a `Notice` row carries when it is a completed,
|
||||
* cancelled or failed turn's durable footer — `null` for every other notice. */
|
||||
export function turnMeta(row: TranscriptRow): TurnMeta | null {
|
||||
if (row.kind !== "notice" || !row.data || typeof row.data !== "object") return null;
|
||||
const value = (row.data as { maestroTurnMeta?: unknown }).maestroTurnMeta;
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const meta = value as Partial<TurnMeta>;
|
||||
if (typeof meta.elapsedMs !== "number" || typeof meta.outcome !== "string") return null;
|
||||
return meta as TurnMeta;
|
||||
}
|
||||
|
||||
/** Notices the web client consumes as state and omits from the visible
|
||||
* transcript (docs/STRUCTURED-VIEW.md, "A client decides"). */
|
||||
export function isStateOnlyNotice(row: TranscriptRow): boolean {
|
||||
if (row.kind !== "notice" || !row.data || typeof row.data !== "object") return false;
|
||||
const update = (row.data as { sessionUpdate?: string }).sessionUpdate;
|
||||
return update === "available_commands_update" || update === "session_info_update";
|
||||
}
|
||||
|
||||
/** Which decisions a permission request actually backs, by the
|
||||
* `PermissionOptionKind` values it offers (docs/STRUCTURED-VIEW.md's
|
||||
* "Decision mapping"). A decision with no matching option resolves
|
||||
* `Cancelled` server-side regardless of what the client sent — offering its
|
||||
* button would let the operator "allow" something that silently no-ops. */
|
||||
export function offeredDecisions(options: AcpPermissionOption[]): StructuredApprovalDecision[] {
|
||||
const kinds = new Set(options.map((o) => o.kind));
|
||||
const decisions: StructuredApprovalDecision[] = [];
|
||||
if (kinds.has("allow_once")) decisions.push("allow_once");
|
||||
if (kinds.has("allow_always")) decisions.push("allow_for_session");
|
||||
if (kinds.has("reject_once") || kinds.has("reject_always")) decisions.push("deny");
|
||||
return decisions;
|
||||
}
|
||||
|
||||
/** The row id a live `pending_approval` names: the newest `Approval` row.
|
||||
* Only one approval is ever live (docs/STRUCTURED-VIEW.md, "One live
|
||||
* approval at a time"), so the last one is the pending one. */
|
||||
export function pendingApprovalRowId(rows: TranscriptRow[], state: ReducedState): string | null {
|
||||
return state.pending_approval ? lastRowIdOfKind(rows, "approval") : null;
|
||||
}
|
||||
|
||||
export function lastRowIdOfKind(rows: TranscriptRow[], kind: TranscriptRowKind): string | null {
|
||||
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function turnIsActive(state: ReducedState): boolean {
|
||||
return state.turn.responding || state.turn.thinking;
|
||||
}
|
||||
|
||||
export interface ActivityGroup {
|
||||
/** Header label count — tool rows only, never absorbed text. */
|
||||
toolCount: number;
|
||||
/** Tools plus absorbed intermediate text/approvals, in transcript order. */
|
||||
rows: TranscriptRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group tool rows (and intermediate agent text / resolved approvals between
|
||||
* them) by turn. An agent_text/agent_thought row is "intermediate" when a tool
|
||||
* row follows it in the same turn — it gets absorbed into the group so the
|
||||
* collapsed block preserves chronological order. Resolved approval rows (not
|
||||
* the currently pending one) are absorbed the same way. The *last* agent text
|
||||
* with no subsequent tool stays standalone (the prominent final answer).
|
||||
*
|
||||
* Returns groups keyed by `turn:<userId>`.
|
||||
*/
|
||||
export function activityGroupsByTurn(
|
||||
rows: TranscriptRow[],
|
||||
pendingApprovalId: string | null,
|
||||
turnActive: boolean,
|
||||
): Map<string, ActivityGroup> {
|
||||
// First: count tools per turn to know which turns qualify for grouping.
|
||||
const toolCounts = new Map<string, number>();
|
||||
let turnId = "before-first-message";
|
||||
for (const row of rows) {
|
||||
if (row.kind === "user") turnId = row.id;
|
||||
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
|
||||
const gid = `turn:${turnId}`;
|
||||
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
|
||||
}
|
||||
// Second: build groups with interleaved text for turns with 2+ tools.
|
||||
const groups = new Map<string, ActivityGroup>();
|
||||
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 !== pendingApprovalId;
|
||||
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.
|
||||
// While the turn is still live the most recent one stays outside as visible
|
||||
// narration (docs/STRUCTURED-VIEW.md), and is absorbed when the turn ends.
|
||||
const lastGroup = groups.get(currentGid);
|
||||
if (lastGroup && pending.length > 0 && pending.every((row) => row.kind === "agent_thought")) {
|
||||
const absorb = turnActive ? pending.slice(0, -1) : pending;
|
||||
lastGroup.rows.push(...absorb);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** One entry of the flattened render list: either a standalone row or a whole
|
||||
* activity group. */
|
||||
export type TranscriptItem =
|
||||
| { type: "row"; row: TranscriptRow }
|
||||
| { type: "group"; id: string; group: ActivityGroup };
|
||||
|
||||
/**
|
||||
* The transcript in render order: rows, with each turn's tool activity folded
|
||||
* into a single group item where one exists. Resolved approvals not absorbed
|
||||
* into a group are dropped — the tool row itself already identifies the
|
||||
* action. `plan` rows and state-only notices are dropped too (both clients
|
||||
* omit `Plan` from the transcript). A turn footer only survives as the final
|
||||
* item, so old footers stop reading like transcript content.
|
||||
*/
|
||||
export function transcriptItems(
|
||||
rows: TranscriptRow[],
|
||||
pendingApprovalId: string | null,
|
||||
turnActive: boolean,
|
||||
): TranscriptItem[] {
|
||||
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive);
|
||||
const grouped = new Set<string>();
|
||||
for (const group of groups.values()) for (const row of group.rows) grouped.add(row.id);
|
||||
|
||||
const items: TranscriptItem[] = [];
|
||||
let turnId = "before-first-message";
|
||||
const emitted = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (row.kind === "user") turnId = row.id;
|
||||
const groupId = `turn:${turnId}`;
|
||||
const group = groups.get(groupId);
|
||||
if (group && grouped.has(row.id)) {
|
||||
// The group renders once, at its first member's position.
|
||||
if (!emitted.has(groupId)) {
|
||||
emitted.add(groupId);
|
||||
items.push({ type: "group", id: groupId, group });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (row.kind === "plan") continue;
|
||||
if (row.kind === "notice" && isStateOnlyNotice(row)) continue;
|
||||
// A resolved approval outside a group adds nothing the tool row does not
|
||||
// already say. A pending one always renders.
|
||||
if (row.kind === "approval" && row.id !== pendingApprovalId) continue;
|
||||
items.push({ type: "row", row });
|
||||
}
|
||||
|
||||
return items.filter(
|
||||
(item, index) =>
|
||||
!(item.type === "row" && turnMeta(item.row) !== null) || index === items.length - 1,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// composer: `/` command autocomplete off `available_commands_update` notices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AvailableCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
input?: { hint?: string };
|
||||
}
|
||||
|
||||
/** The live command list is whatever the most recent
|
||||
* `available_commands_update` notice carries (docs/STRUCTURED-VIEW.md). */
|
||||
export function commandsFromTranscript(rows: TranscriptRow[]): AvailableCommand[] {
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
if (rows[i].kind !== "notice") continue;
|
||||
const data = rows[i].data as { sessionUpdate?: string; availableCommands?: unknown[] } | undefined;
|
||||
if (data?.sessionUpdate !== "available_commands_update") continue;
|
||||
return (data.availableCommands ?? []).flatMap((value) => {
|
||||
if (!value || typeof value !== "object") return [];
|
||||
const command = value as Partial<AvailableCommand>;
|
||||
if (typeof command.name !== "string" || typeof command.description !== "string") return [];
|
||||
return [{ name: command.name, description: command.description, input: command.input }];
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export interface CommandToken {
|
||||
start: number;
|
||||
end: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
/** The whitespace-delimited token under the caret, when it is a `/command`
|
||||
* being typed. Completing one replaces only that token. */
|
||||
export function commandTokenAt(
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number,
|
||||
): CommandToken | null {
|
||||
if (selectionStart !== selectionEnd) return null;
|
||||
let start = selectionStart;
|
||||
while (start > 0 && !/\s/.test(value[start - 1])) start--;
|
||||
let end = selectionStart;
|
||||
while (end < value.length && !/\s/.test(value[end])) end++;
|
||||
const match = /^\/([^\s/]*)$/.exec(value.slice(start, selectionStart));
|
||||
return match ? { start, end, query: match[1].toLocaleLowerCase() } : null;
|
||||
}
|
||||
|
||||
/** Substring match, prefix matches first, then alphabetical. */
|
||||
export function filterCommands(commands: AvailableCommand[], query: string): AvailableCommand[] {
|
||||
return commands
|
||||
.filter((command) => command.name.toLocaleLowerCase().includes(query))
|
||||
.sort((a, b) => {
|
||||
const aStarts = a.name.toLocaleLowerCase().startsWith(query);
|
||||
const bStarts = b.name.toLocaleLowerCase().startsWith(query);
|
||||
return aStarts === bStarts ? a.name.localeCompare(b.name) : aStarts ? -1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
/** Replace the token under the caret with `/name`, returning the new draft and
|
||||
* where the caret lands. A trailing space is added only when the completion
|
||||
* ends the draft. */
|
||||
export function applyCommand(
|
||||
value: string,
|
||||
token: CommandToken,
|
||||
name: string,
|
||||
): { value: string; cursor: number } {
|
||||
const before = value.slice(0, token.start);
|
||||
const after = value.slice(token.end);
|
||||
let replacement = `/${name}`;
|
||||
let cursor = before.length + replacement.length;
|
||||
if (after.length === 0) {
|
||||
replacement += " ";
|
||||
cursor++;
|
||||
} else if (/^[ \t]/.test(after)) {
|
||||
cursor++;
|
||||
}
|
||||
return { value: before + replacement + after, cursor };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// session config menu
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Category order the config menu sorts by; anything else lands after. */
|
||||
const CONFIG_CATEGORY_ORDER = ["model", "thought_level", "mode", "model_config"];
|
||||
|
||||
export function orderConfigOptions<T extends { category?: string | null }>(options: T[]): T[] {
|
||||
return [...options].sort((a, b) => {
|
||||
const rank = (category?: string | null) => CONFIG_CATEGORY_ORDER.indexOf(category ?? "");
|
||||
const aRank = rank(a.category);
|
||||
const bRank = rank(b.category);
|
||||
return (aRank < 0 ? 99 : aRank) - (bRank < 0 ? 99 : bRank);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
||||
// What a tool row shows below its head, derived from `rawInput`/`rawOutput`.
|
||||
// The adapters emit a handful of recognisable payload shapes (a shell command,
|
||||
// an MCP call, a search, a plain function call); anything else falls through to
|
||||
// the raw JSON disclosure rather than being guessed at.
|
||||
|
||||
import type { AcpContentBlock, AcpToolCallContent, AcpToolCallLike } from "../../types.js";
|
||||
|
||||
/** One labelled `<pre>` block under a tool head. */
|
||||
export interface ToolDetail {
|
||||
label: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ToolPayload {
|
||||
details: ToolDetail[];
|
||||
/** A shell command's exit code, when the output carried one. */
|
||||
exitCode: number | null;
|
||||
raw: { input: string | null; output: string | null };
|
||||
}
|
||||
|
||||
function printable(value: unknown): string | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function jsonPrintable(value: unknown): string | null {
|
||||
if (value === undefined) return null;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2) ?? null;
|
||||
} catch {
|
||||
return printable(value);
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Push a detail unless it is empty, or repeats text the content blocks above
|
||||
* already show verbatim. */
|
||||
function push(details: ToolDetail[], label: string, value: unknown, duplicates?: Set<string>): void {
|
||||
const text = printable(value);
|
||||
if (text !== null && text !== "" && !duplicates?.has(text)) details.push({ label, text });
|
||||
}
|
||||
|
||||
/** The text of every `content` block, so a `rawOutput` echoing it is not shown
|
||||
* a second time. */
|
||||
function contentTexts(content: AcpToolCallContent[]): Set<string> {
|
||||
return new Set(
|
||||
content
|
||||
.filter((block): block is Extract<AcpToolCallContent, { type: "content" }> => block.type === "content")
|
||||
.filter((block) => block.content.type === "text")
|
||||
.map((block) => (block.content as Extract<AcpContentBlock, { type: "text" }>).text),
|
||||
);
|
||||
}
|
||||
|
||||
export function toolPayload(tool: AcpToolCallLike, content: AcpToolCallContent[]): ToolPayload {
|
||||
const details: ToolDetail[] = [];
|
||||
let exitCode: number | null = null;
|
||||
const input = record(tool.rawInput);
|
||||
const texts = contentTexts(content);
|
||||
|
||||
if (input) {
|
||||
if (typeof input.command === "string") {
|
||||
details.push({ label: "Command", text: input.command });
|
||||
push(details, "Working directory", input.cwd);
|
||||
const result = record(tool.rawOutput);
|
||||
if (result) {
|
||||
push(details, "Output", result.formatted_output ?? result.stdout ?? result.output, texts);
|
||||
if (typeof result.exit_code === "number") exitCode = result.exit_code;
|
||||
}
|
||||
} else if (typeof input.server === "string" && typeof input.tool === "string" && "arguments" in input) {
|
||||
push(details, "Arguments", input.arguments);
|
||||
const result = record(tool.rawOutput);
|
||||
if (result) {
|
||||
push(details, "Result", result.result, texts);
|
||||
push(details, "Error", result.error);
|
||||
}
|
||||
} else if (typeof input.query === "string" && (tool.kind === "search" || input.type === "webSearch")) {
|
||||
details.push({ label: "Query", text: input.query });
|
||||
push(details, "Action", input.action);
|
||||
} else if ("arguments" in input) {
|
||||
push(details, "Arguments", input.arguments);
|
||||
const result = record(tool.rawOutput);
|
||||
if (result) push(details, "Result", result.output, texts);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
details,
|
||||
exitCode,
|
||||
raw: { input: jsonPrintable(tool.rawInput), output: jsonPrintable(tool.rawOutput) },
|
||||
};
|
||||
}
|
||||
|
||||
/** A `data:` URL for an inline image/audio block, or `null` when the mime type
|
||||
* is absent or does not name that family — never a URL built from an
|
||||
* unvalidated string. */
|
||||
export function safeMediaSource(
|
||||
mimeType: string | undefined,
|
||||
data: string | undefined,
|
||||
family: "image" | "audio",
|
||||
): string | null {
|
||||
if (!mimeType || !data || !new RegExp(`^${family}/[a-z0-9.+-]+$`, "i").test(mimeType)) return null;
|
||||
return `data:${mimeType};base64,${data}`;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// The `WS /api/structured/:id` half of the pane (#246). Every connect — fresh
|
||||
// or reconnect — resends the whole transcript rather than resuming a cursor
|
||||
// (docs/STRUCTURED-VIEW.md), so a snapshot replaces state wholesale instead of
|
||||
// merging into what came before.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { structuredWsUrl } from "../../routes.js";
|
||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
|
||||
import type {
|
||||
ReducedState,
|
||||
StructuredEvent,
|
||||
TranscriptDelta,
|
||||
TranscriptRow,
|
||||
Uuid,
|
||||
} from "../../types.js";
|
||||
|
||||
export type StructuredStatus = "connecting" | "open" | "reconnecting" | "closed" | "error";
|
||||
|
||||
/** Injectable so tests can drive the socket without a daemon — the same seam
|
||||
* structured.ts used. */
|
||||
export type StructuredSocketFactory = (sessionId: Uuid) => WebSocket;
|
||||
|
||||
const idleState = (): ReducedState => ({
|
||||
turn: { responding: false, thinking: false, active_tool_calls: [] },
|
||||
});
|
||||
|
||||
function applyDelta(rows: TranscriptRow[], delta: TranscriptDelta): TranscriptRow[] {
|
||||
if (delta.type === "append") {
|
||||
// A patch can arrive for a row this connection has never seen (its
|
||||
// snapshot predates it); treating an unknown id as an append keeps the
|
||||
// transcript complete rather than dropping the row.
|
||||
const at = rows.findIndex((row) => row.id === delta.row.id);
|
||||
return at === -1 ? [...rows, delta.row] : rows.map((row, i) => (i === at ? delta.row : row));
|
||||
}
|
||||
if (delta.type === "patch") {
|
||||
const at = rows.findIndex((row) => row.id === delta.row.id);
|
||||
return at === -1 ? [...rows, delta.row] : rows.map((row, i) => (i === at ? delta.row : row));
|
||||
}
|
||||
return rows.filter((row) => row.id !== delta.id);
|
||||
}
|
||||
|
||||
export interface StructuredSession {
|
||||
rows: TranscriptRow[];
|
||||
state: ReducedState;
|
||||
status: StructuredStatus;
|
||||
/** A daemon-supplied close reason (code ≥ 4000), when there is one. */
|
||||
detail: string | null;
|
||||
/** The number of sockets this pane has opened, newest last. A submission
|
||||
* that straddles a reconnect uses it to notice its acceptance may predate
|
||||
* the current snapshot. */
|
||||
socketGeneration: number;
|
||||
/** Drop the socket and reconnect, so the next snapshot cannot predate a
|
||||
* POST that has already been accepted. */
|
||||
reconnect(): void;
|
||||
}
|
||||
|
||||
export interface UseStructuredSessionOptions {
|
||||
shouldReconnect?: (code: number) => boolean;
|
||||
onClosed?: () => void;
|
||||
socketFactory?: StructuredSocketFactory;
|
||||
}
|
||||
|
||||
/** Application-defined close codes (daemon rejection) and the clean-close code
|
||||
* — retrying either would only loop. */
|
||||
function defaultShouldReconnect(code: number): boolean {
|
||||
return code < 4000 && code !== 1000;
|
||||
}
|
||||
|
||||
export function useStructuredSession(
|
||||
sessionId: Uuid,
|
||||
{
|
||||
shouldReconnect = defaultShouldReconnect,
|
||||
onClosed,
|
||||
socketFactory = (id) => new WebSocket(structuredWsUrl(id)),
|
||||
}: UseStructuredSessionOptions = {},
|
||||
): StructuredSession {
|
||||
const [rows, setRows] = useState<TranscriptRow[]>([]);
|
||||
const [state, setState] = useState<ReducedState>(idleState);
|
||||
const [status, setStatus] = useState<StructuredStatus>("connecting");
|
||||
const [detail, setDetail] = useState<string | null>(null);
|
||||
const [socketGeneration, setSocketGeneration] = useState(0);
|
||||
|
||||
// Read through refs so a changed callback never tears the socket down.
|
||||
const shouldReconnectRef = useRef(shouldReconnect);
|
||||
const onClosedRef = useRef(onClosed);
|
||||
const factoryRef = useRef(socketFactory);
|
||||
shouldReconnectRef.current = shouldReconnect;
|
||||
onClosedRef.current = onClosed;
|
||||
factoryRef.current = socketFactory;
|
||||
|
||||
const reconnectRef = useRef<() => void>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
let reconnectMs = INITIAL_RECONNECT_MS;
|
||||
let timer: number | null = null;
|
||||
|
||||
const closeSocket = () => {
|
||||
const existing = socket;
|
||||
socket = null;
|
||||
if (!existing) return;
|
||||
existing.onopen = null;
|
||||
existing.onmessage = null;
|
||||
existing.onerror = null;
|
||||
existing.onclose = null;
|
||||
try {
|
||||
existing.close();
|
||||
} catch {
|
||||
// ignore — an already-closing socket has nothing to close
|
||||
}
|
||||
};
|
||||
|
||||
const cancelTimer = () => {
|
||||
if (timer === null) return;
|
||||
window.clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
|
||||
function connect(): void {
|
||||
if (disposed) return;
|
||||
cancelTimer();
|
||||
closeSocket();
|
||||
const current = factoryRef.current(sessionId);
|
||||
socket = current;
|
||||
setSocketGeneration((n) => n + 1);
|
||||
|
||||
current.onopen = () => {
|
||||
if (disposed || socket !== current) return;
|
||||
reconnectMs = INITIAL_RECONNECT_MS;
|
||||
setDetail(null);
|
||||
setStatus("open");
|
||||
};
|
||||
|
||||
current.onmessage = (ev) => {
|
||||
if (disposed || socket !== current) return;
|
||||
if (typeof ev.data !== "string") return;
|
||||
let msg: StructuredEvent;
|
||||
try {
|
||||
msg = JSON.parse(ev.data) as StructuredEvent;
|
||||
} catch {
|
||||
return; // not a structured event we understand
|
||||
}
|
||||
switch (msg.type) {
|
||||
case "transcript_snapshot":
|
||||
setRows([...msg.snapshot.rows]);
|
||||
break;
|
||||
case "reduced_state":
|
||||
setState(msg.state);
|
||||
break;
|
||||
case "projection_update":
|
||||
if (msg.transcript) {
|
||||
const delta = msg.transcript;
|
||||
setRows((previous) => applyDelta(previous, delta));
|
||||
}
|
||||
setState(msg.state);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
current.onerror = () => {
|
||||
if (disposed || socket !== current) return;
|
||||
setStatus("error");
|
||||
};
|
||||
|
||||
current.onclose = (ev) => {
|
||||
if (disposed || socket !== current) return;
|
||||
socket = null;
|
||||
const reason = ev.reason?.trim();
|
||||
if (ev.code >= 4000 && reason) {
|
||||
setDetail(reason);
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
if (shouldReconnectRef.current(ev.code)) {
|
||||
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
|
||||
setStatus("reconnecting");
|
||||
timer = window.setTimeout(() => {
|
||||
timer = null;
|
||||
if (disposed) return;
|
||||
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
|
||||
connect();
|
||||
}, delay);
|
||||
return;
|
||||
}
|
||||
setDetail(String(ev.code));
|
||||
setStatus("closed");
|
||||
onClosedRef.current?.();
|
||||
};
|
||||
}
|
||||
|
||||
reconnectRef.current = () => {
|
||||
if (disposed) return;
|
||||
setStatus("reconnecting");
|
||||
connect();
|
||||
};
|
||||
|
||||
setStatus("connecting");
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
reconnectRef.current = () => {};
|
||||
cancelTimer();
|
||||
closeSocket();
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
const reconnect = useCallback(() => reconnectRef.current(), []);
|
||||
|
||||
return useMemo(
|
||||
() => ({ rows, state, status, detail, socketGeneration, reconnect }),
|
||||
[rows, state, status, detail, socketGeneration, reconnect],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// @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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
// 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,163 @@
|
||||
// @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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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 />);
|
||||
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.ts";
|
||||
@@ -182,26 +182,32 @@ describe("sidebar preview requests (docs/PREVIEW.md)", () => {
|
||||
expect(seen.at(-1)).toBe(null);
|
||||
});
|
||||
|
||||
it("asks for a preview of the hovered card, and clears it on the way out", () => {
|
||||
it("ctrl+hover previews a card, plain hover does not", () => {
|
||||
const seen: (string | null)[] = [];
|
||||
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
||||
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
||||
|
||||
// Plain hover — no preview.
|
||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
||||
expect(seen).toEqual([]);
|
||||
|
||||
// Ctrl+hover — preview fires.
|
||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
|
||||
expect(seen).toEqual([A]);
|
||||
// Moving within the same card is not a new preview.
|
||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
||||
|
||||
// Deduplicated while still on the same card.
|
||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
|
||||
expect(seen).toHaveLength(1);
|
||||
|
||||
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
|
||||
expect(seen.at(-1)).toBe(null);
|
||||
});
|
||||
|
||||
it("ignores touch, where a tap already opens the session", () => {
|
||||
it("ignores touch even with ctrl", () => {
|
||||
const seen: (string | null)[] = [];
|
||||
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
||||
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch" }));
|
||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch", ctrlKey: true }));
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { OPEN_COMMAND_BAR_EVENT, renderSidebar } from "./catalog.js";
|
||||
import { sessionBarCandidates } from "./commandBar.js";
|
||||
import { matchAndRank } from "./matcher.js";
|
||||
import { matchAndRank } from "./utils/matcher.js";
|
||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
|
||||
+55
-26
@@ -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 } from "./toolIcon.js";
|
||||
import { splitCommand, toolIconSvg, TOOL_LABELS } from "./toolIcon.js";
|
||||
import { abbreviateCwd } from "./types.js";
|
||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||
|
||||
const STRUCTURED_VIEW_TOOLS = new Set(["claude", "codex"]);
|
||||
export const STRUCTURED_VIEW_TOOLS = new Set(["claude", "codex"]);
|
||||
|
||||
function supportsStructuredView(command: string): boolean {
|
||||
export function supportsStructuredView(command: string): boolean {
|
||||
const tool = splitCommand(command).tool;
|
||||
return tool !== null && STRUCTURED_VIEW_TOOLS.has(tool);
|
||||
}
|
||||
@@ -140,6 +140,12 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
const form = document.createElement("form");
|
||||
form.className = "spawn-form";
|
||||
form.innerHTML = `
|
||||
<div class="field">
|
||||
<span class="field-label">Harness</span>
|
||||
<div class="harness-grid cmd-tiles"></div>
|
||||
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
|
||||
placeholder="bash -l" autocomplete="off" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Project</span>
|
||||
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
||||
@@ -151,12 +157,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Command</span>
|
||||
<div class="tile-row cmd-tiles"></div>
|
||||
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
|
||||
placeholder="bash -l" autocomplete="off" />
|
||||
</div>
|
||||
<div class="advanced">
|
||||
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
|
||||
<span class="chevron">▾</span>
|
||||
@@ -348,37 +348,69 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
command = preset!.command;
|
||||
}
|
||||
updateStructuredAvailability();
|
||||
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
|
||||
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
|
||||
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === id);
|
||||
}
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Re-highlight the operator's already-picked tile after `renderCommandTiles`
|
||||
* rebuilds the row from scratch (new tiles carry no `is-active` class) —
|
||||
/** Re-highlight the operator's already-picked card after `renderCommandTiles`
|
||||
* rebuilds the grid from scratch (new cards carry no `is-active` class) —
|
||||
* without touching `command`, since that was already settled. */
|
||||
function reapplyActiveTile(): void {
|
||||
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId);
|
||||
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
|
||||
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === selectedPresetId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a preset id to a tool icon id. The preset's command is the
|
||||
* source of truth — `splitCommand` extracts the tool the same way the
|
||||
* sidebar does. For the "Custom…" preset (empty command) there is no
|
||||
* tool to show, and a terminal prompt stands in. */
|
||||
function presetToolId(p: SpawnPreset): string | null {
|
||||
if (!p.command) return null;
|
||||
return splitCommand(p.command).tool;
|
||||
}
|
||||
|
||||
function renderCommandTiles(): void {
|
||||
cmdTiles.replaceChildren();
|
||||
for (const p of presets) {
|
||||
const tile = document.createElement("button");
|
||||
tile.type = "button";
|
||||
tile.className = "tile";
|
||||
tile.dataset.id = p.id;
|
||||
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
|
||||
tile.addEventListener("click", () => {
|
||||
const isCustom = p.command === "";
|
||||
const card = document.createElement("button");
|
||||
card.type = "button";
|
||||
card.className = isCustom ? "harness-card harness-custom" : "harness-card";
|
||||
card.dataset.id = p.id;
|
||||
const toolId = presetToolId(p);
|
||||
const iconHtml = isCustom ? customIcon() : toolId ? harnessIcon(toolId) : harnessIcon("shell");
|
||||
const label = TOOL_LABELS[toolId ?? ""] ?? p.label;
|
||||
card.innerHTML =
|
||||
`<span class="harness-icon">${iconHtml}</span>` +
|
||||
`<span class="harness-label">${escapeHtml(isCustom ? "Custom\u2026" : label)}</span>`;
|
||||
card.addEventListener("click", () => {
|
||||
commandTouched = true;
|
||||
selectPreset(p.id);
|
||||
});
|
||||
cmdTiles.appendChild(tile);
|
||||
cmdTiles.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
/** Render a harness card icon at card size (24x24). Re-uses the SVG bodies
|
||||
* from toolIcon.ts but at a larger viewport so the marks breathe. */
|
||||
function harnessIcon(toolId: string): string {
|
||||
// toolIconSvg renders at 13x13 with viewBox 0 0 16 16 — we want the
|
||||
// same shape at 24x24 for the card context.
|
||||
const small = toolIconSvg(toolId);
|
||||
if (!small) return "";
|
||||
return small
|
||||
.replace(/width="13"/g, 'width="24"')
|
||||
.replace(/height="13"/g, 'height="24"');
|
||||
}
|
||||
|
||||
/** An ellipsis mark for the "Custom…" escape hatch. */
|
||||
function customIcon(): string {
|
||||
return `<svg class="tool-icon" viewBox="0 0 16 16" width="24" height="24" role="img" aria-label="Custom command"><title>Custom command</title><g fill="currentColor"><circle cx="4" cy="8" r="1.3"/><circle cx="8" cy="8" r="1.3"/><circle cx="12" cy="8" r="1.3"/></g></svg>`;
|
||||
}
|
||||
|
||||
// Set once the operator picks a tile or types a custom command — guards
|
||||
// `applyInitialCommand()` the same way `nameTouched` guards a late name
|
||||
// batch: a late `/api/info` resolution must never overwrite a field the
|
||||
@@ -648,16 +680,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
box.type = "button";
|
||||
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
|
||||
box.dataset.cwd = project.path;
|
||||
const trees = worktreesOf(group).length;
|
||||
// The parent directory only — the last segment is the name line above it.
|
||||
// Unabbreviated beyond `~`, since the stem is what tells two projects
|
||||
// sharing a basename apart (#95).
|
||||
const parent = abbreviateCwd(parentDir(project.path), homeDir);
|
||||
const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "";
|
||||
box.innerHTML = `
|
||||
<span class="project-name">${escapeHtml(project.name)}</span>
|
||||
<span class="project-path">${escapeHtml(parent)}</span>
|
||||
<span class="project-meta">${escapeHtml(meta)}</span>`;
|
||||
<span class="project-path">${escapeHtml(parent)}</span>`;
|
||||
box.addEventListener("click", () => selectProject(group));
|
||||
return box;
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ describe("spawnForm Enter-to-submit (#97)", () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
form.querySelector<HTMLButtonElement>('.cmd-tiles .tile[data-id="custom"]')!.click();
|
||||
form.querySelector<HTMLButtonElement>('.cmd-tiles .harness-card[data-id="custom"]')!.click();
|
||||
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||
raw.value = "bash -l";
|
||||
raw.dispatchEvent(new Event("input"));
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
|
||||
await drain();
|
||||
|
||||
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
|
||||
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
||||
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
|
||||
(t) => t.dataset.id === "zsh",
|
||||
)!;
|
||||
zshTile.click();
|
||||
@@ -88,7 +88,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
|
||||
document.body.appendChild(form);
|
||||
await drain();
|
||||
|
||||
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
||||
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
|
||||
(t) => t.dataset.id === "custom",
|
||||
)!;
|
||||
customTile.click();
|
||||
@@ -116,7 +116,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
|
||||
|
||||
// Untouched: the refined preset list is still free to settle the command.
|
||||
expect(values?.command).toBe("claude");
|
||||
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
||||
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
|
||||
(t) => t.dataset.id === "fish",
|
||||
);
|
||||
expect(fishTile).toBeDefined();
|
||||
|
||||
@@ -52,14 +52,14 @@ describe("spawn modal", () => {
|
||||
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens with the project filter focused, ahead of the command tiles", async () => {
|
||||
it("opens with the project filter focused, harness before project", async () => {
|
||||
openSpawnModal({ onSuccess: () => {} });
|
||||
await flush();
|
||||
|
||||
const form = modal()!.querySelector("form.spawn-form")!;
|
||||
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
|
||||
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
|
||||
expect(fields.slice(0, 2)).toEqual(["Project", "Command"]);
|
||||
expect(fields.slice(0, 2)).toEqual(["Harness", "Project"]);
|
||||
});
|
||||
|
||||
it("titles a seeded open as a clone", async () => {
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
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>;
|
||||
@@ -0,0 +1,414 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
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>;
|
||||
+124
-5
@@ -446,6 +446,62 @@ describe("structured connection: data flow", () => {
|
||||
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] });
|
||||
@@ -790,7 +846,9 @@ describe("structured connection: turn/composer gating", () => {
|
||||
expect(send.disabled).toBe(false);
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
|
||||
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("1 message queued");
|
||||
const queued = conn.el.querySelectorAll(".st-row-queued");
|
||||
expect(queued).toHaveLength(1);
|
||||
expect(queued[0].textContent).toBe("queued next");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
@@ -810,7 +868,7 @@ describe("structured connection: turn/composer gating", () => {
|
||||
submit("second");
|
||||
submit("third");
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
||||
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("2 messages queued");
|
||||
expect(conn.el.querySelectorAll(".st-row-queued")).toHaveLength(2);
|
||||
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
@@ -819,7 +877,7 @@ describe("structured connection: turn/composer gating", () => {
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(3, "s1", "third");
|
||||
expect((conn.el.querySelector(".structured-queue") as HTMLElement).hidden).toBe(true);
|
||||
expect(conn.el.querySelectorAll(".st-row-queued")).toHaveLength(0);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
@@ -933,7 +991,7 @@ describe("structured connection: approvals", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("removes the buttons once the approval resolves, even with no accompanying transcript delta", () => {
|
||||
it("hides a standalone resolved approval entirely when no tool group absorbs it", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
@@ -949,7 +1007,68 @@ describe("structured connection: approvals", () => {
|
||||
// Resolved on another client: state clears with no transcript delta at all.
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
expect(conn.el.querySelector(".st-approval-acts")).toBeNull();
|
||||
expect(conn.el.querySelector(".st-approval-resolved")).not.toBeNull();
|
||||
// Standalone resolved approval is hidden — no card, no compact line.
|
||||
expect(conn.el.querySelector(".st-row-approval")).toBeNull();
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("absorbs a resolved approval into the tool group as a compact line", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 0,
|
||||
rows: [
|
||||
row("user:1", "user", { content: { text: "go" } }),
|
||||
toolRow("tool:1", "first"),
|
||||
row("approval:1", "approval", { toolCall: { title: "Write file.ts" }, options: allApprovalOptions }),
|
||||
toolRow("tool:2", "second"),
|
||||
],
|
||||
},
|
||||
});
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
// 2+ tools in the turn → tool group exists; resolved approval is inside it.
|
||||
const group = conn.el.querySelector(".st-tool-group");
|
||||
expect(group).not.toBeNull();
|
||||
const compact = group!.querySelector(".st-approval-compact");
|
||||
expect(compact).not.toBeNull();
|
||||
expect(compact!.textContent).toContain("Write file.ts");
|
||||
expect(compact!.textContent).toContain("approved");
|
||||
// No full-card approval anywhere on the transcript.
|
||||
expect(conn.el.querySelector(".st-approval")).toBeNull();
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("keeps a pending approval standalone even when a tool group exists in the same turn", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 0,
|
||||
rows: [
|
||||
row("user:1", "user", { content: { text: "go" } }),
|
||||
toolRow("tool:1", "first"),
|
||||
row("approval:1", "approval", { toolCall: { title: "Write file.ts" }, options: allApprovalOptions }),
|
||||
toolRow("tool:2", "second"),
|
||||
],
|
||||
},
|
||||
});
|
||||
ws.emit({
|
||||
type: "reduced_state",
|
||||
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
||||
});
|
||||
// Pending approval stays outside the tool group as a full card.
|
||||
const approvalCard = conn.el.querySelector(".st-approval");
|
||||
expect(approvalCard).not.toBeNull();
|
||||
expect(conn.el.querySelectorAll(".st-approval-acts button").length).toBeGreaterThan(0);
|
||||
// It should not be inside the tool group.
|
||||
const group = conn.el.querySelector(".st-tool-group");
|
||||
expect(group).not.toBeNull();
|
||||
expect(group!.querySelector(".st-approval")).toBeNull();
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
|
||||
+169
-45
@@ -85,10 +85,10 @@ export function createStructuredConnection(
|
||||
<div class="structured-main">
|
||||
<div class="structured-scroll">
|
||||
<div class="structured-rows"></div>
|
||||
</div>
|
||||
<div class="structured-activity" role="status" aria-live="polite" hidden>
|
||||
<span class="structured-activity-pulse" aria-hidden="true"></span>
|
||||
<span class="structured-activity-text">Thinking</span>
|
||||
<div class="structured-activity" role="status" aria-live="polite" hidden>
|
||||
<span class="structured-activity-pulse" aria-hidden="true"></span>
|
||||
<span class="structured-activity-text">Thinking</span>
|
||||
</div>
|
||||
</div>
|
||||
<form class="structured-composer">
|
||||
<button type="button" class="btn btn-ghost btn-icon structured-attach" aria-label="Attach files" title="Attach files (up to 20 MiB each)">
|
||||
@@ -117,7 +117,6 @@ export function createStructuredConnection(
|
||||
<svg class="structured-cancel-spinner" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="50 14" stroke-linecap="round"/></svg>
|
||||
<svg class="structured-cancel-stop" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<span class="structured-queue" role="status" aria-live="polite" hidden></span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,7 +138,6 @@ export function createStructuredConnection(
|
||||
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
|
||||
const sendLabel = sendBtn.querySelector("span") as HTMLSpanElement;
|
||||
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
|
||||
const queueEl = el.querySelector(".structured-queue") as HTMLSpanElement;
|
||||
const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement;
|
||||
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
|
||||
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
|
||||
@@ -253,6 +251,8 @@ export function createStructuredConnection(
|
||||
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,6 +383,9 @@ export function createStructuredConnection(
|
||||
}
|
||||
}
|
||||
|
||||
/** Pattern matching `Attached file "name": "path"` references appended by dispatchPrompt. */
|
||||
const attachedFileRe = /Attached file "(?:[^"\\]|\\.)*": "((?:[^"\\]|\\.)*)"/g;
|
||||
|
||||
function buildRowElement(row: TranscriptRow): HTMLElement | null {
|
||||
const el = buildTranscriptRow(
|
||||
row,
|
||||
@@ -399,16 +402,51 @@ 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 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. 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, in order). */
|
||||
/** 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>();
|
||||
@@ -422,33 +460,44 @@ export function createStructuredConnection(
|
||||
// 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 text rows that might be intermediate (not yet confirmed by a
|
||||
// Pending rows that might be intermediate (not yet confirmed by a
|
||||
// following tool). If no tool follows, they stay standalone.
|
||||
let pendingText: TranscriptRow[] = [];
|
||||
let pending: TranscriptRow[] = [];
|
||||
let currentGid = `turn:${turnId}`;
|
||||
for (const row of rows) {
|
||||
if (row.kind === "user") {
|
||||
turnId = row.id;
|
||||
currentGid = `turn:${turnId}`;
|
||||
pendingText = [];
|
||||
pending = [];
|
||||
continue;
|
||||
}
|
||||
const count = toolCounts.get(currentGid) ?? 0;
|
||||
if (count < 2) continue;
|
||||
if (row.kind === "agent_text" || row.kind === "agent_thought") {
|
||||
pendingText.push(row);
|
||||
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 text — a tool follows, so it's intermediate.
|
||||
group.rows.push(...pendingText);
|
||||
pendingText = [];
|
||||
// Flush any pending rows — a tool follows, so they're intermediate.
|
||||
group.rows.push(...pending);
|
||||
pending = [];
|
||||
group.rows.push(row);
|
||||
groups.set(currentGid, group);
|
||||
}
|
||||
}
|
||||
// pendingText at end of a turn = final text, stays outside (not flushed).
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -463,6 +512,7 @@ export function createStructuredConnection(
|
||||
const liveGroupIds = new Set([...groups.keys()]);
|
||||
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
|
||||
|
||||
const prevGroupEls = new Map(groupEls);
|
||||
rowEls.clear();
|
||||
groupEls.clear();
|
||||
const children: HTMLElement[] = [];
|
||||
@@ -481,25 +531,43 @@ export function createStructuredConnection(
|
||||
rowEls.set(memberRow.id, memberEl);
|
||||
return [memberEl];
|
||||
});
|
||||
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");
|
||||
|
||||
// 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);
|
||||
}
|
||||
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);
|
||||
@@ -551,6 +619,24 @@ 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);
|
||||
@@ -595,12 +681,14 @@ export function createStructuredConnection(
|
||||
}
|
||||
}
|
||||
|
||||
let configDirty = false;
|
||||
function renderConfig(): void {
|
||||
const options = sessionConfigOptions();
|
||||
configEl.hidden = options.length === 0;
|
||||
if (options.length === 0) {
|
||||
configMenu.hidden = true;
|
||||
configTrigger.setAttribute("aria-expanded", "false");
|
||||
configDirty = false;
|
||||
return;
|
||||
}
|
||||
const ordered = [...options].sort((a, b) => {
|
||||
@@ -614,6 +702,15 @@ export function createStructuredConnection(
|
||||
configTrigger.title = summary;
|
||||
configTrigger.setAttribute("aria-label", `Session settings. ${summary.replaceAll("\n", ". ")}`);
|
||||
configTrigger.disabled = configBusy;
|
||||
|
||||
// When the menu is open and the user is interacting (not mid-change),
|
||||
// skip the DOM rebuild so open <select> dropdowns aren't destroyed.
|
||||
// Mark dirty so the next open rebuilds with fresh data.
|
||||
if (!configMenu.hidden && !configBusy) {
|
||||
configDirty = true;
|
||||
return;
|
||||
}
|
||||
configDirty = false;
|
||||
configMenu.replaceChildren();
|
||||
for (const option of ordered) {
|
||||
const row = document.createElement("label");
|
||||
@@ -652,6 +749,22 @@ 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 projectedActive = state.turn.responding || state.turn.thinking;
|
||||
input.disabled = false;
|
||||
@@ -662,10 +775,7 @@ export function createStructuredConnection(
|
||||
sendBtn.hidden = false;
|
||||
sendLabel.textContent = "Send";
|
||||
cancelBtn.hidden = !projectedActive;
|
||||
queueEl.hidden = promptQueue.length === 0;
|
||||
queueEl.textContent = promptQueue.length === 1
|
||||
? "1 message queued"
|
||||
: `${promptQueue.length} messages queued`;
|
||||
renderQueuedPrompts();
|
||||
renderActivity();
|
||||
}
|
||||
|
||||
@@ -677,6 +787,7 @@ export function createStructuredConnection(
|
||||
|
||||
function renderActivity(): void {
|
||||
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
||||
const wasHidden = activityEl.hidden;
|
||||
activityEl.hidden = !active;
|
||||
if (!active) {
|
||||
optimisticTurnStartedAt = null;
|
||||
@@ -699,6 +810,11 @@ export function createStructuredConnection(
|
||||
}
|
||||
activityText.textContent = parts.join(" · ");
|
||||
if (activityTimer === null) activityTimer = window.setInterval(renderActivity, 1_000);
|
||||
// Activity lives inside the scroll area; nudge scroll when it first appears.
|
||||
if (wasHidden && followingBottom) {
|
||||
beginTranscriptLayout(true);
|
||||
finishTranscriptLayout();
|
||||
}
|
||||
}
|
||||
|
||||
function reconcileAwaitingBegin(): void {
|
||||
@@ -714,15 +830,17 @@ export function createStructuredConnection(
|
||||
}
|
||||
|
||||
function applyState(next: ReducedState, connectionGeneration: number): void {
|
||||
const wasTurnActive = state.turn.responding || state.turn.thinking;
|
||||
state = next;
|
||||
stateSocketGeneration = connectionGeneration;
|
||||
reconcileAwaitingBegin();
|
||||
const isTurnActive = state.turn.responding || state.turn.thinking;
|
||||
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
|
||||
if (newPendingId !== pendingApprovalRowId) {
|
||||
const needsRebuild = newPendingId !== pendingApprovalRowId || wasTurnActive !== isTurnActive;
|
||||
if (needsRebuild) {
|
||||
updateTranscriptLayout(() => {
|
||||
const prev = pendingApprovalRowId;
|
||||
pendingApprovalRowId = newPendingId;
|
||||
if (prev !== null || newPendingId !== null) rebuildTranscript();
|
||||
rebuildTranscript();
|
||||
});
|
||||
}
|
||||
renderConfig();
|
||||
@@ -737,6 +855,7 @@ export function createStructuredConnection(
|
||||
|
||||
configTrigger.addEventListener("click", () => {
|
||||
const opening = configMenu.hidden;
|
||||
if (opening && configDirty) renderConfig();
|
||||
configMenu.hidden = !opening;
|
||||
configTrigger.setAttribute("aria-expanded", String(opening));
|
||||
});
|
||||
@@ -943,6 +1062,9 @@ 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 }) =>
|
||||
@@ -1182,6 +1304,8 @@ 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);
|
||||
|
||||
@@ -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,13 +368,14 @@ describe("buildTranscriptRow: approval", () => {
|
||||
expect(el.querySelector(".st-approval-path")!.textContent).toBe("/etc/x");
|
||||
});
|
||||
|
||||
it("hides the buttons and shows an answered note once resolved", () => {
|
||||
it("renders a compact one-liner once resolved", () => {
|
||||
const el = buildTranscriptRow(approvalRow(allThreeOptions), {
|
||||
pending: false,
|
||||
onDecide: () => Promise.resolve(),
|
||||
});
|
||||
expect(el.querySelector(".st-approval-acts")).toBeNull();
|
||||
expect(el.querySelector(".st-approval-resolved")!.textContent).toBe("answered");
|
||||
expect(el.classList.contains("st-approval-compact")).toBe(true);
|
||||
expect(el.textContent).toContain("approved");
|
||||
});
|
||||
|
||||
it("invokes onDecide with the clicked decision and disables the buttons", () => {
|
||||
|
||||
+46
-41
@@ -223,15 +223,16 @@ function buildDiffBlock(diff: { path: string; oldText?: string | null; newText:
|
||||
wrap.appendChild(label);
|
||||
const scroller = el("div", "st-diff");
|
||||
const pre = document.createElement("pre");
|
||||
for (const line of computeDiffLines(diff.oldText, diff.newText)) {
|
||||
const lines = computeDiffLines(diff.oldText, diff.newText);
|
||||
const prefixes: Record<string, string> = { add: "+ ", del: "- ", ctx: " ", hunk: "", eof: "" };
|
||||
for (const line of lines) {
|
||||
const span = document.createElement("span");
|
||||
span.className = `st-ln st-ln-${line.kind}`;
|
||||
span.textContent = line.text;
|
||||
span.textContent = (prefixes[line.kind] ?? "") + line.text;
|
||||
pre.appendChild(span);
|
||||
pre.appendChild(document.createTextNode("\n"));
|
||||
}
|
||||
scroller.appendChild(pre);
|
||||
makeCopyable(scroller, pre.textContent ?? "", "diff");
|
||||
makeCopyable(scroller, lines.map((l) => (prefixes[l.kind] ?? "") + l.text).join("\n"), "diff");
|
||||
wrap.appendChild(scroller);
|
||||
return wrap;
|
||||
}
|
||||
@@ -704,6 +705,16 @@ function offeredDecisions(options: AcpPermissionOption[]): StructuredApprovalDec
|
||||
function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLElement {
|
||||
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
|
||||
const toolCall = request.toolCall ?? {};
|
||||
|
||||
// Resolved approvals render as a compact one-liner inside a tool group
|
||||
// (or are hidden entirely when standalone — see rebuildTranscript).
|
||||
if (!approval.pending) {
|
||||
const wrap = el("div", "st-row st-row-approval st-approval-compact");
|
||||
const label = toolCall.title ?? toolKindLabel(toolCall.kind);
|
||||
wrap.textContent = `${label} — approved`;
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const wrap = el("div", "st-row st-row-approval");
|
||||
const card = el("div", "st-approval");
|
||||
wrap.appendChild(card);
|
||||
@@ -723,45 +734,39 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
|
||||
card.appendChild(pathEl);
|
||||
}
|
||||
|
||||
if (approval.pending) {
|
||||
const acts = el("div", "st-approval-acts");
|
||||
const mk = (label: string, decision: StructuredApprovalDecision, cls: string) => {
|
||||
const btn = document.createElement("button");
|
||||
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));
|
||||
});
|
||||
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);
|
||||
} else {
|
||||
const none = el("div", "st-approval-resolved");
|
||||
none.textContent = "no decision offered";
|
||||
card.appendChild(none);
|
||||
}
|
||||
});
|
||||
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 resolved = el("div", "st-approval-resolved");
|
||||
resolved.textContent = "answered";
|
||||
card.appendChild(resolved);
|
||||
const none = el("div", "st-approval-resolved");
|
||||
none.textContent = "no decision offered";
|
||||
card.appendChild(none);
|
||||
}
|
||||
|
||||
return wrap;
|
||||
|
||||
+84
-57
@@ -2318,9 +2318,7 @@ input.leaf-rename:focus {
|
||||
gap: var(--sp-2);
|
||||
min-height: 30px;
|
||||
padding: var(--sp-1) var(--sp-3);
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
background: var(--surface-2);
|
||||
font-size: var(--fs-11);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -2377,11 +2375,36 @@ 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);
|
||||
@@ -2484,7 +2507,8 @@ input.leaf-rename:focus {
|
||||
|
||||
.st-row-thought {
|
||||
color: var(--faint);
|
||||
font-style: italic;
|
||||
font-size: var(--fs-12);
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -2967,6 +2991,7 @@ input.leaf-rename:focus {
|
||||
display: block;
|
||||
white-space: pre;
|
||||
padding: 0 var(--sp-3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.st-ln-add {
|
||||
@@ -3040,6 +3065,16 @@ input.leaf-rename:focus {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
/* compact resolved approval inside a tool group — same muted treatment
|
||||
as intermediate agent text so the audit trail is preserved without
|
||||
drawing attention to itself. */
|
||||
.st-approval-compact {
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-12);
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.act {
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
@@ -3104,18 +3139,6 @@ input.leaf-rename:focus {
|
||||
transition: border-color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.structured-queue {
|
||||
position: absolute;
|
||||
right: var(--sp-2);
|
||||
bottom: calc(100% + var(--sp-1));
|
||||
padding: 3px var(--sp-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-11);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.structured-composer:focus-within {
|
||||
border-top-color: var(--border);
|
||||
@@ -3651,61 +3674,78 @@ input.leaf-rename:focus {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* One-click tiles: host row + command row. */
|
||||
.tile-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
/* Harness cards: the first decision in the spawn form — which tool runs. */
|
||||
.harness-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.tile {
|
||||
flex: 1 1 auto;
|
||||
min-width: 96px;
|
||||
.harness-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: flex-start;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3) var(--sp-2);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
color: var(--ink);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
text-align: center;
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.tile:hover {
|
||||
.harness-card:hover {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.tile.is-active {
|
||||
.harness-card.is-active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
|
||||
.tile-label {
|
||||
font-size: var(--fs-14);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tile-sub {
|
||||
font-size: var(--fs-12);
|
||||
color: var(--muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.tile-more {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
.harness-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.harness-card.is-active .harness-icon {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.harness-icon .tool-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.harness-label {
|
||||
font-size: var(--fs-13);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* The "Custom…" card is an escape hatch, not a peer tool — dashed border
|
||||
and subdued color separate it from the named harnesses. */
|
||||
.harness-custom {
|
||||
border-style: dashed;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.harness-custom .harness-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
|
||||
picked project's worktree choices. Stale beyond 7 days dims but stays
|
||||
selectable. */
|
||||
@@ -3732,7 +3772,7 @@ input.leaf-rename:focus {
|
||||
|
||||
.picker.projects {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
align-content: start;
|
||||
gap: var(--sp-2);
|
||||
max-height: 300px;
|
||||
@@ -3788,19 +3828,6 @@ input.leaf-rename:focus {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-12);
|
||||
color: var(--faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-meta:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.project-other {
|
||||
align-self: flex-start;
|
||||
padding: 4px 0;
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ export type SessionStatus = "Running" | "Dead";
|
||||
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
|
||||
* Absent on payloads from daemons that predate the field. `Error` is only set
|
||||
* when an agent reports failure (#105); the daemon never infers it. */
|
||||
export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error";
|
||||
export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error" | "Starting";
|
||||
|
||||
export interface Session {
|
||||
id: Uuid;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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;
|
||||
@@ -4,6 +4,7 @@
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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:
|
||||
@@ -18,6 +20,7 @@ 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",
|
||||
|
||||
Reference in New Issue
Block a user