Adds DesignRequest.concurrency, group_subtasks_by_screen + ScreenGroup,
effective_concurrency, and clamp_concurrency in a new concurrent.rs module.
Faithful port of orchestrator.ts:780-810 (minus S3b-4 append gate).
All 17 DesignRequest literals updated; 183 tests green.
Part 1: replace hardcoded PLANNING_TIMEOUT / SUBAGENT_TIMEOUT constants
in prompt.rs with profile-derived timeouts from timeouts.rs:
- build_orchestrator_prompt Rich/Minimal → orchestrator_timeouts(prompt_len)
scaled by timeout_multiplier (port of TS fastTimeout=false branch).
- build_orchestrator_prompt Compact → builtin_planning_timeouts(tier)
(port of TS fastTimeout=true / builtin-provider path).
- build_subagent_prompt → sub_agent_timeouts(prompt_len, tier) scaled by
timeout_multiplier (port of getSubAgentTimeouts).
All three CallRequest fields (timeout, no_text_timeout, first_text_timeout)
are now populated; no_text/first_text are Some instead of None.
Part 2: remove stale #![allow(dead_code)] from retry.rs, plan_repair.rs,
and timeouts.rs — their callers have been wired in by C2/C3/this task.
Replace the single-attempt subtask loop with the 3-attempt retry
ladder from orchestrator-sub-agent.ts:128-206 (sequential path).
Attempt 1: reduced_complexity=false, minimal_skills=false
Attempt 2: reduced_complexity=(tier==Basic), minimal_skills=false
Attempt 3: reduced_complexity=true, minimal_skills=true
Retryable = error.is_some() && node_count==0 && !abort.is_set()
&& !is_non_retryable(&err). The non-retryable predicate
is evaluated once from attempt-1's error (faithful to TS).
Partial results (node_count>0) are never retried.
After 3 still-zero the existing zero_node_failure stop applies.
Abort classification preserved.
4 new tests; existing run_zero_node_subtask_stops_and_errors updated
to supply 3 garbage responses (the ladder now consumes up to 3).
166 tests green; clippy + fmt clean; run.rs at 764 lines.
Port of the TS `executeSubAgent` skill-filtering branches and the
`retryAllowed` set from `orchestrator-sub-agent-compact.ts`.
- New `compact_skills.rs`: `apply_skill_filter` + `SkillNamed` trait.
- `minimal_skills=true` → keep only `schema`+`jsonl-format`/`-simplified`.
- `reduced_complexity=true` + `ModelTier::Basic` → keep the 8-skill
`retryAllowed` set (drops `elements`, `overflow`, `icon-catalog`, etc.).
- Standard/Full tier: `reduced_complexity` is a no-op (full set through).
- `build_subagent_prompt` gains `reduced_complexity: bool` + `minimal_skills: bool`
params; resolves tier from the request model and applies the filter after
`resolve_generation_skills`.
- `run_subtask` gains the same two params and threads them into `build_subagent_prompt`.
- All existing callers in `run.rs` and tests pass `false, false` (single-attempt path).
Port parseOrchestratorResponse from orchestrator-planning.ts:20-55.
Three text-extraction strategies (direct / fenced / brace-slice), each
tried strict-then-repair for six probes total; repaired=true when the
result came from the repair path. Fence extraction uses plain string ops
(str::find / strip_prefix / strip_suffix) — no regex crate added.
Task B2: appends repair_plan_object, finalize_plan,
extract_subtask_candidates, coerce_subtask, build_fallback_heights
to plan_repair.rs. Extends Subtask with optional elements/screen
fields and updates four existing struct literals accordingly.
Adds 12 new tests; full suite 140 tests green.
OrchestratorPlan now accepts the TS-native rootFrame/fill-array/styleGuideName
shape emitted by decomposition.md. Removes StyleGuide struct; adds PlanFill +
first_solid_hex helper. variables.rs enters dormant state (no palette to seed).
Local verification pass: with a temporary design-md stub the S3a
crates compile against the available jian. op-editor-core (262 tests,
incl. 8 InsertSubtree) and op-orchestrator (31 tests) all pass; clippy
clean. This commit folds in the rustfmt diffs that surfaced.
Host-side impls of op-orchestrator's two trait seams:
- DesktopLlmClient: implements LlmClient over agent::QueryEngine —
each call() builds a fresh engine (isolated context per spec §3.2),
spawns it on the shared runtime, and bridges agent Events to
LlmChunk via a futures mpsc channel.
- DesktopDocSink: implements DocSink over op_editor_core::EditorState.
- run_design_request: the post-intent-gate entry that runs
Orchestrator::run().
NOT verified — op-host-desktop is casement-blocked (and op-editor-core
jian-blocked). Two integration points are left to the host author and
documented in the module header: (1) the intent gate in
chat_runtime.rs (classify_intent -> design vs chat), kept untouched to
avoid blind-editing the threading model; (2) DocSink undo-batch wiring
to op-editor-core's History batch API.
S3a Plan C Task 6 (partial).
run_cleanup_passes now does two of the three TS cleanup passes:
- remove_duplicate_status_bars: keeps the first status-bar child
under each root, deletes the rest.
- adjust_root_height_to_content: sets root frame height to the sum
of its direct children's pixel heights.
Signature gains root_ids: &[&str] so S3b's concurrent multi-root
path reuses it (spec §9). unwrapSingleComponentSectionRoot left as a
documented follow-up (heuristic-heavy).
Codex stop-time review found two bugs:
- A subtask aborted mid-stream returns node_count==0, which the run
loop classified as zero_node_failure (error path) — wrongly removing
the scaffold root and returning NoContent. Now checks abort.is_set()
after run_subtask: an abort during the call takes the abort path
(keeps the root, returns Aborted).
- The happy-path test asserted root_frame_id == "root", but
InsertSubtree remaps every id so the live root id is fresh. Assert
it is non-empty instead.
Codex reviewed the blind-written S3a code against the real
op-editor-core / jian-ops-schema APIs and fixed:
- run.rs: InsertSubtree remaps every node id, so the planned
root_frame.id is NOT the live id. Capture the actual root id from
active_children() after the scaffold insert and rewrite each
subtask's parent_frame_id to it.
- run.rs: a rejected scaffold InsertSubtree was treated as success;
now rolls back + ends the undo batch + returns Internal error.
- run.rs / prompt.rs: r#"..."# raw strings broke on the "# inside
embedded JSON ("fill": "#RRGGBB"); switched to r##"..."##.
- cleanup.rs: test asserted the literal "root" id; reads the
remapped id instead.
- prompt.rs: sub-agent NODE_FORMAT now states fields are camelCase
(cuts runtime parse failures from snake_case model output).
Still unverified — vendor/jian unpushed (see prior commits). Codex
confirmed scaffold.rs's PenNode JSON shape (type tag, camelCase,
SizingBehavior bare number, PenFill) is correct.
New crate restoring the design orchestrator (TS orchestrator.ts
phases 1-4, single-screen sequential) that the Rust migration
dropped. Pure crate — no winit/casement/agent dependency.
- types.rs: DocSink + LlmClient traits, CallRequest/LlmChunk/
Progress/RunSummary and friends
- intent.rs: classify_intent (design vs chat)
- plan.rs: OrchestratorPlan parse + heuristic fallback
- parse.rs: LLM output -> canonical PenNode trees
- plan_normalize.rs: single-screen plan normalization
- variables.rs: plan-derived $color-* seed/snapshot/rollback
- prompt.rs: planning + sub-agent prompt assembly via op-ai-skills
- scaffold.rs: single-screen canvas scaffold (InsertSubtree)
- subagent.rs: sequential sub-agent execution
- cleanup.rs: descendant_count + run_cleanup_passes (reusable for S3b)
- run.rs: Orchestrator::run() — 4-phase spine, spec §6 error/abort/
zero-content semantics
- test_support.rs: VecDocSink + ScriptedLlm test stubs
NOT verified locally: op-orchestrator depends on op-editor-core,
which does not currently build — vendor/jian is pinned to unpushed
commit 80121906 (see prior commit). Unit tests written throughout;
they run once the jian build is restored.
S3a Plan B + Plan C (orchestrator modules).
Existing EditorCommand variants are leaf-only (BatchInsertItem carries
only kind/name/x/y/w/h/fill_hex). The design orchestrator (S3a) must
apply rich nested designs — frames with children, layout, text — so
add InsertSubtree { nodes: Vec<PenNode>, parent_id }.
cmd_insert_subtree validates the parent is a container (or NONE = page
root), remaps every incoming node id to a fresh editor id via
remap_subtree_ids (so an externally-authored subtree can't collide
with live ids), and appends under the parent. The apply arm wraps it
in a history snapshot so the insert is one undo step.
NOT verified locally: op-editor-core does not currently build —
vendor/jian is pinned to unpushed commit 80121906 whose DesignMd*
types op-editor-core depends on are absent from every available jian.
The 8 InsertSubtree tests in command_subtree_tests.rs run once the
jian build is restored.
S3a Plan A.
Export section: the scale / format dropdowns open inline select
popups (1x/2x/3x, PNG/JPEG/WEBP/SVG/PDF) instead of the Export
modal, which is now reached only via File > Export Image. Adds a
full-width Export button; popup rows highlight on hover. Pickers
open upward so they are not clipped at the panel's bottom edge.
PropertyPanel scrolls when its content overflows the viewport,
with the Design / Code tab strip pinned; scroll is clamped on
every paint + hit-test, not just on wheel events.
LayerPanel: the Pages and Layers sections get bounded heights and
independent scroll regions; layer drag/drop is gated to the
visible Layers viewport.
Splits property_panel into property_panel_action / _export and
the host press paths into property_dispatch / scroll modules to
keep every edited file under the 800-line cap.
editor_state_to_layout_scene took active_page_index from the payload, which pen_document_to_payload hardcodes to 0 — so picking a page in the LayerPanel updated the panel but never switched the canvas. Read the live EditorState.ui.active_page_index instead, clamped to the page count.
Add op-figma as a dependency and implement FileAction::ImportFigma — an rfd .fig picker parses the binary file via parse_fig_binary and re-seeds EditorState.
run_action now returns a 3-state ActionOutcome instead of a bool: an import returns PathChangedUnsaved so it is treated as unsaved work (close still prompts) while the Git session is rebound to the now-pathless document. mark_document_saved is split so the rebind can run without refreshing the dirty baseline.
Wraps the release binary in a minimal `OpenPencil.app`
(Info.plist + icon) so a dev run gets the proper Dock name +
icon — an unbundled binary shows the raw executable name and a
generic icon, and the runtime objc2 fallback in `macos_app.rs`
can't fully override that. Run the binary from inside the bundle
(`OpenPencil.app/Contents/MacOS/openpencil-desktop`) and macOS
picks up the bundle identity.
Run bare, the non-bundled binary shows in the Dock as the raw
`openpencil-desktop` executable name with a blank icon — no
`Info.plist` to read `CFBundleName` / the icon from.
New `macos_app::apply()` sets both at startup via objc2:
`NSProcessInfo::setProcessName` for the Dock / menu-bar name and
`NSApplication::setApplicationIconImage` (from an embedded
`assets/icon.png`) for the Dock tile. `[package.metadata.bundle]`
also gains `icon`, so a packaged `.app` carries it natively.
objc2-app-kit / -foundation are pinned to the 0.2 line winit /
casement already lock.
cargo-deny failed on `clipboard-win` / `error-code` — pulled in by
`arboard`'s Win32 clipboard path — which are BSL-1.0. The Boost
Software License is permissive + OSI-approved; add it to the
licenses allow-list.
Hide the native title bar and let the TopBar own the window
chrome — the Electron `titleBarStyle: 'hidden'` recipe:
- macOS keeps a real `NSWindow` (rounded corners, shadow,
edge-resize, key-window responsiveness) with the title bar made
transparent + emptied; the native traffic-light buttons stay,
pushed down via casement's `with_traffic_light_inset` to centre
in the 40 px TopBar. Windows / Linux drop decorations and the
TopBar paints its own close / minimise / maximise dots.
- The TopBar reserves a left inset for the controls; it collapses
in macOS fullscreen (native lights hide), tracked by a
per-frame `window.fullscreen()` poll. `window_control_at`
returns `None` on macOS so a fullscreen click on a left-edge
app icon can't trigger a window control.
- A press on the TopBar's blank area drags the window.
TopBar chip also gains one brand icon per connected agent + an
`N agent[s] · M MCP` status (new `topbar.agentPlural` across all
15 locales).
Bumps the vendor/casement submodule to 5ad98f1c.
A click on the colour swatch inside the Fill / Stroke hex input
now opens the picker. Previously only the head-row swatch did, and
the Stroke section had no picker hit-test at all. The head-row
swatch was dropped as a trigger in a follow-up — the hex-row
swatch is the intuitive target. `hit_test_action` runs before the
hex-input focus hit-test, so a swatch click opens the picker
instead of focusing the hex field.
The infinite-canvas grid painted one `fill_round_rect` per dot —
~1200+ separate skia draw ops every frame, the dominant cost of an
empty-canvas pan / drag. New `RenderBackend::fill_dots` collects
the dot centres and the native backend draws them in a single
`Canvas::draw_points` (round-capped points); other backends keep a
`fill_oval` loop via the default impl.
Also: `NativeBackend::fill_rect` went through `Paint::solid`, which
hardcodes `opacity: 1.0` and dropped the colour's alpha — a
translucent fill (the 12% marquee-selection band) painted fully
opaque. It now carries alpha through `Paint.opacity` like
`stroke_rect` does.
Three chat-panel fixes that share `ai_chat_panel.rs` / `input.rs` /
`scroll.rs`, so they land together:
- Model picker scoped to connected agents: discovery still probes
every installed CLI into `chat.discovered_models`, but the picker
lists only providers the user connected (`rebuild_available_models`,
re-run on connect-toggle + discovery). Connect state persists in
settings.json. The dropdown is height-capped, scrolls (wheel /
trackpad) with a thumb, and tints the hovered row.
- Chat input wraps: long input flows across up to 3 visible rows,
clipped + bottom-anchored, instead of overflowing the panel edge.
- perf: a canvas pan / zoom no longer marks the layout scene dirty
— it only moves the viewport transform, so re-running the taffy
layout solve + skia text measurement every drag frame was pure
waste. The repaint still re-applies the viewport.
Cmd+C / Cmd+V / Cmd+X did document node-clipboard ops regardless
of focus, so there was no way to paste a prompt into the AI chat
input. They now branch on chat focus: with the chat input focused
they read/write the OS text clipboard (`arboard`), otherwise the
node clipboard as before.
- `clipboard.rs` — thin best-effort arboard wrapper.
- `WidgetHostNative::chat_input_paste` / `chat_input_cut` — append
/ cut on the focused chat buffer.
The TopBar agent chip hardcoded `agent_count: 0`, so it always
painted the empty 'Agents & MCP' set-up affordance even after the
user connected a provider in Settings → Agents. It now reflects
the real count of connected providers (`agent_settings.connected`).
The chip's '{N} agent' label keyed `topbar.agentSingular`, which
was missing from every locale table — the chip rendered the raw
key. Added the key to all 15 locales.
A fresh launch seeded the demo `sample()` document — a Frame with
a 'Hello OpenPencil' title and a 'Click me' button group. New
`EditorState::starter()` returns just one empty Frame (selected),
and both hosts open with it; `sample()` stays as the widget-test
fixture. input_tests retarget their `n11` selections to the
starter frame's `n10`.
Newer Claude Code CLIs emit stream message types the bundled SDK
was not compiled against — `rate_limit_event` being the one that
surfaced — and serde aborted the whole chat stream on the first
one ("unknown variant rate_limit_event").
The `Message` enum gains a `#[serde(other)] Unknown` catch-all so
any unmodelled `type` deserializes cleanly instead of failing the
parse; `hooks.rs` and `chat_claude.rs` match it as a silent no-op
(an unknown event carries no hook payload and no chat turn).
The pre-commit `bun run format` step was reformatting upstream
casement files (Cargo.toml indent, .swcrc / changelog .md / CI yml
flow), leaving the submodule working tree dirty after every commit
that touched openpencil. Listing vendor/casement alongside the
other submodules in .prettierignore stops the formatter from
walking it — same as vendor/agent and vendor/jian.
`muda` is gated to macOS / Windows so the Linux backend stub
returns `None` from `poll()` and never constructs a `MenuAction`
variant. clippy's `-D dead_code` then fires on every variant on
Linux. Adding a target-gated `#[cfg_attr(…, allow(dead_code))]`
silences it there while keeping the lint live on macOS / Windows
where the variants actually need to stay reachable.
Use `messages.iter().enumerate().skip(start)` instead of the
explicit index loop `for i in start..messages.len()`; clippy's
`-D warnings` gate on Rust 1.94 trips on the former. Also bump
the casement submodule to da0bf09 so its .gitattributes forces
LF for source files (fixes Windows CI `cargo fmt --check` on
the vendored fork).
The casement crate was depended on through a sibling-repo path
(`../../../winit`) that only existed on the maintainer's machine,
so CI couldn't load the workspace manifest and every Rust Check
job died with "failed to read winit/Cargo.toml".
Vendoring it as a real submodule under `vendor/casement` (matching
the `vendor/jian` pattern, picked up by CI's `submodules:
recursive` checkout) closes that gap. The renamed GitHub repo
`ZSeven-W/casement` (was `ZSeven-W/winit`) tracks the `op-file-open`
branch — `feat(macos): drain_opened_file_urls` + the package rename
landed there as commit 5877fa83.
- `.gitmodules`: add vendor/casement.
- Root Cargo.toml: exclude vendor/casement from the workspace glob
(it's its own workspace).
- op-host-native + op-host-desktop: path = "../../vendor/casement".