Phase 6 strangler scaffolding: WidgetHostNative now holds an
op_editor_core::EditorState alongside the legacy shell-core Document
so the ~30 widgets can migrate onto the canonical model one group at
a time while the workspace stays build-green.
The bridge is intentionally minimal — Document remains the source of
truth (every un-migrated widget paints from it and every apply_*
mutates it), and editor_state is not yet wired into paint or input.
There is no Document -> PenDocument converter, and the desktop
pen_doc_adapter only goes the other way (baking flex layout into AABB
rects, which is irreversible), so a per-frame round-trip would be
unsound. Each later 6.x task adds its own per-group sync point as it
switches a widget group's paint/input onto editor_state.
Field, accessors and dependency are all marked TEMPORARY — deleted in
Phase 7 when Document dies and editor_state becomes the host's only
state.
FigmaClipboardNode::to_node now produces canonical
jian_ops_schema::PenNode instead of shell-core's flat Node. Maps the
8 common Figma kinds onto Frame/Group/Rectangle/Ellipse/Line/Polygon/
Path/Text variants; unmapped kinds fall back to Frame with the
original kind preserved in base.role since PenNode has no Other
escape hatch. Tests ported to assert on PenNode variants + base
accessors. The import stays in shell-core (Option B, in-place port).
Port the in-process MCP server off shell-core's legacy `Document` /
`McpCommand` onto `op_editor_core::EditorState` / `EditorCommand`. The
module physically stays inside `openpencil-shell-core` (an `op-mcp`
crate extraction is a later Phase-7 task).
- Read tools snapshot `EditorState` (canonical `PenDocument`); write
tools emit `op_editor_core::EditorCommand` applied via
`EditorState::apply`. Node ids are now canonical `.op` schema
strings, not the old `u64`.
- Component commands surface a clean `ToolFailed` "known gap" error —
`op-editor-core` has no component registry yet. Same for
`set_node_collapsed` (`NodeFlag::Collapsed` has no schema field).
- `mcp_serve.rs` loads the `.op` file straight into an `EditorState`
(plain `jian-ops-schema` deserialization) and saves the
`PenDocument` back on every successful write.
- Delete the orphaned legacy apply path (`document/mcp_apply*.rs` +
`Document`/`VariableTable::apply_mcp_command`); the widgets that use
`Document` are untouched.
- Split `tools.rs` → `tools.rs` + `read_tools.rs` and
`component_tools.rs` → `component_tools.rs` + `page_tools.rs` to
hold the 800-line cap.
Codex review of Task 1.2 found four dangling references to the deleted
stub crates (pen-types/core/engine/codegen/figma + openpencil-app):
- package.json: drop the five -p <crate> args from cargo:wasm-check
- rust-release.yml: restore the build job, re-pointed at the real
openpencil-desktop crate so release-draft has artifacts to publish
- check-jian-boundaries.sh: 4 -> 3 invariants in the success message
- README.md: remove the deleted crate rows from the crate-list table
Replace the in-workspace copilot-sdk fork with the official
`github-copilot-sdk` crate (crates.io 0.1). Rewrite chat_copilot.rs
against its API: Client::start + a streaming SessionConfig whose
SessionHandler forwards `assistant.message_delta` / `session.error`
events into the ChatProvider channel, one client+session per turn.
Delete the crates/copilot-sdk fork directory. Build + 72 desktop
tests pass on the Rust 1.94 toolchain.
The official github-copilot-sdk requires rust-version 1.94 +
edition 2024; the workspace was pinned to 1.85. Bump the toolchain
so the SDK can be adopted for the Copilot connection. Verified:
full native build, wasm32 web check, and shell-core tests all pass
on 1.94.1 with no new errors.
The property panel's 效果 header drew a "+" icon that did nothing.
Add a PropertyPanelAction::AddEffect, emit its hit rect over the
icon in the layout walker (no paint/layout change — the icon was
already painted), and dispatch it in both hosts to
Document::add_drop_shadow_to_selected. Clicking "+" now appends a
drop shadow that renders + persists.
Append a default drop-shadow effect (CSS-style 0 4px 8px
rgba(0,0,0,0.25)) to the selected node — the building block the
property panel's 效果 "+" action will call. Guarded on a real,
editable selection like the sibling set_selected_* mutators.
Mirror the native fill_drop_shadow override on the wasm web
backend with skia MaskFilter::blur so drop-shadow effects paint as
a real soft shadow on both native and web — keeping the two
shells visually consistent.
The fill_drop_shadow RenderBackend primitive only had the default
un-blurred approximation. Override it on the native backend with
skia's MaskFilter::blur (CSS blur-radius → sigma = radius/2) so
drop-shadow effects paint as a real soft shadow on desktop.
The Copilot / Codex response parsers ran serde on the whole stdout
line, so a `Content-Length:` header prefix or a glued next-frame
header suffix would fail the parse. Add brace-counted (string- and
escape-aware) JSON-object extraction so discovery works whether the
CLI replies newline-delimited or Content-Length-framed.
The Copilot discovery wrote newline-delimited requests to
`copilot --stdio`; the official copilot-sdk frames requests with
`Content-Length` headers. The CLI happens to accept both, but
relying on that leniency is fragile. Write proper LSP-style
Content-Length frames so we speak the protocol the SDK defines.
Verified live against the installed `copilot` CLI.
Copilot discovery now drives `copilot --stdio` over JSON-RPC
(connect → models.list) — the same wire protocol (version 3) the
official github-copilot-sdk uses. Speaking the protocol directly
rather than linking the SDK crate keeps the workspace on Rust 1.85
(the SDK crate requires 1.94). Documented model names stay as the
fallback when the CLI is installed but the query doesn't answer.
Codex discovery now drives `codex app-server` over JSON-RPC stdio
(initialize → initialized → model/list), the official App Server
API — more accurate and stable than reading models_cache.json,
which may be stale or absent. The cache stays as a fallback, then
a minimal placeholder when codex is installed but neither source
answers. A reader thread + deadline keeps a hung server from
blocking discovery.
Replace the chat panel's agent-cycling chip with a real model
picker: the chip shows the selected model's provider brand logo +
display name, and clicking it opens an upward dropdown listing
chat.available_models grouped by provider with a selected check
(mirrors the TS ai-chat-model-selector). Selecting a model also
re-syncs chat_selected_agent to its provider so the chat transport
targets the matching CLI. Outside clicks dismiss the picker.
The chat panel never had a model list — shell-core is transport-free
and cannot query a CLI. Add desktop-side cross-platform discovery:
Codex's models_cache.json (real, file-based), `opencode models`
(real, subprocess), and the documented CLI model names for Claude /
Gemini / Copilot which expose no listing interface — each gated on
the CLI actually being installed (PATH walk with platform exe
extensions). Discovery runs on a worker thread and drains into
chat.available_models. shell-core keeps only the ModelEntry type.
Move the per-frame paint pass to frame.rs to keep main.rs under cap.
Node.effects already round-trips through persistence and canonical
import, but the canvas never rendered it. Add a fill_drop_shadow
RenderBackend primitive (default impl approximates with a translucent
fill; native + web will override with skia MaskFilter blur) and paint
each DropShadow behind Frame / Rect / Ellipse fills, offset + blur
scaled by viewport zoom.
Canonical .op shadow colours use functional rgba()/rgb() notation, which
the hex-only parser rejected — valid shadows were imported as opaque
black, then dropped entirely after the prior fix. Add parse_css_color
covering both hex and functional forms. Move the effect-conversion
helpers into persistence_effects.rs so pen_doc_adapter.rs stays under
the 800-line cap.
`shadows_from_canonical` fell back to `[0,0,0,1.0]` (opaque black)
when `parse_hex` rejected a shadow colour — so an `rgba()`,
named, or short-hex colour imported as a wrong solid-black
shadow (codex stop-gate). Switch the fallback to `filter_map`
`None`: an unparseable colour drops that shadow entirely, which
is honest (no shadow) rather than misleading (wrong shadow).
Matches `first_solid_color`'s existing `parse_hex(...)?` discipline.
`pen_doc_adapter::node_to_payload` dropped every `PenEffect` —
a `.op` authored with shadows lost them on import (codex stop-gate;
the inverse of the save-path effects round-trip just added).
`shadows_from_canonical` reads each variant's effects (Frame /
Group / Rectangle carry them on `container`, leaf shapes directly;
IconFont / Ref have none) and maps `PenEffect::Shadow` →
`ShadowPayload`. Blur / background-blur are skipped — the shell's
`Effect` model is drop-shadow-only today.
First slice of the Effects gap — the property panel's 效果 section
was a header-only stub with no data behind it.
- `Effect` / `DropShadow` types in `document.rs`; `Node.effects:
Vec<Effect>` (offset_x/y + blur + color, doc-px). Both `Node`
builders + `deep_clone_with_new_ids` carry it.
- `persistence_effects.rs` — `ShadowPayload` + `effects_to_payload`
/ `effects_from_payload`; `NodePayload.effects` is `#[serde(default)]`
so legacy `.op` files still load. Carved into its own module so
`persistence.rs` stays under the 800-line cap.
- Round-trip tested through the real serde JSON path (2 tests).
858 workspace tests pass. Still pending (own slices): canvas
drop-shadow paint (needs a blur primitive on `RenderBackend`),
the property-panel 效果 editing rows, and a `set_node_shadow`
MCP tool. Shadows currently round-trip through save/load but do
not yet render.
- crates/CLAUDE.md MCP catalog was stale at 21 tools; replace the
outdated table with the current 77-tool categorized summary and
point at mcp_serve.rs TOOL_SCHEMAS as the source of truth.
- Refresh the mcp/ file-layout list (node_attr_tools,
selected_ops_tools, extra_read_tools, json_serializer, …).
- New "AI chat (real provider integration)" section documenting
begin_send / ChatProvider / chat_session / the model chip and
the Codex/OpenCode honest-error behavior.
- chat_session: gate the test-only `finished()` accessor behind
`#[cfg(test)]` to drop a dead-code warning.
When a send for an unwired agent (Codex / OpenCode) landed while a
previous turn was still streaming, `launch_if_pending` wrote the
error bubble but left `current` pointing at the old session — so
the next `pump` streamed the prior agent's deltas straight into
the fresh error message. Clear `current` before writing the error.
`provider_for_agent` silently substituted Claude Code when the
user picked Codex or OpenCode — so the transcript looked like the
chosen CLI answered when it hadn't. Codex stop-gate.
It now returns `Option`: `None` for Codex / OpenCode (HTTP-server
transport not yet bridged). `launch_if_pending` writes an explicit
`error: … not wired yet` into the assistant bubble and starts no
session, so the user knows their pick didn't run.
The chat panel's bottom-toolbar chip was a hardcoded "Default"
label. It now shows the selected CLI agent and cycles through the
connected ones on click.
- `UiState.chat_selected_agent` — index into `AgentProvider::ALL`
(Claude Code / Codex CLI / OpenCode / GitHub Copilot / Gemini).
- `Document::cycle_chat_agent` — advances to the next *connected*
agent (`agent_settings.connected`); walks all 5 when none are
connected so the user can pre-pick before connecting.
- `AIChatHit::CycleModel` — the chip's left 150 px of the input
toolbar; `apply_click` routes it to `cycle_chat_agent`.
- The chip label now renders `AgentProvider::name()` instead of
the static "Default".
- `chat_session::provider_for_agent` routes the selected agent to
its `ChatProvider`: Claude Code → SDK adapter, Copilot / Gemini
→ subprocess transport. Codex / OpenCode (HTTP-server CLIs whose
bridge isn't wired yet) fall back to Claude Code.
856 workspace tests pass (+2: cycle walks connected / wraps,
cycle with nothing connected walks all five).
The chat send path was hardwired to a stub (`ChatState::send` →
"(stub) Got it"). The real providers existed but main.rs never
called them.
- `ChatState::begin_send()` — native-host send: pushes the user
message + an empty assistant bubble, raises `pending_send`. The
web shell keeps the offline `send()` stub.
- `chat_session.rs` — `ChatSession` runs the turn on a background
thread (`ChatProvider::send` is a blocking iterator; draining it
on the UI thread would freeze the window). `poll()` is
non-blocking; `launch_if_pending` / `pump` are the desktop glue.
- main.rs — Enter key + Send-button click drain `pending_send`
into a `ChatSession` against `ClaudeCodeProvider`; the winit
loop pumps deltas into the transcript, waking ~30 fps mid-turn.
A missing / unauthenticated `claude` CLI now surfaces as an
`error: claude query: …` message instead of a fake stub reply.
854 workspace tests pass (+4: chat_session stream/error,
begin_send push/empty).