Chat App
Full AI chat screen: conversation sidebar (drawer on mobile), agent header with model chip, a log where the user speaks in pills and the assistant answers in plain text, and a glowing composer. Sending shows a ‘Thinking…’ shimmer, then streams the reply word by word with a caret; send becomes stop meanwhile.
nocturne/layout/chat-app100%Open ↗
Source
"use client";
import { useEffect, useId, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
import { ArrowUp, Brain, ChevronDown, Menu, Monitor, Palette, Plus, Search, Square, SquarePen, UserRound, X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ChatAppMessage {
id: string;
role: "user" | "assistant";
text: string;
thoughtFor?: number;
}
export interface ChatAppProps {
brand?: string;
agent?: { name: string; model: string; role: string };
directs?: { label: string; initials: string }[];
conversations?: string[];
initialMessages?: ChatAppMessage[];
/** Canned answers the demo assistant cycles through. Wire `onSend` to a real model instead. */
replies?: string[];
/** Called with the user's text; the built-in demo reply still streams unless you replace the component logic. */
onSend?: (text: string) => void;
className?: string;
}
const DEFAULT_REPLIES = [
"Sure! Here's where things stand: the onboarding redesign ships behind a flag on Oct 14, Maya owns the copy and Leo the analytics events. The only open question is the video intro — decision due Friday.",
"I drafted three subject lines for the launch email: “Your team, now with memory”, “Ask once. Nova remembers.” and “Stop re-explaining, start shipping”. Want me to A/B test the first two?",
"Done — I created ONB-231 in Linear, assigned it to Leo, and linked the product sync notes so the context travels with the ticket.",
];
function FaceAvatar({ className }: { className?: string }) {
return (
<svg aria-hidden viewBox="0 0 32 32" className={cn("shrink-0", className)}>
<circle cx="16" cy="16" r="16" className="fill-da-primary" />
<path d="M10.5 14.5l.8 3.2M18.5 14.5l.8 3.2" stroke="var(--da-primary-fg)" strokeWidth="2.2" strokeLinecap="round" />
</svg>
);
}
/**
* Full AI chat screen: conversation sidebar (drawer on mobile), agent header with model chip, a message log where
* the user speaks in pills and the assistant answers in plain text, and a glowing composer. Sending shows a
* "thinking" shimmer, then streams the reply word by word with a caret; the send button becomes stop meanwhile.
*/
export function ChatApp({
brand = "Nova",
agent = { name: "Director", model: "nova-3 · memory", role: "Planning agent" },
directs = [
{ label: "Director", initials: "D" },
{ label: "Research", initials: "R" },
{ label: "Copywriter", initials: "C" },
],
conversations = ["Onboarding redesign recap", "Q4 pricing experiments"],
initialMessages = [
{ id: "m1", role: "user", text: "Hello!" },
{ id: "m2", role: "assistant", text: "Hi Chloé! Message received. How can I help you today?", thoughtFor: 1 },
],
replies = DEFAULT_REPLIES,
onSend,
className,
}: ChatAppProps) {
const [messages, setMessages] = useState<ChatAppMessage[]>(initialMessages);
const [draft, setDraft] = useState("");
const [phase, setPhase] = useState<"idle" | "thinking" | "streaming">("idle");
const [drawer, setDrawer] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const replyIndex = useRef(0);
const log = useRef<HTMLDivElement>(null);
const inputId = useId();
const drawerId = useId();
useEffect(() => () => {
if (timer.current) clearTimeout(timer.current);
}, []);
useEffect(() => {
const el = log.current;
if (el) el.scrollTo({ top: el.scrollHeight });
}, [messages, phase]);
const stop = () => {
if (timer.current) clearTimeout(timer.current);
timer.current = null;
setPhase("idle");
};
const streamReply = (full: string, thoughtFor: number) => {
const id = `a${Date.now()}`;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
setMessages((m) => [...m, { id, role: "assistant", text: full, thoughtFor }]);
setPhase("idle");
return;
}
const words = full.split(" ");
setMessages((m) => [...m, { id, role: "assistant", text: "", thoughtFor }]);
setPhase("streaming");
let i = 0;
const tick = () => {
i += 1;
const text = words.slice(0, i).join(" ");
setMessages((m) => m.map((msg) => (msg.id === id ? { ...msg, text } : msg)));
if (i < words.length) timer.current = setTimeout(tick, 35 + Math.random() * 45);
else {
timer.current = null;
setPhase("idle");
}
};
timer.current = setTimeout(tick, 60);
};
const send = () => {
const text = draft.trim();
if (!text || phase !== "idle") return;
onSend?.(text);
setDraft("");
setMessages((m) => [...m, { id: `u${Date.now()}`, role: "user", text }]);
setPhase("thinking");
const reply = replies[replyIndex.current % replies.length] ?? "";
replyIndex.current += 1;
const thinkMs = 900 + Math.random() * 700;
timer.current = setTimeout(() => streamReply(reply, Math.max(1, Math.round(thinkMs / 1000))), thinkMs);
};
const onSubmit = (e: FormEvent) => {
e.preventDefault();
if (phase === "idle") send();
else stop();
};
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
send();
}
};
const busy = phase !== "idle";
const iconBtn = "da-focus da-transition grid size-8 place-items-center rounded-da-sm text-da-muted-fg hover:bg-da-muted hover:text-da-fg";
return (
<div className={cn("relative flex h-dvh overflow-hidden bg-da-bg", className)}>
<div aria-hidden onClick={() => setDrawer(false)} className={cn("da-transition absolute inset-0 z-30 bg-da-overlay lg:hidden", drawer ? "opacity-100" : "pointer-events-none opacity-0")} />
<aside
id={drawerId}
aria-label="Conversations"
className={cn(
"da-transition-slow absolute inset-y-0 left-0 z-40 flex w-60 flex-col border-r border-da-border bg-da-bg lg:static lg:translate-x-0",
drawer ? "translate-x-0" : "-translate-x-full",
)}
>
<div className="flex h-14 items-center gap-2.5 px-4">
<span aria-hidden className="grid size-7 place-items-center rounded-full bg-gradient-to-br from-da-primary to-da-accent">
<span className="size-2.5 rounded-full bg-da-bg" />
</span>
<span className="flex-1 font-da-display text-[15px] font-semibold">{brand}</span>
<button type="button" aria-label="New chat" onClick={() => setMessages([])} className={iconBtn}>
<SquarePen aria-hidden className="size-4" />
</button>
<button type="button" aria-label="Close sidebar" onClick={() => setDrawer(false)} className={cn(iconBtn, "lg:hidden")}>
<X aria-hidden className="size-4" />
</button>
</div>
<nav aria-label="Workspace" className="flex-1 overflow-y-auto px-2 text-sm">
<a href="#studio" className="da-focus flex items-center gap-2.5 rounded-da-sm px-2.5 py-2 hover:bg-da-muted">
<Palette aria-hidden className="size-4 text-da-muted-fg" /> Agent Studio
</a>
<p className="mt-5 flex items-center justify-between px-2.5 font-da-mono text-[11px] tracking-da-label text-da-muted-fg uppercase">
Direct <Plus aria-hidden className="size-3.5" />
</p>
<ul className="mt-1 space-y-0.5">
{directs.map((d, i) => (
<li key={d.label}>
<a
href={`#${d.label.toLowerCase()}`}
aria-current={i === 0 ? "page" : undefined}
className={cn("da-focus flex items-center gap-2.5 rounded-da-pill px-2.5 py-1.5", i === 0 ? "bg-da-surface-2" : "text-da-fg/80 hover:bg-da-muted")}
>
<span aria-hidden className={cn("grid size-5 place-items-center rounded-full text-[10px] font-semibold", i === 0 ? "bg-da-accent text-da-accent-fg" : "bg-da-primary text-da-primary-fg")}>
{d.initials}
</span>
{d.label}
</a>
</li>
))}
</ul>
<p className="mt-5 flex items-center justify-between px-2.5 font-da-mono text-[11px] tracking-da-label text-da-muted-fg uppercase">
Conversations <Plus aria-hidden className="size-3.5" />
</p>
<ul className="mt-1 space-y-0.5">
{conversations.map((c) => (
<li key={c}>
<a href="#conversation" className="da-focus block truncate rounded-da-pill px-2.5 py-1.5 text-da-fg/80 hover:bg-da-muted hover:text-da-fg">
{c}
</a>
</li>
))}
</ul>
</nav>
</aside>
<section aria-label={`Chat with ${agent.name}`} className="relative isolate flex min-w-0 flex-1 flex-col">
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-da-border px-3 sm:px-4">
<button type="button" aria-label="Open sidebar" aria-expanded={drawer} aria-controls={drawerId} onClick={() => setDrawer(true)} className={cn(iconBtn, "lg:hidden")}>
<Menu aria-hidden className="size-5" />
</button>
<FaceAvatar className="size-8" />
<div className="min-w-0">
<h2 className="truncate font-da-display text-sm leading-tight font-semibold">{agent.name}</h2>
<p className="mt-0.5 flex items-center gap-2 text-[11px] text-da-muted-fg">
<span className="rounded-da-pill bg-da-secondary px-2 py-px font-da-mono text-da-fg">{agent.model}</span>
<span className="hidden sm:inline">{agent.role}</span>
</p>
</div>
<div className="ml-auto hidden items-center gap-0.5 sm:flex">
<button type="button" aria-label="Search in conversation" className={iconBtn}>
<Search aria-hidden className="size-4" />
</button>
<button type="button" aria-label="Agent profile" className={iconBtn}>
<UserRound aria-hidden className="size-4" />
</button>
<button type="button" aria-label="Open canvas" className={iconBtn}>
<Monitor aria-hidden className="size-4" />
</button>
</div>
</header>
<div ref={log} role="log" aria-live="polite" aria-label="Messages" className="flex-1 overflow-y-auto">
<div className="mx-auto flex max-w-2xl flex-col gap-7 px-4 py-8">
{messages.length === 0 && <p className="mt-16 text-center font-da-display text-xl text-da-muted-fg">What should we work on?</p>}
{messages.map((m, i) =>
m.role === "user" ? (
<p key={m.id} className="ml-auto max-w-[85%] rounded-da-lg bg-da-secondary px-4 py-2.5 text-[15px] leading-relaxed">
{m.text}
</p>
) : (
<div key={m.id} className="flex gap-3">
<FaceAvatar className="size-8" />
<div className="min-w-0 flex-1">
<p className="font-da-display text-sm font-semibold">{agent.name}</p>
{m.thoughtFor !== undefined && (
<p className="mt-1 flex items-center gap-1.5 text-[13px] text-da-muted-fg">
<Brain aria-hidden className="size-3.5" /> Thought for {m.thoughtFor} second{m.thoughtFor === 1 ? "" : "s"}
<ChevronDown aria-hidden className="size-3.5" />
</p>
)}
<p className="mt-2 text-[15px] leading-relaxed">
{m.text}
{phase === "streaming" && i === messages.length - 1 && (
<span aria-hidden className="ml-0.5 inline-block h-[1.1em] w-0.5 translate-y-[3px] animate-pulse bg-da-fg" />
)}
</p>
</div>
</div>
),
)}
{phase === "thinking" && (
<div className="flex gap-3">
<FaceAvatar className="size-8" />
<p className="mt-1.5 bg-gradient-to-r from-da-muted-fg via-da-fg to-da-muted-fg bg-[length:200%_100%] bg-clip-text text-[15px] text-transparent motion-safe:animate-[nocturne-shimmer_1.6s_linear_infinite]">
Thinking…
</p>
</div>
)}
</div>
</div>
<div className="relative shrink-0 px-3 pb-3 sm:px-4">
<div aria-hidden className="pointer-events-none absolute inset-x-[15%] -top-24 -z-10 h-40 bg-(image:--nocturne-glow)" />
<form onSubmit={onSubmit} className="da-stroke mx-auto max-w-2xl rounded-da-lg bg-da-surface p-3 shadow-da-md focus-within:border-da-border-strong">
<label htmlFor={inputId} className="sr-only">
Message {agent.name}
</label>
<textarea
id={inputId}
rows={2}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
placeholder={`Write to ${agent.name}…`}
className="block max-h-40 min-h-12 w-full resize-none bg-transparent px-2 pt-1 text-[15px] leading-relaxed outline-none [field-sizing:content] placeholder:text-da-muted-fg"
/>
<div className="mt-1 flex items-center justify-end gap-1.5">
<button type="button" aria-label="Add files or context" className="da-focus da-transition grid size-9 place-items-center rounded-full text-da-muted-fg hover:bg-da-muted hover:text-da-fg">
<Plus aria-hidden className="size-5" />
</button>
<button
type="submit"
disabled={!busy && draft.trim().length === 0}
aria-label={busy ? "Stop generating" : "Send message"}
className="da-focus da-transition grid size-9 place-items-center rounded-full bg-da-fg text-da-bg hover:bg-da-fg/90 disabled:bg-da-muted disabled:text-da-muted-fg"
>
{busy ? <Square aria-hidden className="size-3.5 fill-current" /> : <ArrowUp aria-hidden className="size-4" />}
</button>
</div>
</form>
<p className="mt-2 text-center text-[11px] text-da-muted-fg">{agent.name} can make mistakes. Check important information.</p>
</div>
</section>
<style href="nocturne-shimmer" precedence="default">{`@keyframes nocturne-shimmer { from { background-position: 100% 0 } to { background-position: -100% 0 } }`}</style>
</div>
);
}
export default ChatApp;
modules/nocturne/layout/chat-app/index.tsx
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| brand | string | — | Brand name. |
| agent | { name, model, role } | — | Current agent. |
| directs | { label, initials }[] | — | Sidebar agents. |
| conversations | string[] | — | Sidebar threads. |
| initialMessages | { id, role, text, thoughtFor? }[] | — | Starting log. |
| replies | string[] | — | Demo answers streamed in turn. |
| onSend | (text: string) => void | — | Hook to forward messages to your backend. |