a73x

f589c333

feat: web wall management — reconcile by id, add tile, remove tile

a73x   2026-08-18 18:00

Commit message
feat: web wall management — reconcile by id, add tile, remove tile

web/index.html
Old New
@@ -59,6 +59,24 @@
59 display: none; position: fixed; inset: 0; z-index: 10; background: #000a; 59 display: none; position: fixed; inset: 0; z-index: 10; background: #000a;
60 } 60 }
61 #shade.on { display: block; } 61 #shade.on { display: block; }
62 /* The add tile: the wall's one standing control. A tile-shaped door,
63 not a toolbar — the wall stays a wall. */
64 .tile.add {
65 min-height: 80px; align-items: center; justify-content: center;
66 cursor: text; border-style: dashed;
67 }
68 .tile.add input {
69 width: 90%; background: transparent; border: 0; outline: none;
70 color: var(--fg); font: inherit; text-align: center;
71 }
72 .tile.add .err { color: #e07a7a; font-size: 11px; }
73 .tile header .close {
74 padding: 0 6px; border: 0; background: transparent; color: var(--dim);
75 font: inherit; cursor: pointer;
76 }
77 .tile header .close:hover { color: #e07a7a; }
78 /* Zoomed = the terminal owns the header; wall management waits. */
79 .tile.zoomed header .close { display: none; }
62 /* IME target: focusable, invisible, never display:none (that kills IME). */ 80 /* IME target: focusable, invisible, never display:none (that kills IME). */
63 #ime { 81 #ime {
64 position: fixed; left: -9999px; top: 0; width: 1px; height: 1px; 82 position: fixed; left: -9999px; top: 0; width: 1px; height: 1px;
web/mux.js
Old New
@@ -117,9 +117,13 @@ const METRICS = (() => {
117 let compiledCore = null; // one compile, one instance per tile 117 let compiledCore = null; // one compile, one instance per tile
118 118
119 class Tile { 119 class Tile {
120 constructor(idx, label, wallEl, session) { 120 constructor(id, label, wallEl, session) {
121 this.idx = idx; 121 // The hub's tile id, stable for the run and NOT a position: the wall is
122 // reorderable, so an index would name a different tile after one drag.
123 this.id = id;
122 this.label = label; 124 this.label = label;
125 this.dead = false; // shutdown ran: this tile reconnects no more
126 this.reconnectTimer = null;
123 // The daemon session this tile attaches to, '' for the default. It is 127 // The daemon session this tile attaches to, '' for the default. It is
124 // the hub's `TARGET#NAME` suffix, handed over by /tiles rather than dug 128 // the hub's `TARGET#NAME` suffix, handed over by /tiles rather than dug
125 // back out of the label — see sendAttach for what happens to it. 129 // back out of the label — see sendAttach for what happens to it.
@@ -160,9 +164,21 @@ class Tile {
160 164
161 this.el = document.createElement('div'); 165 this.el = document.createElement('div');
162 this.el.className = 'tile'; 166 this.el.className = 'tile';
167 // The id lives on the node too: reorder reads the wall's order back out
168 // of the DOM, and the node is what a drag moves.
169 this.el.dataset.tileId = String(id);
163 this.el.innerHTML = 170 this.el.innerHTML =
164 `<header><span class="label"></span><button class="copy-request" type="button">Copy</button><span class="badge connecting">connecting</span></header>`; 171 `<header><span class="label"></span><button class="copy-request" type="button">Copy</button><button class="close" type="button">×</button><span class="badge connecting">connecting</span></header>`;
165 this.el.querySelector('.label').textContent = `${idx}: ${label}`; 172 // The label only: the wall's order is mutable, so a baked-in number
173 // would lie the moment anything moved.
174 this.el.querySelector('.label').textContent = label;
175 this.el.querySelector('.close').addEventListener('click', (ev) => {
176 ev.stopPropagation(); // a close is not a zoom
177 removeTile(this.id);
178 // Same reason the copy control does it: clicking a button takes focus,
179 // and the hidden IME is what a zoomed tile's keys come from.
180 ime.focus();
181 });
166 this.copyButton = this.el.querySelector('.copy-request'); 182 this.copyButton = this.el.querySelector('.copy-request');
167 this.copyButton.addEventListener('click', (ev) => { 183 this.copyButton.addEventListener('click', (ev) => {
168 ev.stopPropagation(); 184 ev.stopPropagation();
@@ -216,7 +232,7 @@ class Tile {
216 // re-attach on `up` still quotes have_seq/have_epoch and resumes. 232 // re-attach on `up` still quotes have_seq/have_epoch and resumes.
217 connect() { 233 connect() {
218 this.wsOpened = false; 234 this.wsOpened = false;
219 this.ws = new WebSocket(`ws://${location.host}/ws/${this.idx}`); 235 this.ws = new WebSocket(`ws://${location.host}/ws/${this.id}`);
220 this.ws.binaryType = 'arraybuffer'; 236 this.ws.binaryType = 'arraybuffer';
221 this.ws.onopen = () => { 237 this.ws.onopen = () => {
222 this.cancelScrollRequest(); 238 this.cancelScrollRequest();
@@ -236,6 +252,10 @@ class Tile {
236 // onerror always precedes onclose; the badge is decided in one place. 252 // onerror always precedes onclose; the badge is decided in one place.
237 this.ws.onerror = () => {}; 253 this.ws.onerror = () => {};
238 this.ws.onclose = () => { 254 this.ws.onclose = () => {
255 // FIRST: a shut-down tile's socket closes BECAUSE it was shut down.
256 // Reconnecting it would resurrect a tile the wall no longer has, and
257 // a status change would paint a node already out of the document.
258 if (this.dead) return;
239 this.cancelScrollRequest(); 259 this.cancelScrollRequest();
240 this.clearSelection(); 260 this.clearSelection();
241 this.wsFailures = this.wsOpened ? 0 : this.wsFailures + 1; 261 this.wsFailures = this.wsOpened ? 0 : this.wsFailures + 1;
@@ -243,10 +263,28 @@ class Tile {
243 this.setStatus(dead ? 'gone' : 'reconnecting', dead ? 'gone' : 'reconnecting'); 263 this.setStatus(dead ? 'gone' : 'reconnecting', dead ? 'gone' : 'reconnecting');
244 const wait = this.wsBackoffMs; 264 const wait = this.wsBackoffMs;
245 this.wsBackoffMs = nextBackoffMs(this.wsBackoffMs); 265 this.wsBackoffMs = nextBackoffMs(this.wsBackoffMs);
246 setTimeout(() => this.connect(), wait); 266 this.reconnectTimer = setTimeout(() => {
267 this.reconnectTimer = null;
268 this.connect();
269 }, wait);
247 }; 270 };
248 } 271 }
249 272
273 // The tile is gone from the wall (the hub said so). Everything this tile
274 // owns that outlives its DOM node — the socket, the pending reconnect —
275 // is cut here, because a timer that fires later would dial a `/ws/<id>`
276 // the hub answers with a plain 404 and narrate it onto a detached node.
277 shutdown() {
278 this.dead = true;
279 if (zoomedTile === this) unzoom(); // never leave the shade over nothing
280 if (this.reconnectTimer !== null) clearTimeout(this.reconnectTimer);
281 this.reconnectTimer = null;
282 this.cancelScrollRequest();
283 this.clearSelection(false);
284 this.ws?.close();
285 this.el.remove();
286 }
287
250 // Every recovery from a bad frame answers with a re-attach, and the 288 // Every recovery from a bad frame answers with a re-attach, and the
251 // daemon answers THAT with the same snapshot that just failed — so 289 // daemon answers THAT with the same snapshot that just failed — so
252 // "recover and retry" is a flood unless it is bounded. Bounded on the 290 // "recover and retry" is a flood unless it is bounded. Bounded on the
@@ -260,7 +298,7 @@ class Tile {
260 replayFailed(why, terminal) { 298 replayFailed(why, terminal) {
261 if (this.replayDead) return; 299 if (this.replayDead) return;
262 this.replayFailures++; 300 this.replayFailures++;
263 console.warn(`mux tile ${this.idx}: ${why} (replay failure ${this.replayFailures})`); 301 console.warn(`mux tile ${this.id}: ${why} (replay failure ${this.replayFailures})`);
264 if (this.replayFailures >= GONE_AFTER_FAILURES) { 302 if (this.replayFailures >= GONE_AFTER_FAILURES) {
265 this.replayDead = true; // sendAttach is gated on this 303 this.replayDead = true; // sendAttach is gated on this
266 this.setStatus('stuck', terminal); 304 this.setStatus('stuck', terminal);
@@ -333,6 +371,9 @@ class Tile {
333 // ONE gate for every attach, wherever it comes from — the `up` 371 // ONE gate for every attach, wherever it comes from — the `up`
334 // control message included. A tile that gave up on replaying must 372 // control message included. A tile that gave up on replaying must
335 // not be talked back into asking for the same frame again. 373 // not be talked back into asking for the same frame again.
374 // A shut-down tile has no wall to talk for: replayFailed's retry may
375 // already be scheduled, and would otherwise repaint a detached node.
376 if (this.dead) return;
336 this.cancelScrollRequest(); 377 this.cancelScrollRequest();
337 this.clearSelection(); 378 this.clearSelection();
338 if (this.replayDead) return; 379 if (this.replayDead) return;
@@ -1275,6 +1316,9 @@ shade.addEventListener('click', unzoom);
1275 1316
1276 // Keys go ONLY to the zoomed tile — no zoom, no bytes (spec). 1317 // Keys go ONLY to the zoomed tile — no zoom, no bytes (spec).
1277 document.addEventListener('keydown', (ev) => { 1318 document.addEventListener('keydown', (ev) => {
1319 // The add input is a real text field: while it has focus its keys are the
1320 // user spelling a target, not input for any terminal.
1321 if (addInput && document.activeElement === addInput) return;
1278 const t = zoomedTile; 1322 const t = zoomedTile;
1279 if (!t) return; 1323 if (!t) return;
1280 if (ev.isComposing) return; // IME owns it; compositionend delivers 1324 if (ev.isComposing) return; // IME owns it; compositionend delivers
@@ -1353,32 +1397,132 @@ ime.addEventListener('compositionend', (ev) => {
1353 if (zoomedTile && ev.data) zoomedTile.sendText(ev.data); 1397 if (zoomedTile && ev.data) zoomedTile.sendText(ev.data);
1354 ime.value = ''; 1398 ime.value = '';
1355 }); 1399 });
1356 const tiles = []; 1400 // --- the wall ---
1401 // Keyed by the hub's tile id, never by position: /tiles is reorderable and
1402 // two entries can share a label. The DOM under #wall carries the order.
1403 const tilesById = new Map();
1404 let addTileEl = null; // the add tile, built once in boot, always last
1405 let addInput = null; // its one <input> — a real focus target (see keydown)
1406
1357 window.addEventListener('resize', () => { 1407 window.addEventListener('resize', () => {
1358 // The zoomed tile may claim a new grid; every wall tile just re-fits to 1408 // The zoomed tile may claim a new grid; every wall tile just re-fits to
1359 // the width the reflowed grid gave it. 1409 // the width the reflowed grid gave it.
1360 zoomedTile?.sendResizeIfDiffers(); 1410 zoomedTile?.sendResizeIfDiffers();
1361 for (const t of tiles) if (t !== zoomedTile) t.reflow(); 1411 for (const t of tilesById.values()) if (t !== zoomedTile) t.reflow();
1362 zoomedTile?.reflow(); 1412 zoomedTile?.reflow();
1363 }); 1413 });
1364 1414
1415 // `GET /tiles` is the truth about the wall; this makes the page match it.
1416 // Every mutation (add, remove, reorder) goes to the hub and then comes back
1417 // through here, so there is one shape of "what the wall is" and no local
1418 // guess to drift from it.
1419 //
1420 // Two refetches can be in flight at once (Enter twice quickly, a reorder
1421 // overlapping an add) and nothing orders their responses. The last one
1422 // STARTED is the only one allowed to reconcile: an older cfg landing last
1423 // would shut down a tile the newer cfg still contains, and the tile would
1424 // vanish from the wall until something else refetched. One counter, checked
1425 // after every await that precedes a side effect.
1426 let refetchGen = 0;
1427 async function refetchWall() {
1428 const gen = ++refetchGen;
1429 const res = await fetch('/tiles');
1430 if (gen !== refetchGen) return;
1431 // A 500 answers in plain text, not JSON: parsing it would reject and take
1432 // the caller's await with it. The wall simply keeps what it has.
1433 if (!res.ok) { console.error(`mux wall: GET /tiles said ${res.status}`); return; }
1434 const cfg = await res.json();
1435 if (gen !== refetchGen) return;
1436 const wallEl = document.getElementById('wall');
1437 const liveIds = new Set(cfg.map((t) => t.id));
1438 for (const [id, tile] of tilesById) {
1439 if (!liveIds.has(id)) { tilesById.delete(id); tile.shutdown(); }
1440 }
1441 for (const t of cfg) {
1442 let tile = tilesById.get(t.id);
1443 if (!tile) {
1444 // Each entry is {id, label, session}: the label is what the tile calls
1445 // itself, the session is what it attaches to. Two entries can name the
1446 // same host and differ only in the session — that is the whole point.
1447 tile = new Tile(t.id, t.label, wallEl, t.session);
1448 tilesById.set(t.id, tile);
1449 // start() is async: instantiate or mux_init can fail, and an unhandled
1450 // rejection left the tile stuck on 'connecting' with the reason only
1451 // in the console's rejection noise.
1452 tile.start().catch((err) => {
1453 tile.setStatus('gone', 'gone');
1454 console.error(`mux tile ${t.id} (${t.label}): start failed`, err);
1455 });
1456 }
1457 // appendChild MOVES an attached node: walking cfg in order lays the
1458 // wall out in wall order, existing sockets undisturbed.
1459 wallEl.appendChild(tile.el);
1460 }
1461 if (addTileEl) wallEl.appendChild(addTileEl); // the door stays last
1462 }
1463
1464 async function removeTile(id) {
1465 try {
1466 const res = await fetch(`/tiles/${id}`, { method: 'DELETE' });
1467 // 404 is not worth shouting about — the wall already lost it, and the
1468 // refetch below is what agrees the page with that.
1469 if (!res.ok && res.status !== 404)
1470 console.error(`mux remove tile ${id}: hub said ${res.status}`);
1471 } catch (err) {
1472 console.error(`mux remove tile ${id}: request failed`, err);
1473 }
1474 // The close button calls this and drops the promise: a rejection here
1475 // would be an unhandled one, reported as a page error with no owner.
1476 await refetchWall().catch((err) => {
1477 console.error('mux wall: refetch failed', err);
1478 });
1479 }
1480
1481 // The add tile: a tile-shaped door at the end of the wall. It is NOT a Tile
1482 // — no core, no socket, never zooms — so the zoom click path (bound per
1483 // Tile) cannot reach it.
1484 function buildAddTile() {
1485 const el = document.createElement('div');
1486 el.className = 'tile add';
1487 el.innerHTML =
1488 `<input type="text" name="target" aria-label="add a tile"` +
1489 ` autocomplete="off" autocapitalize="off" spellcheck="false"` +
1490 ` placeholder="HOST[#SESSION] | quic://HOST:PORT | --sock PATH"><span class="err"></span>`;
1491 const input = el.querySelector('input');
1492 const err = el.querySelector('.err');
1493 // The whole tile is the affordance; a click anywhere in it lands in the
1494 // input and goes no further (nothing above it should read this click).
1495 el.addEventListener('click', (ev) => { ev.stopPropagation(); input.focus(); });
1496 input.addEventListener('keydown', async (ev) => {
1497 if (ev.key !== 'Enter') return;
1498 ev.preventDefault();
1499 const target = input.value.trim();
1500 if (!target) return;
1501 err.textContent = '';
1502 try {
1503 const res = await fetch('/tiles', { method: 'POST', body: target });
1504 if (!res.ok) {
1505 // The hub's own words for why, kept next to the spelling that
1506 // earned them — the value stays so it can be edited, not retyped.
1507 err.textContent = (await res.text()).trim() || `hub said ${res.status}`;
1508 return;
1509 }
1510 input.value = '';
1511 await refetchWall();
1512 } catch (e) {
1513 // An over-long body is dropped without any reply (the hub's cap), so
1514 // fetch rejects rather than resolving. Silence would read as success.
1515 err.textContent = 'request failed';
1516 console.error('mux add tile: POST /tiles failed', e);
1517 }
1518 });
1519 return el;
1520 }
1521
1365 // --- boot --- 1522 // --- boot ---
1366 (async function boot() { 1523 (async function boot() {
1367 compiledCore = await WebAssembly.compileStreaming(fetch('/mux_core.wasm')); 1524 compiledCore = await WebAssembly.compileStreaming(fetch('/mux_core.wasm'));
1368 // Each entry is {label, session}: the label is what the tile calls 1525 addTileEl = buildAddTile();
1369 // itself, the session is what it attaches to. Two entries can name the 1526 addInput = addTileEl.querySelector('input');
1370 // same host and differ only in the session — that is the whole point. 1527 await refetchWall();
1371 const tilesCfg = await (await fetch('/tiles')).json();
1372 const wall = document.getElementById('wall');
1373 for (let i = 0; i < tilesCfg.length; i++) {
1374 const tile = new Tile(i, tilesCfg[i].label, wall, tilesCfg[i].session);
1375 tiles.push(tile);
1376 // start() is async: instantiate or mux_init can fail, and an unhandled
1377 // rejection left the tile stuck on 'connecting' with the reason only
1378 // in the console's rejection noise.
1379 tile.start().catch((err) => {
1380 tile.setStatus('gone', 'gone');
1381 console.error(`mux tile ${i} (${tilesCfg[i].label}): start failed`, err);
1382 });
1383 }
1384 })(); 1528 })();