/ai/assistantAssistant
A two-pane assistant: threads by day in a rail, a greeting with quick prompts, and a transcript whose replies come from a seeded composer.
Installs at /ai/assistant. The page reads every thread through db.aiThreads on the server, writes the dates into words, and hands the rows to one island that owns the rail, the open thread and the composer. The open thread lives in ?thread=, seeded from the query and written back with replace, so a conversation is a link a reader can send and a preview can deep-link; with no thread the pane greets the signed-in member by the time of day REFERENCE_DATE falls in, and the four chips fill the composer. sendPrompt appends the user's turn and a reply built from the prompt's own words by composeReply — there is no model behind the page, and that one function in data.ts is where a consumer plugs theirs in; a prompt sent with no thread open creates one and selects it. A blank prompt is refused in a callout under the box; deleteThread drops the row and returns the pane to the greeting. Composes AppShell, PageHeader, InputGroup, Select, CopyButton, DataList, SearchInput, Callout, Avatar, Badge, Message, MessageAvatar, MessageContent, MessageFooter, Bubble and BubbleContent.
Preview
import * as React from "react"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { Skeleton } from "@/components/ui/skeleton"
import { signOut } from "./actions"
import { ChatView } from "./components/chat-view"
import { currentUser, firstName, greeting, shellNotifications, threads } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/ai/nav"
/**
* The assistant. The page is a server component: it reads every thread
* through `db.aiThreads`, writes the dates into words, and hands the rows to
* one client island that owns the rail, the open thread and the composer —
* and the header, whose thread count follows the rail. The island reads
* `?thread=` off the URL, so it sits inside a Suspense boundary — a
* prerendered page that calls useSearchParams outside one fails the build.
*/
export default function AssistantPage() {
const rows = threads()
const user = currentUser()
return (
<AppShell
nav={NAV}
activeHref={ROUTES.assistant}
user={user}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<React.Suspense fallback={<Skeleton className="h-[38rem] w-full rounded-xl" />}>
<ChatView threads={rows} greeting={greeting()} firstName={firstName()} initials={user.initials} avatarUrl={user.avatarUrl} />
</React.Suspense>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/ai-assistantNeeds the @vibra registry in your components.json — set it up once.
Source
import * as React from "react"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { Skeleton } from "@/components/ui/skeleton"
import { signOut } from "./actions"
import { ChatView } from "./components/chat-view"
import { currentUser, firstName, greeting, shellNotifications, threads } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/ai/nav"
/**
* The assistant. The page is a server component: it reads every thread
* through `db.aiThreads`, writes the dates into words, and hands the rows to
* one client island that owns the rail, the open thread and the composer —
* and the header, whose thread count follows the rail. The island reads
* `?thread=` off the URL, so it sits inside a Suspense boundary — a
* prerendered page that calls useSearchParams outside one fails the build.
*/
export default function AssistantPage() {
const rows = threads()
const user = currentUser()
return (
<AppShell
nav={NAV}
activeHref={ROUTES.assistant}
user={user}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<React.Suspense fallback={<Skeleton className="h-[38rem] w-full rounded-xl" />}>
<ChatView threads={rows} greeting={greeting()} firstName={firstName()} initials={user.initials} avatarUrl={user.avatarUrl} />
</React.Suspense>
</AppShell>
)
}/**
* What this page reads. Threads come from `db.aiThreads`; every date on them
* is turned into words here, on the server, so the island never needs a clock
* or the store — it renders rows and appends what the actions hand back.
* "Now" is `REFERENCE_DATE`: the greeting's time of day, the rail's groups and
* the "2h ago" beside a thread are all measured against it.
*/
import { greetingFor, type AssistantModel } from "@/lib/dashboards/ai/vocabulary"
import { formatRelative, getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, seeded, type AiThread } from "@/lib/sample-data"
/** A thread's model: the one model type both assistants share, from vocabulary-ai. */
export type ThreadModel = AssistantModel
/** Which heading of the rail a thread sits under, by the UTC day it was last touched. */
export type ThreadGroup = "Today" | "Yesterday" | "Earlier"
/** One turn, with its time already written for the reader. */
export type Turn = {
id: string
role: "user" | "assistant"
text: string
time: string
}
/** A thread as the rail and the transcript read it. */
export type ThreadRow = {
id: string
title: string
model: ThreadModel
group: ThreadGroup
/** How long ago the last turn landed, or the date once that stops being useful. */
when: string
messages: Turn[]
}
const DAY_MS = 86_400_000
/**
* When a turn landed, pinned to UTC so the transcript reads the same on both
* sides of Greenwich — the sample's instants are UTC, and a host-zone format
* would date a late-evening turn on the next day for half the planet.
*/
const turnTime = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZone: "UTC",
})
const shortDay = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
/** Midnight UTC of the day `date` falls on, as a number of days. */
const utcDay = (date: Date): number =>
Math.floor(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) / DAY_MS)
/** Today, yesterday or earlier — calendar days in UTC, not 24-hour spans. */
export function groupFor(at: Date): ThreadGroup {
const gap = utcDay(REFERENCE_DATE) - utcDay(at)
if (gap <= 0) return "Today"
if (gap === 1) return "Yesterday"
return "Earlier"
}
/** "2h ago" against `REFERENCE_DATE`, and the date itself past a week. */
export function since(at: Date): string {
const days = (REFERENCE_DATE.getTime() - at.getTime()) / DAY_MS
return days < 7 ? formatRelative(at, REFERENCE_DATE) : shortDay.format(at)
}
/** A thread shaped for the page; the actions use it too, so a row appended at runtime reads like a seeded one. */
export function toRow(thread: AiThread): ThreadRow {
return {
id: thread.id,
title: thread.title,
model: thread.model,
group: groupFor(thread.updatedAt),
when: since(thread.updatedAt),
messages: thread.messages.map((message) => ({
id: message.id,
role: message.role,
text: message.text,
time: turnTime.format(message.at),
})),
}
}
/** Every thread, most recently touched first. */
export function threads(): ThreadRow[] {
return db.aiThreads
.all()
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
.map(toRow)
}
/** "Good morning", "Good afternoon" or "Good evening" by the hour of `REFERENCE_DATE`, in UTC — the AI dashboard's one rule for it. */
export function greeting(): string {
return greetingFor(REFERENCE_DATE)
}
const TITLE_WORDS = 6
const TITLE_MAX = 48
/** The rail's name for a thread: the prompt's first few words, no trailing punctuation. */
export function titleFor(prompt: string): string {
const words = prompt.trim().split(/\s+/).slice(0, TITLE_WORDS).join(" ")
const clean = words.replace(/[\s.,;:!?…]+$/g, "")
const cut = clean.length > TITLE_MAX ? `${clean.slice(0, TITLE_MAX - 1).trimEnd()}…` : clean
return cut.charAt(0).toUpperCase() + cut.slice(1)
}
const STOP_WORDS = new Set([
"about", "after", "again", "could", "every", "first", "from", "have", "into", "just", "last",
"more", "most", "should", "some", "than", "that", "their", "them", "there", "these", "this",
"those", "what", "when", "where", "which", "while", "with", "would", "your", "please", "write",
"give", "help", "make", "explain", "summarise", "summarize", "draft", "tell",
])
const OPENINGS = [
"Here is a short take on {topic}.",
"A few thoughts on {topic}.",
"Taking {topic} first: the short answer is that it depends on what you measure.",
"On {topic}, there is one thing to settle before anything else.",
]
const MIDDLES = [
"The part worth getting right is {word}: start there and the rest follows from it.",
"Most of the effort goes into {word}, so it deserves a paragraph of its own before anything else.",
"I would lead with {word}, keep the rest to a line each, and cut anything that does not change a decision.",
"Two things matter: what {word} means to the reader, and what they can do about it once they know.",
]
const CLOSINGS = [
"Tell me the audience and I can tighten it further.",
"If you share the numbers behind it, I can be more specific.",
"Happy to turn this into a draft you can paste.",
]
/**
* A reply built from the prompt's own words — two or three sentences, seeded
* from the prompt so the same question always draws the same answer. There is
* no model behind this page; a consumer swaps this for a call to theirs.
*/
export function composeReply(prompt: string): string {
const clean = prompt.trim().replace(/[\s.,;:!?…]+$/g, "")
const rand = seeded(`ai-chat:${clean.toLowerCase()}`)
const words = clean.split(/\s+/)
// What the reply quotes stays a phrase and a word, however long the prompt:
// a prompt that is one 4,000-character string is not a topic to repeat twice.
const topic = words.slice(0, 8).join(" ").toLowerCase().slice(0, 160)
const candidates = words
.map((word) => word.replace(/[^\p{L}\p{N}-]/gu, "").toLowerCase())
.filter((word) => word.length >= 4 && word.length <= 40 && !STOP_WORDS.has(word))
const word = candidates.sort((a, b) => b.length - a.length)[0] ?? "the first step"
const at = <T,>(list: readonly T[]): T => list[Math.floor(rand() * list.length)]
const sentences = [
at(OPENINGS).replace("{topic}", topic),
at(MIDDLES).replace("{word}", word),
]
if (rand() < 0.6) sentences.push(at(CLOSINGS))
return sentences.join(" ")
}
// Read per call, never held at module scope: a rename on the profile page
// shows here on the next render.
const memberRow = () => db.members.all()[0]
/** The person looking at the page: the first member of the workspace, who owns it. */
export function currentUser() {
const member = memberRow()
return { name: member.name, email: member.email, initials: getInitials(member.name), avatarUrl: member.avatarUrl }
}
/** The greeting's name: what the member goes by, not the whole record. */
export function firstName(): string {
return memberRow().name.split(" ")[0]
}
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { asString, db, REFERENCE_DATE, type AiThread, type Result } from "@/lib/sample-data"
import { isModel } from "@/lib/dashboards/ai/vocabulary"
import { composeReply, titleFor, toRow, type ThreadRow } from "./data"
/**
* What this page changes. Server actions so the page can stay a server
* component, and `Result` so every caller reads one success-or-error shape:
* a refusal is rendered as a callout where the composer is, a success as the
* turns it produced.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}
/** The longest prompt the composer sends: what the page's other assistant takes too. */
const MAX_PROMPT = 4_000
export type SentPrompt = {
/** The whole thread as it now stands — created when `threadId` was null. */
thread: ThreadRow
created: boolean
}
/**
* Sends a prompt: the user's turn and a seeded reply are appended to the
* thread, or a thread is opened for them when there is none. The time is
* `REFERENCE_DATE`, never a clock, and the reply comes from `composeReply` —
* there is no model behind the page, which is where a consumer plugs theirs in.
*/
export async function sendPrompt(
threadId: string | null,
text: string,
model: string
): Promise<Result<SentPrompt>> {
const prompt = asString(text).trim()
if (!prompt) {
return { ok: false, error: { code: "required", message: "Write a prompt before sending.", field: "prompt" } }
}
if (prompt.length > MAX_PROMPT) {
return { ok: false, error: { code: "too_long", message: `Keep a prompt under ${MAX_PROMPT.toLocaleString("en-US")} characters.`, field: "prompt" } }
}
if (!isModel(model)) {
return { ok: false, error: { code: "invalid", message: "Pick one of the models the workspace offers.", field: "model" } }
}
const at = REFERENCE_DATE
const reply = composeReply(prompt)
if (threadId !== null) {
const thread = typeof threadId === "string" ? await db.aiThreads.get(threadId) : undefined
if (!thread) {
return { ok: false, error: { code: "not_found", message: "That thread is no longer here. Start a new chat." } }
}
const messages = [...thread.messages, ...turns(thread.messages.length, prompt, reply, at)]
const updated = await db.aiThreads.update(thread.id, { messages, updatedAt: at, model })
if (!updated.ok) return updated
return { ok: true, data: { thread: toRow(updated.data), created: false } }
}
// One write: the thread is born with its first exchange, so a failure can
// never leave a thread with no turns behind it.
const created = await db.aiThreads.create({
title: titleFor(prompt),
model,
createdAt: at,
updatedAt: at,
messages: turns(0, prompt, reply, at),
})
if (!created.ok) return created
return { ok: true, data: { thread: toRow(created.data), created: true } }
}
/**
* The user's turn and the reply, numbered on from the turns already there.
* The ids count turns rather than carrying the thread's id: a thread's id is
* only known once its row exists, and a key needs to be unique within the
* transcript, not across the store.
*/
function turns(count: number, prompt: string, reply: string, at: Date): AiThread["messages"] {
return [
{ id: `turn-${count + 1}`, role: "user", text: prompt, at },
{ id: `turn-${count + 2}`, role: "assistant", text: reply, at },
]
}
/** Removes a thread for good; the rail drops it and the greeting takes its place. */
export async function deleteThread(id: string): Promise<Result<{ id: string }>> {
const removed = await db.aiThreads.remove(id)
if (!removed.ok) {
return { ok: false, error: { code: "not_found", message: "That thread has already been deleted." } }
}
return removed
}"use client"
import * as React from "react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { BotIcon } from "lucide-react"
import { PageHeader } from "@/components/ui/page-header"
import { deleteThread, sendPrompt } from "../actions"
import { type ThreadModel, type ThreadRow } from "../data"
import { MODELS } from "./chat-vocabulary"
import { Composer } from "./composer"
import { ThreadRail } from "./thread-rail"
import { Transcript } from "./transcript"
/** The one query key this page owns; every other key on the URL is left alone. */
const THREAD_KEY = "thread"
export type ChatViewProps = {
threads: ThreadRow[]
/** "Good afternoon" — worked out on the server against REFERENCE_DATE. */
greeting: string
firstName: string
initials: string
/** The signed-in member's face, beside the initials it falls back to. */
avatarUrl?: string
}
/**
* The assistant. One island, because picking a thread, writing a prompt and
* reading the reply are the same piece of state. The open thread lives in
* `?thread=`: it is seeded from the query on mount and owned here afterwards,
* and every change is written back with `replace`, so a thread is a link a
* reader can send and a preview can deep-link. `replace` rather than `push`,
* because switching threads is not a place the Back button should have to
* walk out of, and `scroll: false` because the reader is already looking at
* the pane that changed.
*/
export function ChatView({ threads, greeting, firstName, initials, avatarUrl }: ChatViewProps) {
const router = useRouter()
const pathname = usePathname()
const params = useSearchParams()
const [rows, setRows] = React.useState(threads)
const [selectedId, setSelectedId] = React.useState<string | null>(() => params.get(THREAD_KEY))
const selected = rows.find((row) => row.id === selectedId) ?? null
const [model, setModel] = React.useState<ThreadModel>(() => selected?.model ?? MODELS[0].value)
const [search, setSearch] = React.useState("")
const [draft, setDraft] = React.useState("")
const [sending, setSending] = React.useState(false)
const [error, setError] = React.useState<string | undefined>(undefined)
const [notice, setNotice] = React.useState<string | undefined>(undefined)
function write(id: string | null) {
// A copy of what is already there: a framed preview carries its palette
// and its density in the same query, and dropping those would repaint it.
const next = new URLSearchParams(params)
if (id) next.set(THREAD_KEY, id)
else next.delete(THREAD_KEY)
const query = next.toString()
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false })
}
function open(id: string | null, list: ThreadRow[] = rows) {
setSelectedId(id)
setError(undefined)
setNotice(undefined)
const row = id ? list.find((thread) => thread.id === id) : undefined
if (row) setModel(row.model)
write(id)
}
// One send at a time: Enter and the button both land here, and a server
// action is a round trip, so a second press while the first is in flight
// would append the same turns twice — or, against a real model, lose one.
const inFlight = React.useRef(false)
async function send() {
if (inFlight.current) return
inFlight.current = true
setSending(true)
let result: Awaited<ReturnType<typeof sendPrompt>>
try {
result = await sendPrompt(selected?.id ?? null, draft, model)
} finally {
inFlight.current = false
setSending(false)
}
if (!result.ok) {
setError(result.error.message)
return
}
const { thread, created } = result.data
// The thread just touched goes to the top of the rail, whichever group it
// came from: that is what "most recent first" means once it has moved.
const next = [thread, ...rows.filter((row) => row.id !== thread.id)]
setRows(next)
setDraft("")
setError(undefined)
if (created) open(thread.id, next)
}
async function remove() {
if (!selected) return
const result = await deleteThread(selected.id)
if (!result.ok) {
setError(result.error.message)
return
}
setRows((current) => current.filter((row) => row.id !== result.data.id))
open(null)
setNotice("Thread deleted.")
}
const composer = (
<Composer
draft={draft}
onDraftChange={(value) => {
setDraft(value)
if (error) setError(undefined)
}}
model={model}
onModelChange={setModel}
sending={sending}
error={error}
notice={notice}
onNotice={setNotice}
onSend={() => void send()}
showQuickPrompts={!selected}
/>
)
return (
<div className="flex flex-col gap-4">
<PageHeader
title="Assistant"
description="Ask, draft and explain, with every thread kept."
meta={`${rows.length} ${rows.length === 1 ? "thread" : "threads"}`}
/>
<div className="grid gap-4 lg:h-[calc(100svh-13rem)] lg:min-h-[34rem] lg:grid-cols-[17rem_minmax(0,1fr)]">
<ThreadRail
threads={rows}
selectedId={selected?.id ?? null}
search={search}
onSearchChange={setSearch}
onSelect={(id) => open(id)}
onNew={() => open(null)}
/>
<section aria-label="Assistant" className="flex min-h-[28rem] min-w-0 flex-col panel lg:min-h-0">
{selected ? (
<>
<Transcript thread={selected} initials={initials} avatarUrl={avatarUrl} onDelete={() => void remove()} />
<div className="shrink-0 border-t p-3">{composer}</div>
</>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-6 px-4 py-10">
<span
aria-hidden="true"
className="flex size-16 items-center justify-center rounded-full bg-brand-muted text-brand [&_svg]:size-8"
>
<BotIcon />
</span>
<div className="flex flex-col items-center gap-1 text-center">
<h2 className="type-display text-2xl text-foreground">
{greeting}, {firstName}
</h2>
<p className="text-base text-muted-foreground">How can I help?</p>
</div>
<div className="w-full max-w-2xl">{composer}</div>
</div>
)}
</section>
</div>
</div>
)
}/**
* The words the composer offers: the models as a reader picks them, and the
* four quick prompts. UI vocabulary, so it lives beside the island rather than
* in data.ts, which reads the store and must never reach the browser.
*/
import { ASSISTANT_MODELS } from "@/lib/dashboards/ai/vocabulary"
/** The models, from the AI dashboard's vocabulary — the same list the centred assistant offers. */
export const MODELS = ASSISTANT_MODELS
/** A chip under the composer and the prompt it fills in. */
export const QUICK_PROMPTS: { label: string; prompt: string }[] = [
// Questions of this page's own: the second assistant answers its suggestions
// in writing, and the same words should not get two kinds of answer.
{ label: "Summary", prompt: "Summarise this week's support tickets by theme." },
{ label: "Code", prompt: "Write a SQL query for the ten customers with the most seats." },
{ label: "Design", prompt: "Suggest a clearer label for the Billing tab in settings." },
{ label: "Research", prompt: "Compare the current pricing page with the draft and say which converts better." },
]
/** The rows under the thread list that a fuller product would route somewhere. */
export const RAIL_LINKS = ["Explore", "Library", "History", "Upgrade"] as const"use client"
import * as React from "react"
import { ArrowUpIcon, MicIcon, PaperclipIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { InputGroup, InputGroupAddon, InputGroupTextarea } from "@/components/ui/input-group"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Spinner } from "@/components/ui/spinner"
import { type ThreadModel } from "../data"
import { MODELS, QUICK_PROMPTS } from "./chat-vocabulary"
export type ComposerProps = {
draft: string
onDraftChange: (value: string) => void
model: ThreadModel
onModelChange: (value: ThreadModel) => void
sending: boolean
/** The refusal, rendered under the field it refers to. */
error?: string
/** A quiet line for the things this composer cannot do yet. */
notice?: string
onNotice: (text: string) => void
onSend: () => void
/** Shows the four quick prompts under the box — the greeting state only. */
showQuickPrompts?: boolean
className?: string
}
const NOT_WIRED = "Attachments and voice input are not wired up in the sample workspace."
/** The box a prompt is written in: the field, the attach and mic buttons, the model, and send. */
export function Composer({
draft,
onDraftChange,
model,
onModelChange,
sending,
error,
notice,
onNotice,
onSend,
showQuickPrompts = false,
className,
}: ComposerProps) {
const errorId = React.useId()
return (
<div className={cn("flex flex-col gap-2", className)}>
<form
onSubmit={(event) => {
event.preventDefault()
onSend()
}}
>
<InputGroup className="bg-card">
<InputGroupTextarea
aria-label="Prompt"
placeholder="Ask anything…"
rows={2}
value={draft}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
onChange={(event) => onDraftChange(event.target.value)}
onKeyDown={(event) => {
// Enter sends, Shift+Enter breaks a line — the convention every
// chat box a reader has used follows. The Enter that commits an
// IME composition is the input method's, not a send.
if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return
event.preventDefault()
onSend()
}}
className="min-h-14"
/>
<InputGroupAddon align="block-end" className="flex-wrap gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Attach a file"
onClick={() => onNotice(NOT_WIRED)}
>
<PaperclipIcon aria-hidden="true" />
</Button>
<Select
value={model}
onValueChange={(next) => onModelChange((next ?? MODELS[0].value) as ThreadModel)}
items={MODELS}
>
<SelectTrigger size="sm" aria-label="Model" className="min-w-40 font-normal text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODELS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Speak a prompt"
onClick={() => onNotice(NOT_WIRED)}
>
<MicIcon aria-hidden="true" />
</Button>
<Button type="submit" size="icon-sm" aria-label="Send" disabled={sending}>
{sending ? <Spinner aria-hidden="true" /> : <ArrowUpIcon aria-hidden="true" />}
</Button>
</div>
</InputGroupAddon>
</InputGroup>
</form>
{error ? (
<Callout id={errorId} variant="danger" role="alert">
{error}
</Callout>
) : null}
<p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{notice}
</p>
{showQuickPrompts ? (
<div className="flex flex-wrap items-center justify-center gap-2 pt-1">
{QUICK_PROMPTS.map((chip) => (
<Button
key={chip.label}
type="button"
variant="outline"
size="sm"
className="rounded-full"
onClick={() => onDraftChange(chip.prompt)}
>
{chip.label}
</Button>
))}
</div>
) : null}
</div>
)
}"use client"
import * as React from "react"
import { CompassIcon, HistoryIcon, LibraryIcon, PlusIcon, SparklesIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { DataList, DataListItem } from "@/components/ui/data-list"
import { EmptyState } from "@/components/ui/empty-state"
import { SearchInput } from "@/components/ui/search-input"
import { type ThreadGroup, type ThreadRow } from "../data"
import { RAIL_LINKS } from "./chat-vocabulary"
const GROUPS: readonly ThreadGroup[] = ["Today", "Yesterday", "Earlier"]
// The four rows a fuller assistant routes somewhere. They are drawn as the
// muted rows the reference has, and they go nowhere here: a link to a route
// this block does not ship would be a dead tab in every preview.
const RAIL_ICONS: Record<(typeof RAIL_LINKS)[number], React.ReactNode> = {
Explore: <CompassIcon aria-hidden="true" />,
Library: <LibraryIcon aria-hidden="true" />,
History: <HistoryIcon aria-hidden="true" />,
Upgrade: <SparklesIcon aria-hidden="true" />,
}
export type ThreadRailProps = {
threads: ThreadRow[]
selectedId: string | null
search: string
onSearchChange: (value: string) => void
onSelect: (id: string) => void
onNew: () => void
}
/** The left pane: a search, the threads by day, the four muted rows, and the one primary action. */
export function ThreadRail({ threads, selectedId, search, onSearchChange, onSelect, onNew }: ThreadRailProps) {
const needle = search.trim().toLowerCase()
const visible = needle ? threads.filter((thread) => thread.title.toLowerCase().includes(needle)) : threads
const grouped = GROUPS.map((group) => ({
group,
rows: visible.filter((thread) => thread.group === group),
})).filter((entry) => entry.rows.length > 0)
return (
<aside aria-label="Threads" className="flex min-h-0 flex-col panel">
<div className="shrink-0 border-b p-2">
<SearchInput
aria-label="Search chats"
placeholder="Search chats…"
size="sm"
value={search}
onValueChange={onSearchChange}
/>
</div>
<div className="max-h-[40svh] min-h-0 flex-1 overflow-y-auto lg:max-h-none">
{grouped.length === 0 ? (
<EmptyState size="sm" title="No threads" description="Nothing matches that search." />
) : (
grouped.map(({ group, rows }) => (
<section key={group} aria-label={group} className="py-2">
<p className="px-3 pb-1 type-eyebrow">{group}</p>
<DataList>
{rows.map((thread) => (
<DataListItem
key={thread.id}
selected={thread.id === selectedId}
onClick={() => onSelect(thread.id)}
aria-label={`Open ${thread.title}`}
className="py-1.5"
title={<span className={thread.id === selectedId ? undefined : "font-normal"}>{thread.title}</span>}
meta={thread.when}
/>
))}
</DataList>
</section>
))
)}
</div>
<ul aria-label="More" className="shrink-0 border-t px-1 py-2">
{RAIL_LINKS.map((label) => (
<li
key={label}
className="flex h-8 items-center gap-2 rounded-md px-2 text-sm text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0"
>
{RAIL_ICONS[label]}
{label}
</li>
))}
</ul>
<div className="shrink-0 border-t p-2">
<Button type="button" className="w-full" onClick={onNew}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
New chat
</Button>
</div>
</aside>
)
}"use client"
import * as React from "react"
import { BotIcon, Trash2Icon } from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { CopyButton } from "@/components/ui/copy-button"
import { Message, MessageAvatar, MessageContent, MessageFooter } from "@/components/ui/message"
import { type ThreadRow, type Turn } from "../data"
import { MODELS } from "./chat-vocabulary"
/** The brand mark beside every reply: the assistant has no face, so it gets the product's. */
function BrandMark() {
return (
<MessageAvatar aria-hidden="true" className="size-7 min-w-7 bg-brand-muted text-brand [&_svg]:size-4">
<BotIcon />
</MessageAvatar>
)
}
/** A reply, paragraph by paragraph: the text breaks where the author did. */
function Paragraphs({ text }: { text: string }) {
return (
<>
{text.split(/\n{2,}/).map((paragraph, index) => (
<p key={index} className="whitespace-pre-line">
{paragraph}
</p>
))}
</>
)
}
/**
* One turn each way, both the kit's Message around the kit's Bubble: the
* member's on the right in the accent, the reply on the left on the muted
* plane, with the time — and, under a reply, the copy control — in the footer.
*/
function UserTurn({ turn, initials, avatarUrl }: { turn: Turn; initials: string; avatarUrl?: string }) {
return (
<li data-role="user">
<Message align="end">
<MessageAvatar className="min-w-7">
<Avatar size="sm">
{avatarUrl ? <AvatarImage src={avatarUrl} alt="" /> : null}
<AvatarFallback className="text-avatar">{initials}</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent className="gap-1">
<Bubble variant="default" align="end">
<BubbleContent className="flex flex-col gap-2">
<Paragraphs text={turn.text} />
</BubbleContent>
</Bubble>
<MessageFooter className="font-normal">
<time className="tabular-nums">{turn.time}</time>
</MessageFooter>
</MessageContent>
</Message>
</li>
)
}
function AssistantTurn({ turn }: { turn: Turn }) {
return (
<li data-role="assistant">
<Message align="start">
<BrandMark />
<MessageContent className="gap-1">
<Bubble variant="muted" align="start">
<BubbleContent className="flex flex-col gap-2">
<Paragraphs text={turn.text} />
</BubbleContent>
</Bubble>
<MessageFooter className="gap-1 font-normal">
<time className="tabular-nums">{turn.time}</time>
<CopyButton value={turn.text} label="Copy reply" successLabel="Reply copied" size="icon-xs" />
</MessageFooter>
</MessageContent>
</Message>
</li>
)
}
export type TranscriptProps = {
thread: ThreadRow
/** The signed-in member's initials, on their own turns. */
initials: string
/** Their face, where they have one. */
avatarUrl?: string
onDelete: () => void
}
/** The open thread: its title and model up top, the turns, newest at the bottom. */
export function Transcript({ thread, initials, avatarUrl, onDelete }: TranscriptProps) {
const scrollerRef = React.useRef<HTMLDivElement>(null)
const count = thread.messages.length
// A new turn lands under the fold of the transcript's own scroller, so that
// is what moves — never the page. scrollIntoView would have scrolled the
// window to the last turn on a phone, where the pane has no scroller of its
// own, and a page that jumps on load reads as broken.
React.useEffect(() => {
const scroller = scrollerRef.current
if (scroller) scroller.scrollTop = scroller.scrollHeight
}, [count])
const model = MODELS.find((option) => option.value === thread.model)?.label ?? thread.model
return (
<div className="flex min-h-0 flex-1 flex-col">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-3">
<div className="flex min-w-0 flex-col gap-0.5">
<h2 className="truncate text-sm font-semibold">{thread.title}</h2>
<p className="text-xs text-muted-foreground">
{count} turns · updated {thread.when}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge variant="outline">{model}</Badge>
<Button type="button" variant="ghost" size="icon-sm" aria-label="Delete thread" onClick={onDelete}>
<Trash2Icon aria-hidden="true" />
</Button>
</div>
</header>
<div ref={scrollerRef} className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
<ol aria-label="Conversation" className="flex flex-col gap-4">
{thread.messages.map((turn) =>
turn.role === "user" ? (
<UserTurn key={turn.id} turn={turn} initials={initials} avatarUrl={avatarUrl} />
) : (
<AssistantTurn key={turn.id} turn={turn} />
)
)}
</ol>
</div>
</div>
)
}