hop2 0.11.7 → 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
  }
@@ -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
@@ -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); }