multica/apps/web/features/issues/components/issue-detail.tsx
Naiyuan Qing 7cc4e63e0e fix(editor): unify image upload flow for paste and button
Paste/drop and attachment button previously used separate upload paths.
The button uploaded first then called insertFile (which replaced the
current selection), while paste inserted a blob preview first. This
caused the second image to overwrite the first when both were used.

Now both paths share the same flow via uploadAndInsertFile():
blob preview with uploading animation → background upload → replace URL.

- Extract shared uploadAndInsertFile() function
- Replace insertFile ref method with uploadFile (inserts at doc end)
- Simplify FileUploadButton to onSelect(file) — no more onUpload/onInsert
- Wire onUploadFile in comment edit mode (was missing, upload was no-op)
- Unify image border-radius CSS for both editing and readonly modes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:02:28 +08:00

1057 lines
44 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef, memo } from "react";
import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Calendar,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Link2,
MoreHorizontal,
PanelRight,
Trash2,
UserMinus,
Users,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
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,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from "@/components/ui/dropdown-menu";
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
import { RichTextEditor } from "@/components/common/rich-text-editor";
import { FileUploadButton } from "@/components/common/file-upload-button";
import { TitleEditor } from "@/components/common/title-editor";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";
import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar";
import { ActorAvatar } from "@/components/common/actor-avatar";
import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent } from "@/features/issues/components";
import { CommentCard } from "./comment-card";
import { CommentInput } from "./comment-input";
import { AgentLiveCard, TaskRunHistory } from "./agent-live-card";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useIssueStore } from "@/features/issues";
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 {
if (!date) return "—";
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
function statusLabel(status: string): string {
return STATUS_CONFIG[status as IssueStatus]?.label ?? status;
}
function priorityLabel(priority: string): string {
return PRIORITY_CONFIG[priority as IssuePriority]?.label ?? priority;
}
function formatActivity(
entry: TimelineEntry,
resolveActorName?: (type: string, id: string) => string,
): string {
const details = (entry.details ?? {}) as Record<string, string>;
switch (entry.action) {
case "created":
return "created this issue";
case "status_changed":
return `changed status from ${statusLabel(details.from ?? "?")} to ${statusLabel(details.to ?? "?")}`;
case "priority_changed":
return `changed priority from ${priorityLabel(details.from ?? "?")} to ${priorityLabel(details.to ?? "?")}`;
case "assignee_changed": {
const isSelfAssign = details.to_type === entry.actor_type && details.to_id === entry.actor_id;
if (isSelfAssign) return "self-assigned this issue";
const toName = details.to_id && details.to_type && resolveActorName
? resolveActorName(details.to_type, details.to_id)
: null;
if (toName) return `assigned to ${toName}`;
if (details.from_id && !details.to_id) return "removed assignee";
return "changed assignee";
}
case "due_date_changed": {
if (!details.to) return "removed due date";
const formatted = new Date(details.to).toLocaleDateString("en-US", { month: "short", day: "numeric" });
return `set due date to ${formatted}`;
}
case "title_changed":
return `renamed this issue from "${details.from ?? "?"}" to "${details.to ?? "?"}"`;
case "description_updated":
return "updated the description";
case "task_completed":
return "completed the task";
case "task_failed":
return "task failed";
default:
return entry.action ?? "";
}
}
// ---------------------------------------------------------------------------
// 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-xs truncate">
{children}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface IssueDetailProps {
issueId: string;
onDelete?: () => void;
defaultSidebarOpen?: boolean;
layoutId?: string;
/** When set, the issue detail will auto-scroll to this comment and briefly highlight it. */
highlightCommentId?: string;
}
// ---------------------------------------------------------------------------
// IssueDetail
// ---------------------------------------------------------------------------
export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layoutId = "multica_issue_detail_layout", highlightCommentId }: 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);
const currentMemberRole = members.find((m) => m.user_id === user?.id)?.role;
// 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 } = useActorName();
const { uploadWithToast } = useFileUpload();
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: layoutId,
});
const sidebarRef = usePanelRef();
const [sidebarOpen, setSidebarOpen] = useState(defaultSidebarOpen);
const [deleting, setDeleting] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [propertiesOpen, setPropertiesOpen] = useState(true);
const [detailsOpen, setDetailsOpen] = useState(true);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showScrollBottom, setShowScrollBottom] = useState(false);
const [highlightedId, setHighlightedId] = useState<string | null>(null);
// Single source of truth: read issue directly from global store
const issue = useIssueStore((s) => s.issues.find((i) => i.id === id)) ?? null;
const [issueLoading, setIssueLoading] = useState(!issue);
// If issue isn't in the store yet, fetch and upsert it
useEffect(() => {
if (issue) {
setIssueLoading(false);
return;
}
setIssueLoading(true);
api
.getIssue(id)
.then((iss) => {
useIssueStore.getState().addIssue(iss);
})
.catch((e) => {
console.error(e);
toast.error("Failed to load issue");
})
.finally(() => setIssueLoading(false));
}, [id, !!issue]);
// Custom hooks — encapsulate timeline, reactions, subscribers
const {
timeline, loading: timelineLoading, submitting, submitComment, submitReply,
editComment, deleteComment, toggleReaction: handleToggleReaction,
} = useIssueTimeline(id, user?.id);
const {
reactions: issueReactions, loading: reactionsLoading,
toggleReaction: handleToggleIssueReaction,
} = useIssueReactions(id, user?.id);
const {
subscribers, loading: subscribersLoading, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
} = useIssueSubscribers(id, user?.id);
const loading = issueLoading;
// Scroll to highlighted comment once timeline loads
useEffect(() => {
if (!highlightCommentId || timeline.length === 0) return;
// Find the comment element — could be a top-level comment or a reply
const el = document.getElementById(`comment-${highlightCommentId}`);
if (el) {
// Small delay to ensure layout is settled
requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "smooth", block: "center" });
setHighlightedId(highlightCommentId);
// Clear highlight after animation
const timer = setTimeout(() => setHighlightedId(null), 2000);
return () => clearTimeout(timer);
});
}
}, [highlightCommentId, timeline.length]);
// Track scroll position for jump-to-bottom button
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
const onScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
setShowScrollBottom(scrollHeight - scrollTop - clientHeight > 200);
};
container.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => container.removeEventListener("scroll", onScroll);
}, []);
const scrollToBottom = useCallback(() => {
scrollContainerRef.current?.scrollTo({ top: scrollContainerRef.current.scrollHeight, behavior: "smooth" });
}, []);
// Issue field updates — write directly to the global store (single source of truth)
const handleUpdateField = useCallback(
(updates: Partial<UpdateIssueRequest>) => {
if (!issue) return;
const prev = { ...issue };
useIssueStore.getState().updateIssue(id, updates);
api.updateIssue(id, updates).catch(() => {
useIssueStore.getState().updateIssue(id, prev);
toast.error("Failed to update issue");
});
},
[issue, id],
);
const descEditorRef = useRef<import("@/components/common/rich-text-editor").RichTextEditorRef>(null);
const handleDescriptionUpload = useCallback(
(file: File) => uploadWithToast(file, { issueId: id }),
[uploadWithToast, id],
);
const handleDelete = async () => {
setDeleting(true);
try {
await api.deleteIssue(issue!.id);
useIssueStore.getState().removeIssue(issue!.id);
toast.success("Issue deleted");
if (onDelete) onDelete();
else router.push("/issues");
} catch {
toast.error("Failed to delete issue");
setDeleting(false);
}
};
if (loading) {
return (
<div className="flex flex-1 min-h-0 flex-col">
{/* Header skeleton */}
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex flex-1 min-h-0">
{/* Content skeleton */}
<div className="flex-1 p-8 space-y-6">
<Skeleton className="h-8 w-3/4" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
<Skeleton className="h-px w-full" />
<div className="space-y-3">
<Skeleton className="h-4 w-20" />
<div className="flex items-start gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-16 w-full rounded-lg" />
</div>
</div>
</div>
</div>
{/* Sidebar skeleton */}
<div className="w-64 border-l p-4 space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-5 w-24" />
</div>
))}
<Skeleton className="h-px w-full" />
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-28" />
</div>
))}
</div>
</div>
</div>
);
}
if (!issue) {
return (
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
<p>This issue does not exist or has been deleted in this workspace.</p>
{!onDelete && (
<Button variant="outline" size="sm" onClick={() => router.push("/issues")}>
<ChevronLeft className="mr-1 h-3.5 w-3.5" />
Back to Issues
</Button>
)}
</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.identifier}
</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 })}
>
<span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium ${PRIORITY_CONFIG[p].badgeBg} ${PRIORITY_CONFIG[p].badgeText}`}>
<PriorityIcon priority={p} className="h-3 w-3" inheritColor />
{PRIORITY_CONFIG[p].label}
</span>
{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 })}
>
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
{m.name}
{issue.assignee_type === "member" && issue.assignee_id === m.user_id && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
))}
{agents.filter((a) => canAssignAgent(a, user?.id, currentMemberRole)).map((a) => (
<DropdownMenuItem
key={a.id}
onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}
>
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
{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 ref={scrollContainerRef} className="relative flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-4xl px-8 py-8">
<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 });
}}
/>
<RichTextEditor
ref={descEditorRef}
key={id}
defaultValue={issue.description || ""}
placeholder="Add description..."
onUpdate={(md) => handleUpdateField({ description: md || undefined })}
onUploadFile={handleDescriptionUpload}
debounceMs={1500}
className="mt-5"
/>
<div className="flex items-center gap-1 mt-3">
{reactionsLoading ? (
<div className="flex items-center gap-1">
<Skeleton className="h-7 w-14 rounded-full" />
<Skeleton className="h-7 w-14 rounded-full" />
</div>
) : (
<ReactionBar
reactions={issueReactions}
currentUserId={user?.id}
onToggle={handleToggleIssueReaction}
/>
)}
<FileUploadButton
size="sm"
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
/>
</div>
<div className="my-8 border-t" />
{/* Activity / Comments */}
<div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h2 className="text-base font-semibold">Activity</h2>
</div>
<div className="flex items-center gap-2">
{subscribersLoading ? (
<div className="flex items-center gap-1">
<Skeleton className="h-4 w-16" />
<div className="flex -space-x-1">
<Skeleton className="h-6 w-6 rounded-full" />
<Skeleton className="h-6 w-6 rounded-full" />
</div>
</div>
) : (<>
<button
onClick={handleToggleSubscribe}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{isSubscribed ? "Unsubscribe" : "Subscribe"}
</button>
<Popover>
<PopoverTrigger className="cursor-pointer hover:opacity-80 transition-opacity">
{subscribers.length > 0 ? (
<AvatarGroup>
{subscribers.slice(0, 4).map((sub) => (
<ActorAvatar
key={`${sub.user_type}-${sub.user_id}`}
actorType={sub.user_type}
actorId={sub.user_id}
size={24}
/>
))}
{subscribers.length > 4 && (
<AvatarGroupCount>+{subscribers.length - 4}</AvatarGroupCount>
)}
</AvatarGroup>
) : (
<span className="flex items-center justify-center h-6 w-6 rounded-full border border-dashed border-muted-foreground/30 text-muted-foreground">
<Users className="h-3 w-3" />
</span>
)}
</PopoverTrigger>
<PopoverContent align="end" className="w-64 p-0">
<Command>
<CommandInput placeholder="Change subscribers..." />
<CommandList className="max-h-64">
<CommandEmpty>No results found</CommandEmpty>
{members.length > 0 && (
<CommandGroup heading="Members">
{members.filter((m, i, arr) => arr.findIndex((x) => x.user_id === m.user_id) === i).map((m) => {
const sub = subscribers.find((s) => s.user_type === "member" && s.user_id === m.user_id);
const isSubbed = !!sub;
return (
<CommandItem
key={`member-${m.user_id}`}
onSelect={() => toggleSubscriber(m.user_id, "member", isSubbed)}
className="flex items-center gap-2.5"
>
<Checkbox checked={isSubbed} className="pointer-events-none" />
<ActorAvatar actorType="member" actorId={m.user_id} size={22} />
<span className="truncate flex-1">{m.name}</span>
</CommandItem>
);
})}
</CommandGroup>
)}
{agents.length > 0 && (
<CommandGroup heading="Agents">
{agents.map((a) => {
const sub = subscribers.find((s) => s.user_type === "agent" && s.user_id === a.id);
const isSubbed = !!sub;
return (
<CommandItem
key={`agent-${a.id}`}
onSelect={() => toggleSubscriber(a.id, "agent", isSubbed)}
className="flex items-center gap-2.5"
>
<Checkbox checked={isSubbed} className="pointer-events-none" />
<ActorAvatar actorType="agent" actorId={a.id} size={22} />
<span className="truncate flex-1">{a.name}</span>
</CommandItem>
);
})}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</>)}
</div>
</div>
{/* Agent live output */}
<div className="mt-4">
<AgentLiveCard
issueId={id}
agentName={issue.assignee_type === "agent" && issue.assignee_id ? getActorName("agent", issue.assignee_id) : undefined}
/>
</div>
{/* Agent execution history */}
<div className="mt-3">
<TaskRunHistory issueId={id} />
</div>
{/* Timeline entries */}
<div className="mt-4 flex flex-col gap-3">
{timelineLoading ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-start gap-3 px-4">
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-16 w-full rounded-lg" />
</div>
</div>
))}
</div>
) : (() => {
const topLevel = timeline.filter((e) => e.type === "activity" || !e.parent_id);
const repliesByParent = new Map<string, TimelineEntry[]>();
for (const e of timeline) {
if (e.type === "comment" && e.parent_id) {
const list = repliesByParent.get(e.parent_id) ?? [];
list.push(e);
repliesByParent.set(e.parent_id, list);
}
}
// Coalesce: same actor + same action within 2 min → keep last only
const COALESCE_MS = 2 * 60 * 1000;
const coalesced: TimelineEntry[] = [];
for (const entry of topLevel) {
if (entry.type === "activity") {
const prev = coalesced[coalesced.length - 1];
if (
prev?.type === "activity" &&
prev.action === entry.action &&
prev.actor_type === entry.actor_type &&
prev.actor_id === entry.actor_id &&
Math.abs(new Date(entry.created_at).getTime() - new Date(prev.created_at).getTime()) <= COALESCE_MS
) {
// Replace previous with this one (keep the later result)
coalesced[coalesced.length - 1] = entry;
continue;
}
}
coalesced.push(entry);
}
// Group consecutive activities together so the connector line works
const groups: { type: "activities" | "comment"; entries: TimelineEntry[] }[] = [];
for (const entry of coalesced) {
if (entry.type === "activity") {
const last = groups[groups.length - 1];
if (last?.type === "activities") {
last.entries.push(entry);
} else {
groups.push({ type: "activities", entries: [entry] });
}
} else {
groups.push({ type: "comment", entries: [entry] });
}
}
return groups.map((group) => {
if (group.type === "comment") {
const entry = group.entries[0]!;
return (
<div key={entry.id} id={`comment-${entry.id}`}>
<CommentCard
issueId={id}
entry={entry}
allReplies={repliesByParent}
currentUserId={user?.id}
onReply={submitReply}
onEdit={editComment}
onDelete={deleteComment}
onToggleReaction={handleToggleReaction}
highlightedCommentId={highlightedId}
/>
</div>
);
}
return (
<div key={group.entries[0]!.id} className="px-4 flex flex-col gap-3">
{group.entries.map((entry, idx) => {
const details = (entry.details ?? {}) as Record<string, string>;
const isStatusChange = entry.action === "status_changed";
const isPriorityChange = entry.action === "priority_changed";
const isDueDateChange = entry.action === "due_date_changed";
let leadIcon: React.ReactNode;
if (isStatusChange && details.to) {
leadIcon = <StatusIcon status={details.to as IssueStatus} className="h-4 w-4 shrink-0" />;
} else if (isPriorityChange && details.to) {
leadIcon = <PriorityIcon priority={details.to as IssuePriority} className="h-4 w-4 shrink-0" />;
} else if (isDueDateChange) {
leadIcon = <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />;
} else {
leadIcon = <ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={16} />;
}
return (
<div key={entry.id} className="flex items-center text-xs text-muted-foreground">
<div className="mr-2 flex w-4 shrink-0 justify-center">
{leadIcon}
</div>
<div className="flex min-w-0 flex-1 items-center gap-1">
<span className="shrink-0 font-medium">{getActorName(entry.actor_type, entry.actor_id)}</span>
<span className="truncate">{formatActivity(entry, getActorName)}</span>
<Tooltip>
<TooltipTrigger
render={
<span className="ml-auto shrink-0 cursor-default">
{timeAgo(entry.created_at)}
</span>
}
/>
<TooltipContent side="top">
{new Date(entry.created_at).toLocaleString()}
</TooltipContent>
</Tooltip>
</div>
</div>
);
})}
</div>
);
});
})()}
</div>
{/* Bottom comment input — no avatar, full width */}
<div className="mt-4">
<CommentInput issueId={id} onSubmit={submitComment} />
</div>
</div>
</div>
{/* Jump to bottom button */}
{showScrollBottom && (
<div className="sticky bottom-4 flex justify-center pointer-events-none">
<Button
variant="secondary"
size="sm"
className="pointer-events-auto shadow-md"
onClick={scrollToBottom}
>
<ChevronDown className="mr-1 h-3.5 w-3.5" />
Jump to bottom
</Button>
</div>
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel
id="sidebar"
defaultSize={defaultSidebarOpen ? 320 : 0}
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">
{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}
{s === issue.status && <Check className="ml-auto h-3.5 w-3.5" />}
</DropdownMenuItem>
))}
</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">
{PRIORITY_ORDER.map((p) => (
<DropdownMenuItem key={p} onClick={() => handleUpdateField({ priority: p })}>
<span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium ${PRIORITY_CONFIG[p].badgeBg} ${PRIORITY_CONFIG[p].badgeText}`}>
<PriorityIcon priority={p} className="h-3 w-3" inheritColor />
{PRIORITY_CONFIG[p].label}
</span>
{p === issue.priority && <Check className="ml-auto h-3.5 w-3.5" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</PropRow>
{/* Assignee */}
<PropRow label="Assignee">
<AssigneePicker
assigneeType={issue.assignee_type}
assigneeId={issue.assignee_id}
onUpdate={handleUpdateField}
align="start"
/>
</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>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}