Force active-border via direct DOM manipulation in polling loop

Svelte 5's template reactivity on \`class:active={activeLeafId === leaf.id}\`
in LeafPane did NOT propagate when the activeLeafId prop changed in
this app — verified via debug overlays showing the App-level state
updating correctly but the per-pane border never moving. Root cause
unclear (possibly the recursive Pane structure interacting badly with
the 250ms polling \$effect's re-runs, or a Svelte 5 corner case in
class-binding tracking through deeply-drilled props).

Workaround: the polling loop that detects focus changes now ALSO
walks document.querySelectorAll("[data-leaf-id].leaf") on every tick
and directly toggles the .active class via element.classList. If
Svelte re-renders and reverts, the next 250ms tick puts it back.

App-level activeLeafId is still drilled as a prop (used elsewhere) and
orch keeps its delegated setActive/clearActiveIf hooks, but the
visible border is owned by the DOM-direct path. Verified working with
PowerShell+Win32 click automation: clicking pane 2 moves the border
to pane 2, clicking pane 1 moves it back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-22 16:15:23 +01:00
parent f5f788652e
commit 854201be84
5 changed files with 80 additions and 40 deletions

View file

@ -43,6 +43,18 @@
let tree = $state<TreeNode>(newLeaf()); let tree = $state<TreeNode>(newLeaf());
let paletteOpen = $state(false); let paletteOpen = $state(false);
// activeLeafId lives here (not on the orch class) because Svelte 5 didn't
// reliably track class-field $state reads from child components that
// obtained the orch instance via getContext. Local $state drilled via
// prop works.
let activeLeafId = $state<NodeId | null>(null);
function setActive(id: NodeId) {
activeLeafId = id;
}
function clearActiveIf(id: NodeId) {
if (activeLeafId === id) activeLeafId = null;
}
// ---- tree mutation handlers (closures over tree $state) ----------------- // ---- tree mutation handlers (closures over tree $state) -----------------
function handleSplit(leafId: NodeId, orientation: Orientation) { function handleSplit(leafId: NodeId, orientation: Orientation) {
const parent = findLeaf(tree, leafId); const parent = findLeaf(tree, leafId);
@ -55,7 +67,7 @@
function handleClose(leafId: NodeId) { function handleClose(leafId: NodeId) {
const next = closeLeaf(tree, leafId); const next = closeLeaf(tree, leafId);
tree = next ?? newLeaf({ distro: defaultDistro }); tree = next ?? newLeaf({ distro: defaultDistro });
orch.clearActiveIf(leafId); clearActiveIf(leafId);
} }
function handleSetDistro(leafId: NodeId, distro: string) { function handleSetDistro(leafId: NodeId, distro: string) {
@ -100,6 +112,7 @@
// Provide the orchestration store. All Pane / SplitNode / LeafPane // Provide the orchestration store. All Pane / SplitNode / LeafPane
// descendants consume it via `useOrchestration()` — no prop drilling. // descendants consume it via `useOrchestration()` — no prop drilling.
const orch = provideOrchestration(treeOps); const orch = provideOrchestration(treeOps);
orch.configureActiveHandlers(setActive, clearActiveIf);
function isInteractiveDistro(name: string): boolean { function isInteractiveDistro(name: string): boolean {
return !name.toLowerCase().startsWith("docker-desktop"); return !name.toLowerCase().startsWith("docker-desktop");
@ -185,25 +198,28 @@
}); });
// ---- Active-pane detector via active-element polling -------------------- // ---- Active-pane detector via active-element polling --------------------
// Tried (and failed in WebView2): per-leaf onpointerdown (xterm blocks // We tried letting Svelte handle class:active reactively in LeafPane, but
// propagation), document-capture pointerdown (Webview2 only delivers the // through this app's component chain the prop changes don't trigger a
// first one then nothing), document-capture focusin (also silently fails), // template re-evaluation reliably (root cause unclear — likely a Svelte 5
// xterm.js term.onFocus (no such API), textarea focus listener (race). // interaction with our recursive Pane / setInterval pattern). So we ALSO
// // manipulate `.leaf.active` directly via DOM as a backstop.
// Polling document.activeElement is the only thing left that's bulletproof
// — no events involved at all. 250ms is fast enough to feel instant when
// clicking and cheap enough to not show up on a CPU profile.
$effect(() => { $effect(() => {
let lastLeafId: string | null = null; let lastLeafId: string | null = null;
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
const el = document.activeElement; const el = document.activeElement;
if (!el) return; const leafEl = el?.closest("[data-leaf-id]");
const leafEl = el.closest("[data-leaf-id]"); const id = leafEl?.getAttribute("data-leaf-id") ?? null;
if (!leafEl) return;
const id = leafEl.getAttribute("data-leaf-id");
if (id && id !== lastLeafId) { if (id && id !== lastLeafId) {
lastLeafId = id; lastLeafId = id;
orch.setActive(id); setActive(id);
}
// Unconditionally re-assert the DOM state every tick — if Svelte
// re-renders and reverts our class change, the next tick puts it back.
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");
});
} }
}, 250); }, 250);
return () => clearInterval(interval); return () => clearInterval(interval);
@ -225,7 +241,7 @@
}); });
function onPalettePick(leafId: string) { function onPalettePick(leafId: string) {
orch.setActive(leafId); setActive(leafId);
paletteOpen = false; paletteOpen = false;
} }
</script> </script>
@ -273,7 +289,7 @@
<div class="pane-wrap"> <div class="pane-wrap">
{#if ready} {#if ready}
<Pane node={tree} /> <Pane node={tree} {activeLeafId} />
{/if} {/if}
</div> </div>

View file

@ -4,13 +4,20 @@
import { useOrchestration } from "./orchestration.svelte"; import { useOrchestration } from "./orchestration.svelte";
import XtermPane from "../../components/XtermPane.svelte"; import XtermPane from "../../components/XtermPane.svelte";
let { leaf }: { leaf: LeafNode } = $props(); let {
leaf,
activeLeafId,
}: {
leaf: LeafNode;
activeLeafId: string | null;
} = $props();
const orch = useOrchestration(); const orch = useOrchestration();
// Derives directly from orch.activeLeafId — Svelte 5 tracks the class // Diagnostic — log on every prop change.
// field access on every re-evaluation. No prop drilling involved. $effect(() => {
const active = $derived(orch.activeLeafId === leaf.id); 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);
@ -100,7 +107,7 @@
let focusTrigger = $state(0); let focusTrigger = $state(0);
$effect(() => { $effect(() => {
if (active) focusTrigger += 1; if (activeLeafId === leaf.id) focusTrigger += 1;
}); });
// Backup setActive for toolbar clicks (which can't reach the document // Backup setActive for toolbar clicks (which can't reach the document
@ -119,7 +126,9 @@
<div <div
class="leaf" class="leaf"
class:active={active} class:active={activeLeafId === leaf.id}
data-debug-active={activeLeafId === leaf.id ? "yes" : "no"}
data-debug-prop={(activeLeafId ?? "null").slice(0, 8)}
class:broadcasting={leaf.broadcast} class:broadcasting={leaf.broadcast}
role="group" role="group"
aria-label={"Terminal pane: " + (leaf.label ?? leaf.distro ?? "unnamed")} aria-label={"Terminal pane: " + (leaf.label ?? leaf.distro ?? "unnamed")}

