Skip to content

Mobile: keep the reply you were half-way through typing - #58

Merged
thomwolf merged 1 commit into
mainfrom
mobile/persist-draft
Aug 12, 2026
Merged

Mobile: keep the reply you were half-way through typing#58
thomwolf merged 1 commit into
mainfrom
mobile/persist-draft

Conversation

@thomwolf

Copy link
Copy Markdown
Member

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:

leaving by unmodified main
tapping back to the session list draft survives
a full page reload draft lost
the tab being evicted while backgrounded draft lost

The pane does not unmount on an in-app trip to the list — App.tsx keeps up
to twelve terminal panes warm (WARM_TERMINAL_LIMIT), reader and composer
included — 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.ts is the storage policy and takes no React dependency, so its rules are
unit-testable without a browser. useDraft.ts is the binding — a drop-in for the
useState('') that both composers already had.

  • One draft per agent, keyed by session id, shared by the Overview card and
    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 Composer extraction), so text started in one is handed back by the
    other. There is no global draft that could follow you between agents.
  • Restoring only fills the box. No focus, no cursor move, no send. Verified
    by asserting document.activeElement and that nothing is ever POSTed.
  • Sending clears it through the setDraft('') the send already did, so
    nothing new has to remember to.
  • Bounded on three axes, because a composer that throws on a keystroke is far
    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.
  • IME composition pauses writes between compositionstart and
    compositionend. The pre-composition snapshot is a string the user meant; a
    mid-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 main first as a control. The control fails 7 and reproduces the
report exactly, so the checks demonstrably detect something:

main this PR
a full page reload keeps the draft FAIL PASS
a tab discard keeps the draft FAIL PASS
each agent keeps its own draft FAIL PASS
an IME composition is not persisted half-finished (4 checks) FAIL PASS
an in-app trip to the list keeps the draft PASS PASS
nothing is ever POSTed / no turn is gained / focus is not stolen PASS PASS

Plus web/test/drafts.test.mjs (11 checks, wired into npm test) for the things
a 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:

  1. The composition gate silently did nothing. It bound its listeners to
    inputRef.current in an effect — but the reader renders reading 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 compositionstartinput
    compositionend sequence.
  2. Eviction shed an arbitrary draft, not the oldest. at orders the set as
    well as dating it, and Date.now() cannot separate two drafts saved in the
    same 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) — touches App.tsx only, so there is no
overlap. 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-ref is the agent you were in).

#49 (design/reader-followups) — I read it, and it does move the component I
add state to: it extracts one Composer used by both surfaces. It does not
conflict, because the draft state stays in ConversationView/Card either way
and Composer is presentational — it takes onChange and inputRef, which is
exactly what useDraft hands it. Merged locally: Overview.tsx and
ConversationView.tsx both auto-merge, the only conflict is in
conversation.css, a file I do not touch (it is #49's own conflict with main,
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 main today, where the clear waits for the round trip.

Not covered, and why

  • A real iOS device. The discard is simulated the only way it can be driven
    headless: pagehide with persisted: false, then a fresh document in the same
    browsing 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.
  • The send POST is stubbed in the browser harness. A real POST to a stopped
    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.
  • Two composers for the same agent mounted at once (a card and a pane) keep
    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.
  • Drafts of deleted sessions are not swept on deletion; the 24-hour window and
    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

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
thomwolf force-pushed the mobile/persist-draft branch from a32b851 to 6cec9e1 Compare August 12, 2026 16:14
@thomwolf
thomwolf merged commit ecbf2fa into main Aug 12, 2026
@thomwolf
thomwolf deleted the mobile/persist-draft branch August 12, 2026 17:27
@thomwolf
thomwolf restored the mobile/persist-draft branch August 12, 2026 17:28
@thomwolf
thomwolf deleted the mobile/persist-draft branch August 12, 2026 17:31
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>
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