Elements

Elements · Messages

Timestamps

Chronology in a long thread: days marked, times on hover.

Yesterday
Why does the draft survive a reload?16:04
It's persisted per thread, so the slot is read back on mount.16:04
Today
And across thread switches?09:12
Each thread owns its own slot, so nothing leaks between them.09:12
fig. 01

Installation

npx shadcn@latest add "@assistant-ui/elements-day-separator"
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.

DaySeparator lays a flat list of messages out as a transcript, inserting a day header whenever the day changes and revealing each message's time on hover. With a runtime you derive that list from the thread's own messages; standalone you already hold it.

Getting started

Every message carries a real createdAt (a Date) and role, and a thread's full message list is available as plain state, so building the day-grouped shape is formatting and text extraction, not new wiring.

Read the thread's messages

components/assistant-ui/elements/thread-history.tsx
"use client";

import { useAuiState } from "@assistant-ui/react";
import { DaySeparator, type DatedMessage } from "@/components/assistant-ui/elements/day-separator";

const dayFormat = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric" });
const timeFormat = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit" });

export function ThreadHistory() {
  const messages = useAuiState((s) => s.thread.messages);

  const dated: DatedMessage[] = messages
    .filter((m) => m.role !== "system")
    .map((m) => ({
      id: m.id,
      day: dayFormat.format(m.createdAt),
      time: timeFormat.format(m.createdAt),
      role: m.role === "user" ? "user" : "assistant",
      text: m.content
        .filter((part): part is { type: "text"; text: string } => part.type === "text")
        .map((part) => part.text)
        .join(" "),
    }));

  return <DaySeparator messages={dated} />;
}

s.thread.messages is the active branch only: reloading a message or editing an earlier one swaps which messages appear here, the same way it does everywhere else in the thread.

Keep it to a preview, not the transcript

Flattening to text drops tool calls, images, and reasoning, so this composition suits a compact history view rather than the live conversation. Render the thread itself with ThreadPrimitive.Messages and MessagePrimitive.Parts, the composition Thread already ships, so rich content still renders in full there.

Anatomy

<div data-slot="day-separator">
  {/* per message: an optional day header (rule, label, rule) when `day` differs from the previous entry, then the row */}
  <div>{/* user rows sit right-aligned in a filled bubble; assistant rows sit left-aligned as plain text */}</div>
</div>

The day comparison only looks at the immediately preceding entry, so the list must already be in chronological order; out-of-order entries repeat a header instead of merging into the last one. Each row's time is transparent by default and fades in only on hover of that row (group-hover), so the list stays quiet until you look for a timestamp.

Examples

A compact history, not the live transcript

Pair ThreadHistory alongside the real Thread, for example as a searchable sidebar or a "jump to" list, rather than in place of it. Selecting an entry there is a normal branch or thread switch, not something this component does on its own.

Restyle the rule and bubble

Both lanes take className on the root. The day label and hover time read the shared mono surface from surfaces.tsx.

<DaySeparator className="max-w-none gap-3" messages={messages} />

API reference

Thread state

SelectorTypeDescription
s.thread.messagesreadonly MessageState[]Every message on the thread's active branch, each with id, role, createdAt, and content.
message.createdAtDateUsed to derive day and time; formatting is your own choice, not something the runtime provides pre-formatted.
message.contentreadonly ThreadAssistantMessagePart[] | readonly ThreadUserMessagePart[]Filter for type === "text" parts to build a flat preview string.