mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 05:46:58 +02:00
* feat(agent-status): add workspace live-tasks endpoint and TaskFailureReason type Lays the API + type contract for the front-end agent presence cache: - New `GET /api/active-tasks` returns active (queued/dispatched/running) tasks plus failed tasks within the last 2 minutes for the current workspace. The 2-minute window powers a UI-side auto-clearing "Failed" agent state without back-end pollers. - `agent_task_queue` has no workspace_id column, so the query JOINs agent; `SELECT atq.*` keeps `failure_reason` (migration 055) on the wire. - Adds `TaskFailureReason` to `AgentTask` so the UI can map the 5 backend classifiers (agent_error / timeout / runtime_offline / runtime_recovery / manual) to copy without parsing free-text errors. - New `api.getActiveTasksForWorkspace()` client method; workspace is resolved server-side from the X-Workspace-Slug header (no path param, matching /api/agents and /api/runtimes conventions). Includes the joint engineering plan and designer brief that scope the broader Agent / Runtime status redesign — Phase 0 is this contract plus the front-end derivation layer landing in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(agent-status): derive presence/health states with WS sync and desktop IPC bridge Adds the front-end derivation layer that turns raw server data into the user-facing 5-state agent / 4-state runtime enums. UI files are deliberately untouched in this commit — derivation lives behind hooks (useAgentPresence, useRuntimeHealth) that any component can call with zero additional network traffic. Architecture: - Derivation is pure functions in packages/core/{agents,runtimes}; the back-end stays free of UI translation. Agents algorithm: runtime offline > recent failed (2-min window) > running > queued > available. Runtimes algorithm: status + last_seen_at -> online / recently_lost / offline / about_to_gc. - A single workspace-wide active-tasks query backs all per-agent presence reads, eliminating N+1 across hover cards, list rows, and pickers. 30-second tick re-renders the hooks so the failed window expires even when no underlying data changes. - WS task lifecycle events (dispatch / completed / failed / cancelled) invalidate active-tasks via the prefix dispatcher. completed/failed were removed from specificEvents so they go through both the prefix invalidate and the existing chat ws.on() handlers. Reconnect refetch picks up active-tasks too. - Desktop bridges window.daemonAPI.onStatusChange directly into the runtimes cache via setQueryData, giving the local daemon sub-second feedback (vs. 75s server sweep). Bridge is wsId-bound so workspace switches automatically rebind the subscription; daemon_id matching covers the same-daemon-multiple-providers case. 24 derivation unit tests cover all branches plus null/empty/boundary inputs (FAILED_WINDOW_MS edges, null last_seen_at, missing completed_at). Full core suite: 112 tests passing. Typecheck green across all 8 workspace packages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(agent-status): redesign agent runtime status as two orthogonal dimensions Splits the conflated 5-state agent presence into two independent axes: - AgentAvailability (3-state): online / unstable / offline — drives the dot indicator everywhere a dot appears. Pure runtime reachability; never sticky-red because of a past task outcome. - LastTaskState (5-state): running / completed / failed / cancelled / idle — surfaced as text + icon on focused surfaces (hover card, agent detail page, agents list, runtime detail). Never colours the dot. Major changes: * Domain layer: AgentPresence union → AgentAvailability + LastTaskState. derive-presence split into deriveAgentAvailability + deriveLastTaskState + deriveAgentPresenceDetail orchestrator. Tests reorganised into three groups (availability invariants, last-task invariants, composition). * Visual config: presenceConfig (5 entries) → availabilityConfig (3) + taskStateConfig (5). availabilityOrder + lastTaskOrder for filter chips. * Workspace-level presence prefetch: new useWorkspacePresencePrefetch hook + WorkspacePresencePrefetch mount component, wired into DashboardLayout (web) and WorkspaceRouteLayout (desktop). Hover cards render synchronously with no skeleton flash on first hover. * ActorAvatar hover: flipped default — disableHoverCard removed, enableHoverCard added (default false). Opt-in at ~14 decision-moment surfaces; pickers / decoration sub-chips stay plain. Status dot decoupled (showStatusDot prop) so picker rows can show presence without nesting popovers. * Hover cards: AgentProfileCard simplified — availability dot only, Detail link top-right (logs live on the detail page). New MemberProfileCard mirrors the structure: name + role + email + top-2 owned agents (sorted by 30d run count) with click-through to agent detail. * Agents list: split Status into two columns — availability (3-color dot + label) and Last run (task icon + label, optional running counts). Two independent filter chip groups (Status + Last run); combination acts as intersection ("online + failed" finds broken- but-alive agents). * Other UI surfaces (issue list/board/detail, comments, autopilots, projects, runtimes, mention autocomplete, subscribers picker) updated to the new dot semantics; status dot now strictly 3-color. Server changes accompany the client redesign — workspace-wide agent-task-snapshot endpoint, runtime usage queries, etc. — to feed the derive layer with the data it needs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(agent-detail): drop last-task chip from detail header + inspector The Recent work section on the agent detail page already shows the same data (with task titles, timestamps, error context) — surfacing "Completed" / "Failed" / etc. up in the header was redundant chrome. Detail surfaces now show only the 3-state availability dot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tables): handle narrow viewports across agents / skills / runtimes Three table layouts were squeezing content into adjacent cells at intermediate widths. Each fix is small and targeted: * runtime-list: the Runtime cell's base name had `shrink-0`, so it refused to truncate when its grid column was narrowed under width pressure — the name visually overflowed into the Health column ("ClaudeOnline" etc). Removed shrink-0, added truncate. The Health column was also a fixed 9.5rem reservation for the worst-case "Recently lost · 2m 14s ago" copy; switched to minmax(0,1fr) so it competes fairly with Runtime. * skills-page: had a single grid template with no responsive breakpoints — all 6 columns were rendered at any width and got visually jammed below md. Added a <md template that drops Source + Updated; the row markup hides those cells via `hidden md:block` / `md:contents`. * agent-list-item: the new Last run column was reserved at minmax(8rem, max-content); on narrow md viewports the 8rem floor pushed the row past available width. Changed to minmax(0,max-content) so the cell shrinks under pressure (its content already truncates). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(agent-card): hover-only Detail + add Runtime row + breathing room Three small polish tweaks to the agent hover card: - Detail link gets `mr-1` + fades in only on card hover (group-hover). It was visually flush against the popover edge and competing for attention; now it stays out of the way during a quick glance and surfaces only when the user is dwelling on the card. - Runtime row is back, in the meta block (cloud/local icon + runtime name). The earlier removal was over-aggressive — knowing where an agent runs is part of "who is this agent". The wifi badge stays dropped because the availability dot in the header already conveys reachability. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(runtime): wifi-style health icon (4-state) for runtime list + agent card Replaces the 6px coloured dot with a wifi-shape icon that carries both state (Wifi vs WifiOff) and severity (success/warning/muted/destructive). Mapping: - online → Wifi (success) - recently_lost → WifiHigh (warning) — transient hiccup, fewer bars - offline → WifiOff (muted) — long unreachable - about_to_gc → WifiOff (destructive) — sweeper coming soon Used in two places: - Runtime list: replaces HealthDot in the dedicated leading-icon column. Bumped the column from 0.5rem (dot-sized) to 0.875rem (icon-sized). - Agent profile card RuntimeRow: derives runtime health from runtime + clock (matching the 4-state semantics) and renders HealthIcon next to the runtime name. Cloud runtimes always read as online. The duplicate signal with the header availability dot is intentional — it confirms WHICH runtime is the one currently in the dot's state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
582 lines
21 KiB
TypeScript
582 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useRef, useState } from "react";
|
|
import { ChevronRight, Copy, Download, FileText, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Card } from "@multica/ui/components/ui/card";
|
|
import { Button } from "@multica/ui/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
} from "@multica/ui/components/ui/dropdown-menu";
|
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@multica/ui/components/ui/alert-dialog";
|
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@multica/ui/components/ui/collapsible";
|
|
import { ActorAvatar } from "../../common/actor-avatar";
|
|
import { ReactionBar } from "@multica/ui/components/common/reaction-bar";
|
|
import { QuickEmojiPicker } from "@multica/ui/components/common/quick-emoji-picker";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { useActorName } from "@multica/core/workspace/hooks";
|
|
import { timeAgo } from "@multica/core/utils";
|
|
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent, useFileDropZone, FileDropOverlay } from "../../editor";
|
|
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
|
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
|
import { api } from "@multica/core/api";
|
|
import { ReplyInput } from "./reply-input";
|
|
import type { TimelineEntry, Attachment } from "@multica/core/types";
|
|
import { useCommentCollapseStore } from "@multica/core/issues/stores";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface CommentCardProps {
|
|
issueId: string;
|
|
entry: TimelineEntry;
|
|
allReplies: Map<string, TimelineEntry[]>;
|
|
currentUserId?: string;
|
|
onReply: (parentId: string, content: string, attachmentIds?: string[]) => Promise<void>;
|
|
onEdit: (commentId: string, content: string) => Promise<void>;
|
|
onDelete: (commentId: string) => void;
|
|
onToggleReaction: (commentId: string, emoji: string) => void;
|
|
/** ID of the comment to highlight (flash animation). */
|
|
highlightedCommentId?: string | null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared delete confirmation dialog
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function DeleteCommentDialog({
|
|
open,
|
|
onOpenChange,
|
|
onConfirm,
|
|
hasReplies,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onConfirm: () => void;
|
|
hasReplies?: boolean;
|
|
}) {
|
|
return (
|
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete comment</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{hasReplies
|
|
? "This comment and all its replies will be permanently deleted. This cannot be undone."
|
|
: "This comment will be permanently deleted. This cannot be undone."}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction variant="destructive" onClick={onConfirm}>
|
|
Delete
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Standalone attachment list — renders attachments not already in the markdown
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function AttachmentList({ attachments, content, className }: { attachments?: Attachment[]; content?: string; className?: string }) {
|
|
if (!attachments?.length) return null;
|
|
// Skip attachments whose URL is already referenced in the markdown content,
|
|
// and duplicates of the same file (same name/type/size) that are referenced.
|
|
const standalone = content
|
|
? attachments.filter((a) => {
|
|
if (content.includes(a.url)) return false;
|
|
// Dedup: if another attachment with the same file identity is already
|
|
// inline in the content, this is a duplicate upload — skip it.
|
|
const hasSiblingInContent = attachments.some(
|
|
(other) =>
|
|
other.id !== a.id &&
|
|
other.filename === a.filename &&
|
|
other.content_type === a.content_type &&
|
|
other.size_bytes === a.size_bytes &&
|
|
content.includes(other.url),
|
|
);
|
|
if (hasSiblingInContent) return false;
|
|
return true;
|
|
})
|
|
: attachments;
|
|
if (!standalone.length) return null;
|
|
|
|
return (
|
|
<div className={cn("flex flex-col gap-1", className)}>
|
|
{standalone.map((a) => (
|
|
<div
|
|
key={a.id}
|
|
className="flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted"
|
|
>
|
|
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm">{a.filename}</p>
|
|
</div>
|
|
{a.download_url && (
|
|
<button
|
|
type="button"
|
|
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
|
onClick={() => window.open(a.download_url, "_blank", "noopener,noreferrer")}
|
|
>
|
|
<Download className="size-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Single comment row (used for both parent and replies within the same Card)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function CommentRow({
|
|
issueId,
|
|
entry,
|
|
currentUserId,
|
|
onEdit,
|
|
onDelete,
|
|
onToggleReaction,
|
|
}: {
|
|
issueId: string;
|
|
entry: TimelineEntry;
|
|
currentUserId?: string;
|
|
onEdit: (commentId: string, content: string) => Promise<void>;
|
|
onDelete: (commentId: string) => void;
|
|
onToggleReaction: (commentId: string, emoji: string) => void;
|
|
}) {
|
|
const { getActorName } = useActorName();
|
|
const [editing, setEditing] = useState(false);
|
|
const editEditorRef = useRef<ContentEditorRef>(null);
|
|
const cancelledRef = useRef(false);
|
|
const { uploadWithToast } = useFileUpload(api);
|
|
const { isDragOver, dropZoneProps } = useFileDropZone({
|
|
onDrop: (files) => files.forEach((f) => editEditorRef.current?.uploadFile(f)),
|
|
enabled: editing,
|
|
});
|
|
|
|
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
|
const isTemp = entry.id.startsWith("temp-");
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
|
|
const startEdit = () => {
|
|
cancelledRef.current = false;
|
|
setEditing(true);
|
|
};
|
|
|
|
const cancelEdit = () => {
|
|
cancelledRef.current = true;
|
|
setEditing(false);
|
|
};
|
|
|
|
const saveEdit = async () => {
|
|
if (cancelledRef.current) return;
|
|
const trimmed = editEditorRef.current
|
|
?.getMarkdown()
|
|
?.replace(/(\n\s*)+$/, "")
|
|
.trim();
|
|
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
|
|
setEditing(false);
|
|
return;
|
|
}
|
|
try {
|
|
await onEdit(entry.id, trimmed);
|
|
setEditing(false);
|
|
} catch {
|
|
toast.error("Failed to update comment");
|
|
}
|
|
};
|
|
|
|
const reactions = entry.reactions ?? [];
|
|
const contentText = entry.content ?? "";
|
|
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
|
|
|
|
return (
|
|
<div className={`py-3${isTemp ? " opacity-60" : ""}`}>
|
|
<div className="flex items-center gap-2.5">
|
|
<ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={24} enableHoverCard showStatusDot />
|
|
<span className="cursor-pointer text-sm font-medium">
|
|
{getActorName(entry.actor_type, entry.actor_id)}
|
|
</span>
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<span className="text-xs text-muted-foreground cursor-default">
|
|
{timeAgo(entry.created_at)}
|
|
</span>
|
|
}
|
|
/>
|
|
<TooltipContent side="top">
|
|
{new Date(entry.created_at).toLocaleString()}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
{!isTemp && (
|
|
<div className="ml-auto flex items-center gap-0.5">
|
|
<QuickEmojiPicker
|
|
onSelect={(emoji) => onToggleReaction(entry.id, emoji)}
|
|
align="end"
|
|
/>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button variant="ghost" size="icon-sm" className="text-muted-foreground">
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => {
|
|
copyMarkdown(entry.content ?? "");
|
|
toast.success("Copied");
|
|
}}>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
Copy
|
|
</DropdownMenuItem>
|
|
{isOwn && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={startEdit}>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
Edit
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<DeleteCommentDialog
|
|
open={confirmDelete}
|
|
onOpenChange={setConfirmDelete}
|
|
onConfirm={() => onDelete(entry.id)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{editing ? (
|
|
<div
|
|
{...dropZoneProps}
|
|
className="relative mt-1.5 pl-8"
|
|
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
|
>
|
|
<div className="text-sm leading-relaxed">
|
|
<ContentEditor
|
|
ref={editEditorRef}
|
|
defaultValue={entry.content ?? ""}
|
|
placeholder="Edit comment..."
|
|
onSubmit={saveEdit}
|
|
onUploadFile={(file) => uploadWithToast(file, { issueId })}
|
|
debounceMs={100}
|
|
currentIssueId={issueId}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between mt-2">
|
|
<FileUploadButton
|
|
size="sm"
|
|
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
|
|
<Button size="sm" variant="outline" onClick={saveEdit}>Save</Button>
|
|
</div>
|
|
</div>
|
|
{isDragOver && <FileDropOverlay />}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
|
|
<ReadonlyContent content={entry.content ?? ""} />
|
|
</div>
|
|
<AttachmentList attachments={entry.attachments} content={entry.content} className="mt-1.5 pl-8" />
|
|
{!isTemp && (
|
|
<ReactionBar
|
|
reactions={reactions}
|
|
currentUserId={currentUserId}
|
|
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
|
|
getActorName={getActorName}
|
|
hideAddButton={!isLongContent}
|
|
className="mt-1.5 pl-8"
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CommentCard — One Card per thread (parent + all replies flat inside)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function CommentCard({
|
|
issueId,
|
|
entry,
|
|
allReplies,
|
|
currentUserId,
|
|
onReply,
|
|
onEdit,
|
|
onDelete,
|
|
onToggleReaction,
|
|
highlightedCommentId,
|
|
}: CommentCardProps) {
|
|
const { getActorName } = useActorName();
|
|
const { uploadWithToast } = useFileUpload(api);
|
|
const isCollapsed = useCommentCollapseStore((s) => s.isCollapsed(issueId, entry.id));
|
|
const toggleCollapse = useCommentCollapseStore((s) => s.toggle);
|
|
const open = !isCollapsed;
|
|
const handleOpenChange = useCallback((_open: boolean) => toggleCollapse(issueId, entry.id), [toggleCollapse, issueId, entry.id]);
|
|
const [editing, setEditing] = useState(false);
|
|
const editEditorRef = useRef<ContentEditorRef>(null);
|
|
const cancelledRef = useRef(false);
|
|
const { isDragOver: parentDragOver, dropZoneProps: parentDropZoneProps } = useFileDropZone({
|
|
onDrop: (files) => files.forEach((f) => editEditorRef.current?.uploadFile(f)),
|
|
enabled: editing,
|
|
});
|
|
|
|
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
|
const isTemp = entry.id.startsWith("temp-");
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
|
|
const startEdit = () => {
|
|
cancelledRef.current = false;
|
|
setEditing(true);
|
|
};
|
|
|
|
const cancelEdit = () => {
|
|
cancelledRef.current = true;
|
|
setEditing(false);
|
|
};
|
|
|
|
const saveEdit = async () => {
|
|
if (cancelledRef.current) return;
|
|
const trimmed = editEditorRef.current
|
|
?.getMarkdown()
|
|
?.replace(/(\n\s*)+$/, "")
|
|
.trim();
|
|
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
|
|
setEditing(false);
|
|
return;
|
|
}
|
|
try {
|
|
await onEdit(entry.id, trimmed);
|
|
setEditing(false);
|
|
} catch {
|
|
toast.error("Failed to update comment");
|
|
}
|
|
};
|
|
|
|
// Collect all nested replies recursively into a flat list
|
|
const allNestedReplies: TimelineEntry[] = [];
|
|
const collectReplies = (parentId: string) => {
|
|
const children = allReplies.get(parentId) ?? [];
|
|
for (const child of children) {
|
|
allNestedReplies.push(child);
|
|
collectReplies(child.id);
|
|
}
|
|
};
|
|
collectReplies(entry.id);
|
|
|
|
const replyCount = allNestedReplies.length;
|
|
const contentPreview = (entry.content ?? "").replace(/\n/g, " ").slice(0, 80);
|
|
const reactions = entry.reactions ?? [];
|
|
const contentText = entry.content ?? "";
|
|
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
|
|
|
|
const isHighlighted = highlightedCommentId === entry.id;
|
|
|
|
return (
|
|
<Card className={cn("!py-0 !gap-0 overflow-hidden transition-colors duration-700", isTemp && "opacity-60", isHighlighted && "ring-2 ring-brand/50 bg-brand/5")}>
|
|
<Collapsible open={open} onOpenChange={handleOpenChange}>
|
|
{/* Header — always visible, acts as toggle */}
|
|
<div className="px-4 py-3">
|
|
<div className="flex items-center gap-2.5">
|
|
<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} enableHoverCard showStatusDot />
|
|
<span className="shrink-0 cursor-pointer text-sm font-medium">
|
|
{getActorName(entry.actor_type, entry.actor_id)}
|
|
</span>
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<span className="shrink-0 text-xs text-muted-foreground cursor-default">
|
|
{timeAgo(entry.created_at)}
|
|
</span>
|
|
}
|
|
/>
|
|
<TooltipContent side="top">
|
|
{new Date(entry.created_at).toLocaleString()}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
{!open && contentPreview && (
|
|
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
|
{contentPreview}
|
|
</span>
|
|
)}
|
|
{!open && replyCount > 0 && (
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|
{replyCount} {replyCount === 1 ? "reply" : "replies"}
|
|
</span>
|
|
)}
|
|
|
|
{open && !isTemp && (
|
|
<div className="ml-auto flex items-center gap-0.5">
|
|
<QuickEmojiPicker
|
|
onSelect={(emoji) => onToggleReaction(entry.id, emoji)}
|
|
align="end"
|
|
/>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button variant="ghost" size="icon-sm" className="text-muted-foreground">
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => {
|
|
copyMarkdown(entry.content ?? "");
|
|
toast.success("Copied");
|
|
}}>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
Copy
|
|
</DropdownMenuItem>
|
|
{isOwn && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={startEdit}>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
Edit
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<DeleteCommentDialog
|
|
open={confirmDelete}
|
|
onOpenChange={setConfirmDelete}
|
|
onConfirm={() => onDelete(entry.id)}
|
|
hasReplies
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Collapsible body */}
|
|
<CollapsibleContent>
|
|
{/* Parent comment body */}
|
|
<div className="px-4 pb-3">
|
|
{editing ? (
|
|
<div
|
|
{...parentDropZoneProps}
|
|
className="relative pl-10"
|
|
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
|
>
|
|
<div className="text-sm leading-relaxed">
|
|
<ContentEditor
|
|
ref={editEditorRef}
|
|
defaultValue={entry.content ?? ""}
|
|
placeholder="Edit comment..."
|
|
onSubmit={saveEdit}
|
|
onUploadFile={(file) => uploadWithToast(file, { issueId })}
|
|
debounceMs={100}
|
|
currentIssueId={issueId}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between mt-2">
|
|
<FileUploadButton
|
|
size="sm"
|
|
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
|
|
<Button size="sm" variant="outline" onClick={saveEdit}>Save</Button>
|
|
</div>
|
|
</div>
|
|
{parentDragOver && <FileDropOverlay />}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="pl-10 text-sm leading-relaxed text-foreground/85">
|
|
<ReadonlyContent content={entry.content ?? ""} />
|
|
</div>
|
|
<AttachmentList attachments={entry.attachments} content={entry.content} className="mt-1.5 pl-10" />
|
|
{!isTemp && (
|
|
<ReactionBar
|
|
reactions={reactions}
|
|
currentUserId={currentUserId}
|
|
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
|
|
getActorName={getActorName}
|
|
hideAddButton={!isLongContent}
|
|
className="mt-1.5 pl-10"
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Replies */}
|
|
{allNestedReplies.map((reply) => (
|
|
<div key={reply.id} id={`comment-${reply.id}`} className={cn("border-t border-border/50 px-4 transition-colors duration-700", highlightedCommentId === reply.id && "bg-brand/5")}>
|
|
<CommentRow
|
|
issueId={issueId}
|
|
entry={reply}
|
|
currentUserId={currentUserId}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
onToggleReaction={onToggleReaction}
|
|
/>
|
|
</div>
|
|
))}
|
|
|
|
{/* 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"
|
|
avatarId={currentUserId ?? ""}
|
|
onSubmit={(content, attachmentIds) => onReply(entry.id, content, attachmentIds)}
|
|
/>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export { CommentCard, type CommentCardProps };
|