Skip to content

feat: branches/worktrees tabs, canvas selection + solo, zoom-bug fix#39

Open
lukataylo wants to merge 8 commits into
mainfrom
feat/branches-worktrees-tabs-and-canvas-zoom-fix
Open

feat: branches/worktrees tabs, canvas selection + solo, zoom-bug fix#39
lukataylo wants to merge 8 commits into
mainfrom
feat/branches-worktrees-tabs-and-canvas-zoom-fix

Conversation

@lukataylo
Copy link
Copy Markdown
Owner

Summary

  • Branches + Worktrees tabs ship as siblings of Layers in the left side panel. Click a branch row to pan-and-zoom canvas to its frames; Cmd-click toggles Solo mode (other branches hidden, banner pinned). Click a worktree row to set the dispatch target — surfaces as a → ~/path chip in the TopBar and threads through to MCP as worktreeHint on the dispatch payload.
  • Cross-browser canvas zoom fix — Safari pinch was killing the toolbar layout because (a) the wheel listener was rebinding on every zoom event and dropping preventDefault under fast pinches, and (b) Safari fires gesturestart/change/end instead of wheel+ctrl during a pinch, so our handler was silent. Wheel listener now binds once via a ref; Safari gesture handlers drive zoom directly from e.scale. All chrome z-indexes lifted to 95–110.
  • Marketing route blocked by ad-blockersCookieBanner.tsx / CookiePolicy.tsx paths matched uBlock/Safari Content Blocker heuristics → ERR_BLOCKED_BY_CONTENT_BLOCKER on lazy import. Renamed to ConsentNotice.tsx / StoragePolicy.tsx. URLs preserved (/cookies, /cookie-policy still route).

What's in the box

Selection model (new)

  • apps/web/src/state/selectionStore.ts — small useSyncExternalStore + localStorage store owning selectedBranchId / soloBranchId / activeWorktreeId. Setters auto-interlock (selecting a new branch exits Solo; entering Solo selects the branch).
  • registerFitToHook(rect) in plugins/registry.ts — Branches plugin can pan canvas to a world rect without importing canvas internals (same escape-hatch pattern as registerSelectFrameHook).
  • SoloBanner pinned at canvas top with Exit Solo button so the mode can't get stuck even if both panels are pill-collapsed.

Branches panel

  • Real branch data from BoardStore + stubbed PR/ahead-behind metadata (deterministic by branch name). Banner declares what's stubbed.
  • Grouped by status (Active / Stale 30d+ / Merged); filter chips (Active / Mine / Stale / All); search input.
  • Selection paints accent left-border + bg tint + "Focused on canvas" status line. Per-row actions: Check out, Open PR, Solo. Esc clears.

Worktrees panel

  • Stubbed 3-row list in the shape the future /api/worktrees endpoint will return. Banner declares the data is stubbed.
  • Selecting a row writes activeWorktreeId to the selection store. No canvas pan (worktree = future-tense dispatch target, not visual focus).

TopBar worktree chip

  • New → ~/path chip right of the MCP chip; click does nothing (status only); hides when no worktree is active.

MCP wire

  • CreateDispatchRequest.worktreeHint?: string in @foldo/protocol.
  • useDispatchFlow and DomEditor save-to-source both read selectionStore.activeWorktreeId at dispatch time and attach the path.
  • Server attaches worktreeHint to the in-flight Dispatch object before broadcasting + routing to /ws/mcp (not yet persisted — schema migration is a follow-up).
  • MCP bridge logs worktree hint: <path> as a dispatch.progress event. Honouring it as cwd in runApplyEdit is the next backend piece.

SidePanel pill collapse

  • Both LeftPanel and RightPanel get a manual ‹/› collapse button. Collapsed = 36px vertical icon rail with one button per tab; click expands + activates that tab. State persisted to localStorage per side.

Canvas zoom

  • Canvas.tsx wheel listener now reads zoom through a zoomRef instead of listing viewport.zoom in effect deps. Listener binds once on mount.
  • Safari gesturestart / gesturechange / gestureend listeners drive zoom from e.scale and preventDefault Safari's visual-viewport zoom that previously pushed toolbars off-screen.
  • Chrome chrome path unchanged (wheel+ctrlKey).

Polish

  • Comment pin shadow uses filter: drop-shadow so the teardrop outline traces correctly instead of haloing.
  • Markdown frame "TINTS ON" button no longer wraps to two lines (whitespace-nowrap + flex-shrink-0).
  • Inspect (DomEditor) tightened (24px controls, smaller gaps); Layers panel typography tightened (11–12px font, 24px rows); SidePanel + TopBar both sit on the 12px left edge.
  • Demo User chip height now matches Capture/Tests/Share.

