From c29e323bb65e39421f147b0e05aa54efa02a39ac Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 19 Oct 2026 05:25:32 +0200 Subject: [PATCH] feat(story-mode): the wire updates in place and glides after new events The Director's cut rebuilt the whole wire with replaceChildren on every refresh (every 4 s poll and SSE burst of a running turn), so it never followed new events and a rebuild could throw the reader's place, focus and hover away. Rows are now keyed by event id and headings by the row they head (a panel that changes kind or number as the turn grows keeps its heading node). Unchanged nodes are reused, a changed row is patched in place (a running tag flips to its result on the same node), new rows are inserted in order with a short transform and opacity entrance, and a new turn replaces the list. _storyEventRow still builds every row. While the wire follows, it glides to the newest row with a critically damped spring on animation frames: a burst of updates moves the target and the glide keeps its speed, so there is no stop and restart. Measured against the browser's own smooth scrollTo on a growing transcript, the spring moved a median 8 px a frame over ~25 frames with no stalls, while scrollTo snapped at ~41 px a frame over ~10 frames and its read of the new scrollHeight forced a layout inside the update (a 57 ms long task). Scrolling up by hand (wheel, touch, scroll keys, the scrollbar) stops following and shows a pill that counts the rows that came in since; clicking it, or scrolling back to the bottom, follows again. The glide only moves down and is never read as the reader scrolling up. A running newest turn, and the Director's cut turned on during one, open at the bottom, following; a finished turn opens at the top. Under prefers-reduced-motion there is no entrance and the list jumps. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/story-mode.css | 61 ++++- src/web/public/story-mode.js | 466 +++++++++++++++++++++++++++++++++- test/story-mode-ui.test.ts | 222 ++++++++++++++++ 3 files changed, 735 insertions(+), 14 deletions(-) diff --git a/src/web/public/story-mode.css b/src/web/public/story-mode.css index 14ee75a5..85c71bd6 100644 --- a/src/web/public/story-mode.css +++ b/src/web/public/story-mode.css @@ -790,6 +790,60 @@ body.story-open .toast-container { .story-tag--pending { color: var(--story-wait-text); } +/* A row that just came in slides up a little and fades in: transform and opacity only, so the list's + height (and the glide to its bottom) never moves with it; 6px stays inside the list's bottom padding. */ +.story-wire-list > .is-new { + animation: story-wire-in 0.28s cubic-bezier(0.2, 0.7, 0.3, 1) both; +} +@keyframes story-wire-in { + from { + opacity: 0; + transform: translateY(6px); + } +} +.story-wire-list:focus { + outline: none; +} +.story-wire-list:focus-visible { + outline: 3px solid var(--story-cyan); + outline-offset: -3px; +} +/* The pill sits on the list's bottom edge: a zero-height foot, so showing it moves nothing. */ +.story-wire-foot { + position: relative; + height: 0; +} +.story-overlay .story-wire-pill { + position: absolute; + bottom: 10px; + left: 50%; + z-index: 1; + translate: -50% 0; + display: inline-flex; + align-items: center; + min-height: 30px; + padding: 4px 14px; + font: 700 12.5px/1 var(--story-mono); + white-space: nowrap; + color: var(--story-line); + background: var(--story-yellow); + border: 2px solid var(--story-line); + border-radius: 999px; + box-shadow: 0 3px 0 rgb(0 0 0 / 35%); + animation: story-wire-pill-in 0.2s ease-out both; +} +.story-overlay .story-wire-pill:hover { + box-shadow: 0 4px 0 rgb(0 0 0 / 45%); +} +.story-overlay .story-wire-pill[hidden] { + display: none; +} +@keyframes story-wire-pill-in { + from { + opacity: 0; + transform: translateY(6px); + } +} /* Drawer */ .story-drawer { @@ -1046,6 +1100,9 @@ body.story-open .toast-container { .story-turns { min-height: 44px; } + .story-overlay .story-wire-pill { + min-height: 44px; + } .story-cap-dir, .story-ev, .story-code, @@ -1067,7 +1124,9 @@ body.story-open .toast-container { @media (prefers-reduced-motion: reduce) { .story-fresh *, .story-arrive, - .story-live i { + .story-live i, + .story-wire-list > .is-new, + .story-overlay .story-wire-pill { animation: none !important; } } diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index f0533cc7..52d9e805 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -1760,7 +1760,8 @@ Object.assign(CodemanApp.prototype, { wireHead.append(el('p', 'story-wire-title', 'The wire'), wireCount); const wireList = el('ol', 'story-wire-list'); wireList.dataset.i18nSkip = ''; - wire.append(wireHead, wireList); + const { foot: wireFoot, pill: wirePill } = this._storyWireControls(wire, wireList); + wire.append(wireHead, wireList, wireFoot); stage.append(sheet, wire); main.append(note, stage); @@ -1838,6 +1839,7 @@ Object.assign(CodemanApp.prototype, { wire, wireCount, wireList, + wirePill, drawerEl: drawer, drawerKind, drawerLive, @@ -3043,13 +3045,25 @@ Object.assign(CodemanApp.prototype, { // ── The wire ── + /** + * The wire, updated in place: rows are keyed by event id and headings by the row they head, so a + * refresh keeps every node it can (focus, hover and the reader's place survive), patches a row whose + * state changed and adds new rows in order. A new turn replaces the whole list. While it follows, + * the list glides to the newest row (`_storyWireGlide`). + */ _storyRenderWire() { const st = this._story; const page = st.data?.supported ? st.data.page : null; const list = st.wireList; + const view = this._storyWireView(); if (!page) { - list.replaceChildren(); - st.wireCount.textContent = ''; + if (list.firstElementChild) list.replaceChildren(); + view.nodes.clear(); + view.pageId = null; + view.unseen = 0; + this._storyWireStop(); + this._storyWirePill(); + if (st.wireCount.textContent) st.wireCount.textContent = ''; return; } const panelOf = new Map(); @@ -3062,23 +3076,355 @@ Object.assign(CodemanApp.prototype, { if (panelOf.has(i)) next = panelOf.get(i); else panelOf.set(i, next); } - const rows = []; + // What the list should hold, in order. A row's key is its event id (numbered if an id repeats); a + // heading's is the row it heads, so a panel that changes kind or number as the turn grows (a fix + // that becomes a loop, a fold into a montage) keeps its heading node and only its words change. + const seen = new Map(); + const unique = (key) => { + const n = seen.get(key) || 0; + seen.set(key, n + 1); + return n ? `${key}#${n}` : key; + }; + const want = []; let lastPanel = -2; page.events.forEach((ev, i) => { const pi = panelOf.get(i); + const key = unique(`e:${ev.id}`); if (pi !== lastPanel && pi >= 0) { - const g = document.createElement('li'); - g.className = 'story-wire-group'; - const h = document.createElement('h3'); - h.textContent = `Panel ${pi + 1} · ${STORY_KIND_NAMES[page.panels[pi].kind] || page.panels[pi].kind}`; - g.append(h); - rows.push(g); + const panel = page.panels[pi]; + const text = `Panel ${pi + 1} · ${STORY_KIND_NAMES[panel.kind] || panel.kind}`; + want.push({ + key: `g:${key}`, + sig: text, + make: () => { + const g = document.createElement('li'); + g.className = 'story-wire-group'; + const h = document.createElement('h3'); + h.textContent = text; + g.append(h); + return g; + }, + }); lastPanel = pi; } - rows.push(this._storyEventRow(ev, i)); + want.push({ + key, + // Everything _storyEventRow reads: the same signature draws the same row. + sig: JSON.stringify([ + ev.actor, + ev.at, + ev.tool, + ev.label, + ev.intent, + ev.ok, + !!ev.pending, + !!ev.running, + this._storyEventTarget(ev, i), + ]), + event: true, + make: () => this._storyEventRow(ev, i), + }); }); - list.replaceChildren(...rows); - st.wireCount.textContent = `${page.toolCalls} tool call${page.toolCalls === 1 ? '' : 's'}, ${page.events.length} events. Underlined parts open.`; + + const reset = view.pageId !== page.id || !list.firstElementChild; + if (reset) { + view.nodes.clear(); + view.pageId = page.id; + view.unseen = 0; + } + // Geometry is read once, before any write, and only when the decision needs it: a list that + // follows keeps following (its own glide may still be under way, so the gap says nothing). + const gap = reset || view.parked || view.follow ? 0 : list.scrollHeight - list.scrollTop - list.clientHeight; + const animate = !reset && !view.parked && !matchMedia('(prefers-reduced-motion: reduce)').matches; + const nodes = new Map(); + let added = 0; + let entering = 0; + let changed = false; + for (const w of want) { + let node = view.nodes.get(w.key); + if (node && view.sigs.get(node) !== w.sig) { + const fresh = w.make(); + // A row still entering keeps its entrance. + if (node.classList.contains('is-new')) fresh.classList.add('is-new'); + fresh.dataset.wireKey = w.key; + if (node.style.animationDelay) fresh.style.animationDelay = node.style.animationDelay; + node = storyMorph(node, fresh); + view.sigs.set(node, w.sig); + changed = true; + } else if (!node) { + node = w.make(); + node.dataset.wireKey = w.key; + view.sigs.set(node, w.sig); + if (animate) { + node.classList.add('is-new'); + // Rows that arrive together come in one after another, not as a block. + const k = Math.min(entering++, 5); + if (k > 0) node.style.animationDelay = `${k * 45}ms`; + } + if (w.event) added++; + changed = true; + } + nodes.set(w.key, node); + } + if (reset) list.replaceChildren(...nodes.values()); + else { + // Gone first, so the rows that stay never move (moving a node would drop its focus). + for (const [key, node] of view.nodes) { + if (!nodes.has(key)) { + node.remove(); + changed = true; + } + } + let prev = null; + for (const node of nodes.values()) { + if (prev ? prev.nextElementSibling !== node : list.firstElementChild !== node) { + if (prev) prev.after(node); + else list.prepend(node); + } + prev = node; + } + } + view.nodes = nodes; + const count = `${page.toolCalls} tool call${page.toolCalls === 1 ? '' : 's'}, ${page.events.length} events. Underlined parts open.`; + if (st.wireCount.textContent !== count) st.wireCount.textContent = count; + + if (view.parked) return; // a hidden wire is placed when it shows again (_storyWireShown) + if (reset) { + this._storyWirePlace(page); + return; + } + Object.assign(view, storyWireAfterUpdate(view, { gap, added })); + if (view.follow && changed) this._storyWireGlide(); + this._storyWirePill(); + }, + + /** The wire's follow and scroll state, made on first use. */ + _storyWireView() { + const st = this._story; + st.wireView ??= { + pageId: null, + nodes: new Map(), + /** What each node was drawn from (kept off the DOM: it holds the transcript's words). */ + sigs: new WeakMap(), + follow: true, + unseen: 0, + lastTop: 0, + parked: !!st.wire?.hidden, + userAt: -Infinity, + held: false, + raf: 0, + v: 0, + t: 0, + pos: null, + wrote: null, + }; + return st.wireView; + }, + + /** Whether the wire shows the newest turn while it is still running (it then opens at the bottom). */ + _storyWireLive(page) { + const st = this._story; + return !!page && !st.turnId && !page.complete && !page.interrupted; + }, + + /** + * Where the wire opens: a running newest turn at the bottom, following; any other turn at the top. + * Instant, never a glide: this is a new list, not news arriving in one. + */ + _storyWirePlace(page) { + const st = this._story; + const view = this._storyWireView(); + const list = st.wireList; + this._storyWireStop(); + view.follow = this._storyWireLive(page); + view.unseen = 0; + list.scrollTop = view.follow ? list.scrollHeight : 0; + view.lastTop = list.scrollTop; + this._storyWirePill(); + }, + + /** + * The pill under the list ("↓ 3 new"), a list that stopped following, and the listeners that tell + * the reader's own scrolling from the list's glide. Built once, with the overlay. + */ + _storyWireControls(wire, list) { + const foot = document.createElement('div'); + foot.className = 'story-wire-foot'; + const pill = document.createElement('button'); + pill.type = 'button'; + pill.className = 'story-wire-pill'; + pill.hidden = true; + foot.append(pill); + pill.addEventListener('click', () => this._storyWireResume()); + // The reader's own hand: a wheel, a finger, a scroll key or the scrollbar. Each stops the glide (it + // would fight them); only a move UP while they scroll stops following (_storyWireScrolled). + const byHand = () => { + if (!this._story) return; + this._storyWireView().userAt = performance.now(); + this._storyWireStop(); + }; + list.addEventListener('wheel', byHand, { passive: true }); + list.addEventListener('touchstart', byHand, { passive: true }); + list.addEventListener('touchmove', byHand, { passive: true }); + list.addEventListener('keydown', (e) => { + if (STORY_WIRE_SCROLL_KEYS.has(e.key)) byHand(); + }); + // A press on the list itself (not on a row) is its scrollbar; a drag there takes a while. + list.addEventListener('pointerdown', (e) => { + if (e.target !== list || !this._story) return; + const view = this._storyWireView(); + view.held = true; + byHand(); + const release = () => { + view.held = false; + view.userAt = performance.now(); + window.removeEventListener('pointerup', release, true); + window.removeEventListener('pointercancel', release, true); + }; + window.addEventListener('pointerup', release, true); + window.addEventListener('pointercancel', release, true); + }); + list.addEventListener('scroll', () => this._storyWireScrolled(), { passive: true }); + list.addEventListener('animationend', (e) => { + if (e.animationName !== 'story-wire-in') return; + e.target.classList.remove('is-new'); + e.target.style.animationDelay = ''; + }); + // The wire is hidden outside the Director's cut, and a hidden list has no scroll position: when it + // shows again it is placed afresh, whoever showed it. + new MutationObserver(() => this._storyWireShown()).observe(wire, { attributes: true, attributeFilter: ['hidden'] }); + // A wire that changes size while it follows (a resize, the drawer) stays at the bottom. + if (typeof ResizeObserver === 'function') { + new ResizeObserver(() => { + const view = this._story?.wireView; + if (!view || view.parked || !view.follow || view.raf || view.held) return; + list.scrollTop = list.scrollHeight; + }).observe(list); + } + return { foot, pill }; + }, + + /** The wire was hidden or shown: hidden, it rests; shown, it opens where a new list opens. */ + _storyWireShown() { + const st = this._story; + if (!st) return; + const view = this._storyWireView(); + if (st.wire.hidden) { + view.parked = true; + this._storyWireStop(); + this._storyWirePill(); + return; + } + if (!view.parked) return; + view.parked = false; + const page = st.data?.supported ? st.data.page : null; + if (page && st.open) this._storyWirePlace(page); + }, + + /** A scroll of the list: the reader's, or the glide's (see storyWireAfterScroll). */ + _storyWireScrolled() { + const st = this._story; + if (!st?.open) return; + const view = this._storyWireView(); + if (view.parked) return; + const list = st.wireList; + const top = list.scrollTop; + const gap = list.scrollHeight - top - list.clientHeight; + const user = view.held || performance.now() - view.userAt < STORY_WIRE_HAND_MS; + const next = storyWireAfterScroll(view, { top, gap, user }); + const flip = next.follow !== view.follow || next.unseen !== view.unseen; + Object.assign(view, next); + if (flip) this._storyWirePill(); + }, + + /** The pill: shown while the wire does not follow a running turn, or rows came in below. */ + _storyWirePill() { + const st = this._story; + const pill = st?.wirePill; + if (!pill) return; + const view = this._storyWireView(); + const page = st.data?.supported ? st.data.page : null; + const show = !!page && !view.parked && !view.follow && (view.unseen > 0 || this._storyWireLive(page)); + const { text, label } = storyWirePillText(view.unseen); + if (pill.textContent !== text) pill.textContent = text; + if (pill.getAttribute('aria-label') !== label) pill.setAttribute('aria-label', label); + if (pill.hidden === show) { + // A pill that hides under keyboard focus hands it to the list it brought down. + if (!show && document.activeElement === pill) { + st.wireList.tabIndex = -1; + st.wireList.focus({ preventScroll: true }); + } + pill.hidden = !show; + } + }, + + /** The pill, clicked: down to the newest row, following again. */ + _storyWireResume() { + const view = this._storyWireView(); + view.follow = true; + view.unseen = 0; + view.userAt = -Infinity; + this._storyWirePill(); + this._storyWireGlide(); + }, + + /** + * Glides the list to its bottom. One glide at a time: an update that lands while it runs moves the + * target, and the glide carries its speed into the new target (a critically damped spring, + * storyWireSpring), so a burst of rows reads as one smooth motion. Under prefers-reduced-motion + * the list jumps. + */ + _storyWireGlide() { + const view = this._storyWireView(); + if (view.raf || view.parked || view.held) return; + view.t = 0; + view.raf = requestAnimationFrame((t) => this._storyWireStep(t)); + }, + + /** One frame of the glide: read the list once, then write its position once. */ + _storyWireStep(t) { + const st = this._story; + const view = st?.wireView; + if (!view) return; + view.raf = 0; + if (!st.open || view.parked || !view.follow || view.held) { + this._storyWireStop(); + return; + } + const list = st.wireList; + const max = Math.max(0, list.scrollHeight - list.clientHeight); + const at = list.scrollTop; + // Something else moved the list (a row above changed height, a resize): carry on from there. + if (view.pos === null || Math.abs(at - view.wrote) > 1.5) view.pos = at; + // A frame that came late (the page drew panels) moves at most two frames' worth: a short slowdown + // reads as smooth, a leap does not. + const dt = view.t ? Math.min(1 / 30, (t - view.t) / 1000) : 1 / 60; + view.t = t; + const s = matchMedia('(prefers-reduced-motion: reduce)').matches + ? { pos: max, v: 0, done: true } + : storyWireSpring(view.pos, view.v, max, dt); + list.scrollTop = s.pos; + view.pos = s.pos; + view.wrote = s.pos; + view.lastTop = list.scrollTop; // "up" is measured from where the glide put it + view.v = s.v; + if (s.done) { + view.pos = null; + view.t = 0; + return; + } + view.raf = requestAnimationFrame((t2) => this._storyWireStep(t2)); + }, + + /** Stops a glide where it is. */ + _storyWireStop() { + const view = this._story?.wireView; + if (!view) return; + if (view.raf) cancelAnimationFrame(view.raf); + view.raf = 0; + view.v = 0; + view.t = 0; + view.pos = null; }, _storyEventRow(ev, index) { @@ -3757,3 +4103,97 @@ Object.assign(CodemanApp.prototype, { return pre; }, }); + +// ── The wire: follow and glide (pure) ── + +/** Within this many pixels of the bottom, the wire counts as at the bottom: it follows. */ +const STORY_WIRE_BOTTOM_PX = 40; +/** A scroll this soon after the reader's own input (wheel, finger, key, scrollbar) is theirs. */ +const STORY_WIRE_HAND_MS = 400; +/** Keys that scroll a focused list. */ +const STORY_WIRE_SCROLL_KEYS = new Set(['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' ']); +/** + * Stiffness of the glide (1/s): a critically damped spring settles in about 5.8 / ω, here ~0.48 s, + * before the next refresh can land (0.8 s debounce). Stiffer made a tall row (a long command) arrive + * at ~25 px a frame. + */ +const STORY_WIRE_OMEGA = 12; + +/** + * What a scroll of the wire means for following. Back at the bottom (by hand or by the glide) it + * follows again; moving up by the reader's own hand stops it. Anything else changes nothing: the + * glide only ever moves down, and a row above that changes height moves the list without anyone + * scrolling it. + */ +function storyWireAfterScroll(view, { top, gap, user }) { + const up = top < view.lastTop - 1; + if (gap <= STORY_WIRE_BOTTOM_PX) return { follow: true, unseen: 0, lastTop: top }; + if (up && user) return { follow: false, unseen: view.unseen, lastTop: top }; + return { follow: view.follow, unseen: view.unseen, lastTop: top }; +} + +/** + * New rows came in. A wire that follows keeps following, and one at the bottom starts; one the + * reader scrolled up counts them for the pill. `gap` is the distance to the bottom before the rows + * came in. + */ +function storyWireAfterUpdate(view, { gap, added }) { + if (view.follow || gap <= STORY_WIRE_BOTTOM_PX) return { follow: true, unseen: 0 }; + return { follow: false, unseen: view.unseen + added }; +} + +/** The pill's words, and what a screen reader hears. */ +function storyWirePillText(unseen) { + if (!unseen) return { text: '↓ Newest', label: 'Scroll to the newest event' }; + return { + text: `↓ ${unseen} new`, + label: `${unseen} new event${unseen === 1 ? '' : 's'}. Scroll to the newest.`, + }; +} + +/** + * One step (`dt` seconds) of a critically damped spring from `pos` (moving at `v` px/s) toward + * `target`. A new target keeps the speed, so a glide that is retargeted mid-way never stops and + * restarts. Toward a target below, it only ever moves down and never past it. + */ +function storyWireSpring(pos, v, target, dt, omega = STORY_WIRE_OMEGA) { + if (target < pos) return { pos: target, v: 0, done: true }; + const x = pos - target; + const e = Math.exp(-omega * dt); + const c = v + omega * x; + let np = target + (x + c * dt) * e; + let nv = (v - omega * c * dt) * e; + if (np < pos) { + np = pos; + nv = Math.max(0, nv); + } + if (np > target) np = target; + if (target - np < 0.5 && Math.abs(nv) < 30) return { pos: target, v: 0, done: true }; + return { pos: np, v: nv, done: false }; +} + +/** + * Makes `node` match `fresh` (built the same way, from newer data) while keeping every node that can + * stay: an element whose tag is unchanged is patched (attributes, text, children), so focus and hover + * on it survive. Returns the node that ends up in the tree. + */ +function storyMorph(node, fresh) { + if (node.nodeType !== fresh.nodeType || node.nodeName !== fresh.nodeName) { + node.replaceWith(fresh); + return fresh; + } + if (node.nodeType !== 1) { + if (node.nodeValue !== fresh.nodeValue) node.nodeValue = fresh.nodeValue; + return node; + } + for (const a of [...node.attributes]) if (!fresh.hasAttribute(a.name)) node.removeAttribute(a.name); + for (const a of [...fresh.attributes]) if (node.getAttribute(a.name) !== a.value) node.setAttribute(a.name, a.value); + const want = [...fresh.childNodes]; + want.forEach((child, i) => { + const have = node.childNodes[i]; + if (have) storyMorph(have, child); + else node.append(child); + }); + while (node.childNodes.length > want.length) node.lastChild.remove(); + return node; +} diff --git a/test/story-mode-ui.test.ts b/test/story-mode-ui.test.ts index 8b458ee7..bfc4d359 100644 --- a/test/story-mode-ui.test.ts +++ b/test/story-mode-ui.test.ts @@ -733,6 +733,228 @@ describe('story-mode.js overlay', () => { expect(rows[rows.length - 1]).toContain('All done here.'); }); + /** + * JSDOM lays nothing out: the wire list gets a fake scroll box whose content grows with its rows, + * so the follow logic sees a list that really scrolls (and `scrollTop` clamps like a browser's). + */ + function fakeScroller(list: HTMLElement, height = 200, rowPx = 20) { + let top = 0; + const max = () => Math.max(0, list.children.length * rowPx - height); + Object.defineProperty(list, 'clientHeight', { configurable: true, get: () => height }); + Object.defineProperty(list, 'scrollHeight', { + configurable: true, + get: () => Math.max(height, list.children.length * rowPx), + }); + Object.defineProperty(list, 'scrollTop', { + configurable: true, + get: () => top, + set: (v: number) => { + const was = top; + top = Math.max(0, Math.min(Number(v) || 0, max())); + // A browser tells the page with a scroll event, a moment later. + if (top !== was) setTimeout(() => list.dispatchEvent(new win.Event('scroll')), 0); + }, + }); + return { + max, + /** The reader scrolls the list by hand. */ + scrollBy(dy: number) { + list.dispatchEvent(new win.WheelEvent('wheel', { deltaY: dy, bubbles: true })); + top = Math.max(0, Math.min(top + dy, max())); + list.dispatchEvent(new win.Event('scroll')); + }, + }; + } + /** Waits until `ok()` holds (the glide runs on animation frames), at most `ms`. */ + async function until(ok: () => boolean, ms = 2000) { + const t0 = Date.now(); + while (!ok() && Date.now() - t0 < ms) await new Promise((r) => setTimeout(r, 16)); + return ok(); + } + const wireRows = () => $$('.story-wire-list > li'); + + it('a refresh keeps the wire rows it already has: same nodes, focus kept, the new one added in place', async () => { + const lines = [prompt('go'), ...read('r1', '/work/demo-shop/a.ts')]; + boot(storyOf(lines)); + app.openStoryMode(SID); + await settle(); + app._storySetDirector(true); + await settle(); + const before = wireRows(); + const link = $('.story-wire [data-story-open="file:event:1"]')!; + link.focus(); + responses = [ + { status: 200, body: { success: true, data: storyOf([...lines, ...read('r2', '/work/demo-shop/b.ts')]) } }, + ]; + await app._storyRefresh(); + await settle(); + const after = wireRows(); + expect(after.length).toBeGreaterThan(before.length); + before.forEach((row, i) => expect(after[i]).toBe(row)); + expect(win.document.activeElement).toBe(link); + const added = after[after.length - 1]; + expect(added.dataset.wireKey).toBe('e:r2'); + expect(added.classList.contains('is-new')).toBe(true); + expect(before.some((row) => row.classList.contains('is-new'))).toBe(false); + // A refresh that changes nothing touches nothing. + const html = $('.story-wire-list')!.innerHTML; + app._storyRenderWire(); + expect($('.story-wire-list')!.innerHTML).toBe(html); + }); + + it('a call that finishes updates its row in place: the same tag flips from running to its result', async () => { + const lines = [prompt('go'), tool('b1', 'Bash', { command: 'npm test', description: 'Run tests' })]; + boot(storyOf(lines)); + app.openStoryMode(SID); + await settle(); + app._storySetDirector(true); + await settle(); + const row = $('.story-wire-list [data-wire-key="e:b1"]')!; + const tag = row.querySelector('.story-tag')!; + expect(tag.textContent).toBe('running'); + const done = result('b1', ' Tests 4 passed (4)', { + toolUseResult: { stdout: ' Tests 4 passed (4)', stderr: '' }, + }); + responses = [{ status: 200, body: { success: true, data: storyOf([...lines, done]) } }]; + await app._storyRefresh(); + await settle(); + expect($('.story-wire-list [data-wire-key="e:b1"]')).toBe(row); + expect(row.querySelector('.story-tag')).toBe(tag); + expect(tag.textContent).not.toBe('running'); + expect(tag.className).not.toContain('pending'); + // Its output arrived, so the label became a button that opens it. + expect(row.querySelector('.story-link')?.getAttribute('data-story-open')).toBe('output:event:1'); + expect(row.classList.contains('is-new')).toBe(false); + }); + + it('the wire follows a running turn; scrolled up it counts new rows on a pill that brings it back', async () => { + const lines = [prompt('go'), ...read('r1', '/work/demo-shop/a.ts'), ...read('r2', '/work/demo-shop/b.ts')]; + boot(storyOf(lines)); + app.openStoryMode(SID); + await settle(); + const list = $('.story-wire-list')!; + const box = fakeScroller(list, 40); + app._storySetDirector(true); + await settle(); + const view = app._story.wireView; + const pill = $('.story-wire-pill') as HTMLButtonElement; + // Turned on during a live turn: at the bottom, following. + expect(view.follow).toBe(true); + expect(list.scrollTop).toBe(box.max()); + expect(pill.hidden).toBe(true); + box.scrollBy(-30); // still within reach of the bottom: following + expect(view.follow).toBe(true); + box.scrollBy(-30); + expect(view.follow).toBe(false); + expect(pill.hidden).toBe(false); + expect(pill.textContent).toBe('↓ Newest'); + const more = [...lines, ...read('r3', '/work/demo-shop/c.ts'), ...read('r4', '/work/demo-shop/d.ts')]; + responses = [{ status: 200, body: { success: true, data: storyOf(more) } }]; + await app._storyRefresh(); + await settle(); + expect(view.follow).toBe(false); + expect(pill.textContent).toBe('↓ 2 new'); + expect(pill.getAttribute('aria-label')).toBe('2 new events. Scroll to the newest.'); + const top = list.scrollTop; + expect(top).toBeLessThan(box.max()); // the reader's place, not moved + pill.focus(); + pill.click(); + expect(view.follow).toBe(true); + expect(pill.hidden).toBe(true); + expect(win.document.activeElement).toBe(list); // focus is not dropped with the pill + expect(await until(() => list.scrollTop === box.max())).toBe(true); + // Back at the bottom by hand also follows again. + box.scrollBy(-60); + expect(view.follow).toBe(false); + box.scrollBy(200); + expect(view.follow).toBe(true); + expect(pill.hidden).toBe(true); + }); + + it('a turn switch starts the wire afresh: a finished turn at the top, the running newest one at the bottom', async () => { + const lines = [ + prompt('first job'), + ...read('r1', '/work/demo-shop/a.ts'), + ...read('r2', '/work/demo-shop/b.ts'), + said('Done.'), + end(2000), + prompt('second job'), + ...read('r3', '/work/demo-shop/c.ts'), + ...read('r4', '/work/demo-shop/d.ts'), + ]; + boot(storyOf(lines)); + app.openStoryMode(SID); + await settle(); + const list = $('.story-wire-list')!; + const box = fakeScroller(list, 40); + app._storySetDirector(true); + await settle(); + const newest = wireRows(); + expect(app._story.wireView.follow).toBe(true); + expect(list.scrollTop).toBe(box.max()); + responses = [{ status: 200, body: { success: true, data: storyAt(lines, 0) } }]; + app._storyPickTurn(app._story.data.turns[0].id); + await settle(12); + const older = wireRows(); + expect(older.some((row) => newest.includes(row))).toBe(false); + expect(older.some((row) => row.classList.contains('is-new'))).toBe(false); + expect(older.map((row) => row.dataset.wireKey)).toContain('e:r1'); + expect(app._story.wireView.follow).toBe(false); + expect(list.scrollTop).toBe(0); + expect(($('.story-wire-pill') as HTMLButtonElement).hidden).toBe(true); + responses = [{ status: 200, body: { success: true, data: storyOf(lines) } }]; + app._storyPickTurn(app._story.data.turns[1].id); + await settle(12); + expect(wireRows().map((row) => row.dataset.wireKey)).toContain('e:r4'); + expect(app._story.wireView.follow).toBe(true); + expect(list.scrollTop).toBe(box.max()); + }); + + it('the follow decision: following sticks, scrolling up by hand stops it, the bottom resumes it', () => { + boot(null); + const update = win.storyWireAfterUpdate; + const scroll = win.storyWireAfterScroll; + expect(update({ follow: true, unseen: 0 }, { gap: 900, added: 2 })).toEqual({ follow: true, unseen: 0 }); + expect(update({ follow: false, unseen: 1 }, { gap: 900, added: 2 })).toEqual({ follow: false, unseen: 3 }); + expect(update({ follow: false, unseen: 3 }, { gap: 30, added: 1 })).toEqual({ follow: true, unseen: 0 }); + const following = { follow: true, unseen: 0, lastTop: 500 }; + expect(scroll(following, { top: 400, gap: 300, user: true }).follow).toBe(false); + // Up without the reader's hand (a row above shrank, the browser kept the view still): still following. + expect(scroll(following, { top: 467, gap: 300, user: false }).follow).toBe(true); + // The glide moving down is never the reader scrolling up. + expect(scroll(following, { top: 540, gap: 260, user: true }).follow).toBe(true); + expect(scroll({ follow: false, unseen: 4, lastTop: 100 }, { top: 880, gap: 12, user: true })).toEqual({ + follow: true, + unseen: 0, + lastTop: 880, + }); + expect(win.storyWirePillText(0).text).toBe('↓ Newest'); + expect(win.storyWirePillText(1)).toEqual({ text: '↓ 1 new', label: '1 new event. Scroll to the newest.' }); + }); + + it('the glide only moves down, never past its target, and keeps its speed when the target moves', () => { + boot(null); + const spring = win.storyWireSpring; + let s = { pos: 0, v: 0, done: false }; + const deltas: number[] = []; + let target = 100; + for (let f = 0; f < 90 && !s.done; f++) { + if (f === 8) target = 260; // more rows land mid-glide + const next = spring(s.pos, s.v, target, 1 / 60); + expect(next.pos).toBeGreaterThanOrEqual(s.pos); + expect(next.pos).toBeLessThanOrEqual(target); + deltas.push(next.pos - s.pos); + s = next; + } + expect(s.done).toBe(true); + expect(s.pos).toBe(260); + // No stop and restart at the new target: the step after it is no smaller than the one before. + expect(deltas[8]).toBeGreaterThanOrEqual(deltas[7]); + expect(deltas.slice(0, 40).every((d) => d > 0)).toBe(true); + // A target above (the list got shorter) is taken at once. + expect(spring(300, 50, 200, 1 / 60)).toEqual({ pos: 200, v: 0, done: true }); + }); + it('a fix made from the terminal opens what ran, not an empty diff', async () => { boot( storyOf([