Files
IdeA/crates/app-tauri/src/lib.rs
Blomios 23a3c2788f feat(backend): support des providers OpenCode cloud (#92)
Ajoute le catalogue statique de providers OpenCode (lot B3), le stockage
sécurisé des secrets (SecretStore + adapter infrastructure), et les
use cases SaveOpenCodeProviderProfile/DeleteProfile câblés en composition
root. Couvre le fix B1 et les tests de régression demandés par QA.

cargo build --workspace propre, cargo test --workspace -- --test-threads=1
intégralement vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 08:03:03 +02:00

840 lines
30 KiB
Rust

//! # IdeA — `app-tauri` (presentation / driving adapter + composition root)
//!
//! This crate is the **only** place that knows every other crate. It:
//! - builds the concrete adapters and injects them into use cases
//! ([`state::AppState`], the composition root),
//! - exposes `#[tauri::command]` handlers ([`commands`]) mapping DTOs ↔ use cases,
//! - relays domain events to the frontend ([`events::TauriEventRelay`]),
//! - hosts the generic PTY↔Channel bridge ([`pty::PtyBridge`]) for L3 and its
//! structured-chat twin ([`chat::ChatBridge`]) for §17.
//!
//! The wiring lives in the library (testable) and `main.rs` is a thin shim.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod chat;
pub mod commands;
pub mod dto;
pub mod embedded_server;
pub mod events;
pub mod mcp_bridge;
pub mod mcp_endpoint;
pub mod openai_tools;
pub mod plugins;
pub mod pty;
pub mod server;
pub mod state;
pub mod stream;
pub mod templates;
pub mod tickets;
use std::process::ExitCode;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use application::{AppError, GetAppExitWorkGuardStateInput, SnapshotOpenWindowsInput};
use domain::{
PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition, PersistedWindowSize,
PersistedWindowState, ProjectId,
};
use tauri::{
Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow,
WebviewWindowBuilder,
};
use uuid::Uuid;
use state::AppState;
static EXIT_GUARD_CONFIRMED: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MainCloseAction {
AllowShutdown,
PreventAndNotify,
}
fn decide_main_close_action(
has_work_in_progress: bool,
already_confirmed: bool,
) -> MainCloseAction {
if has_work_in_progress && !already_confirmed {
MainCloseAction::PreventAndNotify
} else {
MainCloseAction::AllowShutdown
}
}
fn should_install_exit_guard(window_label: &str) -> bool {
window_label == "main"
}
fn apply_main_close_decision(
guard: application::AppExitWorkGuardState,
already_confirmed: bool,
mut prevent_close: impl FnMut(),
mut emit_guard: impl FnMut(application::AppExitWorkGuardState),
mut shutdown: impl FnMut(),
) -> MainCloseAction {
let action = decide_main_close_action(guard.has_work_in_progress, already_confirmed);
match action {
MainCloseAction::PreventAndNotify => {
prevent_close();
emit_guard(guard);
}
MainCloseAction::AllowShutdown => shutdown(),
}
action
}
/// The `argv[1]` subcommand token that switches the binary into the headless
/// `mcp-server` **bridge** mode (cadrage v5 §1.3) instead of launching Tauri.
pub const MCP_SERVER_SUBCOMMAND: &str = "mcp-server";
/// The `argv[1]` subcommand token that starts the secure HTTP server adapter.
pub const SERVE_SUBCOMMAND: &str = "--serve";
/// Process entry point: routes `argv` **before** anything Tauri/WebKit is touched.
///
/// When invoked as `<exe> mcp-server …` (an MCP CLI spawned us from the injected
/// `.mcp.json` declaration), we run the **stdio↔loopback bridge** headless and
/// **never** initialise the webview — see [`mcp_bridge::run_mcp_bridge`]. When
/// invoked as `<exe> --serve …`, we run the secure HTTP adapter headless. Any
/// other invocation is the normal IDE launch: [`run`] (which blocks until the
/// window closes and then exits the process itself).
///
/// Returns the [`ExitCode`] for the bridge path; the normal path does not return.
#[must_use]
pub fn dispatch() -> ExitCode {
let mut args = std::env::args_os().skip(1);
if args.next().is_some_and(|a| a == *MCP_SERVER_SUBCOMMAND) {
// Headless bridge: bypass Tauri entirely. Forward the remaining args
// (`--endpoint`, `--project`, `--requester`) to the bridge parser.
let rest: Vec<String> = args.map(|a| a.to_string_lossy().into_owned()).collect();
return mcp_bridge::run_mcp_bridge(rest);
}
let mut args = std::env::args_os().skip(1);
if args.next().is_some_and(|a| a == *SERVE_SUBCOMMAND) {
let rest: Vec<String> = args.map(|a| a.to_string_lossy().into_owned()).collect();
return server::run_from_args(rest);
}
run();
ExitCode::SUCCESS
}
/// Builds and runs the Tauri application.
///
/// Sets up the composition root (resolving the app-data directory via the Tauri
/// path API), registers commands, spawns the event relay, and starts the main
/// window.
///
/// # Panics
/// Panics if the Tauri application fails to build or run (no window/webview), or
/// if the app-data directory cannot be resolved.
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.register_uri_scheme_protocol("idea-plugin", |ctx, request| {
plugins::plugin_asset_protocol(ctx.app_handle(), request)
})
.setup(|app| {
// Resolve the machine-local IDE data directory (ARCHITECTURE §9.2)
// and build the composition root once the app handle exists, so the
// stores receive a concrete path without ever touching Tauri.
let app_data_dir = app
.path()
.app_data_dir()
.expect("failed to resolve the app data directory");
let resource_dir = app.path().resource_dir().ok();
// Point the orchestrator's best-effort diagnostics at a persistent file
// (`<app-data>/logs/idea.log`) so inter-agent rendezvous beacons survive a
// click-launched AppImage (whose stderr is otherwise discarded). Best-effort:
// if the file can't be opened the beacons simply stay on stderr.
application::diag::set_log_path(app_data_dir.join("logs").join("idea.log"));
application::diag!("[startup] IdeA launched; diagnostics log armed");
let app_state = AppState::build_with_resource_dir(app_data_dir, resource_dir);
// Wire the domain event bus → Tauri events relay.
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
let embedded_server = Arc::clone(&app_state.embedded_server);
let plugin_mcp_reconcile = Arc::clone(&app_state.reconcile_plugin_mcp_servers);
let core = app_state.core();
app.manage(app_state);
tauri::async_runtime::spawn(async move {
if let Err(err) = embedded_server.auto_start_if_enabled(core).await {
application::diag!(
"[embedded-server] auto-start failed: {}: {}",
err.code,
err.message
);
}
});
tauri::async_runtime::spawn(async move {
if let Err(err) = plugin_mcp_reconcile.execute().await {
application::diag!("[plugins] MCP reconcile at boot failed: {err}");
}
});
// Kill all live PTYs cleanly when the main window is closing. This is
// independent of the per-view (navigation/layout) lifecycle — those
// must NEVER kill a PTY — and only fires on a genuine app shutdown.
// A brutal crash is best-effort and out of scope.
if should_install_exit_guard("main") && app.get_webview_window("main").is_some() {
let window = app
.get_webview_window("main")
.expect("main window existence checked above");
let handle = app.handle().clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
let already_confirmed = consume_exit_guard_confirmation();
let guard =
tauri::async_runtime::block_on(app_exit_work_guard_state(&handle));
if let Ok(guard) = guard {
let action = apply_main_close_decision(
guard,
already_confirmed,
|| api.prevent_close(),
|guard| {
let _ = handle.emit(
"app-exit-work-guard",
backend::dto::AppExitWorkGuardStateDto::from(guard),
);
},
|| shutdown_app_after_confirm(&handle),
);
if action == MainCloseAction::PreventAndNotify {
return;
}
} else {
shutdown_app_after_confirm(&handle);
}
}
});
}
restore_open_webview_windows(app.handle());
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::health,
commands::create_project,
commands::open_project,
commands::close_project,
commands::list_projects,
commands::read_project_context,
commands::update_project_context,
commands::get_project_permissions,
commands::update_project_permissions,
commands::update_agent_permissions,
commands::resolve_agent_permissions,
commands::get_mcp_tool_permissions,
commands::update_project_mcp_tool_permissions,
commands::update_agent_mcp_tool_permissions,
commands::open_terminal,
commands::write_terminal,
commands::resize_terminal,
commands::close_terminal,
commands::reattach_terminal,
commands::load_layout,
commands::mutate_layout,
commands::list_layouts,
commands::create_layout,
commands::rename_layout,
commands::delete_layout,
commands::set_active_layout,
commands::first_run_state,
commands::reference_profiles,
commands::detect_profiles,
commands::list_profiles,
commands::save_profile,
commands::save_opencode_provider_profile,
commands::list_opencode_providers,
commands::clone_opencode_profile_from_seed,
commands::delete_profile,
commands::configure_profiles,
commands::list_model_servers,
commands::save_model_server,
commands::preview_model_server_command,
commands::delete_model_server,
commands::list_embedder_profiles,
commands::save_embedder_profile,
commands::delete_embedder_profile,
commands::describe_embedder_engines,
commands::dismiss_embedder_suggestion,
commands::create_agent,
commands::list_agents,
commands::get_app_exit_work_guard_state,
commands::confirm_app_exit,
tickets::ticket_create,
tickets::ticket_read,
tickets::ticket_delete,
tickets::open_ticket_chat,
tickets::close_ticket_chat,
tickets::ticket_list,
tickets::ticket_update,
tickets::ticket_read_carnet,
tickets::ticket_update_carnet,
tickets::ticket_link,
tickets::ticket_unlink,
tickets::ticket_assign,
tickets::sprint_create,
tickets::sprint_list,
tickets::sprint_rename,
tickets::sprint_reorder,
tickets::sprint_delete,
tickets::ticket_assign_sprint,
tickets::ticket_unassign_sprint,
commands::get_project_work_state,
commands::read_conversation_page,
commands::list_live_agents,
commands::attach_live_agent,
commands::stop_live_agent,
commands::read_agent_context,
commands::update_agent_context,
commands::delete_agent,
commands::launch_agent,
commands::change_agent_profile,
commands::agent_send,
commands::cancel_resume,
commands::set_resume_at,
commands::interrupt_agent,
commands::delegation_delivered,
commands::set_front_attached,
commands::reattach_agent_chat,
commands::close_agent_session,
commands::list_resumable_agents,
commands::inspect_conversation,
commands::create_template,
commands::update_template,
commands::list_templates,
commands::delete_template,
commands::create_agent_from_template,
commands::detect_agent_drift,
commands::sync_agent_with_template,
commands::git_status,
commands::git_stage,
commands::git_unstage,
commands::git_commit,
commands::git_branches,
commands::git_checkout,
commands::git_log,
commands::git_init,
commands::git_graph,
commands::create_skill,
commands::update_skill,
commands::list_skills,
commands::delete_skill,
commands::assign_skill_to_agent,
commands::unassign_skill_from_agent,
commands::create_memory,
commands::update_memory,
commands::list_memories,
commands::get_memory,
commands::delete_memory,
commands::read_memory_index,
commands::recall_memory,
commands::resolve_memory_links,
commands::set_focused_project,
commands::get_focused_project,
commands::list_open_view_windows,
commands::open_view_window,
commands::close_view_window,
commands::move_tab_to_new_window,
commands::spawn_background_command,
commands::cancel_background_task,
commands::retry_background_task,
commands::list_background_tasks,
plugins::plugin_list_plugins,
plugins::plugin_review_package,
plugins::plugin_install_from_archive,
plugins::plugin_install_from_directory,
plugins::plugin_set_enabled,
plugins::plugin_uninstall,
plugins::plugin_list_runtime_contributions,
plugins::plugin_open_plugins_folder,
commands::get_server_exposure_settings,
commands::save_server_exposure_settings,
commands::preview_server_exposure_settings,
commands::embedded_server_status,
commands::embedded_server_start,
commands::embedded_server_stop,
commands::embedded_server_generate_pairing_code,
commands::list_devices,
commands::create_pairing_code,
commands::rename_device,
commands::revoke_device,
commands::revoke_all_devices,
])
.run(tauri::generate_context!())
.expect("error while running IdeA Tauri application");
}
async fn app_exit_work_guard_state(
handle: &tauri::AppHandle,
) -> Result<application::AppExitWorkGuardState, AppError> {
let Some(state) = handle.try_state::<AppState>() else {
return Ok(application::AppExitWorkGuardState {
has_work_in_progress: false,
busy_agent_count: 0,
active_background_task_count: 0,
details: Vec::new(),
});
};
let mut projects = Vec::new();
for project_id in state.open_project_ids() {
projects.push(state.project_store.load_project(project_id).await?);
}
state
.get_app_exit_work_guard_state
.execute(GetAppExitWorkGuardStateInput { projects })
.await
}
/// Executes the global shutdown teardown after the close guard has allowed exit.
///
/// The order intentionally mirrors the historical inline `CloseRequested` hook:
/// snapshot windows, snapshot `agent_was_running`, kill PTYs, stop model servers,
/// stop the embedded server, then close secondary webview windows.
pub fn shutdown_app_after_confirm(handle: &tauri::AppHandle) {
if let Some(state) = handle.try_state::<AppState>() {
let open_windows = snapshot_open_webview_windows(handle);
let window_snapshot = std::sync::Arc::clone(&state.snapshot_open_windows);
let pty = std::sync::Arc::clone(&state.pty_port);
let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents);
let model_servers = std::sync::Arc::clone(&state.ensure_local_model_server);
let embedded_server = std::sync::Arc::clone(&state.embedded_server);
let open_projects = state.open_project_ids();
let handles = state.terminal_sessions.handles();
tauri::async_runtime::block_on(async move {
let _ = window_snapshot
.execute(SnapshotOpenWindowsInput {
windows: open_windows,
})
.await;
for project_id in open_projects {
let _ = snapshot
.execute(application::SnapshotRunningAgentsInput { project_id })
.await;
}
for h in handles {
let _ = pty.kill(&h).await;
}
let _ = model_servers.stop_on_app_exit().await;
let _ = embedded_server.stop().await;
});
}
close_non_main_webview_windows(handle);
}
pub(crate) fn confirm_next_main_window_close() {
EXIT_GUARD_CONFIRMED.store(true, Ordering::SeqCst);
}
fn consume_exit_guard_confirmation() -> bool {
EXIT_GUARD_CONFIRMED.swap(false, Ordering::SeqCst)
}
pub(crate) async fn read_app_exit_work_guard_state(
handle: &tauri::AppHandle,
) -> Result<application::AppExitWorkGuardState, AppError> {
app_exit_work_guard_state(handle).await
}
fn close_non_main_webview_windows(handle: &tauri::AppHandle) {
for (label, window) in handle.webview_windows() {
if !should_close_with_main_window(&label) {
continue;
}
let _ = window.close();
}
}
fn should_close_with_main_window(label: &str) -> bool {
label != "main"
}
fn snapshot_open_webview_windows(handle: &tauri::AppHandle) -> Vec<PersistedWindowState> {
handle
.webview_windows()
.into_iter()
.filter_map(|(label, window)| snapshot_webview_window(&label, &window))
.collect()
}
fn snapshot_webview_window(label: &str, window: &WebviewWindow) -> Option<PersistedWindowState> {
let (kind, panel, project_id, url) = persisted_window_identity(label)?;
let outer_position = window
.outer_position()
.ok()
.map(|p| PersistedWindowPosition { x: p.x, y: p.y });
let outer_size = window.outer_size().ok().map(|s| PersistedWindowSize {
width: s.width,
height: s.height,
});
let monitor = window.current_monitor().ok().flatten().map(|m| {
let position = m.position();
let size = m.size();
PersistedMonitorState {
name: m.name().cloned(),
scale_factor: Some(m.scale_factor()),
position: Some(PersistedWindowPosition {
x: position.x,
y: position.y,
}),
size: Some(PersistedWindowSize {
width: size.width,
height: size.height,
}),
}
});
Some(PersistedWindowState {
label: label.to_owned(),
kind,
panel,
project_id,
url,
visible: window.is_visible().unwrap_or(true),
maximized: window.is_maximized().unwrap_or(false),
fullscreen: window.is_fullscreen().unwrap_or(false),
outer_position,
outer_size,
monitor,
last_focused_at: None,
})
}
fn persisted_window_identity(
label: &str,
) -> Option<(
PersistedWindowKind,
Option<String>,
Option<ProjectId>,
Option<String>,
)> {
if label == "main" {
return Some((PersistedWindowKind::Main, None, None, None));
}
let panel = persisted_view_identity_from_label(label)?;
Some((
PersistedWindowKind::View,
Some(panel.as_str().to_owned()),
None,
Some(commands::view_window_url(panel)),
))
}
fn persisted_view_identity_from_label(label: &str) -> Option<commands::ViewPanel> {
let rest = label.strip_prefix("view-")?;
if let Ok(panel) = commands::ViewPanel::parse(rest) {
return Some(panel);
}
let (panel, project_id) = rest.rsplit_once('-')?;
if project_id.len() != 32 || Uuid::parse_str(project_id).is_err() {
return None;
}
commands::ViewPanel::parse(panel).ok()
}
fn restore_open_webview_windows(handle: &tauri::AppHandle) {
let Some(state) = handle.try_state::<AppState>() else {
return;
};
let restore = std::sync::Arc::clone(&state.restore_open_windows);
let Ok(output) = tauri::async_runtime::block_on(async move { restore.execute().await }) else {
return;
};
for window_state in output.windows {
match window_state.kind {
PersistedWindowKind::Main => {
if let Some(window) = handle.get_webview_window("main") {
apply_persisted_window_state(handle, &window, &window_state);
}
}
PersistedWindowKind::View => {
restore_view_window(handle, &window_state);
}
}
}
}
fn restore_view_window(handle: &tauri::AppHandle, state: &PersistedWindowState) {
let Some(panel) = state
.panel
.as_deref()
.and_then(|raw| commands::ViewPanel::parse(raw).ok())
.or_else(|| persisted_view_identity_from_label(&state.label))
else {
return;
};
let label = commands::view_window_label(panel);
if handle.get_webview_window(&label).is_some() {
return;
}
let url = commands::view_window_url(panel);
let Ok(window) = WebviewWindowBuilder::new(handle, &label, WebviewUrl::App(url.into()))
.title(format!("IdeA - {}", panel.title()))
.inner_size(1120.0, 760.0)
.min_inner_size(720.0, 480.0)
.resizable(true)
.maximizable(true)
.minimizable(true)
.closable(true)
.decorations(true)
.visible(state.visible)
.build()
else {
return;
};
let event_app = handle.clone();
let event_label = label.clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
commands::emit_view_window_lifecycle(&event_app, "closed", panel, &event_label);
}
});
apply_persisted_window_state(handle, &window, state);
commands::emit_view_window_lifecycle(handle, "opened", panel, &label);
}
fn apply_persisted_window_state(
handle: &tauri::AppHandle,
window: &WebviewWindow,
state: &PersistedWindowState,
) {
if persisted_monitor_is_available(handle, state.monitor.as_ref()) {
if let Some(size) = state.outer_size {
let _ = window.set_size(PhysicalSize::new(size.width, size.height));
}
if let Some(position) = state.outer_position {
let _ = window.set_position(PhysicalPosition::new(position.x, position.y));
}
} else {
let _ = window.center();
}
if state.fullscreen {
let _ = window.set_fullscreen(true);
} else if state.maximized {
let _ = window.maximize();
}
if state.visible {
let _ = window.show();
} else {
let _ = window.hide();
}
}
fn persisted_monitor_is_available(
handle: &tauri::AppHandle,
monitor: Option<&PersistedMonitorState>,
) -> bool {
let Some(saved) = monitor else {
return true;
};
let Ok(monitors) = handle.available_monitors() else {
return false;
};
monitors.into_iter().any(|current| {
if let Some(saved_name) = saved.name.as_deref() {
if current.name().is_some_and(|name| name == saved_name) {
return true;
}
}
let position_matches = saved.position.is_some_and(|p| {
let current_position = current.position();
p.x == current_position.x && p.y == current_position.y
});
let size_matches = saved.size.is_some_and(|s| {
let current_size = current.size();
s.width == current_size.width && s.height == current_size.height
});
position_matches && size_matches
})
}
#[cfg(test)]
mod tests {
use super::{
apply_main_close_decision, confirm_next_main_window_close, consume_exit_guard_confirmation,
decide_main_close_action, persisted_view_identity_from_label, persisted_window_identity,
should_install_exit_guard, MainCloseAction,
};
use super::{should_close_with_main_window, PersistedWindowKind};
use application::AppExitWorkGuardState;
use std::cell::Cell;
#[test]
fn main_close_without_work_allows_shutdown_without_preventing_close() {
assert_eq!(
decide_main_close_action(false, false),
MainCloseAction::AllowShutdown
);
}
#[test]
fn main_close_with_work_prevents_and_notifies_before_shutdown() {
assert_eq!(
decide_main_close_action(true, false),
MainCloseAction::PreventAndNotify
);
}
#[test]
fn main_close_with_work_emits_guard_payload_and_skips_shutdown() {
let prevented = Cell::new(false);
let shutdown = Cell::new(false);
let emitted = Cell::new(None);
let guard = AppExitWorkGuardState {
has_work_in_progress: true,
busy_agent_count: 2,
active_background_task_count: 1,
details: Vec::new(),
};
let action = apply_main_close_decision(
guard,
false,
|| prevented.set(true),
|payload| {
emitted.set(Some((
payload.busy_agent_count,
payload.active_background_task_count,
)))
},
|| shutdown.set(true),
);
assert_eq!(action, MainCloseAction::PreventAndNotify);
assert!(prevented.get());
assert_eq!(emitted.get(), Some((2, 1)));
assert!(!shutdown.get());
}
#[test]
fn main_close_without_work_runs_shutdown_without_prevent_or_emit() {
let prevented = Cell::new(false);
let shutdown = Cell::new(false);
let emitted = Cell::new(false);
let guard = AppExitWorkGuardState {
has_work_in_progress: false,
busy_agent_count: 0,
active_background_task_count: 0,
details: Vec::new(),
};
let action = apply_main_close_decision(
guard,
false,
|| prevented.set(true),
|_| emitted.set(true),
|| shutdown.set(true),
);
assert_eq!(action, MainCloseAction::AllowShutdown);
assert!(!prevented.get());
assert!(!emitted.get());
assert!(shutdown.get());
}
#[test]
fn confirmed_main_close_bypasses_guard_once_then_rearms() {
confirm_next_main_window_close();
let first_attempt_confirmed = consume_exit_guard_confirmation();
assert!(first_attempt_confirmed);
assert_eq!(
decide_main_close_action(true, first_attempt_confirmed),
MainCloseAction::AllowShutdown
);
let second_attempt_confirmed = consume_exit_guard_confirmation();
assert!(!second_attempt_confirmed);
assert_eq!(
decide_main_close_action(true, second_attempt_confirmed),
MainCloseAction::PreventAndNotify
);
}
#[test]
fn exit_guard_is_scoped_to_main_window_only() {
assert!(should_install_exit_guard("main"));
assert!(!should_install_exit_guard(
"view-work-00000000-0000-0000-0000-000000000001"
));
assert!(!should_install_exit_guard("settings"));
}
#[test]
fn main_window_close_does_not_target_main_again() {
assert!(!should_close_with_main_window("main"));
}
#[test]
fn main_window_close_targets_detached_view_windows() {
assert!(should_close_with_main_window(
"view-work-00000000-0000-0000-0000-000000000001"
));
}
#[test]
fn main_window_close_targets_other_auxiliary_windows() {
assert!(should_close_with_main_window("settings"));
}
#[test]
fn persisted_identity_accepts_main_and_stable_view_labels() {
let (kind, panel, project_id, url) = persisted_window_identity("main").unwrap();
assert_eq!(kind, PersistedWindowKind::Main);
assert!(panel.is_none());
assert!(project_id.is_none());
assert!(url.is_none());
let (kind, panel, project_id, url) =
persisted_window_identity("view-tickets-0000000000000000000000000000002a").unwrap();
assert_eq!(kind, PersistedWindowKind::View);
assert_eq!(panel.as_deref(), Some("tickets"));
assert!(project_id.is_none());
assert_eq!(url.as_deref(), Some("index.html?panel=tickets"));
assert_eq!(
persisted_view_identity_from_label("view-tickets-0000000000000000000000000000002a")
.unwrap()
.as_str(),
"tickets"
);
}
#[test]
fn persisted_identity_accepts_new_panel_only_view_labels() {
let (kind, panel, project_id, url) = persisted_window_identity("view-agents").unwrap();
assert_eq!(kind, PersistedWindowKind::View);
assert_eq!(panel.as_deref(), Some("agents"));
assert!(project_id.is_none());
assert_eq!(url.as_deref(), Some("index.html?panel=agents"));
}
#[test]
fn persisted_identity_filters_unknown_or_headless_labels() {
assert!(persisted_window_identity("mcp-server").is_none());
assert!(persisted_window_identity("settings").is_none());
assert!(
persisted_window_identity("view-unknown-0000000000000000000000000000002a").is_none()
);
assert!(persisted_window_identity("view-tickets-not-a-project").is_none());
}
}