From ac2a4c419f481e96d205eb2863d8545ec7094349 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:38:29 +0800 Subject: [PATCH 1/6] refactor(editor): migrate to @tiptap/markdown, fix mention rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace tiptap-markdown with official @tiptap/markdown (markdown→JSON direct, skip DOM) - Add contentType:"markdown" for proper \n\n paragraph parsing - Fix mention renderHTML: use mergeAttributes for class/data-type, - Fix type attribute leak: add renderHTML:()=>({}) to suppress raw "type" attr - Link style: permanent underline → hover-only underline (matches read-only) - Mention style: primary+background pill → brand color text only - Comment edit: replace with RichTextEditor for consistency Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/common/rich-text-editor.css | 10 +- .../components/common/rich-text-editor.tsx | 109 ++++++++---------- apps/web/components/markdown/Markdown.tsx | 5 +- .../issues/components/comment-card.tsx | 42 +++---- 4 files changed, 74 insertions(+), 92 deletions(-) diff --git a/apps/web/components/common/rich-text-editor.css b/apps/web/components/common/rich-text-editor.css index 47c3c416..4df15d5d 100644 --- a/apps/web/components/common/rich-text-editor.css +++ b/apps/web/components/common/rich-text-editor.css @@ -127,18 +127,20 @@ /* Links */ .rich-text-editor a { color: var(--primary); + text-decoration: none; +} + +.rich-text-editor a:hover { text-decoration: underline; text-underline-offset: 2px; } /* Mentions */ .rich-text-editor .mention { - color: var(--primary); - background: color-mix(in srgb, var(--primary) 8%, transparent); - padding: 0 0.2em; - border-radius: calc(var(--radius) * 0.5); + color: var(--brand); font-weight: 500; text-decoration: none; + margin: 0 0.125rem; } /* Strong / emphasis */ diff --git a/apps/web/components/common/rich-text-editor.tsx b/apps/web/components/common/rich-text-editor.tsx index 447c4278..dd1c148a 100644 --- a/apps/web/components/common/rich-text-editor.tsx +++ b/apps/web/components/common/rich-text-editor.tsx @@ -13,7 +13,7 @@ import Link from "@tiptap/extension-link"; import Typography from "@tiptap/extension-typography"; import Mention from "@tiptap/extension-mention"; import { Markdown } from "@tiptap/markdown"; -import { Extension } from "@tiptap/core"; +import { Extension, mergeAttributes } from "@tiptap/core"; import { cn } from "@/lib/utils"; import { createMentionSuggestion } from "./mention-suggestion"; import "./rich-text-editor.css"; @@ -38,48 +38,12 @@ interface RichTextEditorRef { focus: () => void; } -// --------------------------------------------------------------------------- -// Submit shortcut extension (Mod+Enter) -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Mention extension configured for markdown serialization -// Stores as: [@Label](mention://type/id) -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Link extension — always serialize as [text](url), never autolinks; -// support Cmd+Click / Ctrl+Click to open in new tab. -// --------------------------------------------------------------------------- - const LinkExtension = Link.configure({ openOnClick: true, autolink: true, HTMLAttributes: { class: "text-primary hover:underline cursor-pointer", }, -}).extend({ - addStorage() { - return { - markdown: { - serialize: { - open() { - return "["; - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - close(_state: any, mark: any) { - const href = (mark.attrs.href as string).replace(/[\(\)"]/g, "\\$&"); - const title = mark.attrs.title - ? ` "${(mark.attrs.title as string).replace(/"/g, '\\"')}"` - : ""; - return `](${href}${title})`; - }, - mixable: true, - }, - parse: {}, - }, - }; - }, }); const MentionExtension = Mention.configure({ @@ -88,13 +52,16 @@ const MentionExtension = Mention.configure({ }).extend({ renderHTML({ node, HTMLAttributes }) { return [ - "a", - { - ...HTMLAttributes, - href: `mention://${node.attrs.type ?? "member"}/${node.attrs.id}`, - "data-mention-type": node.attrs.type ?? "member", - "data-mention-id": node.attrs.id, - }, + "span", + mergeAttributes( + { "data-type": "mention" }, + this.options.HTMLAttributes, + HTMLAttributes, + { + "data-mention-type": node.attrs.type ?? "member", + "data-mention-id": node.attrs.id, + }, + ), `@${node.attrs.label ?? node.attrs.id}`, ]; }, @@ -103,21 +70,39 @@ const MentionExtension = Mention.configure({ ...this.parent?.(), type: { default: "member", - parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-type") ?? "member", + parseHTML: (el: HTMLElement) => + el.getAttribute("data-mention-type") ?? "member", + renderHTML: () => ({}), }, }; }, - addStorage() { - return { - markdown: { - serialize(state: { write: (s: string) => void }, node: { attrs: { label?: string; type?: string; id?: string } }) { - state.write( - `[@${node.attrs.label ?? node.attrs.id}](mention://${node.attrs.type ?? "member"}/${node.attrs.id})`, - ); - }, - parse: {}, - }, - }; + // @tiptap/markdown: custom tokenizer to parse [@Label](mention://type/id) + markdownTokenizer: { + name: "mention", + level: "inline" as const, + start(src: string) { + return src.search(/\[@[^\]]+\]\(mention:\/\//); + }, + tokenize(src: string) { + const match = src.match( + /^\[@([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/, + ); + if (!match) return undefined; + return { + type: "mention", + raw: match[0], + attributes: { label: match[1], type: match[2], id: match[3] }, + }; + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parseMarkdown: (token: any, helpers: any) => { + return helpers.createNode("mention", token.attributes); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + renderMarkdown: (node: any) => { + const { id, label, type = "member" } = node.attrs || {}; + return `[@${label ?? id}](mention://${type}/${id})`; }, }); @@ -160,11 +145,6 @@ const RichTextEditor = forwardRef( const onUpdateRef = useRef(onUpdate); const onSubmitRef = useRef(onSubmit); - // Helper to get markdown from tiptap-markdown storage - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const getEditorMarkdown = (ed: any): string => - ed?.storage?.markdown?.getMarkdown?.() ?? ""; - // Keep refs in sync without recreating editor onUpdateRef.current = onUpdate; onSubmitRef.current = onSubmit; @@ -172,7 +152,8 @@ const RichTextEditor = forwardRef( const editor = useEditor({ immediatelyRender: false, editable, - content: defaultValue, + content: defaultValue || "", + contentType: defaultValue ? "markdown" : undefined, extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, @@ -191,7 +172,7 @@ const RichTextEditor = forwardRef( if (!onUpdateRef.current) return; if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { - onUpdateRef.current?.(getEditorMarkdown(ed)); + onUpdateRef.current?.(ed.getMarkdown()); }, debounceMs); }, editorProps: { @@ -223,7 +204,7 @@ const RichTextEditor = forwardRef( }, []); useImperativeHandle(ref, () => ({ - getMarkdown: () => getEditorMarkdown(editor), + getMarkdown: () => editor?.getMarkdown() ?? "", clearContent: () => { editor?.commands.clearContent(); }, diff --git a/apps/web/components/markdown/Markdown.tsx b/apps/web/components/markdown/Markdown.tsx index 511084da..a63c6213 100644 --- a/apps/web/components/markdown/Markdown.tsx +++ b/apps/web/components/markdown/Markdown.tsx @@ -61,10 +61,7 @@ function createComponents( // Mention links: mention://member/id or mention://agent/id if (href?.startsWith('mention://')) { return ( - + {children} ) diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index b72baec2..13aed067 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { Card } from "@/components/ui/card"; @@ -18,6 +18,7 @@ import { ReactionBar } from "@/components/common/reaction-bar"; import { Markdown } from "@/components/markdown"; import { useActorName } from "@/features/workspace"; import { timeAgo } from "@/shared/utils"; +import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; import { ReplyInput } from "./reply-input"; import type { TimelineEntry } from "@/shared/types"; @@ -54,28 +55,28 @@ function CommentRow({ }) { const { getActorName } = useActorName(); const [editing, setEditing] = useState(false); - const [editContent, setEditContent] = useState(""); + const editEditorRef = useRef(null); const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId; const isTemp = entry.id.startsWith("temp-"); const startEdit = () => { - setEditContent(entry.content ?? ""); setEditing(true); }; const cancelEdit = () => { setEditing(false); - setEditContent(""); }; const saveEdit = async () => { - const trimmed = editContent.trim(); + const trimmed = editEditorRef.current + ?.getMarkdown() + ?.replace(/(\n\s*)+$/, "") + .trim(); if (!trimmed) return; try { await onEdit(entry.id, trimmed); setEditing(false); - setEditContent(""); } catch { toast.error("Failed to update comment"); } @@ -140,23 +141,24 @@ function CommentRow({ {editing ? ( -
{ e.preventDefault(); saveEdit(); }} +
{ if (e.key === "Escape") cancelEdit(); }} > - setEditContent(e.target.value)} - aria-label="Edit comment" - className="w-full text-sm bg-transparent border-b border-border outline-none py-1" - onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }} - /> -
- - +
+
- +
+ + +
+
) : ( <>
From 9f03b73809176153593b751ecf5ead138d3e2a94 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:38:42 +0800 Subject: [PATCH 2/6] feat(editor): add TitleEditor component, replace for issue titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New TitleEditor: minimal tiptap (Document+Paragraph+Text+Placeholder) - Single-paragraph constraint prevents Enter from creating new lines - contenteditable div enables visual word-wrap (no horizontal scroll) - Enter→submit+blur, Shift+Enter blocked, Escape→blur - Replace in create-issue modal and in issue-detail - Remove titleDraft state/titleFocusedRef/sync effect from issue-detail - Fix duplicate React key: TitleEditor key={`title-${id}`} Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/app/globals.css | 8 +- apps/web/components/common/title-editor.css | 18 +++ apps/web/components/common/title-editor.tsx | 141 ++++++++++++++++++ .../issues/components/issue-detail.tsx | 38 ++--- apps/web/features/modals/create-issue.tsx | 18 +-- 5 files changed, 178 insertions(+), 45 deletions(-) create mode 100644 apps/web/components/common/title-editor.css create mode 100644 apps/web/components/common/title-editor.tsx diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 66118975..ab982eeb 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -96,8 +96,8 @@ --warning: oklch(0.75 0.16 85); --info: oklch(0.55 0.18 250); --priority: oklch(0.65 0.18 50); - --scrollbar-thumb: oklch(0.82 0.003 286); - --scrollbar-thumb-hover: oklch(0.705 0.015 286.067); + --scrollbar-thumb: oklch(0 0 0 / 10%); + --scrollbar-thumb-hover: oklch(0 0 0 / 18%); --scrollbar-track: transparent; } @@ -140,8 +140,8 @@ --warning: oklch(0.70 0.16 85); --info: oklch(0.65 0.18 250); --priority: oklch(0.70 0.18 50); - --scrollbar-thumb: oklch(1 0 0 / 15%); - --scrollbar-thumb-hover: oklch(1 0 0 / 30%); + --scrollbar-thumb: oklch(1 0 0 / 8%); + --scrollbar-thumb-hover: oklch(1 0 0 / 18%); --scrollbar-track: transparent; } diff --git a/apps/web/components/common/title-editor.css b/apps/web/components/common/title-editor.css new file mode 100644 index 00000000..70d63a06 --- /dev/null +++ b/apps/web/components/common/title-editor.css @@ -0,0 +1,18 @@ +/* Title editor: minimal ProseMirror for single-line titles */ + +.title-editor.ProseMirror { + outline: none; +} + +.title-editor.ProseMirror p { + margin: 0; +} + +/* Placeholder */ +.title-editor .is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + color: var(--muted-foreground); + pointer-events: none; + height: 0; +} diff --git a/apps/web/components/common/title-editor.tsx b/apps/web/components/common/title-editor.tsx new file mode 100644 index 00000000..14837b27 --- /dev/null +++ b/apps/web/components/common/title-editor.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; +import { useEditor, EditorContent } from "@tiptap/react"; +import { Extension } from "@tiptap/core"; +import { Document } from "@tiptap/extension-document"; +import { Paragraph } from "@tiptap/extension-paragraph"; +import { Text } from "@tiptap/extension-text"; +import Placeholder from "@tiptap/extension-placeholder"; +import { cn } from "@/lib/utils"; +import "./title-editor.css"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface TitleEditorProps { + defaultValue?: string; + placeholder?: string; + className?: string; + autoFocus?: boolean; + onSubmit?: () => void; + onBlur?: (value: string) => void; + onChange?: (value: string) => void; +} + +interface TitleEditorRef { + getText: () => string; + focus: () => void; +} + +// --------------------------------------------------------------------------- +// Single-paragraph document — prevents Enter from creating new lines +// --------------------------------------------------------------------------- + +const SingleLineDocument = Document.extend({ + content: "paragraph", +}); + +// --------------------------------------------------------------------------- +// Keyboard shortcuts: Enter → submit, Escape → blur +// --------------------------------------------------------------------------- + +function createTitleKeymap(opts: { + onSubmitRef: React.RefObject<(() => void) | undefined>; +}) { + return Extension.create({ + name: "titleKeymap", + addKeyboardShortcuts() { + return { + Enter: ({ editor }) => { + opts.onSubmitRef.current?.(); + editor.commands.blur(); + return true; + }, + "Shift-Enter": () => true, // swallow — no line breaks + Escape: ({ editor }) => { + editor.commands.blur(); + return true; + }, + }; + }, + }); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +const TitleEditor = forwardRef( + function TitleEditor( + { + defaultValue = "", + placeholder: placeholderText = "", + className, + autoFocus = false, + onSubmit, + onBlur, + onChange, + }, + ref, + ) { + const onSubmitRef = useRef(onSubmit); + const onBlurRef = useRef(onBlur); + const onChangeRef = useRef(onChange); + + onSubmitRef.current = onSubmit; + onBlurRef.current = onBlur; + onChangeRef.current = onChange; + + const editor = useEditor({ + immediatelyRender: false, + content: `

