Compare commits
16
Commits
recorder-2
...
etude
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed5a088299 | ||
|
|
65d7186e1c | ||
|
|
47654aa434 | ||
|
|
6ca2ab1707 | ||
|
|
b0b0160ccb | ||
|
|
c4cd8a691e | ||
|
|
0381a571f2 | ||
|
|
8f3d47c49e | ||
|
|
ba0c54759b | ||
|
|
78912e7925 | ||
|
|
fef9be6c01 | ||
|
|
5387b29049 | ||
|
|
88afd0df3f | ||
|
|
df51aa7be9 | ||
|
|
ac8a1b319d | ||
|
|
43bfda5a60 |
@@ -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:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
|||||||
env,
|
env,
|
||||||
metadata,
|
metadata,
|
||||||
worktree,
|
worktree,
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
client.create_session(&body).await
|
client.create_session(&body).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ async fn info_carries_version_and_presets() {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
}],
|
}],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
@@ -97,6 +98,7 @@ async fn spawn_round_trips_through_the_daemon() {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata: Default::default(),
|
metadata: Default::default(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
|||||||
@@ -313,16 +313,19 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "opencode".into(),
|
id: "opencode".into(),
|
||||||
label: "opencode".into(),
|
label: "opencode".into(),
|
||||||
command: "opencode".into(),
|
command: "opencode".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "zsh".into(),
|
id: "zsh".into(),
|
||||||
label: "Shell".into(),
|
label: "Shell".into(),
|
||||||
command: "zsh".into(),
|
command: "zsh".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -334,6 +337,7 @@ pub fn custom_preset() -> SpawnPreset {
|
|||||||
id: CUSTOM_PRESET_ID.into(),
|
id: CUSTOM_PRESET_ID.into(),
|
||||||
label: "Custom…".into(),
|
label: "Custom…".into(),
|
||||||
command: String::new(),
|
command: String::new(),
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,6 +519,7 @@ auto_install = false
|
|||||||
id: "fish".into(),
|
id: "fish".into(),
|
||||||
label: "fish".into(),
|
label: "fish".into(),
|
||||||
command: "fish".into(),
|
command: "fish".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -551,6 +556,7 @@ auto_install = false
|
|||||||
id: " ".into(),
|
id: " ".into(),
|
||||||
label: "x".into(),
|
label: "x".into(),
|
||||||
command: "x".into(),
|
command: "x".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -561,6 +567,7 @@ auto_install = false
|
|||||||
id: "x".into(),
|
id: "x".into(),
|
||||||
label: "x".into(),
|
label: "x".into(),
|
||||||
command: "".into(),
|
command: "".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -578,11 +585,13 @@ auto_install = false
|
|||||||
id: "a".into(),
|
id: "a".into(),
|
||||||
label: "a".into(),
|
label: "a".into(),
|
||||||
command: "a".into(),
|
command: "a".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "a".into(),
|
id: "a".into(),
|
||||||
label: "a2".into(),
|
label: "a2".into(),
|
||||||
command: "a2".into(),
|
command: "a2".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -597,6 +606,7 @@ auto_install = false
|
|||||||
id: "custom".into(),
|
id: "custom".into(),
|
||||||
label: "Custom".into(),
|
label: "Custom".into(),
|
||||||
command: "whatever".into(),
|
command: "whatever".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ pub struct SessionRecord {
|
|||||||
/// Completion signal for the task draining ACP notifications into the
|
/// Completion signal for the task draining ACP notifications into the
|
||||||
/// event log. Shutdown waits for it before returning.
|
/// event log. Shutdown waits for it before returning.
|
||||||
acp_event_pump: Option<watch::Receiver<bool>>,
|
acp_event_pump: Option<watch::Receiver<bool>>,
|
||||||
|
/// When `true`, the permission pump auto-responds `AllowAlways` to every
|
||||||
|
/// ACP permission request without surfacing it to clients.
|
||||||
|
pub auto_approve: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionRecord {
|
impl SessionRecord {
|
||||||
@@ -1444,7 +1447,7 @@ impl SessionManager {
|
|||||||
let mut sessions = self.inner.sessions.lock().await;
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
let event_pump = event_pump.map(|(acp, events)| {
|
let event_pump = event_pump.map(|(acp, events)| {
|
||||||
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
|
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
|
||||||
start_acp_permission_pump(&self.inner, id, &acp, events);
|
start_acp_permission_pump(&self.inner, id, &acp, events, req.auto_approve);
|
||||||
done
|
done
|
||||||
});
|
});
|
||||||
sessions.insert(
|
sessions.insert(
|
||||||
@@ -1465,6 +1468,7 @@ impl SessionManager {
|
|||||||
acp_events,
|
acp_events,
|
||||||
structured_activity_watermark: None,
|
structured_activity_watermark: None,
|
||||||
acp_event_pump: event_pump,
|
acp_event_pump: event_pump,
|
||||||
|
auto_approve: req.auto_approve,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1677,6 +1681,7 @@ impl SessionManager {
|
|||||||
acp_events: None,
|
acp_events: None,
|
||||||
structured_activity_watermark: None,
|
structured_activity_watermark: None,
|
||||||
acp_event_pump: None,
|
acp_event_pump: None,
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let _ = self
|
let _ = self
|
||||||
@@ -1854,11 +1859,14 @@ impl SessionManager {
|
|||||||
acp_events,
|
acp_events,
|
||||||
structured_activity_watermark: None,
|
structured_activity_watermark: None,
|
||||||
acp_event_pump: None,
|
acp_event_pump: None,
|
||||||
|
// Recovery cannot know the original preset's flag; the
|
||||||
|
// operator re-enables it by restarting with the preset.
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if let Some((acp, events)) = event_pump {
|
if let Some((acp, events)) = event_pump {
|
||||||
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
|
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
|
||||||
start_acp_permission_pump(&self.inner, id, &acp, events);
|
start_acp_permission_pump(&self.inner, id, &acp, events, false);
|
||||||
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
|
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
|
||||||
record.acp_event_pump = Some(done);
|
record.acp_event_pump = Some(done);
|
||||||
}
|
}
|
||||||
@@ -2357,6 +2365,7 @@ fn start_acp_permission_pump(
|
|||||||
id: Uuid,
|
id: Uuid,
|
||||||
acp: &Arc<AcpSession>,
|
acp: &Arc<AcpSession>,
|
||||||
events: Arc<Mutex<AcpEventStream>>,
|
events: Arc<Mutex<AcpEventStream>>,
|
||||||
|
auto_approve: bool,
|
||||||
) {
|
) {
|
||||||
let Some(mut requests) = acp.take_permission_requests() else {
|
let Some(mut requests) = acp.take_permission_requests() else {
|
||||||
return;
|
return;
|
||||||
@@ -2370,6 +2379,12 @@ fn start_acp_permission_pump(
|
|||||||
response,
|
response,
|
||||||
}) = requests.recv().await
|
}) = requests.recv().await
|
||||||
{
|
{
|
||||||
|
if auto_approve {
|
||||||
|
let outcome =
|
||||||
|
permission_outcome(ApprovalDecision::AllowForSession, &request.options);
|
||||||
|
let _ = response.send(outcome);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let mut events = events.lock().await;
|
let mut events = events.lock().await;
|
||||||
if events.projection.active_turn() != Some(turn) {
|
if events.projection.active_turn() != Some(turn) {
|
||||||
let _ = response.send(maestro_acp::schema::RequestPermissionOutcome::Cancelled);
|
let _ = response.send(maestro_acp::schema::RequestPermissionOutcome::Cancelled);
|
||||||
@@ -3090,6 +3105,7 @@ mod tests {
|
|||||||
env: std::collections::HashMap::new(),
|
env: std::collections::HashMap::new(),
|
||||||
metadata: std::collections::HashMap::new(),
|
metadata: std::collections::HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ fn structured_command(
|
|||||||
env,
|
env,
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,6 +706,7 @@ async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
|
|||||||
branch: branch.into(),
|
branch: branch.into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1187,6 +1189,7 @@ async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
|
|||||||
branch: "cancelled-create".into(),
|
branch: "cancelled-create".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let create = {
|
let create = {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata: Default::default(),
|
metadata: Default::default(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ async fn create_bash_session(h: &Harness) -> Uuid {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata,
|
metadata,
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> Creat
|
|||||||
branch: branch.into(),
|
branch: branch.into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>)
|
|||||||
env,
|
env,
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -547,6 +548,7 @@ async fn structured_prompt_rejects_unknown_and_unavailable_sessions() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -680,6 +682,7 @@ async fn structured_replay_rejects_unknown_terminal_and_dead_sessions() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -822,6 +825,7 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
let created: Session = reqwest::Client::new()
|
let created: Session = reqwest::Client::new()
|
||||||
.post(url(h.addr, "/api/sessions"))
|
.post(url(h.addr, "/api/sessions"))
|
||||||
@@ -1039,6 +1043,7 @@ async fn patch_session_renames_it() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1082,6 +1087,7 @@ async fn post_focus_stamps_last_focused_at() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1116,6 +1122,7 @@ async fn delete_owned_session_kills_it() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1138,6 +1145,7 @@ async fn spawn_via_manager(h: &Harness) -> Uuid {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -1252,6 +1260,7 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
|
|||||||
branch: "feat/guarded".into(),
|
branch: "feat/guarded".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1305,6 +1314,7 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
|
|||||||
branch: "feat/guarded".into(),
|
branch: "feat/guarded".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1335,6 +1345,7 @@ async fn attach_ws_streams_pty_bytes() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1437,6 +1448,7 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1549,6 +1561,7 @@ async fn every_events_subscriber_gets_its_own_snapshot() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata,
|
metadata,
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ impl Fx {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ async fn create_then_kill_round_trip() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -70,6 +71,7 @@ async fn create_appends_to_spawn_history() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]),
|
metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -100,6 +102,7 @@ async fn create_without_history_logs_nothing() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -123,6 +126,7 @@ async fn recover_adopts_existing_tmux_sessions() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -166,6 +170,7 @@ async fn create_captures_initial_output_in_replay_buffer() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -215,6 +220,7 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -273,6 +279,7 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -324,6 +331,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -387,6 +395,7 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -438,6 +447,7 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -508,6 +518,7 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
|
|||||||
branch: "feat/indicator".into(),
|
branch: "feat/indicator".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -601,6 +612,7 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
|
|||||||
branch: "feat/keep".into(),
|
branch: "feat/keep".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -639,6 +651,7 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
|
|||||||
branch: "feat/guarded".into(),
|
branch: "feat/guarded".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -703,6 +716,7 @@ async fn killing_refuses_until_the_branch_is_pushed() {
|
|||||||
branch: "feat/pushed".into(),
|
branch: "feat/pushed".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -753,6 +767,7 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
|
|||||||
branch: "feat/forced".into(),
|
branch: "feat/forced".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -799,6 +814,7 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
|||||||
branch: "feat/clear".into(),
|
branch: "feat/clear".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -840,6 +856,7 @@ async fn killing_an_ignored_only_worktree_succeeds() {
|
|||||||
branch: "feat/rusty".into(),
|
branch: "feat/rusty".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -882,6 +899,7 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
|
|||||||
branch: "feat/exited".into(),
|
branch: "feat/exited".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -944,6 +962,7 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
|||||||
branch: "feat/clean".into(),
|
branch: "feat/clean".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1001,6 +1020,7 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1040,6 +1060,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
|||||||
branch: "feat/one".into(),
|
branch: "feat/one".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create first");
|
.expect("create first");
|
||||||
@@ -1058,6 +1079,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
|||||||
branch: "feat/two".into(),
|
branch: "feat/two".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create second");
|
.expect("create second");
|
||||||
@@ -1093,6 +1115,7 @@ async fn spawned_sessions_export_maestro_session_id_and_url() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1132,6 +1155,7 @@ async fn a_freshly_created_session_has_never_been_focused() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1158,6 +1182,7 @@ async fn set_focused_true_stamps_last_focused_at() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1207,6 +1232,7 @@ async fn attaching_stamps_last_focused_at() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +112,7 @@ fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> Cr
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(base, branch, create_branch)),
|
worktree: Some(worktree_spec(base, branch, create_branch)),
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +208,7 @@ async fn kill_removes_clean_worktree() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
|
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||||
@@ -260,6 +263,7 @@ async fn kill_refuses_worktree_with_uncommitted_changes() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
|
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||||
@@ -319,6 +323,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
|
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let s = client
|
let s = client
|
||||||
@@ -827,6 +832,7 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
|
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("spawn dirty");
|
.expect("spawn dirty");
|
||||||
@@ -876,6 +882,7 @@ fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateS
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(cwd, branch, true)),
|
worktree: Some(worktree_spec(cwd, branch, true)),
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,6 +1075,7 @@ async fn sigterm_stops_structured_child_before_events_ws_drains() {
|
|||||||
env: fake.env.clone(),
|
env: fake.env.clone(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("spawn structured session");
|
.expect("spawn structured session");
|
||||||
|
|||||||
@@ -383,6 +383,10 @@ pub struct CreateSessionRequest {
|
|||||||
/// untouched.
|
/// untouched.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub view: SessionView,
|
pub view: SessionView,
|
||||||
|
/// When `true`, the daemon auto-approves every ACP permission request
|
||||||
|
/// for this structured session.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_approve: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of `GET /api/dirs`: the immediate subdirectories of one directory.
|
/// Result of `GET /api/dirs`: the immediate subdirectories of one directory.
|
||||||
@@ -522,6 +526,11 @@ pub struct SpawnPreset {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub command: String,
|
pub command: String,
|
||||||
|
/// When `true`, the daemon auto-approves every ACP permission request
|
||||||
|
/// for structured sessions spawned from this preset instead of surfacing
|
||||||
|
/// them to the client.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_approve: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build provenance of the running daemon: the commit it was compiled
|
/// Build provenance of the running daemon: the commit it was compiled
|
||||||
@@ -1251,6 +1260,7 @@ mod tests {
|
|||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
view: SessionView::Terminal,
|
view: SessionView::Terminal,
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
assert_eq!(req, round_trip(&req));
|
assert_eq!(req, round_trip(&req));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3105,6 +3105,7 @@ mod tests {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata: Default::default(),
|
metadata: Default::default(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
app.submit_spawn(req).await;
|
app.submit_spawn(req).await;
|
||||||
|
|
||||||
@@ -3153,6 +3154,7 @@ mod tests {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata: Default::default(),
|
metadata: Default::default(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let structured_id = Uuid::from_u128(188);
|
let structured_id = Uuid::from_u128(188);
|
||||||
|
|||||||
@@ -734,6 +734,10 @@ impl DraftPane {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let worktree = self.build_worktree()?;
|
let worktree = self.build_worktree()?;
|
||||||
|
let auto_approve = self
|
||||||
|
.presets
|
||||||
|
.get(self.selected_preset)
|
||||||
|
.is_some_and(|p| p.auto_approve);
|
||||||
Ok(CreateSessionRequest {
|
Ok(CreateSessionRequest {
|
||||||
view: if self.structured_view && self.supports_structured_view() {
|
view: if self.structured_view && self.supports_structured_view() {
|
||||||
SessionView::Structured
|
SessionView::Structured
|
||||||
@@ -745,6 +749,7 @@ impl DraftPane {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata,
|
metadata,
|
||||||
worktree,
|
worktree,
|
||||||
|
auto_approve,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1792,6 +1797,7 @@ mod tests {
|
|||||||
id: "aider".into(),
|
id: "aider".into(),
|
||||||
label: "Aider".into(),
|
label: "Aider".into(),
|
||||||
command: "aider".into(),
|
command: "aider".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]);
|
}]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
draft.command(),
|
draft.command(),
|
||||||
@@ -1814,11 +1820,13 @@ mod tests {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "aider".into(),
|
id: "aider".into(),
|
||||||
label: "Aider".into(),
|
label: "Aider".into(),
|
||||||
command: "aider".into(),
|
command: "aider".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert_eq!(draft.command(), "aider");
|
assert_eq!(draft.command(), "aider");
|
||||||
@@ -1838,6 +1846,7 @@ mod tests {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]);
|
}]);
|
||||||
assert!(draft.is_custom());
|
assert!(draft.is_custom());
|
||||||
assert_eq!(draft.command(), "bash -l");
|
assert_eq!(draft.command(), "bash -l");
|
||||||
@@ -1914,16 +1923,19 @@ mod tests {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "codex".into(),
|
id: "codex".into(),
|
||||||
label: "Codex".into(),
|
label: "Codex".into(),
|
||||||
command: "codex".into(),
|
command: "codex".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "opencode".into(),
|
id: "opencode".into(),
|
||||||
label: "OpenCode".into(),
|
label: "OpenCode".into(),
|
||||||
command: "opencode".into(),
|
command: "opencode".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert!(draft.structured_view, "on by default for a supported agent");
|
assert!(draft.structured_view, "on by default for a supported agent");
|
||||||
@@ -2066,16 +2078,19 @@ mod tests {
|
|||||||
id: "claude-wrapped".into(),
|
id: "claude-wrapped".into(),
|
||||||
label: "Claude (npx)".into(),
|
label: "Claude (npx)".into(),
|
||||||
command: "npx claude-code --resume".into(),
|
command: "npx claude-code --resume".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "codex-wrapped".into(),
|
id: "codex-wrapped".into(),
|
||||||
label: "Codex (npx)".into(),
|
label: "Codex (npx)".into(),
|
||||||
command: "npx codex exec".into(),
|
command: "npx codex exec".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "aider".into(),
|
id: "aider".into(),
|
||||||
label: "Aider".into(),
|
label: "Aider".into(),
|
||||||
command: "aider".into(),
|
command: "aider".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -2115,11 +2130,13 @@ mod tests {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "codex".into(),
|
id: "codex".into(),
|
||||||
label: "Codex".into(),
|
label: "Codex".into(),
|
||||||
command: "codex".into(),
|
command: "codex".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert!(draft.structured_view);
|
assert!(draft.structured_view);
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ impl FastForwardDefaults {
|
|||||||
branch: handle.to_string(),
|
branch: handle.to_string(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +185,7 @@ mod tests {
|
|||||||
id: id.into(),
|
id: id.into(),
|
||||||
label: id.into(),
|
label: id.into(),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-5
@@ -190,9 +190,13 @@ choices, not daemon ones.
|
|||||||
from the transcript; the TUI may show the current plan in its status line,
|
from the transcript; the TUI may show the current plan in its status line,
|
||||||
while the web client deliberately has no stats or plan rail.
|
while the web client deliberately has no stats or plan rail.
|
||||||
- The web client groups two or more tool rows after the same `User` row into
|
- The web client groups two or more tool rows after the same `User` row into
|
||||||
one collapsed turn activity block. Opening it reveals independently
|
one collapsed turn activity block. `AgentText` and `AgentThought` rows that
|
||||||
expandable tool calls, including any raw command, input, output, and diff
|
appear *between* tool rows in the same turn are absorbed into the group
|
||||||
content the adapter supplied.
|
(intermediate narration); the last `AgentText` with no subsequent tool stays
|
||||||
|
outside as the prominent final answer. The header still reads
|
||||||
|
"N tool calls" (counting only tool rows). Opening the group reveals tools
|
||||||
|
and intermediate text in chronological order, with the text rendered in a
|
||||||
|
subdued style.
|
||||||
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
||||||
copy their complete source text when clicked or keyboard-activated.
|
copy their complete source text when clicked or keyboard-activated.
|
||||||
- The web client consumes `available_commands_update` notices as the live
|
- The web client consumes `available_commands_update` notices as the live
|
||||||
@@ -346,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
|
in flight, and a cancelled turn must fully settle before the next prompt is
|
||||||
allowed to claim the daemon's idea of "the active turn."
|
allowed to claim the daemon's idea of "the active turn."
|
||||||
|
|
||||||
- A prompt is appended as a durable `User` row before it claims the turn and
|
- Web clients may collect a visible FIFO of operator messages while a turn is
|
||||||
|
active. They submit each queued message only after the projected turn becomes
|
||||||
|
idle; the queue is client-local and unsent messages do not survive a reload.
|
||||||
|
- A submitted prompt is appended as a durable `User` row before it claims the turn and
|
||||||
dispatches to ACP, so the transcript never depends on an adapter echoing the
|
dispatches to ACP, so the transcript never depends on an adapter echoing the
|
||||||
operator's input. If that append fails, the prompt is not dispatched. While a
|
operator's input. If that append fails, the prompt is not dispatched. While a
|
||||||
turn is active, a second prompt gets `409 turn_in_flight` rather than
|
turn is active, a second prompt gets `409 turn_in_flight` rather than
|
||||||
queuing — one active turn per session, never a hidden queue of prompts.
|
queuing in the daemon — one active turn per session, never a hidden
|
||||||
|
server-side queue of prompts.
|
||||||
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
|
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
|
||||||
and ends the turn's client-visible lifecycle immediately. If the cancelled
|
and ends the turn's client-visible lifecycle immediately. If the cancelled
|
||||||
turn was still *dispatched* to ACP (waiting on its response), the daemon
|
turn was still *dispatched* to ACP (waiting on its response), the daemon
|
||||||
|
|||||||
+4
-3
@@ -663,8 +663,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
applyNav();
|
applyNav();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
|
// Pointer hover previews only while Ctrl is held — plain hover was too
|
||||||
// out: there is no hover there, and a tap already opens the session.
|
// disruptive when sweeping the sidebar. Keyboard walk (j/k) still previews
|
||||||
|
// unconditionally via applyNav.
|
||||||
let hovered: string | null = null;
|
let hovered: string | null = null;
|
||||||
const setHovered = (id: string | null) => {
|
const setHovered = (id: string | null) => {
|
||||||
if (hovered === id) return;
|
if (hovered === id) return;
|
||||||
@@ -672,7 +673,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
opts.onPreview?.(id);
|
opts.onPreview?.(id);
|
||||||
};
|
};
|
||||||
treeHost.addEventListener("pointerover", (e) => {
|
treeHost.addEventListener("pointerover", (e) => {
|
||||||
if (e.pointerType === "touch") return;
|
if (e.pointerType === "touch" || !e.ctrlKey) return;
|
||||||
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
|
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
|
||||||
setHovered(row?.dataset.sessionId ?? null);
|
setHovered(row?.dataset.sessionId ?? null);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -182,26 +182,32 @@ describe("sidebar preview requests (docs/PREVIEW.md)", () => {
|
|||||||
expect(seen.at(-1)).toBe(null);
|
expect(seen.at(-1)).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("asks for a preview of the hovered card, and clears it on the way out", () => {
|
it("ctrl+hover previews a card, plain hover does not", () => {
|
||||||
const seen: (string | null)[] = [];
|
const seen: (string | null)[] = [];
|
||||||
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
||||||
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
||||||
|
|
||||||
|
// Plain hover — no preview.
|
||||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
|
||||||
|
// Ctrl+hover — preview fires.
|
||||||
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
|
||||||
expect(seen).toEqual([A]);
|
expect(seen).toEqual([A]);
|
||||||
// Moving within the same card is not a new preview.
|
|
||||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
// Deduplicated while still on the same card.
|
||||||
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
|
||||||
expect(seen).toHaveLength(1);
|
expect(seen).toHaveLength(1);
|
||||||
|
|
||||||
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
|
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
|
||||||
expect(seen.at(-1)).toBe(null);
|
expect(seen.at(-1)).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores touch, where a tap already opens the session", () => {
|
it("ignores touch even with ctrl", () => {
|
||||||
const seen: (string | null)[] = [];
|
const seen: (string | null)[] = [];
|
||||||
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
||||||
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
||||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch" }));
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch", ctrlKey: true }));
|
||||||
expect(seen).toEqual([]);
|
expect(seen).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const base: SpawnFormValues = {
|
|||||||
cwd: "/home/user/project",
|
cwd: "/home/user/project",
|
||||||
name: "amber-fox",
|
name: "amber-fox",
|
||||||
structuredView: false,
|
structuredView: false,
|
||||||
|
autoApprove: false,
|
||||||
worktreeEnabled: false,
|
worktreeEnabled: false,
|
||||||
worktreeBranch: "",
|
worktreeBranch: "",
|
||||||
worktreeBranchMode: "new",
|
worktreeBranchMode: "new",
|
||||||
|
|||||||
+96
-56
@@ -11,7 +11,7 @@ import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
|||||||
import { parentDir } from "./paths.js";
|
import { parentDir } from "./paths.js";
|
||||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||||
import type { ProjectGroup } from "./projectList.js";
|
import type { ProjectGroup } from "./projectList.js";
|
||||||
import { splitCommand } from "./toolIcon.js";
|
import { splitCommand, toolIconSvg, TOOL_LABELS } from "./toolIcon.js";
|
||||||
import { abbreviateCwd } from "./types.js";
|
import { abbreviateCwd } from "./types.js";
|
||||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||||
|
|
||||||
@@ -49,6 +49,7 @@ export interface SpawnFormValues {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
name: string;
|
name: string;
|
||||||
structuredView: boolean;
|
structuredView: boolean;
|
||||||
|
autoApprove: boolean;
|
||||||
worktreeEnabled: boolean;
|
worktreeEnabled: boolean;
|
||||||
worktreeBranch: string;
|
worktreeBranch: string;
|
||||||
worktreeBranchEdited: boolean;
|
worktreeBranchEdited: boolean;
|
||||||
@@ -79,6 +80,10 @@ export function buildSpawnRequest(
|
|||||||
req.view = "Structured";
|
req.view = "Structured";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (values.autoApprove) {
|
||||||
|
req.auto_approve = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (values.worktreeEnabled) {
|
if (values.worktreeEnabled) {
|
||||||
const mode = values.worktreeBranchMode ?? "new";
|
const mode = values.worktreeBranchMode ?? "new";
|
||||||
const branch = values.worktreeBranch.trim();
|
const branch = values.worktreeBranch.trim();
|
||||||
@@ -135,6 +140,12 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
const form = document.createElement("form");
|
const form = document.createElement("form");
|
||||||
form.className = "spawn-form";
|
form.className = "spawn-form";
|
||||||
form.innerHTML = `
|
form.innerHTML = `
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Harness</span>
|
||||||
|
<div class="harness-grid cmd-tiles"></div>
|
||||||
|
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
|
||||||
|
placeholder="bash -l" autocomplete="off" />
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field-label">Project</span>
|
<span class="field-label">Project</span>
|
||||||
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
||||||
@@ -146,26 +157,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="advanced">
|
||||||
<span class="field-label">Command</span>
|
|
||||||
<div class="tile-row cmd-tiles"></div>
|
|
||||||
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
|
|
||||||
placeholder="bash -l" autocomplete="off" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<span class="field-label">Name</span>
|
|
||||||
<div class="name-row">
|
|
||||||
<input name="name" type="text" autocomplete="off"
|
|
||||||
placeholder="named after your first prompt" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<fieldset class="worktree">
|
|
||||||
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
|
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
|
||||||
<span class="chevron">▾</span>
|
<span class="chevron">▾</span>
|
||||||
<span class="folder-name">Advanced options</span>
|
<span class="folder-name">Advanced</span>
|
||||||
<span class="wt-summary"></span>
|
<span class="wt-summary"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="wt-collapsible" hidden>
|
<div class="wt-collapsible" hidden>
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Session name</span>
|
||||||
|
<div class="name-row">
|
||||||
|
<input name="name" type="text" autocomplete="off"
|
||||||
|
placeholder="named after your first prompt" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label class="row-inline structured-view" hidden>
|
<label class="row-inline structured-view" hidden>
|
||||||
<input type="checkbox" name="structured_view" />
|
<input type="checkbox" name="structured_view" />
|
||||||
Structured view
|
Structured view
|
||||||
@@ -174,34 +179,36 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
<span class="field-label">Worktree</span>
|
<span class="field-label">Worktree</span>
|
||||||
<div class="picker wt-choices"></div>
|
<div class="picker wt-choices"></div>
|
||||||
</div>
|
</div>
|
||||||
<label class="row-inline">
|
<div class="adv-group">
|
||||||
<input type="checkbox" name="worktree_enabled" />
|
<label class="row-inline">
|
||||||
Create git worktree
|
<input type="checkbox" name="worktree_enabled" />
|
||||||
</label>
|
Create git worktree
|
||||||
<p class="wt-hint" hidden></p>
|
</label>
|
||||||
<div class="wt-body" hidden>
|
<p class="wt-hint" hidden></p>
|
||||||
<p class="wt-repo"></p>
|
<div class="wt-body" hidden>
|
||||||
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
<p class="wt-repo"></p>
|
||||||
<label class="row-inline">
|
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
||||||
<input type="radio" name="worktree_branch_mode" value="new" />
|
<label class="row-inline">
|
||||||
New branch
|
<input type="radio" name="worktree_branch_mode" value="new" />
|
||||||
|
New branch
|
||||||
|
</label>
|
||||||
|
<label class="row-inline">
|
||||||
|
<input type="radio" name="worktree_branch_mode" value="existing" />
|
||||||
|
Existing branch
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="wt-new-wrap">
|
||||||
|
New branch name
|
||||||
|
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
<label class="row-inline">
|
<label class="wt-exist-wrap" hidden>
|
||||||
<input type="radio" name="worktree_branch_mode" value="existing" />
|
Pick a branch
|
||||||
Existing branch
|
<select class="branch-select"></select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label class="wt-new-wrap">
|
|
||||||
New branch name
|
|
||||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
|
||||||
</label>
|
|
||||||
<label class="wt-exist-wrap" hidden>
|
|
||||||
Pick a branch
|
|
||||||
<select class="branch-select"></select>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</div>
|
||||||
<p class="form-error" role="alert" aria-live="polite"></p>
|
<p class="form-error" role="alert" aria-live="polite"></p>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">Spawn</button>
|
<button type="submit" class="btn btn-primary">Spawn</button>
|
||||||
@@ -261,6 +268,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
let ready = false;
|
let ready = false;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
const branchValue = () => (branchMode === "existing" ? branchSelect.value : branchInput.value);
|
const branchValue = () => (branchMode === "existing" ? branchSelect.value : branchInput.value);
|
||||||
|
const selectedAutoApprove = () =>
|
||||||
|
presets.find((p) => p.id === selectedPresetId)?.auto_approve ?? false;
|
||||||
const emit = () => {
|
const emit = () => {
|
||||||
if (!ready || destroyed) return;
|
if (!ready || destroyed) return;
|
||||||
opts.onChange?.({
|
opts.onChange?.({
|
||||||
@@ -268,6 +277,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
cwd,
|
cwd,
|
||||||
name: nameInput.value,
|
name: nameInput.value,
|
||||||
structuredView: structuredView.checked,
|
structuredView: structuredView.checked,
|
||||||
|
autoApprove: selectedAutoApprove(),
|
||||||
worktreeEnabled: wtEnabled.checked,
|
worktreeEnabled: wtEnabled.checked,
|
||||||
worktreeBranch: branchValue(),
|
worktreeBranch: branchValue(),
|
||||||
worktreeBranchEdited: branchEdited,
|
worktreeBranchEdited: branchEdited,
|
||||||
@@ -338,37 +348,69 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
command = preset!.command;
|
command = preset!.command;
|
||||||
}
|
}
|
||||||
updateStructuredAvailability();
|
updateStructuredAvailability();
|
||||||
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
|
||||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
|
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === id);
|
||||||
}
|
}
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-highlight the operator's already-picked tile after `renderCommandTiles`
|
/** Re-highlight the operator's already-picked card after `renderCommandTiles`
|
||||||
* rebuilds the row from scratch (new tiles carry no `is-active` class) —
|
* rebuilds the grid from scratch (new cards carry no `is-active` class) —
|
||||||
* without touching `command`, since that was already settled. */
|
* without touching `command`, since that was already settled. */
|
||||||
function reapplyActiveTile(): void {
|
function reapplyActiveTile(): void {
|
||||||
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
|
||||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId);
|
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === selectedPresetId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a preset id to a tool icon id. The preset's command is the
|
||||||
|
* source of truth — `splitCommand` extracts the tool the same way the
|
||||||
|
* sidebar does. For the "Custom…" preset (empty command) there is no
|
||||||
|
* tool to show, and a terminal prompt stands in. */
|
||||||
|
function presetToolId(p: SpawnPreset): string | null {
|
||||||
|
if (!p.command) return null;
|
||||||
|
return splitCommand(p.command).tool;
|
||||||
|
}
|
||||||
|
|
||||||
function renderCommandTiles(): void {
|
function renderCommandTiles(): void {
|
||||||
cmdTiles.replaceChildren();
|
cmdTiles.replaceChildren();
|
||||||
for (const p of presets) {
|
for (const p of presets) {
|
||||||
const tile = document.createElement("button");
|
const isCustom = p.command === "";
|
||||||
tile.type = "button";
|
const card = document.createElement("button");
|
||||||
tile.className = "tile";
|
card.type = "button";
|
||||||
tile.dataset.id = p.id;
|
card.className = isCustom ? "harness-card harness-custom" : "harness-card";
|
||||||
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
|
card.dataset.id = p.id;
|
||||||
tile.addEventListener("click", () => {
|
const toolId = presetToolId(p);
|
||||||
|
const iconHtml = isCustom ? customIcon() : toolId ? harnessIcon(toolId) : harnessIcon("shell");
|
||||||
|
const label = TOOL_LABELS[toolId ?? ""] ?? p.label;
|
||||||
|
card.innerHTML =
|
||||||
|
`<span class="harness-icon">${iconHtml}</span>` +
|
||||||
|
`<span class="harness-label">${escapeHtml(isCustom ? "Custom\u2026" : label)}</span>`;
|
||||||
|
card.addEventListener("click", () => {
|
||||||
commandTouched = true;
|
commandTouched = true;
|
||||||
selectPreset(p.id);
|
selectPreset(p.id);
|
||||||
});
|
});
|
||||||
cmdTiles.appendChild(tile);
|
cmdTiles.appendChild(card);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Render a harness card icon at card size (24x24). Re-uses the SVG bodies
|
||||||
|
* from toolIcon.ts but at a larger viewport so the marks breathe. */
|
||||||
|
function harnessIcon(toolId: string): string {
|
||||||
|
// toolIconSvg renders at 13x13 with viewBox 0 0 16 16 — we want the
|
||||||
|
// same shape at 24x24 for the card context.
|
||||||
|
const small = toolIconSvg(toolId);
|
||||||
|
if (!small) return "";
|
||||||
|
return small
|
||||||
|
.replace(/width="13"/g, 'width="24"')
|
||||||
|
.replace(/height="13"/g, 'height="24"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An ellipsis mark for the "Custom…" escape hatch. */
|
||||||
|
function customIcon(): string {
|
||||||
|
return `<svg class="tool-icon" viewBox="0 0 16 16" width="24" height="24" role="img" aria-label="Custom command"><title>Custom command</title><g fill="currentColor"><circle cx="4" cy="8" r="1.3"/><circle cx="8" cy="8" r="1.3"/><circle cx="12" cy="8" r="1.3"/></g></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Set once the operator picks a tile or types a custom command — guards
|
// Set once the operator picks a tile or types a custom command — guards
|
||||||
// `applyInitialCommand()` the same way `nameTouched` guards a late name
|
// `applyInitialCommand()` the same way `nameTouched` guards a late name
|
||||||
// batch: a late `/api/info` resolution must never overwrite a field the
|
// batch: a late `/api/info` resolution must never overwrite a field the
|
||||||
@@ -638,16 +680,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
box.type = "button";
|
box.type = "button";
|
||||||
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
|
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
|
||||||
box.dataset.cwd = project.path;
|
box.dataset.cwd = project.path;
|
||||||
const trees = worktreesOf(group).length;
|
|
||||||
// The parent directory only — the last segment is the name line above it.
|
// The parent directory only — the last segment is the name line above it.
|
||||||
// Unabbreviated beyond `~`, since the stem is what tells two projects
|
// Unabbreviated beyond `~`, since the stem is what tells two projects
|
||||||
// sharing a basename apart (#95).
|
// sharing a basename apart (#95).
|
||||||
const parent = abbreviateCwd(parentDir(project.path), homeDir);
|
const parent = abbreviateCwd(parentDir(project.path), homeDir);
|
||||||
const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "";
|
|
||||||
box.innerHTML = `
|
box.innerHTML = `
|
||||||
<span class="project-name">${escapeHtml(project.name)}</span>
|
<span class="project-name">${escapeHtml(project.name)}</span>
|
||||||
<span class="project-path">${escapeHtml(parent)}</span>
|
<span class="project-path">${escapeHtml(parent)}</span>`;
|
||||||
<span class="project-meta">${escapeHtml(meta)}</span>`;
|
|
||||||
box.addEventListener("click", () => selectProject(group));
|
box.addEventListener("click", () => selectProject(group));
|
||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
@@ -927,6 +966,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
cwd,
|
cwd,
|
||||||
name: nameInput.value,
|
name: nameInput.value,
|
||||||
structuredView: structuredView.checked,
|
structuredView: structuredView.checked,
|
||||||
|
autoApprove: selectedAutoApprove(),
|
||||||
worktreeEnabled: wtEnabled.checked,
|
worktreeEnabled: wtEnabled.checked,
|
||||||
worktreeBranch: branchValue(),
|
worktreeBranch: branchValue(),
|
||||||
worktreeBranchEdited: branchEdited,
|
worktreeBranchEdited: branchEdited,
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ describe("spawnForm Enter-to-submit (#97)", () => {
|
|||||||
const form = await mount([MAESTRO]);
|
const form = await mount([MAESTRO]);
|
||||||
box(form, MAESTRO.path).click();
|
box(form, MAESTRO.path).click();
|
||||||
await drain();
|
await drain();
|
||||||
form.querySelector<HTMLButtonElement>('.cmd-tiles .tile[data-id="custom"]')!.click();
|
form.querySelector<HTMLButtonElement>('.cmd-tiles .harness-card[data-id="custom"]')!.click();
|
||||||
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
|
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||||
raw.value = "bash -l";
|
raw.value = "bash -l";
|
||||||
raw.dispatchEvent(new Event("input"));
|
raw.dispatchEvent(new Event("input"));
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
|
|||||||
await drain();
|
await drain();
|
||||||
|
|
||||||
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
|
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
|
||||||
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
|
||||||
(t) => t.dataset.id === "zsh",
|
(t) => t.dataset.id === "zsh",
|
||||||
)!;
|
)!;
|
||||||
zshTile.click();
|
zshTile.click();
|
||||||
@@ -88,7 +88,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
|
|||||||
document.body.appendChild(form);
|
document.body.appendChild(form);
|
||||||
await drain();
|
await drain();
|
||||||
|
|
||||||
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
|
||||||
(t) => t.dataset.id === "custom",
|
(t) => t.dataset.id === "custom",
|
||||||
)!;
|
)!;
|
||||||
customTile.click();
|
customTile.click();
|
||||||
@@ -116,7 +116,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
|
|||||||
|
|
||||||
// Untouched: the refined preset list is still free to settle the command.
|
// Untouched: the refined preset list is still free to settle the command.
|
||||||
expect(values?.command).toBe("claude");
|
expect(values?.command).toBe("claude");
|
||||||
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
|
||||||
(t) => t.dataset.id === "fish",
|
(t) => t.dataset.id === "fish",
|
||||||
);
|
);
|
||||||
expect(fishTile).toBeDefined();
|
expect(fishTile).toBeDefined();
|
||||||
|
|||||||
@@ -52,14 +52,14 @@ describe("spawn modal", () => {
|
|||||||
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
|
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens with the project filter focused, ahead of the command tiles", async () => {
|
it("opens with the project filter focused, harness before project", async () => {
|
||||||
openSpawnModal({ onSuccess: () => {} });
|
openSpawnModal({ onSuccess: () => {} });
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
const form = modal()!.querySelector("form.spawn-form")!;
|
const form = modal()!.querySelector("form.spawn-form")!;
|
||||||
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
|
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
|
||||||
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
|
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
|
||||||
expect(fields.slice(0, 2)).toEqual(["Project", "Command"]);
|
expect(fields.slice(0, 2)).toEqual(["Harness", "Project"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("titles a seeded open as a clone", async () => {
|
it("titles a seeded open as a clone", async () => {
|
||||||
|
|||||||
+78
-14
@@ -134,7 +134,7 @@ describe("structured connection: data flow", () => {
|
|||||||
} });
|
} });
|
||||||
input.dispatchEvent(paste);
|
input.dispatchEvent(paste);
|
||||||
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
|
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
|
||||||
await vi.waitFor(() => expect(input.disabled).toBe(false));
|
await vi.waitFor(() => expect(conn.el.textContent).toContain("Upload failed"));
|
||||||
expect(input.value).toBe("Read this");
|
expect(input.value).toBe("Read this");
|
||||||
expect(sendStructuredPrompt).not.toHaveBeenCalled();
|
expect(sendStructuredPrompt).not.toHaveBeenCalled();
|
||||||
const remove = conn.el.querySelector<HTMLButtonElement>('[aria-label="Remove notes.txt"]')!;
|
const remove = conn.el.querySelector<HTMLButtonElement>('[aria-label="Remove notes.txt"]')!;
|
||||||
@@ -413,6 +413,39 @@ describe("structured connection: data flow", () => {
|
|||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("absorbs intermediate agent text into tool group, keeps final text outside", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 8,
|
||||||
|
rows: [
|
||||||
|
row("user:1", "user", { content: { type: "text", text: "do it" } }),
|
||||||
|
row("agent:1", "agent_text", { content: { type: "text", text: "Let me check" } }),
|
||||||
|
toolRow("tool:1", "one"),
|
||||||
|
toolRow("tool:2", "two"),
|
||||||
|
row("agent:2", "agent_text", { content: { type: "text", text: "Found issues, fixing" } }),
|
||||||
|
toolRow("tool:3", "three"),
|
||||||
|
toolRow("tool:4", "four"),
|
||||||
|
row("agent:3", "agent_text", { content: { type: "text", text: "All done" } }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const groups = conn.el.querySelectorAll<HTMLElement>(".st-tool-group");
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
// Header counts only tool rows, not intermediate text.
|
||||||
|
expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("4 tool calls");
|
||||||
|
// Intermediate text (agent:2) is inside the group.
|
||||||
|
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
|
||||||
|
// First text (agent:1) is outside — it precedes all tools.
|
||||||
|
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).toBeNull();
|
||||||
|
// Final text (agent:3) is outside — no tool follows it.
|
||||||
|
expect(conn.el.querySelector('[data-row-id="agent:3"]')?.closest(".st-tool-group")).toBeNull();
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps an expanded running tool open without emitting a click when patched", () => {
|
it("keeps an expanded running tool open without emitting a click when patched", () => {
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
@@ -660,7 +693,7 @@ describe("structured connection: turn/composer gating", () => {
|
|||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables the composer while a turn is active and re-enables when idle", () => {
|
it("keeps the composer available while a turn is active", () => {
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
ws.open();
|
ws.open();
|
||||||
@@ -668,13 +701,13 @@ describe("structured connection: turn/composer gating", () => {
|
|||||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||||
const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement;
|
const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement;
|
||||||
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||||
expect(input.disabled).toBe(true);
|
expect(input.disabled).toBe(false);
|
||||||
expect(cancel.hidden).toBe(false);
|
expect(cancel.hidden).toBe(false);
|
||||||
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(true);
|
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
|
||||||
ws.emit({ type: "reduced_state", state: idleState });
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
expect(input.disabled).toBe(false);
|
expect(input.disabled).toBe(false);
|
||||||
expect(cancel.hidden).toBe(true);
|
expect(cancel.hidden).toBe(true);
|
||||||
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(false);
|
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
|
||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -737,7 +770,7 @@ describe("structured connection: turn/composer gating", () => {
|
|||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables immediately and dispatches only once before begin-turn is projected", () => {
|
it("queues a second message before begin-turn is projected", () => {
|
||||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
|
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
@@ -750,12 +783,43 @@ describe("structured connection: turn/composer gating", () => {
|
|||||||
input.value = "only once";
|
input.value = "only once";
|
||||||
|
|
||||||
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
|
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
|
||||||
|
input.value = "queued next";
|
||||||
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
|
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
expect(input.disabled).toBe(true);
|
expect(input.disabled).toBe(false);
|
||||||
expect(send.disabled).toBe(true);
|
expect(send.disabled).toBe(false);
|
||||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
||||||
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
|
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
|
||||||
|
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();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -807,7 +871,7 @@ describe("structured connection: turn/composer gating", () => {
|
|||||||
|
|
||||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(2);
|
expect(sendStructuredPrompt).toHaveBeenCalledTimes(2);
|
||||||
expect(input.value).toBe("");
|
expect(input.value).toBe("");
|
||||||
expect(input.disabled).toBe(true);
|
expect(input.disabled).toBe(false);
|
||||||
expect(conn.el.textContent).not.toContain("stale failure");
|
expect(conn.el.textContent).not.toContain("stale failure");
|
||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
@@ -1126,7 +1190,7 @@ describe("structured connection: reconnection/error/loading", () => {
|
|||||||
second.open();
|
second.open();
|
||||||
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||||
second.emit({ type: "reduced_state", state: idleState });
|
second.emit({ type: "reduced_state", state: idleState });
|
||||||
expect(input.disabled).toBe(true);
|
expect(input.disabled).toBe(false);
|
||||||
|
|
||||||
second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||||
second.emit({ type: "reduced_state", state: idleState });
|
second.emit({ type: "reduced_state", state: idleState });
|
||||||
@@ -1178,12 +1242,12 @@ describe("structured connection: reconnection/error/loading", () => {
|
|||||||
second.open();
|
second.open();
|
||||||
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||||
second.emit({ type: "reduced_state", state: idleState });
|
second.emit({ type: "reduced_state", state: idleState });
|
||||||
expect(input.disabled).toBe(true);
|
expect(input.disabled).toBe(false);
|
||||||
|
|
||||||
accept();
|
accept();
|
||||||
await Promise.resolve().then(() => Promise.resolve());
|
await Promise.resolve().then(() => Promise.resolve());
|
||||||
|
|
||||||
expect(input.disabled).toBe(true);
|
expect(input.disabled).toBe(false);
|
||||||
expect(second.closed).toBe(true);
|
expect(second.closed).toBe(true);
|
||||||
|
|
||||||
third.open();
|
third.open();
|
||||||
@@ -1584,7 +1648,7 @@ describe("structured connection: pane lifecycle", () => {
|
|||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("focusTerminal focuses the composer only when it is enabled", () => {
|
it("focusTerminal keeps the composer reachable during an active turn", () => {
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
ws.open();
|
ws.open();
|
||||||
@@ -1592,7 +1656,7 @@ describe("structured connection: pane lifecycle", () => {
|
|||||||
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||||
conn.focusTerminal();
|
conn.focusTerminal();
|
||||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||||
expect(document.activeElement).not.toBe(input);
|
expect(document.activeElement).toBe(input);
|
||||||
ws.emit({ type: "reduced_state", state: idleState });
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
conn.focusTerminal();
|
conn.focusTerminal();
|
||||||
expect(document.activeElement).toBe(input);
|
expect(document.activeElement).toBe(input);
|
||||||
|
|||||||
+117
-36
@@ -113,7 +113,11 @@ export function createStructuredConnection(
|
|||||||
<button type="submit" class="btn btn-primary structured-send"><span>Send</span>
|
<button type="submit" class="btn btn-primary structured-send"><span>Send</span>
|
||||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7 5 5 5-5 5M12 10H3"/></svg>
|
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7 5 5 5-5 5M12 10H3"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn structured-cancel" hidden>Cancel turn</button>
|
<button type="button" class="btn btn-icon structured-cancel" hidden aria-label="Cancel turn" title="Cancel turn">
|
||||||
|
<svg class="structured-cancel-spinner" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="50 14" stroke-linecap="round"/></svg>
|
||||||
|
<svg class="structured-cancel-stop" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class="structured-queue" role="status" aria-live="polite" hidden></span>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,11 +137,14 @@ export function createStructuredConnection(
|
|||||||
const commandMenu = el.querySelector(".structured-command-menu") as HTMLDivElement;
|
const commandMenu = el.querySelector(".structured-command-menu") as HTMLDivElement;
|
||||||
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
|
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||||
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
|
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
|
||||||
|
const sendLabel = sendBtn.querySelector("span") as HTMLSpanElement;
|
||||||
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
|
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
|
||||||
|
const queueEl = el.querySelector(".structured-queue") as HTMLSpanElement;
|
||||||
const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement;
|
const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement;
|
||||||
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
|
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
|
||||||
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
|
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
|
||||||
type Attachment = { file: File; path?: string };
|
type Attachment = { file: File; path?: string };
|
||||||
|
type QueuedPrompt = { text: string; attachments: Attachment[] };
|
||||||
let attachments: Attachment[] = [];
|
let attachments: Attachment[] = [];
|
||||||
let uploading = false;
|
let uploading = false;
|
||||||
let pasteCount = 0;
|
let pasteCount = 0;
|
||||||
@@ -155,7 +162,7 @@ export function createStructuredConnection(
|
|||||||
remove.type = "button";
|
remove.type = "button";
|
||||||
remove.textContent = "×";
|
remove.textContent = "×";
|
||||||
remove.setAttribute("aria-label", `Remove ${attachment.file.name}`);
|
remove.setAttribute("aria-label", `Remove ${attachment.file.name}`);
|
||||||
remove.disabled = input.disabled;
|
remove.disabled = uploading;
|
||||||
remove.addEventListener("click", () => {
|
remove.addEventListener("click", () => {
|
||||||
attachments = attachments.filter((item) => item !== attachment);
|
attachments = attachments.filter((item) => item !== attachment);
|
||||||
renderAttachments();
|
renderAttachments();
|
||||||
@@ -166,7 +173,7 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addFiles(files: File[]): void {
|
function addFiles(files: File[]): void {
|
||||||
if (input.disabled || disposed) return;
|
if (uploading || disposed) return;
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.size > 20 * 1024 * 1024) {
|
if (file.size > 20 * 1024 * 1024) {
|
||||||
showError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
|
showError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
|
||||||
@@ -193,7 +200,7 @@ export function createStructuredConnection(
|
|||||||
});
|
});
|
||||||
input.addEventListener("paste", (event) => {
|
input.addEventListener("paste", (event) => {
|
||||||
const clipboard = event.clipboardData;
|
const clipboard = event.clipboardData;
|
||||||
if (!clipboard || input.disabled) return;
|
if (!clipboard || uploading) return;
|
||||||
if (clipboard.files.length) {
|
if (clipboard.files.length) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
addFiles(Array.from(clipboard.files));
|
addFiles(Array.from(clipboard.files));
|
||||||
@@ -244,6 +251,8 @@ export function createStructuredConnection(
|
|||||||
let disposed = false;
|
let disposed = false;
|
||||||
let optimisticTurnStartedAt: number | null = null;
|
let optimisticTurnStartedAt: number | null = null;
|
||||||
let activityTimer: number | null = null;
|
let activityTimer: number | null = null;
|
||||||
|
const promptQueue: QueuedPrompt[] = [];
|
||||||
|
let promptRequestPending = false;
|
||||||
|
|
||||||
// Within 64px, wheel and touch imprecision should still count as following.
|
// Within 64px, wheel and touch imprecision should still count as following.
|
||||||
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
|
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
|
||||||
@@ -393,17 +402,53 @@ export function createStructuredConnection(
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolGroupsByTurn(): Map<string, TranscriptRow[]> {
|
/** Group tool rows (and intermediate agent text between them) by turn.
|
||||||
const groups = new Map<string, TranscriptRow[]>();
|
* 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";
|
let turnId = "before-first-message";
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.kind === "user") turnId = row.id;
|
if (row.kind === "user") turnId = row.id;
|
||||||
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
|
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
|
||||||
const id = `turn:${turnId}`;
|
const gid = `turn:${turnId}`;
|
||||||
const group = groups.get(id) ?? [];
|
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
|
||||||
group.push(row);
|
|
||||||
groups.set(id, group);
|
|
||||||
}
|
}
|
||||||
|
// Second: build groups with interleaved text for turns with 2+ tools.
|
||||||
|
const groups = new Map<string, { toolCount: number; rows: TranscriptRow[] }>();
|
||||||
|
turnId = "before-first-message";
|
||||||
|
// Pending 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;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,9 +458,9 @@ export function createStructuredConnection(
|
|||||||
const focusedGroupId = active?.closest<HTMLElement>("[data-tool-group-id]")?.dataset.toolGroupId;
|
const focusedGroupId = active?.closest<HTMLElement>("[data-tool-group-id]")?.dataset.toolGroupId;
|
||||||
const restoreToolFocus = active?.classList.contains("st-tool-head") ?? false;
|
const restoreToolFocus = active?.classList.contains("st-tool-head") ?? false;
|
||||||
const restoreGroupFocus = active?.classList.contains("st-tool-group-head") ?? false;
|
const restoreGroupFocus = active?.classList.contains("st-tool-group-head") ?? false;
|
||||||
const groups = toolGroupsByTurn();
|
const groups = activityGroupsByTurn();
|
||||||
const groupedRowIds = new Set<string>();
|
const groupedRowIds = new Set<string>();
|
||||||
const liveGroupIds = new Set([...groups].filter(([, tools]) => tools.length > 1).map(([id]) => id));
|
const liveGroupIds = new Set([...groups.keys()]);
|
||||||
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
|
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
|
||||||
|
|
||||||
rowEls.clear();
|
rowEls.clear();
|
||||||
@@ -425,28 +470,30 @@ export function createStructuredConnection(
|
|||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.kind === "user") turnId = row.id;
|
if (row.kind === "user") turnId = row.id;
|
||||||
const groupId = `turn:${turnId}`;
|
const groupId = `turn:${turnId}`;
|
||||||
const groupRows = groups.get(groupId) ?? [];
|
const group = groups.get(groupId);
|
||||||
if ((row.kind === "tool_call" || row.kind === "tool_diff") && groupRows.length > 1) {
|
if (group && groupedRowIds.has(row.id)) continue;
|
||||||
if (groupedRowIds.has(row.id)) continue;
|
if (group && (row.kind === "tool_call" || row.kind === "tool_diff") && !groupedRowIds.has(row.id)) {
|
||||||
const toolEls = groupRows.flatMap((toolRow) => {
|
// First ungrouped tool in this turn — render the whole activity group.
|
||||||
groupedRowIds.add(toolRow.id);
|
const memberEls = group.rows.flatMap((memberRow) => {
|
||||||
const toolEl = buildRowElement(toolRow);
|
groupedRowIds.add(memberRow.id);
|
||||||
if (!toolEl) return [];
|
const memberEl = buildRowElement(memberRow);
|
||||||
rowEls.set(toolRow.id, toolEl);
|
if (!memberEl) return [];
|
||||||
return [toolEl];
|
rowEls.set(memberRow.id, memberEl);
|
||||||
|
return [memberEl];
|
||||||
});
|
});
|
||||||
const groupEl = buildToolGroup(
|
const groupEl = buildToolGroup(
|
||||||
groupId,
|
groupId,
|
||||||
groupRows,
|
group.rows,
|
||||||
toolEls,
|
memberEls,
|
||||||
expandedGroupIds.has(groupId),
|
expandedGroupIds.has(groupId),
|
||||||
(expanded) => {
|
(expanded) => {
|
||||||
if (expanded) expandedGroupIds.add(groupId);
|
if (expanded) expandedGroupIds.add(groupId);
|
||||||
else expandedGroupIds.delete(groupId);
|
else expandedGroupIds.delete(groupId);
|
||||||
settleDisclosureLayout();
|
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");
|
groupEl.classList.add("st-row-enter");
|
||||||
}
|
}
|
||||||
groupEls.set(groupId, groupEl);
|
groupEls.set(groupId, groupEl);
|
||||||
@@ -606,16 +653,19 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderComposerState(): void {
|
function renderComposerState(): void {
|
||||||
const active = uploading || state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
|
||||||
const projectedActive = state.turn.responding || state.turn.thinking;
|
const projectedActive = state.turn.responding || state.turn.thinking;
|
||||||
input.disabled = active;
|
input.disabled = false;
|
||||||
attachBtn.disabled = active;
|
attachBtn.disabled = uploading;
|
||||||
fileInput.disabled = active;
|
fileInput.disabled = uploading;
|
||||||
renderAttachments();
|
renderAttachments();
|
||||||
sendBtn.disabled = active;
|
sendBtn.disabled = false;
|
||||||
sendBtn.hidden = projectedActive;
|
sendBtn.hidden = false;
|
||||||
|
sendLabel.textContent = "Send";
|
||||||
cancelBtn.hidden = !projectedActive;
|
cancelBtn.hidden = !projectedActive;
|
||||||
if (active) closeCommandMenu();
|
queueEl.hidden = promptQueue.length === 0;
|
||||||
|
queueEl.textContent = promptQueue.length === 1
|
||||||
|
? "1 message queued"
|
||||||
|
: `${promptQueue.length} messages queued`;
|
||||||
renderActivity();
|
renderActivity();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,6 +727,7 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
renderConfig();
|
renderConfig();
|
||||||
renderComposerState();
|
renderComposerState();
|
||||||
|
dispatchQueuedPrompt();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeConfigMenu(): void {
|
function closeConfigMenu(): void {
|
||||||
@@ -876,17 +927,14 @@ export function createStructuredConnection(
|
|||||||
if (!commandMenu.contains(document.activeElement)) closeCommandMenu();
|
if (!commandMenu.contains(document.activeElement)) closeCommandMenu();
|
||||||
}, 0);
|
}, 0);
|
||||||
});
|
});
|
||||||
composerForm.addEventListener("submit", (e) => {
|
function dispatchPrompt(text: string, submittedAttachments: Attachment[] = []): void {
|
||||||
e.preventDefault();
|
|
||||||
const text = input.value;
|
|
||||||
if ((!text.trim() && attachments.length === 0) || input.disabled) return;
|
|
||||||
const submittedAttachments = attachments;
|
|
||||||
uploading = submittedAttachments.length > 0;
|
uploading = submittedAttachments.length > 0;
|
||||||
const generation = ++submissionGeneration;
|
const generation = ++submissionGeneration;
|
||||||
const submissionSocketGeneration = socketGeneration;
|
const submissionSocketGeneration = socketGeneration;
|
||||||
awaitingBeginGeneration = generation;
|
awaitingBeginGeneration = generation;
|
||||||
optimisticTurnStartedAt = Date.now();
|
optimisticTurnStartedAt = Date.now();
|
||||||
acceptedSubmission = null;
|
acceptedSubmission = null;
|
||||||
|
promptRequestPending = true;
|
||||||
input.value = "";
|
input.value = "";
|
||||||
autoResize();
|
autoResize();
|
||||||
clearError();
|
clearError();
|
||||||
@@ -903,6 +951,7 @@ export function createStructuredConnection(
|
|||||||
};
|
};
|
||||||
void send().then(
|
void send().then(
|
||||||
() => {
|
() => {
|
||||||
|
promptRequestPending = false;
|
||||||
if (disposed || generation !== submissionGeneration) return;
|
if (disposed || generation !== submissionGeneration) return;
|
||||||
uploading = false;
|
uploading = false;
|
||||||
if (attachments === submittedAttachments) {
|
if (attachments === submittedAttachments) {
|
||||||
@@ -916,11 +965,17 @@ export function createStructuredConnection(
|
|||||||
// A replacement socket may have captured idle before the POST claimed
|
// A replacement socket may have captured idle before the POST claimed
|
||||||
// the prompt. Reconnect again so its snapshot cannot predate the 204.
|
// the prompt. Reconnect again so its snapshot cannot predate the 204.
|
||||||
if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation();
|
if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation();
|
||||||
|
renderComposerState();
|
||||||
|
dispatchQueuedPrompt();
|
||||||
},
|
},
|
||||||
(e: unknown) => {
|
(e: unknown) => {
|
||||||
|
promptRequestPending = false;
|
||||||
uploading = false;
|
uploading = false;
|
||||||
if (disposed || generation !== submissionGeneration) return;
|
if (disposed || generation !== submissionGeneration) return;
|
||||||
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
|
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
|
||||||
|
if (attachments.length === 0 && submittedAttachments.length > 0) {
|
||||||
|
attachments = submittedAttachments;
|
||||||
|
}
|
||||||
if (input.value === "") {
|
if (input.value === "") {
|
||||||
input.value = text;
|
input.value = text;
|
||||||
autoResize();
|
autoResize();
|
||||||
@@ -929,6 +984,32 @@ export function createStructuredConnection(
|
|||||||
renderComposerState();
|
renderComposerState();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchQueuedPrompt(): void {
|
||||||
|
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
||||||
|
if (disposed || active || promptRequestPending || promptQueue.length === 0) return;
|
||||||
|
const next = promptQueue.shift()!;
|
||||||
|
dispatchPrompt(next.text, next.attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
composerForm.addEventListener("submit", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const text = input.value;
|
||||||
|
if ((!text.trim() && attachments.length === 0) || uploading) return;
|
||||||
|
const submittedAttachments = attachments;
|
||||||
|
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
||||||
|
if (active || promptQueue.length > 0) {
|
||||||
|
promptQueue.push({ text, attachments: submittedAttachments });
|
||||||
|
attachments = [];
|
||||||
|
input.value = "";
|
||||||
|
autoResize();
|
||||||
|
clearError();
|
||||||
|
renderComposerState();
|
||||||
|
dispatchQueuedPrompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatchPrompt(text, submittedAttachments);
|
||||||
});
|
});
|
||||||
cancelBtn.addEventListener("click", () => {
|
cancelBtn.addEventListener("click", () => {
|
||||||
clearError();
|
clearError();
|
||||||
|
|||||||
@@ -581,7 +581,9 @@ export function buildToolGroup(
|
|||||||
children: HTMLElement[],
|
children: HTMLElement[],
|
||||||
expanded: boolean,
|
expanded: boolean,
|
||||||
onExpandedChange?: (expanded: boolean) => void,
|
onExpandedChange?: (expanded: boolean) => void,
|
||||||
|
toolCount?: number,
|
||||||
): HTMLElement {
|
): HTMLElement {
|
||||||
|
const count = toolCount ?? rows.length;
|
||||||
const wrap = el("div", "st-tool-group");
|
const wrap = el("div", "st-tool-group");
|
||||||
wrap.dataset.toolGroupId = id;
|
wrap.dataset.toolGroupId = id;
|
||||||
const head = document.createElement("button");
|
const head = document.createElement("button");
|
||||||
@@ -593,7 +595,7 @@ export function buildToolGroup(
|
|||||||
glyph.textContent = "›";
|
glyph.textContent = "›";
|
||||||
glyph.setAttribute("aria-hidden", "true");
|
glyph.setAttribute("aria-hidden", "true");
|
||||||
const title = el("span", "st-tool-group-title");
|
const title = el("span", "st-tool-group-title");
|
||||||
title.textContent = `${rows.length} tool calls`;
|
title.textContent = `${count} tool calls`;
|
||||||
head.append(glyph, title);
|
head.append(glyph, title);
|
||||||
|
|
||||||
const body = el("div", "st-tool-group-body st-disclosure");
|
const body = el("div", "st-tool-group-body st-disclosure");
|
||||||
|
|||||||
+131
-61
@@ -2290,6 +2290,9 @@ input.leaf-rename:focus {
|
|||||||
.structured-main {
|
.structured-main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
max-width: 820px;
|
||||||
|
margin-inline: auto;
|
||||||
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@@ -2895,6 +2898,26 @@ input.leaf-rename:focus {
|
|||||||
border-radius: var(--r-1);
|
border-radius: var(--r-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* intermediate agent text inside a tool group — subdued so the final
|
||||||
|
answer outside the group stays the prominent one. */
|
||||||
|
.st-tool-group-body .st-row-agent {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
padding: var(--sp-1) var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-tool-group-body .st-row-agent .st-body > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-tool-group-body .st-row-agent .st-body > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-tool-group-body .st-row-thought {
|
||||||
|
padding: var(--sp-1) var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
.st-tool-group-body[hidden] {
|
.st-tool-group-body[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -3070,6 +3093,7 @@ input.leaf-rename:focus {
|
|||||||
hairline that doubles as the focus indicator. Inner metrics line the draft
|
hairline that doubles as the focus indicator. Inner metrics line the draft
|
||||||
up with the transcript's own 12px gutter. */
|
up with the transcript's own 12px gutter. */
|
||||||
.structured-composer {
|
.structured-composer {
|
||||||
|
position: relative;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: var(--sp-1) var(--sp-1) max(var(--sp-1), env(safe-area-inset-bottom));
|
padding: var(--sp-1) var(--sp-1) max(var(--sp-1), env(safe-area-inset-bottom));
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
@@ -3080,8 +3104,21 @@ input.leaf-rename:focus {
|
|||||||
transition: border-color 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 {
|
.structured-composer:focus-within {
|
||||||
border-top-color: var(--accent);
|
border-top-color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.structured-input-wrap {
|
.structured-input-wrap {
|
||||||
@@ -3197,7 +3234,9 @@ input.leaf-rename:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.structured-composer .structured-cancel {
|
.structured-composer .structured-cancel {
|
||||||
padding: 0 var(--sp-3);
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
padding: 0;
|
||||||
border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
|
border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
@@ -3206,6 +3245,23 @@ input.leaf-rename:focus {
|
|||||||
background: var(--danger-tint);
|
background: var(--danger-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.structured-cancel-stop {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-cancel-spinner {
|
||||||
|
position: absolute;
|
||||||
|
inset: 4px;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
animation: cancel-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cancel-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
/* One row cannot hold a draft plus three controls at phone width — the
|
/* 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. */
|
draft gets a line of its own and the controls drop below it. */
|
||||||
@@ -3443,14 +3499,9 @@ input.leaf-rename:focus {
|
|||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rides the bottom of the scroll box: reachable at any scroll position, and
|
|
||||||
`margin-top: auto` keeps it at the screen's foot when the form is short. */
|
|
||||||
.spawn-modal .form-actions {
|
.spawn-modal .form-actions {
|
||||||
position: sticky;
|
|
||||||
bottom: 0;
|
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
padding: var(--sp-2) 0 calc(var(--sp-2) + env(safe-area-inset-bottom, 0px));
|
padding-bottom: calc(var(--sp-2) + env(safe-area-inset-bottom, 0px));
|
||||||
background: var(--surface);
|
|
||||||
box-shadow: 0 -8px 12px -8px oklch(0 0 0 / 0.5);
|
box-shadow: 0 -8px 12px -8px oklch(0 0 0 / 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3532,20 +3583,16 @@ input.leaf-rename:focus {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.worktree {
|
.advanced {
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--r-2);
|
|
||||||
padding: 0 var(--sp-3);
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: color-mix(in oklab, var(--surface-2) 50%, transparent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.worktree .wt-toggle {
|
.advanced .wt-toggle {
|
||||||
margin: 0 calc(var(--sp-3) * -1);
|
border-top: 1px solid var(--border);
|
||||||
padding-left: var(--sp-3);
|
border-radius: 0;
|
||||||
width: calc(100% + var(--sp-3) * 2);
|
padding-top: var(--sp-2);
|
||||||
|
padding-bottom: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wt-summary {
|
.wt-summary {
|
||||||
@@ -3557,13 +3604,27 @@ input.leaf-rename:focus {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--sp-3);
|
gap: var(--sp-3);
|
||||||
padding-bottom: var(--sp-3);
|
padding: var(--sp-2) 0 var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wt-collapsible[hidden] {
|
.wt-collapsible[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Worktree sub-group: left accent border when expanded, visually nested. */
|
||||||
|
.adv-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding-left: var(--sp-3);
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
margin-top: var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adv-group:has(input[name="worktree_enabled"]:checked) {
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.form-error {
|
.form-error {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -3590,61 +3651,78 @@ input.leaf-rename:focus {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* One-click tiles: host row + command row. */
|
/* Harness cards: the first decision in the spawn form — which tool runs. */
|
||||||
.tile-row {
|
.harness-grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile {
|
.harness-card {
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 96px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
align-items: center;
|
||||||
align-items: flex-start;
|
gap: var(--sp-2);
|
||||||
padding: var(--sp-2) var(--sp-3);
|
padding: var(--sp-3) var(--sp-2);
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--r-2);
|
border-radius: var(--r-2);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: center;
|
||||||
transition:
|
transition:
|
||||||
border-color var(--dur-fast) var(--ease-out),
|
border-color var(--dur-fast) var(--ease-out),
|
||||||
background var(--dur-fast) var(--ease-out);
|
background var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile:hover {
|
.harness-card:hover {
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
|
background: var(--surface-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile.is-active {
|
.harness-card.is-active {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--accent-tint);
|
background: var(--accent-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-label {
|
.harness-icon {
|
||||||
font-size: var(--fs-14);
|
display: flex;
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-sub {
|
|
||||||
font-size: var(--fs-12);
|
|
||||||
color: var(--muted);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-more {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
min-width: 0;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.harness-card.is-active .harness-icon {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.harness-icon .tool-icon {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harness-label {
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The "Custom…" card is an escape hatch, not a peer tool — dashed border
|
||||||
|
and subdued color separate it from the named harnesses. */
|
||||||
|
.harness-custom {
|
||||||
|
border-style: dashed;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.harness-custom .harness-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
|
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
|
||||||
picked project's worktree choices. Stale beyond 7 days dims but stays
|
picked project's worktree choices. Stale beyond 7 days dims but stays
|
||||||
selectable. */
|
selectable. */
|
||||||
@@ -3671,7 +3749,7 @@ input.leaf-rename:focus {
|
|||||||
|
|
||||||
.picker.projects {
|
.picker.projects {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
grid-template-columns: repeat(2, 1fr);
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
@@ -3727,19 +3805,6 @@ input.leaf-rename:focus {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-meta {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--fs-12);
|
|
||||||
color: var(--faint);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-meta:empty {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-other {
|
.project-other {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
@@ -3881,6 +3946,11 @@ input.leaf-rename:focus {
|
|||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: var(--sp-2);
|
margin-top: var(--sp-2);
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
padding: var(--sp-2) 0;
|
||||||
|
background: var(--surface);
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reroll {
|
.reroll {
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export interface SpawnPreset {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
command: string;
|
command: string;
|
||||||
|
auto_approve?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `GET /api/info` — the daemon's identity plus its spawn presets (#26).
|
/** `GET /api/info` — the daemon's identity plus its spawn presets (#26).
|
||||||
@@ -277,6 +278,7 @@ export interface CreateSessionRequest {
|
|||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
worktree?: WorktreeSpec;
|
worktree?: WorktreeSpec;
|
||||||
view?: SessionView;
|
view?: SessionView;
|
||||||
|
auto_approve?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CatalogEvent =
|
export type CatalogEvent =
|
||||||
|
|||||||
Reference in New Issue
Block a user