agent-canvas 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,8 @@
1
1
  import type { ReviewAnchor } from '../src/review/model'
2
+ import { browserOf } from './browser'
2
3
  import { contextOf, htmlOf } from './markup'
4
+ import { createdInLibrary } from './source'
5
+ import { styleOf } from './style'
3
6
 
4
7
  const MAX_TEXT = 120
5
8
  const QUOTE_CONTEXT = 40
@@ -10,6 +13,14 @@ function shorten(text: string, max = MAX_TEXT): string {
10
13
  return collapsed.length > max ? `${collapsed.slice(0, max - 1)}…` : collapsed
11
14
  }
12
15
 
16
+ /** The element's text with a space between text nodes: adjacent inline elements otherwise run their words together. */
17
+ function textOf(element: Element): string {
18
+ const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT)
19
+ const parts: string[] = []
20
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) parts.push(node.textContent ?? '')
21
+ return parts.join(' ')
22
+ }
23
+
13
24
  const IMPLICIT_ROLES: Record<string, string> = {
14
25
  button: 'button',
15
26
  select: 'combobox',
@@ -65,7 +76,8 @@ export function nameOf(element: Element): string | undefined {
65
76
  const role = roleOf(element)
66
77
  const namedByContent = role === 'button' || role === 'link' || role === 'heading' || role === 'cell' || role === 'columnheader' || role === 'tab'
67
78
  || role === 'menuitem' || role === 'option'
68
- if (namedByContent && element.textContent !== null && element.textContent.trim() !== '') return shorten(element.textContent)
79
+ const text = textOf(element)
80
+ if (namedByContent && text.trim() !== '') return shorten(text)
69
81
  return undefined
70
82
  }
71
83
 
@@ -114,8 +126,9 @@ export function componentsOf(element: Element): string[] {
114
126
  while (isRecord(fiber) && names.length < 40) {
115
127
  // Bundlers suffix a name that collides in their scope (`Card` → `Card13`); the source still says `Card`.
116
128
  const name = componentName(fiber['type'])?.replace(/(?<=[a-z])\d+$/, '')
117
- // Context providers wrap whole apps and say nothing about where the element is.
118
- if (name !== undefined && /^[A-Z]/.test(name) && !name.endsWith('Provider') && names[0] !== name) names.unshift(name)
129
+ // Context providers wrap whole apps and say nothing about where the element is; nor do a library's own wrappers.
130
+ const telling = name !== undefined && /^[A-Z]/.test(name) && !name.endsWith('Provider') && !createdInLibrary(fiber)
131
+ if (telling && names[0] !== name) names.unshift(name)
119
132
  fiber = fiber['return']
120
133
  }
121
134
  return names.slice(-MAX_COMPONENTS)
@@ -144,10 +157,12 @@ function quoteOf(element: Element, range: Range): ReviewAnchor['quote'] {
144
157
  export function describe(element: Element, url: string, range?: Range): ReviewAnchor {
145
158
  const role = roleOf(element)
146
159
  const name = nameOf(element)
147
- const text = element.textContent === null ? '' : shorten(element.textContent)
160
+ const text = shorten(textOf(element))
148
161
  const components = componentsOf(element)
149
162
  const quote = range === undefined ? undefined : quoteOf(element, range)
150
163
  const context = contextOf(element)
164
+ const view = element.ownerDocument.defaultView
165
+ const style = view === null ? '' : styleOf(view.getComputedStyle(element))
151
166
  return {
152
167
  url,
153
168
  selector: selectorOf(element),
@@ -157,6 +172,8 @@ export function describe(element: Element, url: string, range?: Range): ReviewAn
157
172
  ...(components.length === 0 ? {} : { components }),
158
173
  ...(quote === undefined ? {} : { quote }),
159
174
  ...(context === undefined ? {} : { context }),
175
+ ...(view === null ? {} : { browser: browserOf(view) }),
176
+ ...(style === '' ? {} : { style }),
160
177
  html: htmlOf(element),
161
178
  }
162
179
  }
@@ -0,0 +1,46 @@
1
+ import type { ReviewAnchor } from '../src/review/model'
2
+
3
+ type ReviewBrowser = NonNullable<ReviewAnchor['browser']>
4
+
5
+ const MAX_UNKNOWN = 100
6
+
7
+ /** Most specific first: Edge and Opera also name Chrome, and Chrome also names Safari. */
8
+ const BROWSERS: ReadonlyArray<readonly [string, RegExp]> = [
9
+ ['Edge', /\bEdg(?:e|A|iOS)?\/(\d+)/],
10
+ ['Opera', /\bOPR\/(\d+)/],
11
+ ['Firefox', /\b(?:Firefox|FxiOS)\/(\d+)/],
12
+ ['Chrome', /\b(?:Chrome|CriOS)\/(\d+)/],
13
+ ['Safari', /\bVersion\/(\d+)[^ ]* (?:Mobile\/\S+ )?Safari\//],
14
+ ]
15
+
16
+ /** Most specific first: Android also names Linux, and iOS names Mac OS X. */
17
+ const SYSTEMS: ReadonlyArray<readonly [string, RegExp]> = [
18
+ ['Android', /\bAndroid\b/],
19
+ ['iOS', /\b(?:iPhone|iPad|iPod)\b/],
20
+ ['Windows', /\bWindows\b/],
21
+ ['macOS', /\bMac OS X\b/],
22
+ ['Linux', /\bLinux\b/],
23
+ ]
24
+
25
+ /** Browser, major version and operating system, e.g. `Chrome 141 · Linux`; the rest of a user agent is noise. */
26
+ export function browserName(userAgent: string): string {
27
+ let browser: string | undefined
28
+ for (const [name, pattern] of BROWSERS) {
29
+ const version = pattern.exec(userAgent)?.[1]
30
+ if (version !== undefined) {
31
+ browser = `${name} ${version}`
32
+ break
33
+ }
34
+ }
35
+ const system = SYSTEMS.find(([, pattern]) => pattern.test(userAgent))?.[0]
36
+ const parts = [browser, system].filter((part) => part !== undefined)
37
+ return parts.length === 0 ? userAgent.slice(0, MAX_UNKNOWN) : parts.join(' · ')
38
+ }
39
+
40
+ export function browserOf(view: Window): ReviewBrowser {
41
+ return {
42
+ name: browserName(view.navigator.userAgent),
43
+ viewport: { width: view.innerWidth, height: view.innerHeight, scale: view.devicePixelRatio },
44
+ colorScheme: view.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
45
+ }
46
+ }
@@ -7,6 +7,7 @@ export interface ReviewEvents {
7
7
  threads(threads: readonly ReviewThread[]): void
8
8
  agentWatching(watching: boolean): void
9
9
  connected(connected: boolean): void
10
+ reload(path: string): void
10
11
  }
11
12
 
12
13
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -24,6 +25,7 @@ function readFrame(data: unknown): ReviewBrowserFrame | undefined {
24
25
  }
25
26
  if (!isRecord(frame)) return undefined
26
27
  if (frame['type'] === 'agent-status' && typeof frame['watching'] === 'boolean') return { type: 'agent-status', watching: frame['watching'] }
28
+ if (frame['type'] === 'reload' && typeof frame['path'] === 'string') return { type: 'reload', path: frame['path'] }
27
29
  if (frame['type'] !== 'threads') return undefined
28
30
  const parsed = threadListSchema.safeParse(frame)
29
31
  return parsed.success ? { type: 'threads', threads: parsed.data.threads } : undefined
@@ -51,6 +53,7 @@ export class ReviewClient {
51
53
  const frame = readFrame(event.data)
52
54
  if (frame?.type === 'threads') events.threads(frame.threads)
53
55
  if (frame?.type === 'agent-status') events.agentWatching(frame.watching)
56
+ if (frame?.type === 'reload') events.reload(frame.path)
54
57
  }
55
58
  socket.onclose = () => {
56
59
  events.connected(false)
@@ -75,6 +78,10 @@ export class ReviewClient {
75
78
  return this.request('POST', `/threads/${threadId}/send`)
76
79
  }
77
80
 
81
+ sendNotes(): Promise<unknown> {
82
+ return this.request('POST', '/notes/send')
83
+ }
84
+
78
85
  setResolved(threadId: string, resolved: boolean): Promise<unknown> {
79
86
  return this.request('POST', `/threads/${threadId}/${resolved ? 'resolve' : 'reopen'}`)
80
87
  }
@@ -1,5 +1,5 @@
1
1
  import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping'
2
- import type { ReviewSource } from '../src/review/model'
2
+ import { MAX_SOURCE_FILE, type ReviewSource } from '../src/review/model'
3
3
 
4
4
  /** A frame of a development stack, still in the bundle the page loaded. */
5
5
  export interface BundleFrame {
@@ -19,13 +19,20 @@ function isRecord(value: unknown): value is Record<string, unknown> {
19
19
  return typeof value === 'object' && value !== null
20
20
  }
21
21
 
22
+ /** React Refresh assigns an anonymous component to `_c`, `_c2`, …, which then becomes its name. */
23
+ const REFRESH_TEMPORARY = /^_c\d*$/
24
+
22
25
  function nameOf(type: unknown): string | undefined {
23
26
  if (typeof type === 'string') return type
24
- if (typeof type === 'function') return type.name === '' ? undefined : type.name.replace(/(?<=[a-z])\d+$/, '')
27
+ if (typeof type === 'function') {
28
+ return type.name === '' || REFRESH_TEMPORARY.test(type.name) ? undefined : type.name.replace(/(?<=[a-z])\d+$/, '')
29
+ }
25
30
  if (isRecord(type)) return nameOf(type['type']) ?? nameOf(type['render'])
26
31
  return undefined
27
32
  }
28
33
 
34
+ const LIBRARY = /\/node_modules\//
35
+
29
36
  /** The first application frame of a stack: where the JSX was written. */
30
37
  function creationFrame(stack: string): Omit<BundleFrame, 'label'> | undefined {
31
38
  for (const line of stack.split('\n').slice(1)) {
@@ -54,13 +61,22 @@ export function bundleFrames(element: Element): BundleFrame[] {
54
61
  const stack = isRecord(fiber['_debugStack']) ? fiber['_debugStack']['stack'] : undefined
55
62
  if (typeof stack !== 'string') continue
56
63
  const frame = creationFrame(stack)
57
- if (frame === undefined) continue
64
+ // An element a dependency created is not the reviewer's code to change.
65
+ if (frame === undefined || LIBRARY.test(frame.url)) continue
58
66
  const owner = isRecord(fiber['_debugOwner']) ? nameOf(fiber['_debugOwner']['type']) : undefined
59
67
  frames.push({ label: owner === undefined ? `<${name}>` : `<${name}> in ${owner}`, ...frame })
60
68
  }
61
69
  return frames
62
70
  }
63
71
 
72
+ /** Whether a dependency, not the application, created the fiber's element; unknown without a development stack. */
73
+ export function createdInLibrary(fiber: Record<string, unknown>): boolean {
74
+ const stack = isRecord(fiber['_debugStack']) ? fiber['_debugStack']['stack'] : undefined
75
+ if (typeof stack !== 'string') return false
76
+ const frame = creationFrame(stack)
77
+ return frame !== undefined && LIBRARY.test(frame.url)
78
+ }
79
+
64
80
  const traceMaps = new Map<string, Promise<TraceMap | null>>()
65
81
 
66
82
  async function loadTraceMap(scriptUrl: string): Promise<TraceMap | null> {
@@ -70,7 +86,17 @@ async function loadTraceMap(scriptUrl: string): Promise<TraceMap | null> {
70
86
  const mapUrl = new URL(reference, scriptUrl).href
71
87
  const map: unknown = await fetch(mapUrl).then((response) => response.json())
72
88
  if (!isRecord(map) || map['version'] !== 3) return null
73
- return new TraceMap(JSON.stringify(map), mapUrl)
89
+ return new TraceMap(JSON.stringify(map), sourcesBase(map, mapUrl, scriptUrl))
90
+ }
91
+
92
+ /**
93
+ * The URL a map's relative `sources` resolve against. An inline `data:` map has no location of its own: Vite names
94
+ * the module's absolute path in `file`; otherwise the sources sit next to the script.
95
+ */
96
+ function sourcesBase(map: Record<string, unknown>, mapUrl: string, scriptUrl: string): string {
97
+ if (!mapUrl.startsWith('data:')) return mapUrl
98
+ const file = map['file']
99
+ return typeof file === 'string' && file.startsWith('/') ? new URL(file, 'file:///').href : scriptUrl
74
100
  }
75
101
 
76
102
  function traceMapFor(scriptUrl: string): Promise<TraceMap | null> {
@@ -82,8 +108,15 @@ function traceMapFor(scriptUrl: string): Promise<TraceMap | null> {
82
108
  return map
83
109
  }
84
110
 
85
- function sourcePath(source: string): string {
86
- return source.startsWith('file://') ? decodeURIComponent(new URL(source).pathname) : source
111
+ /** A file path when the map names one; a source on the dev server becomes its path from the server's root. */
112
+ function sourcePath(source: string, scriptUrl: string): string {
113
+ if (!URL.canParse(source)) return source
114
+ const url = new URL(source)
115
+ if (url.protocol === 'file:') return decodeURIComponent(url.pathname)
116
+ if (url.origin !== new URL(scriptUrl).origin) return source
117
+ const path = decodeURIComponent(url.pathname)
118
+ // Vite serves files outside its root under `/@fs/<absolute path>`.
119
+ return path.startsWith('/@fs/') ? path.slice('/@fs'.length) : path.slice(1)
87
120
  }
88
121
 
89
122
  async function resolveFrame(frame: BundleFrame): Promise<ReviewSource | null> {
@@ -92,7 +125,10 @@ async function resolveFrame(frame: BundleFrame): Promise<ReviewSource | null> {
92
125
  // Stack columns count from 1, source map columns from 0.
93
126
  const position = originalPositionFor(map, { line: frame.line, column: frame.column - 1 })
94
127
  if (position.source === null || position.line === null) return null
95
- return { label: frame.label, file: sourcePath(position.source), line: position.line, column: position.column + 1 }
128
+ const file = sourcePath(position.source, frame.url)
129
+ // One unusable source must not fail the whole thread against the schema.
130
+ if (file.length > MAX_SOURCE_FILE) return null
131
+ return { label: frame.label, file, line: position.line, column: position.column + 1 }
96
132
  }
97
133
 
98
134
  /** Source positions of the frames, through the bundles' source maps; frames a map does not cover are left out. */
@@ -0,0 +1,51 @@
1
+ import { MAX_STYLE } from '../src/review/model'
2
+
3
+ type ComputedStyle = Pick<CSSStyleDeclaration, 'getPropertyValue'>
4
+
5
+ const TRANSPARENT = 'rgba(0, 0, 0, 0)'
6
+
7
+ /** Four sides in CSS shorthand order, collapsed the way a person writes them: `8px`, `8px 12px`, … */
8
+ function sides(style: ComputedStyle, property: 'margin' | 'padding'): string {
9
+ const [top, right, bottom, left] = ['top', 'right', 'bottom', 'left'].map((side) => style.getPropertyValue(`${property}-${side}`))
10
+ if (top === right && right === bottom && bottom === left) return top ?? ''
11
+ if (top === bottom && right === left) return `${top} ${right}`
12
+ if (right === left) return `${top} ${right} ${bottom}`
13
+ return `${top} ${right} ${bottom} ${left}`
14
+ }
15
+
16
+ function gapOf(style: ComputedStyle): string {
17
+ const row = style.getPropertyValue('row-gap')
18
+ const column = style.getPropertyValue('column-gap')
19
+ return row === column ? row : `${row} ${column}`
20
+ }
21
+
22
+ /**
23
+ * The computed values that explain an element's box, layout and type, as CSS declarations. Initial values are left
24
+ * out; flex and grid properties only appear on flex and grid containers.
25
+ */
26
+ export function styleOf(style: ComputedStyle): string {
27
+ const display = style.getPropertyValue('display')
28
+ const flex = display.endsWith('flex')
29
+ const grid = display.endsWith('grid')
30
+ const declarations: Array<readonly [string, string, boolean]> = [
31
+ ['display', display, true],
32
+ ['position', style.getPropertyValue('position'), style.getPropertyValue('position') !== 'static'],
33
+ ['flex-direction', style.getPropertyValue('flex-direction'), flex],
34
+ ['flex-wrap', style.getPropertyValue('flex-wrap'), flex && style.getPropertyValue('flex-wrap') !== 'nowrap'],
35
+ ['grid-template-columns', style.getPropertyValue('grid-template-columns'), grid],
36
+ ['align-items', style.getPropertyValue('align-items'), flex || grid],
37
+ ['justify-content', style.getPropertyValue('justify-content'), flex || grid],
38
+ ['gap', gapOf(style), (flex || grid) && gapOf(style) !== 'normal'],
39
+ ['width', style.getPropertyValue('width'), true],
40
+ ['height', style.getPropertyValue('height'), true],
41
+ ['margin', sides(style, 'margin'), sides(style, 'margin') !== '0px'],
42
+ ['padding', sides(style, 'padding'), sides(style, 'padding') !== '0px'],
43
+ ['font-size', style.getPropertyValue('font-size'), true],
44
+ ['line-height', style.getPropertyValue('line-height'), true],
45
+ ['font-weight', style.getPropertyValue('font-weight'), true],
46
+ ['color', style.getPropertyValue('color'), true],
47
+ ['background-color', style.getPropertyValue('background-color'), style.getPropertyValue('background-color') !== TRANSPARENT],
48
+ ]
49
+ const text = declarations.filter(([, value, shown]) => shown && value !== '').map(([property, value]) => `${property}: ${value}`).join('; ')
50
+ return text.length > MAX_STYLE ? `${text.slice(0, MAX_STYLE - 1)}…` : text
51
+ }
@@ -268,6 +268,14 @@ textarea:focus {
268
268
  border-bottom: 1px solid var(--border);
269
269
  }
270
270
 
271
+ .send-notes {
272
+ padding: 0 12px 8px;
273
+ }
274
+
275
+ .send-notes button {
276
+ width: 100%;
277
+ }
278
+
271
279
  .tabs button.active {
272
280
  background: var(--bg-muted);
273
281
  font-weight: 600;
@@ -297,6 +305,10 @@ textarea:focus {
297
305
  gap: 8px;
298
306
  }
299
307
 
308
+ .thread.resolved {
309
+ opacity: 0.65;
310
+ }
311
+
300
312
  .thread.current {
301
313
  border-color: var(--accent);
302
314
  }
@@ -357,3 +369,21 @@ textarea:focus {
357
369
  color: var(--warn);
358
370
  font-size: 12px;
359
371
  }
372
+
373
+ .reload-pending {
374
+ display: flex;
375
+ align-items: center;
376
+ gap: 8px;
377
+ padding: 6px 8px;
378
+ border-radius: 6px;
379
+ background: var(--bg-muted);
380
+ font-size: 12px;
381
+ }
382
+
383
+ .reload-pending button {
384
+ flex: none;
385
+ }
386
+
387
+ .panel > .error {
388
+ padding: 0 12px 8px;
389
+ }
@@ -12,5 +12,6 @@
12
12
  "noEmit": true,
13
13
  "types": []
14
14
  },
15
- "include": ["**/*.ts", "**/*.tsx", "../src/review/model.ts"]
15
+ "include": ["**/*.ts", "**/*.tsx", "../src/review/model.ts"],
16
+ "exclude": ["**/*.test.ts"]
16
17
  }
@@ -1,5 +1,13 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
2
- import type { ReviewAnchor, ReviewSource, ReviewThread, ReviewThreadState } from '../src/review/model'
2
+ import { z } from 'zod'
3
+ import {
4
+ isUnsentNote,
5
+ type ReviewAnchor,
6
+ reviewAnchorSchema,
7
+ type ReviewSource,
8
+ type ReviewThread,
9
+ type ReviewThreadState,
10
+ } from '../src/review/model'
3
11
  import { describe, find } from './anchor'
4
12
  import type { ReviewClient } from './client'
5
13
  import { captureElement } from './screenshot'
@@ -19,6 +27,32 @@ interface Draft {
19
27
  element: Element
20
28
  /** Resolved while the reviewer writes: source maps can take a moment to load. */
21
29
  sources: Promise<ReviewSource[]>
30
+ body: string
31
+ screenshot: boolean
32
+ }
33
+
34
+ /** The comment being written, kept for the tab: a dev server reloads the page on many edits. */
35
+ const savedDraftSchema = z.object({ anchor: reviewAnchorSchema, body: z.string(), screenshot: z.boolean() })
36
+ type SavedDraft = z.infer<typeof savedDraftSchema>
37
+
38
+ const DRAFT_KEY = 'agent-canvas-review:draft'
39
+
40
+ function saveDraft(draft: SavedDraft | null): void {
41
+ try {
42
+ if (draft === null) sessionStorage.removeItem(DRAFT_KEY)
43
+ else sessionStorage.setItem(DRAFT_KEY, JSON.stringify(draft))
44
+ } catch {}
45
+ }
46
+
47
+ function loadDraft(): SavedDraft | null {
48
+ try {
49
+ const saved = sessionStorage.getItem(DRAFT_KEY)
50
+ if (saved === null) return null
51
+ const parsed = savedDraftSchema.safeParse(JSON.parse(saved))
52
+ return parsed.success ? parsed.data : null
53
+ } catch {
54
+ return null
55
+ }
22
56
  }
23
57
 
24
58
  const STATE_LABELS: Record<ReviewThreadState, string> = {
@@ -57,6 +91,13 @@ function takePendingReveal(): string | null {
57
91
  }
58
92
  }
59
93
 
94
+ /** Vite reloads every page for a module change, and for an HTML change only the page that file serves. */
95
+ function reloadsThisPage(path: string): boolean {
96
+ if (!path.endsWith('.html')) return true
97
+ const page = decodeURI(location.pathname)
98
+ return path === '/index.html' || page === path || (page.endsWith('/') && `${page}index.html` === path)
99
+ }
100
+
60
101
  function boxOf(element: Element): Box {
61
102
  const rect = element.getBoundingClientRect()
62
103
  return { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
@@ -179,9 +220,17 @@ function popoverPosition(box: Box): { top: number; left: number } {
179
220
  return { top, left }
180
221
  }
181
222
 
182
- function Composer(props: { draft: Draft; onSubmit: (body: string, send: boolean, screenshot: boolean) => Promise<void>; onCancel: () => void }) {
183
- const [body, setBody] = useState('')
184
- const [screenshot, setScreenshot] = useState(false)
223
+ function Composer(props: {
224
+ draft: Draft
225
+ reloadPending: boolean
226
+ onChange: (body: string, screenshot: boolean) => void
227
+ onSubmit: (body: string, send: boolean, screenshot: boolean) => Promise<void>
228
+ onCancel: () => void
229
+ }) {
230
+ const [body, setBody] = useState(props.draft.body)
231
+ const [screenshot, setScreenshot] = useState(props.draft.screenshot)
232
+ const { onChange } = props
233
+ useEffect(() => onChange(body, screenshot), [onChange, body, screenshot])
185
234
  const [busy, setBusy] = useState(false)
186
235
  const [error, setError] = useState<string | null>(null)
187
236
  const textarea = useRef<HTMLTextAreaElement>(null)
@@ -217,6 +266,12 @@ function Composer(props: { draft: Draft; onSubmit: (body: string, send: boolean,
217
266
  <input type="checkbox" checked={screenshot} onChange={(event) => setScreenshot(event.currentTarget.checked)} />
218
267
  Attach a screenshot
219
268
  </label>
269
+ {props.reloadPending && (
270
+ <div class="reload-pending">
271
+ <span>The application changed. It reloads once you save or cancel this comment.</span>
272
+ <button type="button" class="ghost" onClick={() => location.reload()}>Reload now</button>
273
+ </div>
274
+ )}
220
275
  {error !== null && <div class="error">{error}</div>}
221
276
  <div class="actions">
222
277
  <button type="button" class="ghost" onClick={props.onCancel}>Cancel</button>
@@ -242,7 +297,7 @@ function ThreadCard(props: { thread: ReviewThread; number: number | null; client
242
297
  }
243
298
 
244
299
  return (
245
- <div class={`thread${props.number === null ? '' : ' current'}`}>
300
+ <div class={`thread${props.number === null ? '' : ' current'}${thread.state === 'resolved' ? ' resolved' : ''}`}>
246
301
  <div class="thread-head">
247
302
  <button type="button" class="ghost" title="Show in page" aria-label="Show in page" onClick={props.onReveal}>
248
303
  {props.number === null ? '↗' : `#${props.number}`}
@@ -299,16 +354,26 @@ export function ReviewWidget(props: { client: ReviewClient; host: HTMLElement })
299
354
  const [picking, setPicking] = useState(false)
300
355
  const [hovered, setHovered] = useState<Element | null>(null)
301
356
  const [draft, setDraft] = useState<Draft | null>(null)
357
+ const [unrestored, setUnrestored] = useState<SavedDraft | null>(loadDraft)
358
+ const [reloadPending, setReloadPending] = useState(false)
302
359
  const [panelOpen, setPanelOpen] = useState(false)
303
360
  const [showAll, setShowAll] = useState(false)
304
- const [showResolved, setShowResolved] = useState(false)
361
+ const [hideResolved, setHideResolved] = useState(false)
362
+ const [panelError, setPanelError] = useState<string | null>(null)
305
363
  const [revealId, setRevealId] = useState<string | null>(takePendingReveal)
306
364
  const [flash, setFlash] = useState<Box | null>(null)
307
365
  const href = useHref()
308
- useLayoutTick()
366
+ const tick = useLayoutTick()
309
367
 
310
368
  useEffect(() => {
311
- client.connect({ threads: setThreads, agentWatching: setWatching, connected: setConnected })
369
+ client.connect({
370
+ threads: setThreads,
371
+ agentWatching: setWatching,
372
+ connected: setConnected,
373
+ reload: (path) => {
374
+ if (reloadsThisPage(path)) setReloadPending(true)
375
+ },
376
+ })
312
377
  return () => client.close()
313
378
  }, [client])
314
379
 
@@ -320,24 +385,39 @@ export function ReviewWidget(props: { client: ReviewClient; host: HTMLElement })
320
385
  setPicking(false)
321
386
  setHovered(null)
322
387
  window.getSelection()?.removeAllRanges()
323
- setDraft({ anchor: describe(element, location.href, range), box: boxOf(element), element, sources: resolveSources(bundleFrames(element)) })
388
+ setUnrestored(null)
389
+ const anchor = describe(element, location.href, range)
390
+ setDraft({ anchor, box: boxOf(element), element, sources: resolveSources(bundleFrames(element)), body: '', screenshot: false })
324
391
  }, [])
392
+ const discardDraft = useCallback(() => {
393
+ setDraft(null)
394
+ saveDraft(null)
395
+ }, [])
396
+ const anchor = draft?.anchor
397
+ const keepDraft = useCallback((body: string, screenshot: boolean) => {
398
+ if (anchor !== undefined) saveDraft({ anchor, body, screenshot })
399
+ }, [anchor])
325
400
  usePicker(host, picking, setHovered, pick, cancelPicking)
326
401
  // The panel covers the right edge of the page, which the reviewer may want to pick from.
327
402
  const startPicking = () => {
328
403
  setPanelOpen(false)
329
- setDraft(null)
404
+ discardDraft()
330
405
  setPicking(true)
331
406
  }
332
407
 
333
408
  const page = pageKey(href)
334
409
  const onPage = useMemo(
335
- () => threads.filter((thread) => pageKey(thread.anchor.url) === page && (showResolved || thread.state !== 'resolved')),
336
- [threads, page, showResolved],
410
+ () => threads.filter((thread) => pageKey(thread.anchor.url) === page && (!hideResolved || thread.state !== 'resolved')),
411
+ [threads, page, hideResolved],
337
412
  )
338
- const listed = showAll ? threads.filter((thread) => showResolved || thread.state !== 'resolved') : onPage
413
+ const listed = showAll ? threads.filter((thread) => !hideResolved || thread.state !== 'resolved') : onPage
339
414
  const numbers = new Map(onPage.map((thread, index) => [thread.id, index + 1]))
340
415
  const pending = threads.filter((thread) => thread.state !== 'resolved').length
416
+ const notes = threads.filter(isUnsentNote).length
417
+ const sendNotes = () => {
418
+ setPanelError(null)
419
+ client.sendNotes().catch((failure: unknown) => setPanelError(failure instanceof Error ? failure.message : String(failure)))
420
+ }
341
421
 
342
422
  useEffect(() => {
343
423
  if (revealId === null) return
@@ -351,6 +431,20 @@ export function ReviewWidget(props: { client: ReviewClient; host: HTMLElement })
351
431
  setTimeout(() => setFlash(null), 1600)
352
432
  }, [revealId, threads, page])
353
433
 
434
+ // The draft survives the reload in sessionStorage; held back only so the reviewer can finish writing.
435
+ useEffect(() => {
436
+ if (reloadPending && draft === null) location.reload()
437
+ }, [reloadPending, draft])
438
+
439
+ // The application may render the element a moment after the reload.
440
+ useEffect(() => {
441
+ if (unrestored === null || draft !== null || pageKey(unrestored.anchor.url) !== page) return
442
+ const element = find(document, unrestored.anchor)
443
+ if (element === null) return
444
+ setUnrestored(null)
445
+ setDraft({ ...unrestored, box: boxOf(element), element, sources: resolveSources(bundleFrames(element)) })
446
+ }, [unrestored, draft, page, tick])
447
+
354
448
  const reveal = (thread: ReviewThread) => {
355
449
  setRevealId(thread.id)
356
450
  if (pageKey(thread.anchor.url) !== page) navigate(thread.anchor.url, thread.id)
@@ -384,13 +478,15 @@ export function ReviewWidget(props: { client: ReviewClient; host: HTMLElement })
384
478
  {draft !== null && (
385
479
  <Composer
386
480
  draft={draft}
387
- onCancel={() => setDraft(null)}
481
+ reloadPending={reloadPending}
482
+ onChange={keepDraft}
483
+ onCancel={discardDraft}
388
484
  onSubmit={async (body, send, screenshot) => {
389
485
  // The capture asks for the tab, which the browser allows only while the click still counts as a gesture.
390
486
  const image = screenshot ? await captureElement(draft.element, host) : undefined
391
487
  const sources = await draft.sources
392
488
  await client.createThread(sources.length === 0 ? draft.anchor : { ...draft.anchor, sources }, body, send, image)
393
- setDraft(null)
489
+ discardDraft()
394
490
  }}
395
491
  />
396
492
  )}
@@ -403,8 +499,16 @@ export function ReviewWidget(props: { client: ReviewClient; host: HTMLElement })
403
499
  <div class="tabs">
404
500
  <button type="button" class={showAll ? '' : 'active'} onClick={() => setShowAll(false)}>This page</button>
405
501
  <button type="button" class={showAll ? 'active' : ''} onClick={() => setShowAll(true)}>All pages</button>
406
- <button type="button" class={showResolved ? 'active' : ''} onClick={() => setShowResolved(!showResolved)}>Resolved</button>
502
+ <button type="button" class={hideResolved ? 'active' : ''} onClick={() => setHideResolved(!hideResolved)}>Hide resolved</button>
407
503
  </div>
504
+ {notes > 0 && (
505
+ <div class="send-notes">
506
+ <button type="button" class="primary" onClick={sendNotes}>
507
+ Send {notes === 1 ? '1 note' : `${notes} notes`} to Claude
508
+ </button>
509
+ </div>
510
+ )}
511
+ {panelError !== null && <div class="error">{panelError}</div>}
408
512
  <div class="threads">
409
513
  {listed.length === 0 && <div class="empty">No comments yet. Click “Comment”, then an element or a text selection.</div>}
410
514
  {listed.map((thread) => (
@@ -19,9 +19,14 @@ function describeAnchor(anchor: ReviewAnchor, screenshot: string | undefined): s
19
19
  }
20
20
  if (anchor.quote !== undefined) {
21
21
  lines.push(`- Selected text: …${anchor.quote.before}**${anchor.quote.exact}**${anchor.quote.after}…`)
22
- } else if (anchor.text !== undefined && anchor.text !== '') {
22
+ } else if (anchor.text !== undefined && anchor.text !== '' && anchor.text !== anchor.name) {
23
23
  lines.push(`- Text: "${anchor.text}"`)
24
24
  }
25
+ if (anchor.browser !== undefined) {
26
+ const { name, viewport, colorScheme } = anchor.browser
27
+ lines.push(`- Browser: ${name}, viewport ${viewport.width}×${viewport.height} @${viewport.scale}x, ${colorScheme} color scheme`)
28
+ }
29
+ if (anchor.style !== undefined) lines.push(`- Computed style: \`${anchor.style}\``)
25
30
  if (screenshot !== undefined) lines.push(`- Screenshot, the element outlined in red: \`${screenshot}\` (read the file to see it)`)
26
31
  if (anchor.html !== undefined) lines.push('- Markup, trimmed:', '', fence('html', anchor.html))
27
32
  return lines
@@ -25,12 +25,14 @@ export function isLoopbackOrigin(origin: string): boolean {
25
25
  const shortText = z.string().max(500)
26
26
 
27
27
  export const MAX_HTML = 4000
28
+ export const MAX_SOURCE_FILE = 1000
29
+ export const MAX_STYLE = 1000
28
30
 
29
31
  /** A position in the application's source, resolved from a development stack through the page's source maps. */
30
32
  export const reviewSourceSchema = z.object({
31
33
  /** What was created there, e.g. `<a> in ProductListCell` or `<ProductListCell> in DataGrid`. */
32
34
  label: shortText,
33
- file: z.string().max(1000),
35
+ file: z.string().max(MAX_SOURCE_FILE),
34
36
  line: z.number().int().positive(),
35
37
  column: z.number().int().nonnegative(),
36
38
  })
@@ -53,6 +55,14 @@ export const reviewAnchorSchema = z.object({
53
55
  sources: z.array(reviewSourceSchema).max(10).optional(),
54
56
  /** The landmarks around the element: page heading, section, dialog. */
55
57
  context: shortText.optional(),
58
+ /** The reviewer's browser and operating system, e.g. `Chrome 141 · Linux`, viewport and colour scheme. */
59
+ browser: z.object({
60
+ name: shortText,
61
+ viewport: z.object({ width: z.number().int().nonnegative(), height: z.number().int().nonnegative(), scale: z.number().positive() }),
62
+ colorScheme: z.enum(['light', 'dark']),
63
+ }).optional(),
64
+ /** The element's computed box, layout and type as CSS declarations, initial values left out. */
65
+ style: z.string().max(MAX_STYLE).optional(),
56
66
  /** The element's markup, trimmed: deep subtrees, long lists and long texts are cut. */
57
67
  html: z.string().max(MAX_HTML).optional(),
58
68
  /** A text selection inside the element, with its surroundings. */
@@ -92,6 +102,11 @@ export const reviewThreadSchema = z.object({
92
102
  })
93
103
  export type ReviewThread = z.infer<typeof reviewThreadSchema>
94
104
 
105
+ /** A note the person saved and has not sent: open, with their own comment last rather than the agent's answer. */
106
+ export function isUnsentNote(thread: ReviewThread): boolean {
107
+ return thread.state === 'open' && thread.comments.at(-1)?.author === 'user'
108
+ }
109
+
95
110
  export const reviewThreadsFileSchema = z.object({ threads: z.array(reviewThreadSchema) })
96
111
 
97
112
  const commentBody = z.string().trim().min(1).max(20_000)
@@ -125,6 +140,8 @@ export type ReviewProxy = z.infer<typeof reviewProxySchema>
125
140
  export type ReviewBrowserFrame =
126
141
  | { type: 'threads'; threads: readonly ReviewThread[] }
127
142
  | { type: 'agent-status'; watching: boolean }
143
+ /** The application's dev server asked for a full reload; the widget holds it while the reviewer writes. */
144
+ | { type: 'reload'; path: string }
128
145
 
129
146
  /** The frame a waiting CLI receives when the person sends threads; it reads them back through the claim endpoint. */
130
147
  export interface ReviewWaitFrame {