refactor(host): split design_session; keep GUI pumps in op-host-desktop (Phase 4, Task 4.4)

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.
This commit is contained in:
Kayshen-X 2026-06-19 21:01:47 +08:00
parent 199d9bbec9
commit 18a162c732
7 changed files with 217 additions and 216 deletions

View file

@ -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

View file

@ -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

View file

@ -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<L: LlmClient + Send + 'static>(
llm: L,
request: DesignRequest,
initial_state: EditorState,
) -> DesignSession {
let (delta_tx, delta_rx) = mpsc::channel::<DesignDelta>();
let (cmd_tx, cmd_rx) = mpsc::channel::<DesignCmdReq>();
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<L: LlmClient + Send>(
llm: L,
request: DesignRequest,
initial_state: EditorState,
delta_tx: Sender<DesignDelta>,
cmd_tx: Sender<DesignCmdReq>,
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<DocRect> {
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.

View file

@ -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

View file

@ -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,

View file

@ -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<L: LlmClient + Send + 'static>(
llm: L,
request: DesignRequest,
initial_state: EditorState,
) -> DesignSession {
let (delta_tx, delta_rx) = mpsc::channel::<DesignDelta>();
let (cmd_tx, cmd_rx) = mpsc::channel::<DesignCmdReq>();
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<L: LlmClient + Send>(
llm: L,
request: DesignRequest,
initial_state: EditorState,
delta_tx: Sender<DesignDelta>,
cmd_tx: Sender<DesignCmdReq>,
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<DocRect> {
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),
)
}

View file

@ -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;