From c0a370069569cb84e6992ebe9589f24136603abf Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 7 Jul 2026 18:49:07 -0700 Subject: [PATCH 1/7] feat(ui): AnnotationPanel renderCardFooter + readOnly host props MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-card footer slot for host reply/resolve UI (clicks inside don't select the card); readOnly hides delete/edit on all card kinds. Both optional, both no-op by default — Plannotator unchanged. --- .github/workflows/test.yml | 1 + .../components/AnnotationPanel.props.test.tsx | 113 ++++++++++++++++++ packages/ui/components/AnnotationPanel.tsx | 102 ++++++++++------ .../ui/components/EditorAnnotationCard.tsx | 23 ++-- 4 files changed, 194 insertions(+), 45 deletions(-) create mode 100644 packages/ui/components/AnnotationPanel.props.test.tsx diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4e77cf5d9..e9ab35ddd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,6 +47,7 @@ jobs: packages/ui/markdownEditorFidelity.test.tsx packages/ui/annotationDraftPersistence.test.tsx packages/ui/codeAnnotationDraftPersistence.test.tsx + packages/ui/components/AnnotationPanel.props.test.tsx packages/ui/components/InlineMarkdown.seam.test.tsx packages/ui/components/ImageThumbnail.seam.test.tsx packages/ui/hooks/useAnnotationHighlighter.test.tsx diff --git a/packages/ui/components/AnnotationPanel.props.test.tsx b/packages/ui/components/AnnotationPanel.props.test.tsx new file mode 100644 index 000000000..542795d5e --- /dev/null +++ b/packages/ui/components/AnnotationPanel.props.test.tsx @@ -0,0 +1,113 @@ +/** + * Consumer-surface contract for AnnotationPanel's host props: + * - readOnly hides every mutation affordance (delete/edit on all card kinds) + * - renderCardFooter renders a per-card slot whose interactions do NOT + * select the card + * Both default to today's behavior (mutable, no footer). + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import React from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; + +import { AnnotationPanel } from './AnnotationPanel'; +import { AnnotationType, type Annotation } from '../types'; + +const hasDom = typeof document !== 'undefined'; + +const annotation: Annotation = { + id: 'a1', + blockId: 'b1', + startOffset: 0, + endOffset: 5, + type: AnnotationType.COMMENT, + text: 'a note', + originalText: 'hello', + createdA: 1, +}; + +let root: Root | null = null; +let host: HTMLElement | null = null; + +async function mount(ui: React.ReactElement): Promise { + host = document.createElement('div'); + document.body.appendChild(host); + await act(async () => { + root = createRoot(host!); + root.render(ui); + }); +} + +afterEach(async () => { + if (root) { + await act(async () => { + root!.unmount(); + }); + root = null; + } + host?.remove(); + host = null; + if (hasDom) document.body.innerHTML = ''; +}); + +const baseProps = { + isOpen: true, + annotations: [annotation], + blocks: [], + onSelect: () => {}, + onDelete: () => {}, + selectedId: null, +}; + +describe('AnnotationPanel consumer props', () => { + test.skipIf(!hasDom)('default renders the delete affordance (today’s behavior)', async () => { + await mount(); + expect(document.querySelector('button[title="Delete annotation"]')).not.toBeNull(); + }); + + test.skipIf(!hasDom)('readOnly hides delete and edit affordances', async () => { + await mount( + {}} + />, + ); + expect(document.querySelector('button[title="Delete annotation"]')).toBeNull(); + expect(document.querySelector('button[title="Edit annotation"]')).toBeNull(); + }); + + test.skipIf(!hasDom)('renderCardFooter renders per-card and does not select the card', async () => { + const selected: string[] = []; + let footerClicks = 0; + await mount( + selected.push(id)} + renderCardFooter={(a) => ( + + )} + />, + ); + + const slot = document.querySelector('[data-annotation-card-footer="true"]'); + expect(slot).not.toBeNull(); + expect(slot!.textContent).toContain('reply to a1'); + + const replyBtn = document.querySelector('[data-testid="reply"]') as HTMLButtonElement; + await act(async () => { + replyBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(footerClicks).toBe(1); + // The click bubbled only to the slot wrapper, which stops propagation — + // the card's onSelect must not fire. + expect(selected).toHaveLength(0); + }); + + test.skipIf(!hasDom)('no footer prop → no slot rendered', async () => { + await mount(); + expect(document.querySelector('[data-annotation-card-footer="true"]')).toBeNull(); + }); +}); diff --git a/packages/ui/components/AnnotationPanel.tsx b/packages/ui/components/AnnotationPanel.tsx index ba49ee7e9..32968c282 100644 --- a/packages/ui/components/AnnotationPanel.tsx +++ b/packages/ui/components/AnnotationPanel.tsx @@ -73,6 +73,13 @@ interface PanelProps { /** Committed direct edits to one or more documents. Rendered as pinned cards * above the annotation timeline with expandable unified diffs. */ directEdits?: DirectEditsPanelItem[] | null; + /** Host slot rendered at the foot of each plan-annotation card (e.g. reply/ + * resolve UI). The panel stays presentation-only; clicks inside the slot + * do not select the card. Default: nothing rendered. */ + renderCardFooter?: (annotation: Annotation) => React.ReactNode; + /** Hide every mutation affordance (delete/edit buttons on all card kinds). + * Selection and scrolling still work. Default false — today's behavior. */ + readOnly?: boolean; } export const AnnotationPanel: React.FC = ({ @@ -97,6 +104,8 @@ export const AnnotationPanel: React.FC = ({ otherFileAnnotations, onOtherFileAnnotationsClick, directEdits = null, + renderCardFooter, + readOnly = false, }) => { const isMobile = useIsMobile(); const [copiedText, setCopiedText] = useState(false); @@ -195,6 +204,8 @@ export const AnnotationPanel: React.FC = ({ onSelect={() => onSelect(entry.annotation.id)} onDelete={() => onDelete(entry.annotation.id)} onEdit={onEdit ? (updates: Partial) => onEdit(entry.annotation.id, updates) : undefined} + readOnly={readOnly} + footer={renderCardFooter?.(entry.annotation)} /> ) : ( = ({ onSelect={() => onSelectCodeAnnotation?.(entry.annotation.id)} onDelete={() => onDeleteCodeAnnotation?.(entry.annotation.id)} onEdit={onEditCodeAnnotation ? (updates: Partial) => onEditCodeAnnotation(entry.annotation.id, updates) : undefined} + readOnly={readOnly} /> ) ))} @@ -221,7 +233,7 @@ export const AnnotationPanel: React.FC = ({ onDeleteEditorAnnotation?.(ann.id)} + onDelete={readOnly ? undefined : () => onDeleteEditorAnnotation?.(ann.id)} /> ))} @@ -418,7 +430,9 @@ const AnnotationCard: React.FC<{ onSelect: () => void; onDelete: () => void; onEdit?: (updates: Partial) => void; -}> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit }) => { + readOnly?: boolean; + footer?: React.ReactNode; +}> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer }) => { const [isEditing, setIsEditing] = useState(false); const [editText, setEditText] = useState(annotation.text || ''); const textareaRef = useRef(null); @@ -517,26 +531,28 @@ const AnnotationCard: React.FC<{ {annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdA)} -
- {onEdit && annotation.type !== AnnotationType.DELETION && !isEditing && ( + {!readOnly && ( +
+ {onEdit && annotation.type !== AnnotationType.DELETION && !isEditing && ( + + )} - )} - -
+
+ )} {/* Global Comment - show text directly */} @@ -585,6 +601,19 @@ const AnnotationCard: React.FC<{ ))} )} + + {/* Host footer slot (reply/resolve UI etc.) — interactions inside it + must not toggle card selection. */} + {footer != null && footer !== false && ( +
e.stopPropagation()} + onKeyDown={(e: React.KeyboardEvent) => e.stopPropagation()} + > + {footer} +
+ )} ); }; @@ -596,7 +625,8 @@ const CodeAnnotationCard: React.FC<{ onSelect: () => void; onDelete: () => void; onEdit?: (updates: Partial) => void; -}> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit }) => { + readOnly?: boolean; +}> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false }) => { const [isEditing, setIsEditing] = useState(false); const [editText, setEditText] = useState(annotation.text || ''); const textareaRef = useRef(null); @@ -650,26 +680,28 @@ const CodeAnnotationCard: React.FC<{ {annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdAt)} -
- {onEdit && !isEditing && ( + {!readOnly && ( +
+ {onEdit && !isEditing && ( + + )} - )} - -
+
+ )} {/* File / line meta */} diff --git a/packages/ui/components/EditorAnnotationCard.tsx b/packages/ui/components/EditorAnnotationCard.tsx index 7fd858521..cb757ee33 100644 --- a/packages/ui/components/EditorAnnotationCard.tsx +++ b/packages/ui/components/EditorAnnotationCard.tsx @@ -14,7 +14,8 @@ type EditorAnnotationVariant = 'plan' | 'code-review'; interface EditorAnnotationCardProps { annotation: EditorAnnotation; - onDelete: () => void; + /** Omit to render the card read-only (no delete affordance). */ + onDelete?: () => void; variant?: EditorAnnotationVariant; } @@ -38,15 +39,17 @@ export const EditorAnnotationCard: React.FC = ({ anno {annotation.filePath}:{lineRange} - + {onDelete && ( + + )} {/* Selected text */} From 307d43f330a8285eb8504d4728be448441b5c817 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 7 Jul 2026 18:50:21 -0700 Subject: [PATCH 2/7] feat(ui): bless 6 exports into the strict-consumer surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TableOfContents, ResizeHandle, useResizablePanel, useActiveSection, useScrollViewport, utils/annotationHelpers — verified strict-clean and backend-free (useResizablePanel persists via the storageBackend seam); added to the gate and the HANDOFF supported-imports table. --- packages/ui/HANDOFF.md | 5 +++++ packages/ui/tsconfig.strict-consumer.json | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/packages/ui/HANDOFF.md b/packages/ui/HANDOFF.md index 7023bd2fc..bd288a0bf 100644 --- a/packages/ui/HANDOFF.md +++ b/packages/ui/HANDOFF.md @@ -197,6 +197,11 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r | `components/AttachmentsButton` | Routes through `uploadTransport`. | | Seam-backed hooks: `useAnnotationHighlighter`, `useAnnotationDraft`, `useCodeAnnotationDraft`, `useExternalAnnotations`, `useFileBrowser` | Their network access goes through the seams in the catalog above. | | `config` (`ConfigStore`) | Persists through `storageBackend`. | +| `components/TableOfContents` | Pure — renders from `blocks`; pair with `useActiveSection` for scroll-spy. *(Blessed in 0.24.0.)* | +| `components/ResizeHandle` + `hooks/useResizablePanel` | Layout pair for draggable panel widths; persists the width through the `storageBackend` seam. *(Blessed in 0.24.0.)* | +| `hooks/useActiveSection` | Scroll-spy over rendered headings; no backend. *(Blessed in 0.24.0.)* | +| `hooks/useScrollViewport` | Resolves the scrolling element for viewport-aware UI; no backend. *(Blessed in 0.24.0.)* | +| `utils/annotationHelpers` | Pure annotation utilities (`getAnnotationCountBySection`, `buildTocHierarchy` + `TocItem`). *(Blessed in 0.24.0.)* | **AI is fully avoidable** — with one precision worth knowing. No AI *UI* is reachable from the supported components: `useAIChat` is imported only by `components/ai/DocumentAIChatPanel` and `useAIProviderConfig`, neither of which any supported component imports, and `CommentPopover`'s Ask-AI affordance exists only behind the optional `onAskAI` prop. `configure.ts` does statically import the `useAIChat` module (it needs `setAITransport`), but if you never use AI the hook is dead code and bundlers eliminate it — verified empirically: a standalone consumer's production bundle importing the full supported surface contains zero `/api/ai` strings. Don't import `components/ai/*` and don't pass `aiTransport`, and you ship no AI code. diff --git a/packages/ui/tsconfig.strict-consumer.json b/packages/ui/tsconfig.strict-consumer.json index c3166bf0a..6a2706a95 100644 --- a/packages/ui/tsconfig.strict-consumer.json +++ b/packages/ui/tsconfig.strict-consumer.json @@ -32,11 +32,17 @@ "components/ThemeProvider.tsx", "components/ImageThumbnail.tsx", "components/AttachmentsButton.tsx", + "components/TableOfContents.tsx", + "components/ResizeHandle.tsx", "hooks/useAnnotationHighlighter.ts", "hooks/useAnnotationDraft.ts", "hooks/useCodeAnnotationDraft.ts", "hooks/useExternalAnnotations.ts", "hooks/useFileBrowser.ts", + "hooks/useResizablePanel.ts", + "hooks/useActiveSection.ts", + "hooks/useScrollViewport.ts", + "utils/annotationHelpers.ts", "config/index.ts" ] } From 831a04adc1a2d7435094822e85a5f1ac21824d91 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 7 Jul 2026 18:54:49 -0700 Subject: [PATCH 3/7] feat(ui): Viewer allowImages + readOnly host props MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit allowImages threads to both CommentPopover sites (popover already gated its attach affordance; Viewer never exposed the knob). readOnly suppresses every composer entry point — selection toolbar (highlighter 'enabled'), pinpoint, global comment, attachments, checkbox toggles — while existing annotations still render and select. Both default to today's behavior. --- .github/workflows/test.yml | 1 + .../ui/components/Viewer.consumer.test.tsx | 144 ++++++++++++++++++ packages/ui/components/Viewer.tsx | 24 ++- 3 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 packages/ui/components/Viewer.consumer.test.tsx diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e9ab35ddd..5eac1ad6d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,6 +48,7 @@ jobs: packages/ui/annotationDraftPersistence.test.tsx packages/ui/codeAnnotationDraftPersistence.test.tsx packages/ui/components/AnnotationPanel.props.test.tsx + packages/ui/components/Viewer.consumer.test.tsx packages/ui/components/InlineMarkdown.seam.test.tsx packages/ui/components/ImageThumbnail.seam.test.tsx packages/ui/hooks/useAnnotationHighlighter.test.tsx diff --git a/packages/ui/components/Viewer.consumer.test.tsx b/packages/ui/components/Viewer.consumer.test.tsx new file mode 100644 index 000000000..d48d68bbe --- /dev/null +++ b/packages/ui/components/Viewer.consumer.test.tsx @@ -0,0 +1,144 @@ +/** + * Consumer-surface contract for Viewer's host props: + * - readOnly suppresses the composer entry points (global-comment button, + * attachments) while the document still renders + * - allowImages threads to CommentPopover, which hides its attach affordance + * Defaults preserve today's behavior (composer on, images on). + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import React from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; + +import { Viewer } from './Viewer'; +import { CommentPopover } from './CommentPopover'; +import { AnnotationType, type Block } from '../types'; + +const hasDom = typeof document !== 'undefined'; + +const blocks: Block[] = [ + { id: 'b1', type: 'paragraph', content: 'hello world', order: 0, startLine: 1 }, +]; + +let root: Root | null = null; +let host: HTMLElement | null = null; + +async function mount(ui: React.ReactElement): Promise { + host = document.createElement('div'); + document.body.appendChild(host); + await act(async () => { + root = createRoot(host!); + root.render(ui); + }); +} + +afterEach(async () => { + if (root) { + await act(async () => { + root!.unmount(); + }); + root = null; + } + host?.remove(); + host = null; + if (hasDom) document.body.innerHTML = ''; +}); + +const viewerProps = { + blocks, + markdown: 'hello world', + annotations: [], + onAddAnnotation: () => {}, + onSelectAnnotation: () => {}, + selectedAnnotationId: null, + mode: 'comment' as const, + taterMode: false, + // Host posture: no /api/doc/exists endpoint. + disableCodePathValidation: true, +}; + +function globalCommentButton(): Element | null { + return document.querySelector('button[title="Add global comment"]'); +} + +describe('Viewer consumer props', () => { + test.skipIf(!hasDom)('default renders the global-comment composer entry (today’s behavior)', async () => { + await mount( + {}} + onRemoveGlobalAttachment={() => {}} + />, + ); + expect(globalCommentButton()).not.toBeNull(); + expect(document.querySelector('button[title="Attachments"]')).not.toBeNull(); + expect(document.body.textContent).toContain('hello world'); + }); + + test.skipIf(!hasDom)('readOnly hides composer entry points but still renders the document', async () => { + await mount( + {}} + onRemoveGlobalAttachment={() => {}} + />, + ); + expect(globalCommentButton()).toBeNull(); + expect(document.querySelector('button[title="Attachments"]')).toBeNull(); + expect(document.body.textContent).toContain('hello world'); + }); +}); + +describe('CommentPopover allowImages', () => { + function makeAnchor(): HTMLElement { + const el = document.createElement('span'); + el.textContent = 'anchor'; + document.body.appendChild(el); + return el; + } + const popoverProps = { + contextText: 'ctx', + isGlobal: true, + onSubmit: () => {}, + onClose: () => {}, + }; + + test.skipIf(!hasDom)('default shows the attach affordance', async () => { + await mount(); + expect(document.querySelector('button[title="Attachments"]')).not.toBeNull(); + }); + + test.skipIf(!hasDom)('allowImages={false} hides the attach affordance', async () => { + await mount(); + expect(document.querySelector('button[title="Attachments"]')).toBeNull(); + }); + + test.skipIf(!hasDom)('submit with allowImages={false} never reports images', async () => { + const submitted: Array = []; + await mount( + submitted.push({ text, images })} + />, + ); + const textarea = document.querySelector('textarea') as HTMLTextAreaElement; + await act(async () => { + const proto = Object.getPrototypeOf(textarea); + const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; + setter?.call(textarea, 'a comment'); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + }); + await act(async () => { + textarea.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', metaKey: true, bubbles: true }), + ); + }); + expect(submitted).toEqual([{ text: 'a comment', images: undefined }]); + }); +}); + +// Keep the import shape honest: AnnotationType is part of the tested surface. +void AnnotationType; diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index ce8fd9fe1..8ad01e119 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -112,6 +112,15 @@ interface ViewerProps { onToggleCheckbox?: (blockId: string, checked: boolean) => void; checkboxOverrides?: Map; onAskAI?: CommentAskAIHandler; + /** Whether comment popovers offer image attachments. Hosts without an + * uploadTransport pass false so the attach affordance never dead-ends. + * Default true — today's behavior. */ + allowImages?: boolean; + /** View-only mode: suppresses every annotation-creation entry point + * (selection toolbar, comment popovers, quick labels, pinpoint, global + * comment, attachments, checkbox toggles). Existing annotations still + * render and remain selectable. Default false — today's behavior. */ + readOnly?: boolean; } export interface ViewerHandle { @@ -191,6 +200,8 @@ export const Viewer = forwardRef(({ onToggleCheckbox, checkboxOverrides, onAskAI, + allowImages = true, + readOnly = false, }, ref) => { const [copied, setCopied] = useState(false); const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null); @@ -283,6 +294,7 @@ export const Viewer = forwardRef(({ onSelectAnnotation, selectedAnnotationId, mode, + enabled: !readOnly, }); // Refs for code block annotation path @@ -319,7 +331,7 @@ export const Viewer = forwardRef(({ containerRef, highlighterRef, inputMethod, - enabled: !toolbarState && !hookCommentPopover && !viewerCommentPopover && !hookQuickLabelPicker && !codeBlockQuickLabelPicker && !(isPlanDiffActive ?? false), + enabled: !readOnly && !toolbarState && !hookCommentPopover && !viewerCommentPopover && !hookQuickLabelPicker && !codeBlockQuickLabelPicker && !(isPlanDiffActive ?? false), onCodeBlockClick: handlePinpointCodeBlockClick, }); @@ -612,7 +624,7 @@ export const Viewer = forwardRef(({ )} {/* Attachments button */} - {onAddGlobalAttachment && onRemoveGlobalAttachment && ( + {!readOnly && onAddGlobalAttachment && onRemoveGlobalAttachment && ( (({ )} {/* CommentGlobal comment button */} + {!readOnly && ( + )} {/* Copy plan/file button */}