Skip to content

Commit b17fbf4

Browse files
authored
feat(catalog): click-to-annotate captures with GitHub issue handoff (#42183)
1 parent 9d6e05b commit b17fbf4

8 files changed

Lines changed: 739 additions & 24 deletions

File tree

‎packages/lab/catalog/catalog/feedback.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, test } from "bun:test"
22
import { feedbackIssueUrl } from "../src/feedback"
3+
import { annotationUrl, readAnnotations } from "../src/annotations"
34

45
describe("catalog feedback", () => {
56
test("opens a prefilled issue for an exact capture", () => {
@@ -18,4 +19,39 @@ describe("catalog feedback", () => {
1819
expect(url.searchParams.get("body")).toContain("`skill-picker`")
1920
expect(url.searchParams.get("body")).toContain("screen=skill-picker&set=opencode")
2021
})
22+
23+
test("round-trips a capture annotation document through the URL fragment", () => {
24+
const document = {
25+
version: 1 as const,
26+
identifier: "skill-picker",
27+
variant: "opencode",
28+
annotations: [{ id: "one", row: 4, column: 12, note: "This label needs more contrast." }],
29+
}
30+
const url = new URL(annotationUrl("https://dev.opencode.ai/lab/catalog?screen=skill-picker", document))
31+
32+
expect(url.hash).toStartWith("#annotations=")
33+
expect(readAnnotations(url, "skill-picker", "opencode")).toEqual(document.annotations)
34+
expect(readAnnotations(url, "other-screen", "opencode")).toEqual([])
35+
})
36+
37+
test("includes human and machine-readable annotations in the issue", () => {
38+
const annotations = [{ id: "one", row: 4, column: 12, note: "This label needs more contrast." }]
39+
const document = { version: 1 as const, identifier: "skill-picker", variant: "opencode", annotations }
40+
const url = new URL(
41+
feedbackIssueUrl({
42+
title: "Skill picker",
43+
identifier: "skill-picker",
44+
deepLink: annotationUrl("https://dev.opencode.ai/lab/catalog?screen=skill-picker", document),
45+
variant: "opencode",
46+
annotations,
47+
document,
48+
}),
49+
)
50+
const body = url.searchParams.get("body") ?? ""
51+
52+
expect(body).toContain("## 1. Row 5, column 13")
53+
expect(body).toContain("This label needs more contrast.")
54+
expect(body).toContain("<summary>Annotation data</summary>")
55+
expect(body).toContain('"row": 4')
56+
})
2157
})

‎packages/lab/catalog/src/App.tsx‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ export function App({ catalog }: AppProps) {
334334
}, [])
335335

336336
useEffect(() => {
337+
if (ui.viewerOpen) return
337338
window.history.replaceState(
338339
null,
339340
"",
@@ -349,18 +350,22 @@ export function App({ catalog }: AppProps) {
349350
states: ui.facets.state,
350351
}),
351352
)
352-
}, [activeVariant.id, ui.facets, ui.mode, ui.query, ui.screenLabels, ui.uiElements])
353+
}, [activeVariant.id, ui.facets, ui.mode, ui.query, ui.screenLabels, ui.uiElements, ui.viewerOpen])
353354

354355
useEffect(() => {
355356
if (!ui.viewerOpen || !selectedScreen) return
356-
window.history.replaceState(
357-
null,
358-
"",
357+
const url = new URL(
359358
catalogDeepLink(selectedScreen.id, {
360359
flowId: ui.mode === "flows" ? activeFlow?.id : undefined,
361360
variantId: activeVariant.id,
362361
}),
363362
)
363+
if (window.location.hash.startsWith("#annotations=")) url.hash = window.location.hash
364+
window.history.replaceState(
365+
null,
366+
"",
367+
url,
368+
)
364369
}, [activeVariant.id, activeFlow?.id, selectedScreen, ui.mode, ui.viewerOpen])
365370

366371
useEffect(() => {
@@ -483,6 +488,7 @@ export function App({ catalog }: AppProps) {
483488
</main>
484489
{ui.viewerOpen && selectedScreen ? (
485490
<Viewer
491+
key={`${selectedScreen.id}:${activeVariant.id}`}
486492
screen={selectedScreen}
487493
identifier={
488494
ui.mode === "flows" && activeFlow?.replayable ? `${activeFlow.id}/${selectedScreen.id}` : selectedScreen.id
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
export interface Annotation {
2+
readonly id: string
3+
readonly row: number
4+
readonly column: number
5+
readonly note: string
6+
}
7+
8+
export interface AnnotationDocument {
9+
readonly version: 1
10+
readonly identifier: string
11+
readonly variant: string
12+
readonly annotations: ReadonlyArray<Annotation>
13+
}
14+
15+
const FragmentKey = "annotations"
16+
const MaxAnnotations = 24
17+
const MaxNoteLength = 2_000
18+
19+
export function annotationUrl(deepLink: string, document: AnnotationDocument) {
20+
const url = new URL(deepLink)
21+
url.hash = `${FragmentKey}=${encode(document)}`
22+
return url.href
23+
}
24+
25+
export function readAnnotations(url: URL, identifier: string, variant: string): ReadonlyArray<Annotation> {
26+
const params = new URLSearchParams(url.hash.slice(1))
27+
const encoded = params.get(FragmentKey)
28+
if (!encoded) return []
29+
const value = decode(encoded)
30+
if (!isDocument(value) || value.identifier !== identifier || value.variant !== variant) return []
31+
return value.annotations
32+
}
33+
34+
export function readAnnotationDraft(value: string): ReadonlyArray<Annotation> {
35+
try {
36+
const annotations: unknown = JSON.parse(value)
37+
return isAnnotations(annotations) ? annotations : []
38+
} catch {
39+
return []
40+
}
41+
}
42+
43+
function encode(value: AnnotationDocument) {
44+
const bytes = new TextEncoder().encode(JSON.stringify(value))
45+
return btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join(""))
46+
.replaceAll("+", "-")
47+
.replaceAll("/", "_")
48+
.replace(/=+$/, "")
49+
}
50+
51+
function decode(value: string): unknown {
52+
try {
53+
const binary = atob(value.replaceAll("-", "+").replaceAll("_", "/"))
54+
return JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0))))
55+
} catch {
56+
return undefined
57+
}
58+
}
59+
60+
function isDocument(value: unknown): value is AnnotationDocument {
61+
if (!value || typeof value !== "object") return false
62+
const document = value as Partial<AnnotationDocument>
63+
if (document.version !== 1 || typeof document.identifier !== "string" || typeof document.variant !== "string")
64+
return false
65+
return isAnnotations(document.annotations)
66+
}
67+
68+
function isAnnotations(value: unknown): value is ReadonlyArray<Annotation> {
69+
if (!Array.isArray(value) || value.length > MaxAnnotations) return false
70+
return value.every(
71+
(annotation) =>
72+
annotation &&
73+
typeof annotation === "object" &&
74+
typeof annotation.id === "string" &&
75+
Number.isInteger(annotation.row) &&
76+
annotation.row >= 0 &&
77+
Number.isInteger(annotation.column) &&
78+
annotation.column >= 0 &&
79+
typeof annotation.note === "string" &&
80+
annotation.note.length <= MaxNoteLength,
81+
)
82+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { useEffect, useRef, useState } from "react"
2+
import type { Annotation } from "../annotations"
3+
4+
interface AnnotationEditorProps {
5+
readonly cols: number
6+
readonly rows: number
7+
readonly annotations: ReadonlyArray<Annotation>
8+
readonly onAdd: (row: number, column: number, note: string) => void
9+
readonly onChange: (id: string, note: string) => void
10+
readonly onDelete: (id: string) => void
11+
readonly issueLink: string
12+
readonly onDone: () => void
13+
}
14+
15+
interface Draft {
16+
readonly id?: string
17+
readonly row: number
18+
readonly column: number
19+
readonly note: string
20+
}
21+
22+
export function AnnotationEditor(props: AnnotationEditorProps) {
23+
const [draft, setDraft] = useState<Draft>()
24+
const textareaRef = useRef<HTMLTextAreaElement>(null)
25+
const complete = props.annotations.filter((annotation) => annotation.note.trim() !== "")
26+
27+
useEffect(() => {
28+
if (!draft) return
29+
const frame = requestAnimationFrame(() => {
30+
textareaRef.current?.focus()
31+
textareaRef.current?.setSelectionRange(draft.note.length, draft.note.length)
32+
})
33+
return () => cancelAnimationFrame(frame)
34+
}, [draft?.id, draft?.row, draft?.column])
35+
36+
const save = () => {
37+
if (!draft?.note.trim()) return
38+
if (draft.id) props.onChange(draft.id, draft.note.trim())
39+
else props.onAdd(draft.row, draft.column, draft.note.trim())
40+
setDraft(undefined)
41+
}
42+
43+
const edit = (annotation: Annotation) =>
44+
setDraft({ id: annotation.id, row: annotation.row, column: annotation.column, note: annotation.note })
45+
46+
return (
47+
<>
48+
<div
49+
className="annotation-layer"
50+
aria-label="Click the terminal to add an annotation"
51+
onPointerDown={(event) => {
52+
if (event.target !== event.currentTarget) return
53+
const bounds = event.currentTarget.getBoundingClientRect()
54+
const column = Math.min(props.cols - 1, Math.max(0, Math.floor(((event.clientX - bounds.left) / bounds.width) * props.cols)))
55+
const row = Math.min(props.rows - 1, Math.max(0, Math.floor(((event.clientY - bounds.top) / bounds.height) * props.rows)))
56+
setDraft({ row, column, note: "" })
57+
}}
58+
>
59+
{props.annotations.map((annotation, index) => (
60+
<button
61+
key={annotation.id}
62+
type="button"
63+
className={`annotation-pin${annotation.id === draft?.id ? " selected" : ""}`}
64+
style={{
65+
left: `${((annotation.column + 0.5) / props.cols) * 100}%`,
66+
top: `${((annotation.row + 0.5) / props.rows) * 100}%`,
67+
}}
68+
aria-label={`Edit annotation ${index + 1}, row ${annotation.row + 1}, column ${annotation.column + 1}`}
69+
onPointerDown={(event) => event.stopPropagation()}
70+
onClick={() => edit(annotation)}
71+
>
72+
{index + 1}
73+
</button>
74+
))}
75+
{draft ? (
76+
<div
77+
className={`annotation-composer${draft.row > props.rows / 2 ? " above" : ""}`}
78+
style={{
79+
left: `clamp(9rem, ${((draft.column + 0.5) / props.cols) * 100}%, calc(100% - 9rem))`,
80+
top: `${((draft.row + 0.5) / props.rows) * 100}%`,
81+
}}
82+
onPointerDown={(event) => event.stopPropagation()}
83+
>
84+
<header>
85+
<span>{draft.id ? "Edit annotation" : "New annotation"}</span>
86+
<small>R{draft.row + 1} · C{draft.column + 1}</small>
87+
</header>
88+
<textarea
89+
ref={textareaRef}
90+
name="annotation-note"
91+
aria-label="Annotation note"
92+
value={draft.note}
93+
maxLength={2_000}
94+
rows={2}
95+
placeholder="What should change?"
96+
onChange={(event) => setDraft({ ...draft, note: event.target.value })}
97+
onKeyDown={(event) => {
98+
if (event.nativeEvent.isComposing) return
99+
if (event.key === "Enter" && !event.shiftKey) {
100+
event.preventDefault()
101+
save()
102+
}
103+
if (event.key === "Escape") {
104+
setDraft(undefined)
105+
}
106+
}}
107+
/>
108+
<footer>
109+
{draft.id ? (
110+
<button
111+
type="button"
112+
className="annotation-composer-delete"
113+
onClick={() => {
114+
if (draft.id) props.onDelete(draft.id)
115+
setDraft(undefined)
116+
}}
117+
>
118+
Delete
119+
</button>
120+
) : <span />}
121+
<button type="button" onClick={() => setDraft(undefined)}>Cancel</button>
122+
<button type="button" className="annotation-composer-save" disabled={!draft.note.trim()} onClick={save}>
123+
{draft.id ? "Save" : "Add"}
124+
</button>
125+
</footer>
126+
</div>
127+
) : undefined}
128+
</div>
129+
<aside className="annotation-panel" aria-label="Capture annotations">
130+
<header>
131+
<div>
132+
<strong>Annotations</strong>
133+
<span>{props.annotations.length === 0 ? "Click anywhere on the terminal" : `${props.annotations.length} placed`}</span>
134+
</div>
135+
<button type="button" onClick={props.onDone}>Done</button>
136+
</header>
137+
<div className="annotation-list">
138+
{props.annotations.map((annotation, index) => (
139+
<button key={annotation.id} type="button" className="annotation-list-row" onClick={() => edit(annotation)}>
140+
<span className="annotation-list-pin">{index + 1}</span>
141+
<span>
142+
<small>Row {annotation.row + 1} · Column {annotation.column + 1}</small>
143+
<strong>{annotation.note}</strong>
144+
</span>
145+
</button>
146+
))}
147+
</div>
148+
<footer>
149+
<a
150+
className="annotation-issue"
151+
href={complete.length === 0 ? undefined : props.issueLink}
152+
target="_blank"
153+
rel="noreferrer"
154+
aria-disabled={complete.length === 0}
155+
>
156+
Open GitHub issue
157+
</a>
158+
<span>{complete.length === 0 ? "Add a note to continue" : `${complete.length} note${complete.length === 1 ? "" : "s"} will be included`}</span>
159+
</footer>
160+
</aside>
161+
</>
162+
)
163+
}

‎packages/lab/catalog/src/components/CaptureSetSwitcher.tsx‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,22 @@ interface CaptureSetSwitcherProps {
88

99
export function CaptureSetSwitcher({ sets, active, onSelect }: CaptureSetSwitcherProps) {
1010
return (
11-
<label className="variant-switcher" title={active.label}>
12-
<span className="sr-only">Theme</span>
11+
<label className="variant-switcher" title="Switch theme">
1312
<select aria-label="Select theme" value={active.id} onChange={(event) => onSelect(event.target.value)}>
1413
{sets.map((set) => (
1514
<option key={set.id} value={set.id}>
1615
{set.label}
1716
</option>
1817
))}
1918
</select>
19+
<span className="variant-hint" aria-hidden="true">
20+
Theme
21+
</span>
22+
<span className="variant-name" aria-hidden="true">
23+
{active.label}
24+
</span>
2025
<span className="variant-chevron" aria-hidden="true">
21-
↓
26+
▾
2227
</span>
2328
</label>
2429
)

0 commit comments

Comments
 (0)