${defaultValue}

`, + extensions: [ + SingleLineDocument, + Paragraph, + Text, + Placeholder.configure({ + placeholder: placeholderText, + showOnlyCurrent: false, + }), + createTitleKeymap({ onSubmitRef }), + ], + editorProps: { + attributes: { + class: cn("title-editor outline-none", className), + role: "textbox", + "aria-multiline": "false", + "aria-label": placeholderText || "Title", + }, + }, + onUpdate: ({ editor: ed }) => { + onChangeRef.current?.(ed.getText()); + }, + onBlur: ({ editor: ed }) => { + onBlurRef.current?.(ed.getText()); + }, + }); + + // Auto-focus after mount + useEffect(() => { + if (autoFocus && editor) { + // Move cursor to end + editor.commands.focus("end"); + } + }, [autoFocus, editor]); + + useImperativeHandle(ref, () => ({ + getText: () => editor?.getText() ?? "", + focus: () => { + editor?.commands.focus("end"); + }, + })); + + if (!editor) return null; + + return ; + }, +); + +export { TitleEditor, type TitleEditorProps, type TitleEditorRef }; diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index 53e44bb9..c6f8db86 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback, useRef, memo } from "react"; +import { useState, useEffect, useCallback, memo } from "react"; import { useDefaultLayout, usePanelRef } from "react-resizable-panels"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -43,8 +43,8 @@ import { DropdownMenuSubContent, } from "@/components/ui/dropdown-menu"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"; -import { Input } from "@/components/ui/input"; import { RichTextEditor } from "@/components/common/rich-text-editor"; +import { TitleEditor } from "@/components/common/title-editor"; import { Tooltip, TooltipTrigger, @@ -185,8 +185,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo const sidebarRef = usePanelRef(); const [sidebarOpen, setSidebarOpen] = useState(defaultSidebarOpen); const [deleting, setDeleting] = useState(false); - const [titleDraft, setTitleDraft] = useState(""); - const titleFocusedRef = useRef(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [propertiesOpen, setPropertiesOpen] = useState(true); const [detailsOpen, setDetailsOpen] = useState(true); @@ -211,13 +209,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo .finally(() => setIssueLoading(false)); }, [id, !!issue]); - // Sync titleDraft when issue title changes (from WS or other views) - useEffect(() => { - if (issue && !titleFocusedRef.current) { - setTitleDraft(issue.title); - } - }, [issue?.title]); - // Custom hooks — encapsulate timeline, reactions, subscribers const { timeline, submitting, submitComment, submitReply, @@ -547,26 +538,15 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo {/* Content — scrollable */}
- setTitleDraft(e.target.value)} - onFocus={() => { titleFocusedRef.current = true; }} - onBlur={() => { - titleFocusedRef.current = false; - const trimmed = titleDraft.trim(); + { + const trimmed = value.trim(); if (trimmed && trimmed !== issue.title) handleUpdateField({ title: trimmed }); - else setTitleDraft(issue.title); }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - (e.target as HTMLInputElement).blur(); - } else if (e.key === "Escape") { - setTitleDraft(issue.title); - (e.target as HTMLInputElement).blur(); - } - }} - className="w-full bg-transparent text-2xl font-bold leading-snug tracking-tight outline-none placeholder:text-muted-foreground" /> void; data? {/* Title */}
- updateTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSubmit(); - } - }} + defaultValue={draft.title} placeholder="Issue title" - className="border-none shadow-none px-0 text-lg font-semibold focus-visible:ring-0 dark:bg-transparent" + className="text-lg font-semibold" + onChange={(v) => updateTitle(v)} + onSubmit={handleSubmit} />
From b9ea10c89dfaf285534abbbd00ef1133005c7cf8 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:50:22 +0800 Subject: [PATCH 3/6] fix(comments): unify rendering with RichTextEditor, fix mention/link colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Comment display: replace with - Link color: primary → brand (blue) - Mention color: brand → primary + semibold - Add MentionHoverCard component with HoverCardTrigger render={} - Markdown.tsx: sync mention style to text-primary font-semibold Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/common/mention-hover-card.tsx | 70 +++++++++++++++++++ .../components/common/rich-text-editor.css | 6 +- apps/web/components/markdown/Markdown.tsx | 12 +++- .../issues/components/comment-card.tsx | 3 +- 4 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 apps/web/components/common/mention-hover-card.tsx diff --git a/apps/web/components/common/mention-hover-card.tsx b/apps/web/components/common/mention-hover-card.tsx new file mode 100644 index 00000000..f54d4ad2 --- /dev/null +++ b/apps/web/components/common/mention-hover-card.tsx @@ -0,0 +1,70 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Bot } from "lucide-react"; +import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card"; +import { ActorAvatar } from "@/components/common/actor-avatar"; +import { useWorkspaceStore } from "@/features/workspace"; + +interface MentionHoverCardProps { + type: string; + id: string; + children: ReactNode; +} + +function MentionHoverCard({ type, id, children }: MentionHoverCardProps) { + const members = useWorkspaceStore((s) => s.members); + const agents = useWorkspaceStore((s) => s.agents); + + if (type === "member") { + const member = members.find((m) => m.user_id === id); + if (!member) return <>{children}; + + return ( + + } className="cursor-default"> + {children} + + +
+ +
+

{member.name}

+

{member.email}

+
+
+
+
+ ); + } + + if (type === "agent") { + const agent = agents.find((a) => a.id === id); + if (!agent) return <>{children}; + + return ( + + } className="cursor-default"> + {children} + + +
+
+ +
+
+

{agent.name}

+ {agent.description && ( +

{agent.description}

+ )} +
+
+
+
+ ); + } + + return <>{children}; +} + +export { MentionHoverCard }; diff --git a/apps/web/components/common/rich-text-editor.css b/apps/web/components/common/rich-text-editor.css index 4df15d5d..f387d05d 100644 --- a/apps/web/components/common/rich-text-editor.css +++ b/apps/web/components/common/rich-text-editor.css @@ -126,7 +126,7 @@ /* Links */ .rich-text-editor a { - color: var(--primary); + color: var(--brand); text-decoration: none; } @@ -137,8 +137,8 @@ /* Mentions */ .rich-text-editor .mention { - color: var(--brand); - font-weight: 500; + color: var(--primary); + font-weight: 600; text-decoration: none; margin: 0 0.125rem; } diff --git a/apps/web/components/markdown/Markdown.tsx b/apps/web/components/markdown/Markdown.tsx index a63c6213..00038dad 100644 --- a/apps/web/components/markdown/Markdown.tsx +++ b/apps/web/components/markdown/Markdown.tsx @@ -3,6 +3,7 @@ import ReactMarkdown, { type Components } from 'react-markdown' import rehypeRaw from 'rehype-raw' import remarkGfm from 'remark-gfm' import { cn } from '@/lib/utils' +import { MentionHoverCard } from '@/components/common/mention-hover-card' import { CodeBlock, InlineCode } from './CodeBlock' import { preprocessLinks } from './linkify' @@ -60,10 +61,15 @@ function createComponents( a: ({ href, children }) => { // Mention links: mention://member/id or mention://agent/id if (href?.startsWith('mention://')) { + const parts = href.replace('mention://', '').split('/') + const mentionType = parts[0] ?? 'member' + const mentionId = parts[1] ?? '' return ( - - {children} - + + + {children} + + ) } diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index 13aed067..2e2b7b51 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -15,7 +15,6 @@ import { import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { ActorAvatar } from "@/components/common/actor-avatar"; import { ReactionBar } from "@/components/common/reaction-bar"; -import { Markdown } from "@/components/markdown"; import { useActorName } from "@/features/workspace"; import { timeAgo } from "@/shared/utils"; import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; @@ -162,7 +161,7 @@ function CommentRow({ ) : ( <>
- {entry.content ?? ""} +
{!isTemp && ( Date: Tue, 31 Mar 2026 16:03:13 +0800 Subject: [PATCH 4/6] fix(comments): replace optimistic updates with loading state - Remove temp-xxx optimistic inserts from submitComment/submitReply - Wait for API response, then insert real comment into timeline - Add Loader2 spinner to comment/reply submit buttons during loading - Remove hover card from Markdown.tsx (will be handled via NodeView later) Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/components/markdown/Markdown.tsx | 12 ++---- .../issues/components/comment-input.tsx | 4 +- .../issues/components/reply-input.tsx | 4 +- .../issues/hooks/use-issue-timeline.ts | 42 ++++--------------- 4 files changed, 15 insertions(+), 47 deletions(-) diff --git a/apps/web/components/markdown/Markdown.tsx b/apps/web/components/markdown/Markdown.tsx index 00038dad..d09f31a5 100644 --- a/apps/web/components/markdown/Markdown.tsx +++ b/apps/web/components/markdown/Markdown.tsx @@ -3,7 +3,6 @@ import ReactMarkdown, { type Components } from 'react-markdown' import rehypeRaw from 'rehype-raw' import remarkGfm from 'remark-gfm' import { cn } from '@/lib/utils' -import { MentionHoverCard } from '@/components/common/mention-hover-card' import { CodeBlock, InlineCode } from './CodeBlock' import { preprocessLinks } from './linkify' @@ -61,15 +60,10 @@ function createComponents( a: ({ href, children }) => { // Mention links: mention://member/id or mention://agent/id if (href?.startsWith('mention://')) { - const parts = href.replace('mention://', '').split('/') - const mentionType = parts[0] ?? 'member' - const mentionId = parts[1] ?? '' return ( - - - {children} - - + + {children} + ) } diff --git a/apps/web/features/issues/components/comment-input.tsx b/apps/web/features/issues/components/comment-input.tsx index 6e2b8052..6aaa2041 100644 --- a/apps/web/features/issues/components/comment-input.tsx +++ b/apps/web/features/issues/components/comment-input.tsx @@ -1,7 +1,7 @@ "use client"; import { useRef, useState } from "react"; -import { ArrowUp } from "lucide-react"; +import { ArrowUp, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; @@ -44,7 +44,7 @@ function CommentInput({ onSubmit }: CommentInputProps) { disabled={isEmpty || submitting} onClick={handleSubmit} > - + {submitting ? : }
diff --git a/apps/web/features/issues/components/reply-input.tsx b/apps/web/features/issues/components/reply-input.tsx index b95662c4..d58c8443 100644 --- a/apps/web/features/issues/components/reply-input.tsx +++ b/apps/web/features/issues/components/reply-input.tsx @@ -1,7 +1,7 @@ "use client"; import { useRef, useState } from "react"; -import { ArrowUp } from "lucide-react"; +import { ArrowUp, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; import { ActorAvatar } from "@/components/common/actor-avatar"; @@ -83,7 +83,7 @@ function ReplyInput({ onClick={handleSubmit} tabIndex={isEmpty ? -1 : 0} > - + {submitting ? : }
diff --git a/apps/web/features/issues/hooks/use-issue-timeline.ts b/apps/web/features/issues/hooks/use-issue-timeline.ts index 98530d9a..63555d0c 100644 --- a/apps/web/features/issues/hooks/use-issue-timeline.ts +++ b/apps/web/features/issues/hooks/use-issue-timeline.ts @@ -176,27 +176,14 @@ export function useIssueTimeline(issueId: string, userId?: string) { const submitComment = useCallback( async (content: string) => { if (!content.trim() || submitting || !userId) return; - const tempId = "temp-" + Date.now(); - const tempEntry: TimelineEntry = { - type: "comment", - id: tempId, - actor_type: "member", - actor_id: userId, - content, - parent_id: null, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - comment_type: "comment", - }; - setTimeline((prev) => [...prev, tempEntry]); setSubmitting(true); try { const comment = await api.createComment(issueId, content); - setTimeline((prev) => - prev.map((e) => (e.id === tempId ? commentToTimelineEntry(comment) : e)), - ); + setTimeline((prev) => { + if (prev.some((e) => e.id === comment.id)) return prev; + return [...prev, commentToTimelineEntry(comment)]; + }); } catch { - setTimeline((prev) => prev.filter((e) => e.id !== tempId)); toast.error("Failed to send comment"); } finally { setSubmitting(false); @@ -208,26 +195,13 @@ export function useIssueTimeline(issueId: string, userId?: string) { const submitReply = useCallback( async (parentId: string, content: string) => { if (!content.trim() || !userId) return; - const tempId = "temp-" + Date.now(); - const tempEntry: TimelineEntry = { - type: "comment", - id: tempId, - actor_type: "member", - actor_id: userId, - content, - parent_id: parentId, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - comment_type: "comment", - }; - setTimeline((prev) => [...prev, tempEntry]); try { const comment = await api.createComment(issueId, content, "comment", parentId); - setTimeline((prev) => - prev.map((e) => (e.id === tempId ? commentToTimelineEntry(comment) : e)), - ); + setTimeline((prev) => { + if (prev.some((e) => e.id === comment.id)) return prev; + return [...prev, commentToTimelineEntry(comment)]; + }); } catch { - setTimeline((prev) => prev.filter((e) => e.id !== tempId)); toast.error("Failed to send reply"); } }, From bb2dd679414006370595729e617150f14347ad7e Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:15:26 +0800 Subject: [PATCH 5/6] fix(comments): collapsible header overflow, hover style on toggle - Add shrink-0 to name/time to prevent wrapping when collapsed - Content preview: min-w-0 flex-1 truncate for proper ellipsis - Collapsible trigger: add rounded p-0.5 hover:bg-muted for click affordance Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/features/issues/components/comment-card.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index 57091474..2bea2dda 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -245,17 +245,17 @@ function CommentCard({ {/* Header — always visible, acts as toggle */}
- + - + {getActorName(entry.actor_type, entry.actor_id)} + {timeAgo(entry.created_at)} } @@ -266,12 +266,12 @@ function CommentCard({ {!open && contentPreview && ( - - {contentPreview}{(entry.content ?? "").length > 80 ? "..." : ""} + + {contentPreview} )} {!open && replyCount > 0 && ( - + {replyCount} {replyCount === 1 ? "reply" : "replies"} )} From 27987adf37bae18d02631478beb090b0c2d66314 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:17:41 +0800 Subject: [PATCH 6/6] fix(inbox): use issue_id as selection key instead of inbox item id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - URL param: ?id= → ?issue= (keyed by issue, not notification) - Multiple notifications for same issue now share selection state - Archive correctly clears selection when archived item's issue matches Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/app/(dashboard)/inbox/page.tsx | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index 9d3b62d2..83501a99 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -219,9 +219,9 @@ function InboxListItem({ export default function InboxPage() { const searchParams = useSearchParams(); - const selectedId = searchParams.get("id") ?? ""; - const setSelectedId = (id: string) => { - const url = id ? `/inbox?id=${id}` : "/inbox"; + const selectedKey = searchParams.get("issue") ?? ""; + const setSelectedKey = (key: string) => { + const url = key ? `/inbox?issue=${key}` : "/inbox"; window.history.replaceState(null, "", url); }; @@ -232,12 +232,12 @@ export default function InboxPage() { id: "multica_inbox_layout", }); - const selected = items.find((i) => i.id === selectedId) ?? null; + const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null; const unreadCount = items.filter((i) => !i.read).length; // Click-to-read: select + auto-mark-read const handleSelect = async (item: InboxItem) => { - setSelectedId(item.id); + setSelectedKey(item.issue_id ?? item.id); if (!item.read) { useInboxStore.getState().markRead(item.id); try { @@ -254,7 +254,8 @@ export default function InboxPage() { try { await api.archiveInbox(id); useInboxStore.getState().archive(id); - if (selectedId === id) setSelectedId(""); + const archived = items.find((i) => i.id === id); + if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey(""); } catch { toast.error("Failed to archive"); } @@ -274,7 +275,7 @@ export default function InboxPage() { const handleArchiveAll = async () => { try { useInboxStore.getState().archiveAll(); - setSelectedId(""); + setSelectedKey(""); await api.archiveAllInbox(); } catch { toast.error("Failed to archive all"); @@ -284,9 +285,9 @@ export default function InboxPage() { const handleArchiveAllRead = async () => { try { - const readIds = items.filter((i) => i.read).map((i) => i.id); + const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id); useInboxStore.getState().archiveAllRead(); - if (readIds.includes(selectedId)) setSelectedId(""); + if (readKeys.includes(selectedKey)) setSelectedKey(""); await api.archiveAllReadInbox(); } catch { toast.error("Failed to archive read items"); @@ -297,7 +298,7 @@ export default function InboxPage() { const handleArchiveCompleted = async () => { try { await api.archiveCompletedInbox(); - setSelectedId(""); + setSelectedKey(""); await useInboxStore.getState().fetch(); } catch { toast.error("Failed to archive completed"); @@ -395,7 +396,7 @@ export default function InboxPage() { handleSelect(item)} onArchive={() => handleArchive(item.id)} />