hop2 0.11.6 → 0.11.8

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.
@@ -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
  }
@@ -497,17 +506,16 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
497
506
  // tolerance, so no resize is ever asked for and the crop is permanent.
498
507
  // Translate so the LAST row is flush with the box bottom; overflow now
499
508
  // crops the oldest rows off the top, which is what scrollback is for.
500
- // Transform the SCREEN, not `.xterm`. `.xterm` sets overflow:hidden on
501
- // itself, so translating it slides an already-clipped window: the rows
502
- // outside never appear no matter how far you pan (and a rect-based
503
- // measurement is fooled, because getBoundingClientRect reports clipped
504
- // geometry as if visible — which is how the first version of this
505
- // "fixed" the bottom crop on paper while the pixels stayed hidden).
506
- // Moving the screen inside that clip is what actually reveals rows.
509
+ // WHICH ELEMENT CLIPS, measured rather than assumed (both earlier
510
+ // attempts got this wrong). `.switcher-focus-term` is the clip: 450px
511
+ // tall with overflow:hidden. `.xterm` is NOT — it sizes to its content
512
+ // (475px for a 25-row grid), so its own overflow:hidden clips nothing,
513
+ // and measuring against it made the overflow compute as ZERO and the
514
+ // anchor a no-op. Measure the CLIP, move the terminal inside it.
507
515
  inner.style.transform = "";
508
- screen.style.transformOrigin = "bottom left";
509
516
  screen.style.transform = "";
510
- const boxRect = inner.getBoundingClientRect(); // .xterm IS the clip
517
+ inner.style.transformOrigin = "bottom left";
518
+ const boxRect = box.getBoundingClientRect(); // the clip
511
519
  const screenRect = screen.getBoundingClientRect(); // reads after the reset above
512
520
  const overflowX = Math.max(0, Math.round(screenRect.width - boxRect.width));
513
521
  const overflowY = Math.max(0, Math.round(screenRect.height - boxRect.height));
@@ -519,7 +527,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
519
527
  const px = pan ? Math.max(-overflowX, Math.min(0, pan.x)) : 0;
520
528
  const py = pan ? Math.max(autoY, Math.min(0, pan.y)) : autoY;
521
529
  if (pan) panRef.current = { x: px, y: py };
522
- if (px !== 0 || py !== 0) screen.style.transform = `translate(${px}px, ${py}px)`;
530
+ if (px !== 0 || py !== 0) inner.style.transform = `translate(${px}px, ${py}px)`;
523
531
  // Only publish on CHANGE: rescale runs from a ResizeObserver, and
524
532
  // setting a fresh object every pass would re-render (and re-observe)
525
533
  // in a loop.
@@ -589,9 +597,8 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
589
597
  let panning: { sx: number; sy: number; ox: number; oy: number } | null = null;
