Compare commits

..
27 Commits
Author SHA1 Message Date
Miguel PalhasandClaude Opus 4.6 ed5a088299 feat(web): equal-width project cards, drop worktree counters
Project boxes now use a fixed two-column grid so every card is the
same width. The worktree count line is removed — the information
lives in the worktree picker that follows a project selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 20:48:43 +01:00
Miguel PalhasandClaude Opus 4.6 65d7186e1c feat(web): harness cards with tool logos lead the spawn dialog
ci / rust (push) Successful in 8m28s
ci / web (push) Successful in 25s
ci / flake (push) Successful in 3m27s
Move the harness/command choice above the project picker so the
operator decides what runs before where. Replace the flat command
tiles with larger centered cards that render each tool's icon from
toolIcon.ts at 24x24. The Custom escape hatch gets a three-dot
ellipsis icon and a dashed border to read as secondary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 20:29:35 +01:00
Miguel PalhasandClaude Opus 4.6 47654aa434 feat(web): gate sidebar hover preview on ctrl
ci / rust (push) Failing after 5s
ci / web (push) Successful in 26s
ci / flake (push) Successful in 3m0s
Plain hover no longer previews — ctrl+hover does.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 15:22:58 +01:00
Miguel PalhasandClaude Opus 4.6 6ca2ab1707 merge: resolve conflict with main's prompt queue
ci / web (push) Successful in 30s
ci / flake (push) Successful in 3m38s
ci / rust (push) Successful in 6m18s
Keep icon cancel button, always-"Send" label, and queue status span
from main. Drop unused `active` local after label simplification.
Update test expectation to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 14:35:47 +01:00
Miguel PalhasandClaude Opus 4.6 b0b0160ccb feat(web): align composer and activity to content width, icon cancel button
Move max-width constraint from .structured-rows up to .structured-main
so the activity banner and composer match the transcript width. Replace
text cancel button with a stop-sign icon inside a spinning ring. Drop
the accent border-top on composer focus.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 14:32:56 +01:00
naps62 c4cd8a691e Merge pull request 'fix(web): queue structured prompts' (#204) from quaver into main
ci / web (push) Successful in 30s
ci / flake (push) Successful in 3m46s
ci / rust (push) Successful in 6m33s
Reviewed-on: #204
2026-09-07 12:28:49 +01:00
Miguel Palhas 0381a571f2 Merge remote-tracking branch 'origin/main' into brillante
ci / web (push) Failing after 6s
ci / flake (push) Successful in 4m23s
ci / rust (push) Successful in 7m8s
2026-09-07 11:42:28 +01:00
Miguel PalhasandClaude Opus 4.6 8f3d47c49e feat(web): absorb intermediate agent text into tool groups
Intermediate agent text/thought rows between tool calls in the same
turn now render inside the collapsed tool group instead of after it,
preserving chronological order. The last agent text (no subsequent
tool) stays outside as the prominent final answer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 11:42:25 +01:00
Miguel Palhas ba0c54759b fix(tui): complete test presets
ci / web (pull_request) Successful in 27s
ci / flake (pull_request) Successful in 47s
ci / rust (pull_request) Successful in 7m3s
2026-09-07 11:39:51 +01:00
Miguel PalhasandClaude Opus 4.6 78912e7925 fix(tui): add missing auto_approve field in draft test
ci / web (push) Successful in 27s
ci / flake (push) Successful in 3m30s
ci / rust (push) Successful in 6m1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 11:32:32 +01:00
Miguel PalhasandClaude Opus 4.6 fef9be6c01 feat(web): cap structured view width for readability
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 11:30:52 +01:00
Miguel Palhas 5387b29049 Merge remote-tracking branch 'origin/main' into quaver
ci / web (pull_request) Successful in 30s
ci / flake (pull_request) Successful in 49s
ci / rust (pull_request) Failing after 52s
2026-09-07 11:25:33 +01:00
Miguel PalhasandClaude Opus 4.6 88afd0df3f feat(maestro): auto-approve ACP permissions via preset config
ci / web (push) Failing after 6s
ci / rust (push) Failing after 51s
ci / flake (push) Successful in 2m41s
Structured sessions ignored the --dangerously-skip-permissions flags in
preset commands because the daemon discards CLI flags when spawning the
ACP adapter binary. This adds an `auto_approve` bool to SpawnPreset and
CreateSessionRequest so the operator can declare it in config.toml:

  { id = "claude", command = "claude", auto_approve = true }

When set, the daemon's permission pump immediately responds AllowAlways
to every ACP permission request without surfacing it to clients.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 11:19:24 +01:00
Miguel Palhas df51aa7be9 Merge remote-tracking branch 'origin/main' into quaver
ci / web (pull_request) Successful in 27s
ci / flake (pull_request) Successful in 46s
ci / rust (pull_request) Successful in 5m16s
# Conflicts:
#	web/src/structured.ts
2026-09-07 11:06:13 +01:00
naps62-yolo ac8a1b319d feat(web): redesign spawn dialog advanced area (#205)
ci / web (push) Successful in 40s
ci / flake (push) Successful in 3m51s
ci / rust (push) Successful in 6m26s
2026-09-07 10:50:44 +01:00
Miguel PalhasandClaude Opus 4.6 17a57269f7 fix(maestro): satisfy field_reassign_with_default lint
ci / rust (push) Successful in 9m13s
ci / web (push) Failing after 7s
ci / flake (push) Successful in 3m56s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 10:47:07 +01:00
Miguel Palhas 43bfda5a60 fix(web): queue structured prompts
ci / web (pull_request) Successful in 47s
ci / flake (pull_request) Successful in 1m17s
ci / rust (pull_request) Successful in 9m35s
2026-09-07 10:42:49 +01:00
Miguel Palhas 6ffbca3596 Merge remote-tracking branch 'origin/main' into recorder-2 2026-09-07 10:41:59 +01:00
Miguel PalhasandClaude Opus 4.6 2cde58b42f fix(web): compact tool-call groups in structured view
Drop the per-tool name list from group headers (redundant with the rows
below), remove individual borders inside groups, and tighten padding so
20-tool blocks scan as a dense list instead of a wall of cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 10:41:55 +01:00
Miguel Palhas 721c34aa11 Merge remote-tracking branch 'origin/main' into tritone
ci / web (push) Successful in 28s
ci / rust (push) Failing after 1m4s
ci / flake (push) Successful in 3m4s
2026-09-07 10:40:10 +01:00
Miguel PalhasandClaude Opus 5 a7d8b5e22f fix(maestro): keep the draft first when the composer wraps
ci / web (push) Successful in 28s
ci / flake (push) Successful in 3m24s
ci / rust (push) Successful in 6m8s
The attach button precedes the draft in the DOM, so under the ≤520px wrap
rule it took a line of its own above the textarea. An explicit order puts
the draft back on the first line and groups every control below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 10:27:46 +01:00
Miguel Palhas 2f695e1354 Merge remote-tracking branch 'origin/main' into balalaika 2026-09-07 10:25:40 +01:00
Miguel Palhas 1e30b9ade1 Merge remote-tracking branch 'origin/main' into timbre
ci / rust (push) Successful in 8m43s
ci / web (push) Failing after 7s
ci / flake (push) Successful in 4m21s
2026-09-07 10:24:22 +01:00
Miguel PalhasandClaude Opus 5 b4d4b0712c fix(maestro): keep the composer's one-row layout
Reverts the stacked control strip from f78aa13. The floating card is still
gone — flush band, one hairline against the transcript that doubles as the
focus indicator — but the composer keeps its own `--surface` fill so it
still reads as distinct from the pane, and the draft, gear and Send stay on
one bottom-aligned row.

At phone width that row cannot hold a draft plus three controls, so it
wraps: the draft takes a full line and the controls drop below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 10:23:15 +01:00
Miguel Palhas 4df8fdb7dc feat(maestro): attach files in structured views 2026-09-07 10:20:08 +01:00
Miguel Palhas b949cffb9e feat(tui): refine structured transcript 2026-09-07 10:18:19 +01:00
Miguel PalhasandClaude Opus 5 f78aa13b36 fix(maestro): flatten structured composer
The composer was a floating bordered card inset from the pane edge, and
its send button was bottom-aligned beside the textarea, so it sank as the
draft grew.

It is now a flush band at the foot of the pane: one hairline rule against
the transcript, which doubles as the focus indicator. The textarea spans
the full width and the controls sit in a fixed strip below it, so the send
button keeps its place however tall the draft grows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 10:16:56 +01:00
39 changed files with 1215 additions and 238 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git fetch:*)",
"Bash(git merge:*)",
"Bash(npx tsc:*)",
"Bash(npm run build:*)",
"Bash(npm install:*)",
"Bash(./node_modules/.bin/tsc:*)",
"Bash(npm test:*)",
"Bash(cargo check:*)",
"Bash(cargo clippy:*)"
]
}
}
+1
View File
@@ -57,6 +57,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
env,
metadata,
worktree,
auto_approve: false,
};
client.create_session(&body).await
}
@@ -28,6 +28,7 @@ async fn info_carries_version_and_presets() {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
}],
..Default::default()
});
@@ -97,6 +98,7 @@ async fn spawn_round_trips_through_the_daemon() {
env: Default::default(),
metadata: Default::default(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
+10
View File
@@ -313,16 +313,19 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
},
SpawnPreset {
id: "opencode".into(),
label: "opencode".into(),
command: "opencode".into(),
auto_approve: false,
},
SpawnPreset {
id: "zsh".into(),
label: "Shell".into(),
command: "zsh".into(),
auto_approve: false,
},
]
}
@@ -334,6 +337,7 @@ pub fn custom_preset() -> SpawnPreset {
id: CUSTOM_PRESET_ID.into(),
label: "Custom…".into(),
command: String::new(),
auto_approve: false,
}
}
@@ -515,6 +519,7 @@ auto_install = false
id: "fish".into(),
label: "fish".into(),
command: "fish".into(),
auto_approve: false,
}]),
..Default::default()
};
@@ -551,6 +556,7 @@ auto_install = false
id: " ".into(),
label: "x".into(),
command: "x".into(),
auto_approve: false,
}]),
..Default::default()
};
@@ -561,6 +567,7 @@ auto_install = false
id: "x".into(),
label: "x".into(),
command: "".into(),
auto_approve: false,
}]),
..Default::default()
};
@@ -578,11 +585,13 @@ auto_install = false
id: "a".into(),
label: "a".into(),
command: "a".into(),
auto_approve: false,
},
SpawnPreset {
id: "a".into(),
label: "a2".into(),
command: "a2".into(),
auto_approve: false,
},
]),
..Default::default()
@@ -597,6 +606,7 @@ auto_install = false
id: "custom".into(),
label: "Custom".into(),
command: "whatever".into(),
auto_approve: false,
}]),
..Default::default()
};
+46 -1
View File
@@ -12,7 +12,8 @@
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::body::Bytes;
use axum::extract::{DefaultBodyLimit, Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{any, delete, get, post};
@@ -70,6 +71,10 @@ pub fn router(state: LocalApiState) -> Router {
.route(routes::patterns::STRUCTURED, any(structured_ws))
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
.route(
routes::patterns::STRUCTURED_ATTACHMENT,
post(structured_attachment).layer(DefaultBodyLimit::max(20 * 1024 * 1024)),
)
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
.route(routes::patterns::STRUCTURED_CONFIG, post(structured_config))
.route(
@@ -432,6 +437,46 @@ async fn structured_replay(
}
}
#[derive(Deserialize)]
struct AttachmentQuery {
name: String,
}
async fn structured_attachment(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Query(query): Query<AttachmentQuery>,
bytes: Bytes,
) -> Response {
let name = query.name;
if name.is_empty()
|| name.len() > 180
|| name == "."
|| name == ".."
|| name
.chars()
.any(|c| c.is_control() || c == '/' || c == '\\')
{
return error_response(
StatusCode::BAD_REQUEST,
"invalid_filename",
"use a filename without directory separators, up to 180 bytes",
);
}
match s.manager.store_acp_attachment(id, &name, &bytes).await {
Ok(Some(path)) => Json(serde_json::json!({ "path": path })).into_response(),
Ok(None) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
async fn structured_prompt(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
+48 -2
View File
@@ -122,6 +122,9 @@ pub struct SessionRecord {
/// Completion signal for the task draining ACP notifications into the
/// event log. Shutdown waits for it before returning.
acp_event_pump: Option<watch::Receiver<bool>>,
/// When `true`, the permission pump auto-responds `AllowAlways` to every
/// ACP permission request without surfacing it to clients.
pub auto_approve: bool,
}
impl SessionRecord {
@@ -604,6 +607,36 @@ impl SessionManager {
Ok(Some(replay))
}
/// Store attachment bytes for a live structured session.
pub async fn store_acp_attachment(
&self,
id: Uuid,
name: &str,
bytes: &[u8],
) -> Result<Option<std::path::PathBuf>, SessionError> {
{
let sessions = self.inner.sessions.lock().await;
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
if !record.is_live_structured() {
return Ok(None);
}
}
let dir = self.inner.sidecar.dir().join(format!("{id}.attachments"));
tokio::fs::create_dir_all(&dir).await?;
let dir = tokio::fs::canonicalize(dir).await?;
let path = dir.join(format!("{}-{name}", Uuid::new_v4()));
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
.await?;
use tokio::io::AsyncWriteExt;
file.write_all(bytes).await?;
file.sync_all().await?;
Ok(Some(path))
}
/// Subscribe to the two projections of a structured session's ACP log.
///
/// The transcript snapshot and whole reduced state are folded once by the
@@ -1414,7 +1447,7 @@ impl SessionManager {
let mut sessions = self.inner.sessions.lock().await;
let event_pump = event_pump.map(|(acp, events)| {
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
start_acp_permission_pump(&self.inner, id, &acp, events);
start_acp_permission_pump(&self.inner, id, &acp, events, req.auto_approve);
done
});
sessions.insert(
@@ -1435,6 +1468,7 @@ impl SessionManager {
acp_events,
structured_activity_watermark: None,
acp_event_pump: event_pump,
auto_approve: req.auto_approve,
},
);
}
@@ -1647,6 +1681,7 @@ impl SessionManager {
acp_events: None,
structured_activity_watermark: None,
acp_event_pump: None,
auto_approve: false,
},
);
let _ = self
@@ -1824,11 +1859,14 @@ impl SessionManager {
acp_events,
structured_activity_watermark: None,
acp_event_pump: None,
// Recovery cannot know the original preset's flag; the
// operator re-enables it by restarting with the preset.
auto_approve: false,
},
);
if let Some((acp, events)) = event_pump {
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
start_acp_permission_pump(&self.inner, id, &acp, events);
start_acp_permission_pump(&self.inner, id, &acp, events, false);
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
record.acp_event_pump = Some(done);
}
@@ -2327,6 +2365,7 @@ fn start_acp_permission_pump(
id: Uuid,
acp: &Arc<AcpSession>,
events: Arc<Mutex<AcpEventStream>>,
auto_approve: bool,
) {
let Some(mut requests) = acp.take_permission_requests() else {
return;
@@ -2340,6 +2379,12 @@ fn start_acp_permission_pump(
response,
}) = requests.recv().await
{
if auto_approve {
let outcome =
permission_outcome(ApprovalDecision::AllowForSession, &request.options);
let _ = response.send(outcome);
continue;
}
let mut events = events.lock().await;
if events.projection.active_turn() != Some(turn) {
let _ = response.send(maestro_acp::schema::RequestPermissionOutcome::Cancelled);
@@ -3060,6 +3105,7 @@ mod tests {
env: std::collections::HashMap::new(),
metadata: std::collections::HashMap::new(),
worktree: None,
auto_approve: false,
}
}
+3
View File
@@ -112,6 +112,7 @@ fn structured_command(
env,
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
}
}
@@ -705,6 +706,7 @@ async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
branch: branch.into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -1187,6 +1189,7 @@ async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
branch: "cancelled-create".into(),
create_branch: true,
}),
auto_approve: false,
};
let create = {
@@ -48,6 +48,7 @@ async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
env: Default::default(),
metadata: Default::default(),
worktree: None,
auto_approve: false,
})
.await
.unwrap()
+1
View File
@@ -81,6 +81,7 @@ async fn create_bash_session(h: &Harness) -> Uuid {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
+1
View File
@@ -56,6 +56,7 @@ async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
env: Default::default(),
metadata,
worktree: None,
auto_approve: false,
})
.await
.unwrap()
@@ -29,6 +29,7 @@ fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> Creat
branch: branch.into(),
create_branch: true,
}),
auto_approve: false,
}
}
+67
View File
@@ -162,6 +162,7 @@ async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>)
env,
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap()
@@ -234,6 +235,60 @@ async fn structured_ws_sends_snapshot_then_live_projection() {
);
}
#[tokio::test]
async fn structured_attachments_preserve_bytes_and_reject_paths() {
let h = spawn("attachments").await;
let fake = FakeAcp::new();
let id = structured_session(&h, &fake).await;
let client = reqwest::Client::new();
let endpoint = url(
h.addr,
&format!("/api/sessions/{id}/structured/attachments"),
);
let bytes = vec![0, 255, 10, 13];
let response = client
.post(&endpoint)
.query(&[("name", "sample.bin")])
.body(bytes.clone())
.send()
.await
.unwrap();
assert_eq!(response.status(), 200);
let body: serde_json::Value = response.json().await.unwrap();
let path = body["path"].as_str().unwrap();
assert!(Path::new(path).is_absolute());
assert_eq!(std::fs::read(path).unwrap(), bytes);
for name in ["../escape", "a/b", "a\\b", "..", ""] {
let response = client
.post(&endpoint)
.query(&[("name", name)])
.body("text")
.send()
.await
.unwrap();
assert_eq!(response.status(), 400, "{name}");
}
let response = client
.post(&endpoint)
.query(&[("name", "large.bin")])
.body(vec![0; 20 * 1024 * 1024 + 1])
.send()
.await
.unwrap();
assert_eq!(response.status(), 413);
let response = client
.post(url(
h.addr,
&format!("/api/sessions/{}/structured/attachments", Uuid::new_v4()),
))
.query(&[("name", "file.txt")])
.body("text")
.send()
.await
.unwrap();
assert_eq!(response.status(), 404);
}
#[tokio::test]
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() {
let h = spawn("naps-laptop").await;
@@ -493,6 +548,7 @@ async fn structured_prompt_rejects_unknown_and_unavailable_sessions() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -626,6 +682,7 @@ async fn structured_replay_rejects_unknown_terminal_and_dead_sessions() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -768,6 +825,7 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
};
let created: Session = reqwest::Client::new()
.post(url(h.addr, "/api/sessions"))
@@ -985,6 +1043,7 @@ async fn patch_session_renames_it() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -1028,6 +1087,7 @@ async fn post_focus_stamps_last_focused_at() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -1062,6 +1122,7 @@ async fn delete_owned_session_kills_it() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -1084,6 +1145,7 @@ async fn spawn_via_manager(h: &Harness) -> Uuid {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap()
@@ -1198,6 +1260,7 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
branch: "feat/guarded".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.unwrap();
@@ -1251,6 +1314,7 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
branch: "feat/guarded".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.unwrap();
@@ -1281,6 +1345,7 @@ async fn attach_ws_streams_pty_bytes() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -1383,6 +1448,7 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -1495,6 +1561,7 @@ async fn every_events_subscriber_gets_its_own_snapshot() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
+1
View File
@@ -57,6 +57,7 @@ async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
env: Default::default(),
metadata,
worktree: None,
auto_approve: false,
})
.await
.unwrap()
@@ -70,6 +70,7 @@ impl Fx {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
}
}
}
@@ -28,6 +28,7 @@ async fn create_then_kill_round_trip() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -70,6 +71,7 @@ async fn create_appends_to_spawn_history() {
env: HashMap::new(),
metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -100,6 +102,7 @@ async fn create_without_history_logs_nothing() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -123,6 +126,7 @@ async fn recover_adopts_existing_tmux_sessions() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -166,6 +170,7 @@ async fn create_captures_initial_output_in_replay_buffer() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -215,6 +220,7 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -273,6 +279,7 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -324,6 +331,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -387,6 +395,7 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -438,6 +447,7 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -508,6 +518,7 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
branch: "feat/indicator".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -601,6 +612,7 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
branch: "feat/keep".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -639,6 +651,7 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
branch: "feat/guarded".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -703,6 +716,7 @@ async fn killing_refuses_until_the_branch_is_pushed() {
branch: "feat/pushed".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -753,6 +767,7 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
branch: "feat/forced".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -799,6 +814,7 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
branch: "feat/clear".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -840,6 +856,7 @@ async fn killing_an_ignored_only_worktree_succeeds() {
branch: "feat/rusty".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -882,6 +899,7 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
branch: "feat/exited".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -944,6 +962,7 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
branch: "feat/clean".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -1001,6 +1020,7 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -1040,6 +1060,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
branch: "feat/one".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create first");
@@ -1058,6 +1079,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
branch: "feat/two".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create second");
@@ -1093,6 +1115,7 @@ async fn spawned_sessions_export_maestro_session_id_and_url() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -1132,6 +1155,7 @@ async fn a_freshly_created_session_has_never_been_focused() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -1158,6 +1182,7 @@ async fn set_focused_true_stamps_last_focused_at() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -1207,6 +1232,7 @@ async fn attaching_stamps_last_focused_at() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
+8
View File
@@ -33,6 +33,7 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
}
}
@@ -111,6 +112,7 @@ fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> Cr
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(base, branch, create_branch)),
auto_approve: false,
}
}
@@ -206,6 +208,7 @@ async fn kill_removes_clean_worktree() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
auto_approve: false,
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
@@ -260,6 +263,7 @@ async fn kill_refuses_worktree_with_uncommitted_changes() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
auto_approve: false,
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
@@ -319,6 +323,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
auto_approve: false,
};
let s = client
@@ -827,6 +832,7 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
auto_approve: false,
})
.await
.expect("spawn dirty");
@@ -876,6 +882,7 @@ fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateS
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(cwd, branch, true)),
auto_approve: false,
}
}
@@ -1068,6 +1075,7 @@ async fn sigterm_stops_structured_child_before_events_ws_drains() {
env: fake.env.clone(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("spawn structured session");
+10
View File
@@ -383,6 +383,10 @@ pub struct CreateSessionRequest {
/// untouched.
#[serde(default)]
pub view: SessionView,
/// When `true`, the daemon auto-approves every ACP permission request
/// for this structured session.
#[serde(default)]
pub auto_approve: bool,
}
/// Result of `GET /api/dirs`: the immediate subdirectories of one directory.
@@ -522,6 +526,11 @@ pub struct SpawnPreset {
pub id: String,
pub label: String,
pub command: String,
/// When `true`, the daemon auto-approves every ACP permission request
/// for structured sessions spawned from this preset instead of surfacing
/// them to the client.
#[serde(default)]
pub auto_approve: bool,
}
/// Build provenance of the running daemon: the commit it was compiled
@@ -1251,6 +1260,7 @@ mod tests {
create_branch: true,
}),
view: SessionView::Terminal,
auto_approve: false,
};
assert_eq!(req, round_trip(&req));
}
+6
View File
@@ -86,6 +86,11 @@ pub fn structured_prompt(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/prompt")
}
/// `POST /api/sessions/:id/structured/attachments` — upload a file.
pub fn structured_attachment(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/attachments")
}
/// `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn.
pub fn structured_cancel(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/cancel")
@@ -113,6 +118,7 @@ pub mod patterns {
pub const STRUCTURED: &str = "/api/structured/:id";
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
pub const STRUCTURED_ATTACHMENT: &str = "/api/sessions/:id/structured/attachments";
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
+98
View File
@@ -11,6 +11,7 @@
//! `dispatch` / the overlay routing.
use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::path::PathBuf;
use std::time::Duration;
@@ -54,6 +55,36 @@ enum OverlayDecision {
Pass,
}
fn copy_to_terminal_clipboard(text: &str) -> std::io::Result<()> {
let encoded = base64_encode(text.as_bytes());
let mut output = std::io::stdout();
write!(output, "\x1b]52;c;{encoded}\x07")?;
output.flush()
}
fn base64_encode(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let bits = (u32::from(chunk[0]) << 16)
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
| u32::from(*chunk.get(2).unwrap_or(&0));
output.push(TABLE[((bits >> 18) & 0x3f) as usize] as char);
output.push(TABLE[((bits >> 12) & 0x3f) as usize] as char);
output.push(if chunk.len() > 1 {
TABLE[((bits >> 6) & 0x3f) as usize] as char
} else {
'='
});
output.push(if chunk.len() > 2 {
TABLE[(bits & 0x3f) as usize] as char
} else {
'='
});
}
output
}
pub struct App {
cfg: TuiConfig,
home_dir: String,
@@ -340,6 +371,10 @@ impl App {
return Ok(false);
}
if self.handle_structured_mouse(&key) {
return Ok(false);
}
if let Some(effects) = self.handle_structured_key(&key) {
self.perform(effects).await;
return Ok(false);
@@ -423,6 +458,61 @@ impl App {
true
}
/// Transcript blocks are pointer targets: click selects the whole block,
/// gives it the selection surface, and copies its complete text.
fn handle_structured_mouse(&mut self, key: &InputKey) -> bool {
let InputKey::Mouse {
kind,
region: Region::Pane,
row,
..
} = key
else {
return false;
};
if !matches!(kind, MouseKind::Click | MouseKind::Hover) {
return false;
}
let Some(sid) = self.state.active_session() else {
return false;
};
let Some(handle) = self.structured.get(&sid) else {
return false;
};
let Some(pane) = self.region.pane else {
return false;
};
let offset = usize::from(row.saturating_sub(pane.y));
let transcript_height = pane.height.saturating_sub(pane.height.min(3));
if offset >= usize::from(transcript_height) {
return true;
}
let mut state = handle.state.lock().expect("structured state mutex");
let rendered_line = state.scroll.saturating_add(offset);
if !state.select_rendered_line(rendered_line) {
return true;
}
let Some(id) = state.selected.as_deref() else {
return true;
};
let Some(transcript) = state.rows.iter().find(|item| item.id == id) else {
return true;
};
if *kind == MouseKind::Hover {
return true;
}
let text = crate::structured_pane::copy_text(transcript);
drop(state);
if text.is_empty() {
return true;
}
match copy_to_terminal_clipboard(&text) {
Ok(()) => self.state.toast("copied transcript block"),
Err(error) => self.state.toast(format!("copy failed: {error}")),
}
true
}
/// Carry out what `dispatch` decided. The only place in `App` that talks to
/// the daemon.
async fn perform(&mut self, effects: Vec<Effect>) {
@@ -1905,6 +1995,12 @@ async fn fetch_branches(
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clipboard_payload_uses_standard_base64() {
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
assert_eq!(base64_encode("✓".as_bytes()), "4pyT");
}
use crate::action::Action;
use crate::draft::DraftField;
use crate::keymap::Mode;
@@ -3009,6 +3105,7 @@ mod tests {
env: Default::default(),
metadata: Default::default(),
worktree: None,
auto_approve: false,
};
app.submit_spawn(req).await;
@@ -3057,6 +3154,7 @@ mod tests {
env: Default::default(),
metadata: Default::default(),
worktree: None,
auto_approve: false,
};
let structured_id = Uuid::from_u128(188);
+17
View File
@@ -734,6 +734,10 @@ impl DraftPane {
);
}
let worktree = self.build_worktree()?;
let auto_approve = self
.presets
.get(self.selected_preset)
.is_some_and(|p| p.auto_approve);
Ok(CreateSessionRequest {
view: if self.structured_view && self.supports_structured_view() {
SessionView::Structured
@@ -745,6 +749,7 @@ impl DraftPane {
env: Default::default(),
metadata,
worktree,
auto_approve,
})
}
@@ -1792,6 +1797,7 @@ mod tests {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
auto_approve: false,
}]);
assert_eq!(
draft.command(),
@@ -1814,11 +1820,13 @@ mod tests {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
},
SpawnPreset {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
auto_approve: false,
},
]);
assert_eq!(draft.command(), "aider");
@@ -1838,6 +1846,7 @@ mod tests {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
}]);
assert!(draft.is_custom());
assert_eq!(draft.command(), "bash -l");
@@ -1914,16 +1923,19 @@ mod tests {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
},
SpawnPreset {
id: "codex".into(),
label: "Codex".into(),
command: "codex".into(),
auto_approve: false,
},
SpawnPreset {
id: "opencode".into(),
label: "OpenCode".into(),
command: "opencode".into(),
auto_approve: false,
},
]);
assert!(draft.structured_view, "on by default for a supported agent");
@@ -2066,16 +2078,19 @@ mod tests {
id: "claude-wrapped".into(),
label: "Claude (npx)".into(),
command: "npx claude-code --resume".into(),
auto_approve: false,
},
SpawnPreset {
id: "codex-wrapped".into(),
label: "Codex (npx)".into(),
command: "npx codex exec".into(),
auto_approve: false,
},
SpawnPreset {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
auto_approve: false,
},
]);
@@ -2115,11 +2130,13 @@ mod tests {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
},
SpawnPreset {
id: "codex".into(),
label: "Codex".into(),
command: "codex".into(),
auto_approve: false,
},
]);
assert!(draft.structured_view);
+2
View File
@@ -121,6 +121,7 @@ impl FastForwardDefaults {
branch: handle.to_string(),
create_branch: true,
}),
auto_approve: false,
})
}
@@ -184,6 +185,7 @@ mod tests {
id: id.into(),
label: id.into(),
command: command.into(),
auto_approve: false,
}
}
+7
View File
@@ -55,6 +55,7 @@ pub enum MouseKind {
Click,
DblClick,
Drag,
Hover,
ScrollUp,
ScrollDown,
}
@@ -148,6 +149,7 @@ impl InputKey {
MouseKind::Click => "click",
MouseKind::DblClick => "dblclick",
MouseKind::Drag => "drag",
MouseKind::Hover => "hover",
MouseKind::ScrollUp => "scrollup",
MouseKind::ScrollDown => "scrolldown",
};
@@ -178,6 +180,7 @@ impl InputKey {
"click" => MouseKind::Click,
"dblclick" => MouseKind::DblClick,
"drag" => MouseKind::Drag,
"hover" => MouseKind::Hover,
"scrollup" => MouseKind::ScrollUp,
"scrolldown" => MouseKind::ScrollDown,
other => return Err(InputParseError::UnknownMouseKind(other.into())),
@@ -297,6 +300,7 @@ where
let mk = match kind {
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
MouseEventKind::Moved => MouseKind::Hover,
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
_ => return None,
@@ -681,6 +685,9 @@ mod tests {
let s = "mouse:dblclick(sidebar-row)";
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s);
let s = "mouse:hover(pane)";
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s);
}
#[test]
+83 -6
View File
@@ -308,11 +308,12 @@ fn fallback_after_removal(
fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
let mut out = Vec::new();
for row in &state.rows {
for (index, row) in state.rows.iter().enumerate() {
if row.kind == TranscriptRowKind::Plan {
continue;
}
let row_width = width.saturating_sub(1).max(1);
let start = out.len();
match row.kind {
TranscriptRowKind::User => push_wrapped(
&mut out,
@@ -356,10 +357,53 @@ fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
),
TranscriptRowKind::Plan => {}
}
if out.len() > start
&& state.rows[index + 1..]
.iter()
.any(|next| next.kind != TranscriptRowKind::Plan)
{
// A blank surface line is the vertical padding that keeps adjacent
// transcript blocks readable at a glance.
out.push(VisualLine {
text: String::new(),
row_id: None,
rail: false,
tone: Tone::Normal,
});
}
}
out
}
/// Text supplied to the terminal clipboard when a transcript block is
/// clicked. It preserves the full tool detail rather than its compact preview.
pub fn copy_text(row: &TranscriptRow) -> String {
match row.kind {
TranscriptRowKind::User
| TranscriptRowKind::AgentText
| TranscriptRowKind::AgentThought => content_text(row),
TranscriptRowKind::ToolCall | TranscriptRowKind::ToolDiff => {
let (name, argument) = tool_title(row);
let mut lines = vec![if argument.is_empty() {
name
} else {
format!("{name} {argument}")
}];
lines.extend(tool_content_lines(row, None));
lines.join("\n")
}
TranscriptRowKind::Approval => row
.data
.get("toolCall")
.and_then(|tool| tool.get("title"))
.and_then(serde_json::Value::as_str)
.unwrap_or("permission requested")
.into(),
TranscriptRowKind::Notice => "[notice]".into(),
TranscriptRowKind::Plan => String::new(),
}
}
fn push_wrapped(
out: &mut Vec<VisualLine>,
row: &TranscriptRow,
@@ -1000,6 +1044,39 @@ mod tests {
);
}
#[test]
fn transcript_blocks_have_a_padding_line_between_them() {
let mut state = StructuredState::default();
state.rows = vec![
row(
"one",
TranscriptRowKind::User,
json!({"content":{"type":"text","text":"first"}}),
),
row(
"two",
TranscriptRowKind::AgentText,
json!({"content":{"type":"text","text":"second"}}),
),
];
let lines = build_lines(&state, 80);
assert_eq!(lines.len(), 3);
assert_eq!(lines[1].text, "");
assert!(lines[1].row_id.is_none());
}
#[test]
fn copying_a_tool_keeps_its_full_detail() {
let tool = row(
"t",
TranscriptRowKind::ToolCall,
json!({"title":"Bash(cargo test)","content":[{"type":"content","content":{"type":"text","text":"all green"}}]}),
);
assert_eq!(copy_text(&tool), "Bash cargo test\nall green");
}
#[test]
fn tool_is_compact_until_selected_row_expands() {
let mut state = StructuredState::default();
@@ -1420,7 +1497,7 @@ mod tests {
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
state.scroll = 20;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
@@ -1439,7 +1516,7 @@ mod tests {
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
state.scroll = 20;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
@@ -1459,7 +1536,7 @@ mod tests {
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
state.scroll = 20;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
@@ -1478,7 +1555,7 @@ mod tests {
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
state.scroll = 20;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
@@ -1497,7 +1574,7 @@ mod tests {
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
state.scroll = 20;
render_transcript_lines(&pane, &mut state, 30, 5);
// Simulate a `TranscriptDelta::Patch` on a collapsed tool row above the
+31
View File
@@ -199,6 +199,18 @@ impl StructuredState {
self.follow_selection = true;
}
/// Select the transcript block under a rendered transcript line. Pointer
/// input uses the same row identity map as scroll reconciliation, so a
/// wrapped block behaves as one target.
pub fn select_rendered_line(&mut self, line: usize) -> bool {
let Some(id) = self.line_rows.get(line).and_then(Clone::clone) else {
return false;
};
self.selected = Some(id);
self.follow_selection = false;
true
}
pub fn toggle_selected(&mut self) {
let Some(id) = self.selected.clone() else {
return;
@@ -657,6 +669,25 @@ mod tests {
assert_eq!(state.composer.value(), "hi");
}
#[test]
fn selecting_a_rendered_line_selects_its_whole_block() {
let mut state = StructuredState {
line_rows: vec![
Some("one".into()),
Some("one".into()),
None,
Some("two".into()),
],
..Default::default()
};
assert!(state.select_rendered_line(1));
assert_eq!(state.selected.as_deref(), Some("one"));
assert!(!state.follow_selection);
assert!(!state.select_rendered_line(2));
assert_eq!(state.selected.as_deref(), Some("one"));
}
#[test]
fn authoritative_idle_snapshot_reconciles_a_wholly_missed_turn() {
let mut state = StructuredState::default();
+33 -5
View File
@@ -190,9 +190,13 @@ choices, not daemon ones.
from the transcript; the TUI may show the current plan in its status line,
while the web client deliberately has no stats or plan rail.
- The web client groups two or more tool rows after the same `User` row into
one collapsed turn activity block. Opening it reveals independently
expandable tool calls, including any raw command, input, output, and diff
content the adapter supplied.
one collapsed turn activity block. `AgentText` and `AgentThought` rows that
appear *between* tool rows in the same turn are absorbed into the group
(intermediate narration); the last `AgentText` with no subsequent tool stays
outside as the prominent final answer. The header still reads
"N tool calls" (counting only tool rows). Opening the group reveals tools
and intermediate text in chronological order, with the text rendered in a
subdued style.
- The web client's user, agent, thought, tool-detail, and notice text blocks
copy their complete source text when clicked or keyboard-activated.
- The web client consumes `available_commands_update` notices as the live
@@ -318,6 +322,26 @@ pending decision, entirely on the daemon side:
## Prompt and cancel: bounded liveness
The web composer accepts up to ten files through its attachment picker, file
drop, or clipboard. Pasted text of at least 4,000 characters or 40 lines becomes
a UTF-8 `.txt` attachment. Shorter pastes stay in the composer. Attachments can
be removed before sending; failed sends retain the draft and attachments.
Pastes also stay inline when all ten attachment slots are occupied or the text
exceeds the 20 MiB file limit.
On send, `POST /api/sessions/:id/structured/attachments?name=<filename>` uploads
each file as raw bytes (20 MiB maximum) and returns its absolute `path`.
Only live structured sessions accept uploads. Filenames are limited to 180
bytes and cannot contain directory separators or control characters. Files get unique names and private
file permissions in `<id>.attachments` beside the session sidecar. They persist
across daemon restarts; removing a composer chip does not delete uploaded data.
There is currently no automatic attachment cleanup.
The prompt includes each filename and host path so the adapter can read it
through its own file tools, including images when the provider supports them.
These references are persisted in the ordinary user transcript row. Attachment
bytes are not embedded in ACP messages or transcript history.
`POST /api/sessions/:id/structured/prompt` and `.../cancel` are serialized
against each other through one per-session `operation` lock
(`AcpEventStream::operation`), because ACP permission callbacks carry no
@@ -326,11 +350,15 @@ thing a callback can be attributed to, so at most one turn may ever be
in flight, and a cancelled turn must fully settle before the next prompt is
allowed to claim the daemon's idea of "the active turn."
- A prompt is appended as a durable `User` row before it claims the turn and
- Web clients may collect a visible FIFO of operator messages while a turn is
active. They submit each queued message only after the projected turn becomes
idle; the queue is client-local and unsent messages do not survive a reload.
- A submitted prompt is appended as a durable `User` row before it claims the turn and
dispatches to ACP, so the transcript never depends on an adapter echoing the
operator's input. If that append fails, the prompt is not dispatched. While a
turn is active, a second prompt gets `409 turn_in_flight` rather than
queuing — one active turn per session, never a hidden queue of prompts.
queuing in the daemon — one active turn per session, never a hidden
server-side queue of prompts.
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
and ends the turn's client-visible lifecycle immediately. If the cancelled
turn was still *dispatched* to ACP (waiting on its response), the daemon
+9
View File
@@ -184,6 +184,15 @@ export async function sendStructuredPrompt(id: Uuid, prompt: string): Promise<vo
);
}
export async function uploadStructuredFile(id: Uuid, file: File): Promise<{ path: string }> {
return jsonOrError(await fetch(`${routes.structuredAttachment(id)}?name=${encodeURIComponent(file.name)}`, {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/octet-stream" },
body: file,
}));
}
/** Cancel a structured session's active ACP turn (#138). */
export async function cancelStructuredTurn(id: Uuid): Promise<void> {
await jsonOrError(
+4 -3
View File
@@ -663,8 +663,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
applyNav();
};
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
// out: there is no hover there, and a tap already opens the session.
// Pointer hover previews only while Ctrl is held — plain hover was too
// disruptive when sweeping the sidebar. Keyboard walk (j/k) still previews
// unconditionally via applyNav.
let hovered: string | null = null;
const setHovered = (id: string | null) => {
if (hovered === id) return;
@@ -672,7 +673,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
opts.onPreview?.(id);
};
treeHost.addEventListener("pointerover", (e) => {
if (e.pointerType === "touch") return;
if (e.pointerType === "touch" || !e.ctrlKey) return;
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
setHovered(row?.dataset.sessionId ?? null);
});
+4
View File
@@ -47,6 +47,10 @@ export function structuredPrompt(id: Uuid): string {
return `${session(id)}/structured/prompt`;
}
export function structuredAttachment(id: Uuid): string {
return `${session(id)}/structured/attachments`;
}
/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */
export function structuredCancel(id: Uuid): string {
return `${session(id)}/structured/cancel`;
+11 -5
View File
@@ -182,26 +182,32 @@ describe("sidebar preview requests (docs/PREVIEW.md)", () => {
expect(seen.at(-1)).toBe(null);
});
it("asks for a preview of the hovered card, and clears it on the way out", () => {
it("ctrl+hover previews a card, plain hover does not", () => {
const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
// Plain hover — no preview.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
expect(seen).toEqual([]);
// Ctrl+hover — preview fires.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
expect(seen).toEqual([A]);
// Moving within the same card is not a new preview.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
// Deduplicated while still on the same card.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
expect(seen).toHaveLength(1);
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
expect(seen.at(-1)).toBe(null);
});
it("ignores touch, where a tap already opens the session", () => {
it("ignores touch even with ctrl", () => {
const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch" }));
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch", ctrlKey: true }));
expect(seen).toEqual([]);
});
});
+1
View File
@@ -7,6 +7,7 @@ const base: SpawnFormValues = {
cwd: "/home/user/project",
name: "amber-fox",
structuredView: false,
autoApprove: false,
worktreeEnabled: false,
worktreeBranch: "",
worktreeBranchMode: "new",
+96 -56
View File
@@ -11,7 +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 { splitCommand, toolIconSvg, TOOL_LABELS } from "./toolIcon.js";
import { abbreviateCwd } from "./types.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
@@ -49,6 +49,7 @@ export interface SpawnFormValues {
cwd: string;
name: string;
structuredView: boolean;
autoApprove: boolean;
worktreeEnabled: boolean;
worktreeBranch: string;
worktreeBranchEdited: boolean;
@@ -79,6 +80,10 @@ export function buildSpawnRequest(
req.view = "Structured";
}
if (values.autoApprove) {
req.auto_approve = true;
}
if (values.worktreeEnabled) {
const mode = values.worktreeBranchMode ?? "new";
const branch = values.worktreeBranch.trim();
@@ -135,6 +140,12 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const form = document.createElement("form");
form.className = "spawn-form";
form.innerHTML = `
<div class="field">
<span class="field-label">Harness</span>
<div class="harness-grid cmd-tiles"></div>
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
placeholder="bash -l" autocomplete="off" />
</div>
<div class="field">
<span class="field-label">Project</span>
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
@@ -146,26 +157,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
</div>
</div>
<div class="field">
<span class="field-label">Command</span>
<div class="tile-row cmd-tiles"></div>
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
placeholder="bash -l" autocomplete="off" />
</div>
<div class="field">
<span class="field-label">Name</span>
<div class="name-row">
<input name="name" type="text" autocomplete="off"
placeholder="named after your first prompt" />
</div>
</div>
<fieldset class="worktree">
<div class="advanced">
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
<span class="chevron">▾</span>
<span class="folder-name">Advanced options</span>
<span class="folder-name">Advanced</span>
<span class="wt-summary"></span>
</button>
<div class="wt-collapsible" hidden>
<div class="field">
<span class="field-label">Session name</span>
<div class="name-row">
<input name="name" type="text" autocomplete="off"
placeholder="named after your first prompt" />
</div>
</div>
<label class="row-inline structured-view" hidden>
<input type="checkbox" name="structured_view" />
Structured view
@@ -174,34 +179,36 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
<span class="field-label">Worktree</span>
<div class="picker wt-choices"></div>
</div>
<label class="row-inline">
<input type="checkbox" name="worktree_enabled" />
Create git worktree
</label>
<p class="wt-hint" hidden></p>
<div class="wt-body" hidden>
<p class="wt-repo"></p>
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="new" />
New branch
<div class="adv-group">
<label class="row-inline">
<input type="checkbox" name="worktree_enabled" />
Create git worktree
</label>
<p class="wt-hint" hidden></p>
<div class="wt-body" hidden>
<p class="wt-repo"></p>
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="new" />
New branch
</label>
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="existing" />
Existing branch
</label>
</div>
<label class="wt-new-wrap">
New branch name
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
</label>
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="existing" />
Existing branch
<label class="wt-exist-wrap" hidden>
Pick a branch
<select class="branch-select"></select>
</label>
</div>
<label class="wt-new-wrap">
New branch name
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
</label>
<label class="wt-exist-wrap" hidden>
Pick a branch
<select class="branch-select"></select>
</label>
</div>
</div>
</fieldset>
</div>
<p class="form-error" role="alert" aria-live="polite"></p>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Spawn</button>
@@ -261,6 +268,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
let ready = false;
let destroyed = false;
const branchValue = () => (branchMode === "existing" ? branchSelect.value : branchInput.value);
const selectedAutoApprove = () =>
presets.find((p) => p.id === selectedPresetId)?.auto_approve ?? false;
const emit = () => {
if (!ready || destroyed) return;
opts.onChange?.({
@@ -268,6 +277,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwd,
name: nameInput.value,
structuredView: structuredView.checked,
autoApprove: selectedAutoApprove(),
worktreeEnabled: wtEnabled.checked,
worktreeBranch: branchValue(),
worktreeBranchEdited: branchEdited,
@@ -338,37 +348,69 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
command = preset!.command;
}
updateStructuredAvailability();
for (const tile of cmdTiles.querySelectorAll(".tile")) {
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === id);
}
emit();
}
/** Re-highlight the operator's already-picked tile after `renderCommandTiles`
* rebuilds the row from scratch (new tiles carry no `is-active` class) —
/** Re-highlight the operator's already-picked card after `renderCommandTiles`
* rebuilds the grid from scratch (new cards carry no `is-active` class) —
* without touching `command`, since that was already settled. */
function reapplyActiveTile(): void {
for (const tile of cmdTiles.querySelectorAll(".tile")) {
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId);
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === selectedPresetId);
}
}
/** Resolve a preset id to a tool icon id. The preset's command is the
* source of truth — `splitCommand` extracts the tool the same way the
* sidebar does. For the "Custom…" preset (empty command) there is no
* tool to show, and a terminal prompt stands in. */
function presetToolId(p: SpawnPreset): string | null {
if (!p.command) return null;
return splitCommand(p.command).tool;
}
function renderCommandTiles(): void {
cmdTiles.replaceChildren();
for (const p of presets) {
const tile = document.createElement("button");
tile.type = "button";
tile.className = "tile";
tile.dataset.id = p.id;
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
tile.addEventListener("click", () => {
const isCustom = p.command === "";
const card = document.createElement("button");
card.type = "button";
card.className = isCustom ? "harness-card harness-custom" : "harness-card";
card.dataset.id = p.id;
const toolId = presetToolId(p);
const iconHtml = isCustom ? customIcon() : toolId ? harnessIcon(toolId) : harnessIcon("shell");
const label = TOOL_LABELS[toolId ?? ""] ?? p.label;
card.innerHTML =
`<span class="harness-icon">${iconHtml}</span>` +
`<span class="harness-label">${escapeHtml(isCustom ? "Custom\u2026" : label)}</span>`;
card.addEventListener("click", () => {
commandTouched = true;
selectPreset(p.id);
});
cmdTiles.appendChild(tile);
cmdTiles.appendChild(card);
}
}
/** Render a harness card icon at card size (24x24). Re-uses the SVG bodies
* from toolIcon.ts but at a larger viewport so the marks breathe. */
function harnessIcon(toolId: string): string {
// toolIconSvg renders at 13x13 with viewBox 0 0 16 16 — we want the
// same shape at 24x24 for the card context.
const small = toolIconSvg(toolId);
if (!small) return "";
return small
.replace(/width="13"/g, 'width="24"')
.replace(/height="13"/g, 'height="24"');
}
/** An ellipsis mark for the "Custom…" escape hatch. */
function customIcon(): string {
return `<svg class="tool-icon" viewBox="0 0 16 16" width="24" height="24" role="img" aria-label="Custom command"><title>Custom command</title><g fill="currentColor"><circle cx="4" cy="8" r="1.3"/><circle cx="8" cy="8" r="1.3"/><circle cx="12" cy="8" r="1.3"/></g></svg>`;
}
// Set once the operator picks a tile or types a custom command — guards
// `applyInitialCommand()` the same way `nameTouched` guards a late name
// batch: a late `/api/info` resolution must never overwrite a field the
@@ -638,16 +680,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
box.type = "button";
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
box.dataset.cwd = project.path;
const trees = worktreesOf(group).length;
// The parent directory only — the last segment is the name line above it.
// Unabbreviated beyond `~`, since the stem is what tells two projects
// sharing a basename apart (#95).
const parent = abbreviateCwd(parentDir(project.path), homeDir);
const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "";
box.innerHTML = `
<span class="project-name">${escapeHtml(project.name)}</span>
<span class="project-path">${escapeHtml(parent)}</span>
<span class="project-meta">${escapeHtml(meta)}</span>`;
<span class="project-path">${escapeHtml(parent)}</span>`;
box.addEventListener("click", () => selectProject(group));
return box;
}
@@ -927,6 +966,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwd,
name: nameInput.value,
structuredView: structuredView.checked,
autoApprove: selectedAutoApprove(),
worktreeEnabled: wtEnabled.checked,
worktreeBranch: branchValue(),
worktreeBranchEdited: branchEdited,
+1 -1
View File
@@ -139,7 +139,7 @@ describe("spawnForm Enter-to-submit (#97)", () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
await drain();
form.querySelector<HTMLButtonElement>('.cmd-tiles .tile[data-id="custom"]')!.click();
form.querySelector<HTMLButtonElement>('.cmd-tiles .harness-card[data-id="custom"]')!.click();
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
raw.value = "bash -l";
raw.dispatchEvent(new Event("input"));
+3 -3
View File
@@ -63,7 +63,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
await drain();
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
(t) => t.dataset.id === "zsh",
)!;
zshTile.click();
@@ -88,7 +88,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
document.body.appendChild(form);
await drain();
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
(t) => t.dataset.id === "custom",
)!;
customTile.click();
@@ -116,7 +116,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
// Untouched: the refined preset list is still free to settle the command.
expect(values?.command).toBe("claude");
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
(t) => t.dataset.id === "fish",
);
expect(fishTile).toBeDefined();
+2 -2
View File
@@ -52,14 +52,14 @@ describe("spawn modal", () => {
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
});
it("opens with the project filter focused, ahead of the command tiles", async () => {
it("opens with the project filter focused, harness before project", async () => {
openSpawnModal({ onSuccess: () => {} });
await flush();
const form = modal()!.querySelector("form.spawn-form")!;
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
expect(fields.slice(0, 2)).toEqual(["Project", "Command"]);
expect(fields.slice(0, 2)).toEqual(["Harness", "Project"]);
});
it("titles a seeded open as a clone", async () => {
+126 -13
View File
@@ -6,6 +6,7 @@ vi.mock("./api.js", async () => {
return {
...actual,
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
uploadStructuredFile: vi.fn().mockResolvedValue({ path: "/uploads/pasted.txt" }),
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
setStructuredConfig: vi.fn().mockResolvedValue(undefined),
@@ -16,6 +17,7 @@ import {
cancelStructuredTurn,
resolveStructuredApproval,
sendStructuredPrompt,
uploadStructuredFile,
setStructuredConfig,
} from "./api.js";
import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
@@ -95,6 +97,53 @@ function mount(opts: {
describe("structured connection: data flow", () => {
beforeEach(() => vi.clearAllMocks());
it("converts long pasted text into a removable file and sends it without reply text", async () => {
const conn = mount({ sockets: [new FakeSocket()] });
const input = conn.el.querySelector("textarea")!;
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: {
files: [], getData: () => "a".repeat(4000),
} });
input.dispatchEvent(paste);
expect(paste.defaultPrevented).toBe(true);
expect(input.value).toBe("");
expect(conn.el.querySelector(".structured-attachments")!.textContent).toContain("pasted-1.txt");
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
await vi.waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", expect.stringContaining("/uploads/pasted.txt")));
expect(uploadStructuredFile).toHaveBeenCalledWith("s1", expect.objectContaining({ name: "pasted-1.txt", size: 4000 }));
conn.dispose();
});
it("leaves short pastes alone", () => {
const conn = mount({ sockets: [new FakeSocket()] });
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: { files: [], getData: () => "hello" } });
conn.el.querySelector("textarea")!.dispatchEvent(paste);
expect(paste.defaultPrevented).toBe(false);
conn.dispose();
});
it("retains failed uploads for retry and lets the operator remove them", async () => {
vi.mocked(uploadStructuredFile).mockRejectedValueOnce(new Error("Upload failed"));
const conn = mount({ sockets: [new FakeSocket()] });
const input = conn.el.querySelector("textarea")!;
input.value = "Read this";
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: {
files: [new File(["content"], "notes.txt")], getData: () => "",
} });
input.dispatchEvent(paste);
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
await vi.waitFor(() => expect(conn.el.textContent).toContain("Upload failed"));
expect(input.value).toBe("Read this");
expect(sendStructuredPrompt).not.toHaveBeenCalled();
const remove = conn.el.querySelector<HTMLButtonElement>('[aria-label="Remove notes.txt"]')!;
expect(remove).not.toBeNull();
remove.click();
expect(conn.el.querySelector<HTMLElement>(".structured-attachments")!.hidden).toBe(true);
conn.dispose();
});
it("renders every row from the initial snapshot", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
@@ -364,6 +413,39 @@ describe("structured connection: data flow", () => {
conn.dispose();
});
it("absorbs intermediate agent text into tool group, keeps final text outside", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 8,
rows: [
row("user:1", "user", { content: { type: "text", text: "do it" } }),
row("agent:1", "agent_text", { content: { type: "text", text: "Let me check" } }),
toolRow("tool:1", "one"),
toolRow("tool:2", "two"),
row("agent:2", "agent_text", { content: { type: "text", text: "Found issues, fixing" } }),
toolRow("tool:3", "three"),
toolRow("tool:4", "four"),
row("agent:3", "agent_text", { content: { type: "text", text: "All done" } }),
],
},
});
const groups = conn.el.querySelectorAll<HTMLElement>(".st-tool-group");
expect(groups).toHaveLength(1);
// Header counts only tool rows, not intermediate text.
expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("4 tool calls");
// Intermediate text (agent:2) is inside the group.
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
// First text (agent:1) is outside — it precedes all tools.
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).toBeNull();
// Final text (agent:3) is outside — no tool follows it.
expect(conn.el.querySelector('[data-row-id="agent:3"]')?.closest(".st-tool-group")).toBeNull();
conn.dispose();
});
it("keeps an expanded running tool open without emitting a click when patched", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
@@ -611,7 +693,7 @@ describe("structured connection: turn/composer gating", () => {
conn.dispose();
});
it("disables the composer while a turn is active and re-enables when idle", () => {
it("keeps the composer available while a turn is active", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
@@ -619,13 +701,13 @@ describe("structured connection: turn/composer gating", () => {
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement;
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
expect(input.disabled).toBe(true);
expect(input.disabled).toBe(false);
expect(cancel.hidden).toBe(false);
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(true);
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
ws.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(cancel.hidden).toBe(true);
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(false);
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
conn.dispose();
});
@@ -688,7 +770,7 @@ describe("structured connection: turn/composer gating", () => {
conn.dispose();
});
it("disables immediately and dispatches only once before begin-turn is projected", () => {
it("queues a second message before begin-turn is projected", () => {
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
@@ -701,12 +783,43 @@ describe("structured connection: turn/composer gating", () => {
input.value = "only once";
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
input.value = "queued next";
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
expect(input.disabled).toBe(true);
expect(send.disabled).toBe(true);
expect(input.disabled).toBe(false);
expect(send.disabled).toBe(false);
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("1 message queued");
conn.dispose();
});
it("dispatches queued messages in order as turns become idle", async () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const submit = (text: string) => {
input.value = text;
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
};
submit("first");
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
submit("second");
submit("third");
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("2 messages queued");
await Promise.resolve().then(() => Promise.resolve());
ws.emit({ type: "reduced_state", state: idleState });
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(2, "s1", "second");
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
await Promise.resolve().then(() => Promise.resolve());
ws.emit({ type: "reduced_state", state: idleState });
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(3, "s1", "third");
expect((conn.el.querySelector(".structured-queue") as HTMLElement).hidden).toBe(true);
conn.dispose();
});
@@ -758,7 +871,7 @@ describe("structured connection: turn/composer gating", () => {
expect(sendStructuredPrompt).toHaveBeenCalledTimes(2);
expect(input.value).toBe("");
expect(input.disabled).toBe(true);
expect(input.disabled).toBe(false);
expect(conn.el.textContent).not.toContain("stale failure");
conn.dispose();
});
@@ -1077,7 +1190,7 @@ describe("structured connection: reconnection/error/loading", () => {
second.open();
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
second.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(true);
expect(input.disabled).toBe(false);
second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
second.emit({ type: "reduced_state", state: idleState });
@@ -1129,12 +1242,12 @@ describe("structured connection: reconnection/error/loading", () => {
second.open();
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
second.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(true);
expect(input.disabled).toBe(false);
accept();
await Promise.resolve().then(() => Promise.resolve());
expect(input.disabled).toBe(true);
expect(input.disabled).toBe(false);
expect(second.closed).toBe(true);
third.open();
@@ -1535,7 +1648,7 @@ describe("structured connection: pane lifecycle", () => {
conn.dispose();
});
it("focusTerminal focuses the composer only when it is enabled", () => {
it("focusTerminal keeps the composer reachable during an active turn", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
@@ -1543,7 +1656,7 @@ describe("structured connection: pane lifecycle", () => {
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
conn.focusTerminal();
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
expect(document.activeElement).not.toBe(input);
expect(document.activeElement).toBe(input);
ws.emit({ type: "reduced_state", state: idleState });
conn.focusTerminal();
expect(document.activeElement).toBe(input);
+215 -36
View File
@@ -10,6 +10,7 @@ import {
cancelStructuredTurn,
resolveStructuredApproval,
sendStructuredPrompt,
uploadStructuredFile,
setStructuredConfig,
structuredWsUrl,
} from "./api.js";
@@ -90,7 +91,12 @@ export function createStructuredConnection(
<span class="structured-activity-text">Thinking</span>
</div>
<form class="structured-composer">
<button type="button" class="btn btn-ghost btn-icon structured-attach" aria-label="Attach files" title="Attach files (up to 20 MiB each)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="m8 12 7-7a3 3 0 0 1 4 4L9 19a5 5 0 0 1-7-7L12 2M5 14l9-9"/></svg>
</button>
<input class="structured-file-input" type="file" multiple hidden>
<div class="structured-input-wrap">
<div class="structured-attachments" aria-label="Attachments" aria-live="polite" hidden></div>
<div id="structured-command-menu-${sessionId}" class="structured-command-menu"
role="listbox" hidden></div>
<textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…"
@@ -107,7 +113,11 @@ export function createStructuredConnection(
<button type="submit" class="btn btn-primary structured-send"><span>Send</span>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7 5 5 5-5 5M12 10H3"/></svg>
</button>
<button type="button" class="btn structured-cancel" hidden>Cancel turn</button>
<button type="button" class="btn btn-icon structured-cancel" hidden aria-label="Cancel turn" title="Cancel turn">
<svg class="structured-cancel-spinner" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="50 14" stroke-linecap="round"/></svg>
<svg class="structured-cancel-stop" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/></svg>
</button>
<span class="structured-queue" role="status" aria-live="polite" hidden></span>
</form>
</div>
</div>
@@ -127,7 +137,83 @@ export function createStructuredConnection(
const commandMenu = el.querySelector(".structured-command-menu") as HTMLDivElement;
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
const sendLabel = sendBtn.querySelector("span") as HTMLSpanElement;
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
const queueEl = el.querySelector(".structured-queue") as HTMLSpanElement;
const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement;
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
type Attachment = { file: File; path?: string };
type QueuedPrompt = { text: string; attachments: Attachment[] };
let attachments: Attachment[] = [];
let uploading = false;
let pasteCount = 0;
function renderAttachments(): void {
attachmentEl.replaceChildren();
attachmentEl.hidden = attachments.length === 0;
for (const attachment of attachments) {
const chip = document.createElement("span");
chip.className = "structured-attachment";
const label = document.createElement("span");
label.textContent = attachment.file.name;
label.title = `${attachment.file.name} (${attachment.file.size.toLocaleString()} bytes)`;
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "×";
remove.setAttribute("aria-label", `Remove ${attachment.file.name}`);
remove.disabled = uploading;
remove.addEventListener("click", () => {
attachments = attachments.filter((item) => item !== attachment);
renderAttachments();
});
chip.append(label, remove);
attachmentEl.append(chip);
}
}
function addFiles(files: File[]): void {
if (uploading || disposed) return;
for (const file of files) {
if (file.size > 20 * 1024 * 1024) {
showError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
} else if (attachments.length >= 10) {
showError("Attach up to 10 files per message.");
} else {
attachments.push({ file });
}
}
renderAttachments();
}
attachBtn.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => {
addFiles(Array.from(fileInput.files ?? []));
fileInput.value = "";
});
composerForm.addEventListener("dragover", (event) => {
if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
});
composerForm.addEventListener("drop", (event) => {
if (!event.dataTransfer?.files.length) return;
event.preventDefault();
addFiles(Array.from(event.dataTransfer.files));
});
input.addEventListener("paste", (event) => {
const clipboard = event.clipboardData;
if (!clipboard || uploading) return;
if (clipboard.files.length) {
event.preventDefault();
addFiles(Array.from(clipboard.files));
return;
}
const text = clipboard.getData("text/plain");
if (text.length < 4000 && text.split("\n").length < 40) return;
if (attachments.length >= 10 || new Blob([text]).size > 20 * 1024 * 1024) return;
event.preventDefault();
addFiles([new File([text], `pasted-${++pasteCount}.txt`, { type: "text/plain" })]);
input.setRangeText("", input.selectionStart, input.selectionEnd, "end");
autoResize();
});
disconnectBtn.addEventListener("click", () => opts.onRequestClose?.(sessionId));
// Mobile-only (CSS hides it on desktop, where the sidebar is always in
@@ -165,6 +251,8 @@ export function createStructuredConnection(
let disposed = false;
let optimisticTurnStartedAt: number | null = null;
let activityTimer: number | null = null;
const promptQueue: QueuedPrompt[] = [];
let promptRequestPending = false;
// Within 64px, wheel and touch imprecision should still count as following.
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
@@ -314,17 +402,53 @@ export function createStructuredConnection(
return el;
}
function toolGroupsByTurn(): Map<string, TranscriptRow[]> {
const groups = new Map<string, TranscriptRow[]>();
/** Group tool rows (and intermediate agent text between them) by turn.
* An agent_text/agent_thought row is "intermediate" when a tool row follows
* it in the same turn — it gets absorbed into the group so the collapsed
* block preserves chronological order. The *last* agent text with no
* subsequent tool stays standalone (the prominent final answer).
* Returns groups keyed by `turn:<userId>`, each with `toolCount` (for the
* header label) and `rows` (tools + intermediate text, in order). */
function activityGroupsByTurn(): Map<string, { toolCount: number; rows: TranscriptRow[] }> {
// First: count tools per turn to know which turns qualify for grouping.
const toolCounts = new Map<string, number>();
let turnId = "before-first-message";
for (const row of rows) {
if (row.kind === "user") turnId = row.id;
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
const id = `turn:${turnId}`;
const group = groups.get(id) ?? [];
group.push(row);
groups.set(id, group);
const gid = `turn:${turnId}`;
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
}
// Second: build groups with interleaved text for turns with 2+ tools.
const groups = new Map<string, { toolCount: number; rows: TranscriptRow[] }>();
turnId = "before-first-message";
// Pending text rows that might be intermediate (not yet confirmed by a
// following tool). If no tool follows, they stay standalone.
let pendingText: TranscriptRow[] = [];
let currentGid = `turn:${turnId}`;
for (const row of rows) {
if (row.kind === "user") {
turnId = row.id;
currentGid = `turn:${turnId}`;
pendingText = [];
continue;
}
const count = toolCounts.get(currentGid) ?? 0;
if (count < 2) continue;
if (row.kind === "agent_text" || row.kind === "agent_thought") {
pendingText.push(row);
continue;
}
if (row.kind === "tool_call" || row.kind === "tool_diff") {
const group = groups.get(currentGid) ?? { toolCount: count, rows: [] };
// Flush any pending text — a tool follows, so it's intermediate.
group.rows.push(...pendingText);
pendingText = [];
group.rows.push(row);
groups.set(currentGid, group);
}
}
// pendingText at end of a turn = final text, stays outside (not flushed).
return groups;
}
@@ -334,9 +458,9 @@ export function createStructuredConnection(
const focusedGroupId = active?.closest<HTMLElement>("[data-tool-group-id]")?.dataset.toolGroupId;
const restoreToolFocus = active?.classList.contains("st-tool-head") ?? false;
const restoreGroupFocus = active?.classList.contains("st-tool-group-head") ?? false;
const groups = toolGroupsByTurn();
const groups = activityGroupsByTurn();
const groupedRowIds = new Set<string>();
const liveGroupIds = new Set([...groups].filter(([, tools]) => tools.length > 1).map(([id]) => id));
const liveGroupIds = new Set([...groups.keys()]);
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
rowEls.clear();
@@ -346,28 +470,30 @@ export function createStructuredConnection(
for (const row of rows) {
if (row.kind === "user") turnId = row.id;
const groupId = `turn:${turnId}`;
const groupRows = groups.get(groupId) ?? [];
if ((row.kind === "tool_call" || row.kind === "tool_diff") && groupRows.length > 1) {
if (groupedRowIds.has(row.id)) continue;
const toolEls = groupRows.flatMap((toolRow) => {
groupedRowIds.add(toolRow.id);
const toolEl = buildRowElement(toolRow);
if (!toolEl) return [];
rowEls.set(toolRow.id, toolEl);
return [toolEl];
const group = groups.get(groupId);
if (group && groupedRowIds.has(row.id)) continue;
if (group && (row.kind === "tool_call" || row.kind === "tool_diff") && !groupedRowIds.has(row.id)) {
// First ungrouped tool in this turn — render the whole activity group.
const memberEls = group.rows.flatMap((memberRow) => {
groupedRowIds.add(memberRow.id);
const memberEl = buildRowElement(memberRow);
if (!memberEl) return [];
rowEls.set(memberRow.id, memberEl);
return [memberEl];
});
const groupEl = buildToolGroup(
groupId,
groupRows,
toolEls,
group.rows,
memberEls,
expandedGroupIds.has(groupId),
(expanded) => {
if (expanded) expandedGroupIds.add(groupId);
else expandedGroupIds.delete(groupId);
settleDisclosureLayout();
},
group.toolCount,
);
if (groupRows.some((toolRow) => enteringRowIds.has(toolRow.id))) {
if (group.rows.some((r) => enteringRowIds.has(r.id))) {
groupEl.classList.add("st-row-enter");
}
groupEls.set(groupId, groupEl);
@@ -527,13 +653,19 @@ export function createStructuredConnection(
}
function renderComposerState(): void {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const projectedActive = state.turn.responding || state.turn.thinking;
input.disabled = active;
sendBtn.disabled = active;
sendBtn.hidden = projectedActive;
input.disabled = false;
attachBtn.disabled = uploading;
fileInput.disabled = uploading;
renderAttachments();
sendBtn.disabled = false;
sendBtn.hidden = false;
sendLabel.textContent = "Send";
cancelBtn.hidden = !projectedActive;
if (active) closeCommandMenu();
queueEl.hidden = promptQueue.length === 0;
queueEl.textContent = promptQueue.length === 1
? "1 message queued"
: `${promptQueue.length} messages queued`;
renderActivity();
}
@@ -595,6 +727,7 @@ export function createStructuredConnection(
}
renderConfig();
renderComposerState();
dispatchQueuedPrompt();
}
function closeConfigMenu(): void {
@@ -794,35 +927,55 @@ export function createStructuredConnection(
if (!commandMenu.contains(document.activeElement)) closeCommandMenu();
}, 0);
});
composerForm.addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value;
if (!text.trim() || input.disabled) return;
function dispatchPrompt(text: string, submittedAttachments: Attachment[] = []): void {
uploading = submittedAttachments.length > 0;
const generation = ++submissionGeneration;
const submissionSocketGeneration = socketGeneration;
awaitingBeginGeneration = generation;
optimisticTurnStartedAt = Date.now();
acceptedSubmission = null;
promptRequestPending = true;
input.value = "";
autoResize();
clearError();
renderComposerState();
sendStructuredPrompt(sessionId, text).then(
const send = async () => {
for (const attachment of submittedAttachments) {
if (disposed) return;
if (!attachment.path) attachment.path = (await uploadStructuredFile(sessionId, attachment.file)).path;
}
if (disposed) return;
const references = submittedAttachments.map(({ file, path }) =>
`Attached file ${JSON.stringify(file.name)}: ${JSON.stringify(path)}`);
await sendStructuredPrompt(sessionId, [text, ...references].filter(Boolean).join("\n\n"));
};
void send().then(
() => {
if (
disposed
|| generation !== submissionGeneration
|| awaitingBeginGeneration !== generation
) return;
promptRequestPending = false;
if (disposed || generation !== submissionGeneration) return;
uploading = false;
if (attachments === submittedAttachments) {
attachments = [];
renderAttachments();
}
renderComposerState();
if (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();
renderComposerState();
dispatchQueuedPrompt();
},
(e: unknown) => {
promptRequestPending = false;
uploading = false;
if (disposed || generation !== submissionGeneration) return;
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
if (attachments.length === 0 && submittedAttachments.length > 0) {
attachments = submittedAttachments;
}
if (input.value === "") {
input.value = text;
autoResize();
@@ -831,6 +984,32 @@ export function createStructuredConnection(
renderComposerState();
},
);
}
function dispatchQueuedPrompt(): void {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
if (disposed || active || promptRequestPending || promptQueue.length === 0) return;
const next = promptQueue.shift()!;
dispatchPrompt(next.text, next.attachments);
}
composerForm.addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value;
if ((!text.trim() && attachments.length === 0) || uploading) return;
const submittedAttachments = attachments;
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
if (active || promptQueue.length > 0) {
promptQueue.push({ text, attachments: submittedAttachments });
attachments = [];
input.value = "";
autoResize();
clearError();
renderComposerState();
dispatchQueuedPrompt();
return;
}
dispatchPrompt(text, submittedAttachments);
});
cancelBtn.addEventListener("click", () => {
clearError();
+4 -9
View File
@@ -581,7 +581,9 @@ export function buildToolGroup(
children: HTMLElement[],
expanded: boolean,
onExpandedChange?: (expanded: boolean) => void,
toolCount?: number,
): HTMLElement {
const count = toolCount ?? rows.length;
const wrap = el("div", "st-tool-group");
wrap.dataset.toolGroupId = id;
const head = document.createElement("button");
@@ -593,15 +595,8 @@ export function buildToolGroup(
glyph.textContent = "›";
glyph.setAttribute("aria-hidden", "true");
const title = el("span", "st-tool-group-title");
title.textContent = `${rows.length} tool calls`;
const names = el("span", "st-tool-group-names");
names.textContent = rows
.map((row) => {
const tool = (row.data ?? {}) as AcpToolCallLike;
return splitToolTitle(tool.title, tool.name, tool.kind).label;
})
.join(" · ");
head.append(glyph, title, names);
title.textContent = `${count} tool calls`;
head.append(glyph, title);
const body = el("div", "st-tool-group-body st-disclosure");
const bodyClip = el("div", "st-disclosure-clip");
+216 -96
View File
@@ -2290,6 +2290,9 @@ input.leaf-rename:focus {
.structured-main {
min-width: 0;
min-height: 0;
max-width: 820px;
margin-inline: auto;
width: 100%;
display: flex;
flex-direction: column;
flex: 1 1 auto;
@@ -2828,12 +2831,12 @@ input.leaf-rename:focus {
border: 1px solid var(--border);
border-radius: var(--r-2);
background: color-mix(in oklab, var(--surface-2) 55%, transparent);
padding: var(--sp-2) var(--sp-3);
padding: var(--sp-1) var(--sp-2);
transition: background var(--dur-fast) var(--ease-out);
}
.st-tool-group:hover {
background: var(--surface-2);
background: color-mix(in oklab, var(--surface-2) 70%, transparent);
}
.st-tool-group-head {
@@ -2870,15 +2873,6 @@ input.leaf-rename:focus {
font-weight: 500;
}
.st-tool-group-names {
min-width: 0;
overflow: hidden;
color: var(--muted);
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.st-tool-group-body {
padding-top: 0;
}
@@ -2886,11 +2880,42 @@ input.leaf-rename:focus {
.st-tool-group-body .st-disclosure-inner {
display: flex;
flex-direction: column;
padding-top: var(--sp-2);
padding-top: var(--sp-1);
}
.st-tool-group-body .st-disclosure-inner > .st-row-tool {
padding: var(--sp-1) 0;
padding: 0;
}
/* inside a group, tool cards lose their border and background —
the group container already provides the visual boundary. */
.st-tool-group-body .st-tool {
border: 0;
}
.st-tool-group-body .st-tool-head {
padding: var(--sp-1) var(--sp-2);
border-radius: var(--r-1);
}
/* intermediate agent text inside a tool group — subdued so the final
answer outside the group stays the prominent one. */
.st-tool-group-body .st-row-agent {
color: var(--muted);
font-size: var(--fs-12);
padding: var(--sp-1) var(--sp-2);
}
.st-tool-group-body .st-row-agent .st-body > :first-child {
margin-top: 0;
}
.st-tool-group-body .st-row-agent .st-body > :last-child {
margin-bottom: 0;
}
.st-tool-group-body .st-row-thought {
padding: var(--sp-1) var(--sp-2);
}
.st-tool-group-body[hidden] {
@@ -3062,31 +3087,38 @@ input.leaf-rename:focus {
}
/* The composer is a sibling of the scrolling transcript, not part of it: it
stays reachable at the pane edge without changing transcript scroll math. */
stays reachable at the pane edge without changing transcript scroll math.
It is a flush band at the foot of the pane rather than a floating card: its
own `--surface` fill is what separates it from the transcript, over a
hairline that doubles as the focus indicator. Inner metrics line the draft
up with the transcript's own 12px gutter. */
.structured-composer {
position: relative;
flex: 0 0 auto;
margin: var(--sp-2) var(--sp-3) max(var(--sp-2), env(safe-area-inset-bottom));
padding: var(--sp-1);
border: 1px solid var(--border-strong);
border-radius: var(--r-3);
padding: var(--sp-1) var(--sp-1) max(var(--sp-1), env(safe-area-inset-bottom));
border-top: 1px solid var(--border);
background: var(--surface);
display: flex;
align-items: flex-end;
gap: var(--sp-1);
box-shadow:
inset 0 1px 0 color-mix(in oklab, var(--ink) 6%, transparent),
var(--shadow-1);
transition:
border-color var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out);
transition: border-color var(--dur-fast) var(--ease-out);
}
.structured-queue {
position: absolute;
right: var(--sp-2);
bottom: calc(100% + var(--sp-1));
padding: 3px var(--sp-2);
border: 1px solid var(--border);
border-radius: var(--r-pill);
background: var(--surface);
color: var(--muted);
font-size: var(--fs-11);
box-shadow: var(--shadow-1);
}
.structured-composer:focus-within {
border-color: var(--accent);
box-shadow:
inset 0 1px 0 color-mix(in oklab, var(--ink) 8%, transparent),
0 0 0 3px var(--accent-tint),
var(--shadow-1);
border-top-color: var(--border);
}
.structured-input-wrap {
@@ -3095,6 +3127,45 @@ input.leaf-rename:focus {
min-width: 0;
}
.structured-attachments {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1);
padding: var(--sp-1);
}
.structured-attachments[hidden] { display: none; }
.structured-attachment {
display: inline-flex;
align-items: center;
min-width: 0;
max-width: 100%;
padding-left: var(--sp-2);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-1);
font-size: var(--fs-11);
}
.structured-attachment > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.structured-attachment button {
flex-shrink: 0;
min-width: var(--tap);
min-height: var(--tap);
border: 0;
background: transparent;
color: var(--muted);
}
.structured-attachment button:hover { color: var(--ink); }
.structured-attachment button:disabled { opacity: 0.5; }
.structured-input {
display: block;
width: 100%;
@@ -3112,6 +3183,10 @@ input.leaf-rename:focus {
line-height: 20px;
}
.structured-input:focus-visible {
outline: none;
}
.structured-composer .structured-send,
.structured-composer .structured-cancel {
height: 40px;
@@ -3159,7 +3234,9 @@ input.leaf-rename:focus {
}
.structured-composer .structured-cancel {
padding: 0 var(--sp-3);
position: relative;
width: 40px;
padding: 0;
border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
color: var(--danger);
}
@@ -3168,18 +3245,47 @@ input.leaf-rename:focus {
background: var(--danger-tint);
}
.structured-cancel-stop {
width: 16px;
height: 16px;
}
.structured-cancel-spinner {
position: absolute;
inset: 4px;
width: auto;
height: auto;
animation: cancel-spin 0.8s linear infinite;
}
@keyframes cancel-spin {
to { transform: rotate(360deg); }
}
@media (max-width: 520px) {
/* One row cannot hold a draft plus three controls at phone width — the
draft gets a line of its own and the controls drop below it. */
.structured-composer {
margin: var(--sp-1) 0 0;
padding-bottom: calc(var(--sp-1) + env(safe-area-inset-bottom, 0px));
border-right: 0;
border-bottom: 0;
border-left: 0;
border-radius: var(--r-2) var(--r-2) 0 0;
flex-wrap: wrap;
justify-content: flex-end;
padding-left: var(--sp-2);
padding-right: var(--sp-2);
}
/* `order` keeps the draft on the first line: without it the attach button,
which precedes it in the DOM, wraps onto a line of its own. */
.structured-input-wrap {
flex: 1 1 100%;
order: -1;
}
.structured-composer .structured-config {
margin-right: auto;
}
.structured-config-menu {
right: calc(-1 * (var(--control-h) + var(--sp-2)));
left: 0;
right: auto;
}
}
@@ -3393,14 +3499,9 @@ input.leaf-rename:focus {
overflow-y: visible;
}
/* Rides the bottom of the scroll box: reachable at any scroll position, and
`margin-top: auto` keeps it at the screen's foot when the form is short. */
.spawn-modal .form-actions {
position: sticky;
bottom: 0;
margin-top: auto;
padding: var(--sp-2) 0 calc(var(--sp-2) + env(safe-area-inset-bottom, 0px));
background: var(--surface);
padding-bottom: calc(var(--sp-2) + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -8px 12px -8px oklch(0 0 0 / 0.5);
}
@@ -3482,20 +3583,16 @@ input.leaf-rename:focus {
flex: 0 0 auto;
}
.worktree {
border: 1px solid var(--border);
border-radius: var(--r-2);
padding: 0 var(--sp-3);
margin: 0;
.advanced {
display: flex;
flex-direction: column;
background: color-mix(in oklab, var(--surface-2) 50%, transparent);
}
.worktree .wt-toggle {
margin: 0 calc(var(--sp-3) * -1);
padding-left: var(--sp-3);
width: calc(100% + var(--sp-3) * 2);
.advanced .wt-toggle {
border-top: 1px solid var(--border);
border-radius: 0;
padding-top: var(--sp-2);
padding-bottom: var(--sp-2);
}
.wt-summary {
@@ -3507,13 +3604,27 @@ input.leaf-rename:focus {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding-bottom: var(--sp-3);
padding: var(--sp-2) 0 var(--sp-1);
}
.wt-collapsible[hidden] {
display: none;
}
/* Worktree sub-group: left accent border when expanded, visually nested. */
.adv-group {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding-left: var(--sp-3);
border-left: 2px solid var(--border);
margin-top: var(--sp-1);
}
.adv-group:has(input[name="worktree_enabled"]:checked) {
border-left-color: var(--accent);
}
.form-error {
color: var(--danger);
margin: 0;
@@ -3540,61 +3651,78 @@ input.leaf-rename:focus {
color: var(--ink);
}
/* One-click tiles: host row + command row. */
.tile-row {
display: flex;
flex-wrap: wrap;
/* Harness cards: the first decision in the spawn form — which tool runs. */
.harness-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: var(--sp-2);
}
.tile {
flex: 1 1 auto;
min-width: 96px;
.harness-card {
display: flex;
flex-direction: column;
gap: 2px;
align-items: flex-start;
padding: var(--sp-2) var(--sp-3);
align-items: center;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-2);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-2);
color: var(--ink);
font-family: inherit;
cursor: pointer;
text-align: left;
text-align: center;
transition:
border-color var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out);
}
.tile:hover {
.harness-card:hover {
border-color: var(--border-strong);
background: var(--surface-3);
}
.tile.is-active {
.harness-card.is-active {
border-color: var(--accent);
background: var(--accent-tint);
}
.tile-label {
font-size: var(--fs-14);
font-weight: 600;
}
.tile-sub {
font-size: var(--fs-12);
color: var(--muted);
font-family: var(--font-mono);
}
.tile-more {
flex: 0 0 auto;
min-width: 0;
justify-content: center;
.harness-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: var(--muted);
}
.harness-card.is-active .harness-icon {
color: var(--ink);
}
.harness-icon .tool-icon {
display: block;
}
.harness-label {
font-size: var(--fs-13);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* The "Custom…" card is an escape hatch, not a peer tool — dashed border
and subdued color separate it from the named harnesses. */
.harness-custom {
border-style: dashed;
color: var(--muted);
}
.harness-custom .harness-label {
font-weight: 500;
}
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
picked project's worktree choices. Stale beyond 7 days dims but stays
selectable. */
@@ -3621,7 +3749,7 @@ input.leaf-rename:focus {
.picker.projects {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
grid-template-columns: repeat(2, 1fr);
align-content: start;
gap: var(--sp-2);
max-height: 300px;
@@ -3677,19 +3805,6 @@ input.leaf-rename:focus {
overflow-wrap: anywhere;
}
.project-meta {
font-family: var(--font-mono);
font-size: var(--fs-12);
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-meta:empty {
display: none;
}
.project-other {
align-self: flex-start;
padding: 4px 0;
@@ -3831,6 +3946,11 @@ input.leaf-rename:focus {
gap: var(--sp-2);
justify-content: flex-end;
margin-top: var(--sp-2);
position: sticky;
bottom: 0;
padding: var(--sp-2) 0;
background: var(--surface);
z-index: 1;
}
.reroll {
+2
View File
@@ -91,6 +91,7 @@ export interface SpawnPreset {
id: string;
label: string;
command: string;
auto_approve?: boolean;
}
/** `GET /api/info` — the daemon's identity plus its spawn presets (#26).
@@ -277,6 +278,7 @@ export interface CreateSessionRequest {
metadata?: Record<string, unknown>;
worktree?: WorktreeSpec;
view?: SessionView;
auto_approve?: boolean;
}
export type CatalogEvent =