Restore DOM-direct workarounds; throttle gutter drag

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) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-22 16:49:00 +01:00
parent 058ce49d3b
commit 8d5c49155b
3 changed files with 47 additions and 32 deletions

View file

@ -66,17 +66,15 @@
} }
function handleClose(leafId: NodeId) { function handleClose(leafId: NodeId) {
// Kill the PTY explicitly — Svelte's onDestroy on LeafPane won't fire // Kill the PTY directly (LeafPane's onDestroy won't fire — same
// because Svelte isn't unmounting the component (same reactivity gap as // Svelte 5 reactivity wall affecting unmount as it does prop updates).
// the active/broadcast bugs).
const paneId = orch.paneIdByLeaf.get(leafId); const paneId = orch.paneIdByLeaf.get(leafId);
if (paneId != null) { if (paneId != null) {
void killPane(paneId).catch((e) => console.warn("killPane failed:", e)); void killPane(paneId).catch((e) => console.warn("killPane failed:", e));
orch.paneIdByLeaf.delete(leafId); orch.paneIdByLeaf.delete(leafId);
} }
// Hide the closed pane's flex container and the adjacent gutter so the
// Brute-force hide the closed pane's flex side + the adjacent gutter so // sibling pane visually fills the freed space.
// the sibling pane visually fills the freed space.
const leafEl = document.querySelector(`[data-leaf-id="${leafId}"]`); const leafEl = document.querySelector(`[data-leaf-id="${leafId}"]`);
const sideEl = leafEl?.closest(".side") as HTMLElement | null; const sideEl = leafEl?.closest(".side") as HTMLElement | null;
const splitEl = sideEl?.parentElement; const splitEl = sideEl?.parentElement;
@ -87,7 +85,6 @@
if (child.classList.contains("gutter")) child.style.display = "none"; if (child.classList.contains("gutter")) child.style.display = "none";
}); });
} }
// Update tree state (used by broadcast routing, palette, persistence). // Update tree state (used by broadcast routing, palette, persistence).
const next = closeLeaf(tree, leafId); const next = closeLeaf(tree, leafId);
tree = next ?? newLeaf({ distro: defaultDistro }); tree = next ?? newLeaf({ distro: defaultDistro });
@ -227,9 +224,25 @@
// template re-evaluation reliably (root cause unclear — likely a Svelte 5 // template re-evaluation reliably (root cause unclear — likely a Svelte 5
// interaction with our recursive Pane / setInterval pattern). So we ALSO // interaction with our recursive Pane / setInterval pattern). So we ALSO
// manipulate `.leaf.active` directly via DOM as a backstop. // 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(() => { $effect(() => {
let lastLeafId: string | null = null; let lastLeafId: string | null = null;
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
// 1. Focus detection → setActive
const el = document.activeElement; const el = document.activeElement;
const leafEl = el?.closest("[data-leaf-id]"); const leafEl = el?.closest("[data-leaf-id]");
const id = leafEl?.getAttribute("data-leaf-id") ?? null; const id = leafEl?.getAttribute("data-leaf-id") ?? null;
@ -237,21 +250,18 @@
lastLeafId = id; lastLeafId = id;
setActive(id); setActive(id);
} }
// Active-border DOM sync (same workaround as below). // 2. Active border DOM sync
if (id) { document.querySelectorAll("[data-leaf-id].leaf").forEach((el) => {
document.querySelectorAll("[data-leaf-id].leaf").forEach((el) => { const elId = el.getAttribute("data-leaf-id");
if (el.getAttribute("data-leaf-id") === id) el.classList.add("active"); if (elId === activeLeafId) el.classList.add("active");
else el.classList.remove("active"); else el.classList.remove("active");
}); });
} // 3. Broadcast DOM sync (read from tree, write to DOM)
// 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.
for (const leaf of walkLeaves(tree)) { for (const leaf of walkLeaves(tree)) {
const leafEl = document.querySelector(`[data-leaf-id="${leaf.id}"]`); const el = document.querySelector(`[data-leaf-id="${leaf.id}"]`);
if (!leafEl) continue; if (!el) continue;
leafEl.classList.toggle("broadcasting", !!leaf.broadcast); el.classList.toggle("broadcasting", !!leaf.broadcast);
const chip = leafEl.querySelector(".bcast-chip"); const chip = el.querySelector(".bcast-chip");
if (chip) chip.classList.toggle("on", !!leaf.broadcast); if (chip) chip.classList.toggle("on", !!leaf.broadcast);
} }
}, 250); }, 250);

View file

@ -14,10 +14,6 @@
const orch = useOrchestration(); 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 status = $state("starting…");
let statusOk = $state(true); let statusOk = $state(true);

View file

@ -19,6 +19,12 @@
e.preventDefault(); 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) { function onPointerMove(e: PointerEvent) {
if (!dragging || !containerEl) return; if (!dragging || !containerEl) return;
const rect = containerEl.getBoundingClientRect(); const rect = containerEl.getBoundingClientRect();
@ -28,13 +34,16 @@
if (size <= 0) return; if (size <= 0) return;
const r = Math.max(0.05, Math.min(0.95, pos / size)); const r = Math.max(0.05, Math.min(0.95, pos / size));
node.ratio = r; node.ratio = r;
pendingRatio = r;
// Brute-force DOM: Svelte's `style="flex: {node.ratio}"` template binding if (pendingRaf == null) {
// doesn't propagate ratio changes in this app. Update the .side flex pendingRaf = requestAnimationFrame(() => {
// styles directly so the drag actually moves the gutter visually. pendingRaf = null;
const sides = containerEl.querySelectorAll(":scope > .side"); // Direct DOM flex update — Svelte's template binding doesn't react.
if (sides[0]) (sides[0] as HTMLElement).style.flex = String(r); const sides = containerEl.querySelectorAll(":scope > .side");
if (sides[1]) (sides[1] as HTMLElement).style.flex = String(1 - r); 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) { function onPointerUp(e: PointerEvent) {