Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85feb433ae | ||
|
|
a11933b7bf | ||
|
|
fa40eb7378 | ||
|
|
b680266990 | ||
|
|
ded1f050b2 | ||
|
|
a58ebfed93 | ||
|
|
31b429a840 | ||
|
|
12bd68a2af | ||
|
|
f765d415d6 | ||
|
|
d3e3010245 | ||
|
|
7a0dbbf7f2 | ||
|
|
84fa4a3076 | ||
|
|
eafeac5c05 | ||
|
|
f96f94db8f | ||
|
|
efb54750ed | ||
|
|
9e59121eec | ||
|
|
4a5c3b9982 | ||
|
|
443800bd79 | ||
|
|
d55d92d58a | ||
|
|
41770a54a7 | ||
|
|
6cf0b200f8 | ||
|
|
25344abe4f | ||
|
|
b52a9085e9 | ||
|
|
8bd943a8ca | ||
|
|
b86335ddf1 | ||
|
|
c348381c5f |
@@ -19,6 +19,12 @@ use tokio_tungstenite::tungstenite::Message;
|
||||
pub enum AttachError {
|
||||
#[error("ws: {0}")]
|
||||
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
|
||||
#[error("daemon returned {status} ({code}): {message}")]
|
||||
Rejected {
|
||||
status: String,
|
||||
code: String,
|
||||
message: String,
|
||||
},
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
#[error("termios: {0}")]
|
||||
@@ -32,6 +38,21 @@ pub enum AttachError {
|
||||
}
|
||||
|
||||
impl AttachError {
|
||||
fn from_websocket(error: tokio_tungstenite::tungstenite::Error) -> Self {
|
||||
if let tokio_tungstenite::tungstenite::Error::Http(response) = &error {
|
||||
if let Some(body) = response.body().as_deref() {
|
||||
if let Ok(error_body) = serde_json::from_slice::<maestro_protocol::ApiError>(body) {
|
||||
return Self::Rejected {
|
||||
status: response.status().to_string(),
|
||||
code: error_body.error.code,
|
||||
message: error_body.error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::WebSocket(error)
|
||||
}
|
||||
|
||||
/// True when the failure was an inability to reach the WS endpoint at the
|
||||
/// transport level (connection refused), as opposed to an HTTP-level
|
||||
/// rejection from a daemon that did answer.
|
||||
@@ -111,7 +132,9 @@ fn current_winsize() -> Option<(u16, u16)> {
|
||||
/// byte that exits cleanly without killing the session.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
|
||||
let (ws, _resp) = tokio_tungstenite::connect_async(url).await?;
|
||||
let (ws, _resp) = tokio_tungstenite::connect_async(url)
|
||||
.await
|
||||
.map_err(AttachError::from_websocket)?;
|
||||
let (mut sink, mut stream) = ws.split();
|
||||
|
||||
let raw = RawTty::enable()?;
|
||||
@@ -233,4 +256,25 @@ mod tests {
|
||||
Err(AttachError::InvalidDetachKey(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_surfaces_structured_handshake_errors() {
|
||||
let body = serde_json::to_vec(&maestro_protocol::ApiError::new(
|
||||
"not_terminal",
|
||||
"session is not a terminal session",
|
||||
))
|
||||
.unwrap();
|
||||
let response = tokio_tungstenite::tungstenite::http::Response::builder()
|
||||
.status(409)
|
||||
.body(Some(body))
|
||||
.unwrap();
|
||||
|
||||
let error =
|
||||
AttachError::from_websocket(tokio_tungstenite::tungstenite::Error::Http(response));
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"daemon returned 409 Conflict (not_terminal): session is not a terminal session"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::io::Write;
|
||||
use maestro_protocol::display::abbreviate_dir;
|
||||
use maestro_protocol::{
|
||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
||||
Session, SessionStatus, WorktreeSpec,
|
||||
Session, SessionStatus, SessionView, WorktreeSpec,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -179,8 +179,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {}",
|
||||
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
||||
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {}",
|
||||
"ID", "STATUS", "VIEW", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
||||
)
|
||||
.ok();
|
||||
for s in sorted {
|
||||
@@ -190,6 +190,10 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
SessionStatus::Running => "running",
|
||||
SessionStatus::Dead => "dead",
|
||||
};
|
||||
let view = match s.view {
|
||||
SessionView::Terminal => "terminal",
|
||||
SessionView::Structured => "structured",
|
||||
};
|
||||
let activity = match s.activity {
|
||||
ActivityState::Active => "active",
|
||||
ActivityState::Idle => "idle",
|
||||
@@ -207,8 +211,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
let blocked = blocked_label(s);
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}",
|
||||
s.id, status, activity, dir, command
|
||||
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {blocked}",
|
||||
s.id, status, view, activity, dir, command
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
@@ -230,7 +234,7 @@ mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use maestro_protocol::{ActivityState, Session, SessionStatus};
|
||||
use maestro_protocol::{ActivityState, Session, SessionStatus, SessionView};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::DaemonInfo;
|
||||
@@ -286,6 +290,22 @@ mod tests {
|
||||
assert!(s.contains("idle"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_exposes_terminal_and_structured_views() {
|
||||
let terminal = session("/tmp", "bash", 1);
|
||||
let mut structured = session("/tmp", "claude", 2);
|
||||
structured.view = SessionView::Structured;
|
||||
|
||||
let mut buf = Vec::new();
|
||||
super::print_session_table(&mut buf, &[terminal, structured], &info(""));
|
||||
let out = String::from_utf8(buf).unwrap();
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
let header = lines.iter().position(|line| line.contains("VIEW")).unwrap();
|
||||
|
||||
assert!(lines[header + 1].contains("terminal"), "{out}");
|
||||
assert!(lines[header + 2].contains("structured"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_cwd_overrides_spawn_dir_in_table() {
|
||||
let mut s = session("/home/naps/maestro", "bash", 1);
|
||||
|
||||
@@ -26,7 +26,7 @@ use maestro_protocol::{
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::session::{QueueAcpPrompt, SessionManager};
|
||||
use crate::session::{QueueAcpPrompt, SessionError, SessionManager};
|
||||
use crate::sidecar::EventCursor;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -179,6 +179,7 @@ fn session_error_status(code: &str) -> StatusCode {
|
||||
// The session outlived its pane: nothing to type into, and nothing
|
||||
// the daemon can do about it.
|
||||
"not_running" => StatusCode::CONFLICT,
|
||||
"not_terminal" => StatusCode::CONFLICT,
|
||||
"shutting_down" => StatusCode::SERVICE_UNAVAILABLE,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
}
|
||||
@@ -534,12 +535,23 @@ async fn attach_ws(
|
||||
State(s): State<LocalApiState>,
|
||||
ws: axum::extract::WebSocketUpgrade,
|
||||
) -> Result<Response, Response> {
|
||||
let Some((replay, rx)) = s.manager.subscribe_pty_with_replay(id).await else {
|
||||
return Err(error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown_session",
|
||||
"no such session on this daemon",
|
||||
));
|
||||
let (replay, rx) = match s.manager.subscribe_pty_with_replay(id).await {
|
||||
Ok(subscription) => subscription,
|
||||
Err(SessionError::NotFound(_)) => {
|
||||
return Err(error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown_session",
|
||||
"no such session on this daemon",
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
let info = error.to_error_info();
|
||||
return Err(error_response(
|
||||
session_error_status(&info.code),
|
||||
&info.code,
|
||||
&info.message,
|
||||
));
|
||||
}
|
||||
};
|
||||
let manager = s.manager.clone();
|
||||
Ok(ws.on_upgrade(move |socket| async move {
|
||||
|
||||
@@ -169,6 +169,8 @@ pub enum SessionError {
|
||||
/// a stray keystroke on an attach socket, which is dropped.
|
||||
#[error("session is no longer running: {0}")]
|
||||
NotRunning(Uuid),
|
||||
#[error("session is not a terminal session")]
|
||||
NotTerminal(Uuid),
|
||||
#[error("daemon is shutting down")]
|
||||
ShuttingDown,
|
||||
/// The kill was refused (#79): the session's worktree holds unsaved work.
|
||||
@@ -205,6 +207,7 @@ impl SessionError {
|
||||
SessionError::NotFound(_) => "not_found",
|
||||
SessionError::InvalidEnv(_) => "invalid_env",
|
||||
SessionError::NotRunning(_) => "not_running",
|
||||
SessionError::NotTerminal(_) => "not_terminal",
|
||||
SessionError::ShuttingDown => "shutting_down",
|
||||
SessionError::KillGuarded(_) => "kill_guarded",
|
||||
};
|
||||
@@ -879,11 +882,17 @@ impl SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to a session's PTY broadcast. `None` if the session is
|
||||
/// unknown to this daemon.
|
||||
pub async fn subscribe_pty(&self, id: Uuid) -> Option<broadcast::Receiver<Vec<u8>>> {
|
||||
/// Subscribe to a terminal session's PTY broadcast.
|
||||
pub async fn subscribe_pty(
|
||||
&self,
|
||||
id: Uuid,
|
||||
) -> Result<broadcast::Receiver<Vec<u8>>, SessionError> {
|
||||
let sessions = self.inner.sessions.lock().await;
|
||||
sessions.get(&id).map(|r| r.bridge.subscribe())
|
||||
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
|
||||
if record.session.view != SessionView::Terminal {
|
||||
return Err(SessionError::NotTerminal(id));
|
||||
}
|
||||
Ok(record.bridge.subscribe())
|
||||
}
|
||||
|
||||
/// Subscribe to a session's PTY broadcast and return the replay buffer
|
||||
@@ -897,11 +906,14 @@ impl SessionManager {
|
||||
pub async fn subscribe_pty_with_replay(
|
||||
&self,
|
||||
id: Uuid,
|
||||
) -> Option<(Vec<u8>, broadcast::Receiver<Vec<u8>>)> {
|
||||
) -> Result<(Vec<u8>, broadcast::Receiver<Vec<u8>>), SessionError> {
|
||||
let tmux = self.inner.tmux.clone();
|
||||
let (result, updated) = {
|
||||
let mut sessions = self.inner.sessions.lock().await;
|
||||
let rec = sessions.get_mut(&id)?;
|
||||
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
|
||||
if rec.session.view != SessionView::Terminal {
|
||||
return Err(SessionError::NotTerminal(id));
|
||||
}
|
||||
rec.session.last_focused_at = Some(Utc::now());
|
||||
let updated = rec.session.clone();
|
||||
// For a live session, tmux's own screen beats the byte tail: the
|
||||
@@ -932,7 +944,7 @@ impl SessionManager {
|
||||
.inner
|
||||
.events
|
||||
.send(CatalogEvent::SessionUpdated { session: updated });
|
||||
Some(result)
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn has(&self, id: Uuid) -> bool {
|
||||
@@ -945,6 +957,15 @@ impl SessionManager {
|
||||
sessions.get(&id).map(|r| r.session.status)
|
||||
}
|
||||
|
||||
async fn terminal_status(&self, id: Uuid) -> Result<SessionStatus, SessionError> {
|
||||
let sessions = self.inner.sessions.lock().await;
|
||||
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
|
||||
if record.session.view != SessionView::Terminal {
|
||||
return Err(SessionError::NotTerminal(id));
|
||||
}
|
||||
Ok(record.session.status)
|
||||
}
|
||||
|
||||
/// Whether the natural-exit settle step (#82) has finished for this
|
||||
/// session: `Some(true)` once applied or refused, `Some(false)` while
|
||||
/// still pending, `None` if the session is unknown. `Dead` is observable
|
||||
@@ -1626,10 +1647,9 @@ impl SessionManager {
|
||||
/// its pane is gone, so this is a no-op rather than a tmux failure the
|
||||
/// client would have to interpret.
|
||||
pub async fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), SessionError> {
|
||||
match self.status(id).await {
|
||||
None => return Err(SessionError::NotFound(id)),
|
||||
Some(SessionStatus::Dead) => return Ok(()),
|
||||
Some(SessionStatus::Running) => {}
|
||||
match self.terminal_status(id).await? {
|
||||
SessionStatus::Dead => return Ok(()),
|
||||
SessionStatus::Running => {}
|
||||
}
|
||||
let tmux = self.inner.tmux.clone();
|
||||
let bytes = bytes.to_vec();
|
||||
@@ -1645,10 +1665,9 @@ impl SessionManager {
|
||||
/// asked for text to land somewhere, and a silent success would let a
|
||||
/// script believe an agent had been told something it never heard.
|
||||
pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> {
|
||||
match self.status(id).await {
|
||||
None => return Err(SessionError::NotFound(id)),
|
||||
Some(SessionStatus::Dead) => return Err(SessionError::NotRunning(id)),
|
||||
Some(SessionStatus::Running) => {}
|
||||
match self.terminal_status(id).await? {
|
||||
SessionStatus::Dead => return Err(SessionError::NotRunning(id)),
|
||||
SessionStatus::Running => {}
|
||||
}
|
||||
let tmux = self.inner.tmux.clone();
|
||||
let bytes = text.as_bytes().to_vec();
|
||||
@@ -1668,10 +1687,9 @@ impl SessionManager {
|
||||
/// Resize a session's window. No-op for a dead session, as with
|
||||
/// [`Self::send_keys`].
|
||||
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
|
||||
match self.status(id).await {
|
||||
None => return Err(SessionError::NotFound(id)),
|
||||
Some(SessionStatus::Dead) => return Ok(()),
|
||||
Some(SessionStatus::Running) => {}
|
||||
match self.terminal_status(id).await? {
|
||||
SessionStatus::Dead => return Ok(()),
|
||||
SessionStatus::Running => {}
|
||||
}
|
||||
let tmux = self.inner.tmux.clone();
|
||||
tmux_blocking(move || tmux.resize_window(id, cols, rows))
|
||||
@@ -1703,6 +1721,7 @@ impl SessionManager {
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
) -> Result<(), SessionError> {
|
||||
self.terminal_status(id).await?;
|
||||
let target = self
|
||||
.inner
|
||||
.viewports
|
||||
@@ -2807,13 +2826,15 @@ mod tests {
|
||||
async fn manager_with_structured_stream() -> (
|
||||
tempfile::TempDir,
|
||||
SessionManager,
|
||||
Arc<FakePanes>,
|
||||
uuid::Uuid,
|
||||
Arc<tokio::sync::Mutex<AcpEventStream>>,
|
||||
uuid::Uuid,
|
||||
) {
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
let panes = Arc::new(FakePanes::new());
|
||||
let manager = SessionManager::new(
|
||||
Arc::new(FakePanes::new()),
|
||||
panes.clone(),
|
||||
SidecarStore::new(state.path().join("sessions")),
|
||||
WorktreeManager::new(None),
|
||||
state.path().join("fifos"),
|
||||
@@ -2848,12 +2869,12 @@ mod tests {
|
||||
record.focused_since = Some(std::time::Instant::now() - FOCUS_CLEAR_HOLD);
|
||||
record.acp_events = Some(events.clone());
|
||||
}
|
||||
(state, manager, structured.id, events, terminal.id)
|
||||
(state, manager, panes, structured.id, events, terminal.id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocked_structured_activity_read_does_not_block_the_catalog() {
|
||||
let (state, manager, structured_id, events, terminal_id) =
|
||||
let (state, manager, _panes, structured_id, events, terminal_id) =
|
||||
manager_with_structured_stream().await;
|
||||
let held_stream = events.lock().await;
|
||||
let activity_pass = run_structured_activity_pass(&manager.inner);
|
||||
@@ -2898,9 +2919,55 @@ mod tests {
|
||||
assert_eq!(structured.activity, ActivityState::Idle);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_sessions_reject_every_pty_operation_before_panes() {
|
||||
let (_state, manager, panes, structured_id, _events, terminal_id) =
|
||||
manager_with_structured_stream().await;
|
||||
let before = panes.calls();
|
||||
|
||||
assert!(matches!(
|
||||
manager.subscribe_pty(structured_id).await,
|
||||
Err(super::SessionError::NotTerminal(id)) if id == structured_id
|
||||
));
|
||||
assert!(matches!(
|
||||
manager.subscribe_pty_with_replay(structured_id).await,
|
||||
Err(super::SessionError::NotTerminal(id)) if id == structured_id
|
||||
));
|
||||
assert!(matches!(
|
||||
manager.send_keys(structured_id, b"ignored").await,
|
||||
Err(super::SessionError::NotTerminal(id)) if id == structured_id
|
||||
));
|
||||
assert!(matches!(
|
||||
manager.send_input(structured_id, "ignored", false).await,
|
||||
Err(super::SessionError::NotTerminal(id)) if id == structured_id
|
||||
));
|
||||
assert!(matches!(
|
||||
manager.resize(structured_id, 80, 24).await,
|
||||
Err(super::SessionError::NotTerminal(id)) if id == structured_id
|
||||
));
|
||||
assert!(matches!(
|
||||
manager
|
||||
.set_viewport(structured_id, manager.attach_viewport(), 80, 24)
|
||||
.await,
|
||||
Err(super::SessionError::NotTerminal(id)) if id == structured_id
|
||||
));
|
||||
|
||||
let after = panes.calls();
|
||||
assert_eq!(after, before, "structured PTY operations touched panes");
|
||||
|
||||
manager
|
||||
.send_input(terminal_id, "works", false)
|
||||
.await
|
||||
.expect("terminal input remains available");
|
||||
manager
|
||||
.resize(terminal_id, 100, 30)
|
||||
.await
|
||||
.expect("terminal resize remains available");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delayed_activity_from_a_replaced_stream_is_discarded() {
|
||||
let (_state, manager, structured_id, old_events, _terminal_id) =
|
||||
let (_state, manager, _panes, structured_id, old_events, _terminal_id) =
|
||||
manager_with_structured_stream().await;
|
||||
let replacement = Arc::new(tokio::sync::Mutex::new(stream(
|
||||
manager
|
||||
|
||||
@@ -1311,6 +1311,39 @@ async fn attach_ws_streams_pty_bytes() {
|
||||
assert!(saw_hello, "PTY did not echo through the attach WS");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pty_routes_reject_structured_sessions_as_not_terminal() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fake = FakeAcp::new();
|
||||
let id = structured_session(&h, &fake).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(url(h.addr, &format!("/api/sessions/{id}/input")))
|
||||
.json(&maestro_protocol::SendInputRequest {
|
||||
text: "ignored".into(),
|
||||
submit: false,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
||||
let error: ApiError = response.json().await.unwrap();
|
||||
assert_eq!(error.error.code, "not_terminal");
|
||||
assert_eq!(error.error.message, "session is not a terminal session");
|
||||
|
||||
let error = connect_async(ws_url(h.addr, &format!("/api/attach/{id}")))
|
||||
.await
|
||||
.expect_err("structured session must not upgrade to a PTY socket");
|
||||
let tokio_tungstenite::tungstenite::Error::Http(response) = error else {
|
||||
panic!("expected an HTTP handshake rejection, got {error}");
|
||||
};
|
||||
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
||||
let body = response.body().as_deref().expect("ApiError response body");
|
||||
let error: ApiError = serde_json::from_slice(body).unwrap();
|
||||
assert_eq!(error.error.code, "not_terminal");
|
||||
assert_eq!(error.error.message, "session is not a terminal session");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attach_ws_for_unknown_session_returns_404() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
|
||||
@@ -477,7 +477,7 @@ impl App {
|
||||
.state
|
||||
.lock()
|
||||
.expect("structured state mutex")
|
||||
.approval_failed(error.to_string());
|
||||
.approval_failed(&nonce, error.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+120
-11
@@ -41,8 +41,10 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use maestro_config::{builtin_presets, custom_preset};
|
||||
use maestro_protocol::display::abbreviate_dir;
|
||||
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
|
||||
use maestro_protocol::display::{abbreviate_dir, command_tool};
|
||||
use maestro_protocol::{
|
||||
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
|
||||
};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
@@ -86,9 +88,11 @@ pub enum DraftField {
|
||||
/// apply immediately, mirroring the web form's per-click choice.
|
||||
WorktreeChoice,
|
||||
Name,
|
||||
/// Disclosure collapsing the worktree/branch controls below (#93).
|
||||
/// Disclosure collapsing the optional view and worktree controls below.
|
||||
/// Closed by default; Enter / Space toggles.
|
||||
Advanced,
|
||||
/// ACP-backed transcript instead of a terminal. Claude Code only.
|
||||
StructuredToggle,
|
||||
/// Worktree on/off for this session (#65). Enter / Space toggles.
|
||||
WorktreeToggle,
|
||||
/// New-vs-existing branch source; Left / Right switches.
|
||||
@@ -251,6 +255,9 @@ pub struct DraftPane {
|
||||
/// "Advanced options" disclosure (#93). Closed by default — most spawns
|
||||
/// take the default target.
|
||||
pub advanced_collapsed: bool,
|
||||
/// ACP-backed structured transcript. Off by default and supported only
|
||||
/// when the resolved command is Claude Code.
|
||||
pub structured_view: bool,
|
||||
/// Enclosing repository of the chosen target — from the project scan for
|
||||
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
|
||||
pub base_repo: Option<PathBuf>,
|
||||
@@ -311,6 +318,7 @@ impl Default for DraftPane {
|
||||
name_touched: false,
|
||||
worktree_enabled: true,
|
||||
advanced_collapsed: true,
|
||||
structured_view: false,
|
||||
base_repo: None,
|
||||
probed_cwd: None,
|
||||
probe_seq: 0,
|
||||
@@ -348,6 +356,7 @@ impl DraftPane {
|
||||
.position(|p| !p.command.is_empty())
|
||||
.unwrap_or(0);
|
||||
self.apply_seed_command();
|
||||
self.clear_unsupported_structured_view();
|
||||
}
|
||||
|
||||
/// Land a seeded command on the tile that runs it: its own preset when the
|
||||
@@ -663,6 +672,22 @@ impl DraftPane {
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_structured_view(&self) -> bool {
|
||||
command_tool(self.command()) == Some("claude")
|
||||
}
|
||||
|
||||
fn clear_unsupported_structured_view(&mut self) {
|
||||
if !self.supports_structured_view() {
|
||||
self.structured_view = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_structured_view(&mut self) {
|
||||
if self.supports_structured_view() {
|
||||
self.structured_view = !self.structured_view;
|
||||
}
|
||||
}
|
||||
|
||||
/// The chosen working directory: a picked project row, else the raw path
|
||||
/// input when it holds something.
|
||||
pub fn cwd(&self) -> Option<PathBuf> {
|
||||
@@ -694,7 +719,11 @@ impl DraftPane {
|
||||
}
|
||||
let worktree = self.build_worktree()?;
|
||||
Ok(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
view: if self.structured_view && self.supports_structured_view() {
|
||||
SessionView::Structured
|
||||
} else {
|
||||
SessionView::Terminal
|
||||
},
|
||||
cwd,
|
||||
command: command.to_string(),
|
||||
env: Default::default(),
|
||||
@@ -754,6 +783,9 @@ impl DraftPane {
|
||||
order.push(DraftField::Name);
|
||||
order.push(DraftField::Advanced);
|
||||
if !self.advanced_collapsed {
|
||||
if self.supports_structured_view() {
|
||||
order.push(DraftField::StructuredToggle);
|
||||
}
|
||||
if !self.cwd_raw_visible && self.selected_group_key.is_some() {
|
||||
order.push(DraftField::WorktreeChoice);
|
||||
}
|
||||
@@ -795,6 +827,7 @@ impl DraftPane {
|
||||
}
|
||||
self.selected_preset =
|
||||
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
|
||||
self.clear_unsupported_structured_view();
|
||||
}
|
||||
|
||||
/// Step-one boxes surviving the type-to-filter, in display order. A
|
||||
@@ -1001,9 +1034,9 @@ impl DraftPane {
|
||||
// Two fields keep their own Enter instead: `DirPicker`, whose
|
||||
// highlighted row isn't the picked one until a first Enter chooses
|
||||
// it (the next Enter, now that it's active, falls through to
|
||||
// submit); and the disclosure/toggle pair (`Advanced`,
|
||||
// `WorktreeToggle`), which activate on Enter like a button rather
|
||||
// than submitting, same as the web form's own `<button>` controls.
|
||||
// submit); and the disclosure/checkbox fields, which activate on
|
||||
// Enter like buttons rather than submitting, matching the web
|
||||
// form controls.
|
||||
InputKey::Enter if self.focused_field == DraftField::DirPicker => {
|
||||
let is_other_row = self.selection() >= self.visible_groups().len();
|
||||
let choose_only =
|
||||
@@ -1023,6 +1056,10 @@ impl DraftPane {
|
||||
self.toggle_worktree();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Enter if self.focused_field == DraftField::StructuredToggle => {
|
||||
self.toggle_structured_view();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Enter => DraftInputResult::Submit,
|
||||
InputKey::Char {
|
||||
c: ' ',
|
||||
@@ -1040,6 +1077,14 @@ impl DraftPane {
|
||||
self.toggle_worktree();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Char {
|
||||
c: ' ',
|
||||
ctrl: false,
|
||||
..
|
||||
} if self.focused_field == DraftField::StructuredToggle => {
|
||||
self.toggle_structured_view();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Up if self.focused_field == DraftField::DirPicker => {
|
||||
self.move_selection(-1);
|
||||
DraftInputResult::Consumed
|
||||
@@ -1083,7 +1128,10 @@ impl DraftPane {
|
||||
other => {
|
||||
match self.focused_field {
|
||||
DraftField::Preset | DraftField::Submit => {}
|
||||
DraftField::CustomCommand => self.custom_command.handle_key(other),
|
||||
DraftField::CustomCommand => {
|
||||
self.custom_command.handle_key(other);
|
||||
self.clear_unsupported_structured_view();
|
||||
}
|
||||
// Typing filters the list; the highlight resets to the top
|
||||
// so a narrowed search always starts at its best match.
|
||||
DraftField::DirPicker => {
|
||||
@@ -1121,9 +1169,9 @@ impl DraftPane {
|
||||
|
||||
let show_custom = self.is_custom();
|
||||
let show_raw = self.cwd_raw_visible;
|
||||
// The worktree choice step, the worktree toggle and its body all stay
|
||||
// behind the "Advanced options" disclosure (#93): collapsed hides
|
||||
// them, regardless of what they'd otherwise show.
|
||||
let show_structured = !self.advanced_collapsed && self.supports_structured_view();
|
||||
// Optional view and worktree controls stay behind the "Advanced
|
||||
// options" disclosure (#93), regardless of what they'd otherwise show.
|
||||
let show_wt_toggle = !self.advanced_collapsed;
|
||||
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
|
||||
let show_wt_choice =
|
||||
@@ -1142,6 +1190,7 @@ impl DraftPane {
|
||||
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* advanced header */ + 1 /* spawn */
|
||||
+ u16::from(show_custom)
|
||||
+ u16::from(show_raw)
|
||||
+ u16::from(show_structured)
|
||||
+ u16::from(show_wt_toggle)
|
||||
+ u16::from(show_wt_choice) * wt_choice_capacity
|
||||
+ u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows })
|
||||
@@ -1161,6 +1210,9 @@ impl DraftPane {
|
||||
}
|
||||
constraints.push(Constraint::Length(1)); // name
|
||||
constraints.push(Constraint::Length(1)); // advanced header
|
||||
if show_structured {
|
||||
constraints.push(Constraint::Length(1));
|
||||
}
|
||||
if show_wt_choice {
|
||||
constraints.push(Constraint::Length(wt_choice_capacity));
|
||||
}
|
||||
@@ -1359,6 +1411,24 @@ impl DraftPane {
|
||||
advanced_row,
|
||||
);
|
||||
|
||||
if show_structured {
|
||||
let row = next_row();
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::raw(format!("{:<GUTTER$}", "view:")),
|
||||
Span::styled(
|
||||
if self.structured_view {
|
||||
"[x] structured view"
|
||||
} else {
|
||||
"[ ] structured view"
|
||||
},
|
||||
field_style(DraftField::StructuredToggle),
|
||||
),
|
||||
])),
|
||||
row,
|
||||
);
|
||||
}
|
||||
|
||||
if show_wt_choice {
|
||||
// Step two (#96 rule 3): "+ new" first, then the repository's
|
||||
// worktrees newest-first.
|
||||
@@ -1817,6 +1887,45 @@ mod tests {
|
||||
assert_eq!(req.cwd, PathBuf::from("/srv/work"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_view_is_default_off_and_only_visible_for_claude_code() {
|
||||
let mut draft = DraftPane {
|
||||
advanced_collapsed: false,
|
||||
..DraftPane::default()
|
||||
};
|
||||
assert!(!draft.structured_view);
|
||||
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||
assert!(rendered.contains("[ ] structured view"), "{rendered}");
|
||||
|
||||
draft.toggle_structured_view();
|
||||
assert!(draft.structured_view);
|
||||
draft.cycle_preset(1);
|
||||
assert_eq!(draft.command(), "opencode");
|
||||
assert!(
|
||||
!draft.structured_view,
|
||||
"a hidden value must not stay checked"
|
||||
);
|
||||
assert!(!draft.field_order().contains(&DraftField::StructuredToggle));
|
||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||
assert!(!rendered.contains("structured view"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_view_request_is_guarded_against_a_stale_command() {
|
||||
let mut draft = DraftPane {
|
||||
picked_cwd: Some(PathBuf::from("/srv/work")),
|
||||
worktree_enabled: false,
|
||||
structured_view: true,
|
||||
..DraftPane::default()
|
||||
};
|
||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
||||
|
||||
draft.selected_preset = 1;
|
||||
assert_eq!(draft.command(), "opencode");
|
||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() {
|
||||
let mut draft = DraftPane {
|
||||
|
||||
@@ -14,6 +14,8 @@ use crate::session_task::SessionState;
|
||||
use crate::structured_task::{offered_decisions, StructuredHandle, StructuredState};
|
||||
|
||||
const DIFF_PREVIEW_LINES: usize = 3;
|
||||
const DIFF_CONTEXT_LINES: usize = 3;
|
||||
const LCS_CELL_LIMIT: usize = 250_000;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StructuredPane {
|
||||
@@ -322,8 +324,8 @@ fn tool_lines(
|
||||
},
|
||||
});
|
||||
|
||||
let content = tool_content_lines(row);
|
||||
if row.kind == TranscriptRowKind::ToolDiff && !expanded {
|
||||
let content = tool_content_lines(row, Some(DIFF_PREVIEW_LINES + 1));
|
||||
let shown = content.len().min(DIFF_PREVIEW_LINES);
|
||||
for text in content.iter().take(shown) {
|
||||
push_wrapped(out, row, " ", text.clone(), width, diff_tone(text), false);
|
||||
@@ -340,6 +342,7 @@ fn tool_lines(
|
||||
);
|
||||
}
|
||||
} else if expanded {
|
||||
let content = tool_content_lines(row, None);
|
||||
for text in content {
|
||||
push_wrapped(out, row, " ", text.clone(), width, diff_tone(&text), false);
|
||||
}
|
||||
@@ -448,7 +451,7 @@ fn tool_title(row: &TranscriptRow) -> (String, String) {
|
||||
(explicit.unwrap_or(fallback).to_string(), title.to_string())
|
||||
}
|
||||
|
||||
fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
|
||||
fn tool_content_lines(row: &TranscriptRow, limit: Option<usize>) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for block in row
|
||||
.data
|
||||
@@ -457,6 +460,9 @@ fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if limit.is_some_and(|limit| out.len() >= limit) {
|
||||
break;
|
||||
}
|
||||
match block.get("type").and_then(serde_json::Value::as_str) {
|
||||
Some("content") => {
|
||||
let nested = block.get("content").cloned().unwrap_or_default();
|
||||
@@ -466,7 +472,8 @@ fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
|
||||
data: serde_json::json!({"content": nested}),
|
||||
};
|
||||
let text = content_text(&fake);
|
||||
out.extend(text.lines().map(str::to_string));
|
||||
let remaining = limit.map_or(usize::MAX, |limit| limit.saturating_sub(out.len()));
|
||||
out.extend(text.lines().take(remaining).map(str::to_string));
|
||||
}
|
||||
Some("diff") => {
|
||||
let path = block
|
||||
@@ -474,11 +481,10 @@ fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("file");
|
||||
out.push(format!("{path}:"));
|
||||
if let Some(old) = block.get("oldText").and_then(serde_json::Value::as_str) {
|
||||
out.extend(old.lines().map(|line| format!("- {line}")));
|
||||
}
|
||||
let old = block.get("oldText").and_then(serde_json::Value::as_str);
|
||||
if let Some(new) = block.get("newText").and_then(serde_json::Value::as_str) {
|
||||
out.extend(new.lines().map(|line| format!("+ {line}")));
|
||||
let remaining = limit.map(|limit| limit.saturating_sub(out.len()));
|
||||
out.extend(render_diff(old, new, remaining));
|
||||
}
|
||||
}
|
||||
Some("terminal") => out.push("[embedded terminal]".into()),
|
||||
@@ -488,6 +494,236 @@ fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct SourceLine<'a> {
|
||||
text: &'a str,
|
||||
terminated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum DiffKind {
|
||||
Add,
|
||||
Delete,
|
||||
Context,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct DiffOp<'a> {
|
||||
kind: DiffKind,
|
||||
line: SourceLine<'a>,
|
||||
}
|
||||
|
||||
fn split_diff_lines(text: &str) -> Vec<SourceLine<'_>> {
|
||||
text.split_inclusive('\n')
|
||||
.map(|part| SourceLine {
|
||||
text: part.strip_suffix('\n').unwrap_or(part),
|
||||
terminated: part.ends_with('\n'),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn lcs_diff<'a>(old: &[SourceLine<'a>], new: &[SourceLine<'a>]) -> Vec<DiffOp<'a>> {
|
||||
if old.is_empty() {
|
||||
return new
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|line| DiffOp {
|
||||
kind: DiffKind::Add,
|
||||
line,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if new.is_empty() {
|
||||
return old
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|line| DiffOp {
|
||||
kind: DiffKind::Delete,
|
||||
line,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if old.len().saturating_mul(new.len()) > LCS_CELL_LIMIT {
|
||||
return old
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|line| DiffOp {
|
||||
kind: DiffKind::Delete,
|
||||
line,
|
||||
})
|
||||
.chain(new.iter().copied().map(|line| DiffOp {
|
||||
kind: DiffKind::Add,
|
||||
line,
|
||||
}))
|
||||
.collect();
|
||||
}
|
||||
|
||||
let columns = new.len() + 1;
|
||||
let mut table = vec![0_u32; (old.len() + 1) * columns];
|
||||
for i in (0..old.len()).rev() {
|
||||
for j in (0..new.len()).rev() {
|
||||
table[i * columns + j] = if old[i] == new[j] {
|
||||
table[(i + 1) * columns + j + 1] + 1
|
||||
} else {
|
||||
table[(i + 1) * columns + j].max(table[i * columns + j + 1])
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let mut ops = Vec::with_capacity(old.len() + new.len());
|
||||
let (mut i, mut j) = (0, 0);
|
||||
while i < old.len() && j < new.len() {
|
||||
if old[i] == new[j] {
|
||||
ops.push(DiffOp {
|
||||
kind: DiffKind::Context,
|
||||
line: old[i],
|
||||
});
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if table[(i + 1) * columns + j] >= table[i * columns + j + 1] {
|
||||
ops.push(DiffOp {
|
||||
kind: DiffKind::Delete,
|
||||
line: old[i],
|
||||
});
|
||||
i += 1;
|
||||
} else {
|
||||
ops.push(DiffOp {
|
||||
kind: DiffKind::Add,
|
||||
line: new[j],
|
||||
});
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
ops.extend(old[i..].iter().copied().map(|line| DiffOp {
|
||||
kind: DiffKind::Delete,
|
||||
line,
|
||||
}));
|
||||
ops.extend(new[j..].iter().copied().map(|line| DiffOp {
|
||||
kind: DiffKind::Add,
|
||||
line,
|
||||
}));
|
||||
ops
|
||||
}
|
||||
|
||||
fn diff_ops<'a>(old: &'a str, new: &'a str) -> Vec<DiffOp<'a>> {
|
||||
let old = split_diff_lines(old);
|
||||
let new = split_diff_lines(new);
|
||||
let mut prefix = 0;
|
||||
while prefix < old.len() && prefix < new.len() && old[prefix] == new[prefix] {
|
||||
prefix += 1;
|
||||
}
|
||||
let mut suffix = 0;
|
||||
while suffix < old.len() - prefix
|
||||
&& suffix < new.len() - prefix
|
||||
&& old[old.len() - 1 - suffix] == new[new.len() - 1 - suffix]
|
||||
{
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
let mut ops = old[..prefix]
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|line| DiffOp {
|
||||
kind: DiffKind::Context,
|
||||
line,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
ops.extend(lcs_diff(
|
||||
&old[prefix..old.len() - suffix],
|
||||
&new[prefix..new.len() - suffix],
|
||||
));
|
||||
ops.extend(
|
||||
old[old.len() - suffix..]
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|line| DiffOp {
|
||||
kind: DiffKind::Context,
|
||||
line,
|
||||
}),
|
||||
);
|
||||
ops
|
||||
}
|
||||
|
||||
fn render_diff_ops(ops: &[DiffOp<'_>]) -> Vec<String> {
|
||||
let mut rendered = Vec::with_capacity(ops.len());
|
||||
let mut index = 0;
|
||||
while index < ops.len() {
|
||||
if ops[index].kind != DiffKind::Context {
|
||||
push_diff_op(&mut rendered, ops[index]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut end = index;
|
||||
while end < ops.len() && ops[end].kind == DiffKind::Context {
|
||||
end += 1;
|
||||
}
|
||||
let run = &ops[index..end];
|
||||
let whole_diff = index == 0 && end == ops.len();
|
||||
let keep_head = if whole_diff || index > 0 {
|
||||
DIFF_CONTEXT_LINES
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let keep_tail = if whole_diff || end < ops.len() {
|
||||
DIFF_CONTEXT_LINES
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if run.len() <= keep_head + keep_tail {
|
||||
for op in run {
|
||||
push_diff_op(&mut rendered, *op);
|
||||
}
|
||||
} else {
|
||||
for op in run.iter().take(keep_head) {
|
||||
push_diff_op(&mut rendered, *op);
|
||||
}
|
||||
let collapsed = &run[keep_head..run.len() - keep_tail];
|
||||
rendered.push(format!("… {} unchanged lines …", collapsed.len()));
|
||||
if collapsed.iter().any(|op| !op.line.terminated) {
|
||||
rendered.push("\\ No newline at end of file".into());
|
||||
}
|
||||
for op in run.iter().skip(run.len() - keep_tail) {
|
||||
push_diff_op(&mut rendered, *op);
|
||||
}
|
||||
}
|
||||
index = end;
|
||||
}
|
||||
rendered
|
||||
}
|
||||
|
||||
fn push_diff_op(rendered: &mut Vec<String>, op: DiffOp<'_>) {
|
||||
let prefix = match op.kind {
|
||||
DiffKind::Add => "+ ",
|
||||
DiffKind::Delete => "- ",
|
||||
DiffKind::Context => " ",
|
||||
};
|
||||
rendered.push(format!("{prefix}{}", op.line.text));
|
||||
if !op.line.terminated {
|
||||
rendered.push("\\ No newline at end of file".into());
|
||||
}
|
||||
}
|
||||
|
||||
fn render_diff(old: Option<&str>, new: &str, limit: Option<usize>) -> Vec<String> {
|
||||
let ops = old.map_or_else(
|
||||
|| {
|
||||
split_diff_lines(new)
|
||||
.into_iter()
|
||||
.map(|line| DiffOp {
|
||||
kind: DiffKind::Add,
|
||||
line,
|
||||
})
|
||||
.collect()
|
||||
},
|
||||
|old| diff_ops(old, new),
|
||||
);
|
||||
let mut rendered = render_diff_ops(&ops);
|
||||
if let Some(limit) = limit {
|
||||
rendered.truncate(limit);
|
||||
}
|
||||
rendered
|
||||
}
|
||||
|
||||
fn diff_tone(text: &str) -> Tone {
|
||||
if text.starts_with("+ ") {
|
||||
Tone::Active
|
||||
@@ -722,6 +958,230 @@ mod tests {
|
||||
assert!(lines.last().unwrap().text.contains("Enter to expand"));
|
||||
}
|
||||
|
||||
fn diff_content(old_text: Option<&str>, new_text: &str) -> Vec<String> {
|
||||
let old_text = old_text.map_or(serde_json::Value::Null, |text| json!(text));
|
||||
let row = row(
|
||||
"d",
|
||||
TranscriptRowKind::ToolDiff,
|
||||
json!({"content":[{"type":"diff","path":"x","oldText":old_text,"newText":new_text}]}),
|
||||
);
|
||||
tool_content_lines(&row, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_diff_preserves_unchanged_lines() {
|
||||
assert_eq!(diff_content(Some("a\nb\n"), "a\nb\n"), ["x:", " a", " b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_diff_handles_insert_delete_replace_and_empty_files() {
|
||||
assert_eq!(
|
||||
diff_content(Some("a\nc\n"), "a\nb\nc\n"),
|
||||
["x:", " a", "+ b", " c"]
|
||||
);
|
||||
assert_eq!(
|
||||
diff_content(Some("a\nb\nc\n"), "a\nc\n"),
|
||||
["x:", " a", "- b", " c"]
|
||||
);
|
||||
assert_eq!(
|
||||
diff_content(Some("a\nb\n"), "a\nc\n"),
|
||||
["x:", " a", "- b", "+ c"]
|
||||
);
|
||||
assert_eq!(diff_content(Some(""), ""), ["x:"]);
|
||||
assert_eq!(diff_content(None, "a\n"), ["x:", "+ a"]);
|
||||
assert_eq!(diff_content(Some("a\n"), ""), ["x:", "- a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_diff_marks_added_and_removed_final_newlines() {
|
||||
assert_eq!(
|
||||
diff_content(Some("a"), "a\n"),
|
||||
["x:", "- a", "\\ No newline at end of file", "+ a"]
|
||||
);
|
||||
assert_eq!(
|
||||
diff_content(Some("a\n"), "a"),
|
||||
["x:", "- a", "+ a", "\\ No newline at end of file"]
|
||||
);
|
||||
assert_eq!(
|
||||
diff_content(None, "a"),
|
||||
["x:", "+ a", "\\ No newline at end of file"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_diff_marks_unchanged_unterminated_final_context() {
|
||||
assert_eq!(
|
||||
diff_content(Some("a\nb\nc"), "a\nX\nc"),
|
||||
[
|
||||
"x:",
|
||||
" a",
|
||||
"- b",
|
||||
"+ X",
|
||||
" c",
|
||||
"\\ No newline at end of file"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_diff_collapses_long_context_around_changes() {
|
||||
let common = (0..20)
|
||||
.map(|index| format!("line{index}"))
|
||||
.collect::<Vec<_>>();
|
||||
let old = common[..10]
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(["OLD".into()])
|
||||
.chain(common[10..].iter().cloned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let new = common[..10]
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(["NEW".into()])
|
||||
.chain(common[10..].iter().cloned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
assert_eq!(
|
||||
render_diff(Some(&old), &new, None),
|
||||
[
|
||||
"… 7 unchanged lines …",
|
||||
" line7",
|
||||
" line8",
|
||||
" line9",
|
||||
"- OLD",
|
||||
"+ NEW",
|
||||
" line10",
|
||||
" line11",
|
||||
" line12",
|
||||
"… 7 unchanged lines …",
|
||||
"\\ No newline at end of file",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_diff_preserves_eof_marker_through_trailing_collapse() {
|
||||
let trailing = (0..10).map(|index| format!("t{index}")).collect::<Vec<_>>();
|
||||
let old = ["OLD".into()]
|
||||
.into_iter()
|
||||
.chain(trailing.iter().cloned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let new = ["NEW".into()]
|
||||
.into_iter()
|
||||
.chain(trailing)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
assert_eq!(
|
||||
render_diff(Some(&old), &new, None),
|
||||
[
|
||||
"- OLD",
|
||||
"+ NEW",
|
||||
" t0",
|
||||
" t1",
|
||||
" t2",
|
||||
"… 7 unchanged lines …",
|
||||
"\\ No newline at end of file",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_diff_collapses_only_long_interior_context() {
|
||||
let middle = (0..10).map(|index| format!("c{index}")).collect::<Vec<_>>();
|
||||
let old = ["lead0", "lead1", "OLD1"]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.chain(middle.iter().cloned())
|
||||
.chain(["OLD2", "trail0", "trail1"].into_iter().map(str::to_string))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let new = ["lead0", "lead1", "NEW1"]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.chain(middle.iter().cloned())
|
||||
.chain(["NEW2", "trail0", "trail1"].into_iter().map(str::to_string))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let rendered = render_diff(Some(&old), &new, None);
|
||||
|
||||
assert_eq!(
|
||||
rendered
|
||||
.iter()
|
||||
.filter(|line| line.contains("unchanged lines"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(&rendered[..4], [" lead0", " lead1", "- OLD1", "+ NEW1"]);
|
||||
assert_eq!(
|
||||
&rendered[4..11],
|
||||
[
|
||||
" c0",
|
||||
" c1",
|
||||
" c2",
|
||||
"… 4 unchanged lines …",
|
||||
" c7",
|
||||
" c8",
|
||||
" c9"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
&rendered[11..],
|
||||
[
|
||||
"- OLD2",
|
||||
"+ NEW2",
|
||||
" trail0",
|
||||
" trail1",
|
||||
"\\ No newline at end of file"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_diff_preview_remains_bounded_after_hunking() {
|
||||
let common = (0..10)
|
||||
.map(|index| format!("line{index}"))
|
||||
.collect::<Vec<_>>();
|
||||
let old = common
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(["OLD".into()])
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let new = common
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(["NEW".into()])
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let expanded = render_diff(Some(&old), &new, None);
|
||||
|
||||
assert_eq!(render_diff(Some(&old), &new, Some(4)), expanded[..4]);
|
||||
assert_eq!(expanded[0], "… 7 unchanged lines …");
|
||||
assert!(expanded.contains(&"- OLD".into()));
|
||||
assert!(expanded.contains(&"+ NEW".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_diff_bounds_pathological_changed_middles() {
|
||||
let old_lines = (0..501)
|
||||
.map(|index| format!("line{index}"))
|
||||
.collect::<Vec<_>>();
|
||||
let mut new_lines = old_lines[1..].to_vec();
|
||||
new_lines.push(old_lines[0].clone());
|
||||
let old = format!("{}\n", old_lines.join("\n"));
|
||||
let new = format!("{}\n", new_lines.join("\n"));
|
||||
let rendered = render_diff(Some(&old), &new, None);
|
||||
|
||||
assert_eq!(rendered.len(), 1_002);
|
||||
assert!(rendered[..501].iter().all(|line| line.starts_with("- ")));
|
||||
assert!(rendered[501..].iter().all(|line| line.starts_with("+ ")));
|
||||
assert_eq!(render_diff(Some(&old), &new, Some(4)), rendered[..4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_is_not_a_transcript_row_and_status_is_two_lines() {
|
||||
let mut state = StructuredState::default();
|
||||
|
||||
@@ -289,7 +289,10 @@ impl StructuredState {
|
||||
Some((nonce, decision))
|
||||
}
|
||||
|
||||
pub fn approval_failed(&mut self, error: String) {
|
||||
pub fn approval_failed(&mut self, nonce: &str, error: String) {
|
||||
if self.pending_nonce() != Some(nonce) {
|
||||
return;
|
||||
}
|
||||
self.approval_submitting = false;
|
||||
self.error = Some(error);
|
||||
}
|
||||
@@ -554,6 +557,81 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_approval_ignores_its_late_failure() {
|
||||
let mut state = StructuredState::default();
|
||||
state.rows.push(row(
|
||||
"approval:1",
|
||||
TranscriptRowKind::Approval,
|
||||
json!({"options":[{"kind":"allow_once"}]}),
|
||||
));
|
||||
state.selected = Some("approval:1".into());
|
||||
state.reduced.pending_approval = Some(json!({"nonce":"old"}));
|
||||
assert!(state.approval_decision('a').is_some());
|
||||
|
||||
state.apply(StructuredEvent::ProjectionUpdate {
|
||||
transcript: None,
|
||||
state: ReducedState::default(),
|
||||
});
|
||||
state.approval_failed("old", "response lost".into());
|
||||
|
||||
assert_eq!(state.error, None);
|
||||
assert!(!state.approval_submitting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superseded_approval_ignores_its_late_failure() {
|
||||
let mut state = StructuredState::default();
|
||||
state.rows.push(row(
|
||||
"approval:1",
|
||||
TranscriptRowKind::Approval,
|
||||
json!({"options":[{"kind":"allow_once"}]}),
|
||||
));
|
||||
state.selected = Some("approval:1".into());
|
||||
state.reduced.pending_approval = Some(json!({"nonce":"old"}));
|
||||
assert!(state.approval_decision('a').is_some());
|
||||
|
||||
state.rows.push(row(
|
||||
"approval:2",
|
||||
TranscriptRowKind::Approval,
|
||||
json!({"options":[{"kind":"allow_once"}]}),
|
||||
));
|
||||
state.apply(StructuredEvent::ProjectionUpdate {
|
||||
transcript: None,
|
||||
state: ReducedState {
|
||||
pending_approval: Some(json!({"nonce":"new"})),
|
||||
..ReducedState::default()
|
||||
},
|
||||
});
|
||||
assert_eq!(
|
||||
state.approval_decision('a'),
|
||||
Some(("new".into(), StructuredApprovalDecision::AllowOnce))
|
||||
);
|
||||
|
||||
state.approval_failed("old", "response lost".into());
|
||||
|
||||
assert_eq!(state.error, None);
|
||||
assert!(state.approval_submitting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_approval_surfaces_its_failure() {
|
||||
let mut state = StructuredState::default();
|
||||
state.rows.push(row(
|
||||
"approval:1",
|
||||
TranscriptRowKind::Approval,
|
||||
json!({"options":[{"kind":"allow_once"}]}),
|
||||
));
|
||||
state.selected = Some("approval:1".into());
|
||||
state.reduced.pending_approval = Some(json!({"nonce":"current"}));
|
||||
assert!(state.approval_decision('a').is_some());
|
||||
|
||||
state.approval_failed("current", "request failed".into());
|
||||
|
||||
assert_eq!(state.error.as_deref(), Some("request failed"));
|
||||
assert!(!state.approval_submitting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_is_gated_by_the_projected_turn_lifecycle() {
|
||||
let mut state = StructuredState::default();
|
||||
|
||||
@@ -162,8 +162,10 @@ choices, not daemon ones.
|
||||
**A client decides:**
|
||||
|
||||
- How each row kind is drawn — markdown for `AgentText`/`AgentThought`, a
|
||||
diff view for `ToolDiff`, a tool card for `ToolCall`, a checklist for
|
||||
`Plan`, and a floor presentation for `Notice`.
|
||||
diff view for `ToolDiff`, a tool card for `ToolCall`, and a floor
|
||||
presentation for `Notice`. `Plan` is the exception: both clients omit it
|
||||
from the transcript entirely and render the current plan in a separate
|
||||
plan rail (web) / status line (TUI) instead.
|
||||
- How `responding` vs. `thinking` are distinguished visually, if at all.
|
||||
- How the operator is asked to resolve an `Approval` row — the daemon only
|
||||
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
|
||||
|
||||
@@ -6,6 +6,7 @@ const base: SpawnFormValues = {
|
||||
command: "claude",
|
||||
cwd: "/home/user/project",
|
||||
name: "amber-fox",
|
||||
structuredView: false,
|
||||
worktreeEnabled: false,
|
||||
worktreeBranch: "",
|
||||
worktreeBranchMode: "new",
|
||||
@@ -25,6 +26,22 @@ describe("buildSpawnRequest", () => {
|
||||
expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } });
|
||||
});
|
||||
|
||||
it("requests structured view only for Claude Code", () => {
|
||||
expect(buildSpawnRequest({ ...base, structuredView: true }, null)).toEqual({
|
||||
req: {
|
||||
cwd: base.cwd,
|
||||
command: "claude",
|
||||
metadata: { name: "amber-fox" },
|
||||
view: "Structured",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
buildSpawnRequest({ ...base, command: "opencode", structuredView: true }, null),
|
||||
).toEqual({
|
||||
req: { cwd: base.cwd, command: "opencode", metadata: { name: "amber-fox" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches a trimmed branch when worktree is enabled, and always cleans up", () => {
|
||||
const res = buildSpawnRequest(
|
||||
{ ...base, worktreeEnabled: true, worktreeBranch: " feat/foo " },
|
||||
|
||||
+28
-3
@@ -11,6 +11,7 @@ import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
||||
import { parentDir } from "./paths.js";
|
||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||
import type { ProjectGroup } from "./projectList.js";
|
||||
import { splitCommand } from "./toolIcon.js";
|
||||
import { abbreviateCwd } from "./types.js";
|
||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||
|
||||
@@ -40,6 +41,7 @@ export interface SpawnFormValues {
|
||||
command: string;
|
||||
cwd: string;
|
||||
name: string;
|
||||
structuredView: boolean;
|
||||
worktreeEnabled: boolean;
|
||||
worktreeBranch: string;
|
||||
worktreeBranchEdited: boolean;
|
||||
@@ -66,6 +68,10 @@ export function buildSpawnRequest(
|
||||
metadata,
|
||||
};
|
||||
|
||||
if (values.structuredView && splitCommand(values.command).tool === "claude") {
|
||||
req.view = "Structured";
|
||||
}
|
||||
|
||||
if (values.worktreeEnabled) {
|
||||
const mode = values.worktreeBranchMode ?? "new";
|
||||
const branch = values.worktreeBranch.trim();
|
||||
@@ -153,6 +159,10 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
<span class="wt-summary"></span>
|
||||
</button>
|
||||
<div class="wt-collapsible" hidden>
|
||||
<label class="row-inline structured-view" hidden>
|
||||
<input type="checkbox" name="structured_view" />
|
||||
Structured view
|
||||
</label>
|
||||
<div class="field wt-step" hidden>
|
||||
<span class="field-label">Worktree</span>
|
||||
<div class="picker wt-choices"></div>
|
||||
@@ -203,6 +213,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
const wtChoices = form.querySelector(".wt-choices") as HTMLElement;
|
||||
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
|
||||
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
|
||||
const structuredWrap = form.querySelector(".structured-view") as HTMLElement;
|
||||
const structuredView = form.querySelector("input[name=structured_view]") as HTMLInputElement;
|
||||
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
|
||||
const wtToggle = form.querySelector(".wt-toggle") as HTMLButtonElement;
|
||||
const wtCollapsible = form.querySelector(".wt-collapsible") as HTMLElement;
|
||||
@@ -248,6 +260,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
command,
|
||||
cwd,
|
||||
name: nameInput.value,
|
||||
structuredView: structuredView.checked,
|
||||
worktreeEnabled: wtEnabled.checked,
|
||||
worktreeBranch: branchValue(),
|
||||
worktreeBranchEdited: branchEdited,
|
||||
@@ -317,6 +330,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
} else {
|
||||
command = preset!.command;
|
||||
}
|
||||
updateStructuredAvailability();
|
||||
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
|
||||
}
|
||||
@@ -354,10 +368,19 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
// operator has already touched (#76).
|
||||
let commandTouched = false;
|
||||
|
||||
function updateStructuredAvailability(): void {
|
||||
const supported = splitCommand(command).tool === "claude";
|
||||
structuredWrap.hidden = !supported;
|
||||
if (!supported) structuredView.checked = false;
|
||||
}
|
||||
|
||||
structuredView.addEventListener("change", emit);
|
||||
|
||||
cmdRaw.addEventListener("input", () => {
|
||||
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
|
||||
commandTouched = true;
|
||||
command = cmdRaw.value.trim();
|
||||
updateStructuredAvailability();
|
||||
emit();
|
||||
}
|
||||
});
|
||||
@@ -844,10 +867,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
if (e.key !== "Enter" || e.defaultPrevented) return;
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
if (target === wtEnabled) {
|
||||
if (target === wtEnabled || target === structuredView) {
|
||||
e.preventDefault();
|
||||
wtEnabled.checked = !wtEnabled.checked;
|
||||
wtEnabled.dispatchEvent(new Event("change"));
|
||||
const checkbox = target as HTMLInputElement;
|
||||
checkbox.checked = !checkbox.checked;
|
||||
checkbox.dispatchEvent(new Event("change"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -885,6 +909,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
command,
|
||||
cwd,
|
||||
name: nameInput.value,
|
||||
structuredView: structuredView.checked,
|
||||
worktreeEnabled: wtEnabled.checked,
|
||||
worktreeBranch: branchValue(),
|
||||
worktreeBranchEdited: branchEdited,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||
|
||||
function stubApi(): RequestInit[] {
|
||||
const sessionRequests: RequestInit[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/api/sessions") && init?.method === "POST") {
|
||||
sessionRequests.push(init);
|
||||
return new Response(JSON.stringify({ id: "s1", command: "claude", cwd: "/srv/work" }), {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/info")) {
|
||||
return new Response(
|
||||
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user" }),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url.includes("/api/names")) {
|
||||
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify([]), { status: 200 });
|
||||
}),
|
||||
);
|
||||
return sessionRequests;
|
||||
}
|
||||
|
||||
describe("spawn form structured view", () => {
|
||||
let handle: SpawnFormHandle | null = null;
|
||||
|
||||
async function drain(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
await drain();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("is default-off and shown only for the resolved Claude Code command", async () => {
|
||||
stubApi();
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
|
||||
const wrap = handle.el.querySelector<HTMLElement>(".structured-view")!;
|
||||
const checkbox = wrap.querySelector<HTMLInputElement>("input")!;
|
||||
const disclosure = handle.el.querySelector<HTMLElement>(".wt-collapsible")!;
|
||||
expect(disclosure.hidden).toBe(true);
|
||||
handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
|
||||
expect(disclosure.hidden).toBe(false);
|
||||
expect(wrap.hidden).toBe(false);
|
||||
expect(checkbox.checked).toBe(false);
|
||||
|
||||
handle.el.querySelector<HTMLButtonElement>('[data-id="opencode"]')!.click();
|
||||
expect(wrap.hidden).toBe(true);
|
||||
|
||||
handle.el.querySelector<HTMLButtonElement>('[data-id="claude"]')!.click();
|
||||
expect(wrap.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("clears a checked value when the command changes away from Claude Code", async () => {
|
||||
stubApi();
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
|
||||
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
|
||||
checkbox.click();
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
|
||||
custom.click();
|
||||
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||
command.value = "opencode";
|
||||
command.dispatchEvent(new Event("input"));
|
||||
|
||||
expect(checkbox.closest<HTMLElement>(".structured-view")!.hidden).toBe(true);
|
||||
expect(checkbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("emits Structured in the submitted request when selected", 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))).toMatchObject({
|
||||
command: "claude",
|
||||
view: "Structured",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,22 @@ function row(id: string, kind: TranscriptRow["kind"], data: unknown): Transcript
|
||||
return { id, kind, data };
|
||||
}
|
||||
|
||||
function toolRow(id: string, text: string): TranscriptRow {
|
||||
return row(id, "tool_call", {
|
||||
title: `Read(${id})`,
|
||||
status: "completed",
|
||||
content: [{ type: "content", content: { type: "text", text } }],
|
||||
});
|
||||
}
|
||||
|
||||
function diffRow(id: string, newText: string): TranscriptRow {
|
||||
return row(id, "tool_diff", {
|
||||
title: `Edit(${id})`,
|
||||
status: "completed",
|
||||
content: [{ type: "diff", path: "file.ts", oldText: "before", newText }],
|
||||
});
|
||||
}
|
||||
|
||||
const idleState: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
|
||||
|
||||
const allApprovalOptions = [
|
||||
@@ -93,6 +109,121 @@ describe("structured connection: data flow", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("keeps snapshot plan rows out of the transcript while rendering the plan rail", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 3,
|
||||
rows: [
|
||||
row("user:1", "user", { content: { type: "text", text: "before plan" } }),
|
||||
row("plan", "plan", { entries: [{ content: "ship it", status: "pending" }] }),
|
||||
row("agent:1", "agent_text", { content: { type: "text", text: "after plan" } }),
|
||||
],
|
||||
},
|
||||
});
|
||||
ws.emit({
|
||||
type: "reduced_state",
|
||||
state: {
|
||||
...idleState,
|
||||
plan: { entries: [{ content: "ship it", priority: "high", status: "pending" }] },
|
||||
},
|
||||
});
|
||||
|
||||
const transcript = conn.el.querySelector(".structured-rows")!;
|
||||
expect(transcript.querySelectorAll(".st-row")).toHaveLength(2);
|
||||
expect(transcript.querySelector('[data-row-id="plan"]')).toBeNull();
|
||||
expect(transcript.textContent).toContain("before plan");
|
||||
expect(transcript.textContent).toContain("after plan");
|
||||
expect(transcript.textContent).not.toContain("[plan]");
|
||||
expect(conn.el.querySelector(".structured-rail")!.textContent).toContain("ship it");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("applies plan append, patch, and remove without disturbing surrounding rows", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 1,
|
||||
rows: [row("user:1", "user", { content: { type: "text", text: "keep me" } })],
|
||||
},
|
||||
});
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "append",
|
||||
row: row("plan", "plan", { entries: [{ content: "first", status: "pending" }] }),
|
||||
},
|
||||
state: {
|
||||
...idleState,
|
||||
plan: { entries: [{ content: "first", priority: "high", status: "pending" }] },
|
||||
},
|
||||
});
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "append",
|
||||
row: row("agent:1", "agent_text", { content: { type: "text", text: "still here" } }),
|
||||
},
|
||||
state: {
|
||||
...idleState,
|
||||
plan: { entries: [{ content: "first", priority: "high", status: "pending" }] },
|
||||
},
|
||||
});
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "patch",
|
||||
row: row("plan", "plan", { entries: [{ content: "updated", status: "completed" }] }),
|
||||
},
|
||||
state: {
|
||||
...idleState,
|
||||
plan: { entries: [{ content: "updated", priority: "high", status: "completed" }] },
|
||||
},
|
||||
});
|
||||
|
||||
const transcript = conn.el.querySelector(".structured-rows")!;
|
||||
expect([...transcript.querySelectorAll<HTMLElement>(".st-row")].map((el) => el.dataset.rowId)).toEqual([
|
||||
"user:1",
|
||||
"agent:1",
|
||||
]);
|
||||
expect(conn.el.querySelector(".structured-rail")!.textContent).toContain("updated");
|
||||
expect(conn.el.querySelector(".structured-rail")!.textContent).not.toContain("first");
|
||||
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: { type: "remove", id: "plan" },
|
||||
state: idleState,
|
||||
});
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "patch",
|
||||
row: row("agent:1", "agent_text", { content: { type: "text", text: "patched neighbor" } }),
|
||||
},
|
||||
state: idleState,
|
||||
});
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: { type: "remove", id: "user:1" },
|
||||
state: idleState,
|
||||
});
|
||||
|
||||
expect(conn.el.querySelector(".structured-rail .st-plan")).toBeNull();
|
||||
expect([...transcript.querySelectorAll<HTMLElement>(".st-row")].map((el) => el.dataset.rowId)).toEqual([
|
||||
"agent:1",
|
||||
]);
|
||||
expect(transcript.textContent).toContain("patched neighbor");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("appends and patches rows via projection_update without duplicating them", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
@@ -115,6 +246,113 @@ describe("structured connection: data flow", () => {
|
||||
expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("keeps an expanded running tool open without emitting a click when patched", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 1,
|
||||
rows: [
|
||||
row("tool:1", "tool_call", {
|
||||
title: "Bash(pnpm test)",
|
||||
status: "in_progress",
|
||||
content: [{ type: "content", content: { type: "text", text: "starting" } }],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
|
||||
const originalHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
|
||||
originalHead.click();
|
||||
expect(originalHead.getAttribute("aria-expanded")).toBe("true");
|
||||
const clickListener = vi.fn();
|
||||
window.addEventListener("click", clickListener, { capture: true, once: true });
|
||||
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "patch",
|
||||
row: row("tool:1", "tool_call", {
|
||||
title: "Bash(pnpm test)",
|
||||
status: "in_progress",
|
||||
content: [{ type: "content", content: { type: "text", text: "still running" } }],
|
||||
}),
|
||||
},
|
||||
state: idleState,
|
||||
});
|
||||
|
||||
expect(clickListener).not.toHaveBeenCalled();
|
||||
window.removeEventListener("click", clickListener, true);
|
||||
const patchedHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
|
||||
expect(patchedHead).not.toBe(originalHead);
|
||||
expect(patchedHead.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(conn.el.querySelector<HTMLElement>(".st-tool-out")!.hidden).toBe(false);
|
||||
expect(conn.el.textContent).toContain("still running");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("restores focus to a patched tool header with the same stable row id", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 1,
|
||||
rows: [row("tool:1", "tool_call", { title: "Read(file.ts)", status: "in_progress" })],
|
||||
},
|
||||
});
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
const originalHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
|
||||
originalHead.focus();
|
||||
expect(document.activeElement).toBe(originalHead);
|
||||
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "patch",
|
||||
row: row("tool:1", "tool_call", { title: "Read(file.ts)", status: "completed" }),
|
||||
},
|
||||
state: idleState,
|
||||
});
|
||||
|
||||
const patchedHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
|
||||
expect(patchedHead).not.toBe(originalHead);
|
||||
expect(document.activeElement).toBe(patchedHead);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("does not move unrelated focus when a tool row is patched", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 1,
|
||||
rows: [row("tool:1", "tool_call", { title: "Read(file.ts)", status: "in_progress" })],
|
||||
},
|
||||
});
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
||||
input.focus();
|
||||
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "patch",
|
||||
row: row("tool:1", "tool_call", { title: "Read(file.ts)", status: "completed" }),
|
||||
},
|
||||
state: idleState,
|
||||
});
|
||||
|
||||
expect(document.activeElement).toBe(input);
|
||||
conn.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("structured connection: turn/composer gating", () => {
|
||||
@@ -147,6 +385,8 @@ describe("structured connection: turn/composer gating", () => {
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "do the thing");
|
||||
expect(input.value).toBe("");
|
||||
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
|
||||
vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom"));
|
||||
input.value = "retry me";
|
||||
@@ -158,6 +398,103 @@ describe("structured connection: turn/composer gating", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("disables immediately and dispatches only once before begin-turn is projected", () => {
|
||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
const send = conn.el.querySelector(".structured-send") as HTMLButtonElement;
|
||||
const form = conn.el.querySelector(".structured-composer") as HTMLFormElement;
|
||||
input.value = "only once";
|
||||
|
||||
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
|
||||
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
|
||||
|
||||
expect(input.disabled).toBe(true);
|
||||
expect(send.disabled).toBe(true);
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("does not overwrite a newer operator draft when the matching request fails", async () => {
|
||||
let reject!: (error: unknown) => void;
|
||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(
|
||||
() => new Promise((_, rejectPromise) => { reject = rejectPromise; }),
|
||||
);
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
input.value = "first prompt";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
input.value = "newer draft";
|
||||
|
||||
reject(new Error("late failure"));
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
|
||||
expect(input.value).toBe("newer draft");
|
||||
expect(conn.el.textContent).toContain("late failure");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("ignores an older failure after a newer submission generation starts", async () => {
|
||||
let rejectFirst!: (error: unknown) => void;
|
||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(
|
||||
() => new Promise((_, rejectPromise) => { rejectFirst = rejectPromise; }),
|
||||
);
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
input.value = "first prompt";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
input.value = "second prompt";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
|
||||
rejectFirst(new Error("stale failure"));
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(2);
|
||||
expect(input.value).toBe("");
|
||||
expect(input.disabled).toBe(true);
|
||||
expect(conn.el.textContent).not.toContain("stale failure");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("ignores a submission failure after disposal", async () => {
|
||||
let reject!: (error: unknown) => void;
|
||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(
|
||||
() => new Promise((_, rejectPromise) => { reject = rejectPromise; }),
|
||||
);
|
||||
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;
|
||||
input.value = "do not restore";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
|
||||
conn.dispose();
|
||||
input.value = "detached draft";
|
||||
reject(new Error("late failure"));
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
|
||||
expect(input.value).toBe("detached draft");
|
||||
expect(conn.el.querySelector(".structured-error")).toBeNull();
|
||||
});
|
||||
|
||||
it("cancels the active turn via the cancel button", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
@@ -301,6 +638,8 @@ describe("structured connection: reconnection/error/loading", () => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("shows connecting, then connected, then reconnecting with backoff, and rebuilds from the fresh snapshot", () => {
|
||||
const first = new FakeSocket();
|
||||
const second = new FakeSocket();
|
||||
@@ -327,6 +666,258 @@ describe("structured connection: reconnection/error/loading", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("does not mount plan rows from a reconnect snapshot", () => {
|
||||
const first = new FakeSocket();
|
||||
const second = new FakeSocket();
|
||||
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
||||
first.open();
|
||||
first.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 1,
|
||||
rows: [row("agent:old", "agent_text", { content: { type: "text", text: "old" } })],
|
||||
},
|
||||
});
|
||||
|
||||
first.drop();
|
||||
vi.advanceTimersByTime(1000);
|
||||
second.open();
|
||||
second.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 4,
|
||||
rows: [
|
||||
row("user:new", "user", { content: { type: "text", text: "new" } }),
|
||||
row("plan", "plan", { entries: [{ content: "reconnected", status: "in_progress" }] }),
|
||||
],
|
||||
},
|
||||
});
|
||||
second.emit({
|
||||
type: "reduced_state",
|
||||
state: {
|
||||
...idleState,
|
||||
plan: { entries: [{ content: "reconnected", priority: "high", status: "in_progress" }] },
|
||||
},
|
||||
});
|
||||
|
||||
const transcript = conn.el.querySelector(".structured-rows")!;
|
||||
expect(transcript.textContent).toBe("Younew");
|
||||
expect(transcript.querySelector('[data-row-id="plan"]')).toBeNull();
|
||||
expect(conn.el.querySelector(".structured-rail")!.textContent).toContain("reconnected");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("keeps surviving expanded rows open across a reconnect snapshot and leaves fresh rows collapsed", () => {
|
||||
const first = new FakeSocket();
|
||||
const second = new FakeSocket();
|
||||
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
||||
first.open();
|
||||
first.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: { cursor: 2, rows: [toolRow("tool:1", "before"), diffRow("diff:1", "first")] },
|
||||
});
|
||||
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) head.click();
|
||||
|
||||
first.drop();
|
||||
vi.advanceTimersByTime(1000);
|
||||
second.open();
|
||||
second.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 3,
|
||||
rows: [toolRow("tool:1", "after"), diffRow("diff:1", "second"), toolRow("tool:2", "new")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
conn.el.querySelector('[data-row-id="tool:1"] .st-tool-head')?.getAttribute("aria-expanded"),
|
||||
).toBe("true");
|
||||
expect(
|
||||
conn.el.querySelector('[data-row-id="diff:1"] .st-tool-head')?.getAttribute("aria-expanded"),
|
||||
).toBe("true");
|
||||
expect(
|
||||
conn.el.querySelector('[data-row-id="tool:2"] .st-tool-head')?.getAttribute("aria-expanded"),
|
||||
).toBe("false");
|
||||
expect(conn.el.querySelector('[data-row-id="tool:1"] .st-tool-out')?.textContent).toContain("after");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("prunes expanded IDs removed or made non-expandable by a snapshot", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: { cursor: 2, rows: [toolRow("tool:gone", "gone"), toolRow("tool:plain", "plain")] },
|
||||
});
|
||||
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) head.click();
|
||||
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 3,
|
||||
rows: [row("tool:plain", "agent_text", { content: { type: "text", text: "not a tool" } })],
|
||||
},
|
||||
});
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: { cursor: 5, rows: [toolRow("tool:gone", "returned"), toolRow("tool:plain", "returned")] },
|
||||
});
|
||||
|
||||
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) {
|
||||
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||||
}
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("keeps a local submission gated across a reconnect until begin-turn arrives", () => {
|
||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
|
||||
const first = new FakeSocket();
|
||||
const second = new FakeSocket();
|
||||
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
||||
first.open();
|
||||
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
first.emit({ type: "reduced_state", state: idleState });
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
input.value = "survive reconnect";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
|
||||
first.drop();
|
||||
vi.advanceTimersByTime(1000);
|
||||
second.open();
|
||||
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
second.emit({ type: "reduced_state", state: idleState });
|
||||
expect(input.disabled).toBe(true);
|
||||
|
||||
second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||
second.emit({ type: "reduced_state", state: idleState });
|
||||
expect(input.disabled).toBe(false);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("releases an accepted submission from a post-acceptance idle snapshot", async () => {
|
||||
vi.mocked(sendStructuredPrompt).mockResolvedValueOnce(undefined);
|
||||
const first = new FakeSocket();
|
||||
const second = new FakeSocket();
|
||||
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
||||
first.open();
|
||||
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
first.emit({ type: "reduced_state", state: idleState });
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
input.value = "finishes while disconnected";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
|
||||
first.drop();
|
||||
vi.advanceTimersByTime(1000);
|
||||
second.open();
|
||||
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
second.emit({ type: "reduced_state", state: idleState });
|
||||
|
||||
expect(input.disabled).toBe(false);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("does not release from reconnect idle cached before submission acceptance", async () => {
|
||||
let accept!: () => void;
|
||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(
|
||||
() => new Promise((resolve) => { accept = resolve; }),
|
||||
);
|
||||
const first = new FakeSocket();
|
||||
const second = new FakeSocket();
|
||||
const third = new FakeSocket();
|
||||
const conn = mount({ sockets: [first, second, third], shouldReconnect: () => true });
|
||||
first.open();
|
||||
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
first.emit({ type: "reduced_state", state: idleState });
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
input.value = "accepted after reconnect";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
|
||||
first.drop();
|
||||
vi.advanceTimersByTime(1000);
|
||||
second.open();
|
||||
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
second.emit({ type: "reduced_state", state: idleState });
|
||||
expect(input.disabled).toBe(true);
|
||||
|
||||
accept();
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
|
||||
expect(input.disabled).toBe(true);
|
||||
expect(second.closed).toBe(true);
|
||||
|
||||
third.open();
|
||||
third.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
third.emit({ type: "reduced_state", state: idleState });
|
||||
expect(input.disabled).toBe(false);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("cancels pending backoff when acceptance forces reconciliation", async () => {
|
||||
let accept!: () => void;
|
||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(
|
||||
() => new Promise((resolve) => { accept = resolve; }),
|
||||
);
|
||||
const first = new FakeSocket();
|
||||
const second = new FakeSocket();
|
||||
const reconciliation = new FakeSocket();
|
||||
const duplicate = new FakeSocket();
|
||||
const conn = mount({
|
||||
sockets: [first, second, reconciliation, duplicate],
|
||||
shouldReconnect: () => true,
|
||||
});
|
||||
first.open();
|
||||
first.emit({ type: "reduced_state", state: idleState });
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
input.value = "accepted during backoff";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
|
||||
first.drop();
|
||||
vi.advanceTimersByTime(1000);
|
||||
second.drop();
|
||||
accept();
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
|
||||
expect(reconciliation.onopen).not.toBeNull();
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(duplicate.onopen).toBeNull();
|
||||
|
||||
reconciliation.open();
|
||||
reconciliation.emit({ type: "reduced_state", state: idleState });
|
||||
expect(input.disabled).toBe(false);
|
||||
conn.dispose();
|
||||
expect(reconciliation.closed).toBe(true);
|
||||
});
|
||||
|
||||
it("does not reconcile when submission acceptance arrives after disposal", async () => {
|
||||
let accept!: () => void;
|
||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(
|
||||
() => new Promise((resolve) => { accept = resolve; }),
|
||||
);
|
||||
const first = new FakeSocket();
|
||||
const second = new FakeSocket();
|
||||
const late = new FakeSocket();
|
||||
const conn = mount({ sockets: [first, second, late], shouldReconnect: () => true });
|
||||
first.open();
|
||||
first.emit({ type: "reduced_state", state: idleState });
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
input.value = "accepted after disposal";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
|
||||
first.drop();
|
||||
vi.advanceTimersByTime(1000);
|
||||
second.open();
|
||||
second.emit({ type: "reduced_state", state: idleState });
|
||||
conn.dispose();
|
||||
accept();
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
expect(second.closed).toBe(true);
|
||||
expect(late.onopen).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a daemon rejection reason and does not schedule a retry", () => {
|
||||
const only = new FakeSocket();
|
||||
const conn = mount({ sockets: [only], shouldReconnect: () => true });
|
||||
@@ -341,6 +932,298 @@ describe("structured connection: reconnection/error/loading", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("structured connection: transcript scrolling", () => {
|
||||
let frames: Map<number, FrameRequestCallback>;
|
||||
let nextFrame: number;
|
||||
let resizeCallback: ResizeObserverCallback;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
frames = new Map();
|
||||
nextFrame = 1;
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
||||
const id = nextFrame++;
|
||||
frames.set(id, cb);
|
||||
return id;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
|
||||
frames.delete(id);
|
||||
});
|
||||
vi.stubGlobal("ResizeObserver", class {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback;
|
||||
}
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function metrics(conn: ReturnType<typeof mount>, height: number, clientHeight: number, top = 0) {
|
||||
const scroll = conn.el.querySelector<HTMLElement>(".structured-scroll")!;
|
||||
const values = { height, clientHeight, top };
|
||||
Object.defineProperties(scroll, {
|
||||
scrollHeight: { configurable: true, get: () => values.height },
|
||||
clientHeight: { configurable: true, get: () => values.clientHeight },
|
||||
scrollTop: {
|
||||
configurable: true,
|
||||
get: () => values.top,
|
||||
set: (value: number) => { values.top = value; },
|
||||
},
|
||||
});
|
||||
return { scroll, values };
|
||||
}
|
||||
|
||||
function runFrames(): void {
|
||||
const pending = [...frames.entries()];
|
||||
frames.clear();
|
||||
for (const [, cb] of pending) cb(0);
|
||||
}
|
||||
|
||||
function resizeBody(width: number): void {
|
||||
resizeCallback(
|
||||
[{ contentRect: { width } } as ResizeObserverEntry],
|
||||
{} as ResizeObserver,
|
||||
);
|
||||
}
|
||||
|
||||
it("shows the bottom after the initial transcript snapshot is laid out", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
const { values } = metrics(conn, 900, 300, 40);
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 1,
|
||||
rows: [
|
||||
row("agent:1", "agent_text", { content: { type: "text", text: "latest" } }),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(values.top).toBe(40);
|
||||
runFrames();
|
||||
expect(values.top).toBe(600);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("keeps pinned appends and patches at the bottom after layout", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
const { values } = metrics(conn, 600, 200);
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
runFrames();
|
||||
|
||||
values.height = 720;
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "append",
|
||||
row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }),
|
||||
},
|
||||
state: idleState,
|
||||
});
|
||||
runFrames();
|
||||
expect(values.top).toBe(520);
|
||||
|
||||
values.height = 780;
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "patch",
|
||||
row: row("agent:1", "agent_text", {
|
||||
content: { type: "text", text: "a longer completion" },
|
||||
}),
|
||||
},
|
||||
state: idleState,
|
||||
});
|
||||
runFrames();
|
||||
expect(values.top).toBe(580);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ position: "pinned", top: 800, expected: 880, manuallyScrolled: false },
|
||||
{ position: "manually scrolled", top: 180, expected: 180, manuallyScrolled: true },
|
||||
])("preserves the $position position when the composer grows", ({ top, expected, manuallyScrolled }) => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
const { scroll, values } = metrics(conn, 1000, 200);
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
runFrames();
|
||||
|
||||
values.top = top;
|
||||
if (manuallyScrolled) scroll.dispatchEvent(new Event("scroll"));
|
||||
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
||||
Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 });
|
||||
input.value = "one\ntwo\nthree";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
values.height = 1080;
|
||||
runFrames();
|
||||
|
||||
expect(input.style.height).toBe("120px");
|
||||
expect(values.top).toBe(expected);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ position: "pinned", top: 800, expected: 850, manuallyScrolled: false },
|
||||
{ position: "manually scrolled", top: 180, expected: 180, manuallyScrolled: true },
|
||||
])("preserves the $position position across the narrow breakpoint", ({ top, expected, manuallyScrolled }) => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
const { scroll, values } = metrics(conn, 1000, 200);
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
runFrames();
|
||||
|
||||
values.top = top;
|
||||
if (manuallyScrolled) scroll.dispatchEvent(new Event("scroll"));
|
||||
resizeBody(500);
|
||||
values.clientHeight = 150;
|
||||
runFrames();
|
||||
|
||||
expect(conn.el.querySelector(".structured-body")!.classList).toContain("st-narrow");
|
||||
expect(values.top).toBe(expected);
|
||||
resizeBody(500);
|
||||
expect(frames.size).toBe(0);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("preserves a user-scrolled position across updates, approvals, and tool expansion", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
const { scroll, values } = metrics(conn, 1000, 200);
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 1, rows: [toolRow("tool:1", "output")] } });
|
||||
runFrames();
|
||||
|
||||
values.top = 180;
|
||||
scroll.dispatchEvent(new Event("scroll"));
|
||||
values.height = 1120;
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: { type: "append", row: row("agent:2", "agent_text", { content: { type: "text", text: "new" } }) },
|
||||
state: idleState,
|
||||
});
|
||||
runFrames();
|
||||
expect(values.top).toBe(180);
|
||||
|
||||
values.height = 1180;
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "append",
|
||||
row: row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions }),
|
||||
},
|
||||
state: {
|
||||
...idleState,
|
||||
pending_approval: {
|
||||
nonce: "n1",
|
||||
request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions },
|
||||
},
|
||||
},
|
||||
});
|
||||
runFrames();
|
||||
expect(values.top).toBe(180);
|
||||
|
||||
conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!.click();
|
||||
values.height = 1260;
|
||||
runFrames();
|
||||
expect(values.top).toBe(180);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("resumes following when the user scrolls back near the bottom", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
const { scroll, values } = metrics(conn, 1000, 200);
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
runFrames();
|
||||
|
||||
values.top = 750;
|
||||
scroll.dispatchEvent(new Event("scroll"));
|
||||
values.height = 1100;
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "append",
|
||||
row: row("agent:1", "agent_text", { content: { type: "text", text: "follow" } }),
|
||||
},
|
||||
state: idleState,
|
||||
});
|
||||
runFrames();
|
||||
expect(values.top).toBe(900);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("preserves a user-scrolled position across a reconnect snapshot", () => {
|
||||
const first = new FakeSocket();
|
||||
const second = new FakeSocket();
|
||||
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
||||
const { scroll, values } = metrics(conn, 1000, 200);
|
||||
first.open();
|
||||
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
runFrames();
|
||||
|
||||
values.top = 160;
|
||||
scroll.dispatchEvent(new Event("scroll"));
|
||||
first.drop();
|
||||
vi.advanceTimersByTime(1000);
|
||||
second.open();
|
||||
values.height = 1200;
|
||||
second.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 1,
|
||||
rows: [
|
||||
row("agent:1", "agent_text", {
|
||||
content: { type: "text", text: "reconnected" },
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
runFrames();
|
||||
expect(values.top).toBe(160);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("cancels a pending transcript frame when disposed or parked", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
const { values } = metrics(conn, 900, 300, 25);
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
expect(frames.size).toBe(1);
|
||||
|
||||
conn.onUnmount();
|
||||
expect(frames.size).toBe(0);
|
||||
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
||||
Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 });
|
||||
input.dispatchEvent(new Event("input"));
|
||||
resizeBody(500);
|
||||
expect(frames.size).toBe(0);
|
||||
conn.onMount({ focus: false });
|
||||
expect(frames.size).toBe(2);
|
||||
conn.dispose();
|
||||
expect(frames.size).toBe(0);
|
||||
resizeBody(700);
|
||||
expect(conn.el.querySelector(".structured-body")!.classList).toContain("st-narrow");
|
||||
runFrames();
|
||||
expect(values.top).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("structured connection: pane lifecycle", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
|
||||
+296
-67
@@ -80,6 +80,7 @@ export function createStructuredConnection(
|
||||
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
||||
const pillsEl = el.querySelector(".st-pills") as HTMLSpanElement;
|
||||
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
||||
const scrollEl = el.querySelector(".structured-scroll") as HTMLDivElement;
|
||||
const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement;
|
||||
const railEl = el.querySelector(".structured-rail") as HTMLElement;
|
||||
const bodyEl = el.querySelector(".structured-body") as HTMLElement;
|
||||
@@ -106,9 +107,75 @@ export function createStructuredConnection(
|
||||
let rows: TranscriptRow[] = [];
|
||||
const rowIndex = new Map<string, number>();
|
||||
const rowEls = new Map<string, HTMLElement>();
|
||||
const expandedRowIds = new Set<string>();
|
||||
let state: ReducedState = { turn: idleTurn() };
|
||||
let pendingApprovalRowId: string | null = null;
|
||||
let latestSession: Session | undefined = store?.snapshot().sessions.get(sessionId);
|
||||
let submissionGeneration = 0;
|
||||
let awaitingBeginGeneration: number | null = null;
|
||||
let acceptedSubmission: { generation: number; socketGeneration: number } | null = null;
|
||||
let socketGeneration = 0;
|
||||
let stateSocketGeneration = 0;
|
||||
let disposed = false;
|
||||
|
||||
// Within 64px, wheel and touch imprecision should still count as following.
|
||||
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
|
||||
let followingBottom = true;
|
||||
let receivedSnapshot = false;
|
||||
let scrollWorkActive = true;
|
||||
let parkedScrollTop: number | null = null;
|
||||
let scrollFrame: number | null = null;
|
||||
let pendingScroll: { top: number; follow: boolean } | null = null;
|
||||
|
||||
function cancelScrollFrame(): void {
|
||||
if (scrollFrame !== null) window.cancelAnimationFrame(scrollFrame);
|
||||
scrollFrame = null;
|
||||
pendingScroll = null;
|
||||
}
|
||||
|
||||
function beginTranscriptLayout(forceBottom = false): void {
|
||||
if (!scrollWorkActive || disposed) return;
|
||||
if (pendingScroll) {
|
||||
if (forceBottom) pendingScroll.follow = true;
|
||||
return;
|
||||
}
|
||||
pendingScroll = {
|
||||
top: scrollEl.scrollTop,
|
||||
follow: forceBottom || followingBottom,
|
||||
};
|
||||
}
|
||||
|
||||
function finishTranscriptLayout(): void {
|
||||
if (!scrollWorkActive || disposed || !pendingScroll || scrollFrame !== null) return;
|
||||
scrollFrame = window.requestAnimationFrame(() => {
|
||||
scrollFrame = null;
|
||||
const pending = pendingScroll;
|
||||
pendingScroll = null;
|
||||
if (!pending || disposed || !scrollWorkActive) return;
|
||||
scrollEl.scrollTop = pending.follow
|
||||
? Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight)
|
||||
: pending.top;
|
||||
});
|
||||
}
|
||||
|
||||
function updateTranscriptLayout(update: () => void, forceBottom = false): void {
|
||||
beginTranscriptLayout(forceBottom);
|
||||
update();
|
||||
finishTranscriptLayout();
|
||||
}
|
||||
|
||||
function onTranscriptScroll(): void {
|
||||
const distance = scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop;
|
||||
followingBottom = distance <= FOLLOW_BOTTOM_THRESHOLD_PX;
|
||||
cancelScrollFrame();
|
||||
}
|
||||
|
||||
function onTranscriptClick(event: Event): void {
|
||||
if ((event.target as Element | null)?.closest(".st-tool-head")) beginTranscriptLayout();
|
||||
}
|
||||
|
||||
scrollEl.addEventListener("scroll", onTranscriptScroll);
|
||||
scrollEl.addEventListener("click", onTranscriptClick, true);
|
||||
|
||||
function lastRowIdOfKind(kind: TranscriptRowKind): string | null {
|
||||
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
|
||||
@@ -138,61 +205,113 @@ export function createStructuredConnection(
|
||||
// request was in flight (superseded, cancelled, answered elsewhere),
|
||||
// the state-driven re-render already replaced the row; reviving
|
||||
// buttons on it would be reviving a decision that no longer applies.
|
||||
if (rowId === pendingApprovalRowId) {
|
||||
if (!disposed && rowId === pendingApprovalRowId) {
|
||||
showError(String(e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mountRow(row: TranscriptRow): void {
|
||||
const el = buildTranscriptRow(row, approvalStateFor(row));
|
||||
function buildRowElement(row: TranscriptRow): HTMLElement | null {
|
||||
const el = buildTranscriptRow(
|
||||
row,
|
||||
approvalStateFor(row),
|
||||
expandedRowIds.has(row.id),
|
||||
(expanded) => {
|
||||
if (expanded) expandedRowIds.add(row.id);
|
||||
else expandedRowIds.delete(row.id);
|
||||
finishTranscriptLayout();
|
||||
},
|
||||
);
|
||||
if (!el) return null;
|
||||
if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") {
|
||||
expandedRowIds.delete(row.id);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function insertRowElement(row: TranscriptRow, el: HTMLElement): void {
|
||||
const index = rowIndex.get(row.id) ?? rows.length - 1;
|
||||
let next: HTMLElement | null = null;
|
||||
for (let i = index + 1; i < rows.length; i++) {
|
||||
next = rowEls.get(rows[i].id) ?? null;
|
||||
if (next) break;
|
||||
}
|
||||
rowEls.set(row.id, el);
|
||||
rowsEl.appendChild(el);
|
||||
rowsEl.insertBefore(el, next);
|
||||
}
|
||||
|
||||
function mountRow(row: TranscriptRow): void {
|
||||
const el = buildRowElement(row);
|
||||
if (el) insertRowElement(row, el);
|
||||
}
|
||||
|
||||
function remountRow(row: TranscriptRow): void {
|
||||
const old = rowEls.get(row.id);
|
||||
const el = buildTranscriptRow(row, approvalStateFor(row));
|
||||
rowEls.set(row.id, el);
|
||||
if (old) old.replaceWith(el);
|
||||
else rowsEl.appendChild(el);
|
||||
const oldToolHead = old?.querySelector<HTMLButtonElement>(".st-tool-head") ?? null;
|
||||
const restoreFocus = document.activeElement === oldToolHead;
|
||||
const el = buildRowElement(row);
|
||||
if (!el) {
|
||||
old?.remove();
|
||||
rowEls.delete(row.id);
|
||||
expandedRowIds.delete(row.id);
|
||||
return;
|
||||
}
|
||||
if (old) {
|
||||
rowEls.set(row.id, el);
|
||||
old.replaceWith(el);
|
||||
} else {
|
||||
insertRowElement(row, el);
|
||||
}
|
||||
const newToolHead = el.querySelector<HTMLButtonElement>(".st-tool-head");
|
||||
if (restoreFocus) newToolHead?.focus();
|
||||
}
|
||||
|
||||
function applySnapshot(snapshot: { rows: TranscriptRow[] }): void {
|
||||
rows = [...snapshot.rows];
|
||||
rowIndex.clear();
|
||||
rows.forEach((row, i) => rowIndex.set(row.id, i));
|
||||
rowEls.clear();
|
||||
rowsEl.replaceChildren();
|
||||
for (const row of rows) mountRow(row);
|
||||
const forceBottom = !receivedSnapshot;
|
||||
updateTranscriptLayout(() => {
|
||||
rows = [...snapshot.rows];
|
||||
const expandableIds = new Set(
|
||||
rows.filter((row) => row.kind === "tool_call" || row.kind === "tool_diff").map((row) => row.id),
|
||||
);
|
||||
for (const id of expandedRowIds) if (!expandableIds.has(id)) expandedRowIds.delete(id);
|
||||
rowIndex.clear();
|
||||
rows.forEach((row, i) => rowIndex.set(row.id, i));
|
||||
rowEls.clear();
|
||||
rowsEl.replaceChildren();
|
||||
for (const row of rows) mountRow(row);
|
||||
}, forceBottom);
|
||||
receivedSnapshot = true;
|
||||
}
|
||||
|
||||
function applyDelta(delta: TranscriptDelta | null | undefined): void {
|
||||
if (!delta) return;
|
||||
if (delta.type === "append") {
|
||||
rowIndex.set(delta.row.id, rows.length);
|
||||
rows.push(delta.row);
|
||||
mountRow(delta.row);
|
||||
} else if (delta.type === "patch") {
|
||||
const i = rowIndex.get(delta.row.id);
|
||||
if (i === undefined) {
|
||||
updateTranscriptLayout(() => {
|
||||
if (delta.type === "append") {
|
||||
rowIndex.set(delta.row.id, rows.length);
|
||||
rows.push(delta.row);
|
||||
mountRow(delta.row);
|
||||
} else if (delta.type === "patch") {
|
||||
const i = rowIndex.get(delta.row.id);
|
||||
if (i === undefined) {
|
||||
rowIndex.set(delta.row.id, rows.length);
|
||||
rows.push(delta.row);
|
||||
mountRow(delta.row);
|
||||
} else {
|
||||
rows[i] = delta.row;
|
||||
remountRow(delta.row);
|
||||
}
|
||||
} else {
|
||||
rows[i] = delta.row;
|
||||
remountRow(delta.row);
|
||||
const i = rowIndex.get(delta.id);
|
||||
if (i === undefined) return;
|
||||
rows.splice(i, 1);
|
||||
expandedRowIds.delete(delta.id);
|
||||
rowIndex.clear();
|
||||
rows.forEach((row, idx) => rowIndex.set(row.id, idx));
|
||||
rowEls.get(delta.id)?.remove();
|
||||
rowEls.delete(delta.id);
|
||||
}
|
||||
} else {
|
||||
const i = rowIndex.get(delta.id);
|
||||
if (i === undefined) return;
|
||||
rows.splice(i, 1);
|
||||
rowIndex.clear();
|
||||
rows.forEach((row, idx) => rowIndex.set(row.id, idx));
|
||||
rowEls.get(delta.id)?.remove();
|
||||
rowEls.delete(delta.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toolRowCount(): number {
|
||||
@@ -217,20 +336,36 @@ export function createStructuredConnection(
|
||||
}
|
||||
|
||||
function renderComposerState(): void {
|
||||
const active = state.turn.responding || state.turn.thinking;
|
||||
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
||||
input.disabled = active;
|
||||
sendBtn.disabled = active;
|
||||
cancelBtn.hidden = !active;
|
||||
cancelBtn.hidden = !state.turn.responding && !state.turn.thinking;
|
||||
}
|
||||
|
||||
function applyState(next: ReducedState): void {
|
||||
function reconcileAwaitingBegin(): void {
|
||||
if (state.turn.responding || state.turn.thinking) {
|
||||
awaitingBeginGeneration = null;
|
||||
} else if (
|
||||
awaitingBeginGeneration !== null
|
||||
&& acceptedSubmission?.generation === awaitingBeginGeneration
|
||||
&& stateSocketGeneration > acceptedSubmission.socketGeneration
|
||||
) {
|
||||
awaitingBeginGeneration = null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyState(next: ReducedState, connectionGeneration: number): void {
|
||||
state = next;
|
||||
stateSocketGeneration = connectionGeneration;
|
||||
reconcileAwaitingBegin();
|
||||
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
|
||||
if (newPendingId !== pendingApprovalRowId) {
|
||||
const prev = pendingApprovalRowId;
|
||||
pendingApprovalRowId = newPendingId;
|
||||
if (prev !== null && rowIndex.has(prev)) remountRow(rows[rowIndex.get(prev)!]);
|
||||
if (newPendingId !== null) remountRow(rows[rowIndex.get(newPendingId)!]);
|
||||
updateTranscriptLayout(() => {
|
||||
const prev = pendingApprovalRowId;
|
||||
pendingApprovalRowId = newPendingId;
|
||||
if (prev !== null && rowIndex.has(prev)) remountRow(rows[rowIndex.get(prev)!]);
|
||||
if (newPendingId !== null) remountRow(rows[rowIndex.get(newPendingId)!]);
|
||||
});
|
||||
}
|
||||
renderPills();
|
||||
renderRailNow();
|
||||
@@ -254,8 +389,10 @@ export function createStructuredConnection(
|
||||
|
||||
// ---- composer ----
|
||||
function autoResize(): void {
|
||||
input.style.height = "auto";
|
||||
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
|
||||
updateTranscriptLayout(() => {
|
||||
input.style.height = "auto";
|
||||
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
|
||||
});
|
||||
}
|
||||
input.addEventListener("input", autoResize);
|
||||
input.addEventListener("keydown", (e) => {
|
||||
@@ -268,47 +405,112 @@ export function createStructuredConnection(
|
||||
e.preventDefault();
|
||||
const text = input.value;
|
||||
if (!text.trim() || input.disabled) return;
|
||||
const generation = ++submissionGeneration;
|
||||
const submissionSocketGeneration = socketGeneration;
|
||||
awaitingBeginGeneration = generation;
|
||||
acceptedSubmission = null;
|
||||
input.value = "";
|
||||
autoResize();
|
||||
clearError();
|
||||
sendStructuredPrompt(sessionId, text).catch((e: unknown) => {
|
||||
input.value = text;
|
||||
autoResize();
|
||||
showError(String(e));
|
||||
});
|
||||
renderComposerState();
|
||||
sendStructuredPrompt(sessionId, text).then(
|
||||
() => {
|
||||
if (
|
||||
disposed
|
||||
|| generation !== submissionGeneration
|
||||
|| awaitingBeginGeneration !== generation
|
||||
) return;
|
||||
const acceptanceSocketGeneration = socketGeneration;
|
||||
acceptedSubmission = { generation, socketGeneration: acceptanceSocketGeneration };
|
||||
// A replacement socket may have captured idle before the POST claimed
|
||||
// the prompt. Reconnect again so its snapshot cannot predate the 204.
|
||||
if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation();
|
||||
},
|
||||
(e: unknown) => {
|
||||
if (disposed || generation !== submissionGeneration) return;
|
||||
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
|
||||
if (input.value === "") {
|
||||
input.value = text;
|
||||
autoResize();
|
||||
}
|
||||
showError(String(e));
|
||||
renderComposerState();
|
||||
},
|
||||
);
|
||||
});
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
clearError();
|
||||
cancelStructuredTurn(sessionId).catch((e: unknown) => showError(String(e)));
|
||||
cancelStructuredTurn(sessionId).catch((e: unknown) => {
|
||||
if (!disposed) showError(String(e));
|
||||
});
|
||||
});
|
||||
|
||||
// ---- narrow-pane rail collapse (a split pane, not just the viewport) ----
|
||||
const NARROW_BREAKPOINT = 560;
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
if (disposed) return;
|
||||
const width = entries[0]?.contentRect.width ?? bodyEl.clientWidth;
|
||||
bodyEl.classList.toggle("st-narrow", width < NARROW_BREAKPOINT);
|
||||
const narrow = width < NARROW_BREAKPOINT;
|
||||
if (bodyEl.classList.contains("st-narrow") === narrow) return;
|
||||
updateTranscriptLayout(() => bodyEl.classList.toggle("st-narrow", narrow));
|
||||
});
|
||||
resizeObserver.observe(bodyEl);
|
||||
|
||||
// ---- live worktree/git-status rail data ----
|
||||
const unsubscribeStore = store?.subscribe((catalog: CatalogState) => {
|
||||
if (disposed) return;
|
||||
latestSession = catalog.sessions.get(sessionId);
|
||||
renderRailNow();
|
||||
});
|
||||
|
||||
// ---- socket lifecycle ----
|
||||
let ws: WebSocket;
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectMs = INITIAL_RECONNECT_MS;
|
||||
let reconnectTimer: number | null = null;
|
||||
let disposed = false;
|
||||
|
||||
function cancelReconnect(): void {
|
||||
if (reconnectTimer === null) return;
|
||||
window.clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
function closeCurrentSocket(): void {
|
||||
const socket = ws;
|
||||
ws = null;
|
||||
if (!socket) return;
|
||||
socket.onopen = null;
|
||||
socket.onmessage = null;
|
||||
socket.onerror = null;
|
||||
socket.onclose = null;
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function reconnectForReconciliation(): void {
|
||||
if (disposed) return;
|
||||
cancelReconnect();
|
||||
closeCurrentSocket();
|
||||
setConnState("reconnecting…", "connecting");
|
||||
connect();
|
||||
}
|
||||
|
||||
function connect(): void {
|
||||
ws = socketFactory(sessionId);
|
||||
ws.onopen = () => {
|
||||
if (disposed) return;
|
||||
cancelReconnect();
|
||||
closeCurrentSocket();
|
||||
const connectionGeneration = ++socketGeneration;
|
||||
const socket = socketFactory(sessionId);
|
||||
ws = socket;
|
||||
socket.onopen = () => {
|
||||
if (disposed || ws !== socket) return;
|
||||
reconnectMs = INITIAL_RECONNECT_MS;
|
||||
setConnState("connected", "open");
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
socket.onmessage = (ev) => {
|
||||
if (disposed || ws !== socket) return;
|
||||
if (typeof ev.data !== "string") return;
|
||||
let msg: StructuredEvent;
|
||||
try {
|
||||
@@ -321,28 +523,33 @@ export function createStructuredConnection(
|
||||
applySnapshot(msg.snapshot);
|
||||
break;
|
||||
case "reduced_state":
|
||||
applyState(msg.state);
|
||||
applyState(msg.state, connectionGeneration);
|
||||
break;
|
||||
case "projection_update":
|
||||
applyDelta(msg.transcript);
|
||||
applyState(msg.state);
|
||||
applyState(msg.state, connectionGeneration);
|
||||
break;
|
||||
}
|
||||
};
|
||||
ws.onerror = () => setConnState("error", "error");
|
||||
ws.onclose = (ev) => {
|
||||
socket.onerror = () => {
|
||||
if (disposed || ws !== socket) return;
|
||||
setConnState("error", "error");
|
||||
};
|
||||
socket.onclose = (ev) => {
|
||||
if (disposed || ws !== socket) return;
|
||||
ws = null;
|
||||
const reason = ev.reason?.trim();
|
||||
if (ev.code >= 4000 && reason && !disposed) {
|
||||
if (ev.code >= 4000 && reason) {
|
||||
setConnState(reason, "error");
|
||||
showError(`cannot attach: ${reason}`);
|
||||
return;
|
||||
}
|
||||
if (disposed) return;
|
||||
if (opts.shouldReconnect?.(sessionId, ev.code)) {
|
||||
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
|
||||
setConnState("reconnecting…", "connecting");
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
if (disposed) return;
|
||||
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
|
||||
connect();
|
||||
}, delay);
|
||||
@@ -360,6 +567,12 @@ export function createStructuredConnection(
|
||||
// `mountToken` is bumped on every mount/unmount so a stale frame can tell
|
||||
// it is no longer the most recent one.
|
||||
let mountToken = 0;
|
||||
let focusFrame: number | null = null;
|
||||
|
||||
function cancelFocusFrame(): void {
|
||||
if (focusFrame !== null) window.cancelAnimationFrame(focusFrame);
|
||||
focusFrame = null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
@@ -367,13 +580,26 @@ export function createStructuredConnection(
|
||||
onMount(mountOpts?: { focus?: boolean }) {
|
||||
const focusOnMount = mountOpts?.focus !== false;
|
||||
const token = ++mountToken;
|
||||
requestAnimationFrame(() => {
|
||||
if (token !== mountToken) return; // parked/remounted since this was scheduled
|
||||
scrollWorkActive = true;
|
||||
pendingScroll = {
|
||||
top: parkedScrollTop ?? scrollEl.scrollTop,
|
||||
follow: followingBottom,
|
||||
};
|
||||
parkedScrollTop = null;
|
||||
finishTranscriptLayout();
|
||||
cancelFocusFrame();
|
||||
focusFrame = window.requestAnimationFrame(() => {
|
||||
focusFrame = null;
|
||||
if (disposed || token !== mountToken) return; // parked/remounted since this was scheduled
|
||||
if (focusOnMount && !input.disabled) input.focus();
|
||||
});
|
||||
},
|
||||
onUnmount() {
|
||||
mountToken++;
|
||||
parkedScrollTop = pendingScroll?.top ?? scrollEl.scrollTop;
|
||||
scrollWorkActive = false;
|
||||
cancelScrollFrame();
|
||||
cancelFocusFrame();
|
||||
},
|
||||
focusTerminal() {
|
||||
if (!input.disabled) input.focus();
|
||||
@@ -385,14 +611,17 @@ export function createStructuredConnection(
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer);
|
||||
mountToken++;
|
||||
scrollWorkActive = false;
|
||||
cancelScrollFrame();
|
||||
cancelFocusFrame();
|
||||
cancelReconnect();
|
||||
resizeObserver.disconnect();
|
||||
unsubscribeStore?.();
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
closeCurrentSocket();
|
||||
expandedRowIds.clear();
|
||||
scrollEl.removeEventListener("scroll", onTranscriptScroll);
|
||||
scrollEl.removeEventListener("click", onTranscriptClick, true);
|
||||
el.remove();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -42,6 +42,61 @@ describe("computeDiffLines", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles an insertion without changing surrounding lines", () => {
|
||||
expect(texts(computeDiffLines("a\nc\n", "a\nb\nc\n"))).toEqual([
|
||||
["ctx", "a"],
|
||||
["add", "b"],
|
||||
["ctx", "c"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles a deletion without changing surrounding lines", () => {
|
||||
expect(texts(computeDiffLines("a\nb\nc\n", "a\nc\n"))).toEqual([
|
||||
["ctx", "a"],
|
||||
["del", "b"],
|
||||
["ctx", "c"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders two empty files without invented lines", () => {
|
||||
expect(computeDiffLines("", "")).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows when a final newline is added", () => {
|
||||
expect(texts(computeDiffLines("a", "a\n"))).toEqual([
|
||||
["del", "a"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
["add", "a"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows when a final newline is removed", () => {
|
||||
expect(texts(computeDiffLines("a\n", "a"))).toEqual([
|
||||
["del", "a"],
|
||||
["add", "a"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks an unchanged unterminated final context line", () => {
|
||||
expect(texts(computeDiffLines("a\nb\nc", "a\nX\nc"))).toEqual([
|
||||
["ctx", "a"],
|
||||
["del", "b"],
|
||||
["add", "X"],
|
||||
["ctx", "c"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks an unterminated new file only once", () => {
|
||||
const lines = computeDiffLines(null, "a");
|
||||
expect(texts(lines)).toEqual([
|
||||
["add", "a"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
]);
|
||||
expect(lines.filter((line) => line.kind === "eof")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("collapses a long unchanged interior run, keeping 3 lines of context each side", () => {
|
||||
const common = Array.from({ length: 20 }, (_, i) => `line${i}`);
|
||||
const oldText = [...common.slice(0, 10), "OLD", ...common.slice(10)].join("\n");
|
||||
@@ -59,6 +114,22 @@ describe("computeDiffLines", () => {
|
||||
expect(lines.filter((l) => l.kind === "hunk")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("preserves the EOF marker when a long unterminated trailing run collapses", () => {
|
||||
const trailing = Array.from({ length: 10 }, (_, i) => `t${i}`);
|
||||
const oldText = ["OLD", ...trailing].join("\n");
|
||||
const newText = ["NEW", ...trailing].join("\n");
|
||||
|
||||
expect(texts(computeDiffLines(oldText, newText))).toEqual([
|
||||
["del", "OLD"],
|
||||
["add", "NEW"],
|
||||
["ctx", "t0"],
|
||||
["ctx", "t1"],
|
||||
["ctx", "t2"],
|
||||
["hunk", "⋯ 7 unchanged lines ⋯"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("produces one hunk for the long middle run, none for the short edges", () => {
|
||||
// Edge runs (2 lines) fit within their one-sided 3-line context budget;
|
||||
// only the interior run (10 lines, budgeted 3+3=6) needs to collapse.
|
||||
@@ -75,9 +146,10 @@ describe("computeDiffLines", () => {
|
||||
["ctx", "lead0"],
|
||||
["ctx", "lead1"],
|
||||
]);
|
||||
expect(texts(lines).slice(-2)).toEqual([
|
||||
expect(texts(lines).slice(-3)).toEqual([
|
||||
["ctx", "trail0"],
|
||||
["ctx", "trail1"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -85,10 +157,22 @@ describe("computeDiffLines", () => {
|
||||
const lines = computeDiffLines("a\nb", "a\nb\nc");
|
||||
expect(texts(lines)).toEqual([
|
||||
["ctx", "a"],
|
||||
["ctx", "b"],
|
||||
["del", "b"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
["add", "b"],
|
||||
["add", "c"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to a bounded coarse diff for a pathological changed middle", () => {
|
||||
const oldLines = Array.from({ length: 501 }, (_, index) => `line${index}`);
|
||||
const newLines = [...oldLines.slice(1), oldLines[0]];
|
||||
const lines = computeDiffLines(`${oldLines.join("\n")}\n`, `${newLines.join("\n")}\n`);
|
||||
expect(lines).toHaveLength(1_002);
|
||||
expect(lines.slice(0, 501).every((line) => line.kind === "del")).toBe(true);
|
||||
expect(lines.slice(501).every((line) => line.kind === "add")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffStats", () => {
|
||||
|
||||
+57
-31
@@ -2,7 +2,7 @@
|
||||
// hands over full `oldText`/`newText`, not a unified-diff patch, so the
|
||||
// coloured hunks the design calls for have to be computed here.
|
||||
|
||||
export type DiffLineKind = "add" | "del" | "ctx" | "hunk";
|
||||
export type DiffLineKind = "add" | "del" | "ctx" | "hunk" | "eof";
|
||||
|
||||
export interface DiffLine {
|
||||
kind: DiffLineKind;
|
||||
@@ -19,66 +19,82 @@ const CONTEXT = 3;
|
||||
* still correct, just not minimal. */
|
||||
const LCS_CELL_LIMIT = 250_000;
|
||||
|
||||
function splitLines(text: string): string[] {
|
||||
if (text === "") return [];
|
||||
const body = text.endsWith("\n") ? text.slice(0, -1) : text;
|
||||
return body.split("\n");
|
||||
interface SourceLine {
|
||||
text: string;
|
||||
terminated: boolean;
|
||||
}
|
||||
|
||||
type Op = { kind: "add" | "del" | "ctx"; text: string };
|
||||
function splitLines(text: string): SourceLine[] {
|
||||
if (text === "") return [];
|
||||
const parts = text.split("\n");
|
||||
const hasFinalNewline = text.endsWith("\n");
|
||||
if (hasFinalNewline) parts.pop();
|
||||
return parts.map((line, index) => ({
|
||||
text: line,
|
||||
terminated: index < parts.length - 1 || hasFinalNewline,
|
||||
}));
|
||||
}
|
||||
|
||||
type Op = { kind: "add" | "del" | "ctx"; line: SourceLine };
|
||||
|
||||
function sameLine(a: SourceLine, b: SourceLine): boolean {
|
||||
return a.text === b.text && a.terminated === b.terminated;
|
||||
}
|
||||
|
||||
/** Longest-common-subsequence diff of two line arrays, already trimmed of any
|
||||
* shared prefix/suffix by the caller. */
|
||||
function lcsDiff(a: string[], b: string[]): Op[] {
|
||||
function lcsDiff(a: SourceLine[], b: SourceLine[]): Op[] {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
if (n === 0) return b.map((text) => ({ kind: "add", text }));
|
||||
if (m === 0) return a.map((text) => ({ kind: "del", text }));
|
||||
if (n === 0) return b.map((line) => ({ kind: "add", line }));
|
||||
if (m === 0) return a.map((line) => ({ kind: "del", line }));
|
||||
if (n * m > LCS_CELL_LIMIT) {
|
||||
return [...a.map((text) => ({ kind: "del" as const, text })), ...b.map((text) => ({ kind: "add" as const, text }))];
|
||||
return [...a.map((line) => ({ kind: "del" as const, line })), ...b.map((line) => ({ kind: "add" as const, line }))];
|
||||
}
|
||||
// dp[i][j] = length of the LCS of a[i:] and b[j:].
|
||||
const dp: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
||||
const columns = m + 1;
|
||||
const dp = new Uint32Array((n + 1) * columns);
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
dp[i][j] =
|
||||
a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
dp[i * columns + j] = sameLine(a[i], b[j])
|
||||
? dp[(i + 1) * columns + j + 1] + 1
|
||||
: Math.max(dp[(i + 1) * columns + j], dp[i * columns + j + 1]);
|
||||
}
|
||||
}
|
||||
const ops: Op[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
ops.push({ kind: "ctx", text: a[i] });
|
||||
if (sameLine(a[i], b[j])) {
|
||||
ops.push({ kind: "ctx", line: a[i] });
|
||||
i++;
|
||||
j++;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
ops.push({ kind: "del", text: a[i] });
|
||||
} else if (dp[(i + 1) * columns + j] >= dp[i * columns + j + 1]) {
|
||||
ops.push({ kind: "del", line: a[i] });
|
||||
i++;
|
||||
} else {
|
||||
ops.push({ kind: "add", text: b[j] });
|
||||
ops.push({ kind: "add", line: b[j] });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < n) ops.push({ kind: "del", text: a[i++] });
|
||||
while (j < m) ops.push({ kind: "add", text: b[j++] });
|
||||
while (i < n) ops.push({ kind: "del", line: a[i++] });
|
||||
while (j < m) ops.push({ kind: "add", line: b[j++] });
|
||||
return ops;
|
||||
}
|
||||
|
||||
function diffLines(a: string[], b: string[]): Op[] {
|
||||
function diffLines(a: SourceLine[], b: SourceLine[]): Op[] {
|
||||
let pre = 0;
|
||||
while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++;
|
||||
while (pre < a.length && pre < b.length && sameLine(a[pre], b[pre])) pre++;
|
||||
let suf = 0;
|
||||
while (
|
||||
suf < a.length - pre &&
|
||||
suf < b.length - pre &&
|
||||
a[a.length - 1 - suf] === b[b.length - 1 - suf]
|
||||
sameLine(a[a.length - 1 - suf], b[b.length - 1 - suf])
|
||||
) {
|
||||
suf++;
|
||||
}
|
||||
const prefixCtx: Op[] = a.slice(0, pre).map((text) => ({ kind: "ctx", text }));
|
||||
const suffixCtx: Op[] = a.slice(a.length - suf).map((text) => ({ kind: "ctx", text }));
|
||||
const prefixCtx: Op[] = a.slice(0, pre).map((line) => ({ kind: "ctx", line }));
|
||||
const suffixCtx: Op[] = a.slice(a.length - suf).map((line) => ({ kind: "ctx", line }));
|
||||
const middle = lcsDiff(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf));
|
||||
return [...prefixCtx, ...middle, ...suffixCtx];
|
||||
}
|
||||
@@ -91,10 +107,16 @@ function diffLines(a: string[], b: string[]): Op[] {
|
||||
* (unless it is the *only* run, i.e. nothing changed at all). */
|
||||
function hunk(ops: Op[]): DiffLine[] {
|
||||
const out: DiffLine[] = [];
|
||||
const push = (op: Op) => {
|
||||
out.push({ kind: op.kind, text: op.line.text });
|
||||
if (!op.line.terminated) {
|
||||
out.push({ kind: "eof", text: "\\ No newline at end of file" });
|
||||
}
|
||||
};
|
||||
let i = 0;
|
||||
while (i < ops.length) {
|
||||
if (ops[i].kind !== "ctx") {
|
||||
out.push(ops[i]);
|
||||
push(ops[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
@@ -105,14 +127,18 @@ function hunk(ops: Op[]): DiffLine[] {
|
||||
const keepHead = wholeDiff || i > 0 ? CONTEXT : 0;
|
||||
const keepTail = wholeDiff || j < ops.length ? CONTEXT : 0;
|
||||
if (run.length <= keepHead + keepTail) {
|
||||
out.push(...run);
|
||||
run.forEach(push);
|
||||
} else {
|
||||
if (keepHead) out.push(...run.slice(0, keepHead));
|
||||
if (keepHead) run.slice(0, keepHead).forEach(push);
|
||||
const collapsed = run.slice(keepHead, run.length - keepTail);
|
||||
out.push({
|
||||
kind: "hunk",
|
||||
text: `⋯ ${run.length - keepHead - keepTail} unchanged lines ⋯`,
|
||||
text: `⋯ ${collapsed.length} unchanged lines ⋯`,
|
||||
});
|
||||
if (keepTail) out.push(...run.slice(run.length - keepTail));
|
||||
if (collapsed.some((op) => !op.line.terminated)) {
|
||||
out.push({ kind: "eof", text: "\\ No newline at end of file" });
|
||||
}
|
||||
if (keepTail) run.slice(run.length - keepTail).forEach(push);
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
@@ -123,7 +149,7 @@ function hunk(ops: Op[]): DiffLine[] {
|
||||
* (a new file) renders every line of `newText` as added. */
|
||||
export function computeDiffLines(oldText: string | null | undefined, newText: string): DiffLine[] {
|
||||
const newLines = splitLines(newText);
|
||||
if (oldText == null) return newLines.map((text) => ({ kind: "add", text }));
|
||||
if (oldText == null) return hunk(newLines.map((line) => ({ kind: "add", line })));
|
||||
const ops = diffLines(splitLines(oldText), newLines);
|
||||
return hunk(ops);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
} from "./structuredRender.js";
|
||||
import type { Session, TranscriptRow } from "./types.js";
|
||||
|
||||
function row(kind: TranscriptRow["kind"], data: unknown, id = "r1"): TranscriptRow {
|
||||
function row<K extends TranscriptRow["kind"]>(
|
||||
kind: K,
|
||||
data: unknown,
|
||||
id = "r1",
|
||||
): TranscriptRow & { kind: K } {
|
||||
return { id, kind, data };
|
||||
}
|
||||
|
||||
@@ -157,6 +161,17 @@ describe("buildTranscriptRow: tool diffs", () => {
|
||||
// stylesheet keys `overflow-x: auto` off, not a computed style.
|
||||
expect(el.querySelector(".st-diff")!.className).toBe("st-diff");
|
||||
});
|
||||
|
||||
it("renders a visible marker for a removed final newline", () => {
|
||||
const el = buildTranscriptRow(
|
||||
row("tool_diff", {
|
||||
title: "Edit(x.rs)",
|
||||
content: [{ type: "diff", path: "/repo/x.rs", oldText: "same\n", newText: "same" }],
|
||||
}),
|
||||
);
|
||||
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||||
expect(el.querySelector(".st-ln-eof")!.textContent).toBe("\\ No newline at end of file");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTranscriptRow: approval", () => {
|
||||
@@ -264,9 +279,16 @@ describe("buildTranscriptRow: approval", () => {
|
||||
});
|
||||
|
||||
describe("buildTranscriptRow: notice / unknown kinds", () => {
|
||||
it("renders a plan or notice row without crashing", () => {
|
||||
expect(() => buildTranscriptRow(row("notice", { sessionUpdate: "future" }))).not.toThrow();
|
||||
expect(() => buildTranscriptRow(row("plan", { entries: [] }))).not.toThrow();
|
||||
it("renders notice and unknown row kinds safely", () => {
|
||||
expect(buildTranscriptRow(row("notice", { sessionUpdate: "future" }))?.textContent).toBe(
|
||||
"[notice]",
|
||||
);
|
||||
const unknown = { id: "r1", kind: "future_kind", data: {} } as unknown as TranscriptRow;
|
||||
expect(buildTranscriptRow(unknown)?.textContent).toBe("[future_kind]");
|
||||
});
|
||||
|
||||
it("returns no transcript element for a plan row", () => {
|
||||
expect(buildTranscriptRow(row("plan", { entries: [] }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+44
-10
@@ -187,7 +187,11 @@ function statusMeta(status: string | undefined, hasDiff: boolean): { text: strin
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildToolRow(row: TranscriptRow): HTMLElement {
|
||||
function buildToolRow(
|
||||
row: TranscriptRow,
|
||||
expanded: boolean,
|
||||
onExpandedChange?: (expanded: boolean) => void,
|
||||
): HTMLElement {
|
||||
const tool = (row.data ?? {}) as AcpToolCallLike;
|
||||
const content = tool.content ?? [];
|
||||
const hasDiff = content.some((c) => c.type === "diff");
|
||||
@@ -251,13 +255,17 @@ function buildToolRow(row: TranscriptRow): HTMLElement {
|
||||
};
|
||||
card.appendChild(out);
|
||||
|
||||
const setExpanded = (nextExpanded: boolean) => {
|
||||
if (nextExpanded) buildOut();
|
||||
if (out.children.length === 0) nextExpanded = false;
|
||||
head.setAttribute("aria-expanded", String(nextExpanded));
|
||||
out.hidden = !nextExpanded;
|
||||
};
|
||||
head.addEventListener("click", () => {
|
||||
const expanded = head.getAttribute("aria-expanded") === "true";
|
||||
if (!expanded) buildOut();
|
||||
if (out.children.length === 0) return; // nothing to show — stay collapsed
|
||||
head.setAttribute("aria-expanded", String(!expanded));
|
||||
out.hidden = expanded;
|
||||
setExpanded(head.getAttribute("aria-expanded") !== "true");
|
||||
onExpandedChange?.(head.getAttribute("aria-expanded") === "true");
|
||||
});
|
||||
setExpanded(expanded);
|
||||
|
||||
return wrap;
|
||||
}
|
||||
@@ -359,9 +367,33 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** Build the DOM for one transcript row. `approval` is consulted only for an
|
||||
* `approval` row; pass it whenever the row might be that kind. */
|
||||
export function buildTranscriptRow(row: TranscriptRow, approval?: ApprovalRowState): HTMLElement {
|
||||
/** Build the DOM for one transcript row, or `null` for a plan shown only in
|
||||
* the rail. `approval` is consulted only for an `approval` row; tool
|
||||
* expansion is supplied and reported explicitly. */
|
||||
export function buildTranscriptRow(
|
||||
row: TranscriptRow & { kind: "plan" },
|
||||
approval?: ApprovalRowState,
|
||||
toolExpanded?: boolean,
|
||||
onToolExpandedChange?: (expanded: boolean) => void,
|
||||
): null;
|
||||
export function buildTranscriptRow(
|
||||
row: TranscriptRow & { kind: Exclude<TranscriptRow["kind"], "plan"> },
|
||||
approval?: ApprovalRowState,
|
||||
toolExpanded?: boolean,
|
||||
onToolExpandedChange?: (expanded: boolean) => void,
|
||||
): HTMLElement;
|
||||
export function buildTranscriptRow(
|
||||
row: TranscriptRow,
|
||||
approval?: ApprovalRowState,
|
||||
toolExpanded?: boolean,
|
||||
onToolExpandedChange?: (expanded: boolean) => void,
|
||||
): HTMLElement | null;
|
||||
export function buildTranscriptRow(
|
||||
row: TranscriptRow,
|
||||
approval?: ApprovalRowState,
|
||||
toolExpanded = false,
|
||||
onToolExpandedChange?: (expanded: boolean) => void,
|
||||
): HTMLElement | null {
|
||||
let built: HTMLElement;
|
||||
switch (row.kind) {
|
||||
case "user":
|
||||
@@ -375,11 +407,13 @@ export function buildTranscriptRow(row: TranscriptRow, approval?: ApprovalRowSta
|
||||
break;
|
||||
case "tool_call":
|
||||
case "tool_diff":
|
||||
built = buildToolRow(row);
|
||||
built = buildToolRow(row, toolExpanded, onToolExpandedChange);
|
||||
break;
|
||||
case "approval":
|
||||
built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() });
|
||||
break;
|
||||
case "plan":
|
||||
return null;
|
||||
default:
|
||||
built = buildPlainRow(row);
|
||||
break;
|
||||
|
||||
@@ -2509,6 +2509,11 @@ input.leaf-rename:focus {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.st-ln-eof {
|
||||
color: var(--faint);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* the one lifted, card-like element on the surface: the only thing the
|
||||
operator owes a decision on */
|
||||
.st-approval {
|
||||
|
||||
@@ -276,6 +276,7 @@ export interface CreateSessionRequest {
|
||||
env?: Record<string, string>;
|
||||
metadata?: Record<string, unknown>;
|
||||
worktree?: WorktreeSpec;
|
||||
view?: SessionView;
|
||||
}
|
||||
|
||||
export type CatalogEvent =
|
||||
|
||||
Reference in New Issue
Block a user