Plugin substrate (root cause of "3 toolbars" bug)

  • PluginRegistry.install is now idempotent by manifest.id; new registry.reset() called from bootPlugins so dev-mode HMR no longer accumulates duplicate plugin contributions.

Cleanup

  • Deleted apps/web/src/components/LeftRail.tsx. Its foldo-canvas-leftrail + foldo-rail-tool-* testids now live on the bottom PluginToolBar, so the existing e2e suite keeps clicking tools by name.
  • core-plugins-still-work.spec.ts no longer asserts both legacy and new toolbar testids.

Test plan

  • npm run typecheck — clean across all 8 workspaces.
  • npx vitest run — 150 passed / 12 skipped (pre-existing).
  • npm --workspace @foldo/web run build — clean; App-*.js is 173.6 → 177.3 KB (+3.7 KB raw / +1.1 KB gzip). Bundle budget headroom: 69%.
  • prettier --check on new files — formatted.
  • Playwright e2e (will run in CI).
  • Manual smoke: branches panel selection + pan-to-fit, worktree chip in topbar, Solo banner + exit, comment pin shadow on light/dark, "TINTS ON" wrap, Inspect density, canvas pinch zoom in Chrome + Safari, marketing routes load with ad-blockers active.

Notes for review

  • worktreeHint is intentionally not persisted on the dispatches table yet — it's attached in-flight only. DLQ retries after a process restart will lose the hint but the dispatch still succeeds; persisting it is the next schema migration.
  • Stub banners on the Branches + Worktrees panels make it visually obvious which pieces are awaiting backend wiring (/api/branches/:id/git, /api/worktrees).

🤖 Generated with Claude Code

lukataylor-pixel and others added 8 commits May 25, 2026 14:45
Ships the cross-cutting batch the user pulled together this session:

Branches + Worktrees side-panel tabs
  - New core/branches + core/worktrees plugins; live in the left
    SidePanel alongside Layers
  - Selection store (apps/web/src/state/selectionStore.ts) owns
    selectedBranchId / soloBranchId / activeWorktreeId, persisted to
    localStorage and observable via useSyncExternalStore
  - Branch row click pans canvas to fit that branch's frame bounds
    via new registerFitToHook() — Cmd-click toggles Solo
  - Branches grouped Active / Stale / Merged with filter chips
    (active default; merged collapsed)
  - First-load dismissable tips explain the model

Solo / isolate mode
  - FrameLayer accepts soloBranchId prop; skips non-matching frames
  - SoloBanner pinned to canvas top with "Exit Solo" so the mode can
    never get stuck even when both panels are collapsed
  - Selection store keeps solo + selection state interlocked

Worktree dispatch target
  - Active worktree shown as "→ ~/path" chip in TopBar (right of MCP)
  - CreateDispatchRequest.worktreeHint? added to @foldo/protocol
  - useDispatchFlow + DomEditor save-to-source inject the hint at
    dispatch time; server attaches to in-flight Dispatch object;
    MCP bridge logs "worktree hint: <path>" as a progress event

SidePanel pill-collapse
  - Manual ‹/› collapse on either panel; localStorage-persisted
  - Pill: 36px vertical icon rail with one button per tab; click
    expands + activates

Canvas zoom toolbar-loss bug (cross-browser)
  - Wheel listener now binds once with a zoomRef instead of
    re-binding on every zoom event (eliminated race window that
    dropped preventDefault under fast trackpad pinches)
  - Safari proprietary gesture events (gesturestart/change/end)
    drive zoom directly from e.scale, anchored at the snap point
    — previously Safari suppressed wheel+ctrlKey during a pinch
    and our preventDefault left zoom dead
  - All chrome elements (TopBar, panels, plugin toolbar, zoom
    control, first-run hint) bumped to z-index 95–110 so canvas
    content can never paint over them

Marketing route fix
  - CookieBanner.tsx -> ConsentNotice.tsx, CookiePolicy.tsx
    -> StoragePolicy.tsx (file paths matched ad-blocker heuristics
    causing ERR_BLOCKED_BY_CONTENT_BLOCKER on lazy import)
  - /cookies and /cookie-policy URLs preserved

Polish
  - Comment pin shadow now uses filter:drop-shadow so the teardrop
    outline traces correctly instead of haloing on light backgrounds
  - Markdown frame "TINTS ON" button no longer wraps to two lines
    (added whitespace-nowrap + flex-shrink-0)
  - Inspect (DomEditor) tightened: 28→24px controls, 12→8px stack
    gap, smaller ghost/save buttons; pick/save/ghost button styling
  - Layers panel typography tightened: search 16→12px, toolbar
    buttons 40→24px, tree rows 36→24px
  - SidePanel + TopBar align on the same 12px left edge inset
  - Demo User chip matches Capture/Tests/Share chip height

