merge: resolve conflicts with main
- Take main's router.go, rich-text-editor.tsx, comment-card.tsx - Remove deleted daemon_pairing.go - Keep issue mention card feature
This commit is contained in:
commit
b8c784dda3
68 changed files with 2359 additions and 1139 deletions
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { X, Trash2, Bot, UserMinus } from "lucide-react";
|
||||
import { X, Trash2, Bot, Lock, UserMinus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -19,8 +19,9 @@ import {
|
|||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import type { UpdateIssueRequest } from "@/shared/types";
|
||||
import type { Agent, UpdateIssueRequest } from "@/shared/types";
|
||||
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
|
||||
|
|
@ -206,6 +207,13 @@ export function BatchActionToolbar() {
|
|||
);
|
||||
}
|
||||
|
||||
function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean {
|
||||
if (agent.visibility !== "private") return true;
|
||||
if (agent.owner_id === userId) return true;
|
||||
if (memberRole === "owner" || memberRole === "admin") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function BatchAssigneePicker({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -218,10 +226,14 @@ function BatchAssigneePicker({
|
|||
loading: boolean;
|
||||
}) {
|
||||
const [filter, setFilter] = useState("");
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
const { getActorInitials } = useActorName();
|
||||
|
||||
const currentMember = members.find((m) => m.user_id === user?.id);
|
||||
const memberRole = currentMember?.role;
|
||||
|
||||
const query = filter.toLowerCase();
|
||||
const filteredMembers = members.filter((m) =>
|
||||
m.name.toLowerCase().includes(query),
|
||||
|
|
@ -297,22 +309,30 @@ function BatchAssigneePicker({
|
|||
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Agents
|
||||
</div>
|
||||
{filteredAgents.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onUpdate({ assignee_type: "agent", assignee_id: a.id });
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
||||
<Bot className="size-2.5" />
|
||||
</div>
|
||||
<span>{a.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredAgents.map((a) => {
|
||||
const allowed = canAssignAgent(a, user?.id, memberRole);
|
||||
return (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
disabled={!allowed}
|
||||
onClick={() => {
|
||||
if (!allowed) return;
|
||||
onUpdate({ assignee_type: "agent", assignee_id: a.id });
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${allowed ? "hover:bg-accent" : "opacity-50 cursor-not-allowed"}`}
|
||||
>
|
||||
<div className={`inline-flex size-4.5 shrink-0 items-center justify-center rounded-full ${allowed ? "bg-info/10 text-info" : "bg-muted text-muted-foreground"}`}>
|
||||
<Bot className="size-2.5" />
|
||||
</div>
|
||||
<span className={allowed ? "" : "text-muted-foreground"}>{a.name}</span>
|
||||
{a.visibility === "private" && (
|
||||
<Lock className="ml-auto h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, MoreHorizontal, Pencil, Trash2, ChevronRight } from "lucide-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";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -28,6 +28,7 @@ import type { TimelineEntry } from "@/shared/types";
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CommentCardProps {
|
||||
issueId: string;
|
||||
entry: TimelineEntry;
|
||||
allReplies: Map<string, TimelineEntry[]>;
|
||||
currentUserId?: string;
|
||||
|
|
@ -56,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");
|
||||
}
|
||||
|
|
@ -142,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
|
||||
|
|
@ -183,6 +185,7 @@ function CommentRow({
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CommentCard({
|
||||
issueId,
|
||||
entry,
|
||||
allReplies,
|
||||
currentUserId,
|
||||
|
|
@ -194,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");
|
||||
}
|
||||
|
|
@ -242,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>
|
||||
}
|
||||
|
|
@ -263,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>
|
||||
)}
|
||||
|
|
@ -315,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
|
||||
|
|
@ -365,6 +369,7 @@ function CommentCard({
|
|||
{/* Reply input */}
|
||||
<div className="border-t border-border/50 px-4 py-2.5">
|
||||
<ReplyInput
|
||||
issueId={issueId}
|
||||
placeholder="Leave a reply..."
|
||||
size="sm"
|
||||
avatarType="member"
|
||||
|
|
|
|||
|
|
@ -1,18 +1,34 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { ArrowUp } 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";
|
||||
|
||||
interface CommentInputProps {
|
||||
issueId: string;
|
||||
onSubmit: (content: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function CommentInput({ onSubmit }: CommentInputProps) {
|
||||
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
const editorRef = useRef<RichTextEditorRef>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { uploadWithToast, uploading } = useFileUpload();
|
||||
|
||||
const handleUpload = (file: File) => uploadWithToast(file, { issueId });
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = "";
|
||||
const result = await handleUpload(file);
|
||||
if (result) {
|
||||
editorRef.current?.insertFile(result.filename, result.link, file.type.startsWith("image/"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||
|
|
@ -35,16 +51,32 @@ function CommentInput({ onSubmit }: CommentInputProps) {
|
|||
placeholder="Leave a comment..."
|
||||
onUpdate={(md) => setIsEmpty(!md.trim())}
|
||||
onSubmit={handleSubmit}
|
||||
onUploadFile={handleUpload}
|
||||
debounceMs={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-1.5 right-1.5">
|
||||
<div className="absolute bottom-1.5 right-1.5 flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -69,6 +69,7 @@ import { useIssueTimeline } from "@/features/issues/hooks/use-issue-timeline";
|
|||
import { useIssueReactions } from "@/features/issues/hooks/use-issue-reactions";
|
||||
import { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers";
|
||||
import { ReactionBar } from "@/components/common/reaction-bar";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
import { timeAgo } from "@/shared/utils";
|
||||
|
||||
function shortDate(date: string | null): string {
|
||||
|
|
@ -179,14 +180,13 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null;
|
||||
const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null;
|
||||
const { getActorName, getActorInitials } = useActorName();
|
||||
const { uploadWithToast } = useFileUpload();
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: layoutId,
|
||||
});
|
||||
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 +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,
|
||||
|
|
@ -249,6 +242,11 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
[issue, id],
|
||||
);
|
||||
|
||||
const handleDescriptionUpload = useCallback(
|
||||
(file: File) => uploadWithToast(file, { issueId: id }),
|
||||
[uploadWithToast, id],
|
||||
);
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
|
|
@ -547,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
|
||||
|
|
@ -574,6 +561,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
defaultValue={issue.description || ""}
|
||||
placeholder="Add description..."
|
||||
onUpdate={(md) => handleUpdateField({ description: md || undefined })}
|
||||
onUploadFile={handleDescriptionUpload}
|
||||
debounceMs={1500}
|
||||
className="mt-5"
|
||||
/>
|
||||
|
|
@ -741,6 +729,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
return (
|
||||
<CommentCard
|
||||
key={entry.id}
|
||||
issueId={id}
|
||||
entry={entry}
|
||||
allReplies={repliesByParent}
|
||||
currentUserId={user?.id}
|
||||
|
|
@ -803,7 +792,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
|
||||
{/* Bottom comment input — no avatar, full width */}
|
||||
<div className="mt-4">
|
||||
<CommentInput onSubmit={submitComment} />
|
||||
<CommentInput issueId={id} onSubmit={submitComment} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { ArrowUp } 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";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ReplyInputProps {
|
||||
issueId: string;
|
||||
placeholder?: string;
|
||||
avatarType: string;
|
||||
avatarId: string;
|
||||
|
|
@ -23,6 +25,7 @@ interface ReplyInputProps {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ReplyInput({
|
||||
issueId,
|
||||
placeholder = "Leave a reply...",
|
||||
avatarType,
|
||||
avatarId,
|
||||
|
|
@ -30,8 +33,22 @@ function ReplyInput({
|
|||
size = "default",
|
||||
}: ReplyInputProps) {
|
||||
const editorRef = useRef<RichTextEditorRef>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { uploadWithToast, uploading } = useFileUpload();
|
||||
|
||||
const handleUpload = (file: File) => uploadWithToast(file, { issueId });
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = "";
|
||||
const result = await handleUpload(file);
|
||||
if (result) {
|
||||
editorRef.current?.insertFile(result.filename, result.link, file.type.startsWith("image/"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||
|
|
@ -67,6 +84,7 @@ function ReplyInput({
|
|||
placeholder={placeholder}
|
||||
onUpdate={(md) => setIsEmpty(!md.trim())}
|
||||
onSubmit={handleSubmit}
|
||||
onUploadFile={handleUpload}
|
||||
debounceMs={100}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -76,14 +94,30 @@ function ReplyInput({
|
|||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="flex items-center justify-end pt-1">
|
||||
<div className="flex items-center justify-end gap-1 pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
tabIndex={isEmpty ? -1 : 0}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Paperclip className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
disabled={isEmpty || submitting}
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,11 @@ import { useWorkspaceStore } from "@/features/workspace";
|
|||
import { createLogger } from "@/shared/logger";
|
||||
import { useRealtimeSync } from "./use-realtime-sync";
|
||||
|
||||
const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8080/ws";
|
||||
const WS_URL =
|
||||
process.env.NEXT_PUBLIC_WS_URL ||
|
||||
(typeof window !== "undefined"
|
||||
? `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`
|
||||
: "ws://localhost:8080/ws");
|
||||
|
||||
type EventHandler = (payload: unknown) => void;
|
||||
|
||||
|
|
|
|||
|
|
@ -32,5 +32,11 @@ export function useActorName() {
|
|||
.slice(0, 2);
|
||||
};
|
||||
|
||||
return { getMemberName, getAgentName, getActorName, getActorInitials };
|
||||
const getActorAvatarUrl = (type: string, id: string): string | null => {
|
||||
if (type === "member") return members.find((m) => m.user_id === id)?.avatar_url ?? null;
|
||||
if (type === "agent") return agents.find((a) => a.id === id)?.avatar_url ?? null;
|
||||
return null;
|
||||
};
|
||||
|
||||
return { getMemberName, getAgentName, getActorName, getActorInitials, getActorAvatarUrl };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue