multica/apps/web/features/issues/components/issue-detail.tsx
Naiyuan Qing bfe9498def feat(notifications): replace hardcoded inbox notifications with subscriber-driven model
Replace inbox_listeners.go with a subscriber-driven notification system:

- Add issue_subscriber table with auto-subscribe on create/assign/comment
- New subscriber_listeners.go: maintains subscriber data on domain events
- New notification_listeners.go: notifySubscribers (fanout to all subscribers
  minus actor) and notifyDirect (targeted, punches through unsubscribe)
- Subscriber API: list/subscribe/unsubscribe endpoints
- Frontend: subscribers section in issue detail sidebar with real-time sync
- Frontend: inbox notification grouping by (issue_id, type, actor_id)
- Remove createInboxForIssueCreator from task.go (unified through event bus)
- 21 new Go tests, all passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:33:20 +08:00

970 lines
38 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ArrowUp,
Bot,
Calendar,
ChevronLeft,
ChevronRight,
Link2,
MoreHorizontal,
PanelRight,
Pencil,
Trash2,
UserMinus,
X,
} from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from "@/components/ui/dropdown-menu";
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
import { Input } from "@/components/ui/input";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { Markdown } from "@/components/markdown";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { ActorAvatar } from "@/components/common/actor-avatar";
import type { Issue, Comment, IssueSubscriber, UpdateIssueRequest, IssueStatus, IssuePriority } from "@/shared/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { StatusIcon, PriorityIcon, DueDatePicker } from "@/features/issues/components";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useWSEvent } from "@/features/realtime";
import { useIssueStore } from "@/features/issues";
import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload, SubscriberAddedPayload, SubscriberRemovedPayload } from "@/shared/types";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function shortDate(date: string | null): string {
if (!date) return "—";
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
// ---------------------------------------------------------------------------
// Property row
// ---------------------------------------------------------------------------
function PropRow({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="flex min-h-8 items-center gap-2 rounded-md px-2 -mx-2 hover:bg-accent/50 transition-colors">
<span className="w-16 shrink-0 text-xs text-muted-foreground">{label}</span>
<div className="flex min-w-0 flex-1 items-center gap-1.5 text-sm truncate">
{children}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface IssueDetailProps {
issueId: string;
onDelete?: () => void;
}
// ---------------------------------------------------------------------------
// IssueDetail
// ---------------------------------------------------------------------------
export function IssueDetail({ issueId, onDelete }: IssueDetailProps) {
const id = issueId;
const router = useRouter();
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
// Issue navigation
const allIssues = useIssueStore((s) => s.issues);
const currentIndex = allIssues.findIndex((i) => i.id === id);
const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null;
const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null;
const { getActorName, getActorInitials } = useActorName();
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_issue_detail_layout",
});
const sidebarRef = usePanelRef();
const [sidebarOpen, setSidebarOpen] = useState(true);
const [issue, setIssue] = useState<Issue | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [subscribers, setSubscribers] = useState<IssueSubscriber[]>([]);
const [loading, setLoading] = useState(true);
const [commentEmpty, setCommentEmpty] = useState(true);
const commentEditorRef = useRef<RichTextEditorRef>(null);
const [submitting, setSubmitting] = useState(false);
const [deleting, setDeleting] = useState(false);
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [editContent, setEditContent] = useState("");
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState("");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [propertiesOpen, setPropertiesOpen] = useState(true);
const [detailsOpen, setDetailsOpen] = useState(true);
// Watch the global issue store for real-time updates from other users/agents
const storeIssue = useIssueStore((s) => s.issues.find((i) => i.id === id));
useEffect(() => {
if (storeIssue) {
setIssue(storeIssue);
}
}, [storeIssue]);
useEffect(() => {
setIssue(null);
setComments([]);
setSubscribers([]);
setLoading(true);
Promise.all([api.getIssue(id), api.listComments(id), api.listIssueSubscribers(id)])
.then(([iss, cmts, subs]) => {
setIssue(iss);
setComments(cmts);
setSubscribers(subs);
})
.catch(console.error)
.finally(() => setLoading(false));
}, [id]);
const handleSubmitComment = async () => {
const content = commentEditorRef.current?.getMarkdown()?.trim();
if (!content || submitting || !user) return;
const tempId = "temp-" + Date.now();
const tempComment: Comment = {
id: tempId,
issue_id: id,
author_type: "member",
author_id: user.id,
content,
type: "comment",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
setComments((prev) => [...prev, tempComment]);
commentEditorRef.current?.clearContent();
setCommentEmpty(true);
setSubmitting(true);
try {
const comment = await api.createComment(id, content);
setComments((prev) => prev.map((c) => (c.id === tempId ? comment : c)));
} catch {
setComments((prev) => prev.filter((c) => c.id !== tempId));
toast.error("Failed to send comment");
} finally {
setSubmitting(false);
}
};
const handleUpdateField = useCallback(
(updates: Partial<UpdateIssueRequest>) => {
if (!issue) return;
const prev = issue;
setIssue((curr) => (curr ? ({ ...curr, ...updates } as Issue) : curr));
api.updateIssue(id, updates).catch(() => {
setIssue(prev);
toast.error("Failed to update issue");
});
},
[issue, id],
);
const handleDelete = async () => {
setDeleting(true);
try {
await api.deleteIssue(issue!.id);
toast.success("Issue deleted");
if (onDelete) onDelete();
else router.push("/issues");
} catch {
toast.error("Failed to delete issue");
setDeleting(false);
}
};
const startEditComment = (c: Comment) => {
setEditingCommentId(c.id);
setEditContent(c.content);
};
const handleSaveEditComment = async () => {
if (!editingCommentId || !editContent.trim()) return;
try {
const updated = await api.updateComment(editingCommentId, editContent.trim());
setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
setEditingCommentId(null);
} catch {
toast.error("Failed to update comment");
}
};
const handleDeleteComment = async (commentId: string) => {
try {
await api.deleteComment(commentId);
setComments((prev) => prev.filter((c) => c.id !== commentId));
} catch {
toast.error("Failed to delete comment");
}
};
// Subscriber state
const isSubscribed = subscribers.some(
(s) => s.user_type === "member" && s.user_id === user?.id
);
const handleToggleSubscribe = async () => {
if (!user || !issue) return;
try {
if (isSubscribed) {
await api.unsubscribeFromIssue(id);
setSubscribers((prev) => prev.filter((s) => s.user_id !== user.id));
} else {
await api.subscribeToIssue(id);
setSubscribers((prev) => [
...prev,
{ issue_id: id, user_type: "member" as const, user_id: user.id, reason: "manual" as const, created_at: new Date().toISOString() },
]);
}
} catch {
// silently fail
}
};
// Real-time comment updates
useWSEvent(
"comment:created",
useCallback((payload: unknown) => {
const { comment } = payload as CommentCreatedPayload;
if (comment.issue_id !== id) return;
// Skip own comments — already added locally via API response
if (comment.author_type === "member" && comment.author_id === user?.id) return;
setComments((prev) => {
if (prev.some((c) => c.id === comment.id)) return prev;
return [...prev, comment];
});
}, [id, user?.id]),
);
useWSEvent(
"comment:updated",
useCallback((payload: unknown) => {
const { comment } = payload as CommentUpdatedPayload;
if (comment.issue_id === id) {
setComments((prev) => prev.map((c) => (c.id === comment.id ? comment : c)));
}
}, [id]),
);
useWSEvent(
"comment:deleted",
useCallback((payload: unknown) => {
const { comment_id, issue_id } = payload as CommentDeletedPayload;
if (issue_id === id) {
setComments((prev) => prev.filter((c) => c.id !== comment_id));
}
}, [id]),
);
// Real-time subscriber updates
useWSEvent(
"subscriber:added",
useCallback((payload: unknown) => {
const p = payload as SubscriberAddedPayload;
if (p.issue_id !== id) return;
setSubscribers((prev) => {
if (prev.some((s) => s.user_id === p.user_id)) return prev;
return [...prev, {
issue_id: p.issue_id,
user_type: p.user_type as "member" | "agent",
user_id: p.user_id,
reason: p.reason as IssueSubscriber["reason"],
created_at: new Date().toISOString(),
}];
});
}, [id]),
);
useWSEvent(
"subscriber:removed",
useCallback((payload: unknown) => {
const p = payload as SubscriberRemovedPayload;
if (p.issue_id !== id) return;
setSubscribers((prev) => prev.filter((s) => s.user_id !== p.user_id));
}, [id]),
);
if (loading) {
return (
<div className="flex flex-1 min-h-0 items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
);
}
if (!issue) {
return (
<div className="flex flex-1 min-h-0 items-center justify-center text-sm text-muted-foreground">
Issue not found
</div>
);
}
return (
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="content" minSize="50%">
{/* LEFT: Content area */}
<div className="flex h-full flex-col">
{/* Header bar */}
<div className="flex h-12 shrink-0 items-center justify-between border-b bg-background px-4 text-sm">
<div className="flex items-center gap-1.5 min-w-0">
{workspace && (
<>
<Link
href="/issues"
className="text-muted-foreground hover:text-foreground transition-colors truncate shrink-0"
>
{workspace.name}
</Link>
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
</>
)}
<span className="truncate text-muted-foreground">
{issue.id.slice(0, 8)}
</span>
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
<span className="truncate">{issue.title}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{/* Issue navigation */}
{allIssues.length > 1 && (
<div className="flex items-center gap-0.5 mr-1">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
disabled={!prevIssue}
onClick={() => prevIssue && router.push(`/issues/${prevIssue.id}`)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
}
/>
<TooltipContent side="bottom">Previous issue</TooltipContent>
</Tooltip>
<span className="text-xs text-muted-foreground tabular-nums px-0.5">
{currentIndex >= 0 ? currentIndex + 1 : "?"} / {allIssues.length}
</span>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
disabled={!nextIssue}
onClick={() => nextIssue && router.push(`/issues/${nextIssue.id}`)}
>
<ChevronRight className="h-4 w-4" />
</Button>
}
/>
<TooltipContent side="bottom">Next issue</TooltipContent>
</Tooltip>
</div>
)}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-xs" className="text-muted-foreground">
<MoreHorizontal className="h-4 w-4" />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-auto">
{/* Status */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
Status
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{ALL_STATUSES.map((s) => (
<DropdownMenuItem
key={s}
onClick={() => handleUpdateField({ status: s })}
>
<StatusIcon status={s} className="h-3.5 w-3.5" />
{STATUS_CONFIG[s].label}
{issue.status === s && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Priority */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<PriorityIcon priority={issue.priority} />
Priority
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{PRIORITY_ORDER.map((p) => (
<DropdownMenuItem
key={p}
onClick={() => handleUpdateField({ priority: p })}
>
<PriorityIcon priority={p} />
{PRIORITY_CONFIG[p].label}
{issue.priority === p && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Assignee */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<UserMinus className="h-3.5 w-3.5" />
Assignee
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={() => handleUpdateField({ assignee_type: null, assignee_id: null })}
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
Unassigned
{!issue.assignee_type && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
{members.map((m) => (
<DropdownMenuItem
key={m.user_id}
onClick={() => handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })}
>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
{m.name}
{issue.assignee_type === "member" && issue.assignee_id === m.user_id && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
))}
{agents.map((a) => (
<DropdownMenuItem
key={a.id}
onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}
>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="size-2.5" />
</div>
{a.name}
{issue.assignee_type === "agent" && issue.assignee_id === a.id && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Due date */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Calendar className="h-3.5 w-3.5" />
Due date
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => handleUpdateField({ due_date: new Date().toISOString() })}>
Today
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
const d = new Date(); d.setDate(d.getDate() + 1);
handleUpdateField({ due_date: d.toISOString() });
}}>
Tomorrow
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
const d = new Date(); d.setDate(d.getDate() + 7);
handleUpdateField({ due_date: d.toISOString() });
}}>
Next week
</DropdownMenuItem>
{issue.due_date && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleUpdateField({ due_date: null })}>
Clear date
</DropdownMenuItem>
</>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
{/* Copy link */}
<DropdownMenuItem onClick={() => {
navigator.clipboard.writeText(window.location.href);
toast.success("Link copied");
}}>
<Link2 className="h-3.5 w-3.5" />
Copy link
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Delete */}
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2 className="h-3.5 w-3.5" />
Delete issue
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger
render={
<Button
variant={sidebarOpen ? "secondary" : "ghost"}
size="icon-xs"
className={sidebarOpen ? "" : "text-muted-foreground"}
onClick={() => {
const panel = sidebarRef.current;
if (!panel) return;
if (panel.isCollapsed()) panel.expand();
else panel.collapse();
}}
>
<PanelRight className="h-4 w-4" />
</Button>
}
/>
<TooltipContent side="bottom">Toggle sidebar</TooltipContent>
</Tooltip>
</div>
{/* Delete confirmation dialog (controlled by state) */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete issue</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this issue and all its comments. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={deleting}
className="bg-destructive text-white hover:bg-destructive/90"
>
{deleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{/* Content — scrollable */}
<div className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-3xl px-8 py-8">
{editingTitle ? (
<Input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={() => {
if (titleDraft.trim()) handleUpdateField({ title: titleDraft.trim() });
setEditingTitle(false);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (titleDraft.trim()) handleUpdateField({ title: titleDraft.trim() });
setEditingTitle(false);
} else if (e.key === "Escape") {
setEditingTitle(false);
}
}}
className="text-2xl font-bold leading-snug tracking-tight"
/>
) : (
<h1
className="text-2xl font-bold leading-snug tracking-tight cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1"
onClick={() => { setTitleDraft(issue.title); setEditingTitle(true); }}
>
{issue.title}
</h1>
)}
<RichTextEditor
defaultValue={issue.description || ""}
placeholder="Add description..."
onUpdate={(md) => handleUpdateField({ description: md || undefined })}
debounceMs={1500}
className="mt-5"
/>
<div className="my-8 border-t" />
{/* Activity / Comments */}
<div>
<h2 className="text-base font-semibold">Activity</h2>
<div className="mt-4">
{comments.map((comment) => {
const isOwn = comment.author_type === "member" && comment.author_id === user?.id;
return (
<div key={comment.id} className={`group relative py-3${comment.id.startsWith("temp-") ? " opacity-60" : ""}`}>
<div className="flex items-center gap-2.5">
<ActorAvatar
actorType={comment.author_type}
actorId={comment.author_id}
size={28}
/>
<span className="text-sm font-medium">
{getActorName(comment.author_type, comment.author_id)}
</span>
<Tooltip>
<TooltipTrigger
render={
<span className="text-xs text-muted-foreground cursor-default">
{timeAgo(comment.created_at)}
</span>
}
/>
<TooltipContent side="top">
{new Date(comment.created_at).toLocaleString()}
</TooltipContent>
</Tooltip>
{isOwn && (
<div className="ml-auto flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
onClick={() => startEditComment(comment)}
className="text-muted-foreground hover:text-foreground"
>
<Pencil className="h-3 w-3" />
</Button>
}
/>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
onClick={() => handleDeleteComment(comment.id)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
}
/>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
)}
</div>
{editingCommentId === comment.id ? (
<form onSubmit={(e) => { e.preventDefault(); handleSaveEditComment(); }} className="mt-2 pl-9.5">
<input
autoFocus
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
aria-label="Edit comment"
className="w-full text-sm bg-transparent border-b outline-none"
onKeyDown={(e) => { if (e.key === "Escape") setEditingCommentId(null); }}
/>
</form>
) : (
<div className="mt-2 pl-9.5 text-sm leading-relaxed text-foreground/85">
<Markdown mode="minimal">{comment.content}</Markdown>
</div>
)}
</div>
);
})}
</div>
{/* Comment input */}
<div className="mt-4 rounded-md border bg-muted/30">
<div className="min-h-20 max-h-48 overflow-y-auto px-3 py-2">
<RichTextEditor
ref={commentEditorRef}
placeholder="Leave a comment..."
onUpdate={(md) => setCommentEmpty(!md.trim())}
onSubmit={handleSubmitComment}
debounceMs={100}
/>
</div>
<div className="flex items-center justify-end px-2 pb-2">
<Tooltip>
<TooltipTrigger
render={
<Button
size="icon-sm"
disabled={commentEmpty || submitting}
onClick={handleSubmitComment}
>
<ArrowUp className="h-4 w-4" />
</Button>
}
/>
<TooltipContent>Send</TooltipContent>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel
id="sidebar"
defaultSize={320}
minSize={260}
maxSize={420}
collapsible
groupResizeBehavior="preserve-pixel-size"
panelRef={sidebarRef}
onResize={(size) => setSidebarOpen(size.inPixels > 0)}
>
{/* RIGHT: Properties sidebar */}
<div className="overflow-y-auto border-l h-full">
<div className="p-4 space-y-5">
{/* Properties section */}
<div>
<button
className={`flex w-full items-center gap-1 text-xs font-medium transition-colors mb-2 ${propertiesOpen ? "" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => setPropertiesOpen(!propertiesOpen)}
>
<ChevronRight className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform ${propertiesOpen ? "rotate-90" : ""}`} />
Properties
</button>
{propertiesOpen && <div className="space-y-0.5 pl-2">
{/* Status */}
<PropRow label="Status">
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
<StatusIcon status={issue.status} className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{STATUS_CONFIG[issue.status].label}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
<DropdownMenuRadioGroup value={issue.status} onValueChange={(v) => handleUpdateField({ status: v as IssueStatus })}>
{ALL_STATUSES.map((s) => (
<DropdownMenuRadioItem key={s} value={s}>
<StatusIcon status={s} className="h-3.5 w-3.5" />
{STATUS_CONFIG[s].label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</PropRow>
{/* Priority */}
<PropRow label="Priority">
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
<PriorityIcon priority={issue.priority} className="shrink-0" />
<span className="truncate">{PRIORITY_CONFIG[issue.priority].label}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
<DropdownMenuRadioGroup value={issue.priority} onValueChange={(v) => handleUpdateField({ priority: v as IssuePriority })}>
{PRIORITY_ORDER.map((p) => (
<DropdownMenuRadioItem key={p} value={p}>
<PriorityIcon priority={p} />
{PRIORITY_CONFIG[p].label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</PropRow>
{/* Assignee */}
<PropRow label="Assignee">
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
{issue.assignee_type && issue.assignee_id ? (
<>
<ActorAvatar
actorType={issue.assignee_type}
actorId={issue.assignee_id}
size={18}
/>
<span className="truncate">{getActorName(issue.assignee_type, issue.assignee_id)}</span>
</>
) : (
<span className="text-muted-foreground">Unassigned</span>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuItem onClick={() => handleUpdateField({ assignee_type: null, assignee_id: null })}>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
Unassigned
</DropdownMenuItem>
{members.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Members</DropdownMenuLabel>
{members.map((m) => (
<DropdownMenuItem key={m.user_id} onClick={() => handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })}>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
{m.name}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</>
)}
{agents.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Agents</DropdownMenuLabel>
{agents.map((a) => (
<DropdownMenuItem key={a.id} onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="size-2.5" />
</div>
{a.name}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</PropRow>
{/* Due date */}
<PropRow label="Due date">
<DueDatePicker
dueDate={issue.due_date}
onUpdate={handleUpdateField}
/>
</PropRow>
</div>}
</div>
{/* Details section */}
<div>
<button
className={`flex w-full items-center gap-1 text-xs font-medium transition-colors mb-2 ${detailsOpen ? "" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => setDetailsOpen(!detailsOpen)}
>
<ChevronRight className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform ${detailsOpen ? "rotate-90" : ""}`} />
Details
</button>
{detailsOpen && <div className="space-y-0.5 pl-2">
<PropRow label="Created by">
<ActorAvatar
actorType={issue.creator_type}
actorId={issue.creator_id}
size={18}
/>
<span className="truncate">{getActorName(issue.creator_type, issue.creator_id)}</span>
</PropRow>
<PropRow label="Created">
<span className="text-muted-foreground">{shortDate(issue.created_at)}</span>
</PropRow>
<PropRow label="Updated">
<span className="text-muted-foreground">{shortDate(issue.updated_at)}</span>
</PropRow>
</div>}
</div>
{/* Subscribers section */}
<div>
<h4 className="text-xs font-medium text-muted-foreground mb-2">Subscribers</h4>
<div className="space-y-1 pl-2">
{subscribers.map((sub) => (
<div key={sub.user_id} className="flex items-center gap-2 text-sm">
<ActorAvatar actorType={sub.user_type} actorId={sub.user_id} size={18} />
<span className="truncate">{getActorName(sub.user_type, sub.user_id)}</span>
<span className="text-xs text-muted-foreground">({sub.reason})</span>
</div>
))}
</div>
<Button
variant="ghost"
size="sm"
className="mt-1 h-7 text-xs w-full"
onClick={handleToggleSubscribe}
>
{isSubscribed ? "Unsubscribe" : "Subscribe"}
</Button>
</div>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}