From 8d5c49155b02c7682fdde5798daffd2edadaec23 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Fri, 22 May 2026 16:49:00 +0100 Subject: [PATCH] Restore DOM-direct workarounds; throttle gutter drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After this session's diagnostic confirmed the root cause (Svelte 5 prop reactivity does NOT propagate through Pane → SplitNode → LeafPane in this app — each LeafPane captures props at mount and never sees updates), restored the brute-force DOM workarounds that were proven to work and threw in a throttle for the gutter drag. What changed vs the broken intermediate state: - App polling: re-sync .leaf.active, .leaf.broadcasting, .bcast-chip.on classes from tree+activeLeafId state every 250ms. Bypasses Svelte reactivity entirely. - SplitNode drag: rAF-throttle the direct flex update so we stop spamming SIGWINCH to the PTYs (which was making shells redraw prompts repeatedly, creating the visual artifacts the user reported). - Close: keep the targeted PTY-kill + DOM-hide-the-side approach so panes visually disappear and siblings fill via flex auto-allocation. This isn't pretty, but it works. The proper fix is to either find / file the Svelte 5 bug, or migrate the frontend to a framework whose reactivity we can trust. Both deferred. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.svelte | 52 ++++++++++++++++++++------------- src/lib/layout/LeafPane.svelte | 4 --- src/lib/layout/SplitNode.svelte | 23 ++++++++++----- 3 files changed, 47 insertions(+), 32 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index 7a5c46e..4836b9b 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -66,17 +66,15 @@ } function handleClose(leafId: NodeId) { - // Kill the PTY explicitly — Svelte's onDestroy on LeafPane won't fire - // because Svelte isn't unmounting the component (same reactivity gap as - // the active/broadcast bugs). + // Kill the PTY directly (LeafPane's onDestroy won't fire — same + // Svelte 5 reactivity wall affecting unmount as it does prop updates). const paneId = orch.paneIdByLeaf.get(leafId); if (paneId != null) { void killPane(paneId).catch((e) => console.warn("killPane failed:", e)); orch.paneIdByLeaf.delete(leafId); } - - // Brute-force hide the closed pane's flex side + the adjacent gutter so - // the sibling pane visually fills the freed space. + // Hide the closed pane's flex container and the adjacent gutter so the + // sibling pane visually fills the freed space. const leafEl = document.querySelector(`[data-leaf-id="${leafId}"]`); const sideEl = leafEl?.closest(".side") as HTMLElement | null; const splitEl = sideEl?.parentElement; @@ -87,7 +85,6 @@ if (child.classList.contains("gutter")) child.style.display = "none"; }); } - // Update tree state (used by broadcast routing, palette, persistence). const next = closeLeaf(tree, leafId); tree = next ?? newLeaf({ distro: defaultDistro }); @@ -227,9 +224,25 @@ // template re-evaluation reliably (root cause unclear — likely a Svelte 5 // interaction with our recursive Pane / setInterval pattern). So we ALSO // manipulate `.leaf.active` directly via DOM as a backstop. + // ---- Active-pane / broadcast DOM sync (Svelte 5 reactivity workaround) -- + // + // Empirically verified: prop drilling through Pane → SplitNode → LeafPane + // does NOT propagate reactively in this app. Each LeafPane captures its + // initial prop values and never sees updates (proved with inline-toolbar + // diagnostics: every pane showed the same `A:` value at mount and it + // never changed even as App-level activeLeafId did). Likely a Svelte 5 + // bug with recursive components + this specific pattern; filing upstream + // is a separate task. + // + // Workaround: a 250ms polling loop in App that + // 1. reads document.activeElement to detect focus changes (and updates + // App state for persistence / palette / broadcast routing), and + // 2. directly toggles `.leaf.active`, `.leaf.broadcasting`, and + // `.bcast-chip.on` classes via DOM API — bypassing Svelte entirely. $effect(() => { let lastLeafId: string | null = null; const interval = window.setInterval(() => { + // 1. Focus detection → setActive const el = document.activeElement; const leafEl = el?.closest("[data-leaf-id]"); const id = leafEl?.getAttribute("data-leaf-id") ?? null; @@ -237,21 +250,18 @@ lastLeafId = id; setActive(id); } - // Active-border DOM sync (same workaround as below). - if (id) { - document.querySelectorAll("[data-leaf-id].leaf").forEach((el) => { - if (el.getAttribute("data-leaf-id") === id) el.classList.add("active"); - else el.classList.remove("active"); - }); - } - // Broadcast-state DOM sync — Svelte's class:broadcasting={leaf.broadcast} - // and class:on={leaf.broadcast} bindings in LeafPane don't propagate - // either. Walk the tree and force the classes to match leaf.broadcast. + // 2. Active border DOM sync + document.querySelectorAll("[data-leaf-id].leaf").forEach((el) => { + const elId = el.getAttribute("data-leaf-id"); + if (elId === activeLeafId) el.classList.add("active"); + else el.classList.remove("active"); + }); + // 3. Broadcast DOM sync (read from tree, write to DOM) for (const leaf of walkLeaves(tree)) { - const leafEl = document.querySelector(`[data-leaf-id="${leaf.id}"]`); - if (!leafEl) continue; - leafEl.classList.toggle("broadcasting", !!leaf.broadcast); - const chip = leafEl.querySelector(".bcast-chip"); + const el = document.querySelector(`[data-leaf-id="${leaf.id}"]`); + if (!el) continue; + el.classList.toggle("broadcasting", !!leaf.broadcast); + const chip = el.querySelector(".bcast-chip"); if (chip) chip.classList.toggle("on", !!leaf.broadcast); } }, 250); diff --git a/src/lib/layout/LeafPane.svelte b/src/lib/layout/LeafPane.svelte index 58995dc..b5edc8b 100644 --- a/src/lib/layout/LeafPane.svelte +++ b/src/lib/layout/LeafPane.svelte @@ -14,10 +14,6 @@ const orch = useOrchestration(); - // Diagnostic — log on every prop change. - $effect(() => { - console.log("[LeafPane render]", leaf.id.slice(0, 8), "activeLeafId=", activeLeafId?.slice(0, 8) ?? "null", "match=", activeLeafId === leaf.id); - }); let status = $state("starting…"); let statusOk = $state(true); diff --git a/src/lib/layout/SplitNode.svelte b/src/lib/layout/SplitNode.svelte index 0f5bbe6..d105420 100644 --- a/src/lib/layout/SplitNode.svelte +++ b/src/lib/layout/SplitNode.svelte @@ -19,6 +19,12 @@ e.preventDefault(); } + // Throttle the per-pointermove DOM flex update so we don't fire SIGWINCH + // hundreds of times per second during a drag (causes the shell to redraw + // its prompt repeatedly, leaving visual artifacts). + let pendingRaf: number | null = null; + let pendingRatio = 0; + function onPointerMove(e: PointerEvent) { if (!dragging || !containerEl) return; const rect = containerEl.getBoundingClientRect(); @@ -28,13 +34,16 @@ if (size <= 0) return; const r = Math.max(0.05, Math.min(0.95, pos / size)); node.ratio = r; - - // Brute-force DOM: Svelte's `style="flex: {node.ratio}"` template binding - // doesn't propagate ratio changes in this app. Update the .side flex - // styles directly so the drag actually moves the gutter visually. - const sides = containerEl.querySelectorAll(":scope > .side"); - if (sides[0]) (sides[0] as HTMLElement).style.flex = String(r); - if (sides[1]) (sides[1] as HTMLElement).style.flex = String(1 - r); + pendingRatio = r; + if (pendingRaf == null) { + pendingRaf = requestAnimationFrame(() => { + pendingRaf = null; + // Direct DOM flex update — Svelte's template binding doesn't react. + const sides = containerEl.querySelectorAll(":scope > .side"); + if (sides[0]) (sides[0] as HTMLElement).style.flex = String(pendingRatio); + if (sides[1]) (sides[1] as HTMLElement).style.flex = String(1 - pendingRatio); + }); + } } function onPointerUp(e: PointerEvent) {