hop2 0.11.7 → 0.11.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  // Sanitized live-footage capture driver for the hop product video (dark theme).
2
2
  // Usage: node demo/capture/capture.mjs <clip> where clip in:
3
- // 01-sessions | 02-agent-live | 02b-desktop-terminal | 03-phone-live |
3
+ // 00-wall | 01-sessions | 02-agent-live | 02b-desktop-terminal | 03-phone-live |
4
4
  // 04-phone-switcher | 05-presence | 06-math | 07-theme
5
5
  //
6
6
  // Run demo/capture/setup-sessions.mjs and demo/capture/spawn-aurora.mjs first
@@ -112,7 +112,12 @@ async function sanitizeContext(context) {
112
112
  const resp = await route.fetch();
113
113
  let json;
114
114
  try { json = await resp.json(); } catch { return await route.fulfill({ response: resp }); }
115
- json.sessions = (json.sessions || []).filter((s) => ALLOWED.includes(s.name));
115
+ json.sessions = (json.sessions || []).filter((s) => ALLOWED.includes(s.name))
116
+ // The rig creates its sessions through the terminal API, which the
117
+ // origin classifier (correctly) marks agent-created — but the wall
118
+ // defaults to the USER scope, so the whole demo cast would be
119
+ // hidden behind the Agent toggle. They PLAY user sessions.
120
+ .map((s) => ({ ...s, createdBy: "user" }));
116
121
  json.active = (json.active || []).filter((n) => ALLOWED.includes(n));
117
122
  json.starting = (json.starting || []).filter((n) => ALLOWED.includes(n));
118
123
  json.aliases = {};
@@ -598,6 +603,48 @@ try {
598
603
  await sleep(2400);
599
604
  await saveVideo(page, "07-theme", trimFor(t0, marker));
600
605
 
606
+ } else if (clip === "00-wall") {
607
+ // THE signature shot of the switcher-first era: the wall is the product.
608
+ // Live tiles ticking, folders, the briefing card up top, filter-to-find,
609
+ // click = engage in place, ⌘⏎ = the one deliberate door into full screen.
610
+ // The briefing is staged by ROUTE INTERCEPTION — no file ever touches
611
+ // hay-web/assets, so nothing sanitization-sensitive can leak or linger.
612
+ const { ctx, page, t0 } = await newRecordedPage(browser, {
613
+ css: "try{localStorage.setItem('hay_tile_zoom','6');localStorage.setItem('hay_theme','dark');}catch(e){}"
614
+ });
615
+ await ctx.route("**/assets/digest.json", (route) =>
616
+ route.fulfill({
617
+ contentType: "application/json",
618
+ body: JSON.stringify({
619
+ generated_at: new Date().toISOString(),
620
+ summary: "Nebula finished the ingest refactor; Polaris is mid-benchmark; Aurora wants a review.",
621
+ items: [
622
+ { session: "Nebula", headline: "Ingest refactor landed — 41s → 9s on the sample day", urgency: "info" },
623
+ { session: "Polaris", headline: "Benchmark sweep 60% done, no regressions so far", urgency: "info" },
624
+ { session: "Aurora", headline: "Wants review: error-handling change in the exporter", urgency: "attention" }
625
+ ]
626
+ })
627
+ })
628
+ );
629
+ await page.goto(`${state.localUrl}/s/Lyra2/?view=wall`, { waitUntil: "networkidle", timeout: 30000 });
630
+ await sleep(3500);
631
+ await assertDark(page, "00-wall");
632
+ const marker = Date.now();
633
+ await sleep(2600); // the wall breathing: tiles tick, briefing reads
634
+ // Find-by-typing: the filter narrows the wall live.
635
+ const filterBox = page.locator(".switcher-filter").first();
636
+ await filterBox.click();
637
+ await page.keyboard.type("neb", { delay: 160 });
638
+ await sleep(1600);
639
+ for (let i = 0; i < 3; i++) { await page.keyboard.press("Backspace"); await sleep(120); }
640
+ await sleep(900);
641
+ // Click a session: it engages IN PLACE — the wall never yanks you away.
642
+ await page.locator(".switcher-card", { hasText: "Nebula" }).first().click();
643
+ await sleep(3200);
644
+ // ⌘⏎ — the one deliberate door into full screen.
645
+ await page.keyboard.press("Meta+Enter");
646
+ await sleep(3400);
647
+ await saveVideo(page, "00-wall", trimFor(t0, marker));
601
648
  } else if (clip === "01-sessions") {
602
649
  const { page, t0 } = await newRecordedPage(browser, {});
603
650
  await page.goto(`${state.localUrl}/sessions`, { waitUntil: "networkidle", timeout: 30000 });
@@ -0,0 +1,64 @@
1
+ # Product video v2 — the switcher-first cut
2
+
3
+ The March cut opened on a session *list* and treated full screen as the
4
+ product. That is backwards now: **the wall is the product**, full screen is a
5
+ deliberate mode, and three new stories exist — the briefing, hop-ios, and
6
+ HopBoard dictation. Target: ~60s, captioned, no voiceover.
7
+
8
+ ## Shot list
9
+
10
+ Captured with the sanitized rig (`demo/capture/`) unless marked PHONE.
11
+ Dark theme throughout; captions succinct, not salesy.
12
+
13
+ 1. **00-wall** (10–13s) — CAPTURED, `demo-output/footage/live/00-wall.webm`
14
+ The wall breathing: live tiles ticking, briefing card up top, filter
15
+ narrows live, click engages a session in place, ⌘⏎ enters full screen.
16
+ Caption: `Every terminal, one wall. Sessions are the home screen.`
17
+ 2. **01-briefing** (5s) — crop/zoom of the briefing card reading itself.
18
+ Caption: `A briefing, not a backlog — what happened while you were away.`
19
+ (Staged via route interception in the 00-wall clip; can reuse frames.)
20
+ 3. **02-agent-live** (8s) — existing rig clip, still valid: an agent working
21
+ in a session while you watch and type into the same terminal.
22
+ Caption: `Agents work in real terminals. Watch, steer, take over.`
23
+ 4. **03-phone-live** (6s) — existing rig clip (mobile web).
24
+ Caption: `The same fleet from your phone.`
25
+ 5. **04-mobile** (6s) — `docs/hero-mobile.svg` as a Ken-Burns still
26
+ (slow push-in, left phone then right phone). Covers BOTH hop-ios and
27
+ HopBoard in the house illustration style (same canvas/typography as
28
+ hero-overview.svg). Caption: none needed — the annotations are in
29
+ the frame. UPGRADE PATH: real device footage replaces this still
30
+ whenever it gets filmed; the storyboard slot and timing stay.
31
+ Why not simulator footage: simctl has no tap injection (driving the
32
+ app means running Orion's XCUITest rig inside their repo), system
33
+ dialogs (Apple ID sign-in) land on camera, and HopBoard specifically
34
+ cannot be real there — keyboard-extension + mic pipeline + Whisper on
35
+ the Neural Engine don't function in the simulator, so the 1–3s
36
+ transcription moment would have to be staged. The dictation demo's
37
+ honesty is its point; an illustration is honest, a fake capture isn't.
38
+ 7. **06-close** (3s) — wall again, one tile ringing its attention bell,
39
+ cursor moves to it. Caption: `hop — github.com/jzthree/hop`.
40
+
41
+ ## Rig notes (what changed since March)
42
+
43
+ - New clip `00-wall` in `capture.mjs`; briefing is staged by intercepting
44
+ `/assets/digest.json` — nothing written to disk, nothing to leak.
45
+ - The rig's sessions are created through the terminal API, so the origin
46
+ classifier marks them agent-created and the wall's default USER scope
47
+ would hide the entire cast — the network filter now presents them as
48
+ user sessions (`createdBy: "user"` rewrite).
49
+ - The legacy `01-sessions` clip targets the old `/sessions` hub page.
50
+ Superseded by `00-wall`; keep the code until the v2 edit is locked.
51
+
52
+ ## Phone footage checklist (only you can film these)
53
+
54
+ - iPhone, dark mode, notifications off, screen-record at 60fps.
55
+ - hop-ios: open on the wall, scroll it once, tap a tile, type one short
56
+ line (keyboard feel is the shot), open the briefing.
57
+ - HopBoard: in Notes or a claude session, tap mic key, speak one sentence
58
+ ("route the exporter errors into the retry queue"), stop, let the text
59
+ land. Keep the orange mic indicator in frame — it is the privacy story.
60
+
61
+ ## Assembly
62
+
63
+ `stitch-demo-video.mjs` concatenates WebM → MP4 as before. Captions get
64
+ burned in at edit time (same as March cut) — keep each on screen ≥2.5s.
@@ -0,0 +1,105 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="980" height="620" viewBox="0 0 980 620" role="img" aria-label="hop on the phone: the native iOS app showing the session wall with a morning briefing and live terminal tiles, and HopBoard, a system-wide keyboard with on-device Whisper dictation — audio never leaves the phone.">
2
+ <defs>
3
+ <linearGradient id="canvasM" x1="0" y1="0" x2="0" y2="1">
4
+ <stop offset="0" stop-color="#16171c"/>
5
+ <stop offset="1" stop-color="#101116"/>
6
+ </linearGradient>
7
+ <filter id="shadowM" x="-20%" y="-20%" width="140%" height="160%">
8
+ <feDropShadow dx="0" dy="6" stdDeviation="14" flood-color="#000000" flood-opacity="0.45"/>
9
+ </filter>
10
+ <clipPath id="phoneA"><rect x="120" y="96" width="250" height="470" rx="28"/></clipPath>
11
+ <clipPath id="phoneB"><rect x="612" y="96" width="250" height="470" rx="28"/></clipPath>
12
+ </defs>
13
+
14
+ <!-- canvas -->
15
+ <rect x="0.5" y="0.5" width="979" height="619" rx="18" fill="url(#canvasM)" stroke="#272831"/>
16
+
17
+ <!-- kicker -->
18
+ <text x="490" y="44" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12.5" letter-spacing="0.14em" fill="#8d909b">THE FLEET IN YOUR POCKET — NATIVE APP, AND A KEYBOARD THAT LISTENS ON-DEVICE</text>
19
+
20
+ <!-- ═══════════ ① hop-ios: the wall, native ═══════════ -->
21
+ <g filter="url(#shadowM)">
22
+ <rect x="120" y="96" width="250" height="470" rx="28" fill="#0b0b0e" stroke="#33343c" stroke-width="2"/>
23
+ </g>
24
+ <g clip-path="url(#phoneA)" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">
25
+ <!-- dynamic island -->
26
+ <rect x="205" y="106" width="80" height="16" rx="8" fill="#000000"/>
27
+ <!-- app header -->
28
+ <text x="136" y="146" fill="#e6e6eb" font-weight="700" font-size="13">Sessions</text>
29
+ <text x="354" y="146" text-anchor="end" fill="#8d909b" font-size="10">4 live</text>
30
+ <!-- briefing card -->
31
+ <rect x="132" y="158" width="226" height="86" rx="10" fill="#17181f" stroke="#2c2d36"/>
32
+ <text x="142" y="176" font-size="9.5"><tspan fill="#a78bfa" font-weight="700">✦ Briefing</tspan><tspan fill="#8d909b">&#160;&#160;7:30am</tspan></text>
33
+ <text x="142" y="192" fill="#c9cad3" font-size="9">Nebula landed the ingest refactor</text>
34
+ <text x="142" y="205" fill="#c9cad3" font-size="9">overnight — 41s → 9s. Polaris is</text>
35
+ <text x="142" y="218" fill="#c9cad3" font-size="9">mid-benchmark, no regressions.</text>
36
+ <text x="142" y="235" font-size="9"><tspan fill="#f0883e">●</tspan><tspan fill="#e6e6eb">&#160;Aurora wants a review</tspan></text>
37
+ <!-- tile: Nebula -->
38
+ <rect x="132" y="254" width="226" height="92" rx="10" fill="#101116" stroke="#2c2d36"/>
39
+ <text x="142" y="270" font-size="9.5"><tspan fill="#28c840">●</tspan><tspan fill="#e6e6eb" font-weight="700">&#160;Nebula</tspan></text>
40
+ <text x="142" y="288" fill="#8d909b" font-size="8.5">✓ tests: 47 passed</text>
41
+ <text x="142" y="301" fill="#8d909b" font-size="8.5">transform src/hooks/usePresence.ts</text>
42
+ <text x="142" y="314" fill="#8d909b" font-size="8.5">128 modules in 296ms</text>
43
+ <text x="142" y="334" font-size="8.5"><tspan fill="#6e7079">❯</tspan><tspan fill="#c9cad3">&#160;</tspan></text>
44
+ <rect x="152" y="326" width="5" height="10" fill="#a78bfa" opacity="0.85"/>
45
+ <!-- tile: Polaris with bell -->
46
+ <rect x="132" y="356" width="226" height="70" rx="10" fill="#101116" stroke="#7c3aed"/>
47
+ <text x="142" y="372" font-size="9.5"><tspan fill="#a78bfa">◉</tspan><tspan fill="#e6e6eb" font-weight="700">&#160;Polaris</tspan><tspan fill="#f0883e">&#160;· needs you</tspan></text>
48
+ <text x="142" y="390" fill="#8d909b" font-size="8.5">rollout wave 3/4 complete</text>
49
+ <text x="142" y="403" fill="#8d909b" font-size="8.5">continue to wave 4/4? (y/n)</text>
50
+ <!-- keyboard hint strip -->
51
+ <rect x="132" y="436" width="226" height="30" rx="8" fill="#17181f" stroke="#2c2d36"/>
52
+ <text x="245" y="455" text-anchor="middle" fill="#8d909b" font-size="9">esc · tab · ctrl · ↑ ↓ · ⌘K</text>
53
+ <!-- home indicator -->
54
+ <rect x="205" y="546" width="80" height="4" rx="2" fill="#33343c"/>
55
+ </g>
56
+ <text x="245" y="592" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11" fill="#c9cad3"><tspan fill="#a78bfa" font-weight="700">①</tspan> native app — the wall, the briefing, push when a session rings</text>
57
+
58
+ <!-- connective arrow -->
59
+ <g stroke="#3d3e49" stroke-width="1.5" fill="none">
60
+ <path d="M 386 330 C 450 330 530 330 596 330" stroke-dasharray="4 5"/>
61
+ </g>
62
+ <text x="491" y="318" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="10" fill="#8d909b">same fleet · same daemon</text>
63
+
64
+ <!-- ═══════════ ② HopBoard: dictation keyboard ═══════════ -->
65
+ <g filter="url(#shadowM)">
66
+ <rect x="612" y="96" width="250" height="470" rx="28" fill="#0b0b0e" stroke="#33343c" stroke-width="2"/>
67
+ </g>
68
+ <g clip-path="url(#phoneB)" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">
69
+ <rect x="697" y="106" width="80" height="16" rx="8" fill="#000000"/>
70
+ <!-- orange mic indicator -->
71
+ <circle cx="789" cy="114" r="3.5" fill="#ff9f0a"/>
72
+ <!-- a claude session being dictated into -->
73
+ <text x="628" y="150" font-size="9.5"><tspan fill="#28c840">●</tspan><tspan fill="#e6e6eb" font-weight="700">&#160;Aurora</tspan><tspan fill="#8d909b">&#160;· full screen</tspan></text>
74
+ <text x="628" y="172" fill="#8d909b" font-size="8.5">⏺ Exporter errors now surface in</text>
75
+ <text x="628" y="185" fill="#8d909b" font-size="8.5"> the retry queue with backoff.</text>
76
+ <rect x="624" y="200" width="226" height="42" rx="8" fill="#17181f" stroke="#2c2d36"/>
77
+ <text x="634" y="217" font-size="9"><tspan fill="#6e7079">❯</tspan><tspan fill="#e6e6eb">&#160;route the exporter errors into</tspan></text>
78
+ <text x="634" y="231" font-size="9"><tspan fill="#e6e6eb"> the retry queue</tspan></text>
79
+ <rect x="762" y="222" width="5" height="11" fill="#a78bfa" opacity="0.85"/>
80
+ <!-- transcribing pill -->
81
+ <rect x="624" y="252" width="226" height="24" rx="12" fill="#1c1420" stroke="#7c3aed"/>
82
+ <text x="737" y="268" text-anchor="middle" font-size="9"><tspan fill="#a78bfa">●</tspan><tspan fill="#c9cad3">&#160;whisper large-v3-turbo · on-device · 1.4s</tspan></text>
83
+ <!-- keyboard -->
84
+ <rect x="612" y="290" width="250" height="276" fill="#141419"/>
85
+ <g fill="#232430">
86
+ <rect x="624" y="302" width="20" height="30" rx="5"/><rect x="648" y="302" width="20" height="30" rx="5"/><rect x="672" y="302" width="20" height="30" rx="5"/><rect x="696" y="302" width="20" height="30" rx="5"/><rect x="720" y="302" width="20" height="30" rx="5"/><rect x="744" y="302" width="20" height="30" rx="5"/><rect x="768" y="302" width="20" height="30" rx="5"/><rect x="792" y="302" width="20" height="30" rx="5"/><rect x="816" y="302" width="20" height="30" rx="5"/><rect x="838" y="302" width="12" height="30" rx="5"/>
87
+ <rect x="636" y="338" width="20" height="30" rx="5"/><rect x="660" y="338" width="20" height="30" rx="5"/><rect x="684" y="338" width="20" height="30" rx="5"/><rect x="708" y="338" width="20" height="30" rx="5"/><rect x="732" y="338" width="20" height="30" rx="5"/><rect x="756" y="338" width="20" height="30" rx="5"/><rect x="780" y="338" width="20" height="30" rx="5"/><rect x="804" y="338" width="20" height="30" rx="5"/><rect x="828" y="338" width="22" height="30" rx="5"/>
88
+ <rect x="624" y="374" width="30" height="30" rx="5"/><rect x="660" y="374" width="20" height="30" rx="5"/><rect x="684" y="374" width="20" height="30" rx="5"/><rect x="708" y="374" width="20" height="30" rx="5"/><rect x="732" y="374" width="20" height="30" rx="5"/><rect x="756" y="374" width="20" height="30" rx="5"/><rect x="780" y="374" width="20" height="30" rx="5"/><rect x="806" y="374" width="44" height="30" rx="5"/>
89
+ </g>
90
+ <!-- mic key, active -->
91
+ <rect x="624" y="410" width="52" height="34" rx="7" fill="#7c3aed"/>
92
+ <text x="650" y="432" text-anchor="middle" fill="#ffffff" font-size="13">🎙</text>
93
+ <rect x="682" y="410" width="120" height="34" rx="7" fill="#232430"/>
94
+ <text x="742" y="431" text-anchor="middle" fill="#8d909b" font-size="9">space</text>
95
+ <rect x="808" y="410" width="42" height="34" rx="7" fill="#232430"/>
96
+ <text x="829" y="431" text-anchor="middle" fill="#8d909b" font-size="9">⏎</text>
97
+ <!-- waveform -->
98
+ <g stroke="#a78bfa" stroke-width="2" stroke-linecap="round">
99
+ <line x1="662" y1="470" x2="662" y2="482"/><line x1="672" y1="464" x2="672" y2="488"/><line x1="682" y1="458" x2="682" y2="494"/><line x1="692" y1="466" x2="692" y2="486"/><line x1="702" y1="472" x2="702" y2="480"/><line x1="712" y1="462" x2="712" y2="490"/><line x1="722" y1="468" x2="722" y2="484"/><line x1="732" y1="456" x2="732" y2="496"/><line x1="742" y1="466" x2="742" y2="486"/><line x1="752" y1="472" x2="752" y2="480"/><line x1="762" y1="460" x2="762" y2="492"/><line x1="772" y1="468" x2="772" y2="484"/><line x1="782" y1="474" x2="782" y2="478"/><line x1="792" y1="464" x2="792" y2="488"/><line x1="802" y1="470" x2="802" y2="482"/>
100
+ </g>
101
+ <text x="737" y="518" text-anchor="middle" fill="#8d909b" font-size="9">hold to speak · release to insert</text>
102
+ <rect x="697" y="546" width="80" height="4" rx="2" fill="#33343c"/>
103
+ </g>
104
+ <text x="737" y="592" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11" fill="#c9cad3"><tspan fill="#a78bfa" font-weight="700">②</tspan> HopBoard — on-device Whisper dictation; audio never leaves the phone</text>
105
+ </svg>
@@ -175,6 +175,10 @@ const toPresence = (client: ClientState): PresenceClient => ({
175
175
  });
176
176
 
177
177
  class ClientState {
178
+ // True from attach until this client's serialized snapshot has been sent.
179
+ // Output broadcasts are withheld meanwhile (see broadcast) and delivered
180
+ // as catch-up appended to the snapshot itself.
181
+ awaitingSnapshot = false;
178
182
  id: string;
179
183
  name: string;
180
184
  color: string;
@@ -438,9 +442,22 @@ export class Room extends EventEmitter {
438
442
  // the web full screen's ~384KB request wants the grid's full depth.
439
443
  const serializedScrollback = replayRequest !== null && replayRequest <= 131_072 ? 200 : undefined;
440
444
  if (gridForAttach) {
445
+ // Everything already in the ring at THIS moment will be in the
446
+ // serialized screen; everything after it will not (grid writes queue
447
+ // behind the serialize marker). Mute output to this client until the
448
+ // snapshot goes out, then hand it the exact delta from this cursor.
449
+ client.awaitingSnapshot = true;
450
+ const catchUpFrom = this.outputStart + this.outputBytes;
441
451
  gridForAttach.serialize((serialized) => {
442
- if (this.ended || !this.clients.has(client.id) || !socket.isOpen()) return;
452
+ if (this.ended || !this.clients.has(client.id) || !socket.isOpen()) {
453
+ client.awaitingSnapshot = false;
454
+ return;
455
+ }
443
456
  if (serialized === null) {
457
+ // Raw fallback reads the ring as it stands NOW — everything the
458
+ // mute withheld is inside it. Unmute first so nothing new slips
459
+ // into the erased-by-reset gap either (raw has no RIS).
460
+ client.awaitingSnapshot = false;
444
461
  sendRawSnapshot();
445
462
  wiggle();
446
463
  return;
@@ -453,14 +470,19 @@ export class Room extends EventEmitter {
453
470
  // read the separate flags — ends up in the app's actual modes.
454
471
  const modeSeq = (this.mouseReporting ? "\x1b[?1002h" + (this.mouseSgr ? "\x1b[?1006h" : "") : "")
455
472
  + (this.cursorHidden ? "\x1b[?25l" : "");
473
+ // Catch-up: output that arrived while the snapshot was being
474
+ // serialized. Appended INSIDE the snapshot payload so snapshot and
475
+ // delta land as one atomic parse — no frame between reset and delta.
476
+ const catchUp = this.getOutputSince(catchUpFrom);
456
477
  socket.send(JSON.stringify({
457
478
  type: "snapshot",
458
479
  // In-band RIS: every client applies the serialized screen onto a
459
480
  // clean slate, including ones that don't reset on their own.
460
- data: "\x1bc" + serialized + modeSeq,
481
+ data: "\x1bc" + serialized + modeSeq + (catchUp.reset ? "" : catchUp.data),
461
482
  capped: this.outputBytes > SERIALIZED_CAPPED_HINT_BYTES,
462
483
  ...snapshotFlags()
463
484
  } satisfies ServerMessage));
485
+ client.awaitingSnapshot = false;
464
486
  // No wiggle: the serialized grid IS the current screen.
465
487
  }, serializedScrollback);
466
488
  } else {
@@ -923,7 +945,16 @@ export class Room extends EventEmitter {
923
945
 
924
946
  private broadcast(message: ServerMessage) {
925
947
  const payload = JSON.stringify(message);
948
+ const isOutput = (message as { type?: string }).type === "output";
926
949
  for (const client of this.clients.values()) {
950
+ // A client whose serialized snapshot is still being prepared must not
951
+ // receive live output yet: the snapshot opens with an in-band RIS, so
952
+ // output sent before it would be ERASED by it — and the serialized
953
+ // grid does not contain those bytes either (they queued behind the
954
+ // serialize marker). That silent loss was 'the screen is stale until
955
+ // the app repaints' on every attach that raced live output. The
956
+ // withheld bytes are delivered as catch-up appended to the snapshot.
957
+ if (isOutput && client.awaitingSnapshot) continue;
927
958
  if (client.socket.isOpen()) {
928
959
  client.socket.send(payload);
929
960
  }
@@ -780,6 +780,36 @@ describe("Room", () => {
780
780
  expect(snapshot.capped).toBe(true);
781
781
  });
782
782
 
783
+ // THE ordering bug behind 'text doesn't update until I scroll' surviving
784
+ // the serialized-attach fix: serialization is ASYNC, the client is already
785
+ // in the broadcast list, so live output landed before the snapshot — and
786
+ // the snapshot's in-band RIS then erased it, while the serialized grid
787
+ // didn't contain those bytes either (they queue behind the serialize
788
+ // marker). Output during the window must be withheld and delivered as
789
+ // catch-up inside the snapshot payload itself.
790
+ it("output racing the serialize is withheld and appended to the snapshot", async () => {
791
+ const { room, pty } = await setup("race");
792
+ pty().emit("\x1b[2J\x1b[1;1Hpre-attach content\r\n");
793
+ const socket = new FakeSocket();
794
+ room.attachClient(
795
+ { id: "racer", name: "Racer", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
796
+ socket
797
+ );
798
+ // Arrives while the snapshot is still serializing (same tick).
799
+ pty().emit("LATE-MARKER-XYZ\r\n");
800
+ const snapshot = await waitForSnapshot(socket);
801
+ const messages = readMessages(socket);
802
+ const snapIdx = messages.findIndex((m) => m.type === "snapshot");
803
+ const outputBefore = messages.slice(0, snapIdx).filter((m) => m.type === "output");
804
+ expect(outputBefore).toHaveLength(0);
805
+ expect(String(snapshot.data)).toContain("LATE-MARKER-XYZ");
806
+ // After the snapshot the stream flows live again, no duplication.
807
+ pty().emit("AFTER-MARKER\r\n");
808
+ const after = readMessages(socket).filter((m) => m.type === "output");
809
+ expect(after.some((m) => String(m.data).includes("AFTER-MARKER"))).toBe(true);
810
+ expect(after.some((m) => String(m.data).includes("LATE-MARKER-XYZ"))).toBe(false);
811
+ });
812
+
783
813
  it("replay=0 means no snapshot at all — live stream only", async () => {
784
814
  const { room, pty } = await setup("claim");
785
815
  pty().emit("some prior output\r\n");
@@ -1102,11 +1102,43 @@ const App = () => {
1102
1102
  const t = termRef.current;
1103
1103
  if (!t) return;
1104
1104
  try {
1105
+ // refresh() alone re-renders from the SAME GPU texture atlas, so it
1106
+ // cannot fix the failure people actually hit: cells that draw blank
1107
+ // because their glyphs are missing or stale in the atlas (font-size
1108
+ // changes and context restores are the usual triggers). Rebuild the
1109
+ // atlas first — the theme-change path has always done this, and it is
1110
+ // the local equivalent of what a scroll achieves by making the app
1111
+ // repaint from scratch.
1112
+ const withAtlas = t as unknown as { clearTextureAtlas?: () => void };
1113
+ if (typeof withAtlas.clearTextureAtlas === "function") withAtlas.clearTextureAtlas();
1105
1114
  t.refresh(0, t.rows - 1);
1106
1115
  } catch { /* terminal disposed mid-flight */ }
1107
1116
  };
1108
1117
  const forceRepaintRef = useRef(forceRepaint);
1109
1118
  forceRepaintRef.current = forceRepaint;
1119
+
1120
+ // The explicit "redraw" — what a scroll does, on demand. Local half:
1121
+ // rebuild the glyph atlas and repaint (forceRepaint). Remote half: when
1122
+ // the app owns the mouse (claude, vim), send a net-zero wheel pair so the
1123
+ // APP repaints from scratch — that remote repaint is the thing scrolling
1124
+ // actually provides, and no amount of local refreshing can substitute for
1125
+ // it when rows never arrived. Manual only: it injects input, so it must
1126
+ // never run on a timer.
1127
+ const redrawSession = () => {
1128
+ forceRepaintRef.current();
1129
+ const t = termRef.current;
1130
+ if (!t || !remoteMouseReportingRef.current) return;
1131
+ const col = 1 + Math.floor((t.cols || 2) / 2);
1132
+ const row = 1 + Math.floor((t.rows || 2) / 2);
1133
+ // Order matters: UP then DOWN ends where the view started. (Down first
1134
+ // is a no-op at the bottom, and the up then leaves the app scrolled.)
1135
+ const pair = remoteMouseSgrRef.current
1136
+ ? `\x1b[<64;${col};${row}M\x1b[<65;${col};${row}M`
1137
+ : "";
1138
+ if (pair) handleUserInputRef.current?.(pair);
1139
+ };
1140
+ const redrawSessionRef = useRef(redrawSession);
1141
+ redrawSessionRef.current = redrawSession;
1110
1142
  // Mirror of the switcher-open state for callbacks that outlive a render.
1111
1143
  const switcherOpenRef = useRef(false);
1112
1144
  switcherOpenRef.current = switcherOpen;
@@ -3697,6 +3729,17 @@ const App = () => {
3697
3729
  : "Locked"}
3698
3730
  </span>
3699
3731
  </div>
3732
+ <div className="drawer-row">
3733
+ <label>Redraw</label>
3734
+ <button
3735
+ type="button"
3736
+ className="quick-btn"
3737
+ title="Repaint the screen — rebuilds local glyphs and, in apps like Claude, asks the app itself to repaint (what a scroll does)"
3738
+ onClick={() => redrawSessionRef.current()}
3739
+ >
3740
+ Redraw screen
3741
+ </button>
3742
+ </div>
3700
3743
  <div className="drawer-row">
3701
3744
  <label>Typing</label>
3702
3745
  <div className="view-mode-buttons">
@@ -3942,6 +3985,15 @@ const App = () => {
3942
3985
  {isMacPlatform ? "⌘/" : "Ctrl+Shift+/"} keys
3943
3986
  </button>
3944
3987
  )}
3988
+ <button
3989
+ type="button"
3990
+ className="footer-find-toggle"
3991
+ aria-label="Redraw the screen"
3992
+ title="Repaint the screen — rebuilds local glyphs and, in apps like Claude, asks the app itself to repaint (what a scroll does)"
3993
+ onClick={() => redrawSessionRef.current()}
3994
+ >
3995
+ redraw
3996
+ </button>
3945
3997
  <span title={sortedPresence.map((c) => c.name).join(", ")}>
3946
3998
  {(() => {
3947
3999
  // Someone else typing is the fact worth surfacing —
@@ -0,0 +1,119 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { SwitcherSession } from "../utils/switcherModel";
3
+
4
+ // The briefing, on the desktop wall — the same digest.json the iOS app
5
+ // renders, written by the host-side agent (hop-ios tools/digest.mjs) into the
6
+ // directory the daemon serves under /assets/. This card is deliberately
7
+ // self-contained: one fetch, localStorage for dismissed/read state, no new
8
+ // endpoints, and the same reading contract as the phone — the summary is the
9
+ // headline, every story shows in full under its session DATELINE, unread
10
+ // stories carry a dot that opening clears.
11
+
12
+ type DigestItem = {
13
+ session: string;
14
+ headline: string;
15
+ why?: string;
16
+ urgency?: string;
17
+ };
18
+
19
+ type Digest = {
20
+ generated_at?: string;
21
+ summary?: string;
22
+ items?: DigestItem[];
23
+ };
24
+
25
+ const READ_KEY = "hop_digest_read"; // "<stamp>|a,b,c" — self-pruning
26
+ const DISMISS_KEY = "hop_digest_dismissed"; // "<stamp>"
27
+
28
+ const readSet = (stamp: string): Set<string> => {
29
+ try {
30
+ const raw = localStorage.getItem(READ_KEY) || "";
31
+ const [s, list] = raw.split("|");
32
+ if (s !== stamp || !list) return new Set();
33
+ return new Set(list.split(",").filter(Boolean));
34
+ } catch { return new Set(); }
35
+ };
36
+
37
+ const urgencyClass = (u?: string) =>
38
+ u === "needs-you" ? "needs" : u === "blocked" ? "blocked" : u === "finished" ? "done" : "fyi";
39
+
40
+ export const DigestCard = ({ sessions, onOpen }: {
41
+ sessions: SwitcherSession[];
42
+ onOpen: (session: SwitcherSession) => void;
43
+ }) => {
44
+ const [digest, setDigest] = useState<Digest | null>(null);
45
+ const [dismissed, setDismissed] = useState(() => {
46
+ try { return localStorage.getItem(DISMISS_KEY) || ""; } catch { return ""; }
47
+ });
48
+ const [read, setRead] = useState<Set<string>>(new Set());
49
+
50
+ useEffect(() => {
51
+ let cancelled = false;
52
+ const load = () => {
53
+ fetch("/assets/digest.json", { cache: "no-store" })
54
+ .then((r) => (r.ok ? r.json() : null))
55
+ .then((d: Digest | null) => {
56
+ if (cancelled || !d?.summary) return;
57
+ setDigest(d);
58
+ setRead(readSet(d.generated_at || ""));
59
+ })
60
+ .catch(() => { /* absent is the normal state until the job runs */ });
61
+ };
62
+ load();
63
+ // A briefing written while this tab sat open should appear on return.
64
+ const onVis = () => { if (!document.hidden) load(); };
65
+ document.addEventListener("visibilitychange", onVis);
66
+ return () => { cancelled = true; document.removeEventListener("visibilitychange", onVis); };
67
+ }, []);
68
+
69
+ if (!digest?.summary) return null;
70
+ const stamp = digest.generated_at || "";
71
+ if (dismissed === stamp) return null;
72
+ const items = digest.items || [];
73
+
74
+ const markRead = (session: string) => {
75
+ const next = new Set(read);
76
+ next.add(session);
77
+ setRead(next);
78
+ try { localStorage.setItem(READ_KEY, stamp + "|" + [...next].sort().join(",")); } catch { /* private mode */ }
79
+ };
80
+
81
+ // The daemon folds case when a name addresses a session; match it.
82
+ const find = (internalName: string) =>
83
+ sessions.find((s) => (s.internalName || s.name) === internalName)
84
+ || sessions.find((s) => (s.internalName || s.name).toLowerCase() === internalName.toLowerCase());
85
+
86
+ return (
87
+ <div className="digest-card">
88
+ <div className="digest-head">
89
+ <span className="digest-badge">✦ Briefing</span>
90
+ <button className="digest-dismiss" title="Dismiss briefing"
91
+ onClick={() => { setDismissed(stamp); try { localStorage.setItem(DISMISS_KEY, stamp); } catch { /* ok */ } }}>
92
+ ×
93
+ </button>
94
+ </div>
95
+ <div className="digest-summary">{digest.summary}</div>
96
+ {items.map((item) => {
97
+ const target = find(item.session);
98
+ const name = target ? (target.displayName || target.name) : item.session;
99
+ return (
100
+ <button
101
+ key={item.session + item.headline}
102
+ className={"digest-item " + urgencyClass(item.urgency) + (read.has(item.session) ? " read" : "")}
103
+ onClick={() => {
104
+ markRead(item.session);
105
+ if (target) onOpen(target);
106
+ }}
107
+ >
108
+ <span className="digest-dateline">
109
+ {!read.has(item.session) && <span className="digest-dot" />}
110
+ {name}
111
+ </span>
112
+ <span className="digest-headline">{item.headline}</span>
113
+ {item.why ? <span className="digest-why">{item.why}</span> : null}
114
+ </button>
115
+ );
116
+ })}
117
+ </div>
118
+ );
119
+ };
@@ -27,6 +27,7 @@ import {
27
27
  type SwitcherSortMode
28
28
  } from "../utils/switcherModel";
29
29
  import { scanKeyboardProtocol } from "../utils/keyboardProtocol";
30
+ import { DigestCard } from "./DigestCard";
30
31
 
31
32
  // Full-screen, in-app session switcher (mobile-first). Hot sessions — current,
32
33
  // attention, most recent — are preview cards; the tail is compact rows grouped
@@ -483,6 +484,14 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
483
484
  const wanted = tileFontFor(boxW);
484
485
  if (Math.abs(wanted - font) >= 0.25) {
485
486
  term.options.fontSize = wanted;
487
+ // A font-size change invalidates the GPU glyph atlas. Without an
488
+ // explicit rebuild, already-drawn cells can keep pointing at stale
489
+ // slots and paint BLANK until something forces re-rasterisation —
490
+ // which is exactly the "renders wrong until I scroll" report, and
491
+ // why a plain refresh() never cured it. The wall changes font size
492
+ // on every zoom and resize, so this is the hottest trigger there is.
493
+ const withAtlas = term as unknown as { clearTextureAtlas?: () => void };
494
+ if (typeof withAtlas.clearTextureAtlas === "function") withAtlas.clearTextureAtlas();
486
495
  requestAnimationFrame(() => rescaleRef.current());
487
496
  return;
488
497
  }
@@ -1584,31 +1593,33 @@ export const SessionSwitcher = ({
1584
1593
  flatNav.forEach((s, i) => m.set(sessionKey(s), i));
1585
1594
  return m;
1586
1595
  }, [flatNav]);
1587
- // Opening the wall focuses the session the app is ON. A deep link like
1588
- // /s/<name>/?view=wall names a session, so landing on the wall with
1589
- // nothing focused (and the keyboard sitting on the FIRST card) loses the
1590
- // one thing the URL asked for. The old name works too — the room id is
1591
- // the internal name, so a renamed session still resolves. This is not an
1592
- // "accidental" focus: arriving here is the user's explicit act, and it
1593
- // focuses in place rather than entering full screen.
1596
+ // Opening the wall focuses the session the app is ON — once. A deep link
1597
+ // like /s/<name>/?view=wall names a session, so landing with nothing
1598
+ // focused loses what the URL asked for.
1599
+ //
1600
+ // ONCE is load-bearing: flatNav changes on every filter keystroke, so an
1601
+ // unguarded effect re-anchored the selection to the current session while
1602
+ // you typed — and ⌘⏎ then opened THAT instead of the match you were
1603
+ // looking at. Filtering owns the selection (top match); this only seeds it.
1604
+ const initialFocusDoneRef = useRef(false);
1605
+ useEffect(() => { if (!open) initialFocusDoneRef.current = false; }, [open]);
1594
1606
  useEffect(() => {
1595
1607
  if (!open || focusedKey || !currentRoom) return;
1608
+ if (initialFocusDoneRef.current || filter) return;
1596
1609
  const current = flatNav.find((s) => (s.internalName || s.name) === currentRoom || s.name === currentRoom);
1597
1610
  if (!current) return;
1611
+ initialFocusDoneRef.current = true;
1598
1612
  const key = sessionKey(current);
1599
1613
  setFocusedKey(key);
1600
1614
  const idx = navIndexByKey.get(key);
1601
1615
  if (typeof idx === "number") {
1602
1616
  setKbdIndex(idx);
1603
- // ...and bring it into view. A deep link can name a session sitting
1604
- // far down a long wall; focusing a card the user cannot see is the
1605
- // same as not focusing it. Deferred a frame so the card exists.
1606
1617
  requestAnimationFrame(() => {
1607
1618
  document.querySelector(`[data-nav-index="${idx}"]`)?.scrollIntoView({ block: "center" });
1608
1619
  });
1609
1620
  }
1610
1621
  // eslint-disable-next-line react-hooks/exhaustive-deps
1611
- }, [open, currentRoom, flatNav, navIndexByKey]);
1622
+ }, [open, currentRoom, flatNav, navIndexByKey, filter, focusedKey]);
1612
1623
  // Latest visual order, for reorderManual (defined earlier in the component).
1613
1624
  // Every session in the current scope, in manual order — the canonical list
1614
1625
  // reordering works against, regardless of what the filter is showing.
@@ -2657,6 +2668,12 @@ export const SessionSwitcher = ({
2657
2668
  )}
2658
2669
  </div>
2659
2670
  <div className="switcher-scroll" ref={scrollRef} onContextMenu={openWallMenu}>
2671
+ {/* The host agent's briefing — same digest.json the phone renders.
2672
+ Hidden while filtering: search is about one session, the briefing
2673
+ is about the fleet. */}
2674
+ {model.mode !== "filter" && (
2675
+ <DigestCard sessions={sessions} onOpen={handleTap} />
2676
+ )}
2660
2677
  {model.mode === "filter" ? (
2661
2678
  // Matches keep their terminal previews: search results are preview
2662
2679
  // cards at the current zoom, same as the wall — the screen content