How pi‑web Implements the Draft Store for Local Message Persistence
pi‑web implements the draft store as an in‑memory Map<string, ChatDraft> that saves chat input (text and base‑64 images) while users compose messages, providing transient persistence across navigation and page reloads without filesystem access.
The agegr/pi-web repository uses a lightweight, client‑side draft store to keep partially typed messages alive during a browser session. Located in lib/draft-store.ts, this module provides a simple API for saving, reading, merging, and rekeying chat drafts without relying on localStorage or server persistence.
Core Data Structures
The ChatDraft Type
The draft store centers on the ChatDraft interface defined at line 11–14 of lib/draft-store.ts:
interface ChatDraft {
value: string; // Plain text content
images: ChatDraftImage[]; // Attached images
}
Each ChatDraftImage contains a data field (base‑64 string) and a mimeType (e.g., "image/png"). These structures are kept immutable through cloning operations.
In‑Memory Storage Backend
Drafts live in a module‑level ES6 Map declared at line 16:
const drafts = new Map<string, ChatDraft>();
This map uses draft keys—typically constructed as sessionId:leafId—to isolate drafts per chat pane. The store exists only for the lifetime of the page/tab, which matches the transient nature of chat composition.
Cloning and Safety Guarantees
Preventing External Mutation
The cloneDraft helper (lines 18–23) deep‑copies draft objects before any read or write operation:
function cloneDraft(draft: ChatDraft): ChatDraft {
return {
value: draft.value,
images: draft.images.map(img => ({ ...img }))
};
}
This ensures that UI components cannot accidentally corrupt stored drafts by mutating returned objects.
Empty Draft Detection
Drafts containing neither text nor images are automatically purged. The isEmptyDraft predicate (lines 25–27) implements this logic:
function isEmptyDraft(draft: ChatDraft): boolean {
return !draft.value && draft.images.length === 0;
}
CRUD Operations
Reading Drafts: getDraft
The getDraft function (lines 29–32) returns a fresh clone or null if no draft exists for the given key:
export function getDraft(key: string): ChatDraft | null {
const draft = drafts.get(key);
return draft ? cloneDraft(draft) : null;
}
This pattern guarantees that callers receive independent copies they can modify safely.
Writing Drafts: setDraft
The setDraft function (lines 34–40) handles persistence with automatic cleanup:
export function setDraft(key: string, draft: ChatDraft): void {
if (isEmptyDraft(draft)) {
drafts.delete(key);
} else {
drafts.set(key, cloneDraft(draft));
}
}
Empty drafts are deleted rather than stored, keeping the map lean.
Clearing Drafts: clearDraft
For explicit removal, clearDraft (lines 42–44) simply deletes the entry:
export function clearDraft(key: string): void {
drafts.delete(key);
}
Advanced Operations: Merging and Rekeying
Restoring Failed Submissions
When a message send fails, restoreDraftSubmission (lines 69–83) merges the failed content with any existing draft:
export function restoreDraftSubmission(
key: string,
text: string,
images: ChatDraftImage[]
): ChatDraft {
const current = getDraft(key) || { value: '', images: [] };
const merged = mergeRestoredSubmissionDraft(current, text, images);
setDraft(key, merged);
return merged;
}
The internal mergeRestoredSubmissionDraft helper (lines 58–66) enforces image limits imported from lib/image-attachments.ts: maximum 10 images and 10 MiB per image. These constraints prevent unbounded growth of restored drafts.
Rekeying Drafts on Navigation
When users switch chat branches, rekeyDraft (lines 85–105) transfers drafts between keys:
export function rekeyDraft(oldKey: string, newKey: string): ChatDraft | null {
const oldDraft = getDraft(oldKey);
if (!oldDraft) return getDraft(newKey);
clearDraft(oldKey);
const newDraft = getDraft(newKey);
if (!newDraft) {
setDraft(newKey, oldDraft);
return oldDraft;
}
const merged: ChatDraft = {
value: oldDraft.value || newDraft.value,
images: [...oldDraft.images, ...newDraft.images].slice(0, MAX_ATTACHED_IMAGES)
};
setDraft(newKey, merged);
return merged;
}
This merge prioritizes the old draft's text but combines images from both drafts, truncating to respect the global limit.
Practical Usage Examples
Saving Drafts on User Input
import { setDraft } from '@/lib/draft-store';
function handleInput(key: string, text: string, images: ChatDraftImage[]) {
setDraft(key, { value: text, images });
}
Called on every keystroke in components/ChatInput.tsx, this keeps the draft current without debouncing complexity.
Loading Drafts for Display
import { getDraft } from '@/lib/draft-store';
const draft = getDraft(draftKey);
if (draft) {
textarea.value = draft.value;
renderImageThumbnails(draft.images);
}
The cloned return value permits safe UI modifications.
Handling Navigation Between Chat Branches
import { rekeyDraft } from '@/lib/draft-store';
// User navigates from branch A to branch B
const preserved = rekeyDraft('session-123:branch-A', 'session-123:branch-B');
Drafts follow the user's context automatically.
Integration Points
The draft store connects to the broader application through:
hooks/useAgentSession.ts– orchestratesclearDraft,rekeyDraft, andrestoreDraftSubmissionduring session lifecycle eventscomponents/ChatInput.tsx– triggerssetDrafton input changes and consumes drafts on mountlib/image-attachments.ts– providesMAX_ATTACHED_IMAGESandisBase64ImageWithinLimitsfor validation
Summary
- pi‑web's draft store uses a simple
Map<string, ChatDraft>for in‑memory persistence - All operations clone drafts to prevent mutation bugs
- Empty drafts are automatically deleted, keeping storage minimal
- Image limits (10 images, 10 MiB each) are enforced during merge operations
rekeyDraftenables seamless draft transfer when users navigate between chat branches- The design intentionally avoids
localStorage—drafts are transient and tab‑scoped
Frequently Asked Questions
Does pi‑web persist drafts across browser restarts?
No. The draft store is strictly in‑memory and tied to the browser tab's lifetime. Drafts survive page reloads within the same tab but disappear when the tab closes. This matches the intended transient behavior for chat composition.
How does pi‑web prevent draft data corruption from UI components?
Every read and write operation uses cloneDraft to produce deep copies. When getDraft returns a draft, modifications to that object do not affect the stored version. Similarly, setDraft clones before storing to isolate internal state from caller mutations.
What happens when a user attaches too many images to a draft?
The mergeRestoredSubmissionDraft helper and rekeyDraft both enforce a hard limit of 10 images defined in lib/image-attachments.ts. When merging drafts would exceed this limit, excess images are truncated from the end of the combined array.
Can drafts be recovered after a failed message send?
Yes. The restoreDraftSubmission function merges failed submission content with any existing draft, respecting image size and count limits. This allows users to retry or edit messages that failed due to network errors without retyping.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →