Skip to contentVibraUI
Part of the AI dashboardinstalls at /ai/assistant-v2

Assistant v2

The assistant as one centred column: a greeting by name and time of day, six suggestion cards that fill the composer, a composer with files and a model, and answers with lists, code and a copy, regenerate and rating row.

Open the live page

Installs at /ai/assistant-v2, beside the AI dashboard's other assistant. The page is a server component: it greets the first member by first name and the time of day at REFERENCE_DATE, read in UTC through vocabulary-ai's greetingFor, and hands six suggestions to one client island. The column is centred and at most 45rem wide. Before anything is asked it shows the greeting and the suggestion cards — each an icon, a title and a line — which fill the composer and move focus to it rather than sending; after, it is the conversation, each question joining it the moment it is sent and brought to the top of the view with its answer landing under it (smoothly, unless the reader asks for less motion): the member's turns on the right in a muted Bubble with the files they carried, the answers on the left beside the assistant's mark in plain text, with lists as lists, a draft set apart as a quote, and code in a CodeBlock (mono, its language, a copy button), and under each answer copy, regenerate and a thumb up or down that are one choice; Send and Regenerate stay focusable while their answer is on its way, as a busy AsyncButton does, and take no second press. The composer holds the files to send as the kit's Attachment chips that come off again, the model (vocabulary-ai's list, the same one the other assistant offers) and a named Send; Enter sends, Shift+Enter breaks a line, and the composer stays at the foot of the page: one box and one status line, in the same place before and during a conversation, so neither the first answer nor a new conversation moves focus out of the box or replaces the live region. Every answer comes from askAssistant, a server action: a suggestion's prompt gets its own written answer — two versions each, so Regenerate says it another way — and anything else a polite general one that says the sample has no model behind it; it refuses a blank or overlong prompt and a model the workspace does not offer, printed under the composer. There is no network: replyFor in data.ts is the seam for a real model. An answer types itself out unless the reader asks for less motion (useReducedMotion), when it is simply there. Composes AppShell, PageHeader, Button, Message, MessageContent, MessageFooter, Bubble, BubbleContent, CodeBlock, CopyButton, InputGroup, InputGroupAddon, InputGroupTextarea, NativeSelect, Attachment, AttachmentAction, Spinner and Callout.

Preview

Install

npx shadcn@latest add @vibra/ai-assistant-v2

Needs the @vibra registry in your components.json — set it up once.

Source

app/ai/assistant-v2/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"

import { signOut } from "./actions"
import { AssistantView } from "./components/assistant-view"
import { currentUser, greeting, shellNotifications, suggestions } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/ai/nav"

/**
 * The assistant as one centred column. The page is a server component: it
 * works out the greeting against REFERENCE_DATE and hands the six suggestions
 * to one client island, which owns the conversation; every answer comes back
 * from a server action, the one place a real model would be called.
 */
export default function AssistantV2Page() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.assistantV2}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <AssistantView greeting={greeting()} suggestions={suggestions()} />
    </AppShell>
  )
}
app/ai/assistant-v2/data.ts
/**
 * What the centred assistant reads and says. The member is the first row of
 * `db.members`; the greeting is the AI dashboard's own rule over
 * `REFERENCE_DATE`, in UTC. There is no model behind this page: the six
 * suggestions each carry a written answer, and anything else gets a polite
 * general one — `replyFor` is the seam a consumer swaps for a call to theirs.
 */
import { getInitials } from "@/lib/format"
import { greetingFor } from "@/lib/dashboards/ai/vocabulary"
import { db, REFERENCE_DATE } from "@/lib/sample-data"

import { SUGGESTIONS, type ReplyBlock } from "./replies"

export type { ReplyBlock, Suggestion, SuggestionIcon } from "./replies"

/** A suggestion as a card shows it: what it asks, without its answer. */
export function suggestions() {
  return SUGGESTIONS.map(({ id, icon, title, line, prompt }) => ({ id, icon, title, line, prompt }))
}

const GENERAL: ReplyBlock[][] = [
  [
    { kind: "text", text: "Thanks for asking. This assistant answers from a fixed set of examples in the sample workspace, so it has nothing written for that question." },
    { kind: "text", text: "Try one of the suggestions to see a full answer, or connect a model to the page and it will answer anything." },
  ],
  [
    { kind: "text", text: "I do not have an answer for that in the sample workspace." },
    { kind: "text", text: "The six suggestions show what a connected model would do here: a summary, a query, a draft, a checklist, an explanation and some names. Pick one to see it answered." },
  ],
]

const normalise = (text: string) => text.trim().replace(/\s+/g, " ").toLowerCase()

/**
 * The answer to a prompt: the suggestion's own when the prompt is one of
 * theirs, otherwise the general one. `attempt` picks among the written
 * versions, so Regenerate gives the next one rather than the same words.
 */
export function replyFor(prompt: string, attempt = 0, files = 0): { blocks: ReplyBlock[]; suggestion: string | null } {
  const asked = normalise(prompt)
  const match = SUGGESTIONS.find((suggestion) => normalise(suggestion.prompt) === asked)
  const versions = match ? match.replies : GENERAL
  const blocks = versions[Math.abs(attempt) % versions.length]
  if (files === 0 || match) return { blocks, suggestion: match?.id ?? null }
  const note: ReplyBlock = {
    kind: "text",
    text: `You attached ${files === 1 ? "a file" : `${files} files`}; the sample workspace keeps the names but does not read what is inside.`,
  }
  return { blocks: [note, ...blocks], suggestion: null }
}

// 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 greeting: the time of day at `REFERENCE_DATE`, and the name the member goes by. */
export function greeting(): string {
  return `${greetingFor(REFERENCE_DATE)}, ${memberRow().name.split(" ")[0]}`
}

/** The person looking at the page: the first member of the workspace. */
export function currentUser() {
  const member = memberRow()
  return { name: member.name, email: member.email, initials: getInitials(member.name), avatarUrl: member.avatarUrl }
}

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 }))
}
app/ai/assistant-v2/replies.ts
/**
 * The six things the assistant offers to do, and what it answers to each —
 * two versions apiece, so Regenerate says it another way. Written for
 * Northwind's own work: its signups, its release, its customers. An answer is
 * blocks — paragraphs, lists and code — so the page can draw a list as a list
 * and a query in mono with a copy button.
 */

export type ReplyBlock =
  | { kind: "text"; text: string }
  | { kind: "list"; ordered: boolean; items: string[] }
  | { kind: "code"; language: string; code: string }
  /** Words written for someone else to send, set apart from the assistant's own. */
  | { kind: "quote"; lines: string[] }

export type SuggestionIcon = "summary" | "code" | "draft" | "checklist" | "metric" | "naming"

export type Suggestion = { id: string; icon: SuggestionIcon; title: string; line: string; prompt: string }

type Written = Suggestion & { replies: ReplyBlock[][] }

const WEEKLY_ACTIVE = `select
  date_trunc('week', e.occurred_at) as week,
  count(distinct e.workspace_id) as active_workspaces
from events e
where e.occurred_at >= date_trunc('week', now()) - interval '12 weeks'
group by 1
order by 1;`

const WEEKLY_ACTIVE_BY_PLAN = `select
  date_trunc('week', e.occurred_at) as week,
  w.plan,
  count(distinct e.workspace_id) as active_workspaces
from events e
join workspaces w on w.id = e.workspace_id
where e.occurred_at >= date_trunc('week', now()) - interval '12 weeks'
group by 1, 2
order by 1, 2;`

