From eebc0140cd15718a919981678168030c87f40db4 Mon Sep 17 00:00:00 2001 From: Luca Mangiacotti Date: Wed, 11 Nov 2026 01:12:21 +0100 Subject: [PATCH] feat: remote machines in the sidebar, last closure and project search The session sidebar groups folders, workspaces and sessions by the machine they run on, and the new-session wizard asks first where the session runs (this computer, a saved SSH connection or a connect command), then the provider, then the configuration. A search field filters projects once the sidebar holds several folders or projects. The resume modal gains a "Last closure" tab fed by a local open-state.json record of the sessions and agents open in the previous run, written continuously so it survives a crash or a force-quit. Also keeps the chosen lead agent across restarts and wakes from standby, draws the run, copy and open buttons on terminal rows in fullscreen TUIs too, stops parking a session from moving the focus to another one, and adds Claude Fable 5.2 with updated Opus 5.5 pricing. Co-Authored-By: Claude Fable 5.2 Signed-off-by: Luca Mangiacotti --- .../src-tauri/permissions/main-window.toml | 1 + apps/desktop/src-tauri/src/commands/agents.rs | 12 +- .../desktop/src-tauri/src/commands/metrics.rs | 5 +- apps/desktop/src-tauri/src/commands/mod.rs | 1 + .../src-tauri/src/commands/open_state.rs | 393 +++++++++++++ .../desktop/src-tauri/src/commands/session.rs | 27 + .../src-tauri/src/commands/speedtest.rs | 2 + .../src-tauri/src/commands/subagent.rs | 2 + apps/desktop/src-tauri/src/lib.rs | 4 + .../desktop/src-tauri/src/promptops_bridge.js | 12 +- .../helper/terminal-bang-command-runner.ts | 170 +++--- .../helper/terminal-file-link-provider.ts | 2 +- .../app/core/helper/terminal-link-copier.ts | 284 ++++++---- .../app/core/helper/terminal-row-buttons.ts | 235 ++++++++ .../web/src/app/core/icons/icons.generated.ts | 2 +- .../git-writer/git-writer.service.ts | 1 + .../agents/agent-roster.service.spec.ts | 41 ++ .../sessions/agents/agent-roster.service.ts | 45 +- .../create-session-modal.component.html | 532 +++++++++++------- .../create-session-modal.component.spec.ts | 12 +- .../create-session-modal.component.ts | 325 ++++++++++- .../create-session-modal.host.spec.ts | 397 +++++++++++++ .../resume-subagent-modal.component.spec.ts | 3 + .../resume-subagent-modal.component.ts | 243 +++++++- .../sessions/services/last-closure.spec.ts | 74 +++ .../sessions/services/model-labels.ts | 1 + .../sessions/services/session-host.spec.ts | 173 ++++++ .../sessions/services/session-host.ts | 223 ++++++++ .../sessions/services/sub-agent.service.ts | 167 +++++- .../session-sidebar/machine-tree.spec.ts | 208 +++++++ .../sessions/session-sidebar/machine-tree.ts | 177 ++++++ .../session-sidebar/project-search.spec.ts | 40 ++ .../session-sidebar/project-search.ts | 37 ++ .../session-sidebar.component.html | 120 +++- .../session-sidebar.component.ts | 302 +++++++++- .../session-sidebar.machines.spec.ts | 420 ++++++++++++++ .../sidebar-machine-state.service.spec.ts | 100 ++++ .../sidebar-machine-state.service.ts | 113 ++++ .../session-terminal.component.ts | 5 + .../session-view/session-view.component.ts | 6 +- .../sub-agent-terminal.component.ts | 5 + .../speed-test/speed-test.service.ts | 1 + apps/web/src/assets/i18n/ar.json | 40 +- apps/web/src/assets/i18n/de.json | 40 +- apps/web/src/assets/i18n/en.json | 40 +- apps/web/src/assets/i18n/es.json | 40 +- apps/web/src/assets/i18n/fr.json | 40 +- apps/web/src/assets/i18n/hi.json | 40 +- apps/web/src/assets/i18n/it.json | 40 +- apps/web/src/assets/i18n/ja.json | 40 +- apps/web/src/assets/i18n/ko.json | 40 +- apps/web/src/assets/i18n/nl.json | 40 +- apps/web/src/assets/i18n/pl.json | 40 +- apps/web/src/assets/i18n/pt.json | 40 +- apps/web/src/assets/i18n/ru.json | 40 +- apps/web/src/assets/i18n/sv.json | 40 +- apps/web/src/assets/i18n/zh.json | 40 +- .../web/src/store/sessions/session.reducer.ts | 17 +- 58 files changed, 5000 insertions(+), 540 deletions(-) create mode 100644 apps/desktop/src-tauri/src/commands/open_state.rs create mode 100644 apps/web/src/app/core/helper/terminal-row-buttons.ts create mode 100644 apps/web/src/app/views/admin-core/sessions/create-session-modal/create-session-modal.host.spec.ts create mode 100644 apps/web/src/app/views/admin-core/sessions/services/last-closure.spec.ts create mode 100644 apps/web/src/app/views/admin-core/sessions/services/session-host.spec.ts create mode 100644 apps/web/src/app/views/admin-core/sessions/services/session-host.ts create mode 100644 apps/web/src/app/views/admin-core/sessions/session-sidebar/machine-tree.spec.ts create mode 100644 apps/web/src/app/views/admin-core/sessions/session-sidebar/machine-tree.ts create mode 100644 apps/web/src/app/views/admin-core/sessions/session-sidebar/project-search.spec.ts create mode 100644 apps/web/src/app/views/admin-core/sessions/session-sidebar/project-search.ts create mode 100644 apps/web/src/app/views/admin-core/sessions/session-sidebar/session-sidebar.machines.spec.ts create mode 100644 apps/web/src/app/views/admin-core/sessions/session-sidebar/sidebar-machine-state.service.spec.ts create mode 100644 apps/web/src/app/views/admin-core/sessions/session-sidebar/sidebar-machine-state.service.ts diff --git a/apps/desktop/src-tauri/permissions/main-window.toml b/apps/desktop/src-tauri/permissions/main-window.toml index 59775e2..91a602c 100644 --- a/apps/desktop/src-tauri/permissions/main-window.toml +++ b/apps/desktop/src-tauri/permissions/main-window.toml @@ -216,6 +216,7 @@ commands.allow = [ "notify_set_labels", "open_detached_window", "open_session_window", + "open_state_last_closure", "optimization_get_file_cache", "optimization_get_status", "optimization_refresh_context", diff --git a/apps/desktop/src-tauri/src/commands/agents.rs b/apps/desktop/src-tauri/src/commands/agents.rs index f8049ab..cadef59 100644 --- a/apps/desktop/src-tauri/src/commands/agents.rs +++ b/apps/desktop/src-tauri/src/commands/agents.rs @@ -34,6 +34,11 @@ use crate::commands::session::{SessionManager, SessionSummary}; pub struct AgentsMeta { #[serde(default, skip_serializing_if = "Option::is_none")] pub lead_id: Option, + /// Stable identity (`role|title`) of the chosen lead. A sub-agent's id + /// changes on every respawn (restart, wake from standby): without this the + /// lead preference was lost and fell back to the first pane. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lead_key: Option, #[serde(default)] pub order: Vec, #[serde(default)] @@ -177,8 +182,13 @@ pub fn agents_set_lead( manager: tauri::State<'_, Arc>, session_id: String, lead_id: Option, + lead_key: Option, ) -> AgentsMeta { - let meta = update(&manager, &session_id, |m| m.lead_id = lead_id); + let meta = update(&manager, &session_id, |m| { + // With no chosen lead, no identity is left dangling. + m.lead_key = lead_id.as_ref().and(lead_key); + m.lead_id = lead_id; + }); emit_changed(&app, &session_id, &meta); meta } diff --git a/apps/desktop/src-tauri/src/commands/metrics.rs b/apps/desktop/src-tauri/src/commands/metrics.rs index 665b37a..3690ba2 100644 --- a/apps/desktop/src-tauri/src/commands/metrics.rs +++ b/apps/desktop/src-tauri/src/commands/metrics.rs @@ -2741,8 +2741,9 @@ fn resolve_pricing(provider: &str, model_hint: Option<&str>) -> ModelPricing { // Claude 5 family before legacy: matching is by substring, in order. // opus-5 first = default_model. ("opus-5", "Claude Opus 5", 5.0, 25.0, 0.5, 6.25), - ("opus-5-5", "Claude Opus 5.5", 5.0, 25.0, 0.5, 6.25), - // fable-5-1 before fable-5: matching is by substring, in order. + ("opus-5-5", "Claude Opus 5.5", 4.0, 20.0, 0.2, 5.0), + // The match picks the longest key contained in the id (fable-5-2 beats fable-5). + ("fable-5-2", "Claude Fable 5.2", 8.0, 40.0, 0.2, 10.0), ("fable-5-1", "Claude Fable 5.1", 10.0, 50.0, 1.0, 12.5), ("fable-5", "Claude Fable 5", 10.0, 50.0, 1.0, 12.5), ("mythos-5", "Claude Mythos 5", 10.0, 50.0, 1.0, 12.5), diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 54d3504..5af49cb 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -23,6 +23,7 @@ pub mod metrics; pub mod notify; #[cfg(target_os = "macos")] pub mod notify_macos; +pub mod open_state; pub mod output_watchers; pub mod ports; pub mod profile; diff --git a/apps/desktop/src-tauri/src/commands/open_state.rs b/apps/desktop/src-tauri/src/commands/open_state.rs new file mode 100644 index 0000000..c373bf5 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/open_state.rs @@ -0,0 +1,393 @@ +//! Local record of what is open (`open-state.json`). +//! +//! Written continuously (scheduler tick + every roster change), so it +//! survives a crash or a force-quit. It decides NOTHING: the session store +//! stays the source of truth for which sessions exist, and the restore at +//! boot does not read it. It only feeds the "Last closure" tab of the resume +//! modal, which shows the sessions and agents open in the previous run. +//! +//! On first access the content of the file (previous run) becomes +//! `previous`; the current run starts from scratch and carries `previous` +//! along until it has at least one session of its own to replace it with at +//! the next boot. +//! +//! Kill switch: `PROMPTOPS_OPEN_STATE=0` turns off both writes and reads +//! (the command returns `null`). + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::OnceLock; + +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; + +const FILE_VERSION: u32 = 1; + +/// Session row supplied by the SessionManager (main sessions only). +#[derive(Clone, Debug, PartialEq)] +pub struct SessionRow { + pub session_id: String, + pub db_id: Option, + pub name: String, + pub provider: String, + pub workspace_path: Option, + pub workspace_name: Option, + pub standby: bool, + pub created_at: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenSession { + pub name: String, + #[serde(default)] + pub session_id: String, + #[serde(default)] + pub db_id: Option, + #[serde(default)] + pub provider: String, + #[serde(default)] + pub workspace_path: Option, + #[serde(default)] + pub workspace_name: Option, + #[serde(default)] + pub standby: bool, + /// Open agents, in the slim stash shape (`StashEntry`). Kept as opaque + /// JSON: this module does not depend on the sub-agent types. + #[serde(default)] + pub agents: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Snapshot { + #[serde(default)] + pub saved_at: String, + #[serde(default)] + pub clean_exit: bool, + #[serde(default)] + pub sessions: Vec, +} + +#[derive(Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileV1 { + #[serde(default)] + version: u32, + #[serde(flatten)] + current: Snapshot, + #[serde(default)] + previous: Option, +} + +#[derive(Default)] +struct State { + /// Previous run (what the modal shows). + previous: Option, + /// Rosters of the current run, by local session id. + rosters: HashMap>, + /// Rosters inherited from the previous run, by session name: they hold + /// until the session publishes a roster of its own (lazy restore: a + /// session that was never woken must not lose the agents it had). + inherited: HashMap>, + rows: Vec, + last_written: Option>, + /// No writes after the clean exit: the shutdown kills would empty the + /// rosters. + frozen: bool, +} + +fn enabled() -> bool { + std::env::var("PROMPTOPS_OPEN_STATE") + .map(|v| v != "0") + .unwrap_or(true) +} + +fn file_path() -> PathBuf { + crate::commands::hook::hook_data_dir().join("open-state.json") +} + +/// Which snapshot of the file is "the last closure": the previous run if it +/// had sessions, otherwise the one before it (a run that died before the +/// restore must not erase the memory of the good one). +fn pick_previous(file: FileV1) -> Option { + if !file.current.sessions.is_empty() { + Some(file.current) + } else { + file.previous.filter(|p| !p.sessions.is_empty()) + } +} + +fn inherited_from(previous: &Option) -> HashMap> { + previous + .iter() + .flat_map(|p| p.sessions.iter()) + .filter(|s| !s.agents.is_empty()) + .map(|s| (s.name.clone(), s.agents.clone())) + .collect() +} + +fn load() -> State { + let previous = if enabled() { + std::fs::read_to_string(file_path()) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(pick_previous) + } else { + None + }; + State { + inherited: inherited_from(&previous), + previous, + ..State::default() + } +} + +fn state() -> &'static Mutex { + static STATE: OnceLock> = OnceLock::new(); + STATE.get_or_init(|| Mutex::new(load())) +} + +/// Sessions open right now, in creation order, each with its roster (the +/// current run's if published, otherwise the one inherited by name). +fn build_sessions( + rows: &[SessionRow], + rosters: &HashMap>, + inherited: &HashMap>, +) -> Vec { + let mut ordered: Vec<&SessionRow> = rows.iter().collect(); + ordered.sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.name.cmp(&b.name))); + ordered + .into_iter() + .map(|r| OpenSession { + name: r.name.clone(), + session_id: r.session_id.clone(), + db_id: r.db_id.clone(), + provider: r.provider.clone(), + workspace_path: r.workspace_path.clone(), + workspace_name: r.workspace_name.clone(), + standby: r.standby, + agents: rosters + .get(&r.session_id) + .or_else(|| inherited.get(&r.name)) + .cloned() + .unwrap_or_default(), + }) + .collect() +} + +fn write_file(file: &FileV1) { + let path = file_path(); + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let Ok(json) = serde_json::to_vec(file) else { + return; + }; + // Atomic and synced: never a half-written file after a crash. + let tmp = path.with_extension("json.tmp"); + let written = (|| -> std::io::Result<()> { + use std::io::Write; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); // holds prompt excerpts + } + let mut f = options.open(&tmp)?; + f.write_all(&json)?; + f.sync_all() + })(); + if written.is_ok() { + let _ = std::fs::rename(&tmp, &path); + } else { + let _ = std::fs::remove_file(&tmp); + } +} + +fn flush(st: &mut State, clean_exit: bool) { + let sessions = build_sessions(&st.rows, &st.rosters, &st.inherited); + if !clean_exit && st.last_written.as_ref() == Some(&sessions) { + return; + } + write_file(&FileV1 { + version: FILE_VERSION, + current: Snapshot { + saved_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + clean_exit, + sessions: sessions.clone(), + }, + previous: st.previous.clone(), + }); + st.last_written = Some(sessions); +} + +/// Scheduler tick: refreshes the session list and writes when it changes. +pub fn tick(rows: Vec) { + if !enabled() { + return; + } + let mut st = state().lock(); + if st.frozen { + return; + } + // Rosters of sessions that are gone are no longer needed. + st.rosters + .retain(|id, _| rows.iter().any(|r| &r.session_id == id)); + st.rows = rows; + flush(&mut st, false); +} + +/// Change in a session's live roster (same points where the stash snapshot +/// goes out to the renderer, same rules). +pub fn note_roster(session_id: &str, agents: &[T]) { + if !enabled() { + return; + } + let values: Vec = agents + .iter() + .filter_map(|a| serde_json::to_value(a).ok()) + .collect(); + let mut st = state().lock(); + if st.frozen { + return; + } + st.rosters.insert(session_id.to_string(), values); + if st.rows.iter().any(|r| r.session_id == session_id) { + flush(&mut st, false); + } +} + +/// Clean exit: one last write with `cleanExit`, then the record stops. +pub fn mark_clean_exit(rows: Vec) { + if !enabled() { + return; + } + let mut st = state().lock(); + if st.frozen { + return; + } + st.rows = rows; + flush(&mut st, true); + st.frozen = true; +} + +/// open_state:last-closure — sessions and agents open in the previous run. +#[tauri::command] +pub fn open_state_last_closure() -> Option { + if !enabled() { + return None; + } + state().lock().previous.clone() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn row(id: &str, name: &str, created: &str, standby: bool) -> SessionRow { + SessionRow { + session_id: id.into(), + db_id: None, + name: name.into(), + provider: "claude-code-cli".into(), + workspace_path: Some("/w".into()), + workspace_name: None, + standby, + created_at: created.into(), + } + } + + fn snap(names: &[&str]) -> Snapshot { + Snapshot { + saved_at: "t".into(), + clean_exit: false, + sessions: names + .iter() + .map(|n| OpenSession { + name: n.to_string(), + ..OpenSession::default() + }) + .collect(), + } + } + + #[test] + fn previous_is_the_last_run_that_had_sessions() { + let file = FileV1 { + version: 1, + current: snap(&["a"]), + previous: Some(snap(&["old"])), + }; + assert_eq!(pick_previous(file).unwrap().sessions[0].name, "a"); + } + + #[test] + fn a_run_that_died_before_restore_keeps_the_older_closure() { + let file = FileV1 { + version: 1, + current: snap(&[]), + previous: Some(snap(&["old"])), + }; + assert_eq!(pick_previous(file).unwrap().sessions[0].name, "old"); + let empty = FileV1 { + version: 1, + current: snap(&[]), + previous: Some(snap(&[])), + }; + assert!(pick_previous(empty).is_none()); + } + + #[test] + fn sessions_are_ordered_by_creation_and_carry_their_roster() { + let rows = vec![ + row("2", "b", "2026-01-02", false), + row("1", "a", "2026-01-01", true), + ]; + let mut rosters = HashMap::new(); + rosters.insert("2".to_string(), vec![json!({"title": "live"})]); + let mut inherited = HashMap::new(); + inherited.insert("a".to_string(), vec![json!({"title": "kept"})]); + inherited.insert("b".to_string(), vec![json!({"title": "stale"})]); + let out = build_sessions(&rows, &rosters, &inherited); + assert_eq!(out[0].name, "a"); + // Never woken in this run: keeps the agents of the previous run. + assert_eq!(out[0].agents[0]["title"], "kept"); + // Roster published in this run: wins over the inherited one. + assert_eq!(out[1].agents[0]["title"], "live"); + } + + #[test] + fn an_emptied_live_roster_does_not_fall_back_to_the_inherited_one() { + let rows = vec![row("1", "a", "2026-01-01", false)]; + let mut rosters = HashMap::new(); + rosters.insert("1".to_string(), Vec::new()); + let mut inherited = HashMap::new(); + inherited.insert( + "a".to_string(), + vec![json!({"title": "closed by the user"})], + ); + assert!(build_sessions(&rows, &rosters, &inherited)[0] + .agents + .is_empty()); + } + + #[test] + fn file_round_trips_and_tolerates_unknown_and_missing_fields() { + let file = FileV1 { + version: 1, + current: snap(&["a"]), + previous: Some(snap(&["old"])), + }; + let text = serde_json::to_string(&file).unwrap(); + let back: FileV1 = serde_json::from_str(&text).unwrap(); + assert_eq!(back.current, file.current); + assert_eq!(back.previous, file.previous); + let loose: FileV1 = + serde_json::from_str(r#"{"sessions":[{"name":"x","future":1}],"extra":true}"#).unwrap(); + assert_eq!(loose.current.sessions[0].name, "x"); + } +} diff --git a/apps/desktop/src-tauri/src/commands/session.rs b/apps/desktop/src-tauri/src/commands/session.rs index 1b75738..09f80f7 100644 --- a/apps/desktop/src-tauri/src/commands/session.rs +++ b/apps/desktop/src-tauri/src/commands/session.rs @@ -1403,6 +1403,31 @@ impl SessionManager { self.state.lock().db_ids.get(session_id).cloned() } + /// Main sessions open right now, for the local `open-state.json` record + /// (the "Last closure" tab of the resume modal). Read only. + pub(crate) fn open_state_rows(&self) -> Vec { + let guard = self.state.lock(); + guard + .sessions + .iter() + .filter(|(_, s)| s.parent_session_id.is_none() && !s.name.starts_with("[sub]")) + .map(|(id, s)| crate::commands::open_state::SessionRow { + session_id: id.clone(), + db_id: guard.db_ids.get(id).cloned(), + name: s.name.clone(), + provider: s.provider.clone(), + workspace_path: s + .config + .cwd + .clone() + .or_else(|| s.workspace.as_ref().map(|w| w.path.clone())), + workspace_name: s.workspace.as_ref().map(|w| w.name.clone()), + standby: s.standby_at.is_some(), + created_at: s.created_at.clone(), + }) + .collect() + } + /// Other main sessions of the same resume family on the same cwd? /// Without a captured id, `--continue` would resume the most recent /// conversation in the folder: with "neighbors" it is almost certainly another @@ -3472,6 +3497,8 @@ pub fn start_auto_standby_scheduler( // Autosave of live sessions' screens (crash/force-quit safety), // whether auto-standby is on or off. sessions.autosave_live_buffers(); + // Record of what is open: same tick, written only when it changes. + crate::commands::open_state::tick(sessions.open_state_rows()); if slept { let guard = sessions.state.lock(); for s in guard.sessions.values() { diff --git a/apps/desktop/src-tauri/src/commands/speedtest.rs b/apps/desktop/src-tauri/src/commands/speedtest.rs index 0ff3a6d..2b7256f 100644 --- a/apps/desktop/src-tauri/src/commands/speedtest.rs +++ b/apps/desktop/src-tauri/src/commands/speedtest.rs @@ -51,6 +51,7 @@ pub(crate) fn model_baseline(model_id: &str) -> Option { "claude-haiku-4-5" => baseline("fast", 60.0, 110.0), "claude-sonnet-5" => baseline("balanced", 40.0, 80.0), // Max raised 60 → 70 after real end-to-end runs (~65 tok/s, 2026-07-16). + "claude-fable-5-2" => baseline("reasoning", 25.0, 70.0), "claude-fable-5-1" => baseline("reasoning", 25.0, 70.0), "claude-fable-5" => baseline("reasoning", 25.0, 70.0), "claude-opus-5-5" => baseline("reasoning", 25.0, 60.0), @@ -131,6 +132,7 @@ pub fn speed_test_models() -> Vec { }, }; vec![ + claude("claude-fable-5-2", "Claude Fable 5.2"), claude("claude-fable-5-1", "Claude Fable 5.1"), claude("claude-fable-5", "Claude Fable 5"), claude("claude-opus-5-5", "Claude Opus 5.5"), diff --git a/apps/desktop/src-tauri/src/commands/subagent.rs b/apps/desktop/src-tauri/src/commands/subagent.rs index c8ee5b9..ac6b852 100644 --- a/apps/desktop/src-tauri/src/commands/subagent.rs +++ b/apps/desktop/src-tauri/src/commands/subagent.rs @@ -403,6 +403,7 @@ pub(crate) fn push_stash_snapshot( .take(MAX_STASH_ENTRIES) .map(|(_, e)| e) .collect(); + crate::commands::open_state::note_roster(parent_session_id, &sub_agents); let _ = app.emit( "subagent:stash-updated", StashUpdatedEvent { @@ -462,6 +463,7 @@ pub(crate) fn push_stash_frozen( .take(MAX_STASH_ENTRIES) .map(|(_, e)| e) .collect(); + crate::commands::open_state::note_roster(parent_session_id, &sub_agents); let _ = app.emit( "subagent:stash-updated", StashUpdatedEvent { diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 184f335..9fd43bc 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -615,6 +615,7 @@ pub fn run() { commands::subagent::subagent_buffer, commands::subagent::subagent_resize, commands::subagent::subagent_list_archived, + commands::open_state::open_state_last_closure, commands::subagent::subagent_clear_archived, commands::subagent::subagent_worker_transcript, commands::subagent::subagent_worker_send, @@ -1239,6 +1240,9 @@ pub fn run() { // restored sessions restart with the last screen (no black // terminal during the provider's resume). sessions.persist_live_buffers_to_disk(); + // Local record: mark the clean exit BEFORE the shutdown + // kills, which would otherwise empty the rosters. + commands::open_state::mark_clean_exit(sessions.open_state_rows()); let subs = subagents.archived_snapshot_json(); let _ = sessions.save_all_to_store(&subs); // Normal quit: agents in termd stop like the in-process ones diff --git a/apps/desktop/src-tauri/src/promptops_bridge.js b/apps/desktop/src-tauri/src/promptops_bridge.js index cc9edc6..ed3b40a 100644 --- a/apps/desktop/src-tauri/src/promptops_bridge.js +++ b/apps/desktop/src-tauri/src/promptops_bridge.js @@ -621,8 +621,12 @@ agentsMetaGet: function (sessionId) { return call('agents_meta_get', { sessionId: sessionId }); }, - agentsSetLead: function (sessionId, leadId) { - return call('agents_set_lead', { sessionId: sessionId, leadId: leadId == null ? null : leadId }); + agentsSetLead: function (sessionId, leadId, leadKey) { + return call('agents_set_lead', { + sessionId: sessionId, + leadId: leadId == null ? null : leadId, + leadKey: leadId == null || leadKey == null ? null : leadKey, + }); }, agentsSetOrder: function (sessionId, order) { return call('agents_set_order', { sessionId: sessionId, order: order || [] }); @@ -1114,6 +1118,10 @@ .then(function (list) { return { ok: true, archived: list || [] }; }) .catch(function (e) { return { ok: false, error: String(e) }; }); }, + // Sessions and agents open in the previous run (open-state.json), or null. + getLastClosure: function () { + return call('open_state_last_closure').catch(function () { return null; }); + }, clearArchivedSubAgent: function (parentSessionId, index) { return call('subagent_clear_archived', { parentSessionId: parentSessionId, index: index }); }, diff --git a/apps/web/src/app/core/helper/terminal-bang-command-runner.ts b/apps/web/src/app/core/helper/terminal-bang-command-runner.ts index 79a14ac..c50f3b9 100644 --- a/apps/web/src/app/core/helper/terminal-bang-command-runner.ts +++ b/apps/web/src/app/core/helper/terminal-bang-command-runner.ts @@ -1,4 +1,5 @@ -import type { Terminal, IDisposable, IMarker, IDecoration } from '@xterm/xterm'; +import type { Terminal, IDisposable } from '@xterm/xterm'; +import { createRowButtonLayer, isTerminalVisible, makeActionButton, type RowButton } from './terminal-row-buttons'; /** * Play button on Claude Code's "run this yourself" lines. @@ -12,6 +13,10 @@ import type { Terminal, IDisposable, IMarker, IDecoration } from '@xterm/xterm'; * * Lines inside/below Claude's input box (between the `────` rules at the * bottom) are ignored, so the user's own `! …` draft never gets a button. + * + * Works on the alternate screen too (Claude Code `"tui": "fullscreen"`): + * see terminal-row-buttons.ts. The copy icon at the end of the same rows is + * drawn by terminal-link-copier.ts. */ // The command can also be the FIRST line of Claude's reply: then the row is @@ -22,12 +27,6 @@ const SCAN_BACK_LINES = 300; const BOTTOM_LOOKUP_LINES = 30; const MAX_BUTTONS = 60; -interface BangEntry { - marker: IMarker; - decoration: IDecoration; - command: string; -} - export interface BangCommandRunnerOptions { /** Re-evaluated on every scan (provider can change in place). */ isEnabled: () => boolean; @@ -66,13 +65,18 @@ const NEW_ITEM_RE = /^\s*(?:\d+[.)]|[-*•●⏺]|!)\s/; * long commands with hard newlines (not xterm soft wraps), so a following row * is a continuation when its first word could not have fit on the row above. */ -function bangAt(terminal: Terminal, index: number, limit: number): { indent: number; command: string } | null { +export function bangAt( + terminal: Terminal, + index: number, + limit: number, +): { indent: number; command: string; endRow: number; endCol: number } | null { const m = BANG_LINE_RE.exec(lineText(terminal, index)); if (!m) return null; const buffer = terminal.buffer.active; const cols = terminal.cols; let command = m[2]; let prev = lineText(terminal, index).replace(/\s+$/, ''); + let endRow = index; for (let i = index + 1; i < limit; i++) { const row = buffer.getLine(i); if (!row) break; @@ -80,6 +84,7 @@ function bangAt(terminal: Terminal, index: number, limit: number): { indent: num if (row.isWrapped) { command += text.replace(/\s+$/, ''); prev = text; + endRow = i; continue; } const body = text.trim(); @@ -89,119 +94,75 @@ function bangAt(terminal: Terminal, index: number, limit: number): { indent: num if (prev.length + 1 + firstWord.length <= cols) break; command += ' ' + body; prev = text.replace(/\s+$/, ''); + endRow = i; + } + return { indent: m[1].length, command, endRow, endCol: prev.replace(/\s+$/, '').length }; +} + +/** + * Rows `[from, limit)` where a `! command` can be Claude's: everything above + * the input box (the user's own draft never gets a button). + */ +export function bangScanRange(terminal: Terminal): { from: number; limit: number } { + const buffer = terminal.buffer.active; + if (buffer.type !== 'normal') { + // Fullscreen TUI: the cursor can sit anywhere. The input box is the last + // pair of rules near the bottom, its top rule is the limit. + let limit = buffer.length; + let rules = 0; + for (let i = buffer.length - 1; i >= Math.max(0, buffer.length - BOTTOM_LOOKUP_LINES) && rules < 2; i--) { + if (INPUT_RULE_RE.test(lineText(terminal, i))) { limit = i; rules++; } + } + return { from: 0, limit }; + } + const cursorAbs = buffer.baseY + buffer.cursorY; + let limit = cursorAbs + 1; + for (let i = cursorAbs; i >= Math.max(0, cursorAbs - BOTTOM_LOOKUP_LINES); i--) { + if (INPUT_RULE_RE.test(lineText(terminal, i))) limit = i; } - return { indent: m[1].length, command }; + return { from: Math.max(0, limit - SCAN_BACK_LINES), limit }; } export function attachBangCommandRunner(terminal: Terminal, opts: BangCommandRunnerOptions): IDisposable { - let entries: BangEntry[] = []; + const layer = createRowButtonLayer(terminal, MAX_BUTTONS); let timer: ReturnType | null = null; let disposed = false; - const drop = (entry: BangEntry) => { - entry.decoration.dispose(); - entry.marker.dispose(); - }; - const clear = () => { - for (const e of entries) drop(e); - entries = []; - }; - - const renderButton = (el: HTMLElement, entry: BangEntry) => { - if (el.dataset['poBang']) return; - el.dataset['poBang'] = '1'; - el.style.pointerEvents = 'auto'; - el.style.zIndex = '7'; - const btn = document.createElement('button'); - btn.type = 'button'; - btn.textContent = '▶'; - const label = opts.runLabel ? opts.runLabel(entry.command) : `Run: ${entry.command}`; - btn.title = label; - btn.setAttribute('aria-label', label); - Object.assign(btn.style, { - width: '100%', - height: '100%', - padding: '0', - margin: '0', - border: 'none', - borderRadius: '3px', - background: '#4F41FA', - color: '#fff', - fontSize: '0.7em', - lineHeight: '1', - cursor: 'pointer', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - } satisfies Partial); - // Keep xterm from starting a selection / stealing the click. - btn.addEventListener('mousedown', (ev) => { ev.preventDefault(); ev.stopPropagation(); }); - btn.addEventListener('click', (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - // Stale rows are dropped by scan(); don't re-validate here, a redraw in - // progress made the click silently do nothing. - if (btn.disabled) return; - btn.disabled = true; - btn.textContent = '✓'; - btn.style.background = '#16a34a'; - void typeBangCommand(opts.write, entry.command).finally(() => { - setTimeout(() => { - btn.disabled = false; - btn.textContent = '▶'; - btn.style.background = '#4F41FA'; - }, 1500); - terminal.focus(); - }); - }); - el.appendChild(btn); - }; - const scan = () => { timer = null; if (disposed) return; - const buffer = terminal.buffer.active; - if (!opts.isEnabled() || buffer.type !== 'normal') { - clear(); + // Hidden tab / pane: no work now, look again later (cheap check). + if (!isTerminalVisible(terminal)) { + timer = setTimeout(scan, 1500); return; } - - const cursorAbs = buffer.baseY + buffer.cursorY; - let limit = cursorAbs + 1; - for (let i = cursorAbs; i >= Math.max(0, cursorAbs - BOTTOM_LOOKUP_LINES); i--) { - if (INPUT_RULE_RE.test(buffer.getLine(i)?.translateToString(true) ?? '')) limit = i; + if (!opts.isEnabled()) { + layer.clear(); + return; } - - // Drop buttons whose row was trimmed or redrawn with something else. - entries = entries.filter((e) => { - const b = e.marker.isDisposed ? null : bangAt(terminal, e.marker.line, limit); - if (b && b.command === e.command) return true; - drop(e); - return false; - }); - - const taken = new Set(entries.map((e) => e.marker.line)); - for (let i = Math.max(0, limit - SCAN_BACK_LINES); i < limit; i++) { + const buffer = terminal.buffer.active; + const { from, limit } = bangScanRange(terminal); + const items: RowButton[] = []; + for (let i = from; i < limit; i++) { const row = buffer.getLine(i); - if (!row || row.isWrapped || taken.has(i)) continue; + if (!row || row.isWrapped) continue; const b = bangAt(terminal, i, limit); if (!b) continue; - const indent = b.indent; - const marker = terminal.registerMarker(i - cursorAbs); - if (!marker) continue; - const decoration = terminal.registerDecoration({ - marker, - x: Math.max(0, indent - 2), - width: Math.min(2, indent), - layer: 'top', + const command = b.command; + items.push({ + row: i, + x: Math.max(0, b.indent - 2), + width: Math.min(2, b.indent), + key: command, + create: () => + makeActionButton(terminal, { + label: '▶', + title: opts.runLabel ? opts.runLabel(command) : `Run: ${command}`, + run: () => typeBangCommand(opts.write, command), + }), }); - if (!decoration) { marker.dispose(); continue; } - const entry: BangEntry = { marker, decoration, command: b.command }; - decoration.onRender((el) => renderButton(el, entry)); - entries.push(entry); } - - while (entries.length > MAX_BUTTONS) drop(entries.shift()!); + layer.sync(items, from); }; const schedule = () => { @@ -210,6 +171,7 @@ export function attachBangCommandRunner(terminal: Terminal, opts: BangCommandRun const subs = [ terminal.onWriteParsed(schedule), terminal.buffer.onBufferChange(schedule), + terminal.onResize(schedule), ]; schedule(); @@ -218,7 +180,7 @@ export function attachBangCommandRunner(terminal: Terminal, opts: BangCommandRun disposed = true; if (timer !== null) clearTimeout(timer); for (const s of subs) s.dispose(); - clear(); + layer.dispose(); }, }; } diff --git a/apps/web/src/app/core/helper/terminal-file-link-provider.ts b/apps/web/src/app/core/helper/terminal-file-link-provider.ts index 0740b95..0340b3f 100644 --- a/apps/web/src/app/core/helper/terminal-file-link-provider.ts +++ b/apps/web/src/app/core/helper/terminal-file-link-provider.ts @@ -12,7 +12,7 @@ import type { Terminal, ILinkProvider, ILink } from '@xterm/xterm'; * An optional `:line[:col]` suffix (common in CLI output) is captured so it can * be stripped before the path is resolved. */ -const FILE_PATH_RE = +export const FILE_PATH_RE = /(?` rows (next to the play + * button of terminal-bang-command-runner.ts). With `openUrl` / `openFile` + * a `↗` icon opens the link, or the file / image path Claude mentions (same + * paths as terminal-file-link-provider.ts). Normal and alternate screen, see + * terminal-row-buttons.ts. */ // Optional box border (fullscreen TUI dialogs) around the URL text. @@ -19,18 +28,30 @@ const LOGIN_HINT_RE = /oauth|authorize|login|signin|sign-in|auth\?/i; const MIN_PLAIN_URL_LEN = 100; const MAX_CONT_ROWS = 20; const SCAN_BACK_LINES = 300; -const MAX_BUTTONS = 20; +const MAX_BUTTONS = 80; const BTN_WIDTH = 6; -interface LinkEntry { - marker: IMarker; - decoration: IDecoration; - url: string; - /** Rows between the anchor (button) row and the URL's first row. */ - urlOffset: number; +const INLINE_URL_RE = /https?:\/\/[^\s│┃<>"`]+/g; +const URL_TAIL_RE = /^[^\s│┃<>"`]+/; +const TRAILING_PUNCT_RE = /[.,;:!?)\]}'»]+$/; + +export interface LinkCopierLabels { + copy: string; + copied: string; + copyLink: string; + openLink: string; + copyCommand: string; + openFile: (path: string) => string; } -const urlRowOf = (e: LinkEntry) => e.marker.line + e.urlOffset; +const DEFAULT_LABELS: LinkCopierLabels = { + copy: 'Copy', + copied: 'Copied', + copyLink: 'Copy link', + openLink: 'Open link', + copyCommand: 'Copy command', + openFile: (path) => `Open ${path}`, +}; export interface LinkCopierOptions { /** Re-evaluated on every scan (provider can change in place). */ @@ -38,7 +59,11 @@ export interface LinkCopierOptions { /** Clipboard write (navigator.clipboard / native bridge). */ copy: (text: string) => Promise; /** Button texts, translated by the caller (English when omitted). */ - labels?: { copy: string; copied: string; copyLink: string }; + labels?: Partial; + /** Open a URL: adds a `↗` icon next to the copy one. */ + openUrl?: (url: string) => void; + /** Open a file path printed by the CLI (code editor / image preview): `↗` icon after it. */ + openFile?: (path: string) => void; } function rowText(terminal: Terminal, index: number): string { @@ -88,104 +113,92 @@ export function readUrlAt(terminal: Terminal, index: number): { url: string; ind } export function attachLinkCopier(terminal: Terminal, opts: LinkCopierOptions): IDisposable { - let entries: LinkEntry[] = []; + const layer = createRowButtonLayer(terminal, MAX_BUTTONS); let timer: ReturnType | null = null; let disposed = false; - const drop = (entry: LinkEntry) => { - entry.decoration.dispose(); - entry.marker.dispose(); - }; - const clear = () => { - for (const e of entries) drop(e); - entries = []; + // Row text, read once per scan. + let rowCache = new Map(); + const physical = (i: number) => { + let t = rowCache.get(i); + if (t === undefined) { + t = terminal.buffer.active.getLine(i)?.translateToString(true) ?? ''; + rowCache.set(i, t); + } + return t; }; - const renderButton = (el: HTMLElement, entry: LinkEntry) => { - if (el.dataset['poLinkCopy']) return; - el.dataset['poLinkCopy'] = '1'; - el.style.pointerEvents = 'auto'; - el.style.zIndex = '7'; - const btn = document.createElement('button'); - btn.type = 'button'; - const labels = opts.labels ?? { copy: 'Copy', copied: 'Copied', copyLink: 'Copy link' }; - btn.textContent = `⧉ ${labels.copy}`; - btn.title = labels.copyLink; - btn.setAttribute('aria-label', labels.copyLink); - Object.assign(btn.style, { - width: '100%', - height: '100%', - padding: '0 2px', - margin: '0', - border: 'none', - borderRadius: '3px', - background: '#4F41FA', - color: '#fff', - fontSize: '0.75em', - lineHeight: '1', - whiteSpace: 'nowrap', - cursor: 'pointer', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - } satisfies Partial); - btn.addEventListener('mousedown', (ev) => { ev.preventDefault(); ev.stopPropagation(); }); - btn.addEventListener('click', (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - if (btn.disabled) return; - // Re-read: the TUI may have redrawn the rows since the scan. - const url = readUrlAt(terminal, urlRowOf(entry))?.url ?? entry.url; - btn.disabled = true; - void opts.copy(url) - .then(() => { - btn.textContent = `✓ ${labels.copied}`; - btn.style.background = '#16a34a'; - }) - .catch(() => { - btn.textContent = '✗'; - btn.style.background = '#dc2626'; - }) - .finally(() => { - setTimeout(() => { - btn.disabled = false; - btn.textContent = `⧉ ${labels.copy}`; - btn.style.background = '#4F41FA'; - }, 1500); - terminal.focus(); - }); - }); - el.appendChild(btn); + const labels: LinkCopierLabels = { ...DEFAULT_LABELS, ...opts.labels }; + + const copyIcon = (text: string, title: string) => () => + makeActionButton(terminal, { label: '⧉', title, run: () => opts.copy(text) }); + + const openIcon = (title: string, open: () => void) => () => + makeActionButton(terminal, { label: '↗', title, run: async () => open() }); + + /** + * Cells for up to `count` icons right after `endCol` on `row` (as many as + * the blank run allows, at least one), else the blank before `startCol`. + */ + const iconSlots = ( + row: number, + endCol: number, + startRow: number, + startCol: number, + count: number, + ): { row: number; x: number; width: number }[] => { + const rest = physical(row).slice(endCol); + const free = Math.min( + terminal.cols - endCol, + rest.trim() === '' ? Infinity : rest.length - rest.trimStart().length, + ); + for (let n = count; n >= 1; n--) { + if (free >= 2 * n + 1) return Array.from({ length: n }, (_, k) => ({ row, x: endCol + 1 + 2 * k, width: 2 })); + if (free >= 2 * n) return Array.from({ length: n }, (_, k) => ({ row, x: endCol + 2 * k, width: 2 })); + } + if (free >= 1) return [{ row, x: endCol, width: 1 }]; + if (startCol >= 1 && physical(startRow).slice(startCol - 1, startCol).trim() === '') { + return [{ row: startRow, x: startCol - 1, width: 1 }]; + } + return []; }; const scan = () => { timer = null; if (disposed) return; + // Hidden tab / pane: no work now, look again later (cheap check). + if (!isTerminalVisible(terminal)) { + timer = setTimeout(scan, 1500); + return; + } const buffer = terminal.buffer.active; // Normal AND alternate buffer: Claude Code with `"tui": "fullscreen"` - // draws `/login` on the alternate screen. + // draws on the alternate screen. if (!opts.isEnabled()) { - clear(); + layer.clear(); return; } - entries = entries.filter((e) => { - const r = e.marker.isDisposed ? null : readUrlAt(terminal, urlRowOf(e)); - if (r && r.url === e.url) return true; - drop(e); - return false; - }); - + rowCache = new Map(); const cursorAbs = buffer.baseY + buffer.cursorY; // Alternate screen: no scrollback, the cursor can sit anywhere → whole screen. const lastRow = buffer.type === 'alternate' ? buffer.length - 1 : cursorAbs; - const taken = new Set(entries.map(urlRowOf)); + const from = Math.max(0, lastRow - SCAN_BACK_LINES); const cols = terminal.cols; - for (let i = Math.max(0, lastRow - SCAN_BACK_LINES); i <= lastRow; i++) { + const items: RowButton[] = []; + const bigRows = new Set(); + const openUrl = opts.openUrl; + const openFile = opts.openFile; + + // 1) Long / login URLs alone on their rows: "⧉ Copy" button. + for (let i = from; i <= lastRow; i++) { const row = buffer.getLine(i); - if (!row || row.isWrapped || taken.has(i)) continue; + if (!row || row.isWrapped || bigRows.has(i) || !physical(i).includes('http')) continue; const found = readUrlAt(terminal, i); if (!found) continue; + let end = i; + for (let r = 0; r < found.rows; r++) end = nextLogicalRow(terminal, end); + for (let r = i; r < end; r++) bigRows.add(r); // Anchor: room on the row above (e.g. "…sign in:", not a border) → // button after its text; else the indent gutter; else the right edge. const aboveRaw = i > 0 ? rowText(terminal, i - 1).replace(/\s+$/, '') : null; @@ -200,21 +213,94 @@ export function attachLinkCopier(terminal: Terminal, opts: LinkCopierOptions): I } else { x = cols - BTN_WIDTH; } - const marker = terminal.registerMarker(anchorRow - cursorAbs); - if (!marker) continue; - const decoration = terminal.registerDecoration({ marker, x, width: BTN_WIDTH, layer: 'top' }); - if (!decoration) { marker.dispose(); continue; } - const entry: LinkEntry = { marker, decoration, url: found.url, urlOffset: i - anchorRow }; - decoration.onRender((el) => renderButton(el, entry)); - entries.push(entry); - taken.add(i); + const url = found.url; + // Open icon just left of the Copy button, when that cell pair is blank. + if (openUrl && x >= 3 && physical(anchorRow).slice(x - 3, x).trim() === '') { + items.push({ row: anchorRow, x: x - 2, width: 2, key: `O:${url}`, create: openIcon(labels.openLink, () => openUrl(url)) }); + } + items.push({ + row: anchorRow, + x, + width: BTN_WIDTH, + key: `U:${url}`, + create: () => + makeActionButton(terminal, { + label: `⧉ ${labels.copy}`, + title: labels.copyLink, + doneLabel: `✓ ${labels.copied}`, + fontSize: '0.75em', + run: () => opts.copy(url), + }), + }); + } + + // 2) Any other URL in the text: `⧉` icon right after it. + for (let i = from; i <= lastRow; i++) { + if (bigRows.has(i)) continue; + const text = physical(i); + const hasUrl = text.includes('http'); + if (!hasUrl && !(openFile && text.includes('/') && text.includes('.'))) continue; + const urlSpans: [number, number][] = []; + INLINE_URL_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while (hasUrl && (m = INLINE_URL_RE.exec(text))) { + urlSpans.push([m.index, m.index + m[0].length]); + let url = m[0]; + let endRow = i; + let endCol = m.index + url.length; + // Soft-wrapped onto the next rows. + while (endCol >= cols && buffer.getLine(endRow + 1)?.isWrapped) { + const tail = URL_TAIL_RE.exec(physical(endRow + 1)); + if (!tail) break; + url += tail[0]; + endRow++; + endCol = tail[0].length; + } + const punct = TRAILING_PUNCT_RE.exec(url); + if (punct && punct[0].length < endCol) { + url = url.slice(0, -punct[0].length); + } + if (url.length < 12) continue; + const target = url; + const actions = [{ key: `u:${target}`, create: copyIcon(target, labels.copyLink) }]; + if (openUrl) actions.push({ key: `o:${target}`, create: openIcon(labels.openLink, () => openUrl(target)) }); + iconSlots(endRow, endCol, i, m.index, actions.length).forEach((slot, k) => items.push({ ...slot, ...actions[k] })); + } + + // 2b) File / image paths Claude mentions: `↗` opens them. + if (!openFile || !text.includes('/') || !text.includes('.')) continue; + FILE_PATH_RE.lastIndex = 0; + while ((m = FILE_PATH_RE.exec(text))) { + const start = m.index; + const end = start + m[0].length; + if (urlSpans.some(([a, b]) => start < b && end > a)) continue; + const path = stripLineSuffix(m[0]); + const [slot] = iconSlots(i, end, i, start, 1); + if (slot) items.push({ ...slot, key: `f:${path}`, create: openIcon(labels.openFile(path), () => openFile(path)) }); + } + } + + // 3) Claude's `! ` rows: `⧉` icon after the command. + const bang = bangScanRange(terminal); + for (let i = Math.max(from, bang.from); i < bang.limit; i++) { + const row = buffer.getLine(i); + if (!row || row.isWrapped || !physical(i).includes('! ')) continue; + const b = bangAt(terminal, i, bang.limit); + if (!b || b.endCol + 3 > cols) continue; + items.push({ + row: b.endRow, + x: b.endCol + 1, + width: 2, + key: `c:${b.command}`, + create: copyIcon(b.command, labels.copyCommand), + }); } - while (entries.length > MAX_BUTTONS) drop(entries.shift()!); + layer.sync(items, from); }; const schedule = () => { - if (timer === null && !disposed) timer = setTimeout(scan, 250); + if (timer === null && !disposed) timer = setTimeout(scan, 300); }; const subs = [terminal.onWriteParsed(schedule), terminal.buffer.onBufferChange(schedule), terminal.onResize(schedule)]; schedule(); @@ -224,7 +310,7 @@ export function attachLinkCopier(terminal: Terminal, opts: LinkCopierOptions): I disposed = true; if (timer !== null) clearTimeout(timer); for (const s of subs) s.dispose(); - clear(); + layer.dispose(); }, }; } diff --git a/apps/web/src/app/core/helper/terminal-row-buttons.ts b/apps/web/src/app/core/helper/terminal-row-buttons.ts new file mode 100644 index 0000000..fddbc6c --- /dev/null +++ b/apps/web/src/app/core/helper/terminal-row-buttons.ts @@ -0,0 +1,235 @@ +import type { Terminal, IMarker, IDecoration } from '@xterm/xterm'; + +/** False while the terminal sits in a hidden tab / pane: callers skip their scan. */ +export function isTerminalVisible(terminal: Terminal): boolean { + const el = terminal.element; + return !!el && el.offsetParent !== null; +} + +/** + * Small DOM buttons anchored to terminal cells (play on `! command` rows, + * copy on links…), shared by terminal-bang-command-runner.ts and + * terminal-link-copier.ts. + * + * Normal buffer: xterm decorations on markers, so they scroll with the row. + * Alternate buffer (Claude Code `"tui": "fullscreen"`): xterm hides every + * decoration there (BufferDecorationRenderer → display:none), so the buttons + * go in our own overlay inside `.xterm-screen` (no scrollback: buffer row = + * viewport row), rebuilt by the caller's scan on every write. The TUI + * redraws the rows itself when it scrolls, so each overlay button remembers + * the text of its row and is removed in the same frame the row changes: it + * never lingers over the wrong line while waiting for the next scan. + */ + +export interface RowButton { + /** Absolute buffer line. */ + row: number; + /** First cell and width in cells. */ + x: number; + width: number; + /** Identity of the content (same row + x + key = same button, kept as is). */ + key: string; + create: () => HTMLElement; +} + +export interface RowButtonLayer { + /** + * Make the buttons match `items`. Buttons above `scannedFrom` (rows the + * caller did not re-read: old scrollback) are kept untouched. + */ + sync(items: RowButton[], scannedFrom: number): void; + clear(): void; + dispose(): void; +} + +interface Entry { + key: string; + x: number; + marker: IMarker; + decoration: IDecoration; +} + +const idOf = (row: number, x: number, key: string) => `${row}|${x}|${key}`; + +export function createRowButtonLayer(terminal: Terminal, maxButtons: number): RowButtonLayer { + let entries: Entry[] = []; + let overlay: HTMLElement | null = null; + const cells = new Map(); + const rowText = (row: number) => terminal.buffer.active.getLine(row)?.translateToString(true) ?? ''; + + const drop = (e: Entry) => { + e.decoration.dispose(); + e.marker.dispose(); + }; + const clearNormal = () => { + for (const e of entries) drop(e); + entries = []; + }; + const clearAlt = () => { + cells.clear(); + overlay?.remove(); + overlay = null; + }; + + const syncNormal = (items: RowButton[], scannedFrom: number) => { + const buffer = terminal.buffer.active; + const cursorAbs = buffer.baseY + buffer.cursorY; + const wanted = new Map(items.map((it) => [idOf(it.row, it.x, it.key), it])); + entries = entries.filter((e) => { + if (!e.marker.isDisposed) { + if (e.marker.line < scannedFrom) return true; + if (wanted.delete(idOf(e.marker.line, e.x, e.key))) return true; + } + drop(e); + return false; + }); + for (const it of wanted.values()) { + const marker = terminal.registerMarker(it.row - cursorAbs); + if (!marker) continue; + const decoration = terminal.registerDecoration({ marker, x: it.x, width: it.width, layer: 'top' }); + if (!decoration) { marker.dispose(); continue; } + decoration.onRender((el) => { + if (el.dataset['poRowBtn']) return; + el.dataset['poRowBtn'] = '1'; + el.style.pointerEvents = 'auto'; + el.style.zIndex = '7'; + el.appendChild(it.create()); + }); + entries.push({ key: it.key, x: it.x, marker, decoration }); + } + while (entries.length > maxButtons) drop(entries.shift()!); + }; + + const syncAlt = (items: RowButton[]) => { + const screen = terminal.element?.querySelector('.xterm-screen'); + if (!screen) return; + const cellW = screen.clientWidth / terminal.cols; + const cellH = screen.clientHeight / terminal.rows; + const seen = new Set(); + for (const it of items.slice(-maxButtons)) { + const id = idOf(it.row, it.x, it.key); + seen.add(id); + const known = cells.get(id); + let cell = known?.el; + if (known) known.text = rowText(it.row); + if (!cell) { + if (!overlay || !overlay.isConnected) { + overlay = document.createElement('div'); + Object.assign(overlay.style, { position: 'absolute', inset: '0', pointerEvents: 'none', zIndex: '7' }); + screen.appendChild(overlay); + } + cell = document.createElement('div'); + cell.style.position = 'absolute'; + cell.style.pointerEvents = 'auto'; + cell.appendChild(it.create()); + overlay.appendChild(cell); + cells.set(id, { el: cell, row: it.row, text: rowText(it.row) }); + } + Object.assign(cell.style, { + left: `${it.x * cellW}px`, + top: `${it.row * cellH}px`, + width: `${it.width * cellW}px`, + height: `${cellH}px`, + fontSize: `${terminal.options.fontSize ?? 13}px`, + }); + } + for (const [id, cell] of cells) { + if (seen.has(id)) continue; + cell.el.remove(); + cells.delete(id); + } + }; + + // Alternate screen only: drop at once the buttons whose row was redrawn. + const onWrite = terminal.onWriteParsed(() => { + if (!cells.size) return; + for (const [id, cell] of cells) { + if (rowText(cell.row) === cell.text) continue; + cell.el.remove(); + cells.delete(id); + } + }); + + return { + sync: (items, scannedFrom) => { + if (terminal.buffer.active.type === 'normal') { + clearAlt(); + syncNormal(items, scannedFrom); + } else { + clearNormal(); + syncAlt(items); + } + }, + clear: () => { + clearNormal(); + clearAlt(); + }, + dispose: () => { + onWrite.dispose(); + clearNormal(); + clearAlt(); + }, + }; +} + +export interface ActionButtonOptions { + label: string; + title: string; + /** Label shown for 1.5s after `run` resolved (default `✓`). */ + doneLabel?: string; + fontSize?: string; + run: () => Promise; +} + +/** Accent button filling its cell(s): ✓ green when `run` resolves, ✗ red when it rejects. */ +export function makeActionButton(terminal: Terminal, o: ActionButtonOptions): HTMLButtonElement { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = o.label; + btn.title = o.title; + btn.setAttribute('aria-label', o.title); + Object.assign(btn.style, { + width: '100%', + height: '100%', + padding: '0', + margin: '0', + border: 'none', + borderRadius: '3px', + background: '#4F41FA', + color: '#fff', + fontSize: o.fontSize ?? '0.7em', + lineHeight: '1', + whiteSpace: 'nowrap', + overflow: 'hidden', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + } satisfies Partial); + // Keep xterm from starting a selection / stealing the click. + btn.addEventListener('mousedown', (ev) => { ev.preventDefault(); ev.stopPropagation(); }); + btn.addEventListener('click', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + if (btn.disabled) return; + btn.disabled = true; + void o.run() + .then(() => { + btn.textContent = o.doneLabel ?? '✓'; + btn.style.background = '#16a34a'; + }) + .catch(() => { + btn.textContent = '✗'; + btn.style.background = '#dc2626'; + }) + .finally(() => { + setTimeout(() => { + btn.disabled = false; + btn.textContent = o.label; + btn.style.background = '#4F41FA'; + }, 1500); + terminal.focus(); + }); + }); + return btn; +} diff --git a/apps/web/src/app/core/icons/icons.generated.ts b/apps/web/src/app/core/icons/icons.generated.ts index 8f0dee5..381e0f9 100644 --- a/apps/web/src/app/core/icons/icons.generated.ts +++ b/apps/web/src/app/core/icons/icons.generated.ts @@ -1,4 +1,4 @@ // Generated by scripts/build-icons.mjs. Do not edit by hand. import type { IconifyJSON } from '@iconify/types'; -export const ICON_COLLECTIONS: IconifyJSON[] = [{"prefix":"codicon","icons":{"add":{"body":""},"chevron-right":{"body":""},"close":{"body":""},"copy":{"body":""},"edit":{"body":""},"globe":{"body":""},"info":{"body":""},"link-external":{"body":""},"loading":{"body":""},"open-preview":{"body":""},"plug":{"body":""},"refresh":{"body":""},"sparkle":{"body":""}},"lastModified":1792510296},{"prefix":"fluent","icons":{"people-team-24-filled":{"body":"","width":24,"height":24}},"lastModified":1791785206,"width":20,"height":20},{"prefix":"logos","icons":{"chrome":{"body":""}},"lastModified":1792341236,"width":256,"height":256},{"prefix":"mdi","icons":{"account-check":{"body":""},"alert":{"body":""},"alert-circle":{"body":""},"alert-circle-outline":{"body":""},"alert-octagon":{"body":""},"alert-outline":{"body":""},"apple":{"body":""},"arrow-down":{"body":""},"arrow-down-bold":{"body":""},"arrow-up":{"body":""},"arrow-up-bold":{"body":""},"backup-restore":{"body":""},"broom":{"body":""},"check":{"body":""},"check-circle":{"body":""},"check-circle-outline":{"body":""},"chevron-down":{"body":""},"chevron-left":{"body":""},"chevron-right":{"body":""},"chevron-up":{"body":""},"circle":{"body":""},"circle-outline":{"body":""},"close":{"body":""},"close-circle":{"body":""},"cloud-check-outline":{"body":""},"cloud-outline":{"body":""},"cloud-sync-outline":{"body":""},"cloud-upload-outline":{"body":""},"code-braces":{"body":""},"cog-outline":{"body":""},"comment":{"body":""},"comment-multiple-outline":{"body":""},"comment-text-outline":{"body":""},"connection":{"body":""},"console":{"body":""},"content-copy":{"body":""},"content-duplicate":{"body":""},"content-save":{"body":""},"database":{"body":""},"database-cog":{"body":""},"database-edit":{"body":""},"database-off":{"body":""},"database-plus":{"body":""},"database-remove":{"body":""},"database-sync":{"body":""},"delete-outline":{"body":""},"docker":{"body":""},"dots-vertical":{"body":""},"fast-forward":{"body":""},"file-compare":{"body":""},"file-document-edit":{"body":""},"file-document-multiple-outline":{"body":""},"file-document-outline":{"body":""},"file-edit-outline":{"body":""},"file-link-outline":{"body":""},"file-multiple-outline":{"body":""},"file-outline":{"body":""},"file-plus":{"body":""},"file-plus-outline":{"body":""},"file-question-outline":{"body":""},"file-remove":{"body":""},"file-swap":{"body":""},"file-tree":{"body":""},"file-tree-outline":{"body":""},"filter":{"body":""},"filter-outline":{"body":""},"find-replace":{"body":""},"folder":{"body":""},"folder-open":{"body":""},"folder-open-outline":{"body":""},"folder-outline":{"body":""},"folder-plus-outline":{"body":""},"fruit-cherries":{"body":""},"git":{"body":""},"help-circle-outline":{"body":""},"history":{"body":""},"key":{"body":""},"key-arrow-right":{"body":""},"key-plus":{"body":""},"lan-connect":{"body":""},"laptop":{"body":""},"link-variant":{"body":""},"linux":{"body":""},"loading":{"body":""},"lock":{"body":""},"lock-open":{"body":""},"lock-outline":{"body":""},"magnify":{"body":""},"message-text-outline":{"body":""},"microphone-off":{"body":""},"microsoft-windows":{"body":""},"minus":{"body":""},"monitor-off":{"body":""},"open-in-new":{"body":""},"pencil":{"body":""},"pencil-outline":{"body":""},"pine-tree":{"body":""},"play":{"body":""},"plus":{"body":""},"plus-circle-outline":{"body":""},"radiobox-blank":{"body":""},"radiobox-marked":{"body":""},"refresh":{"body":""},"rename-box-outline":{"body":""},"robot":{"body":""},"send":{"body":""},"shield-check-outline":{"body":""},"shield-key":{"body":""},"sleep":{"body":""},"source-branch":{"body":""},"source-branch-check":{"body":""},"source-branch-plus":{"body":""},"source-branch-refresh":{"body":""},"source-branch-sync":{"body":""},"source-commit":{"body":""},"source-merge":{"body":""},"source-repository":{"body":""},"swap-horizontal":{"body":""},"table":{"body":""},"table-arrow-left":{"body":""},"table-off":{"body":""},"tag":{"body":""},"tag-outline":{"body":""},"tag-plus-outline":{"body":""},"target":{"body":""},"text-box-check":{"body":""},"text-short":{"body":""},"trash-can-outline":{"body":""},"tray-arrow-down":{"body":""},"tray-arrow-up":{"body":""},"tune":{"body":""},"undo-variant":{"body":""},"web":{"body":""},"wrench":{"body":""}},"lastModified":1737398331,"aliases":{"terminal":{"parent":"console"}},"width":24,"height":24},{"prefix":"simple-icons","icons":{"anthropic":{"body":""},"apple":{"body":""},"cursor":{"body":""},"github":{"body":""},"githubcopilot":{"body":""},"google":{"body":""},"googlegemini":{"body":""},"linux":{"body":""},"meta":{"body":""},"openai":{"body":"","hidden":true},"powershell":{"body":"","hidden":true},"windows":{"body":"","hidden":true}},"lastModified":1791785520,"width":24,"height":24},{"prefix":"solar","icons":{"add-circle-bold":{"body":""},"add-circle-bold-duotone":{"body":""},"add-circle-linear":{"body":""},"add-folder-bold":{"body":""},"add-folder-bold-duotone":{"body":""},"add-folder-linear":{"body":""},"alt-arrow-down-bold":{"body":""},"alt-arrow-down-linear":{"body":""},"alt-arrow-left-bold":{"body":""},"alt-arrow-right-bold":{"body":""},"alt-arrow-right-linear":{"body":""},"alt-arrow-up-bold":{"body":""},"alt-arrow-up-linear":{"body":""},"arrow-left-line-duotone":{"body":""},"arrow-right-bold":{"body":""},"arrow-right-linear":{"body":""},"arrow-right-up-bold":{"body":""},"arrow-right-up-linear":{"body":""},"arrow-to-down-left-linear":{"body":""},"atom-bold-duotone":{"body":""},"bed-bold-duotone":{"body":""},"bell-bing-bold-duotone":{"body":"","hidden":true},"bell-bold-duotone":{"body":""},"black-hole-bold-duotone":{"body":""},"bolt-bold":{"body":""},"bolt-bold-duotone":{"body":""},"bolt-circle-bold":{"body":""},"bolt-circle-bold-duotone":{"body":""},"book-2-bold-duotone":{"body":""},"book-bookmark-bold-duotone":{"body":""},"box-bold-duotone":{"body":""},"box-minimalistic-linear":{"body":""},"brain-bold":{"body":""},"branching-paths-up-bold":{"body":""},"branching-paths-up-bold-duotone":{"body":""},"branching-paths-up-linear":{"body":""},"broom-bold":{"body":""},"bug-bold":{"body":""},"bug-bold-duotone":{"body":""},"buildings-3-bold-duotone":{"body":""},"chart-square-bold-duotone":{"body":""},"chat-round-call-bold":{"body":""},"chat-round-dots-bold":{"body":""},"chart-square-2-bold-duotone":{"body":""},"chat-square-code-bold-duotone":{"body":""},"check-bold":{"body":""},"check-circle-bold":{"body":""},"check-read-bold":{"body":""},"check-read-linear":{"body":""},"check-square-bold":{"body":""},"checklist-bold-duotone":{"body":""},"checklist-minimalistic-bold":{"body":""},"checklist-minimalistic-bold-duotone":{"body":""},"clipboard-list-bold-duotone":{"body":""},"close-circle-bold":{"body":""},"close-circle-line-duotone":{"body":""},"close-circle-linear":{"body":""},"cloud-bolt-bold-duotone":{"body":""},"cloud-cross-bold-duotone":{"body":""},"cloud-download-bold":{"body":""},"cloud-download-bold-duotone":{"body":""},"cloud-upload-bold-duotone":{"body":""},"code-bold":{"body":""},"code-bold-duotone":{"body":""},"file-code-bold":{"body":""},"code-square-bold":{"body":""},"code-square-bold-duotone":{"body":""},"command-bold":{"body":""},"command-bold-duotone":{"body":""},"copy-bold":{"body":""},"copy-bold-duotone":{"body":""},"copy-line-duotone":{"body":""},"cpu-bold":{"body":""},"cpu-bold-duotone":{"body":""},"cpu-bolt-bold":{"body":""},"cpu-bolt-bold-duotone":{"body":""},"cpu-bolt-linear":{"body":""},"cursor-bold":{"body":""},"cursor-bold-duotone":{"body":""},"danger-circle-bold":{"body":""},"danger-triangle-bold":{"body":""},"danger-triangle-bold-duotone":{"body":""},"danger-triangle-linear":{"body":""},"database-bold":{"body":""},"database-bold-duotone":{"body":""},"diskette-bold":{"body":""},"document-add-bold":{"body":""},"document-medicine-bold":{"body":""},"document-text-bold":{"body":""},"document-text-bold-duotone":{"body":""},"document-text-linear":{"body":""},"download-bold":{"body":""},"download-minimalistic-bold-duotone":{"body":""},"download-minimalistic-linear":{"body":""},"eye-scan-bold-duotone":{"body":""},"file-bold-duotone":{"body":""},"file-lock-bold":{"body":""},"file-terminal-bold":{"body":""},"file-terminal-bold-duotone":{"body":""},"file-text-bold":{"body":""},"filter-bold":{"body":""},"fire-bold-duotone":{"body":""},"folder-bold":{"body":""},"folder-bold-duotone":{"body":""},"folder-check-bold-duotone":{"body":""},"folder-error-bold-duotone":{"body":""},"folder-error-linear":{"body":""},"folder-linear":{"body":""},"folder-open-bold":{"body":""},"folder-open-bold-duotone":{"body":""},"folder-path-connect-bold-duotone":{"body":""},"folder-with-files-bold":{"body":""},"folder-with-files-bold-duotone":{"body":""},"folder-with-files-linear":{"body":""},"gallery-bold-duotone":{"body":""},"ghost-bold-duotone":{"body":""},"gift-bold-duotone":{"body":""},"global-bold":{"body":""},"global-linear":{"body":""},"globus-bold-duotone":{"body":"","hidden":true},"graph-bold":{"body":""},"hamburger-menu-linear":{"body":""},"history-bold":{"body":""},"home-2-linear":{"body":""},"home-smile-bold-duotone":{"body":""},"hourglass-bold-duotone":{"body":""},"inbox-line-bold-duotone":{"body":""},"info-circle-bold":{"body":""},"info-circle-bold-duotone":{"body":""},"info-circle-line-duotone":{"body":""},"info-circle-linear":{"body":""},"key-bold":{"body":""},"key-bold-duotone":{"body":""},"key-minimalistic-bold":{"body":""},"key-minimalistic-bold-duotone":{"body":""},"keyboard-bold":{"body":""},"keyboard-bold-duotone":{"body":""},"layers-bold":{"body":""},"layers-minimalistic-bold":{"body":""},"leaf-bold":{"body":""},"leaf-bold-duotone":{"body":""},"library-bold-duotone":{"body":""},"lightbulb-bold-duotone":{"body":""},"lightbulb-bolt-bold-duotone":{"body":""},"link-bold":{"body":""},"unlink-linear":{"body":""},"link-round-bold":{"body":""},"list-bold":{"body":""},"list-down-bold":{"body":""},"lock-bold":{"body":""},"lock-keyhole-bold":{"body":""},"lock-keyhole-bold-duotone":{"body":""},"lock-keyhole-minimalistic-bold":{"body":""},"lock-keyhole-minimalistic-linear":{"body":""},"lock-unlocked-bold":{"body":""},"magic-wand-3-bold":{"body":""},"magic-stick-3-bold-duotone":{"body":"","hidden":true},"magic-wand-bold":{"body":""},"magnifier-bold":{"body":""},"magnifer-bold-duotone":{"body":"","hidden":true},"magnifer-bug-bold-duotone":{"body":"","hidden":true},"magnifier-linear":{"body":""},"magnifier-zoom-in-bold":{"body":""},"magnifer-zoom-in-bold-duotone":{"body":"","hidden":true},"map-arrow-square-bold":{"body":""},"map-arrow-square-bold-duotone":{"body":""},"map-bold":{"body":""},"maximize-bold":{"body":""},"maximize-square-bold":{"body":""},"menu-dots-bold":{"body":""},"microphone-3-bold-duotone":{"body":""},"microphone-bold":{"body":""},"microphone-bold-duotone":{"body":""},"minimalistic-magnifer-bold-duotone":{"body":"","hidden":true},"minimize-bold":{"body":""},"minimize-square-bold":{"body":""},"minimize-square-bold-duotone":{"body":""},"minus-circle-bold":{"body":""},"monitor-bold":{"body":""},"monitor-bold-duotone":{"body":""},"monitor-smartphone-bold-duotone":{"body":""},"monitor-smartphone-line-duotone":{"body":""},"moon-bold":{"body":""},"moon-bold-duotone":{"body":""},"moon-sleep-bold-duotone":{"body":""},"mouse-bold":{"body":""},"notebook-bold-duotone":{"body":""},"pallete-2-bold-duotone":{"body":"","hidden":true},"paperclip-bold-duotone":{"body":""},"password-bold":{"body":""},"pause-bold":{"body":""},"pause-circle-linear":{"body":""},"pen-2-bold":{"body":""},"pen-2-linear":{"body":""},"pen-bold":{"body":""},"pen-new-round-bold":{"body":""},"pin-bold":{"body":""},"plane-bold":{"body":""},"plain-bold-duotone":{"body":"","hidden":true},"planet-bold-duotone":{"body":""},"play-bold":{"body":""},"play-circle-bold":{"body":""},"plug-circle-bold":{"body":""},"plug-circle-bold-duotone":{"body":""},"power-bold":{"body":""},"power-bold-duotone":{"body":""},"presentation-graph-bold":{"body":""},"programming-bold":{"body":""},"question-circle-bold":{"body":""},"question-circle-bold-duotone":{"body":""},"question-circle-linear":{"body":""},"record-circle-linear":{"body":""},"refresh-bold":{"body":""},"refresh-bold-duotone":{"body":""},"refresh-circle-bold-duotone":{"body":""},"refresh-linear":{"body":""},"restart-bold":{"body":""},"rocket-2-bold-duotone":{"body":""},"rocket-bold-duotone":{"body":""},"running-round-bold":{"body":""},"sad-circle-bold-duotone":{"body":""},"scissors-bold":{"body":""},"server-bold-duotone":{"body":""},"server-path-bold-duotone":{"body":""},"server-square-bold-duotone":{"body":""},"server-square-cloud-linear":{"body":""},"server-square-line-duotone":{"body":""},"settings-bold":{"body":""},"settings-bold-duotone":{"body":""},"settings-minimalistic-bold-duotone":{"body":""},"share-circle-bold":{"body":""},"shield-check-bold":{"body":""},"shield-check-bold-duotone":{"body":""},"shield-cross-bold-duotone":{"body":""},"shield-keyhole-bold":{"body":""},"shield-warning-bold":{"body":""},"shield-warning-bold-duotone":{"body":""},"panel-right-bold":{"body":""},"sledgehammer-bold-duotone":{"body":""},"smartphone-bold":{"body":""},"sort-vertical-linear":{"body":""},"square-arrow-right-up-bold":{"body":""},"square-top-up-linear":{"body":""},"ssd-square-bold":{"body":""},"star-bold":{"body":""},"star-bold-duotone":{"body":""},"star-line-duotone":{"body":""},"stars-bold-duotone":{"body":""},"stop-bold":{"body":""},"stop-circle-bold":{"body":""},"stop-circle-bold-duotone":{"body":""},"stop-linear":{"body":""},"sun-bold":{"body":""},"tag-bold":{"body":""},"target-bold":{"body":""},"target-bold-duotone":{"body":""},"test-tube-bold-duotone":{"body":""},"text-bold-bold-duotone":{"body":""},"transfer-horizontal-bold":{"body":""},"trash-bin-minimalistic-bold":{"body":""},"trash-bin-minimalistic-bold-duotone":{"body":""},"trash-bin-minimalistic-linear":{"body":""},"trash-bin-trash-bold":{"body":""},"trash-bin-trash-bold-duotone":{"body":""},"trash-bin-trash-linear":{"body":""},"tuning-2-bold":{"body":""},"tuning-2-bold-duotone":{"body":""},"tuning-2-linear":{"body":""},"upload-bold":{"body":""},"upload-minimalistic-bold":{"body":""},"user-circle-bold-duotone":{"body":""},"user-rounded-linear":{"body":""},"users-group-rounded-bold":{"body":""},"users-group-rounded-bold-duotone":{"body":""},"wallet-bold":{"body":""},"wallet-bold-duotone":{"body":""},"wallet-money-bold-duotone":{"body":""},"widget-2-bold":{"body":""},"widget-2-bold-duotone":{"body":""},"widget-4-linear":{"body":""},"widget-add-bold-duotone":{"body":""},"widget-bold":{"body":""},"window-frame-bold":{"body":""},"window-frame-bold-duotone":{"body":""},"window-frame-linear":{"body":""}},"lastModified":1790422589,"aliases":{"chat-square-2-bold-duotone":{"parent":"chart-square-2-bold-duotone"},"code-file-bold":{"parent":"file-code-bold"},"link-broken-linear":{"parent":"unlink-linear"},"magic-stick-3-bold":{"parent":"magic-wand-3-bold"},"magnifer-bold":{"parent":"magnifier-bold"},"magnifer-linear":{"parent":"magnifier-linear"},"magnifer-zoom-in-bold":{"parent":"magnifier-zoom-in-bold"},"plain-bold":{"parent":"plane-bold"},"sidebar-minimalistic-bold":{"parent":"panel-right-bold"}},"width":24,"height":24},{"prefix":"svg-spinners","icons":{"90-ring-with-bg":{"body":""},"ring-resize":{"body":""}},"lastModified":1754901419,"width":24,"height":24},{"prefix":"tabler","icons":{"app-window":{"body":""},"arrow-back-up":{"body":""},"arrows-maximize":{"body":""},"arrows-minimize":{"body":""},"code":{"body":""},"layout-columns":{"body":""},"layout-grid":{"body":""},"layout-rows":{"body":""},"terminal-2":{"body":""},"users-group":{"body":""}},"lastModified":1791401203,"width":24,"height":24},{"prefix":"vscode-icons","icons":{"file-type-css":{"body":""},"file-type-docker":{"body":""},"file-type-dotenv":{"body":""},"file-type-git":{"body":""},"file-type-go":{"body":""},"file-type-graphql":{"body":""},"file-type-html":{"body":""},"file-type-image":{"body":""},"file-type-java":{"body":""},"file-type-js":{"body":""},"file-type-json":{"body":""},"file-type-less":{"body":""},"file-type-markdown":{"body":""},"file-type-php":{"body":""},"file-type-python":{"body":""},"file-type-ruby":{"body":""},"file-type-rust":{"body":""},"file-type-scss":{"body":""},"file-type-shell":{"body":""},"file-type-sql":{"body":""},"file-type-svg":{"body":""},"file-type-toml":{"body":""},"file-type-typescript":{"body":""},"file-type-vue":{"body":""},"file-type-xml":{"body":""},"file-type-yaml":{"body":""}},"lastModified":1792510922,"width":32,"height":32}]; +export const ICON_COLLECTIONS: IconifyJSON[] = [{"prefix":"codicon","icons":{"add":{"body":""},"chevron-right":{"body":""},"close":{"body":""},"copy":{"body":""},"edit":{"body":""},"globe":{"body":""},"info":{"body":""},"link-external":{"body":""},"loading":{"body":""},"open-preview":{"body":""},"plug":{"body":""},"refresh":{"body":""},"sparkle":{"body":""}},"lastModified":1792510296},{"prefix":"fluent","icons":{"people-team-24-filled":{"body":"","width":24,"height":24}},"lastModified":1791785206,"width":20,"height":20},{"prefix":"logos","icons":{"chrome":{"body":""}},"lastModified":1792341236,"width":256,"height":256},{"prefix":"mdi","icons":{"account-check":{"body":""},"alert":{"body":""},"alert-circle":{"body":""},"alert-circle-outline":{"body":""},"alert-octagon":{"body":""},"alert-outline":{"body":""},"apple":{"body":""},"arrow-down":{"body":""},"arrow-down-bold":{"body":""},"arrow-up":{"body":""},"arrow-up-bold":{"body":""},"backup-restore":{"body":""},"broom":{"body":""},"check":{"body":""},"check-circle":{"body":""},"check-circle-outline":{"body":""},"chevron-down":{"body":""},"chevron-left":{"body":""},"chevron-right":{"body":""},"chevron-up":{"body":""},"circle":{"body":""},"circle-outline":{"body":""},"close":{"body":""},"close-circle":{"body":""},"cloud-check-outline":{"body":""},"cloud-outline":{"body":""},"cloud-sync-outline":{"body":""},"cloud-upload-outline":{"body":""},"code-braces":{"body":""},"cog-outline":{"body":""},"comment":{"body":""},"comment-multiple-outline":{"body":""},"comment-text-outline":{"body":""},"connection":{"body":""},"console":{"body":""},"content-copy":{"body":""},"content-duplicate":{"body":""},"content-save":{"body":""},"database":{"body":""},"database-cog":{"body":""},"database-edit":{"body":""},"database-off":{"body":""},"database-plus":{"body":""},"database-remove":{"body":""},"database-sync":{"body":""},"delete-outline":{"body":""},"docker":{"body":""},"dots-vertical":{"body":""},"fast-forward":{"body":""},"file-compare":{"body":""},"file-document-edit":{"body":""},"file-document-multiple-outline":{"body":""},"file-document-outline":{"body":""},"file-edit-outline":{"body":""},"file-link-outline":{"body":""},"file-multiple-outline":{"body":""},"file-outline":{"body":""},"file-plus":{"body":""},"file-plus-outline":{"body":""},"file-question-outline":{"body":""},"file-remove":{"body":""},"file-swap":{"body":""},"file-tree":{"body":""},"file-tree-outline":{"body":""},"filter":{"body":""},"filter-outline":{"body":""},"find-replace":{"body":""},"folder":{"body":""},"folder-open":{"body":""},"folder-open-outline":{"body":""},"folder-outline":{"body":""},"folder-plus-outline":{"body":""},"fruit-cherries":{"body":""},"git":{"body":""},"help-circle-outline":{"body":""},"history":{"body":""},"key":{"body":""},"key-arrow-right":{"body":""},"key-plus":{"body":""},"lan-connect":{"body":""},"laptop":{"body":""},"link-variant":{"body":""},"linux":{"body":""},"loading":{"body":""},"lock":{"body":""},"lock-open":{"body":""},"lock-outline":{"body":""},"magnify":{"body":""},"message-text-outline":{"body":""},"microphone-off":{"body":""},"microsoft-windows":{"body":""},"minus":{"body":""},"monitor-off":{"body":""},"open-in-new":{"body":""},"pencil":{"body":""},"pencil-outline":{"body":""},"pine-tree":{"body":""},"play":{"body":""},"plus":{"body":""},"plus-circle-outline":{"body":""},"radiobox-blank":{"body":""},"radiobox-marked":{"body":""},"refresh":{"body":""},"rename-box-outline":{"body":""},"robot":{"body":""},"send":{"body":""},"shield-check-outline":{"body":""},"shield-key":{"body":""},"sleep":{"body":""},"source-branch":{"body":""},"source-branch-check":{"body":""},"source-branch-plus":{"body":""},"source-branch-refresh":{"body":""},"source-branch-sync":{"body":""},"source-commit":{"body":""},"source-merge":{"body":""},"source-repository":{"body":""},"swap-horizontal":{"body":""},"table":{"body":""},"table-arrow-left":{"body":""},"table-off":{"body":""},"tag":{"body":""},"tag-outline":{"body":""},"tag-plus-outline":{"body":""},"target":{"body":""},"text-box-check":{"body":""},"text-short":{"body":""},"trash-can-outline":{"body":""},"tray-arrow-down":{"body":""},"tray-arrow-up":{"body":""},"tune":{"body":""},"undo-variant":{"body":""},"web":{"body":""},"wrench":{"body":""}},"lastModified":1737398331,"aliases":{"terminal":{"parent":"console"}},"width":24,"height":24},{"prefix":"simple-icons","icons":{"anthropic":{"body":""},"apple":{"body":""},"cursor":{"body":""},"github":{"body":""},"githubcopilot":{"body":""},"google":{"body":""},"googlegemini":{"body":""},"linux":{"body":""},"meta":{"body":""},"openai":{"body":"","hidden":true},"powershell":{"body":"","hidden":true},"windows":{"body":"","hidden":true}},"lastModified":1791785520,"width":24,"height":24},{"prefix":"solar","icons":{"add-circle-bold":{"body":""},"add-circle-bold-duotone":{"body":""},"add-circle-linear":{"body":""},"add-folder-bold":{"body":""},"add-folder-bold-duotone":{"body":""},"add-folder-linear":{"body":""},"alt-arrow-down-bold":{"body":""},"alt-arrow-down-linear":{"body":""},"alt-arrow-left-bold":{"body":""},"alt-arrow-right-bold":{"body":""},"alt-arrow-right-linear":{"body":""},"alt-arrow-up-bold":{"body":""},"alt-arrow-up-linear":{"body":""},"arrow-left-line-duotone":{"body":""},"arrow-right-bold":{"body":""},"arrow-right-linear":{"body":""},"arrow-right-up-bold":{"body":""},"arrow-right-up-linear":{"body":""},"arrow-to-down-left-linear":{"body":""},"atom-bold-duotone":{"body":""},"bed-bold-duotone":{"body":""},"bell-bing-bold-duotone":{"body":"","hidden":true},"bell-bold-duotone":{"body":""},"black-hole-bold-duotone":{"body":""},"bolt-bold":{"body":""},"bolt-bold-duotone":{"body":""},"bolt-circle-bold":{"body":""},"bolt-circle-bold-duotone":{"body":""},"book-2-bold-duotone":{"body":""},"book-bookmark-bold-duotone":{"body":""},"box-bold-duotone":{"body":""},"box-minimalistic-linear":{"body":""},"brain-bold":{"body":""},"branching-paths-up-bold":{"body":""},"branching-paths-up-bold-duotone":{"body":""},"branching-paths-up-linear":{"body":""},"broom-bold":{"body":""},"bug-bold":{"body":""},"bug-bold-duotone":{"body":""},"buildings-3-bold-duotone":{"body":""},"chart-square-bold-duotone":{"body":""},"chat-round-call-bold":{"body":""},"chat-round-dots-bold":{"body":""},"chart-square-2-bold-duotone":{"body":""},"chat-square-code-bold-duotone":{"body":""},"check-bold":{"body":""},"check-circle-bold":{"body":""},"check-read-bold":{"body":""},"check-read-linear":{"body":""},"check-square-bold":{"body":""},"checklist-bold-duotone":{"body":""},"checklist-minimalistic-bold":{"body":""},"checklist-minimalistic-bold-duotone":{"body":""},"clipboard-list-bold-duotone":{"body":""},"clock-circle-bold":{"body":""},"close-circle-bold":{"body":""},"close-circle-line-duotone":{"body":""},"close-circle-linear":{"body":""},"cloud-bolt-bold-duotone":{"body":""},"cloud-cross-bold-duotone":{"body":""},"cloud-download-bold":{"body":""},"cloud-download-bold-duotone":{"body":""},"cloud-upload-bold-duotone":{"body":""},"code-bold":{"body":""},"code-bold-duotone":{"body":""},"file-code-bold":{"body":""},"code-square-bold":{"body":""},"code-square-bold-duotone":{"body":""},"command-bold":{"body":""},"command-bold-duotone":{"body":""},"copy-bold":{"body":""},"copy-bold-duotone":{"body":""},"copy-line-duotone":{"body":""},"cpu-bold":{"body":""},"cpu-bold-duotone":{"body":""},"cpu-bolt-bold":{"body":""},"cpu-bolt-bold-duotone":{"body":""},"cpu-bolt-linear":{"body":""},"cursor-bold":{"body":""},"cursor-bold-duotone":{"body":""},"danger-circle-bold":{"body":""},"danger-triangle-bold":{"body":""},"danger-triangle-bold-duotone":{"body":""},"danger-triangle-linear":{"body":""},"database-bold":{"body":""},"database-bold-duotone":{"body":""},"diskette-bold":{"body":""},"document-add-bold":{"body":""},"document-medicine-bold":{"body":""},"document-text-bold":{"body":""},"document-text-bold-duotone":{"body":""},"document-text-linear":{"body":""},"download-bold":{"body":""},"download-minimalistic-bold-duotone":{"body":""},"download-minimalistic-linear":{"body":""},"eye-scan-bold-duotone":{"body":""},"file-bold-duotone":{"body":""},"file-lock-bold":{"body":""},"file-terminal-bold":{"body":""},"file-terminal-bold-duotone":{"body":""},"file-text-bold":{"body":""},"filter-bold":{"body":""},"fire-bold-duotone":{"body":""},"folder-bold":{"body":""},"folder-bold-duotone":{"body":""},"folder-check-bold-duotone":{"body":""},"folder-error-bold-duotone":{"body":""},"folder-error-linear":{"body":""},"folder-linear":{"body":""},"folder-open-bold":{"body":""},"folder-open-bold-duotone":{"body":""},"folder-path-connect-bold-duotone":{"body":""},"folder-with-files-bold":{"body":""},"folder-with-files-bold-duotone":{"body":""},"folder-with-files-linear":{"body":""},"gallery-bold-duotone":{"body":""},"ghost-bold-duotone":{"body":""},"gift-bold-duotone":{"body":""},"global-bold":{"body":""},"global-linear":{"body":""},"globus-bold-duotone":{"body":"","hidden":true},"graph-bold":{"body":""},"hamburger-menu-linear":{"body":""},"history-bold":{"body":""},"home-2-linear":{"body":""},"home-smile-bold-duotone":{"body":""},"hourglass-bold-duotone":{"body":""},"inbox-line-bold-duotone":{"body":""},"info-circle-bold":{"body":""},"info-circle-bold-duotone":{"body":""},"info-circle-line-duotone":{"body":""},"info-circle-linear":{"body":""},"key-bold":{"body":""},"key-bold-duotone":{"body":""},"key-minimalistic-bold":{"body":""},"key-minimalistic-bold-duotone":{"body":""},"keyboard-bold":{"body":""},"keyboard-bold-duotone":{"body":""},"layers-bold":{"body":""},"layers-minimalistic-bold":{"body":""},"leaf-bold":{"body":""},"leaf-bold-duotone":{"body":""},"library-bold-duotone":{"body":""},"lightbulb-bold-duotone":{"body":""},"lightbulb-bolt-bold-duotone":{"body":""},"link-bold":{"body":""},"unlink-linear":{"body":""},"link-round-bold":{"body":""},"list-bold":{"body":""},"list-down-bold":{"body":""},"lock-bold":{"body":""},"lock-keyhole-bold":{"body":""},"lock-keyhole-bold-duotone":{"body":""},"lock-keyhole-minimalistic-bold":{"body":""},"lock-keyhole-minimalistic-linear":{"body":""},"lock-unlocked-bold":{"body":""},"magic-wand-3-bold":{"body":""},"magic-stick-3-bold-duotone":{"body":"","hidden":true},"magic-wand-bold":{"body":""},"magnifier-bold":{"body":""},"magnifer-bold-duotone":{"body":"","hidden":true},"magnifer-bug-bold-duotone":{"body":"","hidden":true},"magnifier-linear":{"body":""},"magnifier-zoom-in-bold":{"body":""},"magnifer-zoom-in-bold-duotone":{"body":"","hidden":true},"map-arrow-square-bold":{"body":""},"map-arrow-square-bold-duotone":{"body":""},"map-bold":{"body":""},"maximize-bold":{"body":""},"maximize-square-bold":{"body":""},"menu-dots-bold":{"body":""},"microphone-3-bold-duotone":{"body":""},"microphone-bold":{"body":""},"microphone-bold-duotone":{"body":""},"minimalistic-magnifer-bold-duotone":{"body":"","hidden":true},"minimize-bold":{"body":""},"minimize-square-bold":{"body":""},"minimize-square-bold-duotone":{"body":""},"minus-circle-bold":{"body":""},"monitor-bold":{"body":""},"monitor-bold-duotone":{"body":""},"monitor-smartphone-bold-duotone":{"body":""},"monitor-smartphone-line-duotone":{"body":""},"moon-bold":{"body":""},"moon-bold-duotone":{"body":""},"moon-sleep-bold-duotone":{"body":""},"mouse-bold":{"body":""},"notebook-bold-duotone":{"body":""},"pallete-2-bold-duotone":{"body":"","hidden":true},"paperclip-bold-duotone":{"body":""},"password-bold":{"body":""},"pause-bold":{"body":""},"pause-circle-linear":{"body":""},"pen-2-bold":{"body":""},"pen-2-linear":{"body":""},"pen-bold":{"body":""},"pen-new-round-bold":{"body":""},"pin-bold":{"body":""},"plane-bold":{"body":""},"plain-bold-duotone":{"body":"","hidden":true},"planet-bold-duotone":{"body":""},"play-bold":{"body":""},"play-circle-bold":{"body":""},"plug-circle-bold":{"body":""},"plug-circle-bold-duotone":{"body":""},"power-bold":{"body":""},"power-bold-duotone":{"body":""},"presentation-graph-bold":{"body":""},"programming-bold":{"body":""},"question-circle-bold":{"body":""},"question-circle-bold-duotone":{"body":""},"question-circle-linear":{"body":""},"record-circle-linear":{"body":""},"refresh-bold":{"body":""},"refresh-bold-duotone":{"body":""},"refresh-circle-bold-duotone":{"body":""},"refresh-linear":{"body":""},"restart-bold":{"body":""},"rocket-2-bold-duotone":{"body":""},"rocket-bold-duotone":{"body":""},"running-round-bold":{"body":""},"sad-circle-bold-duotone":{"body":""},"scissors-bold":{"body":""},"server-bold-duotone":{"body":""},"server-path-bold-duotone":{"body":""},"server-square-bold-duotone":{"body":""},"server-square-cloud-linear":{"body":""},"server-square-line-duotone":{"body":""},"settings-bold":{"body":""},"settings-bold-duotone":{"body":""},"settings-minimalistic-bold-duotone":{"body":""},"share-circle-bold":{"body":""},"shield-check-bold":{"body":""},"shield-check-bold-duotone":{"body":""},"shield-cross-bold-duotone":{"body":""},"shield-keyhole-bold":{"body":""},"shield-warning-bold":{"body":""},"shield-warning-bold-duotone":{"body":""},"panel-right-bold":{"body":""},"sledgehammer-bold-duotone":{"body":""},"smartphone-bold":{"body":""},"sort-vertical-linear":{"body":""},"square-arrow-right-up-bold":{"body":""},"square-top-up-linear":{"body":""},"ssd-square-bold":{"body":""},"star-bold":{"body":""},"star-bold-duotone":{"body":""},"star-line-duotone":{"body":""},"stars-bold-duotone":{"body":""},"stop-bold":{"body":""},"stop-circle-bold":{"body":""},"stop-circle-bold-duotone":{"body":""},"stop-linear":{"body":""},"sun-bold":{"body":""},"tag-bold":{"body":""},"target-bold":{"body":""},"target-bold-duotone":{"body":""},"test-tube-bold-duotone":{"body":""},"text-bold-bold-duotone":{"body":""},"transfer-horizontal-bold":{"body":""},"trash-bin-minimalistic-bold":{"body":""},"trash-bin-minimalistic-bold-duotone":{"body":""},"trash-bin-minimalistic-linear":{"body":""},"trash-bin-trash-bold":{"body":""},"trash-bin-trash-bold-duotone":{"body":""},"trash-bin-trash-linear":{"body":""},"tuning-2-bold":{"body":""},"tuning-2-bold-duotone":{"body":""},"tuning-2-linear":{"body":""},"upload-bold":{"body":""},"upload-minimalistic-bold":{"body":""},"user-circle-bold-duotone":{"body":""},"user-rounded-linear":{"body":""},"users-group-rounded-bold":{"body":""},"users-group-rounded-bold-duotone":{"body":""},"wallet-bold":{"body":""},"wallet-bold-duotone":{"body":""},"wallet-money-bold-duotone":{"body":""},"widget-2-bold":{"body":""},"widget-2-bold-duotone":{"body":""},"widget-4-linear":{"body":""},"widget-add-bold-duotone":{"body":""},"widget-bold":{"body":""},"window-frame-bold":{"body":""},"window-frame-bold-duotone":{"body":""},"window-frame-linear":{"body":""}},"lastModified":1790422589,"aliases":{"chat-square-2-bold-duotone":{"parent":"chart-square-2-bold-duotone"},"code-file-bold":{"parent":"file-code-bold"},"link-broken-linear":{"parent":"unlink-linear"},"magic-stick-3-bold":{"parent":"magic-wand-3-bold"},"magnifer-bold":{"parent":"magnifier-bold"},"magnifer-linear":{"parent":"magnifier-linear"},"magnifer-zoom-in-bold":{"parent":"magnifier-zoom-in-bold"},"plain-bold":{"parent":"plane-bold"},"sidebar-minimalistic-bold":{"parent":"panel-right-bold"}},"width":24,"height":24},{"prefix":"svg-spinners","icons":{"90-ring-with-bg":{"body":""},"ring-resize":{"body":""}},"lastModified":1754901419,"width":24,"height":24},{"prefix":"tabler","icons":{"app-window":{"body":""},"arrow-back-up":{"body":""},"arrows-maximize":{"body":""},"arrows-minimize":{"body":""},"code":{"body":""},"layout-columns":{"body":""},"layout-grid":{"body":""},"layout-rows":{"body":""},"terminal-2":{"body":""},"users-group":{"body":""}},"lastModified":1791401203,"width":24,"height":24},{"prefix":"vscode-icons","icons":{"file-type-css":{"body":""},"file-type-docker":{"body":""},"file-type-dotenv":{"body":""},"file-type-git":{"body":""},"file-type-go":{"body":""},"file-type-graphql":{"body":""},"file-type-html":{"body":""},"file-type-image":{"body":""},"file-type-java":{"body":""},"file-type-js":{"body":""},"file-type-json":{"body":""},"file-type-less":{"body":""},"file-type-markdown":{"body":""},"file-type-php":{"body":""},"file-type-python":{"body":""},"file-type-ruby":{"body":""},"file-type-rust":{"body":""},"file-type-scss":{"body":""},"file-type-shell":{"body":""},"file-type-sql":{"body":""},"file-type-svg":{"body":""},"file-type-toml":{"body":""},"file-type-typescript":{"body":""},"file-type-vue":{"body":""},"file-type-xml":{"body":""},"file-type-yaml":{"body":""}},"lastModified":1792510922,"width":32,"height":32}]; diff --git a/apps/web/src/app/views/admin-core/git-writer/git-writer.service.ts b/apps/web/src/app/views/admin-core/git-writer/git-writer.service.ts index 8dd8144..917f834 100644 --- a/apps/web/src/app/views/admin-core/git-writer/git-writer.service.ts +++ b/apps/web/src/app/views/admin-core/git-writer/git-writer.service.ts @@ -37,6 +37,7 @@ export const ARTIFACT_TYPES: ArtifactTypeDef[] = [ * listed here (gemini, grok) run with their CLI default. */ export const WRITER_MODELS: Record> = { 'claude-code-cli': [ + { id: 'claude-fable-5-2', label: 'Fable 5.2' }, { id: 'claude-fable-5-1', label: 'Fable 5.1' }, { id: 'claude-fable-5', label: 'Fable 5' }, { id: 'claude-opus-5-5', label: 'Opus 5.5' }, diff --git a/apps/web/src/app/views/admin-core/sessions/agents/agent-roster.service.spec.ts b/apps/web/src/app/views/admin-core/sessions/agents/agent-roster.service.spec.ts index edeabaf..55c1383 100644 --- a/apps/web/src/app/views/admin-core/sessions/agents/agent-roster.service.spec.ts +++ b/apps/web/src/app/views/admin-core/sessions/agents/agent-roster.service.spec.ts @@ -94,6 +94,47 @@ describe('AgentRosterService rosterFor', () => { expect(roster.rosterFor(session, [sub]).map((p) => p.id)).toEqual([AgentRosterService.LEAD_PANE_ID, 'sa-1']); }); + it('keeps the chosen lead by id within a run', async () => { + await loadMeta({ leadId: 'sa-1', leadKey: 'custom|Worker' }); + const panes = roster.rosterFor(session, [sub]); + expect(roster.effectiveLeadId('s1', panes)).toBe('sa-1'); + }); + + it('finds the chosen lead again after a respawn changed its id', async () => { + await loadMeta({ leadId: 'sa-old', leadKey: 'custom|Worker' }); + const other = { id: 'sa-2', role: 'custom', title: 'Other', parentSessionId: 's1' } as SubAgent; + const panes = roster.rosterFor(session, [other, sub]); + expect(roster.preferredLeadId('s1', panes)).toBe('sa-1'); + expect(roster.effectiveLeadId('s1', panes)).toBe('sa-1'); + }); + + it('falls back to the first pane when the chosen lead is gone', async () => { + await loadMeta({ leadId: 'sa-old', leadKey: 'custom|Gone' }); + const panes = roster.rosterFor(session, [sub]); + expect(roster.preferredLeadId('s1', panes)).toBeNull(); + expect(roster.effectiveLeadId('s1', panes)).toBe(AgentRosterService.LEAD_PANE_ID); + // Old metas without a key behave exactly as before. + await loadMeta({ leadId: 'sa-old' }); + expect(roster.effectiveLeadId('s1', panes)).toBe(AgentRosterService.LEAD_PANE_ID); + }); + + it('saves the stable identity next to the id, and none for the session pane', async () => { + const calls: unknown[][] = []; + (window as any).promptops = { + agentsMetaGet: () => Promise.resolve({}), + agentsSetLead: (...args: unknown[]) => { calls.push(args); return Promise.resolve(null); }, + }; + await roster.load('s1'); + await roster.setLead('s1', 'sa-1', sub); + await roster.setLead('s1', AgentRosterService.LEAD_PANE_ID, { id: AgentRosterService.LEAD_PANE_ID, role: 'lead', title: 'Solo' }); + await roster.setLead('s1', null); + expect(calls).toEqual([ + ['s1', 'sa-1', 'custom|Worker'], + ['s1', AgentRosterService.LEAD_PANE_ID, null], + ['s1', null, null], + ]); + }); + it('carries waiting_input over to the lead', async () => { await loadMeta({}); const panes = roster.rosterFor({ ...session, status: 'waiting_input' }, [], true); diff --git a/apps/web/src/app/views/admin-core/sessions/agents/agent-roster.service.ts b/apps/web/src/app/views/admin-core/sessions/agents/agent-roster.service.ts index 33767a6..0d62a1b 100644 --- a/apps/web/src/app/views/admin-core/sessions/agents/agent-roster.service.ts +++ b/apps/web/src/app/views/admin-core/sessions/agents/agent-roster.service.ts @@ -7,12 +7,14 @@ import { AGENTS_MODEL } from './agents-model.token'; export interface AgentsMeta { /** Lead preference; null/missing = session PTY. */ leadId?: string | null; + /** Stable identity (`role|title`) of the chosen lead: the id changes on every respawn. */ + leadKey?: string | null; order: string[]; /** The session PTY was closed by the user: it does not come back until they reopen it. */ leadClosed: boolean; } -const EMPTY_META: AgentsMeta = { leadId: null, order: [], leadClosed: false }; +const EMPTY_META: AgentsMeta = { leadId: null, leadKey: null, order: [], leadClosed: false }; /** The minimum needed from a store session to draw the lead. */ export interface AgentSessionLike { @@ -145,17 +147,44 @@ export class AgentRosterService { * reconcile when a lead is closed: the pointer resolves itself. */ effectiveLeadId(sessionId: string, roster: SubAgent[]): string | null { - const pref = this.meta(sessionId).leadId; - if (pref && roster.some((p) => p.id === pref)) return pref; - return roster[0]?.id ?? null; + return this.preferredLeadId(sessionId, roster) ?? roster[0]?.id ?? null; } - async setLead(sessionId: string, leadId: string | null): Promise { + /** + * The lead the user chose, if it is in the roster: by id (same run), or + * by `role|title` identity when the id has changed (restart, wake from + * standby: every respawn gives a new id). null = no valid preference. + */ + preferredLeadId(sessionId: string, roster: SubAgent[]): string | null { + const { leadId, leadKey } = this.meta(sessionId); + if (!leadId) return null; + if (roster.some((p) => p.id === leadId)) return leadId; + if (!leadKey) return null; + const match = roster.find( + (p) => p.id !== AgentRosterService.LEAD_PANE_ID && AgentRosterService.leadKeyOf(p) === leadKey, + ); + return match?.id ?? null; + } + + static leadKeyOf(pane: Pick): string { + return `${pane.role}|${pane.title}`; + } + + /** `pane` = the chosen pane: needed to save its stable identity next to the id. */ + async setLead( + sessionId: string, + leadId: string | null, + pane?: Pick | null, + ): Promise { if (!this.enabled) return; - this.put(sessionId, { leadId }); + const leadKey = + leadId && pane && pane.id !== AgentRosterService.LEAD_PANE_ID + ? AgentRosterService.leadKeyOf(pane) + : null; + this.put(sessionId, { leadId, leadKey }); try { - const meta = await this.api?.agentsSetLead?.(sessionId, leadId); - if (meta) this.put(sessionId, meta); + const meta = await this.api?.agentsSetLead?.(sessionId, leadId, leadKey); + if (meta) this.put(sessionId, { leadKey: null, ...meta }); } catch { /* the preference stays local until the next load */ } diff --git a/apps/web/src/app/views/admin-core/sessions/create-session-modal/create-session-modal.component.html b/apps/web/src/app/views/admin-core/sessions/create-session-modal/create-session-modal.component.html index ea50e29..3794cca 100644 --- a/apps/web/src/app/views/admin-core/sessions/create-session-modal/create-session-modal.component.html +++ b/apps/web/src/app/views/admin-core/sessions/create-session-modal/create-session-modal.component.html @@ -6,11 +6,7 @@ @@ -18,32 +14,125 @@ - @if (draggedItemInFolder()) { + @if (draggedItemInFolder() && (machine === null || machine === draggedMachine())) {
{{ 'SESSIONS.Sessions' | translate }} } - @if (ungroupedWorkspaceGroups().length > 0) { + @if (groups.length > 0) { } - @for (group of ungroupedWorkspaceGroups(); track group.workspace.id) { + @for (group of groups; track group.workspace.id) { } - @if (ungroupedStandalone().length > 0) { + @if (standalone.length > 0) { } - @for (session of ungroupedStandalone(); track session.id) { + @for (session of standalone; track session.id) { } + + + @if (machineLevel()) { + + @for (m of machineSections(); track m.key) { +
+
+ + + + @if (m.kind === 'local') { + {{ 'SESSIONS.HOST.Local name' | translate }} + } @else if (m.name) { + {{ m.name }} + } @else { + {{ 'SESSIONS.MACHINES.Unknown remote' | translate }} + } + + @if (machineAttention(m); as ma) { + + {{ attentionLabel(ma) }} + + } + + {{ m.count }} +
+ @if (isMachineExpanded(m.key)) { +
+ + @if (m.folders.length === 0 && m.groups.length === 0 && m.standalone.length === 0) { +
{{ 'SESSIONS.MACHINES.Empty hint' | translate }}
+ } +
+ } +
+ } + } @else { + + } @if (ungroupedStandalone().length === 0 && ungroupedWorkspaceGroups().length === 0 && folderSections().length === 0) { -
- - {{ 'SESSIONS.No sessions' | translate }} - {{ 'SESSIONS.Create first session hint' | translate }} -
+ @if (searching()) { +
+ + {{ 'SESSIONS.SEARCH.No results' | translate }} +
+ } @else { +
+ + {{ 'SESSIONS.No sessions' | translate }} + {{ 'SESSIONS.Create first session hint' | translate }} +
+ } }
} @@ -723,7 +819,7 @@ {{ 'SESSIONS.FOLDERS.Move to folder' | translate }}
- @for (f of foldersService.folders(); track f.id) { + @for (f of ctxMoveFolders(); track f.id) {