Files
vibn-frontend/components/assistant-ui/thread.tsx
Mark Henderson 9858a7fa15 Apply Stackless chat design to Atlas thread
- Remove card container (no more rounded-2xl, ring, 600px height)
- Chat fills the full layout space naturally
- Avatars: 28x28 rounded-7 squares (black for Atlas, warm gray for user)
- Both sides use same avatar+label layout (no right-aligned bubbles)
- Sender labels: tiny uppercase ATLAS / YOU above each message
- Input bar: white pill with border, Send button, Stop for streaming
- User initial pulled from session (name or email first letter)

Made-with: Cursor
2026-03-02 16:15:25 -08:00

313 lines
11 KiB
TypeScript

"use client";
import {
ActionBarPrimitive,
BranchPickerPrimitive,
ComposerPrimitive,
MessagePrimitive,
ThreadPrimitive,
} from "@assistant-ui/react";
import {
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
CopyIcon,
RefreshCwIcon,
SquareIcon,
} from "lucide-react";
import type { FC } from "react";
import { MarkdownText } from "./markdown-text";
// ---------------------------------------------------------------------------
// Thread root — Stackless style: flat on beige, no card
// ---------------------------------------------------------------------------
export const Thread: FC<{ userInitial?: string }> = ({ userInitial = "Y" }) => (
<ThreadPrimitive.Root
style={{
display: "flex",
flexDirection: "column",
height: "100%",
background: "#f6f4f0",
fontFamily: "Outfit, sans-serif",
}}
>
{/* Empty state */}
<ThreadPrimitive.Empty>
<div style={{
display: "flex", height: "100%",
flexDirection: "column", alignItems: "center", justifyContent: "center",
gap: 12, padding: "40px 32px",
}}>
<div style={{
width: 44, height: 44, borderRadius: 11, background: "#1a1a1a",
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 500,
color: "#fff",
}}>
A
</div>
<div style={{ textAlign: "center" }}>
<p style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>Atlas</p>
<p style={{ fontSize: "0.78rem", color: "#a09a90", maxWidth: 260, lineHeight: 1.5 }}>
Your product strategist. Let&apos;s define what you&apos;re building.
</p>
</div>
<div style={{ width: "100%", maxWidth: 600 }}>
<Composer userInitial={userInitial} />
</div>
</div>
</ThreadPrimitive.Empty>
{/* Messages */}
<ThreadPrimitive.Viewport style={{ flex: 1, overflowY: "auto", padding: "28px 32px" }}>
<ThreadPrimitive.Messages
components={{
UserMessage: (props) => <UserMessage {...props} userInitial={userInitial} />,
AssistantMessage,
}}
/>
</ThreadPrimitive.Viewport>
{/* Input bar */}
<div style={{ padding: "14px 32px 22px", flexShrink: 0 }}>
<Composer userInitial={userInitial} />
</div>
</ThreadPrimitive.Root>
);
// ---------------------------------------------------------------------------
// Composer — Stackless white pill input bar
// ---------------------------------------------------------------------------
const Composer: FC<{ userInitial?: string }> = () => (
<ComposerPrimitive.Root style={{ width: "100%" }}>
<div style={{
display: "flex", gap: 8,
padding: "5px 5px 5px 16px",
background: "#fff",
border: "1px solid #e0dcd4",
borderRadius: 10,
alignItems: "center",
boxShadow: "0 1px 4px #1a1a1a06",
}}>
<ComposerPrimitive.Input
placeholder="Describe your thinking..."
rows={1}
autoFocus
style={{
flex: 1,
border: "none",
background: "none",
fontSize: "0.86rem",
fontFamily: "Outfit, sans-serif",
color: "#1a1a1a",
padding: "8px 0",
resize: "none",
outline: "none",
minHeight: 24,
maxHeight: 120,
}}
/>
<ThreadPrimitive.If running={false}>
<ComposerPrimitive.Send asChild>
<button
style={{
padding: "9px 16px",
borderRadius: 7,
border: "none",
background: "#1a1a1a",
color: "#fff",
fontSize: "0.78rem",
fontWeight: 600,
fontFamily: "Outfit, sans-serif",
cursor: "pointer",
transition: "opacity 0.15s",
flexShrink: 0,
}}
onMouseEnter={(e) => (e.currentTarget.style.opacity = "0.8")}
onMouseLeave={(e) => (e.currentTarget.style.opacity = "1")}
>
Send
</button>
</ComposerPrimitive.Send>
</ThreadPrimitive.If>
<ThreadPrimitive.If running>
<ComposerPrimitive.Cancel asChild>
<button
style={{
padding: "9px 16px",
borderRadius: 7,
border: "none",
background: "#eae6de",
color: "#8a8478",
fontSize: "0.78rem",
fontWeight: 600,
fontFamily: "Outfit, sans-serif",
cursor: "pointer",
flexShrink: 0,
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<SquareIcon style={{ width: 10, height: 10 }} />
Stop
</button>
</ComposerPrimitive.Cancel>
</ThreadPrimitive.If>
</div>
</ComposerPrimitive.Root>
);
// ---------------------------------------------------------------------------
// Assistant message — black avatar, "Atlas" label, plain text
// ---------------------------------------------------------------------------
const AssistantMessage: FC = () => (
<MessagePrimitive.Root style={{ display: "flex", gap: 12, marginBottom: 22 }} className="group">
{/* Avatar */}
<div style={{
width: 28, height: 28, borderRadius: 7, flexShrink: 0, marginTop: 2,
background: "#1a1a1a",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.68rem", fontWeight: 700, color: "#fff",
fontFamily: "Newsreader, serif",
}}>
A
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{/* Sender label */}
<div style={{
fontSize: "0.68rem", fontWeight: 600, color: "#a09a90",
marginBottom: 5, textTransform: "uppercase", letterSpacing: "0.04em",
}}>
Atlas
</div>
{/* Message content */}
<div style={{ fontSize: "0.88rem", color: "#2a2824", lineHeight: 1.72 }}>
<MessagePrimitive.Content components={{ Text: AssistantText }} />
</div>
<AssistantActionBar />
<BranchPicker />
</div>
</MessagePrimitive.Root>
);
const AssistantText: FC = () => <MarkdownText />;
const AssistantActionBar: FC = () => (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
className="group-hover:opacity-100"
style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 6, opacity: 0, transition: "opacity 0.15s" }}
>
<ActionBarPrimitive.Copy asChild>
<button style={{
display: "flex", alignItems: "center", gap: 4,
fontSize: "0.68rem", color: "#b5b0a6",
background: "none", border: "none", cursor: "pointer",
fontFamily: "Outfit, sans-serif", padding: "2px 6px", borderRadius: 4,
}}>
<MessagePrimitive.If copied>
<CheckIcon style={{ width: 10, height: 10 }} /> Copied
</MessagePrimitive.If>
<MessagePrimitive.If copied={false}>
<CopyIcon style={{ width: 10, height: 10 }} /> Copy
</MessagePrimitive.If>
</button>
</ActionBarPrimitive.Copy>
<ActionBarPrimitive.Reload asChild>
<button style={{
display: "flex", alignItems: "center", gap: 4,
fontSize: "0.68rem", color: "#b5b0a6",
background: "none", border: "none", cursor: "pointer",
fontFamily: "Outfit, sans-serif", padding: "2px 6px", borderRadius: 4,
}}>
<RefreshCwIcon style={{ width: 10, height: 10 }} /> Retry
</button>
</ActionBarPrimitive.Reload>
</ActionBarPrimitive.Root>
);
// ---------------------------------------------------------------------------
// User message — warm avatar, "You" label, same layout as Atlas
// ---------------------------------------------------------------------------
const UserMessage: FC<{ userInitial?: string }> = ({ userInitial = "Y" }) => (
<MessagePrimitive.Root style={{ display: "flex", gap: 12, marginBottom: 22 }} className="group">
{/* Avatar */}
<div style={{
width: 28, height: 28, borderRadius: 7, flexShrink: 0, marginTop: 2,
background: "#e8e4dc",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.68rem", fontWeight: 700, color: "#8a8478",
fontFamily: "Outfit, sans-serif",
}}>
{userInitial}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{/* Sender label */}
<div style={{
fontSize: "0.68rem", fontWeight: 600, color: "#a09a90",
marginBottom: 5, textTransform: "uppercase", letterSpacing: "0.04em",
}}>
You
</div>
{/* Message content */}
<div style={{ fontSize: "0.88rem", color: "#2a2824", lineHeight: 1.72, whiteSpace: "pre-wrap" }}>
<MessagePrimitive.Content components={{ Text: UserText }} />
</div>
<UserActionBar />
</div>
</MessagePrimitive.Root>
);
const UserText: FC<{ text: string }> = ({ text }) => <span>{text}</span>;
const UserActionBar: FC = () => (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
className="group-hover:opacity-100"
style={{ display: "flex", alignItems: "center", gap: 4, marginTop: 4, opacity: 0, transition: "opacity 0.15s" }}
>
<ActionBarPrimitive.Edit asChild>
<button style={{
fontSize: "0.68rem", color: "#b5b0a6",
background: "none", border: "none", cursor: "pointer",
fontFamily: "Outfit, sans-serif", padding: "2px 6px", borderRadius: 4,
}}>
Edit
</button>
</ActionBarPrimitive.Edit>
</ActionBarPrimitive.Root>
);
// ---------------------------------------------------------------------------
// Branch picker
// ---------------------------------------------------------------------------
const BranchPicker: FC = () => (
<BranchPickerPrimitive.Root
hideWhenSingleBranch
className="group-hover:opacity-100"
style={{ display: "flex", alignItems: "center", gap: 4, marginTop: 4, opacity: 0, transition: "opacity 0.15s" }}
>
<BranchPickerPrimitive.Previous asChild>
<button style={{ background: "none", border: "none", cursor: "pointer", color: "#b5b0a6" }}>
<ChevronLeftIcon style={{ width: 12, height: 12 }} />
</button>
</BranchPickerPrimitive.Previous>
<span style={{ fontSize: "0.68rem", color: "#b5b0a6" }}>
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next asChild>
<button style={{ background: "none", border: "none", cursor: "pointer", color: "#b5b0a6" }}>
<ChevronRightIcon style={{ width: 12, height: 12 }} />
</button>
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
);