feat(chat): migrate composer to Tiptap and implement markdown mentions#11
feat(chat): migrate composer to Tiptap and implement markdown mentions#11BuckyMcYolo merged 3 commits intomainfrom
Conversation
end-to-end - replace textarea composer with Tiptap + mention suggestion UI - move compose-related chat pieces into the composer component folder - wire Enter/Shift+Enter behavior (Enter sends, Shift+Enter inserts newline) - prevent self-mentions and keep mention selection Enter behavior intact - add plus-button popover UX (UI button + rotating plus-to-x interaction) - improve composer vertical alignment/spacing in the chat layout - add markdown message rendering with react-markdown + remark-gfm - render mentions as highlighted inline tokens with hover-card shell UI - set hover-card open delay to 500ms - standardize user-facing mention labels to displayUsername first - add mention metadata through API/realtime payloads and schemas - include resolved mention users on realtime broadcast/ack payloads - support optimistic mention metadata to avoid temporary "unknown user" flashes - keep mention storage ID-based (`<@userid>`) while rendering display labels
📝 WalkthroughWalkthroughAdds end-to-end direct-user mention support: API schema and query changes, realtime fanout and mention extraction, new TipTap-based composer and suggestion UI, mention-aware message rendering and optimistic updates, plus supporting UI primitives and types. Changes
Sequence Diagram(s)sequenceDiagram
participant User as Web Client (User)
participant Composer as TipTap Composer
participant API as Backend API
participant DB as Database
participant Realtime as Realtime Service
participant Other as Other Clients
User->>Composer: Type message with `@mention` and send
Composer->>API: POST message payload (content + mentions array)
API->>DB: Insert message and mention relations
API->>Realtime: Notify message created (message + mentions)
Realtime->>DB: (optional) fetch mentioned user details
Realtime->>Realtime: Build messageMentions array
Realtime->>Other: Broadcast message:created with mentions
Other->>Other: Render message content with mention HoverCards
sequenceDiagram
participant User as User Typing
participant TipTap as TipTap Editor
participant Guild as Guild Members API
participant Suggestion as Suggestion List UI
User->>TipTap: Type "@" → trigger suggestion
TipTap->>Guild: Request mention candidates
Guild->>TipTap: Return member list
TipTap->>Suggestion: Show list
User->>Suggestion: Navigate/select candidate
Suggestion->>TipTap: Insert mention node (id + label)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 10
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/api/src/lib/queries/messages.ts`:
- Around line 50-64: The query that builds mentionRows is pulling all
messageMention rows including fanout rows with mentionType "everyone"; update
the db query that selects from messageMention (the mentionRows code) to filter
to direct/user mentions only by adding a WHERE clause that constrains
messageMention.mentionType to the direct mention value (e.g., "user" or the
appropriate enum) so only explicit mentions are returned (keep the rest of the
join with user and the inArray(messageMention.messageId, messageIds) condition
unchanged).
In `@apps/realtime/src/services/notifications.ts`:
- Around line 142-173: The current code builds mentionedUserIds from
mentionTypeByUserId.keys(), which includes users added by an `@everyone` fanout;
change the logic so mentionedUserIds only contains direct mention recipients
(i.e., filter keys by checking mentionTypeByUserId.get(userId) !== 'everyone' or
=== 'direct' depending on your enum/value). Then use that filtered
mentionedUserIds for the DB query and subsequent mentionUserMap/messageMentions
construction (symbols: mentionTypeByUserId, mentionedUserIds, mentionUsers,
mentionUserMap, messageMentions); keep the empty-array guard when no direct
mentions remain.
In `@apps/web/src/components/chat/composer/message-input.tsx`:
- Around line 292-300: The Enter key handler (handleKeyDown) can submit while an
IME composition is active; add an IME guard at the top of the Enter branch to
skip handling when composition is ongoing (use event.nativeEvent.isComposing or
event.isComposing where available) so that when event.key === "Enter" and
!event.shiftKey you first return if composition is active or if
isMentionSuggestionOpen() is true; keep the existing
preventDefault/stopPropagation and call to handleSend only when not composing.
In `@apps/web/src/components/chat/message-markdown.tsx`:
- Around line 19-21: The escapeMarkdownText function currently only escapes
backslashes and closing brackets; update it to also escape opening brackets so
mention labels containing "[" don't break generated markdown links—add a
replacement for "[" to "\\[" inside escapeMarkdownText (referencing the
escapeMarkdownText function used when building mention markdown tokens).
In `@apps/web/src/routes/_authenticated/`$guildSlug/$channelId.tsx:
- Around line 232-241: The mapping that creates mentionCandidates is run on
every render and should be memoized: wrap the guildMembersData?.members.map(...)
expression in React's useMemo (import useMemo if not already) to return the same
array identity unless guildMembersData?.members changes; target the
mentionCandidates const and the guildMembersData?.members source so the
dependency array is [guildMembersData?.members] to avoid needless rerenders and
downstream recomputation.
In `@packages/ui/src/components/calendar.tsx`:
- Line 90: The `table` className is set as a plain string and bypasses the
existing merge pattern; update the assignment that defines `table` in the
defaultClassNames (or wherever `table` is declared) to use the same `cn()` merge
pattern as the other entries (e.g., `table: cn("w-full border-collapse",
defaultClassNames.table)` or equivalent) so custom classes and react-day-picker
defaults are preserved; locate `table` alongside `defaultClassNames` and the
`cn` helper in this file and change it to merge instead of using a raw string.
In `@packages/ui/src/components/combobox.tsx`:
- Around line 55-71: The combobox is applying the incoming className to the
wrapper (InputGroup) instead of the actual input, preventing consumers from
styling the input; to fix, stop passing the incoming className into InputGroup
and instead forward it onto the input by merging it into the
ComboboxPrimitive.Input / InputGroupInput props (e.g. combine props.className
with any InputGroupInput classes), keep the wrapper using only the fixed
"w-auto" class (or a separate wrapperClassName if needed), and ensure the
existing disabled prop and other {...props} still flow into
ComboboxPrimitive.Input so consumers' className targets the real input element
(refer to ComboboxInput, InputGroup, ComboboxPrimitive.Input, InputGroupInput,
and the className/props usage).
In `@packages/ui/src/components/command.tsx`:
- Line 54: The class on the Command wrapper uses selectors targeting
[cmdk-input-wrapper]_svg but CommandInput renders
data-slot="command-input-wrapper", so the icon size rules never apply; update
the two selectors referencing cmdk-input-wrapper to match the data-slot
attribute (use the same :data-[slot=command-input-wrapper] pattern used
elsewhere) so the SVG sizing rules apply to the CommandInput icon; look for the
Command JSX with the long className and replace [&_[cmdk-input-wrapper]_svg]:h-5
and [&_[cmdk-input-wrapper]_svg]:w-5 with the corresponding
:data-[slot=command-input-wrapper] selectors to match CommandInput.
In `@packages/ui/src/components/popover.tsx`:
- Around line 57-63: PopoverTitle is typed as React.ComponentProps<"h2"> but
currently renders a <div>, causing semantic and typing mismatches; change the
rendered element in the PopoverTitle function from <div> to <h2>, keeping
data-slot="popover-title", className={cn("font-medium", className)} and
spreading {...props} so all h2 props (including refs/aria/children) remain
supported and heading semantics are preserved.
- Line 4: PopoverTitle's props are typed as React.ComponentProps<"h2"> but the
component renders a <div>, causing a props/type mismatch; update the
PopoverTitle component so its runtime element and props type match by either
changing the props type to React.ComponentProps<"div"> or replacing the rendered
<div> with an <h2> element; locate the PopoverTitle function and adjust the type
annotation or the JSX element accordingly, ensuring any className or styling
still applies after the change.
ℹ️ Review info
Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 13aa2919-e60d-4d37-8677-83e11606da2a
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (30)
apps/api/src/lib/helpers/openapi/message-schemas.tsapps/api/src/lib/queries/messages.tsapps/api/src/routes/v1/guilds/handlers.tsapps/api/src/routes/v1/guilds/schema.tsapps/realtime/src/index.tsapps/realtime/src/services/messages.tsapps/realtime/src/services/notifications.tsapps/web/package.jsonapps/web/src/components/chat/composer/mention-suggestion-list.tsxapps/web/src/components/chat/composer/mention-types.tsapps/web/src/components/chat/composer/message-input.tsxapps/web/src/components/chat/message-input.tsxapps/web/src/components/chat/message-item.tsxapps/web/src/components/chat/message-markdown.tsxapps/web/src/components/sidebar/channel-panel/channel-list.tsxapps/web/src/lib/realtime-adapter.tsapps/web/src/routes/_authenticated/$guildSlug/$channelId.tsxapps/web/src/routes/_authenticated/dms/$dmId.tsxpackages/realtime-types/src/events.tspackages/ui/package.jsonpackages/ui/src/components/calendar.tsxpackages/ui/src/components/combobox.tsxpackages/ui/src/components/command.tsxpackages/ui/src/components/hover-card.tsxpackages/ui/src/components/input-group.tsxpackages/ui/src/components/popover.tsxpackages/ui/src/components/select.tsxpackages/ui/src/components/sheet.tsxpackages/ui/src/components/switch.tsxpackages/ui/src/components/textarea.tsx
💤 Files with no reviewable changes (1)
- apps/web/src/components/chat/message-input.tsx
web/api/realtime/ui fixes
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/web/src/components/chat/composer/message-input.tsx`:
- Around line 256-286: handleSend uses editor methods without guarding for a
null editor; add an early null check at the top of handleSend (e.g., if
(!editor) return) so you never call editor.getMarkdown(),
editor.commands.clearContent, or editor.commands.focus when the editor is not
initialized, and keep the existing dependency on editor in the useCallback;
ensure all references in handleSend (getMarkdown, commands.clearContent,
commands.focus) are only executed after the editor null-guard.
- Around line 306-311: The effect currently dereferences editor.view.dom and may
throw if editor (or editor.view/dom) is null; update the effect that attaches
event listeners (the block using domNode.addEventListener and
domNode.removeEventListener) to first check that editor and editor.view and
editor.view.dom are non-null (e.g., guard with if (!editor || !editor.view ||
!editor.view.dom) return) before using domNode, and ensure the cleanup only runs
when the listener was actually added; keep references to handleKeyDown (and the
existing dependencies) intact.
In `@packages/ui/src/components/calendar.tsx`:
- Around line 12-15: The CalendarDayButton prop typing is broken because the
code imports DayButton as a type-only symbol but later uses typeof DayButton (a
value), causing a TS error; update the import to pull the proper props type from
react-day-picker (DayButtonProps) and replace uses of typeof DayButton with
DayButtonProps in the CalendarDayButton component signature/typing so the
component uses the correct prop type from react-day-picker.
In `@packages/ui/src/components/combobox.tsx`:
- Around line 42-52: ComboboxClear renders an icon-only button without an
accessible name; update the component (and the other icon-only wrappers in this
file such as the similar instances at the other noted ranges) to provide a
default accessible name by ensuring an aria-label (or aria-labelledby) is
present when none is passed in props—e.g., inside ComboboxClear check
props['aria-label'] || props['aria-labelledby'] and if missing, spread a default
aria-label="Clear" into the ComboboxPrimitive.Clear/ InputGroupButton props so
screen readers announce the control while preserving any explicit labels
provided by callers.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 955cd4b8-0d12-4155-a8b2-20741f0d05f4
📒 Files selected for processing (9)
apps/api/src/lib/queries/messages.tsapps/realtime/src/services/notifications.tsapps/web/src/components/chat/composer/message-input.tsxapps/web/src/components/chat/message-markdown.tsxapps/web/src/routes/_authenticated/$guildSlug/$channelId.tsxpackages/ui/src/components/calendar.tsxpackages/ui/src/components/combobox.tsxpackages/ui/src/components/command.tsxpackages/ui/src/components/popover.tsx
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (1)
packages/ui/src/components/combobox.tsx (1)
275-282:⚠️ Potential issue | 🟠 MajorAdd an accessible name to the chip remove icon button.
Line 276 renders an icon-only remove action without
aria-label/aria-labelledby, so assistive tech won’t announce a meaningful action name.♿ Proposed patch
{showRemove && ( <ComboboxPrimitive.ChipRemove + aria-label="Remove selection" render={<Button variant="ghost" size="icon-xs" />} className="-ml-1 opacity-50 hover:opacity-100" data-slot="combobox-chip-remove" >🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/ui/src/components/combobox.tsx` around lines 275 - 282, The chip remove button rendered by ComboboxPrimitive.ChipRemove lacks an accessible name; update the component to pass an aria-label (or aria-labelledby) to the rendered Button so screen readers announce the action (e.g., add aria-label="Remove item" or aria-label={`Remove ${itemLabel}`} when item label is available). Locate the ComboboxPrimitive.ChipRemove usage and add the aria-label prop to the Button passed to render, ensuring the label is specific if you can reference the chip's display text.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/web/src/components/chat/composer/message-input.tsx`:
- Around line 259-261: The send validation uses two different length metrics
(plain-text for the button and stored-markdown in handleSend), causing
inconsistent behavior; unify them by computing one canonical length value (e.g.,
const stored = toStoredMarkdown(editor.getMarkdown()).trim() and const length =
stored.length) and use that same value both for the send-button enabled state
and inside handleSend (replace the current plain-text checks and the separate
stored-markdown check). Ensure references to MAX_MESSAGE_LENGTH and isSending
remain, and update any UI checks that currently call editor.getText() or
editor.getMarkdown() separately so they rely on this single computed metric.
- Around line 70-77: The mention lookup in the items function uses
candidate.label and candidate.search only, so when candidate.search is missing
it can miss matches in other fields; update the filter in items (which calls
getMentionCandidates) to build a comparable string from candidate.label plus
fallback fields like candidate.username and candidate.name (and candidate.search
when present), and perform the normalized.includes check against that combined
lowercase string so queries match label, username, name or search even if some
fields are undefined.
In `@packages/ui/src/components/calendar.tsx`:
- Around line 42-44: The month dropdown is using a hard-coded "default" locale
in formatMonthDropdown which ignores the DayPicker locale prop; update
formatMonthDropdown to accept the DayPicker locale (or derive it from the
incoming formatters/props) and call toLocaleString with that locale instead of
"default" so month labels respect the DayPicker locale prop—look for
formatMonthDropdown and where formatters are spread into the DayPicker to
pass/consume the locale value and use it in the toLocaleString call.
---
Duplicate comments:
In `@packages/ui/src/components/combobox.tsx`:
- Around line 275-282: The chip remove button rendered by
ComboboxPrimitive.ChipRemove lacks an accessible name; update the component to
pass an aria-label (or aria-labelledby) to the rendered Button so screen readers
announce the action (e.g., add aria-label="Remove item" or aria-label={`Remove
${itemLabel}`} when item label is available). Locate the
ComboboxPrimitive.ChipRemove usage and add the aria-label prop to the Button
passed to render, ensuring the label is specific if you can reference the chip's
display text.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 6de22e7a-bf4d-4f3c-b758-f0f1e842c6eb
📒 Files selected for processing (3)
apps/web/src/components/chat/composer/message-input.tsxpackages/ui/src/components/calendar.tsxpackages/ui/src/components/combobox.tsx
| items: ({ query }) => { | ||
| const normalized = query.trim().toLowerCase() | ||
| const results = getMentionCandidates().filter((candidate) => { | ||
| if (!normalized) return true | ||
| return ( | ||
| candidate.label.toLowerCase().includes(normalized) || | ||
| candidate.search?.toLowerCase().includes(normalized) | ||
| ) |
There was a problem hiding this comment.
Mention lookup is incomplete when search is missing.
In channel context, candidates may not include search, so query matching falls back to label only and can miss username/name matches.
🔧 Proposed fix
items: ({ query }) => {
const normalized = query.trim().toLowerCase()
const results = getMentionCandidates().filter((candidate) => {
if (!normalized) return true
- return (
- candidate.label.toLowerCase().includes(normalized) ||
- candidate.search?.toLowerCase().includes(normalized)
- )
+ const searchable = [
+ candidate.label,
+ candidate.search,
+ candidate.name,
+ candidate.username ?? undefined,
+ candidate.displayUsername ?? undefined,
+ ]
+ .filter((value): value is string => Boolean(value))
+ .join(" ")
+ .toLowerCase()
+
+ return searchable.includes(normalized)
})
return results.slice(0, MAX_MENTION_RESULTS)
},📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| items: ({ query }) => { | |
| const normalized = query.trim().toLowerCase() | |
| const results = getMentionCandidates().filter((candidate) => { | |
| if (!normalized) return true | |
| return ( | |
| candidate.label.toLowerCase().includes(normalized) || | |
| candidate.search?.toLowerCase().includes(normalized) | |
| ) | |
| items: ({ query }) => { | |
| const normalized = query.trim().toLowerCase() | |
| const results = getMentionCandidates().filter((candidate) => { | |
| if (!normalized) return true | |
| const searchable = [ | |
| candidate.label, | |
| candidate.search, | |
| candidate.name, | |
| candidate.username ?? undefined, | |
| candidate.displayUsername ?? undefined, | |
| ] | |
| .filter((value): value is string => Boolean(value)) | |
| .join(" ") | |
| .toLowerCase() | |
| return searchable.includes(normalized) | |
| }) | |
| return results.slice(0, MAX_MENTION_RESULTS) | |
| }, |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/web/src/components/chat/composer/message-input.tsx` around lines 70 -
77, The mention lookup in the items function uses candidate.label and
candidate.search only, so when candidate.search is missing it can miss matches
in other fields; update the filter in items (which calls getMentionCandidates)
to build a comparable string from candidate.label plus fallback fields like
candidate.username and candidate.name (and candidate.search when present), and
perform the normalized.includes check against that combined lowercase string so
queries match label, username, name or search even if some fields are undefined.
| const markdown = toStoredMarkdown(editor.getMarkdown()) | ||
| const trimmed = markdown.trim() | ||
| if (!trimmed || trimmed.length > MAX_MESSAGE_LENGTH || isSending) return |
There was a problem hiding this comment.
Use one consistent length metric for send validation.
The button state uses plain-text length, but submit guard uses stored markdown length. This can enable send in UI but silently block in handleSend (notably with mention markup expansion).
🔧 Proposed fix
const handleSend = useCallback(() => {
if (!editor) return
const markdown = toStoredMarkdown(editor.getMarkdown())
const trimmed = markdown.trim()
- if (!trimmed || trimmed.length > MAX_MESSAGE_LENGTH || isSending) return
+ const trimmedPlainText = editor.getText({ blockSeparator: "\n" }).trim()
+ if (
+ !trimmed ||
+ !trimmedPlainText ||
+ trimmedPlainText.length > MAX_MESSAGE_LENGTH ||
+ isSending
+ ) {
+ return
+ }Also applies to: 319-323
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/web/src/components/chat/composer/message-input.tsx` around lines 259 -
261, The send validation uses two different length metrics (plain-text for the
button and stored-markdown in handleSend), causing inconsistent behavior; unify
them by computing one canonical length value (e.g., const stored =
toStoredMarkdown(editor.getMarkdown()).trim() and const length = stored.length)
and use that same value both for the send-button enabled state and inside
handleSend (replace the current plain-text checks and the separate
stored-markdown check). Ensure references to MAX_MESSAGE_LENGTH and isSending
remain, and update any UI checks that currently call editor.getText() or
editor.getMarkdown() separately so they rely on this single computed metric.
| formatMonthDropdown: (date) => | ||
| date.toLocaleString("default", { month: "short" }), | ||
| ...formatters, |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
react-day-picker v9 formatMonthDropdown signature locale prop
💡 Result:
In react-day-picker v9, the built-in formatter has this signature:
formatMonthDropdown(month: Date, dateLib?: DateLib): string(daypicker.dev)
How the locale prop relates:
- You set the locale on the picker via
<DayPicker locale={someLocale} />(aDayPickerLocale, i.e. date-fns locale). (daypicker.dev) - That locale is carried in the
DateLibinstance passed as the 2nd argument to formatters, available asdateLib.options.locale(becauseDateLibOptionsincludeslocale). (daypicker.dev)
(Also note: in v9 the dropdown formatters receive a Date for the month, not a number.) (daypicker.dev)
Citations:
- 1: https://daypicker.dev/api/functions/formatMonthDropdown
- 2: https://daypicker.dev/localization/changing-locale?utm_source=openai
- 3: https://daypicker.dev/api/interfaces/DateLibOptions
- 4: https://daypicker.dev/v8/changelog?utm_source=openai
🏁 Script executed:
# Check the actual file and surrounding context
cat -n packages/ui/src/components/calendar.tsx | head -60 | tail -30Repository: BuckyMcYolo/townhall
Length of output: 1478
🏁 Script executed:
# Look for package.json to confirm react-day-picker version
cat packages/ui/package.json | grep -A 5 -B 5 "react-day-picker"Repository: BuckyMcYolo/townhall
Length of output: 364
Fix locale handling in formatMonthDropdown to respect the DayPicker locale prop.
Line 42-43 hard-codes the locale as "default", which prevents the locale prop passed to DayPicker from affecting the month dropdown labels.
♻️ Suggested fix
formatters={{
- formatMonthDropdown: (date) =>
- date.toLocaleString("default", { month: "short" }),
+ formatMonthDropdown: (date, dateLib) =>
+ date.toLocaleString(dateLib?.options?.locale?.code ?? undefined, {
+ month: "short",
+ }),
...formatters,
}}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/ui/src/components/calendar.tsx` around lines 42 - 44, The month
dropdown is using a hard-coded "default" locale in formatMonthDropdown which
ignores the DayPicker locale prop; update formatMonthDropdown to accept the
DayPicker locale (or derive it from the incoming formatters/props) and call
toLocaleString with that locale instead of "default" so month labels respect the
DayPicker locale prop—look for formatMonthDropdown and where formatters are
spread into the DayPicker to pass/consume the locale value and use it in the
toLocaleString call.
end-to-end
replace textarea composer with Tiptap + mention suggestion UI
move compose-related chat pieces into the composer component folder
wire Enter/Shift+Enter behavior (Enter sends, Shift+Enter inserts newline)
prevent self-mentions and keep mention selection Enter behavior intact
add plus-button popover UX (UI button + rotating plus-to-x interaction)
improve composer vertical alignment/spacing in the chat layout
add markdown message rendering with react-markdown + remark-gfm
render mentions as highlighted inline tokens with hover-card shell UI
set hover-card open delay to 500ms
standardize user-facing mention labels to displayUsername first
add mention metadata through API/realtime payloads and schemas
include resolved mention users on realtime broadcast/ack payloads
support optimistic mention metadata to avoid temporary "unknown user" flashes
keep mention storage ID-based (
<@userId>) while rendering display labelsChat Composer Migration to Tiptap with Markdown Mentions
This PR replaces the textarea composer with a TipTap-based rich editor and implements full-stack support for markdown-style user mentions, plus several UI and infra additions to support the feature.
Architecture & Implementation
Composer redesign
End-to-end mention support
Markdown rendering & mention UX
Mention candidate discovery
UI & Dependency Additions
Notable Code Changes
Risks, Testing & Suggestions
Confidence Score: 4/5
Well-implemented across frontend, API, and realtime layers with appropriate typing and optimistic UX handling. Primary concerns are lack of automated tests and increased complexity in the new MessageInput component; otherwise the change appears production-ready with minor follow-ups recommended.