Spaces:
Running
Mobile: keep the reply you were half-way through typing
Browse filesStart 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>
|
@@ -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.
|
|
@@ -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": {
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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);
|