Fix document annotation panel styling

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-26 12:09:54 +00:00
1 parent 6805caa566
commit d40e3fcae9
4 files changed
+128 -74

No files matched your search

@@ -45,7 +45,7 @@ export interface AnnotationLayerProps {
testWindow?: { innerWidth: number; innerHeight: number };
/**
* When this number changes, re-read the current document selection and emit a
* pending anchor (used by the panel's "+ New comment on selection" / ⌘⇧M CTA).
* pending anchor for the keyboard shortcut path.
*/
captureSelectionRequestId?: number;
}
@@ -265,15 +265,15 @@ export function DocumentAnnotationLayer({
data-focused={isFocused || undefined}
aria-label="Open annotation thread"
className={cn(
"paperclip-doc-annotation-highlight pointer-events-auto absolute cursor-pointer rounded-sm transition-colors",
"paperclip-doc-annotation-highlight pointer-events-auto absolute cursor-pointer rounded-none transition-colors",
// base box treatment (replaces the previous baseline border)
isResolved
? "bg-muted-foreground/8 outline outline-1 outline-dashed outline-offset-0 outline-muted-foreground/45 hover:bg-muted-foreground/15"
? "bg-yellow-300/25 outline outline-1 outline-dashed outline-offset-0 outline-yellow-700/45 hover:bg-yellow-300/35 dark:bg-yellow-300/18 dark:outline-yellow-200/45"
: isStale
? "bg-amber-500/12 outline outline-2 outline-dashed outline-offset-0 outline-amber-500/70 hover:bg-amber-500/20 dark:bg-amber-500/18 dark:outline-amber-400/80"
? "bg-yellow-300/35 outline outline-2 outline-dashed outline-offset-0 outline-yellow-700/65 hover:bg-yellow-300/45 dark:bg-yellow-300/24 dark:outline-yellow-200/70"
: isFocused
? "bg-primary/20 outline outline-2 outline-offset-0 outline-primary/90 shadow-[0_0_0_1px_var(--color-primary-foreground)] dark:shadow-[0_0_0_1px_color-mix(in_oklab,var(--color-background)_60%,transparent)]"
: "bg-muted-foreground/15 hover:bg-muted-foreground/25",
? "bg-yellow-300/55 outline outline-2 outline-offset-0 outline-yellow-700/85 shadow-[0_0_0_1px_var(--color-background)] dark:bg-yellow-300/35 dark:outline-yellow-200/85"
: "bg-yellow-300/40 hover:bg-yellow-300/55 dark:bg-yellow-300/24 dark:hover:bg-yellow-300/34",
)}
style={{
top: rect.top,
+18 -52
View File
@@ -8,7 +8,6 @@ import type {
import {
Check,
Copy,
MessageSquarePlus,
MoreHorizontal,
RotateCcw,
X,
@@ -60,6 +59,8 @@ export interface AnnotationPanelProps {
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">>;
@@ -74,7 +75,7 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) {
<SheetContent
side="bottom"
showCloseButton={false}
className="paperclip-doc-annotation-sheet flex max-h-[88vh] flex-col rounded-t-xl border-t border-border bg-background p-0"
className="paperclip-doc-annotation-sheet flex max-h-[88vh] flex-col rounded-none border-t border-border bg-background p-0"
>
<SheetTitle className="sr-only">
Comments on {props.documentKey} revision {props.documentRevisionNumber}
@@ -94,9 +95,10 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) {
aria-label={`Annotations for ${props.documentKey.toUpperCase()}, revision ${props.documentRevisionNumber}`}
data-testid="document-annotation-panel"
className={cn(
"flex h-full max-h-[80vh] w-[360px] shrink-0 flex-col overflow-hidden rounded-lg border border-border bg-card shadow-md",
"flex h-full max-h-[80vh] w-[360px] shrink-0 flex-col overflow-hidden rounded-none border border-border bg-card shadow-md",
props.className,
)}
style={props.desktopWidth ? { width: props.desktopWidth, maxWidth: props.desktopWidth } : undefined}
>
<AnnotationPanelBody {...props} />
</aside>
@@ -199,15 +201,6 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
else setFilter("open");
}, [props.focusedThreadId, props.threads]);
const startNewCommentFromSelection = useCallback(() => {
if (props.newCommentDisabled) return;
if (props.pendingAnchor) {
composerRef.current?.focus();
return;
}
props.onRequestCommentFromSelection?.();
}, [props]);
return (
<>
<header
@@ -310,11 +303,8 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
</div>
{props.pendingAnchor ? (
<div className="border-t border-border bg-muted/20 px-3 py-2">
<p className="mb-1 text-[11px] uppercase tracking-[0.12em] text-muted-foreground">
New comment on selection
</p>
<blockquote className="mb-2 max-h-24 overflow-y-auto rounded-md border-l-2 border-muted-foreground/60 bg-background px-2 py-1 text-xs italic text-muted-foreground">
{truncate(props.pendingAnchor.selectedText, 240)}
<blockquote className="mb-2 line-clamp-3 overflow-hidden rounded-none bg-background px-2 py-1 text-xs italic text-muted-foreground">
{truncate(props.pendingAnchor.selectedText, 160)}
</blockquote>
<Textarea
ref={composerRef}
@@ -324,7 +314,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
onChange={(event) => setComposerValue(event.target.value)}
placeholder="Write a comment…"
disabled={props.newCommentDisabled}
className="resize-y text-sm"
className="resize-y rounded-none text-sm"
/>
{createThread.isError ? (
<p className="mt-1 text-xs text-destructive">
@@ -358,31 +348,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
</Button>
</div>
</div>
) : (
<div className="border-t border-border bg-card/60 px-3 py-2">
<Button
type="button"
size="sm"
variant="secondary"
className="w-full justify-start gap-2 text-[12px]"
onClick={startNewCommentFromSelection}
onMouseDown={(event) => {
if (!props.newCommentDisabled) event.preventDefault();
}}
disabled={props.newCommentDisabled}
title={props.newCommentDisabled
? props.newCommentDisabledReason ?? undefined
: "Select text in the document, then click here (⌘⇧M)"}
data-testid="document-annotation-new-comment-cta"
>
<MessageSquarePlus className="h-3.5 w-3.5" />
<span className="flex-1 text-left">New comment on selection</span>
<kbd className="rounded border border-border/80 bg-background px-1 py-0.5 font-mono text-[10px] text-muted-foreground">
⌘⇧M
</kbd>
</Button>
</div>
)}
) : null}
</>
);
}
@@ -423,7 +389,7 @@ function ThreadCard(props: {
data-focused={props.expanded || undefined}
aria-labelledby={`thread-quote-${thread.id}`}
className={cn(
"rounded-md border border-border bg-card transition-colors",
"rounded-none border border-border bg-card transition-colors",
props.expanded && "ring-1 ring-ring/70",
thread.status === "resolved" && "bg-muted/30",
)}
@@ -439,13 +405,11 @@ function ThreadCard(props: {
<blockquote
id={`thread-quote-${thread.id}`}
className={cn(
"mx-3 mt-1 max-h-20 overflow-hidden rounded-sm border-l-2 px-2 py-1 text-xs italic text-muted-foreground",
thread.anchorState === "stale" || thread.status === "resolved"
? "border-dashed border-muted-foreground/40"
: "border-muted-foreground/60",
"mx-3 mt-1 line-clamp-2 overflow-hidden rounded-none bg-muted/40 px-2 py-1 text-xs italic text-muted-foreground",
(thread.anchorState === "stale" || thread.status === "resolved") && "bg-muted/30",
)}
>
{truncate(thread.selectedText, 160)}
{truncate(thread.selectedText, 120)}
</blockquote>
{props.expanded ? (
<div className="space-y-2 px-3 py-2">
@@ -464,7 +428,7 @@ function ThreadCard(props: {
value={props.replyDraft}
onChange={(event) => props.onReplyChange(event.target.value)}
placeholder="Reply…"
className="resize-y text-sm"
className="resize-y rounded-none text-sm"
disabled={props.pendingReply}
/>
<div className="flex items-center justify-end gap-2">
@@ -551,14 +515,16 @@ function CommentRow({
id={`comment-${comment.id}`}
data-focused={focused || undefined}
className={cn(
"rounded-md border border-border bg-background px-2 py-1.5",
"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="min-w-0 truncate">
<span className="font-medium text-foreground">{author.name}</span>
<span className="ml-1 text-muted-foreground">· {author.role}</span>
{author.role === "agent" ? (
<span className="ml-1 text-muted-foreground">· agent</span>
) : null}
</span>
<span className="text-muted-foreground">{relativeTime(comment.createdAt)}</span>
</div>
@@ -277,6 +277,63 @@ describe("IssueDocumentAnnotations", () => {
expect(anchor?.className).toContain("fixed");
});
it("keeps the desktop annotation panel inside the issue content area when properties are visible", async () => {
mockAnnotationsApi.list.mockResolvedValue([makeThread()]);
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
const rectFor = (left: number, top: number, right: number, bottom: number) => ({
x: left,
y: top,
left,
top,
right,
bottom,
width: right - left,
height: bottom - top,
toJSON: () => ({}),
});
const rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
if (this instanceof HTMLElement && this.id === "main-content") {
return rectFor(0, 0, 900, 800);
}
if (
this instanceof HTMLElement
&& this.getAttribute("data-testid") === "document-annotation-body-plan"
) {
return rectFor(80, 120, 640, 620);
}
return originalGetBoundingClientRect.call(this);
});
const root = createRoot(container);
const queryClient = makeQueryClient();
const doc = makeDoc();
try {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<main id="main-content">
<Harness doc={doc} initialPanelOpen />
</main>
</QueryClientProvider>,
);
});
await flush();
await flush();
const anchor = container.querySelector('[data-testid="document-annotation-panel-anchor"]') as HTMLElement | null;
const panel = container.querySelector('[data-testid="document-annotation-panel"]') as HTMLElement | null;
expect(anchor).not.toBeNull();
expect(panel).not.toBeNull();
expect(anchor!.style.left).toBe("524px");
expect(anchor!.style.width).toBe("360px");
expect(panel!.style.width).toBe("360px");
expect(parseFloat(anchor!.style.left) + parseFloat(anchor!.style.width)).toBeLessThanOrEqual(884);
} finally {
rectSpy.mockRestore();
}
});
it("auto-opens the panel and focuses the thread when deep-linked", async () => {
mockAnnotationsApi.list.mockResolvedValue([makeThread({ id: "thread-99" })]);
const root = createRoot(container);
@@ -437,12 +494,12 @@ describe("IssueDocumentAnnotations", () => {
const expandedText = container.querySelector('[data-thread-id="open-1"]')?.textContent ?? "";
expect(expandedText).toContain("Dotta");
expect(expandedText).toContain("· board");
expect(expandedText).not.toContain("· board");
expect(expandedText).toContain("UXDesigner");
expect(expandedText).toContain("· agent");
});
it("exposes a persistent + New comment on selection CTA when panel is open", async () => {
it("does not render a persistent New comment on selection hint when panel is open", async () => {
mockAnnotationsApi.list.mockResolvedValue([]);
const root = createRoot(container);
const queryClient = makeQueryClient();
@@ -459,12 +516,12 @@ describe("IssueDocumentAnnotations", () => {
await flush();
const cta = container.querySelector('[data-testid="document-annotation-new-comment-cta"]');
expect(cta).not.toBeNull();
expect(cta!.textContent).toMatch(/New comment on selection/i);
expect(cta!.textContent).toMatch(/⌘⇧M/);
expect(cta).toBeNull();
expect(container.textContent).not.toMatch(/New comment on selection/i);
expect(container.textContent).not.toMatch(/⌘⇧M/);
});
it("keeps a captured selection available for the panel CTA without opening the composer early", async () => {
it("keeps a captured selection from opening the composer until the layer requests a comment", async () => {
mockAnnotationsApi.list.mockResolvedValue([]);
const root = createRoot(container);
const queryClient = makeQueryClient();
@@ -491,12 +548,13 @@ describe("IssueDocumentAnnotations", () => {
expect(container.querySelector('[data-testid="document-annotation-composer"]')).toBeNull();
const cta = container.querySelector(
'[data-testid="document-annotation-new-comment-cta"]',
expect(container.querySelector('[data-testid="document-annotation-new-comment-cta"]')).toBeNull();
const directRequestButton = container.querySelector(
'[data-testid="mock-annotation-selection"]',
) as HTMLButtonElement | null;
expect(cta).not.toBeNull();
expect(directRequestButton).not.toBeNull();
await act(async () => {
cta!.click();
directRequestButton!.click();
});
await flush();
+36 -6
View File
@@ -12,6 +12,7 @@ import { DocumentAnnotationPanel } from "./DocumentAnnotationPanel";
import type { CompanyUserProfile } from "@/lib/company-members";
const DESKTOP_ANNOTATION_PANEL_WIDTH = 360;
const DESKTOP_ANNOTATION_PANEL_MIN_WIDTH = 280;
const DESKTOP_ANNOTATION_PANEL_GAP = 12;
const DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN = 16;
@@ -64,6 +65,7 @@ export function IssueDocumentAnnotations({
left: number;
top: number;
maxHeight: number;
width: number;
} | null>(null);
const hashHandledRef = useRef<string | null>(null);
// Bus token to ask the body layer to capture the current selection into a pendingAnchor.
@@ -88,22 +90,39 @@ export function IssueDocumentAnnotations({
}
const updatePanelFrame = () => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) {
const container = containerRef.current;
const rect = container?.getBoundingClientRect();
if (!container || !rect) {
setDesktopPanelFrame(null);
return;
}
const boundaryRect = container.closest("main")?.getBoundingClientRect();
const boundaryLeft = boundaryRect?.left ?? 0;
const boundaryRight = boundaryRect?.right ?? window.innerWidth;
const boundaryWidth = Math.max(0, boundaryRight - boundaryLeft);
const maxPanelWidth = Math.max(
DESKTOP_ANNOTATION_PANEL_MIN_WIDTH,
boundaryWidth - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN * 2,
);
const desiredWidth = Math.min(DESKTOP_ANNOTATION_PANEL_WIDTH, maxPanelWidth);
const top = Math.max(DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN, rect.top);
const desiredLeft = rect.right + DESKTOP_ANNOTATION_PANEL_GAP;
const maxVisibleLeft = window.innerWidth
- DESKTOP_ANNOTATION_PANEL_WIDTH
const spaceRightOfDocument = boundaryRight
- desiredLeft
- DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
const width = spaceRightOfDocument >= DESKTOP_ANNOTATION_PANEL_MIN_WIDTH
? Math.min(desiredWidth, spaceRightOfDocument)
: desiredWidth;
const maxVisibleLeft = boundaryRight
- width
- DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
setDesktopPanelFrame({
left: Math.max(
DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN,
boundaryLeft + DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN,
Math.min(desiredLeft, maxVisibleLeft),
),
top,
width,
maxHeight: Math.max(240, window.innerHeight - top - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN),
});
};
@@ -111,9 +130,19 @@ export function IssueDocumentAnnotations({
updatePanelFrame();
window.addEventListener("resize", updatePanelFrame);
window.addEventListener("scroll", updatePanelFrame, true);
const resizeObserver = typeof window.ResizeObserver === "function"
? new window.ResizeObserver(updatePanelFrame)
: null;
const observedContainer = containerRef.current;
if (resizeObserver && observedContainer) {
resizeObserver.observe(observedContainer);
const main = observedContainer.closest("main");
if (main) resizeObserver.observe(main);
}
return () => {
window.removeEventListener("resize", updatePanelFrame);
window.removeEventListener("scroll", updatePanelFrame, true);
resizeObserver?.disconnect();
};
}, [doc.key, isMobile, panelOpen]);
@@ -243,7 +272,7 @@ export function IssueDocumentAnnotations({
newCommentDisabled={newCommentDisabled}
newCommentDisabledReason={newCommentDisabledReason}
isMobile={isMobile}
className={isMobile ? undefined : "lg:w-[360px] lg:max-w-[360px]"}
desktopWidth={desktopPanelFrame?.width}
agentMap={agentMap}
userProfileMap={userProfileMap}
/>
@@ -284,6 +313,7 @@ export function IssueDocumentAnnotations({
left: desktopPanelFrame.left,
maxHeight: desktopPanelFrame.maxHeight,
top: desktopPanelFrame.top,
width: desktopPanelFrame.width,
}}
>
{annotationPanel}