Replaced entire FFI chain (XMoveResizeWindow, begin_resize_drag,
XUngrabPointer) with standard browser APIs window.resizeTo + window.moveTo.
Proven to work in Chromium --app mode with same JS resize logic.
Frame captured synchronously via window.screenX/Y + outerWidth/Height.
Zero RPC, zero FFI, zero GTK involvement.
begin_resize_drag + XUngrabPointer still fails because GTK's layout
cycle re-asserts WebView preferred size, fighting the WM resize.
New approach: JS mousemove → XMoveResizeWindow via libX11.so.6 FFI.
Completely bypasses GTK size negotiation. GTK only receives
ConfigureNotify after the X server has already resized the window.
Added: x11SetFrame() using gdk_x11_display_get_xdisplay +
gdk_x11_window_get_xid + XMoveResizeWindow.
Root cause confirmed via X11 research: WebKitGTK holds an implicit X11
pointer grab from button-press. GTK's gdk_seat_ungrab targets the wrong
device, leaving the grab active. Mutter gets AlreadyGrabbed and gives up.
Fix: call XUngrabPointer(xdisplay, CurrentTime) + XFlush directly via
libX11.so.6 FFI before begin_resize_drag. This releases ALL grabs on
the display connection, matching SDL2's proven approach.
Removed failed approaches: WebView sensitivity toggle, findWebView cache.
Codex review #3 identified: the issue is NOT min-size but pointer grab
conflict. WebKitGTK steals the WM's X11 pointer grab when cursor enters
the WebView during inward resize.
Fix: gtk_widget_set_sensitive(webview, false) before begin_resize_drag,
re-enable after 5s. Also added findWebView() to cache the deepest
GtkBin child pointer for fast access.
Codex review #2 found: set_size_request only controls minimum, not
natural/preferred size. WebView still reports content size as natural,
causing rubber-band effect during shrink.
Fix: reparent WebKitWebView into GtkScrolledWindow via FFI with
propagate-natural-width/height=FALSE and min-content=1x1. This
decouples WebView content size from window size negotiation.
Also reverted to native begin_resize_drag (WM handles everything).
Electrobun's setSize respects WebView min-size constraint. Bypass it
with direct gtk_window_resize() + gtk_window_move() FFI calls.
clearMinSizeTree() runs on every resize frame to suppress WebView
re-propagation. gtkSetFrame() exported as new RPC endpoint.
GTK begin_resize_drag loses grip when cursor moves inward past the 6px
handle zone. Replaced with document-level mousemove/mouseup listeners
that compute delta from initial frame and call setPosition+setSize.
- clearMinSize RPC clears WebView min-size before resize starts
- Cursor locks to resize direction during drag (body.style.cursor)
- user-select disabled during drag to prevent text selection
- Frame captured async before resize starts (no race condition)
WebKitWebView re-propagates content size as minimum on every layout cycle,
overriding the one-time init fix. Now clearMinSizeTree() runs right before
each gtk_window_begin_resize_drag call, allowing resize-in (shrink).
Also removed red debug background from resize handles.
Root cause: WebKitWebView requests min_size = content_size (5120x1387 on
ultrawide), which GTK propagates to WM_NORMAL_HINTS, blocking all resize.
Fix: recursively walk the GTK widget tree (GtkWindow → GtkBin → WebView)
and call gtk_widget_set_size_request(-1, -1) on every widget. Then set
gtk_window_set_geometry_hints with min=400x300.
Result: WM_NORMAL_HINTS now shows min=10x10, window is fully resizable.
- Native dialog: resolve to nearest existing parent dir, detect user cancel
(exit code 1) vs actual error, add createIfMissing option
- Claude models: fallback to KNOWN_CLAUDE_MODELS (6 models) when API key
unavailable. Adds Opus 4.6, Sonnet 4.6, Opus 4.5, Sonnet 4, Haiku 4.5,
Sonnet 3.7. Live API paginated to limit=100.
- PathBrowser: Select button moved to sticky header (always visible).
Current path shown compact in header with RTL ellipsis.
- files.ensureDir RPC: creates directory recursively before project creation
- files.ensureDir added to RPC schema
New files:
- project-state.types.ts: all per-project state interfaces
- project-state.svelte.ts: unified per-project state with version counter
- app-state.svelte.ts: root facade re-exporting all stores as appState.*
Rewired components (no more local $state):
- ProjectCard: reads via appState.agent.* and appState.project.tab.*
- TerminalTabs: state in appState.project.terminals.*
- FileBrowser: state in appState.project.files.*
- CommsTab: state in appState.project.comms.*
- TaskBoardTab: state in appState.project.tasks.*
All follow Rule 57 (no $derived with new objects) and Rule 58
(state tree architecture, components are pure renderers).
TaskBoardTab: $effect called loadTasks() which wrote ++pollToken ($state)
→ triggered $effect re-run → infinite loop. Fix: onMount instead.
Also tasksByCol $derived created new objects via .reduce/.filter.
FileBrowser: $effect read openDirs (via new Set(openDirs)) AND wrote to
it (openDirs = s) → infinite loop. Fix: onMount with fresh Set.
CommsTab: $effect called loadChannels()/loadAgents() which wrote $state
→ potential cycle. Fix: onMount instead.
Rule: NEVER use $effect for initialization that writes to $state.
Always use onMount for async init + side effects.
Root cause: $derived with store getter functions (.filter/.map/?.operator)
created new object references on every evaluation. Svelte 5 interpreted
these as "changed values" → triggered re-render → re-evaluated $derived
→ new references → infinite loop (115% CPU).
Fix: replaced ALL $derived in ProjectCard with plain getter functions.
Functions are called in the template — Svelte tracks the inner $state
reads but doesn't create intermediate reactive nodes that can loop.
Verified via bisect:
- Skeleton (no ProjectCard): 0% CPU
- ProjectCard with $derived: 115% CPU
- ProjectCard with plain functions: 0% CPU (0 ticks in 5s)
Also fixed: CommandPalette $effect that read+wrote selectedIdx.
Root cause found via bisect: blinkVisible prop changed every 500ms,
causing complete re-render of ALL ProjectCard trees (AgentPane, Terminal,
all tabs) — even display:none content is re-evaluated by Svelte 5.
Fix: blink-store.svelte.ts owns the timer. StatusDot reads directly
from store, not from parent prop. No prop cascades.
Also: replaced $derived with .filter()/.map() (creates new arrays)
with plain functions in ProjectCard to prevent reactive loops.
project-tabs-store: replaced Map reassignment (_tabs = new Map(_tabs)) with
version counter pattern. Map reassignment created new object reference on
every getActiveTab() call from $derived → infinite loop.
CommandPalette: replaced $derived COMMANDS array with plain function call.
$derived with .map() created new array every evaluation → infinite loop
when any i18n state changed.
The $effect reading messages.length + requestAnimationFrame was a secondary
cause of effect_update_depth_exceeded. MutationObserver is non-reactive —
observes DOM changes directly without Svelte dependency tracking.
getMountedGroupIds()/getFilteredProjects()/getActiveGroup()/getTotalCost/Tokens
all created new objects per render → Svelte 5 saw 'changed' → re-render → new
objects → infinite effect_update_depth_exceeded loop.
Fix: compute once in $derived variables, reference stable locals in template.
Root cause: WebKitGTK reports stale modifier state (0x14=Ctrl+Alt) after
SIGTERM of previous instance. Electrobun interprets this as Cmd+click and
opens a new window, which closes the main window.
Finding: when modifier state is clean (0x10, isCtrlHeld=0), the window
opens correctly. The event emitter API isn't publicly exported from
electrobun/bun — needs upstream fix or different approach.