Elements

Components / AI

A full-height Cowork layout composed from Conversation, Message, and Prompt Input. Use it as the reference pattern when building AI chat in Instant Strata, or when prompting AI tools to generate on-brand Cowork UI.

Live preview

Send a message, try a suggestion chip, or ask for a plan (for example "Create a plan for preparing the Harbour View AGM") to see the conversation todo bar and in-chat plan notice. Replies use the Vercel AI Gateway when AI_GATEWAY_API_KEY is set.

Cowork

New conversation...

How can I help?

Ask about schemes, draft emails, or set up new plans. Type # for schemes and @ for entities.

Conversation todo bar

When Cowork creates a plan for a multi-step workflow, a collapsible bar appears above the message list. The bar stays hidden until a plan exists. The header always shows the plan title and completion count (for example "3 of 7"). Expand to see each step with status: complete, in progress, or pending. If the session has earlier plans, use the history controls at the bottom of the bar; the latest plan is selected by default. An in-chat notice confirms when a plan was created.

Hidden by defaultNo bar until createTodoList returns a plan for the session.
Collapsible headerTitle plus completion count; chevron toggles the step list.
Step statusComplete (check), in progress (spinner), pending (empty circle).
Plan historyPrevious plans stay available; latest plan is shown first.
In-chat noticeTodoCreatedCard in the thread when a plan is created.

Thin sidebar

A 320px panel for a right sidebar or slide-over. The header drops the subtitle, New chat becomes an icon with a tooltip, and the composer omits the model selector so the input stays usable at narrow widths.

Main application

Scheme dashboard

Cowork docks on the right while managers work in the primary view. Use thin on AssistantPreview or copy the layout below.

Cowork

New conversation...

How can I help?

Ask below or pick a starter. Use # for schemes and @ for entities.

tsx
"use client"

import { PlusIcon } from "lucide-react"
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip"
// …Conversation, Message, PromptInput imports…

export function AssistantSidebar({
  messages,
  status,
  onSubmit,
  onNewChat,
  onStop,
}: {
  messages: { id: string; role: "user" | "assistant"; text: string }[]
  status: ChatStatus
  onSubmit: ({ text }: { text: string }) => void
  onNewChat: () => void
  onStop?: () => void
}) {
  const lastMessage = messages.at(-1)

  return (
    <aside className="flex h-full w-80 shrink-0 flex-col border-l border-border bg-background">
      {/* Compact header: icon-only new chat */}
      <header className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2">
        <p className="text-sm font-semibold text-foreground">Cowork</p>
        <TooltipProvider>
          <Tooltip>
            <TooltipTrigger
              render={
                <button
                  type="button"
                  onClick={onNewChat}
                  aria-label="New chat"
                  className="inline-flex size-8 items-center justify-center rounded-sm border border-border hover:bg-muted"
                >
                  <PlusIcon className="size-4" />
                </button>
              }
            />
            <TooltipContent side="bottom">New chat</TooltipContent>
          </Tooltip>
        </TooltipProvider>
      </header>

      <Conversation className="flex-1">
        <ConversationContent className="gap-4 p-3">
          {/* messages… */}
        </ConversationContent>
        <ConversationScrollButton />
      </Conversation>

      <div className="shrink-0 px-3 pb-3">
        <PromptInput onSubmit={onSubmit}>
          <PromptInputTextarea placeholder="Ask anything…" />
          <PromptInputFooter>
            <PromptInputSubmit status={status} onStop={onStop} />
          </PromptInputFooter>
        </PromptInput>
      </div>
    </aside>
  )
}

// Mount beside your main app shell:
<div className="flex h-svh">
  <main className="min-w-0 flex-1 overflow-auto">{children}</main>
  <AssistantSidebar … />
</div>

Routes in this docs site

AI coding agents and contributors can use these paths to find the Cowork pattern and live demo. Paths include the site base path when deployed to GitHub Pages.

/components/assistantThis page: anatomy, installation, and composition code.
/preview/assistantFull-screen interactive demo with mocked streaming replies.
/components/conversationScroll container primitive.
/components/messageChat bubble and markdown response primitive.
/components/prompt-inputComposer primitive with model selector and tools.

Deployed URLs are prefixed with /elements, for example /elements/preview/assistant/.

Anatomy

HeaderOptional chrome above the chat: title, new-chat action, and links back to docs or the parent app.
ConversationFills remaining height. Auto-scrolls to the latest message and shows a scroll-to-bottom control when the user reads history.
ConversationEmptyStateCentred placeholder with suggestion chips before the first message is sent.
MessageRenders user and assistant turns. Pair MessageResponse with isAnimating while a reply streams.
PromptInputPinned composer with model selector, speech input, attachments, and submit/stop controls.
ConversationTodoBarOptional collapsible plan bar above the message list. Hidden until a todo list exists; shows the latest plan with step status and history navigation.

Installation

Cowork is a composition, not a separate registry entry. Install the three primitives below, then copy the layout from the preview or the example on this page.

