diff --git a/ui/src/components/DocumentAnnotationLayer.tsx b/ui/src/components/DocumentAnnotationLayer.tsx index d9418aae7f..c149f7e013 100644 --- a/ui/src/components/DocumentAnnotationLayer.tsx +++ b/ui/src/components/DocumentAnnotationLayer.tsx @@ -34,16 +34,25 @@ export interface PendingAnchor { selectedText: string; } +export interface AnnotationAnchorRect { + top: number; + left: number; + width: number; + height: number; +} + export interface AnnotationLayerProps { containerRef: React.RefObject; markdown: string; threads: AnnotationOverlayThread[]; focusedThreadId: string | null; - onThreadFocus: (threadId: string) => void; + onThreadFocus: (threadId: string, rect: AnnotationAnchorRect) => void; /** Tracks the most recently captured pending selection. */ pendingAnchor: PendingAnchor | null; onPendingAnchorChange: (anchor: PendingAnchor | null) => void; - onRequestComment: (anchor: PendingAnchor) => void; + onRequestComment: (anchor: PendingAnchor, rect: AnnotationAnchorRect) => void; + /** Publishes refreshed geometry for the currently open popover. */ + onAnchorRectChange?: (rect: AnnotationAnchorRect | null) => void; /** Disables the "add comment" affordance when set. */ newCommentDisabled?: boolean; newCommentDisabledReason?: string | null; @@ -236,6 +245,7 @@ export function DocumentAnnotationLayer({ pendingAnchor, onPendingAnchorChange, onRequestComment, + onAnchorRectChange, newCommentDisabled = false, newCommentDisabledReason = null, hideResolved = true, @@ -247,6 +257,7 @@ export function DocumentAnnotationLayer({ const [toolbarPosition, setToolbarPosition] = useState(null); const overlayRef = useRef(null); const lastCaptureSelectionRequestIdRef = useRef(0); + const lastSelectionRectRef = useRef(null); const reactId = useId(); const nativeHighlightInstanceId = useMemo( () => `document-annotation-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`, @@ -340,7 +351,16 @@ export function DocumentAnnotationLayer({ } setNativeHighlightRanges(nativeHighlightInstanceId, nativeRanges); setHighlightRects(next); - }, [containerRef, focusedThreadId, nativeHighlightInstanceId, pendingHighlightText, visibleThreads]); + const activeId = pendingHighlightText ? PENDING_HIGHLIGHT_THREAD_ID : focusedThreadId; + const activeRects = activeId ? next.filter((rect) => rect.threadId === activeId) : []; + const activeRect = activeRects.find((rect) => rect.isTail) ?? activeRects[0]; + onAnchorRectChange?.(activeRect ? { + top: activeRect.top, + left: activeRect.left, + width: activeRect.width, + height: activeRect.height, + } : null); + }, [containerRef, focusedThreadId, nativeHighlightInstanceId, onAnchorRectChange, pendingHighlightText, visibleThreads]); useLayoutEffect(() => { computeHighlightRects(); @@ -428,6 +448,12 @@ export function DocumentAnnotationLayer({ const top = Math.max(0, rect.top - overlayRect.top - 36); const left = Math.max(0, rect.left - overlayRect.left + rect.width / 2 - 80); setToolbarPosition({ top, left }); + lastSelectionRectRef.current = { + top: rect.top - overlayRect.top, + left: rect.left - overlayRect.left, + width: rect.width, + height: rect.height, + }; return { selector: anchor.selector, selectedText: containerOffset.selectedText, @@ -467,12 +493,12 @@ export function DocumentAnnotationLayer({ const anchor = captureSelection(); if (anchor) { onPendingAnchorChange(anchor); - onRequestComment(anchor); + if (lastSelectionRectRef.current) onRequestComment(anchor, lastSelectionRectRef.current); } }, [captureSelectionRequestId, captureSelection, onPendingAnchorChange, onRequestComment]); const handleAddComment = () => { - if (pendingAnchor) onRequestComment(pendingAnchor); + if (pendingAnchor && lastSelectionRectRef.current) onRequestComment(pendingAnchor, lastSelectionRectRef.current); }; const content = ( @@ -551,7 +577,12 @@ export function DocumentAnnotationLayer({ } onMouseDown={(event) => { event.preventDefault(); - onThreadFocus(rect.threadId); + onThreadFocus(rect.threadId, { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }); }} /> ); diff --git a/ui/src/components/DocumentAnnotationPanel.tsx b/ui/src/components/DocumentAnnotationPanel.tsx index 40305f72b9..3737423ba8 100644 --- a/ui/src/components/DocumentAnnotationPanel.tsx +++ b/ui/src/components/DocumentAnnotationPanel.tsx @@ -1,8 +1,6 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMemo, useRef, useState, useEffect } from "react"; import type { DocumentAnnotationComment, - DocumentAnnotationThreadStatus, DocumentAnnotationThreadWithComments, } from "@paperclipai/shared"; import { @@ -23,9 +21,7 @@ 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 type { DocumentAnnotationTarget } from "@/api/document-annotations"; import { copyTextToClipboard } from "@/lib/clipboard"; import { AgentIcon } from "./AgentIconPicker"; import { deriveInitials } from "./Identity"; @@ -33,6 +29,7 @@ import { MarkdownBody } from "./MarkdownBody"; import type { PendingAnchor } from "./DocumentAnnotationLayer"; import type { Agent } from "@paperclipai/shared"; import type { CompanyUserProfile } from "@/lib/company-members"; +import { useDocumentAnnotationMutations } from "@/hooks/useDocumentAnnotationMutations"; export interface AnnotationPanelProps { open: boolean; @@ -105,11 +102,9 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) { ); } -function AnnotationPanelBody(props: AnnotationPanelProps) { - const queryClient = useQueryClient(); +export function AnnotationPanelBody(props: AnnotationPanelProps) { const [composerValue, setComposerValue] = useState(""); const [replyDrafts, setReplyDrafts] = useState>({}); - const [mutationError, setMutationError] = useState(null); const composerRef = useRef(null); const bodyTestId = props.isMobile ? "document-annotation-panel" : undefined; const annotationTarget = useMemo(() => { @@ -118,19 +113,18 @@ function AnnotationPanelBody(props: AnnotationPanelProps) { 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 { createThread, addReply, updateStatus, mutationError, currentUser } = useDocumentAnnotationMutations({ + target: annotationTarget, + baseRevisionId: props.baseRevisionId, + baseRevisionNumber: props.baseRevisionNumber, + pendingAnchor: props.pendingAnchor, + onFocusThread: props.onFocusThread, + onThreadCreated: () => { + props.onClearPendingAnchor(); + setComposerValue(""); + }, + onReplyAdded: (threadId) => setReplyDrafts((current) => ({ ...current, [threadId]: "" })), }); - 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. @@ -147,164 +141,6 @@ function AnnotationPanelBody(props: AnnotationPanelProps) { [props.threads], ); - const annotationsQueryKey = useMemo( - () => annotationTarget.kind === "routine" - ? queryKeys.routines.documentAnnotations(annotationTarget.routineId, annotationTarget.documentKey, "all") - : annotationTarget.kind === "case" - ? queryKeys.cases.documentAnnotations(annotationTarget.caseId, 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; - } - if (annotationTarget.kind === "case") { - return query.queryKey[0] === "cases" - && query.queryKey[1] === "document-annotations" - && query.queryKey[2] === annotationTarget.caseId - && 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(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( - 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( - 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(annotationsQueryKey); - const optimisticComment = buildOptimisticComment({ - body, - threadId, - target: annotationTarget, - author: currentUser, - }); - queryClient.setQueryData( - 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(annotationsQueryKey); - queryClient.setQueryData( - 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) { @@ -478,7 +314,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) { ); } -function ThreadCard(props: { +export function ThreadCard(props: { thread: DocumentAnnotationThreadWithComments; expanded: boolean; focusedCommentId: string | null; @@ -695,95 +531,12 @@ function resolveAuthor( 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, - caseId: input.target.kind === "case" ? input.target.caseId : 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, - caseId: input.target.kind === "case" ? input.target.caseId : 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) { +export 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) { +export async function copyAnnotationLink(documentKey: string, threadId: string) { if (typeof window === "undefined") return; const { pathname } = window.location; const hash = `#document-${encodeURIComponent(documentKey)}&thread=${encodeURIComponent(threadId)}`; diff --git a/ui/src/components/DocumentAnnotationPopover.test.tsx b/ui/src/components/DocumentAnnotationPopover.test.tsx new file mode 100644 index 0000000000..e52b0cebb8 --- /dev/null +++ b/ui/src/components/DocumentAnnotationPopover.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment jsdom + +import { createRef } from "react"; +import { createRoot } from "react-dom/client"; +import type { DocumentAnnotationThreadWithComments } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DocumentAnnotationPopover } from "./DocumentAnnotationPopover"; + +const mutations = vi.hoisted(() => ({ + create: vi.fn(), + reply: vi.fn(), + status: vi.fn(), +})); + +vi.mock("@/hooks/useDocumentAnnotationMutations", () => ({ + useDocumentAnnotationMutations: () => ({ + createThread: { mutate: mutations.create, isPending: false }, + addReply: { mutate: mutations.reply, isPending: false }, + updateStatus: { mutate: mutations.status, isPending: false }, + mutationError: null, + }), +})); + +vi.mock("./MarkdownBody", () => ({ MarkdownBody: ({ children }: { children: string }) =>

{children}

})); + +const pendingAnchor = { + selector: { + quote: { exact: "selected plan text", prefix: "", suffix: "" }, + position: { normalizedStart: 0, normalizedEnd: 18, markdownStart: 0, markdownEnd: 18 }, + }, + selectedText: "selected plan text", +}; + +function thread(): DocumentAnnotationThreadWithComments { + return { + id: "thread-1", + status: "open", + anchorState: "active", + selectedText: "selected plan text", + comments: [{ + id: "comment-1", + threadId: "thread-1", + body: "Initial comment", + authorType: "user", + authorAgentId: null, + authorUserId: "user-1", + createdAt: new Date(), + updatedAt: new Date(), + }], + } as unknown as DocumentAnnotationThreadWithComments; +} + +describe("DocumentAnnotationPopover", () => { + let host: HTMLDivElement; + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + host = document.createElement("div"); + container = document.createElement("div"); + Object.defineProperties(container, { clientWidth: { value: 500 }, clientHeight: { value: 500 } }); + host.appendChild(container); + document.body.appendChild(host); + root = createRoot(container); + }); + + afterEach(async () => { + await vi.waitFor(() => root.unmount()); + host.remove(); + }); + + const render = async (overrides: Partial> = {}) => { + const onClose = vi.fn(); + const props: React.ComponentProps = { + anchorRect: { top: 40, left: 60, width: 100, height: 20 }, + containerRef: createRef(), + target: { kind: "issue", issueId: "issue-1", documentKey: "plan" }, + documentKey: "plan", + baseRevisionId: "rev-1", + baseRevisionNumber: 1, + pendingAnchor, + thread: null, + focusedCommentId: null, + onFocusThread: vi.fn(), + onClose, + onThreadCreated: vi.fn(), + ...overrides, + }; + props.containerRef.current = container; + root.render(); + await vi.waitFor(() => expect(container.querySelector('[data-testid="document-annotation-popover"]')).not.toBeNull()); + return { onClose }; + }; + + it("anchors compose mode and submits with the keyboard shortcut", async () => { + await render(); + const card = container.querySelector('[data-testid="document-annotation-popover"]') as HTMLElement; + expect(card.style.top).toBe("66px"); + expect(card.style.left).toBe("60px"); + const textarea = container.querySelector("textarea") as HTMLTextAreaElement; + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; + setter?.call(textarea, "Looks good"); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true })); + await vi.waitFor(() => expect(mutations.create).toHaveBeenCalledWith("Looks good")); + }); + + it("dismisses on Escape and outside pointer down", async () => { + const first = await render(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + expect(first.onClose).toHaveBeenCalledTimes(1); + document.body.dispatchEvent(new Event("pointerdown", { bubbles: true })); + expect(first.onClose).toHaveBeenCalledTimes(2); + }); + + it("replies to and resolves a focused thread", async () => { + await render({ pendingAnchor: null, thread: thread() }); + const textarea = container.querySelector("textarea") as HTMLTextAreaElement; + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; + setter?.call(textarea, "A reply"); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + await vi.waitFor(() => expect(textarea.value).toBe("A reply")); + const buttons = Array.from(container.querySelectorAll("button")); + buttons.find((button) => button.textContent?.includes("Reply"))?.click(); + buttons.find((button) => button.textContent?.includes("Resolve"))?.click(); + await vi.waitFor(() => { + expect(mutations.reply).toHaveBeenCalledWith({ threadId: "thread-1", body: "A reply" }); + expect(mutations.status).toHaveBeenCalledWith({ threadId: "thread-1", status: "resolved" }); + }); + }); +}); diff --git a/ui/src/components/DocumentAnnotationPopover.tsx b/ui/src/components/DocumentAnnotationPopover.tsx new file mode 100644 index 0000000000..b4f83b9176 --- /dev/null +++ b/ui/src/components/DocumentAnnotationPopover.tsx @@ -0,0 +1,154 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { Agent, DocumentAnnotationThreadWithComments } from "@paperclipai/shared"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import type { DocumentAnnotationTarget } from "@/api/document-annotations"; +import type { CompanyUserProfile } from "@/lib/company-members"; +import { useDocumentAnnotationMutations } from "@/hooks/useDocumentAnnotationMutations"; +import type { AnnotationAnchorRect, PendingAnchor } from "./DocumentAnnotationLayer"; +import { copyAnnotationLink, ThreadCard, truncate } from "./DocumentAnnotationPanel"; + +export interface DocumentAnnotationPopoverProps { + anchorRect: AnnotationAnchorRect; + containerRef: React.RefObject; + target: DocumentAnnotationTarget; + documentKey: string; + baseRevisionId: string | null; + baseRevisionNumber: number; + pendingAnchor: PendingAnchor | null; + thread: DocumentAnnotationThreadWithComments | null; + focusedCommentId: string | null; + onFocusThread: (threadId: string | null) => void; + onClose: () => void; + onThreadCreated: (thread: DocumentAnnotationThreadWithComments) => void; + newCommentDisabled?: boolean; + agentMap?: ReadonlyMap & Partial>>; + userProfileMap?: ReadonlyMap; +} + +export function DocumentAnnotationPopover(props: DocumentAnnotationPopoverProps) { + const cardRef = useRef(null); + const composerRef = useRef(null); + const [composer, setComposer] = useState(""); + const [reply, setReply] = useState(""); + const target = useMemo(() => props.target, [props.target]); + const { createThread, addReply, updateStatus, mutationError } = useDocumentAnnotationMutations({ + target, + baseRevisionId: props.baseRevisionId, + baseRevisionNumber: props.baseRevisionNumber, + pendingAnchor: props.pendingAnchor, + onFocusThread: props.onFocusThread, + onThreadCreated: props.onThreadCreated, + onReplyAdded: () => setReply(""), + }); + + useEffect(() => composerRef.current?.focus(), [props.pendingAnchor]); + useEffect(() => { + const dismiss = (event: PointerEvent) => { + if (!cardRef.current?.contains(event.target as Node)) props.onClose(); + }; + const escape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + props.onClose(); + } + }; + document.addEventListener("pointerdown", dismiss); + document.addEventListener("keydown", escape); + return () => { + document.removeEventListener("pointerdown", dismiss); + document.removeEventListener("keydown", escape); + }; + }, [props.onClose]); + + const cardHeight = cardRef.current?.offsetHeight ?? 260; + const containerHeight = visibleBottomWithinContainer(props.containerRef.current); + const below = props.anchorRect.top + props.anchorRect.height + 6; + const top = below + cardHeight <= containerHeight + ? below + : Math.max(0, props.anchorRect.top - cardHeight - 6); + const containerWidth = props.containerRef.current?.clientWidth ?? 320; + const left = Math.max(0, Math.min(props.anchorRect.left, containerWidth - 320)); + const submitComposer = () => { + const body = composer.trim(); + if (body && !createThread.isPending && props.baseRevisionId) createThread.mutate(body); + }; + + return ( +
+ {mutationError ?

{mutationError}

: null} + {props.pendingAnchor ? ( +
+
+ {truncate(props.pendingAnchor.selectedText, 120)} +
+