Plugin substrate
  - PluginRegistry.install is now idempotent by manifest.id, and
    bootPlugins() calls registry.reset() each time so dev-mode HMR
    no longer duplicates plugin contributions (root cause of the
    "3 toolbars" bug)

Cleanup
  - Deleted apps/web/src/components/LeftRail.tsx (no longer rendered;
    its testids now live on the bottom PluginToolBar)
  - Updated core-plugins-still-work.spec to stop asserting the
    legacy LeftRail testid alongside the new toolbar testid

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clicking the Demo User chip opened the dropdown and Safari's default
button focus chrome painted a light system colour over `bg-panel`,
"removing" the background. `appearance-none` strips that default;
`focus:bg-panel active:bg-panel focus-visible:bg-panel` pin the
panel colour across every interaction state so the chip matches
the Capture/Tests/Share chips next to it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…anels

The hover state was painting a 5% white tint that read as "background lost"
against the dark chip, especially while the dropdown was open and the chip
was also focused. Drop the bg-white/5 hover and signal interactivity via a
border-colour nudge instead so the dark fill stays solid behind the white
label.

Also bump the TopBar wrapper to z-[120] and the user-picker dropdown to
z-[130] so the SidePanel/InspectPanel (z-[100]) can never overlap the
opened dropdown — previously the dropdown could land under a sibling panel
when both share the same z-index and the panel appears later in the DOM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A soloBranchId persisted to localStorage from an earlier session points
at a branch the current board may not have (different board, seed reset,
etc.) — FrameLayer's Solo filter would then hide every frame on the
canvas. A user landing on the board would see an empty canvas + no
obvious affordance to recover (the SoloBanner shows the branch name but
not why frames are missing). Auto-clear soloBranchId + selectedBranchId
when the soloed branch isn't present in the hydrated board.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the chrome z-indexes got bumped to 100-110 to stay above canvas
content on zoom, several content-floating overlays were left at z-50/60:

  - CommentPopover (z-60): pin dropped, popover rendered, but it sat
    UNDER the LeftPanel/RightPanel (now z-100) so the user couldn't
    see it or compose. This is the user-reported "click frame to add
    comment fails" — the pin appeared, the popover was just hidden.
  - EditPanel (z-50): "AI edit" right-side dispatch panel could be
    fully obscured by RightPanel.
  - ToastStack (z-50): toasts could end up behind the bottom toolbar.

Lifted all three to z-[140] (above panels at z-100/120, toolbar at
z-110, below SoloBanner at z-95 — wait, banner is also bumped via the
same fix). Confirmed CommentPopover is now visible after Comment tool
→ click frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rapper

Two issues hit when dropping a comment pin in fresh Safari:

1. POST /api/comments returned 400 "Invalid comment body" because the
   server validation rejected empty text. The pin-drop flow creates the
   comment optimistically with `text: ''` (the compose popover PATCHes
   the body in afterwards) — rejecting empty text broke that handshake
   and made the optimistic comment vanish a moment after the pin
   appeared. Validation now requires `typeof text === 'string'` so the
   empty-then-patch flow works.

2. React warned "Each child in a list should have a unique 'key' prop"
   from MarkdownView. The line-author tint wraps the rendered element
   in a <div> when there's an entry — the inner element had `key={idx}`
   but the wrapper didn't, so React couldn't reconcile the wrapper
   case. Threaded `idx` into `lineWrap` and put the key on the wrapper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gin spam

The "Unable to post message to http://localhost:5174. Recipient has
origin http://localhost:5173" console spam came from a race: when the
review-mode / overrides effects fired before the iframe finished
loading, iframe.contentWindow was still on about:blank (inheriting the
parent's origin). postMessage with the expected localhost:5174
targetOrigin then logs a cross-origin error in every browser, even
though our try/catch swallows the throw.

Track an `iframeLoadedRef` that flips true on either:
  - `foldo.sample.ready` message from the sample-app (preferred), or
  - the native iframe `onLoad` event (backstop).

Effects gate their postToFrame on the ref. Reset to false whenever
the iframe URL changes or the frame re-enters the viewport, so a
fresh navigation doesn't reuse the previous load's ready state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bottom dock's box-shadow was 0 12px 32px rgba(0,0,0,0.45) — strong
enough on Retina at certain canvas zooms to read as a "ghost toolbar"
outline above the real dock. Pull the offset (12→6) and blur (32→18)
in and trim alpha (0.45→0.35) so the dock reads as a single floating
card without the halo.

Also add a dev-only useEffect that counts how many
data-testid="foldo-canvas-leftrail" elements are on the page after
mount and console.warns if there's more than one. If a real
double-render slips past the substrate's idempotency (HMR misfire,
accidental second <PluginToolBar/>), this surfaces it as a console
warning rather than a silent visual bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants