Ahmad Shah

Chat Surface

Component

About

A chat that sits beside your app in three modes: a minimised pill, a corner tab that peeks on hover, and a resizable dock.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Chat Surface to your project and fits it to your theme.

Code

How Chat Surface is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

'use client';

import * as React from 'react';
import { ArrowUp, FileText } from 'lucide-react';

import { ChatSurface, type ChatSurfaceMode } from '@/components/ui/chat-surface';

const FILES = [
  { name: 'Quarterly report.pdf', meta: '24 pages · edited 2 h ago' },
  { name: 'Vendor agreement.docx', meta: '11 pages · edited yesterday' },
  { name: 'Board deck.pdf', meta: '38 pages · edited Monday' },
  { name: 'Hiring plan.xlsx', meta: '3 sheets · edited last week' },
  { name: 'Launch checklist.md', meta: '1 page · edited last week' },
];

function Workspace({ mode }: { mode: ChatSurfaceMode }) {
  return (
    <div className="mx-auto flex max-w-2xl flex-col gap-6 p-6 sm:p-10">
      <div className="flex flex-col gap-1">
        <h1 className="text-xl font-semibold tracking-tight">Documents</h1>
        <p className="text-sm text-muted-foreground">
          Chat is in <span className="font-medium text-foreground">{mode}</span> mode.{' '}
          {mode === 'peek'
            ? 'Hover the corner tab to peek, click it to keep it open.'
            : mode === 'pill'
              ? 'Click the pill or press ⌘J to bring it back.'
              : 'Drag the grip on its edge to resize.'}
        </p>
      </div>
      <ul className="divide-y divide-border rounded-xl border border-border bg-card text-card-foreground">
        {FILES.map((file) => (
          <li key={file.name} className="flex items-center gap-3 px-4 py-3">
            <FileText aria-hidden="true" className="size-4 flex-none text-muted-foreground" />
            <span className="min-w-0 flex-1 truncate text-sm font-medium">{file.name}</span>
            <span className="hidden text-xs text-muted-foreground sm:block">{file.meta}</span>
          </li>
        ))}
      </ul>
    </div>
  );
}

interface Message {
  role: 'user' | 'assistant';
  text: string;
}

const STARTER: Message[] = [
  { role: 'user', text: 'What changed in the vendor agreement?' },
  {
    role: 'assistant',
    text: 'Two things: the payment terms moved from net 30 to net 45, and the renewal now needs 60 days’ notice instead of 30.',
  },
];

function Conversation({ messages, onSend }: { messages: Message[]; onSend: (text: string) => void }) {
  const [draft, setDraft] = React.useState('');
  const send = (event: React.FormEvent) => {
    event.preventDefault();
    if (!draft.trim()) return;
    onSend(draft.trim());
    setDraft('');
  };
  return (
    <>
      <div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-4 py-2">
        {messages.length === 0 ? (
          <p className="m-auto text-sm text-muted-foreground">Ask anything about your documents.</p>
        ) : (
          messages.map((message, i) =>
            message.role === 'user' ? (
              <p key={i} className="max-w-[85%] self-end rounded-2xl bg-primary px-3 py-2 text-sm text-primary-foreground">
                {message.text}
              </p>
            ) : (
              <p key={i} className="max-w-[95%] text-sm leading-6">
                {message.text}
              </p>
            )
          )
        )}
      </div>
      <form onSubmit={send} className="flex-none p-3">
        <div className="flex items-end gap-2 rounded-xl border border-border bg-background p-2 focus-within:ring-2 focus-within:ring-ring">
          <textarea
            rows={1}
            value={draft}
            onChange={(event) => setDraft(event.target.value)}
            onKeyDown={(event) => {
              if (event.key === 'Enter' && !event.shiftKey) send(event);
            }}
            placeholder="Ask a question…"
            aria-label="Message"
            className="max-h-32 min-h-8 flex-1 resize-none bg-transparent px-1 py-1.5 text-sm text-foreground outline-none placeholder:text-muted-foreground"
          />
          <button
            type="submit"
            aria-label="Send"
            disabled={!draft.trim()}
            className="inline-flex size-8 flex-none items-center justify-center rounded-lg bg-primary text-primary-foreground transition-opacity disabled:opacity-40"
          >
            <ArrowUp aria-hidden="true" className="size-4" />
          </button>
        </div>
      </form>
    </>
  );
}

function ChatSurfaceDemo({ defaultMode, unread }: { defaultMode: ChatSurfaceMode; unread?: boolean }) {
  const [mode, setMode] = React.useState<ChatSurfaceMode>(defaultMode);
  const [messages, setMessages] = React.useState<Message[]>(STARTER);
  const send = (text: string) =>
    setMessages((current) => [
      ...current,
      { role: 'user', text },
      { role: 'assistant', text: 'This is a demo, so there is no model behind it, but your message would be answered here.' },
    ]);
  return (
    <div className="h-[100dvh] min-h-[560px] w-full">
      <ChatSurface
        mode={mode}
        onModeChange={setMode}
        unread={unread && mode === 'pill'}
        onNewChat={() => setMessages([])}
        panel={<Conversation messages={messages} onSend={send} />}
      >
        <Workspace mode={mode} />
      </ChatSurface>
    </div>
  );
}

export default function Demo() {
  return <ChatSurfaceDemo defaultMode="peek" />;
}

Dependencies