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:

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
  • rekeyDraft enables 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →