mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-01 01:16:17 +02:00
* Reapply "perf(issues): virtualize inbox/list/board/swimlane (MUL-4474, 方案2) (#…" (#5395)
This reverts commit c10bfa8f56.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): seed virtualized lists so route-return doesn't flash blank (MUL-4750)
The relanded MUL-4474 virtualization flashed an empty card area (group /
column headers present, rows blank) when returning to /issues or crossing
inbox<->issues. Two stacked blank windows caused it:
1. The scroll element reaches Virtuoso via a callback ref that lands in
state, so the first render after a remount has customScrollParent === null
and the code rendered nothing.
2. Even once mounted, Virtuoso renders 0 rows until its post-paint
ResizeObserver measures the viewport.
Fix both, four surfaces (list / board / swimlane / inbox):
- New shared <VirtuosoSeed> renders a bounded slice of the real rows while the
scroll parent is still null, reusing each caller's own itemContent /
computeItemKey so a seeded row is identical to its virtualized counterpart.
- Pass initialItemCount={Math.min(len, SEED)} so the measurement frame keeps
those rows instead of collapsing to empty.
SEED is capped at 30 and floored by Math.min, so small workspaces
(hasMore=false) and short columns never over-mount — the path that crashed on
real Desktop before. restoreStateFrom (tab-switch Activity restore) is
intentionally out of scope for this round.
Verified: @multica/views tsc --noEmit, eslint, and full vitest (1936 tests)
pass. Real-Desktop route-return / DnD / keyboard / scroll-position regression
pass still owed on device.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* perf(issues): defer per-card popup mounting, share one context menu per surface
Tab-switching to a board froze the main thread for seconds: every card
eagerly mounted ~6 popup roots (context menu, pickers, hover cards) plus
per-card query subscriptions, multiplied by seed x columns x remount.
- DeferredPopup: pickers render a pixel-identical static trigger and mount
the real popover on first pointerenter/keydown (Base UI opens on click,
so the warm mount always wins the race)
- AssigneePicker/PriorityPicker/DateOnlyPicker defer when uncontrolled;
AssigneePicker's members/agents/squads/frequency subscriptions now only
start on interaction
- IssueActionsContextMenu: one controlled ContextMenu per surface anchored
at the cursor via a virtual anchor; items delegate (issue, position) up.
Known debt: iOS Safari long-press no longer opens it
- ActorAvatar hover cards warm-mount on pointerenter with a manual
first-dwell timer matching Base UI's OPEN_DELAY
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(issues): stabilize board column scrollbar across mounts
Column scrollbars redrew visibly on every surface mount (route switches,
first open): the seed frame's scroll height covered only the seeded cards,
then Virtuoso spaced out the full count.
- VirtuosoSeed: optional estimatedItemHeight renders a trailing spacer so
the seed frame's scroll height already approximates the full list
- Board columns: seed capped at 10 (one column viewport of ~110px cards,
not the 36px-row-sized generic 30) and the same estimate feeds Virtuoso's
defaultItemHeight so both phases agree until real measurements land
- Columns at <=30 cards skip virtualization entirely and render plainly
(same itemContent), making their scroll height browser-measured truth in
every scenario -- the per-column split Linear ships
(data-virtual-cluster=false for small clusters)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(editor): reduce issue detail mount cost
Parse long Markdown in smaller chunks without the duplicate initial sync, defer title and empty composer editors until intent, and keep the description editor eager to avoid layout shifts.
* chore(desktop): add navigation boundary lint rule (MUL-4741 Phase 2 prereq)
The tab Coordinator protocol requires that application code never
navigates directly (invariant 1: a Router location change without a
Coordinator token is a protocol error). Enforce it statically:
- renderer app code may not import useNavigate/Navigate from
react-router-dom nor call router.navigate; src/platform is exempt
- the five known legacy sites (the RFC §8.1 migration checklist,
cross-validated: the rule fires on exactly those and nothing else)
carry inline eslint-disable directives tagged MUL-4741 — the Phase 2
migration removes them one by one, and this rule holding with zero
disables is the machine check that the migration is complete
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(views): open deferred pickers on click, align triggerRender types
Pointerenter warm-mounting swapped the trigger element mid-gesture: real
browsers re-hit-test so the click lands on the new trigger, but synthetic
pointer sequences (tests, assistive tech) keep dispatching on the detached
node and the first click dies. Upgrade now happens on click/Enter/Space
only — the same timing as Base UI's own trigger — with the in-flight click
stopped so the popup's just-mounted outside-press dismissal doesn't close
it in the same breath.
Also widen triggerRender to ReactElement<Record<string, unknown>> (React
19 defaults ReactElement props to unknown) and mount the
IssueContextMenuProvider in the swimlane test harness like IssueSurface
does in production.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(desktop): single-router tab sessions with Coordinator protocol (MUL-4741 Phase 2)
Replace the per-tab-router + <Activity> keep-alive model with the approved
single-router session architecture:
- TabSession: tabs are pure serializable state (url, resourceKey, virtual
history stack, scroll memento). Persist v4 does the one-time legacy
view-state import from v3; mountGeneration is deliberately unpersisted.
- Coordinator (platform/tab-coordinator.ts) is the only router writer: it
reconciles THE app router to the active session URL with navigation
tokens; a location change without a token is a protocol error handled by
bounded recovery (invariant 1). The router history is never used — every
reconcile is a replace; back/forward are session-stack operations.
- ActiveTabHost mounts exactly one tab, keyed on tabId:mountGeneration.
reload() = generation bump + active-scope query invalidation (never
router.revalidate, never a global cache invalidation). Warm switches
restore scroll pre-paint; cold restores pre-size containers from the
memento's saved scrollHeight and settle when data lands.
- resourceKey dedup (pathname only) replaces exact-path dedup: opening
/slug/issues?filter=b focuses the existing issues tab (RFC §8.2,
deliberate semantic change).
- All five §8.1 legacy navigation sites migrated (index <Navigate>, error
page recovery, workspace-layout login bounce, overlay parking, shell
back/forward); their MUL-4741 ratchet eslint-disables are removed, so the
navigation boundary rule now holds with zero exemptions outside
src/platform.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(issues): register board columns and list scroller for scroll mementos
Per-container scroll registration for the MUL-4741 tab session memento:
board columns key by group id (each column's offset restores
independently, the per-column split Linear ships), the list view keys as
"list". Chat and issue-detail already carry the marker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tabs): pull-based scroll restoration fed into virtualized lists (MUL-4741)
Rebuild the restore side of the memento protocol on first principles (the
model Linear ships): a saved offset is an INPUT to the mounting view, not a
post-hoc DOM mutation from outside.
- ScrollRestorationProvider (views/platform): views pull their saved offset
while mounting. Virtualized lists feed it into Virtuoso's initialScrollTop
so the first render already materializes the rows around it — this
replaces the pushed spacer+scrollTop hook, whose foreign spacer deadlocked
against Virtuoso's own height model (restore landed the viewport in
phantom space, Virtuoso rendered nothing, and the spacer's removal
condition could never be met → blank issue detail). Plain containers
assign the offset at ref-attach, pre-paint. Web has no provider and
behaves as before.
- Memento keys gain a route dimension (`${pathname}::${containerKey}`) and
capture now also fires before in-tab navigation, so back/forward restores
each route's own offsets and same-named containers on different routes
no longer collide.
- commitScrollMemento uses REPLACE-per-route semantics: a container
scrolled back to 0 clears its stale offset instead of resurrecting the
old position on the next visit.
- List view gets the same estimate alignment as the board (36px rows into
the seed spacer and defaultItemHeight), which keeps the shared scroller's
height truthful from the first frame so the restored offset sticks.
Known gap: chat's bottom-anchored list captures offsets but has no restore
consumer — intentional, it re-anchors to bottom on mount.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(editor): make unlabelled code fences plaintext instead of auto-detected
Follow-up to the issue-detail mount work: lowlight's highlightAuto runs
every registered grammar over the full block for code fences without a
language, which dominated mount cost on code-heavy comments. Extract a
shared syntax-highlight module whose auto fallback deterministically
renders plaintext; explicitly labelled languages highlight as before.
Also ignore .gstack/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(issues): lazy-mount column-header popups, right-size swimlane seed
Trace analysis of tab/view switching (30s session): a swimlane mount spent
its largest slice on eagerly-mounted header machinery — ~170 tooltip roots
(one per lane x status cell add-button), 13 column dropdown-menus, and up
to 30 fully-materialized lanes from the generic seed count.
- DeferredTooltip (views/common): renders only the trigger until first
hover, then mounts a controlled Tooltip anchored to the SAME element
(no trigger swap, so mid-gesture events never land on a detached node);
ui TooltipContent grows an `anchor` passthrough for it.
- Board/list/swimlane header add-buttons and hide-column dropdowns now
defer via DeferredTooltip / DeferredPopup (which gains an ariaHasPopup
option for menu triggers).
- Swimlane lane seed drops 30 -> 6 (a lane row is ~300px+; a viewport fits
~3) on both the pre-scroll seed and Virtuoso's initialItemCount.
- openTab gains an `activate` option so "open and focus" paths (pinned-tab
redirect, explicit open-in-new-tab) are ONE store write instead of
openTab + setActiveTab back-to-back — one full subscriber pass per user
action instead of two.
- Dev-only breadcrumb logs IssueSurfaceContent's remount key: the trace
showed the surface mounting twice inside one task, and the my-issues
relation toggle is one confirmed key-flip source; the log ties the next
trace's mounts to exact key transitions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(issues): stop double full-tree render passes on surface interactions
Trace forensics on view switching showed every interaction paying TWO full
surface render passes (React's own Cascading Update marker sits between
them): entering swimlane flips controller-level loading state (loadProjects
enables the projects query), and any such flag flip re-rendered the entire
unmemoized view tree (~600-1000ms dev per pass).
- Memoize BoardView / ListView / SwimLaneView: controller/data outputs are
already useMemo/useCallback-stable, so a controller flag flip now
re-renders the header, not the whole board. The one unstable prop —
BoardView's inline assigneeGroups.flatMap — moves into a useMemo.
- Selection reset on mount swapped the initial empty Set for a NEW empty
Set, buying a guaranteed extra full pass per surface mount; functional
bail keeps the reference when nothing was selected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(ui): drive sidebar resize by direct DOM writes, drop motion/react
Trace analysis showed sidebar motion.div mounts costing ~1s across a
session. Width previews during drag now write straight to the two layout
shells (CSS disables their transitions while data-sidebar-resizing is set);
React only sees the single committed width on pointer-up, and framer-motion
leaves the sidebar entirely.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(issues): wire swimlane's outer scroller into tab scroll restoration
Review blocker on #5403: board/list/issue-detail register their scroll
containers with the tab session memento protocol, but the swimlane outer
scroller did not — under the single-router architecture an inactive tab
unmounts, so a deep-scrolled swimlane returned at top after a tab switch
or reload.
Same wiring as the other surfaces: data-tab-scroll-root="swimlane" for
capture, useRestoredScrollRef in the scroller's attach callback for the
pre-paint assignment, and the saved offset into the lane Virtuoso's
initialScrollTop. Regression test asserts both the capture marker and the
restored offset.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1113 lines
43 KiB
TypeScript
1113 lines
43 KiB
TypeScript
"use client";
|
|
|
|
import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
|
import { CheckCircle2, ChevronRight, ListChevronsDownUp, Copy, Loader2, MoreHorizontal, Pencil, RotateCcw, 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 { ActorAvatar } from "../../common/actor-avatar";
|
|
import { ReactionBar } from "@multica/ui/components/common/reaction-bar";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { copyText } from "@multica/ui/lib/clipboard";
|
|
import { useActorName } from "@multica/core/workspace/hooks";
|
|
import { useTimeAgo } from "../../i18n";
|
|
import { ContentEditor, type ContentEditorRef, ReadonlyContent, useFileDropZone, FileDropOverlay, Attachment as AttachmentRenderer, AttachmentDownloadProvider } from "../../editor";
|
|
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
|
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
|
import { api, dispatchReasonCode } from "@multica/core/api";
|
|
import { ReplyInput } from "./reply-input";
|
|
import { CommentTriggerChips } from "./comment-trigger-chips";
|
|
import { useCommentTriggerPreview } from "../hooks/use-comment-trigger-preview";
|
|
import type { TimelineEntry, Attachment } from "@multica/core/types";
|
|
import { contentReferencesAttachment } from "@multica/core/types";
|
|
import { useCommentCollapseStore, useCommentDraftStore } from "@multica/core/issues/stores";
|
|
import { useT } from "../../i18n";
|
|
import { CommentsFoldBar } from "./resolved-thread-bar";
|
|
import { deriveThreadResolution } from "./thread-utils";
|
|
|
|
const highlightedCommentBackgroundClass =
|
|
"bg-[color-mix(in_srgb,var(--card)_95%,var(--brand)_5%)]";
|
|
const stickyHeaderFadeClass =
|
|
"after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-1 after:bg-[inherit] after:[mask-image:linear-gradient(to_bottom,#000,transparent)] after:[-webkit-mask-image:linear-gradient(to_bottom,#000,transparent)]";
|
|
|
|
function StickyHeaderShell({
|
|
className,
|
|
sticky = true,
|
|
highlighted,
|
|
children,
|
|
}: {
|
|
className?: string;
|
|
sticky?: boolean;
|
|
highlighted?: boolean;
|
|
children: ReactNode;
|
|
}) {
|
|
if (!sticky) {
|
|
return (
|
|
<div className={cn(highlighted && highlightedCommentBackgroundClass, className)}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"sticky top-0 z-10 transition-colors duration-700",
|
|
!highlighted && stickyHeaderFadeClass,
|
|
highlighted ? highlightedCommentBackgroundClass : "bg-card",
|
|
className,
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface CommentCardProps {
|
|
issueId: string;
|
|
entry: TimelineEntry;
|
|
/**
|
|
* Flat list of every nested reply under this thread root, in render order.
|
|
* Computed once in `issue-detail.tsx`'s `timelineView` and stabilized so
|
|
* the array reference only changes when *this* thread's replies change —
|
|
* an unrelated thread receiving a new reply must NOT bust this card's
|
|
* memo. Passing the full Map here used to do exactly that.
|
|
*/
|
|
replies: TimelineEntry[];
|
|
currentUserId?: string;
|
|
/**
|
|
* True when the current user is a workspace owner/admin and can therefore
|
|
* moderate comments authored by anyone — restoring the admin override that
|
|
* the backend already grants at `comment.go:507-512`. Computed once in
|
|
* `issue-detail.tsx` and threaded down so neither this component nor
|
|
* `CommentRow` has to rerun the rule per row.
|
|
*/
|
|
canModerate?: boolean;
|
|
onReply: (parentId: string, content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<boolean>;
|
|
onEdit: (commentId: string, content: string, attachmentIds: string[], suppressAgentIds?: string[]) => Promise<void>;
|
|
onDelete: (commentId: string) => void;
|
|
onToggleReaction: (commentId: string, emoji: string) => void;
|
|
/** Resolve/unresolve any comment in this thread (commentId = the target row). */
|
|
onResolveToggle?: (commentId: string, resolved: boolean) => void;
|
|
/**
|
|
* When non-null, the thread root is currently rendered as a resolved-but-
|
|
* expanded card. Pass a "Collapse" affordance into the header so the user
|
|
* can fold the thread back to the bar; the parent owns the session state.
|
|
*/
|
|
onCollapseResolved?: () => void;
|
|
/**
|
|
* Per-session set of thread ROOT ids whose reply-resolution fold is expanded.
|
|
* Used only when a REPLY is the resolution (root-resolution folding is handled
|
|
* one level up in issue-detail's resolved-bar). Keyed on root id.
|
|
*/
|
|
expandedResolvedIds?: ReadonlySet<string>;
|
|
onResolvedExpandChange?: (rootId: string, expand: boolean) => 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;
|
|
}) {
|
|
const { t } = useT("issues");
|
|
return (
|
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t(($) => $.comment.delete_title)}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{hasReplies
|
|
? t(($) => $.comment.delete_desc_with_replies)
|
|
: t(($) => $.comment.delete_desc)}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{t(($) => $.comment.cancel_action)}</AlertDialogCancel>
|
|
<AlertDialogAction variant="destructive" onClick={onConfirm}>
|
|
{t(($) => $.comment.delete_action)}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Standalone attachment list — renders attachments not already in the markdown
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function AttachmentList({
|
|
attachments,
|
|
content,
|
|
className,
|
|
onRemove,
|
|
}: {
|
|
attachments?: Attachment[];
|
|
content?: string;
|
|
className?: string;
|
|
onRemove?: (attachmentId: string) => void;
|
|
}) {
|
|
if (!attachments?.length) return null;
|
|
// Skip attachments whose URL (stable or legacy) is already referenced
|
|
// in the markdown content, and duplicates of the same file (same
|
|
// name/type/size) that are referenced. The dual-shape match is the
|
|
// MUL-3130 follow-through — a comment can mix the new
|
|
// /api/attachments/<id>/download URL and the legacy att.url shape.
|
|
const standalone = content
|
|
? attachments.filter((a) => {
|
|
if (contentReferencesAttachment(content, a)) 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 &&
|
|
contentReferencesAttachment(content, other),
|
|
);
|
|
if (hasSiblingInContent) return false;
|
|
return true;
|
|
})
|
|
: attachments;
|
|
if (!standalone.length) return null;
|
|
|
|
return (
|
|
<AttachmentDownloadProvider attachments={attachments}>
|
|
<div className={cn("flex flex-col gap-1", className)}>
|
|
{standalone.map((a) => (
|
|
<AttachmentRenderer
|
|
key={a.id}
|
|
attachment={{ kind: "record", attachment: a }}
|
|
editable={!!onRemove}
|
|
onDelete={onRemove ? () => onRemove(a.id) : undefined}
|
|
/>
|
|
))}
|
|
</div>
|
|
</AttachmentDownloadProvider>
|
|
);
|
|
}
|
|
|
|
function collectActiveAttachmentIds(
|
|
content: string,
|
|
attachments: Attachment[],
|
|
retainedStandaloneIds?: Set<string> | null,
|
|
): string[] {
|
|
const ids = new Set<string>();
|
|
for (const attachment of attachments) {
|
|
if (contentReferencesAttachment(content, attachment)) ids.add(attachment.id);
|
|
}
|
|
for (const id of retainedStandaloneIds ?? []) ids.add(id);
|
|
return [...ids];
|
|
}
|
|
|
|
function sameIdSet(a: string[], b: string[]): boolean {
|
|
if (a.length !== b.length) return false;
|
|
const set = new Set(a);
|
|
return b.every((id) => set.has(id));
|
|
}
|
|
|
|
function initialStandaloneAttachmentIds(entry: TimelineEntry): Set<string> {
|
|
const content = entry.content ?? "";
|
|
return new Set(
|
|
(entry.attachments ?? [])
|
|
.filter((attachment) => !contentReferencesAttachment(content, attachment))
|
|
.map((attachment) => attachment.id),
|
|
);
|
|
}
|
|
|
|
function retryableAgentFailureComment(entry: TimelineEntry): entry is TimelineEntry & { source_task_id: string } {
|
|
return (
|
|
entry.actor_type === "agent" &&
|
|
entry.comment_type === "system" &&
|
|
typeof entry.source_task_id === "string" &&
|
|
entry.source_task_id.length > 0
|
|
);
|
|
}
|
|
|
|
function TaskCommentRetryButton({
|
|
issueId,
|
|
taskId,
|
|
className,
|
|
}: {
|
|
issueId: string;
|
|
taskId: string;
|
|
className?: string;
|
|
}) {
|
|
const { t } = useT("issues");
|
|
const [retrying, setRetrying] = useState(false);
|
|
|
|
const handleRetry = async () => {
|
|
if (retrying) return;
|
|
setRetrying(true);
|
|
try {
|
|
await api.rerunIssue(issueId, taskId);
|
|
} catch (e) {
|
|
// Rerun re-checks the operator's invoke permission (MUL-4525); a
|
|
// structured 403 is a permission block, not a transient failure.
|
|
toast.error(
|
|
dispatchReasonCode(e) === "invocation_not_allowed"
|
|
? t(($) => $.execution_log.retry_blocked)
|
|
: e instanceof Error
|
|
? e.message
|
|
: t(($) => $.execution_log.retry_failed),
|
|
);
|
|
} finally {
|
|
setRetrying(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={cn("flex", className)}>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleRetry}
|
|
disabled={retrying}
|
|
aria-label={t(($) => $.execution_log.retry_task_aria)}
|
|
>
|
|
{retrying ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<RotateCcw className="h-3.5 w-3.5" />
|
|
)}
|
|
{t(($) => $.execution_log.retry_task_tooltip)}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared edit-attachment state hook
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function useEditAttachmentState(
|
|
issueId: string,
|
|
entry: TimelineEntry,
|
|
onEdit: (commentId: string, content: string, attachmentIds: string[], suppressAgentIds?: string[]) => Promise<void>,
|
|
) {
|
|
const { t } = useT("issues");
|
|
const { uploadWithToast } = useFileUpload(api);
|
|
const [editing, setEditing] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const editorRef = useRef<ContentEditorRef>(null);
|
|
const cancelledRef = useRef(false);
|
|
const savingRef = useRef(false);
|
|
const [content, setContent] = useState(entry.content ?? "");
|
|
const [suppressedAgentIds, setSuppressedAgentIds] = useState<Set<string>>(() => new Set());
|
|
const [pendingAttachments, setPendingAttachments] = useState<Attachment[]>([]);
|
|
const [retainedStandaloneIds, setRetainedStandaloneIds] = useState<Set<string> | null>(null);
|
|
const triggerPreview = useCommentTriggerPreview({
|
|
issueId,
|
|
parentId: entry.parent_id ?? undefined,
|
|
editingCommentId: entry.id,
|
|
content: editing ? content : "",
|
|
});
|
|
|
|
const editorAttachments = pendingAttachments.length > 0
|
|
? [...(entry.attachments ?? []), ...pendingAttachments]
|
|
: entry.attachments;
|
|
|
|
const handleUpload = useCallback(async (file: File) => {
|
|
const result = await uploadWithToast(file, { issueId });
|
|
if (result) setPendingAttachments((prev) => [...prev, result]);
|
|
return result;
|
|
}, [uploadWithToast, issueId]);
|
|
|
|
useEffect(() => {
|
|
setSuppressedAgentIds(new Set());
|
|
}, [issueId, entry.id, entry.parent_id]);
|
|
|
|
const { isDragOver, dropZoneProps } = useFileDropZone({
|
|
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
|
|
enabled: editing,
|
|
});
|
|
|
|
const draftKey = `edit:${issueId}:${entry.id}` as const;
|
|
const getDraft = useCommentDraftStore.getState().getDraft;
|
|
const setDraft = useCommentDraftStore((s) => s.setDraft);
|
|
const clearDraft = useCommentDraftStore((s) => s.clearDraft);
|
|
|
|
const initialValue = editing
|
|
? (getDraft(draftKey) ?? entry.content ?? "")
|
|
: (entry.content ?? "");
|
|
|
|
useEffect(() => {
|
|
const visible = new Set(triggerPreview.agents.map((agent) => agent.id));
|
|
setSuppressedAgentIds((prev) => {
|
|
const next = new Set([...prev].filter((id) => visible.has(id)));
|
|
return next.size === prev.size ? prev : next;
|
|
});
|
|
}, [triggerPreview.agents]);
|
|
|
|
const toggleSuppressedAgent = useCallback((agentId: string) => {
|
|
setSuppressedAgentIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(agentId)) next.delete(agentId);
|
|
else next.add(agentId);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const standaloneEditAttachments = (entry.attachments ?? []).filter((a) =>
|
|
retainedStandaloneIds?.has(a.id),
|
|
);
|
|
|
|
const resetState = () => {
|
|
setEditing(false);
|
|
setContent(entry.content ?? "");
|
|
setSuppressedAgentIds(new Set());
|
|
setPendingAttachments([]);
|
|
setRetainedStandaloneIds(null);
|
|
clearDraft(draftKey);
|
|
};
|
|
|
|
const startEdit = () => {
|
|
cancelledRef.current = false;
|
|
setContent(getDraft(draftKey) ?? entry.content ?? "");
|
|
setRetainedStandaloneIds(initialStandaloneAttachmentIds(entry));
|
|
setEditing(true);
|
|
};
|
|
|
|
const cancelEdit = () => {
|
|
cancelledRef.current = true;
|
|
resetState();
|
|
};
|
|
|
|
const saveEdit = async () => {
|
|
if (cancelledRef.current || savingRef.current) return;
|
|
const trimmed = editorRef.current
|
|
?.getMarkdown()
|
|
?.replace(/(\n\s*)+$/, "")
|
|
.trim();
|
|
if (!trimmed) return;
|
|
const activeIds = collectActiveAttachmentIds(
|
|
trimmed,
|
|
[...(entry.attachments ?? []), ...pendingAttachments],
|
|
retainedStandaloneIds,
|
|
);
|
|
const attachmentsChanged = !sameIdSet(activeIds, (entry.attachments ?? []).map((a) => a.id));
|
|
if (trimmed === (entry.content ?? "").trim() && !attachmentsChanged) {
|
|
resetState();
|
|
return;
|
|
}
|
|
const suppressAgentIds = triggerPreview.agents
|
|
.filter((agent) => suppressedAgentIds.has(agent.id))
|
|
.map((agent) => agent.id);
|
|
savingRef.current = true;
|
|
setSaving(true);
|
|
try {
|
|
await onEdit(
|
|
entry.id,
|
|
trimmed,
|
|
activeIds,
|
|
suppressAgentIds.length > 0 ? suppressAgentIds : undefined,
|
|
);
|
|
resetState();
|
|
} catch (err) {
|
|
toast.error(
|
|
err instanceof Error && err.message
|
|
? err.message
|
|
: t(($) => $.comment.update_failed),
|
|
);
|
|
} finally {
|
|
savingRef.current = false;
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return {
|
|
editing,
|
|
saving,
|
|
editorRef,
|
|
editorAttachments,
|
|
handleUpload,
|
|
isDragOver,
|
|
dropZoneProps,
|
|
triggerPreview,
|
|
content,
|
|
suppressedAgentIds,
|
|
toggleSuppressedAgent,
|
|
draftKey,
|
|
setDraft,
|
|
setContent,
|
|
clearDraft,
|
|
initialValue,
|
|
standaloneEditAttachments,
|
|
retainedStandaloneIds,
|
|
setRetainedStandaloneIds,
|
|
startEdit,
|
|
cancelEdit,
|
|
saveEdit,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Single comment row (used for both parent and replies within the same Card)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function CommentRow({
|
|
issueId,
|
|
entry,
|
|
currentUserId,
|
|
canModerate = false,
|
|
isResolution = false,
|
|
isHighlighted = false,
|
|
onEdit,
|
|
onDelete,
|
|
onToggleReaction,
|
|
onResolveToggle,
|
|
}: {
|
|
issueId: string;
|
|
entry: TimelineEntry;
|
|
currentUserId?: string;
|
|
canModerate?: boolean;
|
|
/** True when this reply is the thread's resolution (shows the green badge). */
|
|
isResolution?: boolean;
|
|
/** True when this row is the deep-link target currently being highlighted. */
|
|
isHighlighted?: boolean;
|
|
onEdit: (commentId: string, content: string, attachmentIds: string[], suppressAgentIds?: string[]) => Promise<void>;
|
|
onDelete: (commentId: string) => void;
|
|
onToggleReaction: (commentId: string, emoji: string) => void;
|
|
onResolveToggle?: (commentId: string, resolved: boolean) => void;
|
|
}) {
|
|
const { t } = useT("issues");
|
|
const timeAgo = useTimeAgo();
|
|
const { getActorName } = useActorName();
|
|
|
|
const edit = useEditAttachmentState(issueId, entry, onEdit);
|
|
|
|
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
|
const canEditEntry = isOwn || (canModerate && entry.actor_type === "member");
|
|
const canDeleteEntry = isOwn || canModerate;
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
|
|
const reactions = entry.reactions ?? [];
|
|
|
|
return (
|
|
<div className="py-1.5">
|
|
{/* Header pins to the timeline's scroll parent within this reply's own
|
|
row box, so a LONG reply keeps its
|
|
author + actions visible while you scroll its body, then releases once
|
|
this reply ends. The header stays opaque and matches this comment's
|
|
highlight state while it occludes the body scrolling underneath. */}
|
|
<StickyHeaderShell
|
|
highlighted={isHighlighted}
|
|
className="flex items-center gap-2.5 px-4 pt-1 pb-1.5"
|
|
>
|
|
<ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size="md" 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>
|
|
|
|
{isResolution && (
|
|
<span className="text-xs font-medium text-success">
|
|
{t(($) => $.comment.resolve.resolution_badge)}
|
|
</span>
|
|
)}
|
|
|
|
<div className="ml-auto flex items-center gap-0.5">
|
|
<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={() => {
|
|
void copyText(entry.content ?? "").then((ok) => {
|
|
if (ok) toast.success(t(($) => $.comment.copied_toast));
|
|
});
|
|
}}>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
{t(($) => $.comment.copy_action)}
|
|
</DropdownMenuItem>
|
|
{onResolveToggle && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
{isResolution ? (
|
|
<DropdownMenuItem onClick={() => onResolveToggle(entry.id, false)}>
|
|
<RotateCcw className="h-3.5 w-3.5" />
|
|
{t(($) => $.comment.resolve.unresolve_action)}
|
|
</DropdownMenuItem>
|
|
) : (
|
|
<DropdownMenuItem onClick={() => onResolveToggle(entry.id, true)}>
|
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
|
{t(($) => $.comment.resolve.resolve_with_comment_action)}
|
|
</DropdownMenuItem>
|
|
)}
|
|
</>
|
|
)}
|
|
{(canEditEntry || canDeleteEntry) && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
{canEditEntry && (
|
|
<DropdownMenuItem onClick={edit.startEdit}>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
{t(($) => $.comment.edit_action)}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{canEditEntry && canDeleteEntry && <DropdownMenuSeparator />}
|
|
{canDeleteEntry && (
|
|
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
{t(($) => $.comment.delete_action)}
|
|
</DropdownMenuItem>
|
|
)}
|
|
</>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<DeleteCommentDialog
|
|
open={confirmDelete}
|
|
onOpenChange={setConfirmDelete}
|
|
onConfirm={() => onDelete(entry.id)}
|
|
/>
|
|
</div>
|
|
</StickyHeaderShell>
|
|
|
|
{edit.editing ? (
|
|
<div
|
|
{...edit.dropZoneProps}
|
|
className="relative pl-12 pr-4 pt-1"
|
|
onKeyDown={(e) => { if (e.key === "Escape") edit.cancelEdit(); }}
|
|
>
|
|
<div className="text-sm leading-relaxed">
|
|
<ContentEditor
|
|
ref={edit.editorRef}
|
|
defaultValue={edit.initialValue}
|
|
placeholder={t(($) => $.comment.edit_placeholder)}
|
|
onUpdate={(md) => {
|
|
edit.setContent(md);
|
|
if (md.trim().length > 0) edit.setDraft(edit.draftKey, md);
|
|
else edit.clearDraft(edit.draftKey);
|
|
}}
|
|
onSubmit={edit.saveEdit}
|
|
onUploadFile={edit.handleUpload}
|
|
debounceMs={100}
|
|
currentIssueId={issueId}
|
|
attachments={edit.editorAttachments}
|
|
/>
|
|
</div>
|
|
{edit.standaloneEditAttachments.length > 0 && (
|
|
<AttachmentList
|
|
attachments={edit.standaloneEditAttachments}
|
|
className="mt-2 max-w-full"
|
|
onRemove={(attachmentId) =>
|
|
edit.setRetainedStandaloneIds((ids) => {
|
|
const next = new Set(ids ?? []);
|
|
next.delete(attachmentId);
|
|
return next;
|
|
})
|
|
}
|
|
/>
|
|
)}
|
|
<div className="flex items-center justify-between gap-2 mt-2">
|
|
<div className="min-w-0 flex-1">
|
|
<CommentTriggerChips
|
|
agents={edit.triggerPreview.agents}
|
|
blocked={edit.triggerPreview.blocked}
|
|
draftContent={edit.content}
|
|
suppressedAgentIds={edit.suppressedAgentIds}
|
|
onToggle={edit.toggleSuppressedAgent}
|
|
/>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<FileUploadButton
|
|
size="sm"
|
|
multiple
|
|
onSelect={(file) => edit.editorRef.current?.uploadFile(file)}
|
|
/>
|
|
<Button size="sm" variant="ghost" onClick={edit.cancelEdit} disabled={edit.saving}>{t(($) => $.comment.cancel_edit)}</Button>
|
|
<Button size="sm" variant="outline" onClick={edit.saveEdit} disabled={edit.saving}>
|
|
{edit.saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
|
{t(($) => $.comment.save_action)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{edit.isDragOver && <FileDropOverlay />}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="pl-12 pr-4 pt-1 text-sm leading-relaxed text-foreground/85">
|
|
<ReadonlyContent content={entry.content ?? ""} attachments={entry.attachments} />
|
|
</div>
|
|
<AttachmentList attachments={entry.attachments} content={entry.content} className="mt-1.5 pl-12 pr-4" />
|
|
{retryableAgentFailureComment(entry) && (
|
|
<TaskCommentRetryButton
|
|
issueId={issueId}
|
|
taskId={entry.source_task_id}
|
|
className="mt-2 pl-12 pr-4"
|
|
/>
|
|
)}
|
|
<ReactionBar
|
|
reactions={reactions}
|
|
currentUserId={currentUserId}
|
|
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
|
|
getActorName={getActorName}
|
|
className="mt-1.5 pl-12 pr-4"
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CommentCard — One Card per thread (parent + all replies flat inside)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function CommentCardImpl({
|
|
issueId,
|
|
entry,
|
|
replies,
|
|
currentUserId,
|
|
canModerate = false,
|
|
onReply,
|
|
onEdit,
|
|
onDelete,
|
|
onToggleReaction,
|
|
onResolveToggle,
|
|
onCollapseResolved,
|
|
expandedResolvedIds,
|
|
onResolvedExpandChange,
|
|
highlightedCommentId,
|
|
}: CommentCardProps) {
|
|
const { t } = useT("issues");
|
|
const timeAgo = useTimeAgo();
|
|
const { getActorName } = useActorName();
|
|
const isCollapsed = useCommentCollapseStore((s) => s.isCollapsed(issueId, entry.id));
|
|
const toggleCollapse = useCommentCollapseStore((s) => s.toggle);
|
|
const open = !isCollapsed;
|
|
const handleToggle = useCallback(
|
|
() => toggleCollapse(issueId, entry.id),
|
|
[toggleCollapse, issueId, entry.id],
|
|
);
|
|
|
|
const edit = useEditAttachmentState(issueId, entry, onEdit);
|
|
|
|
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
|
const canEditEntry = isOwn || (canModerate && entry.actor_type === "member");
|
|
const canDeleteEntry = isOwn || canModerate;
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
|
|
const allNestedReplies = replies;
|
|
|
|
const replyCount = allNestedReplies.length;
|
|
const contentPreview = (entry.content ?? "").replace(/\n/g, " ").slice(0, 80);
|
|
const reactions = entry.reactions ?? [];
|
|
|
|
const isHighlighted = highlightedCommentId === entry.id;
|
|
|
|
// Reply-resolution display. When a REPLY is the thread's resolution, the other
|
|
// replies fold behind a bar and the resolution stays visible (root-resolution
|
|
// is handled one level up in issue-detail's resolved-bar, so kind "root" here
|
|
// renders the normal full thread under the Collapse header).
|
|
const resolution = deriveThreadResolution(entry, allNestedReplies);
|
|
const replyResolutionId = resolution.kind === "reply" ? resolution.resolutionId : null;
|
|
const threadExpanded = !!expandedResolvedIds?.has(entry.id);
|
|
const replyFolded = replyResolutionId != null && !threadExpanded;
|
|
const foldedReplies = replyResolutionId
|
|
? allNestedReplies.filter((r) => r.id !== replyResolutionId)
|
|
: allNestedReplies;
|
|
const resolutionReply = replyResolutionId
|
|
? allNestedReplies.find((r) => r.id === replyResolutionId) ?? null
|
|
: null;
|
|
|
|
// Pin the root comment's header to the timeline's scroll parent while the
|
|
// thread is open, so a LONG root comment keeps its author + actions visible
|
|
// as you scroll its body (overflow-clip on the Card anchors this to the
|
|
// timeline, not the card — see below). The root-section wrapper below scopes
|
|
// its containing block to the header + body, so it releases the moment the
|
|
// replies begin — exactly one header is pinned at a time. Each reply pins its
|
|
// header the same way, scoped to its own row (see CommentRow). Skip the root
|
|
// header whenever a resolution collapse bar already owns the top-0 sticky slot
|
|
// (root resolved + expanded, or reply-resolution expanded): two sticky bars at
|
|
// the same offset would stack and hide one.
|
|
const stickyHeader =
|
|
open && !onCollapseResolved && !(replyResolutionId != null && threadExpanded);
|
|
|
|
return (
|
|
// overflow-clip (not -hidden) clips the rounded corners WITHOUT creating a
|
|
// scroll container, so the sticky collapse affordances below resolve to the
|
|
// timeline's scroll parent instead of this card. See PR #3623.
|
|
<Card className="!py-0 !gap-0 overflow-clip transition-colors duration-700">
|
|
{onCollapseResolved && (
|
|
<button
|
|
type="button"
|
|
onClick={onCollapseResolved}
|
|
className="sticky top-0 z-20 flex w-full items-center gap-2.5 border-b border-border/50 bg-muted px-4 py-2.5 text-left text-sm text-muted-foreground transition-colors cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
|
aria-label={t(($) => $.comment.resolve.collapse)}
|
|
>
|
|
<ListChevronsDownUp className="h-3.5 w-3.5" />
|
|
{t(($) => $.comment.resolve.collapse)}
|
|
</button>
|
|
)}
|
|
{/* root-section — the sticky header's containing block. It wraps ONLY
|
|
the header + root body, so the header releases the moment you scroll
|
|
past the body into the replies (which render OUTSIDE this wrapper).
|
|
That is what keeps exactly one header pinned at a time: without this
|
|
wrapper the header's containing block is the whole thread and it
|
|
stays stuck behind every reply. */}
|
|
<div className={cn("transition-colors duration-700", isHighlighted && highlightedCommentBackgroundClass)}>
|
|
{/* Header — always visible, acts as toggle */}
|
|
<StickyHeaderShell
|
|
sticky={stickyHeader}
|
|
highlighted={isHighlighted}
|
|
className="px-4 py-3"
|
|
>
|
|
<div className="flex items-center gap-2.5">
|
|
<button
|
|
type="button"
|
|
aria-expanded={open}
|
|
aria-controls={`comment-body-${entry.id}`}
|
|
onClick={handleToggle}
|
|
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")} />
|
|
</button>
|
|
<ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size="md" 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">
|
|
{t(($) => $.comment.reply_count, { count: replyCount })}
|
|
</span>
|
|
)}
|
|
|
|
{open && (
|
|
<div className="ml-auto flex items-center gap-0.5">
|
|
<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={() => {
|
|
void copyText(entry.content ?? "").then((ok) => {
|
|
if (ok) toast.success(t(($) => $.comment.copied_toast));
|
|
});
|
|
}}>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
{t(($) => $.comment.copy_action)}
|
|
</DropdownMenuItem>
|
|
{onResolveToggle && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={() => onResolveToggle(entry.id, !entry.resolved_at)}>
|
|
{entry.resolved_at ? (
|
|
<>
|
|
<RotateCcw className="h-3.5 w-3.5" />
|
|
{t(($) => $.comment.resolve.unresolve_thread_action)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
|
{t(($) => $.comment.resolve.resolve_thread_action)}
|
|
</>
|
|
)}
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
{(canEditEntry || canDeleteEntry) && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
{canEditEntry && (
|
|
<DropdownMenuItem onClick={edit.startEdit}>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
{t(($) => $.comment.edit_action)}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{canEditEntry && canDeleteEntry && <DropdownMenuSeparator />}
|
|
{canDeleteEntry && (
|
|
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
{t(($) => $.comment.delete_action)}
|
|
</DropdownMenuItem>
|
|
)}
|
|
</>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<DeleteCommentDialog
|
|
open={confirmDelete}
|
|
onOpenChange={setConfirmDelete}
|
|
onConfirm={() => onDelete(entry.id)}
|
|
hasReplies
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</StickyHeaderShell>
|
|
|
|
{/* Root comment body. Avoid Base UI's Panel here: every mounted panel
|
|
probes computed styles to detect animations, forcing a style
|
|
recalculation across long issue-detail documents. */}
|
|
{open && (
|
|
<div id={`comment-body-${entry.id}`} className="px-4 pt-1 pb-3">
|
|
{edit.editing ? (
|
|
<div
|
|
{...edit.dropZoneProps}
|
|
className="relative pl-10"
|
|
onKeyDown={(e) => { if (e.key === "Escape") edit.cancelEdit(); }}
|
|
>
|
|
<div className="text-sm leading-relaxed">
|
|
<ContentEditor
|
|
ref={edit.editorRef}
|
|
defaultValue={edit.initialValue}
|
|
placeholder={t(($) => $.comment.edit_placeholder)}
|
|
onUpdate={(md) => {
|
|
edit.setContent(md);
|
|
if (md.trim().length > 0) edit.setDraft(edit.draftKey, md);
|
|
else edit.clearDraft(edit.draftKey);
|
|
}}
|
|
onSubmit={edit.saveEdit}
|
|
onUploadFile={edit.handleUpload}
|
|
debounceMs={100}
|
|
currentIssueId={issueId}
|
|
attachments={edit.editorAttachments}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between mt-2">
|
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
{edit.standaloneEditAttachments.length > 0 && (
|
|
<AttachmentList
|
|
attachments={edit.standaloneEditAttachments}
|
|
className="max-w-full"
|
|
onRemove={(attachmentId) =>
|
|
edit.setRetainedStandaloneIds((ids) => {
|
|
const next = new Set(ids ?? []);
|
|
next.delete(attachmentId);
|
|
return next;
|
|
})
|
|
}
|
|
/>
|
|
)}
|
|
<CommentTriggerChips
|
|
agents={edit.triggerPreview.agents}
|
|
blocked={edit.triggerPreview.blocked}
|
|
draftContent={edit.content}
|
|
suppressedAgentIds={edit.suppressedAgentIds}
|
|
onToggle={edit.toggleSuppressedAgent}
|
|
/>
|
|
<FileUploadButton
|
|
size="sm"
|
|
multiple
|
|
onSelect={(file) => edit.editorRef.current?.uploadFile(file)}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" variant="ghost" onClick={edit.cancelEdit} disabled={edit.saving}>{t(($) => $.comment.cancel_edit)}</Button>
|
|
<Button size="sm" variant="outline" onClick={edit.saveEdit} disabled={edit.saving}>
|
|
{edit.saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
|
{t(($) => $.comment.save_action)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{edit.isDragOver && <FileDropOverlay />}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="pl-10 text-sm leading-relaxed text-foreground/85">
|
|
<ReadonlyContent content={entry.content ?? ""} attachments={entry.attachments} />
|
|
</div>
|
|
<AttachmentList attachments={entry.attachments} content={entry.content} className="mt-1.5 pl-10" />
|
|
{retryableAgentFailureComment(entry) && (
|
|
<TaskCommentRetryButton
|
|
issueId={issueId}
|
|
taskId={entry.source_task_id}
|
|
className="mt-2 pl-10"
|
|
/>
|
|
)}
|
|
<ReactionBar
|
|
reactions={reactions}
|
|
currentUserId={currentUserId}
|
|
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
|
|
getActorName={getActorName}
|
|
className="mt-1.5 pl-10"
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Replies + reply input — rendered OUTSIDE root-section so the root
|
|
header's sticky containing block ends with the body. Gated on `open`
|
|
to mirror the body Panel's collapse visibility. */}
|
|
{open && (
|
|
<>
|
|
{replyFolded ? (
|
|
<>
|
|
{/* reply-mode folded: other replies behind a bar, resolution pinned below */}
|
|
{foldedReplies.length > 0 && (
|
|
<div className="border-t border-border/50 px-4 py-2.5">
|
|
<CommentsFoldBar
|
|
replies={foldedReplies}
|
|
onExpand={() => onResolvedExpandChange?.(entry.id, true)}
|
|
/>
|
|
</div>
|
|
)}
|
|
{resolutionReply && (
|
|
<div
|
|
id={`comment-${resolutionReply.id}`}
|
|
className={cn(
|
|
"border-t border-border/50 transition-colors duration-700",
|
|
highlightedCommentId === resolutionReply.id && highlightedCommentBackgroundClass,
|
|
)}
|
|
>
|
|
<CommentRow
|
|
issueId={issueId}
|
|
entry={resolutionReply}
|
|
currentUserId={currentUserId}
|
|
canModerate={canModerate}
|
|
isResolution
|
|
isHighlighted={highlightedCommentId === resolutionReply.id}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
onToggleReaction={onToggleReaction}
|
|
onResolveToggle={onResolveToggle}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* reply-mode expanded: a Collapse affordance to fold back */}
|
|
{replyResolutionId != null && onResolvedExpandChange && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onResolvedExpandChange(entry.id, false)}
|
|
className="sticky top-0 z-20 flex w-full items-center gap-2.5 border-t border-border/50 bg-muted px-4 py-2.5 text-left text-sm text-muted-foreground transition-colors cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
|
aria-label={t(($) => $.comment.resolve.collapse)}
|
|
>
|
|
<ListChevronsDownUp className="h-3.5 w-3.5" />
|
|
{t(($) => $.comment.resolve.collapse)}
|
|
</button>
|
|
)}
|
|
{/* Replies — chronological; the resolution keeps its place with a badge */}
|
|
{allNestedReplies.map((reply) => (
|
|
<div
|
|
key={reply.id}
|
|
id={`comment-${reply.id}`}
|
|
className={cn(
|
|
"border-t border-border/50 transition-colors duration-700",
|
|
highlightedCommentId === reply.id && highlightedCommentBackgroundClass,
|
|
)}
|
|
>
|
|
<CommentRow
|
|
issueId={issueId}
|
|
entry={reply}
|
|
currentUserId={currentUserId}
|
|
canModerate={canModerate}
|
|
isResolution={reply.id === replyResolutionId}
|
|
isHighlighted={highlightedCommentId === reply.id}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
onToggleReaction={onToggleReaction}
|
|
onResolveToggle={onResolveToggle}
|
|
/>
|
|
</div>
|
|
))}
|
|
|
|
{/* Reply input */}
|
|
<div className="border-t border-border/50 px-4 py-2.5">
|
|
<ReplyInput
|
|
issueId={issueId}
|
|
parentId={entry.id}
|
|
placeholder={t(($) => $.reply.placeholder)}
|
|
size="sm"
|
|
avatarType="member"
|
|
avatarId={currentUserId ?? ""}
|
|
draftKey={`reply:${issueId}:${entry.id}`}
|
|
onSubmit={(content, attachmentIds, suppressAgentIds) => onReply(entry.id, content, attachmentIds, suppressAgentIds)}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// Memoized so a long timeline (e.g. Inbox-embedded IssueDetail with thousands
|
|
// of comments) does not re-render every card on each parent state update or
|
|
// WS-driven cache refresh. Default shallow comparison is sufficient: the
|
|
// timeline grouping is useMemo'd in issue-detail.tsx (stable Map ref), and
|
|
// every callback is stabilized via useCallback in use-issue-timeline.ts.
|
|
const CommentCard = memo(CommentCardImpl);
|
|
|
|
export { CommentCard, type CommentCardProps };
|