mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
feat(host,orchestrator): wire intent gate + DesignSession into chat runtime
Task #27. Routes user messages through `op_orchestrator::classify_intent` in `chat_session::launch_if_pending` — `Intent::Design` with a configured `agent::Provider` launches into the orchestrator pipeline; everything else (chat intent, or design intent with no Provider) falls through to the existing `ChatProvider` CLI path. The orchestrator is `async + &mut EditorState`; the chat path is built around worker threads + blocking iterators (`ChatProvider::send`). Codex architectural review picked Option E (UI-owned actor + RemoteDocSink + ack channel): - Worker thread owns a `RemoteDocSink` that forwards each `apply(cmd)` over an mpsc channel to the UI thread, which `apply()`s on the real `EditorState` and replies with an ack carrying a fresh state snapshot. - `RemoteDocSink::state()` reads from a locally cached mirror updated by each ack — covers `EditorCommand::InsertSubtree`'s ID-remapping + history bookkeeping which must run on the UI thread. - Two mpsc channels per turn: progress deltas (Planning / SubtaskStarted / etc.) into the chat transcript, and `DesignCmdReq` for apply + undo-batch boundaries. - `BeginUndoBatch` / `EndUndoBatch` are forwarded as own `DesignCmdOp` variants so the UI can route them through real history batching once `op-editor-core` exposes that API (currently no-op, matching `DesktopDocSink`). Provider source MVP: `OPENPENCIL_ANTHROPIC_API_KEY` (preferred) or `ANTHROPIC_API_KEY` from env constructs an `AnthropicProvider`. Model override via `OPENPENCIL_ORCHESTRATOR_MODEL` (defaults to claude-sonnet-4-6). When neither key is set, design intent falls back to the chat-CLI path so the user still gets an answer. Wiring: - `main.rs` `current_design: Option<DesignSession>` field next to `current_chat`; mutually exclusive routing in `launch_if_pending`. - `app_handler.rs` `RedrawRequested` pumps both `pump_commands` (apply + ack) and `pump_progress` (deltas + summary); WaitUntil tick schedules a 33 ms wake when either session is in flight. - `keyboard_input.rs` Enter / send sites pass both Option<&mut> args. Provider features: `agent` crate gains the `anthropic` cargo feature in `op-host-desktop/Cargo.toml` so `AnthropicProvider` is reachable (previously `default-features = false` left it gated out — chat path only needed the trait + engine wiring). 3 new `RemoteDocSink` tests cover ack round-trip + closed-channel safety + undo-batch signal distinguishability. Workspace 1882 passed / 0 failed. cargo fmt + clippy --workspace -D warnings clean. The `chat_orchestrator.rs::run_design_request` legacy entry stays for now (used by future single-shot programmatic callers); the live path is `DesignSession::start`. Validation providers stay `Skipped*` until jian-skia `captureRegion` + vision LLM crate land (task #28).
This commit is contained in:
parent
ad555231e4
commit
35312b960b
7 changed files with 868 additions and 23 deletions
285
Cargo.lock
generated
285
Cargo.lock
generated
|
|
@ -38,9 +38,12 @@ name = "agent"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"eventsource-stream",
|
||||
"futures",
|
||||
"lru",
|
||||
"rand 0.8.6",
|
||||
"reqwest 0.13.3",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -113,7 +116,7 @@ dependencies = [
|
|||
"base64",
|
||||
"dirs 6.0.0",
|
||||
"futures",
|
||||
"reqwest",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
|
@ -383,6 +386,28 @@ version = "1.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
|
|
@ -530,7 +555,7 @@ dependencies = [
|
|||
"calloop",
|
||||
"cfg_aliases",
|
||||
"concurrent-queue",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics",
|
||||
"cursor-icon",
|
||||
"dpi 0.1.1",
|
||||
|
|
@ -629,6 +654,15 @@ dependencies = [
|
|||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
|
|
@ -668,6 +702,16 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
|
|
@ -681,7 +725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
|
|
@ -694,7 +738,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
|
|
@ -910,6 +954,12 @@ version = "0.1.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
|
|
@ -922,6 +972,15 @@ version = "1.15.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
|
|
@ -1001,6 +1060,17 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eventsource-stream"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"nom",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
|
|
@ -1049,6 +1119,12 @@ dependencies = [
|
|||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
|
|
@ -1097,6 +1173,12 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
|
|
@ -1379,6 +1461,25 @@ version = "0.14.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be136d9dacc2a13cc70bb6c8f902b414fb2641f8db1314637c6b7933411a8f82"
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.14.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
|
|
@ -1502,6 +1603,7 @@ dependencies = [
|
|||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
|
|
@ -2037,6 +2139,22 @@ dependencies = [
|
|||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
|
|
@ -2638,7 +2756,7 @@ dependencies = [
|
|||
"op-opmerge",
|
||||
"op-orchestrator",
|
||||
"op-pen-loader",
|
||||
"reqwest",
|
||||
"reqwest 0.12.28",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -2737,6 +2855,12 @@ dependencies = [
|
|||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
|
|
@ -2995,6 +3119,7 @@ version = "0.11.14"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
|
|
@ -3264,11 +3389,56 @@ dependencies = [
|
|||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.5.0",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.14.1"
|
||||
|
|
@ -3364,6 +3534,7 @@ version = "0.23.40"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
|
|
@ -3373,6 +3544,18 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.1"
|
||||
|
|
@ -3383,12 +3566,40 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
|
|
@ -3424,6 +3635,15 @@ dependencies = [
|
|||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
|
|
@ -3499,6 +3719,29 @@ dependencies = [
|
|||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
|
|
@ -4300,6 +4543,12 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
|
@ -4536,6 +4785,19 @@ dependencies = [
|
|||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
|
|
@ -4677,6 +4939,15 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
|
|
@ -5012,7 +5283,7 @@ dependencies = [
|
|||
"calloop",
|
||||
"cfg_aliases",
|
||||
"concurrent-queue",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics",
|
||||
"cursor-icon",
|
||||
"dpi 0.1.2",
|
||||
|
|
|
|||
|
|
@ -160,7 +160,9 @@ dirs = "5"
|
|||
# the trait + the engine wiring + `from_provider`; concrete
|
||||
# Provider impls (Anthropic / OpenAI-compat / Ollama) flip on once the
|
||||
# workspace rust-toolchain bumps.
|
||||
agent = { path = "../../vendor/agent/crates/agent", default-features = false }
|
||||
agent = { path = "../../vendor/agent/crates/agent", default-features = false, features = [
|
||||
"anthropic",
|
||||
] }
|
||||
# Forked from bartolli/anthropic-agent-sdk — lives under
|
||||
# `vendor/anthropic-agent-sdk/` so OP owns the source and can diverge
|
||||
# from upstream. Provides the Claude Code CLI subprocess transport +
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
//! `DesktopApp` struct, its helper `impl`, and `fn main`.
|
||||
|
||||
use crate::{
|
||||
chat_attachment, chat_session, cursor_icon, frame, git_jobs, menu, persistence, settings_io,
|
||||
window_state, DesktopApp, INITIAL_VIEWPORT_H, INITIAL_VIEWPORT_W,
|
||||
chat_attachment, chat_session, cursor_icon, design_session, frame, git_jobs, menu, persistence,
|
||||
settings_io, window_state, DesktopApp, INITIAL_VIEWPORT_H, INITIAL_VIEWPORT_W,
|
||||
};
|
||||
use op_host_native::{NativeBackend, SharedSkiaContext};
|
||||
use std::time::{Duration, Instant};
|
||||
|
|
@ -344,6 +344,16 @@ impl ApplicationHandler for DesktopApp {
|
|||
if chat_session::pump(&mut self.host, &mut self.current_chat) {
|
||||
self.redraw_dirty = true;
|
||||
}
|
||||
// Drain orchestrator apply requests + progress events
|
||||
// for any in-flight design turn (orchestrator runs off
|
||||
// the UI thread; `RemoteDocSink` forwards mutations
|
||||
// here each frame).
|
||||
if design_session::pump_commands(&mut self.host, &mut self.current_design) {
|
||||
self.redraw_dirty = true;
|
||||
}
|
||||
if design_session::pump_progress(&mut self.host, &mut self.current_design) {
|
||||
self.redraw_dirty = true;
|
||||
}
|
||||
// Drain background model discovery once it lands.
|
||||
if self.model_probe.poll_into(&mut self.host) {
|
||||
self.redraw_dirty = true;
|
||||
|
|
@ -406,8 +416,9 @@ impl ApplicationHandler for DesktopApp {
|
|||
);
|
||||
}
|
||||
}
|
||||
// Chat turn streaming → wake ~30 fps to pump deltas.
|
||||
if self.current_chat.is_some() {
|
||||
// Chat or design turn streaming → wake ~30 fps to pump
|
||||
// deltas / orchestrator apply requests.
|
||||
if self.current_chat.is_some() || self.current_design.is_some() {
|
||||
event_loop.set_control_flow(ControlFlow::WaitUntil(
|
||||
Instant::now() + Duration::from_millis(33),
|
||||
));
|
||||
|
|
@ -559,7 +570,11 @@ impl ApplicationHandler for DesktopApp {
|
|||
);
|
||||
// A click on the chat Send button raises
|
||||
// `pending_send` — launch the provider turn.
|
||||
if chat_session::launch_if_pending(&mut self.host, &mut self.current_chat) {
|
||||
if chat_session::launch_if_pending(
|
||||
&mut self.host,
|
||||
&mut self.current_chat,
|
||||
&mut self.current_design,
|
||||
) {
|
||||
self.request_redraw(true);
|
||||
}
|
||||
// A click on the attach button raises
|
||||
|
|
|
|||
|
|
@ -8,15 +8,19 @@
|
|||
//! message.
|
||||
|
||||
use std::sync::mpsc::{self, Receiver, TryRecvError};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
use agent::provider::Provider;
|
||||
use op_ai::chat_provider::{ChatDelta, ChatProvider, ChatRequest, CliName};
|
||||
use op_editor_core::{ChatMessage, ChatToolCall};
|
||||
use op_host_native::WidgetHostNative;
|
||||
use op_orchestrator::{classify_intent, DesignRequest, Intent};
|
||||
|
||||
use crate::chat_claude::ClaudeCodeProvider;
|
||||
use crate::chat_copilot::CopilotProvider;
|
||||
use crate::chat_subprocess::SubprocessProvider;
|
||||
use crate::design_session::DesignSession;
|
||||
|
||||
/// One in-flight chat turn. The worker thread owns the provider and
|
||||
/// drains `provider.send()` into the channel; [`poll`] consumes
|
||||
|
|
@ -142,17 +146,55 @@ impl ChatSession {
|
|||
}
|
||||
}
|
||||
|
||||
/// Drain `chat.pending_send` (raised by `ChatState::begin_send`)
|
||||
/// into a fresh `ChatSession` against the Claude Code CLI.
|
||||
/// `ClaudeCodeProvider` auto-discovers the `claude` binary. A send
|
||||
/// fired mid-turn replaces the in-flight session — the old worker
|
||||
/// thread drains harmlessly once its channel receiver drops.
|
||||
/// Returns true when a turn was launched (caller redraws).
|
||||
pub fn launch_if_pending(host: &mut WidgetHostNative, current: &mut Option<ChatSession>) -> bool {
|
||||
/// Drain `chat.pending_send` (raised by `ChatState::begin_send`) and
|
||||
/// route it through the orchestrator intent gate. `Intent::Design`
|
||||
/// requests with a configured `agent::Provider` launch into
|
||||
/// `current_design`; everything else (chat intent, or design intent
|
||||
/// with no Provider available) falls through to the existing
|
||||
/// `ChatProvider` path in `current_chat`. A send fired mid-turn
|
||||
/// replaces the in-flight session — the old worker thread drains
|
||||
/// harmlessly once its channel receiver drops.
|
||||
///
|
||||
/// Returns true when *any* turn was launched (caller redraws).
|
||||
pub fn launch_if_pending(
|
||||
host: &mut WidgetHostNative,
|
||||
current_chat: &mut Option<ChatSession>,
|
||||
current_design: &mut Option<DesignSession>,
|
||||
) -> bool {
|
||||
let Some(user_text) = host.editor_state_mut().chat.pending_send.take() else {
|
||||
return false;
|
||||
};
|
||||
host.mark_editor_state_dirty();
|
||||
// Intent gate (op_orchestrator::classify_intent) — design verbs
|
||||
// route to the orchestrator pipeline when a Provider is available;
|
||||
// otherwise we fall through to the existing chat path so the user
|
||||
// still gets an answer.
|
||||
if matches!(classify_intent(&user_text), Intent::Design) {
|
||||
if let Some((provider, model)) = provider_for_design() {
|
||||
*current_chat = None;
|
||||
let initial_state = host.editor_state().clone();
|
||||
let request = DesignRequest {
|
||||
prompt: user_text,
|
||||
model: Some(model.clone()),
|
||||
provider: None,
|
||||
design_md: initial_state.doc.design_md.clone(),
|
||||
append_context: None,
|
||||
concurrency: 1,
|
||||
validation_enabled: false,
|
||||
visual_ref_enabled: false,
|
||||
};
|
||||
*current_design = Some(DesignSession::start(
|
||||
provider,
|
||||
model,
|
||||
request,
|
||||
initial_state,
|
||||
));
|
||||
return true;
|
||||
}
|
||||
// Design intent but no Provider configured — fall through to
|
||||
// the chat path. The assistant CLI will still answer the user
|
||||
// (most CLIs handle design verbs as chat).
|
||||
}
|
||||
let agent_idx = host.editor_state().editor_ui.chat_selected_agent;
|
||||
let Some(provider) = provider_for_agent(agent_idx) else {
|
||||
// Selected agent has no `ChatProvider` bridge yet (Codex /
|
||||
|
|
@ -165,7 +207,7 @@ pub fn launch_if_pending(host: &mut WidgetHostNative, current: &mut Option<ChatS
|
|||
// `pump` keeps streaming the previous agent's deltas into
|
||||
// this fresh error bubble (codex stop-gate: stale session
|
||||
// overwrote the unwired-agent error text).
|
||||
*current = None;
|
||||
*current_chat = None;
|
||||
let name = op_editor_core::AgentProvider::ALL
|
||||
.get(agent_idx)
|
||||
.map(|a| a.name())
|
||||
|
|
@ -206,10 +248,30 @@ pub fn launch_if_pending(host: &mut WidgetHostNative, current: &mut Option<ChatS
|
|||
effort,
|
||||
attachments,
|
||||
};
|
||||
*current = Some(ChatSession::start(provider, req));
|
||||
*current_chat = Some(ChatSession::start(provider, req));
|
||||
true
|
||||
}
|
||||
|
||||
/// Build an `agent::Provider` for the orchestrator's design path.
|
||||
/// MVP: reads `OPENPENCIL_ANTHROPIC_API_KEY` (preferred) or
|
||||
/// `ANTHROPIC_API_KEY` from the environment and constructs an
|
||||
/// `AnthropicProvider`. Returns `None` when neither key is set so the
|
||||
/// caller can fall back to the chat-CLI path honestly.
|
||||
///
|
||||
/// `OPENPENCIL_*` is checked first so users can isolate the
|
||||
/// orchestrator's API key from any `ANTHROPIC_API_KEY` other tooling
|
||||
/// might consume (e.g. `claude` CLI, the Anthropic SDK examples).
|
||||
fn provider_for_design() -> Option<(Arc<dyn Provider>, String)> {
|
||||
let api_key = std::env::var("OPENPENCIL_ANTHROPIC_API_KEY")
|
||||
.ok()
|
||||
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
|
||||
.filter(|k| !k.is_empty())?;
|
||||
let model = std::env::var("OPENPENCIL_ORCHESTRATOR_MODEL")
|
||||
.unwrap_or_else(|_| "claude-sonnet-4-6".to_string());
|
||||
let provider = agent::provider::anthropic::AnthropicProvider::new(api_key);
|
||||
Some((Arc::new(provider) as Arc<dyn Provider>, model))
|
||||
}
|
||||
|
||||
/// Build the `ChatProvider` for an agent index (into
|
||||
/// `AgentProvider::ALL`: 0 ClaudeCode, 1 CodexCli, 2 OpenCode,
|
||||
/// 3 GithubCopilot, 4 GeminiCli). Claude Code uses its dedicated
|
||||
|
|
|
|||
483
crates/op-host-desktop/src/design_session.rs
Normal file
483
crates/op-host-desktop/src/design_session.rs
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
//! Background design-turn runner — orchestrator counterpart of [`ChatSession`](crate::chat_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:
|
||||
//!
|
||||
//! - **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.
|
||||
|
||||
use std::sync::mpsc::{self, Receiver, Sender, SyncSender, TryRecvError};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
use agent::provider::Provider;
|
||||
use op_editor_core::{EditorCommand, EditorState};
|
||||
use op_host_native::WidgetHostNative;
|
||||
use op_orchestrator::{
|
||||
AbortFlag, DesignRequest, DocSink, Orchestrator, OrchestratorError, Progress, RunSummary,
|
||||
SkippedScreenshotProvider, SkippedVisionLlmClient, ValidationProviders,
|
||||
};
|
||||
|
||||
use crate::chat_orchestrator::DesktopLlmClient;
|
||||
use crate::chat_runtime::shared_runtime;
|
||||
use crate::pre_validator::LintPreValidator;
|
||||
|
||||
/// One in-flight design turn.
|
||||
pub struct DesignSession {
|
||||
delta_rx: Receiver<DesignDelta>,
|
||||
cmd_rx: Receiver<DesignCmdReq>,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
/// Progress / completion events emitted by the worker.
|
||||
pub enum DesignDelta {
|
||||
/// One `op_orchestrator::Progress` event.
|
||||
Progress(Progress),
|
||||
/// Terminal event — the orchestrator returned. The session is
|
||||
/// finished once this arrives.
|
||||
Done(Result<RunSummary, OrchestratorError>),
|
||||
}
|
||||
|
||||
/// Request from worker to UI to apply one editor mutation (or undo-batch
|
||||
/// boundary). The worker blocks until the matching ack arrives.
|
||||
pub struct DesignCmdReq {
|
||||
pub op: DesignCmdOp,
|
||||
pub ack: SyncSender<DesignCmdAck>,
|
||||
}
|
||||
|
||||
/// What the worker is asking the UI to do.
|
||||
pub enum DesignCmdOp {
|
||||
Apply(EditorCommand),
|
||||
BeginUndoBatch,
|
||||
EndUndoBatch,
|
||||
}
|
||||
|
||||
/// UI's reply to one [`DesignCmdReq`]. Carries an `EditorState` clone
|
||||
/// so the worker's mirror reflects ID-remapped state.
|
||||
pub struct DesignCmdAck {
|
||||
pub applied: bool,
|
||||
pub new_state: EditorState,
|
||||
}
|
||||
|
||||
/// Result of one non-blocking progress drain.
|
||||
pub struct DesignPoll {
|
||||
pub progress: Vec<Progress>,
|
||||
/// Terminal summary when the turn ended; `None` while running.
|
||||
pub summary: Option<Result<RunSummary, OrchestratorError>>,
|
||||
pub finished: bool,
|
||||
}
|
||||
|
||||
impl DesignPoll {
|
||||
#[cfg(test)]
|
||||
fn is_idle(&self) -> bool {
|
||||
self.progress.is_empty() && self.summary.is_none() && !self.finished
|
||||
}
|
||||
}
|
||||
|
||||
impl DesignSession {
|
||||
/// Spawn a worker that runs `Orchestrator::run` against a
|
||||
/// `RemoteDocSink`. Returns immediately; the LLM turn streams off
|
||||
/// the UI thread.
|
||||
pub fn start(
|
||||
provider: Arc<dyn Provider>,
|
||||
default_model: String,
|
||||
request: DesignRequest,
|
||||
initial_state: EditorState,
|
||||
) -> Self {
|
||||
let (delta_tx, delta_rx) = mpsc::channel::<DesignDelta>();
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel::<DesignCmdReq>();
|
||||
|
||||
thread::Builder::new()
|
||||
.name("op-design-turn".into())
|
||||
.spawn(move || {
|
||||
let llm = DesktopLlmClient::new(provider, default_model);
|
||||
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().run(
|
||||
request,
|
||||
&mut sink,
|
||||
&llm,
|
||||
&mut on_progress,
|
||||
&abort,
|
||||
&providers,
|
||||
));
|
||||
let _ = delta_tx.send(DesignDelta::Done(summary));
|
||||
})
|
||||
.expect("spawn op-design-turn thread");
|
||||
|
||||
Self {
|
||||
delta_rx,
|
||||
cmd_rx,
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain every progress delta ready right now. Non-blocking.
|
||||
pub fn poll_progress(&mut self) -> DesignPoll {
|
||||
let mut progress = Vec::new();
|
||||
let mut summary = None;
|
||||
loop {
|
||||
match self.delta_rx.try_recv() {
|
||||
Ok(DesignDelta::Progress(p)) => progress.push(p),
|
||||
Ok(DesignDelta::Done(r)) => {
|
||||
self.finished = true;
|
||||
summary = Some(r);
|
||||
break;
|
||||
}
|
||||
Err(TryRecvError::Empty) => break,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
self.finished = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
DesignPoll {
|
||||
progress,
|
||||
summary,
|
||||
finished: self.finished,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain every pending apply request. Returns the requests; the
|
||||
/// caller must ack each one or the worker will hang on `recv`.
|
||||
pub fn drain_cmd_requests(&mut self) -> Vec<DesignCmdReq> {
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
match self.cmd_rx.try_recv() {
|
||||
Ok(req) => out.push(req),
|
||||
Err(TryRecvError::Empty) => break,
|
||||
Err(TryRecvError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Test-only accessor.
|
||||
#[cfg(test)]
|
||||
pub fn finished(&self) -> bool {
|
||||
self.finished
|
||||
}
|
||||
}
|
||||
|
||||
/// Worker-side `DocSink` impl — forwards every mutation to the UI
|
||||
/// thread over an mpsc channel and blocks on the ack. State reads
|
||||
/// come from a locally cached mirror updated by each ack.
|
||||
pub struct RemoteDocSink {
|
||||
cmd_tx: Sender<DesignCmdReq>,
|
||||
mirror: EditorState,
|
||||
}
|
||||
|
||||
impl RemoteDocSink {
|
||||
pub fn new(cmd_tx: Sender<DesignCmdReq>, initial_state: EditorState) -> Self {
|
||||
Self {
|
||||
cmd_tx,
|
||||
mirror: initial_state,
|
||||
}
|
||||
}
|
||||
|
||||
fn send_and_wait(&mut self, op: DesignCmdOp) -> bool {
|
||||
let (ack_tx, ack_rx) = mpsc::sync_channel::<DesignCmdAck>(1);
|
||||
let req = DesignCmdReq { op, ack: ack_tx };
|
||||
if self.cmd_tx.send(req).is_err() {
|
||||
return false; // UI dropped the receiver — turn aborted
|
||||
}
|
||||
match ack_rx.recv() {
|
||||
Ok(ack) => {
|
||||
self.mirror = ack.new_state;
|
||||
ack.applied
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DocSink for RemoteDocSink {
|
||||
fn state(&self) -> &EditorState {
|
||||
&self.mirror
|
||||
}
|
||||
|
||||
fn apply(&mut self, cmd: EditorCommand) -> bool {
|
||||
self.send_and_wait(DesignCmdOp::Apply(cmd))
|
||||
}
|
||||
|
||||
fn begin_undo_batch(&mut self) {
|
||||
let _ = self.send_and_wait(DesignCmdOp::BeginUndoBatch);
|
||||
}
|
||||
|
||||
fn end_undo_batch(&mut self) {
|
||||
let _ = self.send_and_wait(DesignCmdOp::EndUndoBatch);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain every pending apply request from the in-flight design
|
||||
/// session and execute it against the real `EditorState`. Each
|
||||
/// request gets an ack containing a fresh state snapshot so the
|
||||
/// worker's mirror reflects ID-remapping. Returns true when at least
|
||||
/// one command applied (caller should mark redraw dirty).
|
||||
pub fn pump_commands(host: &mut WidgetHostNative, current: &mut Option<DesignSession>) -> bool {
|
||||
let Some(session) = current.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let reqs = session.drain_cmd_requests();
|
||||
if reqs.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let state = host.editor_state_mut();
|
||||
let mut any_applied = false;
|
||||
for req in reqs {
|
||||
let applied = match req.op {
|
||||
DesignCmdOp::Apply(cmd) => state.apply(cmd),
|
||||
// TODO(host): wire into op-editor-core history batch mode once
|
||||
// available. Today undo-batch boundaries are no-ops, matching
|
||||
// `DesktopDocSink`.
|
||||
DesignCmdOp::BeginUndoBatch | DesignCmdOp::EndUndoBatch => true,
|
||||
};
|
||||
let snapshot = state.clone();
|
||||
let ack = DesignCmdAck {
|
||||
applied,
|
||||
new_state: snapshot,
|
||||
};
|
||||
// If the ack fails to send, the worker already dropped its
|
||||
// receiver (e.g. turn aborted) — nothing to do here.
|
||||
let _ = req.ack.send(ack);
|
||||
if applied {
|
||||
any_applied = true;
|
||||
}
|
||||
}
|
||||
if any_applied {
|
||||
host.mark_editor_state_dirty();
|
||||
}
|
||||
any_applied
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn pump_progress(host: &mut WidgetHostNative, current: &mut Option<DesignSession>) -> bool {
|
||||
let Some(session) = current.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let poll = session.poll_progress();
|
||||
let mut changed = false;
|
||||
if !poll.progress.is_empty() {
|
||||
let appended = render_progress(&poll.progress);
|
||||
let chat = &mut host.editor_state_mut().chat;
|
||||
if let Some(msg) = chat.messages.last_mut() {
|
||||
msg.content.push_str(&appended);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if let Some(summary) = &poll.summary {
|
||||
let chat = &mut host.editor_state_mut().chat;
|
||||
if let Some(msg) = chat.messages.last_mut() {
|
||||
match summary {
|
||||
Ok(s) => {
|
||||
let ok = s.subtasks.iter().filter(|o| o.error.is_none()).count();
|
||||
let failed = s.subtasks.len() - ok;
|
||||
msg.content.push_str(&format!(
|
||||
"\n\nDone — {} subtask(s) succeeded, {} failed, {} node(s) total.",
|
||||
ok, failed, s.total_nodes,
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
msg.content = format!("error: {e}");
|
||||
}
|
||||
}
|
||||
msg.streaming = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
host.mark_editor_state_dirty();
|
||||
}
|
||||
if poll.finished {
|
||||
*current = None;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Render a list of `Progress` events into a human-readable line block
|
||||
/// the chat transcript can append. Matches the spirit of TS
|
||||
/// `apps/web/src/services/ai/visual-ref-orchestrator.ts` step labels.
|
||||
fn render_progress(progress: &[Progress]) -> String {
|
||||
let mut out = String::new();
|
||||
for p in progress {
|
||||
out.push('\n');
|
||||
out.push_str(&progress_label(p));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn progress_label(p: &Progress) -> String {
|
||||
match p {
|
||||
Progress::Planning => "• Planning…".into(),
|
||||
Progress::ScaffoldDone => "• Scaffold ready".into(),
|
||||
Progress::SubtaskStarted { id, label } => format!("• Subtask `{id}` — {label}"),
|
||||
Progress::SubtaskDone { id, node_count } => {
|
||||
format!("• Subtask `{id}` done ({node_count} nodes)")
|
||||
}
|
||||
Progress::SubtaskFailed { id, error } => format!("• Subtask `{id}` failed: {error}"),
|
||||
Progress::CleanupDone => "• Cleanup done".into(),
|
||||
Progress::ValidationStarted => "• Validation started".into(),
|
||||
Progress::ValidationPreCheckDone { applied, .. } => {
|
||||
format!("• Pre-validation applied {applied} fix(es)")
|
||||
}
|
||||
Progress::ValidationRoundStarted { round } => {
|
||||
format!("• Vision round {round} started")
|
||||
}
|
||||
Progress::ValidationRoundDone {
|
||||
round,
|
||||
applied,
|
||||
quality_score,
|
||||
} => {
|
||||
format!("• Vision round {round} done — {applied} fix(es), quality {quality_score}/100")
|
||||
}
|
||||
Progress::ValidationDone { total_applied } => {
|
||||
format!("• Validation done — {total_applied} fix(es) total")
|
||||
}
|
||||
Progress::VisualRefStarted => "• Visual-ref pipeline started".into(),
|
||||
Progress::VisualRefDesignSystem { var_count } => {
|
||||
format!("• Design system ready — {var_count} variable(s) seeded")
|
||||
}
|
||||
Progress::VisualRefHtmlGenerated { byte_len } => {
|
||||
format!("• Visual-ref HTML generated ({byte_len} bytes)")
|
||||
}
|
||||
Progress::VisualRefScreenshotReady { skipped } => {
|
||||
if *skipped {
|
||||
"• Visual-ref screenshot skipped".into()
|
||||
} else {
|
||||
"• Visual-ref screenshot captured".into()
|
||||
}
|
||||
}
|
||||
Progress::VisualRefFallback { reason } => format!("• Visual-ref fallback: {reason}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use op_editor_core::EditorCommand;
|
||||
|
||||
/// `RemoteDocSink::apply` blocks until UI acks. When the UI side
|
||||
/// drops the receiver, `apply` returns false instead of hanging.
|
||||
#[test]
|
||||
fn remote_doc_sink_returns_false_when_ui_channel_closed() {
|
||||
let (tx, rx) = mpsc::channel::<DesignCmdReq>();
|
||||
let mut sink = RemoteDocSink::new(tx, EditorState::new());
|
||||
drop(rx); // simulate UI session dropped before the worker called apply
|
||||
let applied = sink.apply(EditorCommand::ClearSelection);
|
||||
assert!(!applied, "apply on closed channel must return false");
|
||||
}
|
||||
|
||||
/// Happy-path round-trip: worker sends an apply request; UI thread
|
||||
/// acks with an updated state snapshot; worker's mirror reflects it.
|
||||
#[test]
|
||||
fn remote_doc_sink_updates_mirror_on_ack() {
|
||||
let (tx, rx) = mpsc::channel::<DesignCmdReq>();
|
||||
let initial = EditorState::new();
|
||||
let mut sink = RemoteDocSink::new(tx, initial.clone());
|
||||
|
||||
// Spawn UI-side faker that acks one request with a modified state.
|
||||
let ui_thread = thread::spawn(move || {
|
||||
let req = rx.recv().expect("worker should send one request");
|
||||
let mut new_state = initial.clone();
|
||||
// Mutate something the test can observe — viewport zoom.
|
||||
new_state.viewport.zoom = 2.0;
|
||||
let ack = DesignCmdAck {
|
||||
applied: true,
|
||||
new_state,
|
||||
};
|
||||
req.ack.send(ack).expect("ack must reach worker");
|
||||
});
|
||||
|
||||
let applied = sink.apply(EditorCommand::ClearSelection);
|
||||
ui_thread.join().expect("ui thread must finish");
|
||||
assert!(applied, "ack reported applied=true");
|
||||
assert_eq!(
|
||||
sink.state().viewport.zoom,
|
||||
2.0,
|
||||
"mirror should reflect ack snapshot"
|
||||
);
|
||||
}
|
||||
|
||||
/// `BeginUndoBatch` and `EndUndoBatch` are forwarded as their own
|
||||
/// `DesignCmdOp` variants so the UI can route them through the
|
||||
/// real `History::begin_batch` / `end_batch` once wired.
|
||||
#[test]
|
||||
fn undo_batch_signals_are_distinguishable_on_the_wire() {
|
||||
let (tx, rx) = mpsc::channel::<DesignCmdReq>();
|
||||
let mut sink = RemoteDocSink::new(tx, EditorState::new());
|
||||
let ui = thread::spawn(move || {
|
||||
let mut kinds = Vec::new();
|
||||
while let Ok(req) = rx.recv() {
|
||||
let label = match req.op {
|
||||
DesignCmdOp::Apply(_) => "apply",
|
||||
DesignCmdOp::BeginUndoBatch => "begin",
|
||||
DesignCmdOp::EndUndoBatch => "end",
|
||||
};
|
||||
kinds.push(label.to_string());
|
||||
let _ = req.ack.send(DesignCmdAck {
|
||||
applied: true,
|
||||
new_state: EditorState::new(),
|
||||
});
|
||||
}
|
||||
kinds
|
||||
});
|
||||
sink.begin_undo_batch();
|
||||
sink.apply(EditorCommand::ClearSelection);
|
||||
sink.end_undo_batch();
|
||||
drop(sink); // close the channel so the ui-side recv loop exits
|
||||
let kinds = ui.join().expect("ui thread finishes");
|
||||
assert_eq!(kinds, vec!["begin", "apply", "end"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -33,7 +33,11 @@ impl DesktopApp {
|
|||
Key::Named(NamedKey::Enter) if !self.zoom_modifier => {
|
||||
consumed = self.host.apply_send();
|
||||
// apply_send may raise pending_send (chat send).
|
||||
if chat_session::launch_if_pending(&mut self.host, &mut self.current_chat) {
|
||||
if chat_session::launch_if_pending(
|
||||
&mut self.host,
|
||||
&mut self.current_chat,
|
||||
&mut self.current_design,
|
||||
) {
|
||||
self.request_redraw(true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ mod chat_subprocess;
|
|||
mod clipboard;
|
||||
mod cursor_icon;
|
||||
mod design_md_host;
|
||||
mod design_session;
|
||||
mod export;
|
||||
mod export_pdf;
|
||||
mod frame;
|
||||
|
|
@ -84,6 +85,12 @@ struct DesktopApp {
|
|||
/// `chat.pending_send`; the event loop drains that into a
|
||||
/// `ChatSession` here and pumps deltas into the transcript.
|
||||
current_chat: Option<chat_session::ChatSession>,
|
||||
/// In-flight design-orchestrator turn, if any. Mutually exclusive
|
||||
/// with `current_chat`: `chat_session::launch_if_pending` classifies
|
||||
/// the user's message via `op_orchestrator::classify_intent` and
|
||||
/// routes `Intent::Design` here (when an `agent::Provider` is
|
||||
/// available), `Intent::Chat` to `current_chat`.
|
||||
current_design: Option<design_session::DesignSession>,
|
||||
/// Background AI-model discovery — probes the installed CLIs
|
||||
/// on a worker thread; its result is drained into
|
||||
/// `chat.available_models` on a later frame.
|
||||
|
|
@ -169,6 +176,7 @@ impl DesktopApp {
|
|||
current_path: None,
|
||||
error: None,
|
||||
current_chat: None,
|
||||
current_design: None,
|
||||
model_probe: model_discovery::ModelProbe::spawn(),
|
||||
initial_file,
|
||||
app_menu: None,
|
||||
|
|
|
|||
Loading…
Reference in a new issue