feat(ui): unified chat styling + composer markdown

Standardize thinking/tool-call block appearance to
matching pill style. Switch message composer to emit
markdown via tiptap-markdown. Add chat-markdown CSS for
discord-compact rendering. Persist sidebar open state
via cookie. Fix chat-provider resume dispatching
generateUI calls on reload. Use flex layout for channel
page to support thread panel.
This commit is contained in:
Nicholai Vogel 2026-02-16 12:20:05 -07:00
parent 1523d576b3
commit 5922dd9d3a
9 changed files with 129 additions and 30 deletions

View File

@ -110,6 +110,7 @@
"streamdown": "^2.1.0", "streamdown": "^2.1.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"tiptap-markdown": "^0.9.0",
"tokenlens": "^1.3.1", "tokenlens": "^1.3.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.2", "use-stick-to-bottom": "^1.1.2",
@ -1154,13 +1155,13 @@
"@types/koa-compose": ["@types/koa-compose@3.2.9", "", { "dependencies": { "@types/koa": "*" } }, "sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA=="], "@types/koa-compose": ["@types/koa-compose@3.2.9", "", { "dependencies": { "@types/koa": "*" } }, "sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA=="],
"@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="], "@types/linkify-it": ["@types/linkify-it@3.0.5", "", {}, "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw=="],
"@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="], "@types/markdown-it": ["@types/markdown-it@13.0.9", "", { "dependencies": { "@types/linkify-it": "^3", "@types/mdurl": "^1" } }, "sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="], "@types/mdurl": ["@types/mdurl@1.0.5", "", {}, "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA=="],
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
@ -2004,6 +2005,8 @@
"markdown-it": ["markdown-it@14.1.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg=="], "markdown-it": ["markdown-it@14.1.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg=="],
"markdown-it-task-lists": ["markdown-it-task-lists@2.1.1", "", {}, "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@17.0.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-s5HZGFQea7Huv5zZcAGhJLT3qLpAfnY7v7GWkICUr0+Wd5TFEtdlRR2XUL5Gg+RH7u2Df595ifrxR03mBaw7gA=="], "marked": ["marked@17.0.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-s5HZGFQea7Huv5zZcAGhJLT3qLpAfnY7v7GWkICUr0+Wd5TFEtdlRR2XUL5Gg+RH7u2Df595ifrxR03mBaw7gA=="],
@ -2556,6 +2559,8 @@
"tippy.js": ["tippy.js@6.3.7", "", { "dependencies": { "@popperjs/core": "^2.9.0" } }, "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ=="], "tippy.js": ["tippy.js@6.3.7", "", { "dependencies": { "@popperjs/core": "^2.9.0" } }, "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ=="],
"tiptap-markdown": ["tiptap-markdown@0.9.0", "", { "dependencies": { "@types/markdown-it": "^13.0.7", "markdown-it": "^14.1.0", "markdown-it-task-lists": "^2.1.1", "prosemirror-markdown": "^1.11.1" }, "peerDependencies": { "@tiptap/core": "^3.0.1" } }, "sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ=="],
"tldts": ["tldts@7.0.23", "", { "dependencies": { "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw=="], "tldts": ["tldts@7.0.23", "", { "dependencies": { "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw=="],
"tldts-core": ["tldts-core@7.0.23", "", {}, "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ=="], "tldts-core": ["tldts-core@7.0.23", "", {}, "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ=="],
@ -3470,6 +3475,8 @@
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"prosemirror-markdown/@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
"radix-ui/@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="], "radix-ui/@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="],
"radix-ui/@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="], "radix-ui/@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
@ -4030,6 +4037,10 @@
"node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"prosemirror-markdown/@types/markdown-it/@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
"prosemirror-markdown/@types/markdown-it/@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
"rimraf/glob/minimatch": ["minimatch@10.1.2", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.1" } }, "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw=="], "rimraf/glob/minimatch": ["minimatch@10.1.2", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.1" } }, "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw=="],
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="], "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="],

View File

@ -137,6 +137,7 @@
"streamdown": "^2.1.0", "streamdown": "^2.1.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"tiptap-markdown": "^0.9.0",
"tokenlens": "^1.3.1", "tokenlens": "^1.3.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.2", "use-stick-to-bottom": "^1.1.2",

View File

