fix(canvas): playability pass + UX polish (post #1897) - #1970
Merged
HongmingWang-Rabbit merged 18 commits intoApr 24, 2026
Conversation
Two latent bugs kept the "Processing with Claude Code..." timer ticking after the agent had already answered: 1. The A2A_RESPONSE store handler wrote into agentMessages[workspaceId] (no prefix) but ChatTab's "clear sending" effect subscribed to agentMessages["a2a:" + workspaceId]. Keys never matched — the effect was dead code from day one. Removed the dead subscription and moved the setSending(false) into the pendingAgentMsgs effect so any reply delivered via a WS push (Claude Code SDK, Hermes's send_message_to_user) also closes the spinner. 2. Added an activity-log fallback: when the platform emits a successful a2a_receive ACTIVITY_LOGGED for this workspace, clear sending and stop the timer. That covers the "runtime answered but we never saw the store message" case Claude Code exhibited tonight — the HTTP request can stay in flight while the SDK already pushed its reply. Symmetric a2a_receive error path also clears sending and surfaces the error message, so a runtime-side failure no longer hangs the UI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dragging one workspace onto another could pick a nested child as the "nearest" drop target instead of the visible parent card the user actually hovered. The effect: dropping a free-floating Hermes Agent onto a Claude Code Agent that already had a Hermes Agent nested inside showed "Move 'Hermes Agent' inside 'Hermes Agent'?" — the confirmation referenced the nested same-named child, not Claude Code. Why: getIntersectingNodes returns every overlapping node, including hidden=true children that render inside their parent's card. The parent and child share bounding boxes, so the child often "won" the nearest-distance check. Filter them out at the source: a node that's already got a parentId (or is hidden) is never a valid top-level drop target. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every workspace now renders as a first-class card on the canvas regardless of parent_id. The old "parent card contains mini TeamMember chips" layout is gone — if B is parented to A, B renders as a full card inside A's coordinate space using React Flow's `parentId` binding, so moving A carries B along and children have the same detail + actions as root cards. Details: - canvas-topology.ts: topologically sort parents before children (React Flow ordering requirement), compute each child's RF-native parentId + relative position on load. DB keeps absolute x/y; the abs→rel conversion happens here, reverse translation in Canvas.onNodeDragStop before savePosition PATCHes the DB. - WorkspaceNode.tsx: delete the EmbeddedTeam + TeamMemberChip blocks, simplify the size classes, and add NodeResizer (visible when selected) so users can drag any edge/corner to grow or shrink. Parent cards default to a larger min size so nested children have breathing room. - Canvas.tsx drop targeting rewritten: bounds-based hit test against each node's measured absolute bbox, deepest match wins. Fixes two prior bugs at once — dropping onto Claude Code with a nested same- named Hermes no longer picks the wrong node, and the target can now be a nested workspace when that's where the pointer actually released. - canvas.ts nestNode + removeNode: translate position between old and new parent's absolute origin on nest/unnest so the card doesn't jump, and re-point the RF `parentId` alongside `data.parentId` on reparent. - Tests: hidden-flag assertions replaced with parentId checks; obsolete TeamMemberChip a11y/eject tests deleted (the UI component no longer exists). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two playability bugs in the new flat-cards layout:
1. On first load or fresh org import a parent had no explicit width or
height, so children whose stored position sat inside their (eventual)
parent's rectangle rendered visually outside the smaller default
parent box. Compute a parent starting size in canvas-topology:
• 2-column grid of child-default footprints + header/side padding
• Grows per child count (2→1 row, 3-4→2 rows, etc.)
and stamp it onto the Node's width/height so the first paint already
contains every child.
2. If a child's stored relative position actually falls outside the
parent's computed bounds (legacy org-imports at 0,0, pre-refactor
absolute coordinates, manually-nudged rows), assign that child a
deterministic default grid slot inside the parent instead.
Runtime cascade: added growParentsToFitChildren to onNodesChange so when
the user drags or resizes a child past the parent's current bounds, the
parent grows to contain it (+padding). Miro/FigJam-style frame auto-fit
— grow-only, never shrinks under the user's manual resize.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ships the full prioritized improvement list from the canvas research report — aligns our nesting/resize UX with Miro / FigJam / tldraw / Figma conventions. Organized by priority below. ## P1 — baseline playability * Hysteresis on drag-out detach (Miro): a child only un-nests when >=20% of its bbox is outside the parent on release. Prevents accidental un-nesting from twitchy drags. * Drop-target now uses tree-depth DESC, then zIndex DESC, then area ASC to pick targets when nested parents overlap (xyflow #2827). * Children render above ancestors by inheriting zIndex = parent + 1 in topology and on every nest/unnest (xyflow #4012). * Live drop-target outline (existing) plus a Mural-style "Drop into: <name>" floating badge so colour isn't the only cue. * growParentsToFitChildren now fires only on dimension-type changes inside onNodesChange (NodeResizer commits) and once on drag-stop — avoids tldraw's edge-chase artifact (P3.11 commit-on-release). ## P2 — polish * Whimsical-style ghost preview: dashed outline at the next default grid slot inside the drop-target parent during drag. * Alt-drag escape with soft clamp: dropping slightly outside a parent without Alt/Cmd snaps the child back inside (clampChildIntoParent); Alt releases the clamp to allow un-nest; Cmd/Ctrl force-detaches. * Figma-style keyboard hierarchy nav: Enter descends to first child, Shift+Enter ascends to parent, Cmd+]/[ re-orders siblings via the new bumpZOrder store action. * Multi-select re-parent preserves offsets: confirmNest routes through a new batchNest action when the primary drag is part of a batch selection (Lucidchart pattern). ## P3 — long-tail * Minimap now shows parent cards as filled regions with a blue stroke, so hierarchy reads at a glance without zooming. * Out-of-bounds rescue is opt-in: topology no longer silently re-lays children whose stored position is outside the parent bbox (Figma trust-the-data). The new Arrange Children context menu item runs the rescue on demand via arrangeChildren. * Cmd-drag force-detach regardless of hysteresis. * Collapse workspace: the existing Collapse Team action now toggles a local setCollapsed store action that hides every descendant and shrinks the parent card to header-only (Miro frame outline view). Growth pass skips collapsed parents so they don't push back out. All 910 canvas tests green. Backend untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five Critical issues caught in code review of f3423a5. Each one broke an invariant the original commit claimed to uphold. 1. nestNode: descendants kept their old-depth zIndex after a re-parent. Now walks the dragged subtree and shifts every descendant's zIndex by the same depthDelta so "children above ancestors" survives moves between levels of the hierarchy. 2. bumpZOrder: siblings all share zIndex = depth in fresh topology, so a single +1 bump was identical for every sibling and subsequent bumps drifted zIndex unboundedly. Rewritten to sort siblings by current zIndex and swap the target with its neighbour in the bump direction — Figma-style reorder, stays within the sibling tier. 3. findDropTarget: depth-first tiebreaker lost to bumped siblings. The visually-frontmost card after Cmd+] is a shallow sibling, but the hit test picked the deepest nested card regardless. Swapped order so zIndex wins first, depth second, area third. Also pre-computes the depth map once per call (was O(n²) via repeated .find walks — will matter past ~30 workspaces). 4. arrangeChildren: saved absolute position using `slot + parent.position`, but parent.position is RELATIVE to its own parent when nested. Grandchildren's stored x/y were in the parent's local frame and reload placed them in the wrong spot. Now walks the full ancestor chain via absOf() to get the true canvas-absolute origin before PATCHing. 5. setCollapsed: naive flip of every descendant's hidden flag diverged from the topology rebuild on hydrate. Collapse A, collapse B, then expand A — C should stay hidden because B is still collapsed, but before this fix C was unhidden. Rewritten to recompute every descendant's hidden from the full ancestry chain, matching the topology pass byte-for-byte. New round-trip test asserts the two code paths produce identical node.hidden across a full lifecycle. Also: - Removed dead cascadeMessage constant (never rendered). - Replaced hardcoded 260/120 in zoom-to-team with exported constants. - arrangeChildren PATCH catch now logs instead of silently swallowing. - Added 70→76 tests: setCollapsed 3-chain scenarios, bumpZOrder swap semantics, edge-of-list no-op. All 915 canvas tests green. Backend untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two concerns in one commit (separate files, each self-contained):
## Canvas.tsx split (from ~680 to ~250 lines)
Canvas.tsx was holding drag gesture state + keyboard shortcuts +
viewport wiring + JSX. Each concern now lives in its own unit under
canvas/src/components/canvas/:
- dragUtils.ts — pure: shouldDetach, clampChildIntoParent,
DETACH_FRACTION
- DropTargetBadge.tsx — the floating "Drop into: <name>" label + the
dashed ghost preview at the target slot
- useDragHandlers.ts — encapsulates onNodeDragStart / Drag / Stop,
findDropTarget hit-test, pendingNest state,
and confirmNest/cancelNest. Routes multi-
select drags through batchNest automatically.
- useKeyboardShortcuts — Esc, Enter, Shift+Enter, Cmd+]/[, Z — one
window listener, one source of truth.
- useCanvasViewport — pan-to-node + zoom-to-team CustomEvent
listeners and the debounced viewport save.
Canvas.tsx becomes a thin composition + JSX file. No behavioural
change; the refactor is covered by the existing 915 canvas tests.
## batchNest parallelization (2N round-trips → N, all in flight)
Previously nestNode fired two sequential PATCHes (parent_id then x/y)
and batchNest looped nestNode sequentially. For a 5-node selection on
a typical ~200ms link this was ~2s of serialized RPCs.
- nestNode now combines parent_id + x + y into ONE PATCH. The Go
handler (workspace_crud.go Update) already reads all three from the
same body — no backend change.
- batchNest rewritten: compute every re-parent plan against one
snapshot, commit a single set(), then fire N PATCHes via
Promise.allSettled in parallel. Per-node failures roll back only
that node (others stay committed) — same semantics as the single-
node path, just concurrent.
- The state math in the batch path also correctly shifts descendant
zIndex by depthDelta when any re-parented node has a subtree.
## Also
- canvas-topology.ts: reverted P3.12's opt-in rescue to the auto-
rescue default. When a child's stored relative position would render
it outside the parent bbox (the visual regression the user saw after
collapse → reload — Hermes child drawn outside Claude Code Agent on
first paint), the child is placed in the next default grid slot.
The "Arrange Children" context command stays for bigger teams.
All 915 canvas tests pass. No backend changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five issues surfaced in the review of 50b5378. Each was either a real bug waiting to hit users or a silent failure mode. 1. Topology rescue no longer teleports user-resized children. Rescue was comparing against parentMinSize(childCount), so any child the user had placed in space the parent was resized into got snapped to the default grid on reload — undoing the layout. Now rescue fires only on obviously corrupt data: negative relative coords (legacy pre-nesting absolute positions that landed above/left of their assigned parent) or values past an MAX_PLAUSIBLE_OFFSET threshold. Children just-past the initial minimum are left alone. 2. batchNest now filters to selection-roots before planning. Previously selecting both A and A's descendant B and dragging into T yanked B out of A to become a sibling under T. Users reasonably expect the A subtree to move intact. The new pass drops any selected node whose ancestor is also selected — those follow their ancestor via React Flow's parent binding. 3. batchNest surfaces partial failure via showToast. Previously silent: 2 of 5 PATCHes fail, user sees 3 cards re-parented + 2 snapped back with no explanation. Now names the failed cards. 4. confirmNest closes the nest dialog BEFORE dispatching the async store action, so a second drag can't kick off a competing batch while the first is still in flight. 5. collapsed is now persisted. The Go workspace_crud.go Update handler ignored the `collapsed` field, so user-initiated collapse round-tripped to an expanded state on next hydrate. Added the PATCH branch (`UPDATE workspaces SET collapsed = ...`) so the state survives reload. Nits cleaned: * Removed dead dragStartParentRef in useDragHandlers. * Swapped redundant `node.data as WorkspaceNodeData` casts for a named WorkspaceNode type alias. * Canvas.tsx SR-live region now reads n.parentId (matches MiniMap + RF's native field) instead of the mirror n.data.parentId. Tests added (918 total, up from 915): * batchNest happy path — 2-root selection fires 2 combined PATCHes carrying parent_id + x + y, not 2×N sequential round-trips. * batchNest ancestor+descendant selection — subtree stays intact. * batchNest partial failure rollback — only the rejected nodes revert; successful ones stay committed. Backend change is single-line (collapsed PATCH branch); all workspace_crud Go tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three follow-up review findings from the c2b2e13 review: 1. Rescue heuristic uses pure bbox-non-overlap. The previous `position.x < 0` branch rescued any child whose parent was later dragged past it, even when the layout was clearly recoverable (e.g. relative -40, child still overlaps parent). New rule: rescue iff the child's bbox has zero overlap with the parent's bbox — self-calibrating, scales with user-resized parents, catches screenshot-case and legacy huge-positive data. 2. Toast caps failed-name list at 3 and appends "and N more". Stops a 50-node partial failure from overflowing the toast container. 3. Cycle guard on selection-roots walk in batchNest. Corrupt parentId data can't send the loop infinite now. Cheap defensive guard — one Set per selected node. Tests added (923 total, up from 918): * canvas-topology.test: 4 rescue scenarios — screenshot case (zero-overlap rescue), negative drift kept, huge-positive rescued, user-resized layout kept. * canvas.test: selection-roots filter on a 3-level chain. * workspace_crud test: PATCH {collapsed:true} runs the UPDATE. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sibility) Six bugs reported from a live session — all shippable in one commit: 1. Peers tab 401 on local Docker. The /registry/:id/peers endpoint demands a workspace-scoped bearer token (validateDiscoveryCaller) which the canvas session doesn't hold. Added the same Tier-1b dev-mode fail-open hatch that AdminAuth and WorkspaceAuth already use — gated by MOLECULE_ENV=development + empty ADMIN_TOKEN, so SaaS production stays strict. Exported IsDevModeFailOpen from the middleware package for the handler layer to reuse. 2. Org Templates list unscrollable. OrgTemplatesSection was rendered in the TemplatePalette footer — a div without overflow — so when it expanded to 15+ entries the list extended past the viewport with no scroll. Moved it to the top of the flex-1 overflow-y-auto container. Tall lists now scroll naturally. 3. Chat tab: "My Chat" and "Agent Comms" rendered stacked instead of switching. HTML `hidden` attribute was being overridden by Tailwind's `flex` class (display: flex beats the attribute), so both tabpanels rendered concurrently. Swapped to a conditional Tailwind `hidden`/`flex` class so the inactive panel is display:none with proper CSS specificity. 4. Hermes Config form never persists. handleSave wrote config.yaml but name / tier / runtime / model all live on the workspace row (or the dedicated /workspaces/:id/model endpoint) — the form edited in-memory, the request returned 200, the next reload wiped everything back. Hermes + external runtimes manage their own config inside the container anyway, so writing config.yaml is a no-op for them; skip it. Always diff and PATCH the DB-backed fields that actually changed. 5. Channels "+ Connect" dropdown empty on first open. ChannelsTab's load() used Promise.all with a silent catch — if EITHER the channels or adapters fetch failed, both setters were skipped with no error visible. Switched to Promise.allSettled so each endpoint settles independently, and the adapters failure now surfaces via the top-level error state. 6. Plugin registry always "No plugins in registry". Same silent catch pattern in SkillsTab.tsx — load errors for /plugins, /plugins/sources, and /workspaces/:id/plugins swallowed without logging. Replaced the empty catches with console.warn so future failures are at least visible in devtools. Tests: 923 passing (unchanged). Go handler tests pass. Server rebuilt and running with the peers-auth + collapsed-persistence fixes (pid 15875). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five review findings from the 3f11df0 six-bug commit: 1. Add TestPeers_DevModeFailOpen_{Allows,ClosedWhenAdminTokenSet, ClosedInProduction} covering all three gating states for the security-sensitive dev-mode hatch the prior commit added to /registry/:id/peers. Previously shipped untested — a future refactor could have silently inverted polarity or removed the gate. New tests pin the contract: * MOLECULE_ENV=development + ADMIN_TOKEN="" → allow bearerless * MOLECULE_ENV=development + ADMIN_TOKEN set → require token * MOLECULE_ENV=production → require token 2. ConfigTab handleSave diffs against the RAW parsed YAML / form config instead of the DEFAULT_CONFIG-merged shape. The previous code would silently PATCH tier=1 to the DB when a user deleted the `tier:` line in raw mode (the default-merge substituted 1). Now: only fields the user actually typed participate in the diff. Type guards (typeof === "number" / "string") prevent coercion surprises on malformed YAML. 3. ConfigTab model-save failure no longer lies "Saved". The /workspaces/:id/model PATCH can reject when the runtime doesn't support the chosen model; previously we caught + console.warn'd + showed green Saved, and the user watched the model revert on next reload with no explanation. Now the save path collects a `modelSaveError` and surfaces it via setError with a partial- success message ("Other fields saved, but model update failed: …") so the user sees why. 4. ChannelsTab now surfaces BOTH channels-fetch and adapters-fetch failures, distinguishing them in the error text ("Failed to load connected channels and platforms — try refreshing"). Previously only an adapters failure was visible; a channels failure left the user with an apparently-empty list and no indication the API was unreachable. 5. ChatTab panels drop the redundant aria-hidden attribute. The `hidden`/`flex` Tailwind class already sets display:none, which removes the node from the accessibility tree on its own; the extra aria-hidden invited WAI-ARIA lint warnings if a focusable descendant ever landed inside an inactive panel. Tests: 923 canvas + full Go handler suite pass. 3 new Go tests. No behaviour change on the five prior fixes — this commit tightens their edges per the independent review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Importing a 15-workspace org template dropped every child as a freely-positioned card into its parent's coordinate space. Parents with 5-10 kids had the kids spill below the parent's initial min size, producing the "ugly default" layout the user just flagged — a mess of overlapping cards the moment the import completed. Fix: every workspace in an org-template import that HAS children is inserted with `collapsed = true`. Leaf workspaces stay expanded (nothing to hide). The canvas renders a collapsed parent as a compact header-only card with its "N sub" badge — visually identical to the pre-refactor default the user asked for. Double-click on a collapsed parent now EXPANDS it (flipping `collapsed` locally + persisting via PATCH) so the user can drill in to see the subtree. Only once expanded does a second double-click zoom-to-team, matching the prior behaviour. Leaf-first creation order stays the same; the collapsed flag just means "render compact" not "hide from API". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 600-req/min/IP bucket is sized for SaaS where each tenant has a distinct client IP. On a local Docker setup every panel shares one IP — hydration (/workspaces + /templates + /org/templates + /approvals/pending) plus polling (A2A overlay + activity tabs + approvals + schedule + channels + audit trail) can burst past the bucket inside a minute, blanking the canvas with 429s. The user reported it after dragging workspaces — dragging itself is release-only (savePosition in onNodeDragStop), but the polling that's always running added onto startup tripped the limit. Two-layer fix: Server: RateLimiter.Middleware short-circuits when isDevModeFailOpen is true (MOLECULE_ENV=development + empty ADMIN_TOKEN), matching the Tier-1b hatch already applied to AdminAuth, WorkspaceAuth, and discovery. SaaS production keeps the bucket. Client: api.ts auto-retries a single 429 on idempotent GET requests, waiting the server-provided Retry-After (capped at 20s). Mutations (POST/PUT/PATCH/DELETE) never auto-retry to avoid double-applying. Users on SaaS hitting a legitimate rate-limit spike get one transparent recovery instead of an immediately-blank Canvas. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Un-nest used to require holding Alt (or Cmd to force-detach). That
was too conservative — when a user dragged a child clearly outside
its parent's bbox, nothing happened on release, because the default
branch soft-clamped back and only the Alt branch actually opened
the "Extract?" confirm. Matches the exact bug the user just flagged
("I can put agents in other agent, but when I drag it out, it does
not move out").
New rules:
* Past the 20 % hysteresis → confirm un-nest. Plain drag, no
modifier. This is what most users expect (Miro / Figma behave
the same way — drag outside the frame and the shape leaves it).
* Inside or within 20 % of the edge → soft-clamp back inside.
Guards against twitchy releases that momentarily overshoot the
edge by a few pixels.
* Cmd / Ctrl → force un-nest regardless of overlap. Escape-hatch
for when the user dragged within the hysteresis zone but really
wants out.
* Dropping onto a different parent → nest there (unchanged).
Alt is no longer a required modifier for un-nesting. Keeps it as
a non-gesture modifier only; no meaning unless we re-bind it later.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-up polish items for drag-and-nest: 1. Cancelling the "Extract from team?" dialog now snaps the dragged card back to where the drag started. Before, a user who dragged a child out, saw the confirm dialog, then clicked Cancel ended up with the card stranded outside the parent at its drop-point position — which also got persisted via savePosition on drag-stop. Now onNodeDragStart captures the pre-drag position + parent, and cancelNest restores both the RF node position and fires savePosition with the absolute pre-drag coords so reload matches. 2. Un-nesting now clears the ex-parent's explicit width/height in the nodes array. growParentsToFitChildren is grow-only so it could never shrink the parent back down after a child left; the card stayed at its auto-grown size with empty space. Stripping width/height lets React Flow re-measure from the card's own min-width / min-height CSS, so the parent visually shrinks to fit whatever children remain. 923 canvas tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Canceling the nest/extract dialog restored the child's position but left the parent card at its auto-grown size. growParentsToFitChildren fires on drag-stop to fit a then-outside child; when the drag is subsequently cancelled, the parent keeps that grown width/height forever because the grow pass is grow-only. Strip width/height from the ex-parent alongside the child position restore in cancelNest — React Flow re-measures from CSS, parent collapses back to its natural size. Same trick nestNode already uses for the un-nest path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
React Flow requires parent nodes to appear before their children in
the nodes array. When they don't, it logs "Parent node {id} not
found. Please make sure that parent nodes are in front of their
child nodes in the nodes array" and — more importantly — renders
the child at canvas-absolute coords instead of parent-relative,
flashing it far outside the parent.
topology's buildNodesAndEdges already enforced this at hydrate, but
nestNode + batchNest weren't re-sorting after mutating parentId.
A freshly-nested child often ended up after-first-drag at the
wrong screen position because its new parent sat later in the
array than itself.
Extract sortParentsBeforeChildren() into canvas-topology as a
reusable DFS visit; call it at the tail of both nestNode's set()
and batchNest's commit set(). 923 tests still green — no behaviour
change beyond eliminating the warning and the position flash.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tart-plus-hotfixes
2 tasks
HongmingWang-Rabbit
pushed a commit
that referenced
this pull request
Jun 12, 2026
…ecule-core via codegen + verify-CI (NO behavior change)' (#1970) from feat/internal-718-p2a-registry-codegen-distribution into main
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
Follow-up work on the canvas after #1897 was merged. Twelve commits covering nesting-UX refactor, drag/resize playability, plus a batch of UX bugfixes from live testing.
What landed
Canvas nesting UX (the big one)
cc194f0bflat workspace cards via React Flow native parentId (no more embedded mini-chips)d359390fparent auto-fit sizing + rescue out-of-bounds childrenf3423a51industry-pattern playability pass (P1+P2+P3 — hysteresis, drop-target highlight + ghost preview, depth/zIndex tiebreakers, Alt-drag escape, Cmd+]/[ z-order, multi-select re-parent, keyboard Enter/Shift+Enter nav, minimap parent outlines, collapse team)c5abed98address review findings on the playability pass (nestNode descendant zIndex, bumpZOrder swap, tiebreaker order, arrangeChildren absolute coords, setCollapsed/hydrate parity)50b53784split Canvas.tsx into hooks (dragUtils, DropTargetBadge, useDragHandlers, useKeyboardShortcuts, useCanvasViewport); parallelize batchNest (1 PATCH/node, Promise.allSettled)c2b2e13aaddress review findings on refactor (rescue heuristic, batchNest filter/toast, confirmNest race, collapsed PATCH plumbing)4fd7f1e8tighten rescue to bbox-non-overlap, cap toast name list, cycle guard, more tests286dcbfdcollapse org-imported parents on first paint so imports don't sprawlDrop-target + drag fixes
7356cf8dchat sending spinner clears on any reply path (WS push / HTTP / activity_logged)8a07cf40skip already-nested workspaces as drop targetsUX bugs from live testing
3f11df03six bugs (peers 401, org-templates scroll, chat tabs stacked, Hermes config persist, channels dropdown, plugin registry visibility)507696d8review findings on the six-bug commit (peers auth test coverage, ConfigTab raw-mode diff, partial-save UX, channels error surface)Backend changes
/registry/:id/peers(Tier-1b, same gate as AdminAuth/WorkspaceAuth) with 3 new tests pinning the gating.collapsedfield now persisted byPATCH /workspaces/:id(with test).collapsed = trueon any parent with children.runtimeso the canvas Runtime pill renders correctly.a2aClient60s HTTP client-timeout removed (OAuth cold-starts were hitting it).ReadFilehandler joins rootPath + filePath into one cat arg (was two, returning empty).Test plan
🤖 Generated with Claude Code