Shared conversation
A read-only transcript someone sent you, with a way to pick it up yourself.
Installation
Install manually
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 initThen 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.
npx shadcn@latest add "@assistant-ui/elements-shared-conversation"Props-driven: no runtime or provider required.
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 read-only thread. It reports itself disabled, so a composer inside renders disabled; thread mutations are ignored, and composer text, sending, adding attachments, and setting a quote have no effect. The normal thread primitives render it exactly like any other thread.
Wrap the transcript in a readonly thread
"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, answerToolCall, and the rest, reports false, so composer input, regenerate, and branch controls that check s.thread.capabilities disable themselves and the tool fallback drops its approval controls while keeping the prompt, with no 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. The runtime has to accept imports: an external-store runtime needs an onImport handler, or thread.import() throws.
Standalone, SharedConversation is a self-contained card: title, who shared it, the turns, and the continue button, all from props.
Render the shared card
"use client";
import { SharedConversation } from "@/components/assistant-ui/elements/shared-conversation";
export function SharedCard({ onContinue }: { onContinue: () => void }) {
return (
<SharedConversation
title="Debugging the flaky test"
sharedBy="Jordan"
sharedAt="2 days ago"
turns={[
{ id: "1", role: "user", text: "Why does this test fail only in CI?" },
{ id: "2", role: "assistant", text: "Most likely a timing assumption..." },
]}
onContinue={onContinue}
/>
);
}Keep it read only
There is no prop for editing a turn or removing one: turns renders exactly as given, and the only interactive element is the continue button, matching the runtime lane's inert composer.
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
Passing an empty turns array renders the header and footer with nothing between them; there's no dedicated empty-state message, so an app that can receive an empty share should filter it out before rendering the card.
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
| Part | Renders | Notes |
|---|---|---|
ReadonlyThreadProvider | children | Wraps children in a disabled thread scope built from messages. Thread mutations and composer text, send, addAttachment, and setQuote calls have no effect. |
ThreadPrimitive.Viewport / .Messages | div / children | Render the same as on any other thread. |
Thread state and capabilities
| Selector | Type | Description |
|---|---|---|
s.thread.messages | readonly MessageState[] | The messages prop, as rendered messages. |
s.thread.isEmpty | boolean | true when messages is empty. |
s.thread.capabilities | RuntimeCapabilities | Every flag (edit, delete, reload, cancel, attachments, answerToolCall, and the rest) is false. |
SharedConversation
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | required | Shown truncated in the header. |
sharedBy | string | required | Shown after "shared by" in the header. |
sharedAt | string | required | Shown after the sharer's name, already formatted. |
turns | readonly SharedTurn[] ({ id, role: "user" | "assistant", text }) | required | Rendered in order. |
onContinue | () => void | Called by the footer button. | |
className | string | Merged onto the root. |
All other div props are forwarded to the root.