Spaces:
Running
Running
Merge pull request #59 from huggingface/reader/remember-reading-position
Browse files
docs/conversation-view.md
CHANGED
|
@@ -263,6 +263,29 @@ pane with nothing to render (a shell) simply stays a terminal.
|
|
| 263 |
Writes pause between `compositionstart` and `compositionend`: a phone keyboard composes, and the
|
| 264 |
pre-composition snapshot is a string the user meant, where a mid-composition one is half a
|
| 265 |
syllable.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
- **Share** moves here from the sidebar. One session, one place.
|
| 267 |
- **Handover** ("continue from this trace in a new agent") lives in the conversation footer, beside
|
| 268 |
the provenance line — the only place where its meaning is obvious.
|
|
|
|
| 263 |
Writes pause between `compositionstart` and `compositionend`: a phone keyboard composes, and the
|
| 264 |
pre-composition snapshot is a string the user meant, where a mid-composition one is half a
|
| 265 |
syllable.
|
| 266 |
+
- **It comes back to where you were reading** (`readingPosition.ts`). Opening on the end is right
|
| 267 |
+
for a conversation you have not read; it is not right for one you were half-way up. In-app
|
| 268 |
+
navigation already survived — the pane stays mounted and the browser preserves the scroll box
|
| 269 |
+
across the `display: none` that hides a warm tile — so this is about the **cold mount**: a
|
| 270 |
+
reload, an evicted tab, the Hub rebuilding the iframe. Same layer as the draft in §3.3.
|
| 271 |
+
|
| 272 |
+
The anchor is a **turn timestamp**, and the windowed reader forces that choice. Exchanges
|
| 273 |
+
*regroup* as older windows arrive — the one at the top of the list is a fragment whose prompt was
|
| 274 |
+
in the window not yet read, and the two become one when it lands — so no React key, list index or
|
| 275 |
+
pixel offset identifies a place for longer than one fetch. A turn's `ts` comes from the
|
| 276 |
+
transcript and never moves.
|
| 277 |
+
|
| 278 |
+
If the remembered turn is not in the window the reader opened with, it **pages backwards to find
|
| 279 |
+
it**, through the same public `loadOlder()` a scroll would use, bounded to six windows. Past that
|
| 280 |
+
it stays on the end — a remembered position is not worth walking a 19 MB transcript for.
|
| 281 |
+
|
| 282 |
+
Three rules decide whether it feels right rather than merely works. **Nothing is remembered until
|
| 283 |
+
you have moved the view**, and the evidence is a *gesture* — wheel, touch, pointer, keys, the turn
|
| 284 |
+
nav, the load-earlier button — never a `scroll` event, which fires when the reader re-anchors
|
| 285 |
+
under a prepend and would otherwise file a position you never chose. **If you were at the end you
|
| 286 |
+
return to the new end**, not to the row that used to be last. And a **turn that cannot be reached**
|
| 287 |
+
degrades to the end rather than the top. Bounded to 100 sessions; never expires, because unlike a
|
| 288 |
+
draft it is not text you typed.
|
| 289 |
- **Share** moves here from the sidebar. One session, one place.
|
| 290 |
- **Handover** ("continue from this trace in a new agent") lives in the conversation footer, beside
|
| 291 |
the provenance line — the only place where its meaning is obvious.
|
web/src/components/conversation/ConversationView.tsx
CHANGED
|
@@ -18,6 +18,7 @@ import * as api from '../../api';
|
|
| 18 |
import type { TraceTurn } from '../../api';
|
| 19 |
import { useTraceWindows, type TraceSource } from '../../lib/traceWindows';
|
| 20 |
import type { Session } from '../../types';
|
|
|
|
| 21 |
import { useDraft } from './useDraft';
|
| 22 |
import { fmtTok, splitExchanges } from './exchanges';
|
| 23 |
import ExchangeView from './Exchange';
|
|
@@ -176,7 +177,7 @@ export default function ConversationView({ session, paused, isMobile, readOnly,
|
|
| 176 |
: tops.find((t) => t > cur + 8);
|
| 177 |
if (next != null) el.scrollTo({ top: Math.max(0, next - 4), behavior: 'smooth' });
|
| 178 |
};
|
| 179 |
-
const nav = (dir: -1 | 1) => (q && hits ? stepHit(dir) : goTurn(dir)
|
| 180 |
|
| 181 |
// Land on the end of the conversation, and stay there until the reader
|
| 182 |
// decides otherwise. This is not only for a working agent: switching a pane
|
|
@@ -218,6 +219,120 @@ export default function ConversationView({ session, paused, isMobile, readOnly,
|
|
| 218 |
anchor.current = null;
|
| 219 |
}, [version, live, sent]);
|
| 220 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
if (!head) return <div className="cxv-empty mono">{error || 'reading the trace…'}</div>;
|
| 222 |
|
| 223 |
return (
|
|
@@ -246,12 +361,17 @@ export default function ConversationView({ session, paused, isMobile, readOnly,
|
|
| 246 |
</div>
|
| 247 |
|
| 248 |
<div className="cxv-body" ref={scroller}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
onScroll={(e) => {
|
| 250 |
const el = e.currentTarget;
|
| 251 |
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
|
| 252 |
// Reading back into the conversation: fetch the stretch in front of
|
| 253 |
// what we hold before the reader arrives at it.
|
| 254 |
if (el.scrollTop < NEAR_TOP_PX) loadOlder();
|
|
|
|
| 255 |
}}>
|
| 256 |
<div className="cxv-col">
|
| 257 |
{error && <div className="cxv-msg bad mono">{error} · showing the last read</div>}
|
|
@@ -259,7 +379,7 @@ export default function ConversationView({ session, paused, isMobile, readOnly,
|
|
| 259 |
type="button"
|
| 260 |
className="cxv-msg mono cxv-top"
|
| 261 |
disabled={atStart || blocked}
|
| 262 |
-
onClick={() => loadOlder()}
|
| 263 |
title={atStart || blocked ? undefined : 'Load the previous stretch of the conversation'}
|
| 264 |
>
|
| 265 |
{blocked
|
|
|
|
| 18 |
import type { TraceTurn } from '../../api';
|
| 19 |
import { useTraceWindows, type TraceSource } from '../../lib/traceWindows';
|
| 20 |
import type { Session } from '../../types';
|
| 21 |
+
import { recallReading, rememberReading } from './readingPosition';
|
| 22 |
import { useDraft } from './useDraft';
|
| 23 |
import { fmtTok, splitExchanges } from './exchanges';
|
| 24 |
import ExchangeView from './Exchange';
|
|
|
|
| 177 |
: tops.find((t) => t > cur + 8);
|
| 178 |
if (next != null) el.scrollTo({ top: Math.max(0, next - 4), behavior: 'smooth' });
|
| 179 |
};
|
| 180 |
+
const nav = (dir: -1 | 1) => { moved(); return q && hits ? stepHit(dir) : goTurn(dir); };
|
| 181 |
|
| 182 |
// Land on the end of the conversation, and stay there until the reader
|
| 183 |
// decides otherwise. This is not only for a working agent: switching a pane
|
|
|
|
| 219 |
anchor.current = null;
|
| 220 |
}, [version, live, sent]);
|
| 221 |
|
| 222 |
+
// ---- where you had got to -----------------------------------------------
|
| 223 |
+
// Opening on the end is right for a conversation you have not read; it is not
|
| 224 |
+
// right for one you were half-way up. See readingPosition.ts for why the anchor
|
| 225 |
+
// is a turn timestamp and not a key, an index or a pixel.
|
| 226 |
+
|
| 227 |
+
/** Distance from the reading area's top edge to a row, in scroll coordinates. */
|
| 228 |
+
const rowTop = (el: HTMLElement, node: HTMLElement) =>
|
| 229 |
+
node.getBoundingClientRect().top - (el.getBoundingClientRect().top - el.scrollTop);
|
| 230 |
+
|
| 231 |
+
// Nothing is remembered until you have actually moved the view. `stick` starts
|
| 232 |
+
// true as an ASSUMPTION (follow the work), not an observation, so recording on
|
| 233 |
+
// it would file "you were at the end" for every session you merely glanced at.
|
| 234 |
+
// And the evidence is a GESTURE, not a `scroll` event: a scroll fires for
|
| 235 |
+
// reasons that are not you — the reader re-anchoring as older windows arrive,
|
| 236 |
+
// above all — and gating on that still filed phantom positions.
|
| 237 |
+
const touched = useRef(false);
|
| 238 |
+
const moved = () => { touched.current = true; };
|
| 239 |
+
|
| 240 |
+
const shownRef = useRef(shown);
|
| 241 |
+
shownRef.current = shown;
|
| 242 |
+
|
| 243 |
+
const capture = useCallback(() => {
|
| 244 |
+
const el = scroller.current;
|
| 245 |
+
if (!el || !touched.current) return;
|
| 246 |
+
if (stick.current) { rememberReading(session.id, { ts: 0, off: 0, end: true }); return; }
|
| 247 |
+
const list = shownRef.current;
|
| 248 |
+
const hit = [...rows.current.entries()]
|
| 249 |
+
.sort((a, b) => a[0] - b[0])
|
| 250 |
+
// The first row still showing at the top edge is the turn you are reading —
|
| 251 |
+
// skipping any whose prompt is in a window we have not read yet, because a
|
| 252 |
+
// fragment has no timestamp of its own to come back to.
|
| 253 |
+
.find(([i, node]) => rowTop(el, node) + node.offsetHeight > el.scrollTop + 4
|
| 254 |
+
&& (list[i]?.startTs ?? 0) > 0);
|
| 255 |
+
if (!hit) return;
|
| 256 |
+
const [i, node] = hit;
|
| 257 |
+
rememberReading(session.id, {
|
| 258 |
+
ts: list[i].startTs, off: el.scrollTop - rowTop(el, node), end: false,
|
| 259 |
+
});
|
| 260 |
+
}, [session.id]);
|
| 261 |
+
|
| 262 |
+
// Scroll fires in bursts; one write per quiet moment is plenty.
|
| 263 |
+
const settle = useRef<number>(0);
|
| 264 |
+
const onScrolled = () => {
|
| 265 |
+
window.clearTimeout(settle.current);
|
| 266 |
+
settle.current = window.setTimeout(capture, 150);
|
| 267 |
+
};
|
| 268 |
+
// Flush on the way out. Through a ref, because an effect that depended on
|
| 269 |
+
// `capture` would run its cleanup on every dependency change, and this has to
|
| 270 |
+
// mean unmount rather than "something moved".
|
| 271 |
+
const flush = useRef(capture);
|
| 272 |
+
flush.current = capture;
|
| 273 |
+
useEffect(() => () => { window.clearTimeout(settle.current); flush.current(); }, []);
|
| 274 |
+
|
| 275 |
+
/**
|
| 276 |
+
* Looking for the remembered turn, paging backwards until it shows up. Bounded:
|
| 277 |
+
* the reader holds one window and the turn may be several behind it, but a
|
| 278 |
+
* remembered position is not worth walking a 19 MB transcript for, so past this
|
| 279 |
+
* we give up and stay on the end — which is where the reader would have been
|
| 280 |
+
* anyway.
|
| 281 |
+
*/
|
| 282 |
+
const MAX_HOPS = 6;
|
| 283 |
+
const seeking = useRef<{ ts: number; off: number; hops: number } | null>(null);
|
| 284 |
+
|
| 285 |
+
/** One attempt to land on the remembered turn, or one window back towards it. */
|
| 286 |
+
const trySeek = useCallback(() => {
|
| 287 |
+
const st = seeking.current;
|
| 288 |
+
const el = scroller.current;
|
| 289 |
+
if (!st || !el) return;
|
| 290 |
+
const list = shownRef.current;
|
| 291 |
+
const idx = list.findIndex((x) => x.startTs === st.ts);
|
| 292 |
+
const node = idx >= 0 ? rows.current.get(idx) : undefined;
|
| 293 |
+
if (node) {
|
| 294 |
+
el.scrollTop = Math.max(0, rowTop(el, node) + st.off);
|
| 295 |
+
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
|
| 296 |
+
seeking.current = null;
|
| 297 |
+
return;
|
| 298 |
+
}
|
| 299 |
+
// Not in what we hold. Either walk back a window, or stop pretending: the end
|
| 300 |
+
// is where the reader would have been anyway.
|
| 301 |
+
if (atStart || blocked || st.hops >= MAX_HOPS) {
|
| 302 |
+
seeking.current = null;
|
| 303 |
+
stick.current = true;
|
| 304 |
+
el.scrollTop = el.scrollHeight;
|
| 305 |
+
return;
|
| 306 |
+
}
|
| 307 |
+
st.hops += 1;
|
| 308 |
+
loadOlder();
|
| 309 |
+
}, [atStart, blocked, loadOlder]);
|
| 310 |
+
|
| 311 |
+
const wantRestore = useRef(true);
|
| 312 |
+
// Restore on mount, and again on coming back into view: a pane can stay mounted
|
| 313 |
+
// while its tile is hidden, so a browser that drops the scroll offset of a
|
| 314 |
+
// `display: none` scroller would otherwise leave no re-mount to hook.
|
| 315 |
+
useEffect(() => { if (!paused) wantRestore.current = true; }, [paused]);
|
| 316 |
+
useEffect(() => {
|
| 317 |
+
if (!wantRestore.current || !head) return;
|
| 318 |
+
wantRestore.current = false;
|
| 319 |
+
const want = recallReading(session.id);
|
| 320 |
+
// No memory, or you were at the end: leave the landing to whoever owns it,
|
| 321 |
+
// which is the open-on-the-end rule above.
|
| 322 |
+
if (!want || want.end || !want.ts) return;
|
| 323 |
+
seeking.current = { ts: want.ts, off: want.off, hops: 0 };
|
| 324 |
+
stick.current = false; // do not follow the end while we look for the place
|
| 325 |
+
// Start here rather than waiting for the effect below. Passive effects run
|
| 326 |
+
// AFTER layout effects, so arming the seek on this commit and leaving the
|
| 327 |
+
// landing to a layout effect meant nothing tried until the next render — and
|
| 328 |
+
// a quiet trace does not have one.
|
| 329 |
+
trySeek();
|
| 330 |
+
}, [head, paused, session.id, trySeek]);
|
| 331 |
+
|
| 332 |
+
// Every window that arrives is another chance to land. After the hook's own
|
| 333 |
+
// anchor effect, so this has the last word on scrollTop.
|
| 334 |
+
useLayoutEffect(() => { trySeek(); }, [version, trySeek]);
|
| 335 |
+
|
| 336 |
if (!head) return <div className="cxv-empty mono">{error || 'reading the trace…'}</div>;
|
| 337 |
|
| 338 |
return (
|
|
|
|
| 361 |
</div>
|
| 362 |
|
| 363 |
<div className="cxv-body" ref={scroller}
|
| 364 |
+
onWheel={moved}
|
| 365 |
+
onTouchStart={moved}
|
| 366 |
+
onPointerDown={moved}
|
| 367 |
+
onKeyDown={moved}
|
| 368 |
onScroll={(e) => {
|
| 369 |
const el = e.currentTarget;
|
| 370 |
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
|
| 371 |
// Reading back into the conversation: fetch the stretch in front of
|
| 372 |
// what we hold before the reader arrives at it.
|
| 373 |
if (el.scrollTop < NEAR_TOP_PX) loadOlder();
|
| 374 |
+
onScrolled();
|
| 375 |
}}>
|
| 376 |
<div className="cxv-col">
|
| 377 |
{error && <div className="cxv-msg bad mono">{error} · showing the last read</div>}
|
|
|
|
| 379 |
type="button"
|
| 380 |
className="cxv-msg mono cxv-top"
|
| 381 |
disabled={atStart || blocked}
|
| 382 |
+
onClick={() => { moved(); loadOlder(); }}
|
| 383 |
title={atStart || blocked ? undefined : 'Load the previous stretch of the conversation'}
|
| 384 |
>
|
| 385 |
{blocked
|
web/src/components/conversation/readingPosition.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Where you had got to in a conversation.
|
| 2 |
+
//
|
| 3 |
+
// The reader keeps no memory of this, so every cold mount opens on the end of the
|
| 4 |
+
// trace (§3.3, and #55) — better than the top it used to open on, but still not
|
| 5 |
+
// the place you had scrolled to. In-app navigation happens to survive, because
|
| 6 |
+
// the pane stays mounted and the browser preserves the scroll box across the
|
| 7 |
+
// `display: none` that hides a warm tile; that is why this looks fine on a
|
| 8 |
+
// desktop and not on a phone, where coming back is a reload, an evicted tab, or
|
| 9 |
+
// the Hub rebuilding the Space's iframe. Same layer as drafts.ts, same reason.
|
| 10 |
+
//
|
| 11 |
+
// The anchor is a **turn timestamp**, and that choice is forced by how the reader
|
| 12 |
+
// reads. It holds a byte window of the transcript and pages backwards, and when
|
| 13 |
+
// an older window arrives the exchanges REGROUP: an exchange at the top of the
|
| 14 |
+
// list is a fragment whose prompt was in the window not yet read, and the two
|
| 15 |
+
// become one exchange when it lands. So neither the React key, nor an index into
|
| 16 |
+
// the list, nor a pixel offset identifies a place for longer than one fetch. A
|
| 17 |
+
// turn's `ts` comes from the transcript and never moves. `off` then only carries
|
| 18 |
+
// where inside that turn you were.
|
| 19 |
+
//
|
| 20 |
+
// `end` is the case that matters most: if you were reading the tail and the agent
|
| 21 |
+
// added six turns while you were away, you want the new bottom, not the row that
|
| 22 |
+
// used to be at the bottom.
|
| 23 |
+
|
| 24 |
+
const KEY = 'am.reading';
|
| 25 |
+
const VERSION = 2;
|
| 26 |
+
|
| 27 |
+
/**
|
| 28 |
+
* How many sessions' positions to keep. Each entry is a few dozen bytes, so this
|
| 29 |
+
* is about not growing without bound rather than about the storage budget.
|
| 30 |
+
* Unlike a draft this never expires: a reading position is not text you typed, so
|
| 31 |
+
* it carries nothing worth forgetting for its own sake, and it stays useful for
|
| 32 |
+
* exactly as long as the conversation does.
|
| 33 |
+
*/
|
| 34 |
+
const MAX_ENTRIES = 100;
|
| 35 |
+
|
| 36 |
+
export interface Reading {
|
| 37 |
+
/** `ts` of the turn that opened the exchange at the top of the reading area. */
|
| 38 |
+
ts: number;
|
| 39 |
+
/** How far below the reading area's top edge that exchange started, in px. */
|
| 40 |
+
off: number;
|
| 41 |
+
/** You were at the end of the conversation — follow the end, not the turn. */
|
| 42 |
+
end: boolean;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
interface Entry { t: number; o: number; e: 0 | 1; at: number }
|
| 46 |
+
type Store = Record<string, Entry>;
|
| 47 |
+
|
| 48 |
+
const mem = new Map<string, Reading>();
|
| 49 |
+
|
| 50 |
+
// Same reason as drafts.ts: `at` orders the set as well as dating it, and
|
| 51 |
+
// Date.now() cannot separate two writes in the same millisecond.
|
| 52 |
+
let stamped = 0;
|
| 53 |
+
const stamp = () => {
|
| 54 |
+
stamped = Math.max(Date.now(), stamped + 1);
|
| 55 |
+
return stamped;
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
function load(): Store {
|
| 59 |
+
try {
|
| 60 |
+
const raw = localStorage.getItem(KEY);
|
| 61 |
+
if (!raw) return {};
|
| 62 |
+
const parsed = JSON.parse(raw) as { v?: number; d?: Store };
|
| 63 |
+
// A v1 blob anchored on an absolute turn number, which the windowed reader
|
| 64 |
+
// cannot resolve. Not upgradeable; dropped, and the next write replaces it.
|
| 65 |
+
if (parsed?.v !== VERSION || !parsed.d || typeof parsed.d !== 'object') return {};
|
| 66 |
+
const out: Store = {};
|
| 67 |
+
for (const [id, e] of Object.entries(parsed.d)) {
|
| 68 |
+
if (e && typeof e.t === 'number' && typeof e.o === 'number' && typeof e.at === 'number') out[id] = e;
|
| 69 |
+
}
|
| 70 |
+
return out;
|
| 71 |
+
} catch {
|
| 72 |
+
// Denied (private mode, a third-party iframe under tracking prevention) or
|
| 73 |
+
// nonsense under our key. No remembered positions, which is where we were.
|
| 74 |
+
return {};
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function save(store: Store) {
|
| 79 |
+
// Newest first; the tail past MAX_ENTRIES falls off, so the session you are
|
| 80 |
+
// reading right now is the last thing to go.
|
| 81 |
+
const byNewest = Object.entries(store).sort((a, b) => b[1].at - a[1].at);
|
| 82 |
+
let out: Store = Object.fromEntries(byNewest.slice(0, MAX_ENTRIES));
|
| 83 |
+
// A scroll position is never worth an exception on the way past. If the quota
|
| 84 |
+
// is full — of other things, mostly — shed the oldest and try again, then give
|
| 85 |
+
// up in silence.
|
| 86 |
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
| 87 |
+
try {
|
| 88 |
+
localStorage.setItem(KEY, JSON.stringify({ v: VERSION, d: out }));
|
| 89 |
+
return;
|
| 90 |
+
} catch {
|
| 91 |
+
const oldest = Object.keys(out).sort((a, b) => out[a].at - out[b].at)[0];
|
| 92 |
+
if (!oldest) {
|
| 93 |
+
try { localStorage.removeItem(KEY); } catch { /* nothing left to try */ }
|
| 94 |
+
return;
|
| 95 |
+
}
|
| 96 |
+
const next = { ...out };
|
| 97 |
+
delete next[oldest];
|
| 98 |
+
out = next;
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/** Where this session was last being read, or null if we have never seen it. */
|
| 104 |
+
export function recallReading(id: string): Reading | null {
|
| 105 |
+
const held = mem.get(id);
|
| 106 |
+
if (held) return held;
|
| 107 |
+
const e = load()[id];
|
| 108 |
+
if (!e) return null;
|
| 109 |
+
const r: Reading = { ts: e.t, off: e.o, end: !!e.e };
|
| 110 |
+
mem.set(id, r);
|
| 111 |
+
return r;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
export function rememberReading(id: string, r: Reading | null) {
|
| 115 |
+
if (!r) {
|
| 116 |
+
mem.delete(id);
|
| 117 |
+
const store = load();
|
| 118 |
+
delete store[id];
|
| 119 |
+
save(store);
|
| 120 |
+
return;
|
| 121 |
+
}
|
| 122 |
+
mem.set(id, r);
|
| 123 |
+
const store = load();
|
| 124 |
+
store[id] = { t: Math.round(r.ts), o: Math.round(r.off), e: r.end ? 1 : 0, at: stamp() };
|
| 125 |
+
save(store);
|
| 126 |
+
}
|