Mobile: keep the reply you were half-way through typing - #58
Merged
Conversation
Start an answer in an agent's reader on a phone, switch apps, come back — the text was gone. The pane was not what lost it: App.tsx keeps a dozen panes warm, so an in-app trip to the session list already survived. The document was. A phone evicts a backgrounded tab and the Hub rebuilds the Space's iframe on every visit, so coming back is a cold mount, which rules out in-memory state and the URL alike. One draft per agent, in localStorage, written through on every change (a tab being killed does not reliably run unload handlers). The card and the reader share it — same act, same session. Restoring only fills the box; sending clears it. Bounded at 32 KB a draft, 128 KB in total oldest-first, and 24 hours, after which it is deleted rather than hidden. Quota failures and denied storage both degrade in silence, because a composer that throws on a keystroke is worse than one that forgets. Writes pause for the duration of an IME composition. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
thomwolf
force-pushed
the
mobile/persist-draft
branch
from
August 12, 2026 16:14
a32b851 to
6cec9e1
Compare
thomwolf
added a commit
that referenced
this pull request
Aug 12, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Half-type a reply into an agent's reader on a phone, switch apps or lock the
screen, come back — the text is gone. #53 restores which agent you come back
to; this restores what you had written. Neither is much use alone, so I checked
them together (below).
Which layer was actually dropping it
I reproduced it before designing anything, and the obvious suspect is innocent:
mainThe pane does not unmount on an in-app trip to the list —
App.tsxkeeps upto twelve terminal panes warm (
WARM_TERMINAL_LIMIT), reader and composerincluded — so React state already covers navigation. What kills the draft is the
document going away: a reload, a phone evicting a backgrounded tab under
memory pressure, and the Hub rebuilding the Space's iframe on every visit. Coming
back is a cold mount, not a resume.
That rules out in-memory state on its own, and rules out the URL too (the Hub
owns the iframe's
src— same reason #53 went to storage). So:localStorage,written through on every change rather than on unload, because a phone
killing a backgrounded tab does not reliably run unload handlers.
The shape
drafts.tsis the storage policy and takes no React dependency, so its rules areunit-testable without a browser.
useDraft.tsis the binding — a drop-in for theuseState('')that both composers already had.reader mode. They are the same act on the same session (which is the premise of
Reader follow-ups: the prompt band, the bottom bar, one composer #49's
Composerextraction), so text started in one is handed back by theother. There is no global draft that could follow you between agents.
by asserting
document.activeElementand that nothing is ever POSTed.setDraft('')the send already did, sonothing new has to remember to.
worse than one that forgets: 32 KB per draft (past that it stays in memory only,
so a pane switch is still lossless), 128 KB in total with the oldest evicted
first, and 24 hours. Quota failures shed the oldest entry and retry; storage
denied outright (private mode, or a third-party iframe under cross-site tracking
prevention) degrades to today's behaviour. Both swallow.
compositionstartandcompositionend. The pre-composition snapshot is a string the user meant; amid-composition one is half a syllable.
On expiry, since it is a judgement call
24 hours, and expiring means deleted rather than hidden. A draft is text you
typed and never sent, sitting in the storage of whatever device you typed it on —
which on a phone is not always only yours. A day covers the case this exists for
(you left, you came back) without leaving last week's half-written answer
recoverable from the browser. The first version only filtered stale entries out
of what it offered, which is not expiry in any sense the person who typed it would
recognise; a read now sweeps them off the device, and the app reads on every mount.
What I verified by running it
A local server at a 390×844 phone viewport driven with playwright, 25 checks, and
unmodified
mainfirst as a control. The control fails 7 and reproduces thereport exactly, so the checks demonstrably detect something:
mainPlus
web/test/drafts.test.mjs(11 checks, wired intonpm test) for the thingsa browser test can only pass vacuously: the size cap, the total budget, a quota
error mid-write, a quota that cannot be satisfied at all, storage denied,
expiry-as-deletion, and a corrupt or foreign blob under our key.
Two real bugs came out of actually running it rather than reading it:
inputRef.currentin an effect — but the reader rendersreading the trace…on its first commit, so there was no textarea to bind and nothing re-ran the
effect when one appeared. It now listens on the document and asks "was that my
textarea?". Caught by driving a real
compositionstart→input→compositionendsequence.atorders the set aswell as dating it, and
Date.now()cannot separate two drafts saved in thesame millisecond. The stamp is monotonic now. Caught by the unit test.
tsc --noEmit,npm test(web), and a production build are all clean.Coordination with #49 and #53
#53 (
mobile/restore-last-session) — touchesApp.tsxonly, so there is nooverlap. Merged locally and ran the joint journey: background the tab, come back,
and you land on the agent, in reader mode, with the draft in the box (all four
checks pass, including that
am-active-refis the agent you were in).#49 (
design/reader-followups) — I read it, and it does move the component Iadd state to: it extracts one
Composerused by both surfaces. It does notconflict, because the draft state stays in
ConversationView/Cardeither wayand
Composeris presentational — it takesonChangeandinputRef, which isexactly what
useDrafthands it. Merged locally:Overview.tsxandConversationView.tsxboth auto-merge, the only conflict is inconversation.css, a file I do not touch (it is #49's own conflict withmain,from being stacked on #37). Typecheck, unit tests and all 25 browser checks pass
on that combination too.
Order does not matter, so land whichever is ready. #49's optimistic send
composes for free: it clears the draft the instant you send and puts it back in
the box — and back in storage — if the send fails. That is strictly better than
what this PR gets on
maintoday, where the clear waits for the round trip.Not covered, and why
headless:
pagehidewithpersisted: false, then a fresh document in the samebrowsing context — which is exactly what the browser does when you revisit a
discarded tab, and what the Hub does when it rebuilds the iframe. I have not
confirmed it against Safari's real eviction, nor storage inside the Hub's
cross-origin iframe — the same open question Mobile: come back to the agent you were in, not the list #53 flags. If storage turns out to
be denied there, this degrades quietly to today's behaviour.
agent boots the CLI and hands it the prompt — a token call and a stray process
for a test about a textarea. The real path is unchanged by this PR; what the
test asserts is that the client clears the draft on a 200.
separate React state, so the second to change wins the write. They cannot clobber
each other with an empty box (writes only happen on change), and Reader follow-ups: the prompt band, the bottom bar, one composer #49 collapsing
them to one component makes this smaller still. Not worth a subscription today.
the total budget are what bound them. A deleted session's id is never asked for
again, so nothing surfaces under the wrong agent.
🤖 Generated with Claude Code