export const SUGGESTIONS: Written[] = [
  {
    id: "summary",
    icon: "summary",
    title: "Summarise last week",
    line: "How signups moved, and why",
    prompt: "Summarise how signups moved last week and what drove the change.",
    replies: [
      [
        { kind: "text", text: "Signups climbed for the fourth week running, to 312 last week, and most of the lift came from workspaces that finished the new setup checklist." },
        {
          kind: "list",
          ordered: false,
          items: [
            "Two in three new workspaces finished setup, against about half a month earlier.",
            "Visits from the pricing page grew after the plan copy changed.",
            "Trials on the team plan grew too, but fewer of them invited a second person.",
          ],
        },
        { kind: "text", text: "The last point is the one to watch: a trial that stays a team of one rarely converts." },
      ],
      [
        { kind: "text", text: "Short version: 312 signups, the best week in the last twelve, driven by the setup checklist." },
        {
          kind: "list",
          ordered: false,
          items: [
            "The checklist is doing the work: finishers stay, and most new workspaces now finish.",
            "The pricing page sends more people than it did.",
            "Team-plan trials are up, but invitations inside them are down, which is worth a look.",
          ],
        },
      ],
    ],
  },
  {
    id: "query",
    icon: "code",
    title: "Write a SQL query",
    line: "Weekly active workspaces, 12 weeks",
    prompt: "Write a SQL query that counts weekly active workspaces for the last 12 weeks.",
    replies: [
      [
        { kind: "text", text: "Here is a query for Postgres. It counts a workspace as active in a week if anyone in it did anything that week." },
        { kind: "code", language: "sql", code: WEEKLY_ACTIVE },
        { kind: "text", text: "If the events table is large, an index on (occurred_at, workspace_id) keeps it fast." },
      ],
      [
        { kind: "text", text: "The same count, split by plan, so you can see where the active workspaces sit:" },
        { kind: "code", language: "sql", code: WEEKLY_ACTIVE_BY_PLAN },
        { kind: "text", text: "Drop the join and the plan column for the single weekly total." },
      ],
    ],
  },
  {
    id: "draft",
    icon: "draft",
    title: "Draft a reply",
    line: "A customer asks about seat credits",
    prompt: "Draft a short reply to a customer asking whether unused seats are credited when someone leaves mid-month.",
    replies: [
      [
        { kind: "text", text: "Here is a draft you can send as it is:" },
        {
          kind: "quote",
          lines: [
            "Thanks for asking before the renewal.",
            "When you remove a seat partway through a month, the unused days are credited on your next invoice, so you only pay for the time each person had access.",
            "If it helps, I can go through your seats with you before the renewal date.",
          ],
        },
        { kind: "text", text: "Drop the last line if you would rather not offer a call." },
      ],
      [
        { kind: "text", text: "A shorter version:" },
        {
          kind: "quote",
          lines: [
            "Yes — a seat removed mid-month is credited for the days left, on your next invoice.",
            "There is nothing to do on your side; the credit is applied for you.",
          ],
        },
      ],
    ],
  },
  {
    id: "checklist",
    icon: "checklist",
    title: "Plan the release",
    line: "A checklist for Thursday",
    prompt: "Make a checklist for Thursday's release of scheduled reports.",
    replies: [
      [
        { kind: "text", text: "A checklist in the order I would run it:" },
        {
          kind: "list",
          ordered: true,
          items: [
            "Freeze the branch on Wednesday at noon and tag the release candidate.",
            "Run the regression pass on staging, exports and invoices first.",
            "Send a scheduled report to three time zones and check each arrives on time.",
            "Ship at 9:00 with the rollback steps open beside you.",
            "Watch the error log and the queue for an hour, then post in the release channel.",
          ],
        },
        { kind: "text", text: "If step two fails, the release moves, not the checklist." },
      ],
      [
        { kind: "text", text: "The same five steps, by the clock:" },
        {
          kind: "list",
          ordered: true,
          items: [
            "Wednesday noon: freeze and tag.",
            "Wednesday afternoon: regression pass on staging.",
            "Thursday 8:30: a test report to three time zones.",
            "Thursday 9:00: ship, rollback ready.",
            "Thursday 10:00: all clear in the release channel.",
          ],
        },
      ],
    ],
  },
  {
    id: "metric",
    icon: "metric",
    title: "Explain a metric",
    line: "Net revenue retention, in two lines",
    prompt: "Explain what net revenue retention measures, in two sentences.",
    replies: [
      [
        { kind: "text", text: "Net revenue retention is what the customers you had a year ago pay you now — upgrades added, downgrades and cancellations taken away — as a share of what they paid then." },
        { kind: "text", text: "Above 100% means that group pays you more than it used to, before you win anyone new." },
      ],
      [
        { kind: "text", text: "Take last year's customers, and compare what they pay today with what they paid then, counting upgrades and losses." },
        { kind: "text", text: "That ratio is net revenue retention; over 100% means you grow even without new customers." },
      ],
    ],
  },
  {
    id: "naming",
    icon: "naming",
    title: "Name the steps",
    line: "Clearer onboarding step names",
    prompt: "Suggest clearer names for the four onboarding steps: Setup, Connect, Invite, Done.",
    replies: [
      [
        { kind: "text", text: "Names that say what the person does in each step:" },
        {
          kind: "list",
          ordered: true,
          items: ["Setup → Name your workspace", "Connect → Add your data", "Invite → Bring your team", "Done → Open your dashboard"],
        },
        { kind: "text", text: "Starting each with a verb makes every step a small task rather than a stage." },
      ],
      [
        { kind: "text", text: "Or shorter, for a narrow stepper:" },
        { kind: "list", ordered: true, items: ["Name it", "Add data", "Invite", "Open"] },
      ],
    ],
  },
]
app/ai/assistant-v2/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { isModel, type AssistantModel } from "@/lib/dashboards/ai/vocabulary"
import { type Result } from "@/lib/sample-data"

import { replyFor, type ReplyBlock } from "./data"

/**
 * What the assistant does. A server action, so the page stays a server
 * component and the answer comes from where a real model would sit; `Result`,
 * so a refusal says what was refused and the island prints it under the
 * composer. There is no model behind the sample: `replyFor` answers from the
 * written examples, and this is the one function to point at a real one.
 */

export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}

export type Question = {
  prompt: string
  model: string
  /** The names of the files the member attached; the sample keeps the names only. */
  files?: string[]
  /** Which answer this is to the same prompt — Regenerate asks for the next. */
  attempt?: number
}

export type Answer = { blocks: ReplyBlock[]; model: AssistantModel; suggestion: string | null }

const MAX_PROMPT = 4_000

/** Answers a prompt with the model picked; refuses a blank prompt, an overlong one, and a model the workspace does not offer. */
export async function askAssistant(question: Question): Promise<Result<Answer>> {
  const prompt = String(question?.prompt ?? "").trim()
  const files = Array.isArray(question?.files) ? question.files.filter((name) => typeof name === "string" && name) : []
  if (!prompt) {
    return { ok: false, error: { code: "required", message: "Write a message before sending.", field: "prompt" } }
  }
  if (prompt.length > MAX_PROMPT) {
    return { ok: false, error: { code: "invalid", message: `Keep a message under ${MAX_PROMPT.toLocaleString("en-US")} characters.`, field: "prompt" } }
  }
  const model = String(question?.model ?? "")
  if (!isModel(model)) {
    return { ok: false, error: { code: "invalid", message: "Pick one of the models the workspace offers.", field: "model" } }
  }
  const attempt = Number.isInteger(question?.attempt) ? Number(question.attempt) : 0
  const { blocks, suggestion } = replyFor(prompt, attempt, files.length)
  return { ok: true, data: { blocks, model, suggestion } }
}
app/ai/assistant-v2/components/assistant-composer.tsx
"use client"

