From c2033890ff0b4e2b277dbb7221e5f3b959c38416 Mon Sep 17 00:00:00 2001 From: Ghibli1024 Date: Thu, 13 Aug 2026 00:33:29 +0800 Subject: [PATCH 1/6] fix(runtime): stabilize CDP injection and bridge lifecycle --- apps/codex-plus-launcher/src/main.rs | 15 +- crates/codex-plus-core/src/bridge.rs | 266 ++++++++++++++---- crates/codex-plus-core/src/cdp.rs | 45 ++- crates/codex-plus-core/src/launcher.rs | 69 ++++- crates/codex-plus-core/tests/cdp_bridge.rs | 304 ++++++++++++++++++++- crates/codex-plus-core/tests/launcher.rs | 37 ++- 6 files changed, 650 insertions(+), 86 deletions(-) diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index a6947b83e..093e902b6 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -188,16 +188,13 @@ async fn activate_existing_codex_app(options: &LaunchOptions) -> anyhow::Result< hooks.start_helper(helper_port).await?; } let process_ids = codex_plus_core::watcher::find_codex_processes(); - let mut activated = false; #[cfg(windows)] - { - for process_id in &process_ids { - if codex_plus_core::windows_activate_process_window(*process_id) { - activated = true; - break; - } - } - } + let activated = process_ids + .iter() + .copied() + .any(codex_plus_core::windows_activate_process_window); + #[cfg(not(windows))] + let activated = false; let injection_ready = if settings.enhancements_enabled { hooks .ensure_injection(options.debug_port, helper_port, &app_dir) diff --git a/crates/codex-plus-core/src/bridge.rs b/crates/codex-plus-core/src/bridge.rs index 29991523c..6a1417e20 100644 --- a/crates/codex-plus-core/src/bridge.rs +++ b/crates/codex-plus-core/src/bridge.rs @@ -9,6 +9,7 @@ use std::time::Duration; use anyhow::{Context, bail}; use base64::Engine; +use futures_util::stream::FuturesUnordered; use futures_util::{SinkExt, StreamExt}; use serde_json::{Value, json}; use tokio_tungstenite::connect_async; @@ -26,6 +27,69 @@ pub type BridgeHandler = Arc< static NEXT_MESSAGE_ID: AtomicU64 = AtomicU64::new(100); +/// Bridge 会话按注入目标分代。 +/// +/// 同一目标再次安装 Bridge 时,旧会话会在下一次消息循环中退出并关闭 socket, +/// 避免多份 CDP 会话同时应答同一个页面请求。不同目标互不影响。 +static NEXT_BRIDGE_GENERATION: AtomicU64 = AtomicU64::new(1); +static CURRENT_BRIDGE_GENERATIONS: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); + +#[derive(Clone)] +struct BridgeGeneration { + target: String, + id: u64, +} + +type PendingBridgeCall = Pin + Send>>; + +struct CompletedBridgeCall { + request_id: String, + generation: Option, + response: Result, +} + +fn next_bridge_generation(target: &str) -> BridgeGeneration { + BridgeGeneration { + target: target.to_string(), + id: NEXT_BRIDGE_GENERATION.fetch_add(1, Ordering::SeqCst), + } +} + +fn publish_bridge_generation(generation: &BridgeGeneration) -> bool { + let mut generations = CURRENT_BRIDGE_GENERATIONS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if generations + .get(&generation.target) + .is_some_and(|current| *current > generation.id) + { + return false; + } + generations.insert(generation.target.clone(), generation.id); + true +} + +fn bridge_generation_is_current(generation: &BridgeGeneration) -> bool { + CURRENT_BRIDGE_GENERATIONS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(&generation.target) + .is_some_and(|current| *current == generation.id) +} + +fn release_bridge_generation(generation: &BridgeGeneration) { + let mut generations = CURRENT_BRIDGE_GENERATIONS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if generations + .get(&generation.target) + .is_some_and(|current| *current == generation.id) + { + generations.remove(&generation.target); + } +} + pub fn build_bridge_script(binding_name: &str) -> String { format!( r#" @@ -192,6 +256,8 @@ pub async fn install_bridge( ) -> anyhow::Result<()> { let socket = connect_cdp_websocket(websocket_url).await?; let mut session = CdpSession::new(socket).with_handler(handler); + let generation = next_bridge_generation(websocket_url); + session = session.with_generation(generation.clone()); session.send_command(1, "Runtime.enable", json!({})).await?; session @@ -236,17 +302,51 @@ pub async fn install_bridge( .await?; } - session.drain_binding_queue().await?; + if !publish_bridge_generation(&generation) { + let _ = crate::diagnostic_log::append_diagnostic_log( + "bridge.generation_superseded_before_publish", + json!({ "generation": generation.id }), + ); + session.close().await; + return Ok(()); + } + let _ = crate::diagnostic_log::append_diagnostic_log( + "bridge.generation_published", + json!({ "generation": generation.id }), + ); + + let mut pending_calls = FuturesUnordered::new(); + session.enqueue_binding_calls(&mut pending_calls); tokio::spawn(async move { loop { - if session.drain_binding_queue().await.is_err() { + if !bridge_generation_is_current(&generation) { + let _ = crate::diagnostic_log::append_diagnostic_log( + "bridge.generation_superseded", + json!({ "generation": generation.id }), + ); break; } - match session.next_message().await { - Ok(Some(_)) => {} - Ok(None) | Err(_) => break, + + session.enqueue_binding_calls(&mut pending_calls); + tokio::select! { + completed = pending_calls.next(), if !pending_calls.is_empty() => { + let Some(completed) = completed else { + continue; + }; + if session.finish_binding_call(completed).await.is_err() { + break; + } + } + message = session.next_message() => { + match message { + Ok(Some(_)) => {} + Ok(None) | Err(_) => break, + } + } } } + session.close().await; + release_bridge_generation(&generation); }); Ok(()) @@ -308,6 +408,7 @@ struct CdpSession { responses: HashMap, binding_calls: VecDeque, handler: Option, + generation: Option, } impl CdpSession @@ -324,6 +425,7 @@ where responses: HashMap::new(), binding_calls: VecDeque::new(), handler: None, + generation: None, } } @@ -332,6 +434,22 @@ where self } + fn with_generation(mut self, generation: BridgeGeneration) -> Self { + self.generation = Some(generation); + self + } + + fn is_current(&self) -> bool { + self.generation + .as_ref() + .is_none_or(bridge_generation_is_current) + } + + async fn close(&mut self) { + let _ = self.socket.send(Message::Close(None)).await; + let _ = self.socket.close().await; + } + async fn send_command( &mut self, message_id: u64, @@ -421,73 +539,117 @@ where Ok(Some(value)) } - async fn drain_binding_queue(&mut self) -> anyhow::Result<()> { + fn enqueue_binding_calls(&mut self, pending_calls: &mut FuturesUnordered) { while let Some(message) = self.binding_calls.pop_front() { - self.route_binding_call(message).await?; + self.enqueue_binding_call(message, pending_calls); } - Ok(()) } - fn route_binding_call( + fn enqueue_binding_call( &mut self, message: Value, - ) -> Pin> + Send + '_>> { - Box::pin(async move { - let Some(handler) = self.handler.clone() else { - return Ok(()); - }; - - let Some(payload_text) = message - .get("params") - .and_then(|params| params.get("payload")) - .and_then(Value::as_str) - else { - return Ok(()); - }; + pending_calls: &mut FuturesUnordered, + ) { + let Some(handler) = self.handler.clone() else { + return; + }; - let parsed: Value = match serde_json::from_str(payload_text) { - Ok(parsed) => parsed, - Err(error) => { - if let Some(request_id) = extract_string_field(payload_text, "id") { - self.reject_bridge_request( - &request_id, - &format!("failed to parse bridge payload: {error}"), - ) - .await?; - } - return Ok(()); - } - }; - self.route_parsed_binding_call(&handler, parsed).await - }) - } + let Some(payload_text) = message + .get("params") + .and_then(|params| params.get("payload")) + .and_then(Value::as_str) + else { + return; + }; - async fn route_parsed_binding_call( - &mut self, - handler: &BridgeHandler, - parsed: Value, - ) -> anyhow::Result<()> { - let Some(request_id) = parsed.get("id").and_then(Value::as_str) else { - return Ok(()); + let parsed: Value = match serde_json::from_str(payload_text) { + Ok(parsed) => parsed, + Err(error) => { + let Some(request_id) = extract_string_field(payload_text, "id") else { + return; + }; + self.enqueue_completed_binding_call( + request_id, + Err(format!("failed to parse bridge payload: {error}")), + pending_calls, + ); + return; + } + }; + let Some(request_id) = parsed.get("id").and_then(Value::as_str).map(str::to_string) else { + return; }; + if !self.is_current() { + let _ = crate::diagnostic_log::append_diagnostic_log( + "bridge.stale_request_dropped", + json!({ + "request_id": request_id, + "generation": self.generation.as_ref().map(|generation| generation.id) + }), + ); + return; + } let path = parsed .get("path") .and_then(Value::as_str) .unwrap_or_default() .to_string(); let payload = parsed.get("payload").cloned().unwrap_or_else(|| json!({})); + let generation = self.generation.clone(); + + pending_calls.push(Box::pin(async move { + CompletedBridgeCall { + request_id, + generation, + response: handler(path, payload) + .await + .map_err(|error| error.to_string()), + } + })); + } - match handler(path, payload).await { + fn enqueue_completed_binding_call( + &self, + request_id: String, + response: Result, + pending_calls: &mut FuturesUnordered, + ) { + let generation = self.generation.clone(); + pending_calls.push(Box::pin(async move { + CompletedBridgeCall { + request_id, + generation, + response, + } + })); + } + + async fn finish_binding_call(&mut self, completed: CompletedBridgeCall) -> anyhow::Result<()> { + if completed + .generation + .as_ref() + .is_some_and(|generation| !bridge_generation_is_current(generation)) + { + let _ = crate::diagnostic_log::append_diagnostic_log( + "bridge.stale_response_dropped", + json!({ + "request_id": completed.request_id, + "generation": completed.generation.as_ref().map(|generation| generation.id) + }), + ); + return Ok(()); + } + + match completed.response { Ok(result) => { - self.resolve_bridge_request(request_id, &result).await?; + self.resolve_bridge_request(&completed.request_id, &result) + .await } - Err(error) => { - self.reject_bridge_request(request_id, &error.to_string()) - .await?; + Err(message) => { + self.reject_bridge_request(&completed.request_id, &message) + .await } } - - Ok(()) } async fn resolve_bridge_request( diff --git a/crates/codex-plus-core/src/cdp.rs b/crates/codex-plus-core/src/cdp.rs index 9a813e979..047d7d942 100644 --- a/crates/codex-plus-core/src/cdp.rs +++ b/crates/codex-plus-core/src/cdp.rs @@ -253,16 +253,19 @@ pub fn pick_page_target(targets: &[CdpTarget]) -> anyhow::Result { } pub fn pick_injectable_codex_page_target(targets: &[CdpTarget]) -> anyhow::Result { - // Only inject into Codex's own app:// page (or the supported ChatGPT - // desktop page). Embedded browser pages can have titles or URLs containing - // "Codex" (for example a GitHub PR), but they must never become the target. - if let Some(target) = targets.iter().find(|target| { - is_injectable_page_target(target) - && is_primary_codex_page_target(target) - && (is_codex_app_page_target(target) - || is_chatgpt_desktop_page(&target.title, &target.url)) - }) { - return Ok(target.clone()); + let priorities: [fn(&CdpTarget) -> bool; 4] = [ + is_exact_codex_app_main_target, + is_primary_codex_app_target, + is_chatgpt_desktop_page_target, + is_supported_codex_page_target, + ]; + for matches_priority in priorities { + if let Some(target) = targets + .iter() + .find(|target| is_injectable_page_target(target) && matches_priority(target)) + { + return Ok(target.clone()); + } } bail!("No injectable Codex page target found") } @@ -298,6 +301,23 @@ pub fn is_primary_codex_page_target(target: &CdpTarget) -> bool { && !is_quick_chat_page_target(target) } +fn is_exact_codex_app_main_target(target: &CdpTarget) -> bool { + target.url.trim().eq_ignore_ascii_case("app://-/index.html") +} + +fn is_primary_codex_app_target(target: &CdpTarget) -> bool { + is_codex_app_page_target(target) && is_primary_codex_page_target(target) +} + +fn is_chatgpt_desktop_page_target(target: &CdpTarget) -> bool { + is_primary_codex_page_target(target) && is_chatgpt_desktop_page(&target.title, &target.url) +} + +fn is_supported_codex_page_target(target: &CdpTarget) -> bool { + is_primary_codex_page_target(target) + && (is_codex_app_page_target(target) || is_chatgpt_desktop_page(&target.title, &target.url)) +} + pub fn is_avatar_overlay_page_target(target: &CdpTarget) -> bool { initial_route(target).is_some_and(|route| route.eq_ignore_ascii_case("/avatar-overlay")) } @@ -316,10 +336,7 @@ fn initial_route(target: &CdpTarget) -> Option { return None; } let url = reqwest::Url::parse(target.url.trim()).ok()?; - if !url.scheme().eq_ignore_ascii_case("app") - || url.host_str() != Some("-") - || !url.path().eq_ignore_ascii_case("/index.html") - { + if !is_codex_app_page_target(target) { return None; } url.query_pairs() diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index 102404fc0..0493e3d8a 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -25,6 +25,8 @@ const POST_LAUNCH_COMPUTER_USE_GUARD_SECONDS: &[u64] = &[0, 5, 15, 30, 60, 120, const POST_LAUNCH_COMPUTER_USE_GUARD_STABLE_ATTEMPTS: usize = 3; static PET_OVERLAY_SYNC_FAILED: AtomicBool = AtomicBool::new(false); static PET_CURSOR_DRIVER_FAILED: AtomicBool = AtomicBool::new(false); +const MACOS_DEBUG_TAKEOVER_WAIT_MS: u64 = 5_000; +const MACOS_DEBUG_TAKEOVER_INTERVAL_MS: u64 = 100; /// Asynchronous callback used by the bridge watchdog to restore a launcher-specific bridge. /// @@ -59,6 +61,13 @@ pub enum MacosCleanupPolicy { SkipQuitBecauseAlreadyRunning, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MacosDebugLaunchAction { + LaunchNew, + ReuseRunningDebugApp, + RestartRunningApp, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WindowsProcessControlStrategy { NativeWindowsApi, @@ -766,10 +775,26 @@ impl LaunchHooks for DefaultLaunchHooks { } if app_dir.extension().and_then(|value| value.to_str()) == Some("app") { - let cleanup_policy = if is_macos_app_running(app_dir).await { - MacosCleanupPolicy::SkipQuitBecauseAlreadyRunning - } else { - MacosCleanupPolicy::QuitIfNotPreviouslyRunning + let launch_action = select_macos_debug_launch_action( + is_macos_app_running(app_dir).await, + crate::cdp::endpoint_available(debug_port), + ); + let cleanup_policy = match launch_action { + MacosDebugLaunchAction::LaunchNew => MacosCleanupPolicy::QuitIfNotPreviouslyRunning, + MacosDebugLaunchAction::ReuseRunningDebugApp => { + MacosCleanupPolicy::SkipQuitBecauseAlreadyRunning + } + MacosDebugLaunchAction::RestartRunningApp => { + let _ = crate::diagnostic_log::append_diagnostic_log( + "launcher.macos_existing_app_without_cdp_restart_requested", + serde_json::json!({ + "app_dir": app_dir, + "debug_port": debug_port + }), + ); + quit_macos_app_and_wait(app_dir).await?; + MacosCleanupPolicy::QuitIfNotPreviouslyRunning + } }; let command = if let Some(inspector_port) = native_menu_inspector_port { build_macos_open_command_with_native_menu_inspector( @@ -2780,6 +2805,17 @@ pub fn build_macos_cleanup_command( ]) } +pub fn select_macos_debug_launch_action( + app_running: bool, + codex_cdp_available: bool, +) -> MacosDebugLaunchAction { + match (app_running, codex_cdp_available) { + (false, _) => MacosDebugLaunchAction::LaunchNew, + (true, true) => MacosDebugLaunchAction::ReuseRunningDebugApp, + (true, false) => MacosDebugLaunchAction::RestartRunningApp, + } +} + async fn run_macos_cleanup_command( app_dir: &Path, policy: MacosCleanupPolicy, @@ -2800,6 +2836,31 @@ async fn run_macos_cleanup_command( Ok(()) } +async fn quit_macos_app_and_wait(app_dir: &Path) -> anyhow::Result<()> { + run_macos_cleanup_command(app_dir, MacosCleanupPolicy::QuitIfNotPreviouslyRunning).await?; + let deadline = tokio::time::Instant::now() + + std::time::Duration::from_millis(MACOS_DEBUG_TAKEOVER_WAIT_MS); + while is_macos_app_running(app_dir).await { + if tokio::time::Instant::now() >= deadline { + anyhow::bail!( + "macOS app did not exit before debug relaunch: {}", + app_dir.display() + ); + } + tokio::time::sleep(std::time::Duration::from_millis( + MACOS_DEBUG_TAKEOVER_INTERVAL_MS, + )) + .await; + } + let _ = crate::diagnostic_log::append_diagnostic_log( + "launcher.macos_existing_app_without_cdp_stopped", + serde_json::json!({ + "app_dir": app_dir + }), + ); + Ok(()) +} + fn macos_app_dir_from_open_command(command: &[String]) -> Option { let app_index = command.iter().position(|part| part == "-a")?; command.get(app_index + 1).map(PathBuf::from) diff --git a/crates/codex-plus-core/tests/cdp_bridge.rs b/crates/codex-plus-core/tests/cdp_bridge.rs index 03025f736..ce5500e40 100644 --- a/crates/codex-plus-core/tests/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/cdp_bridge.rs @@ -18,7 +18,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use tokio::net::TcpListener; -use tokio::sync::oneshot; +use tokio::sync::{Notify, oneshot}; use tokio_tungstenite::accept_async; use tokio_tungstenite::tungstenite::Message; @@ -2764,6 +2764,31 @@ fn pick_injectable_codex_page_target_accepts_chatgpt_desktop_error_page() { assert_eq!(picked.id, "chatgpt-error"); } +#[test] +fn pick_injectable_codex_page_target_prefers_app_main_over_incidental_codex_page() { + let targets = vec![ + target( + "help", + "page", + "Using Codex with your ChatGPT plan", + "https://help.openai.com/en/articles/using-codex", + Some("ws://help"), + ), + target( + "main", + "page", + "Codex", + "app://-/index.html", + Some("ws://main"), + ), + ]; + + let picked = pick_injectable_codex_page_target(&targets) + .expect("the exact app main renderer should win regardless of target order"); + + assert_eq!(picked.id, "main"); +} + #[test] fn avatar_overlay_target_detection_is_narrow() { let overlay = target( @@ -3438,8 +3463,285 @@ async fn install_bridge_does_not_wait_for_resolve_runtime_evaluate_ack() { .expect("server task should finish without panicking"); } +#[tokio::test] +async fn install_bridge_keeps_status_responsive_while_generate_is_pending() { + let generate_started = Arc::new(Notify::new()); + let release_generate = Arc::new(Notify::new()); + let server_generate_started = Arc::clone(&generate_started); + let server_release_generate = Arc::clone(&release_generate); + let (url, request_rx) = spawn_cdp_server(move |mut socket| async move { + acknowledge_bridge_install(&mut socket).await; + + send_json( + &mut socket, + json!({ + "method": "Runtime.bindingCalled", + "params": { + "payload": serde_json::to_string(&json!({ + "id": "generate", + "path": "/stepwise/generate", + "payload": {}, + })).unwrap(), + }, + }), + ) + .await; + tokio::time::timeout( + Duration::from_millis(500), + server_generate_started.notified(), + ) + .await + .expect("generate handler should start before the status probe"); + + send_json( + &mut socket, + json!({ + "method": "Runtime.bindingCalled", + "params": { + "payload": serde_json::to_string(&json!({ + "id": "status", + "path": "/backend/status", + "payload": {}, + })).unwrap(), + }, + }), + ) + .await; + + let status_resolve = + tokio::time::timeout(Duration::from_millis(500), recv_json(&mut socket)) + .await + .expect("status should resolve while generate remains pending"); + assert_eq!(status_resolve["method"], "Runtime.evaluate"); + assert_expression_contains_request(&status_resolve, "status"); + + server_release_generate.notify_one(); + let generate_resolve = recv_json(&mut socket).await; + assert_eq!(generate_resolve["method"], "Runtime.evaluate"); + assert_expression_contains_request(&generate_resolve, "generate"); + close_socket(&mut socket).await; + }) + .await; + + let handler_generate_started = Arc::clone(&generate_started); + let handler_release_generate = Arc::clone(&release_generate); + let handler = Arc::new(move |path: String, _payload: serde_json::Value| { + let generate_started = Arc::clone(&handler_generate_started); + let release_generate = Arc::clone(&handler_release_generate); + Box::pin(async move { + if path == "/stepwise/generate" { + generate_started.notify_one(); + release_generate.notified().await; + } + Ok(json!({ "status": "ok", "path": path })) + }) as Pin> + Send>> + }); + + tokio::time::timeout( + Duration::from_secs(2), + bridge::install_bridge(&url, BRIDGE_BINDING_NAME, handler, &[]), + ) + .await + .expect("bridge install should return while generate is pending") + .expect("bridge install should start the concurrent message pump"); + request_rx + .await + .expect("server task should finish without panicking"); +} + +#[tokio::test] +async fn superseded_bridge_session_stops_answering_binding_calls() { + let (url, stale_rx, fresh_rx) = spawn_two_session_cdp_server().await; + + bridge::install_bridge(&url, BRIDGE_BINDING_NAME, noop_handler(), &[]) + .await + .expect("first bridge install should succeed"); + bridge::install_bridge(&url, BRIDGE_BINDING_NAME, noop_handler(), &[]) + .await + .expect("second bridge install should succeed"); + + let stale_resolved = stale_rx + .await + .expect("stale server task should finish without panicking"); + assert!( + !stale_resolved, + "superseded session must not resolve bridge requests" + ); + fresh_rx + .await + .expect("fresh server task should finish without panicking"); +} + +#[tokio::test] +async fn failed_bridge_reinstall_keeps_existing_session_current() { + let (url, active_rx, failed_rx) = spawn_failed_reinstall_cdp_server().await; + + bridge::install_bridge(&url, BRIDGE_BINDING_NAME, noop_handler(), &[]) + .await + .expect("first bridge install should succeed"); + let error = bridge::install_bridge(&url, BRIDGE_BINDING_NAME, noop_handler(), &[]) + .await + .expect_err("second bridge install should fail"); + assert!(error.to_string().contains("Runtime.addBinding")); + + assert!( + active_rx + .await + .expect("active server task should finish without panicking"), + "failed reinstall must not supersede the existing bridge session" + ); + failed_rx + .await + .expect("failed reinstall server task should finish without panicking"); +} + type TestSocket = tokio_tungstenite::WebSocketStream; +async fn spawn_two_session_cdp_server() -> (String, oneshot::Receiver, oneshot::Receiver<()>) +{ + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + let (stale_tx, stale_rx) = oneshot::channel(); + let (fresh_tx, fresh_rx) = oneshot::channel(); + + tokio::spawn(async move { + let (stale_stream, _) = listener + .accept() + .await + .expect("stale client should connect"); + let mut stale = accept_async(stale_stream) + .await + .expect("stale websocket should upgrade"); + acknowledge_bridge_install(&mut stale).await; + + let (fresh_stream, _) = listener + .accept() + .await + .expect("fresh client should connect"); + let mut fresh = accept_async(fresh_stream) + .await + .expect("fresh websocket should upgrade"); + acknowledge_bridge_install(&mut fresh).await; + + send_json( + &mut stale, + json!({ + "method": "Runtime.bindingCalled", + "params": { + "payload": serde_json::to_string(&json!({ + "id": "stale", + "path": "/backend/status", + "payload": {}, + })).unwrap(), + }, + }), + ) + .await; + + let stale_resolved = + tokio::time::timeout(Duration::from_millis(500), recv_text_message(&mut stale)) + .await + .is_ok_and(|message| message.is_some_and(|text| text.contains("Runtime.evaluate"))); + let _ = stale_tx.send(stale_resolved); + close_socket(&mut fresh).await; + let _ = fresh_tx.send(()); + }); + + (websocket_url(address), stale_rx, fresh_rx) +} + +async fn spawn_failed_reinstall_cdp_server() +-> (String, oneshot::Receiver, oneshot::Receiver<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + let (active_tx, active_rx) = oneshot::channel(); + let (failed_tx, failed_rx) = oneshot::channel(); + + tokio::spawn(async move { + let (active_stream, _) = listener + .accept() + .await + .expect("active client should connect"); + let mut active = accept_async(active_stream) + .await + .expect("active websocket should upgrade"); + acknowledge_bridge_install(&mut active).await; + + let (failed_stream, _) = listener + .accept() + .await + .expect("failed client should connect"); + let mut failed = accept_async(failed_stream) + .await + .expect("failed websocket should upgrade"); + for expected_id in 1..=2 { + let command = recv_json(&mut failed).await; + assert_eq!(command["id"], expected_id); + send_json(&mut failed, json!({ "id": expected_id, "result": {} })).await; + } + let add_binding = recv_json(&mut failed).await; + assert_eq!(add_binding["id"], 3); + assert_eq!(add_binding["method"], "Runtime.addBinding"); + send_json( + &mut failed, + json!({ + "id": 3, + "error": { "code": -32000, "message": "binding install failed" } + }), + ) + .await; + close_socket(&mut failed).await; + let _ = failed_tx.send(()); + + send_json( + &mut active, + json!({ + "method": "Runtime.bindingCalled", + "params": { + "payload": serde_json::to_string(&json!({ + "id": "active", + "path": "/backend/status", + "payload": {}, + })).unwrap(), + }, + }), + ) + .await; + let active_resolved = + tokio::time::timeout(Duration::from_millis(500), recv_json(&mut active)) + .await + .is_ok_and(|message| { + message["method"] == "Runtime.evaluate" + && message["params"]["expression"] + .as_str() + .is_some_and(|expression| expression.contains("active")) + }); + let _ = active_tx.send(active_resolved); + close_socket(&mut active).await; + }); + + (websocket_url(address), active_rx, failed_rx) +} + +async fn acknowledge_bridge_install(socket: &mut TestSocket) { + for expected_id in 1..=5 { + let command = recv_json(socket).await; + assert_eq!(command["id"], expected_id); + send_json(socket, json!({ "id": expected_id, "result": {} })).await; + } +} + +async fn recv_text_message(socket: &mut TestSocket) -> Option { + match socket.next().await { + Some(Ok(Message::Text(text))) => Some(text.to_string()), + _ => None, + } +} + async fn spawn_cdp_server(handler: F) -> (String, oneshot::Receiver<()>) where F: FnOnce(TestSocket) -> Fut + Send + 'static, diff --git a/crates/codex-plus-core/tests/launcher.rs b/crates/codex-plus-core/tests/launcher.rs index 2995c55a7..b8a9b375d 100644 --- a/crates/codex-plus-core/tests/launcher.rs +++ b/crates/codex-plus-core/tests/launcher.rs @@ -8,12 +8,13 @@ use codex_plus_core::app_paths::{ }; use codex_plus_core::launcher::{ CodexLaunch, DefaultLaunchHooks, LaunchHooks, LaunchOptions, MacosCleanupPolicy, - browser_identity_changed, build_codex_arguments, build_codex_arguments_for_settings, - build_codex_arguments_with_native_menu_inspector, build_codex_command, - build_codex_command_with_native_menu_inspector, build_macos_cleanup_command, - build_macos_open_command, build_macos_open_command_with_native_menu_inspector, - build_packaged_activation, build_packaged_activation_with_native_menu_inspector, - launch_and_inject_with_hooks, + MacosDebugLaunchAction, browser_identity_changed, build_codex_arguments, + build_codex_arguments_for_settings, build_codex_arguments_with_native_menu_inspector, + build_codex_command, build_codex_command_with_native_menu_inspector, + build_macos_cleanup_command, build_macos_open_command, + build_macos_open_command_with_native_menu_inspector, build_packaged_activation, + build_packaged_activation_with_native_menu_inspector, launch_and_inject_with_hooks, + select_macos_debug_launch_action, }; #[cfg(windows)] use codex_plus_core::launcher::{WindowsProcessControlStrategy, windows_process_control_strategy}; @@ -1745,6 +1746,30 @@ fn launcher_macos_cleanup_is_skipped_when_app_was_already_running() { assert_eq!(command, None); } +#[test] +fn launcher_macos_debug_launch_starts_when_app_is_not_running() { + assert_eq!( + select_macos_debug_launch_action(false, false), + MacosDebugLaunchAction::LaunchNew + ); +} + +#[test] +fn launcher_macos_debug_launch_reuses_existing_codex_cdp_instance() { + assert_eq!( + select_macos_debug_launch_action(true, true), + MacosDebugLaunchAction::ReuseRunningDebugApp + ); +} + +#[test] +fn launcher_macos_debug_launch_restarts_existing_non_cdp_instance() { + assert_eq!( + select_macos_debug_launch_action(true, false), + MacosDebugLaunchAction::RestartRunningApp + ); +} + #[tokio::test] async fn default_launch_hooks_provider_sync_enabled_returns_explicit_error() { let error = DefaultLaunchHooks::default() From 06ebae9d2da237649d08501ac882d120a5c64829 Mon Sep 17 00:00:00 2001 From: Ghibli1024 Date: Thu, 13 Aug 2026 00:43:19 +0800 Subject: [PATCH 2/6] feat(stepwise): add multi-protocol generation and mode controls --- .../src-tauri/src/commands.rs | 23 +- crates/codex-plus-core/src/settings.rs | 191 ++++- crates/codex-plus-core/src/stepwise.rs | 808 ++++++++++++++++-- crates/codex-plus-core/tests/bridge_routes.rs | 11 + 4 files changed, 959 insertions(+), 74 deletions(-) diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index c70022a21..16da77dbb 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -3505,6 +3505,9 @@ pub async fn test_relay_profile(profile: RelayProfile) -> CommandResult CommandResult { + let configured_protocol = codex_plus_core::settings::normalize_stepwise_protocol( + &settings.codex_app_stepwise_protocol, + ); match codex_plus_core::stepwise::test_connection(&settings).await { Ok(result) => { let error = result @@ -3517,9 +3520,17 @@ pub async fn test_stepwise_settings( .and_then(Value::as_array) .map(Vec::len) .unwrap_or_default(); + let protocol = result + .get("protocol") + .and_then(Value::as_str) + .unwrap_or(&configured_protocol) + .to_string(); if error.is_empty() { ok( - &format!("Stepwise 连接正常,测试返回 {item_count} 条建议。"), + &format!( + "Stepwise 连接正常({}),测试返回 {item_count} 条建议。", + stepwise_protocol_label(&protocol) + ), StepwiseTestPayload { item_count, error }, ) } else { @@ -3539,6 +3550,16 @@ pub async fn test_stepwise_settings( } } +fn stepwise_protocol_label(protocol: &str) -> &str { + match protocol { + "chat_completions" => "Chat Completions", + "responses" => "Responses", + "anthropic_messages" => "Anthropic Messages", + "auto" => "自动兼容", + _ => protocol, + } +} + #[tauri::command] pub async fn fetch_relay_profile_models( profile: RelayProfile, diff --git a/crates/codex-plus-core/src/settings.rs b/crates/codex-plus-core/src/settings.rs index b889d699e..9e445ba80 100644 --- a/crates/codex-plus-core/src/settings.rs +++ b/crates/codex-plus-core/src/settings.rs @@ -416,6 +416,14 @@ pub struct BackendSettings { pub codex_app_pet_real_mouse_look: bool, #[serde(rename = "codexAppStepwiseEnabled", default)] pub codex_app_stepwise_enabled: bool, + #[serde( + rename = "codexAppStepwiseGenerationMode", + default = "default_stepwise_generation_mode", + deserialize_with = "deserialize_stepwise_generation_mode" + )] + pub codex_app_stepwise_generation_mode: String, + #[serde(rename = "codexAppAnswerOutlineEnabled", default = "default_true")] + pub codex_app_answer_outline_enabled: bool, #[serde(rename = "codexAppStepwiseDirectSend", default)] pub codex_app_stepwise_direct_send: bool, #[serde(rename = "codexAppStepwiseBaseUrl", default)] @@ -428,6 +436,12 @@ pub struct BackendSettings { deserialize_with = "empty_as_default_stepwise_api_key_env" )] pub codex_app_stepwise_api_key_env: String, + #[serde( + rename = "codexAppStepwiseProtocol", + default = "default_stepwise_protocol", + deserialize_with = "deserialize_stepwise_protocol" + )] + pub codex_app_stepwise_protocol: String, #[serde(rename = "codexAppStepwiseModel", default)] pub codex_app_stepwise_model: String, #[serde( @@ -541,10 +555,13 @@ impl Default for BackendSettings { codex_app_service_tier_controls: false, codex_app_pet_real_mouse_look: false, codex_app_stepwise_enabled: false, + codex_app_stepwise_generation_mode: default_stepwise_generation_mode(), + codex_app_answer_outline_enabled: true, codex_app_stepwise_direct_send: false, codex_app_stepwise_base_url: String::new(), codex_app_stepwise_api_key: String::new(), codex_app_stepwise_api_key_env: default_stepwise_api_key_env(), + codex_app_stepwise_protocol: default_stepwise_protocol(), codex_app_stepwise_model: String::new(), codex_app_stepwise_max_items: default_stepwise_max_items(), codex_app_stepwise_max_input_chars: default_stepwise_max_input_chars(), @@ -711,8 +728,32 @@ pub fn default_stepwise_api_key_env() -> String { "CODEX_STEPWISE_API_KEY".to_string() } +pub fn default_stepwise_protocol() -> String { + "chat_completions".to_string() +} + +pub fn default_stepwise_generation_mode() -> String { + "auto".to_string() +} + +pub fn normalize_stepwise_generation_mode(value: &str) -> String { + match value.trim() { + "manual" => "manual".to_string(), + _ => default_stepwise_generation_mode(), + } +} + +pub fn normalize_stepwise_protocol(value: &str) -> String { + match value.trim() { + "chat_completions" | "responses" | "anthropic_messages" | "auto" => { + value.trim().to_string() + } + _ => default_stepwise_protocol(), + } +} + pub fn default_stepwise_max_items() -> u8 { - 6 + 4 } pub fn default_stepwise_max_input_chars() -> u32 { @@ -850,7 +891,7 @@ fn normalize_dream_skin_theme(value: &str) -> String { } pub fn clamp_stepwise_max_items(value: u8) -> u8 { - value.min(default_stepwise_max_items()) + value.min(6) } pub fn clamp_stepwise_max_input_chars(value: u32) -> u32 { @@ -899,6 +940,24 @@ where .unwrap_or_else(default_stepwise_api_key_env)) } +fn deserialize_stepwise_protocol<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)? + .map(|value| normalize_stepwise_protocol(&value)) + .unwrap_or_else(default_stepwise_protocol)) +} + +fn deserialize_stepwise_generation_mode<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)? + .map(|value| normalize_stepwise_generation_mode(&value)) + .unwrap_or_else(default_stepwise_generation_mode)) +} + fn deserialize_image_overlay_opacity<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -1120,6 +1179,16 @@ fn merge_known_setting_fields(target: &mut Map, source: &Map, source: &Map BackendS } else { settings.codex_app_stepwise_api_key_env.trim().to_string() }; + settings.codex_app_stepwise_generation_mode = + normalize_stepwise_generation_mode(&settings.codex_app_stepwise_generation_mode); + settings.codex_app_stepwise_protocol = + normalize_stepwise_protocol(&settings.codex_app_stepwise_protocol); settings.codex_app_stepwise_model = settings.codex_app_stepwise_model.trim().to_string(); settings.codex_app_stepwise_max_items = clamp_stepwise_max_items(settings.codex_app_stepwise_max_items); @@ -1645,6 +1727,8 @@ mod tests { assert!(settings.relay_common_config_contents.is_empty()); assert_eq!(settings.relay_test_model, default_relay_test_model()); assert!(!settings.codex_app_stepwise_enabled); + assert_eq!(settings.codex_app_stepwise_generation_mode, "auto"); + assert!(settings.codex_app_answer_outline_enabled); assert!(!settings.codex_app_stepwise_direct_send); assert!(settings.codex_app_stepwise_base_url.is_empty()); assert!(settings.codex_app_stepwise_api_key.is_empty()); @@ -1652,13 +1736,56 @@ mod tests { settings.codex_app_stepwise_api_key_env, "CODEX_STEPWISE_API_KEY" ); + assert_eq!(settings.codex_app_stepwise_protocol, "chat_completions"); assert!(settings.codex_app_stepwise_model.is_empty()); - assert_eq!(settings.codex_app_stepwise_max_items, 6); + assert_eq!(settings.codex_app_stepwise_max_items, 4); assert_eq!(settings.codex_app_stepwise_max_input_chars, 6000); assert_eq!(settings.codex_app_stepwise_max_output_tokens, 500); assert_eq!(settings.codex_app_stepwise_timeout_ms, 8000); } + #[test] + fn settings_deserialize_normalizes_stepwise_protocol_and_supports_legacy_missing_field() { + let defaults: BackendSettings = serde_json::from_str("{}").unwrap(); + assert_eq!(defaults.codex_app_stepwise_protocol, "chat_completions"); + assert_eq!(defaults.codex_app_stepwise_generation_mode, "auto"); + assert!(defaults.codex_app_answer_outline_enabled); + + for protocol in [ + "chat_completions", + "responses", + "anthropic_messages", + "auto", + ] { + let settings: BackendSettings = serde_json::from_value(json!({ + "codexAppStepwiseProtocol": format!(" {protocol} ") + })) + .unwrap(); + assert_eq!(settings.codex_app_stepwise_protocol, protocol); + } + + let invalid: BackendSettings = serde_json::from_value(json!({ + "codexAppStepwiseProtocol": "unsupported" + })) + .unwrap(); + assert_eq!(invalid.codex_app_stepwise_protocol, "chat_completions"); + } + + #[test] + fn settings_deserialize_normalizes_stepwise_generation_mode() { + let manual: BackendSettings = serde_json::from_value(json!({ + "codexAppStepwiseGenerationMode": " manual " + })) + .unwrap(); + assert_eq!(manual.codex_app_stepwise_generation_mode, "manual"); + + let invalid: BackendSettings = serde_json::from_value(json!({ + "codexAppStepwiseGenerationMode": "unsupported" + })) + .unwrap(); + assert_eq!(invalid.codex_app_stepwise_generation_mode, "auto"); + } + #[test] fn settings_deserialize_ignores_removed_cli_wrapper_keys() { let settings: BackendSettings = serde_json::from_str( @@ -2180,6 +2307,60 @@ experimental_bearer_token = "sk-existing""# ); } + #[test] + fn settings_store_persists_and_normalizes_stepwise_protocol() { + let dir = temp_dir(); + let store = SettingsStore::new(dir.join("settings.json")); + + let updated = store + .update(json!({ + "codexAppStepwiseProtocol": "responses" + })) + .unwrap(); + assert_eq!(updated.codex_app_stepwise_protocol, "responses"); + assert_eq!( + store.load().unwrap().codex_app_stepwise_protocol, + "responses" + ); + + let invalid = store + .update(json!({ + "codexAppStepwiseProtocol": "not-a-protocol" + })) + .unwrap(); + assert_eq!(invalid.codex_app_stepwise_protocol, "chat_completions"); + let saved: Value = + serde_json::from_str(&std::fs::read_to_string(store.path).unwrap()).unwrap(); + assert_eq!(saved["codexAppStepwiseProtocol"], "chat_completions"); + } + + #[test] + fn settings_store_persists_and_normalizes_stepwise_generation_mode() { + let dir = temp_dir(); + let store = SettingsStore::new(dir.join("settings.json")); + + let updated = store + .update(json!({ + "codexAppStepwiseGenerationMode": " manual " + })) + .unwrap(); + assert_eq!(updated.codex_app_stepwise_generation_mode, "manual"); + assert_eq!( + store.load().unwrap().codex_app_stepwise_generation_mode, + "manual" + ); + + let invalid = store + .update(json!({ + "codexAppStepwiseGenerationMode": "not-a-mode" + })) + .unwrap(); + assert_eq!(invalid.codex_app_stepwise_generation_mode, "auto"); + let saved: Value = + serde_json::from_str(&std::fs::read_to_string(store.path).unwrap()).unwrap(); + assert_eq!(saved["codexAppStepwiseGenerationMode"], "auto"); + } + #[test] fn settings_store_save_load_roundtrip_preserves_aggregate_relay_settings() { let dir = temp_dir(); @@ -2387,6 +2568,8 @@ experimental_bearer_token = "sk-existing""# let updated = store .update(json!({ "codexAppStepwiseEnabled": true, + "codexAppStepwiseGenerationMode": "manual", + "codexAppAnswerOutlineEnabled": false, "codexAppStepwiseDirectSend": true, "codexAppStepwiseBaseUrl": "https://api.example.test/v1/", "codexAppStepwiseApiKey": " sk-stepwise ", @@ -2400,6 +2583,8 @@ experimental_bearer_token = "sk-existing""# .unwrap(); assert!(updated.codex_app_stepwise_enabled); + assert_eq!(updated.codex_app_stepwise_generation_mode, "manual"); + assert!(!updated.codex_app_answer_outline_enabled); assert!(updated.codex_app_stepwise_direct_send); assert_eq!( updated.codex_app_stepwise_base_url, diff --git a/crates/codex-plus-core/src/stepwise.rs b/crates/codex-plus-core/src/stepwise.rs index b56e3e88f..a1db98335 100644 --- a/crates/codex-plus-core/src/stepwise.rs +++ b/crates/codex-plus-core/src/stepwise.rs @@ -1,13 +1,47 @@ use std::time::Duration; use anyhow::Context; -use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}; +use reqwest::StatusCode; +use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::settings::BackendSettings; -const MAX_PROMPT_LENGTH: usize = 420; +const MAX_LABEL_LENGTH: usize = 36; +const MAX_SUMMARY_LENGTH: usize = 72; +const ANTHROPIC_VERSION: &str = "2023-06-01"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StepwiseProtocol { + ChatCompletions, + Responses, + AnthropicMessages, +} + +impl StepwiseProtocol { + fn from_setting(value: &str) -> Self { + match value { + "responses" => Self::Responses, + "anthropic_messages" => Self::AnthropicMessages, + _ => Self::ChatCompletions, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::ChatCompletions => "chat_completions", + Self::Responses => "responses", + Self::AnthropicMessages => "anthropic_messages", + } + } +} + +struct StepwiseUpstreamRequest { + endpoint: String, + headers: HeaderMap, + body: Value, +} #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -26,6 +60,8 @@ pub struct StepwiseRequest { pub struct StepwiseItem { #[serde(default, skip_serializing_if = "String::is_empty")] pub label: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub summary: String, pub prompt: String, } @@ -33,11 +69,14 @@ pub struct StepwiseItem { #[serde(rename_all = "camelCase")] pub struct StepwisePublicSettings { pub enabled: bool, + pub generation_mode: String, + pub answer_outline_enabled: bool, pub direct_send: bool, pub base_url_configured: bool, pub api_key_configured: bool, pub api_key_env: String, pub api_key_env_configured: bool, + pub protocol: String, pub model: String, pub max_items: u8, pub max_input_chars: u32, @@ -48,6 +87,8 @@ pub struct StepwisePublicSettings { pub fn public_settings(settings: &BackendSettings) -> StepwisePublicSettings { StepwisePublicSettings { enabled: settings.codex_app_stepwise_enabled, + generation_mode: settings.codex_app_stepwise_generation_mode.clone(), + answer_outline_enabled: settings.codex_app_answer_outline_enabled, direct_send: settings.codex_app_stepwise_direct_send, base_url_configured: !settings.codex_app_stepwise_base_url.trim().is_empty(), api_key_configured: !stepwise_api_key(settings).is_empty(), @@ -55,6 +96,7 @@ pub fn public_settings(settings: &BackendSettings) -> StepwisePublicSettings { api_key_env_configured: std::env::var(settings.codex_app_stepwise_api_key_env.trim()) .map(|value| !value.trim().is_empty()) .unwrap_or(false), + protocol: settings.codex_app_stepwise_protocol.clone(), model: settings.codex_app_stepwise_model.clone(), max_items: settings.codex_app_stepwise_max_items, max_input_chars: settings.codex_app_stepwise_max_input_chars, @@ -73,6 +115,19 @@ pub fn settings_with_payload(mut settings: BackendSettings, payload: &Value) -> { settings.codex_app_stepwise_enabled = value; } + if let Some(value) = raw_settings + .get("codexAppStepwiseGenerationMode") + .and_then(Value::as_str) + { + settings.codex_app_stepwise_generation_mode = + crate::settings::normalize_stepwise_generation_mode(value); + } + if let Some(value) = raw_settings + .get("codexAppAnswerOutlineEnabled") + .and_then(Value::as_bool) + { + settings.codex_app_answer_outline_enabled = value; + } if let Some(value) = raw_settings .get("codexAppStepwiseDirectSend") .and_then(Value::as_bool) @@ -101,6 +156,12 @@ pub fn settings_with_payload(mut settings: BackendSettings, payload: &Value) -> value.trim().to_string() }; } + if let Some(value) = raw_settings + .get("codexAppStepwiseProtocol") + .and_then(Value::as_str) + { + settings.codex_app_stepwise_protocol = crate::settings::normalize_stepwise_protocol(value); + } if let Some(value) = raw_settings .get("codexAppStepwiseModel") .and_then(Value::as_str) @@ -143,8 +204,15 @@ pub async fn generate( request: StepwiseRequest, settings: &BackendSettings, ) -> anyhow::Result { + let configured_protocol = + crate::settings::normalize_stepwise_protocol(&settings.codex_app_stepwise_protocol); if !settings.codex_app_stepwise_enabled { - return Ok(json!({ "status": "ok", "disabled": true, "items": [] })); + return Ok(json!({ + "status": "ok", + "disabled": true, + "protocol": configured_protocol, + "items": [] + })); } let base_url = settings @@ -156,64 +224,269 @@ pub async fn generate( let max_items = settings.codex_app_stepwise_max_items; if max_items == 0 { - return Ok(json!({ "status": "ok", "items": [] })); - } - if base_url.is_empty() || model.is_empty() { return Ok(json!({ - "status": "failed", - "items": [], - "error": "Stepwise Base URL or Model is not configured" + "status": "ok", + "protocol": configured_protocol, + "items": [] })); } + if base_url.is_empty() || model.is_empty() { + return Ok(failed_result( + &configured_protocol, + "Stepwise Base URL or Model is not configured", + )); + } if api_key.is_empty() { - return Ok(json!({ - "status": "failed", - "items": [], - "error": "Stepwise API Key is not configured" - })); + return Ok(failed_result( + &configured_protocol, + "Stepwise API Key is not configured", + )); } let client = crate::http_client::proxied_client("")?; let timeout = Duration::from_millis(settings.codex_app_stepwise_timeout_ms); + let protocols = stepwise_protocols(&configured_protocol); + let auto_protocol = configured_protocol == "auto"; + let mut protocol_errors = Vec::new(); + + for (index, protocol) in protocols.iter().copied().enumerate() { + let has_next_protocol = index + 1 < protocols.len(); + let upstream = + match build_upstream_request(protocol, base_url, &api_key, model, &request, settings) { + Ok(upstream) => upstream, + Err(error) => { + return Ok(failed_result( + protocol.as_str(), + format!( + "failed to build Stepwise {} request: {error}", + protocol.as_str() + ), + )); + } + }; + let response = match client + .post(&upstream.endpoint) + .headers(upstream.headers) + .timeout(timeout) + .json(&upstream.body) + .send() + .await + { + Ok(response) => response, + Err(error) => { + return Ok(failed_result( + protocol.as_str(), + format!( + "failed to request Stepwise {} API: {error}", + protocol.as_str() + ), + )); + } + }; + + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + if auto_protocol + && matches!( + status, + StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED + ) + { + protocol_errors.push(format!( + "{} returned upstream {}", + protocol.as_str(), + status.as_u16() + )); + if has_next_protocol { + continue; + } + break; + } + if !status.is_success() { + return Ok(failed_result( + protocol.as_str(), + format!( + "Stepwise upstream {}: {}", + status.as_u16(), + redact_secret(&text, &api_key) + ), + )); + } + + let data: Value = match serde_json::from_str(&text) { + Ok(data) => data, + Err(error) => { + if auto_protocol { + protocol_errors.push(format!( + "{} returned invalid JSON: {error}", + protocol.as_str() + )); + if has_next_protocol { + continue; + } + break; + } + return Ok(failed_result( + protocol.as_str(), + format!("failed to parse Stepwise API response: {error}"), + )); + } + }; + if auto_protocol && !matches_stepwise_protocol_response(protocol, &data) { + protocol_errors.push(format!( + "{} returned an incompatible response shape", + protocol.as_str() + )); + if has_next_protocol { + continue; + } + break; + } + return Ok(json!({ + "status": "ok", + "protocol": protocol.as_str(), + "items": extract_stepwise_items(&data, max_items) + })); + } + + let details = if protocol_errors.is_empty() { + String::new() + } else { + format!(": {}", protocol_errors.join("; ")) + }; + Ok(failed_result( + &configured_protocol, + format!("Stepwise could not find a supported upstream protocol{details}"), + )) +} + +fn stepwise_protocols(value: &str) -> Vec { + if value == "auto" { + vec![ + StepwiseProtocol::ChatCompletions, + StepwiseProtocol::Responses, + StepwiseProtocol::AnthropicMessages, + ] + } else { + vec![StepwiseProtocol::from_setting(value)] + } +} + +fn matches_stepwise_protocol_response(protocol: StepwiseProtocol, data: &Value) -> bool { + if stepwise_items_value(data).is_some() { + return true; + } + match protocol { + StepwiseProtocol::ChatCompletions => data.get("choices").is_some_and(Value::is_array), + StepwiseProtocol::Responses => { + data.get("output_text").is_some() || data.get("output").is_some_and(Value::is_array) + } + StepwiseProtocol::AnthropicMessages => data.get("content").is_some_and(Value::is_array), + } +} + +fn build_upstream_request( + protocol: StepwiseProtocol, + base_url: &str, + api_key: &str, + model: &str, + request: &StepwiseRequest, + settings: &BackendSettings, +) -> anyhow::Result { + let messages = build_messages(request, settings); let mut headers = HeaderMap::new(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + + let (endpoint, body) = match protocol { + StepwiseProtocol::ChatCompletions => { + insert_bearer_header(&mut headers, api_key)?; + ( + format!("{base_url}/chat/completions"), + json!({ + "model": model, + "messages": messages, + "temperature": 0.2, + "max_tokens": settings.codex_app_stepwise_max_output_tokens, + "response_format": { "type": "json_object" }, + }), + ) + } + StepwiseProtocol::Responses => { + insert_bearer_header(&mut headers, api_key)?; + ( + format!("{base_url}/responses"), + json!({ + "model": model, + "input": messages, + "max_output_tokens": settings.codex_app_stepwise_max_output_tokens, + }), + ) + } + StepwiseProtocol::AnthropicMessages => { + headers.insert( + HeaderName::from_static("x-api-key"), + HeaderValue::from_str(api_key) + .context("failed to build Stepwise API key header")?, + ); + headers.insert( + HeaderName::from_static("anthropic-version"), + HeaderValue::from_static(ANTHROPIC_VERSION), + ); + let system = messages + .first() + .and_then(|message| message.get("content")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let messages = messages + .into_iter() + .filter(|message| message.get("role").and_then(Value::as_str) != Some("system")) + .collect::>(); + ( + format!("{base_url}/messages"), + json!({ + "model": model, + "system": system, + "messages": messages, + "max_tokens": settings.codex_app_stepwise_max_output_tokens, + }), + ) + } + }; + + Ok(StepwiseUpstreamRequest { + endpoint, + headers, + body, + }) +} + +fn insert_bearer_header(headers: &mut HeaderMap, api_key: &str) -> anyhow::Result<()> { headers.insert( AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {api_key}")) .context("failed to build Stepwise authorization header")?, ); + Ok(()) +} - let response = client - .post(format!("{base_url}/chat/completions")) - .headers(headers) - .timeout(timeout) - .json(&json!({ - "model": model, - "messages": build_messages(&request, settings), - "temperature": 0.2, - "max_tokens": settings.codex_app_stepwise_max_output_tokens, - "response_format": { "type": "json_object" }, - })) - .send() - .await - .context("failed to request Stepwise API")?; - - let status = response.status(); - let text = response.text().await.unwrap_or_default(); - if !status.is_success() { - return Ok(json!({ - "status": "failed", - "items": [], - "error": format!("Stepwise upstream {}: {}", status.as_u16(), text.chars().take(240).collect::()) - })); - } +fn failed_result(protocol: &str, error: impl Into) -> Value { + json!({ + "status": "failed", + "protocol": protocol, + "items": [], + "error": error.into() + }) +} - let data: Value = - serde_json::from_str(&text).context("failed to parse Stepwise API response")?; - Ok(json!({ - "status": "ok", - "items": extract_stepwise_items(&data, max_items) - })) +fn redact_secret(value: &str, secret: &str) -> String { + let secret = secret.trim(); + let value = if secret.is_empty() { + value.to_string() + } else { + value.replace(secret, "[redacted]") + }; + value.chars().take(240).collect() } pub async fn test_connection(settings: &BackendSettings) -> anyhow::Result { @@ -236,22 +509,22 @@ pub fn build_messages(request: &StepwiseRequest, settings: &BackendSettings) -> &request.last_assistant_message, limit.saturating_mul(60) / 100, ); - let language_input = if last_user_message.trim().is_empty() { - last_assistant_message.clone() - } else { - last_user_message.clone() - }; let system_content = [ "You generate concise Codex Stepwise actions.", "Return strict JSON only, no markdown.", - "Schema: {\"items\":[{\"prompt\":\"...\",\"label\":\"optional short label\"}]}", + "Schema: {\"items\":[{\"label\":\"short action name\",\"summary\":\"one concise preview sentence\",\"prompt\":\"complete user message\"}]}", &format!( "Generate 1 to {} items when the assistant result is non-empty.", settings.codex_app_stepwise_max_items ), "Every prompt must be directly sendable by the user.", + "Keep label compact and summary within 72 characters when natural; prompt may be detailed and must not omit necessary context.", "Use the latest user intent and assistant result. Avoid generic filler.", - "Language policy: write Stepwise prompts in the dominant natural language of languageInput.", + "Order items by expected usefulness.", + "The first item must be the single most recommended next step for the user.", + "Prioritize unresolved user intent first, useful verification second, and optional improvements or exploration last.", + "Each item must represent a meaningfully different direction. Do not return duplicates, paraphrases, or near-duplicates.", + "Language policy: infer the dominant natural language from lastUserMessage, falling back to lastAssistantMessage, and write Stepwise prompts in that language.", "Ignore technical terms, file names, commands, APIs, and product names when detecting language; keep them in their original language when natural.", "Return {\"items\":[]} only when both the user intent and assistant result are empty or unusable.", ] @@ -266,7 +539,6 @@ pub fn build_messages(request: &StepwiseRequest, settings: &BackendSettings) -> "content": json!({ "lastUserMessage": last_user_message, "lastAssistantMessage": last_assistant_message, - "languageInput": language_input, "threadTitle": short_text(&request.thread_title, 240), "pageUrl": short_text(&request.page_url, 240), "maxItems": settings.codex_app_stepwise_max_items, @@ -287,17 +559,23 @@ pub fn clamp_items(value: Value, max_items: u8) -> Vec { let prompt = first_string_field(raw, &["prompt", "text", "action", "content", "message"]) .or_else(|| raw.as_str()) .unwrap_or(""); - let prompt = normalize_spaces(prompt); - if prompt.is_empty() || seen.contains(&prompt) { + let prompt = normalize_text(prompt); + let dedupe_key = normalize_spaces(&prompt); + if prompt.is_empty() || seen.contains(&dedupe_key) { continue; } - seen.insert(prompt.clone()); + seen.insert(dedupe_key); let label = first_string_field(raw, &["label", "title", "name"]) .map(normalize_spaces) .unwrap_or_default(); + let summary = first_string_field(raw, &["summary", "preview", "description"]) + .map(normalize_spaces) + .filter(|summary| !summary.is_empty()) + .unwrap_or_else(|| summary_for_prompt(&prompt)); items.push(StepwiseItem { - label: short_text(&label, 36), - prompt: short_text(&prompt, MAX_PROMPT_LENGTH), + label: leading_text(&label, MAX_LABEL_LENGTH), + summary: leading_text(&summary, MAX_SUMMARY_LENGTH), + prompt, }); if items.len() >= max_items { break; @@ -330,24 +608,57 @@ fn stepwise_payload_candidates(data: &Value) -> Vec { .and_then(|choice| choice.get("message")) .and_then(|message| message.get("content")) { - candidates.push(content.clone()); - if let Some(parsed) = parse_json_value(content) { - candidates.push(parsed); + if let Some(parts) = content.as_array() { + for part in parts { + if let Some(text) = part.get("text") { + push_payload_candidate(&mut candidates, text); + } + } + } else { + push_payload_candidate(&mut candidates, content); + } + } + + if let Some(output_text) = data.get("output_text") { + push_payload_candidate(&mut candidates, output_text); + } + + if let Some(output) = data.get("output").and_then(Value::as_array) { + for item in output { + if let Some(content) = item.get("content").and_then(Value::as_array) { + for part in content { + if let Some(text) = part.get("text") { + push_payload_candidate(&mut candidates, text); + } + } + } + } + } + + if let Some(content) = data.get("content").and_then(Value::as_array) { + for part in content { + if let Some(text) = part.get("text") { + push_payload_candidate(&mut candidates, text); + } } } for key in ["output", "response", "data", "result"] { if let Some(value) = data.get(key) { - candidates.push(value.clone()); - if let Some(parsed) = parse_json_value(value) { - candidates.push(parsed); - } + push_payload_candidate(&mut candidates, value); } } candidates } +fn push_payload_candidate(candidates: &mut Vec, value: &Value) { + candidates.push(value.clone()); + if let Some(parsed) = parse_json_value(value) { + candidates.push(parsed); + } +} + fn stepwise_items_value(value: &Value) -> Option { if value.as_array().is_some() { return Some(value.clone()); @@ -427,16 +738,58 @@ fn normalize_spaces(value: &str) -> String { value.split_whitespace().collect::>().join(" ") } +fn leading_text(value: &str, limit: usize) -> String { + let text = normalize_text(value); + if text.chars().count() <= limit { + return text; + } + let mut result = text + .chars() + .take(limit.saturating_sub(1)) + .collect::(); + result.push('…'); + result +} + +fn summary_for_prompt(prompt: &str) -> String { + leading_text(&normalize_spaces(prompt), MAX_SUMMARY_LENGTH) +} + #[cfg(test)] mod tests { use super::*; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + const TEST_API_KEY: &str = "sk-stepwise-test"; + + fn test_request() -> StepwiseRequest { + StepwiseRequest { + last_user_message: "请继续检查协议兼容性。".to_string(), + last_assistant_message: "已完成基础实现。".to_string(), + thread_title: "协议兼容测试".to_string(), + page_url: "https://example.test/thread".to_string(), + } + } + + fn test_settings(base_url: String, protocol: &str) -> BackendSettings { + BackendSettings { + codex_app_stepwise_enabled: true, + codex_app_stepwise_base_url: base_url, + codex_app_stepwise_api_key: TEST_API_KEY.to_string(), + codex_app_stepwise_protocol: protocol.to_string(), + codex_app_stepwise_model: "stepwise-test".to_string(), + codex_app_stepwise_timeout_ms: 2000, + ..BackendSettings::default() + } + } #[test] fn clamp_items_dedupes_and_limits() { let items = clamp_items( json!([ - {"label": "继续", "prompt": "继续排查"}, - {"label": "重复", "prompt": "继续排查"}, + {"label": "继续", "summary": "检查当前失败路径", "prompt": "继续排查\n并保留换行"}, + {"label": "重复", "prompt": "继续排查 并保留换行"}, {"prompt": "补测试"}, "更新文档" ]), @@ -445,10 +798,24 @@ mod tests { assert_eq!(items.len(), 2); assert_eq!(items[0].label, "继续"); - assert_eq!(items[0].prompt, "继续排查"); + assert_eq!(items[0].summary, "检查当前失败路径"); + assert_eq!(items[0].prompt, "继续排查\n并保留换行"); + assert_eq!(items[1].summary, "补测试"); assert_eq!(items[1].prompt, "补测试"); } + #[test] + fn clamp_items_keeps_long_prompt_and_backfills_summary() { + let long_prompt = format!("第一段\n\n{}", "完整上下文".repeat(120)); + let items = clamp_items(json!([{"prompt": long_prompt}]), 6); + + assert_eq!(items.len(), 1); + assert!(items[0].prompt.starts_with("第一段\n\n完整上下文")); + assert!(items[0].prompt.chars().count() > 420); + assert!(!items[0].summary.is_empty()); + assert!(items[0].summary.chars().count() <= MAX_SUMMARY_LENGTH); + } + #[test] fn extracts_items_from_common_stepwise_response_shapes() { let response = json!({ @@ -463,12 +830,32 @@ mod tests { assert_eq!(items.len(), 2); assert_eq!(items[0].label, "继续排查"); + assert_eq!(items[0].summary, "请继续检查 Stepwise 返回内容"); assert_eq!(items[0].prompt, "请继续检查 Stepwise 返回内容"); assert_eq!(items[1].prompt, "补一个解析测试"); } #[test] - fn prompt_contains_language_policy() { + fn extracts_items_from_chat_completions_text_blocks() { + let response = json!({ + "choices": [{ + "message": { + "content": [{ + "type": "text", + "text": "{\"items\":[{\"prompt\":\"解析文本块里的建议\"}]}" + }] + } + }] + }); + + let items = extract_stepwise_items(&response, 6); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].prompt, "解析文本块里的建议"); + } + + #[test] + fn prompt_infers_language_without_duplicate_input() { let settings = BackendSettings { codex_app_stepwise_max_items: 4, ..BackendSettings::default() @@ -484,11 +871,23 @@ mod tests { ); let system = messages[0].get("content").and_then(Value::as_str).unwrap(); let user = messages[1].get("content").and_then(Value::as_str).unwrap(); + let user_payload: Value = serde_json::from_str(user).unwrap(); assert!(system.contains("dominant natural language")); + assert!(system.contains("lastUserMessage")); + assert!(system.contains("falling back to lastAssistantMessage")); assert!(system.contains("Generate 1 to 4 items when the assistant result is non-empty.")); - assert!(user.contains("directSend")); - assert!(user.contains("languageInput")); + assert!(system.contains("summary within 72 characters")); + assert!(system.contains("prompt may be detailed")); + assert!(system.contains("Order items by expected usefulness.")); + assert!(system.contains("The first item must be the single most recommended next step")); + assert!(system.contains("Do not return duplicates, paraphrases, or near-duplicates.")); + assert_eq!( + user_payload["lastUserMessage"], + "请补一个 directSend selftest,覆盖 ProseMirror。" + ); + assert_eq!(user_payload["lastAssistantMessage"], "已完成实现。"); + assert!(user_payload.get("languageInput").is_none()); } #[test] @@ -498,10 +897,13 @@ mod tests { &json!({ "settings": { "codexAppStepwiseEnabled": true, + "codexAppStepwiseGenerationMode": "manual", + "codexAppAnswerOutlineEnabled": false, "codexAppStepwiseDirectSend": true, "codexAppStepwiseBaseUrl": "https://api.example.test/v1/", "codexAppStepwiseApiKey": " sk-test ", "codexAppStepwiseApiKeyEnv": "", + "codexAppStepwiseProtocol": "responses", "codexAppStepwiseModel": " stepwise-mini ", "codexAppStepwiseMaxItems": 9, "codexAppStepwiseMaxInputChars": 999999, @@ -512,6 +914,8 @@ mod tests { ); assert!(settings.codex_app_stepwise_enabled); + assert_eq!(settings.codex_app_stepwise_generation_mode, "manual"); + assert!(!settings.codex_app_answer_outline_enabled); assert!(settings.codex_app_stepwise_direct_send); assert_eq!( settings.codex_app_stepwise_base_url, @@ -522,10 +926,274 @@ mod tests { settings.codex_app_stepwise_api_key_env, crate::settings::default_stepwise_api_key_env() ); + assert_eq!(settings.codex_app_stepwise_protocol, "responses"); assert_eq!(settings.codex_app_stepwise_model, "stepwise-mini"); assert_eq!(settings.codex_app_stepwise_max_items, 6); assert_eq!(settings.codex_app_stepwise_max_input_chars, 24000); assert_eq!(settings.codex_app_stepwise_max_output_tokens, 100); assert_eq!(settings.codex_app_stepwise_timeout_ms, 60000); } + + #[tokio::test] + async fn generate_uses_chat_completions_protocol_and_parses_response() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "choices": [{ + "message": { + "content": "{\"items\":[{\"label\":\"继续\",\"prompt\":\"继续检查\"}]}" + } + }] + }))) + .mount(&server) + .await; + + let result = generate( + test_request(), + &test_settings(server.uri(), "chat_completions"), + ) + .await + .unwrap(); + + assert_eq!(result["status"], "ok"); + assert_eq!(result["protocol"], "chat_completions"); + assert_eq!(result["items"][0]["label"], "继续"); + assert_eq!(result["items"][0]["prompt"], "继续检查"); + + let requests = server.received_requests().await.unwrap(); + let request = &requests[0]; + assert_eq!( + request + .headers + .get("authorization") + .and_then(|value| value.to_str().ok()), + Some("Bearer sk-stepwise-test") + ); + let body: Value = request.body_json().unwrap(); + assert_eq!(body["model"], "stepwise-test"); + assert_eq!(body["response_format"]["type"], "json_object"); + assert!(body["messages"].is_array()); + } + + #[tokio::test] + async fn generate_uses_responses_protocol_and_parses_output_text() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/responses")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "output_text": "{\"items\":[{\"prompt\":\"检查 Responses 接口\"}]}" + }))) + .mount(&server) + .await; + + let result = generate(test_request(), &test_settings(server.uri(), "responses")) + .await + .unwrap(); + + assert_eq!(result["status"], "ok"); + assert_eq!(result["protocol"], "responses"); + assert_eq!(result["items"][0]["prompt"], "检查 Responses 接口"); + + let requests = server.received_requests().await.unwrap(); + let request = &requests[0]; + assert_eq!( + request + .headers + .get("authorization") + .and_then(|value| value.to_str().ok()), + Some("Bearer sk-stepwise-test") + ); + let body: Value = request.body_json().unwrap(); + assert_eq!(body["model"], "stepwise-test"); + assert!(body["input"].is_array()); + assert_eq!(body["max_output_tokens"], 500); + } + + #[tokio::test] + async fn generate_uses_anthropic_messages_protocol_and_parses_content() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/messages")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "content": [{ + "type": "text", + "text": "{\"items\":[{\"prompt\":\"检查 Anthropic Messages 接口\"}]}" + }] + }))) + .mount(&server) + .await; + + let result = generate( + test_request(), + &test_settings(server.uri(), "anthropic_messages"), + ) + .await + .unwrap(); + + assert_eq!(result["status"], "ok"); + assert_eq!(result["protocol"], "anthropic_messages"); + assert_eq!(result["items"][0]["prompt"], "检查 Anthropic Messages 接口"); + + let requests = server.received_requests().await.unwrap(); + let request = &requests[0]; + assert_eq!( + request + .headers + .get("x-api-key") + .and_then(|value| value.to_str().ok()), + Some(TEST_API_KEY) + ); + assert_eq!( + request + .headers + .get("anthropic-version") + .and_then(|value| value.to_str().ok()), + Some("2023-06-01") + ); + assert!(request.headers.get("authorization").is_none()); + let body: Value = request.body_json().unwrap(); + assert_eq!(body["model"], "stepwise-test"); + assert!( + body["system"] + .as_str() + .is_some_and(|value| value.contains("strict JSON")) + ); + assert!(body["messages"].is_array()); + assert_eq!(body["max_tokens"], 500); + } + + #[tokio::test] + async fn auto_protocol_falls_back_on_unsupported_endpoint_statuses() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/responses")) + .respond_with(ResponseTemplate::new(405)) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/messages")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "content": [{ + "type": "text", + "text": "{\"items\":[{\"prompt\":\"自动兼容成功\"}]}" + }] + }))) + .mount(&server) + .await; + + let result = generate(test_request(), &test_settings(server.uri(), "auto")) + .await + .unwrap(); + + assert_eq!(result["status"], "ok"); + assert_eq!(result["protocol"], "anthropic_messages"); + assert_eq!(result["items"][0]["prompt"], "自动兼容成功"); + + let requests = server.received_requests().await.unwrap(); + let paths = requests + .iter() + .map(|request| request.url.path()) + .collect::>(); + assert_eq!(paths, vec!["/chat/completions", "/responses", "/messages"]); + } + + #[tokio::test] + async fn auto_protocol_falls_back_on_success_with_empty_body() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_string("")) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/responses")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "output_text": "{\"items\":[{\"prompt\":\"Responses 回退成功\"}]}" + }))) + .mount(&server) + .await; + + let result = generate(test_request(), &test_settings(server.uri(), "auto")) + .await + .unwrap(); + + assert_eq!(result["status"], "ok"); + assert_eq!(result["protocol"], "responses"); + assert_eq!(result["items"][0]["prompt"], "Responses 回退成功"); + + let requests = server.received_requests().await.unwrap(); + let paths = requests + .iter() + .map(|request| request.url.path()) + .collect::>(); + assert_eq!(paths, vec!["/chat/completions", "/responses"]); + } + + #[tokio::test] + async fn auto_protocol_falls_back_on_incompatible_response_shape() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "unexpected": true + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/responses")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "output_text": "{\"items\":[{\"prompt\":\"协议结构回退成功\"}]}" + }))) + .mount(&server) + .await; + + let result = generate(test_request(), &test_settings(server.uri(), "auto")) + .await + .unwrap(); + + assert_eq!(result["status"], "ok"); + assert_eq!(result["protocol"], "responses"); + assert_eq!(result["items"][0]["prompt"], "协议结构回退成功"); + assert_eq!(server.received_requests().await.unwrap().len(), 2); + } + + #[tokio::test] + async fn auto_protocol_does_not_fallback_on_auth_rate_limit_or_server_errors() { + for status in [401, 403, 429, 500] { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with( + ResponseTemplate::new(status) + .set_body_string(format!("upstream rejected {TEST_API_KEY}")), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/responses")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "output_text": "{\"items\":[{\"prompt\":\"不应被调用\"}]}" + }))) + .mount(&server) + .await; + + let result = generate(test_request(), &test_settings(server.uri(), "auto")) + .await + .unwrap(); + + assert_eq!(result["status"], "failed"); + assert_eq!(result["protocol"], "chat_completions"); + let error = result["error"].as_str().unwrap(); + assert!(error.contains(&status.to_string())); + assert!(!error.contains(TEST_API_KEY)); + assert!(error.contains("[redacted]")); + assert_eq!(server.received_requests().await.unwrap().len(), 1); + } + } } diff --git a/crates/codex-plus-core/tests/bridge_routes.rs b/crates/codex-plus-core/tests/bridge_routes.rs index 22d2ec5a0..87a4ccb9f 100644 --- a/crates/codex-plus-core/tests/bridge_routes.rs +++ b/crates/codex-plus-core/tests/bridge_routes.rs @@ -310,6 +310,7 @@ async fn upstream_worktree_routes_are_dispatched_to_runtime() { async fn stepwise_routes_use_settings_service() { let settings = BackendSettings { codex_app_stepwise_enabled: false, + codex_app_stepwise_generation_mode: "manual".to_string(), codex_app_stepwise_direct_send: true, codex_app_stepwise_model: "settings-service-stepwise".to_string(), codex_app_stepwise_max_items: 3, @@ -323,6 +324,14 @@ async fn stepwise_routes_use_settings_service() { let public_settings = handle_bridge_request(ctx.clone(), "/stepwise/settings", json!({})).await; assert_eq!(public_settings["settings"]["enabled"], json!(false)); + assert_eq!( + public_settings["settings"]["generationMode"], + json!("manual") + ); + assert_eq!( + public_settings["settings"]["answerOutlineEnabled"], + json!(true) + ); assert_eq!(public_settings["settings"]["directSend"], json!(true)); assert_eq!( public_settings["settings"]["model"], @@ -339,6 +348,7 @@ async fn stepwise_routes_use_settings_service() { json!({ "status": "ok", "disabled": true, + "protocol": "chat_completions", "items": [] }) ); @@ -347,6 +357,7 @@ async fn stepwise_routes_use_settings_service() { json!({ "status": "ok", "disabled": true, + "protocol": "chat_completions", "items": [] }) ); From 12256c84e71a2bc36053ec4b6d5abe78160cf6cd Mon Sep 17 00:00:00 2001 From: Ghibli1024 Date: Thu, 13 Aug 2026 00:46:57 +0800 Subject: [PATCH 3/6] feat(stepwise): add Answer Outline and redesign the floating panel --- assets/inject/renderer-inject.js | 25 +- assets/inject/stepwise-inject.js | 7543 ++++++++++++++++++-- crates/codex-plus-core/tests/cdp_bridge.rs | 876 ++- 3 files changed, 7703 insertions(+), 741 deletions(-) diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index 2cfec7715..c1445515c 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -1293,7 +1293,7 @@ } function defaultCodexPlusSettings() { - return { pluginMarketplaceUnlock: true, modelWhitelistUnlock: true, sessionDelete: true, markdownExport: true, pasteFix: false, projectMove: true, threadIdBadge: false, conversationView: false, conversationViewMaxWidth: conversationViewDefaultWidth, threadScrollRestore: true, zedRemoteOpen: true, upstreamWorktreeCreate: true, nativeMenuPlacement: true, serviceTierControls: false, petRealMouseLook: false, stepwise: false, dreamSkinEnabled: false, dreamSkinPaused: false, dreamSkinThemeConfig: window.__CODEX_PLUS_DREAM_SKIN_THEME__ || {}, dreamSkinImagePath: "" }; + return { pluginMarketplaceUnlock: true, modelWhitelistUnlock: true, sessionDelete: true, markdownExport: true, pasteFix: false, projectMove: true, threadIdBadge: false, conversationView: false, conversationViewMaxWidth: conversationViewDefaultWidth, threadScrollRestore: true, zedRemoteOpen: true, upstreamWorktreeCreate: true, nativeMenuPlacement: true, serviceTierControls: false, petRealMouseLook: false, stepwise: false, answerOutline: true, dreamSkinEnabled: false, dreamSkinPaused: false, dreamSkinThemeConfig: window.__CODEX_PLUS_DREAM_SKIN_THEME__ || {}, dreamSkinImagePath: "" }; } const codexPlusBackendSettingMap = { @@ -1311,6 +1311,7 @@ serviceTierControls: "codexAppServiceTierControls", petRealMouseLook: "codexAppPetRealMouseLook", stepwise: "codexAppStepwiseEnabled", + answerOutline: "codexAppAnswerOutlineEnabled", pasteFix: "codexAppPasteFix", dreamSkinEnabled: "codexAppDreamSkinEnabled", dreamSkinPaused: "codexAppDreamSkinPaused", @@ -1350,6 +1351,7 @@ serviceTierControls: false, petRealMouseLook: false, stepwise: false, + answerOutline: false, dreamSkinEnabled: false, dreamSkinPaused: false, dreamSkinThemeConfig: window.__CODEX_PLUS_DREAM_SKIN_THEME__ || {}, @@ -2116,9 +2118,10 @@ const backendKey = codexPlusBackendSettingMap[key]; if (backendKey) { if (key === "stepwise") syncStepwisePanel(value); + if (key === "answerOutline") syncStepwisePanel(undefined, value); void setBackendSetting(backendKey, value).then(() => { - if (key === "stepwise") { - Promise.resolve(window.__codexStepwisePanel?.loadSettings?.()).then(() => syncStepwisePanel(value)); + if (key === "stepwise" || key === "answerOutline") { + Promise.resolve(window.__codexStepwisePanel?.loadSettings?.()).then(() => syncStepwisePanel()); } }).catch(() => { void loadBackendSettings(); @@ -2157,9 +2160,15 @@ scan(); } - function syncStepwisePanel(enabled = codexPlusSettings().stepwise) { + function syncStepwisePanel( + enabled = codexPlusSettings().stepwise, + answerOutlineEnabled = codexPlusSettings().answerOutline + ) { try { - window.__codexStepwisePanel?.syncSettings?.({ enabled: !!enabled }); + window.__codexStepwisePanel?.syncSettings?.({ + enabled: !!enabled, + answerOutlineEnabled: !!answerOutlineEnabled, + }); } catch (error) { sendCodexPlusDiagnostic("stepwise_sync_failed", { errorName: error?.name || "", @@ -3864,9 +3873,13 @@ ` : ""}
-
Stepwise
在当前 Codex 页面显示可拖动的下一步建议浮层,可在设置页配置模型和直接发送。
+
悬浮球 · Stepwise
生成下一步建议。
+
+
悬浮球 · 回答大纲
整理回答结构。
+ +
服务模式
继承优先读取 Codex 应用内设置,其次读取 config.toml 的 service_tier;全局模式覆盖全部 thread;自定义允许按 thread 覆盖。
diff --git a/assets/inject/stepwise-inject.js b/assets/inject/stepwise-inject.js index 0b4e0015a..81aa4d707 100644 --- a/assets/inject/stepwise-inject.js +++ b/assets/inject/stepwise-inject.js @@ -1,67 +1,477 @@ (() => { "use strict"; + /* + * Stepwise is a self-contained runtime injected into ChatGPT's renderer. + * It owns the floating shell, Stepwise suggestions, and Answer Outline; + * the Manager only supplies settings and the page bridge supplies requests. + * + * The important invariants are: + * - only one live instance, root, style element, and observer may exist; + * - Stepwise and Outline can be enabled independently; + * - passive page scrolling never changes the pinned answer context; + * - asynchronous results must match the answer, request, feature epoch, + * and runtime generation that created them; + * - every view or shell transition must settle, cancel, or time out cleanly. + */ + + // Runtime identity, DOM markers, storage keys, and stable UI dimensions. const API_KEY = "__codexStepwisePanel"; const STYLE_ID = "codex-stepwise-panel-style"; + const CLEAR_FILTER_ID = "codex-stepwise-clear-distortion"; + const LIQUID_FILTER_ID = "codex-stepwise-liquid-distortion"; + const CRYSTAL_FILTER_ID = "codex-stepwise-crystal-distortion"; const ROOT_ATTR = "data-codex-stepwise-root"; const PAYLOAD_ATTR = "data-codex-stepwise-payload"; - const SCRIPT_VERSION = "1.0.0-core"; + const MARK_ATTR = "data-codex-stepwise-outline-id"; + const HIGHLIGHT_CLASS = "codex-stepwise-outline-target-flash"; + const SCRIPT_VERSION = "2.0.0"; const PAGE_BRIDGE = "__codexSessionDeleteBridge"; - const POSITION_KEY = "codex-stepwise-float-position-v1"; + const CONVERSATION_TURN_SELECTOR = "div.contents[data-content-search-turn-key]"; + const POPOVER_ID = "codex-stepwise-popover"; + const POSITION_KEY = "codex-stepwise-float-position-v2"; + const WIDTH_KEY = "codex-stepwise-panel-width-v1"; + const HEIGHT_KEY = "codex-stepwise-panel-height-v1"; + const FONT_KEY = "codex-stepwise-font-v1"; + const FONT_OFFSET_KEY = "codex-stepwise-font-offset-v1"; + const LEGACY_MATERIAL_KEY = "codex-stepwise-material-v1"; + const PREVIOUS_MATERIAL_KEY = "codex-stepwise-material-v2"; + const MATERIAL_KEY = "codex-stepwise-material-v3"; + const MATERIAL_ORIGIN_KEY = "codex-stepwise-material-v3-origin"; + const MATERIAL_MIGRATION_KEY = "codex-stepwise-material-v3-migrated"; + const LABEL_ONLY_KEY = "codex-stepwise-label-only-v1"; + const PROMPT_CLICK_MODE_KEY = "codex-stepwise-prompt-click-mode-v1"; + const PROMPT_CLICK_MODES = ["direct", "hybrid", "fill"]; + const DEFAULT_PROMPT_CLICK_MODE = "hybrid"; + const GENERATION_MODES = ["auto", "manual"]; + const MATERIAL_MODES = ["frosted", "clear", "liquid", "crystal", "matte"]; + const DEFAULT_MATERIAL = "frosted"; + const LEGACY_MATERIAL_MODES = Object.freeze({ + glass: "frosted", + liquid: "clear", + liquid2: "liquid", + solid: "matte", + opaque: "matte", + }); + const LEGACY_OUTLINE_FONT_KEY = "codex-answer-outline-font"; + const LEGACY_OUTLINE_FONT_OFFSET_KEY = "codex-answer-outline-font-offset"; const DIAGNOSTICS_KEY = "codex-stepwise-diagnostics-v1"; const SCAN_DELAY_MS = 220; const STREAM_IDLE_MS = 1300; + const NEW_ANSWER_EXPRESSION_MS = 700; const BRIDGE_TIMEOUT_MS = 26000; + const SETTINGS_SYNC_INTERVAL_MS = 2000; + const FLASH_MS = 1200; + const COMPLETION_BEAM_MS = 1600; + const MIN_OUTLINE_TEXT_LEN = 280; + const MIN_OUTLINE_ITEMS = 2; + const MAX_OUTLINE_ITEMS = 24; + const MAX_OUTLINE_TITLE_LEN = 56; + const MIN_OUTLINE_TITLE_LEN = 2; + const OUTLINE_SEMANTIC_HEADING_SELECTOR = "h1,h2,h3,h4,h5,h6,[role='heading']"; + const OUTLINE_PSEUDO_HEADING_SELECTOR = "p,div,li,strong,b"; + const OUTLINE_TABLE_SELECTOR = [ + "table", + "thead", + "tbody", + "tfoot", + "tr", + "td", + "th", + "[role='table']", + "[role='row']", + "[role='cell']", + "[role='columnheader']", + "[role='rowheader']", + ].join(","); + const OUTLINE_PSEUDO_MIN_SCORE = 24; + const CHIP_WIDTH = 84; + const CHIP_HEIGHT = 46; + const CHIP_RADIUS = 23; + const PANEL_WIDTH = 404; + const PANEL_HEIGHT = 420; + const SETTINGS_PANEL_HEIGHT = 376; + const PANEL_MIN_WIDTH = 300; + const PANEL_MAX_WIDTH = 640; + const PANEL_MIN_HEIGHT = 340; + const PANEL_MAX_HEIGHT = 720; + const PANEL_RADIUS = 25; + const PANEL_SAFE_MARGIN = 12; + const RIGHT_EDGE_SNAP_DISTANCE = 36; + const DEFAULT_FONT = 13; + const MIN_FONT = 10; + const MAX_FONT = 24; + const HOST_FONT_SIZE_FALLBACK = 15; + const HOST_FONT_SIZE_MIN = 12; + const HOST_FONT_SIZE_MAX = 22; + const ITEM_FONT_RATIO = 13 / 15; + const CHROME_FONT_RATIO = 12 / 15; + const ICON_FONT_RATIO = 16 / 15; + const HOST_FONT_FAMILY_FALLBACK = '-apple-system, "system-ui", "Segoe UI", sans-serif'; + const MIN_MORPH_MS = 840; + const MAX_MORPH_MS = 1450; + const MIN_PHASE_MS = 420; + const MIN_REVERSE_MS = 120; + const MORPH_FALLBACK_BUFFER_MS = 180; + const HORIZONTAL_PHASE = 0.5; + const MORPH_EDGE_SPEED = 0.18; + const UNFOLD_SAMPLES = 28; + const VIEW_SLIDE_MS = 180; + const VIEW_SLIDE_DISTANCE = 12; + const VIEW_INDICATOR_MS = 150; + const VIEW_ORDER = ["next", "outline", "settings"]; + const EYE_MAX_X = 4; + const EYE_MAX_Y = 3; + const CURIOUS_EYE_MAX_X = 3; + const CURIOUS_EYE_MAX_Y = 2.5; const MAX_TEXT_LENGTH = 12000; + const DEFAULT_STEPWISE_ITEMS = 4; const MAX_STEPWISE_ITEMS = 6; - const MAX_PROMPT_LENGTH = 420; + const MAX_PROMPT_SUMMARY_LENGTH = 72; const MAX_DIAGNOSTICS = 80; const EDITABLE_SUBMIT_DELAY_MS = 120; + const PROMPT_PREVIEW_SWITCH_MS = 320; + const PROMPT_CLICK_DELAY_MS = 230; const SUBMIT_RETRY_DELAY_MS = 50; - const SUBMIT_RETRY_LIMIT = 600; + const SUBMIT_RETRY_LIMIT = 80; + const FRIENDLY_BRIDGE_ERRORS = [ + { + pattern: /回答生成中/i, + title: "回答尚未完成,完成后再试", + message: "", + }, + { + pattern: /未找到可用于生成的回答/i, + title: "回答尚未完成,完成后再试", + message: "", + }, + { + pattern: /\b429\b|too many pending|rate[_ -]?limit/i, + title: "请求较多,稍后再试", + message: "", + }, + { + pattern: /timeout|timed out|超时/i, + title: "响应较慢,稍后再试", + message: "", + }, + { + pattern: /\b401\b|\b403\b|unauthori[sz]ed|forbidden|api.?key|鉴权|认证/i, + title: "连接异常,检查模型与配置", + message: "", + }, + { + pattern: /econnrefused|failed to fetch|network|connection|连接失败|无法连接/i, + title: "暂时无法连接,检查服务后重试", + message: "", + }, + { + pattern: /\b5\d{2}\b|upstream/i, + title: "服务暂时不可用,稍后重试", + message: "", + }, + ]; const INSTANCE_ID = `${SCRIPT_VERSION}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; let codexAppActionsPromise = null; let settingsPromise = null; let startupPromise = null; + let settingsRequestId = 0; + let settingsSyncEpoch = 0; + let pendingSettingsPatch = {}; + // Re-injection replaces stale instances instead of layering another UI on top. const previous = window[API_KEY]; + const previousRuntimeHealthy = previous?.state?.runtimeActive === true + && previous?.state?.settingsLoaded === true + && document.readyState !== "loading" + && previous?.state?.root?.isConnected === true + && previous?.state?.popover?.isConnected === true + && Boolean(previous?.state?.observer) + && document.querySelectorAll?.(`[${ROOT_ATTR}="true"]`).length === 1 + && document.querySelectorAll?.(`#${STYLE_ID}`).length === 1; + if (previous?.version === SCRIPT_VERSION + && previous?.state?.destroyed !== true + && previousRuntimeHealthy) { + previous.syncSettings?.(); + previous.start?.(); + return; + } if (previous && typeof previous.destroy === "function") previous.destroy(); document.querySelectorAll?.(`[${ROOT_ATTR}="true"]`).forEach((node) => node.remove()); document.getElementById(STYLE_ID)?.remove(); + const storage = { + get(key) { + try { + return localStorage.getItem(key); + } catch { + return null; + } + }, + set(key, value) { + try { + localStorage.setItem(key, value); + } catch {} + }, + remove(key) { + try { + localStorage.removeItem(key); + } catch {} + }, + }; + + function normalizePromptClickMode(value) { + return PROMPT_CLICK_MODES.includes(value) ? value : DEFAULT_PROMPT_CLICK_MODE; + } + + function readPromptClickMode() { + const stored = storage.get(PROMPT_CLICK_MODE_KEY); + if (PROMPT_CLICK_MODES.includes(stored)) return stored; + storage.set(PROMPT_CLICK_MODE_KEY, DEFAULT_PROMPT_CLICK_MODE); + return DEFAULT_PROMPT_CLICK_MODE; + } + + // All mutable runtime state lives here so cleanup can invalidate one generation. const state = { observer: null, themeObserver: null, + typographyObserver: null, + promptPreviewTimer: 0, + promptClickTimer: 0, + promptPreviewIndex: 0, timer: 0, + expressionTimer: 0, + keepAliveTimer: 0, + flashTimer: 0, + completionBeamTimer: 0, + snapTimer: 0, + materialAnimTimer: 0, + viewAnimation: null, + viewIndicatorFrame: 0, + viewTransitioning: false, + pendingTab: "", + pendingRender: false, root: null, fab: null, popover: null, + glass: null, + rim: null, + completionBeam: null, + clearFilter: null, + clearDisplacement: null, + clearDistortion: null, + liquidFilter: null, + crystalFilter: null, + displacementTexture: null, + panel: null, + contentFadeCleanup: null, open: false, + morphAnimation: null, + rimMorphAnimation: null, + displacementMorphAnimation: null, + panelMorphAnimation: null, + fabMorphAnimation: null, + morphTransition: null, + morphGeneration: 0, + layout: null, + focusAfterMorph: "", activeTab: "next", + returnTab: "next", position: null, + width: readPanelWidth(), + height: readPanelHeight(), + hostTypography: fallbackHostTypography(), + fontOffset: readFontOffset(), + material: readMaterial(), + labelOnly: storage.get(LABEL_ONLY_KEY) === "true", + promptClickMode: readPromptClickMode(), drag: null, + dragCleanup: null, + resizeDrag: null, + resizeCleanup: null, + suppressFabClick: false, + suppressHeadFaceClick: false, + eyePointer: null, + eyeRaf: 0, + eyeCleanup: null, + sourceCueAngle: null, + sourceCueAnimation: 0, lastAssistantHash: "", lastAssistantAt: 0, currentHash: "", + scanStatus: "idle", + scanBusy: false, lastScanStatus: "", bridgeCache: new Map(), + bridgeActiveKey: "", bridgePendingHash: "", + bridgePendingRequestId: 0, + bridgePendingMode: "auto", + bridgeRequestSequence: 0, bridgeStatus: "idle", bridgeError: "", prompts: [], + promptContext: null, + outlineItems: [], + outlineStatus: "idle", + outlineError: "", + outlineFingerprint: "", + outlineSourceHash: "", + outlineRefreshPromise: null, + outlineMessage: null, settings: null, + settingsLoaded: false, + settingsFingerprint: "", + settingsSyncTimer: 0, settingsStatus: "", + surpriseUntil: 0, + fabExpression: "idle", theme: "dark", themeMode: "auto", + pinnedThreadRoot: null, + pinnedThreadAt: 0, + pinnedPaneKey: "", + pinnedSessionId: "", + latestTurnAnchor: null, + threadActivity: new WeakMap(), + nodeKeySeq: 0, + nodeKeys: new WeakMap(), + activeContext: { + paneRoot: null, + paneKey: "", + sessionId: "", + assistantMessageId: "", + generation: 0, + }, + focusHandler: null, + pointerHandler: null, + selectionHandler: null, + scrollHandler: null, + keyHandler: null, scans: 0, + runtimeGeneration: 0, + runtimeActive: false, + stepwiseEpoch: 0, + outlineEpoch: 0, + domReadyHandler: null, destroyed: false, diagnostics: readDiagnostics(), }; + // Runtime gates and feature epochs make stale callbacks harmless after re-injection or disablement. function isCurrentInstance() { return !state.destroyed && window[API_KEY]?.instanceId === INSTANCE_ID; } + function isCurrentRuntime(generation = state.runtimeGeneration) { + return isCurrentInstance() + && state.runtimeActive + && generation === state.runtimeGeneration; + } + + function stepwiseEnabled(settings = state.settings) { + return settings?.enabled === true; + } + + function normalizeGenerationMode(value) { + return value === "manual" ? "manual" : "auto"; + } + + function stepwiseGenerationMode(settings = state.settings) { + return normalizeGenerationMode(settings?.generationMode); + } + + function outlineEnabled(settings = state.settings) { + return settings?.answerOutlineEnabled === true; + } + + function runtimeEnabled(settings = state.settings) { + return stepwiseEnabled(settings) || outlineEnabled(settings); + } + + function configuredMaxPromptItems(settings = state.settings) { + const value = Number(settings?.maxItems); + if (!Number.isFinite(value)) return DEFAULT_STEPWISE_ITEMS; + return clamp(Math.floor(value), 1, MAX_STEPWISE_ITEMS); + } + + function normalizeActiveTab(tab = state.activeTab) { + if (tab === "settings") return "settings"; + if (tab === "next" && stepwiseEnabled()) return "next"; + if (tab === "outline" && outlineEnabled()) return "outline"; + if (stepwiseEnabled()) return "next"; + if (outlineEnabled()) return "outline"; + return "next"; + } + + function resetStepwiseFeature() { + state.stepwiseEpoch += 1; + clearPromptInteractionTimers(); + state.promptPreviewIndex = 0; + state.bridgeActiveKey = ""; + state.bridgePendingHash = ""; + state.bridgePendingRequestId = 0; + state.bridgePendingMode = stepwiseGenerationMode(); + state.bridgeStatus = "idle"; + state.bridgeError = ""; + state.bridgeCache.clear(); + state.prompts = []; + state.promptContext = null; + state.currentHash = ""; + clearStepwisePayloadMarks(); + } + + function invalidateStepwiseRequest(status = stepwiseGenerationMode() === "manual" ? "manual-ready" : "idle") { + state.stepwiseEpoch += 1; + state.bridgeActiveKey = ""; + state.bridgePendingHash = ""; + state.bridgePendingRequestId = 0; + state.bridgePendingMode = stepwiseGenerationMode(); + state.bridgeStatus = status; + state.bridgeError = ""; + } + + function resetOutlineFeature() { + state.outlineEpoch += 1; + outlineClearMarks(); + state.outlineItems = []; + state.outlineRefreshPromise = null; + state.outlineMessage = null; + state.outlineSourceHash = ""; + state.outlineFingerprint = ""; + state.outlineStatus = "idle"; + state.outlineError = ""; + } + + function applyRuntimeSettings(nextSettings) { + const hadStepwise = stepwiseEnabled(); + const hadOutline = outlineEnabled(); + const previousGenerationMode = stepwiseGenerationMode(); + state.settings = nextSettings; + state.settingsFingerprint = settingsFingerprint(nextSettings); + if (hadStepwise && !stepwiseEnabled()) resetStepwiseFeature(); + if (hadOutline && !outlineEnabled()) resetOutlineFeature(); + if (hadStepwise && stepwiseEnabled() && previousGenerationMode !== stepwiseGenerationMode()) { + invalidateStepwiseRequest(); + state.prompts = []; + state.promptContext = null; + state.promptPreviewIndex = 0; + state.currentHash = ""; + } + state.activeTab = normalizeActiveTab(); + return state.settings; + } + + function settingsFingerprint(settings) { + if (!settings || typeof settings !== "object") return ""; + return JSON.stringify( + Object.keys(settings) + .sort() + .map((key) => [key, settings[key]]), + ); + } + + // Shared text and numeric helpers keep DOM extraction and persisted values bounded. function normalizeText(value) { return String(value || "") .replace(/\u00a0/g, " ") @@ -90,6 +500,320 @@ return Math.min(max, Math.max(min, value)); } + function roundPixel(value) { + return Math.round(Number(value) * 100) / 100; + } + + function clampPanelWidth(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return PANEL_WIDTH; + return Math.round(clamp(parsed, PANEL_MIN_WIDTH, PANEL_MAX_WIDTH)); + } + + function readPanelWidth() { + const raw = storage.get(WIDTH_KEY); + return raw == null || raw === "" ? PANEL_WIDTH : clampPanelWidth(raw); + } + + function panelHeightCap() { + const viewportCap = Math.max( + PANEL_MIN_HEIGHT, + Math.floor((window.innerHeight || PANEL_MAX_HEIGHT) - PANEL_SAFE_MARGIN * 2) + ); + return Math.min(PANEL_MAX_HEIGHT, viewportCap); + } + + function clampPanelHeight(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return Math.min(PANEL_HEIGHT, panelHeightCap()); + return Math.round(clamp(parsed, PANEL_MIN_HEIGHT, panelHeightCap())); + } + + function readPanelHeight() { + const raw = storage.get(HEIGHT_KEY); + return raw == null || raw === "" ? clampPanelHeight(PANEL_HEIGHT) : clampPanelHeight(raw); + } + + function clampFontSize(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return DEFAULT_FONT; + return Math.round(clamp(parsed, MIN_FONT, MAX_FONT)); + } + + function clampFontOffset(value, baseItemFontSize = DEFAULT_FONT) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + const parsedBase = Number(baseItemFontSize); + const base = Number.isFinite(parsedBase) ? parsedBase : DEFAULT_FONT; + return roundPixel(clamp(parsed, MIN_FONT - base, MAX_FONT - base)); + } + + function readFontOffset() { + const storedOffset = storage.get(FONT_OFFSET_KEY); + if (storedOffset != null && storedOffset !== "" && Number.isFinite(Number(storedOffset))) { + return clampFontOffset(storedOffset); + } + + const legacyStepwiseFont = storage.get(FONT_KEY); + if (legacyStepwiseFont != null && legacyStepwiseFont !== "") { + const migrated = clampFontOffset(clampFontSize(legacyStepwiseFont) - DEFAULT_FONT); + storage.set(FONT_OFFSET_KEY, String(migrated)); + return migrated; + } + + const outlineOffset = storage.get(LEGACY_OUTLINE_FONT_OFFSET_KEY); + if (outlineOffset != null && outlineOffset !== "" && Number.isFinite(Number(outlineOffset))) { + const migrated = clampFontOffset(outlineOffset); + storage.set(FONT_OFFSET_KEY, String(migrated)); + return migrated; + } + + const outlineFont = storage.get(LEGACY_OUTLINE_FONT_KEY); + const migrated = outlineFont == null || outlineFont === "" + ? 0 + : clampFontOffset(clampFontSize(outlineFont) - DEFAULT_FONT); + storage.set(FONT_OFFSET_KEY, String(migrated)); + return migrated; + } + + // Typography follows the host composer while persisting only the user's relative offset. + function fallbackHostTypography() { + const hostFontSize = HOST_FONT_SIZE_FALLBACK; + return { + source: "fallback", + fontFamily: HOST_FONT_FAMILY_FALLBACK, + fontWeight: 400, + labelWeight: 500, + hostFontSize, + baseItemFontSize: roundPixel(hostFontSize * ITEM_FONT_RATIO), + chromeFontSize: roundPixel(hostFontSize * CHROME_FONT_RATIO), + iconFontSize: roundPixel(hostFontSize * ICON_FONT_RATIO), + }; + } + + function hostTypographySource() { + const trigger = visibleTypographyNode("[data-codex-intelligence-trigger]"); + if (trigger) return { element: trigger, source: "model-trigger" }; + const composer = visibleTypographyNode( + '[data-codex-composer] .ProseMirror, [data-codex-composer] [contenteditable="true"], .ProseMirror, [contenteditable="true"]' + ); + if (composer) return { element: composer, source: "composer" }; + const textarea = visibleTypographyNode("textarea"); + if (textarea) return { element: textarea, source: "textarea" }; + if (document.body) return { element: document.body, source: "body" }; + return { element: document.documentElement, source: "document" }; + } + + function readHostTypography() { + const { element, source } = hostTypographySource(); + if (!(element instanceof Element)) return fallbackHostTypography(); + const computed = getComputedStyle(element); + const parsedSize = Number.parseFloat(computed.fontSize); + const parsedWeight = Number.parseInt(computed.fontWeight, 10); + const hostFontSize = clamp( + Number.isFinite(parsedSize) ? parsedSize : HOST_FONT_SIZE_FALLBACK, + HOST_FONT_SIZE_MIN, + HOST_FONT_SIZE_MAX + ); + const fontWeight = Number.isFinite(parsedWeight) ? parsedWeight : 400; + return { + source, + fontFamily: computed.fontFamily || HOST_FONT_FAMILY_FALLBACK, + fontWeight, + labelWeight: clamp(fontWeight + 100, 500, 700), + hostFontSize: roundPixel(hostFontSize), + baseItemFontSize: roundPixel(hostFontSize * ITEM_FONT_RATIO), + chromeFontSize: roundPixel(hostFontSize * CHROME_FONT_RATIO), + iconFontSize: roundPixel(hostFontSize * ICON_FONT_RATIO), + }; + } + + function typographyFingerprint(value) { + return [ + value.source, + value.fontFamily, + value.fontWeight, + value.hostFontSize, + value.baseItemFontSize, + ].join("|"); + } + + function effectiveFontSize(typography = state.hostTypography) { + return clampFontSize(typography.baseItemFontSize + state.fontOffset); + } + + function persistFontPreference() { + storage.set(FONT_OFFSET_KEY, String(state.fontOffset)); + storage.set(FONT_KEY, String(effectiveFontSize())); + } + + function setPixelVariable(element, property, value) { + if (!(element instanceof HTMLElement)) return; + const next = `${roundPixel(value)}px`; + if (element.style.getPropertyValue(property) !== next) { + element.style.setProperty(property, next); + } + } + + function applyTypographyVariables() { + if (!state.root) return; + state.root.style.setProperty("--csw-font-family", state.hostTypography.fontFamily); + state.root.style.setProperty("--csw-font-weight", String(state.hostTypography.fontWeight)); + state.root.style.setProperty("--csw-label-weight", String(state.hostTypography.labelWeight)); + setPixelVariable(state.root, "--csw-item-font", effectiveFontSize()); + setPixelVariable(state.root, "--csw-chrome-font", state.hostTypography.chromeFontSize); + setPixelVariable(state.root, "--csw-icon-font", state.hostTypography.iconFontSize); + } + + function installTypographyObserver() { + if (state.typographyObserver || !document.documentElement) return; + state.typographyObserver = new MutationObserver(() => syncHostTypography()); + const options = { + attributes: true, + attributeFilter: ["class", "style", "data-theme", "data-appearance", "data-color-mode"], + }; + state.typographyObserver.observe(document.documentElement, options); + if (document.body) state.typographyObserver.observe(document.body, options); + } + + function writeFontSize(value) { + const parsed = Number(value); + const requested = clampFontSize(Number.isFinite(parsed) ? parsed : effectiveFontSize()); + const baseItemFontSize = state.hostTypography.baseItemFontSize; + state.fontOffset = clampFontOffset(requested - baseItemFontSize, baseItemFontSize); + persistFontPreference(); + applyTypographyVariables(); + } + + function bumpFontSize(delta) { + writeFontSize(effectiveFontSize() + delta); + if (state.open) renderFloat({ preserveMorph: true }); + } + + function fontSizeLabel() { + return `${effectiveFontSize()}px`; + } + + // Material v3 migrates legacy names once, then preserves explicit user choices. + function normalizeMaterial(value) { + if (MATERIAL_MODES.includes(value)) return value; + return { + glass: "frosted", + solid: "matte", + opaque: "matte", + }[value] || DEFAULT_MATERIAL; + } + + function migrateLegacyMaterial(value) { + return LEGACY_MATERIAL_MODES[value] || DEFAULT_MATERIAL; + } + + function migrateMaterialStorageV3() { + const previous = storage.get(PREVIOUS_MATERIAL_KEY); + const legacy = storage.get(LEGACY_MATERIAL_KEY); + const previousIsUserChoice = MATERIAL_MODES.includes(previous) + && (legacy === null || previous !== migrateLegacyMaterial(legacy)); + return previousIsUserChoice + ? { material: previous, origin: "user" } + : { material: DEFAULT_MATERIAL, origin: "default" }; + } + + function materialLabel(value = state.material) { + return { + frosted: "磨砂", + clear: "通透", + liquid: "液态", + crystal: "冰晶", + matte: "哑光", + }[normalizeMaterial(value)]; + } + + function nextMaterial(value = state.material) { + const index = MATERIAL_MODES.indexOf(normalizeMaterial(value)); + return MATERIAL_MODES[(index + 1) % MATERIAL_MODES.length]; + } + + function readMaterial() { + const stored = storage.get(MATERIAL_KEY); + if (MATERIAL_MODES.includes(stored)) return stored; + if (storage.get(MATERIAL_MIGRATION_KEY) === "true") { + storage.set(MATERIAL_KEY, DEFAULT_MATERIAL); + storage.set(MATERIAL_ORIGIN_KEY, "default"); + return DEFAULT_MATERIAL; + } + const migrated = migrateMaterialStorageV3(); + storage.set(MATERIAL_KEY, migrated.material); + storage.set(MATERIAL_ORIGIN_KEY, migrated.origin); + storage.set(MATERIAL_MIGRATION_KEY, "true"); + return migrated.material; + } + + function materialButtonLabel() { + return `外观:${materialLabel()};切换为${materialLabel(nextMaterial())}`; + } + + function materialValueLabel() { + return materialLabel(); + } + + function applyMaterial(options = {}) { + const mode = normalizeMaterial(state.material); + const animate = options.animate !== false; + state.material = mode; + state.root?.setAttribute("data-material", mode); + state.popover?.setAttribute("data-material", mode); + if (state.materialAnimTimer) window.clearTimeout(state.materialAnimTimer); + state.materialAnimTimer = 0; + if (animate) { + state.popover?.setAttribute("data-material-animating", "true"); + state.materialAnimTimer = window.setTimeout(() => { + state.popover?.removeAttribute("data-material-animating"); + state.materialAnimTimer = 0; + }, 260); + } else { + state.popover?.removeAttribute("data-material-animating"); + } + const button = state.panel?.querySelector("[data-action='material']"); + if (button) { + button.dataset.material = mode; + button.removeAttribute("aria-pressed"); + button.setAttribute("aria-label", materialButtonLabel()); + button.setAttribute("title", materialButtonLabel()); + const value = button.querySelector("[data-material-value]"); + if (value) value.textContent = materialValueLabel(); + } + if (state.popover?.hasAttribute("data-csw-hot-hover") === true) { + updateMaterialDistortion(state.open, true); + } else { + resetGlassPointer(); + } + } + + function writeMaterial(value) { + state.material = normalizeMaterial(value); + storage.set(MATERIAL_KEY, state.material); + storage.set(MATERIAL_ORIGIN_KEY, "user"); + storage.set(MATERIAL_MIGRATION_KEY, "true"); + applyMaterial(); + return state.material; + } + + function toggleMaterial(event) { + event?.preventDefault(); + event?.stopPropagation(); + return writeMaterial(nextMaterial()); + } + + function toggleLabelOnly(event) { + event?.preventDefault(); + event?.stopPropagation(); + state.labelOnly = !state.labelOnly; + storage.set(LABEL_ONLY_KEY, String(state.labelOnly)); + if (state.open) renderFloat({ preserveMorph: true }); + return state.labelOnly; + } + + // Diagnostics remain local and compact so injection problems can be inspected without logging chat text. function rectSummary(node) { const rect = visibleRect(node); if (!rect) return null; @@ -159,6 +883,7 @@ return 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b; } + // Theme and typography adapters observe ChatGPT without taking ownership of its settings. function detectCodexTheme() { const rootClass = document.documentElement.classList; if (rootClass.contains("electron-dark") || rootClass.contains("theme-dark")) return "dark"; @@ -197,6 +922,21 @@ state.theme = detectCodexTheme(); state.root?.setAttribute("data-theme", state.theme); state.root?.setAttribute("data-theme-mode", state.themeMode); + syncHostTypography(); + } + + function visibleTypographyNode(selector) { + return Array.from(document.querySelectorAll(selector)).find((node) => node.getClientRects().length > 0) || null; + } + + function syncHostTypography(force = false) { + if (!state.root) return; + const next = readHostTypography(); + const changed = force + || typographyFingerprint(next) !== typographyFingerprint(state.hostTypography); + if (changed) state.hostTypography = next; + applyTypographyVariables(); + if (changed || force) persistFontPreference(); } function appActionModuleCandidates() { @@ -265,14 +1005,23 @@ } function themeLabel() { - return state.theme === "dark" ? "切换到浅色主题" : "切换到深色主题"; + return state.theme === "dark" ? "主题:深色;切换到浅色主题" : "主题:浅色;切换到深色主题"; } function iconSvg(name) { const common = `fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"`; + if (name === "next") { + return ``; + } + if (name === "outline") { + return ``; + } if (name === "settings") { return ``; } + if (name === "open-config") { + return ``; + } if (name === "moon") { return ``; } @@ -282,6 +1031,9 @@ if (name === "refresh") { return ``; } + if (name === "connection") { + return ``; + } return ``; } @@ -329,6 +1081,7 @@ return normalizeText(clone.textContent || ""); } + // One stylesheet owns the shell, materials, views, responsive layout, and reduced-motion states. function installStyle() { if (document.getElementById(STYLE_ID)) return; @@ -336,23 +1089,46 @@ style.id = STYLE_ID; style.textContent = ` [${ROOT_ATTR}="true"] { - --csw-bg: rgba(250, 250, 249, 0.98); - --csw-border: rgba(20, 20, 20, 0.12); - --csw-text: rgba(20, 20, 19, 0.94); - --csw-muted: rgba(20, 20, 19, 0.62); - --csw-soft: rgba(20, 20, 19, 0.065); - --csw-row: rgba(255, 255, 255, 0.72); - --csw-input: rgba(255, 255, 255, 0.82); - --csw-fab-bg: rgba(250, 250, 249, 0.98); - --csw-fab-fg: rgba(20, 20, 19, 0.94); - --csw-fab-border: rgba(20, 20, 20, 0.16); - --csw-fab-shadow: 0 10px 26px rgba(0, 0, 0, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.78); - --csw-badge-bg: rgba(86, 86, 84, 0.98); - --csw-badge-fg: rgba(255, 255, 255, 0.96); - --csw-badge-border: rgba(255, 255, 255, 0.28); - --csw-popover-shadow: 0 18px 48px rgba(0, 0, 0, 0.16); + --csw-surface-opaque: var(--color-background-elevated-primary-opaque, var(--color-token-dropdown-background, var(--main-surface-primary, #FAFAFA))); + --csw-text: var(--color-token-text-primary, var(--color-token-foreground, var(--text-primary, #202020))); + --csw-muted: var(--color-token-text-tertiary, var(--color-token-description-foreground, #6F6F6F)); + --csw-faint: color-mix(in srgb, var(--csw-text) 34%, transparent); + --csw-accent: var(--color-token-charts-blue, #4D8DFF); + --csw-danger: #dc5d67; + --csw-ready: var(--csw-accent); + --csw-hover: var(--color-token-list-hover-background, color-mix(in srgb, var(--csw-text) 6%, transparent)); + --csw-divider: color-mix(in srgb, var(--csw-text) 9%, transparent); + --csw-glass-x: 28%; + --csw-glass-y: 22%; + --csw-glass-strength: 0; + --csw-glass-rim-width: 140%; + --csw-glass-rim-height: 120%; + --csw-glass-px: 0px; + --csw-glass-py: 0px; + --csw-glass-angle: -40deg; + --csw-glass-edge: rgba(108, 128, 152, 0.4); + --csw-glass-edge-hi: rgba(168, 190, 214, 0.7); + --csw-hover-core: 0.13; + --csw-hover-mid: 0.04; + --csw-hover-layer-opacity: 0.85; + --csw-hover-rim-gain: 10%; + --csw-hover-core-color: 255, 255, 255; + --csw-hover-mid-color: 190, 210, 230; + --csw-frost-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180' viewBox='0 0 180 180'%3E%3Cfilter id='n' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.008' numOctaves='2' seed='92' stitchTiles='stitch'/%3E%3CfeGaussianBlur stdDeviation='2'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='table' tableValues='0 .055'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.55'/%3E%3C/svg%3E"); + --csw-eye-x: 0px; + --csw-eye-y: 0px; + --csw-curious-eye-x: 0px; + --csw-curious-eye-y: 0px; + --csw-panel-width: ${PANEL_WIDTH}px; + --csw-panel-height: ${PANEL_HEIGHT}px; + --csw-item-font: ${DEFAULT_FONT}px; + --csw-chrome-font: 12px; + --csw-icon-font: 16px; color: var(--csw-text); - font: 13px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Hiragino Sans GB", "Segoe UI", sans-serif; + font-family: var(--csw-font-family, -apple-system, system-ui, "Segoe UI", sans-serif); + font-size: var(--csw-item-font); + font-weight: var(--csw-font-weight, 400); + line-height: 1.4; inset: 0; letter-spacing: 0; pointer-events: none; @@ -360,22 +1136,23 @@ z-index: 2147483000; } + [${ROOT_ATTR}="true"][data-hidden="true"] { + display: none !important; + } + [${ROOT_ATTR}="true"][data-theme="dark"] { - --csw-bg: rgba(31, 31, 30, 0.98); - --csw-border: rgba(255, 255, 255, 0.13); - --csw-text: rgba(247, 247, 246, 0.94); - --csw-muted: rgba(247, 247, 246, 0.62); - --csw-soft: rgba(255, 255, 255, 0.08); - --csw-row: rgba(255, 255, 255, 0.06); - --csw-input: rgba(255, 255, 255, 0.07); - --csw-fab-bg: linear-gradient(180deg, rgba(49, 49, 48, 0.98), rgba(25, 25, 24, 0.99)); - --csw-fab-fg: rgba(255, 255, 255, 0.92); - --csw-fab-border: rgba(255, 255, 255, 0.18); - --csw-fab-shadow: 0 12px 30px rgba(0, 0, 0, 0.38), inset 0 1px 0 rgba(255, 255, 255, 0.08); - --csw-badge-bg: rgba(71, 71, 69, 0.98); - --csw-badge-fg: rgba(255, 255, 255, 0.96); - --csw-badge-border: rgba(255, 255, 255, 0.2); - --csw-popover-shadow: 0 18px 52px rgba(0, 0, 0, 0.38); + --csw-surface-opaque: var(--color-background-elevated-primary-opaque, var(--color-token-dropdown-background, #2B2B2B)); + --csw-text: var(--color-token-text-primary, var(--color-token-foreground, #F3F3F3)); + --csw-muted: var(--color-token-text-tertiary, var(--color-token-description-foreground, #AAAAAA)); + --csw-faint: color-mix(in srgb, var(--csw-text) 32%, transparent); + --csw-accent: var(--color-token-charts-blue, #4D8DFF); + --csw-danger: #ff7f89; + --csw-ready: var(--csw-accent); + --csw-hover: var(--color-token-list-hover-background, rgba(255, 255, 255, 0.078)); + --csw-divider: rgba(255, 255, 255, 0.09); + --csw-glass-strength: 0; + --csw-glass-edge: rgba(132, 154, 180, 0.36); + --csw-glass-edge-hi: rgba(178, 200, 224, 0.62); color: var(--csw-text); } @@ -384,745 +1161,5077 @@ display: none !important; } - .csw-fab { - align-items: center; - appearance: none; - background: var(--csw-fab-bg); - border: 1px solid var(--csw-fab-border); - border-radius: 999px; - box-shadow: var(--csw-fab-shadow); - color: var(--csw-fab-fg); - cursor: grab; - display: flex; - height: 43px; - justify-content: center; - padding: 0; - pointer-events: auto; + .csw-popover { + height: var(--csw-panel-height); + isolation: isolate; + pointer-events: none; position: fixed; - transition: background-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; - user-select: none; - width: 43px; + width: var(--csw-panel-width); } - .csw-fab:active { - cursor: grabbing; - transform: scale(0.98); + .csw-material-layer { + inset: 0; + isolation: isolate; + pointer-events: none; + position: absolute; + z-index: 0; } - .csw-fab:hover { - box-shadow: var(--csw-fab-shadow), 0 0 0 4px rgba(127, 127, 127, 0.08); + .csw-material-layer, + .csw-material-layer * { + pointer-events: none; } - .csw-fab-mark { - align-items: center; - display: block; - font-size: 23px; - font-weight: 650; - line-height: 1; - margin-left: 1px; - transform: translateY(-1px); + .csw-glass { + -webkit-backdrop-filter: blur(18px) saturate(165%) contrast(1.04) brightness(1.04); + backdrop-filter: blur(18px) saturate(165%) contrast(1.04) brightness(1.04); + background-color: color-mix(in srgb, var(--csw-surface-opaque) 68%, transparent); + background-image: linear-gradient(160deg, rgba(255, 255, 255, 0.11) 0%, rgba(255, 255, 255, 0.032) 48%, rgba(150, 170, 195, 0.032) 100%); + border: 0; + border-radius: ${CHIP_RADIUS}px; + box-shadow: none; + box-sizing: border-box; + height: ${CHIP_HEIGHT}px; + left: var(--csw-chip-left, ${Math.max(0, (PANEL_WIDTH - CHIP_WIDTH) / 2)}px); + overflow: hidden; + pointer-events: none; + position: absolute; + top: 0; + transition: background-color 0.2s ease, box-shadow 0.2s ease, backdrop-filter 0.2s ease, -webkit-backdrop-filter 0.2s ease; + width: ${CHIP_WIDTH}px; + z-index: 1; } - .csw-fab-badge { - align-items: center; - background: var(--csw-badge-bg); - border: 1px solid var(--csw-badge-border); - border-radius: 999px; - color: var(--csw-badge-fg); - display: flex; - font-size: 11px; - font-weight: 700; - height: 18px; - justify-content: center; - min-width: 18px; - padding: 0 4px; + .csw-rim { + background: var(--csw-glass-edge); + border-radius: ${CHIP_RADIUS}px; + box-sizing: border-box; + height: ${CHIP_HEIGHT}px; + left: var(--csw-chip-left, ${Math.max(0, (PANEL_WIDTH - CHIP_WIDTH) / 2)}px); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + mask-composite: exclude; + padding: 1px; + pointer-events: none; position: absolute; - right: -4px; - top: -5px; + top: 0; + width: ${CHIP_WIDTH}px; + z-index: 4; } - .csw-fab[data-count="0"] .csw-fab-badge { - display: none; + .csw-popover[data-csw-hot-hover] .csw-rim { + background: conic-gradient( + from calc(var(--csw-glass-angle, -40deg) + 90deg), + color-mix(in srgb, var(--csw-glass-edge-hi) var(--csw-hover-rim-gain, 0%), var(--csw-glass-edge)) 0deg, + var(--csw-glass-edge) 64deg, + var(--csw-glass-edge) 296deg, + color-mix(in srgb, var(--csw-glass-edge-hi) var(--csw-hover-rim-gain, 0%), var(--csw-glass-edge)) 360deg + ); } - .csw-popover { - background: var(--csw-bg); - border: 1px solid var(--csw-border); - border-radius: 8px; - box-shadow: var(--csw-popover-shadow); - box-sizing: border-box; - display: none; - max-height: calc(100vh - 28px); - overflow: hidden; - pointer-events: auto; - position: fixed; - width: min(380px, calc(100vw - 28px)); + .csw-popover[data-morphing="true"] .csw-glass, + .csw-popover[data-morphing="true"] .csw-rim, + .csw-popover[data-morphing="true"] .csw-displacement-texture { + will-change: left, top, width, height, border-radius; } - .csw-popover[data-open="true"] { - display: block; + .csw-popover[data-snap-right="true"] { + transition: left 180ms cubic-bezier(.22, .72, 0, 1), top 180ms cubic-bezier(.22, .72, 0, 1); } - .csw-head { - align-items: center; - border-bottom: 1px solid var(--csw-border); - display: flex; - gap: 8px; - justify-content: space-between; - padding: 9px 10px 9px 12px; + .csw-popover[data-snap-right="true"] .csw-fab, + .csw-popover[data-snap-right="true"] .csw-glass, + .csw-popover[data-snap-right="true"] .csw-rim, + .csw-popover[data-snap-right="true"] .csw-displacement-texture { + transition-property: left, top; + transition-duration: 180ms; + transition-timing-function: cubic-bezier(.22, .72, 0, 1); } - .csw-title { - font-size: 13px; - font-weight: 700; + .csw-completion-beam { + box-sizing: border-box; + color: var(--csw-text); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + mask-composite: exclude; + opacity: 0; + overflow: hidden; + padding: 1px; + pointer-events: none; + position: absolute; + z-index: 5; } - .csw-tabs { - align-items: center; - display: flex; - gap: 2px; + .csw-completion-beam::before { + background: conic-gradient( + from 0deg, + transparent 0deg, + transparent 302deg, + color-mix(in srgb, currentColor 22%, transparent) 320deg, + color-mix(in srgb, currentColor 72%, transparent) 338deg, + transparent 360deg + ); + content: ""; + inset: -170%; + opacity: 0; + position: absolute; + transform: rotate(-64deg); + transform-origin: center; + will-change: opacity, transform; } - .csw-icon { - align-items: center; - appearance: none; - background: transparent; - border: 0; - border-radius: 7px; - color: var(--csw-muted); - cursor: pointer; - display: inline-flex; - font: 600 12px/1 -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif; - height: 30px; - justify-content: center; - padding: 0 8px; - width: 30px; + .csw-popover[data-morphing="false"][data-completion-beam="true"] .csw-completion-beam { + opacity: 1; } - .csw-icon[data-active="true"], - .csw-icon:hover { - background: var(--csw-soft); - color: var(--csw-text); + .csw-popover[data-morphing="false"][data-completion-beam="true"] .csw-completion-beam::before { + animation: csw-completion-beam-sweep ${COMPLETION_BEAM_MS}ms cubic-bezier(.22, .78, .18, 1) 1 both; } - .csw-icon:disabled { - cursor: not-allowed; - opacity: .42; + [${ROOT_ATTR}="true"][data-theme="dark"] .csw-glass { + -webkit-backdrop-filter: blur(18px) saturate(165%) contrast(1.05) brightness(1.08); + backdrop-filter: blur(18px) saturate(165%) contrast(1.05) brightness(1.08); + background-color: color-mix(in srgb, var(--csw-surface-opaque) 60%, transparent); + background-image: linear-gradient(160deg, rgba(160, 185, 215, 0.055) 0%, rgba(255, 255, 255, 0.012) 48%, rgba(30, 45, 70, 0.045) 100%); } - .csw-icon svg { - display: block; - height: 18px; - width: 18px; + .csw-popover[data-material="matte"] { + --csw-hover-core: 0.07; + --csw-hover-mid: 0.022; + --csw-hover-layer-opacity: 0.75; + --csw-hover-rim-gain: 6%; + --csw-hover-core-color: 255, 255, 255; + --csw-hover-mid-color: 205, 215, 225; } - .csw-icon[data-action="close"] { - font-size: 17px; - font-weight: 500; + .csw-popover[data-material="frosted"] { + --csw-hover-core: 0.13; + --csw-hover-mid: 0.04; + --csw-hover-layer-opacity: 0.85; + --csw-hover-rim-gain: 10%; + --csw-hover-core-color: 255, 255, 255; + --csw-hover-mid-color: 190, 210, 230; } - .csw-body { - max-height: calc(100vh - 78px); - overflow: auto; - padding: 10px; + [${ROOT_ATTR}="true"][data-theme="dark"] .csw-popover[data-material="frosted"] { + --csw-hover-core: 0.11; + --csw-hover-mid: 0.034; } - .csw-list { - display: grid; - gap: 6px; + .csw-popover[data-material="clear"] { + --csw-hover-core: 0.12; + --csw-hover-mid: 0.038; + --csw-hover-layer-opacity: 0.65; + --csw-hover-rim-gain: 8%; + --csw-hover-core-color: 255, 255, 255; + --csw-hover-mid-color: 174, 214, 255; } - .csw-row { - appearance: none; - background: var(--csw-row); - border: 1px solid var(--csw-border); - border-radius: 7px; - color: inherit; - cursor: pointer; - display: block; - min-height: 0; - padding: 8px 9px; - text-align: left; - width: 100%; + .csw-popover[data-material="liquid"] { + --csw-hover-core: 0.15; + --csw-hover-mid: 0.045; + --csw-hover-layer-opacity: 0.72; + --csw-hover-rim-gain: 9%; + --csw-hover-core-color: 255, 255, 255; + --csw-hover-mid-color: 145, 205, 255; } - .csw-row:hover, - .csw-row:focus-visible { - background: var(--csw-soft); - outline: none; + .csw-popover[data-material="crystal"] { + --csw-hover-core: 0.14; + --csw-hover-mid: 0.045; + --csw-hover-layer-opacity: 0.68; + --csw-hover-rim-gain: 9%; + --csw-hover-core-color: 242, 252, 255; + --csw-hover-mid-color: 122, 199, 255; } - .csw-row-label { - color: var(--csw-text); - display: block; - font-size: 12px; - font-weight: 700; - margin-bottom: 3px; + .csw-popover[data-material="frosted"] .csw-glass { + -webkit-backdrop-filter: blur(15px) saturate(124%) contrast(1.02); + backdrop-filter: blur(15px) saturate(124%) contrast(1.02); + background-color: color-mix(in srgb, var(--csw-surface-opaque) 18%, transparent); + background-image: var(--csw-frost-noise); + background-blend-mode: soft-light; + background-repeat: no-repeat; + background-size: cover; } - .csw-row-prompt { - color: var(--csw-muted); - display: -webkit-box; - font-size: 12px; - line-height: 1.42; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - overflow: hidden; - } - - .csw-row:hover .csw-row-prompt, - .csw-row:focus-visible .csw-row-prompt { - -webkit-line-clamp: 5; + [${ROOT_ATTR}="true"][data-theme="dark"] .csw-popover[data-material="frosted"] .csw-glass { + -webkit-backdrop-filter: blur(15px) saturate(118%) contrast(1.03) brightness(1.03); + backdrop-filter: blur(15px) saturate(118%) contrast(1.03) brightness(1.03); + background-color: color-mix(in srgb, var(--csw-surface-opaque) 24%, transparent); + background-image: var(--csw-frost-noise); + background-blend-mode: soft-light; } - .csw-empty { - background: var(--csw-row); - border: 1px solid var(--csw-border); - border-radius: 7px; - color: var(--csw-muted); - padding: 12px; + .csw-popover[data-open="true"] { + --csw-glass-rim-width: 92%; + --csw-glass-rim-height: 72%; } - .csw-form { - display: grid; - gap: 9px; + .csw-popover[data-open="false"][data-material="frosted"] .csw-glass { + -webkit-backdrop-filter: blur(15px) saturate(124%) contrast(1.02); + backdrop-filter: blur(15px) saturate(124%) contrast(1.02); + background-color: color-mix(in srgb, var(--csw-surface-opaque) 12%, transparent); + background-image: var(--csw-frost-noise); + background-blend-mode: soft-light; + background-repeat: no-repeat; + background-size: cover; + box-shadow: none; + filter: none !important; + isolation: auto; } - .csw-switch { - align-items: center; - background: var(--csw-row); - border: 1px solid var(--csw-border); - border-radius: 7px; - box-sizing: border-box; - cursor: pointer; - display: flex; - gap: 10px; - justify-content: space-between; - min-height: 40px; - padding: 8px 9px; + [${ROOT_ATTR}="true"][data-theme="dark"] .csw-popover[data-open="false"][data-material="frosted"] .csw-glass { + -webkit-backdrop-filter: blur(15px) saturate(118%) contrast(1.03) brightness(1.03); + backdrop-filter: blur(15px) saturate(118%) contrast(1.03) brightness(1.03); + background-color: color-mix(in srgb, var(--csw-surface-opaque) 18%, transparent); + background-image: var(--csw-frost-noise); + background-blend-mode: soft-light; + box-shadow: none; } - .csw-switch input { - height: 1px; - opacity: 0; - position: absolute; - width: 1px; + .csw-popover[data-material="matte"] .csw-glass { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background-color: var(--csw-surface-opaque); + background-image: none; } - .csw-switch-text { - display: grid; - gap: 2px; + .csw-popover[data-material="clear"] .csw-glass { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background-color: transparent; + background-image: none; + isolation: isolate; } - .csw-switch-title { - color: var(--csw-text); - font-size: 12px; - font-weight: 750; - line-height: 1.25; + .csw-clear-texture { + -webkit-backdrop-filter: none; + backdrop-filter: url(#${CLEAR_FILTER_ID}); + background-color: transparent; + background-image: none; + border-radius: inherit; + display: none; + filter: none; + inset: -12px; + opacity: 1; + pointer-events: none; + position: absolute; + transform: translateZ(0); + will-change: backdrop-filter; + z-index: 0; } - .csw-switch-note { - color: var(--csw-muted); - font-size: 11px; - line-height: 1.35; + .csw-popover[data-material="clear"] .csw-clear-texture { + display: block; } - .csw-switch-control { - background: var(--csw-soft); - border: 1px solid var(--csw-border); - border-radius: 999px; + .csw-clear-distortion { + -webkit-backdrop-filter: none; + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + backdrop-filter: none; + background: transparent; + border-radius: inherit; box-sizing: border-box; - flex: 0 0 auto; - height: 22px; - padding: 2px; - transition: background 140ms ease, border-color 140ms ease; - width: 38px; + display: none; + filter: none; + inset: 1px; + mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + mask-composite: exclude; + opacity: 1; + padding: 1px; + pointer-events: none; + position: absolute; + transform: translate3d(var(--csw-glass-px, 0px), var(--csw-glass-py, 0px), 0); + transition: opacity 0.18s ease, transform 0.14s cubic-bezier(0.23, 1, 0.32, 1); + will-change: transform; + z-index: 0; } - .csw-switch-control::before { - background: var(--csw-muted); - border-radius: 999px; - content: ""; + .csw-popover[data-material="clear"] .csw-clear-distortion { display: block; - height: 16px; - transition: transform 140ms ease, background 140ms ease; - width: 16px; } - .csw-switch input:checked + .csw-switch-control { - background: var(--csw-text); - border-color: var(--csw-text); + .csw-popover[data-material="liquid"] .csw-glass, + .csw-popover[data-material="crystal"] .csw-glass { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background-color: rgba(255, 255, 255, 0); + background-image: none; + isolation: isolate; } - .csw-switch input:checked + .csw-switch-control::before { - background: var(--csw-bg); - transform: translateX(16px); + .csw-popover[data-material="liquid"] .csw-glass::before, + .csw-popover[data-material="crystal"] .csw-glass::before { + box-shadow: none; + mix-blend-mode: screen; + transform: translate3d(var(--csw-glass-px, 0px), var(--csw-glass-py, 0px), 0); + z-index: 1; } - .csw-grid { - display: grid; - gap: 8px; - grid-template-columns: 1fr 1fr; + .csw-displacement-texture { + border-radius: ${CHIP_RADIUS}px; + display: none; + height: ${CHIP_HEIGHT}px; + isolation: isolate; + left: var(--csw-chip-left, ${Math.max(0, (PANEL_WIDTH - CHIP_WIDTH) / 2)}px); + overflow: hidden; + pointer-events: none; + position: absolute; + top: 0; + width: ${CHIP_WIDTH}px; + z-index: 0; } - .csw-section { - display: grid; - gap: 8px; + .csw-displacement-texture::before { + border-radius: inherit; + content: ""; + inset: 0; + pointer-events: none; + position: absolute; } - .csw-section-title { - color: var(--csw-text); - font-size: 11px; - font-weight: 750; - line-height: 1.2; + .csw-popover[data-material="liquid"] .csw-displacement-texture, + .csw-popover[data-material="crystal"] .csw-displacement-texture { + display: block; } - .csw-summary { - background: linear-gradient(180deg, var(--csw-row), transparent); - border: 1px solid var(--csw-border); - border-radius: 7px; - display: grid; - gap: 7px; - padding: 9px 10px; + .csw-popover[data-material="crystal"] .csw-displacement-texture { + overflow: visible; } - .csw-summary-list { - display: grid; + .csw-popover[data-material="liquid"] .csw-displacement-texture::before { + -webkit-backdrop-filter: url(#${LIQUID_FILTER_ID}) blur(0.6px) saturate(112%) contrast(1.02); + backdrop-filter: url(#${LIQUID_FILTER_ID}) blur(0.6px) saturate(112%) contrast(1.02); + background-color: rgba(255, 255, 255, 0.24); + -webkit-filter: none; + filter: none; } - .csw-summary-row { - align-items: center; - border-top: 1px solid var(--csw-border); - display: grid; - gap: 12px; - grid-template-columns: minmax(0, 1fr) auto; - min-height: 30px; + [${ROOT_ATTR}="true"][data-theme="dark"] .csw-popover[data-material="liquid"] .csw-displacement-texture::before { + background-color: rgba(20, 24, 30, 0.34); } - .csw-summary-row:first-child { - border-top: 0; + .csw-popover[data-material="crystal"] .csw-displacement-texture::before { + -webkit-backdrop-filter: blur(7px); + backdrop-filter: blur(7px); + background-color: rgba(255, 255, 255, 0); + border-radius: 0; + clip-path: inset(48px round ${PANEL_RADIUS}px); + inset: -48px; + -webkit-filter: url(#${CRYSTAL_FILTER_ID}); + filter: url(#${CRYSTAL_FILTER_ID}); } - .csw-summary-label { - color: var(--csw-muted); - font-size: 12px; - font-weight: 650; + .csw-popover[data-material-animating="true"] .csw-glass { + transition-duration: 260ms; } - .csw-summary-value { - color: var(--csw-text); - font-size: 12px; - font-weight: 700; - max-width: 178px; - overflow: hidden; - text-align: right; - text-overflow: ellipsis; - white-space: nowrap; + .csw-glass::before { + background: radial-gradient( + var(--csw-glass-rim-width, 140%) var(--csw-glass-rim-height, 120%) at var(--csw-glass-x, 28%) var(--csw-glass-y, 22%), + rgba(var(--csw-hover-core-color, 255, 255, 255), calc(var(--csw-hover-core, 0.13) * var(--csw-glass-strength, 0))) 0%, + rgba(var(--csw-hover-mid-color, 190, 210, 230), calc(var(--csw-hover-mid, 0.04) * var(--csw-glass-strength, 0))) 22%, + transparent 50% + ); + border-radius: inherit; + content: ""; + inset: 0; + mix-blend-mode: screen; + opacity: var(--csw-hover-layer-opacity, 0.85); + pointer-events: none; + position: absolute; + transform: translate3d(var(--csw-glass-px, 0px), var(--csw-glass-py, 0px), 0); + transition: transform 0.14s cubic-bezier(0.23, 1, 0.32, 1); + will-change: transform; } - .csw-summary-value[data-tone="good"], - .csw-summary-value[data-tone="warn"], - .csw-summary-value[data-tone="muted"] { - border: 1px solid var(--csw-border); - border-radius: 999px; - padding: 2px 8px; + .csw-popover[data-open="true"][data-morphing="false"] .csw-glass { + box-shadow: none; } - .csw-summary-value[data-tone="good"] { - background: var(--csw-soft); + .csw-popover[data-morphing="true"] .csw-glass { + cursor: pointer; + pointer-events: auto; + transition: none; } - .csw-summary-value[data-tone="warn"] { - color: var(--csw-muted); + .csw-popover[data-resizing="true"], + .csw-popover[data-resizing="true"] .csw-glass, + .csw-popover[data-resizing="true"] .csw-rim, + .csw-popover[data-resizing="true"] .csw-panel { + transition: none !important; } - .csw-summary-value[data-tone="muted"] { - color: var(--csw-muted); + .csw-fab { + align-items: center; + appearance: none; + background: transparent; + border: 0; + border-radius: 999px; + box-sizing: border-box; + color: var(--csw-text); + cursor: grab; + display: flex; + height: ${CHIP_HEIGHT}px; + justify-content: center; + padding: 0; + pointer-events: auto; + position: absolute; + user-select: none; + width: ${CHIP_WIDTH}px; + z-index: 3; } - .csw-field { - display: grid; - gap: 4px; + .csw-fab[data-expression="hidden"] { + display: none; } - .csw-field label { - color: var(--csw-muted); - font-size: 11px; - font-weight: 600; + .csw-popover[data-open="true"][data-morphing="false"] .csw-fab { + opacity: 0; + pointer-events: none; + visibility: hidden; } - .csw-field input { - background: var(--csw-input); - border: 1px solid var(--csw-border); - border-radius: 6px; - box-sizing: border-box; - color: var(--csw-text); - font: inherit; - height: 32px; - padding: 0 8px; - width: 100%; + .csw-popover[data-morphing="true"] .csw-fab { + opacity: 1; + pointer-events: none; + transform: none; + visibility: visible; + } + + .csw-fab:active { + cursor: grabbing; + transform: scale(0.96); } - .csw-field[data-disabled="true"] { - opacity: .48; + .csw-fab:focus-visible { + outline: 2px solid color-mix(in srgb, var(--csw-accent) 76%, transparent); + outline-offset: 4px; } - .csw-field input:disabled { - cursor: not-allowed; + .csw-fab-face { + align-items: center; + display: flex; + gap: 16px; + height: 27px; + justify-content: center; + position: relative; + width: 52px; + z-index: 1; } - .csw-check { + .csw-status-stage { align-items: center; - background: var(--csw-row); - border: 1px solid var(--csw-border); - border-radius: 7px; - box-sizing: border-box; - cursor: pointer; display: flex; - gap: 8px; - min-height: 34px; - padding: 8px 9px; + height: 28px; + justify-content: center; + position: relative; + width: 58px; + z-index: 1; } - .csw-check input { - accent-color: var(--csw-text); - flex: 0 0 auto; + .csw-source-track { + height: var(--csw-source-track-height, ${CHIP_HEIGHT}px); + left: 50%; + pointer-events: none; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: ${CHIP_WIDTH}px; + z-index: 2; + } + + .csw-fab-eye { + background: currentColor; + border-radius: 999px; + display: block; height: 14px; - margin: 0; - width: 14px; + position: relative; + transform: translate3d(var(--csw-eye-x, 0px), var(--csw-eye-y, 0px), 0); + transform-origin: center; + transition: background 170ms ease, border-color 170ms ease, height 170ms ease, transform 170ms ease, width 170ms ease; + will-change: transform; + width: 8px; } - .csw-check span { - color: var(--csw-text); - font-size: 12px; - font-weight: 650; - line-height: 1.35; + .csw-fab-happy-arc { + display: none; + height: 100%; + overflow: visible; + width: 100%; } - .csw-actions { - display: flex; - gap: 7px; - padding-top: 2px; + .csw-fab-happy-arc path { + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2.6; + vector-effect: non-scaling-stroke; } - .csw-settings-actions { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - padding-top: 0; + :is(.csw-fab, .csw-head-face)[data-expression="idle"] .csw-fab-eye { + animation: csw-face-blink 4.8s infinite; } - .csw-primary, - .csw-secondary { - appearance: none; - border-radius: 6px; - cursor: pointer; - font: 700 12px/1 -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif; - height: 31px; - padding: 0 11px; + :is(.csw-fab, .csw-head-face)[data-expression="answering"] .csw-fab-eye { + height: 13px; + transition-duration: 70ms; } - .csw-primary { - background: var(--csw-text); + :is(.csw-fab, .csw-head-face)[data-expression="surprise"] .csw-fab-eye { + animation: csw-face-star 1.25s ease-in-out infinite; + background: currentColor; border: 0; - color: var(--csw-bg); + border-radius: 0; + clip-path: polygon(50% 0, 61% 36%, 100% 50%, 61% 64%, 50% 100%, 39% 64%, 0 50%, 39% 36%); + height: 18px; + width: 18px; + } + + :is(.csw-fab, .csw-head-face)[data-expression="generating"] .csw-fab-eye { + animation: csw-face-generate-bob .92s cubic-bezier(.45, 0, .2, 1) infinite; + animation-delay: 0s; + height: 14px; + width: 8px; } - .csw-secondary { + :is(.csw-fab, .csw-head-face)[data-expression="ready"] .csw-fab-eye { + animation: csw-face-happy-lift 1.8s ease-in-out infinite; background: transparent; - border: 1px solid var(--csw-border); - color: var(--csw-text); + border: 0; + border-radius: 0; + height: 12px; + width: 18px; } - .csw-secondary:hover, - .csw-secondary:focus-visible { - background: var(--csw-soft); - outline: none; + :is(.csw-fab, .csw-head-face)[data-expression="ready"] .csw-fab-happy-arc { + display: block; } - .csw-primary:disabled, - .csw-secondary:disabled { - cursor: not-allowed; - opacity: .46; + :is(.csw-fab, .csw-head-face)[data-expression="ready"] .csw-fab-eye::before, + :is(.csw-fab, .csw-head-face)[data-expression="ready"] .csw-fab-eye::after { + content: none; } - .csw-status { - color: var(--csw-muted); - font-size: 11px; - min-height: 16px; + :is(.csw-fab, .csw-head-face)[data-expression="empty"] .csw-fab-eye { + animation: csw-face-calm-breathe 3.6s ease-in-out infinite; + height: 3px; + width: 16px; } - .csw-notice { - color: var(--csw-muted); - font-size: 11px; - line-height: 1.4; - min-height: 15px; + :is(.csw-fab, .csw-head-face)[data-expression="error"] .csw-fab-eye { + animation: csw-face-error-breathe 3.8s ease-in-out infinite; + background: transparent; + color: var(--csw-text); + height: 14px; + width: 14px; } - `; - document.head.appendChild(style); - } + :is(.csw-fab, .csw-head-face)[data-expression="error"] .csw-fab-eye::before, + :is(.csw-fab, .csw-head-face)[data-expression="error"] .csw-fab-eye::after { + background: currentColor; + border-radius: 999px; + content: ""; + height: 2.5px; + left: 0; + position: absolute; + top: 5.75px; + width: 14px; + } - function defaultPosition() { - return clampPosition({ - x: window.innerWidth - 76, - y: window.innerHeight - 174, - }); - } + :is(.csw-fab, .csw-head-face)[data-expression="error"] .csw-fab-eye::before { + transform: rotate(45deg); + } - function savedPosition() { - try { - const parsed = JSON.parse(localStorage.getItem(POSITION_KEY) || "null"); - if (Number.isFinite(parsed?.x) && Number.isFinite(parsed?.y)) return clampPosition(parsed); - } catch {} - return defaultPosition(); - } + :is(.csw-fab, .csw-head-face)[data-expression="error"] .csw-fab-eye::after { + transform: rotate(-45deg); + } - function clampPosition(position) { - const margin = 12; - const size = 44; - return { - x: clamp(Number(position?.x) || 0, margin, Math.max(margin, window.innerWidth - size - margin)), - y: clamp(Number(position?.y) || 0, margin, Math.max(margin, window.innerHeight - size - margin)), - }; - } + :is(.csw-fab, .csw-head-face)[data-expression="curious"] .csw-fab-eye { + animation: none; + background: transparent; + border: 3px solid currentColor; + border-radius: 50%; + clip-path: none; + height: 17px; + width: 17px; + } - function savePosition(position) { - state.position = clampPosition(position); - localStorage.setItem(POSITION_KEY, JSON.stringify(state.position)); - applyPosition(); - } + :is(.csw-fab, .csw-head-face)[data-expression="curious"] .csw-fab-eye::before { + content: none; + } - function applyPosition() { - if (!state.fab || !state.position) return; - state.position = clampPosition(state.position); - state.fab.style.left = `${state.position.x}px`; - state.fab.style.top = `${state.position.y}px`; - positionPopover(); - } + :is(.csw-fab, .csw-head-face)[data-expression="curious"] .csw-fab-eye::after { + animation: none; + background: currentColor; + border-radius: 50%; + content: ""; + height: 5px; + left: 50%; + position: absolute; + top: 50%; + transform: translate(-50%, -50%) translate3d(var(--csw-curious-eye-x, 0px), var(--csw-curious-eye-y, 0px), 0); + transition: transform 90ms cubic-bezier(.2, .8, .2, 1); + width: 5px; + } - function positionPopover() { - if (!state.popover || !state.position) return; - const width = Math.min(380, window.innerWidth - 28); - const measuredHeight = state.popover.offsetHeight || 260; - const height = Math.min(measuredHeight, window.innerHeight - 28); - const margin = 14; - const leftSide = state.position.x > window.innerWidth / 2; - const x = leftSide ? state.position.x - width - 12 : state.position.x + 56; - const y = state.position.y > window.innerHeight / 2 ? state.position.y - height + 44 : state.position.y; - state.popover.style.left = `${clamp(x, margin, Math.max(margin, window.innerWidth - width - margin))}px`; - state.popover.style.top = `${clamp(y, margin, Math.max(margin, window.innerHeight - height - margin))}px`; - } + .csw-fab-badge { + display: none; + } - function installFloat() { - if (!isCurrentInstance()) return; - document.querySelectorAll?.(`[${ROOT_ATTR}="true"]`).forEach((node) => { - if (node !== state.root) node.remove(); - }); - if (state.root && document.body.contains(state.root)) return; + .csw-fab[data-count="0"] .csw-fab-badge { + display: none; + } - state.position = savedPosition(); - state.root = document.createElement("div"); - state.root.setAttribute(ROOT_ATTR, "true"); + .csw-fab:not([data-expression="ready"]) .csw-fab-badge { + display: none; + } - state.fab = document.createElement("button"); - state.fab.className = "csw-fab"; - state.fab.type = "button"; - state.fab.title = "Stepwise"; - state.fab.innerHTML = `0`; + .csw-panel { + -webkit-backdrop-filter: none !important; + -webkit-filter: none !important; + backdrop-filter: none !important; + border-radius: ${PANEL_RADIUS}px; + box-sizing: border-box; + container-name: csw-panel; + container-type: inline-size; + display: flex; + filter: none !important; + flex-direction: column; + height: 100%; + opacity: 0; + overflow: hidden; + pointer-events: none; + position: absolute; + inset: 0; + visibility: hidden; + will-change: clip-path; + z-index: 2; + } - state.popover = document.createElement("div"); - state.popover.className = "csw-popover"; + .csw-panel *, + .csw-panel *::before, + .csw-panel *::after { + -webkit-backdrop-filter: none !important; + -webkit-filter: none !important; + backdrop-filter: none !important; + filter: none !important; + } - state.root.append(state.fab, state.popover); - document.body.appendChild(state.root); + .csw-popover[data-open="true"][data-morphing="false"] .csw-panel { + opacity: 1; + pointer-events: auto; + visibility: visible; + } - state.fab.addEventListener("pointerdown", onFabPointerDown); - state.fab.addEventListener("click", onFabClick); - window.addEventListener("resize", onResize); - installThemeObserver(); - syncTheme(); - applyPosition(); - renderFloat(); - } + .csw-popover[data-morphing="true"] .csw-panel { + opacity: 1; + pointer-events: none; + visibility: visible; + } - function onResize() { - if (!state.position) return; - state.position = clampPosition(state.position); - applyPosition(); - } + .csw-head { + align-items: center; + cursor: grab; + display: grid; + flex: 0 0 auto; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + min-height: 48px; + padding: 8px 10px; + touch-action: none; + user-select: none; + } + + .csw-head[data-dragging="true"] { + cursor: grabbing; + } + + .csw-head-side { + align-items: center; + cursor: default; + display: flex; + min-width: 0; + opacity: 0; + pointer-events: auto; + transform: translateY(-2px) scale(.98); + transition: + opacity .15s cubic-bezier(.23, 1, .32, 1), + transform .15s cubic-bezier(.23, 1, .32, 1); + will-change: opacity, transform; + } + + .csw-head-side .csw-icon { + pointer-events: none; + } + + .csw-popover[data-open="true"][data-morphing="false"] .csw-head:hover .csw-head-side, + .csw-popover[data-open="true"][data-morphing="false"] .csw-head:has(:focus-visible) .csw-head-side, + .csw-head[data-dragging="true"] .csw-head-side { + opacity: 1; + transform: translateY(0) scale(1); + } + + .csw-popover[data-open="true"][data-morphing="false"] .csw-head:hover .csw-head-side .csw-icon, + .csw-popover[data-open="true"][data-morphing="false"] .csw-head:has(:focus-visible) .csw-head-side .csw-icon { + pointer-events: auto; + } + + .csw-head-left { + justify-content: flex-start; + } + + .csw-head-right { + align-items: center; + display: flex; + gap: 2px; + justify-content: flex-end; + } + + .csw-head-face { + align-items: center; + appearance: none; + background: transparent; + border: 0; + border-radius: 999px; + color: var(--csw-text); + cursor: grab; + display: flex; + height: 32px; + justify-content: center; + padding: 0; + position: relative; + touch-action: none; + transition: + background-color 140ms ease, + transform 140ms cubic-bezier(.23, 1, .32, 1); + user-select: none; + width: ${CHIP_WIDTH}px; + } + + .csw-head-face:hover { + background: color-mix(in srgb, var(--csw-text) 4%, transparent); + } + + .csw-head-face:active { + background: color-mix(in srgb, var(--csw-text) 6%, transparent); + transform: scale(.97); + } + + .csw-source-dot { + background: color-mix(in srgb, var(--csw-text) 72%, transparent); + border-radius: 999px; + box-shadow: none; + height: 4px; + left: var(--csw-source-x, 50%); + opacity: .72; + pointer-events: none; + position: absolute; + top: var(--csw-source-y, 50%); + transform: translate(-50%, -50%); + transition: opacity .15s ease; + width: 4px; + } + + .csw-source-dot[data-direction="single"] { opacity: 0; } + + :is(.csw-fab, .csw-head-face)[data-expression="generating"] .csw-source-track { + opacity: 0; + } + + .csw-head[data-dragging="true"] .csw-head-face { + cursor: grabbing; + } + + .csw-head-face:focus-visible { + background: color-mix(in srgb, var(--csw-text) 5%, transparent); + } + + .csw-popover[data-morphing="true"] .csw-head-face { + opacity: 0; + visibility: hidden; + } + + .csw-tabs { + align-items: center; + display: flex; + gap: 2px; + } + + .csw-view-tabs { + background: color-mix(in srgb, var(--csw-text) 3.5%, transparent); + border-radius: 10px; + isolation: isolate; + padding: 2px; + position: relative; + } + + .csw-view-indicator { + background: color-mix(in srgb, var(--csw-surface-opaque) 74%, transparent); + border-radius: 8px; + height: 28px; + left: 2px; + opacity: 0; + pointer-events: none; + position: absolute; + top: 2px; + transform: translate3d(0, 0, 0); + transition: + transform ${VIEW_INDICATOR_MS}ms cubic-bezier(.23, 1, .32, 1), + opacity 110ms cubic-bezier(.23, 1, .32, 1); + width: 28px; + will-change: opacity, transform; + z-index: 0; + } + + .csw-icon { + align-items: center; + appearance: none; + background: transparent; + border: 0; + border-radius: 8px; + color: var(--csw-muted); + cursor: pointer; + display: inline-flex; + font: inherit; + font-size: var(--csw-chrome-font); + font-weight: var(--csw-label-weight, 500); + height: 28px; + justify-content: center; + padding: 0; + transition: background-color 140ms ease-out, color 140ms ease-out, transform 140ms cubic-bezier(.23, 1, .32, 1); + width: 28px; + } + + .csw-icon:active { + transform: scale(.94); + } + + .csw-view-tabs .csw-icon { + position: relative; + transform: scale(1); + transition: + color ${VIEW_INDICATOR_MS}ms cubic-bezier(.23, 1, .32, 1), + transform ${VIEW_INDICATOR_MS}ms cubic-bezier(.23, 1, .32, 1); + z-index: 1; + } + + .csw-view-tabs .csw-icon:active { + transform: scale(.9); + } + + .csw-icon[data-active="true"], + .csw-icon:hover { + background: var(--csw-hover); + color: var(--csw-text); + } + + .csw-view-tabs .csw-icon[data-active="true"] { + background: transparent; + box-shadow: none; + transform: scale(1); + } + + .csw-icon:disabled { + cursor: not-allowed; + opacity: .42; + } + + .csw-icon svg { + display: block; + height: var(--csw-icon-font); + transform-origin: center; + width: var(--csw-icon-font); + } + + .csw-icon[data-view="next"] svg { + transform: scale(1.08); + } + + .csw-icon[data-action="refresh"] svg { + transform: scale(.95); + } + + .csw-icon[data-view="settings"] svg { + transform: scale(.86); + } + + .csw-body { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: auto; + overflow-anchor: none; + padding: 2px 16px 14px; + position: relative; + scrollbar-color: color-mix(in srgb, var(--csw-text) 18%, transparent) transparent; + scrollbar-gutter: stable; + scrollbar-width: thin; + } + + .csw-mouth-stage { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 100%; + transform-origin: 50% 0; + will-change: opacity, transform; + } + + .csw-body[data-view-transition="true"] { + overflow: hidden; + } + + .csw-view-transition-layer { + inset: 0; + overflow: hidden; + pointer-events: none; + position: absolute; + z-index: 2; + } + + .csw-view-transition-copy { + left: 16px; + margin: 0; + pointer-events: none; + position: absolute; + right: 16px; + } + + .csw-mouth-stage[data-mouth-stage="settings"] { + height: 100%; + } + + .csw-body[data-view-body="next"] { + overflow: auto; + } + + .csw-popover[data-content-fade="true"] .csw-body[data-view-body="next"], + .csw-popover[data-content-fade="true"] .csw-body[data-view-body="outline"] { + -webkit-mask-image: linear-gradient( + to bottom, + #000 0, + #000 max(0px, calc(100% - var(--csw-content-fade-size, 24px))), + transparent 100% + ); + mask-image: linear-gradient( + to bottom, + #000 0, + #000 max(0px, calc(100% - var(--csw-content-fade-size, 24px))), + transparent 100% + ); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + } + + .csw-mouth-stage[data-mouth-stage="next"] { + height: auto; + min-height: 100%; + } + + .csw-next-layout { + display: grid; + flex: 1 1 auto; + gap: 16px; + grid-template-rows: max-content minmax(clamp(168px, 28vh, 240px), auto); + height: auto; + min-height: 100%; + width: 100%; + } + + .csw-next-layout::after { + content: ""; + height: 8px; + } + + .csw-list { + display: grid; + align-content: start; + align-self: start; + flex: 0 0 auto; + gap: 6px; + grid-auto-rows: max-content; + height: max-content; + min-height: max-content; + overflow: visible; + padding: 4px 2px 6px; + width: 100%; + } + + .csw-row { + align-items: start; + appearance: none; + background: transparent; + border: 0; + border-top: 0; + border-radius: 13px; + color: inherit; + cursor: pointer; + display: grid; + gap: 12px; + grid-template-columns: minmax(0, 1fr) 18px; + isolation: isolate; + box-sizing: border-box; + min-height: 64px; + min-width: 0; + overflow: hidden; + padding: 13px 10px; + position: relative; + text-align: left; + transition: background 140ms ease-out, color 140ms ease-out, transform 90ms ease-out; + width: 100%; + } + + .csw-row:active { + transform: scale(.985); + } + + .csw-row::before { + background: var(--csw-row-surface); + border-radius: inherit; + content: ""; + inset: 0; + opacity: 0; + pointer-events: none; + position: absolute; + transition: background 180ms ease-out, opacity 160ms ease-out; + z-index: -1; + } + + .csw-popover[data-material="frosted"] { + --csw-row-surface: color-mix(in srgb, var(--csw-surface-opaque) 28%, transparent); + --csw-row-selected: color-mix(in srgb, var(--csw-accent) 8.5%, transparent); + } + + .csw-popover[data-material="clear"] { + --csw-row-surface: color-mix(in srgb, var(--csw-text) 3.5%, transparent); + --csw-row-selected: color-mix(in srgb, var(--csw-accent) 8%, transparent); + } + + .csw-popover[data-material="liquid"], + .csw-popover[data-material="crystal"] { + --csw-row-surface: color-mix(in srgb, var(--csw-text) 5%, transparent); + --csw-row-selected: color-mix(in srgb, var(--csw-accent) 9%, transparent); + } + + .csw-popover[data-material="matte"] { + --csw-row-surface: color-mix(in srgb, var(--csw-surface-opaque) 82%, transparent); + --csw-row-selected: color-mix(in srgb, var(--csw-accent) 7%, transparent); + } + + .csw-row:first-child { + border-top: 0; + } + + .csw-row:hover, + .csw-row:focus-visible, + .csw-row:focus-within { + background: transparent; + color: var(--csw-text); + outline: 1px solid color-mix(in srgb, var(--csw-text) 12%, transparent); + outline-offset: -1px; + } + + .csw-row:hover::before, + .csw-row:focus-visible::before, + .csw-row:focus-within::before { + opacity: 1; + } + + .csw-row[data-preview-active="true"] { + background: transparent; + color: var(--csw-text); + outline: 1px solid color-mix(in srgb, var(--csw-accent) 34%, transparent); + outline-offset: -1px; + } + + .csw-row[data-preview-active="true"]::before { + background: var(--csw-row-selected); + opacity: 1; + } + + .csw-row:active { + transform: scale(.992); + } + + .csw-row-copy { + display: block; + min-width: 0; + overflow: hidden; + } + + .csw-row-label { + color: var(--csw-text); + display: block; + font-size: var(--csw-item-font); + font-weight: var(--csw-label-weight, 600); + line-height: 1.3; + margin-bottom: 3px; + overflow-wrap: anywhere; + } + + .csw-row-prompt { + color: var(--csw-muted); + display: -webkit-box; + font-size: max(10px, calc(var(--csw-item-font) - 1px)); + line-height: 1.46; + max-height: 2.92em; + overflow: hidden; + overflow-wrap: anywhere; + white-space: normal; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + + .csw-list[data-label-only="true"] .csw-row-prompt { + display: none; + } + + .csw-list[data-label-only="true"] .csw-row { + align-items: center; + min-height: 44px; + padding-block: 10px; + } + + .csw-list[data-label-only="true"] .csw-row-label { + margin-bottom: 0; + } + + .csw-list[data-label-only="true"] .csw-row-arrow { + align-self: center; + } + + .csw-row-arrow { + color: var(--csw-faint); + font-size: 17px; + line-height: 1; + text-align: center; + transition: color 160ms ease, transform 160ms ease; + } + + .csw-row:hover .csw-row-arrow, + .csw-row:focus-visible .csw-row-arrow, + .csw-row[data-preview-active="true"] .csw-row-arrow { + color: var(--csw-accent); + transform: translateX(2px); + } + + .csw-prompt-preview { + --csw-prompt-edge-fade-size: clamp(36px, 16%, 64px); + background: transparent; + border: 0; + border-radius: 20px; + box-shadow: none; + isolation: isolate; + min-height: 0; + overflow: hidden; + position: relative; + } + + .csw-prompt-preview::before { + -webkit-mask-image: linear-gradient( + to bottom, + #000 0, + #000 calc(100% - var(--csw-prompt-edge-fade-size)), + transparent 100% + ); + -webkit-mask-repeat: no-repeat; + background: + linear-gradient(180deg, color-mix(in srgb, var(--csw-text) 4.5%, transparent), transparent), + color-mix(in srgb, var(--csw-surface-opaque) 22%, transparent); + border: 1px solid color-mix(in srgb, var(--csw-text) 7%, transparent); + border-radius: inherit; + box-sizing: border-box; + content: ""; + inset: 0; + mask-image: linear-gradient( + to bottom, + #000 0, + #000 calc(100% - var(--csw-prompt-edge-fade-size)), + transparent 100% + ); + mask-repeat: no-repeat; + pointer-events: none; + position: absolute; + z-index: 0; + } + + .csw-prompt-preview-scroll { + -webkit-mask-image: none; + height: 100%; + mask-image: none; + overflow: auto; + overscroll-behavior: contain; + padding: 16px 18px 28px; + position: relative; + scrollbar-color: color-mix(in srgb, var(--csw-text) 20%, transparent) transparent; + scrollbar-width: thin; + z-index: 1; + } + + .csw-prompt-preview[data-scroll-fade="true"] .csw-prompt-preview-scroll { + -webkit-mask-image: linear-gradient( + to bottom, + #000 0, + #000 max(0px, calc(100% - 18px)), + transparent 100% + ); + mask-image: linear-gradient( + to bottom, + #000 0, + #000 max(0px, calc(100% - 18px)), + transparent 100% + ); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + } + + .csw-prompt-preview-content { + opacity: 1; + transform: translateY(0); + transition: + opacity 120ms ease-out, + transform 150ms cubic-bezier(.22, .8, .2, 1); + } + + .csw-prompt-preview[data-switching="true"] .csw-prompt-preview-content { + opacity: 0; + transform: translateY(4px); + } + + .csw-prompt-preview-kicker { + color: var(--csw-accent); + display: block; + font-size: max(9px, calc(var(--csw-item-font) - 3px)); + font-weight: var(--csw-label-weight, 600); + letter-spacing: .045em; + line-height: 1.2; + margin-bottom: 7px; + } + + .csw-prompt-preview-title { + color: var(--csw-text); + display: block; + font-size: clamp(12px, calc(var(--csw-item-font) + 1px), 25px); + font-weight: var(--csw-label-weight, 600); + letter-spacing: -.012em; + line-height: 1.35; + margin-bottom: 9px; + } + + .csw-prompt-preview-body { + color: var(--csw-muted); + display: block; + font-size: var(--csw-item-font); + line-height: 1.65; + overflow-wrap: anywhere; + white-space: pre-wrap; + } + + .csw-empty { + align-items: center; + background: transparent; + border: 0; + border-top: 0; + border-radius: 0; + color: var(--csw-muted); + display: grid; + flex: 1 1 auto; + align-content: center; + justify-items: center; + min-height: 0; + min-width: 0; + max-width: 100%; + padding: 24px 12px; + text-align: center; + } + + .csw-empty-title { + color: var(--csw-text); + font-size: clamp(12px, calc(var(--csw-item-font) + 1px), 25px); + font-weight: 720; + line-height: 1.25; + max-width: 100%; + min-width: 0; + overflow-wrap: anywhere; + } + + .csw-empty[data-state="manual"] .csw-empty-title { + color: var(--csw-muted); + font-size: clamp(11px, var(--csw-item-font), 20px); + font-weight: 500; + letter-spacing: 0.01em; + } + + .csw-progress { + align-items: center; + color: var(--csw-muted); + display: flex; + flex: 1 1 auto; + gap: clamp(10px, calc(var(--csw-item-font) - 1px), 18px); + justify-content: center; + min-height: 0; + min-width: 0; + max-width: 100%; + padding: 16px 5px; + } + + .csw-progress-ring { + animation: csw-progress-spin .82s linear infinite; + border: 2px solid color-mix(in srgb, var(--csw-text) 11%, transparent); + border-radius: 999px; + border-top-color: var(--csw-accent); + flex: 0 0 auto; + height: clamp(18px, calc(var(--csw-item-font) + 7px), 31px); + width: clamp(18px, calc(var(--csw-item-font) + 7px), 31px); + } + + .csw-progress-copy { + display: grid; + gap: 2px; + min-width: 0; + max-width: 100%; + } + + .csw-progress-title { + animation: csw-progress-text-shimmer 1.8s linear infinite; + background-image: linear-gradient( + 90deg, + color-mix(in srgb, var(--csw-text) 62%, var(--csw-muted)) 34%, + var(--csw-text) 50%, + color-mix(in srgb, var(--csw-text) 62%, var(--csw-muted)) 66% + ); + background-position: 100% 50%; + background-size: 220% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + font-size: clamp(11px, var(--csw-item-font), 24px); + font-weight: var(--csw-label-weight, 600); + line-height: 1.25; + overflow-wrap: anywhere; + -webkit-text-fill-color: transparent; + } + + .csw-outline-list { + display: grid; + } + + .csw-outline-row { + appearance: none; + background: transparent; + border: 0; + border-radius: 13px; + box-sizing: border-box; + color: var(--csw-text); + cursor: pointer; + display: block; + isolation: isolate; + min-height: 38px; + padding: 8px 12px 8px calc(12px + var(--csw-outline-indent, 0px)); + position: relative; + text-align: left; + transition: color 140ms ease-out, transform 90ms ease-out; + width: 100%; + } + + .csw-outline-row::before { + background: var(--csw-row-surface); + border-radius: inherit; + content: ""; + inset: 0; + opacity: 0; + pointer-events: none; + position: absolute; + transition: background 180ms ease-out, opacity 160ms ease-out; + z-index: -1; + } + + .csw-outline-row:first-child { + border-top: 0; + } + + .csw-outline-row:hover, + .csw-outline-row:focus-visible { + background: transparent; + color: var(--csw-text); + outline: 1px solid color-mix(in srgb, var(--csw-text) 12%, transparent); + outline-offset: -1px; + } + + .csw-outline-row:hover::before, + .csw-outline-row:focus-visible::before { + opacity: 1; + } + + .csw-outline-row[data-active="true"] { + background: transparent; + color: var(--csw-text); + outline: 1px solid color-mix(in srgb, var(--csw-accent) 34%, transparent); + outline-offset: -1px; + } + + .csw-outline-row[data-active="true"]::before { + background: var(--csw-row-selected); + opacity: 1; + } + + .csw-outline-row:active { + transform: scale(.992); + } + + .csw-outline-text { + font-size: var(--csw-item-font); + line-height: 1.35; + position: relative; + z-index: 1; + } + + .${HIGHLIGHT_CLASS} { + outline: 2px solid color-mix(in srgb, var(--csw-accent) 70%, transparent) !important; + outline-offset: 4px !important; + border-radius: 6px !important; + transition: outline-color 0.2s ease; + } + + .csw-resize-handle { + appearance: none; + -webkit-appearance: none; + background: none; + border: 0; + bottom: 0; + box-shadow: none; + color: transparent; + cursor: nwse-resize; + display: none; + font-size: 0; + height: 28px; + line-height: 0; + outline: 0; + padding: 0; + pointer-events: auto; + position: absolute; + touch-action: none; + user-select: none; + width: 28px; + z-index: 5; + } + + .csw-popover[data-open="true"][data-morphing="false"] .csw-resize-handle { + display: block; + } + + .csw-popover[data-view="settings"] .csw-resize-handle { + display: none !important; + } + + .csw-resize-handle[data-corner="bl"] { + cursor: nesw-resize; + left: 0; + } + + .csw-resize-handle[data-corner="br"] { + right: 0; + } + + .csw-settings { + display: grid; + height: 100%; + min-height: 0; + padding-top: 4px; + } + + .csw-settings-surface { + -webkit-backdrop-filter: blur(18px) saturate(145%); + backdrop-filter: blur(18px) saturate(145%); + background: + linear-gradient( + 180deg, + color-mix(in srgb, #000 2%, transparent) 0%, + transparent 16%, + transparent 82%, + color-mix(in srgb, #fff 7%, transparent) 100% + ), + color-mix(in srgb, var(--csw-surface-opaque) 82%, transparent); + border: 1px solid color-mix(in srgb, var(--csw-text) 6%, transparent); + border-radius: 22px; + box-shadow: none; + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + min-height: 0; + overflow: hidden; + } + + [${ROOT_ATTR}="true"][data-theme="dark"] .csw-settings-surface { + background: + linear-gradient( + 180deg, + color-mix(in srgb, #000 8%, transparent) 0%, + transparent 18%, + transparent 82%, + color-mix(in srgb, #fff 3%, transparent) 100% + ), + color-mix(in srgb, var(--csw-surface-opaque) 76%, transparent); + border-color: color-mix(in srgb, #fff 6%, transparent); + box-shadow: none; + } + + .csw-popover[data-material="clear"] .csw-settings-surface { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: transparent; + border-color: color-mix(in srgb, var(--csw-glass-edge-hi) 24%, var(--csw-glass-edge)); + box-shadow: none; + } + + .csw-popover[data-material="liquid"] .csw-settings-surface, + .csw-popover[data-material="crystal"] .csw-settings-surface { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.14); + box-shadow: none; + } + + [${ROOT_ATTR}="true"][data-theme="dark"] .csw-popover[data-material="liquid"] .csw-settings-surface, + [${ROOT_ATTR}="true"][data-theme="dark"] .csw-popover[data-material="crystal"] .csw-settings-surface { + background: rgba(20, 24, 30, 0.28); + border-color: rgba(255, 255, 255, 0.11); + } + + [${ROOT_ATTR}="true"][data-theme="dark"] .csw-popover[data-material="clear"] .csw-settings-surface { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: transparent; + border-color: color-mix(in srgb, rgba(205, 228, 255, 0.5) 20%, var(--csw-glass-edge)); + box-shadow: none; + } + + .csw-popover[data-material="matte"] .csw-settings-surface { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: + linear-gradient( + 180deg, + color-mix(in srgb, #000 1.5%, transparent) 0%, + transparent 18%, + transparent 82%, + color-mix(in srgb, #fff 5%, transparent) 100% + ), + color-mix(in srgb, var(--csw-surface-opaque) 98%, transparent); + } + + [${ROOT_ATTR}="true"][data-theme="dark"] .csw-popover[data-material="matte"] .csw-settings-surface { + background: + linear-gradient( + 180deg, + color-mix(in srgb, #000 8%, transparent) 0%, + transparent 18%, + transparent 82%, + color-mix(in srgb, #fff 3%, transparent) 100% + ), + color-mix(in srgb, var(--csw-surface-opaque) 96%, #000 2%); + } + + .csw-settings-hero { + align-items: center; + display: grid; + gap: 18px; + grid-template-columns: minmax(0, 1fr) minmax(230px, 238px); + min-height: 0; + padding: 18px 18px 14px; + } + + .csw-model-pane { + align-self: center; + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; + padding: 8px 6px; + } + + .csw-metric-label, + .csw-control-label { + color: var(--csw-muted); + font-size: 11px; + font-weight: var(--csw-label-weight, 500); + letter-spacing: .015em; + } + + .csw-metric-label { + font-synthesis: none; + font-weight: 400; + } + + .csw-model-value { + color: var(--csw-text); + font-size: 34px; + font-weight: 580; + letter-spacing: -.035em; + line-height: 1.08; + margin-top: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .csw-settings-surface[data-loading="true"] .csw-model-value { + color: var(--csw-muted); + font-size: 24px; + letter-spacing: -.02em; + } + + .csw-runtime-line { + align-items: center; + color: var(--csw-muted); + display: flex; + font-size: 12px; + gap: 7px; + margin-top: 10px; + min-width: 0; + } + + .csw-runtime-copy { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .csw-runtime-dot { + background: var(--csw-faint); + border-radius: 999px; + flex: 0 0 auto; + height: 7px; + width: 7px; + } + + .csw-runtime-dot[data-tone="busy"] { + animation: csw-status-breathe 1.2s ease-in-out infinite; + background: var(--csw-accent); + } + + .csw-runtime-dot[data-tone="ready"] { + background: var(--csw-ready); + } + + .csw-runtime-dot[data-tone="error"] { + background: var(--csw-danger); + } + + .csw-runtime-grid { + align-items: center; + display: flex; + gap: 14px; + min-width: 0; + padding: 0; + } + + .csw-click-mode, + .csw-generation-mode { + align-items: baseline; + display: inline-flex; + flex: 0 0 auto; + gap: 6px; + max-width: 100%; + min-width: max-content; + position: relative; + } + + .csw-click-mode { + flex: 1 1 auto; + } + + .csw-metric { + align-items: baseline; + display: inline-flex; + gap: 6px; + min-width: 0; + padding: 0; + } + + .csw-metric-value, + .csw-metric-action { + color: color-mix(in srgb, var(--csw-text) 76%, transparent); + font-size: 11px; + font-synthesis: none; + font-variant-numeric: tabular-nums; + font-weight: 400; + letter-spacing: .005em; + line-height: 1; + overflow: visible; + text-overflow: clip; + white-space: nowrap; + } + + .csw-metric-value[data-enabled="true"], + .csw-metric-action { + color: var(--csw-text); + } + + button.csw-metric-action { + appearance: none; + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + color: var(--csw-text); + cursor: pointer; + font: inherit; + margin: 0; + max-width: 100%; + outline: none; + padding: 0; + text-align: left; + } + + button.csw-metric-action:hover, + button.csw-metric-action:focus-visible { + background: transparent; + color: color-mix(in srgb, var(--csw-text) 88%, var(--csw-accent) 12%); + } + + button.csw-metric-action:active { + color: color-mix(in srgb, var(--csw-text) 78%, var(--csw-accent) 22%); + } + + button.csw-metric-action:disabled { + cursor: not-allowed; + opacity: .34; + } + + .csw-control-deck { + align-self: center; + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + display: grid; + gap: 10px; + grid-auto-rows: 32px; + justify-self: end; + min-width: 0; + overflow: visible; + padding: 0; + width: 238px; + } + + .csw-settings-footer { + align-items: center; + background: transparent; + border: 0; + border-radius: 0; + border-top: 1px solid color-mix(in srgb, var(--csw-text) 8%, transparent); + box-shadow: none; + display: grid; + gap: 12px; + grid-template-columns: minmax(0, 1fr) auto; + margin: 0 18px 12px; + min-height: 50px; + overflow: visible; + padding: 9px 2px 0; + } + + .csw-control-group { + align-items: center; + display: grid; + gap: 10px; + grid-template-columns: 76px minmax(0, 152px); + min-width: 0; + padding: 0; + } + + .csw-control-group + .csw-control-group { + border-top: 0; + } + + .csw-control-label { + align-self: center; + font-size: 12px; + line-height: 1; + text-align: left; + } + + .csw-control-row, + .csw-stepper { + align-items: center; + box-sizing: border-box; + justify-self: end; + min-width: 0; + width: 152px; + } + + .csw-control-row { + background: color-mix(in srgb, var(--csw-text) 2%, transparent); + border: 1px solid color-mix(in srgb, var(--csw-text) 7%, transparent); + border-radius: 11px; + box-shadow: none; + display: grid; + gap: 0; + grid-template-columns: minmax(0, 1fr); + height: 32px; + overflow: hidden; + } + + .csw-control-button, + .csw-step-button, + .csw-command-button { + appearance: none; + background: transparent; + border: 0; + color: var(--csw-text); + cursor: pointer; + font: inherit; + } + + .csw-control-button { + align-items: center; + border-radius: 0; + display: flex; + font-size: 13px; + font-weight: var(--csw-label-weight, 500); + gap: 5px; + height: 30px; + justify-content: center; + line-height: 30px; + max-width: none; + overflow: hidden; + padding: 0 8px; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; + } + + .csw-stepper { + display: grid; + background: color-mix(in srgb, var(--csw-text) 2%, transparent); + border: 1px solid color-mix(in srgb, var(--csw-text) 7%, transparent); + border-radius: 11px; + box-shadow: none; + grid-template-columns: 30px minmax(0, 1fr) 30px; + height: 32px; + overflow: hidden; + } + + .csw-step-button { + align-items: center; + border-radius: 0; + color: var(--csw-muted); + display: flex; + font-size: 18px; + height: 30px; + justify-content: center; + line-height: 1; + min-width: 30px; + padding: 0; + } + + .csw-step-value { + align-items: center; + border-left: 1px solid var(--csw-divider); + border-right: 1px solid var(--csw-divider); + color: var(--csw-text); + display: flex; + font-size: 13px; + font-variant-numeric: tabular-nums; + font-weight: 620; + justify-content: center; + min-width: 0; + text-align: center; + } + + .csw-control-button:hover, + .csw-step-button:hover, + .csw-command-button:hover { + background: var(--csw-hover); + } + + .csw-command-deck { + align-items: center; + display: flex; + gap: 3px; + min-width: 0; + padding-left: 0; + } + + .csw-command-button { + align-items: center; + border-radius: 7px; + color: var(--csw-muted); + display: flex; + gap: 5px; + justify-content: center; + height: 30px; + margin: 0; + min-width: 0; + padding: 0 7px; + } + + .csw-command-button:hover { + color: var(--csw-text); + } + + .csw-command-button:disabled, + .csw-step-button:disabled { + cursor: not-allowed; + opacity: .32; + } + + .csw-step-button:disabled:hover { + background: transparent; + } + + .csw-command-icon { + align-items: center; + display: flex; + height: 17px; + justify-content: center; + width: 17px; + } + + .csw-command-icon svg { + height: 16px; + width: 16px; + } + + .csw-command-icon[data-busy="true"] svg { + animation: csw-progress-spin .9s linear infinite; + } + + .csw-command-label { + font-size: 12px; + line-height: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .csw-settings-notice { + align-items: center; + color: var(--csw-muted); + display: flex; + font-size: 11px; + grid-column: 1 / -1; + line-height: 1.4; + max-width: 100%; + min-height: 22px; + min-width: 0; + overflow-wrap: anywhere; + padding: 2px 2px 0; + white-space: normal; + } + + .csw-settings-notice[data-tone="warn"] { + color: color-mix(in srgb, var(--csw-danger) 78%, var(--csw-muted)); + } + + .csw-icon:focus-visible, + .csw-head-face:focus-visible, + .csw-control-button:focus-visible, + .csw-metric-action:focus-visible, + .csw-step-button:focus-visible, + .csw-command-button:focus-visible, + .csw-row:focus-visible { + outline: 2px solid color-mix(in srgb, var(--csw-accent) 72%, transparent); + outline-offset: 2px; + } + + @container csw-panel (max-width: 440px) { + .csw-list { + padding-inline: 0; + } + + .csw-row { + gap: 8px; + grid-template-columns: minmax(0, 1fr) 16px; + padding: 11px 8px; + } + + .csw-row-arrow { + font-size: 16px; + } + + .csw-prompt-preview-scroll { + padding: 14px 14px 26px; + } + + .csw-settings-hero { + align-content: start; + gap: 10px; + grid-template-columns: 1fr; + grid-template-rows: auto auto; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + padding: 12px 14px 10px; + scrollbar-color: color-mix(in srgb, var(--csw-text) 16%, transparent) transparent; + scrollbar-width: thin; + } + + .csw-model-pane { + align-self: start; + min-height: 72px; + padding: 3px 4px 1px; + } + + .csw-model-value { + font-size: 27px; + } + + .csw-settings-surface[data-loading="true"] .csw-model-value { + font-size: 20px; + } + + .csw-runtime-line { + font-size: 11px; + margin-top: 6px; + } + + .csw-control-deck { + gap: 8px; + justify-self: stretch; + min-height: 0; + width: 100%; + } + + .csw-control-group { + gap: 8px; + grid-template-columns: minmax(76px, auto) minmax(0, 1fr); + } + + .csw-control-label { + font-size: 11px; + } + + .csw-control-row, + .csw-stepper { + width: 100%; + } + + .csw-settings-footer { + column-gap: 8px; + display: grid; + grid-template-columns: minmax(max-content, 1fr) auto; + min-height: 0; + padding: 8px 0 0; + row-gap: 6px; + } + + .csw-runtime-grid { + display: grid; + gap: 12px; + grid-template-columns: max-content max-content; + justify-content: flex-start; + min-height: 30px; + width: max-content; + } + + .csw-generation-mode, + .csw-click-mode { + min-width: max-content; + } + + .csw-command-deck { + gap: 2px; + justify-content: flex-end; + } + + .csw-command-button { + flex: 0 0 30px; + height: 30px; + padding: 0; + width: 30px; + } + + .csw-command-label { + display: none; + } + + .csw-settings-notice { + grid-column: 1 / -1; + min-width: 0; + padding-top: 0; + } + } + + @container csw-panel (max-width: 404px) { + .csw-settings-footer { + margin: 0 14px 10px; + } + } + + @container csw-panel (max-width: 360px) { + .csw-click-mode .csw-metric-label { + display: none; + } + } + + @container csw-panel (max-width: 320px) { + .csw-row { + gap: 6px; + grid-template-columns: minmax(0, 1fr) 14px; + padding-inline: 6px; + } + + .csw-prompt-preview { + border-radius: 16px; + } + + .csw-settings-footer { + column-gap: 6px; + margin: 0 12px 10px; + } + + .csw-runtime-grid { + gap: 8px; + } + + .csw-metric { + gap: 5px; + white-space: nowrap; + } + + .csw-metric-label { + display: none; + } + + .csw-command-deck { + gap: 0; + } + + .csw-command-button { + flex: 0 0 28px; + height: 28px; + width: 28px; + } + } + + @keyframes csw-face-blink { + 0%, 45%, 49%, 100% { transform: scaleY(1); } + 47% { transform: scaleY(0.12); } + } + + @keyframes csw-face-star { + 0%, 100% { transform: scale(.9) rotate(0deg); } + 50% { transform: scale(1.08) rotate(8deg); } + } + + @keyframes csw-face-generate-bob { + 0%, 100% { transform: translate3d(0, 2px, 0) scaleY(.9); } + 50% { transform: translate3d(0, -4px, 0) scaleY(1.06); } + } + + @keyframes csw-face-happy-lift { + 0%, 100% { transform: translate3d(0, 1px, 0) scale(.97); } + 50% { transform: translate3d(0, -1px, 0) scale(1.03); } + } + + @keyframes csw-face-calm-breathe { + 0%, 100% { opacity: .74; transform: scaleX(.94); } + 50% { opacity: 1; transform: scaleX(1); } + } + + @keyframes csw-face-error-breathe { + 0%, 100% { opacity: .78; transform: scale(.96); } + 50% { opacity: 1; transform: scale(1); } + } + + @keyframes csw-status-breathe { + 0%, 100% { opacity: .45; transform: scale(.86); } + 50% { opacity: 1; transform: scale(1); } + } + + @keyframes csw-progress-spin { + to { transform: rotate(360deg); } + } + + @keyframes csw-progress-text-shimmer { + to { background-position: -100% 50%; } + } + + @media (prefers-reduced-motion: reduce) { + .csw-progress-title { + animation: none !important; + background: none; + color: var(--csw-text); + -webkit-text-fill-color: currentColor; + } + + .csw-view-indicator, + .csw-view-tabs .csw-icon { + transition: none !important; + } + } + + @media (max-width: 520px) { + .csw-head { + padding-left: 14px; + padding-right: 14px; + } + + .csw-body { + padding-left: 13px; + padding-right: 13px; + } + + } + + .csw-body, + .csw-prompt-preview-scroll, + .csw-settings-hero { + scrollbar-color: transparent transparent; + scrollbar-gutter: auto; + scrollbar-width: none; + } + + .csw-body::-webkit-scrollbar, + .csw-prompt-preview-scroll::-webkit-scrollbar, + .csw-settings-hero::-webkit-scrollbar { + display: none; + height: 0; + width: 0; + } + + .csw-popover[data-material="clear"], + .csw-popover[data-material="clear"] *, + .csw-popover[data-material="clear"]::before, + .csw-popover[data-material="clear"]::after, + .csw-popover[data-material="clear"] *::before, + .csw-popover[data-material="clear"] *::after { + text-shadow: none !important; + } + + .csw-popover[data-material="matte"] .csw-prompt-preview::before { + background: + linear-gradient(180deg, color-mix(in srgb, var(--csw-text) 2.5%, transparent), transparent), + color-mix(in srgb, var(--csw-surface-opaque) 14%, transparent); + border-color: color-mix(in srgb, var(--csw-text) 4.5%, transparent); + } + + .csw-popover[data-material] .csw-settings-surface { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background: transparent !important; + border: 0 !important; + border-radius: 0 !important; + box-shadow: none !important; + } + + @media (prefers-reduced-motion: reduce) { + .csw-completion-beam, + .csw-completion-beam::before { + animation: none !important; + opacity: 0 !important; + } + + :is(.csw-fab, .csw-head-face) .csw-fab-eye, + :is(.csw-fab, .csw-head-face) .csw-fab-eye::before, + :is(.csw-fab, .csw-head-face) .csw-fab-eye::after { + animation: none !important; + } + + [${ROOT_ATTR}="true"] *, + [${ROOT_ATTR}="true"] *::before, + [${ROOT_ATTR}="true"] *::after { + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + transition-duration: 1ms !important; + } + } + + @keyframes csw-completion-beam-sweep { + 0% { + opacity: 0; + transform: rotate(-64deg); + } + 12% { + opacity: .34; + } + 72% { + opacity: .52; + } + 100% { + opacity: 0; + transform: rotate(296deg); + } + } + + `; + document.head.appendChild(style); + } + + // Derived expressions turn backend, parser, and page states into one calm user-facing status. + function expressionError() { + const settings = state.settings; + const configurationMissing = settings?.enabled === true + && (!settings.baseUrlConfigured || !settings.model || !settings.apiKeyConfigured); + return configurationMissing + || state.bridgeStatus === "failed" + || (state.bridgeStatus === "disabled" && Boolean(state.bridgeError)) + || state.scanStatus === "manual-refresh-no-assistant"; + } + + function stepwiseWaitingForManualRefresh(settings = state.settings) { + return stepwiseEnabled(settings) + && stepwiseGenerationMode(settings) === "manual" + && state.bridgeStatus !== "pending" + && state.bridgeStatus !== "ok" + && !state.prompts.length + && !expressionError(); + } + + function resolveStepwiseExpression(now = Date.now()) { + if (!stepwiseEnabled()) return "hidden"; + if (state.bridgeStatus === "pending") return "generating"; + if (expressionError()) return "error"; + if (stepwiseGenerationMode() === "manual") { + if (state.bridgeStatus === "disabled") return "hidden"; + if (state.prompts.length) return "ready"; + if (state.bridgeStatus === "ok") return "empty"; + return "idle"; + } + if (state.scanBusy) return "answering"; + if (state.surpriseUntil > now) return "surprise"; + if (state.scanStatus === "assistant-changed" || state.scanStatus === "assistant-settling") { + return "answering"; + } + if (state.bridgeStatus === "disabled") return "hidden"; + if (state.prompts.length) return "ready"; + if (state.bridgeStatus === "ok") return "empty"; + return "idle"; + } + + function resolveOutlineExpression(now = Date.now()) { + if (!outlineEnabled()) return "hidden"; + if (state.outlineStatus === "pending") return "generating"; + if (state.scanBusy) return "answering"; + if (state.surpriseUntil > now) return "surprise"; + if (state.outlineStatus === "error") return "error"; + if (state.outlineItems.length) return "ready"; + if (state.outlineStatus === "empty") return "empty"; + return "idle"; + } + + function usesOutlineExpression(now = Date.now()) { + const stepwiseExpression = resolveStepwiseExpression(now); + return outlineEnabled() + && (state.activeTab === "outline" + || stepwiseExpression === "hidden" + || stepwiseWaitingForManualRefresh()); + } + + function resolveFabExpression(now = Date.now()) { + if (!runtimeEnabled()) return "hidden"; + return usesOutlineExpression(now) + ? resolveOutlineExpression(now) + : resolveStepwiseExpression(now); + } + + function fabExpressionLabel(expression, outlineExpression = usesOutlineExpression()) { + if (outlineExpression) { + return { + idle: "空闲", + answering: "回答中", + surprise: "正在整理回答", + generating: "正在整理大纲", + ready: "大纲已准备", + empty: "暂无大纲", + error: "生成失败", + curious: "查看设置", + hidden: "已关闭", + }[expression] || "空闲"; + } + return { + idle: "空闲", + answering: "回答中", + surprise: "正在整理回答", + generating: "正在生成建议", + ready: "建议已准备", + empty: "暂无建议", + error: "生成失败", + curious: "查看设置", + hidden: "已关闭", + }[expression] || "空闲"; + } + + function scheduleExpressionRefresh(delay) { + if (!isCurrentRuntime()) return; + if (state.expressionTimer) window.clearTimeout(state.expressionTimer); + const generation = state.runtimeGeneration; + const timer = window.setTimeout(() => { + if (state.expressionTimer === timer) state.expressionTimer = 0; + if (isCurrentRuntime(generation)) renderFloat(); + }, delay); + state.expressionTimer = timer; + } + + function clearCompletionBeam() { + if (state.completionBeamTimer) window.clearTimeout(state.completionBeamTimer); + state.completionBeamTimer = 0; + if (state.popover) state.popover.dataset.completionBeam = "false"; + } + + function triggerCompletionBeam(promptCount) { + clearCompletionBeam(); + if (promptCount < 1 || prefersReducedMotion() || !state.popover) return; + state.popover.dataset.completionBeam = "true"; + const timer = window.setTimeout(() => { + if (state.completionBeamTimer !== timer) return; + state.completionBeamTimer = 0; + if (state.popover) state.popover.dataset.completionBeam = "false"; + }, COMPLETION_BEAM_MS); + state.completionBeamTimer = timer; + } + + // View transitions and shell morphs share deterministic completion and cancellation rules. + function prefersReducedMotion() { + try { + return window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true; + } catch { + return false; + } + } + + function cancelViewAnimation() { + cancelViewStageAnimation(); + cancelViewIndicatorAnimation(); + } + + function cancelViewStageAnimation() { + const transition = state.viewAnimation; + state.viewAnimation = null; + if (!transition) return; + transition.animations?.forEach((animation) => animation.cancel()); + transition.finish?.(); + } + + function cancelViewIndicatorAnimation() { + if (state.viewIndicatorFrame) window.cancelAnimationFrame(state.viewIndicatorFrame); + state.viewIndicatorFrame = 0; + } + + function deferRender() { + state.pendingRender = true; + } + + function flushDeferredRender() { + if (!state.pendingRender || !isCurrentRuntime()) return false; + if (state.viewTransitioning || state.morphAnimation) return false; + state.pendingRender = false; + renderFloat({ preserveMorph: true, allowDuringTransition: true }); + return true; + } + + function viewSlideDirection(fromTab, targetTab) { + const fromIndex = VIEW_ORDER.indexOf(fromTab); + const targetIndex = VIEW_ORDER.indexOf(targetTab); + if (fromIndex < 0 || targetIndex < 0 || fromIndex === targetIndex) return 1; + return targetIndex > fromIndex ? 1 : -1; + } + + function captureViewStage() { + const body = state.panel?.querySelector(".csw-body[data-view-body]"); + const stage = body?.querySelector(":scope > .csw-mouth-stage"); + if (!body || !stage) return null; + return { + node: stage.cloneNode(true), + scrollTop: body.scrollTop, + }; + } + + function animateViewSlide(snapshot, direction) { + const body = state.panel?.querySelector(".csw-body[data-view-body]"); + const incoming = body?.querySelector(":scope > .csw-mouth-stage"); + if (!snapshot?.node || !body || !incoming || prefersReducedMotion() + || typeof incoming.animate !== "function") { + return Promise.resolve(); + } + + cancelViewStageAnimation(); + const layer = document.createElement("div"); + const outgoing = snapshot.node; + layer.className = "csw-view-transition-layer"; + outgoing.classList.add("csw-view-transition-copy"); + outgoing.style.top = `${2 - snapshot.scrollTop}px`; + layer.appendChild(outgoing); + body.appendChild(layer); + body.dataset.viewTransition = "true"; + + const distance = VIEW_SLIDE_DISTANCE * direction; + const options = { + duration: VIEW_SLIDE_MS, + easing: "cubic-bezier(.2, .72, .2, 1)", + fill: "forwards", + }; + const outgoingAnimation = outgoing.animate([ + { opacity: 1, transform: "translate3d(0, 0, 0)" }, + { opacity: 0.08, transform: `translate3d(${-distance}px, 0, 0)` }, + ], options); + const incomingAnimation = incoming.animate([ + { opacity: 0.42, transform: `translate3d(${distance}px, 0, 0)` }, + { opacity: 1, transform: "translate3d(0, 0, 0)" }, + ], options); + let cleaned = false; + const cleanup = () => { + if (cleaned) return; + cleaned = true; + body.removeAttribute("data-view-transition"); + layer.remove(); + }; + let resolveCompletion; + let settled = false; + const completion = new Promise((resolve) => { + resolveCompletion = resolve; + }); + const transition = { + animations: [outgoingAnimation, incomingAnimation], + cleanup, + fallbackTimer: 0, + finish: () => { + if (settled) return; + settled = true; + if (transition.fallbackTimer) window.clearTimeout(transition.fallbackTimer); + cleanup(); + if (state.viewAnimation === transition) state.viewAnimation = null; + resolveCompletion(); + }, + }; + state.viewAnimation = transition; + transition.fallbackTimer = window.setTimeout( + () => { + transition.animations.forEach((animation) => { + if (animation.playState !== "finished") animation.cancel(); + }); + transition.finish(); + }, + VIEW_SLIDE_MS + 120, + ); + void Promise.all(transition.animations.map((animation) => animation.finished.catch(() => null))) + .then(() => transition.finish()); + return completion; + } + + function syncViewTabSelection(targetTab, animate = true) { + const tabs = state.panel?.querySelector(".csw-view-tabs"); + const indicator = tabs?.querySelector(".csw-view-indicator"); + if (!tabs || !indicator) return; + + const buttons = Array.from(tabs.querySelectorAll(".csw-icon[data-view]")); + const target = buttons.find((button) => button.dataset.view === targetTab) || null; + buttons.forEach((button) => { + const selected = button === target; + button.dataset.active = String(selected); + button.setAttribute("aria-selected", String(selected)); + }); + + indicator.style.transition = animate && !prefersReducedMotion() ? "" : "none"; + if (!target) { + indicator.style.opacity = "0"; + tabs.dataset.activeView = ""; + return; + } + + tabs.dataset.activeView = targetTab; + indicator.style.opacity = "1"; + indicator.style.transform = `translate3d(${target.offsetLeft - indicator.offsetLeft}px, 0, 0)`; + if (!animate) indicator.getBoundingClientRect(); + } + + function animateViewTabSelection(fromTab, targetTab) { + syncViewTabSelection(fromTab, false); + if (fromTab === targetTab) return; + state.viewIndicatorFrame = window.requestAnimationFrame(() => { + state.viewIndicatorFrame = 0; + if (!isCurrentRuntime()) return; + syncViewTabSelection(targetTab, true); + }); + } + + async function switchView(nextTab) { + const generation = state.runtimeGeneration; + const targetTab = normalizeActiveTab(nextTab); + if (!isCurrentRuntime(generation) || targetTab === state.activeTab) return; + if (state.viewTransitioning) { + state.pendingTab = targetTab; + return; + } + state.viewTransitioning = true; + try { + const sourceTab = state.activeTab; + const snapshot = captureViewStage(); + const direction = viewSlideDirection(sourceTab, targetTab); + state.activeTab = normalizeActiveTab(targetTab); + state.pendingRender = false; + renderFloat({ + preserveMorph: true, + viewIndicatorFrom: sourceTab, + allowDuringTransition: true, + }); + await animateViewSlide(snapshot, direction); + if (!isCurrentRuntime(generation)) return; + if (targetTab === "settings") void reloadSettings(); + } finally { + if (isCurrentRuntime(generation)) { + state.viewTransitioning = false; + const pendingTab = state.pendingTab; + state.pendingTab = ""; + if (pendingTab && pendingTab !== state.activeTab) void switchView(pendingTab); + else flushDeferredRender(); + } + } + } + + // Shell geometry is sampled from the capsule through the horizontal intermediate to the panel. + function lerp(from, to, progress) { + return from + (to - from) * progress; + } + + function axisEase(progress) { + const value = clamp(progress, 0, 1); + const eased = 1 - Math.pow(1 - value, 1.25); + return eased * 0.4 + value * 0.6; + } + + function expandMotionU(progress) { + return clamp(progress, 0, 1); + } + + function defaultPosition() { + const bounds = contentSafeBounds(); + return clampPosition({ + x: bounds.right - CHIP_WIDTH, + y: Math.min(bounds.bottom - CHIP_HEIGHT, bounds.top + 44), + }, false); + } + + function savedPosition() { + try { + const parsed = JSON.parse(localStorage.getItem(POSITION_KEY) || "null"); + if (Number.isFinite(parsed?.x) && Number.isFinite(parsed?.y)) return clampPosition(parsed); + } catch {} + return defaultPosition(); + } + + function contentSafeBounds() { + const viewportWidth = Math.max(80, window.innerWidth || 0); + const viewportHeight = Math.max(80, window.innerHeight || 0); + let left = PANEL_SAFE_MARGIN; + let top = PANEL_SAFE_MARGIN; + let right = viewportWidth - PANEL_SAFE_MARGIN; + let bottom = viewportHeight - PANEL_SAFE_MARGIN; + + const leftPanel = document.querySelector("aside.app-shell-left-panel"); + if (leftPanel instanceof Element) { + const rect = leftPanel.getBoundingClientRect(); + if (rect.width >= 48 && rect.right > 40 && rect.right < viewportWidth * 0.62) { + left = Math.max(left, rect.right + PANEL_SAFE_MARGIN); + } + } + + const mainStage = document.querySelector( + "main.main-surface, .app-shell-main-content-viewport, .app-shell-main-content-frame" + ); + if (mainStage instanceof Element) { + const rect = mainStage.getBoundingClientRect(); + if (rect.width >= 160) { + if (rect.left > 40 && rect.left < viewportWidth * 0.62) { + left = Math.max(left, rect.left + PANEL_SAFE_MARGIN); + } + if (rect.right > left + 80 && rect.right <= viewportWidth + 2) { + right = Math.min(right, rect.right - PANEL_SAFE_MARGIN); + } + if (rect.top >= 0 && rect.top < viewportHeight * 0.4) { + top = Math.max(top, rect.top + PANEL_SAFE_MARGIN); + } + if (rect.bottom > top + 80 && rect.bottom <= viewportHeight + 2) { + bottom = Math.min(bottom, rect.bottom - PANEL_SAFE_MARGIN); + } + } + } + + const rightRail = document.querySelector( + "aside.app-shell-right-panel, [data-testid='right-sidebar'], aside.app-shell-secondary-panel" + ); + if (rightRail instanceof Element) { + const rect = rightRail.getBoundingClientRect(); + if (rect.width >= 48 && rect.left > viewportWidth * 0.45 && rect.left < viewportWidth - 40) { + right = Math.min(right, rect.left - PANEL_SAFE_MARGIN); + } + } + + document.querySelectorAll( + ".app-header-tint, .draggable.flex.h-toolbar, [class*='h-toolbar'].draggable, header" + ).forEach((bar) => { + if (!(bar instanceof Element)) return; + const rect = bar.getBoundingClientRect(); + if (rect.height < 28 || rect.height > 96) return; + if (rect.top > 24 || rect.width < viewportWidth * 0.45) return; + top = Math.max(top, rect.bottom + PANEL_SAFE_MARGIN); + }); + + if (bottom - top < CHIP_HEIGHT) { + top = PANEL_SAFE_MARGIN; + } + + if (right - left < CHIP_WIDTH) { + left = PANEL_SAFE_MARGIN; + right = viewportWidth - PANEL_SAFE_MARGIN; + } + + return { + left, + top, + right, + bottom, + width: Math.max(0, right - left), + height: Math.max(0, bottom - top), + }; + } + + function clampPosition(position) { + const bounds = contentSafeBounds(); + const visibleWidth = Math.min(CHIP_WIDTH, bounds.width); + const visibleHeight = Math.min(CHIP_HEIGHT, bounds.height); + const sourceX = Number(position?.x); + const sourceY = Number(position?.y); + return { + x: clamp(Number.isFinite(sourceX) ? sourceX : bounds.left, bounds.left, Math.max(bounds.left, bounds.right - visibleWidth)), + y: clamp(Number.isFinite(sourceY) ? sourceY : bounds.top, bounds.top, Math.max(bounds.top, bounds.bottom - visibleHeight)), + }; + } + + function persistPosition() { + if (!state.position) return; + try { localStorage.setItem(POSITION_KEY, JSON.stringify(state.position)); } catch {} + } + + function setPosition(position, persist = false) { + state.position = clampPosition(position); + if (persist) persistPosition(); + applyPosition(); + } + + function dockRightKeepHeight(persist = true) { + const layout = shellLayout(); + setPosition({ + x: layout.bounds.right - layout.chip.width, + y: layout.anchor.y, + }, persist); + } + + function snapRightIfNear(persist = false, animate = false) { + const layout = shellLayout(); + const visibleRight = state.open + ? layout.left + layout.width + : layout.anchor.x + layout.chip.width; + if (layout.bounds.right - visibleRight > RIGHT_EDGE_SNAP_DISTANCE) return false; + if (animate && state.popover && !prefersReducedMotion()) { + if (state.snapTimer) window.clearTimeout(state.snapTimer); + state.popover.dataset.snapRight = "true"; + const timer = window.setTimeout(() => { + if (state.snapTimer !== timer) return; + state.snapTimer = 0; + state.popover?.removeAttribute("data-snap-right"); + }, 220); + state.snapTimer = timer; + } + dockRightKeepHeight(persist); + return true; + } + + function shellLayout() { + const bounds = contentSafeBounds(); + const width = Math.max(CHIP_WIDTH, Math.min(state.width, bounds.width)); + const anchor = clampPosition(state.position || defaultPosition()); + const chipWidth = Math.min(CHIP_WIDTH, width); + const chipHeight = Math.min(CHIP_HEIGHT, bounds.height); + const minimumPanelHeight = Math.min(PANEL_MIN_HEIGHT, bounds.height); + const roomBelow = Math.max(chipHeight, bounds.bottom - anchor.y); + const roomAbove = Math.max(chipHeight, anchor.y + chipHeight - bounds.top); + const panelDrag = state.drag?.source === "panel" ? state.drag : null; + const opensDown = typeof panelDrag?.lockedOpensDown === "boolean" + ? panelDrag.lockedOpensDown + : roomBelow >= minimumPanelHeight || roomBelow >= roomAbove; + const availableHeight = opensDown ? roomBelow : roomAbove; + const requestedHeight = Number.isFinite(panelDrag?.panelHeight) + ? panelDrag.panelHeight + : state.activeTab === "settings" + ? clampPanelHeight(SETTINGS_PANEL_HEIGHT) + : state.height; + const height = Math.max(CHIP_HEIGHT, Math.min(requestedHeight, bounds.height, availableHeight)); + const compressionProgress = state.activeTab === "settings" + ? 0 + : clamp( + (requestedHeight - height) / Math.max(1, requestedHeight - chipHeight), + 0, + 1, + ); + const desiredLeft = anchor.x - (width - chipWidth) / 2; + const left = clamp(desiredLeft, bounds.left, Math.max(bounds.left, bounds.right - width)); + const desiredTop = opensDown ? anchor.y : anchor.y + chipHeight - height; + const top = clamp(desiredTop, bounds.top, Math.max(bounds.top, bounds.bottom - height)); + const chipLeft = clamp(anchor.x - left, 0, Math.max(0, width - chipWidth)); + const chipTop = clamp(anchor.y - top, 0, Math.max(0, height - chipHeight)); + const collapsedShell = { + left: chipLeft, + top: chipTop, + width: chipWidth, + height: chipHeight, + radius: CHIP_RADIUS, + }; + const horizontalShell = { + left: 0, + top: chipTop, + width, + height: chipHeight, + radius: CHIP_RADIUS, + }; + const expandedShell = { + left: 0, + top: 0, + width, + height, + radius: PANEL_RADIUS, + }; + const distX = Math.max(1, expandedShell.width - collapsedShell.width); + const distY = Math.max(1, expandedShell.height - collapsedShell.height); + const stageMs = Math.max( + Math.max(MIN_PHASE_MS, distX / MORPH_EDGE_SPEED), + Math.max(MIN_PHASE_MS, distY / MORPH_EDGE_SPEED) + ); + return { + left, + top, + width, + height, + requestedHeight, + availableHeight, + compressionProgress, + bounds, + anchor, + chip: { + left: chipLeft, + top: chipTop, + width: chipWidth, + height: chipHeight, + radius: CHIP_RADIUS, + }, + collapsedShell, + horizontalShell, + expandedShell, + distX, + distY, + opensDown, + phaseSplit: HORIZONTAL_PHASE, + morphDurationMs: clamp(Math.round(stageMs * 2), MIN_MORPH_MS, MAX_MORPH_MS), + }; + } + + function phaseSplitOf(geometry) { + const split = Number(geometry?.phaseSplit); + if (Number.isFinite(split) && split > 0.05 && split < 0.95) return split; + return HORIZONTAL_PHASE; + } + + // Canceling a morph invalidates its callbacks before stopping animations or clearing state. + function cancelMorphAnimations() { + const transition = state.morphTransition; + if (transition) { + transition.cancelled = true; + if (transition.fallbackTimer) window.clearTimeout(transition.fallbackTimer); + } + state.morphTransition = null; + state.morphGeneration += 1; + const animations = [ + state.morphAnimation, + state.rimMorphAnimation, + state.displacementMorphAnimation, + state.panelMorphAnimation, + state.fabMorphAnimation, + ...(transition?.animations || []), + ]; + [...new Set(animations)].forEach((animation) => animation?.cancel?.()); + state.morphAnimation = null; + state.rimMorphAnimation = null; + state.displacementMorphAnimation = null; + state.panelMorphAnimation = null; + state.fabMorphAnimation = null; + } + + function unfoldAxes(progress, collapsing = false, split = HORIZONTAL_PHASE) { + const value = clamp(progress, 0, 1); + const elapsed = collapsing ? 1 - value : value; + const phase = clamp(split, 0.05, 0.95); + let x; + let y; + if (elapsed <= phase) { + x = axisEase(phase < 0.001 ? 1 : elapsed / phase); + y = 0; + } else { + x = 1; + y = axisEase((elapsed - phase) / Math.max(0.001, 1 - phase)); + } + return collapsing ? { x: 1 - x, y: 1 - y } : { x, y }; + } + + function unfoldShell(geometry, progress, collapsing = false) { + const { x, y } = unfoldAxes(progress, collapsing, phaseSplitOf(geometry)); + const collapsed = geometry.collapsedShell; + const expanded = geometry.expandedShell; + return { + left: lerp(collapsed.left, expanded.left, x), + top: lerp(collapsed.top, expanded.top, y), + width: lerp(collapsed.width, expanded.width, x), + height: lerp(collapsed.height, expanded.height, y), + radius: lerp(collapsed.radius, expanded.radius, Math.max(x, y)), + }; + } + + function morphPathProgress(shell, geometry) { + const split = phaseSplitOf(geometry); + const collapsed = geometry.collapsedShell; + const expanded = geometry.expandedShell; + const widthProgress = clamp( + (shell.width - collapsed.width) / Math.max(1, expanded.width - collapsed.width), + 0, + 1 + ); + const heightProgress = clamp( + (shell.height - collapsed.height) / Math.max(1, expanded.height - collapsed.height), + 0, + 1 + ); + if (heightProgress > 0.002 || widthProgress >= 0.998) { + return split + heightProgress * (1 - split); + } + return widthProgress * split; + } + + function readGlassGeometry(geometry) { + const fallback = unfoldShell(geometry, state.open ? 1 : 0); + if (!state.glass) return fallback; + const computed = getComputedStyle(state.glass); + const number = (value, fallbackValue) => { + const parsed = Number.parseFloat(String(value || "")); + return Number.isFinite(parsed) ? parsed : fallbackValue; + }; + return { + left: number(computed.left, fallback.left), + top: number(computed.top, fallback.top), + width: Math.max(1, number(computed.width, fallback.width)), + height: Math.max(1, number(computed.height, fallback.height)), + radius: Math.max(0, number(computed.borderTopLeftRadius, fallback.radius)), + }; + } + + function morphPx(value) { + return `${Number(value.toFixed(3))}px`; + } + + function glassFrame(shell, offset) { + return { + left: morphPx(shell.left), + top: morphPx(shell.top), + width: morphPx(shell.width), + height: morphPx(shell.height), + borderRadius: morphPx(shell.radius), + offset: Number(offset.toFixed(4)), + }; + } + + function panelClipPath(shell, geometry) { + const top = Math.max(0, shell.top); + const right = Math.max(0, geometry.width - shell.left - shell.width); + const bottom = Math.max(0, geometry.height - shell.top - shell.height); + const left = Math.max(0, shell.left); + return `inset(${morphPx(top)} ${morphPx(right)} ${morphPx(bottom)} ${morphPx(left)} round ${morphPx(shell.radius)})`; + } + + function panelFrame(shell, geometry, offset) { + return { + clipPath: panelClipPath(shell, geometry), + offset: Number(offset.toFixed(4)), + }; + } + + function fabFrame(shell, offset) { + const headerHeight = Math.min(CHIP_HEIGHT + 8, shell.height); + return { + left: morphPx(shell.left + (shell.width - CHIP_WIDTH) / 2), + top: morphPx(shell.top + Math.max(0, (headerHeight - CHIP_HEIGHT) / 2)), + offset: Number(offset.toFixed(4)), + }; + } + + function buildMorphPath(currentShell, expanded, geometry) { + const startProgress = morphPathProgress(currentShell, geometry); + const targetProgress = expanded ? 1 : 0; + const remaining = Math.abs(targetProgress - startProgress); + const baseDuration = clamp( + Number(geometry.morphDurationMs) || MIN_MORPH_MS, + MIN_MORPH_MS, + MAX_MORPH_MS + ); + const duration = remaining < 0.002 + ? 0 + : clamp(Math.round(baseDuration * remaining), MIN_REVERSE_MS, MAX_MORPH_MS); + const samples = [{ shell: currentShell, offset: 0 }]; + const steps = UNFOLD_SAMPLES + 1; + const progressDelta = targetProgress - startProgress; + const stageProgress = phaseSplitOf(geometry); + const stageTimeline = Math.abs(progressDelta) < 0.000001 + ? -1 + : (stageProgress - startProgress) / progressDelta; + const timelines = []; + for (let index = 1; index <= steps; index += 1) { + timelines.push(index / steps); + } + if (stageTimeline > 0.000001 && stageTimeline < 0.999999) { + timelines.push(stageTimeline); + } + timelines.sort((left, right) => left - right); + let previousTimeline = -1; + for (const timeline of timelines) { + if (Math.abs(timeline - previousTimeline) < 0.000001) continue; + const motion = expanded ? expandMotionU(timeline) : timeline; + const sampledProgress = startProgress + progressDelta * motion; + const progress = Math.abs(timeline - stageTimeline) < 0.000001 + ? stageProgress + : sampledProgress; + samples.push({ shell: unfoldShell(geometry, progress, false), offset: timeline }); + previousTimeline = timeline; + } + const targetShell = expanded ? geometry.expandedShell : geometry.collapsedShell; + samples[samples.length - 1] = { shell: targetShell, offset: 1 }; + return { + duration, + frames: samples.map(({ shell, offset }) => glassFrame(shell, offset)), + panelFrames: samples.map(({ shell, offset }) => panelFrame(shell, geometry, offset)), + fabFrames: samples.map(({ shell, offset }) => fabFrame(shell, offset)), + startProgress, + targetProgress, + targetShell, + }; + } + + function applyMorphShell(shell, geometry) { + [state.glass, state.rim, state.displacementTexture, state.completionBeam].forEach((surface) => { + if (!surface) return; + surface.style.left = `${shell.left}px`; + surface.style.top = `${shell.top}px`; + surface.style.width = `${shell.width}px`; + surface.style.height = `${shell.height}px`; + surface.style.borderRadius = `${shell.radius}px`; + }); + if (state.panel) { + state.panel.style.clipPath = panelClipPath(shell, geometry); + } + if (state.fab) { + const frame = fabFrame(shell, 0); + state.fab.style.left = frame.left; + state.fab.style.top = frame.top; + } + } + + function applyMorphProgress(progress) { + if (!state.glass && !state.rim && !state.panel && !state.fab) return; + const geometry = state.layout || shellLayout(); + const shell = unfoldShell(geometry, progress, false); + applyMorphShell(shell, geometry); + } + + function settleMorph(progress, focusTarget = "") { + if (!isCurrentRuntime()) return; + cancelMorphAnimations(); + resetEyePointer(); + const expanded = progress >= 0.999; + state.open = expanded; + state.popover.dataset.open = String(expanded); + state.popover.dataset.morphing = "false"; + state.panel.inert = !expanded; + state.panel.setAttribute("aria-hidden", String(!expanded)); + state.fab.setAttribute("aria-expanded", String(expanded)); + applyMorphProgress(expanded ? 1 : 0); + resetGlassPointer(); + const runtimeGeneration = state.runtimeGeneration; + if (focusTarget === "panel" && expanded) { + window.requestAnimationFrame(() => { + if (isCurrentRuntime(runtimeGeneration)) { + state.panel?.querySelector("[data-action='collapse']")?.focus({ preventScroll: true }); + } + }); + } + if (focusTarget === "chip" && !expanded) { + window.requestAnimationFrame(() => { + if (isCurrentRuntime(runtimeGeneration)) state.fab?.focus({ preventScroll: true }); + }); + } + if (!flushDeferredRender()) syncEyeTracking(); + } + + function startMorph(expanded, focusTarget = "") { + if (!state.glass || !state.rim || !state.fab || !state.panel || !state.popover) return; + resetEyePointer(); + const geometry = state.layout || shellLayout(); + const currentShell = readGlassGeometry(geometry); + cancelMorphAnimations(); + state.open = expanded; + state.focusAfterMorph = focusTarget; + state.popover.dataset.open = String(expanded); + state.popover.dataset.morphing = "true"; + resetGlassPointer(); + state.panel.inert = true; + state.panel.setAttribute("aria-hidden", "true"); + state.fab.setAttribute("aria-expanded", String(expanded)); + const path = buildMorphPath(currentShell, expanded, geometry); + applyMorphShell(currentShell, geometry); + + if (prefersReducedMotion() || path.duration === 0) { + settleMorph(path.targetProgress, focusTarget); + return; + } + + const generation = state.morphGeneration; + const runtimeGeneration = state.runtimeGeneration; + const timing = { + duration: path.duration, + easing: "cubic-bezier(.2, .72, .2, 1)", + fill: "forwards", + }; + const animation = state.glass.animate(path.frames, timing); + state.rimMorphAnimation = state.rim.animate(path.frames, timing); + state.displacementMorphAnimation = state.displacementTexture?.animate(path.frames, timing) || null; + state.panelMorphAnimation = state.panel.animate(path.panelFrames, timing); + state.fabMorphAnimation = state.fab.animate(path.fabFrames, timing); + state.morphAnimation = animation; + const animations = [ + animation, + state.rimMorphAnimation, + state.displacementMorphAnimation, + state.panelMorphAnimation, + state.fabMorphAnimation, + ].filter(Boolean); + let settled = false; + const transition = { + animations, + cancelled: false, + fallbackTimer: 0, + finish: () => { + if (transition.cancelled || settled) return; + settled = true; + if (transition.fallbackTimer) window.clearTimeout(transition.fallbackTimer); + if (state.morphTransition === transition) state.morphTransition = null; + if (!isCurrentRuntime(runtimeGeneration) || generation !== state.morphGeneration) return; + settleMorph(path.targetProgress, focusTarget); + }, + }; + state.morphTransition = transition; + transition.fallbackTimer = window.setTimeout(() => { + transition.animations.forEach((item) => { + if (item.playState !== "finished") item.cancel(); + }); + transition.finish(); + }, path.duration + MORPH_FALLBACK_BUFFER_MS); + void Promise.all(transition.animations.map((item) => item.finished.catch(() => null))) + .then(() => transition.finish()); + } + + function setOpen(expanded, focusTarget = "") { + if (!isCurrentRuntime()) return; + resetEyePointer(); + const target = Boolean(expanded); + if (target === state.open) return; + clearCompletionBeam(); + renderFloat({ preserveMorph: true }); + startMorph(target, focusTarget); + } + + function panelDragPosition(drag, dx, dy) { + const geometry = drag.originLayout; + const bounds = contentSafeBounds(); + const maxLeft = Math.max(bounds.left, bounds.right - geometry.width); + const maxTop = Math.max(bounds.top, bounds.bottom - geometry.height); + const left = clamp(drag.originPanelLeft + dx, bounds.left, maxLeft); + const top = clamp(drag.originPanelTop + dy, bounds.top, maxTop); + return { + x: left + (geometry.width - geometry.chip.width) / 2, + y: drag.lockedOpensDown + ? top + : top + geometry.height - geometry.chip.height, + }; + } + + function applyPosition() { + if (!state.popover || !state.fab || !state.position) return; + state.position = clampPosition(state.position); + state.layout = shellLayout(); + state.popover.style.left = `${state.layout.left}px`; + state.popover.style.top = `${state.layout.top}px`; + state.popover.style.width = `${state.layout.width}px`; + state.popover.style.height = `${state.layout.height}px`; + state.root.style.setProperty("--csw-panel-width", `${state.layout.width}px`); + state.root.style.setProperty("--csw-panel-height", `${state.layout.height}px`); + state.popover.style.setProperty("--csw-chip-left", `${state.layout.chip.left}px`); + const compressionProgress = state.layout.compressionProgress || 0; + const compressed = state.activeTab !== "settings" && compressionProgress > 0.001; + state.popover.dataset.compressed = String(compressed); + state.popover.style.setProperty( + "--csw-content-fade-size", + `${compressed ? Math.min(48, 14 + compressionProgress * 34) : 0}px`, + ); + syncContentFade(); + state.fab.style.left = `${state.layout.chip.left}px`; + state.fab.style.top = `${state.layout.chip.top}px`; + if (!state.morphAnimation) applyMorphProgress(state.open ? 1 : 0); + } + + // SVG filters provide material-specific backdrop treatment without adding third-party runtime code. + function createDisplacementFilter(id, options) { + document.getElementById(id)?.ownerSVGElement?.remove(); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("width", "0"); + svg.setAttribute("height", "0"); + svg.setAttribute("aria-hidden", "true"); + svg.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;pointer-events:none"; + const stitchTiles = options.stitchTiles ? ` stitchTiles="${options.stitchTiles}"` : ""; + const blurNode = Number.isFinite(options.blur) + ? `` + : ""; + const displacementInput = blurNode ? "blurred" : "noise"; + svg.innerHTML = ` + + + + ${blurNode} + + + + `; + return svg; + } + + function createClearFilter() { + const svg = createDisplacementFilter(CLEAR_FILTER_ID, { + x: "-15%", + y: "-15%", + width: "130%", + height: "130%", + baseFrequency: "0.006 0.010", + numOctaves: 1, + seed: 92, + stitchTiles: "stitch", + blur: 7, + scale: 3, + }); + state.clearDisplacement = svg.querySelector("feDisplacementMap"); + return svg; + } + + function createLiquidFilter() { + return createDisplacementFilter(LIQUID_FILTER_ID, { + x: "-45%", + y: "-45%", + width: "190%", + height: "190%", + baseFrequency: "0.012 0.012", + numOctaves: 2, + seed: 92, + blur: 2, + scale: 85, + }); + } + + function createCrystalFilter() { + return createDisplacementFilter(CRYSTAL_FILTER_ID, { + x: "-60%", + y: "-60%", + width: "220%", + height: "220%", + baseFrequency: "0.03 0.03", + numOctaves: 2, + seed: 92, + blur: 2, + scale: 140, + }); + } + + function updateClearDisplacement(expanded, active) { + if (!state.clearDisplacement) return; + const scale = active ? (expanded ? 6 : 6) : (expanded ? 3 : 2); + state.clearDisplacement.setAttribute("scale", String(scale)); + } + + function updateMaterialDistortion(expanded, active) { + updateClearDisplacement(expanded, active); + } + + // The DOM shell is created once; later renders update its contents without stacking another overlay. + function installFloat() { + if (!isCurrentRuntime()) return; + document.querySelectorAll?.(`[${ROOT_ATTR}="true"]`).forEach((node) => { + if (node !== state.root) node.remove(); + }); + if (state.root && document.body.contains(state.root)) return; + + state.position = savedPosition(); + state.root = document.createElement("div"); + state.root.setAttribute(ROOT_ATTR, "true"); + + state.fab = document.createElement("button"); + state.fab.className = "csw-fab"; + state.fab.type = "button"; + state.fab.title = "下一步"; + state.fab.setAttribute("aria-controls", POPOVER_ID); + state.fab.innerHTML = `${statusStageHtml()}${sourceTrackHtml()}`; + + state.popover = document.createElement("div"); + state.popover.className = "csw-popover"; + state.popover.dataset.open = "false"; + state.popover.dataset.morphing = "false"; + state.popover.dataset.completionBeam = "false"; + + state.glass = document.createElement("div"); + state.glass.className = "csw-glass"; + state.glass.setAttribute("aria-hidden", "true"); + + state.rim = document.createElement("div"); + state.rim.className = "csw-rim"; + state.rim.setAttribute("aria-hidden", "true"); + + state.completionBeam = document.createElement("div"); + state.completionBeam.className = "csw-completion-beam"; + state.completionBeam.setAttribute("aria-hidden", "true"); + + state.clearFilter = createClearFilter(); + state.liquidFilter = createLiquidFilter(); + state.crystalFilter = createCrystalFilter(); + + const clearTexture = document.createElement("div"); + clearTexture.className = "csw-clear-texture"; + clearTexture.setAttribute("aria-hidden", "true"); + state.clearDistortion = document.createElement("div"); + state.clearDistortion.className = "csw-clear-distortion"; + state.clearDistortion.setAttribute("aria-hidden", "true"); + state.glass.append(clearTexture, state.clearDistortion); + + state.displacementTexture = document.createElement("div"); + state.displacementTexture.className = "csw-displacement-texture"; + state.displacementTexture.setAttribute("aria-hidden", "true"); + + const materialLayer = document.createElement("div"); + materialLayer.className = "csw-material-layer"; + materialLayer.setAttribute("aria-hidden", "true"); + materialLayer.append(state.displacementTexture, state.glass, state.rim); + materialLayer.append(state.completionBeam); + + state.panel = document.createElement("section"); + state.panel.id = POPOVER_ID; + state.panel.className = "csw-panel"; + state.panel.setAttribute("role", "dialog"); + state.panel.setAttribute("aria-label", "下一步建议与回答大纲"); + state.panel.setAttribute("aria-hidden", "true"); + state.panel.inert = true; + + const resizeBottomLeft = document.createElement("span"); + resizeBottomLeft.className = "csw-resize-handle"; + resizeBottomLeft.dataset.corner = "bl"; + resizeBottomLeft.setAttribute("aria-hidden", "true"); + const resizeBottomRight = document.createElement("span"); + resizeBottomRight.className = "csw-resize-handle"; + resizeBottomRight.dataset.corner = "br"; + resizeBottomRight.setAttribute("aria-hidden", "true"); + + state.popover.append(materialLayer, state.fab, state.panel, resizeBottomLeft, resizeBottomRight); + state.root.append(state.clearFilter, state.liquidFilter, state.crystalFilter, state.popover); + document.body.appendChild(state.root); + + state.fab.addEventListener("pointerdown", onFabPointerDown); + state.fab.addEventListener("click", onFabClick); + bindGlassPointerSurface(state.fab); + state.panel.addEventListener("wheel", onPanelWheel, { passive: false }); + state.glass.addEventListener("click", onGlassClick); + resetGlassPointer(); + state.keyHandler = onKeyDown; + document.addEventListener("keydown", state.keyHandler, true); + window.addEventListener("resize", onResize); + installEyeTracking(); + installThemeObserver(); + installTypographyObserver(); + syncTheme(); + applyMaterial(); + installResize(); + applyPosition(); + settleMorph(0); + } + + function onResize() { + if (!state.position) return; + const target = state.open ? 1 : 0; + cancelMorphAnimations(); + state.position = clampPosition(state.position); + applyPosition(); + settleMorph(target); + syncContentFade(); + } + + function onPanelWheel(event) { + if (!state.open || (!event.altKey && !event.metaKey) || event.deltaY === 0) return; + event.preventDefault(); + event.stopPropagation(); + bumpFontSize(event.deltaY > 0 ? -1 : 1); + } function onFabPointerDown(event) { - if (event.button !== 0) return; - state.drag = { - id: event.pointerId, + beginDrag(event, "fab"); + } + + function dragTargetBlocked(target) { + if (!(target instanceof Element)) return false; + if (target.closest(".csw-head-side")) return true; + if (target.closest(".csw-head-face")) return false; + return Boolean(target.closest("button,a,input,textarea,select,[role='button']")); + } + + function beginDrag(event, source) { + if (event.button !== 0 || state.morphAnimation || !state.position) return; + if (source === "fab" && state.open) return; + if (source === "panel" && (!state.open || dragTargetBlocked(event.target))) return; + + state.dragCleanup?.(); + const handle = event.currentTarget; + const originLayout = state.layout || shellLayout(); + const drag = { + pointerId: event.pointerId, + source, + startedOnHeadFace: source === "panel" && event.target instanceof Element && Boolean(event.target.closest(".csw-head-face")), startX: event.clientX, startY: event.clientY, originX: state.position.x, originY: state.position.y, + originLayout, + originPanelLeft: originLayout.left, + originPanelTop: originLayout.top, + lockedOpensDown: source === "panel" ? originLayout.opensDown : null, + panelHeight: source === "panel" ? originLayout.height : null, moved: false, }; - state.fab.setPointerCapture?.(event.pointerId); - state.fab.addEventListener("pointermove", onFabPointerMove); - state.fab.addEventListener("pointerup", onFabPointerUp, { once: true }); - state.fab.addEventListener("pointercancel", onFabPointerUp, { once: true }); - } - - function onFabPointerMove(event) { - const drag = state.drag; - if (!drag || drag.id !== event.pointerId) return; - const dx = event.clientX - drag.startX; - const dy = event.clientY - drag.startY; - if (Math.abs(dx) + Math.abs(dy) > 4) drag.moved = true; - if (!drag.moved) return; - event.preventDefault(); - savePosition({ x: drag.originX + dx, y: drag.originY + dy }); + state.drag = drag; + state.suppressFabClick = false; + state.suppressHeadFaceClick = false; + resetEyePointer(); + + const onPointerMove = (moveEvent) => { + if (state.drag !== drag || moveEvent.pointerId !== drag.pointerId) return; + const dx = moveEvent.clientX - drag.startX; + const dy = moveEvent.clientY - drag.startY; + if (!drag.moved && Math.hypot(dx, dy) < 3) return; + if (!drag.moved) { + drag.moved = true; + handle?.setAttribute?.("data-dragging", "true"); + try { handle?.setPointerCapture?.(drag.pointerId); } catch {} + } + moveEvent.preventDefault(); + const nextPosition = source === "panel" + ? panelDragPosition(drag, dx, dy) + : { x: drag.originX + dx, y: drag.originY + dy }; + setPosition(nextPosition); + snapRightIfNear(); + }; + + const cleanup = () => { + window.removeEventListener("pointermove", onPointerMove, true); + window.removeEventListener("pointerup", onPointerEnd, true); + window.removeEventListener("pointercancel", onPointerEnd, true); + handle?.removeAttribute?.("data-dragging"); + try { handle?.releasePointerCapture?.(drag.pointerId); } catch {} + if (state.dragCleanup === cleanup) state.dragCleanup = null; + }; + + const onPointerEnd = (endEvent) => { + if (state.drag !== drag || endEvent.pointerId !== drag.pointerId) return; + cleanup(); + if (!drag.moved) { + state.drag = null; + return; + } + const snapped = snapRightIfNear(true, true); + state.drag = null; + if (!snapped) persistPosition(); + if (source === "fab") { + state.suppressFabClick = true; + window.setTimeout(() => { state.suppressFabClick = false; }, 300); + } else { + state.suppressHeadFaceClick = true; + window.setTimeout(() => { state.suppressHeadFaceClick = false; }, 300); + if (drag.startedOnHeadFace && document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + } + syncEyeTracking(); + }; + + state.dragCleanup = cleanup; + window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false }); + window.addEventListener("pointerup", onPointerEnd, true); + window.addEventListener("pointercancel", onPointerEnd, true); + if (source === "panel" && !drag.startedOnHeadFace) event.preventDefault(); + } + + // Dragging, right docking, resizing, and persisted bounds share the same clamped position model. + function installPanelDrag() { + const head = state.panel?.querySelector(".csw-head"); + if (head && head.dataset.dragBound !== "1") { + head.dataset.dragBound = "1"; + head.addEventListener("pointerdown", (event) => beginDrag(event, "panel")); + } + } + + function resizeAnchorForWidth(nextWidth, nextLeft, corner) { + const chipWidth = state.layout?.chip?.width || CHIP_WIDTH; + if (corner === "bl") return nextLeft + (nextWidth - chipWidth) / 2; + return nextLeft + (nextWidth - chipWidth) / 2; + } + + function installResize() { + if (!state.popover || state.popover.dataset.resizeBound === "1") return; + state.popover.dataset.resizeBound = "1"; + state.popover.querySelectorAll(".csw-resize-handle").forEach((handle) => { + handle.addEventListener("pointerdown", (event) => { + if (event.button !== 0 || !state.open || state.activeTab === "settings" || state.morphAnimation || !state.layout) return; + event.preventDefault(); + event.stopPropagation(); + state.resizeCleanup?.(); + const corner = handle.dataset.corner === "bl" ? "bl" : "br"; + const startRect = state.popover.getBoundingClientRect(); + const startWidth = state.width; + const startHeight = state.height; + const startLeft = startRect.left; + const startRight = startRect.right; + const startX = event.clientX; + const startY = event.clientY; + const resize = { pointerId: event.pointerId, corner }; + state.resizeDrag = resize; + state.popover.dataset.resizing = "true"; + + const onMove = (moveEvent) => { + if (state.resizeDrag !== resize || moveEvent.pointerId !== resize.pointerId) return; + moveEvent.preventDefault(); + const dx = moveEvent.clientX - startX; + const dy = moveEvent.clientY - startY; + const nextWidth = clampPanelWidth(corner === "bl" ? startWidth - dx : startWidth + dx); + const nextLeft = corner === "bl" + ? startRight - nextWidth + : startLeft; + const nextHeight = state.layout.opensDown + ? clampPanelHeight(startHeight + dy) + : clampPanelHeight(startHeight - dy); + state.width = nextWidth; + state.height = nextHeight; + state.position = clampPosition({ + x: resizeAnchorForWidth(nextWidth, nextLeft, corner), + y: state.position?.y, + }); + applyPosition(); + }; + + const cleanup = () => { + window.removeEventListener("pointermove", onMove, true); + window.removeEventListener("pointerup", endResize, true); + window.removeEventListener("pointercancel", endResize, true); + window.removeEventListener("blur", finishResize, true); + document.removeEventListener("visibilitychange", onVisibilityChange, true); + handle.removeEventListener("lostpointercapture", onLostPointerCapture, true); + if (state.resizeCleanup === finishResize) state.resizeCleanup = null; + }; + + const finishResize = () => { + cleanup(); + if (state.resizeDrag === resize) state.resizeDrag = null; + state.popover?.removeAttribute("data-resizing"); + storage.set(WIDTH_KEY, String(state.width)); + storage.set(HEIGHT_KEY, String(state.height)); + applyPosition(); + try { handle.releasePointerCapture(resize.pointerId); } catch {} + }; + + const endResize = (endEvent) => { + if (state.resizeDrag !== resize || endEvent.pointerId !== resize.pointerId) return; + finishResize(); + }; + + const onLostPointerCapture = (captureEvent) => { + if (captureEvent.pointerId !== resize.pointerId) return; + finishResize(); + }; + + const onVisibilityChange = () => { + if (document.visibilityState === "hidden") finishResize(); + }; + + state.resizeCleanup = finishResize; + try { handle.setPointerCapture?.(event.pointerId); } catch {} + window.addEventListener("pointermove", onMove, { capture: true, passive: false }); + window.addEventListener("pointerup", endResize, true); + window.addEventListener("pointercancel", endResize, true); + window.addEventListener("blur", finishResize, true); + document.addEventListener("visibilitychange", onVisibilityChange, true); + handle.addEventListener("lostpointercapture", onLostPointerCapture, true); + }); + + handle.addEventListener("dblclick", (event) => { + event.preventDefault(); + event.stopPropagation(); + state.resizeCleanup?.(); + state.width = PANEL_WIDTH; + state.height = clampPanelHeight(PANEL_HEIGHT); + storage.set(WIDTH_KEY, String(state.width)); + storage.set(HEIGHT_KEY, String(state.height)); + applyPosition(); + }); + }); + } + + function eyeTrackingActive() { + return state.fabExpression === "answering" + && !state.open + && !state.morphAnimation + && !state.drag + && state.root?.dataset.hidden !== "true"; + } + + function curiousEyeTrackingActive() { + return state.activeTab === "settings" + && state.open + && !state.morphAnimation + && !state.drag + && state.root?.dataset.hidden !== "true"; + } + + function eyeTrackingNeeded() { + return eyeTrackingActive() || curiousEyeTrackingActive(); + } + + function applyEyeOffset(x = 0, y = 0) { + state.root?.style.setProperty("--csw-eye-x", `${x.toFixed(2)}px`); + state.root?.style.setProperty("--csw-eye-y", `${y.toFixed(2)}px`); + } + + function applyCuriousEyeOffset(x = 0, y = 0) { + state.root?.style.setProperty("--csw-curious-eye-x", `${x.toFixed(2)}px`); + state.root?.style.setProperty("--csw-curious-eye-y", `${y.toFixed(2)}px`); + } + + function pointerInsideRect(pointer, rect) { + return pointer.x >= rect.left + && pointer.x <= rect.right + && pointer.y >= rect.top + && pointer.y <= rect.bottom; + } + + function eyeOffset(pointer, rect, maxX, maxY, reachDistance) { + const dx = pointer.x - (rect.left + rect.width / 2); + const dy = pointer.y - (rect.top + rect.height / 2); + const distance = Math.hypot(dx, dy); + const reach = clamp(distance / reachDistance, 0, 1); + const angle = Math.atan2(dy, dx); + return { + x: Math.cos(angle) * maxX * reach, + y: Math.sin(angle) * maxY * reach, + }; + } + + function flushEyePointer(generation = state.runtimeGeneration) { + if (!isCurrentRuntime(generation)) return; + state.eyeRaf = 0; + if (!state.eyePointer || !eyeTrackingNeeded()) { + applyEyeOffset(); + applyCuriousEyeOffset(); + return; + } + + if (eyeTrackingActive() && state.fab) { + const rect = state.fab.getBoundingClientRect(); + if (rect.width && rect.height) { + const offset = eyeOffset(state.eyePointer, rect, EYE_MAX_X, EYE_MAX_Y, 220); + applyEyeOffset(offset.x, offset.y); + } else { + applyEyeOffset(); + } + } else { + applyEyeOffset(); + } + + if (!curiousEyeTrackingActive()) { + applyCuriousEyeOffset(); + return; + } + const surface = state.panel?.querySelector('.csw-mouth-stage[data-mouth-stage="settings"]'); + const face = state.panel?.querySelector('.csw-head-face[data-expression="curious"]'); + const surfaceRect = surface?.getBoundingClientRect(); + const faceRect = face?.getBoundingClientRect(); + if (!surfaceRect?.width || !surfaceRect.height || !faceRect?.width || !faceRect.height + || !pointerInsideRect(state.eyePointer, surfaceRect)) { + applyCuriousEyeOffset(); + return; + } + const offset = eyeOffset( + state.eyePointer, + faceRect, + CURIOUS_EYE_MAX_X, + CURIOUS_EYE_MAX_Y, + Math.max(120, surfaceRect.height) + ); + applyCuriousEyeOffset(offset.x, offset.y); + } + + function scheduleEyePointer() { + if (!isCurrentRuntime() || state.eyeRaf) return; + const generation = state.runtimeGeneration; + state.eyeRaf = window.requestAnimationFrame(() => flushEyePointer(generation)); + } + + function resetEyePointer(clearPointer = false) { + if (state.eyeRaf) window.cancelAnimationFrame(state.eyeRaf); + state.eyeRaf = 0; + if (clearPointer) state.eyePointer = null; + applyEyeOffset(); + applyCuriousEyeOffset(); + } + + function syncEyeTracking() { + if (!isCurrentRuntime()) return; + if (!eyeTrackingNeeded()) { + resetEyePointer(); + return; + } + scheduleEyePointer(); + } + + // Eye tracking is pointer-only decoration and is reset whenever the pointer leaves our surfaces. + function installEyeTracking() { + if (state.eyeCleanup) return; + const onPointerMove = (event) => { + state.eyePointer = { x: event.clientX, y: event.clientY }; + if (eyeTrackingNeeded()) scheduleEyePointer(); + }; + const onPointerLeave = () => resetEyePointer(true); + window.addEventListener("pointermove", onPointerMove, { passive: true }); + window.addEventListener("blur", onPointerLeave); + document.addEventListener("mouseleave", onPointerLeave); + state.eyeCleanup = () => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("blur", onPointerLeave); + document.removeEventListener("mouseleave", onPointerLeave); + resetEyePointer(true); + state.eyeCleanup = null; + }; + } + + function onFabClick(event) { + if (state.suppressFabClick || state.drag?.moved) { + state.suppressFabClick = false; + event.preventDefault(); + event.stopPropagation(); + return; + } + setOpen(!state.open, state.open ? "chip" : (event.detail === 0 ? "panel" : "")); + } + + function onHeadFaceClick(event) { + if (state.suppressHeadFaceClick || state.drag?.moved) { + state.suppressHeadFaceClick = false; + event.preventDefault(); + event.stopPropagation(); + return; + } + setOpen(false, "chip"); + } + + function onGlassClick(event) { + if (state.popover?.dataset.morphing !== "true") return; + event.preventDefault(); + event.stopPropagation(); + const expanded = !state.open; + startMorph(expanded, expanded ? "" : "chip"); + } + + function bindGlassPointerSurface(surface) { + if (!(surface instanceof Element)) return; + surface.addEventListener("pointerenter", onShellPointerMove); + surface.addEventListener("pointermove", onShellPointerMove); + surface.addEventListener("pointerleave", onShellPointerLeave); + surface.addEventListener("pointercancel", resetGlassPointer); + } + + function onShellPointerMove(event) { + if (!state.glass || !state.popover) return; + const expanded = state.open || state.popover.dataset.open === "true"; + const surface = event.currentTarget; + const validSurface = expanded + ? surface instanceof Element && surface.matches(".csw-head-face") + : surface === state.fab; + if (!validSurface || !(surface instanceof Element)) { + resetGlassPointer(); + return; + } + const surfaceRect = surface.getBoundingClientRect(); + if (!surfaceRect.width || !surfaceRect.height) return; + const rect = state.glass.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + state.popover.toggleAttribute("data-csw-hot-hover", true); + const x = clamp(((event.clientX - rect.left) / rect.width) * 100, 0, 100); + const y = clamp(((event.clientY - rect.top) / rect.height) * 100, 0, 100); + const angle = Math.atan2(event.clientY - rect.top - rect.height / 2, event.clientX - rect.left - rect.width / 2) * 180 / Math.PI; + const normalizedX = (event.clientX - surfaceRect.left) / surfaceRect.width - 0.5; + const normalizedY = (event.clientY - surfaceRect.top) / surfaceRect.height - 0.5; + const proximity = 1 - clamp(Math.hypot(normalizedX, normalizedY) / 0.72, 0, 1); + updateMaterialDistortion(expanded, true); + const strength = expanded ? 0.1 + proximity * 0.12 : 0.62 + proximity * 0.38; + const parallaxX = expanded ? 1.6 : 1.8; + const parallaxY = expanded ? 1.2 : 1.4; + state.popover.style.setProperty("--csw-glass-x", `${x.toFixed(2)}%`); + state.popover.style.setProperty("--csw-glass-y", `${y.toFixed(2)}%`); + state.popover.style.setProperty("--csw-glass-px", `${(normalizedX * parallaxX).toFixed(2)}px`); + state.popover.style.setProperty("--csw-glass-py", `${(normalizedY * parallaxY).toFixed(2)}px`); + state.popover.style.setProperty("--csw-glass-strength", strength.toFixed(3)); + state.popover.style.setProperty("--csw-glass-angle", `${angle.toFixed(2)}deg`); + } + + function onShellPointerLeave() { + resetGlassPointer(); + } + + function resetGlassPointer() { + const expanded = state.open || state.popover?.dataset.open === "true"; + state.popover?.removeAttribute("data-csw-hot-hover"); + updateMaterialDistortion(expanded, false); + state.popover?.style.setProperty("--csw-glass-x", "28%"); + state.popover?.style.setProperty("--csw-glass-y", expanded ? "16%" : "22%"); + state.popover?.style.setProperty("--csw-glass-px", "0px"); + state.popover?.style.setProperty("--csw-glass-py", "0px"); + state.popover?.style.setProperty("--csw-glass-strength", "0"); + state.popover?.style.setProperty("--csw-glass-angle", "-40deg"); + } + + function onKeyDown(event) { + if (event.key === "Escape" && state.open) { + event.preventDefault(); + event.stopImmediatePropagation(); + setOpen(false, "chip"); + return; + } + if (event.altKey || event.ctrlKey || event.metaKey) return; + const target = event.target; + if (target instanceof Element && ( + target.closest("input, textarea, select, [contenteditable='true'], .ProseMirror") || + target.isContentEditable + )) return; + const isOutlineToggle = event.shiftKey && ( + event.code === "KeyO" || String(event.key || "").toUpperCase() === "O" + ); + if (!isOutlineToggle || !state.panel || !outlineEnabled()) return; + event.preventDefault(); + event.stopImmediatePropagation(); + if (state.open && state.activeTab === "outline") { + setOpen(false, "chip"); + return; + } + state.activeTab = "outline"; + renderFloat({ preserveMorph: true }); + void refreshOutline(); + if (!state.open) setOpen(true, "panel"); + } + + function faceEyeHtml() { + return ``; + } + + function faceHtml() { + return ` + + `; + } + + function statusStageHtml() { + return `${faceHtml()}`; + } + + function sourceTrackHtml(paneCue = { direction: "single", angle: null }, trackHeight = CHIP_HEIGHT) { + const angle = Number.isFinite(state.sourceCueAngle) && paneCue.direction !== "single" + ? state.sourceCueAngle + : paneCue.angle; + const cue = paneCueForTrack({ direction: paneCue.direction, angle }, trackHeight); + return ``; + } + + function normalizeSourceCueDelta(fromAngle, toAngle) { + return ((toAngle - fromAngle + Math.PI * 3) % (Math.PI * 2)) - Math.PI; + } + + function cancelSourceCueAnimation() { + if (!state.sourceCueAnimation) return; + cancelAnimationFrame(state.sourceCueAnimation); + state.sourceCueAnimation = 0; + } + + function applySourceCueAngle(angle, direction) { + state.sourceCueAngle = angle; + [ + [state.fab?.querySelector(".csw-source-dot"), CHIP_HEIGHT], + [state.panel?.querySelector(".csw-head-face .csw-source-dot"), 32], + ].forEach(([dot, trackHeight]) => { + if (!dot) return; + dot.setAttribute("data-direction", direction); + if (!Number.isFinite(angle)) return; + const point = capsuleBoundaryPoint(angle, CHIP_WIDTH, trackHeight); + dot.style.setProperty("--csw-source-x", `${point.x}px`); + dot.style.setProperty("--csw-source-y", `${point.y}px`); + }); + } + + function animateSourceCue(paneCue) { + if (!isCurrentRuntime()) return; + cancelSourceCueAnimation(); + if (paneCue.direction === "single" || !Number.isFinite(paneCue.angle)) { + applySourceCueAngle(null, "single"); + return; + } + + const targetAngle = paneCue.angle; + if (!Number.isFinite(state.sourceCueAngle) || prefersReducedMotion()) { + applySourceCueAngle(targetAngle, paneCue.direction); + return; + } + + const startAngle = state.sourceCueAngle; + const delta = normalizeSourceCueDelta(startAngle, targetAngle); + if (Math.abs(delta) < 0.001) { + applySourceCueAngle(targetAngle, paneCue.direction); + return; + } + + const duration = 180 + Math.min(1, Math.abs(delta) / Math.PI) * 120; + const generation = state.runtimeGeneration; + const startedAt = performance.now(); + const tick = (now) => { + if (!isCurrentRuntime(generation)) return; + const progress = clamp((now - startedAt) / duration, 0, 1); + const eased = 1 - Math.pow(1 - progress, 3); + applySourceCueAngle(startAngle + delta * eased, paneCue.direction); + if (progress < 1) state.sourceCueAnimation = requestAnimationFrame(tick); + else { + state.sourceCueAnimation = 0; + applySourceCueAngle(targetAngle, paneCue.direction); + } + }; + state.sourceCueAnimation = requestAnimationFrame(tick); + } + + function bridgeErrorPresentation(error = state.bridgeError) { + const text = normalizeText(error); + const match = FRIENDLY_BRIDGE_ERRORS.find((item) => item.pattern.test(text)); + return match || { + title: "生成失败,稍后重试", + message: "", + }; + } + + function outlineErrorTitle(error = state.outlineError) { + const text = normalizeText(error); + if (/找不到对应的小节/i.test(text)) return "找不到对应内容,刷新后再试"; + return FRIENDLY_BRIDGE_ERRORS.find((item) => item.pattern.test(text))?.title || "大纲暂不可用,稍后重试"; + } + + function statusTone(expression) { + if (expression === "error") return "error"; + if (expression === "answering" || expression === "generating") return "busy"; + if (expression === "ready" || expression === "surprise") return "ready"; + return "idle"; + } + + function statusToneForView(expression) { + if (state.activeTab === "outline") { + if (state.outlineStatus === "pending") return "busy"; + if (state.outlineStatus === "error") return "error"; + if (state.outlineItems.length) return "ready"; + return "idle"; + } + if (state.activeTab === "settings") { + if (!state.settingsLoaded) return "busy"; + if (/失败|错误|不可用/i.test(state.settingsStatus)) return "error"; + if (outlineEnabled() && !stepwiseEnabled()) return "ready"; + if (stepwiseEnabled() + && state.settings.baseUrlConfigured + && state.settings.model + && state.settings.apiKeyConfigured) return "ready"; + return "idle"; + } + return statusTone(expression); + } + + function refreshControlState() { + if (state.activeTab === "settings") { + return { blocked: false, title: "重新读取设置" }; + } + if (state.activeTab === "outline") { + const blocked = state.outlineStatus === "pending"; + return { blocked, title: blocked ? "正在整理大纲" : "刷新大纲" }; + } + const blocked = state.bridgeStatus === "pending" || chatBusy(); + return { blocked, title: blocked ? "等待回答完成" : "刷新建议" }; + } + + function refreshCurrentView() { + if (state.activeTab === "settings") return reloadSettings(); + if (state.activeTab === "outline") return refreshOutline(); + if (!stepwiseEnabled()) return; + return forceRefreshStepwise(); + } + + // Outline extraction is deliberately conservative: only visible, structured headings become targets. + function outlineVisible(node) { + if (!(node instanceof Element)) return false; + const rect = node.getBoundingClientRect(); + return Boolean(rect.width > 8 && rect.height > 8); + } + + function outlineMarkdownRoot(messageNode) { + if (!(messageNode instanceof Element)) return null; + const preferred = messageNode.querySelector( + [ + "[class*='markdownContent']", + "[class*='markdown-content']", + ".markdown", + ".prose", + "article", + ].join(",") + ); + if (preferred && !preferred.closest(`[${ROOT_ATTR}="true"]`)) return preferred; + return messageNode; + } + + function outlineProtectedSurface(node) { + if (!(node instanceof Element)) return true; + return Boolean(node.closest([ + `[${ROOT_ATTR}="true"]`, + "[contenteditable='true']", + "textarea", + "input", + "form", + ".ProseMirror", + ].join(","))); + } + + function outlineInCodeLike(node) { + if (!(node instanceof Element)) return true; + return Boolean(node.closest("pre, code, kbd, samp, [data-code-block], .cm-editor, .monaco-editor")); + } + + function outlineInTableLike(node) { + if (!(node instanceof Element)) return true; + return Boolean(node.closest(OUTLINE_TABLE_SELECTOR)); + } + + function outlineHeadingLevelFromTag(tag) { + const match = /^h([1-6])$/i.exec(tag || ""); + return match ? Number(match[1]) : 0; + } + + function outlineIsMarkerOnlyTitle(text) { + const value = normalizeText(text); + if (!value) return true; + if (/^[一二三四五六七八九十百零]+[、..)]?$/.test(value)) return true; + if (/^\d{1,2}[\.、.)]?$/.test(value)) return true; + if (/^[((]\d{1,2}[))]$/.test(value)) return true; + return /^#{1,6}$/.test(value); + } + + function outlineIsNoiseTitle(text) { + if (!text || outlineIsMarkerOnlyTitle(text)) return true; + if (text.length < MIN_OUTLINE_TITLE_LEN || text.length > MAX_OUTLINE_TITLE_LEN) return true; + if (text.length <= 4 && !/[0-9一二三四五六七八九十#::]/.test(text) && !outlineHasChapterHeading(text)) return true; + if (/^https?:\/\//i.test(text)) return true; + if (/^[\w./~-]+\.(js|ts|json|md|py|sh|log|png|jpg)$/i.test(text)) return true; + if (/^\$ |^>`|^```/.test(text)) return true; + if (/^(复制|copy|edit|编辑|share|分享|continue|继续|retry|重试|项|实现|位置|范围|标题|跳转|折叠|刷新)$/i.test(text)) return true; + if (/^[\d\s:./-]+$/.test(text)) return true; + if (/^\/Users\/|^~\/|^\.\/|^\/Volumes\//.test(text)) return true; + return /^(OK|PASS|FAIL|true|false|null)$/i.test(text); + } + + function outlineHasChapterHeading(text) { + const value = normalizeText(text); + if (!value) return false; + if (/^(摘要|简介|概述|概览|前言|背景|目标|现状|问题(?:分析)?|原因(?:分析)?|分析|方案|解决方案|步骤|实施步骤|实现|验证|验证结果|测试|测试结果|结果|结论|最终结论|总结|建议|后续建议|注意(?:事项)?|说明|补充说明|附录|下一步)(?:\s*[::—-]\s*\S.*)?$/.test(value)) { + return value.length <= 24; + } + return /^(abstract|introduction|overview|background|goals?|problems?|causes?|analysis|solutions?|steps?|implementation|verification|tests?|results?|conclusions?|summary|recommendations?|notes?|appendix|next steps?)(?:\s*[::—-]\s*\S.*)?$/i.test(value) + && value.length <= 32; + } + + function outlineLooksStructuredHeading(text) { + const value = normalizeText(text); + if (!value || outlineIsMarkerOnlyTitle(value)) return false; + if (/^#{1,6}\s+\S/.test(value)) return true; + if (/^第[一二三四五六七八九十百零\d]+[章节部分步]/.test(value)) return true; + if (/^[一二三四五六七八九十]+[、..]\s*\S{2,}/.test(value)) return true; + if (/^(?[0-9]{1,2})\s*\S{2,}/.test(value) || /^\([0-9]{1,2}\)\s*\S{2,}/.test(value)) return true; + if (/^\d{1,2}[\.、.\)]\s*\S{2,}/.test(value)) return true; + return outlineHasChapterHeading(value); + } + + function outlineScorePseudoHeading(text, levelHint) { + let score = levelHint ? 20 : 0; + if (!outlineLooksStructuredHeading(text) && !levelHint) return 0; + if (/^#{1,6}\s+\S/.test(text)) score += 50; + if (/^第[一二三四五六七八九十百零\d]+[章节部分步]/.test(text)) score += 30; + if (/^[一二三四五六七八九十]+[、..]\s*\S{2,}/.test(text)) score += 28; + if (/^(?[0-9]{1,2})\s*\S{2,}/.test(text) || /^\([0-9]{1,2}\)\s*\S{2,}/.test(text)) score += 24; + if (/^\d{1,2}[\.、.\)]\s*\S{2,}/.test(text)) score += 26; + if (/[::]$/.test(text) && text.length <= 18 && text.length >= 4) score += 8; + if (outlineHasChapterHeading(text)) score += 24; + if (text.length >= 4 && text.length <= 20) score += 6; + if (text.length >= 28) score -= 8; + if (/[。!?]$/.test(text)) score -= 12; + if (text.split(" ").length > 12) score -= 10; + return score; + } + + function outlineStripHeadingMarkers(text) { + const stripped = normalizeText(text) + .replace(/^#{1,6}\s+/, "") + .replace(/^([((]?\d{1,2}[))]|[一二三四五六七八九十]{1,3}|\d{1,2})[\.、.\)]\s*/, ""); + return stripped && !outlineIsMarkerOnlyTitle(stripped) ? stripped : normalizeText(text); + } + + function outlineDisplayHeadingTitle(text) { + const value = normalizeText(text).replace(/^#{1,6}\s+/, ""); + return value.length <= MAX_OUTLINE_TITLE_LEN ? value : `${value.slice(0, MAX_OUTLINE_TITLE_LEN - 1)}…`; + } + + function outlineTitlesEquivalent(left, right) { + const a = normalizeText(left); + const b = normalizeText(right); + return Boolean(a && b && (a === b || outlineStripHeadingMarkers(a) === outlineStripHeadingMarkers(b) + || outlineDisplayHeadingTitle(a) === outlineDisplayHeadingTitle(b))); + } + + function outlineOwnsOwnLine(node, text) { + if (!(node instanceof Element)) return false; + const parent = node.parentElement; + if (!parent) return true; + const parentText = normalizeText(parent.innerText || parent.textContent || ""); + if (!parentText || parentText === text) return true; + return parentText.startsWith(text) && parentText.length <= text.length + 4; + } + + function outlineHeadingCandidate(node, kind) { + if (!(node instanceof Element) || !outlineVisible(node) || outlineProtectedSurface(node) || outlineInCodeLike(node)) return null; + if (node.closest(`[${ROOT_ATTR}="true"]`)) return null; + + const text = normalizeText(node.innerText || node.textContent || ""); + if (!text || text.length > MAX_OUTLINE_TITLE_LEN + 8) return null; + const displayText = outlineDisplayHeadingTitle(text); + if (outlineIsNoiseTitle(displayText) || outlineIsMarkerOnlyTitle(displayText)) return null; + + if (kind === "semantic") { + const tagLevel = outlineHeadingLevelFromTag(node.tagName); + const ariaLevel = Number(node.getAttribute("aria-level") || 0); + return { + el: node, + text: displayText, + level: clamp(tagLevel || ariaLevel || 2, 1, 6), + kind, + }; + } + + if (outlineInTableLike(node)) return null; + const childCount = node.children?.length || 0; + if (childCount > 3 || node.querySelector("p,div,li,h1,h2,h3,h4,h5,h6,table,pre")) return null; + + if (node.matches("strong,b")) { + if (!outlineOwnsOwnLine(node, text)) return null; + const score = outlineScorePseudoHeading(text, 1) + 8; + if (score < OUTLINE_PSEUDO_MIN_SCORE) return null; + return { el: node, text: displayText, level: 3, kind }; + } + + const rect = node.getBoundingClientRect(); + if (rect.height > 84 || !outlineLooksStructuredHeading(text)) return null; + const score = outlineScorePseudoHeading(text, 0); + if (score < OUTLINE_PSEUDO_MIN_SCORE) return null; + const numbered = /^\d{1,2}[\.、.\)]\s*\S{2,}/.test(text) + || /^[一二三四五六七八九十]+[、..]\s*\S{2,}/.test(text); + return { el: node, text: displayText, level: numbered ? 2 : text.length <= 12 ? 2 : 3, kind }; + } + + function outlineCollectSemanticHeadings(root) { + if (!(root instanceof Element)) return []; + const result = []; + const nodes = root.querySelectorAll(OUTLINE_SEMANTIC_HEADING_SELECTOR); + for (const node of nodes) { + const item = outlineHeadingCandidate(node, "semantic"); + if (item) result.push(item); + } + return result; + } + + function outlineCollectPseudoHeadings(root) { + if (!(root instanceof Element)) return []; + const result = []; + const nodes = root.querySelectorAll(OUTLINE_PSEUDO_HEADING_SELECTOR); + for (const node of nodes) { + if (node.closest(OUTLINE_SEMANTIC_HEADING_SELECTOR)) continue; + const item = outlineHeadingCandidate(node, "pseudo"); + if (item) result.push(item); + } + return result; + } + + function outlineSortInDocumentOrder(items) { + return items.slice().sort((left, right) => { + if (left.el === right.el) return 0; + const position = left.el.compareDocumentPosition(right.el); + if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1; + if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1; + return 0; + }); + } + + function outlineCollectHeadingElements(root) { + const semanticItems = outlineCollectSemanticHeadings(root); + if (semanticItems.length >= MIN_OUTLINE_ITEMS) return outlineSortInDocumentOrder(semanticItems); + return outlineSortInDocumentOrder([...semanticItems, ...outlineCollectPseudoHeadings(root)]); + } + + function outlineDedupeItems(items) { + const seen = new Set(); + const result = []; + for (const item of items) { + const key = `${item.level}|${item.text}`; + if (seen.has(key)) continue; + const previous = result.at(-1); + if (previous && (previous.text === item.text || previous.el.contains(item.el) || item.el.contains(previous.el))) { + continue; + } + seen.add(key); + result.push(item); + if (result.length >= MAX_OUTLINE_ITEMS) break; + } + return result; + } + + function outlineNormalizeDisplayLevels(items) { + if (!items.length) return items; + const minimumLevel = Math.min(...items.map((item) => item.level)); + items.forEach((item) => { + item.displayLevel = item.level - minimumLevel; + }); + return items; + } + + function outlineMarkItems(items) { + items.forEach((item, index) => { + const id = `stepwise-outline-${hashText(`${index}:${item.text}`)}-${index + 1}`; + item.id = id; + item.el.setAttribute(MARK_ATTR, id); + }); + return items; + } + + function outlineClearMarks(root = document) { + if (!root?.querySelectorAll) return; + root.querySelectorAll(`[${MARK_ATTR}]`).forEach((node) => node.removeAttribute(MARK_ATTR)); + root.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach((node) => node.classList.remove(HIGHLIGHT_CLASS)); + } + + function outlineFindScrollContainer(fromElement) { + let node = fromElement instanceof Element ? fromElement.parentElement : null; + while (node && node !== document.documentElement) { + const style = window.getComputedStyle(node); + const overflowY = style.overflowY || style.overflow; + if (/(auto|scroll|overlay)/.test(overflowY) && node.scrollHeight > node.clientHeight + 4) return node; + node = node.parentElement; + } + return document.scrollingElement || document.documentElement; + } + + function outlineResolveElement(id) { + const item = state.outlineItems.find((entry) => entry.id === id) || null; + if (item?.el?.isConnected) return item.el; + const marked = Array.from(document.querySelectorAll(`[${MARK_ATTR}]`)) + .find((node) => node.getAttribute(MARK_ATTR) === String(id)); + if (marked instanceof Element) { + if (item) item.el = marked; + return marked; + } + const latest = state.outlineMessage?.isConnected ? { node: state.outlineMessage } : findLatestAssistantMessage(); + const root = outlineMarkdownRoot(latest?.node); + if (!root || !item?.text) return null; + const kind = item.kind === "semantic" ? "semantic" : "pseudo"; + const selector = kind === "semantic" ? OUTLINE_SEMANTIC_HEADING_SELECTOR : OUTLINE_PSEUDO_HEADING_SELECTOR; + const candidates = root.querySelectorAll(selector); + for (const node of candidates) { + if (kind === "pseudo" && node.closest(OUTLINE_SEMANTIC_HEADING_SELECTOR)) continue; + const candidate = outlineHeadingCandidate(node, kind); + if (!candidate || !outlineTitlesEquivalent(candidate.text, item.text)) continue; + node.setAttribute(MARK_ATTR, id); + item.el = node; + return node; + } + return null; + } + + function outlineFlash(element) { + if (!(element instanceof Element)) return; + element.classList.add(HIGHLIGHT_CLASS); + if (state.flashTimer) window.clearTimeout(state.flashTimer); + state.flashTimer = window.setTimeout(() => { + element.classList.remove(HIGHLIGHT_CLASS); + state.flashTimer = 0; + }, FLASH_MS); + } + + function outlineJumpTo(id) { + const element = outlineResolveElement(id); + if (!(element instanceof Element)) return false; + state.panel?.querySelectorAll("[data-outline-id]").forEach((button) => { + const isActive = button.dataset.outlineId === id; + button.dataset.active = isActive ? "true" : "false"; + if (isActive) button.setAttribute("aria-current", "location"); + else button.removeAttribute("aria-current"); + }); + const previousMargin = element.style.scrollMarginTop; + element.style.scrollMarginTop = "88px"; + try { + element.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" }); + } catch { + try { element.scrollIntoView(true); } catch {} + } + window.requestAnimationFrame(() => { + const rect = element.getBoundingClientRect(); + if (rect.top < 40 || rect.top > window.innerHeight * 0.55) { + const container = outlineFindScrollContainer(element); + if (container && container !== document.documentElement && container !== document.body) { + const containerRect = container.getBoundingClientRect(); + const next = container.scrollTop + rect.top - containerRect.top - 80; + try { container.scrollTo({ top: next, behavior: "smooth" }); } catch { container.scrollTop = next; } + } + } + window.setTimeout(() => { + if (element.style.scrollMarginTop === "88px") element.style.scrollMarginTop = previousMargin; + }, 420); + }); + outlineFlash(element); + return true; + } + + function outlineBuild(message, sourceHash) { + if (!message?.node) { + outlineClearMarks(); + return { items: [], fingerprint: sourceHash || "", message: null }; + } + const textLength = message.text.length; + const raw = outlineCollectHeadingElements(outlineMarkdownRoot(message.node)); + const items = outlineNormalizeDisplayLevels(outlineDedupeItems(raw)); + const structuredEnough = items.length >= Math.max(MIN_OUTLINE_ITEMS, 3) && textLength >= 160; + outlineClearMarks(); + if (textLength < MIN_OUTLINE_TEXT_LEN && !structuredEnough || items.length < MIN_OUTLINE_ITEMS) { + return { items: [], fingerprint: `${sourceHash}|empty`, message: message.node }; + } + outlineMarkItems(items); + return { + items, + fingerprint: `${sourceHash}|${hashText(items.map((item) => `${item.level}:${item.text}`).join("|"))}`, + message: message.node, + }; } - function onFabPointerUp(event) { - const drag = state.drag; - state.fab.removeEventListener("pointermove", onFabPointerMove); - state.fab.releasePointerCapture?.(event.pointerId); - window.setTimeout(() => { - if (state.drag === drag) state.drag = null; - }, 0); + function invalidateOutline(message = null, sourceHash = "") { + outlineClearMarks(); + state.outlineItems = []; + state.outlineStatus = chatBusy() ? "pending" : "idle"; + state.outlineError = ""; + state.outlineFingerprint = ""; + state.outlineSourceHash = ""; + state.outlineMessage = message?.node || null; + if (state.activeTab === "outline" && state.panel) renderFloat({ preserveMorph: true }); } - function onFabClick(event) { - if (state.drag?.moved) { - event.preventDefault(); - event.stopPropagation(); - return; + // Outline refresh is keyed to the pinned latest answer, so passive scrolling cannot switch context. + async function refreshOutline(options = {}) { + if (!isCurrentRuntime() || !outlineEnabled()) return; + if (state.outlineRefreshPromise) return state.outlineRefreshPromise; + const requestContext = contextSnapshot(); + const requestEpoch = state.outlineEpoch; + const requestCurrent = () => outlineEnabled() + && requestEpoch === state.outlineEpoch + && contextMatches(requestContext); + state.outlineStatus = "pending"; + state.outlineError = ""; + if (state.activeTab === "outline") renderFloat({ preserveMorph: true }); + + const task = Promise.resolve().then(() => { + if (!requestCurrent()) return; + const message = options.message || findLatestAssistantMessage(); + const sourceHash = options.assistantHash || hashText(message?.text || ""); + if (chatBusy()) { + state.outlineError = "回答尚未完成,完成后再试"; + state.outlineStatus = "pending"; + scheduleScan(STREAM_IDLE_MS); + return; + } + const result = outlineBuild(message, sourceHash); + if (!requestCurrent()) return; + state.outlineItems = result.items; + state.outlineFingerprint = result.fingerprint; + state.outlineSourceHash = sourceHash; + state.outlineMessage = result.message; + state.outlineStatus = result.items.length ? "ready" : "empty"; + state.outlineError = ""; + }).catch((error) => { + if (!requestCurrent()) return; + outlineClearMarks(); + state.outlineItems = []; + state.outlineStatus = "error"; + state.outlineError = error?.message || "大纲暂不可用"; + }).finally(() => { + if (!requestCurrent()) return; + if (state.outlineRefreshPromise === task) state.outlineRefreshPromise = null; + if (state.activeTab === "outline") renderFloat({ preserveMorph: true }); + }); + state.outlineRefreshPromise = task; + return task; + } + + function outlineHtml() { + if (state.outlineStatus === "pending") { + return `
+ + + 正在整理大纲 + +
`; } - state.open = !state.open; - renderFloat(); + if (state.outlineStatus === "error") { + return `
+
${escapeHtml(outlineErrorTitle())}
+
`; + } + if (!state.outlineItems.length) { + return `
+
暂无大纲
+
`; + } + return `
${state.outlineItems.map((item) => { + const displayLevel = item.displayLevel ?? 0; + return ` + + `; + }).join("")}
`; } - function renderFloat() { - if (!isCurrentInstance()) return; + function attachOutlineEvents() { + state.panel.querySelectorAll("[data-outline-id]").forEach((button) => { + button.addEventListener("click", () => { + if (!outlineJumpTo(button.dataset.outlineId)) { + state.outlineStatus = "error"; + state.outlineError = "找不到对应的小节,刷新后再试。"; + renderFloat({ preserveMorph: true }); + } + }); + }); + } + + function viewScrollTargets(body = state.panel?.querySelector(".csw-body[data-view-body]")) { + if (!body) return []; + const targets = [body]; + const previewScroll = body.querySelector(".csw-prompt-preview-scroll"); + if (previewScroll) targets.push(previewScroll); + return targets; + } + + function captureViewScroll() { + const body = state.panel?.querySelector(".csw-body[data-view-body]"); + if (!body || body.dataset.viewBody !== state.activeTab) return null; + const preview = body.querySelector(".csw-prompt-preview"); + const previewScroll = preview?.querySelector(".csw-prompt-preview-scroll"); + return { + view: state.activeTab, + top: body.scrollTop, + preview: preview && previewScroll ? { + index: preview.dataset.previewIndex || "", + prompt: preview.querySelector(".csw-prompt-preview-body")?.textContent || "", + top: previewScroll.scrollTop, + } : null, + }; + } + + function restoreViewScroll(snapshot) { + if (!snapshot || snapshot.view !== state.activeTab) return; + const body = state.panel?.querySelector(".csw-body[data-view-body]"); + if (!body || body.dataset.viewBody !== snapshot.view) return; + const maxTop = Math.max(0, body.scrollHeight - body.clientHeight); + body.scrollTop = clamp(snapshot.top, 0, maxTop); + + const preview = body.querySelector(".csw-prompt-preview"); + const previewScroll = preview?.querySelector(".csw-prompt-preview-scroll"); + if (!snapshot.preview || !preview || !previewScroll) return; + const prompt = preview.querySelector(".csw-prompt-preview-body")?.textContent || ""; + if (preview.dataset.previewIndex !== snapshot.preview.index || prompt !== snapshot.preview.prompt) return; + const previewMaxTop = Math.max(0, previewScroll.scrollHeight - previewScroll.clientHeight); + previewScroll.scrollTop = clamp(snapshot.preview.top, 0, previewMaxTop); + } + + function syncContentFade() { + const popover = state.popover; + const body = state.panel?.querySelector(".csw-body[data-view-body]"); + if (!popover || !body) return; + + const preview = body.querySelector(".csw-prompt-preview"); + const previewScroll = preview?.querySelector(".csw-prompt-preview-scroll"); + if (preview && previewScroll) { + const previewMaxTop = Math.max(0, previewScroll.scrollHeight - previewScroll.clientHeight); + const previewOverflowing = previewMaxTop > 2; + const previewAtEnd = !previewOverflowing || previewScroll.scrollTop >= previewMaxTop - 2; + preview.dataset.scrollOverflow = String(previewOverflowing); + preview.dataset.scrollAtEnd = String(previewAtEnd); + preview.dataset.scrollFade = String(previewOverflowing && !previewAtEnd); + } + + const view = body.dataset.viewBody || ""; + const compressed = popover.dataset.compressed === "true"; + const eligible = compressed && (view === "next" || view === "outline"); + const scrollStates = viewScrollTargets(body).map((target) => ({ + target, + maxTop: Math.max(0, target.scrollHeight - target.clientHeight), + })); + const overflowing = eligible && scrollStates.some(({ maxTop }) => maxTop > 2); + const atEnd = !overflowing || scrollStates.every(({ target, maxTop }) => ( + maxTop <= 2 || target.scrollTop >= maxTop - 2 + )); + + popover.dataset.contentOverflow = String(overflowing); + popover.dataset.contentAtEnd = String(atEnd); + popover.dataset.contentFade = String(overflowing && !atEnd); + } + + function installContentFadeTracking() { + state.contentFadeCleanup?.(); + state.contentFadeCleanup = null; + + const body = state.panel?.querySelector(".csw-body[data-view-body]"); + const targets = viewScrollTargets(body); + if (!body || !targets.length) return; + + const onScroll = () => syncContentFade(); + targets.forEach((target) => target.addEventListener("scroll", onScroll, { passive: true })); + + const resizeObserver = typeof window.ResizeObserver === "function" + ? new window.ResizeObserver(onScroll) + : null; + const resizeTargets = new Set(); + targets.forEach((target) => { + resizeTargets.add(target); + if (target.firstElementChild) resizeTargets.add(target.firstElementChild); + }); + resizeTargets.forEach((target) => resizeObserver?.observe(target)); + + state.contentFadeCleanup = () => { + targets.forEach((target) => target.removeEventListener("scroll", onScroll)); + resizeObserver?.disconnect(); + }; + + syncContentFade(); + window.requestAnimationFrame(() => { + if (body.isConnected && state.panel?.contains(body)) syncContentFade(); + }); + } + + // Rendering preserves scroll, active view, and in-flight morph state while replacing only view content. + function renderFloat(options = {}) { + if (!isCurrentRuntime()) return; + if (!options.allowDuringTransition && (state.viewTransitioning || state.morphAnimation)) { + deferRender(); + return; + } + state.activeTab = normalizeActiveTab(); + const viewScroll = captureViewScroll(); + clearPromptInteractionTimers(); + cancelViewAnimation(); installStyle(); installFloat(); - if (!state.fab || !state.popover) return; + if (!state.fab || !state.popover || !state.panel || !state.glass) return; syncTheme(); - const count = state.prompts.length; - state.fab.dataset.count = String(count); - state.fab.querySelector(".csw-fab-badge").textContent = String(count); + normalizePromptState(); + const expressionNow = Date.now(); + const outlineExpression = usesOutlineExpression(expressionNow); + const expression = resolveFabExpression(expressionNow); + const expressionCount = outlineExpression ? state.outlineItems.length : state.prompts.length; + const expressionLabel = fabExpressionLabel(expression, outlineExpression); + const featureLabel = stepwiseEnabled() && outlineEnabled() + ? "悬浮球" + : stepwiseEnabled() ? "下一步" : "回答大纲"; + const hidden = expression === "hidden"; + if (hidden) { + settleMorph(0); + } + state.fabExpression = expression; + state.fab.dataset.expression = expression; + state.fab.dataset.count = String(expressionCount); + state.fab.title = state.open ? "收起" : `${featureLabel} · ${expressionLabel}`; + state.fab.setAttribute("aria-label", state.open + ? "收起" + : expressionCount > 0 && expression === "ready" + ? `${featureLabel} · ${expressionLabel} · ${expressionCount} ${outlineExpression ? "个章节" : "条"}` + : `${featureLabel} · ${expressionLabel}`); + state.fab.setAttribute("aria-expanded", String(state.open)); + state.root.dataset.hidden = String(hidden); state.popover.dataset.open = state.open ? "true" : "false"; - if (!state.open) return; + state.popover.dataset.expression = expression; + state.popover.dataset.view = state.activeTab; + applyPosition(); - const refreshBlocked = state.bridgeStatus === "pending" || chatBusy(); - const refreshTitle = refreshBlocked ? "生成结束后可重新生成" : "重新生成"; - state.popover.innerHTML = ` + const refreshState = refreshControlState(); + const refreshBlocked = refreshState.blocked; + const refreshTitle = refreshState.title; + const headExpression = state.activeTab === "settings" ? "curious" : expression; + const tone = statusToneForView(expression); + const paneCue = activePaneCue(); + state.panel.innerHTML = `
-
Stepwise
-
+
+
+ + ${stepwiseEnabled() ? `` : ""} + ${outlineEnabled() ? `` : ""} +
+
+ +
- - +
-
${state.activeTab === "settings" ? settingsHtml() : nextHtml()}
+
+
${state.activeTab === "settings" ? settingsHtml() : state.activeTab === "outline" ? outlineHtml() : nextHtml()}
+
`; - - state.popover.querySelector("[data-action='settings-toggle']")?.addEventListener("click", () => { - state.activeTab = state.activeTab === "settings" ? "next" : "settings"; - if (state.activeTab === "settings") void loadSettings(); - renderFloat(); - }); - state.popover.querySelector("[data-action='close']")?.addEventListener("click", () => { - state.open = false; - renderFloat(); + animateViewTabSelection(options.viewIndicatorFrom ?? state.activeTab, state.activeTab); + animateSourceCue(paneCue); + restoreViewScroll(viewScroll); + installContentFadeTracking(); + state.panel.querySelectorAll("[data-view]").forEach((button) => { + button.addEventListener("click", () => { + const nextTab = button.dataset.view || "next"; + if (nextTab === state.activeTab) return; + void switchView(nextTab); + }); }); - state.popover.querySelector("[data-action='refresh']")?.addEventListener("click", () => forceRefreshStepwise()); - state.popover.querySelector("[data-action='theme']")?.addEventListener("click", toggleCodexTheme); + const headFace = state.panel.querySelector("[data-action='collapse']"); + headFace?.addEventListener("click", onHeadFaceClick); + bindGlassPointerSurface(headFace); + state.panel.querySelector("[data-action='refresh']")?.addEventListener("click", () => void refreshCurrentView()); + state.panel.querySelector("[data-action='theme']")?.addEventListener("click", toggleCodexTheme); + applyMaterial({ animate: false }); if (state.activeTab === "settings") attachSettingsEvents(); + else if (state.activeTab === "outline") attachOutlineEvents(); else attachNextEvents(); - positionPopover(); + installPanelDrag(); + syncEyeTracking(); + if (!options.preserveMorph && !state.morphAnimation) settleMorph(state.open ? 1 : 0); } - function nextHtml() { + function nextProgressState() { if (state.bridgeStatus === "pending") { - return `
生成中...
`; + return { + title: "正在生成建议", + }; + } + if (stepwiseGenerationMode() === "manual") return null; + if (state.scanStatus === "assistant-changed" || state.scanStatus === "assistant-settling") { + return { + title: "正在整理回答", + }; + } + if (state.scanStatus === "not-ready" && state.scanBusy) { + return { + title: "等待回答完成", + }; + } + return null; + } + + function nextHtml() { + const progress = nextProgressState(); + if (progress) { + return `
+ + + ${progress.title} + +
`; } if (!state.prompts.length) { - const text = emptyStateText(); - return `
${escapeHtml(text)}
`; + const empty = nextEmptyState(); + return `
+
${escapeHtml(empty.title)}
+
`; } - return `
${state.prompts.map((item, index) => ` - - `).join("")}
`; + const previewIndex = clamp(Number(state.promptPreviewIndex) || 0, 0, state.prompts.length - 1); + const previewItem = state.prompts[previewIndex]; + state.promptPreviewIndex = previewIndex; + return `
+
${state.prompts.map((item, index) => ` + + `).join("")}
+
+
+
+ ${previewIndex + 1} / ${state.prompts.length} + ${escapeHtml(previewItem.label || labelForPrompt(previewItem.prompt))} + ${escapeHtml(previewItem.prompt)} +
+
+
+
`; } - function emptyStateText() { - if (state.bridgeError) return state.bridgeError; - if (state.bridgeStatus === "ok") return "Stepwise API 已返回,但没有解析到可用建议"; - if (state.bridgeStatus === "disabled") return "Stepwise 已关闭"; - return "当前没有可用建议"; + function nextEmptyState() { + if (state.bridgeError || state.bridgeStatus === "failed") return bridgeErrorPresentation(); + if (state.bridgeStatus === "ok") { + return { + title: "暂无建议", + message: "", + }; + } + if (state.bridgeStatus === "disabled") { + return { + title: "功能已关闭", + message: "", + }; + } + if (stepwiseGenerationMode() === "manual") { + return { + title: "当前为手动模式", + message: "", + state: "manual", + }; + } + return { + title: "等待回答完成", + message: "", + }; } function attachNextEvents() { - state.popover.querySelectorAll(".csw-row").forEach((button) => { - button.addEventListener("click", () => void selectPrompt(button)); + state.panel.querySelectorAll(".csw-row").forEach((button) => { + button.addEventListener("pointerenter", () => schedulePromptPreview(button)); + button.addEventListener("pointerleave", cancelScheduledPromptPreview); + button.addEventListener("focus", () => showPromptPreview(button, true)); + button.addEventListener("click", (event) => { + if (event.detail >= 2) { + event.preventDefault(); + if (state.promptClickTimer) window.clearTimeout(state.promptClickTimer); + state.promptClickTimer = 0; + showPromptPreview(button, true); + selectPrompt(button, promptClickSubmits(event.detail)); + return; + } + + if (state.promptClickTimer) window.clearTimeout(state.promptClickTimer); + const generation = state.runtimeGeneration; + state.promptClickTimer = window.setTimeout(() => { + state.promptClickTimer = 0; + if (!isCurrentRuntime(generation) || !button.isConnected) return; + showPromptPreview(button, true); + selectPrompt(button, promptClickSubmits(1)); + }, PROMPT_CLICK_DELAY_MS); + }); + button.addEventListener("dblclick", (event) => event.preventDefault()); }); } - async function selectPrompt(button) { - const item = state.prompts[Number(button.dataset.index)]; - if (!item?.prompt) return; - if (state.settings) { - fillSelectedPrompt(item.prompt, state.settings); + function clearPromptInteractionTimers() { + if (state.promptPreviewTimer) window.clearTimeout(state.promptPreviewTimer); + if (state.promptClickTimer) window.clearTimeout(state.promptClickTimer); + state.promptPreviewTimer = 0; + state.promptClickTimer = 0; + } + + function schedulePromptPreview(button) { + if (state.promptPreviewTimer) window.clearTimeout(state.promptPreviewTimer); + state.promptPreviewTimer = 0; + showPromptPreview(button); + } + + function cancelScheduledPromptPreview() { + if (state.promptPreviewTimer) window.clearTimeout(state.promptPreviewTimer); + state.promptPreviewTimer = 0; + } + + function showPromptPreview(button, immediate = false) { + const index = Number(button.dataset.index); + const item = state.prompts[index]; + const preview = state.panel?.querySelector(".csw-prompt-preview"); + if (!item?.prompt || !preview) return; + + if (Number(preview.dataset.previewIndex) === index) { + state.panel.querySelectorAll(".csw-row").forEach((row) => { + const active = row === button; + row.dataset.previewActive = String(active); + row.setAttribute("aria-current", active ? "true" : "false"); + }); + preview.removeAttribute("data-switching"); return; } - pushDiagnostic("settings:missing-before-click", {}); - const settings = await ensureSettings(); - if (!isCurrentInstance()) return; - fillSelectedPrompt(item.prompt, settings); + const applyPreview = () => { + if (!button.isConnected || !preview.isConnected) return; + state.panel.querySelectorAll(".csw-row").forEach((row) => { + const active = row === button; + row.dataset.previewActive = String(active); + row.setAttribute("aria-current", active ? "true" : "false"); + }); + const title = preview.querySelector(".csw-prompt-preview-title"); + const kicker = preview.querySelector(".csw-prompt-preview-kicker"); + const body = preview.querySelector(".csw-prompt-preview-body"); + const scroll = preview.querySelector(".csw-prompt-preview-scroll"); + if (kicker) kicker.textContent = `${index + 1} / ${state.prompts.length}`; + if (title) title.textContent = item.label || labelForPrompt(item.prompt); + if (body) body.textContent = item.prompt; + if (scroll) scroll.scrollTop = 0; + preview.dataset.previewIndex = String(index); + state.promptPreviewIndex = index; + syncContentFade(); + window.requestAnimationFrame(() => { + preview.removeAttribute("data-switching"); + if (preview.isConnected) syncContentFade(); + }); + }; + + if (immediate) { + if (state.promptPreviewTimer) window.clearTimeout(state.promptPreviewTimer); + state.promptPreviewTimer = 0; + preview.removeAttribute("data-switching"); + applyPreview(); + return; + } + const generation = state.runtimeGeneration; + state.promptPreviewTimer = window.setTimeout(() => { + state.promptPreviewTimer = 0; + if (!isCurrentRuntime(generation) || !button.matches(":hover, :focus, :focus-within")) return; + preview.dataset.switching = "true"; + applyPreview(); + }, PROMPT_PREVIEW_SWITCH_MS); } - function fillSelectedPrompt(prompt, settings) { - pushDiagnostic("settings:click-mode", { - directSend: settings?.directSend === true, + function selectPrompt(button, submit) { + const item = state.prompts[Number(button.dataset.index)]; + if (!item?.prompt) return; + pushDiagnostic("prompt:select", { + submit, + clickMode: state.promptClickMode, + index: Number(button.dataset.index), }); - fillComposer(prompt, settings?.directSend === true); - state.open = false; - renderFloat(); + fillComposer(item.prompt, submit); } - function settingsHtml() { - const settings = state.settings; - if (!settings) return `
读取中...
`; - const notice = settingsNotice(settings); + function promptClickSubmits(clickDetail, value = state.promptClickMode) { + const mode = normalizePromptClickMode(value); + if (mode === "direct") return true; + if (mode === "fill") return false; + return clickDetail >= 2; + } + + function settingsModelLabel(settings) { + if (settings && !stepwiseEnabled(settings)) { + return outlineEnabled(settings) ? "回答大纲" : "未启用"; + } + const raw = normalizeText(settings?.model); + if (!raw) return settings ? "未配置" : "读取中"; + const leaf = raw.split("/").pop() || raw; + return leaf + .replace(/^gpt[-_:]?/i, "") + .split(/[-_\s]+/) + .filter(Boolean) + .map((part) => (/^\d/.test(part) ? part : `${part.charAt(0).toUpperCase()}${part.slice(1)}`)) + .join(" "); + } + + function settingsRuntimePresentation(settings) { + if (!settings) return { label: "正在读取设置", tone: "busy" }; + if (!runtimeEnabled(settings)) return { label: "已关闭", tone: "idle" }; + if (stepwiseEnabled(settings) && (!settings.baseUrlConfigured || !settings.model || !settings.apiKeyConfigured)) { + return { label: "等待配置", tone: "error" }; + } + const expressionNow = Date.now(); + const outlineExpression = usesOutlineExpression(expressionNow); + const expression = resolveFabExpression(expressionNow); + const detail = (outlineExpression ? { + idle: "等待回答", + answering: "回答中", + surprise: "正在整理回答", + generating: "正在整理大纲", + ready: `${state.outlineItems.length} 个章节已准备`, + empty: "暂无大纲", + error: "生成失败", + hidden: "已关闭", + } : { + idle: "等待回答", + answering: "回答中", + surprise: "正在整理回答", + generating: "正在生成建议", + ready: `${state.prompts.length} 条建议已准备`, + empty: "暂无建议", + error: "生成失败", + hidden: "已关闭", + })[expression] || "等待回答"; + if (!outlineExpression && stepwiseWaitingForManualRefresh(settings)) { + return { label: "当前为手动模式", tone: "idle" }; + } + return { label: detail, tone: statusTone(expression) }; + } + + function settingsCommandHtml(action, icon, label, title, options = {}) { return ` -
-
-
摘要
-
- ${summaryRow("Stepwise", settings.enabled ? "已开启" : "已关闭", settings.enabled ? "good" : "muted")} - ${summaryRow("直接发送", settings.directSend ? "已开启" : "已关闭", settings.directSend ? "good" : "muted")} - ${summaryRow("模型", settings.model || "未配置", settings.model ? "plain" : "warn")} - ${summaryRow("最多建议", settings.maxItems ?? 6, "plain")} -
+ + `; + } + + function promptClickModeLabel(value = state.promptClickMode) { + return { + direct: "直接发送", + hybrid: "单击填入 · 双击发送", + fill: "仅填入", + }[normalizePromptClickMode(value)]; + } + + function generationModeLabel(value = stepwiseGenerationMode()) { + return normalizeGenerationMode(value) === "manual" ? "手动刷新" : "自动生成"; + } + + function nextGenerationMode(value = stepwiseGenerationMode()) { + const index = GENERATION_MODES.indexOf(normalizeGenerationMode(value)); + return GENERATION_MODES[(index + 1) % GENERATION_MODES.length]; + } + + function nextPromptClickMode(value = state.promptClickMode) { + const index = PROMPT_CLICK_MODES.indexOf(normalizePromptClickMode(value)); + return PROMPT_CLICK_MODES[(index + 1) % PROMPT_CLICK_MODES.length]; + } + + function generationModeButtonLabel(value = stepwiseGenerationMode()) { + return `模式:${generationModeLabel(value)};切换为${generationModeLabel(nextGenerationMode(value))}`; + } + + function promptClickModeButtonLabel(value = state.promptClickMode) { + return `点击:${promptClickModeLabel(value)};切换为${promptClickModeLabel(nextPromptClickMode(value))}`; + } + + function toggleGenerationMode(event) { + event?.preventDefault(); + event?.stopPropagation(); + return setGenerationMode(nextGenerationMode()); + } + + function togglePromptClickMode(event) { + event?.preventDefault(); + event?.stopPropagation(); + return writePromptClickMode(nextPromptClickMode()); + } + + function appearanceSettingsHtml() { + return ` +
+
+ 外观 + + +
-
- - - +
+ 字号 + + + ${fontSizeLabel()} + + +
+
+ 显示 + + +
- ${notice ? `
${escapeHtml(notice)}
` : ""}
`; } - function summaryRow(label, value, tone = "plain") { + function settingsHtml() { + const settings = state.settingsLoaded ? state.settings : null; + const runtime = settingsRuntimePresentation(settings); + const model = settingsModelLabel(settings); + const notice = settings ? settingsNotice(settings) : ""; + const noticeTone = /失败|错误|未配置|关闭|不可用|需要/i.test(notice) ? "warn" : "plain"; + const testing = state.settingsStatus === "正在检查连接"; return ` -
- ${escapeHtml(label)} - ${escapeHtml(value)} +
+
+
+
+ ${escapeHtml(model)} + + + ${escapeHtml(runtime.label)} + +
+ ${appearanceSettingsHtml()} +
+ +
`; } @@ -1131,70 +6240,199 @@ const status = state.settingsStatus || ""; const line = statusLine(settings); if (!status || status === line) { - if (settings.enabled && settings.baseUrlConfigured && settings.model && settings.apiKeyConfigured) return ""; + if (stepwiseEnabled(settings) && settings.baseUrlConfigured && settings.model && settings.apiKeyConfigured) return ""; + if (outlineEnabled(settings) && !stepwiseEnabled(settings)) return ""; return line; } - return status; + return status; + } + + function statusLine(settings) { + if (!runtimeEnabled(settings)) return "悬浮球已关闭"; + if (!stepwiseEnabled(settings)) return "仅显示大纲"; + if (!settings.baseUrlConfigured || !settings.model) return "尚未配置服务地址或模型"; + if (!settings.apiKeyConfigured) return "尚未配置密钥"; + return `连接就绪 · ${settings.model || ""}`.replace(/\s+·\s+$/, ""); + } + + function attachSettingsEvents() { + state.panel.querySelector("[data-action='material']")?.addEventListener("click", toggleMaterial); + state.panel.querySelector("[data-action='label-only']")?.addEventListener("click", toggleLabelOnly); + state.panel.querySelector("[data-action='font-dec']")?.addEventListener("click", () => bumpFontSize(-1)); + state.panel.querySelector("[data-action='font-inc']")?.addEventListener("click", () => bumpFontSize(1)); + state.panel.querySelector("[data-action='open-manager']")?.addEventListener("click", () => void openManager()); + state.panel.querySelector("[data-action='test-settings']")?.addEventListener("click", () => void testSettings()); + state.panel.querySelector("[data-action='generation-mode']")?.addEventListener("click", (event) => { + void toggleGenerationMode(event); + }); + state.panel.querySelector("[data-action='prompt-click-mode']")?.addEventListener("click", togglePromptClickMode); + } + + function writePromptClickMode(value) { + state.promptClickMode = normalizePromptClickMode(value); + storage.set(PROMPT_CLICK_MODE_KEY, state.promptClickMode); + const trigger = state.panel?.querySelector("[data-action='prompt-click-mode']"); + const display = trigger?.querySelector("[data-prompt-click-mode-value]"); + const label = promptClickModeButtonLabel(); + if (trigger) { + trigger.title = label; + trigger.setAttribute("aria-label", label); + } + if (display) display.textContent = promptClickModeLabel(); + return state.promptClickMode; } - function statusLine(settings) { - if (settings.enabled !== true) return "Stepwise 已关闭,请在 Codex++ Manager 里开启。"; - if (!settings.baseUrlConfigured || !settings.model) return "Stepwise 已开启,但 Base URL 或 Model 未配置。"; - if (!settings.apiKeyConfigured) return `Stepwise 已开启,但 API Key 未配置;可填写密钥或设置 ${settings.apiKeyEnv || "环境变量"}。`; - return `Stepwise 已开启 · ${settings.model || ""}`.replace(/\s+·\s+$/, ""); + function updateGenerationModeControl(value = stepwiseGenerationMode(), busy = false) { + const mode = normalizeGenerationMode(value); + const trigger = state.panel?.querySelector("[data-action='generation-mode']"); + const display = trigger?.querySelector("[data-generation-mode-value]"); + const label = generationModeButtonLabel(mode); + if (trigger) { + trigger.title = label; + trigger.setAttribute("aria-label", label); + trigger.setAttribute("aria-busy", String(busy)); + trigger.disabled = busy; + } + if (display) display.textContent = generationModeLabel(mode); } - function attachSettingsEvents() { - state.popover.querySelector("[data-action='open-manager']")?.addEventListener("click", () => void openManager()); - state.popover.querySelector("[data-action='test-settings']")?.addEventListener("click", () => void testSettings()); - state.popover.querySelector("[data-action='reset-position']")?.addEventListener("click", () => { - localStorage.removeItem(POSITION_KEY); - state.position = defaultPosition(); - applyPosition(); - state.settingsStatus = "位置已归位"; - renderFloat(); + async function setGenerationMode(value) { + if (!isCurrentRuntime() || !state.settingsLoaded || !stepwiseEnabled()) return; + const runtimeGeneration = state.runtimeGeneration; + const previousMode = stepwiseGenerationMode(); + const nextMode = normalizeGenerationMode(value); + if (nextMode === previousMode) return; + const previousSettings = state.settings; + const cancelAutoRequestImmediately = previousMode === "auto" && nextMode === "manual"; + const requestEpoch = ++settingsSyncEpoch; + settingsRequestId += 1; + settingsPromise = null; + if (cancelAutoRequestImmediately) { + applyRuntimeSettings({ ...(state.settings || {}), generationMode: nextMode }); + scheduleScan(0); + } + updateGenerationModeControl(nextMode, true); + + const payload = await bridgeCall("/settings/set", { + codexAppStepwiseGenerationMode: nextMode, }); + if (!isCurrentRuntime(runtimeGeneration) || requestEpoch !== settingsSyncEpoch) return; + if (payload?.error) { + if (cancelAutoRequestImmediately) { + applyRuntimeSettings(previousSettings); + scheduleScan(0); + } + state.settingsStatus = payload.error || "模式保存失败"; + renderFloat(); + return; + } + + pendingSettingsPatch = { ...pendingSettingsPatch, generationMode: nextMode }; + if (!cancelAutoRequestImmediately) { + applyRuntimeSettings({ ...(state.settings || {}), generationMode: nextMode }); + } + state.settingsStatus = statusLine(state.settings); + updateGenerationModeControl(nextMode); + scheduleScan(0); + + settingsPromise = null; + await reloadSettings(); } + // Manager settings are the source of truth; local UI state is updated only after request identity checks. async function loadSettings() { + const requestId = ++settingsRequestId; + const requestEpoch = settingsSyncEpoch; const payload = await bridgeCall("/stepwise/settings", {}); - if (!isCurrentInstance()) return null; + if (!isCurrentInstance() + || requestId !== settingsRequestId + || requestEpoch !== settingsSyncEpoch) return null; + let shouldRender = false; if (payload?.settings) { - state.settings = payload.settings; - state.settingsStatus = statusLine(payload.settings); + const nextSettings = { ...payload.settings, ...pendingSettingsPatch }; + if (!Object.prototype.hasOwnProperty.call(nextSettings, "generationMode")) { + nextSettings.generationMode = stepwiseGenerationMode(); + } + pendingSettingsPatch = {}; + const settingsChanged = !state.settingsLoaded + || settingsFingerprint(nextSettings) !== state.settingsFingerprint; + state.settingsLoaded = true; + if (settingsChanged) applyRuntimeSettings(nextSettings); + if (runtimeEnabled(nextSettings)) { + if (!state.runtimeActive) activateRuntime(); + if (settingsChanged) { + state.settingsStatus = statusLine(nextSettings); + shouldRender = true; + scheduleScan(0); + } + } else if (state.runtimeActive) { + stopRuntime(); + } } else { - state.settingsStatus = payload?.error || "Bridge 未就绪"; + const nextStatus = payload?.error || "Bridge 未就绪"; + shouldRender = nextStatus !== state.settingsStatus; + state.settingsStatus = nextStatus; } - if (state.activeTab === "settings" && state.open) renderFloat(); + if (shouldRender && isCurrentRuntime()) renderFloat(); return state.settings; } - async function ensureSettings() { - if (state.settings) return state.settings; + function reloadSettings() { if (!settingsPromise) { - settingsPromise = loadSettings().finally(() => { - settingsPromise = null; + const request = loadSettings(); + const tracked = request.finally(() => { + if (settingsPromise === tracked) settingsPromise = null; }); + settingsPromise = tracked; } return settingsPromise; } + function scheduleSettingsSync(delay = SETTINGS_SYNC_INTERVAL_MS) { + if (!isCurrentInstance()) return; + if (state.settingsSyncTimer) window.clearTimeout(state.settingsSyncTimer); + state.settingsSyncTimer = window.setTimeout(async () => { + state.settingsSyncTimer = 0; + try { + await reloadSettings(); + } catch (error) { + pushDiagnostic("settings:sync-error", { + message: String(error?.message || error || "settings sync failed"), + }); + } finally { + scheduleSettingsSync(); + } + }, delay); + } + + async function ensureSettings() { + if (state.settingsLoaded) return state.settings; + return reloadSettings(); + } + async function testSettings() { - state.settingsStatus = "测试中..."; + if (!isCurrentRuntime()) return; + const generation = state.runtimeGeneration; + state.settingsStatus = "正在检查连接"; renderFloat(); const payload = await bridgeCall("/stepwise/test", {}); - if (!isCurrentInstance()) return; + if (!isCurrentRuntime(generation)) return; const count = Array.isArray(payload?.items) ? payload.items.length : 0; - state.settingsStatus = payload?.error || (payload?.disabled ? "已关闭" : `测试通过 · ${count} 条`); + state.settingsStatus = payload?.error || (payload?.disabled ? "功能已关闭" : `连接正常 · ${count} 条`); renderFloat(); } async function openManager() { - state.settingsStatus = "正在打开 Codex++ Manager..."; + if (!isCurrentRuntime()) return; + const generation = state.runtimeGeneration; + state.settingsStatus = "正在打开 Codex++..."; renderFloat(); - const payload = await bridgeCall("/manager/open-transient", {}); - if (!isCurrentInstance()) return; - state.settingsStatus = payload?.status === "ok" ? "已打开 Manager" : payload?.message || "打开失败"; + const payload = await bridgeCall("/manager/open-transient", { + page: "settings", + section: "stepwise", + }); + if (!isCurrentRuntime(generation)) return; + state.settingsStatus = payload?.status === "ok" ? "已打开 Codex++" : payload?.message || "打开失败"; renderFloat(); } @@ -1221,14 +6459,364 @@ return ""; } - function chatRoot() { + function threadRoots() { return Array.from(document.querySelectorAll(".thread-scroll-container")) - .filter((node) => visibleElement(node) && !state.root?.contains(node)) - .sort((left, right) => { - const leftRect = visibleRect(left); - const rightRect = visibleRect(right); - return (rightRect.width * rightRect.height) - (leftRect.width * leftRect.height); - })[0] || null; + .filter((node) => node instanceof HTMLElement) + .filter((node) => visibleElement(node) && !state.root?.contains(node)); + } + + function threadRootOf(node) { + if (!(node instanceof Element)) return null; + return node.closest?.(".thread-scroll-container") || null; + } + + function stablePaneKeyForRoot(root) { + if (!(root instanceof Element)) return ""; + let current = root; + for (let depth = 0; current && depth < 10; depth += 1, current = current.parentElement) { + const controller = current.getAttribute("data-app-shell-tab-panel-controller"); + if (controller) return `pane:controller:${controller}`; + const focusArea = current.getAttribute("data-app-shell-focus-area"); + if (focusArea) return `pane:focus:${focusArea}`; + const anchorHost = current.getAttribute("data-pip-anchor-host"); + if (anchorHost) return `pane:anchor:${anchorHost === "codex-main-thread" ? "main" : anchorHost}`; + } + + const roots = threadRoots(); + if (roots.length <= 1) return "pane:main"; + const ordered = roots + .map((node) => ({ node, left: visibleRect(node)?.left ?? Number.POSITIVE_INFINITY })) + .sort((left, right) => left.left - right.left); + const index = Math.max(0, ordered.findIndex((item) => item.node === root)); + return index === 0 ? "pane:main" : `pane:secondary:${index}`; + } + + function nodeIdentity(node, prefix = "node") { + if (!(node instanceof Element)) return ""; + const explicit = [ + node.getAttribute("data-conversation-id"), + node.getAttribute("data-session-id"), + node.getAttribute("data-thread-id"), + node.getAttribute("data-message-id"), + node.getAttribute("data-turn-id"), + node.id, + ].find(Boolean); + if (explicit) return `${prefix}:${explicit}`; + if (!state.nodeKeys.has(node)) { + state.nodeKeySeq += 1; + state.nodeKeys.set(node, `${prefix}:${state.nodeKeySeq}`); + } + return state.nodeKeys.get(node); + } + + function sessionIdForRoot(root) { + if (!(root instanceof Element)) return ""; + + const conversationMarkers = [ + "data-above-composer-conversation-id", + "data-response-annotation-conversation", + ]; + for (const attribute of conversationMarkers) { + const marker = root.hasAttribute?.(attribute) + ? root + : root.querySelector?.(`[${attribute}]`); + const value = marker?.getAttribute?.(attribute); + if (value) return String(value); + } + + let current = root; + for (let depth = 0; current && depth < 8; depth += 1, current = current.parentElement) { + const value = [ + current.getAttribute?.("data-conversation-id"), + current.getAttribute?.("data-session-id"), + current.getAttribute?.("data-thread-id"), + ].find(Boolean); + if (value) return String(value); + } + + // Side chats do not expose the main conversation marker; their tab ID is stable. + current = root; + for (let depth = 0; current && depth < 8; depth += 1, current = current.parentElement) { + const tabId = current.getAttribute?.("data-tab-id"); + if (tabId) return String(tabId); + } + + const descendant = root.querySelector?.("[data-conversation-id], [data-session-id], [data-thread-id]"); + const descendantValue = [ + descendant?.getAttribute?.("data-conversation-id"), + descendant?.getAttribute?.("data-session-id"), + descendant?.getAttribute?.("data-thread-id"), + ].find(Boolean); + if (descendantValue) return String(descendantValue); + const links = Array.from(root.querySelectorAll("a[href*='/c/'], a[href*='/conversation/']")); + for (const link of links) { + const match = String(link.getAttribute("href") || "").match(/\/(?:c|conversation)\/([^/?#]+)/i); + if (match?.[1]) return match[1]; + } + const routeMatch = location.pathname.match(/\/(?:c|conversation)\/([^/?#]+)/i); + const paneKey = stablePaneKeyForRoot(root); + if ((paneKey === "pane:anchor:main" || paneKey === "pane:main") && routeMatch?.[1]) return routeMatch[1]; + if (threadRoots().length <= 1 && routeMatch?.[1]) return routeMatch[1]; + return paneKey; + } + + function assistantMessageId(message) { + if (message?.turnKey) return `turn:${message.turnKey}`; + const node = message?.node; + if (!(node instanceof Element)) return ""; + return nodeIdentity(node, "assistant"); + } + + function resetContextContent() { + state.stepwiseEpoch += 1; + state.latestTurnAnchor = null; + state.lastAssistantHash = ""; + state.lastAssistantAt = 0; + state.currentHash = ""; + state.prompts = []; + state.promptPreviewIndex = 0; + state.bridgeActiveKey = ""; + state.bridgePendingHash = ""; + state.bridgePendingRequestId = 0; + state.bridgePendingMode = stepwiseGenerationMode(); + state.bridgeStatus = "idle"; + state.bridgeError = ""; + state.promptContext = null; + state.outlineRefreshPromise = null; + invalidateOutline(); + } + + // Conversation tracking pins one thread and latest completed turn independently of virtualized DOM mounts. + function installContextTracking() { + if (!state.pointerHandler) { + state.pointerHandler = (event) => { + if (pinThreadFromTarget(event.target, "pointer")) scheduleScan(0); + }; + document.addEventListener("pointerdown", state.pointerHandler, true); + } + if (!state.focusHandler) { + state.focusHandler = (event) => { + if (pinThreadFromTarget(event.target, "focus")) scheduleScan(0); + }; + document.addEventListener("focusin", state.focusHandler, true); + } + if (!state.selectionHandler) { + state.selectionHandler = () => { + const selection = document.getSelection(); + const node = selection?.anchorNode; + const target = node instanceof Element ? node : node?.parentElement; + if (target && pinThreadFromTarget(target, "selection")) scheduleScan(0); + }; + document.addEventListener("selectionchange", state.selectionHandler, true); + } + } + + function removeContextTracking() { + if (state.pointerHandler) document.removeEventListener("pointerdown", state.pointerHandler, true); + if (state.focusHandler) document.removeEventListener("focusin", state.focusHandler, true); + if (state.selectionHandler) document.removeEventListener("selectionchange", state.selectionHandler, true); + state.pointerHandler = null; + state.focusHandler = null; + state.selectionHandler = null; + } + + function setActiveThreadRoot(root, reason = "resolve") { + if (!(root instanceof HTMLElement) || !root.isConnected) return false; + const paneKey = stablePaneKeyForRoot(root); + const sessionId = sessionIdForRoot(root); + const previous = state.activeContext; + const sessionChanged = previous.sessionId !== sessionId; + const identityChanged = previous.paneKey !== paneKey || sessionChanged; + if (!identityChanged && previous.paneRoot === root) return false; + if (!identityChanged) { + state.activeContext = { + ...previous, + paneRoot: root, + }; + if (state.pinnedPaneKey === paneKey && state.pinnedSessionId === sessionId) { + state.pinnedThreadRoot = root; + } + pushDiagnostic("context:rebound", { + reason, + paneKey, + sessionId, + generation: state.activeContext.generation, + paneCount: threadRoots().length, + paneRect: rectSummary(root), + }); + renderFloat(); + return true; + } + state.activeContext = { + paneRoot: root, + paneKey, + sessionId, + assistantMessageId: "", + generation: previous.generation + 1, + }; + if (state.pinnedPaneKey === paneKey && state.pinnedThreadRoot === root) { + state.pinnedSessionId = sessionId; + } + resetContextContent(); + pushDiagnostic("context:changed", { + reason, + paneKey, + sessionId, + sessionChanged, + generation: state.activeContext.generation, + paneCount: threadRoots().length, + paneRect: rectSummary(root), + }); + renderFloat(); + return true; + } + + function contextSnapshot() { + return { + runtimeGeneration: state.runtimeGeneration, + generation: state.activeContext.generation, + paneKey: state.activeContext.paneKey, + sessionId: state.activeContext.sessionId, + assistantMessageId: state.activeContext.assistantMessageId, + }; + } + + function contextMatches(snapshot) { + if (!snapshot) return false; + if (!isCurrentRuntime(snapshot.runtimeGeneration)) return false; + const current = state.activeContext; + return snapshot.generation === current.generation && + snapshot.paneKey === current.paneKey && + snapshot.sessionId === current.sessionId && + snapshot.assistantMessageId === current.assistantMessageId; + } + + function pinThreadFromTarget(target, reason) { + if (!(target instanceof Element) || state.root?.contains(target)) return false; + const root = threadRootOf(target); + if (!root) return false; + state.pinnedPaneKey = stablePaneKeyForRoot(root); + state.pinnedSessionId = sessionIdForRoot(root); + state.pinnedThreadRoot = root; + state.pinnedThreadAt = Date.now(); + state.threadActivity.set(root, state.pinnedThreadAt); + return setActiveThreadRoot(root, reason); + } + + function rootMatchesContext(root, paneKey, sessionId) { + if (!(root instanceof Element) || !paneKey) return false; + if (stablePaneKeyForRoot(root) !== paneKey) return false; + return !sessionId || sessionIdForRoot(root) === sessionId; + } + + function rootForContext(paneKey, sessionId, roots = threadRoots()) { + if (!paneKey) return null; + return roots.find((root) => rootMatchesContext(root, paneKey, sessionId)) + || roots.find((root) => stablePaneKeyForRoot(root) === paneKey) + || null; + } + + function resolveActiveThreadRoot() { + const roots = threadRoots(); + if (!roots.length) { + state.activeContext.paneRoot = null; + return null; + } + const current = state.activeContext.paneRoot; + if (current?.isConnected && roots.includes(current)) { + const sessionId = sessionIdForRoot(current); + if (sessionId !== state.activeContext.sessionId) setActiveThreadRoot(current, "session-change"); + return current; + } + const pinned = rootForContext(state.pinnedPaneKey, state.pinnedSessionId, roots) + || (state.pinnedThreadRoot?.isConnected && roots.includes(state.pinnedThreadRoot) ? state.pinnedThreadRoot : null); + if (pinned) { + state.pinnedThreadRoot = pinned; + setActiveThreadRoot(pinned, "pinned"); + return pinned; + } + const rebound = rootForContext(state.activeContext.paneKey, state.activeContext.sessionId, roots); + if (rebound) { + setActiveThreadRoot(rebound, "active-rebound"); + return rebound; + } + const focused = threadRootOf(document.activeElement); + if (focused && roots.includes(focused)) { + setActiveThreadRoot(focused, "focus"); + return focused; + } + const fallback = roots[0]; + setActiveThreadRoot(fallback, roots.length === 1 ? "single-pane" : "fallback"); + return fallback; + } + + function activePaneCue() { + const roots = threadRoots(); + const active = state.activeContext.paneRoot; + const centerCue = paneCueForTrack({ direction: "single", angle: null }, CHIP_HEIGHT); + if (roots.length < 2 || !active?.isConnected) return centerCue; + const activeRect = visibleRect(active); + if (!activeRect) return centerCue; + const rects = roots.map(visibleRect).filter(Boolean); + if (rects.length < 2) return centerCue; + const bounds = { + left: Math.min(...rects.map((rect) => rect.left)), + top: Math.min(...rects.map((rect) => rect.top)), + right: Math.max(...rects.map((rect) => rect.right)), + bottom: Math.max(...rects.map((rect) => rect.bottom)), + }; + const boundsWidth = Math.max(1, bounds.right - bounds.left); + const boundsHeight = Math.max(1, bounds.bottom - bounds.top); + const offsetX = ((activeRect.left + activeRect.width / 2) - (bounds.left + boundsWidth / 2)) / (boundsWidth / 2); + const offsetY = ((activeRect.top + activeRect.height / 2) - (bounds.top + boundsHeight / 2)) / (boundsHeight / 2); + if (Math.abs(offsetX) < 0.01 && Math.abs(offsetY) < 0.01) return centerCue; + const angle = Math.atan2(offsetY, offsetX); + const direction = Math.abs(offsetX) >= Math.abs(offsetY) + ? (offsetX < 0 ? "left" : "right") + : (offsetY < 0 ? "top" : "bottom"); + return paneCueForTrack({ direction, angle }, CHIP_HEIGHT); + } + + function paneCueForTrack(paneCue, trackHeight = CHIP_HEIGHT) { + if (paneCue.direction === "single" || !Number.isFinite(paneCue.angle)) { + return { direction: "single", angle: null, x: CHIP_WIDTH / 2, y: trackHeight / 2 }; + } + const point = capsuleBoundaryPoint(paneCue.angle, CHIP_WIDTH, trackHeight); + return { + direction: paneCue.direction, + angle: paneCue.angle, + x: point.x, + y: point.y, + }; + } + + function capsuleBoundaryPoint(angle, width, height) { + const halfWidth = width / 2; + const halfHeight = height / 2; + const radius = halfHeight; + const innerHalfWidth = Math.max(0, halfWidth - radius); + const cosine = Math.cos(angle); + const sine = Math.sin(angle); + let inside = 0; + let outside = Math.hypot(halfWidth, halfHeight) + radius; + for (let index = 0; index < 24; index += 1) { + const distance = (inside + outside) / 2; + const x = Math.abs(cosine * distance) - innerHalfWidth; + const y = Math.abs(sine * distance); + const outsideX = Math.max(x, 0); + const outsideY = Math.max(y, 0); + const signedDistance = Math.hypot(outsideX, outsideY) + Math.min(Math.max(x, y), 0) - radius; + if (signedDistance <= 0) inside = distance; + else outside = distance; + } + return { + x: Math.round((halfWidth + cosine * inside) * 10) / 10, + y: Math.round((halfHeight + sine * inside) * 10) / 10, + }; + } + + function chatRoot() { + return resolveActiveThreadRoot(); } function elementCenter(rect) { @@ -1245,27 +6833,36 @@ return overlap / Math.max(1, Math.min(left.width, right.width)); } - function ignoredComposerContainer(node) { + function ignoredComposerContainer(node, targetRoot = null) { if (!(node instanceof Element)) return true; if (state.root?.contains(node)) return true; - return Boolean(node.closest([ + const blockedAncestor = node.closest([ `[${ROOT_ATTR}="true"]`, `[${PAYLOAD_ATTR}="true"]`, - "aside", "nav", "[role='dialog']", "[aria-modal='true']", "[role='menu']", "[role='listbox']", - ].join(","))); + ].join(",")); + if (blockedAncestor) return true; + + const activeRoot = targetRoot || chatRoot(); + if (activeRoot?.contains(node)) return false; + + const nodeAside = node.closest("aside"); + if (!nodeAside) return false; + + const activeAside = activeRoot?.closest("aside"); + return !(activeAside && nodeAside === activeAside); } - function composerCandidateScore(node, rootRect) { + function composerCandidateScore(node, rootRect, targetRoot = null) { const rect = visibleRect(node); if (!rect || !rootRect) return -Infinity; if (rect.width < 120 || rect.height < 20) return -Infinity; if (rect.bottom < window.innerHeight * 0.35) return -Infinity; - if (ignoredComposerContainer(node)) return -Infinity; + if (ignoredComposerContainer(node, targetRoot)) return -Infinity; const overlap = horizontalOverlapRatio(rect, rootRect); const center = elementCenter(rect); @@ -1279,18 +6876,59 @@ return overlap * 100 + lowerScreen * 24 + widthMatch * 18 - centerDrift * 48; } - function mainComposerCandidate(candidates) { - const rootRect = visibleRect(chatRoot()); + function mainComposerCandidate(candidates, targetRoot = null) { + const root = targetRoot || chatRoot(); + const rootRect = visibleRect(root); const ranked = candidates - .map((node) => ({ node, score: composerCandidateScore(node, rootRect) })) + .map((node) => ({ node, score: composerCandidateScore(node, rootRect, root) })) .filter((item) => Number.isFinite(item.score)) .sort((left, right) => right.score - left.score); - return ranked[0]?.node || null; + if (ranked[0]?.node) return ranked[0].node; + + if (targetRoot || threadRoots().length > 1) return null; + + const fallback = candidates + .map((node) => ({ node, score: globalComposerCandidateScore(node) })) + .filter((item) => Number.isFinite(item.score)) + .sort((left, right) => right.score - left.score)[0]; + if (fallback?.node) { + pushDiagnostic("composer:global-fallback", { + score: fallback.score, + targetTag: fallback.node.tagName || "", + targetRole: fallback.node.getAttribute?.("role") || "", + targetClass: String(fallback.node.className || "").slice(0, 120), + targetRect: rectSummary(fallback.node), + }); + } + return fallback?.node || null; + } + + function globalComposerCandidateScore(node) { + const rect = visibleRect(node); + if (!rect || rect.width < 120 || rect.height < 20) return -Infinity; + if (rect.bottom < window.innerHeight * 0.35 || ignoredComposerContainer(node)) return -Infinity; + + const label = normalizeText([ + node.getAttribute?.("aria-label"), + node.getAttribute?.("placeholder"), + node.getAttribute?.("data-placeholder"), + ].filter(Boolean).join(" ")); + if (/search|find|查找|搜索/i.test(label)) return -Infinity; + + let score = rect.bottom / Math.max(1, window.innerHeight) * 40; + score += Math.min(rect.width / Math.max(1, window.innerWidth), 1) * 20; + if (node.matches?.("div.ProseMirror")) score += 160; + if (node instanceof HTMLTextAreaElement) score += 130; + if (node.getAttribute?.("role") === "textbox") score += 90; + if (node.isContentEditable) score += 70; + if (/message|prompt|send|ask|消息|输入|提问|发送/i.test(label)) score += 60; + return score; } - function composerCandidates() { + function composerCandidates(targetRoot = null) { + const scope = targetRoot || document; return Array.from( - document.querySelectorAll( + scope.querySelectorAll( [ "textarea", "[contenteditable='true']", @@ -1303,7 +6941,8 @@ const rect = node.getBoundingClientRect(); if (rect.width < 120 || rect.height < 20) return false; if (rect.bottom < window.innerHeight * 0.35) return false; - if (ignoredComposerContainer(node)) return false; + if (targetRoot && threadRootOf(node) !== targetRoot) return false; + if (ignoredComposerContainer(node, targetRoot)) return false; return true; }); } @@ -1313,7 +6952,7 @@ } function sendButtonLabel(label) { - return /^(send message|send|发送消息|发送|提交)$/i.test(label); + return /^(send message|send|add to queue|发送消息|发送|提交|加入队列|添加到队列)$/i.test(label); } function stopButtonLabel(label) { @@ -1357,10 +6996,12 @@ function nearbySubmitButton(target, options = {}) { const includeDisabled = options.includeDisabled === true; + const targetRoot = options.root || threadRootOf(target); let current = target?.parentElement || null; for (let depth = 0; current && depth < 8; depth += 1, current = current.parentElement) { if (current === document.body || current === document.documentElement) break; if (state.root?.contains(current)) return null; + if (targetRoot && !targetRoot.contains(current)) break; const buttons = Array.from(current.querySelectorAll("button,[role='button']")) .filter((node) => node instanceof HTMLElement && !state.root?.contains(node) && visibleElement(node) && (includeDisabled || !disabledButton(node))); @@ -1397,25 +7038,31 @@ function setScanStatus(status, details = {}) { const key = `${status}:${JSON.stringify(details)}`; - if (state.lastScanStatus === key) return; + state.scanStatus = status; + state.scanBusy = status === "manual-refresh-busy" || Boolean(details.busy); + if (state.lastScanStatus === key) return false; state.lastScanStatus = key; pushDiagnostic(`scan:${status}`, details); + return true; } - function composerBusy(target) { + function composerBusy(target, options = {}) { + const targetRoot = options.root || threadRootOf(target); + let hasStopButton = false; let current = target?.parentElement || null; for (let depth = 0; current && depth < 8; depth += 1, current = current.parentElement) { if (current === document.body || current === document.documentElement) break; if (state.root?.contains(current)) return false; - const buttons = Array.from(current.querySelectorAll("button,[role='button']")); - if (buttons.some((node) => { - if (!visibleElement(node)) return false; - return stopButton(node); - })) return true; + if (targetRoot && !targetRoot.contains(current)) break; + const buttons = Array.from(current.querySelectorAll("button,[role='button']")) + .filter((node) => node instanceof HTMLElement && visibleElement(node)); + if (buttons.some((node) => !disabledButton(node) && sendButtonLabel(buttonLabel(node)))) return false; + if (buttons.some((node) => stopButton(node))) hasStopButton = true; } - return false; + return hasStopButton; } + // Message discovery tolerates ChatGPT's changing DOM while preferring semantic role and action-row signals. function messageCandidates() { const root = chatRoot(); if (!root) return []; @@ -1429,7 +7076,6 @@ ].join(","); return Array.from(root.querySelectorAll(selectors)) - .filter(visibleElement) .map((node) => ({ node, role: roleFromElement(node), @@ -1467,6 +7113,115 @@ })); } + function roleFromMessageLabel(label) { + const text = normalizeText(label?.textContent || ""); + if (/^(你说|you said|user)\s*[::]?$/i.test(text)) return "user"; + if (/^(ChatGPT|assistant|codex)(?:\s+说|\s+said)?\s*[::]?$/i.test(text)) return "assistant"; + return ""; + } + + function labeledMessageContainer(turn, role) { + if (!(turn instanceof Element)) return null; + const labels = Array.from(turn.querySelectorAll("h4.sr-only")); + for (let index = labels.length - 1; index >= 0; index -= 1) { + const label = labels[index]; + if (roleFromMessageLabel(label) !== role) continue; + const container = label.parentElement; + if (!(container instanceof Element)) continue; + if (role === "user" && !classTokenMatch(container, "items-end")) continue; + if (role === "assistant" && !classTokenMatch(container, "group")) continue; + return container; + } + return null; + } + + function labeledMessageText(container) { + if (!(container instanceof Element)) return ""; + const clone = stripOwnUi(container.cloneNode(true)); + clone.querySelectorAll?.("h4.sr-only,button,[role='button'],svg").forEach((item) => item.remove()); + return normalizeText(clone.textContent || ""); + } + + function conversationTurn(turn) { + if (!(turn instanceof Element)) return null; + const turnKey = normalizeText(turn.getAttribute("data-content-search-turn-key") || ""); + const userNode = labeledMessageContainer(turn, "user"); + const assistantNode = labeledMessageContainer(turn, "assistant"); + const userText = labeledMessageText(userNode); + const assistantText = labeledMessageText(assistantNode); + return { + node: turn, + turnKey, + userText, + assistantMessage: assistantText.length > 8 ? { + node: assistantNode, + role: "assistant", + text: assistantText, + turnKey, + } : null, + }; + } + + function conversationTurns() { + const root = chatRoot(); + if (!root) return []; + return Array.from(root.querySelectorAll(CONVERSATION_TURN_SELECTOR)) + .map(conversationTurn) + .filter(Boolean); + } + + function compareConversationTurnKeys(left, right) { + if (left === right) return 0; + return left < right ? -1 : 1; + } + + function latestConversationTurnByKey(turns) { + return turns.reduce((latest, turn) => { + if (!turn?.turnKey) return latest; + if (!latest || compareConversationTurnKeys(latest.turnKey, turn.turnKey) < 0) return turn; + return latest; + }, null); + } + + function nextLatestTurnAnchor(previous, turns, sessionId) { + const mounted = latestConversationTurnByKey(turns); + if (!mounted) return previous; + const sameSession = Boolean(sessionId) && previous?.sessionId === sessionId; + if (sameSession && compareConversationTurnKeys(mounted.turnKey, previous.turnKey) < 0) return previous; + + const sameTurn = sameSession && previous?.turnKey === mounted.turnKey; + const assistant = mounted.assistantMessage; + return { + sessionId, + turnKey: mounted.turnKey, + userText: mounted.userText || (sameTurn ? previous.userText : ""), + assistantText: assistant?.text || (sameTurn ? previous.assistantText : ""), + turnNode: mounted.node || (sameTurn ? previous.turnNode : null), + assistantNode: assistant?.node || (sameTurn ? previous.assistantNode : null), + }; + } + + function assistantMessageFromTurnAnchor(anchor) { + if (!anchor?.assistantText || anchor.assistantText.length <= 8) return null; + return { + node: anchor.assistantNode, + role: "assistant", + text: anchor.assistantText, + turnKey: anchor.turnKey, + userText: anchor.userText, + turnNode: anchor.turnNode, + }; + } + + function updateLatestTurnAnchor(turns) { + state.latestTurnAnchor = nextLatestTurnAnchor( + state.latestTurnAnchor, + turns, + state.activeContext.sessionId, + ); + return state.latestTurnAnchor; + } + function latestMessageByDocumentOrder(candidates) { return candidates .filter((item) => item?.node instanceof Node && item.text?.length > 8) @@ -1541,6 +7296,11 @@ } function findLatestAssistantMessage() { + const turns = conversationTurns(); + if (turns.length || state.latestTurnAnchor) { + return assistantMessageFromTurnAnchor(updateLatestTurnAnchor(turns)); + } + const candidates = []; const rows = allActionRows(); for (let index = 0; index < rows.length; index += 1) { @@ -1554,7 +7314,15 @@ return latestMessageByDocumentOrder(candidates); } - function findPreviousUserText(assistantNode) { + function findPreviousUserText(message) { + const snapshotUserText = normalizeText(message?.userText || ""); + if (snapshotUserText) return shortText(snapshotUserText, 2000); + + const assistantNode = message?.node || message; + const turn = assistantNode?.closest?.(CONVERSATION_TURN_SELECTOR); + const turnUserText = conversationTurn(turn)?.userText || ""; + if (turnUserText) return shortText(turnUserText, 2000); + const candidates = messageCandidates(); const before = candidates.filter((item) => { if (item.node === assistantNode) return false; @@ -1584,22 +7352,53 @@ } } + function clearStepwisePayloadMarks() { + document.querySelectorAll(`[${PAYLOAD_ATTR}]`).forEach((node) => { + node.removeAttribute(PAYLOAD_ATTR); + }); + } + function uniquePrompts(items) { const seen = new Set(); const result = []; - for (const item of items) { - const prompt = normalizeText(typeof item === "string" ? item : item.prompt).replace(/\s+/g, " "); - if (!prompt || seen.has(prompt)) continue; - seen.add(prompt); + const maxItems = configuredMaxPromptItems(); + for (const item of Array.isArray(items) ? items : []) { + const prompt = normalizeText(typeof item === "string" ? item : item.prompt); + const dedupeKey = prompt.replace(/\s+/g, " "); + if (!prompt || seen.has(dedupeKey)) continue; + seen.add(dedupeKey); result.push({ - label: normalizeText(typeof item === "string" ? labelForPrompt(prompt) : item.label || labelForPrompt(prompt)), + label: leadingPromptText(typeof item === "string" ? labelForPrompt(prompt) : item.label || labelForPrompt(prompt), 36), + summary: leadingPromptText( + typeof item === "string" ? summaryForPrompt(prompt) : item.summary || summaryForPrompt(prompt), + MAX_PROMPT_SUMMARY_LENGTH, + ), prompt, }); - if (result.length >= MAX_STEPWISE_ITEMS) break; + if (result.length >= maxItems) break; } return result; } + function normalizePromptState(items = state.prompts) { + const normalized = uniquePrompts(items); + state.prompts = normalized; + state.promptPreviewIndex = normalized.length + ? clamp(Number(state.promptPreviewIndex) || 0, 0, normalized.length - 1) + : 0; + return normalized; + } + + function leadingPromptText(value, limit) { + const characters = Array.from(normalizeText(value).replace(/\s+/g, " ")); + if (characters.length <= limit) return characters.join(""); + return `${characters.slice(0, Math.max(0, limit - 1)).join("").trimEnd()}…`; + } + + function summaryForPrompt(prompt) { + return leadingPromptText(prompt, MAX_PROMPT_SUMMARY_LENGTH); + } + function labelForPrompt(prompt) { const text = normalizeText(prompt); const rules = [ @@ -1628,6 +7427,7 @@ .slice(0, 10) || "继续"; } + // Stepwise payload parsing accepts the backend's strict JSON contract and legacy embedded payload shapes. function parseStepwiseJson(text) { const blocks = Array.from(text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)) .map((match) => match[1]) @@ -1724,19 +7524,26 @@ const rawItems = payloadItems(payload); if (!rawItems.length) return []; const items = rawItems - .slice(0, MAX_STEPWISE_ITEMS) + .slice(0, configuredMaxPromptItems()) .map((item) => { - const prompt = shortText( + const prompt = normalizeText( typeof item === "string" ? item : item?.prompt || item?.text || item?.action || item?.content || item?.message || "", - MAX_PROMPT_LENGTH - ).replace(/\s+/g, " "); - const label = shortText( + ); + const label = leadingPromptText( typeof item === "string" ? "" : item?.label || item?.title || item?.name || "", - 36 - ).replace(/\s+/g, " "); - return prompt ? { label: label || labelForPrompt(prompt), prompt } : null; + 36, + ); + const summary = leadingPromptText( + typeof item === "string" ? "" : item?.summary || item?.preview || item?.description || "", + MAX_PROMPT_SUMMARY_LENGTH, + ); + return prompt ? { + label: label || labelForPrompt(prompt), + summary: summary || summaryForPrompt(prompt), + prompt, + } : null; }) .filter(Boolean); return uniquePrompts(items); @@ -1753,15 +7560,40 @@ } function bridgeRequestKey(userText, assistantText) { - return hashText(`${shortText(userText, 2400)}\n\n--- assistant ---\n\n${shortText(assistantText, 5200)}`); + return hashText(`${state.activeContext.sessionId}\n${shortText(userText, 2400)}\n\n--- assistant ---\n\n${shortText(assistantText, 5200)}`); } - function requestBridgeStepwise(key, userText, assistantText) { - if (!key || state.bridgePendingHash === key || state.bridgeCache.has(key)) return; + // Bridge requests are deduplicated by answer identity and guarded against late responses from older turns. + function requestBridgeStepwise(key, userText, assistantText, requestMode = stepwiseGenerationMode(), options = {}) { + if (!stepwiseEnabled() || !key || state.bridgePendingHash === key || state.bridgeCache.has(key)) return; + const normalizedMode = normalizeGenerationMode(requestMode); + if (normalizedMode === "manual" && options.userInitiated !== true) return; + pushDiagnostic("bridge:generate-request", { + userTextLength: userText.length, + assistantTextLength: assistantText.length, + mode: normalizedMode, + }); + const requestContext = contextSnapshot(); + const requestEpoch = state.stepwiseEpoch; + const requestId = ++state.bridgeRequestSequence; + const requestAssistantMessageId = requestContext.assistantMessageId; + const requestOwned = () => state.bridgePendingHash === key + && state.bridgePendingRequestId === requestId + && state.bridgePendingMode === normalizedMode; + const requestCurrent = () => stepwiseEnabled() + && stepwiseGenerationMode() === normalizedMode + && requestEpoch === state.stepwiseEpoch + && contextMatches(requestContext) + && state.activeContext.assistantMessageId === requestAssistantMessageId + && state.bridgeActiveKey === key + && !chatBusy(); state.bridgePendingHash = key; + state.bridgePendingRequestId = requestId; + state.bridgePendingMode = normalizedMode; state.bridgeStatus = "pending"; state.bridgeError = ""; + state.promptContext = requestContext; renderFloat(); bridgeCall( @@ -1776,7 +7608,7 @@ } ) .then((payload) => { - if (!isCurrentInstance()) return; + if (!requestOwned() || !requestCurrent()) return; const prompts = payload?.disabled || payload?.error ? [] : payloadPrompts(payload); pushDiagnostic("bridge:generate-result", { status: payload?.status || "", @@ -1786,30 +7618,46 @@ promptCount: prompts.length, payloadKeys: payload && typeof payload === "object" ? Object.keys(payload).slice(0, 12) : [], }); + const bridgeStatus = payload?.disabled ? "disabled" : payload?.error ? "failed" : "ok"; state.bridgeCache.set(key, { + status: bridgeStatus, disabled: Boolean(payload?.disabled), error: normalizeText(payload?.error || ""), prompts, }); - state.bridgeStatus = payload?.disabled ? "disabled" : payload?.error ? "failed" : "ok"; + state.bridgeStatus = bridgeStatus; state.bridgeError = normalizeText(payload?.error || ""); + state.promptContext = requestContext; + if (bridgeStatus === "ok") triggerCompletionBeam(prompts.length); }) .catch((error) => { - if (!isCurrentInstance()) return; + if (!requestOwned() || !requestCurrent()) return; pushDiagnostic("bridge:generate-failed", { error: error.message }); - state.bridgeCache.set(key, { disabled: true, error: error.message, prompts: [] }); + state.bridgeCache.set(key, { + status: "failed", + disabled: true, + error: error.message, + prompts: [], + }); state.bridgeStatus = "failed"; state.bridgeError = error.message; }) .finally(() => { - if (!isCurrentInstance()) return; - if (state.bridgePendingHash === key) state.bridgePendingHash = ""; + if (!requestOwned()) return; + state.bridgePendingHash = ""; + state.bridgePendingRequestId = 0; + state.bridgePendingMode = stepwiseGenerationMode(); + if (state.bridgeStatus === "pending") { + state.bridgeStatus = "idle"; + state.bridgeError = ""; + state.promptContext = null; + } scheduleScan(0); }); } function forceRefreshStepwise() { - if (!isCurrentInstance()) return; + if (!isCurrentRuntime() || !stepwiseEnabled()) return; if (state.bridgeStatus === "pending") { setScanStatus("manual-refresh-pending", {}); return; @@ -1825,35 +7673,69 @@ if (!message) { state.bridgeError = "未找到可用于生成的回答"; state.prompts = []; + state.promptContext = null; + state.promptPreviewIndex = 0; setScanStatus("manual-refresh-no-assistant", {}); renderFloat(); return; } + const nextAssistantMessageId = assistantMessageId(message); + if (state.activeContext.assistantMessageId !== nextAssistantMessageId) { + state.activeContext.assistantMessageId = nextAssistantMessageId; + } + const stepwisePayload = extractStepwisePayload(message); hideStepwisePayload(message.node); const assistantText = shortText(stepwisePayload.textWithoutPayload || message.text); - const userText = findPreviousUserText(message.node); + const userText = findPreviousUserText(message); const bridgeKey = bridgeRequestKey(userText, assistantText); + const generationMode = stepwiseGenerationMode(); + state.bridgeActiveKey = bridgeKey; + state.stepwiseEpoch += 1; + state.bridgePendingHash = ""; + state.bridgePendingRequestId = 0; + state.bridgePendingMode = generationMode; if (bridgeKey) state.bridgeCache.delete(bridgeKey); state.lastAssistantHash = hashText(assistantText); state.lastAssistantAt = 0; state.currentHash = `${state.lastAssistantHash}:manual-refresh`; state.prompts = []; + state.promptContext = contextSnapshot(); + state.promptPreviewIndex = 0; state.bridgeError = ""; setScanStatus("manual-refresh", { hash: state.lastAssistantHash, textLength: assistantText.length }); - requestBridgeStepwise(bridgeKey, userText, assistantText); + requestBridgeStepwise(bridgeKey, userText, assistantText, generationMode, { userInitiated: true }); renderFloat(); } function clearPromptsForNewAssistant(hash) { + state.stepwiseEpoch += 1; + state.bridgeActiveKey = ""; + state.bridgePendingHash = ""; + state.bridgePendingRequestId = 0; + state.bridgePendingMode = stepwiseGenerationMode(); + state.bridgeStatus = state.bridgePendingMode === "manual" ? "manual-ready" : "idle"; state.currentHash = `${hash}:pending`; state.prompts = []; + state.promptContext = contextSnapshot(); + state.promptPreviewIndex = 0; state.bridgeError = ""; renderFloat(); } + function composerRootForContext(snapshot = state.promptContext) { + if (snapshot?.paneKey) return rootForContext(snapshot.paneKey, snapshot.sessionId); + return chatRoot(); + } + + function composerTargetForContext(snapshot = state.promptContext) { + const root = composerRootForContext(snapshot); + if (!root) return null; + return mainComposerCandidate(composerCandidates(root), root); + } + function setNativeValue(element, value) { const prototype = Object.getPrototypeOf(element); const descriptor = Object.getOwnPropertyDescriptor(prototype, "value"); @@ -1863,7 +7745,7 @@ function composerText(target) { if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) return normalizeText(target.value); - return normalizeText(target?.textContent || ""); + return normalizeText(target?.innerText || target?.textContent || ""); } function pressEnter(target) { @@ -1935,37 +7817,48 @@ } function submitComposerWhenReady(target, expectedText = "", attempt = 0) { - if (!(target instanceof HTMLElement)) return false; - if (!document.contains(target)) { - pushDiagnostic("submit:target-detached", { attempt }); - return false; + let currentTarget = target; + if (!(currentTarget instanceof HTMLElement)) return false; + if (!document.contains(currentTarget)) { + currentTarget = composerTargetForContext(state.promptContext || state.activeContext); + pushDiagnostic("submit:target-detached", { + attempt, + rebound: Boolean(currentTarget), + paneKey: state.promptContext?.paneKey || state.activeContext.paneKey, + sessionId: state.promptContext?.sessionId || state.activeContext.sessionId, + }); + if (!currentTarget) { + if (attempt >= SUBMIT_RETRY_LIMIT) return false; + window.setTimeout(() => submitComposerWhenReady(target, expectedText, attempt + 1), SUBMIT_RETRY_DELAY_MS); + return false; + } } - if (normalizeText(expectedText) && composerText(target) !== normalizeText(expectedText)) { + if (normalizeText(expectedText) && composerText(currentTarget) !== normalizeText(expectedText)) { pushDiagnostic("submit:composer-changed", { attempt, expectedLength: normalizeText(expectedText).length, - actualLength: composerText(target).length, + actualLength: composerText(currentTarget).length, }); return false; } - if (composerBusy(target)) { + if (composerBusy(currentTarget)) { if (attempt === 0 || attempt % 10 === 0 || attempt >= SUBMIT_RETRY_LIMIT) { pushDiagnostic("submit:blocked-local-stop", { attempt, retrying: attempt < SUBMIT_RETRY_LIMIT, - targetRect: rectSummary(target), + targetRect: rectSummary(currentTarget), }); } if (attempt >= SUBMIT_RETRY_LIMIT) { - pushDiagnostic("submit:blocked-local-stop-timeout", { attempt, targetRect: rectSummary(target) }); + pushDiagnostic("submit:blocked-local-stop-timeout", { attempt, targetRect: rectSummary(currentTarget) }); return false; } - window.setTimeout(() => submitComposerWhenReady(target, expectedText, attempt + 1), SUBMIT_RETRY_DELAY_MS); + window.setTimeout(() => submitComposerWhenReady(currentTarget, expectedText, attempt + 1), SUBMIT_RETRY_DELAY_MS); return false; } - if (submitComposer(target, attempt >= SUBMIT_RETRY_LIMIT)) return true; + if (submitComposer(currentTarget, attempt >= SUBMIT_RETRY_LIMIT)) return true; if (attempt >= SUBMIT_RETRY_LIMIT) return false; - window.setTimeout(() => submitComposerWhenReady(target, expectedText, attempt + 1), SUBMIT_RETRY_DELAY_MS); + window.setTimeout(() => submitComposerWhenReady(currentTarget, expectedText, attempt + 1), SUBMIT_RETRY_DELAY_MS); return false; } @@ -1987,16 +7880,22 @@ } function fillComposer(prompt, submit = false) { - const candidates = composerCandidates(); - const target = mainComposerCandidate(candidates); + const context = state.promptContext || state.activeContext; + const targetRoot = composerRootForContext(context); + const candidates = targetRoot ? composerCandidates(targetRoot) : []; + const target = targetRoot + ? mainComposerCandidate(candidates, targetRoot) + : null; pushDiagnostic("fill:start", { submit, candidateCount: candidates.length, + paneKey: context?.paneKey || "", + sessionId: context?.sessionId || "", targetTag: target?.tagName || "", targetRole: target?.getAttribute?.("role") || "", targetClass: String(target?.className || "").slice(0, 120), targetRect: rectSummary(target), - chatRootRect: rectSummary(chatRoot()), + chatRootRect: rectSummary(targetRoot), promptLength: normalizeText(prompt).length, }); if (!target) { @@ -2027,45 +7926,67 @@ return false; } - function scan() { - if (!isCurrentInstance()) return; - state.timer = 0; + // Scanning observes the pinned conversation, settles streamed answers, and schedules only necessary work. + function scan(generation = state.runtimeGeneration, timerId = 0) { + if (!isCurrentRuntime(generation)) return; + if (timerId && state.timer !== timerId) return; + if (timerId) state.timer = 0; state.scans += 1; installStyle(); installFloat(); + const stepwiseActive = stepwiseEnabled(); + const outlineActive = outlineEnabled(); if (!chatSurfaceReady()) { - setScanStatus("not-ready", { + if (outlineActive && (state.outlineItems.length || state.outlineMessage)) invalidateOutline(); + const statusChanged = setScanStatus("not-ready", { hasRoot: Boolean(chatRoot()), composerCount: composerCandidates().length, busy: chatBusy(), }); - renderFloat(); + if (statusChanged) renderFloat(); return; } const message = findLatestAssistantMessage(); if (!message) { - setScanStatus("no-assistant-message", { + if (outlineActive && (state.outlineItems.length || state.outlineMessage)) invalidateOutline(); + const statusChanged = setScanStatus("no-assistant-message", { messageCandidateCount: messageCandidates().length, actionRowCount: allActionRows().length, }); - renderFloat(); + if (statusChanged) renderFloat(); return; } - const stepwisePayload = extractStepwisePayload(message); - hideStepwisePayload(message.node); + const stepwisePayload = stepwiseActive + ? extractStepwisePayload(message) + : { payload: null, prompts: [], textWithoutPayload: "" }; + if (stepwiseActive) hideStepwisePayload(message.node); - const assistantText = shortText(stepwisePayload.textWithoutPayload || message.text); + const nextAssistantMessageId = assistantMessageId(message); + if (state.activeContext.assistantMessageId !== nextAssistantMessageId) { + state.activeContext.assistantMessageId = nextAssistantMessageId; + } + + const assistantText = shortText(stepwiseActive + ? stepwisePayload.textWithoutPayload || message.text + : message.text); const hash = hashText(assistantText); const now = Date.now(); if (hash !== state.lastAssistantHash) { state.lastAssistantHash = hash; state.lastAssistantAt = now; - if (state.prompts.length || state.currentHash) clearPromptsForNewAssistant(hash); + state.surpriseUntil = now + NEW_ANSWER_EXPRESSION_MS; + scheduleExpressionRefresh(NEW_ANSWER_EXPRESSION_MS); setScanStatus("assistant-changed", { hash, textLength: assistantText.length }); + if (outlineActive) invalidateOutline(message, hash); + if (stepwiseActive) { + clearPromptsForNewAssistant(hash); + } else { + renderFloat(); + } scheduleScan(STREAM_IDLE_MS + 120); return; } @@ -2076,19 +7997,49 @@ return; } - const userText = findPreviousUserText(message.node); + if (outlineActive && state.outlineSourceHash !== hash && !state.outlineRefreshPromise) { + void refreshOutline({ message, assistantHash: hash }); + } + if (!stepwiseActive) { + const statusChanged = setScanStatus("ready", { + hash, + outlineOnly: true, + outlineCount: state.outlineItems.length, + }); + if (statusChanged) renderFloat(); + return; + } + + const userText = findPreviousUserText(message); const bridgeKey = bridgeRequestKey(userText, assistantText); + const generationMode = stepwiseGenerationMode(); + state.bridgeActiveKey = bridgeKey; const bridgeResult = state.bridgeCache.get(bridgeKey); - const prompts = bridgeResult?.prompts?.length ? bridgeResult.prompts : stepwisePayload.prompts; - - if (!bridgeResult) { - pushDiagnostic("bridge:generate-request", { - userTextLength: userText.length, - assistantTextLength: assistantText.length, - hasInlinePayload: Boolean(stepwisePayload.payload), - inlinePromptCount: stepwisePayload.prompts.length, - }); - requestBridgeStepwise(bridgeKey, userText, assistantText); + const hasSuccessfulCache = bridgeResult?.status === "ok"; + let prompts = []; + + const manualResultVisible = generationMode === "manual" + && state.bridgeStatus === "ok" + && state.bridgeActiveKey === bridgeKey; + + if (generationMode === "manual" && !manualResultVisible) { + state.bridgeStatus = "manual-ready"; + state.bridgeError = ""; + state.promptContext = contextSnapshot(); + } else if (hasSuccessfulCache) { + prompts = Array.isArray(bridgeResult.prompts) ? bridgeResult.prompts : []; + state.bridgeStatus = "ok"; + state.bridgeError = ""; + state.promptContext = contextSnapshot(); + } else { + prompts = bridgeResult ? [] : stepwisePayload.prompts; + if (bridgeResult) { + state.bridgeStatus = bridgeResult.status || (bridgeResult.error ? "failed" : bridgeResult.disabled ? "disabled" : "ok"); + state.bridgeError = bridgeResult.error || ""; + state.promptContext = contextSnapshot(); + } else { + requestBridgeStepwise(bridgeKey, userText, assistantText, "auto"); + } } setScanStatus("ready", { hash, @@ -2096,26 +8047,33 @@ promptCount: prompts.length, }); - const nextHash = hashText(prompts.map((item) => `${item.label}\n${item.prompt}`).join("\n\n")); - if (state.currentHash !== `${hash}:${nextHash}`) { - state.currentHash = `${hash}:${nextHash}`; + const nextHash = hashText(`${generationMode}:${state.bridgeStatus}:${prompts.map((item) => `${item.label}\n${item.prompt}`).join("\n\n")}`); + const renderedHash = `${hash}:${nextHash}`; + if (state.currentHash !== renderedHash) { + state.currentHash = renderedHash; state.prompts = prompts; + state.promptContext = contextSnapshot(); + state.promptPreviewIndex = 0; renderFloat(); } } function scheduleScan(delay = SCAN_DELAY_MS) { - if (!isCurrentInstance()) return; + if (!isCurrentRuntime()) return; if (state.timer) window.clearTimeout(state.timer); - state.timer = window.setTimeout(scan, delay); + const generation = state.runtimeGeneration; + const timer = window.setTimeout(() => scan(generation, timer), delay); + state.timer = timer; } function installObserver() { - if (!isCurrentInstance()) return false; + if (!isCurrentRuntime()) return false; const root = document.body || document.documentElement; if (!root) return false; + const generation = state.runtimeGeneration; state.observer = new MutationObserver((mutations) => { + if (!isCurrentRuntime(generation)) return; const relevant = mutations.some((mutation) => { if (state.root?.contains(mutation.target)) return false; return mutation.addedNodes.length || mutation.type === "characterData"; @@ -2130,77 +8088,202 @@ return true; } + // Stopping invalidates every generation, removes observers, and leaves no page-owned runtime behind. function stopRuntime() { + state.runtimeActive = false; + state.runtimeGeneration += 1; + state.latestTurnAnchor = null; + if (state.domReadyHandler) document.removeEventListener("DOMContentLoaded", state.domReadyHandler); + state.domReadyHandler = null; if (state.timer) window.clearTimeout(state.timer); + if (state.expressionTimer) window.clearTimeout(state.expressionTimer); + if (state.keepAliveTimer) window.clearTimeout(state.keepAliveTimer); + if (state.flashTimer) window.clearTimeout(state.flashTimer); + if (state.materialAnimTimer) window.clearTimeout(state.materialAnimTimer); + if (state.completionBeamTimer) window.clearTimeout(state.completionBeamTimer); + if (state.snapTimer) window.clearTimeout(state.snapTimer); + if (state.eyeRaf) window.cancelAnimationFrame(state.eyeRaf); state.timer = 0; + state.expressionTimer = 0; + state.keepAliveTimer = 0; + state.flashTimer = 0; + state.materialAnimTimer = 0; + state.completionBeamTimer = 0; + state.snapTimer = 0; + state.eyeRaf = 0; + state.surpriseUntil = 0; + state.bridgeActiveKey = ""; + state.bridgePendingHash = ""; + state.bridgePendingRequestId = 0; + state.viewTransitioning = false; + state.pendingTab = ""; + state.pendingRender = false; + state.popover?.removeAttribute?.("data-snap-right"); + cancelViewAnimation(); + cancelSourceCueAnimation(); + cancelMorphAnimations(); + state.dragCleanup?.(); + state.resizeCleanup?.(); + state.contentFadeCleanup?.(); + state.eyeCleanup?.(); + state.eyeCleanup = null; + state.contentFadeCleanup = null; + state.eyePointer = null; + document.querySelectorAll(".codex-stepwise-active-pane, .codex-stepwise-pane-flash").forEach((node) => { + node.classList.remove("codex-stepwise-active-pane", "codex-stepwise-pane-flash"); + }); + removeContextTracking(); + if (state.keyHandler) document.removeEventListener("keydown", state.keyHandler, true); + state.keyHandler = null; window.removeEventListener("resize", onResize); state.observer?.disconnect(); state.observer = null; state.themeObserver?.disconnect(); state.themeObserver = null; + state.typographyObserver?.disconnect(); + state.typographyObserver = null; + clearPromptInteractionTimers(); + clearStepwisePayloadMarks(); + outlineClearMarks(); + state.outlineItems = []; + state.outlineRefreshPromise = null; + state.outlineMessage = null; + state.outlineSourceHash = ""; + state.outlineFingerprint = ""; + state.outlineStatus = "idle"; + state.outlineError = ""; state.root?.remove(); state.root = null; state.fab = null; state.popover = null; + state.glass = null; + state.rim = null; + state.completionBeam = null; + state.clearFilter = null; + state.clearDisplacement = null; + state.clearDistortion = null; + state.liquidFilter = null; + state.crystalFilter = null; + state.displacementTexture = null; + state.panel = null; + state.layout = null; + state.drag = null; + state.resizeDrag = null; + state.dragCleanup = null; + state.resizeCleanup = null; + state.focusAfterMorph = ""; + state.pinnedThreadRoot = null; + state.pinnedThreadAt = 0; + state.activeContext = { + paneRoot: null, + paneKey: "", + sessionId: "", + assistantMessageId: "", + generation: state.activeContext.generation + 1, + }; document.getElementById(STYLE_ID)?.remove(); state.open = false; } function activateRuntime() { + if (!isCurrentInstance()) return false; + if (!state.runtimeActive) { + state.runtimeGeneration += 1; + state.runtimeActive = true; + } + const generation = state.runtimeGeneration; + state.activeTab = normalizeActiveTab(); installStyle(); installFloat(); + installContextTracking(); if (!state.observer && !installObserver()) { - document.addEventListener( - "DOMContentLoaded", - () => { - if (!isCurrentInstance()) return; - installObserver(); - installFloat(); - void ensureSettings(); - scheduleScan(0); - }, - { once: true } - ); + const domReadyHandler = () => { + if (state.domReadyHandler === domReadyHandler) state.domReadyHandler = null; + if (!isCurrentRuntime(generation)) return; + installObserver(); + installFloat(); + void ensureSettings(); + scheduleScan(0); + }; + state.domReadyHandler = domReadyHandler; + document.addEventListener("DOMContentLoaded", domReadyHandler, { once: true }); } scheduleScan(0); + return true; } async function syncSettings(patch = {}) { if (!isCurrentInstance()) return null; + const normalizedPatch = {}; if (patch && typeof patch === "object") { - state.settings = { ...(state.settings || {}), ...patch }; - } - if (patch?.enabled === false) { - stopRuntime(); - settingsPromise = null; - startupPromise = null; - const settings = await loadSettings(); - if (!isCurrentInstance()) return null; - if (settings?.enabled) activateRuntime(); - else pushDiagnostic("settings:disabled-sync", {}); - return settings; + Object.entries(patch).forEach(([key, value]) => { + if (value !== undefined) normalizedPatch[key] = value; + }); } + if (Object.keys(normalizedPatch).length) { + if (!state.settingsLoaded) { + pendingSettingsPatch = { ...pendingSettingsPatch, ...normalizedPatch }; + } + applyRuntimeSettings({ ...(state.settings || {}), ...normalizedPatch }); + } + const hasRuntimePatch = typeof normalizedPatch.enabled === "boolean" + || typeof normalizedPatch.answerOutlineEnabled === "boolean" + || Object.prototype.hasOwnProperty.call(normalizedPatch, "generationMode"); if (patch?.enabled === true) { pushDiagnostic("settings:enabled-sync", {}); + } + if (patch?.answerOutlineEnabled === true) pushDiagnostic("settings:outline-enabled-sync", {}); + if (Object.prototype.hasOwnProperty.call(normalizedPatch, "generationMode")) { + pushDiagnostic("settings:generation-mode-sync", { + mode: stepwiseGenerationMode(), + }); + } + if (hasRuntimePatch) { + const hasInFlightSettingsRequest = Boolean(settingsPromise); + settingsSyncEpoch += 1; + if (!state.settingsLoaded || hasInFlightSettingsRequest) { + pendingSettingsPatch = { ...pendingSettingsPatch, ...normalizedPatch }; + settingsPromise = null; + void reloadSettings(); + } + if (!runtimeEnabled()) { + pushDiagnostic("settings:disabled-sync", {}); + if (state.runtimeActive) stopRuntime(); + return state.settings; + } activateRuntime(); + renderFloat(); + scheduleScan(0); return state.settings; } + settingsPromise = null; startupPromise = null; const settings = await loadSettings(); if (!isCurrentInstance()) return null; - if (!settings?.enabled) { + if (!runtimeEnabled(settings)) { pushDiagnostic("settings:disabled-sync", {}); - stopRuntime(); + if (state.runtimeActive) stopRuntime(); return settings; } pushDiagnostic("settings:enabled-sync", {}); activateRuntime(); + renderFloat(); + scheduleScan(0); return settings; } function destroy() { state.destroyed = true; + state.promptContext = null; + state.latestTurnAnchor = null; + state.pinnedPaneKey = ""; + state.pinnedSessionId = ""; + state.pinnedThreadRoot = null; + if (state.settingsSyncTimer) window.clearTimeout(state.settingsSyncTimer); + state.settingsSyncTimer = 0; + cancelSourceCueAnimation(); + cancelViewAnimation(); stopRuntime(); if (window[API_KEY]?.instanceId === INSTANCE_ID) delete window[API_KEY]; } @@ -2219,11 +8302,13 @@ } async function start() { + scheduleSettingsSync(); if (startupPromise) return startupPromise; + const generation = state.runtimeGeneration; startupPromise = (async () => { const settings = await ensureSettings(); - if (!isCurrentInstance()) return; - if (!settings?.enabled) { + if (!isCurrentInstance() || generation !== state.runtimeGeneration) return; + if (!runtimeEnabled(settings)) { pushDiagnostic("startup:disabled", {}); startupPromise = null; return; @@ -2233,6 +8318,7 @@ return startupPromise; } + // A small debug surface exposes state and lifecycle controls without leaking chat contents by default. window[API_KEY] = { version: SCRIPT_VERSION, instanceId: INSTANCE_ID, @@ -2242,6 +8328,11 @@ destroy, loadSettings, syncSettings, + setOpen, + setMaterial: writeMaterial, + toggleMaterial, + dockRight: dockRightKeepHeight, + getFabExpression: () => resolveFabExpression(), renderFloat, diagnostics: () => state.diagnostics.slice(), }; diff --git a/crates/codex-plus-core/tests/cdp_bridge.rs b/crates/codex-plus-core/tests/cdp_bridge.rs index ce5500e40..9277a17f7 100644 --- a/crates/codex-plus-core/tests/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/cdp_bridge.rs @@ -760,6 +760,21 @@ fn injection_script_menu_exposes_stepwise_switch_and_syncs_panel() { assert!(script.contains("activateRuntime();")); } +#[test] +fn stepwise_keeps_settings_sync_alive_when_features_are_disabled() { + let script = assets::stepwise_script(); + + assert!(script.contains("const SETTINGS_SYNC_INTERVAL_MS = 2000;")); + assert!(script.contains("function scheduleSettingsSync(")); + assert!(script.contains("await reloadSettings();")); + assert!(script.contains("scheduleSettingsSync();")); + assert!( + script + .contains("if (state.settingsSyncTimer) window.clearTimeout(state.settingsSyncTimer);") + ); + assert!(!script.contains("function stopRuntime() {\n if (state.settingsSyncTimer)")); +} + #[test] fn stepwise_direct_send_targets_main_chat_composer() { let script = assets::stepwise_script(); @@ -768,7 +783,7 @@ fn stepwise_direct_send_targets_main_chat_composer() { assert!(script.contains("function horizontalOverlapRatio(")); assert!(script.contains("function ignoredComposerContainer(")); assert!(script.contains("function mainComposerCandidate(")); - assert!(script.contains("mainComposerCandidate(candidates)")); + assert!(script.contains("mainComposerCandidate(candidates, targetRoot)")); assert!(!script.contains("const target = candidates[candidates.length - 1];")); } @@ -798,9 +813,7 @@ fn stepwise_refreshes_suggestions_for_virtualized_assistant_bubbles() { assert!(script.contains("candidates.push(...assistantBubbleCandidates())")); assert!(script.contains("function latestMessageByDocumentOrder(")); assert!(script.contains("function clearPromptsForNewAssistant(")); - assert!(script.contains( - "if (state.prompts.length || state.currentHash) clearPromptsForNewAssistant(hash);" - )); + assert!(script.contains("clearPromptsForNewAssistant(hash);")); assert!(script.contains("function setScanStatus(")); assert!(script.contains("setScanStatus(\"not-ready\"")); assert!(script.contains("setScanStatus(\"no-assistant-message\"")); @@ -813,18 +826,863 @@ fn stepwise_exposes_manual_refresh_without_refreshing_busy_chats() { let script = assets::stepwise_script(); assert!(script.contains("data-action=\"refresh\"")); + assert!(script.contains("function refreshCurrentView(")); + assert!(script.contains("if (state.activeTab === \"settings\") return reloadSettings();")); + assert!(script.contains("if (state.activeTab === \"outline\") return refreshOutline();")); + assert!(script.contains("return forceRefreshStepwise();")); assert!(script.contains("function forceRefreshStepwise(")); - assert!(script.contains("state.bridgeStatus === \"pending\" || chatBusy()")); + assert!(script.contains("if (state.bridgeStatus === \"pending\")")); + assert!(script.contains("if (chatBusy())")); assert!(script.contains("setScanStatus(\"manual-refresh-busy\"")); assert!(script.contains("state.bridgeCache.delete(bridgeKey)")); - assert!(script.contains("requestBridgeStepwise(bridgeKey, userText, assistantText)")); + let refresh_start = script + .find("function forceRefreshStepwise(") + .expect("manual refresh function should exist"); + let refresh_end = script[refresh_start..] + .find("function clearPromptsForNewAssistant(") + .expect("manual refresh function should have an end") + + refresh_start; + let refresh = &script[refresh_start..refresh_end]; + let delete_cache = refresh + .find("state.bridgeCache.delete(bridgeKey)") + .expect("manual refresh should invalidate the current cache entry"); + let request = refresh + .find("requestBridgeStepwise(bridgeKey, userText, assistantText, generationMode, { userInitiated: true })") + .expect("manual refresh should issue a new request"); + assert!(delete_cache < request); + assert!(!refresh.contains("const cachedResult = state.bridgeCache.get(bridgeKey);")); + assert!(!refresh.contains("manual-refresh-cache")); + assert!(script.contains( + "requestBridgeStepwise(bridgeKey, userText, assistantText, generationMode, { userInitiated: true })" + )); +} + +#[test] +fn stepwise_manual_mode_waits_for_refresh_while_auto_mode_reuses_successful_cache() { + let script = assets::stepwise_script(); + + assert!(script.contains("const generationMode = stepwiseGenerationMode();")); + assert!(script.contains("if (generationMode === \"manual\" && !manualResultVisible) {")); + assert!( + script.contains( + "if (normalizedMode === \"manual\" && options.userInitiated !== true) return;" + ) + ); + assert!(script.contains("state.bridgeStatus = \"manual-ready\";")); + assert!(script.contains("title: \"当前为手动模式\",")); + assert!(script.contains("state: \"manual\",")); + assert!(script.contains("const hasSuccessfulCache = bridgeResult?.status === \"ok\";")); + assert!(script.contains("} else if (hasSuccessfulCache) {")); + assert!(script.contains("const hasSuccessfulCache = bridgeResult?.status === \"ok\";")); + assert!(script.contains("} else if (hasSuccessfulCache) {")); + assert!(!script.contains("manual-refresh-cache")); +} + +#[test] +fn stepwise_generation_mode_is_shared_through_backend_settings() { + let script = assets::stepwise_script(); + + assert!(script.contains("bridgeCall(\"/settings/set\", {")); + assert!(script.contains("codexAppStepwiseGenerationMode: nextMode")); + assert!( + script + .contains("Object.prototype.hasOwnProperty.call(normalizedPatch, \"generationMode\")") + ); + assert!(script.contains("settingsSyncEpoch += 1;")); + assert!( + script.contains("pendingSettingsPatch = { ...pendingSettingsPatch, ...normalizedPatch };") + ); + assert!( + script.contains( + "if (!Object.prototype.hasOwnProperty.call(nextSettings, \"generationMode\"))" + ) + ); + assert!(script.contains("nextSettings.generationMode = stepwiseGenerationMode();")); +} + +#[test] +fn stepwise_restores_cached_bridge_status_after_context_switches() { + let script = assets::stepwise_script(); + + assert!(script.contains("status: bridgeStatus,")); + assert!(script.contains("status: \"failed\",")); + assert!(script.contains("if (bridgeResult) {")); + assert!(script.contains( + "state.bridgeStatus = bridgeResult.status || (bridgeResult.error ? \"failed\" : bridgeResult.disabled ? \"disabled\" : \"ok\");" + )); + assert!(script.contains("state.bridgeError = bridgeResult.error || \"\";")); +} + +#[test] +fn stepwise_releases_only_the_stale_request_that_still_owns_pending_state() { + let script = assets::stepwise_script(); + + assert!(script.contains("const requestId = ++state.bridgeRequestSequence;")); + assert!(script.contains("const requestOwned = () => state.bridgePendingHash === key")); + assert!(script.contains("&& state.bridgePendingRequestId === requestId")); + assert!(script.contains("&& state.bridgePendingMode === normalizedMode;")); + assert!(script.contains("if (!requestOwned() || !requestCurrent()) return;")); + assert!(script.contains("if (!requestOwned()) return;")); + assert!(script.contains("state.bridgePendingRequestId = 0;")); + assert!(script.contains("if (state.bridgeStatus === \"pending\") {")); + assert!(script.contains("state.bridgeStatus = \"idle\";")); + assert!( + !script.contains( + "if (!requestCurrent()) return;\n if (state.bridgePendingHash === key)" + ) + ); +} + +#[test] +fn stepwise_rejects_generation_results_after_the_answer_identity_changes() { + let script = assets::stepwise_script(); + + assert!(script.contains("bridgeActiveKey: \"\",")); + assert!( + script.contains("const requestAssistantMessageId = requestContext.assistantMessageId;") + ); + assert!(script.contains("&& contextMatches(requestContext)")); + assert!( + script.contains("&& state.activeContext.assistantMessageId === requestAssistantMessageId") + ); + assert!(script.contains("&& state.bridgeActiveKey === key")); + assert!(script.contains("&& !chatBusy();")); + assert!(script.contains("state.bridgeActiveKey = bridgeKey;")); + assert!(script.contains( + "function clearPromptsForNewAssistant(hash) {\n state.stepwiseEpoch += 1;\n state.bridgeActiveKey = \"\";" + )); +} + +#[test] +fn stepwise_shows_preparation_state_while_answer_text_settles() { + let script = assets::stepwise_script(); + + assert!(script.contains("function nextProgressState() {")); + assert!(script.contains( + "state.scanStatus === \"assistant-changed\" || state.scanStatus === \"assistant-settling\"" + )); + assert!(script.contains("title: \"正在整理回答\",")); +} + +#[test] +fn stepwise_distinguishes_chat_busy_from_missing_answer() { + let script = assets::stepwise_script(); + + assert!(script.contains("state.scanStatus === \"not-ready\" && state.scanBusy")); + assert!(script.contains("title: \"等待回答完成\",")); +} + +#[test] +fn stepwise_replaces_an_unhealthy_same_version_runtime() { + let script = assets::stepwise_script(); + + assert!( + script.contains("const previousRuntimeHealthy = previous?.state?.runtimeActive === true") + ); + assert!(script.contains("previous?.state?.settingsLoaded === true")); + assert!(script.contains("document.readyState !== \"loading\"")); + assert!(script.contains("previous?.state?.root?.isConnected === true")); + assert!(script.contains("previous?.state?.popover?.isConnected === true")); + assert!(script.contains("Boolean(previous?.state?.observer)")); + assert!(script.contains("document.querySelectorAll?.(`[${ROOT_ATTR}=\"true\"]`).length === 1")); + assert!(script.contains("document.querySelectorAll?.(`#${STYLE_ID}`).length === 1")); + assert!(script.contains("&& previousRuntimeHealthy)")); +} + +#[test] +fn stepwise_records_only_real_bridge_generation_requests() { + let script = assets::stepwise_script(); + + assert_eq!(script.matches("bridge:generate-request").count(), 1); + assert!(script.contains("function requestBridgeStepwise(key, userText, assistantText, requestMode = stepwiseGenerationMode(), options = {})")); + assert!(script.contains("if (!stepwiseEnabled() || !key || state.bridgePendingHash === key || state.bridgeCache.has(key)) return;")); + assert!( + script.contains( + "if (normalizedMode === \"manual\" && options.userInitiated !== true) return;" + ) + ); + assert!(script.contains("pushDiagnostic(\"bridge:generate-request\"")); } #[test] fn stepwise_opens_manager_as_transient_window() { let script = assets::stepwise_script(); - assert!(script.contains("bridgeCall(\"/manager/open-transient\", {})")); + assert!(script.contains("bridgeCall(\"/manager/open-transient\", {")); + assert!(script.contains("page: \"settings\"")); + assert!(script.contains("section: \"stepwise\"")); +} + +#[test] +fn stepwise_uses_one_glass_shell_and_maps_visible_states() { + let script = assets::stepwise_script(); + + for contract in [ + "const MATERIAL_MODES = [\"frosted\", \"clear\", \"liquid\", \"crystal\", \"matte\"];", + "function resolveFabExpression(", + "state.glass.className = \"csw-glass\";", + "materialLayer.append(", + "state.popover.append(materialLayer", + "function unfoldAxes(", + "function unfoldShell(", + "function startMorph(", + "function setOpen(", + "state.glass.addEventListener(\"click\", onGlassClick);", + "state.panel.inert = !expanded;", + "data-action=\"collapse\"", + ] { + assert!( + script.contains(contract), + "missing shell contract: {contract}" + ); + } + + assert_eq!( + script + .matches("state.glass.className = \"csw-glass\";") + .count(), + 1 + ); + for expression in [ + "idle", + "answering", + "surprise", + "generating", + "ready", + "empty", + "error", + ] { + assert!(script.contains(&format!("data-expression=\"{expression}\""))); + } + for obsolete_contract in [ + ".csw-chip-shell", + "state.chipShell", + "--csw-material-progress", + "function materialFrame(", + "state.materialMorphAnimation", + ] { + assert!( + !script.contains(obsolete_contract), + "obsolete shell contract: {obsolete_contract}" + ); + } +} + +#[test] +fn stepwise_morph_animation_always_reaches_a_stable_state() { + let script = assets::stepwise_script(); + + for contract in [ + "const MORPH_FALLBACK_BUFFER_MS = 180;", + "state.morphTransition = transition;", + "if (transition.cancelled || settled) return;", + "path.duration + MORPH_FALLBACK_BUFFER_MS", + "Promise.all(transition.animations.map((item) => item.finished.catch(() => null)))", + "transition.cancelled = true;", + "if (transition.fallbackTimer) window.clearTimeout(transition.fallbackTimer);", + ] { + assert!( + script.contains(contract), + "missing morph lifecycle contract: {contract}" + ); + } +} + +#[test] +fn stepwise_settings_cycles_prompt_and_generation_modes_in_place() { + let script = assets::stepwise_script(); + + for contract in [ + "const PROMPT_CLICK_MODE_KEY = \"codex-stepwise-prompt-click-mode-v1\";", + "const PROMPT_CLICK_MODES = [\"direct\", \"hybrid\", \"fill\"];", + "const DEFAULT_PROMPT_CLICK_MODE = \"hybrid\";", + "function nextPromptClickMode(", + "return PROMPT_CLICK_MODES[(index + 1) % PROMPT_CLICK_MODES.length];", + "function promptClickSubmits(", + "if (mode === \"direct\") return true;", + "if (mode === \"fill\") return false;", + "return clickDetail >= 2;", + "data-action=\"prompt-click-mode\"", + "function writePromptClickMode(", + "function togglePromptClickMode(", + "class=\"csw-metric-action\"", + ] { + assert!( + script.contains(contract), + "missing prompt mode contract: {contract}" + ); + } + + for contract in [ + "const GENERATION_MODES = [\"auto\", \"manual\"];", + "function nextGenerationMode(", + "return GENERATION_MODES[(index + 1) % GENERATION_MODES.length];", + "data-action=\"generation-mode\"", + "function toggleGenerationMode(", + "function setGenerationMode(value)", + "手动刷新", + "自动生成", + ] { + assert!( + script.contains(contract), + "missing generation mode contract: {contract}" + ); + } + + for obsolete_contract in [ + "role=\"menuitemradio\"", + "function generationModeOptionsHtml()", + "data-generation-mode-menu role=\"menu\"", + "function closeSettingsChoiceMenus(", + "state.settingsMenuCleanup = () => {", + ] { + assert!( + !script.contains(obsolete_contract), + "obsolete settings menu contract: {obsolete_contract}" + ); + } +} + +#[test] +fn stepwise_generation_mode_updates_without_rebuilding_the_panel_on_success() { + let script = assets::stepwise_script(); + let body = script + .split_once("async function setGenerationMode(value)") + .expect("generation mode setter") + .1 + .split_once("// Manager settings are the source of truth") + .expect("generation mode setter boundary") + .0; + + assert!(script.contains("function updateGenerationModeControl(")); + assert!(body.contains("updateGenerationModeControl(nextMode, true);")); + assert!(body.contains("updateGenerationModeControl(nextMode);")); + assert_eq!(body.matches("renderFloat();").count(), 1); + assert!(body.contains( + "state.settingsStatus = payload.error || \"模式保存失败\";\n renderFloat();" + )); +} + +#[test] +fn stepwise_header_tools_keep_stable_pointer_hit_regions() { + let script = assets::stepwise_script(); + + for contract in [ + ".csw-head-side {", + "cursor: default;", + "pointer-events: auto;", + ".csw-head-side .csw-icon {\n pointer-events: none;", + ".csw-head:has(:focus-visible) .csw-head-side .csw-icon {\n pointer-events: auto;", + "if (target.closest(\".csw-head-side\")) return true;", + ] { + assert!( + script.contains(contract), + "missing stable header hit-region contract: {contract}" + ); + } +} + +#[test] +fn stepwise_uses_one_shot_completion_beam_for_ready_suggestions_in_both_shapes() { + let script = assets::stepwise_script(); + + for contract in [ + "state.completionBeam.className = \"csw-completion-beam\";", + "function clearCompletionBeam()", + "function triggerCompletionBeam(promptCount)", + "if (promptCount < 1 || prefersReducedMotion() || !state.popover) return;", + "state.popover.dataset.completionBeam = \"true\";", + "if (bridgeStatus === \"ok\") triggerCompletionBeam(prompts.length);", + ] { + assert!( + script.contains(contract), + "missing completion beam contract: {contract}" + ); + } +} + +#[test] +fn stepwise_uses_eye_bob_only_while_generation_is_pending() { + let script = assets::stepwise_script(); + let stepwise_expression = script + .split("function resolveStepwiseExpression") + .nth(1) + .unwrap() + .split("function resolveOutlineExpression") + .next() + .unwrap(); + let outline_expression = script + .split("function resolveOutlineExpression") + .nth(1) + .unwrap() + .split("function usesOutlineExpression") + .next() + .unwrap(); + + assert!(script.contains("@keyframes csw-face-generate-bob")); + assert!(script.contains("[data-expression=\"generating\"] .csw-fab-eye")); + assert!(!script.contains("class=\"csw-thinking-orbs\"")); + assert!( + stepwise_expression + .find("state.bridgeStatus === \"pending\"") + .unwrap() + < stepwise_expression.find("state.scanBusy").unwrap() + ); + assert!( + outline_expression + .find("state.outlineStatus === \"pending\"") + .unwrap() + < outline_expression.find("state.scanBusy").unwrap() + ); +} + +#[test] +fn stepwise_material_names_are_semantic_and_migrate_legacy_storage() { + let script = assets::stepwise_script(); + + for contract in [ + "const MATERIAL_KEY = \"codex-stepwise-material-v3\";", + "const PREVIOUS_MATERIAL_KEY = \"codex-stepwise-material-v2\";", + "const LEGACY_MATERIAL_KEY = \"codex-stepwise-material-v1\";", + "const MATERIAL_ORIGIN_KEY = \"codex-stepwise-material-v3-origin\";", + "const MATERIAL_MIGRATION_KEY = \"codex-stepwise-material-v3-migrated\";", + "const DEFAULT_MATERIAL = \"frosted\";", + "function migrateMaterialStorageV3()", + "storage.set(MATERIAL_KEY, migrated.material);", + "storage.set(MATERIAL_ORIGIN_KEY, migrated.origin);", + "storage.set(MATERIAL_MIGRATION_KEY, \"true\");", + "const CLEAR_FILTER_ID = \"codex-stepwise-clear-distortion\";", + "const LIQUID_FILTER_ID = \"codex-stepwise-liquid-distortion\";", + "const CRYSTAL_FILTER_ID = \"codex-stepwise-crystal-distortion\";", + ] { + assert!( + script.contains(contract), + "missing material contract: {contract}" + ); + } + for migration in [ + "glass: \"frosted\"", + "liquid: \"clear\"", + "liquid2: \"liquid\"", + "solid: \"matte\"", + "opaque: \"matte\"", + ] { + assert!(script.contains(migration)); + } + for obsolete_runtime_name in [ + "LIQUID2_FILTER_ID", + "createLiquid2Filter", + ".csw-liquid2-", + "state.liquid2", + "data-material=\"glass\"", + "data-material=\"solid\"", + "data-material=\"liquid2\"", + ] { + assert!(!script.contains(obsolete_runtime_name)); + } +} + +#[test] +fn stepwise_materials_use_mode_specific_layers_without_legacy_runtime_paths() { + let script = assets::stepwise_script(); + + for contract in [ + "const MATERIAL_MODES = [\"frosted\", \"clear\", \"liquid\", \"crystal\", \"matte\"];", + "const DEFAULT_MATERIAL = \"frosted\";", + "function createClearFilter()", + "function createLiquidFilter()", + "function createCrystalFilter()", + "clearTexture.className = \"csw-clear-texture\";", + "state.clearDistortion.className = \"csw-clear-distortion\";", + "state.displacementTexture.className = \"csw-displacement-texture\";", + "materialLayer.append(state.displacementTexture, state.glass, state.rim);", + ".csw-popover[data-material=\"clear\"] .csw-clear-texture", + ".csw-popover[data-material=\"clear\"] .csw-clear-distortion", + ".csw-popover[data-material=\"liquid\"] .csw-displacement-texture", + ".csw-popover[data-material=\"crystal\"] .csw-displacement-texture", + "state.popover?.setAttribute(\"data-material\", mode);", + ] { + assert!( + script.contains(contract), + "missing material layer contract: {contract}" + ); + } + + for obsolete_runtime_path in [ + "CodexMaterialMorphicons", + "createMorph", + "MATERIAL_MORPHICONS_VERSION", + "LIQUID2_FILTER_ID", + ".csw-liquid2-", + ] { + assert!( + !script.contains(obsolete_runtime_path), + "obsolete material runtime path: {obsolete_runtime_path}" + ); + } +} + +#[test] +fn stepwise_shows_layered_active_pane_feedback() { + let script = assets::stepwise_script(); + + for contract in [ + "class=\"csw-status-stage\"", + "class=\"csw-source-track\"", + "function statusStageHtml()", + "function sourceTrackHtml(", + "function paneCueForTrack(", + "function animateSourceCue(", + "function cancelSourceCueAnimation(", + "cancelAnimationFrame(state.sourceCueAnimation);", + "state.sourceCueAnimation = requestAnimationFrame(tick);", + "prefersReducedMotion()", + ] { + assert!( + script.contains(contract), + "missing pane feedback contract: {contract}" + ); + } + assert!(!script.contains("function flashActivePaneHighlight(")); + assert!(!script.contains("function panelStatusText(")); +} + +#[test] +fn stepwise_settings_reflow_uses_container_queries() { + let script = assets::stepwise_script(); + + assert!(script.contains("container-name: csw-panel;")); + assert!(script.contains("container-type: inline-size;")); + assert!(script.contains("@container csw-panel")); + assert!(script.contains("overflow-y: auto;")); + assert!(script.contains("grid-template-columns: minmax(max-content, 1fr) auto;")); + assert!(script.contains("grid-template-columns: max-content max-content;")); + assert!(script.contains("grid-column: 1 / -1;")); +} + +#[test] +fn stepwise_supports_panel_drag_eye_tracking_outline_and_compact_progress() { + let script = assets::stepwise_script(); + + for contract in [ + "const MARK_ATTR = \"data-codex-stepwise-outline-id\";", + "function outlineBuild(", + "function outlineMarkItems(", + "function outlineJumpTo(", + "function refreshOutline(", + "state.activeTab = \"outline\";", + "data-view=\"next\"", + "data-view=\"outline\"", + "data-view=\"settings\"", + "function installPanelDrag(", + "beginDrag(event, \"panel\")", + "function installEyeTracking(", + "window.addEventListener(\"pointermove\", onPointerMove, { passive: true });", + "state.dragCleanup?.();", + "state.eyeCleanup?.();", + "function bridgeErrorPresentation(", + "class=\"csw-empty\"", + "class=\"csw-progress-ring\"", + ] { + assert!( + script.contains(contract), + "missing interaction contract: {contract}" + ); + } + assert!(!script.contains("const OUTLINE_API_KEY = \"__codexAnswerOutline\";")); + assert!(!script.contains("csw-skeleton")); + assert!(!script.contains(".csw-row-index")); +} + +#[test] +fn stepwise_preserves_scroll_when_same_view_rerenders() { + let script = assets::stepwise_script(); + + assert!(script.contains("function captureViewScroll(")); + assert!(script.contains("body.dataset.viewBody !== state.activeTab")); + assert!(script.contains("function viewScrollTargets(")); + assert!(script.contains("body.querySelector(\".csw-prompt-preview-scroll\")")); + assert!(script.contains("function restoreViewScroll(")); + assert!(script.contains("snapshot.view !== state.activeTab")); + assert!(script.contains("body.scrollHeight - body.clientHeight")); + assert!(script.contains("body.scrollTop = clamp(snapshot.top, 0, maxTop)")); + assert!(script.contains("preview.dataset.previewIndex !== snapshot.preview.index")); + assert!(script.contains("prompt !== snapshot.preview.prompt")); + assert!( + script.contains("previewScroll.scrollTop = clamp(snapshot.preview.top, 0, previewMaxTop)") + ); + assert!(script.contains("const viewScroll = captureViewScroll();")); + assert!(script.contains("restoreViewScroll(viewScroll);")); +} + +#[test] +fn stepwise_tracks_content_overflow_without_fading_settings() { + let script = assets::stepwise_script(); + + for contract in [ + "function syncContentFade()", + "function installContentFadeTracking()", + "state.activeTab !== \"settings\"", + "popover.dataset.contentFade = String(overflowing && !atEnd);", + "target.addEventListener(\"scroll\", onScroll, { passive: true })", + "new window.ResizeObserver(onScroll)", + "resizeObserver?.disconnect();", + "state.contentFadeCleanup?.();", + ] { + assert!( + script.contains(contract), + "missing content fade contract: {contract}" + ); + } + assert!(!script.contains( + ".csw-popover[data-content-fade=\"true\"] .csw-body[data-view-body=\"settings\"]" + )); +} + +#[test] +fn stepwise_previews_prompt_after_hover_or_focus() { + let script = assets::stepwise_script(); + + for contract in [ + "data-label-only=\"${state.labelOnly}\"", + "class=\"csw-prompt-preview\"", + "class=\"csw-prompt-preview-scroll\"", + "class=\"csw-prompt-preview-content\"", + ".csw-prompt-preview::before {", + ".csw-popover[data-material=\"matte\"] .csw-prompt-preview::before {", + "button.addEventListener(\"pointerenter\", () => schedulePromptPreview(button));", + "button.addEventListener(\"pointerleave\", cancelScheduledPromptPreview);", + "button.addEventListener(\"focus\", () => showPromptPreview(button, true));", + "function schedulePromptPreview(button)", + "function cancelScheduledPromptPreview()", + "function showPromptPreview(button, immediate = false)", + "if (body) body.textContent = item.prompt;", + "if (scroll) scroll.scrollTop = 0;", + "if (preview.isConnected) syncContentFade();", + ] { + assert!( + script.contains(contract), + "missing prompt preview contract: {contract}" + ); + } + assert!(!script.contains( + ".csw-popover[data-material=\"matte\"] .csw-prompt-preview::before { + display: none;" + )); + assert!(!script.contains("function expandPromptRow(button)")); + assert!(!script.contains("data-prompt-expanded")); +} + +#[test] +fn stepwise_keeps_context_stable_during_passive_scrolling() { + let script = assets::stepwise_script(); + + assert!(script.contains("pinThreadFromTarget(event.target, \"pointer\")")); + assert!(script.contains("pinThreadFromTarget(event.target, \"focus\")")); + assert!(!script.contains("pinThreadFromTarget(event.target, \"scroll\")")); + assert!(!script.contains("document.addEventListener(\"scroll\", state.scrollHandler, true);")); + assert!(script.contains( + "const CONVERSATION_TURN_SELECTOR = \"div.contents[data-content-search-turn-key]\";" + )); + assert!(script.contains("function conversationTurns()")); + assert!(script.contains("latestTurnAnchor: null,")); + assert!(script.contains("function latestConversationTurnByKey(turns)")); + assert!(script.contains("function nextLatestTurnAnchor(previous, turns, sessionId)")); + assert!( + script.contains( + "const sameSession = Boolean(sessionId) && previous?.sessionId === sessionId;" + ) + ); + assert!(script.contains( + "if (sameSession && compareConversationTurnKeys(mounted.turnKey, previous.turnKey) < 0) return previous;" + )); + assert!(script.contains("sessionId,")); + assert!(script.contains("data-above-composer-conversation-id")); + assert!(script.contains("data-response-annotation-conversation")); + assert!(script.contains("const tabId = current.getAttribute?.(\"data-tab-id\");")); + let marker_priority = script + .find("const conversationMarkers = [") + .expect("conversation markers should exist"); + let tab_fallback = script + .find("// Side chats do not expose the main conversation marker; their tab ID is stable.") + .expect("side-chat tab fallback should exist"); + assert!(marker_priority < tab_fallback); + assert!( + script.contains("return assistantMessageFromTurnAnchor(updateLatestTurnAnchor(turns));") + ); + assert!(!script.contains("if (turns.length) return turns.at(-1)?.assistantMessage || null;")); + assert!(script.contains("if (message?.turnKey) return `turn:${message.turnKey}`;")); + assert!(script.contains("const snapshotUserText = normalizeText(message?.userText || \"\");")); + assert!(script.contains("const userText = findPreviousUserText(message);")); + assert!(!script.contains( + "return Array.from(root.querySelectorAll(selectors))\n .filter(visibleElement)" + )); +} + +#[test] +fn stepwise_keeps_latest_turn_anchor_when_virtualized_history_mounts() { + let script = assets::stepwise_script(); + let helper_start = script + .find("function compareConversationTurnKeys(") + .expect("turn anchor helpers should exist"); + let helper_end = script[helper_start..] + .find("function updateLatestTurnAnchor(") + .expect("turn anchor runtime wrapper should exist") + + helper_start; + let helpers = &script[helper_start..helper_end]; + + let temp = tempfile::tempdir().expect("temp dir should be created"); + let harness_path = temp.path().join("stepwise-turn-anchor.cjs"); + let mut harness = std::fs::File::create(&harness_path).expect("harness should be created"); + writeln!( + harness, + "const vm = require('node:vm');\nconst source = {};", + serde_json::to_string(helpers).expect("helper source should serialize") + ) + .expect("helper source should be written"); + harness + .write_all( + br#" +const context = {}; +vm.runInNewContext(`${source}\nthis.api = { nextLatestTurnAnchor, assistantMessageFromTurnAnchor };`, context); +const { nextLatestTurnAnchor, assistantMessageFromTurnAnchor } = context.api; + +const turn = (turnKey, userText, assistantText) => ({ + turnKey, + userText, + node: `turn:${turnKey}`, + assistantMessage: assistantText == null ? null : { + node: `assistant:${turnKey}`, + role: "assistant", + text: assistantText, + turnKey, + }, +}); + +let anchor = null; +let activeKey = ""; +let requests = 0; +const observe = (sessionId, turns) => { + anchor = nextLatestTurnAnchor(anchor, turns, sessionId); + const message = assistantMessageFromTurnAnchor(anchor); + const key = message ? `${message.userText}\n${message.text}` : ""; + if (key && key !== activeKey) { + activeKey = key; + requests += 1; + } + return { + anchor: anchor?.turnKey || null, + message: message?.text || null, + userText: message?.userText || null, + requests, + }; +}; + +const a = "019fe7ac-d560-79e1-b23f-fb6efc941e96"; +const b = "019fe7ab-d560-79e1-b23f-fb6efc941e96"; +const c = "019fe7ad-d560-79e1-b23f-fb6efc941e96"; +const d = "019fe7ae-d560-79e1-b23f-fb6efc941e96"; +const result = { + afterA: observe("session-a", [turn(a, "A user", "A assistant answer")]), + afterHistoryB: observe("session-a", [turn(b, "B user", "B historical answer")]), + afterC: observe("session-a", [turn(c, "C user", "C assistant answer")]), + afterDStarts: observe("session-a", [turn(d, "D user", null)]), + afterDCompletes: observe("session-a", [turn(d, "D user", "D assistant answer")]), + afterDTransientRemount: observe("session-a", [turn(d, "D user", null)]), + afterSessionB: observe("session-b", [turn(b, "B user", "B session answer")]), +}; +process.stdout.write(JSON.stringify(result)); +"#, + ) + .expect("harness should be written"); + drop(harness); + + let output = Command::new("node") + .arg(&harness_path) + .output() + .expect("node should run turn anchor harness"); + assert!( + output.status.success(), + "turn anchor harness failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let result: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("harness stdout should be JSON"); + + assert_eq!( + result["afterA"]["anchor"], + "019fe7ac-d560-79e1-b23f-fb6efc941e96" + ); + assert_eq!(result["afterA"]["requests"], 1); + assert_eq!(result["afterHistoryB"], result["afterA"]); + assert_eq!( + result["afterC"]["anchor"], + "019fe7ad-d560-79e1-b23f-fb6efc941e96" + ); + assert_eq!(result["afterC"]["message"], "C assistant answer"); + assert_eq!(result["afterC"]["userText"], "C user"); + assert_eq!(result["afterC"]["requests"], 2); + assert_eq!( + result["afterDStarts"]["anchor"], + "019fe7ae-d560-79e1-b23f-fb6efc941e96" + ); + assert_eq!(result["afterDStarts"]["message"], serde_json::Value::Null); + assert_eq!(result["afterDStarts"]["requests"], 2); + assert_eq!(result["afterDCompletes"]["message"], "D assistant answer"); + assert_eq!(result["afterDCompletes"]["userText"], "D user"); + assert_eq!(result["afterDCompletes"]["requests"], 3); + assert_eq!(result["afterDTransientRemount"], result["afterDCompletes"]); + assert_eq!( + result["afterSessionB"]["anchor"], + "019fe7ab-d560-79e1-b23f-fb6efc941e96" + ); + assert_eq!(result["afterSessionB"]["message"], "B session answer"); + assert_eq!(result["afterSessionB"]["requests"], 4); +} + +#[test] +fn stepwise_sends_the_latest_turn_user_and_assistant_text() { + let script = assets::stepwise_script(); + + assert!(script.contains("function labeledMessageContainer(turn, role)")); + assert!(script.contains("function labeledMessageText(container)")); + assert!(script.contains("const turnUserText = conversationTurn(turn)?.userText || \"\";")); + assert!(script.contains("lastUserMessage: userText,")); + assert!(script.contains("lastAssistantMessage: assistantText,")); +} + +#[test] +fn stepwise_prompt_selection_keeps_the_panel_open() { + let script = assets::stepwise_script(); + let start = script + .find("function selectPrompt(button, submit)") + .unwrap(); + let end = script[start..] + .find("function settingsModelLabel(") + .unwrap() + + start; + let select_prompt = &script[start..end]; + + assert!(select_prompt.contains("fillComposer(item.prompt, submit);")); + assert!(!select_prompt.contains("setOpen(false)")); + assert!(!script.contains("PROMPT_FILL_CLOSE_MS")); + assert!(!script.contains("promptCloseTimer")); +} + +#[test] +fn stepwise_does_not_rebuild_stable_empty_states() { + let script = assets::stepwise_script(); + + assert!(script.contains("if (state.lastScanStatus === key) return false;")); + assert!(script.contains("pushDiagnostic(`scan:${status}`, details);\n return true;")); + assert!( + script + .matches("const statusChanged = setScanStatus(") + .count() + >= 2 + ); + assert!(script.matches("if (statusChanged) renderFloat();").count() >= 2); } #[test] @@ -1214,7 +2072,7 @@ const cases = {{ chatGptKinds: chatGpt.marketplaceKinds, unrelatedErrorMatched: api.remoteAuthError({{ message: "network unavailable" }}), }}; -process.stdout.write(JSON.stringify(cases)); +process.stdout.write(JSON.stringify(cases), () => process.exit(0)); "#, script_path = serde_json::to_string(&script_path.to_string_lossy().to_string()) .expect("script path should serialize") @@ -2343,7 +3201,7 @@ process.stdout.write(JSON.stringify({{ pureApiProviderUnchanged, pureApiRecoveryUnscheduled, pureOfficialProviderUnchanged, -}})); +}}), () => process.exit(0)); }}).catch((error) => {{ console.error(error); process.exit(1); From 650f305ad2af8f003f871a323dd96b41a0f1d58f Mon Sep 17 00:00:00 2001 From: Ghibli1024 Date: Thu, 13 Aug 2026 00:50:00 +0800 Subject: [PATCH 4/6] feat(manager): integrate floating panel settings and navigation --- apps/codex-plus-launcher/src/main.rs | 39 +++- .../src-tauri/src/commands.rs | 7 + apps/codex-plus-manager/src-tauri/src/lib.rs | 8 +- apps/codex-plus-manager/src/App.tsx | 131 ++++++++++- apps/codex-plus-manager/src/i18n-en.ts | 3 + .../src/renderer-inject.test.ts | 118 ++++++++++ apps/codex-plus-manager/src/styles.css | 4 + crates/codex-plus-core/src/install/mod.rs | 18 ++ crates/codex-plus-core/src/lib.rs | 1 + .../codex-plus-core/src/manager_navigation.rs | 213 ++++++++++++++++++ crates/codex-plus-core/src/paths.rs | 12 + crates/codex-plus-core/src/routes.rs | 46 ++-- crates/codex-plus-core/tests/bridge_routes.rs | 29 ++- 13 files changed, 587 insertions(+), 42 deletions(-) create mode 100644 crates/codex-plus-core/src/manager_navigation.rs diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index 093e902b6..d4076e1dc 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -798,27 +798,46 @@ impl BridgeRuntimeService for LauncherRuntimeService { })) } - async fn open_manager(&self) -> anyhow::Result { - let target = codex_plus_core::install::spawn_companion( - codex_plus_core::install::MANAGER_BINARY, - std::iter::empty::<&str>(), - ) - .map_err(|error| anyhow::anyhow!("启动管理工具失败:{error}"))?; + async fn open_manager(&self, payload: Value) -> anyhow::Result { + let navigation = + codex_plus_core::manager_navigation::save_pending_manager_navigation_from_payload( + &payload, + )?; + let target = codex_plus_core::install::open_or_activate_manager() + .map_err(|error| anyhow::anyhow!("启动管理工具失败:{error}")) + .map_err(|error| { + codex_plus_core::manager_navigation::rollback_pending_manager_navigation_after_launch_failure( + navigation.as_ref(), + error, + ) + })?; Ok(json!({ "status": "ok", - "path": target + "path": target, + "navigation": navigation })) } - async fn open_transient_manager(&self) -> anyhow::Result { + async fn open_transient_manager(&self, payload: Value) -> anyhow::Result { + let navigation = + codex_plus_core::manager_navigation::save_pending_manager_navigation_from_payload( + &payload, + )?; let target = codex_plus_core::install::spawn_companion( codex_plus_core::install::MANAGER_BINARY, ["--transient"], ) - .map_err(|error| anyhow::anyhow!("启动管理工具失败:{error}"))?; + .map_err(|error| anyhow::anyhow!("启动管理工具失败:{error}")) + .map_err(|error| { + codex_plus_core::manager_navigation::rollback_pending_manager_navigation_after_launch_failure( + navigation.as_ref(), + error, + ) + })?; Ok(json!({ "status": "ok", - "path": target + "path": target, + "navigation": navigation })) } diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 16da77dbb..3205638a7 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -466,6 +466,13 @@ pub fn startup_options() -> CommandResult { ) } +#[tauri::command] +pub fn consume_pending_manager_navigation() +-> Result, String> { + codex_plus_core::manager_navigation::consume_pending_manager_navigation() + .map_err(|error| error.to_string()) +} + pub fn startup_should_show_update() -> bool { should_show_update( std::env::args(), diff --git a/apps/codex-plus-manager/src-tauri/src/lib.rs b/apps/codex-plus-manager/src-tauri/src/lib.rs index da987661a..748ba758c 100644 --- a/apps/codex-plus-manager/src-tauri/src/lib.rs +++ b/apps/codex-plus-manager/src-tauri/src/lib.rs @@ -5,7 +5,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use tauri::menu::{Menu, MenuItem}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; -use tauri::{Manager, WindowEvent}; +use tauri::{Emitter, Manager, WindowEvent}; const TRAY_ID: &str = "codex_plus_tray"; @@ -14,6 +14,7 @@ const TRAY_MENU_SHOW: &str = "tray_show_main"; const TRAY_MENU_DREAM_SKIN_APPLY: &str = "tray_apply_dream_skin"; const TRAY_MENU_QUIT: &str = "tray_quit_app"; const DREAM_SKIN_DEBUG_PORT: u16 = 9229; +const MANAGER_NAVIGATION_EVENT: &str = "manager-navigation-requested"; pub fn run() { install_panic_logger(); @@ -64,6 +65,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ commands::backend_version, commands::startup_options, + commands::consume_pending_manager_navigation, commands::load_overview, commands::launch_codex_plus, commands::restart_codex_plus, @@ -263,6 +265,7 @@ fn register_main_window_events( let minimized_window = event_window.clone(); let close_event_window = event_window.clone(); let close_event_app = event_window.app_handle().clone(); + let focus_event_window = event_window.clone(); event_window.on_window_event(move |event| match event { WindowEvent::Resized(_) => { @@ -270,6 +273,9 @@ fn register_main_window_events( let _ = minimized_window.hide(); } } + WindowEvent::Focused(true) => { + let _ = focus_event_window.emit(MANAGER_NAVIGATION_EVENT, ()); + } WindowEvent::CloseRequested { api, .. } => { if APP_EXITING.load(Ordering::SeqCst) { return; diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 2a7aadfaa..eb1e743e1 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -223,7 +223,10 @@ type BackendSettings = { codexAppServiceTierControls: boolean; codexAppPetRealMouseLook: boolean; codexAppStepwiseEnabled: boolean; + codexAppAnswerOutlineEnabled: boolean; codexAppStepwiseDirectSend: boolean; + codexAppStepwiseGenerationMode: StepwiseGenerationMode; + codexAppStepwiseProtocol: StepwiseProtocol; codexAppStepwiseBaseUrl: string; codexAppStepwiseApiKey: string; codexAppStepwiseApiKeyEnv: string; @@ -334,6 +337,8 @@ type CodexContextEntries = { }; type RelayProtocol = "responses" | "chatCompletions"; +type StepwiseGenerationMode = "auto" | "manual"; +type StepwiseProtocol = "chat_completions" | "responses" | "anthropic_messages" | "auto"; type RelayMode = "official" | "mixedApi" | "pureApi" | "aggregate"; const CHAT_UPSTREAM_BASE_URL_KEY = "codex_plus_chat_base_url"; const SCRIPT_MARKET_REPOSITORY_URL = "https://github.com/BigPizzaV3/CodexPlusPlusScriptMarket"; @@ -772,9 +777,17 @@ type StartupResult = CommandResult<{ showUpdate: boolean; }>; +type ManagerNavigationIntent = { + page: "settings"; + section?: "stepwise"; +}; + type Route = "overview" | "relay" | "relayEnvironment" | "sessions" | "context" | "enhance" | "dreamSkin" | "zedRemote" | "userScripts" | "recommendations" | "maintenance" | "about" | "settings"; type Theme = "dark" | "light"; +const MANAGER_NAVIGATION_EVENT = "manager-navigation-requested"; +const SETTINGS_STEPWISE_SECTION_ID = "settings-stepwise"; + const routes: Array<{ id: Route; label: string; icon: LucideIcon; badge?: string }> = [ { id: "overview", label: t("概览"), icon: LayoutDashboard }, { id: "relay", label: t("供应商配置"), icon: KeyRound }, @@ -822,12 +835,15 @@ const defaultSettings: BackendSettings = { codexAppServiceTierControls: false, codexAppPetRealMouseLook: false, codexAppStepwiseEnabled: false, + codexAppAnswerOutlineEnabled: true, codexAppStepwiseDirectSend: false, + codexAppStepwiseGenerationMode: "auto", + codexAppStepwiseProtocol: "chat_completions", codexAppStepwiseBaseUrl: "", codexAppStepwiseApiKey: "", codexAppStepwiseApiKeyEnv: "CODEX_STEPWISE_API_KEY", codexAppStepwiseModel: "", - codexAppStepwiseMaxItems: 6, + codexAppStepwiseMaxItems: 4, codexAppStepwiseMaxInputChars: 6000, codexAppStepwiseMaxOutputTokens: 500, codexAppStepwiseTimeoutMs: 8000, @@ -886,6 +902,7 @@ const defaultSettings: BackendSettings = { export function App() { const [theme, setTheme] = useState(() => loadInitialTheme()); const [route, setRoute] = useState(() => loadInitialRoute()); + const [pendingSettingsSection, setPendingSettingsSection] = useState(null); const [notice, setNotice] = useState<{ title: string; message: string; status?: Status } | null>(null); const [confirmDialog, setConfirmDialog] = useState<{ title: string; @@ -1769,6 +1786,22 @@ export function App() { } }; + const consumePendingManagerNavigation = async (): Promise => { + try { + const navigation = await invoke("consume_pending_manager_navigation"); + if (!navigation) return false; + if (navigation.page === "settings") { + setPendingSettingsSection(navigation.section ?? null); + setRoute("settings"); + await refreshSettings(true); + return true; + } + } catch (error) { + logDiagnostic("manager.navigation_failed", { error: stringifyError(error) }); + } + return false; + }; + const launch = async () => { const result = await launchCommand("launch_codex_plus"); if (result) { @@ -2486,14 +2519,15 @@ export function App() { useEffect(() => { void (async () => { const startup = await run(() => call("startup_options")); - if (startup?.showUpdate) { + const handledNavigation = await consumePendingManagerNavigation(); + if (!handledNavigation && startup?.showUpdate) { setRoute("about"); void checkUpdate(false); } else { void checkUpdate(true); } await refreshOverview(true); - await refreshSettings(true); + if (!handledNavigation) await refreshSettings(true); await refreshRelay(true); await refreshEnvConflicts(true); await refreshProviderSyncTargets(true); @@ -2503,6 +2537,42 @@ export function App() { })(); }, []); + useEffect(() => { + let disposed = false; + let stopListening: (() => void) | undefined; + void listen(MANAGER_NAVIGATION_EVENT, () => { + if (!disposed) void consumePendingManagerNavigation(); + }).then((unlisten) => { + if (disposed) { + unlisten(); + } else { + stopListening = unlisten; + } + }); + return () => { + disposed = true; + stopListening?.(); + }; + }, []); + + useEffect(() => { + if (route !== "settings" || pendingSettingsSection !== "stepwise") return; + let secondFrame = 0; + const firstFrame = window.requestAnimationFrame(() => { + secondFrame = window.requestAnimationFrame(() => { + document.getElementById(SETTINGS_STEPWISE_SECTION_ID)?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + setPendingSettingsSection(null); + }); + }); + return () => { + window.cancelAnimationFrame(firstFrame); + if (secondFrame) window.cancelAnimationFrame(secondFrame); + }; + }, [pendingSettingsSection, route]); + useEffect(() => { if (getLanguage() === "en") { void invoke("update_tray_labels", { @@ -3654,9 +3724,9 @@ function EnhanceScreen({ setEnhanceFlag("codexAppConversationView", value)} /> setEnhanceFlag("codexAppThreadScrollRestore", value)} /> - - setEnhanceFlag("codexAppStepwiseEnabled", value)} /> - setEnhanceFlag("codexAppStepwiseDirectSend", value)} /> + + setPersistedEnhanceFlag("codexAppStepwiseEnabled", value)} /> + setPersistedEnhanceFlag("codexAppAnswerOutlineEnabled", value)} /> {isWindowsPlatform ? setPersistedEnhanceFlag("codexAppPetRealMouseLook", value)} /> : null} @@ -5400,6 +5470,9 @@ function SettingsScreen({ onFormChange: (value: BackendSettings) => void; actions: Actions; }) { + const stepwiseAutoProtocolHelp = t( + "自动兼容会按 Chat Completions、Responses、Anthropic Messages 的顺序尝试;端点不存在、响应为空或格式不匹配时切换下一种接口。鉴权、限流和服务错误不会自动切换。", + ); return ( <> @@ -5412,14 +5485,14 @@ function SettingsScreen({
- + onFormChange({ ...form, relayTestModel: event.currentTarget.value })} placeholder={t("例如 gpt-5.4-mini")} /> -
+
Stepwise
{t("连接")}
@@ -5438,6 +5511,31 @@ function SettingsScreen({ />
+
+ + onFormChange({ ...form, codexAppStepwiseProtocol: value })} + title={form.codexAppStepwiseProtocol === "auto" ? stepwiseAutoProtocolHelp : ""} + options={[ + { value: "chat_completions", label: "Chat Completions" }, + { value: "responses", label: "Responses" }, + { value: "anthropic_messages", label: "Anthropic Messages" }, + { value: "auto", label: t("自动兼容"), title: stepwiseAutoProtocolHelp }, + ]} + /> + + + onFormChange({ ...form, codexAppStepwiseGenerationMode: value })} + options={[ + { value: "auto", label: t("自动生成") }, + { value: "manual", label: t("手动刷新") }, + ]} + /> + +
- - + +
@@ -8459,7 +8557,9 @@ function normalizeSettings(settings: BackendSettings): BackendSettings { codexAppDreamSkinPaused: settings.codexAppDreamSkinPaused === true, codexAppDreamSkinThemeConfig: normalizeDreamSkinTheme(settings.codexAppDreamSkinThemeConfig), codexAppDreamSkinImagePath: (settings.codexAppDreamSkinImagePath || "").trim(), - codexAppStepwiseMaxItems: clampNumber(settings.codexAppStepwiseMaxItems ?? 6, 0, 6), + codexAppStepwiseGenerationMode: normalizeStepwiseGenerationMode(settings.codexAppStepwiseGenerationMode), + codexAppStepwiseProtocol: normalizeStepwiseProtocol(settings.codexAppStepwiseProtocol), + codexAppStepwiseMaxItems: clampNumber(settings.codexAppStepwiseMaxItems ?? 4, 0, 6), codexAppStepwiseMaxInputChars: clampNumber(settings.codexAppStepwiseMaxInputChars || 6000, 1000, 24000), codexAppStepwiseMaxOutputTokens: clampNumber(settings.codexAppStepwiseMaxOutputTokens || 500, 100, 4000), codexAppStepwiseTimeoutMs: clampNumber(settings.codexAppStepwiseTimeoutMs || 8000, 1000, 60000), @@ -8475,6 +8575,15 @@ function clampNumber(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, Math.round(value))); } +function normalizeStepwiseProtocol(value: StepwiseProtocol | undefined): StepwiseProtocol { + if (value === "responses" || value === "anthropic_messages" || value === "auto") return value; + return "chat_completions"; +} + +function normalizeStepwiseGenerationMode(value: StepwiseGenerationMode | undefined): StepwiseGenerationMode { + return value === "manual" ? "manual" : "auto"; +} + function parsePort(value: string, fallback: number): number { const parsed = Number.parseInt(value, 10); return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535 ? parsed : fallback; diff --git a/apps/codex-plus-manager/src/i18n-en.ts b/apps/codex-plus-manager/src/i18n-en.ts index 39fca3310..f1f8d05ff 100644 --- a/apps/codex-plus-manager/src/i18n-en.ts +++ b/apps/codex-plus-manager/src/i18n-en.ts @@ -54,6 +54,7 @@ export const EN_PLAIN: Record = { "恢复 Codex 默认配色": "Restore Codex default colors", "外观模式": "Appearance mode", "自动": "Auto", + "自动生成": "Automatic generation", "亮色": "Light", "暗色": "Dark", "跟随图片配色": "Use image colors", @@ -486,6 +487,7 @@ export const EN_PLAIN: Record = { "成员数量": "Member count", "或": " or ", "手动": "Manual", + "手动刷新": "Manual refresh", "手动启动": "Manual launch", "打开": "Open", "打开 JOJO Code": "Open JOJO Code", @@ -638,6 +640,7 @@ export const EN_PLAIN: Record = { "没有匹配「": "No providers matching “", "浅色": "Light", "测试模型": "Test model", + "测试": "Test", "消息": "Message", "深色": "Dark", "混入 API": "Mixed-in API", diff --git a/apps/codex-plus-manager/src/renderer-inject.test.ts b/apps/codex-plus-manager/src/renderer-inject.test.ts index 9e6ff4e53..1b646fddd 100644 --- a/apps/codex-plus-manager/src/renderer-inject.test.ts +++ b/apps/codex-plus-manager/src/renderer-inject.test.ts @@ -134,3 +134,121 @@ describe("renderer injection header compatibility", () => { assert.doesNotMatch(renderer, /container\.style\.(?:setProperty|removeProperty)\("display"/); }); }); + +describe("Stepwise generation mode contracts", () => { + it("exposes automatic and manual generation in manager settings", async () => { + const app = await readFile(new URL("./App.tsx", import.meta.url), "utf8"); + + assert.match(app, /type StepwiseGenerationMode = "auto" \| "manual";/); + assert.match(app, /codexAppStepwiseGenerationMode: "auto",/); + assert.match(app, //); + assert.match(app, /\{ value: "auto", label: t\("自动生成"\) \}/); + assert.match(app, /\{ value: "manual", label: t\("手动刷新"\) \}/); + assert.match(app, /return value === "manual" \? "manual" : "auto";/); + }); + + it("defers manual generation until refresh and rejects stale mode results", async () => { + const stepwise = await readFile( + new URL("../../../assets/inject/stepwise-inject.js", import.meta.url), + "utf8", + ); + + assert.match(stepwise, /if \(generationMode === "manual" && !manualResultVisible\)/); + assert.match(stepwise, /state\.bridgeStatus = "manual-ready";/); + assert.match( + stepwise, + /requestBridgeStepwise\(bridgeKey, userText, assistantText, generationMode, \{ userInitiated: true \}\)/, + ); + assert.match(stepwise, /requestBridgeStepwise\(bridgeKey, userText, assistantText, "auto"\)/); + assert.match(stepwise, /normalizedMode === "manual" && options\.userInitiated !== true/); + assert.match(stepwise, /stepwiseGenerationMode\(\) === normalizedMode/); + assert.match(stepwise, /state\.bridgePendingMode === normalizedMode/); + assert.match(stepwise, /Object\.prototype\.hasOwnProperty\.call\(normalizedPatch, "generationMode"\)/); + assert.match(stepwise, /if \(!Object\.prototype\.hasOwnProperty\.call\(nextSettings, "generationMode"\)\)/); + assert.match(stepwise, /nextSettings\.generationMode = stepwiseGenerationMode\(\);/); + const appearanceStart = stepwise.indexOf("function appearanceSettingsHtml()"); + const settingsStart = stepwise.indexOf("function settingsHtml()", appearanceStart); + const appearanceMarkup = stepwise.slice(appearanceStart, settingsStart); + assert.doesNotMatch(appearanceMarkup, /data-action="generation-mode"/); + const footerStart = stepwise.indexOf('
= 0 && generationModeControl > footerStart && promptClickControl > generationModeControl); + assert.match(stepwise, /模式<\/span>/); + assert.match(stepwise, /return normalizeGenerationMode\(value\) === "manual" \? "手动刷新" : "自动生成";/); + assert.match(stepwise, /return setGenerationMode\(nextGenerationMode\(\)\);/); + assert.match(stepwise, /return writePromptClickMode\(nextPromptClickMode\(\)\);/); + assert.match(stepwise, /button\.csw-metric-action\s*\{[^}]*padding:\s*0;/s); + assert.match(stepwise, /\.csw-generation-mode\s*\{[^}]*min-width:\s*max-content;/s); + assert.match(stepwise, /\.csw-click-mode\s*\{[^}]*min-width:\s*max-content;/s); + assert.match( + stepwise, + /\.csw-metric-value,[\s\S]*?\.csw-metric-action\s*\{[^}]*overflow:\s*visible;[^}]*text-overflow:\s*clip;[^}]*white-space:\s*nowrap;/, + ); + assert.match( + stepwise, + /@container csw-panel \(max-width: 440px\)[\s\S]*?\.csw-settings-footer\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*minmax\(max-content, 1fr\) auto;/, + ); + assert.match( + stepwise, + /@container csw-panel \(max-width: 440px\)[\s\S]*?\.csw-runtime-grid\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*max-content max-content;[^}]*width:\s*max-content;/, + ); + assert.match( + stepwise, + /@container csw-panel \(max-width: 440px\)[\s\S]*?\.csw-command-button\s*\{[^}]*flex:\s*0 0 30px;[^}]*height:\s*30px;[^}]*padding:\s*0;[^}]*width:\s*30px;/, + ); + assert.match( + stepwise, + /@container csw-panel \(max-width: 440px\)[\s\S]*?\.csw-command-label\s*\{[^}]*display:\s*none;/, + ); + assert.match( + stepwise, + /class="csw-command-button"[^>]*title="\$\{escapeAttr\(title\)\}"[^>]*aria-label="\$\{escapeAttr\(title\)\}"/, + ); + assert.match( + stepwise, + /@container csw-panel \(max-width: 360px\)[\s\S]*?\.csw-click-mode \.csw-metric-label\s*\{[^}]*display:\s*none;/, + ); + assert.match( + stepwise, + /@container csw-panel \(max-width: 320px\)[\s\S]*?\.csw-metric-label\s*\{[^}]*display:\s*none;/, + ); + assert.match( + stepwise, + /@container csw-panel \(max-width: 320px\)[\s\S]*?\.csw-command-button\s*\{[^}]*flex:\s*0 0 28px;[^}]*height:\s*28px;[^}]*width:\s*28px;/, + ); + const toggleStart = stepwise.indexOf("async function setGenerationMode(value)"); + const immediateCancel = stepwise.indexOf( + "applyRuntimeSettings({ ...(state.settings || {}), generationMode: nextMode });", + toggleStart, + ); + const settingsSave = stepwise.indexOf('bridgeCall("/settings/set", {', toggleStart); + assert.ok(toggleStart >= 0 && immediateCancel > toggleStart && settingsSave > immediateCancel); + + const progressStart = stepwise.indexOf("function nextProgressState()"); + const manualProgressGuard = stepwise.indexOf('if (stepwiseGenerationMode() === "manual") return null;', progressStart); + const localScanProgress = stepwise.indexOf('state.scanStatus === "assistant-changed"', progressStart); + assert.ok(progressStart >= 0 && manualProgressGuard > progressStart && localScanProgress > manualProgressGuard); + assert.match(stepwise, /title: "当前为手动模式"/); + assert.doesNotMatch(stepwise, /title: "待生成"/); + + const outlineExpressionStart = stepwise.indexOf("function usesOutlineExpression("); + const outlineExpressionEnd = stepwise.indexOf("function resolveFabExpression(", outlineExpressionStart); + const outlineExpression = stepwise.slice(outlineExpressionStart, outlineExpressionEnd); + assert.match(outlineExpression, /stepwiseWaitingForManualRefresh\(\)/); + + const runtimePresentationStart = stepwise.indexOf("function settingsRuntimePresentation("); + const runtimePresentationEnd = stepwise.indexOf("function settingsCommandHtml(", runtimePresentationStart); + const runtimePresentation = stepwise.slice(runtimePresentationStart, runtimePresentationEnd); + assert.match(runtimePresentation, /!outlineExpression && stepwiseWaitingForManualRefresh\(settings\)/); + + const scanStart = stepwise.indexOf("function scan("); + const outlineRefresh = stepwise.indexOf("void refreshOutline({ message, assistantHash: hash });", scanStart); + const manualScanBranch = stepwise.indexOf('if (generationMode === "manual" && !manualResultVisible)', scanStart); + const cachedScanBranch = stepwise.indexOf('else if (hasSuccessfulCache)', scanStart); + const automaticGenerate = stepwise.indexOf('requestBridgeStepwise(bridgeKey, userText, assistantText, "auto")', scanStart); + assert.ok(scanStart >= 0 && outlineRefresh > scanStart && manualScanBranch > outlineRefresh); + assert.ok(cachedScanBranch > manualScanBranch); + assert.ok(automaticGenerate > cachedScanBranch); + }); +}); diff --git a/apps/codex-plus-manager/src/styles.css b/apps/codex-plus-manager/src/styles.css index 4e2b78d52..3feb7c13c 100644 --- a/apps/codex-plus-manager/src/styles.css +++ b/apps/codex-plus-manager/src/styles.css @@ -2835,6 +2835,10 @@ body { margin-bottom: 12px; } +.settings-test-model-field { + margin-top: 16px; +} + .field span, .check-row span { color: hsl(var(--foreground)); diff --git a/crates/codex-plus-core/src/install/mod.rs b/crates/codex-plus-core/src/install/mod.rs index 40cc46c95..53828d8ec 100644 --- a/crates/codex-plus-core/src/install/mod.rs +++ b/crates/codex-plus-core/src/install/mod.rs @@ -293,6 +293,24 @@ where Ok(path.to_string_lossy().to_string()) } +pub fn open_or_activate_manager() -> anyhow::Result { + #[cfg(target_os = "macos")] + { + let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from(".")); + if let Some(bundle_id) = macos_companion_bundle_identifier_from_exe(&exe, MANAGER_BINARY) { + let activated = Command::new("/usr/bin/open") + .args(["-b", bundle_id]) + .status() + .is_ok_and(|status| status.success()); + if activated { + return Ok(format!("bundle:{bundle_id}")); + } + } + } + + spawn_companion(MANAGER_BINARY, std::iter::empty::<&str>()) +} + pub fn macos_companion_bundle_identifier_from_exe( exe: &Path, binary: &str, diff --git a/crates/codex-plus-core/src/lib.rs b/crates/codex-plus-core/src/lib.rs index 692380039..a9404d766 100644 --- a/crates/codex-plus-core/src/lib.rs +++ b/crates/codex-plus-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod env_conflicts; pub mod http_client; pub mod install; pub mod launcher; +pub mod manager_navigation; pub mod model_catalog; pub mod model_suffix; pub mod models; diff --git a/crates/codex-plus-core/src/manager_navigation.rs b/crates/codex-plus-core/src/manager_navigation.rs new file mode 100644 index 000000000..08d28042d --- /dev/null +++ b/crates/codex-plus-core/src/manager_navigation.rs @@ -0,0 +1,213 @@ +use anyhow::Context; +use serde_json::Value; +use std::path::Path; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ManagerNavigationIntent { + pub page: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub section: Option, +} + +pub fn save_pending_manager_navigation_from_payload( + payload: &Value, +) -> anyhow::Result> { + let raw_navigation = payload.as_object().context("管理工具导航参数必须是对象")?; + if raw_navigation.is_empty() { + return Ok(None); + } + let navigation: ManagerNavigationIntent = + serde_json::from_value(payload.clone()).context("管理工具导航参数无效")?; + validate_navigation(&navigation)?; + save_pending_manager_navigation(&navigation)?; + Ok(Some(navigation)) +} + +pub fn save_pending_manager_navigation(navigation: &ManagerNavigationIntent) -> anyhow::Result<()> { + save_pending_manager_navigation_at( + &crate::paths::default_pending_manager_navigation_path(), + navigation, + ) +} + +pub fn consume_pending_manager_navigation() -> anyhow::Result> { + consume_pending_manager_navigation_at(&crate::paths::default_pending_manager_navigation_path()) +} + +pub fn rollback_pending_manager_navigation_after_launch_failure( + navigation: Option<&ManagerNavigationIntent>, + launch_error: anyhow::Error, +) -> anyhow::Error { + let Some(navigation) = navigation else { + return launch_error; + }; + match remove_pending_manager_navigation_if_matches(navigation) { + Ok(_) => launch_error, + Err(cleanup_error) => { + launch_error.context(format!("清理未完成的管理工具导航失败:{cleanup_error}")) + } + } +} + +pub fn save_pending_manager_navigation_at( + path: &Path, + navigation: &ManagerNavigationIntent, +) -> anyhow::Result<()> { + validate_navigation(navigation)?; + let contents = format!("{}\n", serde_json::to_string_pretty(navigation)?); + crate::settings::atomic_write(path, contents.as_bytes()) + .with_context(|| format!("保存管理工具导航失败:{}", path.to_string_lossy())) +} + +pub fn consume_pending_manager_navigation_at( + path: &Path, +) -> anyhow::Result> { + let contents = match std::fs::read_to_string(path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(error) + .with_context(|| format!("读取管理工具导航失败:{}", path.to_string_lossy())); + } + }; + let navigation = serde_json::from_str(&contents).context("管理工具导航内容无效")?; + validate_navigation(&navigation)?; + match std::fs::remove_file(path) { + Ok(()) => Ok(Some(navigation)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Some(navigation)), + Err(error) => { + Err(error).with_context(|| format!("清理管理工具导航失败:{}", path.to_string_lossy())) + } + } +} + +fn remove_pending_manager_navigation_if_matches( + navigation: &ManagerNavigationIntent, +) -> anyhow::Result { + remove_pending_manager_navigation_if_matches_at( + &crate::paths::default_pending_manager_navigation_path(), + navigation, + ) +} + +fn remove_pending_manager_navigation_if_matches_at( + path: &Path, + navigation: &ManagerNavigationIntent, +) -> anyhow::Result { + let contents = match std::fs::read_to_string(path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(error) + .with_context(|| format!("读取管理工具导航失败:{}", path.to_string_lossy())); + } + }; + let pending: ManagerNavigationIntent = + serde_json::from_str(&contents).context("管理工具导航内容无效")?; + if pending != *navigation { + return Ok(false); + } + match std::fs::remove_file(path) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => { + Err(error).with_context(|| format!("清理管理工具导航失败:{}", path.to_string_lossy())) + } + } +} + +fn validate_navigation(navigation: &ManagerNavigationIntent) -> anyhow::Result<()> { + match (navigation.page.as_str(), navigation.section.as_deref()) { + ("settings", None | Some("stepwise")) => Ok(()), + _ => anyhow::bail!( + "不支持的管理工具导航:{}/{}", + navigation.page, + navigation.section.as_deref().unwrap_or("") + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn saves_and_consumes_stepwise_navigation_once() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("pending-manager-navigation.json"); + let navigation = ManagerNavigationIntent { + page: "settings".to_string(), + section: Some("stepwise".to_string()), + }; + + save_pending_manager_navigation_at(&path, &navigation).unwrap(); + + assert_eq!( + consume_pending_manager_navigation_at(&path).unwrap(), + Some(navigation) + ); + assert_eq!(consume_pending_manager_navigation_at(&path).unwrap(), None); + } + + #[test] + fn launch_failure_removes_only_matching_navigation() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("pending-manager-navigation.json"); + let failed_navigation = ManagerNavigationIntent { + page: "settings".to_string(), + section: Some("stepwise".to_string()), + }; + let replacement_navigation = ManagerNavigationIntent { + page: "settings".to_string(), + section: None, + }; + + save_pending_manager_navigation_at(&path, &failed_navigation).unwrap(); + assert!( + remove_pending_manager_navigation_if_matches_at(&path, &failed_navigation).unwrap() + ); + assert!(!path.exists()); + + save_pending_manager_navigation_at(&path, &replacement_navigation).unwrap(); + assert!( + !remove_pending_manager_navigation_if_matches_at(&path, &failed_navigation).unwrap() + ); + assert_eq!( + consume_pending_manager_navigation_at(&path).unwrap(), + Some(replacement_navigation) + ); + } + + #[test] + fn rejects_unknown_navigation_targets() { + let payload = serde_json::json!({ + "page": "settings", + "section": "unknown" + }); + + let error = save_pending_manager_navigation_from_payload(&payload).unwrap_err(); + + assert!(error.to_string().contains("不支持的管理工具导航")); + } + + #[test] + fn empty_payload_does_not_create_navigation() { + assert_eq!( + save_pending_manager_navigation_from_payload(&serde_json::json!({})).unwrap(), + None + ); + } + + #[test] + fn rejects_non_object_navigation_payloads() { + for payload in [ + serde_json::json!(null), + serde_json::json!([]), + serde_json::json!("settings"), + ] { + let error = save_pending_manager_navigation_from_payload(&payload).unwrap_err(); + assert!(error.to_string().contains("必须是对象")); + } + } +} diff --git a/crates/codex-plus-core/src/paths.rs b/crates/codex-plus-core/src/paths.rs index a666794be..22f43f3bd 100644 --- a/crates/codex-plus-core/src/paths.rs +++ b/crates/codex-plus-core/src/paths.rs @@ -7,6 +7,7 @@ const LATEST_STATUS_FILE: &str = "latest-status.json"; const DIAGNOSTIC_LOG_FILE: &str = "codex-plus.log"; const PENDING_PROVIDER_IMPORT_FILE: &str = "pending-provider-import.json"; const PENDING_REMOTE_CONTROL_RECOVERY_FILE: &str = "pending-remote-control-recovery.json"; +const PENDING_MANAGER_NAVIGATION_FILE: &str = "pending-manager-navigation.json"; pub fn default_app_state_dir() -> PathBuf { if let Some(home_dir) = directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf()) { @@ -39,6 +40,10 @@ pub fn default_pending_remote_control_recovery_path() -> PathBuf { default_app_state_dir().join(PENDING_REMOTE_CONTROL_RECOVERY_FILE) } +pub fn default_pending_manager_navigation_path() -> PathBuf { + default_app_state_dir().join(PENDING_MANAGER_NAVIGATION_FILE) +} + fn settings_path_for_tests() -> Option { SETTINGS_PATH_FOR_TESTS .get_or_init(|| Mutex::new(None)) @@ -107,4 +112,11 @@ mod tests { assert!(path.ends_with(".codex-session-delete/pending-remote-control-recovery.json")); } + + #[test] + fn default_pending_manager_navigation_path_uses_app_state_directory() { + let path = default_pending_manager_navigation_path(); + + assert!(path.ends_with(".codex-session-delete/pending-manager-navigation.json")); + } } diff --git a/crates/codex-plus-core/src/routes.rs b/crates/codex-plus-core/src/routes.rs index bcc28e8b1..297f04bf2 100644 --- a/crates/codex-plus-core/src/routes.rs +++ b/crates/codex-plus-core/src/routes.rs @@ -83,9 +83,9 @@ pub trait BridgeRuntimeService: Send + Sync { async fn delete_user_script(&self, key: String) -> anyhow::Result; async fn reload_user_scripts(&self) -> anyhow::Result; async fn open_devtools(&self) -> anyhow::Result; - async fn open_manager(&self) -> anyhow::Result; - async fn open_transient_manager(&self) -> anyhow::Result { - self.open_manager().await + async fn open_manager(&self, payload: Value) -> anyhow::Result; + async fn open_transient_manager(&self, payload: Value) -> anyhow::Result { + self.open_manager(payload).await } async fn backend_status(&self) -> anyhow::Result; async fn codex_model_catalog(&self) -> anyhow::Result; @@ -180,8 +180,8 @@ pub async fn handle_bridge_request( } "/user-scripts/reload" => ctx.runtime.reload_user_scripts().await, "/devtools/open" => ctx.runtime.open_devtools().await, - "/manager/open" => ctx.runtime.open_manager().await, - "/manager/open-transient" => ctx.runtime.open_transient_manager().await, + "/manager/open" => ctx.runtime.open_manager(payload.clone()).await, + "/manager/open-transient" => ctx.runtime.open_transient_manager(payload.clone()).await, "/backend/status" => backend_status_value( ctx.runtime.backend_status().await, ctx.settings.get_settings().await, @@ -471,23 +471,39 @@ impl BridgeRuntimeService for CoreRuntimeService { })) } - async fn open_manager(&self) -> anyhow::Result { - let target = crate::install::spawn_companion( - crate::install::MANAGER_BINARY, - std::iter::empty::<&str>(), - )?; + async fn open_manager(&self, payload: Value) -> anyhow::Result { + let navigation = + crate::manager_navigation::save_pending_manager_navigation_from_payload(&payload)?; + let target = crate::install::open_or_activate_manager().map_err(|error| { + crate::manager_navigation::rollback_pending_manager_navigation_after_launch_failure( + navigation.as_ref(), + error, + ) + })?; Ok(json!({ "status": "ok", - "path": target + "path": target, + "navigation": navigation })) } - async fn open_transient_manager(&self) -> anyhow::Result { - let target = - crate::install::spawn_companion(crate::install::MANAGER_BINARY, ["--transient"])?; + async fn open_transient_manager(&self, payload: Value) -> anyhow::Result { + let navigation = + crate::manager_navigation::save_pending_manager_navigation_from_payload(&payload)?; + let target = crate::install::spawn_companion( + crate::install::MANAGER_BINARY, + ["--transient"], + ) + .map_err(|error| { + crate::manager_navigation::rollback_pending_manager_navigation_after_launch_failure( + navigation.as_ref(), + error, + ) + })?; Ok(json!({ "status": "ok", - "path": target + "path": target, + "navigation": navigation })) } diff --git a/crates/codex-plus-core/tests/bridge_routes.rs b/crates/codex-plus-core/tests/bridge_routes.rs index 87a4ccb9f..5dfe43940 100644 --- a/crates/codex-plus-core/tests/bridge_routes.rs +++ b/crates/codex-plus-core/tests/bridge_routes.rs @@ -430,20 +430,35 @@ async fn runtime_routes_keep_user_script_inventory_shape() { #[tokio::test] async fn runtime_status_devtools_repair_and_ads_routes_are_dispatched() { - let ctx = test_context(); + let runtime = Arc::new(FakeRuntime::default()); + let ctx = BridgeContext::new( + Arc::new(FakeSettings::default()), + runtime.clone(), + Arc::new(FakeData::default()), + ); assert_eq!( handle_bridge_request(ctx.clone(), "/devtools/open", json!({})).await, json!({"status": "ok", "opened": true}) ); + let manager_payload = json!({"page": "settings", "section": "stepwise"}); assert_eq!( - handle_bridge_request(ctx.clone(), "/manager/open", json!({})).await, + handle_bridge_request(ctx.clone(), "/manager/open", manager_payload.clone()).await, json!({"status": "ok", "opened": "manager"}) ); + assert_eq!(*runtime.manager_payload.lock().unwrap(), manager_payload); + + let transient_payload = json!({"page": "settings"}); assert_eq!( - handle_bridge_request(ctx.clone(), "/manager/open-transient", json!({})).await, + handle_bridge_request( + ctx.clone(), + "/manager/open-transient", + transient_payload.clone(), + ) + .await, json!({"status": "ok", "opened": "manager-transient"}) ); + assert_eq!(*runtime.manager_payload.lock().unwrap(), transient_payload); assert_eq!( handle_bridge_request(ctx.clone(), "/backend/status", json!({})).await, json!({"status": "ok", "message": "后端已连接", "version": codex_plus_core::version::VERSION, "hideOfficialUsageAlert": false}) @@ -1202,6 +1217,7 @@ impl BridgeSettingsService for FakeSettings { struct FakeRuntime { enabled: Mutex, script_enabled: Mutex, + manager_payload: Mutex, } impl Default for FakeRuntime { @@ -1209,6 +1225,7 @@ impl Default for FakeRuntime { Self { enabled: Mutex::new(true), script_enabled: Mutex::new(true), + manager_payload: Mutex::new(json!({})), } } } @@ -1244,11 +1261,13 @@ impl BridgeRuntimeService for FakeRuntime { Ok(json!({"status": "ok", "opened": true})) } - async fn open_manager(&self) -> anyhow::Result { + async fn open_manager(&self, payload: Value) -> anyhow::Result { + *self.manager_payload.lock().unwrap() = payload; Ok(json!({"status": "ok", "opened": "manager"})) } - async fn open_transient_manager(&self) -> anyhow::Result { + async fn open_transient_manager(&self, payload: Value) -> anyhow::Result { + *self.manager_payload.lock().unwrap() = payload; Ok(json!({"status": "ok", "opened": "manager-transient"})) } From 57d746cfc1a5a2840701258f0c6afc4a0a6831a2 Mon Sep 17 00:00:00 2001 From: Ghibli1024 Date: Thu, 13 Aug 2026 03:37:30 +0800 Subject: [PATCH 5/6] test(ci): keep embedded scripts on LF checkouts --- .gitattributes | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitattributes b/.gitattributes index 2e47b8a64..ed3a3c1b2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,6 +7,9 @@ *.sh text eol=lf .github/workflows/*.yml text eol=lf +# First-party scripts are embedded with include_str! and inspected by source-contract tests. +assets/inject/*.js text eol=lf + # Keep every byte-exact upstream theme asset stable on all checkout platforms. assets/inject/upstream/**/*.js text eol=lf assets/inject/upstream/**/*.css text eol=lf From 7a3c07339e4517de128df846a91fc802d5d02047 Mon Sep 17 00:00:00 2001 From: Ghibli1024 Date: Thu, 13 Aug 2026 04:05:58 +0800 Subject: [PATCH 6/6] fix(ci): retry transient macOS DMG creation --- scripts/installer/macos/package-dmg.sh | 29 +++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/scripts/installer/macos/package-dmg.sh b/scripts/installer/macos/package-dmg.sh index 642c2e4cf..55b0b05a9 100755 --- a/scripts/installer/macos/package-dmg.sh +++ b/scripts/installer/macos/package-dmg.sh @@ -145,5 +145,32 @@ verify_app "$STAGE/Codex++ 管理工具.app" ln -s /Applications "$STAGE/Applications" -hdiutil create -volname "Codex++" -srcfolder "$STAGE" -ov -format UDZO "$DMG" +DMG_WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/codex-plus-plus-dmg.XXXXXX")" +DMG_WORK_PATH="$DMG_WORK_DIR/$(basename "$DMG")" +DMG_CREATED=false + +cleanup_dmg_work_dir() { + rm -f "$DMG_WORK_PATH" + rmdir "$DMG_WORK_DIR" 2>/dev/null || true +} + +trap cleanup_dmg_work_dir EXIT + +for attempt in 1 2 3; do + if hdiutil create -volname "Codex++" -srcfolder "$STAGE" -ov -format UDZO "$DMG_WORK_PATH"; then + mv "$DMG_WORK_PATH" "$DMG" + DMG_CREATED=true + break + fi + + if [ "$attempt" -lt 3 ]; then + sleep "$((attempt * 2))" + fi +done + +if [ "$DMG_CREATED" != true ]; then + echo "error: failed to create DMG after 3 attempts" >&2 + exit 1 +fi + echo "$DMG"