Skip to content

Latest commit

 

History

History
37 lines (34 loc) · 22.6 KB

File metadata and controls

37 lines (34 loc) · 22.6 KB

KB: Architecture patterns

Moved VERBATIM out of CLAUDE.md (tier-2 pass).

Architecture Patterns

  • All frontend cross-class communication goes through App (mediator pattern). Classes receive app in constructor and call app.methodName().
  • One-time WebSocket handler pattern: Register via ws.onGlobal(), match on type + sessionId, remove self after first match. Used in createSession(), attachSession(), resumeSession().
  • Debounced auto-save: LayoutManager.scheduleAutoSave() waits 2s after last change. Blocked by _restoring flag for 5s on page load.
  • Resizer: Reusable component with inside: true mode for fixed-position elements (sidebar). Don't set position or flex on elements that aren't flex children.
  • Layout restore: attachSession() returns winInfo synchronously (the DOM element). Position is applied directly to this winInfo, NOT by guessing via windows.values().pop(). The WebSocket attach is async but the window element already exists.
  • Proportional bounds tracking: win.gridBounds stores position {left, top, width, height} as fractions (0-1) of workspace. _reflowWindows() (via ResizeObserver) recalculates pixel positions on workspace resize. Applies universally — grid snap, edge snap, drag-drop, and applyLayout() all capture bounds. User resize updates proportions on mouseup. Works in both grid and freeform modes.
  • Title-bar right-click = full window menu (2.212.0): showWindowContextMenu (taskbar.js, shared with taskbar items/group items/window-list rows) with switchSubmenu:true — "Switch window" submenu (scope via window.titlebarSwitchScope: overlap = rect intersection like the classic switcher / desktop / all, cross-desktop entries name their desktop, click → app.goToWinId = tab-chain+stage+desktop aware) + Rename…/Task Groups for SESSION windows (sessionForWin resolves the sidebar session via _openSpec backendSessionId/serverId; same bind semantics as the card menu) + Move/Minimize/Move-to-Desktop/Close. The □ title-bar BUTTON keeps the classic overlap popup (_showOverlapSwitcher, _rectsOverlap). utils showContextMenu submenu children now honor disabled (they silently fired their action before).
  • WebSocket reconnect re-attach: On WS reconnect, all active sessions are re-attached. Timeout ≠ dead (2.234.1, userL mass false-death incident): the server sends a synchronous attach-ack at the top of every attach (proof-of-life before any slow await — remote transcript pulls, history rebuilds, MB-scale reply bursts can lawfully exceed 20s on a degraded instance while every session is alive); the client's _reattach no-reply fallback is a RETRY LADDER (re-send while un-acked, wait once acked, flip read-only only after ~2min) and its timeout messages say "may still be running", never "no longer exists" — that certainty is reserved for the explicit not-found error reply. Chat sessions call _reattach() which re-sends attach → server responds with latest normalized messages + isStreaming from wrapper metadata. globalHandlers in ws.js are preserved across reconnects.
  • Layout sync (multi-client): State-based — full workspace state broadcast via layout-sync WS message after every change. Server saves + rebroadcasts (excluding sender). Receiver does smart diff via _applyRemoteState(): matches windows by unique ID, syncs positions/state/z-order, opens/closes windows as needed.
  • Atomic openSpec: createWindow({ openSpec }) sets the openSpec before _notify() fires, ensuring the first layout broadcast includes the window creation recipe. Only createSession (resume) sets openSpec async (in WS created callback) because serverId is unknown at creation time — it explicitly re-broadcasts via scheduleAutoSave().
  • ChatView module split: ChatView is the controller (virtual scroll, op dispatch). Rendering delegated to ChatRenderers (returns DOM elements). renderSystemMsg returns {el, sideEffect} to avoid circular deps — ChatView applies side effects to ChatStatusBar/ChatInput. Search, input, status bar, minimap are standalone classes receiving callbacks for cross-module communication.
  • Menus are REGISTRATIONS (Plugin Ph1, contributions.js): the sidebar card menu ('session-card'), the window title-bar/taskbar/window-list menu ('window') and the ⚙ gear menu ('gear') are registerMenuItem({ menu, command | label+run, group, order, when }) contributions rendered by menuItems(menu, ctx) into showContextMenu's item shape — add a row by registering it in the owning module (session-card.js / taskbar.js / gear-menu.js), never by editing a literal; reusable verbs are registerCommand commands (session.* shared by the card AND the window menu). Separators are explicit contributions; when/children are functions of ctx; an empty submenu drops itself. New shortcuts for plugins go through registerKeybinding (strict chords, bubble-phase document dispatcher under the app-lifetime AbortController); core chords keep their capture-phase dispatchers (modifier-lenient by design) and only route their ACTION through runCommand. File-explorer menus are still literals (not yet migrated). The ⚙ menu is a TREE (2.369.124, docs/design-gear-menu-hierarchy.md): submenu:true heads + parent on members (contributions.js resolves lazily and falls to top level — never drops a row). POPOVER PROTOCOL for a nested flyout: the flyout element is appended INSIDE its head (a DOM descendant, so mouseleave never fires when the pointer crosses into it) and carries data-popover so attachPopoverClose's child rule keeps the parent open; Esc is layered with a POPOVER-LOCAL keydown + stopPropagation while a flyout is open (the createModalShell pattern) and left to the global [data-popover] handler otherwise; a click on a head OPENS and never toggles (emulated mouseenter on tap may already have opened it — the showContextMenu lesson); the mode (left-opening flyout vs inline accordion) is decided at build time from app.isMobile || matchMedia('(hover: none)') — headless chrome answers hover:none by default, so a chrome suite that wants the desktop mode passes --blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4 (test-gear-menu). Initial focus into a freshly created popover must wait for the rAF in which createPopover clears visibility:hidden — nothing inside a hidden subtree is focusable.
  • Keyed-row reconciliation replaces innerHTML wherever an editor may live (2.369.169, the For-you panel): a list that can hold a text box (a reply box, an inline editor) is never re-rendered wholesale on a broadcast — rows are keyed (data-id), patched in place by ONE renderer's patchRow (a content signature decides whether anything is rebuilt; the LIVE half — enabled/tooltip — is applied separately so a live-fact broadcast rebuilds nothing), appended/removed by key, and moved only when out of place; the box node itself is never detached (detaching a focused textarea blurs it) — the rest of the row is replaced AROUND it. Precedents: the a3 keyed status-bar chips, inc-mtw02kbq-kj96's append-only slots. See src/lib/user-todos-row.js.
  • Shared utilities: createPopover(anchor, className, opts) handles popover positioning/dedup/close for all 7+ popover types. showContextMenu(x, y, items) is data-driven. Outside-press close = onOutsidePress(root, close, { exclude, ignore, nested, once, signal }) (utils.js, lane M): createPopover / showContextMenu / attachPopoverClose use it; a hand-built floating surface calls it directly — NEVER a document.addEventListener('mousedown' | 'click', …) closer (an app's picture cancels its pointerdown ⇒ no mousedown ever; test-architecture §59 fails one). Capture-phase passive pointerdown, touch as a tap, armed synchronously (the opening press is recognised by its timestamp); once:false for a persistent popup hidden by class; nested:false for a surface that should close on a press in another popover. attachPopoverClose also stamps lane K's OWNERSHIP fields: a popover removed by any other path — a timer, Esc, a row — calls pop._closeCtl.abort() in its own cleanup (lane K verify r1: a hover chooser opens and closes with no press), and pop._closeExclude is the LIVE exclusion list the helper reads at every press — an owner that rebuilds its anchor pushes the new one. fetchJson(url) wraps fetch+json+catch. copyText(text) with execCommand fallback.
  • Agent sessions get a SANITIZED env, never raw process.env (2.227.12, agentEnv() in ws-handler): the container's own runtime vars broke agent work (NODE_ENV=production → every agent npm install silently skipped devDependencies; PORT=3456 → inherited by dev servers the agent started; npm_* → nested-npm hazard) AND the helm chart injects the instance's SECRETS (VIBESPACE_PASSWORD, S3/CephFS/Drive/frps credentials, telemetry token) which any agent could read with one env. Rule: operational vars + npm_* dropped, ALL VIBESPACE_* dropped except a small allowlist (API/session token/task id/remote-transport hints/instance name) — everything an agent legitimately needs is set EXPLICITLY after the strip. Any NEW spawn path must build its env through agentEnv().
  • Server-side settings reads use serverSetting(key) (server.js; ws-handler gets it via deps) — backed by persistence.js's cached readSettings over data/settings.json, where /api/settings actually persists. getSyncStore('settings') is a dormant EMPTY migration-target store: 9 server reads through it silently returned defaults for every user-configured value until 2.87.0 (onDemandQuotaRefresh modes, activeUsagePolling, shipSubscriptionToRemote, per-turn reminder/stop nudge off-switches, telemetry toggles). Never read settings through the SyncStore server-side.
  • No native dialogs: prompt()/alert()/confirm() are banned — use showInputDialog/showConfirmDialog (Promise-based, reuse .dialog CSS, Enter/Esc handled) and showToast(msg, {type:'error'}) from utils.js. Toasts are CARDS anchored at the inbox button (2.111.0): configureToasts({getSeconds,getAnchor}) wired in app.js (duration = setting taskbar.toastSeconds; anchor falls back to the centered strip on mobile/hidden); every toast is recorded to localStorage history (getToastHistory) which the inbox popup shows as its Notifications tab (window CustomEvent vs-toast live-refreshes it). The pre-2.111.0 invisibility bug was background: var(--bg-panel, var(--bg-secondary)) — BOTH undefined tokens → transparent (same class as the no-global-.hidden rule: verify computed styles, not class names). Storage-row gotcha: .mounts-path keeps its rtl left-truncation in an inner .mounts-path-text span — the type chip must stay OUTSIDE the rtl context or bidi reorders it to the end. Popovers/menus created via createPopover/showContextMenu carry data-popover so the global Escape handler (app.js _setupDialogs) closes them; Esc then closes #dialog-overlay. Esc is NOT intercepted while focus is inside .xterm (TUI apps need it).
  • Drag mousemove is rAF-coalesced everywhere (window.js titlebar drag + resize, tab-group icon drag + tab drag-out): raw mousemove fires at pointer rate (125-1000Hz) and each processMove does elementFromPoint hit-tests (forced style recalc) + rect reads — uncoalesced this visibly stuttered with several live chat windows open. Pattern: store latest event, process once per requestAnimationFrame, cancel the pending frame in onUp. Keep new drag handlers on this pattern.
  • Cursor→window positioning MUST convert viewport→workspace coords (2.100.3, trace-diagnosed real report): e.clientX/Y are VIEWPORT coords; window style.left/top are WORKSPACE-relative (offset by sidebar ~260px + toolbar). Every "center on cursor" re-anchor that wrote raw clientX landed the window a sidebar-width away from the pointer (then tracked parallel — read as "drag drift"; invisible with the sidebar closed, which is why it survived across 7 sites: window.js un-snap/un-maximize/merge-ghost-leave/preview-leave + tab-group detach/follow/merge-leave). Convert via workspace.getBoundingClientRect(): initL = (e.clientX - wsr.left) - w/2. DELTA-based tracking (initL + (cx - startX)) is space-agnostic and safe — only absolute cursor placement needs the conversion. Also: after any mid-frame re-anchor of initL/startX, derive position from the CURRENT anchors at application time (a delta computed before the re-anchor is stale by the pointer's whole first-frame sweep under rAF coalescing). Diagnosis pattern that cracked it: temporary per-frame drag tracer shipped via /api/telemetry on mouseup — the user reproduces on THEIR machine, the agent reads the ndjson.
  • UI scale (DPI) + UI font size (2.257.0, per-DEVICE like the language): gs-menu rows writing localStorage vibespace.uiScale/vibespace.uiFontScale (percent; in CLIENT_PREF_KEYS). applyUiPrefs() (utils.js) runs pre-App in client.js + on change: uiScale = CSS zoom on BODY (desktop only; mobile skipped) + --ui-scale/--ui-font-scale root vars — BOTH in themes.js LAYOUT_VARS (theme sweep exemption, the 2.254.0 lesson: without it the theme apply wipes them and every calc() silently reads 1). #main-wrapper height = calc(100dvh / var(--ui-scale,1)) (100dvh resolves in zoomed px). DRAG RULE (do not regress): every drag handler converts pointer deltas viewport→layout by dividing by uiScale() (utils) — window.js titlebar/resize/anchors/move-mode, tab-group ghosts/detach, resizer.js, desktop-manager toolbar+taskbar handles; layout-px ratios must use offsetWidth, never getBoundingClientRect (viewport px differs by the zoom). Terminals refit + clearTextureAtlas on scale change (canvas softness at ≠100% is inherent — CSS zoom does not change devicePixelRatio). uiFontScale = text-only multiplier on CURATED chrome font rules (calc(Npx * var(--ui-font-scale,1)): desktop-preview labels (via their size vars), taskbar title/sub, session-card name/cwd, folder-header, window-title, context/taskbar/gs menu items). REVIEW-HARDENED (10-finding pass — the sweep's blind spots): ① the proportional-bounds engine (capture/apply gridBounds, snap zones, grid presets/lines, snapToHalf, cell ranges) uses {width: workspace.offsetWidth, height: offsetHeight} — NEVER workspace gBCR (viewport px: fractions divided by the zoom poisoned layouts.json for ALL clients; presets hung windows off-screen); hit tests (_getSnapZone/_getGridCell, viewport-vs-viewport) keep gBCR. ② the shared popover primitives (createPopover/showContextMenu/anchorFixedPopup/setupInstantTooltip/toast anchoring + the right-anchored innerWidth - rect.right pattern) divide every write by uiScale() — fixed body children take LAYOUT px, inputs are VIEWPORT px, and past ~80% of the viewport menus rendered off-screen with clamps that could not recover; clamp math runs in viewport space, only final writes divide. ③ ALL vh/vw in the four stylesheets are calc(Nvh / var(--ui-scale,1)) (blanket sweep; a zoomed 80vh dialog = 100% of the screen at 125% clipping its footer). ④ applyUiPrefs re-runs on window resize (debounced) — crossing the 768px mobile boundary otherwise stranded/leaked the zoom. ⑤ theme-editor/customize-mode/explorer-column/PPTX-sidebar drags compensated (column widths COMPOUNDED ×zoomⁿ into localStorage). KNOWN MINOR: at 140% font scale a short taskbar may clip its two text rows — the taskbar is user-resizable, drag it taller. Smoke: scripts/test-ui-scale.mjs (15 asserts incl. 1:1 drag, in-workspace snap + gridBounds round-trip, on-screen corner context menu at 125% + screenshots to /tmp/vs-uiscale-shots).
  • Sidebar session poll (5s) pauses while document.hidden (30s heartbeat + immediate catch-up on visibilitychange) — single _pollTimer chain, never double-schedule.
  • Tab groups (chain model): Windows can be merged into tab groups by dragging one window's icon onto another. Shared chain object { tabs: [hostId, ...guestIds], active: index } — all grouped windows hold the same reference via win._tabChain. tabs[0] is the host (owns the physical .window element). Guest content divs are reparented into the host element. Tab bar replaces title bar content with Chrome-style rounded tabs. Drag tab downward (>30px) to pull out — detached window raised to front via focusWindow, follows cursor with snap; entering another window's icon/tab bar collapses it to a .tab-ghost (same pattern as titleBar drag). Group resize/snap syncs all tabs' gridBounds via _syncChainBounds. Installed as a mixin on WindowManager from tab-group.js.
  • Tab merge hit-test: Shared helper _detectTabMergeTarget(x, y, sourceWinId, hiddenEls) on tab-group mixin. Uses elementFromPoint after temporarily disabling pointer-events on the source and any ghost, so occluded icons never match. Used by (a) window.js titleBar drag, (b) _setupIconDrag, (c) _setupTabDrag after detach. All three share identical hit-test semantics.
  • Window type icons: Each window type has an inline SVG icon (TYPE_ICONS in tab-group.js): terminal >_, chat bubble, files folder, viewer document, editor pencil, hex grid, browser globe. Shown in title bar, tab bar, taskbar, and overlap switcher. Icon is also the drag handle for tab merge. For chat/terminal windows with a backend, a composite icon is shown: backend logo (Claude/Codex) with a small mode badge (chat bubble / >_ prompt) in the bottom-right corner. Taskbar icons use transform:scale() to uniformly enlarge the composite icon.
  • Move mode: Right-click taskbar → Move. Full-screen overlay blocks all UI interaction. Window restores from maximized/snapped to original size. Click to place.
  • Loading screen: Inline splash in HTML (no CSS dependency). Fades out after app.ready promise resolves (layout restore complete).
  • Resume-all boot popup (2.250.0): sessions that come back STOPPED restore as read-only history windows; loadAutoSave arms _bootStoppedSessions (collected in restoreState's 4 stopped→viewSession branches, deduped by backend:id:host), and after the boot restore offers ONE showConfirmDialog to bulk-resume if ≥2 — staggered 400ms via app.resumeSession (which already replaces the matching read-only window). Collector is nulled after boot so desktop-switch replays never re-prompt. Never fires on soft reconnect (that path is _reattach, not restoreState).
  • showContextMenu takes ONE class name (2.369.125): the row class is className + '-item' over the whole string, so 'context-menu mobile-sheet' produces rows classed context-menu + mobile-sheet-item — no .context-menu-item at all (measured: a sheet with zero rows). Call it with the default and classList.add('<modifier>') on the returned element (mobile-nav.js _sheet, chat-view.js _showMsgMenu).
  • A chrome suite that TAPS chrome must model a returning user (2.369.125): on a fresh data dir with no sessions #welcome.onboarding (position:fixed; inset:0; z-index:9600) sits over the nav bar and every hit-test answers it; set localStorage vs-onboarded=1 through Page.addScriptToEvaluateOnNewDocument BEFORE navigating. Touch gestures are real Input.dispatchTouchEvent sequences (a long-press = touchStart, hold > 500 ms, touchEnd) so installLongPressContextMenu's own path is exercised. r7 (the Actions mirror on cc89d748): the flag is now ONE shared string — ONBOARDED_SOURCE in scripts/scratch.mjs — passed to Page.addScriptToEvaluateOnNewDocument before EVERY Page.navigate on the same receiver, and scripts/test-architecture.mjs §47 is the census (41 chrome suites; a bare navigate fails it; // onboarding-under-test exempts a suite that means to exercise the wizard). Why the miss was invisible for 40 suites: _checkOnboarding (src/lib/app.js) ALSO skips the wizard when the machine has sessions, so every developer box was green while the runner (empty ~/.claude) showed the modal under the chrome — test-gear-menu's first Esc went to the wizard's own capture-phase handler, and the three suites red on every mirror run since 2.369.75 (toolbar-resize / roster-reset-eta / ghost-host-heal) sat under the same modal. The mirror uploads every /tmp/vs-*-shots-* as an artifact on failure since r6 — read it before guessing.
  • A long press on SELECTABLE TEXT is a selection, never a menu (lane mobile-select, 2026-10-02): the long-press = contextmenu rule (kb-design-lessons §7a) has ONE exception, decided at its one door — utils.js installLongPressContextMenu asks PURE src/lib/press-select.js per press (pressFacts → pressTargetClass → longPressVerdict): a glyph under the finger whose effective user-select is not none ⇒ the timer is never armed and a TRUSTED touch contextmenu there is stopImmediatePropagation'd WITHOUT preventDefault (the platform's selection handles + Copy bar stay); controls (button, summary, [role=button], [data-popover], .context-menu), padding, gutters, pictures and user-select:none labels stay menu presses — and once an app handler TOOK a menu press (a menu is open) selectstart is cancelled while that touch is down — one outcome, never a menu AND a selection; when the platform's long press comes first, our contextmenu fires at its selectstart; a press no handler takes keeps the platform's behaviour (selection, its own long-press menu). The trusted contextmenu is one PURE table (trustedContextMenu: pass / stop / swallow). A surface whose menu sits on selectable text needs a visible affordance (the chat's per-message … button, addMsgMoreBtn); a new contextmenu handler never re-derives this — and a user-select: text opt-in is a census row (test-press-select §4). A mouse right-click is never judged.
  • Drag/resize ends at ONE door (lane-drag-release, 2026-10-01; the law line moved here verbatim at the 2.369.200 integration — CLAUDE.md keeps a ≤ 300-character head): every drag door (title bar, resize handle, tab tear-off, icon drag) is fed from the element that CAPTURED the pointer through the ONE feed src/lib/drag-feed.js (captureOn + attachDragFeed + the shield), ended by the PURE src/lib/drag-end.js verdict (pointerup/cancel/lostcapture/blur/hidden/buttons==0), ONCE; body.wm-dragging shields every OTHER pane's content (a release over an iframe/canvas never reaches the document — lane-drag-release, the lane M class twice; verify r1 found the tab/icon doors still on the document's mouseup). Verify r2 (2026-10-02): the owner's abort ENDS the drag (owner-gone — a window closed mid-drag left the shield up for good; every door CANCELS, nothing dropped); the shield is a holder SET keyed per drag; test-window-drag §5 = THE CENSUS (grep-derived: 16 feed doors in 10 files — every handle-style door is one startPointerDrag call — and every document/window move-or-end listener named with its reason; an unfed door is red).