import * as React from "react"
import { ArrowUpIcon, FileTextIcon, PaperclipIcon, XIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { formatBytes } from "@/lib/format"
import { ASSISTANT_MODELS, type AssistantModel } from "@/lib/dashboards/ai/vocabulary"
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
} from "@/components/ui/attachment"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { InputGroup, InputGroupAddon, InputGroupTextarea } from "@/components/ui/input-group"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Spinner } from "@/components/ui/spinner"

export type DraftFile = { name: string; size: string }

export type AssistantComposerProps = {
  draft: string
  onDraftChange: (value: string) => void
  files: DraftFile[]
  onFilesChange: (files: DraftFile[]) => void
  model: AssistantModel
  onModelChange: (model: AssistantModel) => void
  onSend: () => void
  sending: boolean
  /** The refusal, under the box it is about. */
  error?: string
  className?: string
  ref?: React.Ref<HTMLTextAreaElement>
}

/**
 * The box a message is written in: the text, the files to go with it — each
 * a chip that comes off again — the model, and Send. Enter sends and
 * Shift+Enter breaks a line; the Enter that ends an input method's
 * composition is the method's own.
 */
export function AssistantComposer({
  draft,
  onDraftChange,
  files,
  onFilesChange,
  model,
  onModelChange,
  onSend,
  sending,
  error,
  className,
  ref,
}: AssistantComposerProps) {
  const pickerRef = React.useRef<HTMLInputElement>(null)
  const errorId = React.useId()

  return (
    <form
      data-slot="assistant-composer"
      className={cn("flex flex-col gap-2", className)}
      onSubmit={(event) => {
        event.preventDefault()
        onSend()
      }}
    >
      <InputGroup className="bg-card">
        {files.length > 0 ? (
          <InputGroupAddon align="block-start" className="flex-wrap gap-1.5">
            <ul aria-label="Files to send" className="flex flex-wrap gap-1.5">
              {files.map((file) => (
                <li key={file.name}>
                  <Attachment size="xs">
                    <AttachmentMedia>
                      <FileTextIcon aria-hidden="true" />
                    </AttachmentMedia>
                    <AttachmentContent>
                      <AttachmentTitle className="max-w-40">{file.name}</AttachmentTitle>
                      <AttachmentDescription className="tabular-nums">{file.size}</AttachmentDescription>
                    </AttachmentContent>
                    <AttachmentActions>
                      <AttachmentAction
                        aria-label={`Remove ${file.name}`}
                        onClick={() => onFilesChange(files.filter((entry) => entry.name !== file.name))}
                      >
                        <XIcon aria-hidden="true" />
                      </AttachmentAction>
                    </AttachmentActions>
                  </Attachment>
                </li>
              ))}
            </ul>
          </InputGroupAddon>
        ) : null}
        <InputGroupTextarea
          ref={ref}
          aria-label="Message"
          placeholder="Ask about the workspace…"
          rows={2}
          value={draft}
          aria-invalid={error ? true : undefined}
          aria-describedby={error ? errorId : undefined}
          onChange={(event) => onDraftChange(event.target.value)}
          onKeyDown={(event) => {
            if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return
            event.preventDefault()
            onSend()
          }}
          className="min-h-14"
        />
        <InputGroupAddon align="block-end" className="gap-1">
          <input
            ref={pickerRef}
            type="file"
            multiple
            tabIndex={-1}
            aria-hidden="true"
            className="sr-only"
            data-slot="assistant-file-picker"
            onChange={(event) => {
              const picked = Array.from(event.target.files ?? []).map((file) => ({ name: file.name, size: formatBytes(file.size, 1) }))
              const names = new Set(files.map((file) => file.name))
              onFilesChange([...files, ...picked.filter((file) => !names.has(file.name))])
              event.target.value = ""
            }}
          />
          <Button type="button" variant="ghost" size="icon-sm" aria-label="Attach files" onClick={() => pickerRef.current?.click()}>
            <PaperclipIcon aria-hidden="true" />
          </Button>
          <NativeSelect
            size="sm"
            aria-label="Model"
            value={model}
            onChange={(event) => onModelChange(event.target.value as AssistantModel)}
            className="w-auto"
          >
            {ASSISTANT_MODELS.map((option) => (
              <NativeSelectOption key={option.value} value={option.value}>
                {option.label}
              </NativeSelectOption>
            ))}
          </NativeSelect>
          {/* Busy but focusable, as AsyncButton is: a natively disabled button
              drops the focus it was pressed with. */}
          <Button
            type="submit"
            size="icon-sm"
            aria-label="Send"
            disabled={sending}
            focusableWhenDisabled
            aria-disabled={sending || undefined}
            aria-busy={sending || undefined}
            className="ml-auto"
          >
            {sending ? <Spinner aria-hidden="true" /> : <ArrowUpIcon aria-hidden="true" />}
          </Button>
        </InputGroupAddon>
      </InputGroup>
      {error ? (
        <Callout id={errorId} variant="danger" role="alert">
          {error}
        </Callout>
      ) : null}
    </form>
  )
}
app/ai/assistant-v2/components/assistant-turn.tsx
"use client"

