feat(workbench): UI v1.1 — the object desk, fences, and detachable consoles - #354
Merged
Conversation
…307) Self-review fixes: the badge renders `—` (never a quiet 0) when the read fails, with the reason in its title; the fold refuses a non-ok reply; new coverage for the anchored .ralphy/ filter, an unmerged path, the rename output line, and the daemon Query arm's reply field; CONTEXT.md gains the change-set term.
min/vs minus language/**, nls/lang/**, *.d.ts, *.map and the four language workers under assets/. 113 files, 5,570,129 bytes.
RED until the swap lands: 27 vendor/codemirror/ paths are still embedded. Adds wb-monaco.js — the memoised AMD boot, the ADR-0035 "wb" theme, the .toml→ini registration and the four disabled LSP mode configurations.
The source pane and the markdown raw-source editor both mount through WBMonaco. Editor creation is now async (AMD loader), so rec.content stays authoritative until rec.ed exists and a tab closed mid-boot mounts nothing. CodeMirror 5, its 27 vendored files, cmMode() and the .cm-s-wb theme are deleted; the app.js keyboard guard now looks for .monaco-editor.
…308) 15/15 over a real daemon on a scratch RALPHY_DAEMON_DIR: rust/ini language resolution from the path, >=3 theme colour slots (mtk indices only — bracket decorations ride on mtk1 and would fake the count), the find widget, a Save round-tripped byte-equal to disk, CodeMirror 404, and the ADR-0035 ground.
Two HIGH defects from the independent review: - automaticLayout was off, and nothing relayouts on resize — Monaco sizes from inline dimensions (CodeMirror reflowed from CSS), so a sidebar collapse or window resize clipped the pane until the next tab switch, and the detached popup, which has no tab bar, never relayouted at all. - `tokens: false` on jsonDefaults killed JSON highlighting outright: that provider IS the json tokenizer and json has no basic-languages grammar to fall back on. Proved: with it off a .json tab has exactly 1 colour slot. Plus the boot-gap lifecycle: liveness is now record identity, not `map.has` (tab ids are stable per file, so close-then-reopen mid-boot could mount an undisposable editor on a detached container); a create()/wiring throw no longer masquerades as a boot failure; the read-only fallback pane tracks applyFresh; and re-entering mountEditor cannot stack panes or duplicate a model URI. Test grew 15 -> 22 checks with the boot gap, model disposal, the markdown raw editor and a .json regression guard, and the exit count is pinned exactly.
MARKS table + marker() classify the six producer statuses (modified, added, deleted, renamed, untracked, conflicted) to a mark/cls pair; anything else falls back to ?/st-unknown. fold() now carries mark, cls and a rename-aware title per entry.
Adds a per-slug open/entries model, a click-to-expand chevron on the Changes header, and a row list (mark + path + rename arrow) driven off the same changes.list snapshot the badge already counts. Section stays collapsed by default; the list caps at 45% height so it never displaces the file tree.
wb_changes_309.py: 6 scenarios over a real daemon — collapsed by default, one click renders 5 rows in git order, per-status markers, rename shows its origin, untracked files appear, the list scrolls without hiding the tree, and the narrow-viewport render stays intact. 15/15 checks pass. Screenshot at docs/screenshots/309-changes-list-2026-07-25.png.
The collapsed section now DOES carry a toggle (the new chevron), so the old "offers no toggle control" check is replaced with one asserting the header carries exactly one .chg-chev.
the_changes_section_renders_a_status_marked_list source-text pins index.html and wb-changes.js, mirroring the #308 Monaco pin - the JS/HTML deliverable has no Rust build compiling it, so the workspace suite would otherwise stay green after a deletion.
The nudge send lives inside the blocking wait task, so it fires on every exit path of the run — including the shutdown and client-disconnect arms that abandon the `select!` loop while the child keeps living. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…310) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ordering token on `loadChanges` (a nudge landing mid-read could commit a stale count), optional-chained `WBChanges` on the push path, a Rust source pin for the browser half (CI runs neither node nor playwright), a second registry entry so the nudge's repo name is really proved, malformed-frame cases for `shouldReload`, and four mechanism assertions in the browser acceptance (no `changes.list` in the stale window, no socket reopen behind the refresh, B's own run proved to land, the manual move bound to the click). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed run Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Absence at the revision is an outcome, not an error: a newly added file must diff against emptiness. Binary and over-cap follow the working-tree reader's rules so both halves of one diff agree on what text is.
The daemon's argv builder validates the path by shape and stays pure; this process is the one standing in the repo, so it refuses an escaping path before any read. The JSON reply is a struct, not `json!` — serde keeps declaration order, `json!` sorts keys.
The argv builder stays pure: the path is validated by shape only, so a Windows daemon refuses a POSIX-absolute path and vice versa, and the host filesystem never decides what a remote path means. `ReadError::reason()` gives the refusal vocabulary one daemon-side home.
Read-only by construction: the toolbar carries Find alone, and save / applyFresh / externalChange all return early for a diff record. Both models are disposed before the editor on every path — a leaked one keeps its URI registered and reopening the same path throws.
15/15 over a real daemon. "Side by side" is measured geometrically: a diff editor has no getOption, so the probe's equal-width/adjacent-pane geometry is the oracle. The collapse ruler renders only after Monaco's diff computation settles, later than the first `.view-lines` paint — the wait gates on the widget, not the paint.
The HIGH was a coverage defect, not a behavioural one: the "both models were disposed" check was a literal `check(..., True)`, and its premise was false — createDiff puts the per-open `uid` in each model URI, so a reopen can never collide with a leaked model. Replaced with a count against Monaco's own model registry; verified by mutation (removing the two disposes now fails with 6 leaked models, while the reopen check still passed). Also: pin side-by-side against Monaco's `useInlineViewWhenSpaceIsLimited` default, which silently swaps to the inline view under 900px and which `renderSideBySide: true` does not defeat (now asserted at 700px too); re-check tab liveness before mounting, so a tab closed during the two round trips cannot leave an unreachable pane; refuse a tree object rather than relay git's raw fatal with host paths; make the CLI's containment claim true and table-drive its arms; separate the two branches of the binary sniff; cover the format-less output branch; latch the refusal so one gesture flashes once; scan attributes for icon-only mutators; give the row keyboard access.
Both phases accumulated their usage into a default `Usage` with `add_tokens`, which drops `model` by design (it sums across records of differing models). The phase line therefore recorded `model: unknown`, the price table could not resolve it, and its cost fell out of the run figure — the run footer showed a bare `+?` residue with real spend behind it (~$2.25 on the 2026-07-25 run of this repo). The execute phase already routes its resume loop through `Usage::fold_usage` (the one place accumulated-usage model derivation lives, ADR-0008 D8). Do the same here: - the verify-gate repair loop collects one `Usage` per attempt and folds after the loop, no vendor fallback (ADR-0002 — that lives in the adapter); - the protocol bounce runs at most once, so it takes the bounce's usage whole rather than summing it in. The two existing runner tests already drive both paths; they now assert the recorded model as well as the outcome. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ejoins it (#313) `ralphy init` creates a fixed roster of labels, but the runner applies `needs-split` by literal name (phases.rs) and that name was never in the roster — on a fresh repo the first bundle verdict fails with `'needs-split' not found`, a non-transient error the runner only warns about. Adds the new `needs-human-review` label (#313) and closes the same gap for `needs-split`, so every label the runner can apply is one `init` creates. Both consts move to `runner.rs` beside `STOP_BEFORE_LABEL`/`TRIAGE_AGENT_LABEL` so the apply site and the roster read one definition. `needs-human-review` is deliberately NOT in `HUMAN_GATE_LABELS`/`human_return_labels`: it lands on a *closed* issue and must never re-enter the ADR-0014 blocker path. A new test pins that, and another asserts every runner-applied label is in the roster — the check whose absence let `needs-split` drift. Also corrects `ready-for-human`'s spec description, which read "Agent finished — waiting for human review and merge". That is the *opposite* of its role (a pre-run human gate) and collides with what `needs-human-review` now means. setup-pocock's `triage-labels.md` only documented the five renameable triage roles, so the fixed operational labels appeared nowhere. Adds a second table for them, flagged as not-renameable — remapping them there has no effect, since the runner applies them by literal string. The three tests hardcoding the roster size now derive it from the spec list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR-0051 is amended for the fence detach of #344, ahead of any code. Sec. 8 gains the decision: the plane answers "where is my work" but not "I have a second monitor", and sec. 3 forbids the zoom that would let two regions fit on one screen. So a fence detaches into a real `window.open` popup, and the set of detached fences is per-client AND per-tab -- a third lifetime beside the viewport offset, not a third copy. The popup is a child of the tab, never writes the desk (an injected sink, not a flag branched at each call site), and opens no consoles of its own. Sec. 9 gains the paragraph that keeps this from reading as a contradiction. What that section forbids is a client claiming on its own, on a machine-driven path; a detach is one operator, one click, handing over a slot the acting tab already held. It needs no new mechanism: tearing the origin windows down closes their sockets, which releases the slot, and the popup attaches by the ordinary path with no `takeover` anywhere -- so the daemon's 409 still makes theft impossible. Sec. 6 records that the popup holds a membership snapshot rather than participating in centre-point derivation, sec. 7a puts `detach` beside arrange and close and states that a detached fence is otherwise ordinary on the plane, and sec. 10 records the load-bearing non-change: the daemon learns nothing, which is what makes "another browser sees an ordinary fence" true by construction. CONTEXT.md gains the detached fence entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The console module decided WHERE to persist at each call site; a second document rendering the same consoles could only be kept from authoring the desk by a `detached` test every future edit must remember. The write moves behind `WBDeskSink` (daemon / none) injected via `window.WBConsoleOpts`, alongside the view store, the boot and the launcher — each defaulting to the shell's current behaviour, so index.html is unchanged by construction.
detachFold(registry, event) -> { registry, effects } decides every detach
transition with no DOM and no storage: the four-popup cap, one popup per
fence (a second detach focuses the first), and a re-attach that is idempotent
because both the popup's beforeunload and the opener's closed-poll report it.
The registry is never mutated, so the caller commits a transition only once
its effects actually ran — a popup the browser blocked leaves it untouched.
The fence chrome gains a detach verb between arrange and close, and an emptied fence keeps its name, rect and place in the list while carrying the glyph that tells it from an empty one. detachFence opens detached-fence.html FIRST and bails on a null handle, so the invariant holds on every exit path: either the popup exists and the members are torn down, or neither. A member leaves the plane without forgetting its desk record and without closing its daemon session — the record is shared state a second client still renders, and the socket close is the writer-slot release (ADR-0051 section 9). The untranslated snapshot stays in the OPENER, so a drag inside the popup is discarded on re-attach by never having been read.
…holes (#346) Two HIGH findings, both tests that stayed green against gutted code: scenario 5 asserted the popup offers no launcher using three selectors that exist nowhere in the product and one that only a placeholder ever renders — and the fixture had no placeholder, so deleting `canLaunch: false` changed nothing. The fixture gains an `agent` member, and the shell is sampled before the detach as the control. Re-attach was verified as geometry alone, which a placeholder or a permanently parked window satisfies identically; it now asserts live terminals, no parked strip, and a real keystroke reaching the child. Product fixes alongside: removeFence refused on a detached fence (the × destroyed the only way home), the handover reply made demo-aware, the re-attach message trusting the proven owner rather than its payload, a rescue timer for a popup that never completes the handshake, the poll armed before the teardown, detached members pinned against desk eviction, afterLogin no longer respawning them, and pagehide closing popups that would otherwise outlive their opener.
`wb-detach-link.js` owns the two browser facilities the lifecycle needs — the tab-scoped registry and the same-origin broadcast channel — behind the same inject-or-deny surface `wb-desk-sink.js` established, so the popup (which inherits a COPY of its opener's sessionStorage) is incapable of acting on a registry rather than merely careful about it. `peerFold` takes its window as an argument, so the six-second expiry is driven in microseconds by the node table instead of by a real clock.
The registry moves into the acting tab's session-scoped store behind the new link seam, and the two documents re-find each other over a same-origin broadcast channel after the opener's handle has died with its document. Boot restores the registry SYNCHRONOUSLY, before the desk fetch is issued, so `restoreDesk` already knows which members belong to a live popup and skips every verdict for them — a `relaunch` there would have spawned a second PTY against a console the popup drives. The `pagehide` popup-close loop is deleted: it fires on a reload exactly as on a close with no discriminator between them, which is the defect. Silence is the single rule now, covering a clean close and a force-kill alike. The initial handover is untouched — the members still ride #346's concrete-targetOrigin postMessage; only lifecycle chatter uses the channel.
…death (#347) Eight scenarios over a real daemon on port 7440. The no-flash oracle is a MutationObserver installed by `add_init_script`, so it samples from document start: "no console ever appeared on the plane" cannot be read after the page settles. With every member inside the detached fence, its expected peak is exactly 0. `link()` no longer writes at load — the key's presence IS the fact "this tab holds a detach", and a boot-time write would forge it for every tab, which is what scenario 3 asserts against.
restoreDesk threw on the `adopt` verdict, whose record is null by construction (`reconcileDesk`), and the swallowing catch turned that into a plane with no fences and no glyphs at all — on any boot carrying a live session no record claims, detached or not. Both guards are now null-safe and the null emission is pinned, so the reason cannot rot. `popup-here` replaced the map entry with a fresh object, which defeated the glyph's ping comparison with the very reply it waits for: after a reload the focus intent was unreachable. It mutates in place now. The boot skip no longer derives membership from geometry. A detached fence is still movable, and after a drag the fold compared its NEW rect with the members' ORIGINAL records, answered "no members", and put every one of them back under the live popup — a second PTY on a relaunch. The registry carries the member ids instead; new scenario 5b drives the drag-then-reload path that found nothing before. Also: an `origin-close` verb, because a post-reload re-attach holds no handle and had no way to evict its popup; a de-duplication against the plane for the popup-gone-mid-boot race; the popup takes a channel-only link so it reaches no store at all; and four oracles that could pass against broken code are now discriminating.
`.gitignore` already carried `docs/screenshots/`, but a gitignore rule has no jurisdiction over a path that is already in the index. The five PNGs on this branch were force-added after that rule landed (b6726c1), so every acceptance run that regenerated one showed it as a modification and offered it for upload. Untrack them and narrow the rule to `docs/screenshots/*.png`. A screenshot is evidence for one gate run: regenerated by the script that produced it, a new blob per run, and nothing a reader can diff. The narrower pattern also stops the folder from swallowing what is ordinary source — a README naming the convention, an index — which a bare directory rule would have. The files stay on disk; only the index changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Updated fence chrome to only display the name and console count, removing the repo list as fences are not bound to projects. - Adjusted tests to reflect changes in fence chrome output, ensuring empty fences display "(0 consoles)". - Enhanced toolbar list to show fence names and their respective Alt+Shift+F<n> accelerators, while ensuring console counts are read from the title bar. - Modified glyph behavior to close the window holding consoles and return them to their original boxes, regardless of popup state. - Improved test coverage for scenarios involving console management, including handling of closed consoles in popups and ensuring proper state management across multiple contexts. - Updated documentation to clarify glyph functionality and its role in managing console visibility and state.
A saved AGENT console whose session is gone comes back as a placeholder
with a button, and that stays the default: loading a page must never
spawn a vendor CLI and spend quota nobody authorized. But the operator
may authorize it standingly — Settings -> Consoles -> "Relaunch agent
consoles on load". Plain shells are unaffected; they always came back by
themselves.
The preference lives in the per-client view store, not in the desk and
not in a repo's config. Daemon-wide, every tab pointed at the same desk
would relaunch the same consoles and spend the quota once per tab; in a
repo's settings.json, one operator's browser choice would bind every
other client. `reconcileDesk` takes it as a parameter and defaults it to
false, so the fold stays pure and a caller that forgets the option gets
the parked placeholder — never a launch.
Note what the placeholder's button was always doing: the old PTY is
gone, so it is a fresh LAUNCH, not a reconnect, with no scrollback to
come back to. The setting's help line says so.
The browser acceptance found one real defect on the way: the `relaunch`
verdict spawned `{ console: true }` unconditionally, which was correct
while only shells could reach it and opened a plain shell in the agent's
box once the opt-in let an agent record through. It now asks for the
record's own vendor, exactly as the placeholder's button does.
wb_desk_303.py grows scenario 10 (the toggle driven through the real
modal, both ways, against a real daemon) and three fold rows whose
control is the omitted option; wb_view_339.py's shape check admits the
new key, since the rule is "no desk in browser storage", not "no fourth
key". A lib.rs pin is the gate CI can see.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drop the settings modal's foot note and right-align `Done` behind it, the same way the About card's went. The head's subtitle claimed everything below is saved per project, which the client-scoped section had just made false — each section already carries a scope chip that says where it is saved, so the subtitle now defers to it. The relaunch-on-load help line said the same thing three ways. Two facts change the decision: it is a launch rather than a reconnect, and it costs one vendor CLI per console per load. That is all it says now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`/api/about` served `CARGO_PKG_DESCRIPTION`, so the card showed text written for whoever opens the manifest: it cites an ADR path and the rule confining tokio to this crate. An About card is not that reader. The field goes from the route, the seed and the markup rather than being reworded — a product line is a decision, and an empty space says less wrongly than the crate's own note does. Cargo.toml keeps its description, which still has a job where it lives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The topbar brand read `identityName || 'ralphy'`, so a baptized daemon replaced the product's name with its own — and the two were indistinguishable here only because this host's daemon happens to be called ralphy. The bar now says what you are looking at, fixed. The daemon's identity keeps its place on the right, where it answers a different question: the avatar on the account button, the chosen name inside the menu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three controls sit one pixel from something harmless on the same title bar: tiling a fence rearranges every console in it, removing a fence takes the region out from under them, and a console's × ends a live session and its scrollback. All three now ask first. The dialog is built in wb-console.js rather than borrowed from the shell's Alpine one, because this module also runs in the detached-fence popup, which carries neither Alpine nor the modal markup — and the same click must ask the same question in both windows. It reuses the shell's modal classes, so it is the same dialog to look at. `window.confirm` was the other candidate and is worse than either: it blocks the thread, and an automated browser dismisses it by default, which would quietly turn every guarded click into a cancelled one. The gate is on the BUTTONS, not on the verbs: `arrangeFence` and `removeFence` stay exported and unguarded, because a caller that names the verb has already decided. Two clicks skip the question outright — a detached fence refuses both tile and remove and says why, and putting a question before an answer that was never the operator's to give is worse than not asking. Cancel takes the focus, so a stray Enter cannot reproduce the very slip the dialog exists to catch. The eight suites that drive these controls answer the dialog; #342 and #303 gain the check that matters — cancelling moves nothing, closes nothing, and leaves the daemon's session list alone. Answering `yes` everywhere else would pass against an inert dialog too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three unrelated slips in the console chrome, reported together. The account menu hangs from the far side of the top bar and enumerated none of the toolbar's pickers, while they enumerated each other: opening it over a live New-console menu left both on screen, overlapping. Every toggler now goes through one `closeMenus`, so a fifth menu is one line rather than four edits — and the acceptance drives it in BOTH directions, because a fix that only holds the way it was reported is half a fix. The Consoles tab wore a robot, which named the agents rather than the plane they run on. It now wears the same terminal glyph as the New-console button and the rows in its own menu. The button moves from lucide's `square-terminal` to Bootstrap's `terminal` to get there: the tab renders `t.icon` as a class, so that is the only glyph the two can literally share, and they are the same picture to look at. A fence's name was an always-live input sitting in the title bar the operator also clicks to reach the fence — so any such slip was a rename, silently committed on blur. It is read-only until a double click, and shuts again on blur. #340 gains the half that matters: a single click and a keystroke leave the name alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test job compiled the whole workspace under `[profile.release]`, whose `lto = "thin"` and `codegen-units = 1` are a ship-time cost paid once for the single binary `release.yml` builds. The job builds ~50 test binaries instead, and `ralphy-cli`'s integration tests each link the entire CLI: that crate alone spent over 20 minutes linking, and both runners hit `timeout-minutes` without executing a single test. Measured cold on Windows, the whole gate now takes 3m11s to compile and 102s to run 2156 tests. Optimization is what the tests need, not the linking — the daemon's PBKDF2 tests are the slowest in the suite — so `[profile.ci]` inherits `release` and drops only `lto`, `codegen-units` and `strip`. The shipped artifact is unchanged: `release.yml` still builds `--release`. `Build` also gains `--all-targets`, so the test binaries are compiled in the step named for building. A future compile blowup then fails there instead of masquerading as a hang in the Test step, which is what the bound added in #295 exists to tell apart.
The Linux gate has been reporting "cancelled" with no log to read: a hanging test sat until the hosted runner lost communication, and a dead runner uploads nothing. Bounding the job cannot fix that — the bound needs a live runner to be enforced against. Give nextest the kill instead. It terminates the stuck test on its own, the run ends normally, and the summary names it, so the next red gate says which test rather than only that the runner died. The slowest healthy tests are the daemon PBKDF2 hashes (~20s) and `pins_skip_and_stop_vocabulary` (~49s), so nothing legitimate approaches the 360s kill.
…l(1) The Unix tree-kill spent the pgid as text on `kill(1)`, which has to guess whether a leading `-` opens an option or names a process group. procps-ng guesses wrong: `kill -KILL -<pgid>` signals the *calling* process group. So every `run_headless` teardown killed whoever asked for it. Locally that is the test process, which is why all 78 `ralphy-adapter-support::headless` tests died by SIGKILL on Linux; on CI it is the runner agent, which then uploaded no log at all and left the gate red with nothing to read. Windows was never exposed — that arm walks the process table by parent-PID. This is not only a test fault. A shipped Linux build sent that same signal on every agent teardown, so an unrelated process group on the operator's machine could be killed mid-run. `libc::kill` takes the negative pgid as a number, so there is nothing left to misparse, and `libc` was already a Unix dependency of this crate. A pgid of 1 is refused rather than sent: `kill(-1, ...)` means every process the caller may signal. The regression test keeps a bystander in the caller's own group and kills a group that no longer exists. It fails by SIGKILL against the old arm — the blast radius is the assertion.
… rewrite ADR-0053 plus the vocabulary it introduces (retry burn, unpriced volume, model recovery). A ledger line whose `model` is `unknown` is not the price-table gap ADR-0034 closes — it never recorded which engine spent the tokens, so there is no key to look up. The repair is a read-time join to the session store the usage scan already reads, kept in a persisted append-only map beside an append-only ledger. Committed as found in the working tree: this is design work that predates the release, and it is carried here so it is not lost, not because rc16 depends on it. Docs only — no code, and no effect on the shipped binary.
paulocorcino
force-pushed
the
feat/ui-v1.1
branch
from
July 28, 2026 12:52
6fa50be to
64f45d1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Brings the
feat/ui-v1.1line ontomain: the workbench revamp track (PRD #185) through the object desk, the fence model, and detachable fences on a second monitor (PRD #344).Highlights:
Verification
Local green gate on the tip commit:
cargo fmt --check,cargo clippy -- -D warnings,cargo nextest run --workspace. CI runs the same on Windows and Linux.🤖 Generated with Claude Code