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.
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/review.js +33 -3
- package/daemon/review-widget/anchor.ts +21 -4
- package/daemon/review-widget/browser.ts +46 -0
- package/daemon/review-widget/client.ts +7 -0
- package/daemon/review-widget/source.ts +43 -7
- package/daemon/review-widget/style.ts +51 -0
- package/daemon/review-widget/styles.css +30 -0
- package/daemon/review-widget/tsconfig.json +2 -1
- package/daemon/review-widget/widget.tsx +120 -16
- package/daemon/src/review/markdown.ts +6 -1
- package/daemon/src/review/model.ts +18 -1
- package/daemon/src/review/proxy.ts +30 -3
- package/daemon/src/review/server.ts +14 -0
- package/daemon/src/review/store.ts +11 -0
- package/daemon/src/server.ts +1 -1
- package/daemon/tsconfig.json +1 -1
- package/package.json +4 -4
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
2
|
-
import
|
|
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: {
|
|
183
|
-
|
|
184
|
-
|
|
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 [
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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 && (
|
|
336
|
-
[threads, page,
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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(
|
|
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 {
|