Files
PaperClipAI/ui/src/components/DocumentAnnotationPanel.tsx
T
DottaandPaperclip e68188c438 fix: upstream deployed document-comment, routine-annotation, workspace & board-polling fixes (#8536)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - This change touches several already-shipped subsystems —
document-comment annotations, the selected-agent Conference Room chat
surface, routine description annotations, workspace-operation tracking,
and the board polling/inbox UI
> - A batch of incremental fixes and two small backend additions had
accumulated on a local mainline and were deployed to a live instance,
but never landed upstream — so each `origin/master` sync kept
re-diverging
> - Leaving them un-upstreamed means the same delta has to be re-merged
on every sync and risks being lost or silently reverted
> - This pull request rebases that delta cleanly on top of current
`origin/master` (preserving recent upstream work such as reusable
sandbox leases and the relation-list collapse controls) and brings it up
for review
> - The benefit is that mainline and the deployed instance converge, and
these fixes/additions get normal review + CI + Greptile coverage

## Linked Issues or Issue Description

No single GitHub issue tracks this; it is a bundle of bug fixes and two
small feature additions. Following the issue-template fields:

**Bug fixes (what was wrong → what this does):**
- Document comments rendered out of document order, didn't live-update
across clients, swallowed save failures, and lost the markdown text
selection on re-render. Now: doc-order sort, live updates, surfaced
save-failure state, stable selection across re-renders.
- The board polling hot path returned oversized payloads on every poll.
Now: an opt-in `summary` projection trims the heartbeat-run list
payload.
- Assorted UI fixes: sidebar nav peek/streamlining edge cases, markdown
file-viewer re-mount/line-height issues on iOS Safari, inbox badge/skill
deep-link tab selection, and `⌘.` work-mode cycling on iOS.

**Feature additions:**
- `workspace_operations.issue_id` — associate a workspace operation with
the issue that triggered it (new migration `0106`, schema, service,
shared type).
- Routine **description annotations** — comment threads on a routine's
description document, mirroring issue document annotations (new
migration `0107`, `routine_documents` schema, routes/service,
editable-sections UI).
- Selected-agent **Conference Room chat** surface wiring and live
issue-thread updates.

**Related PR:** #8229 (`feat(control-plane): add annotation and
workspace controls`, draft) covers overlapping
annotation/workspace-control territory — flagging it so a reviewer can
reconcile the two rather than double-merging.

## What Changed

- `feat(workspace)`: `workspace_operations.issue_id` migration +
schema/service/type; issue workspace property controls reconciled with
upstream's evolved "Service" row.
- `feat(routines)`: routine description annotations —
`routine_documents` schema, migration `0107`, routes/service,
`editable-sections` UI.
- `fix(document-comments)`: doc-order sort, live updates, save-failure
surfacing, stable markdown selection (+ storybook story, rerender test).
- `feat(chat)`: selected-agent Conference Room chat surface and live
issue-thread updates (`LiveUpdatesProvider`, `issue-chat-messages`,
interactions service).
- `perf(board)`: opt-in `summary` projection for the board
polling/heartbeat-run list payload, plus assorted sidebar / file-viewer
/ inbox / IssueProperties UI fixes.

Organized into 5 logical commits. Migrations are numbered incrementally
after upstream's latest (`0105`) — `0106` then `0107`, no journal
collision.

## Verification

- Built by 3-way merging the deployed delta onto current
`origin/master`; the only merge conflict (`IssueProperties.test.tsx`,
two adjacent test blocks) was resolved in favor of upstream's evolved
"green service link above the workspace row" layout, which matches the
merged component's rendered output.
- Confirmed recent upstream work is preserved post-merge: reusable
sandbox lease teardown (`#8513`), the IssueProperties relation-list
collapse controls, and the sidebar streamlined-nav default.
- Confirmed the net diff vs `origin/master` is exactly the intended
feature delta (65 files) and that overlapping server files (`issues.ts`,
`agents.ts`, `heartbeat.ts`) only add feature code without disturbing
upstream logic.
- This delta is already running on a live deployed instance.
- Full typecheck/test suite + Greptile to run in CI (see checklist).

## Risks

- Two new migrations (`0106`, `0107`). Both are additive (new table /
new nullable column) and ordered after upstream's `0105`; no data
backfill, low risk. If another migration-bearing PR merges first,
renumber before merge.
- Largest blast radius is in the merged overlapping UI/service files;
covered by the existing test suites for those files plus CI.
- Overlaps thematically with draft PR #8229 — reviewers should reconcile
rather than merge both blindly.

## Model Used

Claude Opus 4.8 (`claude-opus-4-8`), extended thinking, with tool use
(git, shell). Agentic coding workflow.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-23 09:00:14 -05:00

782 lines
29 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
DocumentAnnotationComment,
DocumentAnnotationThreadStatus,
DocumentAnnotationThreadWithComments,
} from "@paperclipai/shared";
import {
Check,
Copy,
MoreHorizontal,
RotateCcw,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn, relativeTime } from "@/lib/utils";
import { documentAnnotationsApi, type DocumentAnnotationTarget } from "@/api/document-annotations";
import { authApi } from "@/api/auth";
import { queryKeys } from "@/lib/queryKeys";
import { AgentIcon } from "./AgentIconPicker";
import { deriveInitials } from "./Identity";
import { MarkdownBody } from "./MarkdownBody";
import type { PendingAnchor } from "./DocumentAnnotationLayer";
import type { Agent } from "@paperclipai/shared";
import type { CompanyUserProfile } from "@/lib/company-members";
export interface AnnotationPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
target?: DocumentAnnotationTarget;
issueId?: string;
documentKey: string;
documentRevisionNumber: number;
baseRevisionId: string | null;
baseRevisionNumber: number;
threads: DocumentAnnotationThreadWithComments[];
focusedThreadId: string | null;
onFocusThread: (threadId: string | null) => void;
focusedCommentId: string | null;
/** External pending anchor captured from the layer for the composer. */
pendingAnchor: PendingAnchor | null;
onClearPendingAnchor: () => void;
/** Request the body layer to start a comment from the current text selection (⌘⇧M). */
onRequestCommentFromSelection?: () => void;
newCommentDisabled?: boolean;
newCommentDisabledReason?: string | null;
/** When mobile is true, render via shadcn Sheet at the bottom instead of side panel. */
isMobile?: boolean;
/** Desktop panel width calculated by the document frame. */
desktopWidth?: number;
className?: string;
/** Resolve `<authorAgentId>` to a display name. */
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
/** Resolve `<authorUserId>` to a display name. */
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
}
export function DocumentAnnotationPanel(props: AnnotationPanelProps) {
if (props.isMobile) {
return (
<Sheet open={props.open} onOpenChange={props.onOpenChange}>
<SheetContent
side="bottom"
showCloseButton={false}
className="paperclip-doc-annotation-sheet z-[60] flex max-h-[88vh] flex-col rounded-none border-t border-border bg-popover p-0 text-popover-foreground shadow-2xl"
>
<SheetTitle className="sr-only">
Comments on {props.documentKey} revision {props.documentRevisionNumber}
</SheetTitle>
<div className="mx-auto mt-2 h-1.5 w-12 shrink-0 rounded-full bg-muted-foreground/30" aria-hidden="true" />
<AnnotationPanelBody {...props} />
</SheetContent>
</Sheet>
);
}
if (!props.open) return null;
return (
<aside
role="complementary"
aria-label={`Annotations for ${props.documentKey.toUpperCase()}, revision ${props.documentRevisionNumber}`}
data-testid="document-annotation-panel"
className={cn(
"isolate flex h-full max-h-[80vh] w-[360px] shrink-0 flex-col overflow-hidden rounded-none border border-border bg-popover text-popover-foreground shadow-xl",
props.className,
)}
style={props.desktopWidth ? { width: props.desktopWidth, maxWidth: props.desktopWidth } : undefined}
>
<AnnotationPanelBody {...props} />
</aside>
);
}
function AnnotationPanelBody(props: AnnotationPanelProps) {
const queryClient = useQueryClient();
const [composerValue, setComposerValue] = useState("");
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({});
const [mutationError, setMutationError] = useState<string | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const bodyTestId = props.isMobile ? "document-annotation-panel" : undefined;
const annotationTarget = useMemo<DocumentAnnotationTarget>(() => {
if (props.target) return props.target;
if (!props.issueId) throw new Error("Document annotation panel requires an annotation target.");
return { kind: "issue", issueId: props.issueId, documentKey: props.documentKey };
}, [props.documentKey, props.issueId, props.target]);
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
staleTime: 5 * 60_000,
});
const currentUser = useMemo(() => {
const user = session?.user;
return {
id: user?.id ?? null,
name: user?.name?.trim() || user?.email?.trim() || "You",
image: user?.image ?? null,
};
}, [session]);
// Show every thread that can be anchored in the document (orphaned threads have
// lost their anchor). Filters were removed in favour of a single simple list.
// Sort in document order (top-to-bottom) — not by recency — so the comment list
// stays congruent with the highlights as you scroll the document.
const visibleThreads = useMemo(
() =>
props.threads
.filter((thread) => thread.anchorState !== "orphaned")
.sort((a, b) =>
(a.normalizedStart - b.normalizedStart)
|| (a.markdownStart - b.markdownStart)
|| (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())),
[props.threads],
);
const annotationsQueryKey = useMemo(
() => annotationTarget.kind === "routine"
? queryKeys.routines.documentAnnotations(annotationTarget.routineId, annotationTarget.documentKey, "all")
: queryKeys.issues.documentAnnotations(annotationTarget.issueId, annotationTarget.documentKey, "all"),
[annotationTarget],
);
const invalidateAll = useCallback(() => {
queryClient.invalidateQueries({
predicate: (query) => {
if (!Array.isArray(query.queryKey)) return false;
if (annotationTarget.kind === "routine") {
return query.queryKey[0] === "routines"
&& query.queryKey[1] === "document-annotations"
&& query.queryKey[2] === annotationTarget.routineId
&& query.queryKey[3] === annotationTarget.documentKey;
}
return query.queryKey[0] === "issues"
&& query.queryKey[1] === "document-annotations"
&& query.queryKey[2] === annotationTarget.issueId
&& query.queryKey[3] === annotationTarget.documentKey;
},
});
}, [annotationTarget, queryClient]);
const createThread = useMutation({
mutationFn: async (body: string) => {
if (!props.pendingAnchor) throw new Error("No selection to anchor to.");
if (!props.baseRevisionId) throw new Error("Document has no revision yet.");
return documentAnnotationsApi.createForTarget(annotationTarget, {
baseRevisionId: props.baseRevisionId,
baseRevisionNumber: props.baseRevisionNumber,
selector: props.pendingAnchor.selector,
body,
});
},
// Optimistically drop the new thread into the cache so submission feels instant.
onMutate: async (body: string) => {
const anchor = props.pendingAnchor;
if (!anchor || !props.baseRevisionId) return undefined;
setMutationError(null);
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
const optimisticThread = buildOptimisticThread({
body,
selectedText: anchor.selectedText,
target: annotationTarget,
documentKey: annotationTarget.documentKey,
baseRevisionId: props.baseRevisionId,
baseRevisionNumber: props.baseRevisionNumber,
normalizedStart: anchor.selector.position.normalizedStart,
markdownStart: anchor.selector.position.markdownStart,
author: currentUser,
});
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
annotationsQueryKey,
(current) => [...(current ?? []), optimisticThread],
);
props.onFocusThread(optimisticThread.id);
return { previous, optimisticId: optimisticThread.id };
},
onError: (error, _body, context) => {
if (context?.previous) {
queryClient.setQueryData(annotationsQueryKey, context.previous);
}
setMutationError(error instanceof Error && error.message
? error.message
: "Failed to create comment.");
},
onSuccess: (thread, _body, context) => {
// Swap the optimistic placeholder for the real thread before refetch settles.
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
annotationsQueryKey,
(current) => (current ?? []).map((entry) =>
entry.id === context?.optimisticId ? thread : entry,
),
);
props.onClearPendingAnchor();
setComposerValue("");
setMutationError(null);
props.onFocusThread(thread.id);
},
onSettled: () => invalidateAll(),
});
const addReply = useMutation({
mutationFn: ({ threadId, body }: { threadId: string; body: string }) =>
documentAnnotationsApi.addCommentForTarget(annotationTarget, threadId, { body }),
// Optimistically append the reply so it stays on screen through the round-trip.
onMutate: async ({ threadId, body }) => {
setMutationError(null);
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
const optimisticComment = buildOptimisticComment({
body,
threadId,
target: annotationTarget,
author: currentUser,
});
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
annotationsQueryKey,
(current) => (current ?? []).map((thread) =>
thread.id === threadId
? { ...thread, comments: [...thread.comments, optimisticComment], updatedAt: optimisticComment.createdAt }
: thread,
),
);
return { previous };
},
onError: (error, _variables, context) => {
if (context?.previous) {
queryClient.setQueryData(annotationsQueryKey, context.previous);
}
setMutationError(error instanceof Error && error.message
? error.message
: "Failed to add reply.");
},
onSuccess: (_comment, variables) => {
setReplyDrafts((current) => ({ ...current, [variables.threadId]: "" }));
setMutationError(null);
},
onSettled: () => invalidateAll(),
});
const updateStatus = useMutation({
mutationFn: ({ threadId, status }: { threadId: string; status: DocumentAnnotationThreadStatus }) =>
documentAnnotationsApi.updateStatusForTarget(annotationTarget, threadId, status),
onMutate: async ({ threadId, status }) => {
setMutationError(null);
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
annotationsQueryKey,
(current) => (current ?? []).map((thread) =>
thread.id === threadId ? { ...thread, status } : thread,
),
);
return { previous };
},
onError: (error, _variables, context) => {
if (context?.previous) {
queryClient.setQueryData(annotationsQueryKey, context.previous);
}
setMutationError(error instanceof Error && error.message
? error.message
: "Failed to update comment status.");
},
onSuccess: () => setMutationError(null),
onSettled: () => invalidateAll(),
});
useEffect(() => {
if (!props.open) {
setComposerValue("");
}
}, [props.open]);
useEffect(() => {
if (props.pendingAnchor && props.open) {
composerRef.current?.focus();
}
}, [props.open, props.pendingAnchor]);
// Keep the comment list congruent with the document: when a thread becomes
// focused — whether by clicking its highlight in the doc or by adding a new
// comment — scroll that card into view in the pane.
const listScrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!props.focusedThreadId) return;
const container = listScrollRef.current;
if (!container) return;
const card = container.querySelector<HTMLElement>(
`[data-thread-id="${props.focusedThreadId}"]`,
);
if (card && typeof card.scrollIntoView === "function") {
card.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [props.focusedThreadId, visibleThreads]);
return (
<>
<div
data-testid={bodyTestId}
className="flex items-center justify-end gap-1 border-b border-border bg-popover px-2 py-1.5"
>
<span className="text-[11px] tabular-nums text-muted-foreground">
rev {props.documentRevisionNumber}
</span>
<Button
type="button"
size="icon-xs"
variant="ghost"
className="text-muted-foreground"
onClick={() => {
props.onFocusThread(null);
props.onOpenChange(false);
}}
aria-label="Close annotation panel"
>
<X className="h-4 w-4" />
</Button>
</div>
{props.newCommentDisabled && props.newCommentDisabledReason ? (
<p
data-testid="document-annotation-disabled-reason"
className="border-b border-border bg-muted px-3 py-1.5 text-[11px] text-muted-foreground"
>
{props.newCommentDisabledReason}
</p>
) : null}
{mutationError ? (
<p
data-testid="document-annotation-error"
className="border-b border-border bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive"
>
{mutationError}
</p>
) : null}
<div ref={listScrollRef} className="min-h-0 flex-1 overflow-y-auto bg-popover px-3 py-2">
{visibleThreads.length === 0 ? null : (
<ul className="space-y-2">
{visibleThreads.map((thread) => (
<ThreadCard
key={thread.id}
thread={thread}
expanded={thread.id === props.focusedThreadId}
focusedCommentId={
thread.id === props.focusedThreadId ? props.focusedCommentId : null
}
onFocus={() => props.onFocusThread(thread.id)}
replyDraft={replyDrafts[thread.id] ?? ""}
onReplyChange={(value) =>
setReplyDrafts((current) => ({ ...current, [thread.id]: value }))
}
onSubmitReply={() => {
const body = (replyDrafts[thread.id] ?? "").trim();
if (!body) return;
addReply.mutate({ threadId: thread.id, body });
}}
onResolveToggle={() =>
updateStatus.mutate({
threadId: thread.id,
status: thread.status === "resolved" ? "open" : "resolved",
})
}
onCopyLink={() => copyAnnotationLink(props.documentKey, thread.id)}
pendingReply={addReply.isPending && addReply.variables?.threadId === thread.id}
pendingStatus={updateStatus.isPending && updateStatus.variables?.threadId === thread.id}
agentMap={props.agentMap}
userProfileMap={props.userProfileMap}
/>
))}
</ul>
)}
</div>
{props.pendingAnchor ? (
<div className="border-t border-border bg-popover px-3 py-2">
<blockquote className="mb-2 line-clamp-2 overflow-hidden rounded-none bg-muted px-2 py-1 text-xs italic leading-5 text-muted-foreground [overflow-wrap:anywhere]">
{truncate(props.pendingAnchor.selectedText, 160)}
</blockquote>
<div className="mb-1.5 flex items-center gap-1.5">
<Avatar size="xs" className="shrink-0">
{currentUser.image ? <AvatarImage src={currentUser.image} alt={currentUser.name} /> : null}
<AvatarFallback>{deriveInitials(currentUser.name)}</AvatarFallback>
</Avatar>
<span className="truncate text-[11px] font-medium text-foreground">{currentUser.name}</span>
</div>
<Textarea
ref={composerRef}
data-testid="document-annotation-composer"
rows={3}
value={composerValue}
onChange={(event) => setComposerValue(event.target.value)}
onKeyDown={(event) => {
if (isSubmitShortcut(event)) {
event.preventDefault();
const body = composerValue.trim();
if (
body
&& !createThread.isPending
&& !props.newCommentDisabled
&& props.baseRevisionId
) {
createThread.mutate(body);
}
}
}}
placeholder="Write a comment…"
disabled={props.newCommentDisabled}
className="resize-y rounded-none text-sm"
/>
<div className="mt-2 flex items-center justify-end gap-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
props.onClearPendingAnchor();
setComposerValue("");
}}
>
Cancel
</Button>
<Button
type="button"
size="sm"
disabled={
createThread.isPending
|| !composerValue.trim()
|| props.newCommentDisabled
|| !props.baseRevisionId
}
onClick={() => createThread.mutate(composerValue.trim())}
>
{createThread.isPending ? "Posting…" : "Comment"}
</Button>
</div>
</div>
) : null}
</>
);
}
function ThreadCard(props: {
thread: DocumentAnnotationThreadWithComments;
expanded: boolean;
focusedCommentId: string | null;
onFocus: () => void;
replyDraft: string;
onReplyChange: (value: string) => void;
onSubmitReply: () => void;
onResolveToggle: () => void;
onCopyLink: () => void;
pendingReply: boolean;
pendingStatus: boolean;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
}) {
const { thread } = props;
const latestComment = thread.comments[thread.comments.length - 1];
return (
<li>
<article
role="article"
data-thread-id={thread.id}
data-anchor-state={thread.anchorState}
data-status={thread.status}
data-focused={props.expanded || undefined}
aria-labelledby={`thread-quote-${thread.id}`}
className={cn(
"scroll-mt-2 rounded-none border border-border bg-background transition-colors",
props.expanded && "ring-2 ring-primary/80 ring-offset-1 ring-offset-popover",
thread.status === "resolved" && "bg-muted",
)}
tabIndex={0}
onClick={props.onFocus}
>
<blockquote
id={`thread-quote-${thread.id}`}
className={cn(
"mx-3 mt-2 line-clamp-2 overflow-hidden rounded-none bg-muted px-2 py-1 text-xs italic leading-5 text-muted-foreground [overflow-wrap:anywhere]",
(thread.anchorState === "stale" || thread.status === "resolved") && "bg-muted",
)}
>
{truncate(thread.selectedText, 120)}
</blockquote>
{props.expanded ? (
<div className="space-y-2 px-3 py-2">
{thread.comments.map((comment) => (
<CommentRow
key={comment.id}
comment={comment}
focused={props.focusedCommentId === comment.id}
agentMap={props.agentMap}
userProfileMap={props.userProfileMap}
/>
))}
<Textarea
data-testid={`document-annotation-reply-${thread.id}`}
rows={2}
value={props.replyDraft}
onChange={(event) => props.onReplyChange(event.target.value)}
onKeyDown={(event) => {
if (isSubmitShortcut(event)) {
event.preventDefault();
if (props.replyDraft.trim() && !props.pendingReply) {
props.onSubmitReply();
}
}
}}
placeholder="Reply…"
className="resize-y rounded-none text-sm"
disabled={props.pendingReply}
/>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
size="sm"
variant="secondary"
onClick={props.onResolveToggle}
disabled={props.pendingStatus}
className="gap-1"
>
{thread.status === "resolved" ? (
<>
<RotateCcw className="h-3 w-3" /> Reopen
</>
) : (
<>
<Check className="h-3 w-3" /> Resolve
</>
)}
</Button>
<Button
type="button"
size="sm"
disabled={!props.replyDraft.trim() || props.pendingReply}
onClick={props.onSubmitReply}
>
{props.pendingReply ? "Sending…" : "Reply"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
title="More actions"
aria-label="More thread actions"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(event) => {
event.preventDefault();
props.onCopyLink();
}}
>
<Copy className="h-3.5 w-3.5" />
Copy link
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
) : (
<p className="px-3 py-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">
{thread.comments.length} comment{thread.comments.length === 1 ? "" : "s"}
</span>
{latestComment ? <span className="ml-1">· {truncate(latestComment.body, 120)}</span> : null}
</p>
)}
</article>
</li>
);
}
function CommentRow({
comment,
focused,
agentMap,
userProfileMap,
}: {
comment: DocumentAnnotationComment;
focused: boolean;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
}) {
const author = resolveAuthor(comment, { agentMap, userProfileMap });
return (
<div
id={`comment-${comment.id}`}
data-focused={focused || undefined}
className={cn(
"rounded-none border border-border bg-background px-2 py-1.5",
focused && "ring-2 ring-primary/40",
)}
>
<div className="mb-0.5 flex items-center justify-between gap-2 text-[11px]">
<span className="flex min-w-0 items-center gap-1.5">
<Avatar size="xs" className="shrink-0">
{author.role === "agent" ? (
<AvatarFallback>
<AgentIcon icon={author.agentIcon} className="h-3 w-3" />
</AvatarFallback>
) : (
<>
{author.imageUrl ? <AvatarImage src={author.imageUrl} alt={author.name} /> : null}
<AvatarFallback>{deriveInitials(author.name)}</AvatarFallback>
</>
)}
</Avatar>
<span className="truncate font-medium text-foreground">{author.name}</span>
{author.role === "agent" ? (
<span className="text-muted-foreground">· agent</span>
) : null}
</span>
<span className="shrink-0 text-muted-foreground">{relativeTime(comment.createdAt)}</span>
</div>
<MarkdownBody className="text-sm leading-6">{comment.body}</MarkdownBody>
</div>
);
}
/** ⌘/Ctrl + Enter submits the composer or reply. */
function isSubmitShortcut(event: React.KeyboardEvent<HTMLTextAreaElement>): boolean {
return event.key === "Enter" && (event.metaKey || event.ctrlKey);
}
function resolveAuthor(
comment: DocumentAnnotationComment,
maps: {
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
},
): { name: string; role: "board" | "agent"; agentIcon?: Agent["icon"]; imageUrl?: string | null } {
if (comment.authorAgentId) {
const agent = maps.agentMap?.get(comment.authorAgentId);
return {
name: agent?.name ?? comment.authorAgentId.slice(0, 8),
role: "agent",
agentIcon: agent?.icon,
};
}
if (comment.authorUserId) {
const profile = maps.userProfileMap?.get(comment.authorUserId);
return {
name: profile?.label ?? comment.authorUserId.slice(0, 8),
role: "board",
imageUrl: profile?.image ?? null,
};
}
return { name: comment.authorType === "agent" ? "Agent" : "Board", role: comment.authorType === "agent" ? "agent" : "board" };
}
interface OptimisticAuthor {
id: string | null;
name: string;
image: string | null;
}
function optimisticId(prefix: string): string {
const random = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
return `${prefix}-${random}`;
}
function buildOptimisticComment(input: {
body: string;
threadId: string;
target: DocumentAnnotationTarget;
author: OptimisticAuthor;
}): DocumentAnnotationComment {
const now = new Date();
return {
id: optimisticId("optimistic-comment"),
companyId: "",
threadId: input.threadId,
issueId: input.target.kind === "issue" ? input.target.issueId : null,
routineId: input.target.kind === "routine" ? input.target.routineId : null,
documentId: "",
body: input.body,
authorType: "user",
authorAgentId: null,
authorUserId: input.author.id,
createdByRunId: null,
issueCommentId: null,
createdAt: now,
updatedAt: now,
};
}
function buildOptimisticThread(input: {
body: string;
selectedText: string;
target: DocumentAnnotationTarget;
documentKey: string;
baseRevisionId: string;
baseRevisionNumber: number;
normalizedStart: number;
markdownStart: number;
author: OptimisticAuthor;
}): DocumentAnnotationThreadWithComments {
const id = optimisticId("optimistic-thread");
const now = new Date();
const comment = buildOptimisticComment({
body: input.body,
threadId: id,
target: input.target,
author: input.author,
});
// Only the fields the panel + overlay read need to be accurate; the optimistic
// thread is swapped for the server copy on success. Cast through unknown so we
// don't have to fabricate every backend-only column.
return {
id,
issueId: input.target.kind === "issue" ? input.target.issueId : null,
routineId: input.target.kind === "routine" ? input.target.routineId : null,
documentKey: input.documentKey,
status: "open",
anchorState: "active",
selectedText: input.selectedText,
normalizedStart: input.normalizedStart,
markdownStart: input.markdownStart,
originalRevisionId: input.baseRevisionId,
originalRevisionNumber: input.baseRevisionNumber,
currentRevisionId: input.baseRevisionId,
currentRevisionNumber: input.baseRevisionNumber,
createdByUserId: input.author.id,
createdAt: now,
updatedAt: now,
comments: [comment],
} as unknown as DocumentAnnotationThreadWithComments;
}
function truncate(value: string, limit: number) {
if (value.length <= limit) return value;
return `${value.slice(0, limit - 1)}…`;
}
async function copyAnnotationLink(documentKey: string, threadId: string) {
if (typeof window === "undefined" || !navigator.clipboard) return;
const { pathname } = window.location;
const hash = `#document-${encodeURIComponent(documentKey)}&thread=${encodeURIComponent(threadId)}`;
try {
await navigator.clipboard.writeText(`${window.location.origin}${pathname}${hash}`);
} catch {
/* swallow */
}
}