Fix M4 reactivity bugs via context + class store

Symptoms in v0.1.0 install: 📡 broadcast button didn't change color
on toggle, × close button didn't remove the pane, blue active
border stuck on the first pane. All three were UI-not-rerendering-
on-state-change manifestations of the same prop-reactivity quirk
that drilling activeLeafId tried (and apparently failed) to fix.

Refactor to the Svelte 5 canonical pattern for shared reactive
state:

- New src/lib/layout/orchestration.svelte.ts with an Orchestration
  class. Reactive fields (activeLeafId, notifications, distros) are
  class-field $state declarations; methods mutate them directly.
  Provided via context (provideOrchestration / useOrchestration);
  no prop drilling.
- App.svelte: provideOrchestration(treeOps). Tree mutations remain
  closures over the App-level tree $state; the class delegates to
  them. Pane only takes `node` now.
- Pane.svelte / SplitNode.svelte: stop drilling ops + activeLeafId.
  Pure pass-through of node.
- LeafPane.svelte: useOrchestration(); `active = $derived(
  orch.activeLeafId === leaf.id)` reads the class field directly so
  Svelte 5 tracks it per-property.
- Notifications.svelte: receives notifications + onDismiss from App
  (which gets them from orch).
- Deleted src/lib/layout/ops.ts (TreeOps moved into orchestration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-22 13:59:34 +01:00
parent 6b9a3adf85
commit e871ee8e6e
6 changed files with 243 additions and 204 deletions

View file

@ -1,20 +1,16 @@
<script lang="ts">
import { onDestroy } from "svelte";
import type { LeafNode } from "./tree";
import type { PaneOps } from "./ops";
import { useOrchestration } from "./orchestration.svelte";
import XtermPane from "../../components/XtermPane.svelte";
let {
leaf,
ops,
activeLeafId,
}: {
leaf: LeafNode;
ops: PaneOps;
activeLeafId: string | null;
} = $props();
let { leaf }: { leaf: LeafNode } = $props();
const active = $derived(activeLeafId === leaf.id);
const orch = useOrchestration();
// Derives directly from orch.activeLeafId — Svelte 5 tracks the class
// field access on every re-evaluation. No prop drilling involved.
const active = $derived(orch.activeLeafId === leaf.id);
let status = $state("starting…");
let statusOk = $state(true);
@ -33,7 +29,7 @@
function commitLabel() {
if (!editingLabel) return;
ops.setLabel(leaf.id, labelDraft);
orch.setLabel(leaf.id, labelDraft);
editingLabel = false;
}
@ -61,7 +57,7 @@
function pickDistro(d: string) {
distroOpen = false;
if (d !== leaf.distro) ops.setDistro(leaf.id, d);
if (d !== leaf.distro) orch.setDistro(leaf.id, d);
}
$effect(() => {
@ -75,7 +71,6 @@
const IDLE_THRESHOLD_MS = 5000;
let lastDataTime = Date.now();
let notifiedThisIdle = false;
let idleTimer: number | null = null;
function onDataReceived() {
lastDataTime = Date.now();
@ -89,21 +84,16 @@
notifiedThisIdle = true;
const name = leaf.label ?? leaf.distro ?? "pane";
console.log("[tiletopia] notifying idle:", leaf.id, "quietForMs:", sinceLast);
ops.notify(`${name} is idle`);
orch.notify(`${name} is idle`);
}
}
idleTimer = window.setInterval(checkIdle, 1000);
onDestroy(() => {
if (idleTimer != null) clearInterval(idleTimer);
});
const idleTimer = window.setInterval(checkIdle, 1000);
onDestroy(() => clearInterval(idleTimer));
// ---- broadcast -----------------------------------------------------------
function onTerminalInput(b64: string) {
if (leaf.broadcast) {
console.log("[tiletopia] broadcasting from:", leaf.id);
ops.broadcastFrom(leaf.id, b64);
}
if (leaf.broadcast) orch.broadcastFrom(leaf.id, b64);
}
// ---- focus / active ------------------------------------------------------
@ -115,21 +105,19 @@
function onPaneClick() {
console.log("[tiletopia] pane click:", leaf.id, "currentlyActive:", active);
if (!active) ops.setActivePane(leaf.id);
if (!active) orch.setActive(leaf.id);
}
// ---- pane id registration ------------------------------------------------
function onPaneSpawned(paneId: number) {
ops.registerPaneId(leaf.id, paneId);
orch.registerPaneId(leaf.id, paneId);
}
onDestroy(() => {
ops.registerPaneId(leaf.id, null);
});
onDestroy(() => orch.registerPaneId(leaf.id, null));
</script>
<div
class="leaf"
class:active
class:active={active}
class:broadcasting={leaf.broadcast}
role="group"
aria-label={"Terminal pane: " + (leaf.label ?? leaf.distro ?? "unnamed")}
@ -171,7 +159,7 @@
tabindex="-1"
onkeydown={() => {}}
>
{#each ops.distros as d}
{#each orch.distros as d}
<button
class="distro-menu-item"
class:active={d === leaf.distro}
@ -185,7 +173,7 @@
<button
class="bcast-chip"
class:on={leaf.broadcast}
onclick={(e) => { e.stopPropagation(); ops.toggleBroadcast(leaf.id); }}
onclick={(e) => { e.stopPropagation(); orch.toggleBroadcast(leaf.id); }}
title={leaf.broadcast ? "Broadcasting (click to leave group)" : "Click to broadcast input to other broadcast panes"}
aria-pressed={leaf.broadcast ? "true" : "false"}
>📡</button>
@ -196,19 +184,19 @@
<button
class="pane-btn"
title="Split right"
onclick={(e) => { e.stopPropagation(); ops.split(leaf.id, "h"); }}
onclick={(e) => { e.stopPropagation(); orch.split(leaf.id, "h"); }}
aria-label="Split right"
>⇥</button>
<button
class="pane-btn"
title="Split down"
onclick={(e) => { e.stopPropagation(); ops.split(leaf.id, "v"); }}
onclick={(e) => { e.stopPropagation(); orch.split(leaf.id, "v"); }}
aria-label="Split down"
>⇣</button>
<button
class="pane-btn close"
title="Close pane"
onclick={(e) => { e.stopPropagation(); ops.close(leaf.id); }}
onclick={(e) => { e.stopPropagation(); orch.close(leaf.id); }}
aria-label="Close pane"
>×</button>
</span>
@ -237,12 +225,9 @@
height: 100%;
min-width: 0;
min-height: 0;
border: 1px solid transparent;
border: 2px solid transparent;
box-sizing: border-box;
}
.leaf {
border-width: 2px;
}
.leaf.active {
border-color: #5a8cd8;
}