# How pi‑web Implements the Draft Store for Local Message Persistence

> Discover how pi-web uses an in-memory Map for its draft store, enabling local message persistence for chat drafts with text and images across page reloads.

- Repository: [Alex Yang/pi-web](https://github.com/agegr/pi-web)
- Tags: internals
- Published: 2026-08-13

---

**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`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/lib/draft-store.ts):

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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`](https://github.com/agegr/pi-web/blob/main/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:

```typescript
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

```typescript
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`](https://github.com/agegr/pi-web/blob/main/components/ChatInput.tsx), this keeps the draft current without debouncing complexity.

### Loading Drafts for Display

```typescript
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

```typescript
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`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts)** – orchestrates `clearDraft`, `rekeyDraft`, and `restoreDraftSubmission` during session lifecycle events
- **[`components/ChatInput.tsx`](https://github.com/agegr/pi-web/blob/main/components/ChatInput.tsx)** – triggers `setDraft` on input changes and consumes drafts on mount
- **[`lib/image-attachments.ts`](https://github.com/agegr/pi-web/blob/main/lib/image-attachments.ts)** – provides `MAX_ATTACHED_IMAGES` and `isBase64ImageWithinLimits` for 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
- **`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`](https://github.com/agegr/pi-web/blob/main/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.