@ -25,9 +25,9 @@ export default async function ChannelPage({
const messages = messagesResult.success && messagesResult.data ? messagesResult.data : [] const messages = messagesResult.success && messagesResult.data ? messagesResult.data : []
return ( return (
<> <div className="flex h-full w-full min-w-0 overflow-hidden">
<div <div
className="grid h-full overflow-hidden" className="grid min-w-0 flex-1 overflow-hidden"
style={{ gridTemplateRows: "auto 1fr auto" }} style={{ gridTemplateRows: "auto 1fr auto" }}
> >
<ChannelHeader <ChannelHeader
@ -46,6 +46,6 @@ export default async function ChannelPage({
/> />
</div> </div>
<ThreadPanel /> <ThreadPanel />
</> </div>
) )
} }

View File

@ -20,6 +20,7 @@ import { getProjects } from "@/app/actions/projects"
import { getCustomDashboards } from "@/app/actions/dashboards" import { getCustomDashboards } from "@/app/actions/dashboards"
import { ProjectListProvider } from "@/components/project-list-provider" import { ProjectListProvider } from "@/components/project-list-provider"
import { getCurrentUser, toSidebarUser } from "@/lib/auth" import { getCurrentUser, toSidebarUser } from "@/lib/auth"
import { cookies } from "next/headers"
import { BiometricGuard } from "@/components/native/biometric-guard" import { BiometricGuard } from "@/components/native/biometric-guard"
import { OfflineBanner } from "@/components/native/offline-banner" import { OfflineBanner } from "@/components/native/offline-banner"
import { NativeShell } from "@/components/native/native-shell" import { NativeShell } from "@/components/native/native-shell"
@ -35,12 +36,14 @@ export default async function DashboardLayout({
}: { }: {
readonly children: React.ReactNode readonly children: React.ReactNode
}) { }) {
const [projectList, authUser, dashboardResult] = const [projectList, authUser, dashboardResult, cookieStore] =
await Promise.all([ await Promise.all([
getProjects(), getProjects(),
getCurrentUser(), getCurrentUser(),
getCustomDashboards(), getCustomDashboards(),
cookies(),
]) ])
const sidebarOpen = cookieStore.get("sidebar_state")?.value !== "false"
const user = authUser ? toSidebarUser(authUser) : null const user = authUser ? toSidebarUser(authUser) : null
const activeOrgId = authUser?.organizationId ?? null const activeOrgId = authUser?.organizationId ?? null
const activeOrgName = authUser?.organizationName ?? null const activeOrgName = authUser?.organizationName ?? null
@ -61,7 +64,7 @@ export default async function DashboardLayout({
<FeedbackWidget> <FeedbackWidget>
<DashboardContextMenu> <DashboardContextMenu>
<SidebarProvider <SidebarProvider
defaultOpen={false} defaultOpen={sidebarOpen}
className="h-screen overflow-hidden" className="h-screen overflow-hidden"
style={ style={
{ {

View File

@ -248,6 +248,73 @@ em-emoji-picker {
max-height: 350px; max-height: 350px;
} }
/* discord-like compact markdown in chat messages */
.chat-markdown > div {
display: contents;
}
.chat-markdown p {
margin: 0;
line-height: 1.375;
}
.chat-markdown p + p {
margin-top: 0.25em;
}
.chat-markdown strong {
font-weight: 700;
}
.chat-markdown em {
font-style: italic;
}
.chat-markdown del, .chat-markdown s {
text-decoration: line-through;
}
.chat-markdown code:not(pre code) {
font-family: var(--font-mono);
font-size: 0.85em;
background: color-mix(in oklch, var(--muted) 80%, transparent);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0.1em 0.3em;
}
.chat-markdown pre {
margin: 0.375em 0;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 4px);
background: color-mix(in oklch, var(--muted) 60%, transparent);
overflow-x: auto;
}
.chat-markdown pre code {
font-family: var(--font-mono);
font-size: 0.85em;
display: block;
padding: 0.5em 0.75em;
background: none;
border: none;
}
.chat-markdown blockquote {
margin: 0.25em 0;
padding-left: 0.75em;
border-left: 3px solid var(--muted-foreground);
}
.chat-markdown ul, .chat-markdown ol {
margin: 0.25em 0;
padding-left: 1.5em;
}
.chat-markdown li {
margin: 0;
}
.chat-markdown a {
color: var(--primary);
text-decoration: none;
}
.chat-markdown a:hover {
text-decoration: underline;
}
.chat-markdown hr {
margin: 0.5em 0;
border-color: var(--border);
}
/* 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

@ -535,6 +535,26 @@ export function ChatProvider({
], ],
}) })
) )
// mark all generateUI tool calls from restored messages
// as already-dispatched so the watcher doesn't re-trigger
// renders or navigate to /dashboard on resume
for (const m of restored) {
if (m.role !== "assistant") continue
const parts = m.parts as ReadonlyArray<unknown>
let result = findGenerateUIOutput(
parts,
renderDispatchedRef.current
)
while (result) {
renderDispatchedRef.current.add(result.callId)
result = findGenerateUIOutput(
parts,
renderDispatchedRef.current
)
}
}
chat.setMessages(restored) chat.setMessages(restored)
setResumeLoaded(true) setResumeLoaded(true)
} }

View File

@ -92,7 +92,7 @@ export const Reasoning = memo(
return ( return (
<ReasoningContext.Provider value={{ isStreaming, isOpen, setIsOpen, duration }}> <ReasoningContext.Provider value={{ isStreaming, isOpen, setIsOpen, duration }}>
<Collapsible <Collapsible
className={cn("not-prose mb-4", className)} className={cn("not-prose mb-2", className)}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
open={isOpen} open={isOpen}
{...props} {...props}
@ -130,17 +130,17 @@ export const ReasoningTrigger = memo(
return ( return (
<CollapsibleTrigger <CollapsibleTrigger
className={cn( className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground", "inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs text-muted-foreground hover:bg-muted/80",
className, className,
)} )}
{...props} {...props}
> >
{children ?? ( {children ?? (
<> <>
<BrainIcon className="size-4" /> <BrainIcon className="size-3.5" />
{getThinkingMessage(isStreaming, duration)} {getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon <ChevronDownIcon
className={cn("size-4 transition-transform", isOpen ? "rotate-180" : "rotate-0")} className={cn("size-3 opacity-50 transition-transform", isOpen ? "rotate-180" : "rotate-0")}
/> />
</> </>
)} )}

View File

@ -6,6 +6,7 @@ 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 { Markdown } from "tiptap-markdown"
import { import {
Bold, Bold,
Italic, Italic,
@ -166,7 +167,10 @@ export function MessageComposer({
StarterKit.configure({ StarterKit.configure({
heading: false, heading: false,
horizontalRule: false, horizontalRule: false,
blockquote: false, }),
Markdown.configure({
transformPastedText: true,
transformCopiedText: true,
}), }),
Placeholder.configure({ Placeholder.configure({
placeholder: placeholder ?? `Message #${channelName}`, placeholder: placeholder ?? `Message #${channelName}`,
@ -212,8 +216,8 @@ export function MessageComposer({
const handleSend = React.useCallback(async () => { const handleSend = React.useCallback(async () => {
if (!editor || isSending) return if (!editor || isSending) return
const content = editor.getText().trim() const plainText = editor.getText().trim()
if (!content) return if (!plainText) return
setIsSending(true) setIsSending(true)
setError(null) setError(null)
@ -222,12 +226,16 @@ export function MessageComposer({
const mentions = extractMentions( const mentions = extractMentions(
editor.getJSON() as Record<string, unknown>, editor.getJSON() as Record<string, unknown>,
) )
const contentHtml = editor.getHTML() // send markdown so the server renders it via `marked`
const storage = editor.storage as unknown as Record<
string,
{ getMarkdown?: () => string } | undefined
>
const markdown = storage.markdown?.getMarkdown?.() ?? plainText
const result = await sendMessage({ const result = await sendMessage({
channelId, channelId,
content, content: markdown,
contentHtml,
threadId, threadId,
mentions: mentions.length > 0 ? mentions : undefined, mentions: mentions.length > 0 ? mentions : undefined,
}) })

View File

@ -177,19 +177,8 @@ export const MessageItem = React.memo(function MessageItem({ message }: MessageI
</Button> </Button>
</div> </div>
</div> </div>
) : message.contentHtml ? (
<div
className="mt-1 text-sm prose prose-sm dark:prose-invert max-w-none
prose-p:my-1 prose-p:leading-relaxed
prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:font-mono prose-code:text-sm
prose-pre:bg-muted prose-pre:p-3 prose-pre:rounded-md prose-pre:overflow-x-auto
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5
prose-blockquote:border-l-primary prose-blockquote:text-muted-foreground"
dangerouslySetInnerHTML={{ __html: message.contentHtml }}
/>
) : ( ) : (
<div className="mt-1 text-sm"> <div className="chat-markdown mt-1 text-sm">
<MarkdownRenderer>{message.content}</MarkdownRenderer> <MarkdownRenderer>{message.content}</MarkdownRenderer>
</div> </div>
)} )}