590
598
  const panBounds = () => {
591
599
  const screen = box.querySelector(".xterm-screen") as HTMLElement | null;
592
- const clip = box.querySelector(".xterm") as HTMLElement | null;
593
- if (!screen || !clip) return null;
594
- const boxRect = clip.getBoundingClientRect(); // same clip rescale uses
600
+ if (!screen) return null;
601
+ const boxRect = box.getBoundingClientRect(); // same clip rescale uses
595
602
  const scrRect = screen.getBoundingClientRect();
596
603
  return {
597
604
  overflowX: Math.max(0, scrRect.width - boxRect.width),
@@ -1094,7 +1101,20 @@ export const SessionSwitcher = ({
1094
1101
  onToggleKeyboard,
1095
1102
  onFind
1096
1103
  }: Props) => {
1097
- const [filter, setFilter] = useState("");
1104
+ // View state survives a REFRESH but not a new tab: sessionStorage, not
1105
+ // localStorage. Reloading the page (or hop reconnecting) used to dump the
1106
+ // filter you had typed, the tile you had focused, and where you were
1107
+ // scrolled — none of which is shareable state that belongs in the URL,
1108
+ // and all of which you expect to still be there afterwards.
1109
+ const [filter, setFilter] = useState(() => {
1110
+ try { return sessionStorage.getItem("hay_switcher_filter") || ""; } catch { return ""; }
1111
+ });
1112
+ useEffect(() => {
1113
+ try {
1114
+ if (filter) sessionStorage.setItem("hay_switcher_filter", filter);
1115
+ else sessionStorage.removeItem("hay_switcher_filter");
1116
+ } catch { /* private mode */ }
1117
+ }, [filter]);
1098
1118
  const [originScope, setOriginScope] = useState<SessionOriginScope>("user");
1099
1119
  // Tile zoom: bigger tiles show more of each terminal preview.
1100
1120
  const [zoom, setZoom] = useState(() => {
@@ -1109,6 +1129,37 @@ export const SessionSwitcher = ({
1109
1129
  // Grid content width, kept fresh across viewport/panel resizes — it decides
1110
1130
  // how far the zoom ladder is climbable on THIS screen.
1111
1131
  const scrollRef = useRef<HTMLDivElement | null>(null);
1132
+ // Where you were scrolled on the wall, restored once the cards exist.
1133
+ const scrollRestoredRef = useRef(false);
1134
+ useEffect(() => {
1135
+ const el = scrollRef.current;
1136
+ if (!el) return;
1137
+ const onScroll = () => {
1138
+ try { sessionStorage.setItem("hay_switcher_scroll", String(el.scrollTop)); } catch { /* private mode */ }
1139
+ };
1140
+ el.addEventListener("scroll", onScroll, { passive: true });
1141
+ return () => el.removeEventListener("scroll", onScroll);
1142
+ }, [open]);
1143
+ useEffect(() => {
1144
+ if (!open || scrollRestoredRef.current) return;
1145
+ let saved = 0;
1146
+ try { saved = Number(sessionStorage.getItem("hay_switcher_scroll") || 0); } catch { /* private mode */ }
1147
+ if (!saved) { scrollRestoredRef.current = true; return; }
1148
+ // Cards mount over a couple of frames; retry briefly until the wall is
1149
+ // tall enough for the offset to mean anything.
1150
+ let tries = 0;
1151
+ const tick = () => {
1152
+ const el = scrollRef.current;
1153
+ if (!el) return;
1154
+ if (el.scrollHeight > el.clientHeight + saved || tries++ > 20) {
1155
+ el.scrollTop = saved;
1156
+ scrollRestoredRef.current = true;
1157
+ return;
1158
+ }
1159
+ requestAnimationFrame(tick);
1160
+ };
1161
+ requestAnimationFrame(tick);
1162
+ }, [open]);
1112
1163
  const [gridWidth, setGridWidth] = useState(0);
1113
1164
  useEffect(() => {
1114
1165
  if (!open) return;
@@ -1351,7 +1402,15 @@ export const SessionSwitcher = ({
1351
1402
  const [sheet, setSheet] = useState<Sheet | null>(null);
1352
1403
  // The wall's single interactive tile. Cleared on close / zooming below the
1353
1404
  // interactive threshold.
1354
- const [focusedKey, setFocusedKey] = useState<string | null>(null);
1405
+ const [focusedKey, setFocusedKey] = useState<string | null>(() => {
1406
+ try { return sessionStorage.getItem("hay_switcher_focus"); } catch { return null; }
1407
+ });
1408
+ useEffect(() => {
1409
+ try {
1410
+ if (focusedKey) sessionStorage.setItem("hay_switcher_focus", focusedKey);
1411
+ else sessionStorage.removeItem("hay_switcher_focus");
1412
+ } catch { /* private mode */ }
1413
+ }, [focusedKey]);
1355
1414
  useEffect(() => { if (!open) setFocusedKey(null); }, [open]);
1356
1415
  useEffect(() => { if (!interactiveTiles) setFocusedKey(null); }, [interactiveTiles]);
1357
1416
  const [renameDraft, setRenameDraft] = useState("");
@@ -1534,31 +1593,33 @@ export const SessionSwitcher = ({
1534
1593
  flatNav.forEach((s, i) => m.set(sessionKey(s), i));
1535
1594
  return m;
1536
1595
  }, [flatNav]);
1537
- // Opening the wall focuses the session the app is ON. A deep link like
1538
- // /s/<name>/?view=wall names a session, so landing on the wall with
1539
- // nothing focused (and the keyboard sitting on the FIRST card) loses the
1540
- // one thing the URL asked for. The old name works too — the room id is
1541
- // the internal name, so a renamed session still resolves. This is not an
1542
- // "accidental" focus: arriving here is the user's explicit act, and it
1543
- // 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]);
1544
1606
  useEffect(() => {
1545
1607
  if (!open || focusedKey || !currentRoom) return;
1608
+ if (initialFocusDoneRef.current || filter) return;
1546
1609
  const current = flatNav.find((s) => (s.internalName || s.name) === currentRoom || s.name === currentRoom);
1547
1610
  if (!current) return;
1611
+ initialFocusDoneRef.current = true;
1548
1612
  const key = sessionKey(current);
1549
1613
  setFocusedKey(key);
1550
1614
  const idx = navIndexByKey.get(key);
1551
1615
  if (typeof idx === "number") {
1552
1616
  setKbdIndex(idx);
1553
- // ...and bring it into view. A deep link can name a session sitting
1554
- // far down a long wall; focusing a card the user cannot see is the
1555
- // same as not focusing it. Deferred a frame so the card exists.
1556
1617
  requestAnimationFrame(() => {
1557
1618
  document.querySelector(`[data-nav-index="${idx}"]`)?.scrollIntoView({ block: "center" });
1558
1619
  });
1559
1620
  }
1560
1621
  // eslint-disable-next-line react-hooks/exhaustive-deps
1561
- }, [open, currentRoom, flatNav, navIndexByKey]);
1622
+ }, [open, currentRoom, flatNav, navIndexByKey, filter, focusedKey]);
1562
1623
  // Latest visual order, for reorderManual (defined earlier in the component).
1563
1624
  // Every session in the current scope, in manual order — the canonical list
1564
1625
  // reordering works against, regardless of what the filter is showing.
@@ -1599,14 +1660,30 @@ export const SessionSwitcher = ({
1599
1660
 
1600
1661
  // Every visit starts on user-owned sessions. Agent sessions stay one toggle
1601
1662
  // away without competing for the default view.
1663
+ const restoredFilterRef = useRef<string>((() => {
1664
+ try { return sessionStorage.getItem("hay_switcher_filter") || ""; } catch { return ""; }
1665
+ })());
1666
+ const openedOnceRef = useRef(false);
1602
1667
  useLayoutEffect(() => {
1603
- if (open) {
1604
- setOriginScope("user");
1605
- setFilter("");
1606
- setSheet(null);
1607
- setCreating(false);
1608
- setCreateDraft("");
1668
+ if (!open) return;
1669
+ // OPENING the wall resets it — but a page that LOADS with the wall
1670
+ // already up is not an open, it is a refresh, and it must keep the
1671
+ // filter/focus/scroll it restored. Without this the reset ran on mount
1672
+ // and wiped the restore a frame after it happened.
1673
+ if (!openedOnceRef.current) {
1674
+ openedOnceRef.current = true;
1675
+ if (restoredFilterRef.current) {
1676
+ setSheet(null);
1677
+ setCreating(false);
1678
+ setCreateDraft("");
1679
+ return;
1680
+ }
1609
1681
  }
1682
+ setOriginScope("user");
1683
+ setFilter("");
1684
+ setSheet(null);
1685
+ setCreating(false);
1686
+ setCreateDraft("");
1610
1687
  }, [open]);
1611
1688
 
1612
1689
 
@@ -2142,6 +2219,31 @@ export const SessionSwitcher = ({
2142
2219
  });
2143
2220
  const data = await res.json().catch(() => ({} as { name?: string; error?: string }));
2144
2221
  if (!res.ok || !data.name) {
2222
+ // A name collision is the common failure, and "already in use" alone
2223
+ // leaves the user hunting: the holder may be filed in another folder,
2224
+ // or be an agent-created session they never opened. Name it, say
2225
+ // where it lives, and put it on screen — the answer to "it doesn't
2226
+ // work" should be the session that was in the way.
2227
+ const taken = res.status === 409
2228
+ ? wallSessions.find((s) => (s.displayName || s.name).toLowerCase() === next.toLowerCase())
2229
+ : null;
2230
+ if (taken) {
2231
+ const home = folders.find((f) => f.id === taken.folderId);
2232
+ onNotice(`"${taken.displayName || taken.name}" already exists${home ? ` in ${home.name}` : ""} — showing it`);
2233
+ setCreating(false);
2234
+ setCreateDraft("");
2235
+ setCreateInFolder(null);
2236
+ const key = sessionKey(taken);
2237
+ setFocusedKey(key);
2238
+ const idx = navIndexByKey.get(key);
2239
+ if (typeof idx === "number") {
2240
+ setKbdIndex(idx);
2241
+ requestAnimationFrame(() => {
2242
+ document.querySelector(`[data-nav-index="${idx}"]`)?.scrollIntoView({ block: "center" });
2243
+ });
2244
+ }
2245
+ return;
2246
+ }
2145
2247
  onNotice(data.error || "Failed to create session");
2146
2248
  return;
2147
2249
  }
@@ -2566,6 +2668,12 @@ export const SessionSwitcher = ({
2566
2668
  )}
2567
2669
  </div>
2568
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
+ )}
2569
2677
  {model.mode === "filter" ? (
2570
2678
  // Matches keep their terminal previews: search results are preview
2571
2679
  // cards at the current zoom, same as the wall — the screen content
@@ -3940,3 +3940,50 @@ button.primary {
3940
3940
  bottom: 30px;
3941
3941
  max-width: 86%;
3942
3942
  }
3943
+
3944
+ /* ── The briefing (DigestCard): the host agent's front page, above the wall.
3945
+ Same reading contract as the phone: summary is the headline, stories show
3946
+ in full under session datelines, unread carries a dot. ── */
3947
+ .digest-card {
3948
+ margin: 10px 12px 4px;
3949
+ padding: 12px 14px;
3950
+ border-radius: 12px;
3951
+ background: color-mix(in srgb, var(--panel) 92%, transparent);
3952
+ border: 1px solid color-mix(in srgb, var(--accent, #7c5cff) 22%, transparent);
3953
+ display: flex;
3954
+ flex-direction: column;
3955
+ gap: 7px;
3956
+ }
3957
+ .digest-head { display: flex; align-items: center; justify-content: space-between; }
3958
+ .digest-badge {
3959
+ font-size: 11px; font-weight: 600; letter-spacing: 0.04em;
3960
+ color: var(--accent, #7c5cff);
3961
+ }
3962
+ .digest-dismiss {
3963
+ border: none; background: none; cursor: pointer;
3964
+ color: var(--ink-muted); font-size: 14px; line-height: 1; padding: 2px 4px;
3965
+ }
3966
+ .digest-summary { font-size: 13.5px; font-weight: 550; color: var(--ink); }
3967
+ .digest-item {
3968
+ display: flex; flex-direction: column; gap: 2px;
3969
+ text-align: left; border: none; background: none; cursor: pointer;
3970
+ padding: 5px 6px; margin: 0 -6px; border-radius: 8px;
3971
+ }
3972
+ .digest-item:hover { background: color-mix(in srgb, var(--ink) 6%, transparent); }
3973
+ .digest-dateline {
3974
+ font-family: var(--font-mono);
3975
+ font-size: 10.5px; font-weight: 700; text-transform: uppercase;
3976
+ letter-spacing: 0.05em;
3977
+ display: inline-flex; align-items: center; gap: 5px;
3978
+ }
3979
+ .digest-item.needs .digest-dateline { color: #e5484d; }
3980
+ .digest-item.blocked .digest-dateline { color: #e79d13; }
3981
+ .digest-item.done .digest-dateline { color: #30a46c; }
3982
+ .digest-item.fyi .digest-dateline { color: var(--ink-muted); }
3983
+ .digest-dot {
3984
+ width: 6px; height: 6px; border-radius: 3px;
3985
+ background: var(--accent, #7c5cff);
3986
+ }
3987
+ .digest-headline { font-size: 12.5px; font-weight: 600; color: var(--ink); }
3988
+ .digest-item.read .digest-headline { color: var(--ink-muted); font-weight: 500; }
3989
+ .digest-why { font-size: 11.5px; color: var(--ink-muted); }
@@ -32,6 +32,10 @@ const props = {
32
32
  };
33
33
 
34
34
  beforeEach(() => {
35
+ // The wall persists filter/focus/scroll in sessionStorage so a REFRESH
36
+ // keeps your view. Tests share one jsdom, so a filter typed in one case
37
+ // would restore into the next and silently empty the wall.
38
+ sessionStorage.clear();
35
39
  const values = new Map<string, string>();
36
40
  vi.stubGlobal("localStorage", {
37
41
  getItem: (key: string) => values.get(key) ?? null,