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

@ -5,12 +5,14 @@
saveWorkspace,
loadWorkspace,
writeToPane,
type PaneId,
} from "./ipc";
import Pane from "./lib/layout/Pane.svelte";
import Notifications, { type Toast } from "./components/Notifications.svelte";
import Notifications from "./components/Notifications.svelte";
import Palette from "./components/Palette.svelte";
import type { PaneOps } from "./lib/layout/ops";
import {
provideOrchestration,
type TreeOps,
} from "./lib/layout/orchestration.svelte";
import {
type TreeNode,
type NodeId,
@ -36,20 +38,69 @@
const LEGACY_STORAGE_KEY = "tiletopia.tree.v1";
let distros = $state<string[]>([]);
let defaultDistro = $state<string | undefined>(undefined);
let ready = $state(false);
let tree = $state<TreeNode>(newLeaf());
// ---- orchestration state (M4) -------------------------------------------
// leafId -> backend PaneId. Plain Map (no reactivity needed — only read
// from event handlers). Repopulated on every XtermPane mount.
const paneIdByLeaf = new Map<NodeId, PaneId>();
let notifications = $state<Toast[]>([]);
let nextNotifId = 1;
let activeLeafId = $state<NodeId | null>(null);
let paletteOpen = $state(false);
// ---- tree mutation handlers (closures over tree $state) -----------------
function handleSplit(leafId: NodeId, orientation: Orientation) {
const parent = findLeaf(tree, leafId);
const inherit = parent
? { distro: parent.distro ?? defaultDistro, cwd: parent.cwd }
: { distro: defaultDistro };
tree = splitLeaf(tree, leafId, orientation, inherit);
}
function handleClose(leafId: NodeId) {
const next = closeLeaf(tree, leafId);
tree = next ?? newLeaf({ distro: defaultDistro });
orch.clearActiveIf(leafId);
}
function handleSetDistro(leafId: NodeId, distro: string) {
tree = changeDistro(tree, leafId, distro);
}
function handleSetLabel(leafId: NodeId, label: string | undefined) {
tree = changeLabel(tree, leafId, label);
}
function handleToggleBroadcast(leafId: NodeId) {
tree = toggleBroadcastInTree(tree, leafId);
}
function handleBroadcastFrom(originLeafId: NodeId, dataB64: string) {
let peers = 0;
for (const leaf of walkLeaves(tree)) {
if (leaf.id === originLeafId) continue;
if (!leaf.broadcast) continue;
const paneId = orch.paneIdByLeaf.get(leaf.id);
if (paneId == null) {
console.warn("[tiletopia] broadcast peer has no paneId yet:", leaf.id);
continue;
}
peers++;
writeToPane(paneId, dataB64).catch((e) =>
console.warn("[tiletopia] broadcast write failed:", e),
);
}
console.log("[tiletopia] broadcastFrom", originLeafId, "→", peers, "peer(s)");
}
const treeOps: TreeOps = {
split: handleSplit,
close: handleClose,
setDistro: handleSetDistro,
setLabel: handleSetLabel,
toggleBroadcast: handleToggleBroadcast,
broadcastFrom: handleBroadcastFrom,
};
// Provide the orchestration store. All Pane / SplitNode / LeafPane
// descendants consume it via `useOrchestration()` — no prop drilling.
const orch = provideOrchestration(treeOps);
function isInteractiveDistro(name: string): boolean {
return !name.toLowerCase().startsWith("docker-desktop");
}
@ -82,8 +133,9 @@
// 3. Resolve default distro.
try {
distros = await listDistros();
defaultDistro = distros.find(isInteractiveDistro) ?? distros[0];
const ds = await listDistros();
orch.distros = ds;
defaultDistro = ds.find(isInteractiveDistro) ?? ds[0];
} catch (e) {
console.warn("list_distros failed:", e);
}
@ -128,97 +180,10 @@
paletteOpen = !paletteOpen;
}
}
window.addEventListener("keydown", onKey, true); // capture phase
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
});
// ---- pane ops ------------------------------------------------------------
function handleSplit(leafId: NodeId, orientation: Orientation) {
const parent = findLeaf(tree, leafId);
const inherit = parent
? { distro: parent.distro ?? defaultDistro, cwd: parent.cwd }
: { distro: defaultDistro };
tree = splitLeaf(tree, leafId, orientation, inherit);
}
function handleClose(leafId: NodeId) {
const next = closeLeaf(tree, leafId);
tree = next ?? newLeaf({ distro: defaultDistro });
if (activeLeafId === leafId) activeLeafId = null;
}
function handleSetDistro(leafId: NodeId, distro: string) {
tree = changeDistro(tree, leafId, distro);
}
function handleSetLabel(leafId: NodeId, label: string | undefined) {
tree = changeLabel(tree, leafId, label);
}
function handleToggleBroadcast(leafId: NodeId) {
tree = toggleBroadcastInTree(tree, leafId);
const updated = findLeaf(tree, leafId);
console.log("[tiletopia] toggleBroadcast:", leafId, "now:", updated?.broadcast);
}
function handleBroadcastFrom(originLeafId: NodeId, dataB64: string) {
let peers = 0;
for (const leaf of walkLeaves(tree)) {
if (leaf.id === originLeafId) continue;
if (!leaf.broadcast) continue;
const paneId = paneIdByLeaf.get(leaf.id);
if (paneId == null) {
console.warn("[tiletopia] broadcast peer has no paneId yet:", leaf.id);
continue;
}
peers++;
writeToPane(paneId, dataB64).catch((e) =>
console.warn("[tiletopia] broadcast write failed:", e),
);
}
console.log("[tiletopia] broadcastFrom", originLeafId, "→", peers, "peer(s)");
}
function handleSetActivePane(leafId: NodeId) {
console.log("[tiletopia] setActivePane:", leafId, "(was:", activeLeafId, ")");
activeLeafId = leafId;
}
function handleRegisterPaneId(leafId: NodeId, paneId: PaneId | null) {
if (paneId == null) paneIdByLeaf.delete(leafId);
else paneIdByLeaf.set(leafId, paneId);
}
function handleNotify(message: string) {
const id = nextNotifId++;
console.log("[tiletopia] notify:", message, "(id:", id, ")");
notifications.push({ id, message });
setTimeout(() => {
notifications = notifications.filter((n) => n.id !== id);
}, 5000);
}
function dismissNotification(id: number) {
notifications = notifications.filter((n) => n.id !== id);
}
// Note: activeLeafId is NOT in ops — it's drilled as a separate prop
// through Pane / SplitNode so each LeafPane reactively picks up changes.
// Bundling it in ops via $derived caused subsequent activeLeafId changes
// to not propagate to children (Svelte 5 prop-as-derived-object quirk).
const ops: PaneOps = $derived({
split: handleSplit,
close: handleClose,
setDistro: handleSetDistro,
setLabel: handleSetLabel,
toggleBroadcast: handleToggleBroadcast,
broadcastFrom: handleBroadcastFrom,
setActivePane: handleSetActivePane,
registerPaneId: handleRegisterPaneId,
notify: handleNotify,
distros,
});
// ---- preset layouts ------------------------------------------------------
function applyPreset(make: (d: { distro?: string }) => TreeNode) {
const count = leafCount(tree);
@ -235,7 +200,7 @@
});
function onPalettePick(leafId: string) {
activeLeafId = leafId;
orch.setActive(leafId);
paletteOpen = false;
}
</script>
@ -245,11 +210,11 @@
<span class="label">tiletopia</span>
<span class="distros">
{#if distros.length === 0}
{#if orch.distros.length === 0}
<span class="muted">no distros enumerated</span>
{:else}
<span class="muted">default:</span>
{#each distros as d}
{#each orch.distros as d}
<button
class="distro-btn"
class:active={d === defaultDistro}
@ -272,7 +237,7 @@
<button class="palette-btn" onclick={() => (paletteOpen = true)} title="Jump to pane (Ctrl+K)">
⌘K
</button>
<button class="palette-btn" onclick={() => handleNotify("test toast at " + new Date().toLocaleTimeString())} title="Fire a test toast">
<button class="palette-btn" onclick={() => orch.notify("test toast at " + new Date().toLocaleTimeString())} title="Fire a test toast">
🔔
</button>
@ -283,11 +248,14 @@
<div class="pane-wrap">
{#if ready}
<Pane node={tree} {ops} {activeLeafId} />
<Pane node={tree} />
{/if}
</div>
<Notifications {notifications} onDismiss={dismissNotification} />
<Notifications
notifications={orch.notifications}
onDismiss={(id) => orch.dismiss(id)}
/>
{#if paletteOpen}
<Palette

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;
}

View file

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

View file

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

View file

@ -1,34 +0,0 @@
import type { NodeId, Orientation } from "./tree";
import type { PaneId } from "../../ipc";
/**
* Bundle of operations + data that any pane in the tree may need.
* Passed down through Pane / SplitNode / LeafPane to avoid per-callback
* prop drilling.
*/
export interface PaneOps {
// ---- tree mutation
split: (leafId: NodeId, orientation: Orientation) => void;
close: (leafId: NodeId) => void;
setDistro: (leafId: NodeId, distro: string) => void;
setLabel: (leafId: NodeId, label: string | undefined) => void;
toggleBroadcast: (leafId: NodeId) => void;
// ---- orchestration (M4)
/**
* Called from a broadcasting pane when its user types. App looks up
* every other broadcast-enabled leaf and writes the same bytes to it.
* Origin pane's own PTY is written by XtermPane directly.
*/
broadcastFrom: (originLeafId: NodeId, dataB64: string) => void;
/** Mark a leaf as the active (focused) pane. */
setActivePane: (leafId: NodeId) => void;
/** LeafPane reports its backend PaneId once spawned, or null on destroy. */
registerPaneId: (leafId: NodeId, paneId: PaneId | null) => void;
/** Append a transient toast to the notification stack. */
notify: (message: string) => void;
// ---- data
/** All distros known to the backend; populated once at app start. */
distros: string[];
}

View file

@ -0,0 +1,138 @@
/**
* Orchestration store all shared reactive state + operations the
* pane tree needs. Lives in a class with `$state` fields so Svelte 5
* reactivity tracks per-property access; provided via context so any
* descendant component can `useOrchestration()` without prop drilling.
*
* (File must be `.svelte.ts` because `$state` can only be used in
* Svelte components or files with the `.svelte.{js,ts}` extension.)
*/
import { setContext, getContext } from "svelte";
import type { NodeId, Orientation } from "./tree";
import type { PaneId } from "../../ipc";
export interface Toast {
id: number;
message: string;
}
/**
* Callbacks App provides at construction time. These do tree mutations
* (which require access to App's `tree = $state(...)`) plus broadcast
* routing (which also needs the tree). Kept as an injection seam rather
* than living inside the store so the store doesn't need to own the tree.
*/
export interface TreeOps {
split: (leafId: NodeId, orientation: Orientation) => void;
close: (leafId: NodeId) => void;
setDistro: (leafId: NodeId, distro: string) => void;
setLabel: (leafId: NodeId, label: string | undefined) => void;
toggleBroadcast: (leafId: NodeId) => void;
broadcastFrom: (originLeafId: NodeId, dataB64: string) => void;
}
export class Orchestration {
// ---- shared reactive state ----------------------------------------------
activeLeafId = $state<NodeId | null>(null);
notifications = $state<Toast[]>([]);
distros = $state<string[]>([]);
// ---- non-reactive lookups -----------------------------------------------
// Plain Map: broadcast routing reads this from an event handler, not
// from reactive context. No need for $state.
paneIdByLeaf = new Map<NodeId, PaneId>();
// ---- internal -----------------------------------------------------------
#nextNotifId = 1;
#dismissTimers = new Map<number, ReturnType<typeof setTimeout>>();
#ops: TreeOps;
constructor(ops: TreeOps) {
this.#ops = ops;
}
// ---- active pane --------------------------------------------------------
setActive(leafId: NodeId): void {
console.log("[orch] setActive", leafId, "was", this.activeLeafId);
this.activeLeafId = leafId;
}
clearActiveIf(leafId: NodeId): void {
if (this.activeLeafId === leafId) this.activeLeafId = null;
}
// ---- notifications ------------------------------------------------------
notify(message: string): void {
const id = this.#nextNotifId++;
console.log("[orch] notify", message);
this.notifications.push({ id, message });
const timer = setTimeout(() => {
this.notifications = this.notifications.filter((n) => n.id !== id);
this.#dismissTimers.delete(id);
}, 5000);
this.#dismissTimers.set(id, timer);
}
dismiss(id: number): void {
const t = this.#dismissTimers.get(id);
if (t) {
clearTimeout(t);
this.#dismissTimers.delete(id);
}
this.notifications = this.notifications.filter((n) => n.id !== id);
}
// ---- pane id registry ---------------------------------------------------
registerPaneId(leafId: NodeId, paneId: PaneId | null): void {
if (paneId == null) this.paneIdByLeaf.delete(leafId);
else this.paneIdByLeaf.set(leafId, paneId);
}
// ---- delegated tree ops -------------------------------------------------
// Thin pass-through so consumers only need one object.
split(leafId: NodeId, orientation: Orientation): void {
console.log("[orch] split", leafId, orientation);
this.#ops.split(leafId, orientation);
}
close(leafId: NodeId): void {
console.log("[orch] close", leafId);
this.#ops.close(leafId);
}
setDistro(leafId: NodeId, distro: string): void {
this.#ops.setDistro(leafId, distro);
}
setLabel(leafId: NodeId, label: string | undefined): void {
this.#ops.setLabel(leafId, label);
}
toggleBroadcast(leafId: NodeId): void {
console.log("[orch] toggleBroadcast", leafId);
this.#ops.toggleBroadcast(leafId);
}
broadcastFrom(originLeafId: NodeId, dataB64: string): void {
this.#ops.broadcastFrom(originLeafId, dataB64);
}
}
const KEY = Symbol("tiletopia.orchestration");
export function provideOrchestration(ops: TreeOps): Orchestration {
const o = new Orchestration(ops);
setContext(KEY, o);
return o;
}
export function useOrchestration(): Orchestration {
const o = getContext<Orchestration | undefined>(KEY);
if (!o) {
throw new Error(
"useOrchestration() called outside a provideOrchestration() ancestor",
);
}
return o;
}