mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
Fix document annotation panel styling
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user