Skip to content

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-app
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

PropTypeDefaultDescription
brandstring—Brand name.
agent{ name, model, role }—Current agent.
directs{ label, initials }[]—Sidebar agents.
conversationsstring[]—Sidebar threads.
initialMessages{ id, role, text, thoughtFor? }[]—Starting log.
repliesstring[]—Demo answers streamed in turn.
onSend(text: string) => void—Hook to forward messages to your backend.