Skip to content

Reader: come back to where you were reading - #59

Merged
thomwolf merged 1 commit into
mainfrom
reader/remember-reading-position
Aug 12, 2026
Merged

Reader: come back to where you were reading#59
thomwolf merged 1 commit into
mainfrom
reader/remember-reading-position

Conversation

@thomwolf

@thomwolf thomwolf commented Aug 12, 2026

Copy link
Copy Markdown
Member

Reported on a phone against #58: coming back to a session did not put the reader
where you had left it.

Rewritten on top of #55, which landed while this was open and changed the
premise. The original version of this PR anchored on page.offset + exchange.at
against the old read-the-last-400-turns reader; #55 replaced that with byte
windows that page backwards, so that anchor could not survive and the PR was
rebuilt rather than rebased.

What main does now, measured

coming back after a discard or reload lands on
before #55 turn 1 — the top, which is what was reported
main today (#55) turn 59/60 — the end
with this PR turn 26 — where you had scrolled to

So #55 already fixed the reported symptom. What is left is the actual promise:
opening on the end is right for a conversation you have not read, and wrong for
one you were half-way up. In-app navigation already survived either way — the pane
stays mounted and the browser preserves the scroll box across the display: none
that hides a warm tile — so this is entirely about the cold mount: a reload, an
evicted tab, the Hub rebuilding the Space's iframe.

Why the anchor is a turn timestamp

The windowed reader forces it. Exchanges regroup as older windows arrive: the
one at the top of the list is a fragment whose prompt was in the window not yet
read, and the two become one exchange when it lands. So no React key, no list
index and no pixel offset identifies a place for longer than one fetch. A turn's
ts comes from the transcript and never moves; off then only carries where
inside that turn you were.

If the remembered turn is not in the window the reader opened with, it pages
backwards to find it
— through the same public loadOlder() a scroll would use.
Nothing in lib/traceWindows.ts changed, so #55's cursors, its one-request-at-a-
time guard and its live tailing are all untouched. Bounded to six windows: a
remembered position is not worth walking a 19 MB transcript for, and past that it
stays on the end, which is where the reader would have been anyway.

Three rules that decide whether it feels right

  • Nothing is remembered until you have moved the view, and the evidence is a
    gesture
    — wheel, touch, pointer, keys, the turn nav, the load-earlier button —
    never a scroll event. A scroll fires when the reader re-anchors under a
    prepend, and gating on it filed positions the reader never chose. This is also
    what keeps Trace reader: open on the last exchange, page backwards by byte window #55's open-on-the-end landing intact for a session you merely glanced
    at.
  • If you were at the end, you return to the new end — not to the row that used
    to be last. Six turns may have arrived while you were away, and they are what you
    came back for.
  • A turn that cannot be reached degrades to the end, not to the top.

Bounded to 100 sessions, oldest first. No expiry, deliberately: unlike a draft
this is not text you typed, so it carries nothing worth forgetting for its own
sake. A v1 blob from the earlier anchor scheme is dropped rather than misread.
Quota failures and denied storage both degrade in silence.

Verified by running it

A local server at a 390×844 phone viewport, with unmodified main as a
control
. 20 checks; the control fails exactly the seven that matter:

                                                     main    this PR
the reader is where I left it (switching agents)      PASS     PASS
still where I left it after a trip to the list        PASS     PASS
a discard comes back to the same place / same turn    FAIL     PASS
a reload comes back to the same place / same turn     FAIL     PASS
it is delta's position, not everyone's                FAIL     PASS
a position in a window we did NOT open with:
  it paged back and found the turn                    FAIL     PASS
  and it is not just sitting on the end               FAIL     PASS
never scrolled -> still lands on the end, stores none PASS     PASS
was at the end -> come back to the NEW end            PASS     PASS
an unreachable turn degrades to the end               skip     PASS
a corrupt store does not break the reader             PASS     PASS

The multi-window check uses a 580 KB fixture — several of the reader's 384 KB
windows — because that is the only way to exercise "the turn you were on is not in
the window we opened with". The "worked while I was away" check appends real turns
to the transcript the dev server is reading rather than mocking a response.

Also re-ran #58's 25 draft checks and the joint journey on this build (no
regression), plus tsc --noEmit, npm test and a production build.

Two bugs found by running rather than reading, both worth recording:

  1. The landing never fired on a quiet trace. It was armed in a passive effect
    and applied in a layout effect — and layout effects run first, so nothing
    tried until the next render, which a quiet trace does not have. It now attempts
    the seek the moment it is armed, and again on every window that arrives.
  2. Gating on scroll was not enough (see above). Found on the deployed Space
    and not locally, where the pane is 41px taller and had less slack to re-anchor
    into. The harness now scrolls with mouse.wheel rather than by assigning
    scrollTop, which is a truer test of the same path.

