diff --git a/packages/lab/catalog/catalog/feedback.test.ts b/packages/lab/catalog/catalog/feedback.test.ts index b614e12993..cb50a2a691 100644 --- a/packages/lab/catalog/catalog/feedback.test.ts +++ b/packages/lab/catalog/catalog/feedback.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { feedbackIssueUrl } from "../src/feedback" +import { annotationUrl, readAnnotations } from "../src/annotations" describe("catalog feedback", () => { test("opens a prefilled issue for an exact capture", () => { @@ -18,4 +19,39 @@ describe("catalog feedback", () => { expect(url.searchParams.get("body")).toContain("`skill-picker`") expect(url.searchParams.get("body")).toContain("screen=skill-picker&set=opencode") }) + + test("round-trips a capture annotation document through the URL fragment", () => { + const document = { + version: 1 as const, + identifier: "skill-picker", + variant: "opencode", + annotations: [{ id: "one", row: 4, column: 12, note: "This label needs more contrast." }], + } + const url = new URL(annotationUrl("https://dev.opencode.ai/lab/catalog?screen=skill-picker", document)) + + expect(url.hash).toStartWith("#annotations=") + expect(readAnnotations(url, "skill-picker", "opencode")).toEqual(document.annotations) + expect(readAnnotations(url, "other-screen", "opencode")).toEqual([]) + }) + + test("includes human and machine-readable annotations in the issue", () => { + const annotations = [{ id: "one", row: 4, column: 12, note: "This label needs more contrast." }] + const document = { version: 1 as const, identifier: "skill-picker", variant: "opencode", annotations } + const url = new URL( + feedbackIssueUrl({ + title: "Skill picker", + identifier: "skill-picker", + deepLink: annotationUrl("https://dev.opencode.ai/lab/catalog?screen=skill-picker", document), + variant: "opencode", + annotations, + document, + }), + ) + const body = url.searchParams.get("body") ?? "" + + expect(body).toContain("## 1. Row 5, column 13") + expect(body).toContain("This label needs more contrast.") + expect(body).toContain("Annotation data") + expect(body).toContain('"row": 4') + }) }) diff --git a/packages/lab/catalog/src/App.tsx b/packages/lab/catalog/src/App.tsx index 407487711f..7e65612daa 100644 --- a/packages/lab/catalog/src/App.tsx +++ b/packages/lab/catalog/src/App.tsx @@ -334,6 +334,7 @@ export function App({ catalog }: AppProps) { }, []) useEffect(() => { + if (ui.viewerOpen) return window.history.replaceState( null, "", @@ -349,18 +350,22 @@ export function App({ catalog }: AppProps) { states: ui.facets.state, }), ) - }, [activeVariant.id, ui.facets, ui.mode, ui.query, ui.screenLabels, ui.uiElements]) + }, [activeVariant.id, ui.facets, ui.mode, ui.query, ui.screenLabels, ui.uiElements, ui.viewerOpen]) useEffect(() => { if (!ui.viewerOpen || !selectedScreen) return - window.history.replaceState( - null, - "", + const url = new URL( catalogDeepLink(selectedScreen.id, { flowId: ui.mode === "flows" ? activeFlow?.id : undefined, variantId: activeVariant.id, }), ) + if (window.location.hash.startsWith("#annotations=")) url.hash = window.location.hash + window.history.replaceState( + null, + "", + url, + ) }, [activeVariant.id, activeFlow?.id, selectedScreen, ui.mode, ui.viewerOpen]) useEffect(() => { @@ -483,6 +488,7 @@ export function App({ catalog }: AppProps) { {ui.viewerOpen && selectedScreen ? ( +} + +const FragmentKey = "annotations" +const MaxAnnotations = 24 +const MaxNoteLength = 2_000 + +export function annotationUrl(deepLink: string, document: AnnotationDocument) { + const url = new URL(deepLink) + url.hash = `${FragmentKey}=${encode(document)}` + return url.href +} + +export function readAnnotations(url: URL, identifier: string, variant: string): ReadonlyArray { + const params = new URLSearchParams(url.hash.slice(1)) + const encoded = params.get(FragmentKey) + if (!encoded) return [] + const value = decode(encoded) + if (!isDocument(value) || value.identifier !== identifier || value.variant !== variant) return [] + return value.annotations +} + +export function readAnnotationDraft(value: string): ReadonlyArray { + try { + const annotations: unknown = JSON.parse(value) + return isAnnotations(annotations) ? annotations : [] + } catch { + return [] + } +} + +function encode(value: AnnotationDocument) { + const bytes = new TextEncoder().encode(JSON.stringify(value)) + return btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join("")) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, "") +} + +function decode(value: string): unknown { + try { + const binary = atob(value.replaceAll("-", "+").replaceAll("_", "/")) + return JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0)))) + } catch { + return undefined + } +} + +function isDocument(value: unknown): value is AnnotationDocument { + if (!value || typeof value !== "object") return false + const document = value as Partial + if (document.version !== 1 || typeof document.identifier !== "string" || typeof document.variant !== "string") + return false + return isAnnotations(document.annotations) +} + +function isAnnotations(value: unknown): value is ReadonlyArray { + if (!Array.isArray(value) || value.length > MaxAnnotations) return false + return value.every( + (annotation) => + annotation && + typeof annotation === "object" && + typeof annotation.id === "string" && + Number.isInteger(annotation.row) && + annotation.row >= 0 && + Number.isInteger(annotation.column) && + annotation.column >= 0 && + typeof annotation.note === "string" && + annotation.note.length <= MaxNoteLength, + ) +} diff --git a/packages/lab/catalog/src/components/AnnotationEditor.tsx b/packages/lab/catalog/src/components/AnnotationEditor.tsx new file mode 100644 index 0000000000..ace8705eb7 --- /dev/null +++ b/packages/lab/catalog/src/components/AnnotationEditor.tsx @@ -0,0 +1,163 @@ +import { useEffect, useRef, useState } from "react" +import type { Annotation } from "../annotations" + +interface AnnotationEditorProps { + readonly cols: number + readonly rows: number + readonly annotations: ReadonlyArray + readonly onAdd: (row: number, column: number, note: string) => void + readonly onChange: (id: string, note: string) => void + readonly onDelete: (id: string) => void + readonly issueLink: string + readonly onDone: () => void +} + +interface Draft { + readonly id?: string + readonly row: number + readonly column: number + readonly note: string +} + +export function AnnotationEditor(props: AnnotationEditorProps) { + const [draft, setDraft] = useState() + const textareaRef = useRef(null) + const complete = props.annotations.filter((annotation) => annotation.note.trim() !== "") + + useEffect(() => { + if (!draft) return + const frame = requestAnimationFrame(() => { + textareaRef.current?.focus() + textareaRef.current?.setSelectionRange(draft.note.length, draft.note.length) + }) + return () => cancelAnimationFrame(frame) + }, [draft?.id, draft?.row, draft?.column]) + + const save = () => { + if (!draft?.note.trim()) return + if (draft.id) props.onChange(draft.id, draft.note.trim()) + else props.onAdd(draft.row, draft.column, draft.note.trim()) + setDraft(undefined) + } + + const edit = (annotation: Annotation) => + setDraft({ id: annotation.id, row: annotation.row, column: annotation.column, note: annotation.note }) + + return ( + <> +
{ + if (event.target !== event.currentTarget) return + const bounds = event.currentTarget.getBoundingClientRect() + const column = Math.min(props.cols - 1, Math.max(0, Math.floor(((event.clientX - bounds.left) / bounds.width) * props.cols))) + const row = Math.min(props.rows - 1, Math.max(0, Math.floor(((event.clientY - bounds.top) / bounds.height) * props.rows))) + setDraft({ row, column, note: "" }) + }} + > + {props.annotations.map((annotation, index) => ( + + ))} + {draft ? ( +
props.rows / 2 ? " above" : ""}`} + style={{ + left: `clamp(9rem, ${((draft.column + 0.5) / props.cols) * 100}%, calc(100% - 9rem))`, + top: `${((draft.row + 0.5) / props.rows) * 100}%`, + }} + onPointerDown={(event) => event.stopPropagation()} + > +
+ {draft.id ? "Edit annotation" : "New annotation"} + R{draft.row + 1} · C{draft.column + 1} +
+