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:
parent
1523d576b3
commit
5922dd9d3a
17
bun.lock
17
bun.lock
@ -110,6 +110,7 @@
|
||||
"streamdown": "^2.1.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"tokenlens": "^1.3.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"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/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/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=="],
|
||||
|
||||
@ -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-task-lists": ["markdown-it-task-lists@2.1.1", "", {}, "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA=="],
|
||||
|
||||
"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=="],
|
||||
@ -2556,6 +2559,8 @@
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="],
|
||||
|
||||
@ -137,6 +137,7 @@
|
||||
"streamdown": "^2.1.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"tokenlens": "^1.3.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"use-stick-to-bottom": "^1.1.2",
|
||||
|
||||
@ -25,9 +25,9 @@ export default async function ChannelPage({
|
||||
const messages = messagesResult.success && messagesResult.data ? messagesResult.data : []
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full w-full min-w-0 overflow-hidden">
|
||||
<div
|
||||
className="grid h-full overflow-hidden"
|
||||
className="grid min-w-0 flex-1 overflow-hidden"
|
||||
style={{ gridTemplateRows: "auto 1fr auto" }}
|
||||
>
|
||||
<ChannelHeader
|
||||
@ -46,6 +46,6 @@ export default async function ChannelPage({
|
||||
/>
|
||||
</div>
|
||||
<ThreadPanel />
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ import { getProjects } from "@/app/actions/projects"
|
||||
import { getCustomDashboards } from "@/app/actions/dashboards"
|
||||
import { ProjectListProvider } from "@/components/project-list-provider"
|
||||
import { getCurrentUser, toSidebarUser } from "@/lib/auth"
|
||||
import { cookies } from "next/headers"
|
||||
import { BiometricGuard } from "@/components/native/biometric-guard"
|
||||
import { OfflineBanner } from "@/components/native/offline-banner"
|
||||
import { NativeShell } from "@/components/native/native-shell"
|
||||
@ -35,12 +36,14 @@ export default async function DashboardLayout({
|
||||
}: {
|
||||
readonly children: React.ReactNode
|
||||
}) {
|
||||
const [projectList, authUser, dashboardResult] =
|
||||
const [projectList, authUser, dashboardResult, cookieStore] =
|
||||
await Promise.all([
|
||||
getProjects(),
|
||||
getCurrentUser(),
|
||||
getCustomDashboards(),
|
||||
cookies(),
|
||||
])
|
||||
const sidebarOpen = cookieStore.get("sidebar_state")?.value !== "false"
|
||||
const user = authUser ? toSidebarUser(authUser) : null
|
||||
const activeOrgId = authUser?.organizationId ?? null
|
||||
const activeOrgName = authUser?.organizationName ?? null
|
||||
@ -61,7 +64,7 @@ export default async function DashboardLayout({
|
||||
<FeedbackWidget>
|
||||
<DashboardContextMenu>
|
||||
<SidebarProvider
|
||||
defaultOpen={false}
|
||||
defaultOpen={sidebarOpen}
|
||||
className="h-screen overflow-hidden"
|
||||
style={
|
||||
{
|
||||
|
||||
@ -248,6 +248,73 @@ em-emoji-picker {
|
||||
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 {
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
|
||||
@ -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)
|
||||
setResumeLoaded(true)
|
||||
}
|
||||
|
||||
@ -92,7 +92,7 @@ export const Reasoning = memo(
|
||||
return (
|
||||
<ReasoningContext.Provider value={{ isStreaming, isOpen, setIsOpen, duration }}>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
className={cn("not-prose mb-2", className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
@ -130,17 +130,17 @@ export const ReasoningTrigger = memo(
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
<BrainIcon className="size-3.5" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<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")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -6,6 +6,7 @@ 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 { Markdown } from "tiptap-markdown"
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
@ -166,7 +167,10 @@ export function MessageComposer({
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
horizontalRule: false,
|
||||
blockquote: false,
|
||||
}),
|
||||
Markdown.configure({
|
||||
transformPastedText: true,
|
||||
transformCopiedText: true,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholder ?? `Message #${channelName}`,
|
||||
@ -212,8 +216,8 @@ export function MessageComposer({
|
||||
const handleSend = React.useCallback(async () => {
|
||||
if (!editor || isSending) return
|
||||
|
||||
const content = editor.getText().trim()
|
||||
if (!content) return
|
||||
const plainText = editor.getText().trim()
|
||||
if (!plainText) return
|
||||
|
||||
setIsSending(true)
|
||||
setError(null)
|
||||
@ -222,12 +226,16 @@ export function MessageComposer({
|
||||
const mentions = extractMentions(
|
||||
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({
|
||||
channelId,
|
||||
content,
|
||||
contentHtml,
|
||||
content: markdown,
|
||||
threadId,
|
||||
mentions: mentions.length > 0 ? mentions : undefined,
|
||||
})
|
||||
|
||||
@ -177,19 +177,8 @@ export const MessageItem = React.memo(function MessageItem({ message }: MessageI
|
||||
</Button>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user