From 18a162c73299e6d45697cfff822b5dc4cc45545c Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Fri, 19 Jun 2026 21:01:47 +0800 Subject: [PATCH] refactor(host): split design_session; keep GUI pumps in op-host-desktop (Phase 4, Task 4.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worker spawn (start, run_design_worker) + viewport-fit math (fit_design_viewport_to_content, active_content_bounds, design_canvas_size) move to op_web_daemon::design_session. The host-coupled UI pumps (pump_commands/pump_progress — take &mut WidgetHostNative) + the progress-line renderer (render_progress/progress_label, used only by pump_progress, so kept with their caller per the call graph — deviates from the plan's MOVE list) stay as the residual. active_content_bounds/design_canvas_size made pub for the KEEP-side fit tests; residual re-exports DesignSession (pub use) for main.rs zero-churn. web_chat_standard fit + chat_session_launch start/DesignSession + chat_intent run_design_worker repointed. Completes Phase 4. design_session 8 + op-web-daemon 215 tests green; no dep/lock change. --- crates/op-host-desktop/src/chat_intent.rs | 2 +- .../src/chat_session_launch.rs | 4 +- crates/op-host-desktop/src/design_session.rs | 228 ++---------------- .../src/design_session_tests.rs | 8 +- .../op-host-desktop/src/web_chat_standard.rs | 2 +- crates/op-web-daemon/src/design_session.rs | 188 +++++++++++++++ crates/op-web-daemon/src/lib.rs | 1 + 7 files changed, 217 insertions(+), 216 deletions(-) create mode 100644 crates/op-web-daemon/src/design_session.rs diff --git a/crates/op-host-desktop/src/chat_intent.rs b/crates/op-host-desktop/src/chat_intent.rs index c9fcdfedc..e1a46be17 100644 --- a/crates/op-host-desktop/src/chat_intent.rs +++ b/crates/op-host-desktop/src/chat_intent.rs @@ -51,7 +51,7 @@ use op_orchestrator::{AppendContext, DesignRequest}; use op_web_daemon::chat_canvas_tools::UiChatToolExecutor; use op_web_daemon::chat_provider_llm::ChatProviderLlmClient; -use crate::design_session::{run_design_worker, DesignCmdReq, DesignDelta}; +use op_web_daemon::design_session::{run_design_worker, DesignCmdReq, DesignDelta}; /// Internal host-op name the modify worker sends over the chat tool /// channel; intercepted by `chat_session::drain_tool_requests` (never diff --git a/crates/op-host-desktop/src/chat_session_launch.rs b/crates/op-host-desktop/src/chat_session_launch.rs index 58feaef32..3d4fcaa6d 100644 --- a/crates/op-host-desktop/src/chat_session_launch.rs +++ b/crates/op-host-desktop/src/chat_session_launch.rs @@ -24,7 +24,7 @@ use op_web_daemon::chat_subprocess::SubprocessProvider; use op_web_daemon::chat_system_prompt::{ build_agent_system_prompt, build_chat_system_prompt, chat_history_from_transcript, }; -use crate::design_session::DesignSession; +use op_editor_host_core::design::DesignSession; use super::ChatSession; @@ -94,7 +94,7 @@ pub fn launch_if_pending( crate::chat_intent::detect_append_intent(host.editor_state(), &user_text); let initial_state = host.editor_state().clone(); let request = build_design_request(user_text, &initial_state, append_context); - *current_design = Some(crate::design_session::start(llm, request, initial_state)); + *current_design = Some(op_web_daemon::design_session::start(llm, request, initial_state)); return true; } // Design intent but the selected agent has no ChatProvider diff --git a/crates/op-host-desktop/src/design_session.rs b/crates/op-host-desktop/src/design_session.rs index 824ae2646..991727a4b 100644 --- a/crates/op-host-desktop/src/design_session.rs +++ b/crates/op-host-desktop/src/design_session.rs @@ -1,133 +1,25 @@ -//! Background design-turn runner — orchestrator counterpart of [`ChatSession`](crate::chat_session). +//! Desktop GUI pumps for the background design turn — the host-coupled +//! half of the design session. //! -//! The orchestrator (`op_orchestrator::Orchestrator::run`) is `async`, -//! takes `&mut sink` (the `DocSink` trait — synchronous read + write -//! against `EditorState`), and runs to completion across multiple -//! `apply()` calls during scaffold → subtasks → cleanup. Two competing -//! constraints shape the threading model: +//! The worker spawn + viewport-fit math live in +//! [`op_web_daemon::design_session`]; this residual keeps the two UI-loop +//! pumps (`pump_commands` / `pump_progress`, which take `&mut +//! WidgetHostNative` — orphan rule) plus the progress-line renderer they +//! fold into the chat transcript. //! -//! - **UI owns the canonical `EditorState`.** `EditorCommand::apply` -//! does ID remapping + history bookkeeping that must run on the UI -//! thread (see `command_apply.rs`). -//! - **Don't freeze the UI for the whole turn.** A design turn can take -//! 10+ seconds; `block_on(run(...))` on the UI thread would lock the -//! window during that span. -//! -//! Resolution: the worker thread owns a **`RemoteDocSink`** that -//! forwards each `apply(cmd)` over an mpsc channel to the UI thread, -//! which `apply()`s on the real state and replies with an ack carrying -//! a fresh `EditorState` snapshot. `RemoteDocSink::state()` reads from -//! a locally cached mirror updated by each ack. The orchestrator never -//! sees the channel — it just calls `sink.apply()` synchronously, and -//! the worker's `apply` blocks until UI acks. -//! -//! Progress events emitted by the orchestrator (`Planning`, -//! `SubtaskStarted`, etc.) ride a separate channel into the chat -//! transcript, mirroring `ChatSession`'s delta channel. -//! -//! ## Lifecycle -//! -//! 1. Caller (`chat_session::launch_if_pending`) classifies intent. -//! For `Intent::Design` + a configured `agent::Provider`, builds a -//! `DesignSession` via [`DesignSession::start`]. -//! 2. `start` clones the current `EditorState` for the worker's -//! initial mirror and spawns the worker thread. The worker calls -//! `block_on(Orchestrator::new().run(...))` against its -//! `RemoteDocSink`. -//! 3. UI event loop drains pending `DesignCmdReq` each frame via -//! [`pump_commands`] — applies on the real state, replies ack. -//! 4. UI event loop also drains `DesignDelta` via [`pump_progress`] -//! and renders progress into the trailing chat bubble. -//! 5. On `Done`, the session is dropped and the channels close. -//! -//! Aborting a turn drops `DesignSession`; the worker's next `apply` -//! sees the channel closed and returns `false`, ending the turn. +//! - UI event loop drains pending `DesignCmdReq` each frame via +//! [`pump_commands`] — applies on the real state, replies ack. +//! - UI event loop also drains `DesignDelta` via [`pump_progress`] and +//! renders progress into the trailing chat bubble. -use std::sync::mpsc::{self, Sender}; -use std::thread; - -use op_editor_core::{DocRect, EditorState, Viewport}; -pub use op_editor_host_core::design::{ - DesignCmdAck, DesignCmdOp, DesignCmdReq, DesignDelta, DesignSession, RemoteDocSink, -}; -use op_editor_ui::widgets::TOP_BAR_HEIGHT; +use op_editor_host_core::design::{DesignCmdAck, DesignCmdOp}; +// Re-export so `crate::design_session::DesignSession` (the DesktopApp +// field type in main.rs) resolves with zero churn. +pub use op_editor_host_core::design::DesignSession; use op_host_native::WidgetHostNative; -use op_orchestrator::{ - AbortFlag, DesignRequest, LlmClient, Orchestrator, Progress, SkippedScreenshotProvider, - SkippedVisionLlmClient, ValidationProviders, -}; +use op_orchestrator::Progress; -use op_web_daemon::chat_runtime::shared_runtime; -use op_web_daemon::pre_validator::LintPreValidator; - -/// Spawn a worker that runs `Orchestrator::run` against a `RemoteDocSink`. -pub fn start( - llm: L, - request: DesignRequest, - initial_state: EditorState, -) -> DesignSession { - let (delta_tx, delta_rx) = mpsc::channel::(); - let (cmd_tx, cmd_rx) = mpsc::channel::(); - - let indicator_epoch = op_editor_core::agent_indicators::begin(); - - thread::Builder::new() - .name("op-design-turn".into()) - .spawn(move || { - run_design_worker( - llm, - request, - initial_state, - delta_tx, - cmd_tx, - indicator_epoch, - ) - }) - .expect("spawn op-design-turn thread"); - - DesignSession::from_channels_with_epoch(delta_rx, cmd_rx, indicator_epoch) -} - -/// One full design turn against a `RemoteDocSink` — the body of -/// [`DesignSession::start`]'s worker thread, callable directly by the -/// CLI intent router's worker (which already runs off the UI thread). -pub(crate) fn run_design_worker( - llm: L, - request: DesignRequest, - initial_state: EditorState, - delta_tx: Sender, - cmd_tx: Sender, - indicator_epoch: u64, -) { - let mut sink = RemoteDocSink::new(cmd_tx, initial_state); - let abort = AbortFlag::new(); - let pre_validator = LintPreValidator; - let screenshot = SkippedScreenshotProvider; - let vision = SkippedVisionLlmClient; - let providers = ValidationProviders { - pre_validator: &pre_validator, - screenshot: &screenshot, - vision: &vision, - system_prompt: String::new(), - }; - let delta_tx_for_progress = delta_tx.clone(); - let mut on_progress = move |p: Progress| { - let _ = delta_tx_for_progress.send(DesignDelta::Progress(p)); - }; - let summary = shared_runtime().block_on( - Orchestrator::new() - .with_indicator_epoch(indicator_epoch) - .run( - request, - &mut sink, - &llm, - &mut on_progress, - &abort, - &providers, - ), - ); - let _ = delta_tx.send(DesignDelta::Done(summary)); -} +use op_web_daemon::design_session::fit_design_viewport_to_content; /// Drain every pending apply request from the in-flight design /// session and execute it against the real `EditorState`. Each @@ -182,92 +74,6 @@ pub fn pump_commands( any_applied } -const DESIGN_FIT_PADDING: f32 = 48.0; - -/// Keep the generated design centered and fully visible while the -/// orchestrator progressively applies scaffold/subtask nodes. -pub(crate) fn fit_design_viewport_to_content( - state: &mut EditorState, - viewport_width: f32, - viewport_height: f32, -) -> bool { - let Some(bounds) = active_content_bounds(state) else { - return false; - }; - let (canvas_w, canvas_h) = design_canvas_size(state, viewport_width, viewport_height); - if canvas_w <= 1.0 || canvas_h <= 1.0 { - return false; - } - - let pad_x = DESIGN_FIT_PADDING.min(canvas_w / 4.0); - let pad_y = DESIGN_FIT_PADDING.min(canvas_h / 4.0); - let fit_w = (canvas_w - pad_x * 2.0).max(1.0); - let fit_h = (canvas_h - pad_y * 2.0).max(1.0); - let content_w = (bounds.w as f32).max(1.0); - let content_h = (bounds.h as f32).max(1.0); - let zoom = (fit_w / content_w) - .min(fit_h / content_h) - .clamp(Viewport::MIN_ZOOM, Viewport::MAX_ZOOM); - let center_x = (bounds.x + bounds.w / 2.0) as f32; - let center_y = (bounds.y + bounds.h / 2.0) as f32; - let next_pan_x = canvas_w / 2.0 - center_x * zoom; - let next_pan_y = canvas_h / 2.0 - center_y * zoom; - - let changed = (state.viewport.zoom - zoom).abs() > 0.001 - || (state.viewport.pan_x - next_pan_x).abs() > 0.5 - || (state.viewport.pan_y - next_pan_y).abs() > 0.5; - state.viewport.zoom = zoom; - state.viewport.pan_x = next_pan_x; - state.viewport.pan_y = next_pan_y; - changed -} - -fn active_content_bounds(state: &EditorState) -> Option { - let scene = op_pen_loader::editor_state_to_layout_scene(state); - let page = scene.active_page()?; - let mut iter = page - .children - .iter() - .map(|node| node.aggregate_bounds()) - .filter(|rect| rect.size.x > 0.0 || rect.size.y > 0.0); - let first = iter.next()?; - let (mut min_x, mut min_y) = (first.origin.x, first.origin.y); - let (mut max_x, mut max_y) = (first.origin.x + first.size.x, first.origin.y + first.size.y); - for rect in iter { - min_x = min_x.min(rect.origin.x); - min_y = min_y.min(rect.origin.y); - max_x = max_x.max(rect.origin.x + rect.size.x); - max_y = max_y.max(rect.origin.y + rect.size.y); - } - Some(DocRect { - x: min_x as f64, - y: min_y as f64, - w: (max_x - min_x) as f64, - h: (max_y - min_y) as f64, - }) -} - -fn design_canvas_size( - state: &EditorState, - viewport_width: f32, - viewport_height: f32, -) -> (f32, f32) { - let canvas_left = if state.editor_ui.sidebar_open { - state.editor_ui.layer_panel_width - } else { - 0.0 - }; - let canvas_right = if state.right_rail_visible() { - viewport_width - state.editor_ui.property_panel_width - } else { - viewport_width - }; - ( - (canvas_right - canvas_left).max(0.0), - (viewport_height - TOP_BAR_HEIGHT).max(0.0), - ) -} - /// Drain every pending progress delta and fold it into the trailing /// assistant message. Clears `current` once the terminal `Done` /// arrives. Returns true when the transcript changed. diff --git a/crates/op-host-desktop/src/design_session_tests.rs b/crates/op-host-desktop/src/design_session_tests.rs index 4adba6be8..e28032b58 100644 --- a/crates/op-host-desktop/src/design_session_tests.rs +++ b/crates/op-host-desktop/src/design_session_tests.rs @@ -1,6 +1,12 @@ use super::*; -use op_editor_core::EditorCommand; +use op_web_daemon::design_session::{ + active_content_bounds, design_canvas_size, fit_design_viewport_to_content, +}; +use op_editor_core::{EditorCommand, EditorState}; +use op_editor_host_core::design::{DesignCmdReq, DesignDelta, RemoteDocSink}; use op_orchestrator::{DocSink, RunSummary, SubtaskOutcome}; +use std::sync::mpsc; +use std::thread; use std::time::{Duration, Instant}; /// `RemoteDocSink::apply` blocks until UI acks. When the UI side diff --git a/crates/op-host-desktop/src/web_chat_standard.rs b/crates/op-host-desktop/src/web_chat_standard.rs index 510a5f374..c9fe1f05c 100644 --- a/crates/op-host-desktop/src/web_chat_standard.rs +++ b/crates/op-host-desktop/src/web_chat_standard.rs @@ -491,7 +491,7 @@ impl DocSink for WebDesignDocSink<'_> { let mut guard = self.state.lock().unwrap_or_else(|p| p.into_inner()); let applied = guard.editor.apply(cmd); let version = if applied { - crate::design_session::fit_design_viewport_to_content( + op_web_daemon::design_session::fit_design_viewport_to_content( &mut guard.editor, 1440.0, 900.0, diff --git a/crates/op-web-daemon/src/design_session.rs b/crates/op-web-daemon/src/design_session.rs new file mode 100644 index 000000000..cbe5587d2 --- /dev/null +++ b/crates/op-web-daemon/src/design_session.rs @@ -0,0 +1,188 @@ +//! Background design-turn worker + viewport-fit math — the host-free +//! half carved out of `op-host-desktop`'s `design_session.rs` (the GUI +//! pumps `pump_commands` / `pump_progress` stay desktop-side). +//! +//! The orchestrator (`op_orchestrator::Orchestrator::run`) is `async`, +//! takes `&mut sink` (the `DocSink` trait — synchronous read + write +//! against `EditorState`), and runs to completion across multiple +//! `apply()` calls during scaffold → subtasks → cleanup. The worker +//! thread owns a `RemoteDocSink` that forwards each `apply(cmd)` over an +//! mpsc channel to the UI thread, which `apply()`s on the real state and +//! replies with an ack carrying a fresh `EditorState` snapshot. The UI +//! drains those requests via the desktop residual's `pump_commands`, and +//! progress deltas via `pump_progress`. + +use std::sync::mpsc::{self, Sender}; +use std::thread; + +use op_editor_core::{DocRect, EditorState, Viewport}; +pub use op_editor_host_core::design::{DesignCmdReq, DesignDelta, DesignSession, RemoteDocSink}; +use op_editor_ui::widgets::TOP_BAR_HEIGHT; +use op_orchestrator::{ + AbortFlag, DesignRequest, LlmClient, Orchestrator, Progress, SkippedScreenshotProvider, + SkippedVisionLlmClient, ValidationProviders, +}; + +use crate::chat_runtime::shared_runtime; +use crate::pre_validator::LintPreValidator; + +/// Spawn a worker that runs `Orchestrator::run` against a `RemoteDocSink`. +pub fn start( + llm: L, + request: DesignRequest, + initial_state: EditorState, +) -> DesignSession { + let (delta_tx, delta_rx) = mpsc::channel::(); + let (cmd_tx, cmd_rx) = mpsc::channel::(); + + let indicator_epoch = op_editor_core::agent_indicators::begin(); + + thread::Builder::new() + .name("op-design-turn".into()) + .spawn(move || { + run_design_worker( + llm, + request, + initial_state, + delta_tx, + cmd_tx, + indicator_epoch, + ) + }) + .expect("spawn op-design-turn thread"); + + DesignSession::from_channels_with_epoch(delta_rx, cmd_rx, indicator_epoch) +} + +/// One full design turn against a `RemoteDocSink` — the body of +/// [`start`]'s worker thread, callable directly by the CLI intent +/// router's worker (which already runs off the UI thread). +pub fn run_design_worker( + llm: L, + request: DesignRequest, + initial_state: EditorState, + delta_tx: Sender, + cmd_tx: Sender, + indicator_epoch: u64, +) { + let mut sink = RemoteDocSink::new(cmd_tx, initial_state); + let abort = AbortFlag::new(); + let pre_validator = LintPreValidator; + let screenshot = SkippedScreenshotProvider; + let vision = SkippedVisionLlmClient; + let providers = ValidationProviders { + pre_validator: &pre_validator, + screenshot: &screenshot, + vision: &vision, + system_prompt: String::new(), + }; + let delta_tx_for_progress = delta_tx.clone(); + let mut on_progress = move |p: Progress| { + let _ = delta_tx_for_progress.send(DesignDelta::Progress(p)); + }; + let summary = shared_runtime().block_on( + Orchestrator::new() + .with_indicator_epoch(indicator_epoch) + .run( + request, + &mut sink, + &llm, + &mut on_progress, + &abort, + &providers, + ), + ); + let _ = delta_tx.send(DesignDelta::Done(summary)); +} + +const DESIGN_FIT_PADDING: f32 = 48.0; + +/// Keep the generated design centered and fully visible while the +/// orchestrator progressively applies scaffold/subtask nodes. Called +/// by the desktop residual's `pump_commands` after each applied command. +pub fn fit_design_viewport_to_content( + state: &mut EditorState, + viewport_width: f32, + viewport_height: f32, +) -> bool { + let Some(bounds) = active_content_bounds(state) else { + return false; + }; + let (canvas_w, canvas_h) = design_canvas_size(state, viewport_width, viewport_height); + if canvas_w <= 1.0 || canvas_h <= 1.0 { + return false; + } + + let pad_x = DESIGN_FIT_PADDING.min(canvas_w / 4.0); + let pad_y = DESIGN_FIT_PADDING.min(canvas_h / 4.0); + let fit_w = (canvas_w - pad_x * 2.0).max(1.0); + let fit_h = (canvas_h - pad_y * 2.0).max(1.0); + let content_w = (bounds.w as f32).max(1.0); + let content_h = (bounds.h as f32).max(1.0); + let zoom = (fit_w / content_w) + .min(fit_h / content_h) + .clamp(Viewport::MIN_ZOOM, Viewport::MAX_ZOOM); + let center_x = (bounds.x + bounds.w / 2.0) as f32; + let center_y = (bounds.y + bounds.h / 2.0) as f32; + let next_pan_x = canvas_w / 2.0 - center_x * zoom; + let next_pan_y = canvas_h / 2.0 - center_y * zoom; + + let changed = (state.viewport.zoom - zoom).abs() > 0.001 + || (state.viewport.pan_x - next_pan_x).abs() > 0.5 + || (state.viewport.pan_y - next_pan_y).abs() > 0.5; + state.viewport.zoom = zoom; + state.viewport.pan_x = next_pan_x; + state.viewport.pan_y = next_pan_y; + changed +} + +/// Bounding box of the active page's content in document space, or +/// `None` for an empty page. Public so the desktop residual's +/// viewport-fit tests can exercise it across the crate boundary. +pub fn active_content_bounds(state: &EditorState) -> Option { + let scene = op_pen_loader::editor_state_to_layout_scene(state); + let page = scene.active_page()?; + let mut iter = page + .children + .iter() + .map(|node| node.aggregate_bounds()) + .filter(|rect| rect.size.x > 0.0 || rect.size.y > 0.0); + let first = iter.next()?; + let (mut min_x, mut min_y) = (first.origin.x, first.origin.y); + let (mut max_x, mut max_y) = (first.origin.x + first.size.x, first.origin.y + first.size.y); + for rect in iter { + min_x = min_x.min(rect.origin.x); + min_y = min_y.min(rect.origin.y); + max_x = max_x.max(rect.origin.x + rect.size.x); + max_y = max_y.max(rect.origin.y + rect.size.y); + } + Some(DocRect { + x: min_x as f64, + y: min_y as f64, + w: (max_x - min_x) as f64, + h: (max_y - min_y) as f64, + }) +} + +/// The visible canvas region (width, height) given the current sidebar / +/// right-rail state. Public for the desktop residual's viewport-fit tests. +pub fn design_canvas_size( + state: &EditorState, + viewport_width: f32, + viewport_height: f32, +) -> (f32, f32) { + let canvas_left = if state.editor_ui.sidebar_open { + state.editor_ui.layer_panel_width + } else { + 0.0 + }; + let canvas_right = if state.right_rail_visible() { + viewport_width - state.editor_ui.property_panel_width + } else { + viewport_width + }; + ( + (canvas_right - canvas_left).max(0.0), + (viewport_height - TOP_BAR_HEIGHT).max(0.0), + ) +} diff --git a/crates/op-web-daemon/src/lib.rs b/crates/op-web-daemon/src/lib.rs index bcea0dec9..4cfaa2f6a 100644 --- a/crates/op-web-daemon/src/lib.rs +++ b/crates/op-web-daemon/src/lib.rs @@ -29,6 +29,7 @@ pub mod chat_spawn; pub mod chat_subprocess; pub mod chat_subprocess_quirks; pub mod chat_system_prompt; +pub mod design_session; pub mod doc_io; pub mod export; pub mod export_pdf;