Thomas Wolf commited on
Commit
9162969
·
unverified ·
2 Parent(s): ecbf2faf44b25c

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
+ }