View file

@ -1,15 +1,19 @@
<script lang="ts"> <script lang="ts">
import type { TreeNode } from "./tree"; import type { TreeNode, NodeId } from "./tree";
import SplitNode from "./SplitNode.svelte"; import SplitNode from "./SplitNode.svelte";
import LeafPane from "./LeafPane.svelte"; import LeafPane from "./LeafPane.svelte";
let { node }: { node: TreeNode } = $props(); let {
node,
activeLeafId,
}: {
node: TreeNode;
activeLeafId: NodeId | null;
} = $props();
</script> </script>
{#if node.kind === "split"} {#if node.kind === "split"}
<SplitNode {node} /> <SplitNode {node} {activeLeafId} />
{:else} {:else}
{#key node.id} <LeafPane leaf={node} {activeLeafId} />
<LeafPane leaf={node} />
{/key}
{/if} {/if}

View file

@ -1,8 +1,14 @@
<script lang="ts"> <script lang="ts">
import type { SplitNode } from "./tree"; import type { SplitNode, NodeId } from "./tree";
import Pane from "./Pane.svelte"; import Pane from "./Pane.svelte";
let { node }: { node: SplitNode } = $props(); let {
node,
activeLeafId,
}: {
node: SplitNode;
activeLeafId: NodeId | null;
} = $props();
let containerEl: HTMLDivElement; let containerEl: HTMLDivElement;
let dragging = $state(false); let dragging = $state(false);
@ -38,7 +44,7 @@
bind:this={containerEl} bind:this={containerEl}
> >
<div class="side" style="flex: {node.ratio}"> <div class="side" style="flex: {node.ratio}">
<Pane node={node.a} /> <Pane node={node.a} {activeLeafId} />
</div> </div>
<div <div
class="gutter" class="gutter"
@ -53,7 +59,7 @@
onpointercancel={onPointerUp} onpointercancel={onPointerUp}
></div> ></div>
<div class="side" style="flex: {1 - node.ratio}"> <div class="side" style="flex: {1 - node.ratio}">
<Pane node={node.b} /> <Pane node={node.b} {activeLeafId} />
</div> </div>
</div> </div>

View file

@ -34,7 +34,9 @@ export interface TreeOps {
export class Orchestration { export class Orchestration {
// ---- shared reactive state ---------------------------------------------- // ---- shared reactive state ----------------------------------------------
activeLeafId = $state<NodeId | null>(null); // (activeLeafId lives at App level and is drilled as a prop — Svelte 5
// doesn't seem to track class-field $state reads from child components
// that obtain the instance via getContext. Tested empirically.)
notifications = $state<Toast[]>([]); notifications = $state<Toast[]>([]);
distros = $state<string[]>([]); distros = $state<string[]>([]);
@ -52,14 +54,17 @@ export class Orchestration {
this.#ops = ops; this.#ops = ops;
} }
// ---- active pane -------------------------------------------------------- // ---- active pane (delegated to App) -------------------------------------
setActive(leafId: NodeId): void { // These point at App-level $state mutators set via configure().
console.log("[orch] setActive", leafId, "was", this.activeLeafId); setActive: (leafId: NodeId) => void = () => {};
this.activeLeafId = leafId; clearActiveIf: (leafId: NodeId) => void = () => {};
}
clearActiveIf(leafId: NodeId): void { configureActiveHandlers(
if (this.activeLeafId === leafId) this.activeLeafId = null; setActive: (leafId: NodeId) => void,
clearActiveIf: (leafId: NodeId) => void,
): void {
this.setActive = setActive;
this.clearActiveIf = clearActiveIf;
} }
// ---- notifications ------------------------------------------------------ // ---- notifications ------------------------------------------------------