Not covered

  • A real iOS device. The discard is simulated as pagehide{persisted:false} plus
    a fresh document in the same browsing context, which is what the browser does on
    revisiting a discarded tab.
  • readingPosition.ts repeats the monotonic stamp and quota-safe write from
    drafts.ts. A conscious choice: the policies differ (drafts expire and are
    size-capped, a position is count-bounded and permanent), and a shared abstraction
    wanted four parameters to say so. Worth unifying if a third case appears.

🤖 Generated with Claude Code

@thomwolf
thomwolf deleted the branch main August 12, 2026 17:27
@thomwolf thomwolf closed this Aug 12, 2026
@thomwolf thomwolf reopened this Aug 12, 2026
@thomwolf
thomwolf changed the base branch from mobile/persist-draft to main August 12, 2026 17:28
Reported on a phone against #58: coming back to a session did not put the reader
where it was left. #55 has since changed what "wrong place" means — it opens on
the END now rather than the top — but it still is not the place you had scrolled
to, which is what this adds.

The anchor is a turn timestamp, and the windowed reader forces that choice.
Exchanges regroup as older windows arrive: the one at the top of the list is a
fragment whose prompt was in the window not yet read, and the two become one when
it lands. So no React key, list index or pixel offset identifies a place for
longer than one fetch. A turn's `ts` comes from the transcript and never moves.

If the remembered turn is not in the window the reader opened with, it pages
backwards to find it — through the same public `loadOlder()` a scroll would use,
so nothing in lib/traceWindows.ts had to change, and live tailing is untouched.
Bounded to six windows; past that it stays on the end.

Nothing is remembered until you have moved the view, and the evidence is a
gesture, never a `scroll` event — a scroll fires when the reader re-anchors under
a prepend, and gating on it filed positions the reader never chose. If you were at
the end you return to the new end. A turn that cannot be reached degrades to the
end. Bounded to 100 sessions, no expiry, quota failures swallowed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thomwolf
thomwolf force-pushed the reader/remember-reading-position branch from cbb00de to f44b25c Compare August 12, 2026 17:50
@thomwolf
thomwolf merged commit 9162969 into main Aug 12, 2026
@thomwolf
thomwolf deleted the reader/remember-reading-position branch August 12, 2026 17:50
thomwolf added a commit that referenced this pull request Aug 13, 2026
The bottom bar puts the terminal/reader switch next to the zoom because they are
the same kind of setting — how you are looking at everything. But the zoom only
moved the terminal: `conversation.css` was written in absolute px, so a reader
pane ignored it entirely, and the mode switch quietly changed how big your
session was.

So the grammar gets ONE base size — `--cx-base`, 13px by default — and every
type size in it becomes an em of that. The terminal spends the zoom on its font
size, the reader on `--cx-base` (set on `.pane-reader`), and 13px at 100% either
way means switching modes no longer resizes the conversation.

The reader scales as one surface: bar, turns, tool output, reply line, footer.
A 10.5px toolbar left behind at 150% would be the part you could not read in the
first place — the remote pane already treats its log, composer and status line as
one size for the same reason.

Four things the px-to-em pass had to be careful about:

- **The Overview card shares this grammar and must not move.** That is why the
  base is a `var(--cx-base, 13px)` default rather than something inherited: the
  card resolves to exactly the sizes it had.
- **The answer's markdown needed its own heading sizes.** The shared `.markdown`
  rules are absolute px, so an answer's `h1` would have stayed 22px while the
  prose around it grew. Same proportions, in em, and this file loads after
  styles.css.
- **Spacing does not scale, but glyph columns must.** The prompt's `❯` gutter,
  the step rail's ▸/✓ column and the body inset under it, and the tool-field
  label column (was `4.5rem`) are all em now. At 200% the chevron was running
  into the first word of the prompt.
- **`.cs-pre`'s scroll cap is really "about twenty lines".** In rem it showed ten
  at 200%; in em it stays nineteen at every zoom.

