Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
6ca2ab1707 | ||
|
|
b0b0160ccb | ||
|
|
c4cd8a691e | ||
|
|
0381a571f2 | ||
|
|
8f3d47c49e | ||
|
|
ba0c54759b | ||
|
|
78912e7925 | ||
|
|
fef9be6c01 | ||
|
|
5387b29049 | ||
|
|
88afd0df3f | ||
|
|
df51aa7be9 | ||
|
|
ac8a1b319d | ||
|
|
17a57269f7 | ||
|
|
43bfda5a60 | ||
|
|
6ffbca3596 | ||
|
|
2cde58b42f | ||
|
|
721c34aa11 | ||
|
|
a7d8b5e22f | ||
|
|
2f695e1354 | ||
|
|
1e30b9ade1 | ||
|
|
b4d4b0712c | ||
|
|
4df8fdb7dc | ||
|
|
b949cffb9e | ||
|
|
25653abcf5 | ||
|
|
f78aa13b36 | ||
|
|
0dc10ffe76 |
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(git fetch:*)",
|
||||||
|
"Bash(git merge:*)",
|
||||||
|
"Bash(npx tsc:*)",
|
||||||
|
"Bash(npm run build:*)",
|
||||||
|
"Bash(npm install:*)",
|
||||||
|
"Bash(./node_modules/.bin/tsc:*)",
|
||||||
|
"Bash(npm test:*)",
|
||||||
|
"Bash(cargo check:*)",
|
||||||
|
"Bash(cargo clippy:*)",
|
||||||
|
"Bash(cargo test:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,8 +41,8 @@ pub enum Command {
|
|||||||
Attach(AttachArgs),
|
Attach(AttachArgs),
|
||||||
/// Kill a session by ID.
|
/// Kill a session by ID.
|
||||||
Kill(KillArgs),
|
Kill(KillArgs),
|
||||||
/// Type text into a running session, as if it had been typed on an
|
/// Send text to a running session. Terminal sessions receive raw
|
||||||
/// attached terminal.
|
/// keystrokes; structured sessions receive an ACP prompt.
|
||||||
Send(SendArgs),
|
Send(SendArgs),
|
||||||
/// Install or update the agent activity hook (#106).
|
/// Install or update the agent activity hook (#106).
|
||||||
Hooks(HooksArgs),
|
Hooks(HooksArgs),
|
||||||
@@ -92,6 +92,14 @@ pub struct SpawnArgs {
|
|||||||
/// there.
|
/// there.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub existing_branch: bool,
|
pub existing_branch: bool,
|
||||||
|
/// Run a Claude Code or Codex session in a plain terminal instead of the
|
||||||
|
/// structured (ACP) view it otherwise gets by default.
|
||||||
|
#[arg(long, conflicts_with = "structured")]
|
||||||
|
pub terminal: bool,
|
||||||
|
/// Ask for the structured (ACP) view explicitly. Only Claude Code and
|
||||||
|
/// Codex commands have an adapter; the daemon refuses anything else.
|
||||||
|
#[arg(long)]
|
||||||
|
pub structured: bool,
|
||||||
/// After spawning, attach immediately.
|
/// After spawning, attach immediately.
|
||||||
#[arg(long, short = 'a')]
|
#[arg(long, short = 'a')]
|
||||||
pub attach: bool,
|
pub attach: bool,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
use maestro_protocol::display::abbreviate_dir;
|
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
||||||
Session, SessionStatus, SessionView, WorktreeSpec,
|
Session, SessionStatus, SessionView, WorktreeSpec,
|
||||||
@@ -51,16 +51,33 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
|||||||
};
|
};
|
||||||
|
|
||||||
let body = CreateSessionRequest {
|
let body = CreateSessionRequest {
|
||||||
view: Default::default(),
|
view: spawn_view(&args),
|
||||||
cwd: args.cwd.clone(),
|
cwd: args.cwd.clone(),
|
||||||
command: args.command.clone(),
|
command: args.command.clone(),
|
||||||
env,
|
env,
|
||||||
metadata,
|
metadata,
|
||||||
worktree,
|
worktree,
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
client.create_session(&body).await
|
client.create_session(&body).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The view a `maestro spawn` asks for: structured whenever the command
|
||||||
|
/// resolves to an agent with an ACP adapter, since that is the richer session
|
||||||
|
/// and the one both other clients now default to. `--terminal` opts out;
|
||||||
|
/// `--structured` forces the request through even for a command this side
|
||||||
|
/// does not recognise, leaving the refusal to the daemon.
|
||||||
|
fn spawn_view(args: &SpawnArgs) -> SessionView {
|
||||||
|
if args.terminal {
|
||||||
|
return SessionView::Terminal;
|
||||||
|
}
|
||||||
|
if args.structured || supports_structured_view(&args.command) {
|
||||||
|
SessionView::Structured
|
||||||
|
} else {
|
||||||
|
SessionView::Terminal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
|
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
|
||||||
/// of removing it; the outcome names what was kept so dispatch can say where
|
/// of removing it; the outcome names what was kept so dispatch can say where
|
||||||
/// the work went.
|
/// the work went.
|
||||||
@@ -113,9 +130,12 @@ pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid,
|
|||||||
/// `maestro send`. Answers with the session that was typed into, so the
|
/// `maestro send`. Answers with the session that was typed into, so the
|
||||||
/// caller can name it once the daemon has accepted the text.
|
/// caller can name it once the daemon has accepted the text.
|
||||||
///
|
///
|
||||||
/// A bare ID skips the catalog fetch; anything else needs the list to match
|
/// Terminal sessions get raw keystrokes via `send_input`; structured sessions
|
||||||
/// against. Text comes from the arguments, or from stdin when there is none —
|
/// get an ACP prompt via `send_structured_prompt` (`--no-enter` is ignored
|
||||||
/// the shape a heredoc or a pipe wants.
|
/// there — ACP prompts are always complete messages).
|
||||||
|
///
|
||||||
|
/// Text comes from the arguments, or from stdin when there is none — the
|
||||||
|
/// shape a heredoc or a pipe wants.
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
pub async fn run_send(
|
pub async fn run_send(
|
||||||
client: &DaemonClient,
|
client: &DaemonClient,
|
||||||
@@ -138,19 +158,28 @@ pub async fn run_send(
|
|||||||
return Err(CliError::NothingToSend);
|
return Err(CliError::NothingToSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
let id = match uuid::Uuid::parse_str(args.session.trim()) {
|
// We always need the session list: even a bare UUID needs the view field
|
||||||
Ok(id) => id,
|
// to decide which endpoint to hit.
|
||||||
Err(_) => {
|
let sessions = client.list_sessions().await?;
|
||||||
let sessions = client.list_sessions().await?;
|
let id = resolve_session(&sessions, &args.session)?;
|
||||||
resolve_session(&sessions, &args.session)?
|
let view = sessions
|
||||||
}
|
.iter()
|
||||||
};
|
.find(|s| s.id == id)
|
||||||
|
.map(|s| s.view)
|
||||||
|
.unwrap_or(SessionView::Terminal);
|
||||||
|
|
||||||
let req = SendInputRequest {
|
match view {
|
||||||
text,
|
SessionView::Structured => {
|
||||||
submit: !args.no_enter,
|
client.send_structured_prompt(id, &text).await?;
|
||||||
};
|
}
|
||||||
client.send_input(id, &req).await?;
|
SessionView::Terminal => {
|
||||||
|
let req = SendInputRequest {
|
||||||
|
text,
|
||||||
|
submit: !args.no_enter,
|
||||||
|
};
|
||||||
|
client.send_input(id, &req).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +228,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
ActivityState::Idle => "idle",
|
ActivityState::Idle => "idle",
|
||||||
ActivityState::AwaitingInput => "awaiting",
|
ActivityState::AwaitingInput => "awaiting",
|
||||||
ActivityState::Error => "error",
|
ActivityState::Error => "error",
|
||||||
|
ActivityState::Starting => "starting",
|
||||||
};
|
};
|
||||||
// Show the live foreground command alongside the spawn command when
|
// Show the live foreground command alongside the spawn command when
|
||||||
// the daemon has observed one that differs (NAP-340).
|
// the daemon has observed one that differs (NAP-340).
|
||||||
@@ -239,6 +269,62 @@ mod tests {
|
|||||||
|
|
||||||
use super::DaemonInfo;
|
use super::DaemonInfo;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
use crate::cli::{Cli, Command};
|
||||||
|
|
||||||
|
fn spawn_args(extra: &[&str]) -> crate::cli::SpawnArgs {
|
||||||
|
let mut argv = vec!["maestro", "spawn", "--cwd", "/tmp", "--command"];
|
||||||
|
argv.extend_from_slice(extra);
|
||||||
|
let cli = Cli::try_parse_from(argv).expect("parses");
|
||||||
|
let Command::Spawn(args) = cli.command else {
|
||||||
|
panic!("expected spawn")
|
||||||
|
};
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The CLI follows the two interactive clients: a command with an ACP
|
||||||
|
/// adapter spawns structured unless `--terminal` says otherwise, and
|
||||||
|
/// `--structured` still forces the request for a command this side does
|
||||||
|
/// not recognise.
|
||||||
|
#[test]
|
||||||
|
fn spawn_defaults_to_structured_for_supported_agents() {
|
||||||
|
use super::spawn_view;
|
||||||
|
assert_eq!(
|
||||||
|
spawn_view(&spawn_args(&["claude"])),
|
||||||
|
SessionView::Structured
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
spawn_view(&spawn_args(&["npx codex exec"])),
|
||||||
|
SessionView::Structured
|
||||||
|
);
|
||||||
|
assert_eq!(spawn_view(&spawn_args(&["bash"])), SessionView::Terminal);
|
||||||
|
assert_eq!(
|
||||||
|
spawn_view(&spawn_args(&["claude", "--terminal"])),
|
||||||
|
SessionView::Terminal
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
spawn_view(&spawn_args(&["opencode", "--structured"])),
|
||||||
|
SessionView::Structured
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spawn_refuses_both_view_flags_at_once() {
|
||||||
|
let err = Cli::try_parse_from([
|
||||||
|
"maestro",
|
||||||
|
"spawn",
|
||||||
|
"--cwd",
|
||||||
|
"/tmp",
|
||||||
|
"--command",
|
||||||
|
"claude",
|
||||||
|
"--structured",
|
||||||
|
"--terminal",
|
||||||
|
])
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("--terminal"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
fn session(cwd: &str, command: &str, id: u128) -> Session {
|
fn session(cwd: &str, command: &str, id: u128) -> Session {
|
||||||
Session {
|
Session {
|
||||||
view: Default::default(),
|
view: Default::default(),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! was unit-tested, but nothing exercised the client, the error decoding, or
|
//! was unit-tested, but nothing exercised the client, the error decoding, or
|
||||||
//! the unreachable-daemon path without compiling and forking a real daemon.
|
//! the unreachable-daemon path without compiling and forking a real daemon.
|
||||||
|
|
||||||
use maestro_protocol::{DaemonClient, DaemonInfo};
|
use maestro_protocol::{DaemonClient, DaemonInfo, SessionView};
|
||||||
use maestro_testing::{session, MockDaemon};
|
use maestro_testing::{session, MockDaemon};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ async fn info_carries_version_and_presets() {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
}],
|
}],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
@@ -97,6 +98,7 @@ async fn spawn_round_trips_through_the_daemon() {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata: Default::default(),
|
metadata: Default::default(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -200,6 +202,35 @@ async fn send_resolves_a_session_by_name() {
|
|||||||
assert_eq!(sent, id);
|
assert_eq!(sent, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A structured session routes through the ACP prompt endpoint, not the
|
||||||
|
/// terminal input one — `--no-enter` is irrelevant (ACP prompts are
|
||||||
|
/// complete messages).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_to_structured_session_uses_acp_prompt() {
|
||||||
|
let mock = MockDaemon::start().await;
|
||||||
|
let id = Uuid::from_u128(13);
|
||||||
|
let mut s = session(id, "/tmp", "claude");
|
||||||
|
s.view = SessionView::Structured;
|
||||||
|
mock.add_session(s);
|
||||||
|
|
||||||
|
let args = maestro_cli::cli::SendArgs {
|
||||||
|
session: id.to_string(),
|
||||||
|
text: vec!["fix".into(), "the".into(), "tests".into()],
|
||||||
|
no_enter: false,
|
||||||
|
};
|
||||||
|
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
||||||
|
.await
|
||||||
|
.expect("send");
|
||||||
|
|
||||||
|
assert_eq!(sent, id);
|
||||||
|
// Terminal input must stay empty — the message went through ACP.
|
||||||
|
assert!(mock.inputs().is_empty());
|
||||||
|
let prompts = mock.structured_prompts();
|
||||||
|
assert_eq!(prompts.len(), 1);
|
||||||
|
assert_eq!(prompts[0].0, id);
|
||||||
|
assert_eq!(prompts[0].1.prompt, "fix the tests");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
||||||
let mock = MockDaemon::start().await;
|
let mock = MockDaemon::start().await;
|
||||||
|
|||||||
@@ -313,16 +313,19 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "opencode".into(),
|
id: "opencode".into(),
|
||||||
label: "opencode".into(),
|
label: "opencode".into(),
|
||||||
command: "opencode".into(),
|
command: "opencode".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "zsh".into(),
|
id: "zsh".into(),
|
||||||
label: "Shell".into(),
|
label: "Shell".into(),
|
||||||
command: "zsh".into(),
|
command: "zsh".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -334,6 +337,7 @@ pub fn custom_preset() -> SpawnPreset {
|
|||||||
id: CUSTOM_PRESET_ID.into(),
|
id: CUSTOM_PRESET_ID.into(),
|
||||||
label: "Custom…".into(),
|
label: "Custom…".into(),
|
||||||
command: String::new(),
|
command: String::new(),
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,6 +519,7 @@ auto_install = false
|
|||||||
id: "fish".into(),
|
id: "fish".into(),
|
||||||
label: "fish".into(),
|
label: "fish".into(),
|
||||||
command: "fish".into(),
|
command: "fish".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -551,6 +556,7 @@ auto_install = false
|
|||||||
id: " ".into(),
|
id: " ".into(),
|
||||||
label: "x".into(),
|
label: "x".into(),
|
||||||
command: "x".into(),
|
command: "x".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -561,6 +567,7 @@ auto_install = false
|
|||||||
id: "x".into(),
|
id: "x".into(),
|
||||||
label: "x".into(),
|
label: "x".into(),
|
||||||
command: "".into(),
|
command: "".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -578,11 +585,13 @@ auto_install = false
|
|||||||
id: "a".into(),
|
id: "a".into(),
|
||||||
label: "a".into(),
|
label: "a".into(),
|
||||||
command: "a".into(),
|
command: "a".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "a".into(),
|
id: "a".into(),
|
||||||
label: "a2".into(),
|
label: "a2".into(),
|
||||||
command: "a2".into(),
|
command: "a2".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -597,6 +606,7 @@ auto_install = false
|
|||||||
id: "custom".into(),
|
id: "custom".into(),
|
||||||
label: "Custom".into(),
|
label: "Custom".into(),
|
||||||
command: "whatever".into(),
|
command: "whatever".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -80,13 +80,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_repo_dir_is_none() {
|
fn non_repo_dir_does_not_find_root_inside_fixture() {
|
||||||
// A temp dir with no `.git` anywhere up the chain. Use the temp root so
|
// The fixture has no `.git`, so `find_root` must not return a path
|
||||||
// the walk can't accidentally hit a real repo above the test's cwd.
|
// 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 tmp = tempfile::tempdir().unwrap();
|
||||||
let sub = tmp.path().join("plain/dir");
|
let sub = tmp.path().join("plain/dir");
|
||||||
fs::create_dir_all(&sub).unwrap();
|
fs::create_dir_all(&sub).unwrap();
|
||||||
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]
|
#[test]
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::body::Bytes;
|
||||||
|
use axum::extract::{DefaultBodyLimit, Path, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::{any, delete, get, post};
|
use axum::routing::{any, delete, get, post};
|
||||||
@@ -70,6 +71,10 @@ pub fn router(state: LocalApiState) -> Router {
|
|||||||
.route(routes::patterns::STRUCTURED, any(structured_ws))
|
.route(routes::patterns::STRUCTURED, any(structured_ws))
|
||||||
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
|
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
|
||||||
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
||||||
|
.route(
|
||||||
|
routes::patterns::STRUCTURED_ATTACHMENT,
|
||||||
|
post(structured_attachment).layer(DefaultBodyLimit::max(20 * 1024 * 1024)),
|
||||||
|
)
|
||||||
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
|
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
|
||||||
.route(routes::patterns::STRUCTURED_CONFIG, post(structured_config))
|
.route(routes::patterns::STRUCTURED_CONFIG, post(structured_config))
|
||||||
.route(
|
.route(
|
||||||
@@ -175,7 +180,7 @@ fn dir_error_status(code: &str) -> StatusCode {
|
|||||||
fn session_error_status(code: &str) -> StatusCode {
|
fn session_error_status(code: &str) -> StatusCode {
|
||||||
match code {
|
match code {
|
||||||
"not_found" => StatusCode::NOT_FOUND,
|
"not_found" => StatusCode::NOT_FOUND,
|
||||||
"invalid_env" => StatusCode::BAD_REQUEST,
|
"invalid_env" | "bad_request" => StatusCode::BAD_REQUEST,
|
||||||
// A guard refusal is the daemon doing its job, not a failure (#79).
|
// A guard refusal is the daemon doing its job, not a failure (#79).
|
||||||
"kill_guarded" => StatusCode::CONFLICT,
|
"kill_guarded" => StatusCode::CONFLICT,
|
||||||
// The session outlived its pane: nothing to type into, and nothing
|
// The session outlived its pane: nothing to type into, and nothing
|
||||||
@@ -432,13 +437,55 @@ async fn structured_replay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AttachmentQuery {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn structured_attachment(
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Query(query): Query<AttachmentQuery>,
|
||||||
|
bytes: Bytes,
|
||||||
|
) -> Response {
|
||||||
|
let name = query.name;
|
||||||
|
if name.is_empty()
|
||||||
|
|| name.len() > 180
|
||||||
|
|| name == "."
|
||||||
|
|| name == ".."
|
||||||
|
|| name
|
||||||
|
.chars()
|
||||||
|
.any(|c| c.is_control() || c == '/' || c == '\\')
|
||||||
|
{
|
||||||
|
return error_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid_filename",
|
||||||
|
"use a filename without directory separators, up to 180 bytes",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
match s.manager.store_acp_attachment(id, &name, &bytes).await {
|
||||||
|
Ok(Some(path)) => Json(serde_json::json!({ "path": path })).into_response(),
|
||||||
|
Ok(None) => error_response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"not_structured",
|
||||||
|
"session is not a live structured session",
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn structured_prompt(
|
async fn structured_prompt(
|
||||||
State(s): State<LocalApiState>,
|
State(s): State<LocalApiState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<StructuredPromptRequest>,
|
Json(body): Json<StructuredPromptRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
match s.manager.queue_acp_prompt(id, body.prompt).await {
|
match s.manager.queue_acp_prompt(id, body.prompt).await {
|
||||||
Ok(QueueAcpPrompt::Queued) => StatusCode::NO_CONTENT.into_response(),
|
Ok(QueueAcpPrompt::Queued) | Ok(QueueAcpPrompt::Pending) => {
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
}
|
||||||
Ok(QueueAcpPrompt::InFlight) => error_response(
|
Ok(QueueAcpPrompt::InFlight) => error_response(
|
||||||
StatusCode::CONFLICT,
|
StatusCode::CONFLICT,
|
||||||
"turn_in_flight",
|
"turn_in_flight",
|
||||||
|
|||||||
@@ -6,12 +6,9 @@
|
|||||||
//! handler interact with tmux.
|
//! handler interact with tmux.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::future::Future;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::pin::Pin;
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||||
use std::sync::{Arc, Weak};
|
use std::sync::{Arc, Weak};
|
||||||
use std::task::{Context, Poll};
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
@@ -122,6 +119,12 @@ pub struct SessionRecord {
|
|||||||
/// Completion signal for the task draining ACP notifications into the
|
/// Completion signal for the task draining ACP notifications into the
|
||||||
/// event log. Shutdown waits for it before returning.
|
/// event log. Shutdown waits for it before returning.
|
||||||
acp_event_pump: Option<watch::Receiver<bool>>,
|
acp_event_pump: Option<watch::Receiver<bool>>,
|
||||||
|
/// 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 {
|
impl SessionRecord {
|
||||||
@@ -131,6 +134,13 @@ impl SessionRecord {
|
|||||||
&& self.acp.is_some()
|
&& self.acp.is_some()
|
||||||
&& self.acp_events.is_some()
|
&& self.acp_events.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The session is structured and still booting its adapter.
|
||||||
|
fn is_starting_structured(&self) -> bool {
|
||||||
|
self.session.view == SessionView::Structured
|
||||||
|
&& self.session.activity == ActivityState::Starting
|
||||||
|
&& self.acp.is_none()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -205,6 +215,8 @@ pub enum SessionError {
|
|||||||
NotTerminal(Uuid),
|
NotTerminal(Uuid),
|
||||||
#[error("daemon is shutting down")]
|
#[error("daemon is shutting down")]
|
||||||
ShuttingDown,
|
ShuttingDown,
|
||||||
|
#[error("bad request: {0}")]
|
||||||
|
BadRequest(String),
|
||||||
/// The kill was refused (#79): the session's worktree holds unsaved work.
|
/// The kill was refused (#79): the session's worktree holds unsaved work.
|
||||||
/// Nothing happened — the session keeps running and the tree is untouched,
|
/// Nothing happened — the session keeps running and the tree is untouched,
|
||||||
/// so it stays the operator's handle on their own unfinished work.
|
/// so it stays the operator's handle on their own unfinished work.
|
||||||
@@ -224,6 +236,8 @@ pub enum BeginAcpTurn {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum QueueAcpPrompt {
|
pub enum QueueAcpPrompt {
|
||||||
Queued,
|
Queued,
|
||||||
|
/// The adapter is still booting; the prompt is held until it is ready.
|
||||||
|
Pending,
|
||||||
InFlight,
|
InFlight,
|
||||||
Unavailable,
|
Unavailable,
|
||||||
Failed(String),
|
Failed(String),
|
||||||
@@ -250,6 +264,7 @@ impl SessionError {
|
|||||||
SessionError::NotRunning(_) => "not_running",
|
SessionError::NotRunning(_) => "not_running",
|
||||||
SessionError::NotTerminal(_) => "not_terminal",
|
SessionError::NotTerminal(_) => "not_terminal",
|
||||||
SessionError::ShuttingDown => "shutting_down",
|
SessionError::ShuttingDown => "shutting_down",
|
||||||
|
SessionError::BadRequest(_) => "bad_request",
|
||||||
SessionError::KillGuarded(_) => "kill_guarded",
|
SessionError::KillGuarded(_) => "kill_guarded",
|
||||||
};
|
};
|
||||||
let unsaved_work = match self {
|
let unsaved_work = match self {
|
||||||
@@ -317,18 +332,6 @@ struct StructuredLifecycle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl StructuredLifecycle {
|
impl StructuredLifecycle {
|
||||||
fn admit<'a>(
|
|
||||||
&'a self,
|
|
||||||
shutting_down: &AtomicBool,
|
|
||||||
) -> Result<StructuredCreate<'a>, SessionError> {
|
|
||||||
let _gate = self.gate.lock().expect("structured lifecycle gate");
|
|
||||||
if shutting_down.load(Ordering::Acquire) {
|
|
||||||
return Err(SessionError::ShuttingDown);
|
|
||||||
}
|
|
||||||
self.in_flight.fetch_add(1, Ordering::AcqRel);
|
|
||||||
Ok(StructuredCreate { lifecycle: self })
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn wait_until_settled(&self) {
|
async fn wait_until_settled(&self) {
|
||||||
loop {
|
loop {
|
||||||
let settled = self.settled.notified();
|
let settled = self.settled.notified();
|
||||||
@@ -340,30 +343,18 @@ impl StructuredLifecycle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StructuredCreate<'a> {
|
/// Release the structured-lifecycle in-flight counter that `create` acquires
|
||||||
lifecycle: &'a StructuredLifecycle,
|
/// before spawning the background ACP handshake task. Called when the task
|
||||||
}
|
/// finishes (success or failure) or when `create_inner` fails before the task
|
||||||
|
/// is spawned.
|
||||||
impl Drop for StructuredCreate<'_> {
|
fn drop_structured_lifecycle_permit(inner: &Inner) {
|
||||||
fn drop(&mut self) {
|
if inner
|
||||||
if self.lifecycle.in_flight.fetch_sub(1, Ordering::AcqRel) == 1 {
|
.structured_lifecycle
|
||||||
self.lifecycle.settled.notify_one();
|
.in_flight
|
||||||
}
|
.fetch_sub(1, Ordering::AcqRel)
|
||||||
}
|
== 1
|
||||||
}
|
{
|
||||||
|
inner.structured_lifecycle.settled.notify_one();
|
||||||
struct AdmittedStructuredCreate<'a> {
|
|
||||||
create: Pin<Box<dyn Future<Output = Result<Session, SessionError>> + Send + 'a>>,
|
|
||||||
/// Field order matters: dropping a cancelled create rolls back its inner
|
|
||||||
/// future before this permit releases shutdown's in-flight barrier.
|
|
||||||
_permit: StructuredCreate<'a>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Future for AdmittedStructuredCreate<'_> {
|
|
||||||
type Output = Result<Session, SessionError>;
|
|
||||||
|
|
||||||
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
|
||||||
self.create.as_mut().poll(cx)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,6 +595,36 @@ impl SessionManager {
|
|||||||
Ok(Some(replay))
|
Ok(Some(replay))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Store attachment bytes for a live structured session.
|
||||||
|
pub async fn store_acp_attachment(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
name: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Result<Option<std::path::PathBuf>, SessionError> {
|
||||||
|
{
|
||||||
|
let sessions = self.inner.sessions.lock().await;
|
||||||
|
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
|
||||||
|
if !record.is_live_structured() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let dir = self.inner.sidecar.dir().join(format!("{id}.attachments"));
|
||||||
|
tokio::fs::create_dir_all(&dir).await?;
|
||||||
|
let dir = tokio::fs::canonicalize(dir).await?;
|
||||||
|
let path = dir.join(format!("{}-{name}", Uuid::new_v4()));
|
||||||
|
let mut file = tokio::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(&path)
|
||||||
|
.await?;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
file.write_all(bytes).await?;
|
||||||
|
file.sync_all().await?;
|
||||||
|
Ok(Some(path))
|
||||||
|
}
|
||||||
|
|
||||||
/// Subscribe to the two projections of a structured session's ACP log.
|
/// Subscribe to the two projections of a structured session's ACP log.
|
||||||
///
|
///
|
||||||
/// The transcript snapshot and whole reduced state are folded once by the
|
/// The transcript snapshot and whole reduced state are folded once by the
|
||||||
@@ -627,7 +648,9 @@ impl SessionManager {
|
|||||||
let stream = {
|
let stream = {
|
||||||
let sessions = self.inner.sessions.lock().await;
|
let sessions = self.inner.sessions.lock().await;
|
||||||
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
|
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
|
||||||
if !record.is_live_structured() {
|
// Allow connecting while the adapter is still booting
|
||||||
|
// (`Starting`) — the event stream exists, it is just empty.
|
||||||
|
if !record.is_live_structured() && !record.is_starting_structured() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let Some(stream) = &record.acp_events else {
|
let Some(stream) = &record.acp_events else {
|
||||||
@@ -712,8 +735,14 @@ impl SessionManager {
|
|||||||
text: String,
|
text: String,
|
||||||
) -> Result<QueueAcpPrompt, SessionError> {
|
) -> Result<QueueAcpPrompt, SessionError> {
|
||||||
let (acp, stream) = {
|
let (acp, stream) = {
|
||||||
let sessions = self.inner.sessions.lock().await;
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
|
let record = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
|
||||||
|
// While the adapter is booting, stash the prompt for the
|
||||||
|
// background handshake task to drain on success.
|
||||||
|
if record.is_starting_structured() {
|
||||||
|
record.pending_prompt = Some(text);
|
||||||
|
return Ok(QueueAcpPrompt::Pending);
|
||||||
|
}
|
||||||
if !record.is_live_structured() {
|
if !record.is_live_structured() {
|
||||||
return Ok(QueueAcpPrompt::Unavailable);
|
return Ok(QueueAcpPrompt::Unavailable);
|
||||||
}
|
}
|
||||||
@@ -776,6 +805,10 @@ impl SessionManager {
|
|||||||
});
|
});
|
||||||
drop(stream);
|
drop(stream);
|
||||||
sync_structured_activity(&self.inner, id).await;
|
sync_structured_activity(&self.inner, id).await;
|
||||||
|
#[cfg(feature = "haiku-autoname")]
|
||||||
|
if let Ok(true) = self.claim_autoname(id).await {
|
||||||
|
crate::autoname::spawn_naming(self.clone(), id, text);
|
||||||
|
}
|
||||||
return Ok(QueueAcpPrompt::Queued);
|
return Ok(QueueAcpPrompt::Queued);
|
||||||
}
|
}
|
||||||
Err(message) => {
|
Err(message) => {
|
||||||
@@ -1184,17 +1217,42 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a new session. On success returns the freshly-created `Session`.
|
/// Spawn a new session. On success returns the freshly-created `Session`.
|
||||||
|
///
|
||||||
|
/// For structured sessions, the ACP handshake runs in a background task
|
||||||
|
/// after the session is registered — the HTTP caller gets an instant
|
||||||
|
/// response with `activity: Starting`. The `StructuredLifecycle` gate is
|
||||||
|
/// held across the background task so `shutdown()` waits for it.
|
||||||
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
|
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
|
||||||
if req.view == SessionView::Structured {
|
if req.view == SessionView::Structured {
|
||||||
let permit = self
|
// Acquire the lifecycle gate synchronously (rejects if shutting
|
||||||
|
// down). The background handshake task calls
|
||||||
|
// `drop_structured_lifecycle_permit` when it finishes; we must
|
||||||
|
// not use the RAII `StructuredCreate` because it would drop at
|
||||||
|
// the end of `create_inner`, before the handshake completes.
|
||||||
|
let _gate = self
|
||||||
.inner
|
.inner
|
||||||
.structured_lifecycle
|
.structured_lifecycle
|
||||||
.admit(&self.inner.shutting_down)?;
|
.gate
|
||||||
return AdmittedStructuredCreate {
|
.lock()
|
||||||
create: Box::pin(self.create_inner(req)),
|
.expect("structured lifecycle gate");
|
||||||
_permit: permit,
|
if self.inner.shutting_down.load(Ordering::Acquire) {
|
||||||
|
return Err(SessionError::ShuttingDown);
|
||||||
}
|
}
|
||||||
.await;
|
self.inner
|
||||||
|
.structured_lifecycle
|
||||||
|
.in_flight
|
||||||
|
.fetch_add(1, Ordering::AcqRel);
|
||||||
|
drop(_gate);
|
||||||
|
|
||||||
|
return match self.create_inner(req).await {
|
||||||
|
Ok(session) => Ok(session),
|
||||||
|
Err(e) => {
|
||||||
|
// create_inner failed before spawning the background
|
||||||
|
// task, so release the permit here.
|
||||||
|
drop_structured_lifecycle_permit(&self.inner);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
self.create_inner(req).await
|
self.create_inner(req).await
|
||||||
}
|
}
|
||||||
@@ -1256,32 +1314,39 @@ impl SessionManager {
|
|||||||
{
|
{
|
||||||
session_env.insert("MAESTRO_URL".into(), url);
|
session_env.insert("MAESTRO_URL".into(), url);
|
||||||
}
|
}
|
||||||
// A structured session (#134) gets no tmux pane: the agent is a
|
// ── Structured path: validate adapter, open event log, register
|
||||||
// child of the daemon, driven over ACP. An adapter that cannot come
|
// the session immediately with `Starting`, then handshake in the
|
||||||
// up still yields a session — Dead, with the reason on it — so the
|
// background so the HTTP caller gets an instant response.
|
||||||
// operator sees what to fix instead of a silently missing session.
|
//
|
||||||
let (acp, acp_failure) = match req.view {
|
// ── Terminal path: unchanged — tmux spawn is fast enough to stay
|
||||||
SessionView::Structured => match structured_adapter(&req.command) {
|
// synchronous.
|
||||||
Err(error) => (None, Some(error)),
|
// Structured: validate adapter, open event log eagerly. Terminal:
|
||||||
Ok(adapter) => match maestro_acp::spawn(
|
// spawn the tmux pane synchronously (it is fast).
|
||||||
SpawnConfig::new(
|
let structured_spawn = match req.view {
|
||||||
&effective_cwd,
|
SessionView::Structured => {
|
||||||
session_env
|
// Fail fast on an unsupported command before touching disk.
|
||||||
.iter()
|
let adapter = structured_adapter(&req.command).map_err(SessionError::BadRequest)?;
|
||||||
.map(|(k, v)| (k.clone(), v.clone()))
|
// Open the event log eagerly so the structured WS can
|
||||||
.collect(),
|
// connect before the adapter is up.
|
||||||
)
|
let acp_events = match self.inner.sidecar.open_events(id) {
|
||||||
.adapter(adapter),
|
Ok(log) => {
|
||||||
)
|
let (tx, _) = broadcast::channel(EVENTS_CAPACITY);
|
||||||
.await
|
let (projected_tx, _) = broadcast::channel(EVENTS_CAPACITY);
|
||||||
{
|
Arc::new(Mutex::new(AcpEventStream {
|
||||||
Ok(acp) => (Some(Arc::new(acp)), None),
|
log,
|
||||||
Err(e) => {
|
live: tx,
|
||||||
tracing::warn!(%id, error = %e, "structured session: adapter failed to start");
|
projection: Projection::default(),
|
||||||
(None, Some(e.to_string()))
|
projected_live: projected_tx,
|
||||||
|
pending_approvals: HashMap::new(),
|
||||||
|
operation: Arc::new(Mutex::new(())),
|
||||||
|
dispatch: AcpDispatchState::default(),
|
||||||
|
activity_sequence: 0,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
},
|
Err(e) => return Err(SessionError::Sidecar(e)),
|
||||||
},
|
};
|
||||||
|
Some((adapter, acp_events))
|
||||||
|
}
|
||||||
SessionView::Terminal => {
|
SessionView::Terminal => {
|
||||||
let spawned = {
|
let spawned = {
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
@@ -1295,53 +1360,10 @@ impl SessionManager {
|
|||||||
if let Err(e) = spawned {
|
if let Err(e) = spawned {
|
||||||
return Err(SessionError::Tmux(e));
|
return Err(SessionError::Tmux(e));
|
||||||
}
|
}
|
||||||
(None, None)
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let acp_events = if let Some(acp) = &acp {
|
|
||||||
let persisted =
|
|
||||||
match self
|
|
||||||
.inner
|
|
||||||
.sidecar
|
|
||||||
.read_events(id, EventCursor::Since(0), usize::MAX)
|
|
||||||
{
|
|
||||||
Ok(events) => events,
|
|
||||||
Err(error) => {
|
|
||||||
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
|
||||||
.await;
|
|
||||||
return Err(SessionError::Sidecar(error));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match self.inner.sidecar.open_events(id) {
|
|
||||||
Ok(log) => {
|
|
||||||
let (tx, _) = broadcast::channel(EVENTS_CAPACITY);
|
|
||||||
let (projected_tx, _) = broadcast::channel(EVENTS_CAPACITY);
|
|
||||||
let mut projection = Projection::default();
|
|
||||||
for event in &persisted {
|
|
||||||
apply_persisted_event(&mut projection, event);
|
|
||||||
}
|
|
||||||
Some(Arc::new(Mutex::new(AcpEventStream {
|
|
||||||
log,
|
|
||||||
live: tx,
|
|
||||||
projection,
|
|
||||||
projected_live: projected_tx,
|
|
||||||
pending_approvals: HashMap::new(),
|
|
||||||
operation: Arc::new(Mutex::new(())),
|
|
||||||
dispatch: AcpDispatchState::default(),
|
|
||||||
activity_sequence: 0,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
|
||||||
.await;
|
|
||||||
return Err(SessionError::Sidecar(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// Capture spawn-history inputs before `req` is partially moved into
|
// Capture spawn-history inputs before `req` is partially moved into
|
||||||
// `session`. `history_cwd` is the *chosen* working dir (the request
|
// `session`. `history_cwd` is the *chosen* working dir (the request
|
||||||
// `cwd`), not a generated worktree checkout path — that's what the
|
// `cwd`), not a generated worktree checkout path — that's what the
|
||||||
@@ -1351,10 +1373,13 @@ impl SessionManager {
|
|||||||
resolve_name(&mut metadata);
|
resolve_name(&mut metadata);
|
||||||
let worktree_branch = req.worktree.as_ref().map(|w| w.branch.clone());
|
let worktree_branch = req.worktree.as_ref().map(|w| w.branch.clone());
|
||||||
|
|
||||||
|
let is_structured = structured_spawn.is_some();
|
||||||
|
let events_arc = structured_spawn.as_ref().map(|(_, e)| e.clone());
|
||||||
|
|
||||||
let session = Session {
|
let session = Session {
|
||||||
id,
|
id,
|
||||||
cwd: effective_cwd,
|
cwd: effective_cwd.clone(),
|
||||||
command: req.command,
|
command: req.command.clone(),
|
||||||
env: req.env,
|
env: req.env,
|
||||||
metadata,
|
metadata,
|
||||||
worktree: worktree_path.as_ref().zip(worktree_base_repo.as_ref()).map(
|
worktree: worktree_path.as_ref().zip(worktree_base_repo.as_ref()).map(
|
||||||
@@ -1363,67 +1388,41 @@ impl SessionManager {
|
|||||||
base_repo: base_repo.clone(),
|
base_repo: base_repo.clone(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
status: if acp_failure.is_some() {
|
status: SessionStatus::Running,
|
||||||
SessionStatus::Dead
|
|
||||||
} else {
|
|
||||||
SessionStatus::Running
|
|
||||||
},
|
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
// A terminal spawn begins active until its watcher sees otherwise;
|
|
||||||
// a structured spawn has no turn yet and begins idle.
|
|
||||||
foreground: None,
|
foreground: None,
|
||||||
activity: match (req.view, acp_failure.is_some()) {
|
activity: if is_structured {
|
||||||
(_, true) => ActivityState::Error,
|
ActivityState::Starting
|
||||||
(SessionView::Structured, false) => ActivityState::Idle,
|
} else {
|
||||||
(SessionView::Terminal, false) => ActivityState::Active,
|
ActivityState::Active
|
||||||
},
|
},
|
||||||
activity_detail: acp_failure,
|
activity_detail: None,
|
||||||
last_activity_at: Utc::now(),
|
last_activity_at: Utc::now(),
|
||||||
activity_since: chrono::Utc::now(),
|
activity_since: chrono::Utc::now(),
|
||||||
|
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
// The watcher fills this in on its first blocked-work pass; a
|
|
||||||
// fresh tree has nothing in the way anyway.
|
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: acp.as_ref().map(|a| a.session_id().to_string()),
|
acp_session_id: None,
|
||||||
view: req.view,
|
view: req.view,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = self.inner.sidecar.write(&session) {
|
if let Err(e) = self.inner.sidecar.write(&session) {
|
||||||
// The process already started; end it so no ghost survives a
|
if !is_structured {
|
||||||
// failed sidecar write, then unwind the FIFO + worktree.
|
let tmux = self.inner.tmux.clone();
|
||||||
match &acp {
|
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
|
||||||
Some(acp) => {
|
|
||||||
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let tmux = self.inner.tmux.clone();
|
|
||||||
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Err(SessionError::Sidecar(e));
|
return Err(SessionError::Sidecar(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
let event_pump = acp
|
|
||||||
.as_ref()
|
|
||||||
.zip(acp_events.as_ref())
|
|
||||||
.map(|(acp, events)| (acp.clone(), events.clone()));
|
|
||||||
{
|
{
|
||||||
let mut sessions = self.inner.sessions.lock().await;
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
let event_pump = event_pump.map(|(acp, events)| {
|
|
||||||
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
|
|
||||||
start_acp_permission_pump(&self.inner, id, &acp, events);
|
|
||||||
done
|
|
||||||
});
|
|
||||||
sessions.insert(
|
sessions.insert(
|
||||||
id,
|
id,
|
||||||
SessionRecord {
|
SessionRecord {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
fifo_path,
|
fifo_path,
|
||||||
worktree_path,
|
worktree_path: worktree_path.clone(),
|
||||||
worktree_base_repo,
|
worktree_base_repo: worktree_base_repo.clone(),
|
||||||
bridge,
|
bridge,
|
||||||
reported_activity: None,
|
reported_activity: None,
|
||||||
settled: false,
|
settled: false,
|
||||||
@@ -1431,10 +1430,12 @@ impl SessionManager {
|
|||||||
naming: false,
|
naming: false,
|
||||||
focused_since: None,
|
focused_since: None,
|
||||||
awaiting_suppressed: false,
|
awaiting_suppressed: false,
|
||||||
acp,
|
acp: None,
|
||||||
acp_events,
|
acp_events: events_arc,
|
||||||
structured_activity_watermark: None,
|
structured_activity_watermark: None,
|
||||||
acp_event_pump: event_pump,
|
acp_event_pump: None,
|
||||||
|
auto_approve: req.auto_approve,
|
||||||
|
pending_prompt: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1463,6 +1464,78 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pending.commit();
|
pending.commit();
|
||||||
|
|
||||||
|
// ── Background ACP handshake for structured sessions ──
|
||||||
|
//
|
||||||
|
// The session is registered and visible to clients. The adapter
|
||||||
|
// spawn + handshake runs in a background task; on completion it
|
||||||
|
// fills in `acp`, starts the event/permission pumps, and
|
||||||
|
// transitions activity from `Starting` to `Idle` (or to `Error`
|
||||||
|
// on failure). The `StructuredLifecycle` permit is held until the
|
||||||
|
// task finishes so `shutdown()` waits for it.
|
||||||
|
if let Some((adapter, events)) = structured_spawn {
|
||||||
|
let inner = self.inner.clone();
|
||||||
|
let env: HashMap<String, String> = session_env
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.collect();
|
||||||
|
let cwd = effective_cwd;
|
||||||
|
let auto_approve = req.auto_approve;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = maestro_acp::spawn(SpawnConfig::new(&cwd, env).adapter(adapter)).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(acp_session) => {
|
||||||
|
let acp = Arc::new(acp_session);
|
||||||
|
let done = start_acp_event_pump(&inner, id, &acp, events.clone());
|
||||||
|
start_acp_permission_pump(&inner, id, &acp, events.clone(), auto_approve);
|
||||||
|
// Drain any prompt submitted while Starting.
|
||||||
|
let pending_prompt = {
|
||||||
|
let mut sessions = inner.sessions.lock().await;
|
||||||
|
let Some(record) = sessions.get_mut(&id) else {
|
||||||
|
// Session was killed while we were booting.
|
||||||
|
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
||||||
|
.await;
|
||||||
|
drop_structured_lifecycle_permit(&inner);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
record.acp = Some(acp.clone());
|
||||||
|
record.acp_event_pump = Some(done);
|
||||||
|
record.session.acp_session_id = Some(acp.session_id().to_string());
|
||||||
|
record.session.activity = ActivityState::Idle;
|
||||||
|
record.session.activity_since = Utc::now();
|
||||||
|
let _ = inner.sidecar.write(&record.session);
|
||||||
|
let _ = inner.events.send(CatalogEvent::SessionUpdated {
|
||||||
|
session: record.session.clone(),
|
||||||
|
});
|
||||||
|
record.pending_prompt.take()
|
||||||
|
};
|
||||||
|
if let Some(prompt) = pending_prompt {
|
||||||
|
let manager = SessionManager {
|
||||||
|
inner: inner.clone(),
|
||||||
|
};
|
||||||
|
let _ = manager.queue_acp_prompt(id, prompt).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(%id, error = %e, "structured session: adapter failed to start");
|
||||||
|
let mut sessions = inner.sessions.lock().await;
|
||||||
|
if let Some(record) = sessions.get_mut(&id) {
|
||||||
|
record.session.status = SessionStatus::Dead;
|
||||||
|
record.session.activity = ActivityState::Error;
|
||||||
|
record.session.activity_detail = Some(e.to_string());
|
||||||
|
record.session.activity_since = Utc::now();
|
||||||
|
let _ = inner.sidecar.write(&record.session);
|
||||||
|
let _ = inner.events.send(CatalogEvent::SessionUpdated {
|
||||||
|
session: record.session.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop_structured_lifecycle_permit(&inner);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1647,6 +1720,8 @@ impl SessionManager {
|
|||||||
acp_events: None,
|
acp_events: None,
|
||||||
structured_activity_watermark: None,
|
structured_activity_watermark: None,
|
||||||
acp_event_pump: None,
|
acp_event_pump: None,
|
||||||
|
auto_approve: false,
|
||||||
|
pending_prompt: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let _ = self
|
let _ = self
|
||||||
@@ -1824,11 +1899,15 @@ impl SessionManager {
|
|||||||
acp_events,
|
acp_events,
|
||||||
structured_activity_watermark: None,
|
structured_activity_watermark: None,
|
||||||
acp_event_pump: None,
|
acp_event_pump: None,
|
||||||
|
// 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 {
|
if let Some((acp, events)) = event_pump {
|
||||||
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
|
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
|
||||||
start_acp_permission_pump(&self.inner, id, &acp, events);
|
start_acp_permission_pump(&self.inner, id, &acp, events, false);
|
||||||
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
|
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
|
||||||
record.acp_event_pump = Some(done);
|
record.acp_event_pump = Some(done);
|
||||||
}
|
}
|
||||||
@@ -2283,6 +2362,11 @@ async fn apply_structured_activity(
|
|||||||
if record.session.view != SessionView::Structured {
|
if record.session.view != SessionView::Structured {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// While the adapter is still booting, the background handshake task
|
||||||
|
// owns the activity state — the activity pass must not overwrite it.
|
||||||
|
if record.session.activity == ActivityState::Starting {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let current_events = record.acp_events.as_ref();
|
let current_events = record.acp_events.as_ref();
|
||||||
match (expected_events, current_events) {
|
match (expected_events, current_events) {
|
||||||
(Some((expected, sequence)), Some(current)) if Arc::ptr_eq(expected, current) => {
|
(Some((expected, sequence)), Some(current)) if Arc::ptr_eq(expected, current) => {
|
||||||
@@ -2327,6 +2411,7 @@ fn start_acp_permission_pump(
|
|||||||
id: Uuid,
|
id: Uuid,
|
||||||
acp: &Arc<AcpSession>,
|
acp: &Arc<AcpSession>,
|
||||||
events: Arc<Mutex<AcpEventStream>>,
|
events: Arc<Mutex<AcpEventStream>>,
|
||||||
|
auto_approve: bool,
|
||||||
) {
|
) {
|
||||||
let Some(mut requests) = acp.take_permission_requests() else {
|
let Some(mut requests) = acp.take_permission_requests() else {
|
||||||
return;
|
return;
|
||||||
@@ -2340,6 +2425,12 @@ fn start_acp_permission_pump(
|
|||||||
response,
|
response,
|
||||||
}) = requests.recv().await
|
}) = requests.recv().await
|
||||||
{
|
{
|
||||||
|
if auto_approve {
|
||||||
|
let outcome =
|
||||||
|
permission_outcome(ApprovalDecision::AllowForSession, &request.options);
|
||||||
|
let _ = response.send(outcome);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let mut events = events.lock().await;
|
let mut events = events.lock().await;
|
||||||
if events.projection.active_turn() != Some(turn) {
|
if events.projection.active_turn() != Some(turn) {
|
||||||
let _ = response.send(maestro_acp::schema::RequestPermissionOutcome::Cancelled);
|
let _ = response.send(maestro_acp::schema::RequestPermissionOutcome::Cancelled);
|
||||||
@@ -2541,6 +2632,12 @@ async fn run_liveness_pass(inner: &Arc<Inner>) {
|
|||||||
let mut tmux_backed = Vec::new();
|
let mut tmux_backed = Vec::new();
|
||||||
let mut acp_backed = Vec::new();
|
let mut acp_backed = Vec::new();
|
||||||
for (id, record) in running {
|
for (id, record) in running {
|
||||||
|
if record.is_starting_structured() {
|
||||||
|
// The adapter hasn't finished its handshake yet — no ACP
|
||||||
|
// handle and no tmux pane. Skip; the background task owns
|
||||||
|
// this session's lifecycle until the handshake settles.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
match &record.acp {
|
match &record.acp {
|
||||||
Some(acp) => acp_backed.push((*id, acp.clone())),
|
Some(acp) => acp_backed.push((*id, acp.clone())),
|
||||||
None => tmux_backed.push(*id),
|
None => tmux_backed.push(*id),
|
||||||
@@ -3060,6 +3157,7 @@ mod tests {
|
|||||||
env: std::collections::HashMap::new(),
|
env: std::collections::HashMap::new(),
|
||||||
metadata: std::collections::HashMap::new(),
|
metadata: std::collections::HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ fn structured_command(
|
|||||||
env,
|
env,
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +130,25 @@ async fn wait_for_exit(pid: u32) {
|
|||||||
assert!(!pid_alive(pid), "pid {pid} still alive");
|
assert!(!pid_alive(pid), "pid {pid} still alive");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wait for a session's background ACP handshake to complete (activity
|
||||||
|
/// transitions out of `Starting`). Returns the refreshed session.
|
||||||
|
async fn wait_for_ready(manager: &SessionManager, id: uuid::Uuid) -> maestro_protocol::Session {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
|
loop {
|
||||||
|
let session = manager
|
||||||
|
.snapshot()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.find(|s| s.id == id)
|
||||||
|
.expect("session exists");
|
||||||
|
if session.activity != ActivityState::Starting {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
assert!(Instant::now() < deadline, "handshake timed out");
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
@@ -137,7 +157,7 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
|||||||
let mut env = fake.env();
|
let mut env = fake.env();
|
||||||
env.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
|
env.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
|
||||||
|
|
||||||
let session = fx
|
let created = fx
|
||||||
.manager
|
.manager
|
||||||
.create(structured_command(
|
.create(structured_command(
|
||||||
cwd.path(),
|
cwd.path(),
|
||||||
@@ -147,8 +167,12 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
|||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
|
||||||
assert_eq!(session.view, SessionView::Structured);
|
assert_eq!(created.view, SessionView::Structured);
|
||||||
assert_eq!(session.status, SessionStatus::Running);
|
assert_eq!(created.status, SessionStatus::Running);
|
||||||
|
assert_eq!(created.activity, ActivityState::Starting);
|
||||||
|
assert!(created.acp_session_id.is_none());
|
||||||
|
|
||||||
|
let session = wait_for_ready(&fx.manager, created.id).await;
|
||||||
assert_eq!(session.activity, ActivityState::Idle);
|
assert_eq!(session.activity, ActivityState::Idle);
|
||||||
let acp_id = session.acp_session_id.clone().expect("acp session id");
|
let acp_id = session.acp_session_id.clone().expect("acp session id");
|
||||||
assert!(acp_id.starts_with("fake-"), "{acp_id}");
|
assert!(acp_id.starts_with("fake-"), "{acp_id}");
|
||||||
@@ -163,7 +187,7 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
|||||||
"{log}"
|
"{log}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
log.contains(&format!("env MAESTRO_SESSION_ID={}", session.id)),
|
log.contains(&format!("env MAESTRO_SESSION_ID={}", created.id)),
|
||||||
"{log}"
|
"{log}"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -231,6 +255,7 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
|
|||||||
.create(structured_command(cwd.path(), env, "codex"))
|
.create(structured_command(cwd.path(), env, "codex"))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let session = wait_for_ready(&fx.manager, session.id).await;
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
fx.manager
|
fx.manager
|
||||||
@@ -294,11 +319,12 @@ async fn projection_snapshot_and_live_stream_share_one_fold() {
|
|||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
let fake = Fake::new();
|
let fake = Fake::new();
|
||||||
let cwd = TempDir::new().unwrap();
|
let cwd = TempDir::new().unwrap();
|
||||||
let session = fx
|
let created = fx
|
||||||
.manager
|
.manager
|
||||||
.create(structured(cwd.path(), fake.env()))
|
.create(structured(cwd.path(), fake.env()))
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
let session = wait_for_ready(&fx.manager, created.id).await;
|
||||||
|
|
||||||
wait_for_async(Duration::from_secs(5), || async {
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
fx.manager
|
fx.manager
|
||||||
@@ -396,6 +422,7 @@ async fn queued_prompt_is_a_durable_user_transcript_row() {
|
|||||||
.create(structured(cwd.path(), fake.env()))
|
.create(structured(cwd.path(), fake.env()))
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
let session = wait_for_ready(&fx.manager, session.id).await;
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
fx.manager
|
fx.manager
|
||||||
@@ -441,6 +468,7 @@ async fn stale_turn_completion_cannot_end_the_next_turn() {
|
|||||||
.create(structured(cwd.path(), fake.env()))
|
.create(structured(cwd.path(), fake.env()))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
wait_for_ready(&fx.manager, session.id).await;
|
||||||
|
|
||||||
let maestro_daemon::session::BeginAcpTurn::Started(_, first) =
|
let maestro_daemon::session::BeginAcpTurn::Started(_, first) =
|
||||||
fx.manager.begin_acp_turn(session.id).await.unwrap()
|
fx.manager.begin_acp_turn(session.id).await.unwrap()
|
||||||
@@ -484,6 +512,7 @@ async fn cancelled_prompt_that_never_settles_fails_the_session() {
|
|||||||
.create(structured(cwd.path(), env))
|
.create(structured(cwd.path(), env))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
wait_for_ready(&fx.manager, session.id).await;
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
fx.manager
|
fx.manager
|
||||||
@@ -522,6 +551,7 @@ async fn cancellation_settlement_allows_the_next_prompt_permission() {
|
|||||||
.create(structured(cwd.path(), env))
|
.create(structured(cwd.path(), env))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
wait_for_ready(&fx.manager, session.id).await;
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
fx.manager
|
fx.manager
|
||||||
@@ -581,11 +611,12 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
|
|||||||
let mut env = fake.env();
|
let mut env = fake.env();
|
||||||
env.insert("FAKE_ACP_LOAD_REPLAY".into(), "2".into());
|
env.insert("FAKE_ACP_LOAD_REPLAY".into(), "2".into());
|
||||||
|
|
||||||
let session = fx
|
let created = fx
|
||||||
.manager
|
.manager
|
||||||
.create(structured_command(cwd.path(), env, "codex"))
|
.create(structured_command(cwd.path(), env, "codex"))
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
let session = wait_for_ready(&fx.manager, created.id).await;
|
||||||
let acp_id = session.acp_session_id.clone().expect("acp id");
|
let acp_id = session.acp_session_id.clone().expect("acp id");
|
||||||
wait_for_async(Duration::from_secs(5), || async {
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
fx.manager
|
fx.manager
|
||||||
@@ -705,10 +736,12 @@ async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
|
|||||||
branch: branch.into(),
|
branch: branch.into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
assert_eq!(session.cwd, worktree);
|
assert_eq!(session.cwd, worktree);
|
||||||
|
wait_for_ready(&fx.manager, session.id).await;
|
||||||
push_all_to_origin(repo.path(), origin.path());
|
push_all_to_origin(repo.path(), origin.path());
|
||||||
|
|
||||||
fx.manager.shutdown().await;
|
fx.manager.shutdown().await;
|
||||||
@@ -757,6 +790,7 @@ async fn failed_recovery_cleans_its_fifo() {
|
|||||||
.create(structured(cwd.path(), fake.env()))
|
.create(structured(cwd.path(), fake.env()))
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
wait_for_ready(&fx.manager, session.id).await;
|
||||||
let old_pid = fake.pid();
|
let old_pid = fake.pid();
|
||||||
fx.manager.shutdown().await;
|
fx.manager.shutdown().await;
|
||||||
wait_for_exit(old_pid).await;
|
wait_for_exit(old_pid).await;
|
||||||
@@ -790,12 +824,15 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
|
|||||||
empty.path().to_string_lossy().into_owned(),
|
empty.path().to_string_lossy().into_owned(),
|
||||||
)]);
|
)]);
|
||||||
|
|
||||||
let session = fx
|
let created = fx
|
||||||
.manager
|
.manager
|
||||||
.create(structured_command(cwd.path(), env, "codex"))
|
.create(structured_command(cwd.path(), env, "codex"))
|
||||||
.await
|
.await
|
||||||
.expect("a failed session, not an error");
|
.expect("create registers a Starting session");
|
||||||
|
assert_eq!(created.activity, ActivityState::Starting);
|
||||||
|
|
||||||
|
// The background handshake fails because the adapter binary is missing.
|
||||||
|
let session = wait_for_ready(&fx.manager, created.id).await;
|
||||||
assert_eq!(session.status, SessionStatus::Dead);
|
assert_eq!(session.status, SessionStatus::Dead);
|
||||||
assert_eq!(session.activity, ActivityState::Error);
|
assert_eq!(session.activity, ActivityState::Error);
|
||||||
let detail = session.activity_detail.clone().expect("actionable detail");
|
let detail = session.activity_detail.clone().expect("actionable detail");
|
||||||
@@ -808,13 +845,13 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
|
|||||||
|
|
||||||
// It is a real session: listed, on disk, and dismissable like any other
|
// It is a real session: listed, on disk, and dismissable like any other
|
||||||
// dead one.
|
// dead one.
|
||||||
assert!(fx.manager.has(session.id).await);
|
assert!(fx.manager.has(created.id).await);
|
||||||
assert!(fx
|
assert!(fx
|
||||||
.sidecar_dir()
|
.sidecar_dir()
|
||||||
.join(format!("{}.json", session.id))
|
.join(format!("{}.json", created.id))
|
||||||
.exists());
|
.exists());
|
||||||
fx.manager.kill(session.id, false).await.expect("dismiss");
|
fx.manager.kill(created.id, false).await.expect("dismiss");
|
||||||
assert!(!fx.manager.has(session.id).await);
|
assert!(!fx.manager.has(created.id).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -823,20 +860,20 @@ async fn unsupported_structured_command_fails_without_launching_claude() {
|
|||||||
let fake = Fake::new();
|
let fake = Fake::new();
|
||||||
let cwd = TempDir::new().unwrap();
|
let cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
let session = fx
|
let err = fx
|
||||||
.manager
|
.manager
|
||||||
.create(structured_command(cwd.path(), fake.env(), "opencode"))
|
.create(structured_command(cwd.path(), fake.env(), "opencode"))
|
||||||
.await
|
.await
|
||||||
.expect("failed structured session is persisted");
|
.expect_err("unsupported command is rejected at request validation");
|
||||||
|
|
||||||
assert_eq!(session.status, SessionStatus::Dead);
|
assert!(
|
||||||
let detail = session.activity_detail.expect("selection error");
|
matches!(err, maestro_daemon::SessionError::BadRequest(ref msg) if msg.contains("Claude Code or Codex")),
|
||||||
assert!(detail.contains("Claude Code or Codex"), "{detail}");
|
"{err:?}"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
fake.log().is_empty(),
|
fake.log().is_empty(),
|
||||||
"unsupported command launched an adapter"
|
"unsupported command launched an adapter"
|
||||||
);
|
);
|
||||||
fx.manager.kill(session.id, false).await.expect("dismiss");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -851,6 +888,7 @@ async fn kill_sends_cancel_then_sigterm_and_removes_the_sidecar() {
|
|||||||
.create(structured(cwd.path(), fake.env()))
|
.create(structured(cwd.path(), fake.env()))
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
wait_for_ready(&fx.manager, session.id).await;
|
||||||
let pid = fake.pid();
|
let pid = fake.pid();
|
||||||
assert!(pid_alive(pid));
|
assert!(pid_alive(pid));
|
||||||
|
|
||||||
@@ -893,6 +931,8 @@ async fn shutdown_reaps_every_adapter_child() {
|
|||||||
.create(structured(cwd.path(), b.env()))
|
.create(structured(cwd.path(), b.env()))
|
||||||
.await
|
.await
|
||||||
.expect("create b");
|
.expect("create b");
|
||||||
|
wait_for_ready(&fx.manager, sa.id).await;
|
||||||
|
wait_for_ready(&fx.manager, sb.id).await;
|
||||||
let (pa, pb) = (a.pid(), b.pid());
|
let (pa, pb) = (a.pid(), b.pid());
|
||||||
assert!(pid_alive(pa) && pid_alive(pb));
|
assert!(pid_alive(pa) && pid_alive(pb));
|
||||||
|
|
||||||
@@ -927,10 +967,13 @@ async fn dismissal_during_shutdown_does_not_restore_recovery_artifacts() {
|
|||||||
.create(structured(cwd.path(), env))
|
.create(structured(cwd.path(), env))
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
fx.manager
|
let blocker_session = fx
|
||||||
|
.manager
|
||||||
.create(structured(cwd.path(), blocker.env()))
|
.create(structured(cwd.path(), blocker.env()))
|
||||||
.await
|
.await
|
||||||
.expect("create blocker");
|
.expect("create blocker");
|
||||||
|
wait_for_ready(&fx.manager, session.id).await;
|
||||||
|
wait_for_ready(&fx.manager, blocker_session.id).await;
|
||||||
|
|
||||||
let manager = fx.manager.clone();
|
let manager = fx.manager.clone();
|
||||||
let shutdown = tokio::spawn(async move { manager.shutdown().await });
|
let shutdown = tokio::spawn(async move { manager.shutdown().await });
|
||||||
@@ -982,6 +1025,7 @@ async fn shutdown_does_not_recover_an_adapter_that_already_exited() {
|
|||||||
.create(structured(cwd.path(), env))
|
.create(structured(cwd.path(), env))
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
wait_for_ready(&fx.manager, session.id).await;
|
||||||
wait_for_exit(fake.pid()).await;
|
wait_for_exit(fake.pid()).await;
|
||||||
|
|
||||||
fx.manager.shutdown().await;
|
fx.manager.shutdown().await;
|
||||||
@@ -1015,23 +1059,31 @@ async fn shutdown_waits_for_structured_creation_and_rejects_new_creates() {
|
|||||||
let mut env = fake.env();
|
let mut env = fake.env();
|
||||||
env.insert("FAKE_ACP_DELAY_SESSION_NEW_MS".into(), "500".into());
|
env.insert("FAKE_ACP_DELAY_SESSION_NEW_MS".into(), "500".into());
|
||||||
|
|
||||||
let create_cwd = cwd.path().to_path_buf();
|
// create() returns immediately; the handshake runs in the background
|
||||||
let manager = fx.manager.clone();
|
// and holds a lifecycle permit.
|
||||||
let create = tokio::spawn(async move { manager.create(structured(&create_cwd, env)).await });
|
let session = fx
|
||||||
|
.manager
|
||||||
|
.create(structured(cwd.path(), env))
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
|
||||||
|
// Wait until the adapter has received session/new (handshake in progress).
|
||||||
wait_for_async(Duration::from_secs(5), || async {
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
fake.log().contains("recv session/new")
|
fake.log().contains("recv session/new")
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// Shutdown must wait for the in-flight background handshake.
|
||||||
let manager = fx.manager.clone();
|
let manager = fx.manager.clone();
|
||||||
let shutdown = tokio::spawn(async move { manager.shutdown().await });
|
let shutdown = tokio::spawn(async move { manager.shutdown().await });
|
||||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
assert!(
|
assert!(
|
||||||
!shutdown.is_finished(),
|
!shutdown.is_finished(),
|
||||||
"shutdown returned before create finished"
|
"shutdown returned before handshake finished"
|
||||||
);
|
);
|
||||||
|
|
||||||
let session = create.await.expect("create task").expect("create");
|
// The handshake completes in the background (500ms delay).
|
||||||
|
wait_for_ready(&fx.manager, session.id).await;
|
||||||
let pid = fake.pid();
|
let pid = fake.pid();
|
||||||
shutdown.await.expect("shutdown task");
|
shutdown.await.expect("shutdown task");
|
||||||
|
|
||||||
@@ -1064,27 +1116,37 @@ async fn structured_handshakes_run_independently() {
|
|||||||
gate.to_string_lossy().into_owned(),
|
gate.to_string_lossy().into_owned(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let first_cwd = cwd.path().to_path_buf();
|
// Both create() calls return immediately with Starting; the first
|
||||||
let manager = fx.manager.clone();
|
// adapter's handshake is gated while the second runs freely.
|
||||||
let first =
|
let first = fx
|
||||||
tokio::spawn(async move { manager.create(structured(&first_cwd, blocked_env)).await });
|
.manager
|
||||||
wait_for_async(Duration::from_secs(5), || async {
|
.create(structured(cwd.path(), blocked_env))
|
||||||
blocked.log().contains("recv session/new")
|
.await
|
||||||
})
|
.expect("first create");
|
||||||
.await;
|
let second = fx
|
||||||
|
.manager
|
||||||
|
.create(structured(cwd.path(), ready.env()))
|
||||||
|
.await
|
||||||
|
.expect("second create");
|
||||||
|
|
||||||
let second = tokio::time::timeout(
|
// The second handshake completes independently of the first.
|
||||||
Duration::from_secs(5),
|
let second_ready = wait_for_ready(&fx.manager, second.id).await;
|
||||||
fx.manager.create(structured(cwd.path(), ready.env())),
|
assert_eq!(second_ready.activity, ActivityState::Idle);
|
||||||
)
|
|
||||||
.await
|
// The first handshake is still blocked.
|
||||||
.expect("second handshake was blocked by the first")
|
let first_snap = fx
|
||||||
.expect("second create");
|
.manager
|
||||||
assert_eq!(second.status, SessionStatus::Running);
|
.snapshot()
|
||||||
assert!(!first.is_finished(), "the gate must still block the first");
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.find(|s| s.id == first.id)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(first_snap.activity, ActivityState::Starting);
|
||||||
|
|
||||||
std::fs::write(&gate, b"release").unwrap();
|
std::fs::write(&gate, b"release").unwrap();
|
||||||
let first = first.await.expect("first task").expect("first create");
|
let first_ready = wait_for_ready(&fx.manager, first.id).await;
|
||||||
|
assert_eq!(first_ready.activity, ActivityState::Idle);
|
||||||
|
|
||||||
fx.manager.kill(first.id, false).await.expect("kill first");
|
fx.manager.kill(first.id, false).await.expect("kill first");
|
||||||
fx.manager
|
fx.manager
|
||||||
.kill(second.id, false)
|
.kill(second.id, false)
|
||||||
@@ -1112,16 +1174,18 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
|
|||||||
gate_b.to_string_lossy().into_owned(),
|
gate_b.to_string_lossy().into_owned(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let create_a = {
|
// Both create() calls return immediately; the background handshake
|
||||||
let manager = fx.manager.clone();
|
// tasks hold lifecycle permits and are blocked on their gates.
|
||||||
let cwd = cwd.path().to_path_buf();
|
let session_a = fx
|
||||||
tokio::spawn(async move { manager.create(structured(&cwd, env_a)).await })
|
.manager
|
||||||
};
|
.create(structured(cwd.path(), env_a))
|
||||||
let create_b = {
|
.await
|
||||||
let manager = fx.manager.clone();
|
.expect("create a");
|
||||||
let cwd = cwd.path().to_path_buf();
|
let session_b = fx
|
||||||
tokio::spawn(async move { manager.create(structured(&cwd, env_b)).await })
|
.manager
|
||||||
};
|
.create(structured(cwd.path(), env_b))
|
||||||
|
.await
|
||||||
|
.expect("create b");
|
||||||
wait_for_async(Duration::from_secs(5), || async {
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
a.log().contains("recv session/new") && b.log().contains("recv session/new")
|
a.log().contains("recv session/new") && b.log().contains("recv session/new")
|
||||||
})
|
})
|
||||||
@@ -1141,14 +1205,14 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
|
|||||||
assert!(!shutdown.is_finished());
|
assert!(!shutdown.is_finished());
|
||||||
|
|
||||||
std::fs::write(&gate_a, b"release").unwrap();
|
std::fs::write(&gate_a, b"release").unwrap();
|
||||||
let session_a = create_a.await.expect("create a task").expect("create a");
|
wait_for_ready(&fx.manager, session_a.id).await;
|
||||||
assert!(
|
assert!(
|
||||||
!shutdown.is_finished(),
|
!shutdown.is_finished(),
|
||||||
"shutdown ignored the second create"
|
"shutdown ignored the second create"
|
||||||
);
|
);
|
||||||
|
|
||||||
std::fs::write(&gate_b, b"release").unwrap();
|
std::fs::write(&gate_b, b"release").unwrap();
|
||||||
let session_b = create_b.await.expect("create b task").expect("create b");
|
wait_for_ready(&fx.manager, session_b.id).await;
|
||||||
shutdown.await.expect("shutdown task");
|
shutdown.await.expect("shutdown task");
|
||||||
|
|
||||||
wait_for_exit(a.pid()).await;
|
wait_for_exit(a.pid()).await;
|
||||||
@@ -1164,7 +1228,7 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
|
async fn killing_a_starting_session_rolls_back_and_settles_admission() {
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
let fake = Fake::new();
|
let fake = Fake::new();
|
||||||
let repo = TempDir::new().unwrap();
|
let repo = TempDir::new().unwrap();
|
||||||
@@ -1187,42 +1251,33 @@ async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
|
|||||||
branch: "cancelled-create".into(),
|
branch: "cancelled-create".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let create = {
|
// create() returns immediately with Starting; the handshake is gated.
|
||||||
let manager = fx.manager.clone();
|
let session = fx.manager.create(request).await.expect("create");
|
||||||
tokio::spawn(async move { manager.create(request).await })
|
assert_eq!(session.activity, ActivityState::Starting);
|
||||||
};
|
|
||||||
wait_for_async(Duration::from_secs(5), || async {
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
fake.log().contains("recv session/new")
|
fake.log().contains("recv session/new")
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
let id = fake
|
|
||||||
.log()
|
|
||||||
.lines()
|
|
||||||
.find_map(|line| line.strip_prefix("env MAESTRO_SESSION_ID="))
|
|
||||||
.and_then(|id| uuid::Uuid::parse_str(id).ok())
|
|
||||||
.expect("session id in adapter log");
|
|
||||||
let pid = fake.pid();
|
let pid = fake.pid();
|
||||||
|
|
||||||
create.abort();
|
// Kill the session while its handshake is still in progress.
|
||||||
let shutdown = {
|
// force=true because the worktree has an unpushed branch commit.
|
||||||
let manager = fx.manager.clone();
|
fx.manager.kill(session.id, true).await.expect("kill");
|
||||||
tokio::spawn(async move { manager.shutdown().await })
|
|
||||||
};
|
// Release the gate so the background task can finish and settle.
|
||||||
assert!(create.await.unwrap_err().is_cancelled());
|
std::fs::write(&gate, b"release").unwrap();
|
||||||
tokio::time::timeout(Duration::from_secs(5), shutdown)
|
|
||||||
|
// Shutdown must not hang — the lifecycle permit is settled.
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), fx.manager.shutdown())
|
||||||
.await
|
.await
|
||||||
.expect("cancelled admission kept shutdown waiting")
|
.expect("shutdown hung on a settled permit");
|
||||||
.expect("shutdown task");
|
|
||||||
wait_for_exit(pid).await;
|
wait_for_exit(pid).await;
|
||||||
wait_for_async(Duration::from_secs(5), || async {
|
|
||||||
!fx.fifo_dir().join(format!("{id}.fifo")).exists()
|
|
||||||
&& !repo.path().join("cancelled-create").exists()
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
assert!(fx.manager.snapshot().await.is_empty());
|
assert!(fx.manager.snapshot().await.is_empty());
|
||||||
let id = id.to_string();
|
let id = session.id.to_string();
|
||||||
assert!(
|
assert!(
|
||||||
std::fs::read_dir(fx.sidecar_dir())
|
std::fs::read_dir(fx.sidecar_dir())
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -1232,7 +1287,7 @@ async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
|
|||||||
.file_name()
|
.file_name()
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.starts_with(&id)),
|
.starts_with(&id)),
|
||||||
"cancelled create left a sidecar artifact"
|
"killed Starting session left a sidecar artifact"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1249,6 +1304,7 @@ async fn shutdown_drains_final_notifications_to_the_log() {
|
|||||||
.create(structured(cwd.path(), env))
|
.create(structured(cwd.path(), env))
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
wait_for_ready(&fx.manager, session.id).await;
|
||||||
wait_for_async(Duration::from_secs(5), || async {
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
fx.manager
|
fx.manager
|
||||||
.read_acp_events(session.id, EventCursor::Since(0), 10)
|
.read_acp_events(session.id, EventCursor::Since(0), 10)
|
||||||
@@ -1279,6 +1335,7 @@ async fn adapter_exit_marks_the_session_dead_and_keeps_it_listed() {
|
|||||||
.create(structured(cwd.path(), fake.env()))
|
.create(structured(cwd.path(), fake.env()))
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
wait_for_ready(&fx.manager, session.id).await;
|
||||||
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
||||||
|
|
||||||
// The agent dies out from under the daemon: the liveness pass, not tmux,
|
// The agent dies out from under the daemon: the liveness pass, not tmux,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata: Default::default(),
|
metadata: Default::default(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ async fn create_bash_session(h: &Harness) -> Uuid {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata,
|
metadata,
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> Creat
|
|||||||
branch: branch.into(),
|
branch: branch.into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,8 @@ async fn structured_session(h: &Harness, fake: &FakeAcp) -> Uuid {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>) -> Uuid {
|
async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>) -> Uuid {
|
||||||
h.manager
|
let id = h
|
||||||
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: SessionView::Structured,
|
view: SessionView::Structured,
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
@@ -162,10 +163,13 @@ async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>)
|
|||||||
env,
|
env,
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.id
|
.id;
|
||||||
|
wait_for_session_activity(h, id, ActivityState::Idle).await;
|
||||||
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn wait_for_session_activity(h: &Harness, id: Uuid, expected: ActivityState) {
|
async fn wait_for_session_activity(h: &Harness, id: Uuid, expected: ActivityState) {
|
||||||
@@ -234,6 +238,60 @@ async fn structured_ws_sends_snapshot_then_live_projection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_attachments_preserve_bytes_and_reject_paths() {
|
||||||
|
let h = spawn("attachments").await;
|
||||||
|
let fake = FakeAcp::new();
|
||||||
|
let id = structured_session(&h, &fake).await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let endpoint = url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{id}/structured/attachments"),
|
||||||
|
);
|
||||||
|
let bytes = vec![0, 255, 10, 13];
|
||||||
|
let response = client
|
||||||
|
.post(&endpoint)
|
||||||
|
.query(&[("name", "sample.bin")])
|
||||||
|
.body(bytes.clone())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
let body: serde_json::Value = response.json().await.unwrap();
|
||||||
|
let path = body["path"].as_str().unwrap();
|
||||||
|
assert!(Path::new(path).is_absolute());
|
||||||
|
assert_eq!(std::fs::read(path).unwrap(), bytes);
|
||||||
|
for name in ["../escape", "a/b", "a\\b", "..", ""] {
|
||||||
|
let response = client
|
||||||
|
.post(&endpoint)
|
||||||
|
.query(&[("name", name)])
|
||||||
|
.body("text")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), 400, "{name}");
|
||||||
|
}
|
||||||
|
let response = client
|
||||||
|
.post(&endpoint)
|
||||||
|
.query(&[("name", "large.bin")])
|
||||||
|
.body(vec![0; 20 * 1024 * 1024 + 1])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), 413);
|
||||||
|
let response = client
|
||||||
|
.post(url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{}/structured/attachments", Uuid::new_v4()),
|
||||||
|
))
|
||||||
|
.query(&[("name", "file.txt")])
|
||||||
|
.body("text")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), 404);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() {
|
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() {
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
@@ -493,6 +551,7 @@ async fn structured_prompt_rejects_unknown_and_unavailable_sessions() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -626,6 +685,7 @@ async fn structured_replay_rejects_unknown_terminal_and_dead_sessions() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -768,6 +828,7 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
let created: Session = reqwest::Client::new()
|
let created: Session = reqwest::Client::new()
|
||||||
.post(url(h.addr, "/api/sessions"))
|
.post(url(h.addr, "/api/sessions"))
|
||||||
@@ -942,7 +1003,9 @@ async fn names_honors_count_and_caps_it() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gitroot_probes_path() {
|
async fn gitroot_probes_path() {
|
||||||
// #26: resolves the query against the daemon home dir and walks up for a
|
// #26: resolves the query against the daemon home dir and walks up for a
|
||||||
// `.git`. 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 h = spawn("naps-laptop").await;
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let resp: serde_json::Value = reqwest::get(url(
|
let resp: serde_json::Value = reqwest::get(url(
|
||||||
@@ -957,7 +1020,16 @@ async fn gitroot_probes_path() {
|
|||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.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]
|
#[tokio::test]
|
||||||
@@ -985,6 +1057,7 @@ async fn patch_session_renames_it() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1028,6 +1101,7 @@ async fn post_focus_stamps_last_focused_at() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1062,6 +1136,7 @@ async fn delete_owned_session_kills_it() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1084,6 +1159,7 @@ async fn spawn_via_manager(h: &Harness) -> Uuid {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -1198,6 +1274,7 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
|
|||||||
branch: "feat/guarded".into(),
|
branch: "feat/guarded".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1251,6 +1328,7 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
|
|||||||
branch: "feat/guarded".into(),
|
branch: "feat/guarded".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1281,6 +1359,7 @@ async fn attach_ws_streams_pty_bytes() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1383,6 +1462,7 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1428,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 {
|
let maestro_protocol::CatalogEvent::SessionAdded { session } = next_event(&mut ws).await else {
|
||||||
panic!("expected structured SessionAdded")
|
panic!("expected structured SessionAdded")
|
||||||
};
|
};
|
||||||
|
assert_eq!(session.activity, ActivityState::Starting);
|
||||||
|
// The background handshake completes and sends SessionUpdated → Idle.
|
||||||
|
let maestro_protocol::CatalogEvent::SessionUpdated { session } = next_event(&mut ws).await
|
||||||
|
else {
|
||||||
|
panic!("expected SessionUpdated after handshake")
|
||||||
|
};
|
||||||
assert_eq!(session.activity, ActivityState::Idle);
|
assert_eq!(session.activity, ActivityState::Idle);
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
@@ -1495,6 +1581,7 @@ async fn every_events_subscriber_gets_its_own_snapshot() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata,
|
metadata,
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ impl Fx {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ async fn create_then_kill_round_trip() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -70,6 +71,7 @@ async fn create_appends_to_spawn_history() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]),
|
metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -100,6 +102,7 @@ async fn create_without_history_logs_nothing() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -123,6 +126,7 @@ async fn recover_adopts_existing_tmux_sessions() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -166,6 +170,7 @@ async fn create_captures_initial_output_in_replay_buffer() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -215,6 +220,7 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -273,6 +279,7 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -324,6 +331,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -387,6 +395,7 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -438,6 +447,7 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -508,6 +518,7 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
|
|||||||
branch: "feat/indicator".into(),
|
branch: "feat/indicator".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -601,6 +612,7 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
|
|||||||
branch: "feat/keep".into(),
|
branch: "feat/keep".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -639,6 +651,7 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
|
|||||||
branch: "feat/guarded".into(),
|
branch: "feat/guarded".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -703,6 +716,7 @@ async fn killing_refuses_until_the_branch_is_pushed() {
|
|||||||
branch: "feat/pushed".into(),
|
branch: "feat/pushed".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -753,6 +767,7 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
|
|||||||
branch: "feat/forced".into(),
|
branch: "feat/forced".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -799,6 +814,7 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
|||||||
branch: "feat/clear".into(),
|
branch: "feat/clear".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -840,6 +856,7 @@ async fn killing_an_ignored_only_worktree_succeeds() {
|
|||||||
branch: "feat/rusty".into(),
|
branch: "feat/rusty".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -882,6 +899,7 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
|
|||||||
branch: "feat/exited".into(),
|
branch: "feat/exited".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -944,6 +962,7 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
|||||||
branch: "feat/clean".into(),
|
branch: "feat/clean".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1001,6 +1020,7 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1040,6 +1060,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
|||||||
branch: "feat/one".into(),
|
branch: "feat/one".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create first");
|
.expect("create first");
|
||||||
@@ -1058,6 +1079,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
|||||||
branch: "feat/two".into(),
|
branch: "feat/two".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create second");
|
.expect("create second");
|
||||||
@@ -1093,6 +1115,7 @@ async fn spawned_sessions_export_maestro_session_id_and_url() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1132,6 +1155,7 @@ async fn a_freshly_created_session_has_never_been_focused() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1158,6 +1182,7 @@ async fn set_focused_true_stamps_last_focused_at() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1207,6 +1232,7 @@ async fn attaching_stamps_last_focused_at() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +112,7 @@ fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> Cr
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(base, branch, create_branch)),
|
worktree: Some(worktree_spec(base, branch, create_branch)),
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +208,7 @@ async fn kill_removes_clean_worktree() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
|
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||||
@@ -260,6 +263,7 @@ async fn kill_refuses_worktree_with_uncommitted_changes() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
|
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||||
@@ -319,6 +323,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
|
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let s = client
|
let s = client
|
||||||
@@ -827,6 +832,7 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
|
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("spawn dirty");
|
.expect("spawn dirty");
|
||||||
@@ -876,6 +882,7 @@ fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateS
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(cwd, branch, true)),
|
worktree: Some(worktree_spec(cwd, branch, true)),
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,6 +1075,7 @@ async fn sigterm_stops_structured_child_before_events_ws_drains() {
|
|||||||
env: fake.env.clone(),
|
env: fake.env.clone(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("spawn structured session");
|
.expect("spawn structured session");
|
||||||
|
|||||||
@@ -226,6 +226,15 @@ pub fn command_tool(command: &str) -> Option<&'static str> {
|
|||||||
.map(|(_, tool)| *tool)
|
.map(|(_, tool)| *tool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a command line resolves to an agent Maestro can run as a
|
||||||
|
/// structured (ACP) session — Claude Code or Codex, the two adapters
|
||||||
|
/// `docs/STRUCTURED-VIEW.md` lists. Every client gates its structured-view
|
||||||
|
/// control on this, and the daemon rejects a `Structured` request for
|
||||||
|
/// anything else, so the answer has to come from one place.
|
||||||
|
pub fn supports_structured_view(command: &str) -> bool {
|
||||||
|
matches!(command_tool(command), Some("claude" | "codex"))
|
||||||
|
}
|
||||||
|
|
||||||
/// [`resolve`]'s outcome: the tokenized command line, and the index within it
|
/// [`resolve`]'s outcome: the tokenized command line, and the index within it
|
||||||
/// of the word that is the actual program (past every assignment/wrapper it
|
/// of the word that is the actual program (past every assignment/wrapper it
|
||||||
/// resolved through).
|
/// resolved through).
|
||||||
@@ -1064,4 +1073,14 @@ mod tests {
|
|||||||
assert_eq!(tokenize("\"FOO=1\" claude").unwrap()[0].plain_eq, None);
|
assert_eq!(tokenize("\"FOO=1\" claude").unwrap()[0].plain_eq, None);
|
||||||
assert_eq!(tokenize("FOO\\=1 claude").unwrap()[0].plain_eq, None);
|
assert_eq!(tokenize("FOO\\=1 claude").unwrap()[0].plain_eq, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_view_support_follows_the_resolved_program() {
|
||||||
|
assert!(supports_structured_view("claude --resume"));
|
||||||
|
assert!(supports_structured_view("FOO=1 /usr/local/bin/codex exec"));
|
||||||
|
assert!(supports_structured_view("npx claude-code"));
|
||||||
|
assert!(!supports_structured_view("opencode"));
|
||||||
|
assert!(!supports_structured_view("bash -l"));
|
||||||
|
assert!(!supports_structured_view("claude-code-acp"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,10 @@ pub enum ActivityState {
|
|||||||
AwaitingInput,
|
AwaitingInput,
|
||||||
/// An agent reported a failure, or a structured session/turn failed.
|
/// An agent reported a failure, or a structured session/turn failed.
|
||||||
Error,
|
Error,
|
||||||
|
/// The ACP adapter is booting; the session is registered but cannot accept
|
||||||
|
/// prompts yet. Older clients that do not know this variant degrade it to
|
||||||
|
/// `Idle` via the lenient deserializer — acceptable.
|
||||||
|
Starting,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for ActivityState {
|
impl<'de> Deserialize<'de> for ActivityState {
|
||||||
@@ -187,6 +191,7 @@ impl<'de> Deserialize<'de> for ActivityState {
|
|||||||
"Active" => Self::Active,
|
"Active" => Self::Active,
|
||||||
"AwaitingInput" => Self::AwaitingInput,
|
"AwaitingInput" => Self::AwaitingInput,
|
||||||
"Error" => Self::Error,
|
"Error" => Self::Error,
|
||||||
|
"Starting" => Self::Starting,
|
||||||
// Known or not, any other spelling degrades to the neutral default
|
// Known or not, any other spelling degrades to the neutral default
|
||||||
_ => Self::Idle,
|
_ => Self::Idle,
|
||||||
})
|
})
|
||||||
@@ -225,9 +230,10 @@ where
|
|||||||
"Idle" => Ok(ActivityState::Idle),
|
"Idle" => Ok(ActivityState::Idle),
|
||||||
"AwaitingInput" => Ok(ActivityState::AwaitingInput),
|
"AwaitingInput" => Ok(ActivityState::AwaitingInput),
|
||||||
"Error" => Ok(ActivityState::Error),
|
"Error" => Ok(ActivityState::Error),
|
||||||
|
"Starting" => Ok(ActivityState::Starting),
|
||||||
other => Err(D::Error::unknown_variant(
|
other => Err(D::Error::unknown_variant(
|
||||||
other,
|
other,
|
||||||
&["Active", "Idle", "AwaitingInput", "Error"],
|
&["Active", "Idle", "AwaitingInput", "Error", "Starting"],
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -383,6 +389,10 @@ pub struct CreateSessionRequest {
|
|||||||
/// untouched.
|
/// untouched.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub view: SessionView,
|
pub view: SessionView,
|
||||||
|
/// When `true`, the daemon auto-approves every ACP permission request
|
||||||
|
/// for this structured session.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_approve: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of `GET /api/dirs`: the immediate subdirectories of one directory.
|
/// Result of `GET /api/dirs`: the immediate subdirectories of one directory.
|
||||||
@@ -522,6 +532,11 @@ pub struct SpawnPreset {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub command: String,
|
pub command: String,
|
||||||
|
/// When `true`, the daemon auto-approves every ACP permission request
|
||||||
|
/// for structured sessions spawned from this preset instead of surfacing
|
||||||
|
/// them to the client.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_approve: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build provenance of the running daemon: the commit it was compiled
|
/// Build provenance of the running daemon: the commit it was compiled
|
||||||
@@ -1251,6 +1266,7 @@ mod tests {
|
|||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
view: SessionView::Terminal,
|
view: SessionView::Terminal,
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
assert_eq!(req, round_trip(&req));
|
assert_eq!(req, round_trip(&req));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ pub fn structured_prompt(id: Uuid) -> String {
|
|||||||
format!("{SESSIONS}/{id}/structured/prompt")
|
format!("{SESSIONS}/{id}/structured/prompt")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /api/sessions/:id/structured/attachments` — upload a file.
|
||||||
|
pub fn structured_attachment(id: Uuid) -> String {
|
||||||
|
format!("{SESSIONS}/{id}/structured/attachments")
|
||||||
|
}
|
||||||
|
|
||||||
/// `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn.
|
/// `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn.
|
||||||
pub fn structured_cancel(id: Uuid) -> String {
|
pub fn structured_cancel(id: Uuid) -> String {
|
||||||
format!("{SESSIONS}/{id}/structured/cancel")
|
format!("{SESSIONS}/{id}/structured/cancel")
|
||||||
@@ -113,6 +118,7 @@ pub mod patterns {
|
|||||||
pub const STRUCTURED: &str = "/api/structured/:id";
|
pub const STRUCTURED: &str = "/api/structured/:id";
|
||||||
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
|
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
|
||||||
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
|
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
|
||||||
|
pub const STRUCTURED_ATTACHMENT: &str = "/api/sessions/:id/structured/attachments";
|
||||||
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
|
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
|
||||||
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
|
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
|
||||||
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
|
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ use chrono::Utc;
|
|||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
|
||||||
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION,
|
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, StructuredPromptRequest,
|
||||||
|
PROTOCOL_VERSION,
|
||||||
};
|
};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::{broadcast, oneshot};
|
use tokio::sync::{broadcast, oneshot};
|
||||||
@@ -60,6 +61,8 @@ struct Inner {
|
|||||||
forced_error: Option<(u16, ApiError)>,
|
forced_error: Option<(u16, ApiError)>,
|
||||||
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
|
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
|
||||||
inputs: Vec<(Uuid, SendInputRequest)>,
|
inputs: Vec<(Uuid, SendInputRequest)>,
|
||||||
|
/// Every ACP prompt sent to a structured session, in order.
|
||||||
|
structured_prompts: Vec<(Uuid, StructuredPromptRequest)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -92,6 +95,7 @@ impl MockDaemon {
|
|||||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||||
.route(routes::patterns::SESSION, delete(kill_session))
|
.route(routes::patterns::SESSION, delete(kill_session))
|
||||||
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
||||||
|
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
||||||
.route(routes::INFO, get(daemon_info))
|
.route(routes::INFO, get(daemon_info))
|
||||||
.route(routes::DIRS, get(list_dir))
|
.route(routes::DIRS, get(list_dir))
|
||||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||||
@@ -178,6 +182,16 @@ impl MockDaemon {
|
|||||||
self.state.inner.lock().expect("poisoned").inputs.clone()
|
self.state.inner.lock().expect("poisoned").inputs.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every ACP prompt this daemon received, oldest first.
|
||||||
|
pub fn structured_prompts(&self) -> Vec<(Uuid, StructuredPromptRequest)> {
|
||||||
|
self.state
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.expect("poisoned")
|
||||||
|
.structured_prompts
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn session_count(&self) -> usize {
|
pub fn session_count(&self) -> usize {
|
||||||
self.state.inner.lock().expect("poisoned").sessions.len()
|
self.state.inner.lock().expect("poisoned").sessions.len()
|
||||||
}
|
}
|
||||||
@@ -266,6 +280,26 @@ async fn send_input(
|
|||||||
StatusCode::NO_CONTENT.into_response()
|
StatusCode::NO_CONTENT.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn structured_prompt(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<StructuredPromptRequest>,
|
||||||
|
) -> Response {
|
||||||
|
if let Some(r) = forced(&s) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
let mut guard = s.inner.lock().expect("poisoned");
|
||||||
|
if !guard.sessions.iter().any(|x| x.id == id) {
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ApiError::new("not_found", "no such session on this daemon")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
guard.structured_prompts.push((id, req));
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
async fn daemon_info(State(s): State<AppState>) -> Response {
|
async fn daemon_info(State(s): State<AppState>) -> Response {
|
||||||
if let Some(r) = forced(&s) {
|
if let Some(r) = forced(&s) {
|
||||||
return r;
|
return r;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
//! `dispatch` / the overlay routing.
|
//! `dispatch` / the overlay routing.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::io::Write;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -54,6 +55,36 @@ enum OverlayDecision {
|
|||||||
Pass,
|
Pass,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn copy_to_terminal_clipboard(text: &str) -> std::io::Result<()> {
|
||||||
|
let encoded = base64_encode(text.as_bytes());
|
||||||
|
let mut output = std::io::stdout();
|
||||||
|
write!(output, "\x1b]52;c;{encoded}\x07")?;
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base64_encode(bytes: &[u8]) -> String {
|
||||||
|
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||||
|
for chunk in bytes.chunks(3) {
|
||||||
|
let bits = (u32::from(chunk[0]) << 16)
|
||||||
|
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
|
||||||
|
| u32::from(*chunk.get(2).unwrap_or(&0));
|
||||||
|
output.push(TABLE[((bits >> 18) & 0x3f) as usize] as char);
|
||||||
|
output.push(TABLE[((bits >> 12) & 0x3f) as usize] as char);
|
||||||
|
output.push(if chunk.len() > 1 {
|
||||||
|
TABLE[((bits >> 6) & 0x3f) as usize] as char
|
||||||
|
} else {
|
||||||
|
'='
|
||||||
|
});
|
||||||
|
output.push(if chunk.len() > 2 {
|
||||||
|
TABLE[(bits & 0x3f) as usize] as char
|
||||||
|
} else {
|
||||||
|
'='
|
||||||
|
});
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
cfg: TuiConfig,
|
cfg: TuiConfig,
|
||||||
home_dir: String,
|
home_dir: String,
|
||||||
@@ -340,6 +371,10 @@ impl App {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.handle_structured_mouse(&key) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(effects) = self.handle_structured_key(&key) {
|
if let Some(effects) = self.handle_structured_key(&key) {
|
||||||
self.perform(effects).await;
|
self.perform(effects).await;
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -423,6 +458,61 @@ impl App {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Transcript blocks are pointer targets: click selects the whole block,
|
||||||
|
/// gives it the selection surface, and copies its complete text.
|
||||||
|
fn handle_structured_mouse(&mut self, key: &InputKey) -> bool {
|
||||||
|
let InputKey::Mouse {
|
||||||
|
kind,
|
||||||
|
region: Region::Pane,
|
||||||
|
row,
|
||||||
|
..
|
||||||
|
} = key
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !matches!(kind, MouseKind::Click | MouseKind::Hover) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(sid) = self.state.active_session() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(handle) = self.structured.get(&sid) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(pane) = self.region.pane else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let offset = usize::from(row.saturating_sub(pane.y));
|
||||||
|
let transcript_height = pane.height.saturating_sub(pane.height.min(3));
|
||||||
|
if offset >= usize::from(transcript_height) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let mut state = handle.state.lock().expect("structured state mutex");
|
||||||
|
let rendered_line = state.scroll.saturating_add(offset);
|
||||||
|
if !state.select_rendered_line(rendered_line) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let Some(id) = state.selected.as_deref() else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let Some(transcript) = state.rows.iter().find(|item| item.id == id) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if *kind == MouseKind::Hover {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let text = crate::structured_pane::copy_text(transcript);
|
||||||
|
drop(state);
|
||||||
|
if text.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match copy_to_terminal_clipboard(&text) {
|
||||||
|
Ok(()) => self.state.toast("copied transcript block"),
|
||||||
|
Err(error) => self.state.toast(format!("copy failed: {error}")),
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Carry out what `dispatch` decided. The only place in `App` that talks to
|
/// Carry out what `dispatch` decided. The only place in `App` that talks to
|
||||||
/// the daemon.
|
/// the daemon.
|
||||||
async fn perform(&mut self, effects: Vec<Effect>) {
|
async fn perform(&mut self, effects: Vec<Effect>) {
|
||||||
@@ -1905,6 +1995,12 @@ async fn fetch_branches(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clipboard_payload_uses_standard_base64() {
|
||||||
|
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
|
||||||
|
assert_eq!(base64_encode("✓".as_bytes()), "4pyT");
|
||||||
|
}
|
||||||
use crate::action::Action;
|
use crate::action::Action;
|
||||||
use crate::draft::DraftField;
|
use crate::draft::DraftField;
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
@@ -3009,6 +3105,7 @@ mod tests {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata: Default::default(),
|
metadata: Default::default(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
app.submit_spawn(req).await;
|
app.submit_spawn(req).await;
|
||||||
|
|
||||||
@@ -3057,6 +3154,7 @@ mod tests {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata: Default::default(),
|
metadata: Default::default(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let structured_id = Uuid::from_u128(188);
|
let structured_id = Uuid::from_u128(188);
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use maestro_config::{builtin_presets, custom_preset};
|
use maestro_config::{builtin_presets, custom_preset};
|
||||||
use maestro_protocol::display::{abbreviate_dir, command_tool};
|
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
|
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
|
||||||
};
|
};
|
||||||
@@ -255,9 +255,14 @@ pub struct DraftPane {
|
|||||||
/// "Advanced options" disclosure (#93). Closed by default — most spawns
|
/// "Advanced options" disclosure (#93). Closed by default — most spawns
|
||||||
/// take the default target.
|
/// take the default target.
|
||||||
pub advanced_collapsed: bool,
|
pub advanced_collapsed: bool,
|
||||||
/// ACP-backed structured transcript. Off by default and supported only
|
/// ACP-backed structured transcript. Supported only when the resolved
|
||||||
/// when the resolved command is Claude Code or Codex.
|
/// command is Claude Code or Codex, and on by default whenever it is —
|
||||||
|
/// until the operator works the toggle themselves.
|
||||||
pub structured_view: bool,
|
pub structured_view: bool,
|
||||||
|
/// Set once the operator toggles structured view by hand. From then on a
|
||||||
|
/// command change never re-enables what they turned off, the same guard
|
||||||
|
/// `name_touched` gives the name field.
|
||||||
|
pub structured_view_touched: bool,
|
||||||
/// Enclosing repository of the chosen target — from the project scan for
|
/// Enclosing repository of the chosen target — from the project scan for
|
||||||
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
|
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
|
||||||
pub base_repo: Option<PathBuf>,
|
pub base_repo: Option<PathBuf>,
|
||||||
@@ -298,7 +303,7 @@ impl Default for DraftPane {
|
|||||||
// while its data loads, and stay usable when the daemon reports none.
|
// while its data loads, and stay usable when the daemon reports none.
|
||||||
let mut presets = builtin_presets();
|
let mut presets = builtin_presets();
|
||||||
presets.push(custom_preset());
|
presets.push(custom_preset());
|
||||||
Self {
|
let mut draft = Self {
|
||||||
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||||
presets,
|
presets,
|
||||||
selected_preset: 0,
|
selected_preset: 0,
|
||||||
@@ -319,6 +324,7 @@ impl Default for DraftPane {
|
|||||||
worktree_enabled: true,
|
worktree_enabled: true,
|
||||||
advanced_collapsed: true,
|
advanced_collapsed: true,
|
||||||
structured_view: false,
|
structured_view: false,
|
||||||
|
structured_view_touched: false,
|
||||||
base_repo: None,
|
base_repo: None,
|
||||||
probed_cwd: None,
|
probed_cwd: None,
|
||||||
probe_seq: 0,
|
probe_seq: 0,
|
||||||
@@ -337,7 +343,11 @@ impl Default for DraftPane {
|
|||||||
// straight away, and Tab from here reaches the command tiles.
|
// straight away, and Tab from here reaches the command tiles.
|
||||||
focused_field: DraftField::DirPicker,
|
focused_field: DraftField::DirPicker,
|
||||||
home_dir: String::new(),
|
home_dir: String::new(),
|
||||||
}
|
};
|
||||||
|
// The built-in tile the form opens on decides whether structured view
|
||||||
|
// starts ticked, exactly as a later preset list would.
|
||||||
|
draft.sync_structured_view();
|
||||||
|
draft
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +366,7 @@ impl DraftPane {
|
|||||||
.position(|p| !p.command.is_empty())
|
.position(|p| !p.command.is_empty())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
self.apply_seed_command();
|
self.apply_seed_command();
|
||||||
self.clear_unsupported_structured_view();
|
self.sync_structured_view();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Land a seeded command on the tile that runs it: its own preset when the
|
/// Land a seeded command on the tile that runs it: its own preset when the
|
||||||
@@ -673,18 +683,24 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn supports_structured_view(&self) -> bool {
|
fn supports_structured_view(&self) -> bool {
|
||||||
matches!(command_tool(self.command()), Some("claude" | "codex"))
|
supports_structured_view(self.command())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clear_unsupported_structured_view(&mut self) {
|
/// Re-resolve the toggle after the command changed: an unsupported
|
||||||
|
/// command forces it off, a supported one turns it back on unless the
|
||||||
|
/// operator has already made the choice themselves.
|
||||||
|
fn sync_structured_view(&mut self) {
|
||||||
if !self.supports_structured_view() {
|
if !self.supports_structured_view() {
|
||||||
self.structured_view = false;
|
self.structured_view = false;
|
||||||
|
} else if !self.structured_view_touched {
|
||||||
|
self.structured_view = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn toggle_structured_view(&mut self) {
|
pub fn toggle_structured_view(&mut self) {
|
||||||
if self.supports_structured_view() {
|
if self.supports_structured_view() {
|
||||||
self.structured_view = !self.structured_view;
|
self.structured_view = !self.structured_view;
|
||||||
|
self.structured_view_touched = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -718,6 +734,10 @@ impl DraftPane {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let worktree = self.build_worktree()?;
|
let worktree = self.build_worktree()?;
|
||||||
|
let auto_approve = self
|
||||||
|
.presets
|
||||||
|
.get(self.selected_preset)
|
||||||
|
.is_some_and(|p| p.auto_approve);
|
||||||
Ok(CreateSessionRequest {
|
Ok(CreateSessionRequest {
|
||||||
view: if self.structured_view && self.supports_structured_view() {
|
view: if self.structured_view && self.supports_structured_view() {
|
||||||
SessionView::Structured
|
SessionView::Structured
|
||||||
@@ -729,6 +749,7 @@ impl DraftPane {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata,
|
metadata,
|
||||||
worktree,
|
worktree,
|
||||||
|
auto_approve,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -827,7 +848,7 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
self.selected_preset =
|
self.selected_preset =
|
||||||
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
|
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
|
||||||
self.clear_unsupported_structured_view();
|
self.sync_structured_view();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Step-one boxes surviving the type-to-filter, in display order. A
|
/// Step-one boxes surviving the type-to-filter, in display order. A
|
||||||
@@ -1130,7 +1151,7 @@ impl DraftPane {
|
|||||||
DraftField::Preset | DraftField::Submit => {}
|
DraftField::Preset | DraftField::Submit => {}
|
||||||
DraftField::CustomCommand => {
|
DraftField::CustomCommand => {
|
||||||
self.custom_command.handle_key(other);
|
self.custom_command.handle_key(other);
|
||||||
self.clear_unsupported_structured_view();
|
self.sync_structured_view();
|
||||||
}
|
}
|
||||||
// Typing filters the list; the highlight resets to the top
|
// Typing filters the list; the highlight resets to the top
|
||||||
// so a narrowed search always starts at its best match.
|
// so a narrowed search always starts at its best match.
|
||||||
@@ -1776,6 +1797,7 @@ mod tests {
|
|||||||
id: "aider".into(),
|
id: "aider".into(),
|
||||||
label: "Aider".into(),
|
label: "Aider".into(),
|
||||||
command: "aider".into(),
|
command: "aider".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]);
|
}]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
draft.command(),
|
draft.command(),
|
||||||
@@ -1798,11 +1820,13 @@ mod tests {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "aider".into(),
|
id: "aider".into(),
|
||||||
label: "Aider".into(),
|
label: "Aider".into(),
|
||||||
command: "aider".into(),
|
command: "aider".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert_eq!(draft.command(), "aider");
|
assert_eq!(draft.command(), "aider");
|
||||||
@@ -1822,6 +1846,7 @@ mod tests {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]);
|
}]);
|
||||||
assert!(draft.is_custom());
|
assert!(draft.is_custom());
|
||||||
assert_eq!(draft.command(), "bash -l");
|
assert_eq!(draft.command(), "bash -l");
|
||||||
@@ -1888,7 +1913,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn structured_view_is_default_off_and_visible_for_supported_agents() {
|
fn structured_view_is_default_on_and_visible_for_supported_agents() {
|
||||||
let mut draft = DraftPane {
|
let mut draft = DraftPane {
|
||||||
advanced_collapsed: false,
|
advanced_collapsed: false,
|
||||||
..DraftPane::default()
|
..DraftPane::default()
|
||||||
@@ -1898,25 +1923,26 @@ mod tests {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "codex".into(),
|
id: "codex".into(),
|
||||||
label: "Codex".into(),
|
label: "Codex".into(),
|
||||||
command: "codex".into(),
|
command: "codex".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "opencode".into(),
|
id: "opencode".into(),
|
||||||
label: "OpenCode".into(),
|
label: "OpenCode".into(),
|
||||||
command: "opencode".into(),
|
command: "opencode".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert!(!draft.structured_view);
|
assert!(draft.structured_view, "on by default for a supported agent");
|
||||||
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
||||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||||
assert!(rendered.contains("[ ] structured view"), "{rendered}");
|
assert!(rendered.contains("[x] structured view"), "{rendered}");
|
||||||
|
|
||||||
draft.toggle_structured_view();
|
|
||||||
assert!(draft.structured_view);
|
|
||||||
draft.cycle_preset(1);
|
draft.cycle_preset(1);
|
||||||
assert_eq!(draft.command(), "codex");
|
assert_eq!(draft.command(), "codex");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -2006,16 +2032,16 @@ mod tests {
|
|||||||
};
|
};
|
||||||
draft.selected_preset = draft.presets.len() - 1;
|
draft.selected_preset = draft.presets.len() - 1;
|
||||||
draft.custom_command = crate::text_input::TextInput::with_value(command);
|
draft.custom_command = crate::text_input::TextInput::with_value(command);
|
||||||
|
draft.sync_structured_view();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
draft.field_order().contains(&DraftField::StructuredToggle),
|
draft.field_order().contains(&DraftField::StructuredToggle),
|
||||||
supported,
|
supported,
|
||||||
"toggle visibility for {command:?}"
|
"toggle visibility for {command:?}"
|
||||||
);
|
);
|
||||||
draft.toggle_structured_view();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
draft.structured_view, supported,
|
draft.structured_view, supported,
|
||||||
"toggle takes effect for {command:?}"
|
"default selection for {command:?}"
|
||||||
);
|
);
|
||||||
let view = draft.build_request().unwrap().view;
|
let view = draft.build_request().unwrap().view;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2027,6 +2053,12 @@ mod tests {
|
|||||||
},
|
},
|
||||||
"request view for {command:?}"
|
"request view for {command:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
draft.toggle_structured_view();
|
||||||
|
assert!(
|
||||||
|
!draft.structured_view,
|
||||||
|
"the toggle turns the default off for {command:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2046,22 +2078,24 @@ mod tests {
|
|||||||
id: "claude-wrapped".into(),
|
id: "claude-wrapped".into(),
|
||||||
label: "Claude (npx)".into(),
|
label: "Claude (npx)".into(),
|
||||||
command: "npx claude-code --resume".into(),
|
command: "npx claude-code --resume".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "codex-wrapped".into(),
|
id: "codex-wrapped".into(),
|
||||||
label: "Codex (npx)".into(),
|
label: "Codex (npx)".into(),
|
||||||
command: "npx codex exec".into(),
|
command: "npx codex exec".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "aider".into(),
|
id: "aider".into(),
|
||||||
label: "Aider".into(),
|
label: "Aider".into(),
|
||||||
command: "aider".into(),
|
command: "aider".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert_eq!(draft.command(), "npx claude-code --resume");
|
assert_eq!(draft.command(), "npx claude-code --resume");
|
||||||
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
||||||
draft.toggle_structured_view();
|
|
||||||
assert!(draft.structured_view);
|
assert!(draft.structured_view);
|
||||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
||||||
|
|
||||||
@@ -2080,6 +2114,45 @@ mod tests {
|
|||||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The default only applies until the operator has an opinion: once they
|
||||||
|
/// untick the box, moving between supported commands leaves it unticked
|
||||||
|
/// instead of turning it back on under them.
|
||||||
|
#[test]
|
||||||
|
fn an_unticked_structured_view_survives_a_command_change() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
picked_cwd: Some(PathBuf::from("/srv/work")),
|
||||||
|
worktree_enabled: false,
|
||||||
|
advanced_collapsed: false,
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
draft.set_presets(vec![
|
||||||
|
SpawnPreset {
|
||||||
|
id: "claude".into(),
|
||||||
|
label: "Claude".into(),
|
||||||
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
|
},
|
||||||
|
SpawnPreset {
|
||||||
|
id: "codex".into(),
|
||||||
|
label: "Codex".into(),
|
||||||
|
command: "codex".into(),
|
||||||
|
auto_approve: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert!(draft.structured_view);
|
||||||
|
|
||||||
|
draft.toggle_structured_view();
|
||||||
|
assert!(!draft.structured_view);
|
||||||
|
|
||||||
|
draft.cycle_preset(1);
|
||||||
|
assert_eq!(draft.command(), "codex");
|
||||||
|
assert!(
|
||||||
|
!draft.structured_view,
|
||||||
|
"the operator's choice outranks the default"
|
||||||
|
);
|
||||||
|
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||||
|
}
|
||||||
|
|
||||||
/// Editing the custom-command text away from a supported agent clears an armed
|
/// Editing the custom-command text away from a supported agent clears an armed
|
||||||
/// selection immediately, matching the preset-switch case above — an
|
/// selection immediately, matching the preset-switch case above — an
|
||||||
/// invalid `Structured` request can never reach `build_request`.
|
/// invalid `Structured` request can never reach `build_request`.
|
||||||
@@ -2094,7 +2167,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
draft.selected_preset = draft.presets.len() - 1;
|
draft.selected_preset = draft.presets.len() - 1;
|
||||||
draft.custom_command = crate::text_input::TextInput::with_value("codex");
|
draft.custom_command = crate::text_input::TextInput::with_value("codex");
|
||||||
draft.toggle_structured_view();
|
draft.sync_structured_view();
|
||||||
assert!(draft.structured_view);
|
assert!(draft.structured_view);
|
||||||
|
|
||||||
typed(&mut draft, " -p");
|
typed(&mut draft, " -p");
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ impl FastForwardDefaults {
|
|||||||
branch: handle.to_string(),
|
branch: handle.to_string(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +185,7 @@ mod tests {
|
|||||||
id: id.into(),
|
id: id.into(),
|
||||||
label: id.into(),
|
label: id.into(),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ pub enum MouseKind {
|
|||||||
Click,
|
Click,
|
||||||
DblClick,
|
DblClick,
|
||||||
Drag,
|
Drag,
|
||||||
|
Hover,
|
||||||
ScrollUp,
|
ScrollUp,
|
||||||
ScrollDown,
|
ScrollDown,
|
||||||
}
|
}
|
||||||
@@ -148,6 +149,7 @@ impl InputKey {
|
|||||||
MouseKind::Click => "click",
|
MouseKind::Click => "click",
|
||||||
MouseKind::DblClick => "dblclick",
|
MouseKind::DblClick => "dblclick",
|
||||||
MouseKind::Drag => "drag",
|
MouseKind::Drag => "drag",
|
||||||
|
MouseKind::Hover => "hover",
|
||||||
MouseKind::ScrollUp => "scrollup",
|
MouseKind::ScrollUp => "scrollup",
|
||||||
MouseKind::ScrollDown => "scrolldown",
|
MouseKind::ScrollDown => "scrolldown",
|
||||||
};
|
};
|
||||||
@@ -178,6 +180,7 @@ impl InputKey {
|
|||||||
"click" => MouseKind::Click,
|
"click" => MouseKind::Click,
|
||||||
"dblclick" => MouseKind::DblClick,
|
"dblclick" => MouseKind::DblClick,
|
||||||
"drag" => MouseKind::Drag,
|
"drag" => MouseKind::Drag,
|
||||||
|
"hover" => MouseKind::Hover,
|
||||||
"scrollup" => MouseKind::ScrollUp,
|
"scrollup" => MouseKind::ScrollUp,
|
||||||
"scrolldown" => MouseKind::ScrollDown,
|
"scrolldown" => MouseKind::ScrollDown,
|
||||||
other => return Err(InputParseError::UnknownMouseKind(other.into())),
|
other => return Err(InputParseError::UnknownMouseKind(other.into())),
|
||||||
@@ -297,6 +300,7 @@ where
|
|||||||
let mk = match kind {
|
let mk = match kind {
|
||||||
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
|
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
|
||||||
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
|
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
|
||||||
|
MouseEventKind::Moved => MouseKind::Hover,
|
||||||
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
|
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
|
||||||
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
|
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
@@ -681,6 +685,9 @@ mod tests {
|
|||||||
let s = "mouse:dblclick(sidebar-row)";
|
let s = "mouse:dblclick(sidebar-row)";
|
||||||
let k = InputKey::parse(s).unwrap();
|
let k = InputKey::parse(s).unwrap();
|
||||||
assert_eq!(k.canonical(), s);
|
assert_eq!(k.canonical(), s);
|
||||||
|
let s = "mouse:hover(pane)";
|
||||||
|
let k = InputKey::parse(s).unwrap();
|
||||||
|
assert_eq!(k.canonical(), s);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ impl Surface {
|
|||||||
pub fn activity(&self, state: ActivityState) -> Color {
|
pub fn activity(&self, state: ActivityState) -> Color {
|
||||||
match state {
|
match state {
|
||||||
ActivityState::Active => self.active,
|
ActivityState::Active => self.active,
|
||||||
ActivityState::Idle => self.idle,
|
ActivityState::Idle | ActivityState::Starting => self.idle,
|
||||||
ActivityState::AwaitingInput => self.awaiting,
|
ActivityState::AwaitingInput => self.awaiting,
|
||||||
ActivityState::Error => self.error,
|
ActivityState::Error => self.error,
|
||||||
}
|
}
|
||||||
@@ -928,9 +928,10 @@ pub fn contrast_ratio(a: Color, b: Color) -> Option<f64> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
const STATES: [ActivityState; 4] = [
|
const STATES: [ActivityState; 5] = [
|
||||||
ActivityState::Active,
|
ActivityState::Active,
|
||||||
ActivityState::Idle,
|
ActivityState::Idle,
|
||||||
|
ActivityState::Starting,
|
||||||
ActivityState::AwaitingInput,
|
ActivityState::AwaitingInput,
|
||||||
ActivityState::Error,
|
ActivityState::Error,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -979,6 +979,7 @@ mod tests {
|
|||||||
for state in [
|
for state in [
|
||||||
ActivityState::Active,
|
ActivityState::Active,
|
||||||
ActivityState::Idle,
|
ActivityState::Idle,
|
||||||
|
ActivityState::Starting,
|
||||||
ActivityState::AwaitingInput,
|
ActivityState::AwaitingInput,
|
||||||
ActivityState::Error,
|
ActivityState::Error,
|
||||||
] {
|
] {
|
||||||
@@ -1009,6 +1010,7 @@ mod tests {
|
|||||||
for state in [
|
for state in [
|
||||||
ActivityState::Active,
|
ActivityState::Active,
|
||||||
ActivityState::Idle,
|
ActivityState::Idle,
|
||||||
|
ActivityState::Starting,
|
||||||
ActivityState::AwaitingInput,
|
ActivityState::AwaitingInput,
|
||||||
ActivityState::Error,
|
ActivityState::Error,
|
||||||
] {
|
] {
|
||||||
|
|||||||
@@ -308,11 +308,12 @@ fn fallback_after_removal(
|
|||||||
|
|
||||||
fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for row in &state.rows {
|
for (index, row) in state.rows.iter().enumerate() {
|
||||||
if row.kind == TranscriptRowKind::Plan {
|
if row.kind == TranscriptRowKind::Plan {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let row_width = width.saturating_sub(1).max(1);
|
let row_width = width.saturating_sub(1).max(1);
|
||||||
|
let start = out.len();
|
||||||
match row.kind {
|
match row.kind {
|
||||||
TranscriptRowKind::User => push_wrapped(
|
TranscriptRowKind::User => push_wrapped(
|
||||||
&mut out,
|
&mut out,
|
||||||
@@ -356,10 +357,53 @@ fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
|||||||
),
|
),
|
||||||
TranscriptRowKind::Plan => {}
|
TranscriptRowKind::Plan => {}
|
||||||
}
|
}
|
||||||
|
if out.len() > start
|
||||||
|
&& state.rows[index + 1..]
|
||||||
|
.iter()
|
||||||
|
.any(|next| next.kind != TranscriptRowKind::Plan)
|
||||||
|
{
|
||||||
|
// A blank surface line is the vertical padding that keeps adjacent
|
||||||
|
// transcript blocks readable at a glance.
|
||||||
|
out.push(VisualLine {
|
||||||
|
text: String::new(),
|
||||||
|
row_id: None,
|
||||||
|
rail: false,
|
||||||
|
tone: Tone::Normal,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Text supplied to the terminal clipboard when a transcript block is
|
||||||
|
/// clicked. It preserves the full tool detail rather than its compact preview.
|
||||||
|
pub fn copy_text(row: &TranscriptRow) -> String {
|
||||||
|
match row.kind {
|
||||||
|
TranscriptRowKind::User
|
||||||
|
| TranscriptRowKind::AgentText
|
||||||
|
| TranscriptRowKind::AgentThought => content_text(row),
|
||||||
|
TranscriptRowKind::ToolCall | TranscriptRowKind::ToolDiff => {
|
||||||
|
let (name, argument) = tool_title(row);
|
||||||
|
let mut lines = vec![if argument.is_empty() {
|
||||||
|
name
|
||||||
|
} else {
|
||||||
|
format!("{name} {argument}")
|
||||||
|
}];
|
||||||
|
lines.extend(tool_content_lines(row, None));
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
TranscriptRowKind::Approval => row
|
||||||
|
.data
|
||||||
|
.get("toolCall")
|
||||||
|
.and_then(|tool| tool.get("title"))
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.unwrap_or("permission requested")
|
||||||
|
.into(),
|
||||||
|
TranscriptRowKind::Notice => "[notice]".into(),
|
||||||
|
TranscriptRowKind::Plan => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn push_wrapped(
|
fn push_wrapped(
|
||||||
out: &mut Vec<VisualLine>,
|
out: &mut Vec<VisualLine>,
|
||||||
row: &TranscriptRow,
|
row: &TranscriptRow,
|
||||||
@@ -1000,6 +1044,39 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transcript_blocks_have_a_padding_line_between_them() {
|
||||||
|
let mut state = StructuredState::default();
|
||||||
|
state.rows = vec![
|
||||||
|
row(
|
||||||
|
"one",
|
||||||
|
TranscriptRowKind::User,
|
||||||
|
json!({"content":{"type":"text","text":"first"}}),
|
||||||
|
),
|
||||||
|
row(
|
||||||
|
"two",
|
||||||
|
TranscriptRowKind::AgentText,
|
||||||
|
json!({"content":{"type":"text","text":"second"}}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let lines = build_lines(&state, 80);
|
||||||
|
assert_eq!(lines.len(), 3);
|
||||||
|
assert_eq!(lines[1].text, "");
|
||||||
|
assert!(lines[1].row_id.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copying_a_tool_keeps_its_full_detail() {
|
||||||
|
let tool = row(
|
||||||
|
"t",
|
||||||
|
TranscriptRowKind::ToolCall,
|
||||||
|
json!({"title":"Bash(cargo test)","content":[{"type":"content","content":{"type":"text","text":"all green"}}]}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(copy_text(&tool), "Bash cargo test\nall green");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_is_compact_until_selected_row_expands() {
|
fn tool_is_compact_until_selected_row_expands() {
|
||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
@@ -1420,7 +1497,7 @@ mod tests {
|
|||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
state.follow_selection = false;
|
state.follow_selection = false;
|
||||||
numbered_rows(&mut state, 20);
|
numbered_rows(&mut state, 20);
|
||||||
state.scroll = 10;
|
state.scroll = 20;
|
||||||
|
|
||||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
assert!(before[0].contains("line 10"), "{before:?}");
|
assert!(before[0].contains("line 10"), "{before:?}");
|
||||||
@@ -1439,7 +1516,7 @@ mod tests {
|
|||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
state.follow_selection = false;
|
state.follow_selection = false;
|
||||||
numbered_rows(&mut state, 20);
|
numbered_rows(&mut state, 20);
|
||||||
state.scroll = 10;
|
state.scroll = 20;
|
||||||
|
|
||||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
assert!(before[0].contains("line 10"), "{before:?}");
|
assert!(before[0].contains("line 10"), "{before:?}");
|
||||||
@@ -1459,7 +1536,7 @@ mod tests {
|
|||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
state.follow_selection = false;
|
state.follow_selection = false;
|
||||||
numbered_rows(&mut state, 20);
|
numbered_rows(&mut state, 20);
|
||||||
state.scroll = 10;
|
state.scroll = 20;
|
||||||
|
|
||||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
assert!(before[0].contains("line 10"), "{before:?}");
|
assert!(before[0].contains("line 10"), "{before:?}");
|
||||||
@@ -1478,7 +1555,7 @@ mod tests {
|
|||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
state.follow_selection = false;
|
state.follow_selection = false;
|
||||||
numbered_rows(&mut state, 20);
|
numbered_rows(&mut state, 20);
|
||||||
state.scroll = 10;
|
state.scroll = 20;
|
||||||
|
|
||||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
assert!(before[0].contains("line 10"), "{before:?}");
|
assert!(before[0].contains("line 10"), "{before:?}");
|
||||||
@@ -1497,7 +1574,7 @@ mod tests {
|
|||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
state.follow_selection = false;
|
state.follow_selection = false;
|
||||||
numbered_rows(&mut state, 20);
|
numbered_rows(&mut state, 20);
|
||||||
state.scroll = 10;
|
state.scroll = 20;
|
||||||
|
|
||||||
render_transcript_lines(&pane, &mut state, 30, 5);
|
render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
// Simulate a `TranscriptDelta::Patch` on a collapsed tool row above the
|
// Simulate a `TranscriptDelta::Patch` on a collapsed tool row above the
|
||||||
|
|||||||
@@ -199,6 +199,18 @@ impl StructuredState {
|
|||||||
self.follow_selection = true;
|
self.follow_selection = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Select the transcript block under a rendered transcript line. Pointer
|
||||||
|
/// input uses the same row identity map as scroll reconciliation, so a
|
||||||
|
/// wrapped block behaves as one target.
|
||||||
|
pub fn select_rendered_line(&mut self, line: usize) -> bool {
|
||||||
|
let Some(id) = self.line_rows.get(line).and_then(Clone::clone) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.selected = Some(id);
|
||||||
|
self.follow_selection = false;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
pub fn toggle_selected(&mut self) {
|
pub fn toggle_selected(&mut self) {
|
||||||
let Some(id) = self.selected.clone() else {
|
let Some(id) = self.selected.clone() else {
|
||||||
return;
|
return;
|
||||||
@@ -657,6 +669,25 @@ mod tests {
|
|||||||
assert_eq!(state.composer.value(), "hi");
|
assert_eq!(state.composer.value(), "hi");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selecting_a_rendered_line_selects_its_whole_block() {
|
||||||
|
let mut state = StructuredState {
|
||||||
|
line_rows: vec![
|
||||||
|
Some("one".into()),
|
||||||
|
Some("one".into()),
|
||||||
|
None,
|
||||||
|
Some("two".into()),
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(state.select_rendered_line(1));
|
||||||
|
assert_eq!(state.selected.as_deref(), Some("one"));
|
||||||
|
assert!(!state.follow_selection);
|
||||||
|
assert!(!state.select_rendered_line(2));
|
||||||
|
assert_eq!(state.selected.as_deref(), Some("one"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn authoritative_idle_snapshot_reconciles_a_wholly_missed_turn() {
|
fn authoritative_idle_snapshot_reconciles_a_wholly_missed_turn() {
|
||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ The state word is never spelled out — the colour carries it.
|
|||||||
| ---------------- | --------- | ---------------- |
|
| ---------------- | --------- | ---------------- |
|
||||||
| `Active` | `--live` | `active` |
|
| `Active` | `--live` | `active` |
|
||||||
| `Idle` | `--muted` | `idle` |
|
| `Idle` | `--muted` | `idle` |
|
||||||
|
| `Starting` | `--muted` | `idle` |
|
||||||
| `AwaitingInput` | `--warn` | `awaiting` |
|
| `AwaitingInput` | `--warn` | `awaiting` |
|
||||||
| `Error` | `--danger`| `error` |
|
| `Error` | `--danger`| `error` |
|
||||||
|
|
||||||
|
|||||||
+52
-5
@@ -35,6 +35,22 @@ session's worktree holds unsaved work — applies to a structured session
|
|||||||
exactly as it does to a terminal one; only what gets terminated differs (an
|
exactly as it does to a terminal one; only what gets terminated differs (an
|
||||||
ACP handle instead of a tmux pane).
|
ACP handle instead of a tmux pane).
|
||||||
|
|
||||||
|
## Choosing the view
|
||||||
|
|
||||||
|
`view` is fixed at spawn and never changes. Every client picks it the same
|
||||||
|
way, from the resolved command alone
|
||||||
|
(`maestro_protocol::display::supports_structured_view`): a command that
|
||||||
|
resolves to Claude Code or Codex spawns **Structured** by default, anything
|
||||||
|
else spawns **Terminal**. The operator can override the default — the web and
|
||||||
|
TUI spawn forms show the checkbox ticked and let it be unticked, and
|
||||||
|
`maestro spawn` takes `--terminal` (or `--structured`, which sends the request
|
||||||
|
even for a command the CLI does not recognise and lets the daemon refuse it).
|
||||||
|
Once the operator works the toggle, a later command change never re-ticks it
|
||||||
|
under them; it can still be forced off when the new command has no adapter.
|
||||||
|
|
||||||
|
The daemon is the last word: a `Structured` request for a command with no
|
||||||
|
adapter is refused there, whichever client sent it.
|
||||||
|
|
||||||
## The adapter
|
## The adapter
|
||||||
|
|
||||||
The resolved structured command selects a provider adapter. Its binary is
|
The resolved structured command selects a provider adapter. Its binary is
|
||||||
@@ -174,9 +190,16 @@ choices, not daemon ones.
|
|||||||
from the transcript; the TUI may show the current plan in its status line,
|
from the transcript; the TUI may show the current plan in its status line,
|
||||||
while the web client deliberately has no stats or plan rail.
|
while the web client deliberately has no stats or plan rail.
|
||||||
- The web client groups two or more tool rows after the same `User` row into
|
- The web client groups two or more tool rows after the same `User` row into
|
||||||
one collapsed turn activity block. Opening it reveals independently
|
one collapsed turn activity block. `AgentText` and `AgentThought` rows that
|
||||||
expandable tool calls, including any raw command, input, output, and diff
|
appear *between* tool rows in the same turn are absorbed into the group
|
||||||
content the adapter supplied.
|
(intermediate narration); the last `AgentText` with no subsequent tool stays
|
||||||
|
outside as the prominent final answer. While a turn is still active
|
||||||
|
(responding or thinking), the most recent intermediate `AgentText` or
|
||||||
|
`AgentThought` in the group is kept outside as visible narration — replaced
|
||||||
|
by the next text row when one arrives, and absorbed into the group when the
|
||||||
|
turn ends. The header still reads "N tool calls" (counting only tool rows).
|
||||||
|
Opening the group reveals tools and intermediate text in chronological
|
||||||
|
order, with the text rendered in a subdued style.
|
||||||
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
||||||
copy their complete source text when clicked or keyboard-activated.
|
copy their complete source text when clicked or keyboard-activated.
|
||||||
- The web client consumes `available_commands_update` notices as the live
|
- The web client consumes `available_commands_update` notices as the live
|
||||||
@@ -302,6 +325,26 @@ pending decision, entirely on the daemon side:
|
|||||||
|
|
||||||
## Prompt and cancel: bounded liveness
|
## Prompt and cancel: bounded liveness
|
||||||
|
|
||||||
|
The web composer accepts up to ten files through its attachment picker, file
|
||||||
|
drop, or clipboard. Pasted text of at least 4,000 characters or 40 lines becomes
|
||||||
|
a UTF-8 `.txt` attachment. Shorter pastes stay in the composer. Attachments can
|
||||||
|
be removed before sending; failed sends retain the draft and attachments.
|
||||||
|
Pastes also stay inline when all ten attachment slots are occupied or the text
|
||||||
|
exceeds the 20 MiB file limit.
|
||||||
|
|
||||||
|
On send, `POST /api/sessions/:id/structured/attachments?name=<filename>` uploads
|
||||||
|
each file as raw bytes (20 MiB maximum) and returns its absolute `path`.
|
||||||
|
Only live structured sessions accept uploads. Filenames are limited to 180
|
||||||
|
bytes and cannot contain directory separators or control characters. Files get unique names and private
|
||||||
|
file permissions in `<id>.attachments` beside the session sidecar. They persist
|
||||||
|
across daemon restarts; removing a composer chip does not delete uploaded data.
|
||||||
|
There is currently no automatic attachment cleanup.
|
||||||
|
|
||||||
|
The prompt includes each filename and host path so the adapter can read it
|
||||||
|
through its own file tools, including images when the provider supports them.
|
||||||
|
These references are persisted in the ordinary user transcript row. Attachment
|
||||||
|
bytes are not embedded in ACP messages or transcript history.
|
||||||
|
|
||||||
`POST /api/sessions/:id/structured/prompt` and `.../cancel` are serialized
|
`POST /api/sessions/:id/structured/prompt` and `.../cancel` are serialized
|
||||||
against each other through one per-session `operation` lock
|
against each other through one per-session `operation` lock
|
||||||
(`AcpEventStream::operation`), because ACP permission callbacks carry no
|
(`AcpEventStream::operation`), because ACP permission callbacks carry no
|
||||||
@@ -310,11 +353,15 @@ thing a callback can be attributed to, so at most one turn may ever be
|
|||||||
in flight, and a cancelled turn must fully settle before the next prompt is
|
in flight, and a cancelled turn must fully settle before the next prompt is
|
||||||
allowed to claim the daemon's idea of "the active turn."
|
allowed to claim the daemon's idea of "the active turn."
|
||||||
|
|
||||||
- A prompt is appended as a durable `User` row before it claims the turn and
|
- Web clients may collect a visible FIFO of operator messages while a turn is
|
||||||
|
active. They submit each queued message only after the projected turn becomes
|
||||||
|
idle; the queue is client-local and unsent messages do not survive a reload.
|
||||||
|
- A submitted prompt is appended as a durable `User` row before it claims the turn and
|
||||||
dispatches to ACP, so the transcript never depends on an adapter echoing the
|
dispatches to ACP, so the transcript never depends on an adapter echoing the
|
||||||
operator's input. If that append fails, the prompt is not dispatched. While a
|
operator's input. If that append fails, the prompt is not dispatched. While a
|
||||||
turn is active, a second prompt gets `409 turn_in_flight` rather than
|
turn is active, a second prompt gets `409 turn_in_flight` rather than
|
||||||
queuing — one active turn per session, never a hidden queue of prompts.
|
queuing in the daemon — one active turn per session, never a hidden
|
||||||
|
server-side queue of prompts.
|
||||||
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
|
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
|
||||||
and ends the turn's client-visible lifecycle immediately. If the cancelled
|
and ends the turn's client-visible lifecycle immediately. If the cancelled
|
||||||
turn was still *dispatched* to ACP (waiting on its response), the daemon
|
turn was still *dispatched* to ACP (waiting on its response), the daemon
|
||||||
|
|||||||
@@ -148,7 +148,7 @@
|
|||||||
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
||||||
inherit (finalAttrs) pname version src;
|
inherit (finalAttrs) pname version src;
|
||||||
fetcherVersion = 3;
|
fetcherVersion = 3;
|
||||||
hash = "sha256-hOqiAbnjKsoQnV8lgto5HspDsLIb+48sNLkShIjCZPQ=";
|
hash = "sha256-AzF/rrhqbvl613hNsC18YSylBSVxtatg7fEiUEF1u2o=";
|
||||||
};
|
};
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
|
|||||||
+13
-3
@@ -6,7 +6,7 @@
|
|||||||
"packageManager": "pnpm@9.15.4",
|
"packageManager": "pnpm@9.15.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b tsconfig.build.json && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc -b --noEmit",
|
"typecheck": "tsc -b --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
@@ -17,12 +17,22 @@
|
|||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-webgl": "^0.19.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"marked": "^18.0.11"
|
"marked": "^18.0.11",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
|
"@testing-library/react": "^16.3.3",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.7",
|
||||||
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"happy-dom": "^20.11.6",
|
"happy-dom": "^20.11.6",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^5.4.10",
|
"vite": "^5.4.10",
|
||||||
"vitest": "^2.1.9"
|
"vitest": "^2.1.9",
|
||||||
|
"zustand": "^5.0.15"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -184,6 +184,15 @@ export async function sendStructuredPrompt(id: Uuid, prompt: string): Promise<vo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadStructuredFile(id: Uuid, file: File): Promise<{ path: string }> {
|
||||||
|
return jsonOrError(await fetch(`${routes.structuredAttachment(id)}?name=${encodeURIComponent(file.name)}`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "content-type": "application/octet-stream" },
|
||||||
|
body: file,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/** Cancel a structured session's active ACP turn (#138). */
|
/** Cancel a structured session's active ACP turn (#138). */
|
||||||
export async function cancelStructuredTurn(id: Uuid): Promise<void> {
|
export async function cancelStructuredTurn(id: Uuid): Promise<void> {
|
||||||
await jsonOrError(
|
await jsonOrError(
|
||||||
|
|||||||
+4
-3
@@ -663,8 +663,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
applyNav();
|
applyNav();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
|
// Pointer hover previews only while Ctrl is held — plain hover was too
|
||||||
// out: there is no hover there, and a tap already opens the session.
|
// disruptive when sweeping the sidebar. Keyboard walk (j/k) still previews
|
||||||
|
// unconditionally via applyNav.
|
||||||
let hovered: string | null = null;
|
let hovered: string | null = null;
|
||||||
const setHovered = (id: string | null) => {
|
const setHovered = (id: string | null) => {
|
||||||
if (hovered === id) return;
|
if (hovered === id) return;
|
||||||
@@ -672,7 +673,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
opts.onPreview?.(id);
|
opts.onPreview?.(id);
|
||||||
};
|
};
|
||||||
treeHost.addEventListener("pointerover", (e) => {
|
treeHost.addEventListener("pointerover", (e) => {
|
||||||
if (e.pointerType === "touch") return;
|
if (e.pointerType === "touch" || !e.ctrlKey) return;
|
||||||
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
|
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
|
||||||
setHovered(row?.dataset.sessionId ?? null);
|
setHovered(row?.dataset.sessionId ?? null);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { 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) => (s as { sessions: Map<string, Session> }).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,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";
|
||||||
@@ -47,6 +47,10 @@ export function structuredPrompt(id: Uuid): string {
|
|||||||
return `${session(id)}/structured/prompt`;
|
return `${session(id)}/structured/prompt`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function structuredAttachment(id: Uuid): string {
|
||||||
|
return `${session(id)}/structured/attachments`;
|
||||||
|
}
|
||||||
|
|
||||||
/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */
|
/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */
|
||||||
export function structuredCancel(id: Uuid): string {
|
export function structuredCancel(id: Uuid): string {
|
||||||
return `${session(id)}/structured/cancel`;
|
return `${session(id)}/structured/cancel`;
|
||||||
|
|||||||
@@ -182,26 +182,32 @@ describe("sidebar preview requests (docs/PREVIEW.md)", () => {
|
|||||||
expect(seen.at(-1)).toBe(null);
|
expect(seen.at(-1)).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("asks for a preview of the hovered card, and clears it on the way out", () => {
|
it("ctrl+hover previews a card, plain hover does not", () => {
|
||||||
const seen: (string | null)[] = [];
|
const seen: (string | null)[] = [];
|
||||||
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
||||||
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
||||||
|
|
||||||
|
// Plain hover — no preview.
|
||||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
|
||||||
|
// Ctrl+hover — preview fires.
|
||||||
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
|
||||||
expect(seen).toEqual([A]);
|
expect(seen).toEqual([A]);
|
||||||
// Moving within the same card is not a new preview.
|
|
||||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
// Deduplicated while still on the same card.
|
||||||
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
|
||||||
expect(seen).toHaveLength(1);
|
expect(seen).toHaveLength(1);
|
||||||
|
|
||||||
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
|
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
|
||||||
expect(seen.at(-1)).toBe(null);
|
expect(seen.at(-1)).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores touch, where a tap already opens the session", () => {
|
it("ignores touch even with ctrl", () => {
|
||||||
const seen: (string | null)[] = [];
|
const seen: (string | null)[] = [];
|
||||||
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
||||||
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
||||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch" }));
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch", ctrlKey: true }));
|
||||||
expect(seen).toEqual([]);
|
expect(seen).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const base: SpawnFormValues = {
|
|||||||
cwd: "/home/user/project",
|
cwd: "/home/user/project",
|
||||||
name: "amber-fox",
|
name: "amber-fox",
|
||||||
structuredView: false,
|
structuredView: false,
|
||||||
|
autoApprove: false,
|
||||||
worktreeEnabled: false,
|
worktreeEnabled: false,
|
||||||
worktreeBranch: "",
|
worktreeBranch: "",
|
||||||
worktreeBranchMode: "new",
|
worktreeBranchMode: "new",
|
||||||
|
|||||||
+107
-57
@@ -11,7 +11,7 @@ import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
|||||||
import { parentDir } from "./paths.js";
|
import { parentDir } from "./paths.js";
|
||||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||||
import type { ProjectGroup } from "./projectList.js";
|
import type { ProjectGroup } from "./projectList.js";
|
||||||
import { splitCommand } from "./toolIcon.js";
|
import { splitCommand, toolIconSvg, TOOL_LABELS } from "./toolIcon.js";
|
||||||
import { abbreviateCwd } from "./types.js";
|
import { abbreviateCwd } from "./types.js";
|
||||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||||
|
|
||||||
@@ -49,6 +49,7 @@ export interface SpawnFormValues {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
name: string;
|
name: string;
|
||||||
structuredView: boolean;
|
structuredView: boolean;
|
||||||
|
autoApprove: boolean;
|
||||||
worktreeEnabled: boolean;
|
worktreeEnabled: boolean;
|
||||||
worktreeBranch: string;
|
worktreeBranch: string;
|
||||||
worktreeBranchEdited: boolean;
|
worktreeBranchEdited: boolean;
|
||||||
@@ -79,6 +80,10 @@ export function buildSpawnRequest(
|
|||||||
req.view = "Structured";
|
req.view = "Structured";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (values.autoApprove) {
|
||||||
|
req.auto_approve = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (values.worktreeEnabled) {
|
if (values.worktreeEnabled) {
|
||||||
const mode = values.worktreeBranchMode ?? "new";
|
const mode = values.worktreeBranchMode ?? "new";
|
||||||
const branch = values.worktreeBranch.trim();
|
const branch = values.worktreeBranch.trim();
|
||||||
@@ -135,6 +140,12 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
const form = document.createElement("form");
|
const form = document.createElement("form");
|
||||||
form.className = "spawn-form";
|
form.className = "spawn-form";
|
||||||
form.innerHTML = `
|
form.innerHTML = `
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Harness</span>
|
||||||
|
<div class="harness-grid cmd-tiles"></div>
|
||||||
|
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
|
||||||
|
placeholder="bash -l" autocomplete="off" />
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field-label">Project</span>
|
<span class="field-label">Project</span>
|
||||||
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
||||||
@@ -146,26 +157,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="advanced">
|
||||||
<span class="field-label">Command</span>
|
|
||||||
<div class="tile-row cmd-tiles"></div>
|
|
||||||
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
|
|
||||||
placeholder="bash -l" autocomplete="off" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<span class="field-label">Name</span>
|
|
||||||
<div class="name-row">
|
|
||||||
<input name="name" type="text" autocomplete="off"
|
|
||||||
placeholder="named after your first prompt" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<fieldset class="worktree">
|
|
||||||
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
|
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
|
||||||
<span class="chevron">▾</span>
|
<span class="chevron">▾</span>
|
||||||
<span class="folder-name">Advanced options</span>
|
<span class="folder-name">Advanced</span>
|
||||||
<span class="wt-summary"></span>
|
<span class="wt-summary"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="wt-collapsible" hidden>
|
<div class="wt-collapsible" hidden>
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Session name</span>
|
||||||
|
<div class="name-row">
|
||||||
|
<input name="name" type="text" autocomplete="off"
|
||||||
|
placeholder="named after your first prompt" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label class="row-inline structured-view" hidden>
|
<label class="row-inline structured-view" hidden>
|
||||||
<input type="checkbox" name="structured_view" />
|
<input type="checkbox" name="structured_view" />
|
||||||
Structured view
|
Structured view
|
||||||
@@ -174,34 +179,36 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
<span class="field-label">Worktree</span>
|
<span class="field-label">Worktree</span>
|
||||||
<div class="picker wt-choices"></div>
|
<div class="picker wt-choices"></div>
|
||||||
</div>
|
</div>
|
||||||
<label class="row-inline">
|
<div class="adv-group">
|
||||||
<input type="checkbox" name="worktree_enabled" />
|
<label class="row-inline">
|
||||||
Create git worktree
|
<input type="checkbox" name="worktree_enabled" />
|
||||||
</label>
|
Create git worktree
|
||||||
<p class="wt-hint" hidden></p>
|
</label>
|
||||||
<div class="wt-body" hidden>
|
<p class="wt-hint" hidden></p>
|
||||||
<p class="wt-repo"></p>
|
<div class="wt-body" hidden>
|
||||||
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
<p class="wt-repo"></p>
|
||||||
<label class="row-inline">
|
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
||||||
<input type="radio" name="worktree_branch_mode" value="new" />
|
<label class="row-inline">
|
||||||
New branch
|
<input type="radio" name="worktree_branch_mode" value="new" />
|
||||||
|
New branch
|
||||||
|
</label>
|
||||||
|
<label class="row-inline">
|
||||||
|
<input type="radio" name="worktree_branch_mode" value="existing" />
|
||||||
|
Existing branch
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="wt-new-wrap">
|
||||||
|
New branch name
|
||||||
|
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
<label class="row-inline">
|
<label class="wt-exist-wrap" hidden>
|
||||||
<input type="radio" name="worktree_branch_mode" value="existing" />
|
Pick a branch
|
||||||
Existing branch
|
<select class="branch-select"></select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label class="wt-new-wrap">
|
|
||||||
New branch name
|
|
||||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
|
||||||
</label>
|
|
||||||
<label class="wt-exist-wrap" hidden>
|
|
||||||
Pick a branch
|
|
||||||
<select class="branch-select"></select>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</div>
|
||||||
<p class="form-error" role="alert" aria-live="polite"></p>
|
<p class="form-error" role="alert" aria-live="polite"></p>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">Spawn</button>
|
<button type="submit" class="btn btn-primary">Spawn</button>
|
||||||
@@ -261,6 +268,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
let ready = false;
|
let ready = false;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
const branchValue = () => (branchMode === "existing" ? branchSelect.value : branchInput.value);
|
const branchValue = () => (branchMode === "existing" ? branchSelect.value : branchInput.value);
|
||||||
|
const selectedAutoApprove = () =>
|
||||||
|
presets.find((p) => p.id === selectedPresetId)?.auto_approve ?? false;
|
||||||
const emit = () => {
|
const emit = () => {
|
||||||
if (!ready || destroyed) return;
|
if (!ready || destroyed) return;
|
||||||
opts.onChange?.({
|
opts.onChange?.({
|
||||||
@@ -268,6 +277,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
cwd,
|
cwd,
|
||||||
name: nameInput.value,
|
name: nameInput.value,
|
||||||
structuredView: structuredView.checked,
|
structuredView: structuredView.checked,
|
||||||
|
autoApprove: selectedAutoApprove(),
|
||||||
worktreeEnabled: wtEnabled.checked,
|
worktreeEnabled: wtEnabled.checked,
|
||||||
worktreeBranch: branchValue(),
|
worktreeBranch: branchValue(),
|
||||||
worktreeBranchEdited: branchEdited,
|
worktreeBranchEdited: branchEdited,
|
||||||
@@ -338,50 +348,92 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
command = preset!.command;
|
command = preset!.command;
|
||||||
}
|
}
|
||||||
updateStructuredAvailability();
|
updateStructuredAvailability();
|
||||||
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
|
||||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
|
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === id);
|
||||||
}
|
}
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-highlight the operator's already-picked tile after `renderCommandTiles`
|
/** Re-highlight the operator's already-picked card after `renderCommandTiles`
|
||||||
* rebuilds the row from scratch (new tiles carry no `is-active` class) —
|
* rebuilds the grid from scratch (new cards carry no `is-active` class) —
|
||||||
* without touching `command`, since that was already settled. */
|
* without touching `command`, since that was already settled. */
|
||||||
function reapplyActiveTile(): void {
|
function reapplyActiveTile(): void {
|
||||||
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
|
||||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId);
|
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === selectedPresetId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a preset id to a tool icon id. The preset's command is the
|
||||||
|
* source of truth — `splitCommand` extracts the tool the same way the
|
||||||
|
* sidebar does. For the "Custom…" preset (empty command) there is no
|
||||||
|
* tool to show, and a terminal prompt stands in. */
|
||||||
|
function presetToolId(p: SpawnPreset): string | null {
|
||||||
|
if (!p.command) return null;
|
||||||
|
return splitCommand(p.command).tool;
|
||||||
|
}
|
||||||
|
|
||||||
function renderCommandTiles(): void {
|
function renderCommandTiles(): void {
|
||||||
cmdTiles.replaceChildren();
|
cmdTiles.replaceChildren();
|
||||||
for (const p of presets) {
|
for (const p of presets) {
|
||||||
const tile = document.createElement("button");
|
const isCustom = p.command === "";
|
||||||
tile.type = "button";
|
const card = document.createElement("button");
|
||||||
tile.className = "tile";
|
card.type = "button";
|
||||||
tile.dataset.id = p.id;
|
card.className = isCustom ? "harness-card harness-custom" : "harness-card";
|
||||||
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
|
card.dataset.id = p.id;
|
||||||
tile.addEventListener("click", () => {
|
const toolId = presetToolId(p);
|
||||||
|
const iconHtml = isCustom ? customIcon() : toolId ? harnessIcon(toolId) : harnessIcon("shell");
|
||||||
|
const label = TOOL_LABELS[toolId ?? ""] ?? p.label;
|
||||||
|
card.innerHTML =
|
||||||
|
`<span class="harness-icon">${iconHtml}</span>` +
|
||||||
|
`<span class="harness-label">${escapeHtml(isCustom ? "Custom\u2026" : label)}</span>`;
|
||||||
|
card.addEventListener("click", () => {
|
||||||
commandTouched = true;
|
commandTouched = true;
|
||||||
selectPreset(p.id);
|
selectPreset(p.id);
|
||||||
});
|
});
|
||||||
cmdTiles.appendChild(tile);
|
cmdTiles.appendChild(card);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Render a harness card icon at card size (24x24). Re-uses the SVG bodies
|
||||||
|
* from toolIcon.ts but at a larger viewport so the marks breathe. */
|
||||||
|
function harnessIcon(toolId: string): string {
|
||||||
|
// toolIconSvg renders at 13x13 with viewBox 0 0 16 16 — we want the
|
||||||
|
// same shape at 24x24 for the card context.
|
||||||
|
const small = toolIconSvg(toolId);
|
||||||
|
if (!small) return "";
|
||||||
|
return small
|
||||||
|
.replace(/width="13"/g, 'width="24"')
|
||||||
|
.replace(/height="13"/g, 'height="24"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An ellipsis mark for the "Custom…" escape hatch. */
|
||||||
|
function customIcon(): string {
|
||||||
|
return `<svg class="tool-icon" viewBox="0 0 16 16" width="24" height="24" role="img" aria-label="Custom command"><title>Custom command</title><g fill="currentColor"><circle cx="4" cy="8" r="1.3"/><circle cx="8" cy="8" r="1.3"/><circle cx="12" cy="8" r="1.3"/></g></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Set once the operator picks a tile or types a custom command — guards
|
// Set once the operator picks a tile or types a custom command — guards
|
||||||
// `applyInitialCommand()` the same way `nameTouched` guards a late name
|
// `applyInitialCommand()` the same way `nameTouched` guards a late name
|
||||||
// batch: a late `/api/info` resolution must never overwrite a field the
|
// batch: a late `/api/info` resolution must never overwrite a field the
|
||||||
// operator has already touched (#76).
|
// operator has already touched (#76).
|
||||||
let commandTouched = false;
|
let commandTouched = false;
|
||||||
|
|
||||||
|
// Set once the operator works the checkbox themselves. Until then every
|
||||||
|
// command change re-derives the default (on for Claude Code and Codex, off
|
||||||
|
// for everything else); afterwards their choice outranks it, the same guard
|
||||||
|
// `commandTouched` gives the command field.
|
||||||
|
let structuredTouched = false;
|
||||||
|
|
||||||
function updateStructuredAvailability(): void {
|
function updateStructuredAvailability(): void {
|
||||||
const supported = supportsStructuredView(command);
|
const supported = supportsStructuredView(command);
|
||||||
structuredWrap.hidden = !supported;
|
structuredWrap.hidden = !supported;
|
||||||
if (!supported) structuredView.checked = false;
|
if (!supported) structuredView.checked = false;
|
||||||
|
else if (!structuredTouched) structuredView.checked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
structuredView.addEventListener("change", emit);
|
structuredView.addEventListener("change", () => {
|
||||||
|
structuredTouched = true;
|
||||||
|
emit();
|
||||||
|
});
|
||||||
|
|
||||||
cmdRaw.addEventListener("input", () => {
|
cmdRaw.addEventListener("input", () => {
|
||||||
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
|
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
|
||||||
@@ -628,16 +680,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
box.type = "button";
|
box.type = "button";
|
||||||
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
|
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
|
||||||
box.dataset.cwd = project.path;
|
box.dataset.cwd = project.path;
|
||||||
const trees = worktreesOf(group).length;
|
|
||||||
// The parent directory only — the last segment is the name line above it.
|
// The parent directory only — the last segment is the name line above it.
|
||||||
// Unabbreviated beyond `~`, since the stem is what tells two projects
|
// Unabbreviated beyond `~`, since the stem is what tells two projects
|
||||||
// sharing a basename apart (#95).
|
// sharing a basename apart (#95).
|
||||||
const parent = abbreviateCwd(parentDir(project.path), homeDir);
|
const parent = abbreviateCwd(parentDir(project.path), homeDir);
|
||||||
const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "";
|
|
||||||
box.innerHTML = `
|
box.innerHTML = `
|
||||||
<span class="project-name">${escapeHtml(project.name)}</span>
|
<span class="project-name">${escapeHtml(project.name)}</span>
|
||||||
<span class="project-path">${escapeHtml(parent)}</span>
|
<span class="project-path">${escapeHtml(parent)}</span>`;
|
||||||
<span class="project-meta">${escapeHtml(meta)}</span>`;
|
|
||||||
box.addEventListener("click", () => selectProject(group));
|
box.addEventListener("click", () => selectProject(group));
|
||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
@@ -917,6 +966,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
cwd,
|
cwd,
|
||||||
name: nameInput.value,
|
name: nameInput.value,
|
||||||
structuredView: structuredView.checked,
|
structuredView: structuredView.checked,
|
||||||
|
autoApprove: selectedAutoApprove(),
|
||||||
worktreeEnabled: wtEnabled.checked,
|
worktreeEnabled: wtEnabled.checked,
|
||||||
worktreeBranch: branchValue(),
|
worktreeBranch: branchValue(),
|
||||||
worktreeBranchEdited: branchEdited,
|
worktreeBranchEdited: branchEdited,
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ describe("spawnForm Enter-to-submit (#97)", () => {
|
|||||||
const form = await mount([MAESTRO]);
|
const form = await mount([MAESTRO]);
|
||||||
box(form, MAESTRO.path).click();
|
box(form, MAESTRO.path).click();
|
||||||
await drain();
|
await drain();
|
||||||
form.querySelector<HTMLButtonElement>('.cmd-tiles .tile[data-id="custom"]')!.click();
|
form.querySelector<HTMLButtonElement>('.cmd-tiles .harness-card[data-id="custom"]')!.click();
|
||||||
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
|
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||||
raw.value = "bash -l";
|
raw.value = "bash -l";
|
||||||
raw.dispatchEvent(new Event("input"));
|
raw.dispatchEvent(new Event("input"));
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
|
|||||||
await drain();
|
await drain();
|
||||||
|
|
||||||
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
|
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
|
||||||
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
|
||||||
(t) => t.dataset.id === "zsh",
|
(t) => t.dataset.id === "zsh",
|
||||||
)!;
|
)!;
|
||||||
zshTile.click();
|
zshTile.click();
|
||||||
@@ -88,7 +88,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
|
|||||||
document.body.appendChild(form);
|
document.body.appendChild(form);
|
||||||
await drain();
|
await drain();
|
||||||
|
|
||||||
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
|
||||||
(t) => t.dataset.id === "custom",
|
(t) => t.dataset.id === "custom",
|
||||||
)!;
|
)!;
|
||||||
customTile.click();
|
customTile.click();
|
||||||
@@ -116,7 +116,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
|
|||||||
|
|
||||||
// Untouched: the refined preset list is still free to settle the command.
|
// Untouched: the refined preset list is still free to settle the command.
|
||||||
expect(values?.command).toBe("claude");
|
expect(values?.command).toBe("claude");
|
||||||
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
|
||||||
(t) => t.dataset.id === "fish",
|
(t) => t.dataset.id === "fish",
|
||||||
);
|
);
|
||||||
expect(fishTile).toBeDefined();
|
expect(fishTile).toBeDefined();
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ describe("spawn form structured view", () => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is default-off and shown for resolved Claude Code and Codex commands", async () => {
|
it("is default-on and shown for resolved Claude Code and Codex commands", async () => {
|
||||||
stubApi();
|
stubApi();
|
||||||
handle = createSpawnForm({ onSuccess() {} });
|
handle = createSpawnForm({ onSuccess() {} });
|
||||||
document.body.appendChild(handle.el);
|
document.body.appendChild(handle.el);
|
||||||
@@ -57,7 +57,7 @@ describe("spawn form structured view", () => {
|
|||||||
handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
|
handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
|
||||||
expect(disclosure.hidden).toBe(false);
|
expect(disclosure.hidden).toBe(false);
|
||||||
expect(wrap.hidden).toBe(false);
|
expect(wrap.hidden).toBe(false);
|
||||||
expect(checkbox.checked).toBe(false);
|
expect(checkbox.checked).toBe(true);
|
||||||
|
|
||||||
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
|
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
|
||||||
custom.click();
|
custom.click();
|
||||||
@@ -65,13 +65,34 @@ describe("spawn form structured view", () => {
|
|||||||
command.value = "FOO=1 /usr/local/bin/codex";
|
command.value = "FOO=1 /usr/local/bin/codex";
|
||||||
command.dispatchEvent(new Event("input"));
|
command.dispatchEvent(new Event("input"));
|
||||||
expect(wrap.hidden).toBe(false);
|
expect(wrap.hidden).toBe(false);
|
||||||
|
expect(checkbox.checked).toBe(true);
|
||||||
|
|
||||||
command.value = "opencode";
|
command.value = "opencode";
|
||||||
command.dispatchEvent(new Event("input"));
|
command.dispatchEvent(new Event("input"));
|
||||||
expect(wrap.hidden).toBe(true);
|
expect(wrap.hidden).toBe(true);
|
||||||
|
expect(checkbox.checked).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears a checked value when Codex changes to an unsupported command", async () => {
|
it("keeps an unchecked box unchecked across a command change", async () => {
|
||||||
|
stubApi();
|
||||||
|
handle = createSpawnForm({ onSuccess() {} });
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
|
||||||
|
expect(checkbox.checked).toBe(true);
|
||||||
|
checkbox.click();
|
||||||
|
expect(checkbox.checked).toBe(false);
|
||||||
|
|
||||||
|
handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!.click();
|
||||||
|
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||||
|
command.value = "codex";
|
||||||
|
command.dispatchEvent(new Event("input"));
|
||||||
|
|
||||||
|
expect(checkbox.checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the checked default when Codex changes to an unsupported command", async () => {
|
||||||
stubApi();
|
stubApi();
|
||||||
handle = createSpawnForm({ onSuccess() {} });
|
handle = createSpawnForm({ onSuccess() {} });
|
||||||
document.body.appendChild(handle.el);
|
document.body.appendChild(handle.el);
|
||||||
@@ -83,7 +104,6 @@ describe("spawn form structured view", () => {
|
|||||||
command.dispatchEvent(new Event("input"));
|
command.dispatchEvent(new Event("input"));
|
||||||
|
|
||||||
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
|
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
|
||||||
checkbox.click();
|
|
||||||
expect(checkbox.checked).toBe(true);
|
expect(checkbox.checked).toBe(true);
|
||||||
|
|
||||||
command.value = "opencode";
|
command.value = "opencode";
|
||||||
@@ -93,7 +113,7 @@ describe("spawn form structured view", () => {
|
|||||||
expect(checkbox.checked).toBe(false);
|
expect(checkbox.checked).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["claude", "codex"])("emits Structured for a selected %s command", async (tool) => {
|
it.each(["claude", "codex"])("emits Structured for a %s command by default", async (tool) => {
|
||||||
const requests = stubApi();
|
const requests = stubApi();
|
||||||
handle = createSpawnForm({
|
handle = createSpawnForm({
|
||||||
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
|
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
|
||||||
@@ -109,7 +129,6 @@ describe("spawn form structured view", () => {
|
|||||||
command.dispatchEvent(new Event("input"));
|
command.dispatchEvent(new Event("input"));
|
||||||
}
|
}
|
||||||
|
|
||||||
handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!.click();
|
|
||||||
handle.el.requestSubmit();
|
handle.el.requestSubmit();
|
||||||
await drain();
|
await drain();
|
||||||
|
|
||||||
@@ -119,4 +138,21 @@ describe("spawn form structured view", () => {
|
|||||||
view: "Structured",
|
view: "Structured",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("emits no view once the operator unticks the box", async () => {
|
||||||
|
const requests = stubApi();
|
||||||
|
handle = createSpawnForm({
|
||||||
|
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
|
||||||
|
onSuccess() {},
|
||||||
|
});
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!.click();
|
||||||
|
handle.el.requestSubmit();
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(JSON.parse(String(requests[0].body)).view).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,14 +52,14 @@ describe("spawn modal", () => {
|
|||||||
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
|
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens with the project filter focused, ahead of the command tiles", async () => {
|
it("opens with the project filter focused, harness before project", async () => {
|
||||||
openSpawnModal({ onSuccess: () => {} });
|
openSpawnModal({ onSuccess: () => {} });
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
const form = modal()!.querySelector("form.spawn-form")!;
|
const form = modal()!.querySelector("form.spawn-form")!;
|
||||||
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
|
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
|
||||||
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
|
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
|
||||||
expect(fields.slice(0, 2)).toEqual(["Project", "Command"]);
|
expect(fields.slice(0, 2)).toEqual(["Harness", "Project"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("titles a seeded open as a clone", async () => {
|
it("titles a seeded open as a clone", async () => {
|
||||||
|
|||||||
@@ -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>;
|
||||||
+247
-15
@@ -6,6 +6,7 @@ vi.mock("./api.js", async () => {
|
|||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
|
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
uploadStructuredFile: vi.fn().mockResolvedValue({ path: "/uploads/pasted.txt" }),
|
||||||
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
|
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
|
||||||
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
|
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
|
||||||
setStructuredConfig: vi.fn().mockResolvedValue(undefined),
|
setStructuredConfig: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
cancelStructuredTurn,
|
cancelStructuredTurn,
|
||||||
resolveStructuredApproval,
|
resolveStructuredApproval,
|
||||||
sendStructuredPrompt,
|
sendStructuredPrompt,
|
||||||
|
uploadStructuredFile,
|
||||||
setStructuredConfig,
|
setStructuredConfig,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
|
import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
|
||||||
@@ -95,6 +97,53 @@ function mount(opts: {
|
|||||||
describe("structured connection: data flow", () => {
|
describe("structured connection: data flow", () => {
|
||||||
beforeEach(() => vi.clearAllMocks());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("converts long pasted text into a removable file and sends it without reply text", async () => {
|
||||||
|
const conn = mount({ sockets: [new FakeSocket()] });
|
||||||
|
const input = conn.el.querySelector("textarea")!;
|
||||||
|
const paste = new Event("paste", { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(paste, "clipboardData", { value: {
|
||||||
|
files: [], getData: () => "a".repeat(4000),
|
||||||
|
} });
|
||||||
|
input.dispatchEvent(paste);
|
||||||
|
expect(paste.defaultPrevented).toBe(true);
|
||||||
|
expect(input.value).toBe("");
|
||||||
|
expect(conn.el.querySelector(".structured-attachments")!.textContent).toContain("pasted-1.txt");
|
||||||
|
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
|
||||||
|
await vi.waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", expect.stringContaining("/uploads/pasted.txt")));
|
||||||
|
expect(uploadStructuredFile).toHaveBeenCalledWith("s1", expect.objectContaining({ name: "pasted-1.txt", size: 4000 }));
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves short pastes alone", () => {
|
||||||
|
const conn = mount({ sockets: [new FakeSocket()] });
|
||||||
|
const paste = new Event("paste", { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(paste, "clipboardData", { value: { files: [], getData: () => "hello" } });
|
||||||
|
conn.el.querySelector("textarea")!.dispatchEvent(paste);
|
||||||
|
expect(paste.defaultPrevented).toBe(false);
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains failed uploads for retry and lets the operator remove them", async () => {
|
||||||
|
vi.mocked(uploadStructuredFile).mockRejectedValueOnce(new Error("Upload failed"));
|
||||||
|
const conn = mount({ sockets: [new FakeSocket()] });
|
||||||
|
const input = conn.el.querySelector("textarea")!;
|
||||||
|
input.value = "Read this";
|
||||||
|
const paste = new Event("paste", { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(paste, "clipboardData", { value: {
|
||||||
|
files: [new File(["content"], "notes.txt")], getData: () => "",
|
||||||
|
} });
|
||||||
|
input.dispatchEvent(paste);
|
||||||
|
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
|
||||||
|
await vi.waitFor(() => expect(conn.el.textContent).toContain("Upload failed"));
|
||||||
|
expect(input.value).toBe("Read this");
|
||||||
|
expect(sendStructuredPrompt).not.toHaveBeenCalled();
|
||||||
|
const remove = conn.el.querySelector<HTMLButtonElement>('[aria-label="Remove notes.txt"]')!;
|
||||||
|
expect(remove).not.toBeNull();
|
||||||
|
remove.click();
|
||||||
|
expect(conn.el.querySelector<HTMLElement>(".structured-attachments")!.hidden).toBe(true);
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders every row from the initial snapshot", () => {
|
it("renders every row from the initial snapshot", () => {
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
@@ -364,6 +413,95 @@ describe("structured connection: data flow", () => {
|
|||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("absorbs intermediate agent text into tool group, keeps final text outside", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 8,
|
||||||
|
rows: [
|
||||||
|
row("user:1", "user", { content: { type: "text", text: "do it" } }),
|
||||||
|
row("agent:1", "agent_text", { content: { type: "text", text: "Let me check" } }),
|
||||||
|
toolRow("tool:1", "one"),
|
||||||
|
toolRow("tool:2", "two"),
|
||||||
|
row("agent:2", "agent_text", { content: { type: "text", text: "Found issues, fixing" } }),
|
||||||
|
toolRow("tool:3", "three"),
|
||||||
|
toolRow("tool:4", "four"),
|
||||||
|
row("agent:3", "agent_text", { content: { type: "text", text: "All done" } }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const groups = conn.el.querySelectorAll<HTMLElement>(".st-tool-group");
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
// Header counts only tool rows, not intermediate text.
|
||||||
|
expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("4 tool calls");
|
||||||
|
// Intermediate text (agent:2) is inside the group.
|
||||||
|
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
|
||||||
|
// First text (agent:1) is outside — it precedes all tools.
|
||||||
|
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).toBeNull();
|
||||||
|
// Final text (agent:3) is outside — no tool follows it.
|
||||||
|
expect(conn.el.querySelector('[data-row-id="agent:3"]')?.closest(".st-tool-group")).toBeNull();
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps last intermediate text outside group while turn is active", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
const respondingState: ReducedState = {
|
||||||
|
turn: { responding: true, thinking: false, active_tool_calls: [] },
|
||||||
|
};
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 6,
|
||||||
|
rows: [
|
||||||
|
row("user:1", "user", { content: { type: "text", text: "do it" } }),
|
||||||
|
toolRow("tool:1", "one"),
|
||||||
|
row("agent:1", "agent_text", { content: { type: "text", text: "Now fixing" } }),
|
||||||
|
toolRow("tool:2", "two"),
|
||||||
|
row("agent:2", "agent_text", { content: { type: "text", text: "Found more" } }),
|
||||||
|
toolRow("tool:3", "three"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws.emit({ type: "reduced_state", state: respondingState });
|
||||||
|
// Intermediate updates stay inside the collapsed activity disclosure,
|
||||||
|
// rather than becoming a second visible transcript alongside its summary.
|
||||||
|
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
|
||||||
|
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull();
|
||||||
|
// It remains collapsed when the turn ends.
|
||||||
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
|
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
|
||||||
|
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull();
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses trailing thought commentary with a turn's tool activity", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 5,
|
||||||
|
rows: [
|
||||||
|
row("user:1", "user", { content: { type: "text", text: "do it" } }),
|
||||||
|
toolRow("tool:1", "one"),
|
||||||
|
toolRow("tool:2", "two"),
|
||||||
|
row("thought:1", "agent_thought", { content: { type: "text", text: "Checking the next step" } }),
|
||||||
|
row("thought:2", "agent_thought", { content: { type: "text", text: "Preparing the change" } }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const id of ["thought:1", "thought:2"]) {
|
||||||
|
expect(conn.el.querySelector(`[data-row-id="${id}"]`)?.closest(".st-tool-group")).not.toBeNull();
|
||||||
|
}
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps an expanded running tool open without emitting a click when patched", () => {
|
it("keeps an expanded running tool open without emitting a click when patched", () => {
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
@@ -611,7 +749,7 @@ describe("structured connection: turn/composer gating", () => {
|
|||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables the composer while a turn is active and re-enables when idle", () => {
|
it("keeps the composer available while a turn is active", () => {
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
ws.open();
|
ws.open();
|
||||||
@@ -619,13 +757,13 @@ describe("structured connection: turn/composer gating", () => {
|
|||||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||||
const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement;
|
const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement;
|
||||||
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||||
expect(input.disabled).toBe(true);
|
expect(input.disabled).toBe(false);
|
||||||
expect(cancel.hidden).toBe(false);
|
expect(cancel.hidden).toBe(false);
|
||||||
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(true);
|
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
|
||||||
ws.emit({ type: "reduced_state", state: idleState });
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
expect(input.disabled).toBe(false);
|
expect(input.disabled).toBe(false);
|
||||||
expect(cancel.hidden).toBe(true);
|
expect(cancel.hidden).toBe(true);
|
||||||
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(false);
|
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
|
||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -688,7 +826,7 @@ describe("structured connection: turn/composer gating", () => {
|
|||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables immediately and dispatches only once before begin-turn is projected", () => {
|
it("queues a second message before begin-turn is projected", () => {
|
||||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
|
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
@@ -701,12 +839,45 @@ describe("structured connection: turn/composer gating", () => {
|
|||||||
input.value = "only once";
|
input.value = "only once";
|
||||||
|
|
||||||
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
|
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
|
||||||
|
input.value = "queued next";
|
||||||
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
|
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
expect(input.disabled).toBe(true);
|
expect(input.disabled).toBe(false);
|
||||||
expect(send.disabled).toBe(true);
|
expect(send.disabled).toBe(false);
|
||||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
||||||
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
|
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
|
||||||
|
const queued = conn.el.querySelectorAll(".st-row-queued");
|
||||||
|
expect(queued).toHaveLength(1);
|
||||||
|
expect(queued[0].textContent).toBe("queued next");
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches queued messages in order as turns become idle", async () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||||
|
const submit = (text: string) => {
|
||||||
|
input.value = text;
|
||||||
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
submit("first");
|
||||||
|
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||||
|
submit("second");
|
||||||
|
submit("third");
|
||||||
|
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(conn.el.querySelectorAll(".st-row-queued")).toHaveLength(2);
|
||||||
|
|
||||||
|
await Promise.resolve().then(() => Promise.resolve());
|
||||||
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
|
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(2, "s1", "second");
|
||||||
|
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||||
|
await Promise.resolve().then(() => Promise.resolve());
|
||||||
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
|
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(3, "s1", "third");
|
||||||
|
expect(conn.el.querySelectorAll(".st-row-queued")).toHaveLength(0);
|
||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -758,7 +929,7 @@ describe("structured connection: turn/composer gating", () => {
|
|||||||
|
|
||||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(2);
|
expect(sendStructuredPrompt).toHaveBeenCalledTimes(2);
|
||||||
expect(input.value).toBe("");
|
expect(input.value).toBe("");
|
||||||
expect(input.disabled).toBe(true);
|
expect(input.disabled).toBe(false);
|
||||||
expect(conn.el.textContent).not.toContain("stale failure");
|
expect(conn.el.textContent).not.toContain("stale failure");
|
||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
@@ -820,7 +991,7 @@ describe("structured connection: approvals", () => {
|
|||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes the buttons once the approval resolves, even with no accompanying transcript delta", () => {
|
it("hides a standalone resolved approval entirely when no tool group absorbs it", () => {
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
ws.open();
|
ws.open();
|
||||||
@@ -836,7 +1007,68 @@ describe("structured connection: approvals", () => {
|
|||||||
// Resolved on another client: state clears with no transcript delta at all.
|
// Resolved on another client: state clears with no transcript delta at all.
|
||||||
ws.emit({ type: "reduced_state", state: idleState });
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
expect(conn.el.querySelector(".st-approval-acts")).toBeNull();
|
expect(conn.el.querySelector(".st-approval-acts")).toBeNull();
|
||||||
expect(conn.el.querySelector(".st-approval-resolved")).not.toBeNull();
|
// Standalone resolved approval is hidden — no card, no compact line.
|
||||||
|
expect(conn.el.querySelector(".st-row-approval")).toBeNull();
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("absorbs a resolved approval into the tool group as a compact line", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 0,
|
||||||
|
rows: [
|
||||||
|
row("user:1", "user", { content: { text: "go" } }),
|
||||||
|
toolRow("tool:1", "first"),
|
||||||
|
row("approval:1", "approval", { toolCall: { title: "Write file.ts" }, options: allApprovalOptions }),
|
||||||
|
toolRow("tool:2", "second"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
|
// 2+ tools in the turn → tool group exists; resolved approval is inside it.
|
||||||
|
const group = conn.el.querySelector(".st-tool-group");
|
||||||
|
expect(group).not.toBeNull();
|
||||||
|
const compact = group!.querySelector(".st-approval-compact");
|
||||||
|
expect(compact).not.toBeNull();
|
||||||
|
expect(compact!.textContent).toContain("Write file.ts");
|
||||||
|
expect(compact!.textContent).toContain("approved");
|
||||||
|
// No full-card approval anywhere on the transcript.
|
||||||
|
expect(conn.el.querySelector(".st-approval")).toBeNull();
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a pending approval standalone even when a tool group exists in the same turn", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 0,
|
||||||
|
rows: [
|
||||||
|
row("user:1", "user", { content: { text: "go" } }),
|
||||||
|
toolRow("tool:1", "first"),
|
||||||
|
row("approval:1", "approval", { toolCall: { title: "Write file.ts" }, options: allApprovalOptions }),
|
||||||
|
toolRow("tool:2", "second"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws.emit({
|
||||||
|
type: "reduced_state",
|
||||||
|
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
||||||
|
});
|
||||||
|
// Pending approval stays outside the tool group as a full card.
|
||||||
|
const approvalCard = conn.el.querySelector(".st-approval");
|
||||||
|
expect(approvalCard).not.toBeNull();
|
||||||
|
expect(conn.el.querySelectorAll(".st-approval-acts button").length).toBeGreaterThan(0);
|
||||||
|
// It should not be inside the tool group.
|
||||||
|
const group = conn.el.querySelector(".st-tool-group");
|
||||||
|
expect(group).not.toBeNull();
|
||||||
|
expect(group!.querySelector(".st-approval")).toBeNull();
|
||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1077,7 +1309,7 @@ describe("structured connection: reconnection/error/loading", () => {
|
|||||||
second.open();
|
second.open();
|
||||||
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||||
second.emit({ type: "reduced_state", state: idleState });
|
second.emit({ type: "reduced_state", state: idleState });
|
||||||
expect(input.disabled).toBe(true);
|
expect(input.disabled).toBe(false);
|
||||||
|
|
||||||
second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||||
second.emit({ type: "reduced_state", state: idleState });
|
second.emit({ type: "reduced_state", state: idleState });
|
||||||
@@ -1129,12 +1361,12 @@ describe("structured connection: reconnection/error/loading", () => {
|
|||||||
second.open();
|
second.open();
|
||||||
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||||
second.emit({ type: "reduced_state", state: idleState });
|
second.emit({ type: "reduced_state", state: idleState });
|
||||||
expect(input.disabled).toBe(true);
|
expect(input.disabled).toBe(false);
|
||||||
|
|
||||||
accept();
|
accept();
|
||||||
await Promise.resolve().then(() => Promise.resolve());
|
await Promise.resolve().then(() => Promise.resolve());
|
||||||
|
|
||||||
expect(input.disabled).toBe(true);
|
expect(input.disabled).toBe(false);
|
||||||
expect(second.closed).toBe(true);
|
expect(second.closed).toBe(true);
|
||||||
|
|
||||||
third.open();
|
third.open();
|
||||||
@@ -1535,7 +1767,7 @@ describe("structured connection: pane lifecycle", () => {
|
|||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("focusTerminal focuses the composer only when it is enabled", () => {
|
it("focusTerminal keeps the composer reachable during an active turn", () => {
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
ws.open();
|
ws.open();
|
||||||
@@ -1543,7 +1775,7 @@ describe("structured connection: pane lifecycle", () => {
|
|||||||
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||||
conn.focusTerminal();
|
conn.focusTerminal();
|
||||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||||
expect(document.activeElement).not.toBe(input);
|
expect(document.activeElement).toBe(input);
|
||||||
ws.emit({ type: "reduced_state", state: idleState });
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
conn.focusTerminal();
|
conn.focusTerminal();
|
||||||
expect(document.activeElement).toBe(input);
|
expect(document.activeElement).toBe(input);
|
||||||
|
|||||||
+358
-55
@@ -10,6 +10,7 @@ import {
|
|||||||
cancelStructuredTurn,
|
cancelStructuredTurn,
|
||||||
resolveStructuredApproval,
|
resolveStructuredApproval,
|
||||||
sendStructuredPrompt,
|
sendStructuredPrompt,
|
||||||
|
uploadStructuredFile,
|
||||||
setStructuredConfig,
|
setStructuredConfig,
|
||||||
structuredWsUrl,
|
structuredWsUrl,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
@@ -84,13 +85,18 @@ export function createStructuredConnection(
|
|||||||
<div class="structured-main">
|
<div class="structured-main">
|
||||||
<div class="structured-scroll">
|
<div class="structured-scroll">
|
||||||
<div class="structured-rows"></div>
|
<div class="structured-rows"></div>
|
||||||
</div>
|
<div class="structured-activity" role="status" aria-live="polite" hidden>
|
||||||
<div class="structured-activity" role="status" aria-live="polite" hidden>
|
<span class="structured-activity-pulse" aria-hidden="true"></span>
|
||||||
<span class="structured-activity-pulse" aria-hidden="true"></span>
|
<span class="structured-activity-text">Thinking</span>
|
||||||
<span class="structured-activity-text">Thinking</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form class="structured-composer">
|
<form class="structured-composer">
|
||||||
|
<button type="button" class="btn btn-ghost btn-icon structured-attach" aria-label="Attach files" title="Attach files (up to 20 MiB each)">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="m8 12 7-7a3 3 0 0 1 4 4L9 19a5 5 0 0 1-7-7L12 2M5 14l9-9"/></svg>
|
||||||
|
</button>
|
||||||
|
<input class="structured-file-input" type="file" multiple hidden>
|
||||||
<div class="structured-input-wrap">
|
<div class="structured-input-wrap">
|
||||||
|
<div class="structured-attachments" aria-label="Attachments" aria-live="polite" hidden></div>
|
||||||
<div id="structured-command-menu-${sessionId}" class="structured-command-menu"
|
<div id="structured-command-menu-${sessionId}" class="structured-command-menu"
|
||||||
role="listbox" hidden></div>
|
role="listbox" hidden></div>
|
||||||
<textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…"
|
<textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…"
|
||||||
@@ -107,7 +113,10 @@ export function createStructuredConnection(
|
|||||||
<button type="submit" class="btn btn-primary structured-send"><span>Send</span>
|
<button type="submit" class="btn btn-primary structured-send"><span>Send</span>
|
||||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7 5 5 5-5 5M12 10H3"/></svg>
|
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7 5 5 5-5 5M12 10H3"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn structured-cancel" hidden>Cancel turn</button>
|
<button type="button" class="btn btn-icon structured-cancel" hidden aria-label="Cancel turn" title="Cancel turn">
|
||||||
|
<svg class="structured-cancel-spinner" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="50 14" stroke-linecap="round"/></svg>
|
||||||
|
<svg class="structured-cancel-stop" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,7 +136,82 @@ export function createStructuredConnection(
|
|||||||
const commandMenu = el.querySelector(".structured-command-menu") as HTMLDivElement;
|
const commandMenu = el.querySelector(".structured-command-menu") as HTMLDivElement;
|
||||||
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
|
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||||
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
|
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
|
||||||
|
const sendLabel = sendBtn.querySelector("span") as HTMLSpanElement;
|
||||||
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
|
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
|
||||||
|
const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement;
|
||||||
|
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
|
||||||
|
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
|
||||||
|
type Attachment = { file: File; path?: string };
|
||||||
|
type QueuedPrompt = { text: string; attachments: Attachment[] };
|
||||||
|
let attachments: Attachment[] = [];
|
||||||
|
let uploading = false;
|
||||||
|
let pasteCount = 0;
|
||||||
|
|
||||||
|
function renderAttachments(): void {
|
||||||
|
attachmentEl.replaceChildren();
|
||||||
|
attachmentEl.hidden = attachments.length === 0;
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
const chip = document.createElement("span");
|
||||||
|
chip.className = "structured-attachment";
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.textContent = attachment.file.name;
|
||||||
|
label.title = `${attachment.file.name} (${attachment.file.size.toLocaleString()} bytes)`;
|
||||||
|
const remove = document.createElement("button");
|
||||||
|
remove.type = "button";
|
||||||
|
remove.textContent = "×";
|
||||||
|
remove.setAttribute("aria-label", `Remove ${attachment.file.name}`);
|
||||||
|
remove.disabled = uploading;
|
||||||
|
remove.addEventListener("click", () => {
|
||||||
|
attachments = attachments.filter((item) => item !== attachment);
|
||||||
|
renderAttachments();
|
||||||
|
});
|
||||||
|
chip.append(label, remove);
|
||||||
|
attachmentEl.append(chip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFiles(files: File[]): void {
|
||||||
|
if (uploading || disposed) return;
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.size > 20 * 1024 * 1024) {
|
||||||
|
showError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
|
||||||
|
} else if (attachments.length >= 10) {
|
||||||
|
showError("Attach up to 10 files per message.");
|
||||||
|
} else {
|
||||||
|
attachments.push({ file });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderAttachments();
|
||||||
|
}
|
||||||
|
attachBtn.addEventListener("click", () => fileInput.click());
|
||||||
|
fileInput.addEventListener("change", () => {
|
||||||
|
addFiles(Array.from(fileInput.files ?? []));
|
||||||
|
fileInput.value = "";
|
||||||
|
});
|
||||||
|
composerForm.addEventListener("dragover", (event) => {
|
||||||
|
if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
|
||||||
|
});
|
||||||
|
composerForm.addEventListener("drop", (event) => {
|
||||||
|
if (!event.dataTransfer?.files.length) return;
|
||||||
|
event.preventDefault();
|
||||||
|
addFiles(Array.from(event.dataTransfer.files));
|
||||||
|
});
|
||||||
|
input.addEventListener("paste", (event) => {
|
||||||
|
const clipboard = event.clipboardData;
|
||||||
|
if (!clipboard || uploading) return;
|
||||||
|
if (clipboard.files.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
addFiles(Array.from(clipboard.files));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = clipboard.getData("text/plain");
|
||||||
|
if (text.length < 4000 && text.split("\n").length < 40) return;
|
||||||
|
if (attachments.length >= 10 || new Blob([text]).size > 20 * 1024 * 1024) return;
|
||||||
|
event.preventDefault();
|
||||||
|
addFiles([new File([text], `pasted-${++pasteCount}.txt`, { type: "text/plain" })]);
|
||||||
|
input.setRangeText("", input.selectionStart, input.selectionEnd, "end");
|
||||||
|
autoResize();
|
||||||
|
});
|
||||||
|
|
||||||
disconnectBtn.addEventListener("click", () => opts.onRequestClose?.(sessionId));
|
disconnectBtn.addEventListener("click", () => opts.onRequestClose?.(sessionId));
|
||||||
// Mobile-only (CSS hides it on desktop, where the sidebar is always in
|
// Mobile-only (CSS hides it on desktop, where the sidebar is always in
|
||||||
@@ -165,6 +249,10 @@ export function createStructuredConnection(
|
|||||||
let disposed = false;
|
let disposed = false;
|
||||||
let optimisticTurnStartedAt: number | null = null;
|
let optimisticTurnStartedAt: number | null = null;
|
||||||
let activityTimer: number | null = null;
|
let activityTimer: number | null = null;
|
||||||
|
const promptQueue: QueuedPrompt[] = [];
|
||||||
|
let promptRequestPending = false;
|
||||||
|
/** Server path → object URL for uploaded images, so user bubbles can show previews. */
|
||||||
|
const imageObjectUrls = new Map<string, string>();
|
||||||
|
|
||||||
// Within 64px, wheel and touch imprecision should still count as following.
|
// Within 64px, wheel and touch imprecision should still count as following.
|
||||||
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
|
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
|
||||||
@@ -295,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 {
|
function buildRowElement(row: TranscriptRow): HTMLElement | null {
|
||||||
const el = buildTranscriptRow(
|
const el = buildTranscriptRow(
|
||||||
row,
|
row,
|
||||||
@@ -311,20 +402,102 @@ export function createStructuredConnection(
|
|||||||
if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") {
|
if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") {
|
||||||
expandedRowIds.delete(row.id);
|
expandedRowIds.delete(row.id);
|
||||||
}
|
}
|
||||||
|
// Inject image previews into user message bubbles and strip the path text.
|
||||||
|
if (row.kind === "user" && imageObjectUrls.size > 0) {
|
||||||
|
const data = row.data as { content?: { type: string; text?: string } } | undefined;
|
||||||
|
const text = data?.content?.type === "text" ? data.content.text ?? "" : "";
|
||||||
|
const body = el.querySelector(".st-body");
|
||||||
|
if (body && text) {
|
||||||
|
const images: string[] = [];
|
||||||
|
attachedFileRe.lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = attachedFileRe.exec(text)) !== null) {
|
||||||
|
const serverPath = JSON.parse(`"${match[1]}"`);
|
||||||
|
const objectUrl = imageObjectUrls.get(serverPath);
|
||||||
|
if (objectUrl) images.push(objectUrl);
|
||||||
|
}
|
||||||
|
if (images.length > 0) {
|
||||||
|
// Strip attachment reference lines from the displayed text.
|
||||||
|
const userText = body.querySelector(".st-user-text");
|
||||||
|
if (userText) {
|
||||||
|
const cleaned = text.replace(/\n\nAttached file "[^"]*": "[^"]*"/g, "").trim();
|
||||||
|
userText.textContent = cleaned;
|
||||||
|
if (!cleaned) userText.remove();
|
||||||
|
}
|
||||||
|
for (const src of images) {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.className = "st-user-image";
|
||||||
|
img.src = src;
|
||||||
|
img.alt = "Uploaded image";
|
||||||
|
img.loading = "lazy";
|
||||||
|
body.appendChild(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolGroupsByTurn(): Map<string, TranscriptRow[]> {
|
/** Group tool rows (and intermediate agent text / resolved approvals between
|
||||||
const groups = new Map<string, TranscriptRow[]>();
|
* them) by turn. An agent_text/agent_thought row is "intermediate" when a
|
||||||
|
* tool row follows it in the same turn — it gets absorbed into the group so
|
||||||
|
* the collapsed block preserves chronological order. Resolved approval rows
|
||||||
|
* (not the currently pending one) are absorbed the same way. The *last*
|
||||||
|
* agent text with no subsequent tool stays standalone (the prominent final
|
||||||
|
* answer). Returns groups keyed by `turn:<userId>`, each with `toolCount`
|
||||||
|
* (for the header label) and `rows` (tools + intermediate text/approvals,
|
||||||
|
* in order). */
|
||||||
|
function activityGroupsByTurn(): Map<string, { toolCount: number; rows: TranscriptRow[] }> {
|
||||||
|
// First: count tools per turn to know which turns qualify for grouping.
|
||||||
|
const toolCounts = new Map<string, number>();
|
||||||
let turnId = "before-first-message";
|
let turnId = "before-first-message";
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.kind === "user") turnId = row.id;
|
if (row.kind === "user") turnId = row.id;
|
||||||
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
|
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
|
||||||
const id = `turn:${turnId}`;
|
const gid = `turn:${turnId}`;
|
||||||
const group = groups.get(id) ?? [];
|
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
|
||||||
group.push(row);
|
|
||||||
groups.set(id, group);
|
|
||||||
}
|
}
|
||||||
|
// Second: build groups with interleaved text for turns with 2+ tools.
|
||||||
|
const groups = new Map<string, { toolCount: number; rows: TranscriptRow[] }>();
|
||||||
|
turnId = "before-first-message";
|
||||||
|
// Pending rows that might be intermediate (not yet confirmed by a
|
||||||
|
// following tool). If no tool follows, they stay standalone.
|
||||||
|
let pending: TranscriptRow[] = [];
|
||||||
|
let currentGid = `turn:${turnId}`;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.kind === "user") {
|
||||||
|
turnId = row.id;
|
||||||
|
currentGid = `turn:${turnId}`;
|
||||||
|
pending = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const count = toolCounts.get(currentGid) ?? 0;
|
||||||
|
if (count < 2) continue;
|
||||||
|
const isIntermediateText = row.kind === "agent_text" || row.kind === "agent_thought";
|
||||||
|
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalRowId;
|
||||||
|
if (isIntermediateText || isResolvedApproval) {
|
||||||
|
pending.push(row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (row.kind === "tool_call" || row.kind === "tool_diff") {
|
||||||
|
const group = groups.get(currentGid) ?? { toolCount: count, rows: [] };
|
||||||
|
// Flush any pending rows — a tool follows, so they're intermediate.
|
||||||
|
group.rows.push(...pending);
|
||||||
|
pending = [];
|
||||||
|
group.rows.push(row);
|
||||||
|
groups.set(currentGid, group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A final agent text is the answer, so it stays outside. Thought rows are
|
||||||
|
// working commentary, though: when a turn already has a disclosure they
|
||||||
|
// belong with its activity even if the next tool call has not arrived yet.
|
||||||
|
// Leaving that tail loose made a busy agent look like it was publishing a
|
||||||
|
// long italic answer above a collapsed "N tool calls" summary.
|
||||||
|
const lastGroup = groups.get(currentGid);
|
||||||
|
if (lastGroup && pending.length > 0 && pending.every((row) => row.kind === "agent_thought")) {
|
||||||
|
lastGroup.rows.push(...pending);
|
||||||
|
}
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,11 +507,12 @@ export function createStructuredConnection(
|
|||||||
const focusedGroupId = active?.closest<HTMLElement>("[data-tool-group-id]")?.dataset.toolGroupId;
|
const focusedGroupId = active?.closest<HTMLElement>("[data-tool-group-id]")?.dataset.toolGroupId;
|
||||||
const restoreToolFocus = active?.classList.contains("st-tool-head") ?? false;
|
const restoreToolFocus = active?.classList.contains("st-tool-head") ?? false;
|
||||||
const restoreGroupFocus = active?.classList.contains("st-tool-group-head") ?? false;
|
const restoreGroupFocus = active?.classList.contains("st-tool-group-head") ?? false;
|
||||||
const groups = toolGroupsByTurn();
|
const groups = activityGroupsByTurn();
|
||||||
const groupedRowIds = new Set<string>();
|
const groupedRowIds = new Set<string>();
|
||||||
const liveGroupIds = new Set([...groups].filter(([, tools]) => tools.length > 1).map(([id]) => id));
|
const liveGroupIds = new Set([...groups.keys()]);
|
||||||
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
|
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
|
||||||
|
|
||||||
|
const prevGroupEls = new Map(groupEls);
|
||||||
rowEls.clear();
|
rowEls.clear();
|
||||||
groupEls.clear();
|
groupEls.clear();
|
||||||
const children: HTMLElement[] = [];
|
const children: HTMLElement[] = [];
|
||||||
@@ -346,34 +520,54 @@ export function createStructuredConnection(
|
|||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.kind === "user") turnId = row.id;
|
if (row.kind === "user") turnId = row.id;
|
||||||
const groupId = `turn:${turnId}`;
|
const groupId = `turn:${turnId}`;
|
||||||
const groupRows = groups.get(groupId) ?? [];
|
const group = groups.get(groupId);
|
||||||
if ((row.kind === "tool_call" || row.kind === "tool_diff") && groupRows.length > 1) {
|
if (group && groupedRowIds.has(row.id)) continue;
|
||||||
if (groupedRowIds.has(row.id)) continue;
|
if (group && (row.kind === "tool_call" || row.kind === "tool_diff") && !groupedRowIds.has(row.id)) {
|
||||||
const toolEls = groupRows.flatMap((toolRow) => {
|
// First ungrouped tool in this turn — render the whole activity group.
|
||||||
groupedRowIds.add(toolRow.id);
|
const memberEls = group.rows.flatMap((memberRow) => {
|
||||||
const toolEl = buildRowElement(toolRow);
|
groupedRowIds.add(memberRow.id);
|
||||||
if (!toolEl) return [];
|
const memberEl = buildRowElement(memberRow);
|
||||||
rowEls.set(toolRow.id, toolEl);
|
if (!memberEl) return [];
|
||||||
return [toolEl];
|
rowEls.set(memberRow.id, memberEl);
|
||||||
|
return [memberEl];
|
||||||
});
|
});
|
||||||
const groupEl = buildToolGroup(
|
|
||||||
groupId,
|
// Reuse existing group element when possible — avoids tearing down
|
||||||
groupRows,
|
// and recreating the wrapper, which restarts CSS transitions (visible
|
||||||
toolEls,
|
// flash) and destroys keyboard/focus state.
|
||||||
expandedGroupIds.has(groupId),
|
const existing = prevGroupEls.get(groupId);
|
||||||
(expanded) => {
|
if (existing) {
|
||||||
if (expanded) expandedGroupIds.add(groupId);
|
const titleEl = existing.querySelector<HTMLElement>(".st-tool-group-title");
|
||||||
else expandedGroupIds.delete(groupId);
|
if (titleEl) titleEl.textContent = `${group.toolCount} tool calls`;
|
||||||
settleDisclosureLayout();
|
const inner = existing.querySelector<HTMLElement>(".st-disclosure-inner");
|
||||||
},
|
if (inner) inner.replaceChildren(...memberEls);
|
||||||
);
|
groupEls.set(groupId, existing);
|
||||||
if (groupRows.some((toolRow) => enteringRowIds.has(toolRow.id))) {
|
children.push(existing);
|
||||||
groupEl.classList.add("st-row-enter");
|
} 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Resolved approvals not absorbed into a tool group are hidden — the
|
||||||
|
// tool row itself already identifies the action, so a standalone
|
||||||
|
// "answered" card adds nothing. Pending approvals always render.
|
||||||
|
if (row.kind === "approval" && row.id !== pendingApprovalRowId) continue;
|
||||||
const rowEl = buildRowElement(row);
|
const rowEl = buildRowElement(row);
|
||||||
if (!rowEl) continue;
|
if (!rowEl) continue;
|
||||||
rowEls.set(row.id, rowEl);
|
rowEls.set(row.id, rowEl);
|
||||||
@@ -425,6 +619,24 @@ export function createStructuredConnection(
|
|||||||
rows.push(delta.row);
|
rows.push(delta.row);
|
||||||
} else {
|
} else {
|
||||||
rows[i] = delta.row;
|
rows[i] = delta.row;
|
||||||
|
// Surgical DOM swap: replace just this row's element instead of
|
||||||
|
// tearing down and rebuilding the entire transcript, which causes
|
||||||
|
// visible flashing and breaks text selection.
|
||||||
|
const oldEl = rowEls.get(delta.row.id);
|
||||||
|
if (oldEl) {
|
||||||
|
const active = document.activeElement as HTMLElement | null;
|
||||||
|
const hadToolFocus = active?.classList.contains("st-tool-head") && oldEl.contains(active);
|
||||||
|
const newEl = buildRowElement(delta.row);
|
||||||
|
if (newEl) {
|
||||||
|
oldEl.replaceWith(newEl);
|
||||||
|
rowEls.set(delta.row.id, newEl);
|
||||||
|
if (hadToolFocus) {
|
||||||
|
newEl.querySelector<HTMLButtonElement>(".st-tool-head")?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syncAvailableCommands();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const i = rowIndex.get(delta.id);
|
const i = rowIndex.get(delta.id);
|
||||||
@@ -469,12 +681,14 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let configDirty = false;
|
||||||
function renderConfig(): void {
|
function renderConfig(): void {
|
||||||
const options = sessionConfigOptions();
|
const options = sessionConfigOptions();
|
||||||
configEl.hidden = options.length === 0;
|
configEl.hidden = options.length === 0;
|
||||||
if (options.length === 0) {
|
if (options.length === 0) {
|
||||||
configMenu.hidden = true;
|
configMenu.hidden = true;
|
||||||
configTrigger.setAttribute("aria-expanded", "false");
|
configTrigger.setAttribute("aria-expanded", "false");
|
||||||
|
configDirty = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ordered = [...options].sort((a, b) => {
|
const ordered = [...options].sort((a, b) => {
|
||||||
@@ -488,6 +702,15 @@ export function createStructuredConnection(
|
|||||||
configTrigger.title = summary;
|
configTrigger.title = summary;
|
||||||
configTrigger.setAttribute("aria-label", `Session settings. ${summary.replaceAll("\n", ". ")}`);
|
configTrigger.setAttribute("aria-label", `Session settings. ${summary.replaceAll("\n", ". ")}`);
|
||||||
configTrigger.disabled = configBusy;
|
configTrigger.disabled = configBusy;
|
||||||
|
|
||||||
|
// When the menu is open and the user is interacting (not mid-change),
|
||||||
|
// skip the DOM rebuild so open <select> dropdowns aren't destroyed.
|
||||||
|
// Mark dirty so the next open rebuilds with fresh data.
|
||||||
|
if (!configMenu.hidden && !configBusy) {
|
||||||
|
configDirty = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
configDirty = false;
|
||||||
configMenu.replaceChildren();
|
configMenu.replaceChildren();
|
||||||
for (const option of ordered) {
|
for (const option of ordered) {
|
||||||
const row = document.createElement("label");
|
const row = document.createElement("label");
|
||||||
@@ -526,14 +749,33 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderQueuedPrompts(): void {
|
||||||
|
for (const old of rowsEl.querySelectorAll(".st-row-queued")) old.remove();
|
||||||
|
for (const queued of promptQueue) {
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "st-row st-row-queued";
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "st-body";
|
||||||
|
const line = document.createElement("div");
|
||||||
|
line.className = "st-user-text";
|
||||||
|
line.textContent = queued.text;
|
||||||
|
body.appendChild(line);
|
||||||
|
wrap.appendChild(body);
|
||||||
|
rowsEl.appendChild(wrap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderComposerState(): void {
|
function renderComposerState(): void {
|
||||||
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
|
||||||
const projectedActive = state.turn.responding || state.turn.thinking;
|
const projectedActive = state.turn.responding || state.turn.thinking;
|
||||||
input.disabled = active;
|
input.disabled = false;
|
||||||
sendBtn.disabled = active;
|
attachBtn.disabled = uploading;
|
||||||
sendBtn.hidden = projectedActive;
|
fileInput.disabled = uploading;
|
||||||
|
renderAttachments();
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
sendBtn.hidden = false;
|
||||||
|
sendLabel.textContent = "Send";
|
||||||
cancelBtn.hidden = !projectedActive;
|
cancelBtn.hidden = !projectedActive;
|
||||||
if (active) closeCommandMenu();
|
renderQueuedPrompts();
|
||||||
renderActivity();
|
renderActivity();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,6 +787,7 @@ export function createStructuredConnection(
|
|||||||
|
|
||||||
function renderActivity(): void {
|
function renderActivity(): void {
|
||||||
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
||||||
|
const wasHidden = activityEl.hidden;
|
||||||
activityEl.hidden = !active;
|
activityEl.hidden = !active;
|
||||||
if (!active) {
|
if (!active) {
|
||||||
optimisticTurnStartedAt = null;
|
optimisticTurnStartedAt = null;
|
||||||
@@ -567,6 +810,11 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
activityText.textContent = parts.join(" · ");
|
activityText.textContent = parts.join(" · ");
|
||||||
if (activityTimer === null) activityTimer = window.setInterval(renderActivity, 1_000);
|
if (activityTimer === null) activityTimer = window.setInterval(renderActivity, 1_000);
|
||||||
|
// Activity lives inside the scroll area; nudge scroll when it first appears.
|
||||||
|
if (wasHidden && followingBottom) {
|
||||||
|
beginTranscriptLayout(true);
|
||||||
|
finishTranscriptLayout();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function reconcileAwaitingBegin(): void {
|
function reconcileAwaitingBegin(): void {
|
||||||
@@ -582,19 +830,22 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyState(next: ReducedState, connectionGeneration: number): void {
|
function applyState(next: ReducedState, connectionGeneration: number): void {
|
||||||
|
const wasTurnActive = state.turn.responding || state.turn.thinking;
|
||||||
state = next;
|
state = next;
|
||||||
stateSocketGeneration = connectionGeneration;
|
stateSocketGeneration = connectionGeneration;
|
||||||
reconcileAwaitingBegin();
|
reconcileAwaitingBegin();
|
||||||
|
const isTurnActive = state.turn.responding || state.turn.thinking;
|
||||||
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
|
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
|
||||||
if (newPendingId !== pendingApprovalRowId) {
|
const needsRebuild = newPendingId !== pendingApprovalRowId || wasTurnActive !== isTurnActive;
|
||||||
|
if (needsRebuild) {
|
||||||
updateTranscriptLayout(() => {
|
updateTranscriptLayout(() => {
|
||||||
const prev = pendingApprovalRowId;
|
|
||||||
pendingApprovalRowId = newPendingId;
|
pendingApprovalRowId = newPendingId;
|
||||||
if (prev !== null || newPendingId !== null) rebuildTranscript();
|
rebuildTranscript();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
renderConfig();
|
renderConfig();
|
||||||
renderComposerState();
|
renderComposerState();
|
||||||
|
dispatchQueuedPrompt();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeConfigMenu(): void {
|
function closeConfigMenu(): void {
|
||||||
@@ -604,6 +855,7 @@ export function createStructuredConnection(
|
|||||||
|
|
||||||
configTrigger.addEventListener("click", () => {
|
configTrigger.addEventListener("click", () => {
|
||||||
const opening = configMenu.hidden;
|
const opening = configMenu.hidden;
|
||||||
|
if (opening && configDirty) renderConfig();
|
||||||
configMenu.hidden = !opening;
|
configMenu.hidden = !opening;
|
||||||
configTrigger.setAttribute("aria-expanded", String(opening));
|
configTrigger.setAttribute("aria-expanded", String(opening));
|
||||||
});
|
});
|
||||||
@@ -794,35 +1046,58 @@ export function createStructuredConnection(
|
|||||||
if (!commandMenu.contains(document.activeElement)) closeCommandMenu();
|
if (!commandMenu.contains(document.activeElement)) closeCommandMenu();
|
||||||
}, 0);
|
}, 0);
|
||||||
});
|
});
|
||||||
composerForm.addEventListener("submit", (e) => {
|
function dispatchPrompt(text: string, submittedAttachments: Attachment[] = []): void {
|
||||||
e.preventDefault();
|
uploading = submittedAttachments.length > 0;
|
||||||
const text = input.value;
|
|
||||||
if (!text.trim() || input.disabled) return;
|
|
||||||
const generation = ++submissionGeneration;
|
const generation = ++submissionGeneration;
|
||||||
const submissionSocketGeneration = socketGeneration;
|
const submissionSocketGeneration = socketGeneration;
|
||||||
awaitingBeginGeneration = generation;
|
awaitingBeginGeneration = generation;
|
||||||
optimisticTurnStartedAt = Date.now();
|
optimisticTurnStartedAt = Date.now();
|
||||||
acceptedSubmission = null;
|
acceptedSubmission = null;
|
||||||
|
promptRequestPending = true;
|
||||||
input.value = "";
|
input.value = "";
|
||||||
autoResize();
|
autoResize();
|
||||||
clearError();
|
clearError();
|
||||||
renderComposerState();
|
renderComposerState();
|
||||||
sendStructuredPrompt(sessionId, text).then(
|
const send = async () => {
|
||||||
|
for (const attachment of submittedAttachments) {
|
||||||
|
if (disposed) return;
|
||||||
|
if (!attachment.path) attachment.path = (await uploadStructuredFile(sessionId, attachment.file)).path;
|
||||||
|
if (attachment.path && attachment.file.type.startsWith("image/")) {
|
||||||
|
imageObjectUrls.set(attachment.path, URL.createObjectURL(attachment.file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (disposed) return;
|
||||||
|
const references = submittedAttachments.map(({ file, path }) =>
|
||||||
|
`Attached file ${JSON.stringify(file.name)}: ${JSON.stringify(path)}`);
|
||||||
|
await sendStructuredPrompt(sessionId, [text, ...references].filter(Boolean).join("\n\n"));
|
||||||
|
};
|
||||||
|
void send().then(
|
||||||
() => {
|
() => {
|
||||||
if (
|
promptRequestPending = false;
|
||||||
disposed
|
if (disposed || generation !== submissionGeneration) return;
|
||||||
|| generation !== submissionGeneration
|
uploading = false;
|
||||||
|| awaitingBeginGeneration !== generation
|
if (attachments === submittedAttachments) {
|
||||||
) return;
|
attachments = [];
|
||||||
|
renderAttachments();
|
||||||
|
}
|
||||||
|
renderComposerState();
|
||||||
|
if (awaitingBeginGeneration !== generation) return;
|
||||||
const acceptanceSocketGeneration = socketGeneration;
|
const acceptanceSocketGeneration = socketGeneration;
|
||||||
acceptedSubmission = { generation, socketGeneration: acceptanceSocketGeneration };
|
acceptedSubmission = { generation, socketGeneration: acceptanceSocketGeneration };
|
||||||
// A replacement socket may have captured idle before the POST claimed
|
// A replacement socket may have captured idle before the POST claimed
|
||||||
// the prompt. Reconnect again so its snapshot cannot predate the 204.
|
// the prompt. Reconnect again so its snapshot cannot predate the 204.
|
||||||
if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation();
|
if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation();
|
||||||
|
renderComposerState();
|
||||||
|
dispatchQueuedPrompt();
|
||||||
},
|
},
|
||||||
(e: unknown) => {
|
(e: unknown) => {
|
||||||
|
promptRequestPending = false;
|
||||||
|
uploading = false;
|
||||||
if (disposed || generation !== submissionGeneration) return;
|
if (disposed || generation !== submissionGeneration) return;
|
||||||
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
|
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
|
||||||
|
if (attachments.length === 0 && submittedAttachments.length > 0) {
|
||||||
|
attachments = submittedAttachments;
|
||||||
|
}
|
||||||
if (input.value === "") {
|
if (input.value === "") {
|
||||||
input.value = text;
|
input.value = text;
|
||||||
autoResize();
|
autoResize();
|
||||||
@@ -831,6 +1106,32 @@ export function createStructuredConnection(
|
|||||||
renderComposerState();
|
renderComposerState();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchQueuedPrompt(): void {
|
||||||
|
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
||||||
|
if (disposed || active || promptRequestPending || promptQueue.length === 0) return;
|
||||||
|
const next = promptQueue.shift()!;
|
||||||
|
dispatchPrompt(next.text, next.attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
composerForm.addEventListener("submit", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const text = input.value;
|
||||||
|
if ((!text.trim() && attachments.length === 0) || uploading) return;
|
||||||
|
const submittedAttachments = attachments;
|
||||||
|
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
||||||
|
if (active || promptQueue.length > 0) {
|
||||||
|
promptQueue.push({ text, attachments: submittedAttachments });
|
||||||
|
attachments = [];
|
||||||
|
input.value = "";
|
||||||
|
autoResize();
|
||||||
|
clearError();
|
||||||
|
renderComposerState();
|
||||||
|
dispatchQueuedPrompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatchPrompt(text, submittedAttachments);
|
||||||
});
|
});
|
||||||
cancelBtn.addEventListener("click", () => {
|
cancelBtn.addEventListener("click", () => {
|
||||||
clearError();
|
clearError();
|
||||||
@@ -1003,6 +1304,8 @@ export function createStructuredConnection(
|
|||||||
closeCurrentSocket();
|
closeCurrentSocket();
|
||||||
expandedRowIds.clear();
|
expandedRowIds.clear();
|
||||||
expandedGroupIds.clear();
|
expandedGroupIds.clear();
|
||||||
|
for (const url of imageObjectUrls.values()) URL.revokeObjectURL(url);
|
||||||
|
imageObjectUrls.clear();
|
||||||
scrollEl.removeEventListener("scroll", onTranscriptScroll);
|
scrollEl.removeEventListener("scroll", onTranscriptScroll);
|
||||||
scrollEl.removeEventListener("click", onTranscriptClick, true);
|
scrollEl.removeEventListener("click", onTranscriptClick, true);
|
||||||
scrollEl.removeEventListener("wheel", stopSmoothFollow);
|
scrollEl.removeEventListener("wheel", stopSmoothFollow);
|
||||||
|
|||||||
@@ -315,8 +315,8 @@ describe("buildTranscriptRow: tool diffs", () => {
|
|||||||
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||||||
const diff = el.querySelector(".st-diff") as HTMLElement;
|
const diff = el.querySelector(".st-diff") as HTMLElement;
|
||||||
expect(diff).not.toBeNull();
|
expect(diff).not.toBeNull();
|
||||||
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("c");
|
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("+ c");
|
||||||
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("b");
|
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("- b");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives the diff its own horizontally-scrolling container", () => {
|
it("gives the diff its own horizontally-scrolling container", () => {
|
||||||
@@ -368,13 +368,14 @@ describe("buildTranscriptRow: approval", () => {
|
|||||||
expect(el.querySelector(".st-approval-path")!.textContent).toBe("/etc/x");
|
expect(el.querySelector(".st-approval-path")!.textContent).toBe("/etc/x");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides the buttons and shows an answered note once resolved", () => {
|
it("renders a compact one-liner once resolved", () => {
|
||||||
const el = buildTranscriptRow(approvalRow(allThreeOptions), {
|
const el = buildTranscriptRow(approvalRow(allThreeOptions), {
|
||||||
pending: false,
|
pending: false,
|
||||||
onDecide: () => Promise.resolve(),
|
onDecide: () => Promise.resolve(),
|
||||||
});
|
});
|
||||||
expect(el.querySelector(".st-approval-acts")).toBeNull();
|
expect(el.querySelector(".st-approval-acts")).toBeNull();
|
||||||
expect(el.querySelector(".st-approval-resolved")!.textContent).toBe("answered");
|
expect(el.classList.contains("st-approval-compact")).toBe(true);
|
||||||
|
expect(el.textContent).toContain("approved");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invokes onDecide with the clicked decision and disables the buttons", () => {
|
it("invokes onDecide with the clicked decision and disables the buttons", () => {
|
||||||
|
|||||||
+50
-50
@@ -223,15 +223,16 @@ function buildDiffBlock(diff: { path: string; oldText?: string | null; newText:
|
|||||||
wrap.appendChild(label);
|
wrap.appendChild(label);
|
||||||
const scroller = el("div", "st-diff");
|
const scroller = el("div", "st-diff");
|
||||||
const pre = document.createElement("pre");
|
const pre = document.createElement("pre");
|
||||||
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");
|
const span = document.createElement("span");
|
||||||
span.className = `st-ln st-ln-${line.kind}`;
|
span.className = `st-ln st-ln-${line.kind}`;
|
||||||
span.textContent = line.text;
|
span.textContent = (prefixes[line.kind] ?? "") + line.text;
|
||||||
pre.appendChild(span);
|
pre.appendChild(span);
|
||||||
pre.appendChild(document.createTextNode("\n"));
|
|
||||||
}
|
}
|
||||||
scroller.appendChild(pre);
|
scroller.appendChild(pre);
|
||||||
makeCopyable(scroller, pre.textContent ?? "", "diff");
|
makeCopyable(scroller, lines.map((l) => (prefixes[l.kind] ?? "") + l.text).join("\n"), "diff");
|
||||||
wrap.appendChild(scroller);
|
wrap.appendChild(scroller);
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
@@ -581,7 +582,9 @@ export function buildToolGroup(
|
|||||||
children: HTMLElement[],
|
children: HTMLElement[],
|
||||||
expanded: boolean,
|
expanded: boolean,
|
||||||
onExpandedChange?: (expanded: boolean) => void,
|
onExpandedChange?: (expanded: boolean) => void,
|
||||||
|
toolCount?: number,
|
||||||
): HTMLElement {
|
): HTMLElement {
|
||||||
|
const count = toolCount ?? rows.length;
|
||||||
const wrap = el("div", "st-tool-group");
|
const wrap = el("div", "st-tool-group");
|
||||||
wrap.dataset.toolGroupId = id;
|
wrap.dataset.toolGroupId = id;
|
||||||
const head = document.createElement("button");
|
const head = document.createElement("button");
|
||||||
@@ -593,15 +596,8 @@ export function buildToolGroup(
|
|||||||
glyph.textContent = "›";
|
glyph.textContent = "›";
|
||||||
glyph.setAttribute("aria-hidden", "true");
|
glyph.setAttribute("aria-hidden", "true");
|
||||||
const title = el("span", "st-tool-group-title");
|
const title = el("span", "st-tool-group-title");
|
||||||
title.textContent = `${rows.length} tool calls`;
|
title.textContent = `${count} tool calls`;
|
||||||
const names = el("span", "st-tool-group-names");
|
head.append(glyph, title);
|
||||||
names.textContent = rows
|
|
||||||
.map((row) => {
|
|
||||||
const tool = (row.data ?? {}) as AcpToolCallLike;
|
|
||||||
return splitToolTitle(tool.title, tool.name, tool.kind).label;
|
|
||||||
})
|
|
||||||
.join(" · ");
|
|
||||||
head.append(glyph, title, names);
|
|
||||||
|
|
||||||
const body = el("div", "st-tool-group-body st-disclosure");
|
const body = el("div", "st-tool-group-body st-disclosure");
|
||||||
const bodyClip = el("div", "st-disclosure-clip");
|
const bodyClip = el("div", "st-disclosure-clip");
|
||||||
@@ -709,6 +705,16 @@ function offeredDecisions(options: AcpPermissionOption[]): StructuredApprovalDec
|
|||||||
function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLElement {
|
function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLElement {
|
||||||
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
|
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
|
||||||
const toolCall = request.toolCall ?? {};
|
const toolCall = request.toolCall ?? {};
|
||||||
|
|
||||||
|
// Resolved approvals render as a compact one-liner inside a tool group
|
||||||
|
// (or are hidden entirely when standalone — see rebuildTranscript).
|
||||||
|
if (!approval.pending) {
|
||||||
|
const wrap = el("div", "st-row st-row-approval st-approval-compact");
|
||||||
|
const label = toolCall.title ?? toolKindLabel(toolCall.kind);
|
||||||
|
wrap.textContent = `${label} — approved`;
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
const wrap = el("div", "st-row st-row-approval");
|
const wrap = el("div", "st-row st-row-approval");
|
||||||
const card = el("div", "st-approval");
|
const card = el("div", "st-approval");
|
||||||
wrap.appendChild(card);
|
wrap.appendChild(card);
|
||||||
@@ -728,45 +734,39 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
|
|||||||
card.appendChild(pathEl);
|
card.appendChild(pathEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (approval.pending) {
|
const acts = el("div", "st-approval-acts");
|
||||||
const acts = el("div", "st-approval-acts");
|
const mk = (label: string, decision: StructuredApprovalDecision, cls: string) => {
|
||||||
const mk = (label: string, decision: StructuredApprovalDecision, cls: string) => {
|
const btn = document.createElement("button");
|
||||||
const btn = document.createElement("button");
|
btn.type = "button";
|
||||||
btn.type = "button";
|
btn.className = cls ? `act ${cls}` : "act";
|
||||||
btn.className = cls ? `act ${cls}` : "act";
|
btn.textContent = label;
|
||||||
btn.textContent = label;
|
btn.addEventListener("click", () => {
|
||||||
btn.addEventListener("click", () => {
|
const buttons = [...acts.querySelectorAll<HTMLButtonElement>("button")];
|
||||||
const buttons = [...acts.querySelectorAll<HTMLButtonElement>("button")];
|
buttons.forEach((b) => (b.disabled = true));
|
||||||
buttons.forEach((b) => (b.disabled = true));
|
approval.onDecide(decision).catch(() => {
|
||||||
approval.onDecide(decision).catch(() => {
|
// Rejects only when this approval is still pending (see
|
||||||
// Rejects only when this approval is still pending (see
|
// ApprovalRowState) — restore the controls so the operator can
|
||||||
// ApprovalRowState) — restore the controls so the operator can
|
// retry; the error itself is surfaced by the connection.
|
||||||
// retry; the error itself is surfaced by the connection.
|
buttons.forEach((b) => (b.disabled = false));
|
||||||
buttons.forEach((b) => (b.disabled = false));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
return btn;
|
});
|
||||||
};
|
return btn;
|
||||||
const offered = offeredDecisions(request.options ?? []);
|
};
|
||||||
const specs: [StructuredApprovalDecision, string, string][] = [
|
const offered = offeredDecisions(request.options ?? []);
|
||||||
["allow_once", "Allow once", "primary"],
|
const specs: [StructuredApprovalDecision, string, string][] = [
|
||||||
["allow_for_session", "Allow for session", ""],
|
["allow_once", "Allow once", "primary"],
|
||||||
["deny", "Deny", "deny"],
|
["allow_for_session", "Allow for session", ""],
|
||||||
];
|
["deny", "Deny", "deny"],
|
||||||
for (const [decision, label, cls] of specs) {
|
];
|
||||||
if (offered.includes(decision)) acts.appendChild(mk(label, decision, cls));
|
for (const [decision, label, cls] of specs) {
|
||||||
}
|
if (offered.includes(decision)) acts.appendChild(mk(label, decision, cls));
|
||||||
if (acts.children.length > 0) {
|
}
|
||||||
card.appendChild(acts);
|
if (acts.children.length > 0) {
|
||||||
} else {
|
card.appendChild(acts);
|
||||||
const none = el("div", "st-approval-resolved");
|
|
||||||
none.textContent = "no decision offered";
|
|
||||||
card.appendChild(none);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const resolved = el("div", "st-approval-resolved");
|
const none = el("div", "st-approval-resolved");
|
||||||
resolved.textContent = "answered";
|
none.textContent = "no decision offered";
|
||||||
card.appendChild(resolved);
|
card.appendChild(none);
|
||||||
}
|
}
|
||||||
|
|
||||||
return wrap;
|
return wrap;
|
||||||
|
|||||||
+242
-99
@@ -2290,6 +2290,9 @@ input.leaf-rename:focus {
|
|||||||
.structured-main {
|
.structured-main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
max-width: 820px;
|
||||||
|
margin-inline: auto;
|
||||||
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@@ -2315,9 +2318,7 @@ input.leaf-rename:focus {
|
|||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
padding: var(--sp-1) var(--sp-3);
|
padding: var(--sp-1) var(--sp-3);
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
background: var(--surface-2);
|
|
||||||
font-size: var(--fs-11);
|
font-size: var(--fs-11);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
@@ -2374,11 +2375,36 @@ input.leaf-rename:focus {
|
|||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Queued messages: optimistic user bubbles, not yet sent. */
|
||||||
|
.st-row-queued {
|
||||||
|
justify-content: flex-end;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-row-queued .st-body {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
max-width: min(86%, 72ch);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
background: var(--surface-3);
|
||||||
|
opacity: 0.45;
|
||||||
|
filter: blur(0.5px);
|
||||||
|
}
|
||||||
|
|
||||||
.st-user-text {
|
.st-user-text {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.st-user-image {
|
||||||
|
display: block;
|
||||||
|
max-width: min(100%, 480px);
|
||||||
|
max-height: 360px;
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
border-radius: var(--r-1);
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
/* agent prose: no card, no background — plain text is the point */
|
/* agent prose: no card, no background — plain text is the point */
|
||||||
.st-row-agent .st-body p {
|
.st-row-agent .st-body p {
|
||||||
margin: 0 0 var(--sp-2);
|
margin: 0 0 var(--sp-2);
|
||||||
@@ -2481,7 +2507,8 @@ input.leaf-rename:focus {
|
|||||||
|
|
||||||
.st-row-thought {
|
.st-row-thought {
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
font-style: italic;
|
font-size: var(--fs-12);
|
||||||
|
line-height: 1.4;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
@@ -2828,12 +2855,12 @@ input.leaf-rename:focus {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--r-2);
|
border-radius: var(--r-2);
|
||||||
background: color-mix(in oklab, var(--surface-2) 55%, transparent);
|
background: color-mix(in oklab, var(--surface-2) 55%, transparent);
|
||||||
padding: var(--sp-2) var(--sp-3);
|
padding: var(--sp-1) var(--sp-2);
|
||||||
transition: background var(--dur-fast) var(--ease-out);
|
transition: background var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.st-tool-group:hover {
|
.st-tool-group:hover {
|
||||||
background: var(--surface-2);
|
background: color-mix(in oklab, var(--surface-2) 70%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.st-tool-group-head {
|
.st-tool-group-head {
|
||||||
@@ -2870,15 +2897,6 @@ input.leaf-rename:focus {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.st-tool-group-names {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--muted);
|
|
||||||
text-align: left;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.st-tool-group-body {
|
.st-tool-group-body {
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
@@ -2886,11 +2904,42 @@ input.leaf-rename:focus {
|
|||||||
.st-tool-group-body .st-disclosure-inner {
|
.st-tool-group-body .st-disclosure-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding-top: var(--sp-2);
|
padding-top: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.st-tool-group-body .st-disclosure-inner > .st-row-tool {
|
.st-tool-group-body .st-disclosure-inner > .st-row-tool {
|
||||||
padding: var(--sp-1) 0;
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* inside a group, tool cards lose their border and background —
|
||||||
|
the group container already provides the visual boundary. */
|
||||||
|
.st-tool-group-body .st-tool {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-tool-group-body .st-tool-head {
|
||||||
|
padding: var(--sp-1) var(--sp-2);
|
||||||
|
border-radius: var(--r-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* intermediate agent text inside a tool group — subdued so the final
|
||||||
|
answer outside the group stays the prominent one. */
|
||||||
|
.st-tool-group-body .st-row-agent {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
padding: var(--sp-1) var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-tool-group-body .st-row-agent .st-body > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-tool-group-body .st-row-agent .st-body > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-tool-group-body .st-row-thought {
|
||||||
|
padding: var(--sp-1) var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.st-tool-group-body[hidden] {
|
.st-tool-group-body[hidden] {
|
||||||
@@ -2942,6 +2991,7 @@ input.leaf-rename:focus {
|
|||||||
display: block;
|
display: block;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
padding: 0 var(--sp-3);
|
padding: 0 var(--sp-3);
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.st-ln-add {
|
.st-ln-add {
|
||||||
@@ -3015,6 +3065,16 @@ input.leaf-rename:focus {
|
|||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* compact resolved approval inside a tool group — same muted treatment
|
||||||
|
as intermediate agent text so the audit trail is preserved without
|
||||||
|
drawing attention to itself. */
|
||||||
|
.st-approval-compact {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
padding: var(--sp-1) var(--sp-2);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.act {
|
.act {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -3062,31 +3122,26 @@ input.leaf-rename:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* The composer is a sibling of the scrolling transcript, not part of it: it
|
/* The composer is a sibling of the scrolling transcript, not part of it: it
|
||||||
stays reachable at the pane edge without changing transcript scroll math. */
|
stays reachable at the pane edge without changing transcript scroll math.
|
||||||
|
It is a flush band at the foot of the pane rather than a floating card: its
|
||||||
|
own `--surface` fill is what separates it from the transcript, over a
|
||||||
|
hairline that doubles as the focus indicator. Inner metrics line the draft
|
||||||
|
up with the transcript's own 12px gutter. */
|
||||||
.structured-composer {
|
.structured-composer {
|
||||||
|
position: relative;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
margin: var(--sp-2) var(--sp-3) max(var(--sp-2), env(safe-area-inset-bottom));
|
padding: var(--sp-1) var(--sp-1) max(var(--sp-1), env(safe-area-inset-bottom));
|
||||||
padding: var(--sp-1);
|
border-top: 1px solid var(--border);
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--r-3);
|
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: var(--sp-1);
|
gap: var(--sp-1);
|
||||||
box-shadow:
|
transition: border-color var(--dur-fast) var(--ease-out);
|
||||||
inset 0 1px 0 color-mix(in oklab, var(--ink) 6%, transparent),
|
|
||||||
var(--shadow-1);
|
|
||||||
transition:
|
|
||||||
border-color var(--dur-fast) var(--ease-out),
|
|
||||||
box-shadow var(--dur-fast) var(--ease-out);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.structured-composer:focus-within {
|
.structured-composer:focus-within {
|
||||||
border-color: var(--accent);
|
border-top-color: var(--border);
|
||||||
box-shadow:
|
|
||||||
inset 0 1px 0 color-mix(in oklab, var(--ink) 8%, transparent),
|
|
||||||
0 0 0 3px var(--accent-tint),
|
|
||||||
var(--shadow-1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.structured-input-wrap {
|
.structured-input-wrap {
|
||||||
@@ -3095,6 +3150,45 @@ input.leaf-rename:focus {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.structured-attachments {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
padding: var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-attachments[hidden] { display: none; }
|
||||||
|
|
||||||
|
.structured-attachment {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
padding-left: var(--sp-2);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-1);
|
||||||
|
font-size: var(--fs-11);
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-attachment > span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-attachment button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: var(--tap);
|
||||||
|
min-height: var(--tap);
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-attachment button:hover { color: var(--ink); }
|
||||||
|
.structured-attachment button:disabled { opacity: 0.5; }
|
||||||
|
|
||||||
.structured-input {
|
.structured-input {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -3112,6 +3206,10 @@ input.leaf-rename:focus {
|
|||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.structured-input:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.structured-composer .structured-send,
|
.structured-composer .structured-send,
|
||||||
.structured-composer .structured-cancel {
|
.structured-composer .structured-cancel {
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -3159,7 +3257,9 @@ input.leaf-rename:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.structured-composer .structured-cancel {
|
.structured-composer .structured-cancel {
|
||||||
padding: 0 var(--sp-3);
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
padding: 0;
|
||||||
border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
|
border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
@@ -3168,18 +3268,47 @@ input.leaf-rename:focus {
|
|||||||
background: var(--danger-tint);
|
background: var(--danger-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.structured-cancel-stop {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-cancel-spinner {
|
||||||
|
position: absolute;
|
||||||
|
inset: 4px;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
animation: cancel-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cancel-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
|
/* One row cannot hold a draft plus three controls at phone width — the
|
||||||
|
draft gets a line of its own and the controls drop below it. */
|
||||||
.structured-composer {
|
.structured-composer {
|
||||||
margin: var(--sp-1) 0 0;
|
flex-wrap: wrap;
|
||||||
padding-bottom: calc(var(--sp-1) + env(safe-area-inset-bottom, 0px));
|
justify-content: flex-end;
|
||||||
border-right: 0;
|
padding-left: var(--sp-2);
|
||||||
border-bottom: 0;
|
padding-right: var(--sp-2);
|
||||||
border-left: 0;
|
}
|
||||||
border-radius: var(--r-2) var(--r-2) 0 0;
|
|
||||||
|
/* `order` keeps the draft on the first line: without it the attach button,
|
||||||
|
which precedes it in the DOM, wraps onto a line of its own. */
|
||||||
|
.structured-input-wrap {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-composer .structured-config {
|
||||||
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.structured-config-menu {
|
.structured-config-menu {
|
||||||
right: calc(-1 * (var(--control-h) + var(--sp-2)));
|
left: 0;
|
||||||
|
right: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3393,14 +3522,9 @@ input.leaf-rename:focus {
|
|||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rides the bottom of the scroll box: reachable at any scroll position, and
|
|
||||||
`margin-top: auto` keeps it at the screen's foot when the form is short. */
|
|
||||||
.spawn-modal .form-actions {
|
.spawn-modal .form-actions {
|
||||||
position: sticky;
|
|
||||||
bottom: 0;
|
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
padding: var(--sp-2) 0 calc(var(--sp-2) + env(safe-area-inset-bottom, 0px));
|
padding-bottom: calc(var(--sp-2) + env(safe-area-inset-bottom, 0px));
|
||||||
background: var(--surface);
|
|
||||||
box-shadow: 0 -8px 12px -8px oklch(0 0 0 / 0.5);
|
box-shadow: 0 -8px 12px -8px oklch(0 0 0 / 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3482,20 +3606,16 @@ input.leaf-rename:focus {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.worktree {
|
.advanced {
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--r-2);
|
|
||||||
padding: 0 var(--sp-3);
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: color-mix(in oklab, var(--surface-2) 50%, transparent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.worktree .wt-toggle {
|
.advanced .wt-toggle {
|
||||||
margin: 0 calc(var(--sp-3) * -1);
|
border-top: 1px solid var(--border);
|
||||||
padding-left: var(--sp-3);
|
border-radius: 0;
|
||||||
width: calc(100% + var(--sp-3) * 2);
|
padding-top: var(--sp-2);
|
||||||
|
padding-bottom: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wt-summary {
|
.wt-summary {
|
||||||
@@ -3507,13 +3627,27 @@ input.leaf-rename:focus {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--sp-3);
|
gap: var(--sp-3);
|
||||||
padding-bottom: var(--sp-3);
|
padding: var(--sp-2) 0 var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wt-collapsible[hidden] {
|
.wt-collapsible[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Worktree sub-group: left accent border when expanded, visually nested. */
|
||||||
|
.adv-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding-left: var(--sp-3);
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
margin-top: var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adv-group:has(input[name="worktree_enabled"]:checked) {
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.form-error {
|
.form-error {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -3540,61 +3674,78 @@ input.leaf-rename:focus {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* One-click tiles: host row + command row. */
|
/* Harness cards: the first decision in the spawn form — which tool runs. */
|
||||||
.tile-row {
|
.harness-grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile {
|
.harness-card {
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 96px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
align-items: center;
|
||||||
align-items: flex-start;
|
gap: var(--sp-2);
|
||||||
padding: var(--sp-2) var(--sp-3);
|
padding: var(--sp-3) var(--sp-2);
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--r-2);
|
border-radius: var(--r-2);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: center;
|
||||||
transition:
|
transition:
|
||||||
border-color var(--dur-fast) var(--ease-out),
|
border-color var(--dur-fast) var(--ease-out),
|
||||||
background var(--dur-fast) var(--ease-out);
|
background var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile:hover {
|
.harness-card:hover {
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
|
background: var(--surface-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile.is-active {
|
.harness-card.is-active {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--accent-tint);
|
background: var(--accent-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-label {
|
.harness-icon {
|
||||||
font-size: var(--fs-14);
|
display: flex;
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-sub {
|
|
||||||
font-size: var(--fs-12);
|
|
||||||
color: var(--muted);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-more {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
min-width: 0;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.harness-card.is-active .harness-icon {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.harness-icon .tool-icon {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harness-label {
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The "Custom…" card is an escape hatch, not a peer tool — dashed border
|
||||||
|
and subdued color separate it from the named harnesses. */
|
||||||
|
.harness-custom {
|
||||||
|
border-style: dashed;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.harness-custom .harness-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
|
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
|
||||||
picked project's worktree choices. Stale beyond 7 days dims but stays
|
picked project's worktree choices. Stale beyond 7 days dims but stays
|
||||||
selectable. */
|
selectable. */
|
||||||
@@ -3621,7 +3772,7 @@ input.leaf-rename:focus {
|
|||||||
|
|
||||||
.picker.projects {
|
.picker.projects {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
grid-template-columns: repeat(2, 1fr);
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
@@ -3677,19 +3828,6 @@ input.leaf-rename:focus {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-meta {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--fs-12);
|
|
||||||
color: var(--faint);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-meta:empty {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-other {
|
.project-other {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
@@ -3831,6 +3969,11 @@ input.leaf-rename:focus {
|
|||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: var(--sp-2);
|
margin-top: var(--sp-2);
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
padding: var(--sp-2) 0;
|
||||||
|
background: var(--surface);
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reroll {
|
.reroll {
|
||||||
|
|||||||
+3
-1
@@ -11,7 +11,7 @@ export type SessionStatus = "Running" | "Dead";
|
|||||||
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
|
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
|
||||||
* Absent on payloads from daemons that predate the field. `Error` is only set
|
* Absent on payloads from daemons that predate the field. `Error` is only set
|
||||||
* when an agent reports failure (#105); the daemon never infers it. */
|
* when an agent reports failure (#105); the daemon never infers it. */
|
||||||
export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error";
|
export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error" | "Starting";
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: Uuid;
|
id: Uuid;
|
||||||
@@ -91,6 +91,7 @@ export interface SpawnPreset {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
command: string;
|
command: string;
|
||||||
|
auto_approve?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `GET /api/info` — the daemon's identity plus its spawn presets (#26).
|
/** `GET /api/info` — the daemon's identity plus its spawn presets (#26).
|
||||||
@@ -277,6 +278,7 @@ export interface CreateSessionRequest {
|
|||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
worktree?: WorktreeSpec;
|
worktree?: WorktreeSpec;
|
||||||
view?: SessionView;
|
view?: SessionView;
|
||||||
|
auto_approve?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CatalogEvent =
|
export type CatalogEvent =
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
// The nix derivation's src is `./web`, so the shared fixtures/ directory at
|
||||||
|
// the repo root does not exist there and `*.test.ts` cannot resolve its
|
||||||
|
// imports. Tests are not part of the shipped bundle; `pnpm typecheck` still
|
||||||
|
// checks them from a full checkout.
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { defineConfig, loadEnv } from "vite";
|
import { defineConfig, loadEnv } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
// Where the dev proxy forwards `/api/*` (and the events WS) to. Default to a
|
// Where the dev proxy forwards `/api/*` (and the events WS) to. Default to a
|
||||||
// local daemon on 127.0.0.1:7070. Override with:
|
// local daemon on 127.0.0.1:7070. Override with:
|
||||||
@@ -18,6 +20,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
const target = resolveProxyTarget(env.MAESTRO_DAEMON_URL);
|
const target = resolveProxyTarget(env.MAESTRO_DAEMON_URL);
|
||||||
const secure = target.startsWith("https://");
|
const secure = target.startsWith("https://");
|
||||||
return {
|
return {
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
build: {
|
build: {
|
||||||
target: "es2022",
|
target: "es2022",
|
||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
|
|||||||
Reference in New Issue
Block a user