Fix M4 reactivity bugs: active border, Ctrl+K, diagnostics

- Drill activeLeafId as a separate prop through Pane -> SplitNode ->
  LeafPane instead of bundling it into the \$derived ops object.
  Passing activeLeafId via ops caused subsequent focus changes to
  not propagate to children (LeafPane's active = \$derived(...) wasn't
  re-evaluating when ops's identity changed). Drilling sidesteps any
  prop-as-derived-object reactivity quirks.
- Ctrl+K listener now uses capture phase so it wins over xterm.js's
  keydown handler inside the focused terminal.
- Bump active/broadcasting borders to 2px and brighter colors so the
  visual change is unmissable.
- Add a 🔔 test-toast button in the titlebar to verify the
  notification pipeline independently of idle detection.
- Sprinkle console.log diagnostics through the active/broadcast/
  idle/notify flows so we can pinpoint any remaining issues from
  devtools next time something looks off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-22 13:20:11 +01:00
parent 3c2f6b8640
commit 547b47ded4
5 changed files with 50 additions and 18 deletions

View file

@ -118,15 +118,18 @@
}); });
// ---- Ctrl+K palette toggle ---------------------------------------------- // ---- Ctrl+K palette toggle ----------------------------------------------
// Capture phase so we win over xterm.js's keystroke capture inside terminals.
$effect(() => { $effect(() => {
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
console.log("[tiletopia] Ctrl+K caught, paletteOpen ->", !paletteOpen);
e.preventDefault(); e.preventDefault();
e.stopPropagation();
paletteOpen = !paletteOpen; paletteOpen = !paletteOpen;
} }
} }
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey, true); // capture phase
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey, true);
}); });
// ---- pane ops ------------------------------------------------------------ // ---- pane ops ------------------------------------------------------------
@ -154,21 +157,30 @@
function handleToggleBroadcast(leafId: NodeId) { function handleToggleBroadcast(leafId: NodeId) {
tree = toggleBroadcastInTree(tree, leafId); tree = toggleBroadcastInTree(tree, leafId);
const updated = findLeaf(tree, leafId);
console.log("[tiletopia] toggleBroadcast:", leafId, "now:", updated?.broadcast);
} }
function handleBroadcastFrom(originLeafId: NodeId, dataB64: string) { function handleBroadcastFrom(originLeafId: NodeId, dataB64: string) {
let peers = 0;
for (const leaf of walkLeaves(tree)) { for (const leaf of walkLeaves(tree)) {
if (leaf.id === originLeafId) continue; if (leaf.id === originLeafId) continue;
if (!leaf.broadcast) continue; if (!leaf.broadcast) continue;
const paneId = paneIdByLeaf.get(leaf.id); const paneId = paneIdByLeaf.get(leaf.id);
if (paneId == null) continue; if (paneId == null) {
console.warn("[tiletopia] broadcast peer has no paneId yet:", leaf.id);
continue;
}
peers++;
writeToPane(paneId, dataB64).catch((e) => writeToPane(paneId, dataB64).catch((e) =>
console.warn("broadcast write failed:", e), console.warn("[tiletopia] broadcast write failed:", e),
); );
} }
console.log("[tiletopia] broadcastFrom", originLeafId, "→", peers, "peer(s)");
} }
function handleSetActivePane(leafId: NodeId) { function handleSetActivePane(leafId: NodeId) {
console.log("[tiletopia] setActivePane:", leafId, "(was:", activeLeafId, ")");
activeLeafId = leafId; activeLeafId = leafId;
} }
@ -179,6 +191,7 @@
function handleNotify(message: string) { function handleNotify(message: string) {
const id = nextNotifId++; const id = nextNotifId++;
console.log("[tiletopia] notify:", message, "(id:", id, ")");
notifications.push({ id, message }); notifications.push({ id, message });
setTimeout(() => { setTimeout(() => {
notifications = notifications.filter((n) => n.id !== id); notifications = notifications.filter((n) => n.id !== id);
@ -189,6 +202,10 @@
notifications = notifications.filter((n) => n.id !== id); 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({ const ops: PaneOps = $derived({
split: handleSplit, split: handleSplit,
close: handleClose, close: handleClose,
@ -200,7 +217,6 @@
registerPaneId: handleRegisterPaneId, registerPaneId: handleRegisterPaneId,
notify: handleNotify, notify: handleNotify,
distros, distros,
activeLeafId,
}); });
// ---- preset layouts ------------------------------------------------------ // ---- preset layouts ------------------------------------------------------
@ -256,6 +272,9 @@
<button class="palette-btn" onclick={() => (paletteOpen = true)} title="Jump to pane (Ctrl+K)"> <button class="palette-btn" onclick={() => (paletteOpen = true)} title="Jump to pane (Ctrl+K)">
⌘K ⌘K
</button> </button>
<button class="palette-btn" onclick={() => handleNotify("test toast at " + new Date().toLocaleTimeString())} title="Fire a test toast">
🔔
</button>
<span class="layout-info"> <span class="layout-info">
{leafCount(tree)} pane{leafCount(tree) === 1 ? "" : "s"} {leafCount(tree)} pane{leafCount(tree) === 1 ? "" : "s"}
@ -264,7 +283,7 @@
<div class="pane-wrap"> <div class="pane-wrap">
{#if ready} {#if ready}
<Pane node={tree} {ops} /> <Pane node={tree} {ops} {activeLeafId} />
{/if} {/if}
</div> </div>

View file

@ -7,12 +7,14 @@
let { let {
leaf, leaf,
ops, ops,
activeLeafId,
}: { }: {
leaf: LeafNode; leaf: LeafNode;
ops: PaneOps; ops: PaneOps;
activeLeafId: string | null;
} = $props(); } = $props();
const active = $derived(ops.activeLeafId === leaf.id); const active = $derived(activeLeafId === leaf.id);
let status = $state("starting…"); let status = $state("starting…");
let statusOk = $state(true); let statusOk = $state(true);
@ -82,9 +84,11 @@
function checkIdle() { function checkIdle() {
if (notifiedThisIdle) return; if (notifiedThisIdle) return;
if (Date.now() - lastDataTime >= IDLE_THRESHOLD_MS) { const sinceLast = Date.now() - lastDataTime;
if (sinceLast >= IDLE_THRESHOLD_MS) {
notifiedThisIdle = true; notifiedThisIdle = true;
const name = leaf.label ?? leaf.distro ?? "pane"; const name = leaf.label ?? leaf.distro ?? "pane";
console.log("[tiletopia] notifying idle:", leaf.id, "quietForMs:", sinceLast);
ops.notify(`${name} is idle`); ops.notify(`${name} is idle`);
} }
} }
@ -96,7 +100,10 @@
// ---- broadcast ----------------------------------------------------------- // ---- broadcast -----------------------------------------------------------
function onTerminalInput(b64: string) { function onTerminalInput(b64: string) {
if (leaf.broadcast) ops.broadcastFrom(leaf.id, b64); if (leaf.broadcast) {
console.log("[tiletopia] broadcasting from:", leaf.id);
ops.broadcastFrom(leaf.id, b64);
}
} }
// ---- focus / active ------------------------------------------------------ // ---- focus / active ------------------------------------------------------
@ -107,6 +114,7 @@
}); });
function onPaneClick() { function onPaneClick() {
console.log("[tiletopia] pane click:", leaf.id, "currentlyActive:", active);
if (!active) ops.setActivePane(leaf.id); if (!active) ops.setActivePane(leaf.id);
} }
@ -232,14 +240,17 @@
border: 1px solid transparent; border: 1px solid transparent;
box-sizing: border-box; box-sizing: border-box;
} }
.leaf {
border-width: 2px;
}
.leaf.active { .leaf.active {
border-color: #3a5a8c; border-color: #5a8cd8;
} }
.leaf.broadcasting { .leaf.broadcasting {
border-color: #c98a1f; border-color: #e09838;
} }
.leaf.active.broadcasting { .leaf.active.broadcasting {
border-color: #e0a432; border-color: #ffb840;
} }
.pane-toolbar { .pane-toolbar {

View file

@ -7,16 +7,18 @@
let { let {
node, node,
ops, ops,
activeLeafId,
}: { }: {
node: TreeNode; node: TreeNode;
ops: PaneOps; ops: PaneOps;
activeLeafId: string | null;
} = $props(); } = $props();
</script> </script>
{#if node.kind === "split"} {#if node.kind === "split"}
<SplitNode {node} {ops} /> <SplitNode {node} {ops} {activeLeafId} />
{:else} {:else}
{#key node.id} {#key node.id}
<LeafPane leaf={node} {ops} /> <LeafPane leaf={node} {ops} {activeLeafId} />
{/key} {/key}
{/if} {/if}

View file

@ -6,9 +6,11 @@
let { let {
node, node,
ops, ops,
activeLeafId,
}: { }: {
node: SplitNode; node: SplitNode;
ops: PaneOps; ops: PaneOps;
activeLeafId: string | null;
} = $props(); } = $props();
let containerEl: HTMLDivElement; let containerEl: HTMLDivElement;
@ -45,7 +47,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} {ops} /> <Pane node={node.a} {ops} {activeLeafId} />
</div> </div>
<div <div
class="gutter" class="gutter"
@ -60,7 +62,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} {ops} /> <Pane node={node.b} {ops} {activeLeafId} />
</div> </div>
</div> </div>

View file

@ -31,6 +31,4 @@ export interface PaneOps {
// ---- data // ---- data
/** All distros known to the backend; populated once at app start. */ /** All distros known to the backend; populated once at app start. */
distros: string[]; distros: string[];
/** The currently-focused pane, if any. */
activeLeafId: NodeId | null;
} }