Spaces:
Running
Running
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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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);
|