import * as React from "react"
import { FileTextIcon, RefreshCwIcon, SparklesIcon, ThumbsDownIcon, ThumbsUpIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { modelLabel, type AssistantModel } from "@/lib/dashboards/ai/vocabulary"
import {
  Attachment,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
} from "@/components/ui/attachment"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { CopyButton } from "@/components/ui/copy-button"
import { Message, MessageContent, MessageFooter } from "@/components/ui/message"

import { type ReplyBlock } from "../data"
import { ReplyBlocks } from "./reply-blocks"

export type Rating = "up" | "down" | null

export type UserTurnData = { id: string; role: "user"; text: string; files: { name: string; size: string }[] }

export type AssistantTurnData = {
  id: string
  role: "assistant"
  blocks: ReplyBlock[]
  model: AssistantModel
  /** The prompt it answers, which Regenerate asks again. */
  prompt: string
  files: string[]
  attempt: number
  rating: Rating
}

export type TurnData = UserTurnData | AssistantTurnData

/** The assistant has no face, so it wears the product's mark — the AI dashboard's sparkles on the brand's muted plane. */
export function AssistantMark({ className }: { className?: string }) {
  return (
    <span
      aria-hidden="true"
      data-slot="assistant-mark"
      className={cn(
        "flex size-7 shrink-0 items-center justify-center self-start rounded-full bg-brand-muted text-brand [&_svg]:size-4",
        className
      )}
    >
      <SparklesIcon />
    </span>
  )
}

/** The member's turn: on the right, in a muted bubble, with the files it carried. */
export function UserTurn({ turn }: { turn: UserTurnData }) {
  return (
    <li data-role="user" data-turn={turn.id} className="flex scroll-mt-20 flex-col items-end gap-1.5">
      {turn.files.length > 0 ? (
        <ul aria-label="Attached files" className="flex flex-wrap justify-end gap-1.5">
          {turn.files.map((file) => (
            <li key={file.name}>
              <Attachment size="xs">
                <AttachmentMedia>
                  <FileTextIcon aria-hidden="true" />
                </AttachmentMedia>
                <AttachmentContent>
                  <AttachmentTitle className="max-w-40">{file.name}</AttachmentTitle>
                  <AttachmentDescription className="tabular-nums">{file.size}</AttachmentDescription>
                </AttachmentContent>
              </Attachment>
            </li>
          ))}
        </ul>
      ) : null}
      <Message align="end" className="w-full">
        <MessageContent>
          <Bubble variant="muted" align="end">
            <BubbleContent className="whitespace-pre-line">{turn.text}</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
    </li>
  )
}

export type AssistantTurnProps = {
  turn: AssistantTurnData
  /** Characters of the answer drawn so far while it types; undefined once it has. */
  shown?: number
  onRegenerate: () => void
  onRate: (rating: Rating) => void
  busy: boolean
}

const plain = (blocks: ReplyBlock[]) =>
  blocks
    .map((block) =>
      block.kind === "text"
        ? block.text
        : block.kind === "code"
          ? block.code
          : block.kind === "list"
            ? block.items.map((item, index) => (block.ordered ? `${index + 1}. ${item}` : `- ${item}`)).join("\n")
            : block.lines.join("\n")
    )
    .join("\n\n")

/**
 * The assistant's turn: on the left, beside the mark, in plain text rather
 * than a bubble; its actions under it — copy the answer, ask for another
 * version, and a thumb up or down, which are one choice between two. The
 * actions stay while an answer types itself out, so a reader who pressed
 * Regenerate keeps their place on it.
 */
export function AssistantTurn({ turn, shown, onRegenerate, onRate, busy }: AssistantTurnProps) {
  const typing = shown !== undefined
  return (
    <li data-role="assistant" data-turn={turn.id} data-typing={typing || undefined} aria-busy={typing || undefined} className="scroll-mt-20">
      <Message align="start">
        <AssistantMark className="mt-0.5" />
        <MessageContent className="min-w-0 flex-1 gap-2">
          <ReplyBlocks blocks={turn.blocks} shown={shown} />
          <MessageFooter className="-ml-1 gap-0.5 px-0 font-normal">
            <CopyButton value={plain(turn.blocks)} label="Copy answer" successLabel="Answer copied" size="icon-xs" />
            <Button
              type="button"
              variant="ghost"
              size="icon-xs"
              aria-label="Regenerate"
              // Busy but focusable, as AsyncButton is: a natively disabled
              // button drops the focus it was pressed with.
              disabled={busy}
              focusableWhenDisabled
              aria-disabled={busy || undefined}
              aria-busy={busy || undefined}
              onClick={onRegenerate}
            >
              <RefreshCwIcon aria-hidden="true" />
            </Button>
            <Button
              type="button"
              variant="ghost"
              size="icon-xs"
              aria-label="Good answer"
              aria-pressed={turn.rating === "up"}
              onClick={() => onRate(turn.rating === "up" ? null : "up")}
              className="aria-pressed:bg-brand-muted aria-pressed:text-foreground"
            >
              <ThumbsUpIcon aria-hidden="true" />
            </Button>
            <Button
              type="button"
              variant="ghost"
              size="icon-xs"
              aria-label="Poor answer"
              aria-pressed={turn.rating === "down"}
              onClick={() => onRate(turn.rating === "down" ? null : "down")}
              className="aria-pressed:bg-brand-muted aria-pressed:text-foreground"
            >
              <ThumbsDownIcon aria-hidden="true" />
            </Button>
            <span className="ml-1.5 text-xs text-faint-foreground">{modelLabel(turn.model)}</span>
          </MessageFooter>
        </MessageContent>
      </Message>
    </li>
  )
}
app/ai/assistant-v2/components/assistant-view.tsx
"use client"

import * as React from "react"
import { SquarePenIcon } from "lucide-react"

import { useReducedMotion } from "@/hooks/use-reduced-motion"
import { ASSISTANT_MODELS, modelLabel, type AssistantModel } from "@/lib/dashboards/ai/vocabulary"
import { Button } from "@/components/ui/button"
import { PageHeader } from "@/components/ui/page-header"

import { askAssistant, type Question } from "../actions"
import { type Suggestion } from "../data"
import { AssistantComposer, type DraftFile } from "./assistant-composer"
import {
  AssistantMark,
  AssistantTurn,
  UserTurn,
  type AssistantTurnData,
  type Rating,
  type TurnData,
  type UserTurnData,
} from "./assistant-turn"
import { replyLength } from "./reply-blocks"
import { SuggestionCards } from "./suggestion-cards"

export type AssistantViewProps = {
  /** "Good afternoon, Sonia" — worked out on the server against REFERENCE_DATE. */
  greeting: string
  suggestions: Suggestion[]
}

/** Characters an answer types per tick, and how often it ticks. */
const STEP = 6
const TICK = 24

const RATED: Record<Exclude<Rating, null>, string> = {
  up: "Thanks — marked as a good answer.",
  down: "Thanks — marked as a poor answer.",
}

/**
 * The assistant as one centred column. Before anything is asked it greets the
 * member and offers six things to start from, each of which fills the
 * composer; after, it is the conversation — the member's turns on the right,
 * the answers on the left — with the composer held at the foot of the page.
 * An answer types itself out, unless the reader has asked for less motion;
 * then it is simply there. Only the answer to the question on its way may
 * land: starting a new conversation lets go of it, so an answer the reader has
 * moved past is dropped rather than shown. A refusal is printed under the
 * composer, and a success said once in the status line.
 */
export function AssistantView({ greeting, suggestions }: AssistantViewProps) {
  const rootRef = React.useRef<HTMLDivElement>(null)
  const composerRef = React.useRef<HTMLTextAreaElement>(null)
  const greetingId = React.useId()
  const reduced = useReducedMotion(rootRef)

  const [turns, setTurns] = React.useState<TurnData[]>([])
  const [draft, setDraft] = React.useState("")
  const [files, setFiles] = React.useState<DraftFile[]>([])
  const [model, setModel] = React.useState<AssistantModel>(ASSISTANT_MODELS[0].value)
  const [sending, setSending] = React.useState(false)
  const [error, setError] = React.useState<string>()
  const [notice, setNotice] = React.useState("")
  const [typing, setTyping] = React.useState<{ id: string; shown: number } | null>(null)
  // The question on its way, if one is. The ref, not the order answers
  // arrive in, decides which answer lands (CONVENTIONS rule 12).
  const latest = React.useRef<Question | null>(null)
  const counter = React.useRef(0)
  const nextId = () => `turn-${++counter.current}`

  // The answer on its way out, a few characters a tick. The interval is the
  // only thing that moves it, so nothing here sets state as the effect runs.
  const typingId = typing?.id
  const typingTotal = React.useMemo(() => {
    const turn = turns.find((entry) => entry.id === typingId)
    return turn?.role === "assistant" ? replyLength(turn.blocks) : 0
  }, [turns, typingId])
  React.useEffect(() => {
    if (!typingId || reduced) return
    const timer = window.setInterval(() => {
      setTyping((current) => {
        if (!current) return null
        const shown = current.shown + STEP
        return shown >= typingTotal ? null : { ...current, shown }
      })
    }, TICK)
    return () => window.clearInterval(timer)
  }, [typingId, typingTotal, reduced])

  // Each new question is brought to the top of the view — below the shell's
  // bar, by its scroll margin — and its answer lands under it, so the two read
  // together; the page is the conversation's scroller. Smooth, unless the
  // reader has asked for less motion.
  const lastQuestion = [...turns].reverse().find((turn) => turn.role === "user")?.id
  React.useEffect(() => {
    if (!lastQuestion) return
    rootRef.current
      ?.querySelector(`[data-turn="${lastQuestion}"]`)
      ?.scrollIntoView?.({ block: "start", behavior: reduced ? "auto" : "smooth" })
  }, [lastQuestion, reduced])

  async function send() {
    if (latest.current) return
    const prompt = draft.trim()
    if (!prompt) {
      setError("Write a message before sending.")
      return
    }
    const typed = draft
    const attached = files
    const question: Question = { prompt, model, files: attached.map((file) => file.name) }
    const asked: UserTurnData = { id: nextId(), role: "user", text: prompt, files: attached }
    latest.current = question
    setSending(true)
    setError(undefined)
    // The question joins the conversation as it is sent, and the box is free
    // for the next one while the answer is on its way.
    setTurns((current) => [...current, asked])
    setDraft("")
    setFiles([])
    const result = await askAssistant(question)
    if (latest.current !== question) return
    latest.current = null
    setSending(false)
    if (!result.ok) {
      // Refused, it never joined: it goes back in the box to be put right —
      // unless the reader has started writing something else there since.
      setTurns((current) => current.filter((turn) => turn.id !== asked.id))
      setDraft((current) => current || typed)
      setFiles((current) => (current.length > 0 ? current : attached))
      setError(result.error.message)
      return
    }
    const answer: AssistantTurnData = {
      id: nextId(),
      role: "assistant",
      blocks: result.data.blocks,
      model: result.data.model,
      prompt,
      files: attached.map((file) => file.name),
      attempt: 0,
      rating: null,
    }
    setTurns((current) => [...current, answer])
    setNotice(`Answered with ${modelLabel(answer.model)}.`)
    if (!reduced) setTyping({ id: answer.id, shown: 0 })
  }

  async function regenerate(turn: AssistantTurnData) {
    if (latest.current) return
    const question: Question = { prompt: turn.prompt, model: turn.model, files: turn.files, attempt: turn.attempt + 1 }
    latest.current = question
    setSending(true)
    setError(undefined)
    const result = await askAssistant(question)
    if (latest.current !== question) return
    latest.current = null
    setSending(false)
    if (!result.ok) {
      setError(result.error.message)
      return
    }
    setTurns((current) =>
      current.map((entry) =>
        entry.id === turn.id ? { ...turn, blocks: result.data.blocks, attempt: turn.attempt + 1, rating: null } : entry
      )
    )
    setNotice("Answer regenerated.")
    if (!reduced) setTyping({ id: turn.id, shown: 0 })
  }

  function rate(turn: AssistantTurnData, rating: Rating) {
    setTurns((current) => current.map((entry) => (entry.id === turn.id ? { ...turn, rating } : entry)))
    setNotice(rating ? RATED[rating] : "Rating cleared.")
  }

  function restart() {
    // Whatever is still on its way answers a conversation that is gone.
    latest.current = null
    setSending(false)
    setTurns([])
    setTyping(null)
    setError(undefined)
    setNotice("Started a new conversation.")
    composerRef.current?.focus()
  }

  const composer = (
    <AssistantComposer
      ref={composerRef}
      draft={draft}
      onDraftChange={(value) => {
        setDraft(value)
        if (error) setError(undefined)
      }}
      files={files}
      onFilesChange={setFiles}
      model={model}
      onModelChange={setModel}
      onSend={() => void send()}
      sending={sending}
      error={error}
    />
  )
  const status = (
    <p data-slot="assistant-status" role="status" aria-live="polite" className="min-h-5 text-center text-xs text-muted-foreground">
      {notice}
    </p>
  )

  return (
    <div ref={rootRef} data-slot="assistant-view" className="flex flex-col gap-4">
      <PageHeader
        title="Assistant"
        description="Ask about signups, releases and customers."
        actions={
          turns.length > 0 ? (
            <Button type="button" variant="outline" onClick={restart}>
              <SquarePenIcon data-icon="inline-start" />
              New conversation
            </Button>
          ) : undefined
        }
      />

      <div className="mx-auto flex w-full max-w-[45rem] flex-col gap-6">
        {turns.length === 0 ? (
          <section aria-labelledby={greetingId} className="flex flex-col gap-6 pt-4 sm:pt-10">
            <div className="flex flex-col items-center gap-3 text-center">
              <AssistantMark className="size-12 self-center [&_svg]:size-6" />
              <h2 id={greetingId} className="type-display text-2xl text-foreground">
                {greeting}
              </h2>
              <p className="text-sm text-muted-foreground">Ask about the workspace, or start from one of these.</p>
            </div>
            <SuggestionCards
              suggestions={suggestions}
              onPick={(suggestion) => {
                setDraft(suggestion.prompt)
                setError(undefined)
                composerRef.current?.focus()
              }}
            />
          </section>
        ) : (
          <ol aria-label="Conversation" className="flex flex-col gap-6">
            {turns.map((turn) =>
              turn.role === "user" ? (
                <UserTurn key={turn.id} turn={turn} />
              ) : (
                <AssistantTurn
                  key={turn.id}
                  turn={turn}
                  shown={typing?.id === turn.id && !reduced ? typing.shown : undefined}
                  busy={sending}
                  onRegenerate={() => void regenerate(turn)}
                  onRate={(rating) => rate(turn, rating)}
                />
              )
            )}
          </ol>
        )}
        {/* One composer and one status line, in the same place before and during
            a conversation: the first answer and a new conversation change what
            sits above them and never them, so focus stays in the box and the
            live region is never replaced. Held at the foot of the viewport, so
            the box is in reach on a phone however far the suggestions run. */}
        <div data-slot="assistant-footer" className="sticky bottom-0 flex flex-col gap-1 bg-background pt-2 pb-2">
          {composer}
          {status}
        </div>
      </div>
    </div>
  )
}
app/ai/assistant-v2/components/reply-blocks.tsx
"use client"

import * as React from "react"

import { CodeBlock } from "@/components/ui/code-block"

import { type ReplyBlock } from "../data"

/** How many characters an answer holds, the unit its typing is counted in. */
export function replyLength(blocks: ReplyBlock[]): number {
  return blocks.reduce((sum, block) => sum + blockText(block).length, 0)
}

function blockText(block: ReplyBlock): string {
  if (block.kind === "text") return block.text
  if (block.kind === "code") return block.code
  if (block.kind === "list") return block.items.join("")
  return block.lines.join("")
}

/** The answer cut to its first `shown` characters, block by block, so it types out in reading order. */
function cut(blocks: ReplyBlock[], shown: number): ReplyBlock[] {
  const out: ReplyBlock[] = []
  let left = shown
  for (const block of blocks) {
    if (left <= 0) break
    const length = blockText(block).length
    if (length <= left) {
      out.push(block)
      left -= length
      continue
    }
    if (block.kind === "text") out.push({ ...block, text: block.text.slice(0, left) })
    else if (block.kind === "code") out.push({ ...block, code: block.code.slice(0, left) })
    else {
      const parts = block.kind === "list" ? block.items : block.lines
      const kept: string[] = []
      let room = left
      for (const part of parts) {
        if (room <= 0) break
        kept.push(part.slice(0, room))
        room -= part.length
      }
      out.push(block.kind === "list" ? { ...block, items: kept } : { ...block, lines: kept })
    }
    left = 0
  }
  return out
}

/**
 * An answer, drawn as it was written: paragraphs, lists as lists, a draft for
 * someone else set apart, and code in mono with its language and a copy
 * button. Given `shown`, only that many characters of it are drawn — the
 * typing, which the island runs only when motion is welcome.
 */
export function ReplyBlocks({ blocks, shown }: { blocks: ReplyBlock[]; shown?: number }) {
  const visible = shown === undefined ? blocks : cut(blocks, shown)
  return (
    <div data-slot="reply-blocks" className="flex flex-col gap-3 text-sm leading-6 text-foreground">
      {visible.map((block, index) => {
        if (block.kind === "text") return <p key={index}>{block.text}</p>
        if (block.kind === "code") {
          return <CodeBlock key={index} code={block.code} language={block.language} copyable={shown === undefined} />
        }
        if (block.kind === "quote") {
          return (
            <blockquote key={index} className="flex flex-col gap-2 border-l-2 border-rule pl-4 text-muted-foreground">
              {block.lines.map((line, at) => (
                <p key={at}>{line}</p>
              ))}
            </blockquote>
          )
        }
        const List = block.ordered ? "ol" : "ul"
        return (
          <List
            key={index}
            className={block.ordered ? "flex list-decimal flex-col gap-1.5 pl-5 marker:tabular-nums marker:text-muted-foreground" : "flex list-disc flex-col gap-1.5 pl-5 marker:text-muted-foreground"}
          >
            {block.items.map((item, at) => (
              <li key={at}>{item}</li>
            ))}
          </List>
        )
      })}
    </div>
  )
}
app/ai/assistant-v2/components/suggestion-cards.tsx
"use client"

import * as React from "react"
import {
  CodeIcon,
  FileTextIcon,
  LightbulbIcon,
  ListChecksIcon,
  PenLineIcon,
  TagIcon,
  type LucideIcon,
} from "lucide-react"

import { type Suggestion, type SuggestionIcon } from "../data"

const ICONS: Record<SuggestionIcon, LucideIcon> = {
  summary: FileTextIcon,
  code: CodeIcon,
  draft: PenLineIcon,
  checklist: ListChecksIcon,
  metric: LightbulbIcon,
  naming: TagIcon,
}

export type SuggestionCardsProps = {
  suggestions: Suggestion[]
  /** Fills the composer with the suggestion's prompt; nothing is sent until the member sends it. */
  onPick: (suggestion: Suggestion) => void
}

/** The six things to start from: each a button with an icon, a title and one line, that fills the composer. */
export function SuggestionCards({ suggestions, onPick }: SuggestionCardsProps) {
  return (
    <ul aria-label="Suggestions" className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
      {suggestions.map((suggestion) => {
        const Icon = ICONS[suggestion.icon]
        return (
          <li key={suggestion.id} className="flex">
            <button
              type="button"
              onClick={() => onPick(suggestion)}
              className="flex w-full items-start gap-3 panel p-3 text-left transition-colors duration-(--duration-fast) ease-(--ease-standard) focus-ring hover:bg-muted/60"
            >
              <span aria-hidden="true" className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground [&_svg]:size-4">
                <Icon />
              </span>
              <span className="flex min-w-0 flex-col gap-0.5">
                <span className="text-sm font-medium text-foreground">{suggestion.title}</span>
                <span className="text-xs text-muted-foreground">{suggestion.line}</span>
              </span>
            </button>
          </li>
        )
      })}
    </ul>
  )
}