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) {
// 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);

View file

@ -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);

View file

@ -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) {