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.
- 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 +140 -32
- package/hay/apps/web/src/styles.css +47 -0
- package/hay/apps/web/test/SessionSwitcher.test.tsx +4 -0
- package/hay-web/assets/index-ClpDI5hp.js +133 -0
- package/hay-web/assets/{index-CJHvzXqn.js.map → index-ClpDI5hp.js.map} +1 -1
- package/hay-web/index.html +1 -1
- package/package.json +1 -1
- package/hay-web/assets/index-CJHvzXqn.js +0 -133
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
|
}
|
|
@@ -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
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
593
|
-
|
|
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
|
-
|
|
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>(
|
|
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
|
|
1538
|
-
// /s/<name>/?view=wall names a session, so landing
|
|
1539
|
-
//
|
|
1540
|
-
//
|
|
1541
|
-
//
|
|
1542
|
-
//
|
|
1543
|
-
//
|
|
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
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
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,
|