Elements

Elements · Thread

Shared conversation

A read-only transcript someone sent you, with a way to pick it up yourself.

Draft restore across threadsshared by harry · 2 days ago
Why did the draft survive the thread switch?
The composer read a slot keyed by the old thread. Clearing it on switch fixes it at the source.
read only
fig. 01

Installation

npx shadcn@latest add "@assistant-ui/elements-shared-conversation"
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 shared conversation is somebody else's finished thread, handed to you to read: every turn exactly as they left it, nothing to change, only a door back into your own copy. With a runtime it's a real thread rendered through a read-only runtime; standalone you render the transcript array directly.

Getting started

ReadonlyThreadProvider wraps children in a thread whose mutations all throw: sending, canceling, editing, branching. The normal thread primitives render it exactly like any other thread, just inert.

Wrap the transcript in a readonly thread

app/shared/[id]/page.tsx
"use client";

import { ReadonlyThreadProvider, ThreadPrimitive, MessagePrimitive } from "@assistant-ui/react";
import type { ThreadMessage } from "@assistant-ui/react";

export function SharedThread({ messages }: { messages: readonly ThreadMessage[] }) {
  return (
    <ReadonlyThreadProvider messages={messages}>
      <ThreadPrimitive.Viewport>
        <ThreadPrimitive.Messages>
          {() => <MessagePrimitive.Parts />}
        </ThreadPrimitive.Messages>
      </ThreadPrimitive.Viewport>
    </ReadonlyThreadProvider>
  );
}

Every capability on this thread, edit, delete, reload, cancel, and the rest, reports false, so composer input, regenerate, and branch controls that check s.thread.capabilities disable themselves without any extra work on your part.

Offer a way into a real thread

ReadonlyThreadProvider has no send path at all; put the continue action outside it, against your app's real runtime:

import { useAui } from "@assistant-ui/react";
import type { ThreadMessage } from "@assistant-ui/react";

function ContinueButton({ messages }: { messages: readonly ThreadMessage[] }) {
  const app = useAui();
  return (
    <button
      onClick={() =>
        app.thread.import({
          messages: messages.map((message, i) => ({ message, parentId: messages[i - 1]?.id ?? null })),
        })
      }
    >
      Continue in your own chat
    </button>
  );
}

Placed above the ReadonlyThreadProvider, useAui() there still resolves to your app's real client, not the readonly one.

Anatomy

<div data-slot="shared-conversation">
  <div>{/* link icon, title, "shared by X, when" */}</div>
  <div>{/* turns, most recent last */}</div>
  <div>
    <span>read only</span>
    <button>{/* Continue in your own chat */}</button>
  </div>
</div>

Behaviorally this reads closer to a receipt than a chat panel: nothing inside it is clickable except the continue button, and there's no loading or empty state, turns is expected to already be the full, finished transcript.

Examples

Restyle the header

title, sharedBy, and sharedAt all truncate on overflow rather than wrap, keeping the header to one line regardless of how long the title or the sharer's name is:

<SharedConversation title="A very long conversation title that will truncate" sharedBy="Jordan Alvarez" sharedAt="Aug 12" turns={turns} />

Empty transcript

ReadonlyThreadProvider accepts an empty messages array the same way: the thread reports isEmpty: true, and any <AuiIf condition={(s) => s.thread.isEmpty}> blocks in the surrounding UI show, the same as a real empty thread.

API reference

Primitive parts

PartRendersNotes
ReadonlyThreadProviderchildrenWraps children in a thread scope built from messages. Every mutation on it throws.
ThreadPrimitive.Viewport / .Messagesdiv / childrenRender the same as on any other thread.

Thread state and capabilities

SelectorTypeDescription
s.thread.messagesreadonly MessageState[]The messages prop, as rendered messages.
s.thread.isEmptybooleantrue when messages is empty.
s.thread.capabilitiesRuntimeCapabilitiesEvery flag (edit, delete, reload, cancel, attachments, and the rest) is false.