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.
- package/hay/apps/web/src/App.tsx +52 -0
- package/hay/apps/web/src/components/DigestCard.tsx +119 -0
- package/hay/apps/web/src/components/SessionSwitcher.tsx +28 -11
- package/hay/apps/web/src/styles.css +47 -0
- package/hay-web/assets/{index-bmNjFOSa.js → index-ClpDI5hp.js} +5 -5
- package/hay-web/assets/{index-bmNjFOSa.js.map → index-ClpDI5hp.js.map} +1 -1
- package/hay-web/index.html +1 -1
- package/package.json +1 -1
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -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
|
|
1588
|
-
// /s/<name>/?view=wall names a session, so landing
|
|
1589
|
-
//
|
|
1590
|
-
//
|
|
1591
|
-
//
|
|
1592
|
-
//
|
|
1593
|
-
//
|
|
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); }
|