Thomas Wolf commited on
Commit
ecbf2fa
·
unverified ·
2 Parent(s): e5fc6396cec9e1

Merge pull request #58 from huggingface/mobile/persist-draft

Browse files
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
@@ -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 { fmtTok, splitExchanges } from './exchanges';
22
  import ExchangeView from './Exchange';
23
  import { SendGlyph } from '../icons';
@@ -48,11 +49,14 @@ export default function ConversationView({ session, paused, isMobile, readOnly,
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 [draft, setDraft] = useState('');
 
 
 
 
52
  const [sending, setSending] = useState(false);
53
  const [failed, setFailed] = useState(false);
54
  const [sent, setSent] = useState<{ text: string; at: number } | null>(null);
55
- const inputRef = useRef<HTMLTextAreaElement>(null);
56
 
57
  const live = session.state === 'working' && !paused;
58
 
 
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';
24
  import { SendGlyph } from '../icons';
 
49
  const stick = useRef(true);
50
  // Reading a conversation and answering it are the same act — the card has
51
  // always known that. Only a trace with no agent behind it is read-only.
52
+ const inputRef = useRef<HTMLTextAreaElement>(null);
53
+ // A half-typed reply outlives this component. On a phone, leaving the reader
54
+ // and coming back is usually a cold mount — the tab was evicted, or the Hub
55
+ // rebuilt the iframe — and plain state loses the text. drafts.ts.
56
+ const [draft, setDraft] = useDraft(session.id, inputRef);
57
  const [sending, setSending] = useState(false);
58
  const [failed, setFailed] = useState(false);
59
  const [sent, setSent] = useState<{ text: string; at: number } | null>(null);
 
60
 
61
  const live = session.state === 'working' && !paused;
62
 
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);