a73x

b687a421

feat: web wall — drag reorder, new-session-here

a73x   2026-08-18 18:00

Commit message
feat: web wall — drag reorder, new-session-here

web/index.html
Old New
@@ -70,13 +70,21 @@
70 color: var(--fg); font: inherit; text-align: center; 70 color: var(--fg); font: inherit; text-align: center;
71 } 71 }
72 .tile.add .err { color: #e07a7a; font-size: 11px; } 72 .tile.add .err { color: #e07a7a; font-size: 11px; }
73 .tile header .close { 73 .tile header .close, .tile header .spawn {
74 padding: 0 6px; border: 0; background: transparent; color: var(--dim); 74 padding: 0 6px; border: 0; background: transparent; color: var(--dim);
75 font: inherit; cursor: pointer; 75 font: inherit; cursor: pointer;
76 } 76 }
77 .tile header .close:hover { color: #e07a7a; } 77 .tile header .close:hover { color: #e07a7a; }
78 /* `+` = another session on this tile's host: an add, so it greens. */
79 .tile header .spawn:hover { color: #6fce8a; }
78 /* Zoomed = the terminal owns the header; wall management waits. */ 80 /* Zoomed = the terminal owns the header; wall management waits. */
79 .tile.zoomed header .close { display: none; } 81 .tile.zoomed header .close, .tile.zoomed header .spawn { display: none; }
82 /* Drag to reorder: the dragged tile fades, and the tile under the cursor
83 shows the edge the drop lands on. Both marks are transient — every way
84 out of a drag (dragleave, drop, dragend) clears them. */
85 .tile.dragging { opacity: 0.5; }
86 .tile.drop-before { border-left: 2px solid #6ab0e0; }
87 .tile.drop-after { border-right: 2px solid #6ab0e0; }
80 /* IME target: focusable, invisible, never display:none (that kills IME). */ 88 /* IME target: focusable, invisible, never display:none (that kills IME). */
81 #ime { 89 #ime {
82 position: fixed; left: -9999px; top: 0; width: 1px; height: 1px; 90 position: fixed; left: -9999px; top: 0; width: 1px; height: 1px;
web/mux.js
Old New
@@ -168,7 +168,7 @@ class Tile {
168 // of the DOM, and the node is what a drag moves. 168 // of the DOM, and the node is what a drag moves.
169 this.el.dataset.tileId = String(id); 169 this.el.dataset.tileId = String(id);
170 this.el.innerHTML = 170 this.el.innerHTML =
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>`; 171 `<header><span class="label"></span><button class="copy-request" type="button">Copy</button><button class="spawn" type="button" title="new session here">+</button><button class="close" type="button">×</button><span class="badge connecting">connecting</span></header>`;
172 // The label only: the wall's order is mutable, so a baked-in number 172 // The label only: the wall's order is mutable, so a baked-in number
173 // would lie the moment anything moved. 173 // would lie the moment anything moved.
174 this.el.querySelector('.label').textContent = label; 174 this.el.querySelector('.label').textContent = label;
@@ -179,6 +179,22 @@ class Tile {
179 // and the hidden IME is what a zoomed tile's keys come from. 179 // and the hidden IME is what a zoomed tile's keys come from.
180 ime.focus(); 180 ime.focus();
181 }); 181 });
182 // "New session here": the wall's second door, opened from a tile that
183 // already names the host. It spells a target into the add tile's input
184 // and hands over — one add path, not two.
185 this.el.querySelector('.spawn').addEventListener('click', (ev) => {
186 ev.stopPropagation(); // a spawn is not a zoom
187 // The label is the spelling the user typed, so the host part is that
188 // label minus its trailing `#session` — and only when this tile HAS
189 // a session, since otherwise a '#' can only belong to the host part.
190 const cut = this.session ? this.label.lastIndexOf('#') : -1;
191 const host = cut > 0 ? this.label.slice(0, cut) : this.label;
192 addInput.value = host + '#';
193 // No ime.focus() here, unlike close and copy: putting focus IN the
194 // add input is the entire point of this button, and it is only ever
195 // visible unzoomed (CSS), so no terminal is waiting on the IME.
196 addInput.focus();
197 });
182 this.copyButton = this.el.querySelector('.copy-request'); 198 this.copyButton = this.el.querySelector('.copy-request');
183 this.copyButton.addEventListener('click', (ev) => { 199 this.copyButton.addEventListener('click', (ev) => {
184 ev.stopPropagation(); 200 ev.stopPropagation();
@@ -198,6 +214,45 @@ class Tile {
198 if (this.zoomed) { ev.preventDefault(); this.onWheel(ev); } 214 if (this.zoomed) { ev.preventDefault(); this.onWheel(ev); }
199 // Wall tiles don't scroll (spec): the event falls through to the page. 215 // Wall tiles don't scroll (spec): the event falls through to the page.
200 }, { passive: false }); 216 }, { passive: false });
217 // Drag to reorder. The whole tile is the handle: the header is a thin
218 // strip and a wall tile has no other inert surface to grab.
219 this.el.draggable = true;
220 this.el.addEventListener('dragstart', (ev) => {
221 // A zoomed tile is a terminal — a drag there would fight the pointer
222 // selection. The rest of the wall is under the shade while anything
223 // is zoomed, so nothing on this page is reorderable then.
224 if (zoomedTile) { ev.preventDefault(); return; }
225 draggingTile = this;
226 ev.dataTransfer.setData('text/plain', String(this.id));
227 ev.dataTransfer.effectAllowed = 'move';
228 this.el.classList.add('dragging');
229 });
230 this.el.addEventListener('dragend', () => {
231 draggingTile = null;
232 this.el.classList.remove('dragging');
233 clearDropMarks(); // a drag that ended ANYWHERE leaves no borders
234 });
235 this.el.addEventListener('dragover', (ev) => {
236 if (!draggingTile || draggingTile === this) return; // no drop on self
237 ev.preventDefault(); // preventDefault on dragover IS "yes, drop here"
238 ev.dataTransfer.dropEffect = 'move';
239 // Not offsetX: that is relative to the event's target, which here is
240 // usually the canvas or the header. Measure the tile's own box.
241 this.markDropSide(ev);
242 });
243 this.el.addEventListener('dragleave', () => {
244 this.el.classList.remove('drop-before', 'drop-after');
245 });
246 this.el.addEventListener('drop', (ev) => {
247 ev.preventDefault();
248 this.el.classList.remove('drop-before', 'drop-after');
249 const src = dragSource(ev);
250 if (!src || src === this.el) return;
251 const r = this.el.getBoundingClientRect();
252 if (ev.clientX < r.left + r.width / 2) this.el.before(src);
253 else this.el.after(src);
254 putOrder(); // the DOM moved; the hub has not heard yet
255 });
201 this.canvas.addEventListener('pointerdown', (ev) => this.beginSelection(ev)); 256 this.canvas.addEventListener('pointerdown', (ev) => this.beginSelection(ev));
202 this.canvas.addEventListener('pointermove', (ev) => this.moveSelection(ev)); 257 this.canvas.addEventListener('pointermove', (ev) => this.moveSelection(ev));
203 this.canvas.addEventListener('pointerup', (ev) => this.endSelection(ev)); 258 this.canvas.addEventListener('pointerup', (ev) => this.endSelection(ev));
@@ -209,6 +264,15 @@ class Tile {
209 }); 264 });
210 } 265 }
211 266
267 // Which half of this tile the cursor is over, as a class the CSS draws
268 // an edge for. Exactly one of the two is ever set: toggle, not add.
269 markDropSide(ev) {
270 const r = this.el.getBoundingClientRect();
271 const before = ev.clientX < r.left + r.width / 2;
272 this.el.classList.toggle('drop-before', before);
273 this.el.classList.toggle('drop-after', !before);
274 }
275
212 async start() { 276 async start() {
213 // NOT `const { instance }`: WebAssembly.instantiate has two return 277 // NOT `const { instance }`: WebAssembly.instantiate has two return
214 // shapes, and which one you get depends on what you passed. Given 278 // shapes, and which one you get depends on what you passed. Given
@@ -1403,6 +1467,48 @@ ime.addEventListener('compositionend', (ev) => {
1403 const tilesById = new Map(); 1467 const tilesById = new Map();
1404 let addTileEl = null; // the add tile, built once in boot, always last 1468 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) 1469 let addInput = null; // its one <input> — a real focus target (see keydown)
1470 // The tile whose drag is in flight. dataTransfer is write-only until the
1471 // drop (the spec hides the data from dragover to stop pages snooping what
1472 // is being dragged over them), so the side indicator needs its own handle
1473 // on the source to know it is not pointing at the dragged tile itself.
1474 let draggingTile = null;
1475
1476 function clearDropMarks() {
1477 for (const t of tilesById.values()) t.el.classList.remove('drop-before', 'drop-after');
1478 addTileEl?.classList.remove('drop-before');
1479 }
1480
1481 // The element a drop is moving, named by the id the drag carries. Resolved
1482 // through tilesById rather than from draggingTile so a drop whose source
1483 // the wall lost mid-drag (another browser deleted it) moves nothing at all
1484 // instead of moving a detached node.
1485 function dragSource(ev) {
1486 const tile = tilesById.get(Number(ev.dataTransfer.getData('text/plain')));
1487 return tile ? tile.el : null;
1488 }
1489
1490 // The DOM under #wall IS the order: a drag moves a node, and this tells the
1491 // hub what the nodes now say. Reading the order back out of the page rather
1492 // than keeping an array means there is no second copy to drift from it.
1493 async function putOrder() {
1494 const wallEl = document.getElementById('wall');
1495 const ids = [...wallEl.children]
1496 .filter((el) => el !== addTileEl)
1497 .map((el) => el.dataset.tileId)
1498 .filter((id) => id !== undefined);
1499 try {
1500 const res = await fetch('/tiles', { method: 'PUT', body: ids.join(',') });
1501 // 409 = we dragged against a stale wall (another browser added or
1502 // removed a tile since this page's last refetch). The hub kept its own
1503 // order and answered with it; refetching is what agrees us with the
1504 // truth. 400 and 500 take the same road for the same reason: the page
1505 // has already moved a node the hub may not have accepted.
1506 if (res.status !== 204) await refetchWall();
1507 } catch (err) {
1508 console.error('mux wall: reorder failed', err);
1509 await refetchWall().catch((e) => console.error('mux wall: refetch failed', e));
1510 }
1511 }
1406 1512
1407 window.addEventListener('resize', () => { 1513 window.addEventListener('resize', () => {
1408 // The zoomed tile may claim a new grid; every wall tile just re-fits to 1514 // The zoomed tile may claim a new grid; every wall tile just re-fits to
@@ -1448,8 +1554,10 @@ async function refetchWall() {
1448 tilesById.set(t.id, tile); 1554 tilesById.set(t.id, tile);
1449 // start() is async: instantiate or mux_init can fail, and an unhandled 1555 // start() is async: instantiate or mux_init can fail, and an unhandled
1450 // rejection left the tile stuck on 'connecting' with the reason only 1556 // rejection left the tile stuck on 'connecting' with the reason only
1451 // in the console's rejection noise. 1557 // in the console's rejection noise. The settled promise is kept:
1452 tile.start().catch((err) => { 1558 // "new session here" zooms the tile it just added, and zoom() refuses
1559 // a tile whose core and socket start() has not installed yet.
1560 tile.startup = tile.start().catch((err) => {
1453 tile.setStatus('gone', 'gone'); 1561 tile.setStatus('gone', 'gone');
1454 console.error(`mux tile ${t.id} (${t.label}): start failed`, err); 1562 console.error(`mux tile ${t.id} (${t.label}): start failed`, err);
1455 }); 1563 });
@@ -1493,6 +1601,23 @@ function buildAddTile() {
1493 // The whole tile is the affordance; a click anywhere in it lands in the 1601 // 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). 1602 // input and goes no further (nothing above it should read this click).
1495 el.addEventListener('click', (ev) => { ev.stopPropagation(); input.focus(); }); 1603 el.addEventListener('click', (ev) => { ev.stopPropagation(); input.focus(); });
1604 // The add tile never moves and is never dragged, but it is the wall's
1605 // last child — so dropping on it is the only way to say "put this at the
1606 // end", and it means exactly "insert before me". One side only: there is
1607 // nothing after it to land on.
1608 el.addEventListener('dragover', (ev) => {
1609 if (!draggingTile) return;
1610 ev.preventDefault();
1611 ev.dataTransfer.dropEffect = 'move';
1612 el.classList.add('drop-before');
1613 });
1614 el.addEventListener('dragleave', () => el.classList.remove('drop-before'));
1615 el.addEventListener('drop', (ev) => {
1616 ev.preventDefault();
1617 el.classList.remove('drop-before');
1618 const src = dragSource(ev);
1619 if (src) { el.before(src); putOrder(); }
1620 });
1496 input.addEventListener('keydown', async (ev) => { 1621 input.addEventListener('keydown', async (ev) => {
1497 if (ev.key !== 'Enter') return; 1622 if (ev.key !== 'Enter') return;
1498 ev.preventDefault(); 1623 ev.preventDefault();
@@ -1507,8 +1632,23 @@ function buildAddTile() {
1507 err.textContent = (await res.text()).trim() || `hub said ${res.status}`; 1632 err.textContent = (await res.text()).trim() || `hub said ${res.status}`;
1508 return; 1633 return;
1509 } 1634 }
1635 // The hub answers {"id":N}. Parsed defensively: the tile IS added, so
1636 // a body we cannot read must not be reported back as a failed add.
1637 const body = await res.json().catch(() => null);
1510 input.value = ''; 1638 input.value = '';
1511 await refetchWall(); 1639 await refetchWall();
1640 // A wall tile attaches 1x1 passive and the daemon refuses to CREATE a
1641 // session at that size, so a tile added for a session that does not
1642 // exist yet would sit on a badge that never resolves. Zoom attaches at
1643 // the real size, and that is what creates it — "new session here" has
1644 // to land the user in the shell they asked for.
1645 const fresh = body === null ? null : tilesById.get(body.id);
1646 if (fresh) {
1647 await fresh.startup; // refetchWall fires start(), it does not await it
1648 // Re-checked after the await: a concurrent refetch may have retired
1649 // this tile, and a zoom the user did meanwhile is theirs to keep.
1650 if (tilesById.get(body.id) === fresh && !zoomedTile) zoom(fresh);
1651 }
1512 } catch (e) { 1652 } catch (e) {
1513 // An over-long body is dropped without any reply (the hub's cap), so 1653 // 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. 1654 // fetch rejects rather than resolving. Silence would read as success.