Elements

Elements · Composer

Draft restore

Come back to a thread and the sentence you never sent is still waiting.

Add a regression test for draft restore across thread switchesunsent draft · 2 minutes ago
fig. 01

Installation

npx shadcn@latest add "@assistant-ui/elements-draft-restore"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

A dismissible banner offering to put an unsent message back into the composer, with when it was last touched underneath it. With a runtime you decide when to snapshot the composer's text and when to resurface it; standalone you hold the draft and the timestamp yourself and pass them in.

Getting started

With a runtime, a reload does not keep unsent composer text by default: a thread whose runtime lacks the in-place refetch capability has its hook remounted on reload, which discards whatever was still being typed. Restoring a draft across that gap is app-level work built on the composer's own text state.

Save the draft as it is typed

Read the live text and write it to your own storage, keyed by thread, debounced so every keystroke does not hit disk.

components/assistant-ui/elements/use-draft-persistence.ts
"use client";

import { useAuiState } from "@assistant-ui/react";
import { useEffect, useRef } from "react";

function saveDraft(threadId: string, text: string) {
  if (text) localStorage.setItem(`draft:${threadId}`, text);
  else localStorage.removeItem(`draft:${threadId}`);
}

export function useDraftPersistence(threadId: string) {
  const text = useAuiState((s) => s.composer.text);
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

  useEffect(() => {
    clearTimeout(timer.current);
    timer.current = setTimeout(() => saveDraft(threadId, text), 400);
    return () => clearTimeout(timer.current);
  }, [threadId, text]);
}

Restore or discard it

On mount, read the saved draft for the active thread and show the banner when the composer is still empty. Restoring writes it back into the live composer; discarding just clears storage, since the component itself holds no state.

components/assistant-ui/elements/draft-restore-banner.tsx
"use client";

import { useAui } from "@assistant-ui/react";
import { DraftRestore } from "@/components/assistant-ui/elements/draft-restore";

export function DraftRestoreBanner({
  threadId,
  draft,
  savedAt,
  onDismiss,
}: {
  threadId: string;
  draft: string;
  savedAt: string;
  onDismiss: () => void;
}) {
  const aui = useAui();
  return (
    <DraftRestore
      draft={draft}
      savedAt={savedAt}
      onRestore={() => {
        aui.composer.setText(draft);
        localStorage.removeItem(`draft:${threadId}`);
        onDismiss();
      }}
      onDiscard={() => {
        localStorage.removeItem(`draft:${threadId}`);
        onDismiss();
      }}
    />
  );
}

Anatomy

<div data-slot="draft-restore">
  <svg /* pencil icon */ />
  <div>
    <span>{/* draft, truncated to one line */}</span>
    <span>{/* unsent draft · savedAt */}</span>
  </div>
  <button>Restore</button>
  <button aria-label="Discard the draft">{/* x icon */}</button>
</div>

The banner has no visibility state of its own: it renders whenever it is mounted, and the caller decides when that is, typically by clearing the saved draft inside onRestore and onDiscard so the banner unmounts on either choice. draft is truncated with CSS, not measured or word-wrapped, and savedAt is shown exactly as passed.

Examples

Restyle the banner

className merges onto the root; the Discard button reuses the shared ghostButton surface, so restyling that token restyles every icon-only button across the kit at once.

<DraftRestore className="max-w-md" draft={draft} savedAt={savedAt} onRestore={restore} onDiscard={discard} />

Naming the timestamp

The element never computes relative time. Format it with whatever you already use elsewhere, from a raw Intl.RelativeTimeFormat call to a library.

const savedAt = new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(-2, "minute");

API reference

Composer state

SelectorTypeDescription
s.composer.textstringThe live, unsent text in the active composer. Snapshot this on a timer or on unload to build the saved draft.
aui.composer.setText(text)(text: string) => voidWrites text into the composer. Called from onRestore to put a saved draft back, using the client from useAui().

There is no runtime concept of a saved draft: persistence and the decision to show the banner are both yours.