Merge pull request #226 from multica-ai/feature/comment-context-menu

feat(editor): migrate to @tiptap/markdown, TitleEditor, comment UX improvements
This commit is contained in:
Naiyuan Qing 2026-03-31 16:19:10 +08:00 committed by GitHub
commit 9e06b02cfa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 382 additions and 222 deletions

View file

@ -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() {
<InboxListItem
key={item.id}
item={item}
isSelected={item.id === selectedId}
isSelected={(item.issue_id ?? item.id) === selectedKey}
onClick={() => handleSelect(item)}
onArchive={() => handleArchive(item.id)}
/>

View file

@ -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;
}

View file

@ -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 (
<HoverCard>
<HoverCardTrigger render={<span />} className="cursor-default">
{children}
</HoverCardTrigger>
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
<div className="flex items-center gap-2.5">
<ActorAvatar actorType="member" actorId={id} size={32} />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{member.name}</p>
<p className="text-xs text-muted-foreground truncate">{member.email}</p>
</div>
</div>
</HoverCardContent>
</HoverCard>
);
}
if (type === "agent") {
const agent = agents.find((a) => a.id === id);
if (!agent) return <>{children}</>;
return (
<HoverCard>
<HoverCardTrigger render={<span />} className="cursor-default">
{children}
</HoverCardTrigger>
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
<Bot className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{agent.name}</p>
{agent.description && (
<p className="text-xs text-muted-foreground truncate">{agent.description}</p>
)}
</div>
</div>
</HoverCardContent>
</HoverCard>
);
}
return <>{children}</>;
}
export { MentionHoverCard };

View file

@ -126,7 +126,11 @@
/* Links */
.rich-text-editor a {
color: var(--primary);
color: var(--brand);
text-decoration: none;
}
.rich-text-editor a:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
@ -134,11 +138,9 @@
/* 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);
font-weight: 500;
font-weight: 600;
text-decoration: none;
margin: 0 0.125rem;
}
/* Strong / emphasis */

View file

@ -14,7 +14,7 @@ import Typography from "@tiptap/extension-typography";
import Mention from "@tiptap/extension-mention";
import Image from "@tiptap/extension-image";
import { Markdown } from "@tiptap/markdown";
import { Extension } from "@tiptap/core";
import { Extension, mergeAttributes } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { cn } from "@/lib/utils";
import type { UploadResult } from "@/shared/hooks/use-file-upload";
@ -43,48 +43,12 @@ interface RichTextEditorRef {
insertFile: (filename: string, url: string, isImage: boolean) => 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 <url> 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({
@ -93,13 +57,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}`,
];
},
@ -108,21 +75,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})`;
},
});
@ -238,11 +223,6 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
const onSubmitRef = useRef(onSubmit);
const onUploadFileRef = useRef(onUploadFile);
// Helper to get markdown from @tiptap/markdown extension
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getEditorMarkdown = (ed: any): string =>
ed?.getMarkdown?.() ?? "";
// Keep refs in sync without recreating editor
onUpdateRef.current = onUpdate;
onSubmitRef.current = onSubmit;
@ -251,7 +231,8 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
const editor = useEditor({
immediatelyRender: false,
editable,
content: defaultValue,
content: defaultValue || "",
contentType: defaultValue ? "markdown" : undefined,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
@ -276,7 +257,7 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
if (!onUpdateRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onUpdateRef.current?.(getEditorMarkdown(ed));
onUpdateRef.current?.(ed.getMarkdown());
}, debounceMs);
},
editorProps: {
@ -308,7 +289,7 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
}, []);
useImperativeHandle(ref, () => ({
getMarkdown: () => getEditorMarkdown(editor),
getMarkdown: () => editor?.getMarkdown() ?? "",
clearContent: () => {
editor?.commands.clearContent();
},

View file

@ -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;
}

View file

@ -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<TitleEditorRef, TitleEditorProps>(
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: `<p>${defaultValue}</p>`,
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 <EditorContent editor={editor} />;
},
);
export { TitleEditor, type TitleEditorProps, type TitleEditorRef };

View file

@ -70,10 +70,7 @@ function createComponents(
// Mention links: mention://member/id or mention://agent/id
if (href?.startsWith('mention://')) {
return (
<span
className="text-primary font-medium"
style={{ background: 'color-mix(in srgb, var(--primary) 8%, transparent)', padding: '0 0.2em', borderRadius: 'calc(var(--radius) * 0.5)' }}
>
<span className="text-primary font-semibold mx-0.5">
{children}
</span>
)

View file

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import { ChevronRight, Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Card } from "@/components/ui/card";
@ -16,10 +16,10 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { ReactionBar } from "@/components/common/reaction-bar";
import { Markdown } from "@/components/markdown";
import { cn } from "@/lib/utils";
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";
@ -57,28 +57,28 @@ function CommentRow({
}) {
const { getActorName } = useActorName();
const [editing, setEditing] = useState(false);
const [editContent, setEditContent] = useState("");
const editEditorRef = useRef<RichTextEditorRef>(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");
}
@ -143,27 +143,28 @@ function CommentRow({
</div>
{editing ? (
<form
onSubmit={(e) => { e.preventDefault(); saveEdit(); }}
<div
className="mt-2 pl-8"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
>
<input
autoFocus
value={editContent}
onChange={(e) => 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(); }}
/>
<div className="flex gap-2 mt-1.5">
<Button size="sm" type="submit">Save</Button>
<Button size="sm" variant="ghost" type="button" onClick={cancelEdit}>Cancel</Button>
<div className="max-h-48 overflow-y-auto rounded-md border border-border px-3 py-2">
<RichTextEditor
ref={editEditorRef}
defaultValue={entry.content ?? ""}
placeholder="Edit comment..."
onSubmit={saveEdit}
debounceMs={100}
/>
</div>
</form>
<div className="flex gap-2 mt-1.5">
<Button size="sm" onClick={saveEdit}>Save</Button>
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
</div>
</div>
) : (
<>
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
<RichTextEditor defaultValue={entry.content ?? ""} editable={false} />
</div>
{!isTemp && (
<ReactionBar
@ -196,28 +197,28 @@ function CommentCard({
const { getActorName } = useActorName();
const [open, setOpen] = useState(true);
const [editing, setEditing] = useState(false);
const [editContent, setEditContent] = useState("");
const editEditorRef = useRef<RichTextEditorRef>(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");
}
@ -244,17 +245,17 @@ function CommentCard({
{/* Header — always visible, acts as toggle */}
<div className="px-4 py-3">
<div className="flex items-center gap-2.5">
<CollapsibleTrigger className="shrink-0 text-muted-foreground hover:text-foreground transition-colors">
<CollapsibleTrigger className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", open && "rotate-90")} />
</CollapsibleTrigger>
<ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={24} />
<span className="text-sm font-medium">
<span className="shrink-0 text-sm font-medium">
{getActorName(entry.actor_type, entry.actor_id)}
</span>
<Tooltip>
<TooltipTrigger
render={
<span className="text-xs text-muted-foreground cursor-default">
<span className="shrink-0 text-xs text-muted-foreground cursor-default">
{timeAgo(entry.created_at)}
</span>
}
@ -265,12 +266,12 @@ function CommentCard({
</Tooltip>
{!open && contentPreview && (
<span className="text-xs text-muted-foreground truncate">
{contentPreview}{(entry.content ?? "").length > 80 ? "..." : ""}
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{contentPreview}
</span>
)}
{!open && replyCount > 0 && (
<span className="text-xs text-muted-foreground shrink-0 ml-auto">
<span className="shrink-0 text-xs text-muted-foreground">
{replyCount} {replyCount === 1 ? "reply" : "replies"}
</span>
)}
@ -317,27 +318,28 @@ function CommentCard({
{/* Parent comment body */}
<div className="px-4 pb-3">
{editing ? (
<form
onSubmit={(e) => { e.preventDefault(); saveEdit(); }}
<div
className="pl-10"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
>
<input
autoFocus
value={editContent}
onChange={(e) => 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(); }}
/>
<div className="flex gap-2 mt-1.5">
<Button size="sm" type="submit">Save</Button>
<Button size="sm" variant="ghost" type="button" onClick={cancelEdit}>Cancel</Button>
<div className="max-h-48 overflow-y-auto rounded-md border border-border px-3 py-2">
<RichTextEditor
ref={editEditorRef}
defaultValue={entry.content ?? ""}
placeholder="Edit comment..."
onSubmit={saveEdit}
debounceMs={100}
/>
</div>
</form>
<div className="flex gap-2 mt-1.5">
<Button size="sm" onClick={saveEdit}>Save</Button>
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
</div>
</div>
) : (
<>
<div className="pl-10 text-sm leading-relaxed text-foreground/85">
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
<RichTextEditor defaultValue={entry.content ?? ""} editable={false} />
</div>
{!isTemp && (
<ReactionBar

View file

@ -1,7 +1,7 @@
"use client";
import { useRef, useState } from "react";
import { ArrowUp, Paperclip } from "lucide-react";
import { ArrowUp, Loader2, Paperclip } from "lucide-react";
import { Button } from "@/components/ui/button";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
@ -76,7 +76,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
disabled={isEmpty || submitting}
onClick={handleSubmit}
>
<ArrowUp className="h-4 w-4" />
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <ArrowUp className="h-4 w-4" />}
</Button>
</div>
</div>

View file

@ -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,
@ -187,8 +187,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);
@ -213,13 +211,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,
@ -554,26 +545,15 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{/* Content — scrollable */}
<div className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-4xl px-8 py-8">
<input
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onFocus={() => { titleFocusedRef.current = true; }}
onBlur={() => {
titleFocusedRef.current = false;
const trimmed = titleDraft.trim();
<TitleEditor
key={`title-${id}`}
defaultValue={issue.title}
placeholder="Issue title"
className="w-full text-2xl font-bold leading-snug tracking-tight"
onBlur={(value) => {
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"
/>
<RichTextEditor

View file

@ -1,7 +1,7 @@
"use client";
import { useRef, useState } from "react";
import { ArrowUp, Paperclip } from "lucide-react";
import { ArrowUp, Loader2, Paperclip } 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";
@ -117,7 +117,7 @@ function ReplyInput({
onClick={handleSubmit}
tabIndex={isEmpty ? -1 : 0}
>
<ArrowUp className="h-3.5 w-3.5" />
{submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ArrowUp className="h-3.5 w-3.5" />}
</Button>
</div>
</div>

View file

@ -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");
}
},

View file

@ -24,8 +24,8 @@ import {
import { Calendar } from "@/components/ui/calendar";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { TitleEditor } from "@/components/common/title-editor";
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
@ -186,19 +186,13 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
{/* Title */}
<div className="px-5 pb-2 shrink-0">
<Input
<TitleEditor
autoFocus
type="text"
value={title}
onChange={(e) => 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}
/>
</div>

View file

@ -18,7 +18,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@floating-ui/dom": "^1.7.6",
"@tiptap/extension-image": "^3.21.0",
"@tiptap/extension-image": "^3.20.5",
"@tiptap/extension-link": "^3.20.5",
"@tiptap/extension-mention": "^3.20.5",
"@tiptap/extension-placeholder": "^3.20.5",

12
pnpm-lock.yaml generated
View file

@ -76,8 +76,8 @@ importers:
specifier: ^1.7.6
version: 1.7.6
'@tiptap/extension-image':
specifier: ^3.21.0
version: 3.21.0(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
specifier: ^3.20.5
version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
'@tiptap/extension-link':
specifier: ^3.20.5
version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
@ -1371,10 +1371,10 @@ packages:
'@tiptap/core': ^3.20.5
'@tiptap/pm': ^3.20.5
'@tiptap/extension-image@3.21.0':
resolution: {integrity: sha512-W9786a2K4LSZJMPeRLmoDulJeXOsM0ueRV2MHjTol7ikPRauROB7GUbAz9DyPAJHA2AGUfpswnGAYPO3tz5CLg==}
'@tiptap/extension-image@3.20.5':
resolution: {integrity: sha512-qxKupWKhX75Xc9GJ9Uel+KIFL9x6tb8W3RvQM1UolyJX/H7wyBO7sXp9XmKRkHZsDXRgLVbnkYBe+X83o16AIA==}
peerDependencies:
'@tiptap/core': ^3.21.0
'@tiptap/core': ^3.20.5
'@tiptap/extension-italic@3.20.5':
resolution: {integrity: sha512-7bZCgdJVTvhR5vSmNgFQbGvgRoC6m26KcUpHqWiKA95kLL5Wk4YlMCIqdiDpvJ1eakeFEvDcGZvFLg5+1NiQ+w==}
@ -4957,7 +4957,7 @@ snapshots:
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
'@tiptap/pm': 3.20.5
'@tiptap/extension-image@3.21.0(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
'@tiptap/extension-image@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
dependencies:
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)