feat(conversations): discord-style composer + emoji picker

Redesign message composer to match Discord's compact input bar
with + button, inline editor, and action icons. Add emoji-mart
picker in popover that follows the active theme. Switch channel
page to CSS grid layout to fix composer overflow issues.
This commit is contained in:
Nicholai Vogel 2026-02-16 02:51:14 -07:00
parent ec095fa1db
commit 1523d576b3
7 changed files with 330 additions and 105 deletions

View File

@ -26,6 +26,8 @@
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@json-render/core": "^0.4.0", "@json-render/core": "^0.4.0",
"@json-render/react": "^0.4.0", "@json-render/react": "^0.4.0",
@ -372,6 +374,10 @@
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@emoji-mart/data": ["@emoji-mart/data@1.2.1", "", {}, "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw=="],
"@emoji-mart/react": ["@emoji-mart/react@1.1.1", "", { "peerDependencies": { "emoji-mart": "^5.2", "react": "^16.8 || ^17 || ^18" } }, "sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
@ -1554,6 +1560,8 @@
"embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="], "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="],
"emoji-mart": ["emoji-mart@5.6.0", "", {}, "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],

View File

@ -53,6 +53,8 @@
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@json-render/core": "^0.4.0", "@json-render/core": "^0.4.0",
"@json-render/react": "^0.4.0", "@json-render/react": "^0.4.0",

View File

@ -26,7 +26,10 @@ export default async function ChannelPage({
return ( return (
<> <>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden"> <div
className="grid h-full overflow-hidden"
style={{ gridTemplateRows: "auto 1fr auto" }}
>
<ChannelHeader <ChannelHeader
name={channel.name} name={channel.name}
description={channel.description ?? undefined} description={channel.description ?? undefined}

View File

@ -231,6 +231,23 @@
height: 0; height: 0;
} }
/* constrain composer editor height */
.composer-editor .ProseMirror {
max-height: 200px;
overflow-y: auto;
}
@media (max-width: 639px) {
.composer-editor .ProseMirror {
max-height: 30vh;
}
}
/* emoji-mart theme overrides */
em-emoji-picker {
--border-radius: 12px;
max-height: 350px;
}
/* mention pill styling in messages and editor */ /* mention pill styling in messages and editor */
.mention { .mention {
border-radius: calc(var(--radius) - 4px); border-radius: calc(var(--radius) - 4px);

View File

@ -20,7 +20,7 @@ export function MainContent({
<div <div
{...rest} {...rest}
className={cn( className={cn(
"flex flex-col overflow-x-hidden min-w-0", "flex flex-col overflow-x-hidden min-w-0 min-h-0",
"transition-[flex,opacity] duration-300 ease-in-out", "transition-[flex,opacity] duration-300 ease-in-out",
isCollapsed isCollapsed
? "flex-[0_0_0%] opacity-0 overflow-hidden pointer-events-none" ? "flex-[0_0_0%] opacity-0 overflow-hidden pointer-events-none"
@ -31,7 +31,7 @@ export function MainContent({
)} )}
> >
<div className={cn( <div className={cn(
"@container/main flex flex-1 flex-col min-w-0", "@container/main flex flex-1 flex-col min-w-0 min-h-0",
isConversations && "overflow-hidden" isConversations && "overflow-hidden"
)}> )}>
{children} {children}

View File

@ -6,15 +6,79 @@ import StarterKit from "@tiptap/starter-kit"
import Placeholder from "@tiptap/extension-placeholder" import Placeholder from "@tiptap/extension-placeholder"
import Link from "@tiptap/extension-link" import Link from "@tiptap/extension-link"
import Mention from "@tiptap/extension-mention" import Mention from "@tiptap/extension-mention"
import { Bold, Italic, Code, Link as LinkIcon, List, ListOrdered, Send, Paperclip, Smile } from "lucide-react" import {
Bold,
Italic,
Code,
List,
ListOrdered,
Plus,
Smile,
Sticker,
Gift,
} from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator" import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { sendMessage } from "@/app/actions/chat-messages" import { sendMessage } from "@/app/actions/chat-messages"
import { setTyping } from "@/app/actions/conversations-realtime" import { setTyping } from "@/app/actions/conversations-realtime"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useTheme } from "next-themes"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { createMentionSuggestion } from "./mention-suggestion" import { createMentionSuggestion } from "./mention-suggestion"
// lazy-load emoji picker to keep initial bundle small
const EmojiPicker = React.lazy(() =>
import("@emoji-mart/react").then((mod) => ({ default: mod.default })),
)
type EmojiData = {
readonly native: string
}
/** read a CSS custom property and resolve it to an "R, G, B" string */
function cssVarToRgb(varName: string): string | null {
if (typeof window === "undefined") return null
const style = getComputedStyle(document.documentElement)
const raw = style.getPropertyValue(varName).trim()
if (!raw) return null
// create a temporary element to resolve the color
const el = document.createElement("div")
el.style.color = raw
document.body.appendChild(el)
const computed = getComputedStyle(el).color
document.body.removeChild(el)
// computed is like "rgb(R, G, B)" or "rgba(R, G, B, A)"
const match = computed.match(
/rgba?\(\s*([\d.]+),?\s*([\d.]+),?\s*([\d.]+)/,
)
if (!match) return null
return `${Math.round(Number(match[1]))}, ${Math.round(Number(match[2]))}, ${Math.round(Number(match[3]))}`
}
function useEmojiThemeVars(): Record<string, string> {
const [vars, setVars] = React.useState<Record<string, string>>({})
const { resolvedTheme } = useTheme()
React.useEffect(() => {
const bg = cssVarToRgb("--popover")
const fg = cssVarToRgb("--popover-foreground")
const input = cssVarToRgb("--muted")
const next: Record<string, string> = {}
if (bg) next["--em-rgb-background"] = bg
if (fg) next["--em-rgb-color"] = fg
if (input) next["--em-rgb-input"] = input
setVars(next)
}, [resolvedTheme])
return vars
}
type MessageComposerProps = { type MessageComposerProps = {
readonly channelId: string readonly channelId: string
readonly channelName: string readonly channelName: string
@ -29,7 +93,9 @@ type MentionInput = {
targetId: string | null targetId: string | null
} }
function extractMentions(json: Record<string, unknown>): Array<MentionInput> { function extractMentions(
json: Record<string, unknown>,
): Array<MentionInput> {
const mentions: Array<MentionInput> = [] const mentions: Array<MentionInput> = []
function walk(node: Record<string, unknown>) { function walk(node: Record<string, unknown>) {
@ -42,7 +108,10 @@ function extractMentions(json: Record<string, unknown>): Array<MentionInput> {
} else if (id === "here") { } else if (id === "here") {
mentions.push({ mentionType: "here", targetId: null }) mentions.push({ mentionType: "here", targetId: null })
} else if (id === "compass-agent") { } else if (id === "compass-agent") {
mentions.push({ mentionType: "agent", targetId: "compass-agent" }) mentions.push({
mentionType: "agent",
targetId: "compass-agent",
})
} else { } else {
mentions.push({ mentionType: "user", targetId: id }) mentions.push({ mentionType: "user", targetId: id })
} }
@ -68,10 +137,13 @@ export function MessageComposer({
onSent, onSent,
}: MessageComposerProps) { }: MessageComposerProps) {
const router = useRouter() const router = useRouter()
const { resolvedTheme } = useTheme()
const emojiThemeVars = useEmojiThemeVars()
const [isSending, setIsSending] = React.useState(false) const [isSending, setIsSending] = React.useState(false)
const [error, setError] = React.useState<string | null>(null) const [error, setError] = React.useState<string | null>(null)
const [showToolbar, setShowToolbar] = React.useState(false)
const [emojiOpen, setEmojiOpen] = React.useState(false)
// typing indicator - debounce to avoid spamming server
const lastTypingSentRef = React.useRef<number>(0) const lastTypingSentRef = React.useRef<number>(0)
const TYPING_DEBOUNCE_MS = 3000 const TYPING_DEBOUNCE_MS = 3000
@ -80,7 +152,10 @@ export function MessageComposer({
if (now - lastTypingSentRef.current >= TYPING_DEBOUNCE_MS) { if (now - lastTypingSentRef.current >= TYPING_DEBOUNCE_MS) {
lastTypingSentRef.current = now lastTypingSentRef.current = now
setTyping(channelId).catch((err) => { setTyping(channelId).catch((err) => {
console.error("[MessageComposer] typing indicator error:", err) console.error(
"[MessageComposer] typing indicator error:",
err,
)
}) })
} }
}, [channelId]) }, [channelId])
@ -94,7 +169,7 @@ export function MessageComposer({
blockquote: false, blockquote: false,
}), }),
Placeholder.configure({ Placeholder.configure({
placeholder: placeholder ?? `Message #${channelName}...`, placeholder: placeholder ?? `Message #${channelName}`,
}), }),
Link.configure({ Link.configure({
openOnClick: false, openOnClick: false,
@ -112,7 +187,11 @@ export function MessageComposer({
], ],
editorProps: { editorProps: {
attributes: { attributes: {
class: "prose prose-sm max-w-none focus:outline-none min-h-[80px] p-3", class: cn(
"prose prose-sm max-w-none focus:outline-none",
"min-h-[22px] py-[11px] px-0",
"text-sm leading-[22px]",
),
}, },
}, },
onUpdate: () => { onUpdate: () => {
@ -121,6 +200,15 @@ export function MessageComposer({
}, },
}) })
const handleEmojiSelect = React.useCallback(
(emoji: EmojiData) => {
if (!editor) return
editor.chain().focus().insertContent(emoji.native).run()
setEmojiOpen(false)
},
[editor],
)
const handleSend = React.useCallback(async () => { const handleSend = React.useCallback(async () => {
if (!editor || isSending) return if (!editor || isSending) return
@ -131,7 +219,9 @@ export function MessageComposer({
setError(null) setError(null)
try { try {
const mentions = extractMentions(editor.getJSON() as Record<string, unknown>) const mentions = extractMentions(
editor.getJSON() as Record<string, unknown>,
)
const contentHtml = editor.getHTML() const contentHtml = editor.getHTML()
const result = await sendMessage({ const result = await sendMessage({
@ -150,7 +240,9 @@ export function MessageComposer({
setError(result.error ?? "Failed to send message") setError(result.error ?? "Failed to send message")
} }
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to send message") setError(
err instanceof Error ? err.message : "Failed to send message",
)
} finally { } finally {
setIsSending(false) setIsSending(false)
} }
@ -175,102 +267,205 @@ export function MessageComposer({
}, [editor, handleSend]) }, [editor, handleSend])
return ( return (
<div className="shrink-0 border-t bg-background p-4"> <div className="min-h-[68px] px-2 pb-4 pt-2 sm:px-4">
<div className="rounded-lg border bg-background"> {/* formatting toolbar */}
<EditorContent editor={editor} className="max-h-[200px] overflow-y-auto" /> {editor && showToolbar && (
<div className="mb-1.5 flex items-center gap-0.5 pl-10 sm:pl-12">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-muted-foreground hover:text-foreground"
onClick={() =>
editor.chain().focus().toggleBold().run()
}
disabled={
!editor.can().chain().focus().toggleBold().run()
}
>
<Bold
className={cn(
"h-3.5 w-3.5",
editor.isActive("bold") && "text-primary",
)}
/>
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-muted-foreground hover:text-foreground"
onClick={() =>
editor.chain().focus().toggleItalic().run()
}
disabled={
!editor.can().chain().focus().toggleItalic().run()
}
>
<Italic
className={cn(
"h-3.5 w-3.5",
editor.isActive("italic") && "text-primary",
)}
/>
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-muted-foreground hover:text-foreground"
onClick={() =>
editor.chain().focus().toggleCode().run()
}
disabled={
!editor.can().chain().focus().toggleCode().run()
}
>
<Code
className={cn(
"h-3.5 w-3.5",
editor.isActive("code") && "text-primary",
)}
/>
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-muted-foreground hover:text-foreground"
onClick={() =>
editor.chain().focus().toggleBulletList().run()
}
>
<List
className={cn(
"h-3.5 w-3.5",
editor.isActive("bulletList") && "text-primary",
)}
/>
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-muted-foreground hover:text-foreground"
onClick={() =>
editor.chain().focus().toggleOrderedList().run()
}
>
<ListOrdered
className={cn(
"h-3.5 w-3.5",
editor.isActive("orderedList") && "text-primary",
)}
/>
</Button>
</div>
)}
{editor && ( {/* main composer bar */}
<div className="flex items-center justify-between border-t p-2"> <div
<div className="flex items-center gap-1"> className={cn(
<Button "relative flex items-end rounded-lg",
variant="ghost" "bg-muted/50 ring-1 ring-border",
size="icon" "focus-within:ring-2 focus-within:ring-ring",
className="h-7 w-7" "transition-shadow",
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()}
>
<Bold className={cn(
"h-3.5 w-3.5",
editor.isActive("bold") && "text-primary"
)} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()}
>
<Italic className={cn(
"h-3.5 w-3.5",
editor.isActive("italic") && "text-primary"
)} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => editor.chain().focus().toggleCode().run()}
disabled={!editor.can().chain().focus().toggleCode().run()}
>
<Code className={cn(
"h-3.5 w-3.5",
editor.isActive("code") && "text-primary"
)} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
<List className={cn(
"h-3.5 w-3.5",
editor.isActive("bulletList") && "text-primary"
)} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
<ListOrdered className={cn(
"h-3.5 w-3.5",
editor.isActive("orderedList") && "text-primary"
)} />
</Button>
<Separator orientation="vertical" className="mx-1 h-6" />
<Button variant="ghost" size="icon" className="h-7 w-7" disabled>
<Paperclip className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7">
<Smile className="h-3.5 w-3.5" />
</Button>
</div>
<Button
size="sm"
onClick={handleSend}
disabled={isSending || !editor.getText().trim()}
>
<Send className="mr-1.5 h-3.5 w-3.5" />
Send
</Button>
</div>
)} )}
>
{/* + button */}
<button
type="button"
className={cn(
"flex h-[44px] w-[44px] shrink-0 items-center justify-center",
"text-muted-foreground",
"hover:text-foreground transition-colors",
)}
onClick={() => setShowToolbar((prev) => !prev)}
aria-label="Toggle formatting"
>
<Plus
className={cn(
"h-5 w-5 transition-transform duration-200",
showToolbar && "rotate-45",
)}
/>
</button>
{/* editor area */}
<EditorContent
editor={editor}
className="composer-editor min-w-0 flex-1"
/>
{/* right-side action icons */}
<div className="flex h-[44px] shrink-0 items-center gap-0 pr-1 sm:pr-1.5">
<button
type="button"
className={cn(
"hidden sm:flex",
"h-8 w-8 items-center justify-center rounded-md",
"text-muted-foreground hover:text-foreground transition-colors",
)}
aria-label="Stickers"
>
<Sticker className="h-[18px] w-[18px]" />
</button>
<button
type="button"
className={cn(
"hidden sm:flex",
"h-8 w-8 items-center justify-center rounded-md",
"text-muted-foreground hover:text-foreground transition-colors",
)}
aria-label="GIF"
>
<Gift className="h-[18px] w-[18px]" />
</button>
{/* emoji picker */}
<Popover open={emojiOpen} onOpenChange={setEmojiOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md",
"text-muted-foreground hover:text-foreground transition-colors",
emojiOpen && "text-foreground",
)}
aria-label="Emoji"
>
<Smile className="h-[18px] w-[18px]" />
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="end"
sideOffset={8}
className="w-auto border-none bg-transparent p-0 shadow-none"
>
<React.Suspense
fallback={
<div className="flex h-[350px] w-[352px] items-center justify-center rounded-lg border bg-popover">
<p className="text-sm text-muted-foreground">
Loading...
</p>
</div>
}
>
<div style={emojiThemeVars as React.CSSProperties}>
<EmojiPicker
onEmojiSelect={handleEmojiSelect}
theme={resolvedTheme === "dark" ? "dark" : "light"}
set="native"
skinTonePosition="search"
previewPosition="none"
maxFrequentRows={2}
/>
</div>
</React.Suspense>
</PopoverContent>
</Popover>
</div>
</div> </div>
{error && ( {error && (
<p className="mt-2 text-xs text-destructive">{error}</p> <p className="mt-1.5 text-xs text-destructive">{error}</p>
)} )}
<p className="mt-2 text-xs text-muted-foreground">
<kbd className="rounded border bg-muted px-1.5 py-0.5 font-mono text-xs">Enter</kbd> to send,{" "}
<kbd className="rounded border bg-muted px-1.5 py-0.5 font-mono text-xs">Shift+Enter</kbd> for new line
</p>
</div> </div>
) )
} }

View File

@ -165,7 +165,7 @@ export function MessageList({ channelId, initialMessages }: MessageListProps) {
if (messages.length === 0) { if (messages.length === 0) {
return ( return (
<div className="flex min-h-0 flex-1 items-center justify-center p-8"> <div className="flex h-full min-h-0 items-center justify-center p-8 overflow-hidden">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No messages yet. Start the conversation! No messages yet. Start the conversation!
</p> </p>
@ -174,7 +174,7 @@ export function MessageList({ channelId, initialMessages }: MessageListProps) {
} }
return ( return (
<ScrollArea className="flex-1" ref={scrollRef}> <ScrollArea className="h-full min-h-0 flex-1" ref={scrollRef}>
<div className="flex flex-col gap-4 p-4"> <div className="flex flex-col gap-4 p-4">
{hasMore && ( {hasMore && (
<div className="flex justify-center"> <div className="flex justify-center">