diff --git a/bun.lock b/bun.lock
index 691450f..40f07ac 100644
--- a/bun.lock
+++ b/bun.lock
@@ -26,6 +26,8 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
+ "@emoji-mart/data": "^1.2.1",
+ "@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^5.2.2",
"@json-render/core": "^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=="],
+ "@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/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=="],
+ "emoji-mart": ["emoji-mart@5.6.0", "", {}, "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow=="],
+
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
diff --git a/package.json b/package.json
index f0ea055..72e2b87 100755
--- a/package.json
+++ b/package.json
@@ -53,6 +53,8 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
+ "@emoji-mart/data": "^1.2.1",
+ "@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^5.2.2",
"@json-render/core": "^0.4.0",
"@json-render/react": "^0.4.0",
diff --git a/src/app/dashboard/conversations/[channelId]/page.tsx b/src/app/dashboard/conversations/[channelId]/page.tsx
index b044514..6b2b373 100644
--- a/src/app/dashboard/conversations/[channelId]/page.tsx
+++ b/src/app/dashboard/conversations/[channelId]/page.tsx
@@ -26,7 +26,10 @@ export default async function ChannelPage({
return (
<>
-
+
{children}
diff --git a/src/components/conversations/message-composer.tsx b/src/components/conversations/message-composer.tsx
index 3de7181..32fa352 100644
--- a/src/components/conversations/message-composer.tsx
+++ b/src/components/conversations/message-composer.tsx
@@ -6,15 +6,79 @@ import StarterKit from "@tiptap/starter-kit"
import Placeholder from "@tiptap/extension-placeholder"
import Link from "@tiptap/extension-link"
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 { Separator } from "@/components/ui/separator"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
import { sendMessage } from "@/app/actions/chat-messages"
import { setTyping } from "@/app/actions/conversations-realtime"
import { useRouter } from "next/navigation"
+import { useTheme } from "next-themes"
import { cn } from "@/lib/utils"
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
{
+ const [vars, setVars] = React.useState>({})
+ const { resolvedTheme } = useTheme()
+
+ React.useEffect(() => {
+ const bg = cssVarToRgb("--popover")
+ const fg = cssVarToRgb("--popover-foreground")
+ const input = cssVarToRgb("--muted")
+ const next: Record = {}
+ 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 = {
readonly channelId: string
readonly channelName: string
@@ -29,7 +93,9 @@ type MentionInput = {
targetId: string | null
}
-function extractMentions(json: Record): Array {
+function extractMentions(
+ json: Record,
+): Array {
const mentions: Array = []
function walk(node: Record) {
@@ -42,7 +108,10 @@ function extractMentions(json: Record): Array {
} else if (id === "here") {
mentions.push({ mentionType: "here", targetId: null })
} else if (id === "compass-agent") {
- mentions.push({ mentionType: "agent", targetId: "compass-agent" })
+ mentions.push({
+ mentionType: "agent",
+ targetId: "compass-agent",
+ })
} else {
mentions.push({ mentionType: "user", targetId: id })
}
@@ -68,10 +137,13 @@ export function MessageComposer({
onSent,
}: MessageComposerProps) {
const router = useRouter()
+ const { resolvedTheme } = useTheme()
+ const emojiThemeVars = useEmojiThemeVars()
const [isSending, setIsSending] = React.useState(false)
const [error, setError] = React.useState(null)
+ const [showToolbar, setShowToolbar] = React.useState(false)
+ const [emojiOpen, setEmojiOpen] = React.useState(false)
- // typing indicator - debounce to avoid spamming server
const lastTypingSentRef = React.useRef(0)
const TYPING_DEBOUNCE_MS = 3000
@@ -80,7 +152,10 @@ export function MessageComposer({
if (now - lastTypingSentRef.current >= TYPING_DEBOUNCE_MS) {
lastTypingSentRef.current = now
setTyping(channelId).catch((err) => {
- console.error("[MessageComposer] typing indicator error:", err)
+ console.error(
+ "[MessageComposer] typing indicator error:",
+ err,
+ )
})
}
}, [channelId])
@@ -94,7 +169,7 @@ export function MessageComposer({
blockquote: false,
}),
Placeholder.configure({
- placeholder: placeholder ?? `Message #${channelName}...`,
+ placeholder: placeholder ?? `Message #${channelName}`,
}),
Link.configure({
openOnClick: false,
@@ -112,7 +187,11 @@ export function MessageComposer({
],
editorProps: {
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: () => {
@@ -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 () => {
if (!editor || isSending) return
@@ -131,7 +219,9 @@ export function MessageComposer({
setError(null)
try {
- const mentions = extractMentions(editor.getJSON() as Record)
+ const mentions = extractMentions(
+ editor.getJSON() as Record,
+ )
const contentHtml = editor.getHTML()
const result = await sendMessage({
@@ -150,7 +240,9 @@ export function MessageComposer({
setError(result.error ?? "Failed to send message")
}
} catch (err) {
- setError(err instanceof Error ? err.message : "Failed to send message")
+ setError(
+ err instanceof Error ? err.message : "Failed to send message",
+ )
} finally {
setIsSending(false)
}
@@ -175,102 +267,205 @@ export function MessageComposer({
}, [editor, handleSend])
return (
-
-
-
+
+ {/* formatting toolbar */}
+ {editor && showToolbar && (
+
+
+
+
+
+
+
+ )}
- {editor && (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ {/* main composer bar */}
+
+ {/* + button */}
+
+
+ {/* editor area */}
+
+
+ {/* right-side action icons */}
+
+
+
+
+ {/* emoji picker */}
+
+
+
+
+
+
+
+ Loading...
+
+
+ }
+ >
+
+
+
+
+
+
+
{error && (
-
{error}
+
{error}
)}
-
-
- Enter to send,{" "}
- Shift+Enter for new line
-
)
}
diff --git a/src/components/conversations/message-list.tsx b/src/components/conversations/message-list.tsx
index 8e6ea8d..c890dfb 100644
--- a/src/components/conversations/message-list.tsx
+++ b/src/components/conversations/message-list.tsx
@@ -165,7 +165,7 @@ export function MessageList({ channelId, initialMessages }: MessageListProps) {
if (messages.length === 0) {
return (
-
+
No messages yet. Start the conversation!
@@ -174,7 +174,7 @@ export function MessageList({ channelId, initialMessages }: MessageListProps) {
}
return (
-
+