bash
# Install the three AI primitives this pattern composes
npx shadcn add https://raw.githubusercontent.com/levi-putna/elements/main/registry/conversation/registry.json
npx shadcn add https://raw.githubusercontent.com/levi-putna/elements/main/registry/message/registry.json
npx shadcn add https://raw.githubusercontent.com/levi-putna/elements/main/registry/prompt-input/registry.json

Composition

The preview at /preview/assistant follows this structure. It uses a local mockReply helper so the UI can be developed without a model API. Swap that for useChat when you are ready to go live.

tsx
"use client"

import {
  Conversation,
  ConversationContent,
  ConversationEmptyState,
  ConversationScrollButton,
} from "@/components/ui/conversation"
import {
  Message,
  MessageAction,
  MessageActions,
  MessageContent,
  MessageResponse,
} from "@/components/ui/message"
import {
  PromptInput,
  PromptInputFooter,
  PromptInputSelect,
  PromptInputSelectContent,
  PromptInputSelectItem,
  PromptInputSelectTrigger,
  PromptInputSelectValue,
  PromptInputSpeech,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputTools,
} from "@/components/ui/prompt-input"
import type { ChatStatus } from "ai"

export function AssistantPage({
  messages,
  status,
  onSubmit,
  onStop,
}: {
  messages: { id: string; role: "user" | "assistant"; text: string }[]
  status: ChatStatus
  onSubmit: ({ text }: { text: string }) => void
  onStop?: () => void
}) {
  const lastMessage = messages.at(-1)

  return (
    <div className="flex h-dvh flex-col bg-background">
      {/* Header: title and session actions */}
      <header className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3">
        <p className="text-sm font-semibold text-foreground">Cowork</p>
      </header>

      {/* Conversation: scrollable message list */}
      <Conversation className="flex-1">
        <ConversationContent className="mx-auto w-full max-w-3xl">
          {messages.length === 0 ? (
            <ConversationEmptyState title="How can I help you today?" />
          ) : (
            messages.map((message) => (
              <Message key={message.id} from={message.role}>
                <MessageContent>
                  <MessageResponse
                    isAnimating={
                      status === "streaming" && message.id === lastMessage?.id
                    }
                  >
                    {message.text}
                  </MessageResponse>
                </MessageContent>
                {message.role === "assistant" && message.text && (
                  <MessageActions>
                    <MessageAction tooltip="Copy" onClick={() => {}} />
                  </MessageActions>
                )}
              </Message>
            ))
          )}
        </ConversationContent>
        <ConversationScrollButton />
      </Conversation>

      {/* Composer: pinned prompt input */}
      <div className="shrink-0 px-4 pb-4">
        <div className="mx-auto w-full max-w-3xl">
          <PromptInput onSubmit={onSubmit}>
            <PromptInputTextarea placeholder="Message Cowork…" />
            <PromptInputFooter>
              <PromptInputTools>
                <PromptInputSelect defaultValue="opus">
                  <PromptInputSelectTrigger>
                    <PromptInputSelectValue />
                  </PromptInputSelectTrigger>
                  <PromptInputSelectContent>
                    <PromptInputSelectItem value="opus">Claude Opus</PromptInputSelectItem>
                  </PromptInputSelectContent>
                </PromptInputSelect>
                <PromptInputSpeech />
              </PromptInputTools>
              <PromptInputSubmit status={status} onStop={onStop} />
            </PromptInputFooter>
          </PromptInput>
        </div>
      </div>
    </div>
  )
}

Wiring to the Vercel AI SDK

Point useChat at your route handler, pass status to PromptInputSubmit, and set isAnimating on the final assistant message while status === "streaming". See also the examples on the Conversation page.

tsx
"use client"

import { useChat } from "ai/react"
import {
  Conversation,
  ConversationContent,
  ConversationScrollButton,
} from "@/components/ui/conversation"
import { Message, MessageContent, MessageResponse } from "@/components/ui/message"
import {
  PromptInput,
  PromptInputFooter,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputTools,
} from "@/components/ui/prompt-input"

export function LiveAssistant() {
  const { messages, input, handleInputChange, handleSubmit, status, stop } = useChat({
    api: "/api/chat",
  })

  return (
    <div className="flex h-dvh flex-col">
      <Conversation className="flex-1">
        <ConversationContent className="mx-auto max-w-3xl">
          {messages.map((message) => (
            <Message key={message.id} from={message.role}>
              <MessageContent>
                <MessageResponse isAnimating={status === "streaming"}>
                  {message.content}
                </MessageResponse>
              </MessageContent>
            </Message>
          ))}
        </ConversationContent>
        <ConversationScrollButton />
      </Conversation>

      <div className="border-t border-border p-4">
        <PromptInput
          onSubmit={() => {
            handleSubmit()
          }}
        >
          <PromptInputTextarea value={input} onChange={handleInputChange} />
          <PromptInputFooter>
            <PromptInputTools />
            <PromptInputSubmit status={status} onStop={stop} />
          </PromptInputFooter>
        </PromptInput>
      </div>
    </div>
  )
}