Two deliberate visible changes beyond that. The phone keyboard field keeps its
fixed 16px — that guard is what stops iOS zoom-on-focus, and the new rule is
later in the cascade, so styles.css's version is restated here. And `.cxv-top`
(#59's "earlier messages" line) says the size it means: `font: inherit` sheds the
button's UA font but also resets the size the element gets from `.cxv-msg`, so it
had been landing on the app's inherited 16px in a column of 11px messages.

Verified in Chromium against a fixture built from the real ConversationView /
Exchange / ToolCall DOM, comparing computed sizes with the pre-change stylesheet:
nothing moves at 100% except `.cxv-top`, every element in the reader scales
exactly 0.5x/2x at 50%/200%, the Overview card is untouched at all three, and
nothing overflows the pane sideways at 200%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lvwerra pushed a commit that referenced this pull request Aug 13, 2026
Rebased onto main after #59 and #61, which cover ground this branch also
touched. Theirs wins in both places: #61 made zoom ONE number for both ways of
reading a session (`--cx-base` on `.pane-reader`), so the branch's second path
— a `zoom` prop and `zoom: var(--cx-zoom)` on `.cxv-body` — is gone rather than
reintroduced; and #59 comes back to where you had got to, over a tail window
that already opens on the end, so the branch's own open-on-the-newest-turn
landing (reader and trace viewer both) is gone too. TracePane keeps main's
version entirely.

What is left is what main does not have:

- **The prompt band spans the pane.** Reaching into the left gutter but stopping
  at the text column on the right made it read as a card floating over the
  answer rather than as the head of it. Full bleed both sides; the meta row sits
  tight under the band it belongs to, and one exchange ends well before the next
  begins.
- **Nothing of the terminal's may paint over the reader.** Its covers —
  `restoring last view…`, `starting claude…`, `stopped · output preserved` — sit
  at `z-index: 4` and were drawn straight over the conversation, so a reconnect
  turned the reader into a terminal screen with a reader toolbar on top. They
  are gated off while reading, and the overlay now outranks anything the
  terminal can raise.
- **One `Composer` for the card and the reader.** A composer accretes features —
  paste-to-attach, history recall, a slash-command menu — and duplicated markup
  is how one surface quietly gets them and the other does not. `onPasteFiles`
  and `above` are the seam an attachment strip plugs into. The echo is now
  optimistic in both: the prompt appears before the POST returns, and a failed
  send withdraws it and puts the text back in the box.
- **The reader switch is the same height as the zoom keys** (22px, like the
  layout picker beside it).

Verified against a running instance with a fixture transcript: the reader
renders, the band bleeds to the right edge, an injected `.term-boot` cover
paints behind the overlay, the bottom bar is one height, a sent prompt echoes
before the POST returns, and main's zoom still scales the reader through
`--cx-base` with no second path in play. Server, web and UI suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lvwerra added a commit that referenced this pull request Aug 13, 2026
Rebased onto main after #59 and #61, which cover ground this branch also
touched. Theirs wins in both places: #61 made zoom ONE number for both ways of
reading a session (`--cx-base` on `.pane-reader`), so the branch's second path
— a `zoom` prop and `zoom: var(--cx-zoom)` on `.cxv-body` — is gone rather than
reintroduced; and #59 comes back to where you had got to, over a tail window
that already opens on the end, so the branch's own open-on-the-newest-turn
landing (reader and trace viewer both) is gone too. TracePane keeps main's
version entirely.

What is left is what main does not have:

- **The prompt band spans the pane.** Reaching into the left gutter but stopping
  at the text column on the right made it read as a card floating over the
  answer rather than as the head of it. Full bleed both sides; the meta row sits
  tight under the band it belongs to, and one exchange ends well before the next
  begins.
- **Nothing of the terminal's may paint over the reader.** Its covers —
  `restoring last view…`, `starting claude…`, `stopped · output preserved` — sit
  at `z-index: 4` and were drawn straight over the conversation, so a reconnect
  turned the reader into a terminal screen with a reader toolbar on top. They
  are gated off while reading, and the overlay now outranks anything the
  terminal can raise.
- **One `Composer` for the card and the reader.** A composer accretes features —
  paste-to-attach, history recall, a slash-command menu — and duplicated markup
  is how one surface quietly gets them and the other does not. `onPasteFiles`
  and `above` are the seam an attachment strip plugs into. The echo is now
  optimistic in both: the prompt appears before the POST returns, and a failed
  send withdraws it and puts the text back in the box.
- **The reader switch is the same height as the zoom keys** (22px, like the
  layout picker beside it).

Verified against a running instance with a fixture transcript: the reader
renders, the band bleeds to the right edge, an injected `.term-boot` cover
paints behind the overlay, the bottom bar is one height, a sent prompt echoes
before the POST returns, and main's zoom still scales the reader through
`--cx-base` with no second path in play. Server, web and UI suites green.

Co-authored-by: Agent Manager <agents@agent-manager.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant