thomwolf HF Staff Claude Opus 5 (1M context) commited on
Commit
6cec9e1
·
1 Parent(s): c6c7186

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

Browse files

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>

docs/conversation-view.md CHANGED
@@ -244,6 +244,25 @@ pane with nothing to render (a shell) simply stays a terminal.
244
  same optimistic echo: your prompt appears at the bottom with a `working` line until the
245
  transcript catches up. Only a trace with **no agent behind it** — a shared file, an import — is
246
  read-only, which is what `readOnly` is for.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  - **Share** moves here from the sidebar. One session, one place.
248
  - **Handover** ("continue from this trace in a new agent") lives in the conversation footer, beside
249
  the provenance line — the only place where its meaning is obvious.
 
244
  same optimistic echo: your prompt appears at the bottom with a `working` line until the
245
  transcript catches up. Only a trace with **no agent behind it** — a shared file, an import — is
246
  read-only, which is what `readOnly` is for.
247
+ - **A half-typed reply is kept** (`drafts.ts`, `useDraft.ts`). Reported from a phone: start typing
248
+ an answer, switch apps, come back, and the text is gone. The pane is not what loses it — App.tsx
249
+ keeps a dozen panes warm, so an in-app trip to the session list already survived — the
250
+ **document** is. A phone evicts a backgrounded tab, and the Hub rebuilds the Space's iframe on
251
+ every visit, so coming back is a cold mount. That rules out both in-memory state and the URL
252
+ (the Hub owns the iframe's `src`): it has to be storage, written through on every change, because
253
+ a tab being killed does not reliably run unload handlers.
254
+
255
+ One draft per **agent**, shared by the card and the reader, because they are the same act on the
256
+ same session. Restoring only fills the box — never focus, never send. Sending clears it, via the
257
+ `setDraft('')` the send already did. It is bounded on three axes, because a composer that throws
258
+ on a keystroke is far worse than one that forgets: 32 KB per draft (past that it stays in memory
259
+ only), 128 KB in total with the oldest evicted first, and **24 hours**, after which it is deleted
260
+ rather than merely hidden — an unsent draft is text you typed on a device that may not be only
261
+ yours. Quota failures and storage being denied outright both degrade in silence.
262
+
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.
web/package.json CHANGED
@@ -13,7 +13,7 @@
13
  "dev": "vite",
14
  "build": "tsc --noEmit && vite build",
15
  "typecheck": "tsc --noEmit",
16
- "test": "node test/exchanges.test.mjs && node test/sessionTitle.test.mjs && node test/overviewSort.test.mjs",
17
  "preview": "vite preview"
18
  },
19
  "dependencies": {
 
13
  "dev": "vite",
14
  "build": "tsc --noEmit && vite build",
15
  "typecheck": "tsc --noEmit",
16
+ "test": "node test/exchanges.test.mjs && node test/sessionTitle.test.mjs && node test/overviewSort.test.mjs && node test/drafts.test.mjs",
17
  "preview": "vite preview"
18
  },
19
  "dependencies": {
web/src/components/Overview.tsx CHANGED
@@ -10,6 +10,7 @@ import type { Rankable } from '../lib/overviewSort';
10
  import Logo from './Logo';
11
  import { SendGlyph } from './icons';
12
  import ExchangeView from './conversation/Exchange';
 
13
  import { writePaneMode } from '../lib/paneMode';
14
  import { splitExchanges } from './conversation/exchanges';
15
 
@@ -130,7 +131,11 @@ export function Card({ s, color, group, pending, isMobile, onOpen, onClose }: {
130
  onClose?: () => void; // present when the card lives in the conversation window
131
  }) {
132
  const d = s.digest;
133
- const [draft, setDraft] = useState('');
 
 
 
 
134
  const [sending, setSending] = useState(false);
135
  const [failed, setFailed] = useState(false);
136
  // Optimistic echo: the sent text becomes the prompt line the moment the
@@ -140,7 +145,6 @@ export function Card({ s, color, group, pending, isMobile, onOpen, onClose }: {
140
  const [histIdx, setHistIdx] = useState(0); // digest fallback only: n-th answer back
141
  const [back, setBack] = useState(0); // how many earlier turns are shown
142
  const [openWork, setOpenWork] = useState(false);
143
- const inputRef = useRef<HTMLTextAreaElement>(null);
144
  const bodyRef = useRef<HTMLDivElement>(null);
145
  const latestRef = useRef<HTMLDivElement>(null);
146
  // The window is the only place the card can grow; inline in the list it stays
 
10
  import Logo from './Logo';
11
  import { SendGlyph } from './icons';
12
  import ExchangeView from './conversation/Exchange';
13
+ import { useDraft } from './conversation/useDraft';
14
  import { writePaneMode } from '../lib/paneMode';
15
  import { splitExchanges } from './conversation/exchanges';
16
 
 
131
  onClose?: () => void; // present when the card lives in the conversation window
132
  }) {
133
  const d = s.digest;
134
+ const inputRef = useRef<HTMLTextAreaElement>(null);
135
+ // One unsent reply per agent, shared with reader mode: it is the same act on
136
+ // the same session, so the text you started in the card is the text the reader
137
+ // hands back. See drafts.ts for what it survives.
138
+ const [draft, setDraft] = useDraft(s.id, inputRef);
139
  const [sending, setSending] = useState(false);
140
  const [failed, setFailed] = useState(false);
141
  // Optimistic echo: the sent text becomes the prompt line the moment the
 
145
  const [histIdx, setHistIdx] = useState(0); // digest fallback only: n-th answer back
146
  const [back, setBack] = useState(0); // how many earlier turns are shown
147
  const [openWork, setOpenWork] = useState(false);
 
148
  const bodyRef = useRef<HTMLDivElement>(null);
149
  const latestRef = useRef<HTMLDivElement>(null);
150
  // The window is the only place the card can grow; inline in the list it stays
web/src/components/conversation/ConversationView.tsx CHANGED
@@ -13,6 +13,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
13
  import * as api from '../../api';
14
  import type { TracePage, TraceTurn } from '../../api';
15
  import type { Session } from '../../types';
 
16
  import { fmtTok, splitExchanges } from './exchanges';
17
  import ExchangeView from './Exchange';
18
  import { SendGlyph } from '../icons';
@@ -47,11 +48,14 @@ export default function ConversationView({ session, paused, isMobile, readOnly,
47
  const stick = useRef(true);
48
  // Reading a conversation and answering it are the same act — the card has
49
  // always known that. Only a trace with no agent behind it is read-only.
50
- const [draft, setDraft] = useState('');
 
 
 
 
51
  const [sending, setSending] = useState(false);
52
  const [failed, setFailed] = useState(false);
53
  const [sent, setSent] = useState<{ text: string; at: number } | null>(null);
54
- const inputRef = useRef<HTMLTextAreaElement>(null);
55
 
56
  const live = session.state === 'working' && !paused;
57
 
 
13
  import * as api from '../../api';
14
  import type { TracePage, TraceTurn } from '../../api';
15
  import type { Session } from '../../types';
16
+ import { useDraft } from './useDraft';
17
  import { fmtTok, splitExchanges } from './exchanges';
18
  import ExchangeView from './Exchange';
19
  import { SendGlyph } from '../icons';
 
48
  const stick = useRef(true);
49
  // Reading a conversation and answering it are the same act — the card has
50
  // always known that. Only a trace with no agent behind it is read-only.
51
+ const inputRef = useRef<HTMLTextAreaElement>(null);
52
+ // A half-typed reply outlives this component. On a phone, leaving the reader
53
+ // and coming back is usually a cold mount — the tab was evicted, or the Hub
54
+ // rebuilt the iframe — and plain state loses the text. drafts.ts.
55
+ const [draft, setDraft] = useDraft(session.id, inputRef);
56
  const [sending, setSending] = useState(false);
57
  const [failed, setFailed] = useState(false);
58
  const [sent, setSent] = useState<{ text: string; at: number } | null>(null);
 
59
 
60
  const live = session.state === 'working' && !paused;
61
 
web/src/components/conversation/drafts.ts ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // What the composer remembers when you walk away from it.
2
+ //
3
+ // Reported from a phone: open an agent's reader, start typing a reply, switch
4
+ // apps or lock the screen, come back — the text is gone. Two layers can lose it,
5
+ // and only one of them turned out to be guilty:
6
+ //
7
+ // · The pane does NOT unmount on an in-app trip back to the session list —
8
+ // App.tsx keeps a dozen terminal panes warm, reader and composer included —
9
+ // so React state alone already survives that. Measured on `main`, not assumed.
10
+ // · What kills it is the DOCUMENT going away: a reload, a backgrounded tab
11
+ // evicted under memory pressure, and the Hub rebuilding the Space's iframe
12
+ // on every visit. Coming back is a cold mount, not a resume.
13
+ //
14
+ // So in-memory state cannot be the answer, and neither can the URL (the Hub owns
15
+ // the iframe's src). It has to be storage, written through on every keystroke —
16
+ // a phone killing a backgrounded tab does not reliably run unload handlers.
17
+ //
18
+ // Same shape as filesMemory.ts, with one deliberate difference: every draft
19
+ // lives in ONE key rather than one key per session, because the two things that
20
+ // have to stay bounded — total bytes, and how long a draft may sit around — are
21
+ // properties of the whole set, and enumerating per-session keys to enforce them
22
+ // is how you end up with an unbounded pile nobody sweeps.
23
+ const KEY = 'am.drafts';
24
+ const VERSION = 1;
25
+
26
+ /**
27
+ * Per draft. A reply is a message, not a file: past this you have pasted a log
28
+ * into the box, and it stays in memory (so a pane switch is still lossless)
29
+ * without being written to a ~5 MB budget shared with the whole app.
30
+ */
31
+ const MAX_TEXT = 32 * 1024;
32
+ /** Every draft, together. The oldest fall off the end first. */
33
+ const MAX_TOTAL = 128 * 1024;
34
+ /**
35
+ * A draft is text you typed and never sent, sitting in the storage of whatever
36
+ * device you typed it on — which on a phone is not always only yours. A day is
37
+ * long enough to cover the case this exists for (you left, you came back) and
38
+ * short enough that last week's half-written answer is not still recoverable
39
+ * from the browser. Sending clears it immediately; this is only the floor.
40
+ */
41
+ const MAX_AGE_MS = 24 * 60 * 60 * 1000;
42
+
43
+ interface Entry { t: string; at: number }
44
+ type Store = Record<string, Entry>;
45
+
46
+ // First line of defence: a pane switch stays lossless even where storage is
47
+ // denied outright (private mode, or a third-party iframe under cross-site
48
+ // tracking prevention). Storage is the second line — it is what survives the
49
+ // document.
50
+ const mem = new Map<string, string>();
51
+
52
+ /**
53
+ * `at` orders the set as well as dating it, and Date.now() is not fine-grained
54
+ * enough to order two drafts saved in the same millisecond — which is how an
55
+ * eviction pass ends up shedding an arbitrary one instead of the oldest. This
56
+ * only ever moves forward, so it stays a timestamp (to the millisecond, for
57
+ * expiry) and is a strict order (for eviction).
58
+ */
59
+ let stamped = 0;
60
+ const stamp = () => {
61
+ stamped = Math.max(Date.now(), stamped + 1);
62
+ return stamped;
63
+ };
64
+
65
+ /** `dropped` is true when expired entries were filtered out and should be swept. */
66
+ function read(): { store: Store; dropped: boolean } {
67
+ try {
68
+ const raw = localStorage.getItem(KEY);
69
+ if (!raw) return { store: {}, dropped: false };
70
+ const parsed = JSON.parse(raw) as { v?: number; d?: Store };
71
+ if (parsed?.v !== VERSION || !parsed.d || typeof parsed.d !== 'object') {
72
+ // A older version, or something else's data under our key. Not ours to
73
+ // read; the next write replaces it.
74
+ return { store: {}, dropped: true };
75
+ }
76
+ const cutoff = Date.now() - MAX_AGE_MS;
77
+ const out: Store = {};
78
+ let dropped = false;
79
+ for (const [id, e] of Object.entries(parsed.d)) {
80
+ if (e && typeof e.t === 'string' && typeof e.at === 'number' && e.at > cutoff) out[id] = e;
81
+ else dropped = true;
82
+ }
83
+ return { store: out, dropped };
84
+ } catch {
85
+ // Denied, or nonsense under our key. Either way: no remembered drafts, which
86
+ // is exactly the behaviour we had before this file.
87
+ return { store: {}, dropped: false };
88
+ }
89
+ }
90
+
91
+ const load = (): Store => read().store;
92
+
93
+ function save(store: Store) {
94
+ // Newest first, and drop from the tail once the set is over budget: the draft
95
+ // being typed right now is the newest, so it is the last thing to go.
96
+ const byNewest = Object.entries(store).sort((a, b) => b[1].at - a[1].at);
97
+ let total = 0;
98
+ let out: Store = {};
99
+ for (const [id, e] of byNewest) {
100
+ total += e.t.length + id.length + 24;
101
+ if (total > MAX_TOTAL) break;
102
+ out[id] = e;
103
+ }
104
+ // That budget is ours; the quota is the browser's, and the rest of the app
105
+ // spends from it too. A write that fails sheds the oldest draft and tries
106
+ // again, and if it still fails it gives up without a word — a composer that
107
+ // throws on a keystroke is far worse than one that forgets.
108
+ for (let attempt = 0; attempt < 8; attempt += 1) {
109
+ try {
110
+ localStorage.setItem(KEY, JSON.stringify({ v: VERSION, d: out }));
111
+ return;
112
+ } catch {
113
+ const oldest = Object.keys(out).sort((a, b) => out[a].at - out[b].at)[0];
114
+ if (!oldest) {
115
+ try { localStorage.removeItem(KEY); } catch { /* nothing left to try */ }
116
+ return;
117
+ }
118
+ const next = { ...out };
119
+ delete next[oldest];
120
+ out = next;
121
+ }
122
+ }
123
+ }
124
+
125
+ /** The draft for one session — '' when there isn't one. */
126
+ export function recallDraft(id: string): string {
127
+ const held = mem.get(id);
128
+ if (held !== undefined) return held;
129
+ const { store, dropped } = read();
130
+ // Expiring has to mean deleted, not merely hidden: an abandoned draft that is
131
+ // no longer offered but is still sitting in localStorage has not expired in
132
+ // any sense the person who typed it would recognise. Reading is the moment we
133
+ // know — the app reads on every mount, so nothing waits for a write.
134
+ if (dropped) save(store);
135
+ const text = store[id]?.t || '';
136
+ if (text) mem.set(id, text);
137
+ return text;
138
+ }
139
+
140
+ /**
141
+ * Hold a draft in memory only. For text mid-IME-composition: it is what the
142
+ * textarea contains, but not something the user has committed yet.
143
+ */
144
+ export function holdDraft(id: string, text: string) {
145
+ if (text) mem.set(id, text);
146
+ else mem.delete(id);
147
+ }
148
+
149
+ /** Hold it, and write it through so it survives the document. */
150
+ export function rememberDraft(id: string, text: string) {
151
+ holdDraft(id, text);
152
+ const store = load();
153
+ if (!text || text.length > MAX_TEXT) delete store[id];
154
+ else store[id] = { t: text, at: stamp() };
155
+ save(store);
156
+ }
157
+
158
+ /** Forget one session's draft everywhere. */
159
+ export function forgetDraft(id: string) {
160
+ rememberDraft(id, '');
161
+ }
web/src/components/conversation/useDraft.ts ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The composer's draft, as React state.
2
+ //
3
+ // The rules the text obeys once it leaves the box — where it is kept, how big it
4
+ // may get, how long it lives — are drafts.ts. This is the binding: it reads the
5
+ // remembered draft on mount, writes every change through, and keeps an IME
6
+ // composition from being persisted half-finished.
7
+ import { useCallback, useEffect, useRef, useState } from 'react';
8
+ import type { RefObject } from 'react';
9
+ import { holdDraft, recallDraft, rememberDraft } from './drafts';
10
+
11
+ /**
12
+ * The composer's text, for one session, across everything that can take the
13
+ * page away. A drop-in for `useState('')`.
14
+ *
15
+ * Restoring only ever fills the box: no focus, no cursor move, no send. The send
16
+ * action stays exactly where it was — under the user's thumb. Clearing is the
17
+ * caller's existing `setDraft('')` on a successful send, so a sent message
18
+ * stops being a draft without anything new having to remember to say so.
19
+ *
20
+ * `inputRef` is the textarea the draft is typed into, and is used for one thing:
21
+ * an IME composition gate. A phone keyboard composes — pinyin, kana, autocorrect
22
+ * with candidates — and mid-composition the textarea holds a string the user has
23
+ * not committed. Persisting that means a discard can restore half a syllable, so
24
+ * writes are held back until `compositionend` and the pre-composition snapshot
25
+ * stands in the meantime. The listeners are the hook's own rather than JSX props
26
+ * deliberately: the composer markup is being unified in PR #49, and this has no
27
+ * opinion about which element renders it.
28
+ */
29
+ export function useDraft(
30
+ sessionId: string,
31
+ inputRef?: RefObject<HTMLTextAreaElement | null>,
32
+ ): [string, (text: string) => void] {
33
+ const [draft, setDraftState] = useState(() => recallDraft(sessionId));
34
+ const composing = useRef(false);
35
+ const held = useRef<string | null>(null);
36
+
37
+ // A pane is mounted per session, so this fires on mount and then only if one
38
+ // is ever re-pointed at another agent.
39
+ useEffect(() => { setDraftState(recallDraft(sessionId)); }, [sessionId]);
40
+
41
+ // Composition events bubble, so this listens on the document and asks "was
42
+ // that my textarea?" rather than binding the node. Binding the node looks
43
+ // tidier and is wrong: the reader renders `reading the trace…` on its first
44
+ // commit, so at the moment the effect runs there is no textarea to bind, and
45
+ // nothing re-runs it when one appears. The gate silently did nothing — caught
46
+ // by driving a real composition sequence in a browser, not by reading it.
47
+ useEffect(() => {
48
+ if (!inputRef) return undefined;
49
+ const mine = (e: Event) => e.target === inputRef.current;
50
+ const start = (e: Event) => { if (mine(e)) composing.current = true; };
51
+ const end = (e: Event) => {
52
+ if (!mine(e)) return;
53
+ composing.current = false;
54
+ if (held.current === null) return;
55
+ rememberDraft(sessionId, held.current);
56
+ held.current = null;
57
+ };
58
+ document.addEventListener('compositionstart', start, true);
59
+ document.addEventListener('compositionend', end, true);
60
+ return () => {
61
+ document.removeEventListener('compositionstart', start, true);
62
+ document.removeEventListener('compositionend', end, true);
63
+ // Unmounting mid-composition: commit what the box held rather than leave
64
+ // the last committed keystroke behind forever.
65
+ if (composing.current && held.current !== null) rememberDraft(sessionId, held.current);
66
+ composing.current = false;
67
+ held.current = null;
68
+ };
69
+ }, [sessionId, inputRef]);
70
+
71
+ const setDraft = useCallback((text: string) => {
72
+ setDraftState(text);
73
+ if (composing.current) { held.current = text; holdDraft(sessionId, text); return; }
74
+ rememberDraft(sessionId, text);
75
+ }, [sessionId]);
76
+
77
+ return [draft, setDraft];
78
+ }
web/test/drafts.test.mjs ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The rules a remembered draft has to obey, without a browser in the way.
2
+ //
3
+ // The end-to-end behaviour (does a phone that leaves and comes back still have
4
+ // the text?) is a playwright question. These are the ones a browser test can
5
+ // only ever pass vacuously: what happens at the size cap, at the browser's
6
+ // quota, and after the expiry window.
7
+ //
8
+ // No test runner: esbuild is already here for vite, so the module is transpiled
9
+ // and imported directly. Run with: node test/drafts.test.mjs
10
+ import assert from 'node:assert/strict';
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { fileURLToPath, pathToFileURL } from 'node:url';
15
+ import { build } from 'esbuild';
16
+
17
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
18
+ const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'drafts-')), 'drafts.mjs');
19
+ await build({
20
+ entryPoints: [path.join(HERE, '../src/components/conversation/drafts.ts')],
21
+ outfile: out, format: 'esm', bundle: false, logLevel: 'error',
22
+ });
23
+
24
+ // A localStorage that can be told to be full, or to be denied outright.
25
+ class Store {
26
+ constructor() { this.map = new Map(); this.limit = Infinity; this.denied = false; }
27
+ getItem(k) { if (this.denied) throw new Error('denied'); return this.map.has(k) ? this.map.get(k) : null; }
28
+ setItem(k, v) {
29
+ if (this.denied) throw new Error('denied');
30
+ const size = [...this.map].reduce((n, [a, b]) => n + a.length + b.length, 0)
31
+ - (this.map.get(k)?.length || 0) - (this.map.has(k) ? k.length : 0)
32
+ + k.length + v.length;
33
+ if (size > this.limit) { const e = new Error('QuotaExceededError'); e.name = 'QuotaExceededError'; throw e; }
34
+ this.map.set(k, v);
35
+ }
36
+ removeItem(k) { if (this.denied) throw new Error('denied'); this.map.delete(k); }
37
+ }
38
+ const store = new Store();
39
+ globalThis.localStorage = store;
40
+
41
+ const { recallDraft, rememberDraft, holdDraft, forgetDraft } = await import(pathToFileURL(out).href);
42
+
43
+ const KEY = 'am.drafts';
44
+ const raw = () => { try { return JSON.parse(store.map.get(KEY)).d || {}; } catch { return {}; } };
45
+ // recallDraft prefers the in-memory copy, which is the point of it — so reading
46
+ // "what would a cold mount see?" means asking the stored blob directly.
47
+ const cold = (id) => raw()[id]?.t ?? '';
48
+ // reset() clears the disk, not the module's in-memory map (nothing exported can,
49
+ // and that map is deliberately hard to lose). So any test that reads through
50
+ // recallDraft uses ids no other test has touched.
51
+ const reset = () => { store.map.clear(); store.limit = Infinity; store.denied = false; };
52
+ const tests = [];
53
+ const test = (name, fn) => tests.push([name, fn]);
54
+
55
+ test('a draft comes back for the session it was typed in', () => {
56
+ reset();
57
+ rememberDraft('a', 'for a');
58
+ rememberDraft('b', 'for b');
59
+ assert.equal(cold('a'), 'for a');
60
+ assert.equal(cold('b'), 'for b');
61
+ assert.equal(cold('c'), '');
62
+ assert.equal(recallDraft('a'), 'for a');
63
+ });
64
+
65
+ test('an empty draft is deleted, not stored as empty', () => {
66
+ reset();
67
+ rememberDraft('a', 'something');
68
+ rememberDraft('a', '');
69
+ assert.equal(Object.keys(raw()).length, 0);
70
+ assert.equal(recallDraft('a'), '');
71
+ rememberDraft('a', 'again');
72
+ forgetDraft('a');
73
+ assert.equal(recallDraft('a'), '');
74
+ });
75
+
76
+ test('a draft past the size cap stays in memory but is not written', () => {
77
+ reset();
78
+ const huge = 'x'.repeat(40 * 1024);
79
+ rememberDraft('a', huge);
80
+ assert.equal(cold('a'), '', 'not on disk');
81
+ assert.equal(recallDraft('a'), huge, 'still in memory, so a pane switch keeps it');
82
+ });
83
+
84
+ test('over the total budget, the oldest drafts fall off and the newest survives', () => {
85
+ reset();
86
+ const big = 'y'.repeat(30 * 1024);
87
+ for (const id of ['s1', 's2', 's3', 's4', 's5', 's6']) rememberDraft(id, big);
88
+ const kept = Object.keys(raw());
89
+ assert.ok(kept.includes('s6'), `the newest is kept, got ${kept}`);
90
+ assert.ok(!kept.includes('s1'), `the oldest fell off, got ${kept}`);
91
+ assert.ok(kept.length < 6, `something was evicted, got ${kept}`);
92
+ });
93
+
94
+ test('a quota error sheds old drafts rather than throwing', () => {
95
+ reset();
96
+ rememberDraft('old', 'a'.repeat(2000));
97
+ rememberDraft('mid', 'b'.repeat(2000));
98
+ // Now there is room for roughly one draft, not three.
99
+ store.limit = 2600;
100
+ assert.doesNotThrow(() => rememberDraft('new', 'c'.repeat(2000)));
101
+ assert.equal(cold('new'), 'c'.repeat(2000), 'the draft being typed is the one kept');
102
+ assert.ok(!('old' in raw()), 'the oldest was shed');
103
+ });
104
+
105
+ test('a quota that cannot be satisfied at all degrades quietly', () => {
106
+ reset();
107
+ store.limit = 10;
108
+ assert.doesNotThrow(() => rememberDraft('a', 'no room for this'));
109
+ assert.equal(recallDraft('a'), 'no room for this', 'memory still has it');
110
+ });
111
+
112
+ test('storage denied outright never throws', () => {
113
+ reset();
114
+ store.denied = true;
115
+ assert.doesNotThrow(() => rememberDraft('a', 'private mode'));
116
+ assert.equal(recallDraft('a'), 'private mode', 'memory carries it for this page');
117
+ assert.doesNotThrow(() => forgetDraft('a'));
118
+ });
119
+
120
+ test('a draft older than the window is not restored', () => {
121
+ reset();
122
+ store.map.set(KEY, JSON.stringify({
123
+ v: 1,
124
+ d: {
125
+ stale: { t: 'typed two days ago', at: Date.now() - 48 * 60 * 60 * 1000 },
126
+ fresh: { t: 'typed an hour ago', at: Date.now() - 60 * 60 * 1000 },
127
+ },
128
+ }));
129
+ assert.equal(cold('stale'), 'typed two days ago', 'still on disk until something reads it');
130
+ // A read drops it, and the next write persists that.
131
+ rememberDraft('other', 'x');
132
+ assert.ok(!('stale' in raw()), 'swept on the next write');
133
+ assert.equal(raw().fresh.t, 'typed an hour ago', 'the fresh one is untouched');
134
+ });
135
+
136
+ test('a corrupt or foreign blob reads as no drafts, and is replaced', () => {
137
+ const junks = ['not json', '{}', '[]', 'null', '{"v":99,"d":{"j0":{"t":"x","at":9e12}}}'];
138
+ for (const [i, junk] of junks.entries()) {
139
+ reset();
140
+ store.map.set(KEY, junk);
141
+ // Through the module, not through JSON.parse: refusing a blob we did not
142
+ // write IS the behaviour under test.
143
+ assert.equal(recallDraft(`j${i}`), '', `junk survived: ${junk}`);
144
+ assert.doesNotThrow(() => rememberDraft(`k${i}`, 'recovers'), `threw on: ${junk}`);
145
+ assert.deepEqual(Object.keys(raw()), [`k${i}`], `not replaced: ${junk}`);
146
+ }
147
+ });
148
+
149
+ test('a stale draft is deleted on read, not just hidden', () => {
150
+ reset();
151
+ store.map.set(KEY, JSON.stringify({
152
+ v: 1,
153
+ d: {
154
+ gone: { t: 'sensitive, and two days old', at: Date.now() - 48 * 60 * 60 * 1000 },
155
+ kept: { t: 'from ten minutes ago', at: Date.now() - 10 * 60 * 1000 },
156
+ },
157
+ }));
158
+ assert.equal(recallDraft('kept'), 'from ten minutes ago');
159
+ assert.ok(!('gone' in raw()), 'reading swept it off the device');
160
+ assert.equal(raw().kept.t, 'from ten minutes ago');
161
+ });
162
+
163
+ test('holdDraft keeps it out of storage', () => {
164
+ reset();
165
+ holdDraft('a', 'mid-composition');
166
+ assert.equal(Object.keys(raw()).length, 0, 'nothing written');
167
+ assert.equal(recallDraft('a'), 'mid-composition', 'but the box can be refilled');
168
+ });
169
+
170
+ let failed = 0;
171
+ for (const [name, fn] of tests) {
172
+ try { fn(); console.log(` ok ${name}`); } catch (e) { failed += 1; console.log(` FAIL ${name}\n ${e.message}`); }
173
+ }
174
+ console.log(`\n${tests.length - failed}/${tests.length} passed`);
175
+ process.exit(failed ? 1 : 0);