agent-canvas 0.2.1 → 0.6.0
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/README.md +42 -17
- package/bin/agent-canvas.js +7 -7
- package/bin/agent-canvas.ts +48 -37
- package/bin/lib/commands/daemon.ts +46 -43
- package/bin/lib/commands/exec.ts +100 -100
- package/bin/lib/commands/fetch.ts +15 -15
- package/bin/lib/commands/install.ts +36 -36
- package/bin/lib/commands/instructions.ts +51 -51
- package/bin/lib/commands/push.ts +115 -97
- package/bin/lib/commands/review.ts +190 -0
- package/bin/lib/commands/watch.ts +24 -24
- package/bin/lib/config.ts +12 -12
- package/bin/lib/daemon-lifecycle.ts +47 -51
- package/bin/lib/helpers.ts +82 -76
- package/bin/lib/instructions/app-review.md +88 -0
- package/bin/lib/instructions/component-checklist.md +7 -5
- package/bin/lib/instructions/component-custom.md +20 -14
- package/bin/lib/instructions/component-diff.md +4 -3
- package/bin/lib/instructions/component-item.md +18 -8
- package/bin/lib/instructions/component-markdown.md +4 -2
- package/bin/lib/instructions/component-mermaid.md +12 -6
- package/bin/lib/instructions/component-multichoice.md +6 -2
- package/bin/lib/instructions/component-note.md +1 -1
- package/bin/lib/instructions/component-priority.md +1 -1
- package/bin/lib/instructions/component-rangeinput.md +9 -2
- package/bin/lib/instructions/component-secretinput.md +12 -11
- package/bin/lib/instructions/component-svg.md +62 -16
- package/bin/lib/instructions/component-table.md +6 -6
- package/bin/lib/instructions/component-usefeedback.md +8 -8
- package/bin/lib/instructions/component-userinput.md +6 -2
- package/bin/lib/instructions/flow-feature.md +22 -5
- package/bin/lib/instructions/flow-plan.md +5 -5
- package/bin/lib/instructions/flow-runbook.md +28 -25
- package/bin/lib/instructions/overview.md +27 -18
- package/bin/lib/instructions/theming.md +5 -0
- package/daemon/build.ts +171 -152
- package/daemon/client/App.tsx +1205 -1077
- package/daemon/client/CompareView.tsx +478 -458
- package/daemon/client/FileBrowser.tsx +241 -203
- package/daemon/client/FileViewer.tsx +220 -211
- package/daemon/client/OverviewView.tsx +283 -267
- package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
- package/daemon/client/RevisionSelect.tsx +111 -95
- package/daemon/client/SessionSwitcher.tsx +81 -81
- package/daemon/client/ShareDialog.tsx +211 -212
- package/daemon/client/apiSchemas.ts +78 -0
- package/daemon/client/canvas-runtime.ts +1 -1
- package/daemon/client/clientApi.ts +194 -206
- package/daemon/client/components/SecretInput.tsx +138 -138
- package/daemon/client/components/index.ts +2 -2
- package/daemon/client/domDiff.ts +341 -333
- package/daemon/client/errorMessage.ts +3 -0
- package/daemon/client/exportMarkdown.ts +222 -201
- package/daemon/client/lcs.ts +19 -14
- package/daemon/client/shareCrypto.ts +57 -57
- package/daemon/client/unifiedDiff.ts +660 -649
- package/daemon/dist/annotations-runtime.js +1 -1
- package/daemon/dist/client.css +2 -2
- package/daemon/dist/client.js +77 -80
- package/daemon/dist/components.js +59 -59
- package/daemon/dist/preact-compat.js +1 -1
- package/daemon/dist/review.js +361 -0
- package/daemon/dist/runtime.js +1 -1
- package/daemon/langMap.ts +24 -6
- package/daemon/review-widget/anchor.ts +187 -0
- package/daemon/review-widget/client.ts +95 -0
- package/daemon/review-widget/index.tsx +36 -0
- package/daemon/review-widget/markup.ts +71 -0
- package/daemon/review-widget/screenshot.ts +91 -0
- package/daemon/review-widget/source.ts +103 -0
- package/daemon/review-widget/styles.css +359 -0
- package/daemon/review-widget/text-modules.d.ts +4 -0
- package/daemon/review-widget/tsconfig.json +16 -0
- package/daemon/review-widget/widget.tsx +436 -0
- package/daemon/src/components.ts +3 -3
- package/daemon/src/handlers/api.ts +330 -328
- package/daemon/src/handlers/files.ts +112 -106
- package/daemon/src/handlers/static.ts +47 -46
- package/daemon/src/handlers/uploads.ts +61 -56
- package/daemon/src/paths.ts +13 -10
- package/daemon/src/remote-feedback.ts +192 -192
- package/daemon/src/review/markdown.ts +52 -0
- package/daemon/src/review/model.ts +132 -0
- package/daemon/src/review/proxy.ts +177 -0
- package/daemon/src/review/server.ts +199 -0
- package/daemon/src/review/store.ts +151 -0
- package/daemon/src/server.ts +127 -86
- package/daemon/src/share.ts +163 -169
- package/daemon/src/stubFetch.ts +7 -0
- package/daemon/tsconfig.json +19 -19
- package/package.json +47 -47
- package/skills/canvas/SKILL.md +5 -1
- package/bin/lib/commands/exec.test.ts +0 -27
- package/bin/lib/helpers.test.ts +0 -79
- package/daemon/src/files-api.test.ts +0 -78
- package/daemon/src/remote-feedback.test.ts +0 -165
- package/daemon/src/secrets-api.test.ts +0 -123
- package/daemon/src/share-e2e.test.ts +0 -277
- package/daemon/src/share.test.ts +0 -289
package/daemon/langMap.ts
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
/** File extension → highlight.js language name mapping. */
|
|
2
2
|
export const LANG_MAP: Record<string, string> = {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
ts: 'typescript',
|
|
4
|
+
tsx: 'typescript',
|
|
5
|
+
js: 'javascript',
|
|
6
|
+
jsx: 'javascript',
|
|
7
|
+
py: 'python',
|
|
8
|
+
rs: 'rust',
|
|
9
|
+
go: 'go',
|
|
10
|
+
rb: 'ruby',
|
|
11
|
+
java: 'java',
|
|
12
|
+
json: 'json',
|
|
13
|
+
yaml: 'yaml',
|
|
14
|
+
yml: 'yaml',
|
|
15
|
+
md: 'markdown',
|
|
16
|
+
css: 'css',
|
|
17
|
+
html: 'html',
|
|
18
|
+
sh: 'bash',
|
|
19
|
+
bash: 'bash',
|
|
20
|
+
toml: 'toml',
|
|
21
|
+
sql: 'sql',
|
|
22
|
+
xml: 'xml',
|
|
23
|
+
c: 'c',
|
|
24
|
+
cpp: 'cpp',
|
|
25
|
+
h: 'c',
|
|
26
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { ReviewAnchor } from '../src/review/model'
|
|
2
|
+
import { contextOf, htmlOf } from './markup'
|
|
3
|
+
|
|
4
|
+
const MAX_TEXT = 120
|
|
5
|
+
const QUOTE_CONTEXT = 40
|
|
6
|
+
const MAX_COMPONENTS = 8
|
|
7
|
+
|
|
8
|
+
function shorten(text: string, max = MAX_TEXT): string {
|
|
9
|
+
const collapsed = text.replace(/\s+/g, ' ').trim()
|
|
10
|
+
return collapsed.length > max ? `${collapsed.slice(0, max - 1)}…` : collapsed
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const IMPLICIT_ROLES: Record<string, string> = {
|
|
14
|
+
button: 'button',
|
|
15
|
+
select: 'combobox',
|
|
16
|
+
textarea: 'textbox',
|
|
17
|
+
h1: 'heading',
|
|
18
|
+
h2: 'heading',
|
|
19
|
+
h3: 'heading',
|
|
20
|
+
h4: 'heading',
|
|
21
|
+
h5: 'heading',
|
|
22
|
+
h6: 'heading',
|
|
23
|
+
img: 'img',
|
|
24
|
+
nav: 'navigation',
|
|
25
|
+
main: 'main',
|
|
26
|
+
dialog: 'dialog',
|
|
27
|
+
table: 'table',
|
|
28
|
+
tr: 'row',
|
|
29
|
+
th: 'columnheader',
|
|
30
|
+
td: 'cell',
|
|
31
|
+
li: 'listitem',
|
|
32
|
+
ul: 'list',
|
|
33
|
+
ol: 'list',
|
|
34
|
+
form: 'form',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const INPUT_ROLES: Record<string, string> = { checkbox: 'checkbox', radio: 'radio', button: 'button', submit: 'button', range: 'slider' }
|
|
38
|
+
|
|
39
|
+
export function roleOf(element: Element): string | undefined {
|
|
40
|
+
const explicit = element.getAttribute('role')
|
|
41
|
+
if (explicit !== null && explicit !== '') return explicit.split(' ')[0]
|
|
42
|
+
const tag = element.tagName.toLowerCase()
|
|
43
|
+
if (tag === 'a') return element.hasAttribute('href') ? 'link' : undefined
|
|
44
|
+
if (tag === 'input') return INPUT_ROLES[element.getAttribute('type') ?? 'text'] ?? 'textbox'
|
|
45
|
+
return IMPLICIT_ROLES[tag]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The accessible name, approximated: the sources a reviewer's element usually has, in the order the spec reads them. */
|
|
49
|
+
export function nameOf(element: Element): string | undefined {
|
|
50
|
+
const label = element.getAttribute('aria-label')
|
|
51
|
+
if (label !== null && label.trim() !== '') return shorten(label)
|
|
52
|
+
const labelledBy = element.getAttribute('aria-labelledby')
|
|
53
|
+
if (labelledBy !== null) {
|
|
54
|
+
const text = labelledBy.split(/\s+/).map((id) => element.ownerDocument.getElementById(id)?.textContent ?? '').join(' ')
|
|
55
|
+
if (text.trim() !== '') return shorten(text)
|
|
56
|
+
}
|
|
57
|
+
if (element.id !== '') {
|
|
58
|
+
const forLabel = element.ownerDocument.querySelector(`label[for="${CSS.escape(element.id)}"]`)
|
|
59
|
+
if (forLabel?.textContent != null && forLabel.textContent.trim() !== '') return shorten(forLabel.textContent)
|
|
60
|
+
}
|
|
61
|
+
for (const attribute of ['alt', 'title', 'placeholder']) {
|
|
62
|
+
const value = element.getAttribute(attribute)
|
|
63
|
+
if (value !== null && value.trim() !== '') return shorten(value)
|
|
64
|
+
}
|
|
65
|
+
const role = roleOf(element)
|
|
66
|
+
const namedByContent = role === 'button' || role === 'link' || role === 'heading' || role === 'cell' || role === 'columnheader' || role === 'tab'
|
|
67
|
+
|| role === 'menuitem' || role === 'option'
|
|
68
|
+
if (namedByContent && element.textContent !== null && element.textContent.trim() !== '') return shorten(element.textContent)
|
|
69
|
+
return undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A CSS path from the nearest ancestor with an id, or from `body`, using `:nth-of-type` only where siblings share a tag. */
|
|
73
|
+
export function selectorOf(element: Element): string {
|
|
74
|
+
const parts: string[] = []
|
|
75
|
+
let current: Element | null = element
|
|
76
|
+
while (current !== null && current.tagName.toLowerCase() !== 'body' && current.tagName.toLowerCase() !== 'html') {
|
|
77
|
+
if (current.id !== '' && current.ownerDocument.querySelectorAll(`#${CSS.escape(current.id)}`).length === 1) {
|
|
78
|
+
parts.unshift(`#${CSS.escape(current.id)}`)
|
|
79
|
+
return parts.join(' > ')
|
|
80
|
+
}
|
|
81
|
+
const tag = current.tagName.toLowerCase()
|
|
82
|
+
const parent: Element | null = current.parentElement
|
|
83
|
+
const sameTag = parent === null ? [] : [...parent.children].filter((child) => child.tagName === current?.tagName)
|
|
84
|
+
parts.unshift(sameTag.length > 1 ? `${tag}:nth-of-type(${sameTag.indexOf(current) + 1})` : tag)
|
|
85
|
+
current = parent
|
|
86
|
+
}
|
|
87
|
+
parts.unshift('body')
|
|
88
|
+
return parts.join(' > ')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
92
|
+
return typeof value === 'object' && value !== null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function componentName(type: unknown): string | undefined {
|
|
96
|
+
if (typeof type === 'function') {
|
|
97
|
+
const displayName: unknown = Reflect.get(type, 'displayName')
|
|
98
|
+
if (typeof displayName === 'string' && displayName !== '') return displayName
|
|
99
|
+
return type.name === '' ? undefined : type.name
|
|
100
|
+
}
|
|
101
|
+
if (isRecord(type)) {
|
|
102
|
+
// memo() wraps the component in `type`, forwardRef() in `render`.
|
|
103
|
+
return componentName(type['type']) ?? componentName(type['render'])
|
|
104
|
+
}
|
|
105
|
+
return undefined
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Owner components of a React development build, outermost first; empty for other renderers and production builds. */
|
|
109
|
+
export function componentsOf(element: Element): string[] {
|
|
110
|
+
const fiberKey = Object.keys(element).find((key) => key.startsWith('__reactFiber$'))
|
|
111
|
+
if (fiberKey === undefined) return []
|
|
112
|
+
const names: string[] = []
|
|
113
|
+
let fiber: unknown = Reflect.get(element, fiberKey)
|
|
114
|
+
while (isRecord(fiber) && names.length < 40) {
|
|
115
|
+
// Bundlers suffix a name that collides in their scope (`Card` → `Card13`); the source still says `Card`.
|
|
116
|
+
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)
|
|
119
|
+
fiber = fiber['return']
|
|
120
|
+
}
|
|
121
|
+
return names.slice(-MAX_COMPONENTS)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function quoteOf(element: Element, range: Range): ReviewAnchor['quote'] {
|
|
125
|
+
const exact = range.toString().trim()
|
|
126
|
+
if (exact === '') return undefined
|
|
127
|
+
const before = element.ownerDocument.createRange()
|
|
128
|
+
before.selectNodeContents(element)
|
|
129
|
+
before.setEnd(range.startContainer, range.startOffset)
|
|
130
|
+
const after = element.ownerDocument.createRange()
|
|
131
|
+
after.selectNodeContents(element)
|
|
132
|
+
after.setStart(range.endContainer, range.endOffset)
|
|
133
|
+
return {
|
|
134
|
+
exact: exact.slice(0, 2000),
|
|
135
|
+
before: before.toString().replace(/\s+/g, ' ').slice(-QUOTE_CONTEXT),
|
|
136
|
+
after: after.toString().replace(/\s+/g, ' ').slice(0, QUOTE_CONTEXT),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Describes the element, or the selection inside it, so the agent can find it in code and the widget in the page.
|
|
142
|
+
* Source positions need the page's source maps, so the caller resolves them separately.
|
|
143
|
+
*/
|
|
144
|
+
export function describe(element: Element, url: string, range?: Range): ReviewAnchor {
|
|
145
|
+
const role = roleOf(element)
|
|
146
|
+
const name = nameOf(element)
|
|
147
|
+
const text = element.textContent === null ? '' : shorten(element.textContent)
|
|
148
|
+
const components = componentsOf(element)
|
|
149
|
+
const quote = range === undefined ? undefined : quoteOf(element, range)
|
|
150
|
+
const context = contextOf(element)
|
|
151
|
+
return {
|
|
152
|
+
url,
|
|
153
|
+
selector: selectorOf(element),
|
|
154
|
+
...(role === undefined ? {} : { role }),
|
|
155
|
+
...(name === undefined ? {} : { name }),
|
|
156
|
+
...(text === '' ? {} : { text }),
|
|
157
|
+
...(components.length === 0 ? {} : { components }),
|
|
158
|
+
...(quote === undefined ? {} : { quote }),
|
|
159
|
+
...(context === undefined ? {} : { context }),
|
|
160
|
+
html: htmlOf(element),
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function matches(element: Element, anchor: ReviewAnchor): boolean {
|
|
165
|
+
if (anchor.role !== undefined && roleOf(element) !== anchor.role) return false
|
|
166
|
+
if (anchor.name !== undefined && nameOf(element) !== anchor.name) return false
|
|
167
|
+
return true
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Re-finds the anchored element after the page re-rendered: the CSS path when it still points at an element with the
|
|
172
|
+
* same role and name, otherwise the only element with that role and name.
|
|
173
|
+
*/
|
|
174
|
+
export function find(document: Document, anchor: ReviewAnchor): Element | null {
|
|
175
|
+
let bySelector: Element | null = null
|
|
176
|
+
try {
|
|
177
|
+
bySelector = document.querySelector(anchor.selector)
|
|
178
|
+
} catch {
|
|
179
|
+
bySelector = null
|
|
180
|
+
}
|
|
181
|
+
if (bySelector !== null && matches(bySelector, anchor)) return bySelector
|
|
182
|
+
if (anchor.role !== undefined && anchor.name !== undefined) {
|
|
183
|
+
const candidates = [...document.body.querySelectorAll('*')].filter((element) => matches(element, anchor))
|
|
184
|
+
if (candidates.length === 1) return candidates[0] ?? null
|
|
185
|
+
}
|
|
186
|
+
return bySelector
|
|
187
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { type ReviewAnchor, type ReviewBrowserFrame, type ReviewThread, reviewThreadSchema } from '../src/review/model'
|
|
3
|
+
|
|
4
|
+
const threadListSchema = z.object({ threads: z.array(reviewThreadSchema) })
|
|
5
|
+
|
|
6
|
+
export interface ReviewEvents {
|
|
7
|
+
threads(threads: readonly ReviewThread[]): void
|
|
8
|
+
agentWatching(watching: boolean): void
|
|
9
|
+
connected(connected: boolean): void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
13
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The daemon only ever sends these frames; anything else is ignored rather than trusted. */
|
|
17
|
+
function readFrame(data: unknown): ReviewBrowserFrame | undefined {
|
|
18
|
+
if (typeof data !== 'string') return undefined
|
|
19
|
+
let frame: unknown
|
|
20
|
+
try {
|
|
21
|
+
frame = JSON.parse(data)
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
if (!isRecord(frame)) return undefined
|
|
26
|
+
if (frame['type'] === 'agent-status' && typeof frame['watching'] === 'boolean') return { type: 'agent-status', watching: frame['watching'] }
|
|
27
|
+
if (frame['type'] !== 'threads') return undefined
|
|
28
|
+
const parsed = threadListSchema.safeParse(frame)
|
|
29
|
+
return parsed.success ? { type: 'threads', threads: parsed.data.threads } : undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The widget's side of the daemon: thread mutations over HTTP, the thread list and agent presence over a WebSocket. */
|
|
33
|
+
export class ReviewClient {
|
|
34
|
+
private socket: WebSocket | null = null
|
|
35
|
+
private closed = false
|
|
36
|
+
|
|
37
|
+
constructor(private readonly daemonOrigin: string, private readonly project: string) {}
|
|
38
|
+
|
|
39
|
+
connect(events: ReviewEvents): void {
|
|
40
|
+
const url = `${this.daemonOrigin.replace(/^http/, 'ws')}/ws/review/${encodeURIComponent(this.project)}`
|
|
41
|
+
const socket = new WebSocket(url)
|
|
42
|
+
this.socket = socket
|
|
43
|
+
socket.onopen = () => {
|
|
44
|
+
events.connected(true)
|
|
45
|
+
void this.request('GET', '/threads').then((body) => {
|
|
46
|
+
const parsed = threadListSchema.safeParse(body)
|
|
47
|
+
if (parsed.success) events.threads(parsed.data.threads)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
socket.onmessage = (event) => {
|
|
51
|
+
const frame = readFrame(event.data)
|
|
52
|
+
if (frame?.type === 'threads') events.threads(frame.threads)
|
|
53
|
+
if (frame?.type === 'agent-status') events.agentWatching(frame.watching)
|
|
54
|
+
}
|
|
55
|
+
socket.onclose = () => {
|
|
56
|
+
events.connected(false)
|
|
57
|
+
if (!this.closed) setTimeout(() => this.connect(events), 2000)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
close(): void {
|
|
62
|
+
this.closed = true
|
|
63
|
+
this.socket?.close()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
createThread(anchor: ReviewAnchor, body: string, send: boolean, screenshot?: string): Promise<unknown> {
|
|
67
|
+
return this.request('POST', '/threads', { anchor, body, send, ...(screenshot === undefined ? {} : { screenshot }) })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
comment(threadId: string, body: string, send: boolean): Promise<unknown> {
|
|
71
|
+
return this.request('POST', `/threads/${threadId}/comments`, { body, send })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
send(threadId: string): Promise<unknown> {
|
|
75
|
+
return this.request('POST', `/threads/${threadId}/send`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setResolved(threadId: string, resolved: boolean): Promise<unknown> {
|
|
79
|
+
return this.request('POST', `/threads/${threadId}/${resolved ? 'resolve' : 'reopen'}`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
delete(threadId: string): Promise<unknown> {
|
|
83
|
+
return this.request('DELETE', `/threads/${threadId}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private async request(method: string, path: string, body?: unknown): Promise<unknown> {
|
|
87
|
+
const response = await fetch(`${this.daemonOrigin}/api/review/${encodeURIComponent(this.project)}${path}`, {
|
|
88
|
+
method,
|
|
89
|
+
...(body === undefined ? {} : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
|
|
90
|
+
})
|
|
91
|
+
const result: unknown = await response.json()
|
|
92
|
+
if (!response.ok) throw new Error(isRecord(result) && typeof result['error'] === 'string' ? result['error'] : `Request failed (${response.status})`)
|
|
93
|
+
return result
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { render } from 'preact'
|
|
2
|
+
import { PROJECT_RE } from '../src/review/model'
|
|
3
|
+
import { ReviewClient } from './client'
|
|
4
|
+
import styles from './styles.css' with { type: 'text' }
|
|
5
|
+
import { ReviewWidget } from './widget'
|
|
6
|
+
|
|
7
|
+
const HOST_TAG = 'agent-canvas-review'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `<script src="http://localhost:19400/assets/review.js" data-project="my-app"></script>` in the application under
|
|
11
|
+
* review mounts the widget. Its own origin is the daemon's.
|
|
12
|
+
*/
|
|
13
|
+
function mount(script: HTMLScriptElement): void {
|
|
14
|
+
const project = script.dataset['project'] ?? ''
|
|
15
|
+
if (!PROJECT_RE.test(project)) {
|
|
16
|
+
console.error(`agent-canvas review: data-project must match ${PROJECT_RE}, got "${project}"`)
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
if (document.querySelector(HOST_TAG) !== null) return
|
|
20
|
+
|
|
21
|
+
const pageStyle = document.createElement('style')
|
|
22
|
+
pageStyle.textContent = 'html.agent-canvas-review-picking, html.agent-canvas-review-picking * { cursor: crosshair !important; }'
|
|
23
|
+
document.head.append(pageStyle)
|
|
24
|
+
|
|
25
|
+
const host = document.createElement(HOST_TAG)
|
|
26
|
+
document.documentElement.append(host)
|
|
27
|
+
const root = host.attachShadow({ mode: 'open' })
|
|
28
|
+
const style = document.createElement('style')
|
|
29
|
+
style.textContent = styles
|
|
30
|
+
root.append(style)
|
|
31
|
+
render(<ReviewWidget client={new ReviewClient(new URL(script.src).origin, project)} host={host} />, root)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const script = document.currentScript
|
|
35
|
+
if (script instanceof HTMLScriptElement) mount(script)
|
|
36
|
+
else console.error('agent-canvas review: load review.js with a classic <script> tag')
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { MAX_HTML } from '../src/review/model'
|
|
2
|
+
|
|
3
|
+
const MAX_CHILDREN = 8
|
|
4
|
+
const MAX_TEXT = 80
|
|
5
|
+
const DROPPED_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE'])
|
|
6
|
+
|
|
7
|
+
function trimTree(node: Element, depth: number): void {
|
|
8
|
+
if (node.tagName.toLowerCase() === 'svg') {
|
|
9
|
+
node.replaceChildren()
|
|
10
|
+
return
|
|
11
|
+
}
|
|
12
|
+
let elements = 0
|
|
13
|
+
for (const child of [...node.childNodes]) {
|
|
14
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
15
|
+
const text = child.textContent ?? ''
|
|
16
|
+
if (text.trim() === '') child.remove()
|
|
17
|
+
else if (text.length > MAX_TEXT) child.textContent = `${text.slice(0, MAX_TEXT)}…`
|
|
18
|
+
continue
|
|
19
|
+
}
|
|
20
|
+
if (!(child instanceof Element) || DROPPED_TAGS.has(child.tagName)) {
|
|
21
|
+
child.remove()
|
|
22
|
+
continue
|
|
23
|
+
}
|
|
24
|
+
elements++
|
|
25
|
+
if (depth === 0 || elements > MAX_CHILDREN) child.remove()
|
|
26
|
+
else trimTree(child, depth - 1)
|
|
27
|
+
}
|
|
28
|
+
if (depth === 0 && elements > 0) node.append(node.ownerDocument.createComment(` ${elements} child elements cut `))
|
|
29
|
+
else if (elements > MAX_CHILDREN) node.append(node.ownerDocument.createComment(` ${elements - MAX_CHILDREN} more `))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The element's markup, shallower until it fits: the agent sees structure, classes and attributes, not whole subtrees. */
|
|
33
|
+
export function htmlOf(element: Element): string {
|
|
34
|
+
for (let depth = 5; depth >= 0; depth--) {
|
|
35
|
+
const clone = element.cloneNode(true)
|
|
36
|
+
if (!(clone instanceof Element)) return ''
|
|
37
|
+
trimTree(clone, depth)
|
|
38
|
+
const html = clone.outerHTML
|
|
39
|
+
if (html.length <= MAX_HTML) return html
|
|
40
|
+
}
|
|
41
|
+
const shallow = element.cloneNode(false)
|
|
42
|
+
return shallow instanceof Element ? shallow.outerHTML.slice(0, MAX_HTML) : ''
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function headingIn(container: Element): string | undefined {
|
|
46
|
+
const label = container.getAttribute('aria-label')
|
|
47
|
+
if (label !== null && label.trim() !== '') return label.trim()
|
|
48
|
+
const labelledBy = container.getAttribute('aria-labelledby')
|
|
49
|
+
const labelElement = labelledBy === null ? null : container.ownerDocument.getElementById(labelledBy.split(/\s+/)[0] ?? '')
|
|
50
|
+
const heading = labelElement ?? container.querySelector('h1, h2, h3, h4, legend, [role="heading"]')
|
|
51
|
+
const text = heading?.textContent?.replace(/\s+/g, ' ').trim()
|
|
52
|
+
return text === undefined || text === '' ? undefined : text.slice(0, 80)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const LANDMARKS: readonly { selector: string; kind: string }[] = [
|
|
56
|
+
{ selector: 'dialog, [role="dialog"], [role="alertdialog"], [aria-modal="true"]', kind: 'Dialog' },
|
|
57
|
+
{ selector: 'section, article, fieldset, form, [role="region"], [role="tabpanel"]', kind: 'Section' },
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
/** The page heading and the nearest section and dialog around the element, outermost first. */
|
|
61
|
+
export function contextOf(element: Element): string | undefined {
|
|
62
|
+
const parts: string[] = []
|
|
63
|
+
const pageHeading = element.ownerDocument.querySelector('h1')?.textContent?.replace(/\s+/g, ' ').trim()
|
|
64
|
+
if (pageHeading !== undefined && pageHeading !== '') parts.push(`Page "${pageHeading.slice(0, 80)}"`)
|
|
65
|
+
for (const landmark of LANDMARKS) {
|
|
66
|
+
const container = element.closest(landmark.selector)
|
|
67
|
+
const heading = container === null ? undefined : headingIn(container)
|
|
68
|
+
if (heading !== undefined && !parts.includes(`Page "${heading}"`)) parts.push(`${landmark.kind} "${heading}"`)
|
|
69
|
+
}
|
|
70
|
+
return parts.length === 0 ? undefined : parts.join(' › ')
|
|
71
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** The smallest surroundings worth showing around a picked element, and the tallest an ancestor may be to count as them. */
|
|
2
|
+
const MIN_CONTEXT = { width: 480, height: 160 }
|
|
3
|
+
const MAX_CONTEXT_HEIGHT = 900
|
|
4
|
+
const OUTLINE = '#e11d48'
|
|
5
|
+
|
|
6
|
+
/** The nearest ancestor large enough to show where the element sits, but not a whole scrolling page. */
|
|
7
|
+
function surroundingsOf(element: Element): Element {
|
|
8
|
+
let context = element
|
|
9
|
+
for (let parent = element.parentElement; parent !== null && parent !== element.ownerDocument.body; parent = parent.parentElement) {
|
|
10
|
+
const box = context.getBoundingClientRect()
|
|
11
|
+
if (box.width >= MIN_CONTEXT.width && box.height >= MIN_CONTEXT.height) break
|
|
12
|
+
if (parent.getBoundingClientRect().height > MAX_CONTEXT_HEIGHT) break
|
|
13
|
+
context = parent
|
|
14
|
+
}
|
|
15
|
+
return context
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Resolves once the stream has delivered `count` new frames, so the frame drawn shows the page after our changes. */
|
|
19
|
+
function streamFrames(video: HTMLVideoElement, count: number): Promise<void> {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
const fallback = setTimeout(resolve, 1000)
|
|
22
|
+
const wait = (left: number) => {
|
|
23
|
+
if (left === 0) {
|
|
24
|
+
clearTimeout(fallback)
|
|
25
|
+
resolve()
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
video.requestVideoFrameCallback(() => wait(left - 1))
|
|
29
|
+
}
|
|
30
|
+
wait(count)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function clip(box: DOMRect): { left: number; top: number; width: number; height: number } {
|
|
35
|
+
const left = Math.max(0, box.left)
|
|
36
|
+
const top = Math.max(0, box.top)
|
|
37
|
+
return { left, top, width: Math.min(innerWidth, box.right) - left, height: Math.min(innerHeight, box.bottom) - top }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Real pixels of the element in its surroundings, the element outlined, the widget hidden: the browser asks the
|
|
42
|
+
* reviewer to share this tab for one frame. Rendering the DOM into a picture instead gets fonts and text wrapping
|
|
43
|
+
* wrong, which is exactly what a visual comment is about.
|
|
44
|
+
*
|
|
45
|
+
* Call it straight from the click handler: the browser lets a page ask for the tab only during a user gesture.
|
|
46
|
+
*/
|
|
47
|
+
export async function captureElement(element: Element, widgetHost: HTMLElement): Promise<string> {
|
|
48
|
+
// Hidden before the stream starts: its first frames already lack the widget, later ones can lag behind the page.
|
|
49
|
+
const previousVisibility = widgetHost.style.visibility
|
|
50
|
+
widgetHost.style.visibility = 'hidden'
|
|
51
|
+
element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
|
52
|
+
let stream: MediaStream
|
|
53
|
+
try {
|
|
54
|
+
stream = await navigator.mediaDevices.getDisplayMedia({
|
|
55
|
+
video: { displaySurface: 'browser', width: { ideal: innerWidth * devicePixelRatio }, height: { ideal: innerHeight * devicePixelRatio } },
|
|
56
|
+
audio: false,
|
|
57
|
+
// Chrome's options for sharing the page's own tab without a picker; other browsers ignore them.
|
|
58
|
+
...{ preferCurrentTab: true, selfBrowserSurface: 'include' },
|
|
59
|
+
})
|
|
60
|
+
} catch (error) {
|
|
61
|
+
widgetHost.style.visibility = previousVisibility
|
|
62
|
+
throw error
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const [track] = stream.getVideoTracks()
|
|
66
|
+
if (track === undefined || track.getSettings().displaySurface !== 'browser') throw new Error('Share this tab to attach a screenshot')
|
|
67
|
+
const video = document.createElement('video')
|
|
68
|
+
video.muted = true
|
|
69
|
+
video.srcObject = stream
|
|
70
|
+
await video.play()
|
|
71
|
+
await streamFrames(video, 2)
|
|
72
|
+
|
|
73
|
+
const context = clip(surroundingsOf(element).getBoundingClientRect())
|
|
74
|
+
const target = element.getBoundingClientRect()
|
|
75
|
+
// The captured frame is the tab's viewport at the device's pixel density.
|
|
76
|
+
const ratio = video.videoWidth / innerWidth
|
|
77
|
+
const canvas = document.createElement('canvas')
|
|
78
|
+
canvas.width = Math.round(context.width * ratio)
|
|
79
|
+
canvas.height = Math.round(context.height * ratio)
|
|
80
|
+
const drawing = canvas.getContext('2d')
|
|
81
|
+
if (drawing === null) throw new Error('Canvas is not available')
|
|
82
|
+
drawing.drawImage(video, context.left * ratio, context.top * ratio, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height)
|
|
83
|
+
drawing.strokeStyle = OUTLINE
|
|
84
|
+
drawing.lineWidth = 3 * ratio
|
|
85
|
+
drawing.strokeRect((target.left - context.left) * ratio, (target.top - context.top) * ratio, target.width * ratio, target.height * ratio)
|
|
86
|
+
return canvas.toDataURL('image/png')
|
|
87
|
+
} finally {
|
|
88
|
+
widgetHost.style.visibility = previousVisibility
|
|
89
|
+
for (const track of stream.getTracks()) track.stop()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping'
|
|
2
|
+
import type { ReviewSource } from '../src/review/model'
|
|
3
|
+
|
|
4
|
+
/** A frame of a development stack, still in the bundle the page loaded. */
|
|
5
|
+
export interface BundleFrame {
|
|
6
|
+
label: string
|
|
7
|
+
url: string
|
|
8
|
+
line: number
|
|
9
|
+
column: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const MAX_SOURCES = 6
|
|
13
|
+
const RESOLVE_TIMEOUT_MS = 3000
|
|
14
|
+
/** Frames inside React's element factory and scheduler, which every stack starts with. */
|
|
15
|
+
const REACT_INTERNAL =
|
|
16
|
+
/\bat (?:[\w$]+\.)*(?:jsxDEV|jsxs?|createElement|react_stack_bottom_frame)\b|\/node_modules\/(?:\.vite\/deps\/)?react(?:-dom)?[/_]/
|
|
17
|
+
|
|
18
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
19
|
+
return typeof value === 'object' && value !== null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function nameOf(type: unknown): string | undefined {
|
|
23
|
+
if (typeof type === 'string') return type
|
|
24
|
+
if (typeof type === 'function') return type.name === '' ? undefined : type.name.replace(/(?<=[a-z])\d+$/, '')
|
|
25
|
+
if (isRecord(type)) return nameOf(type['type']) ?? nameOf(type['render'])
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The first application frame of a stack: where the JSX was written. */
|
|
30
|
+
function creationFrame(stack: string): Omit<BundleFrame, 'label'> | undefined {
|
|
31
|
+
for (const line of stack.split('\n').slice(1)) {
|
|
32
|
+
if (REACT_INTERNAL.test(line)) continue
|
|
33
|
+
const match = line.match(/\(?((?:https?|file):\/\/[^\s)]+):(\d+):(\d+)\)?\s*$/)
|
|
34
|
+
if (match?.[1] !== undefined && match[2] !== undefined && match[3] !== undefined) {
|
|
35
|
+
return { url: match[1], line: Number(match[2]), column: Number(match[3]) }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Where the element and its nearest components were created, read off React 19 development fibers (`_debugStack`).
|
|
43
|
+
* Empty for other renderers and production builds.
|
|
44
|
+
*/
|
|
45
|
+
export function bundleFrames(element: Element): BundleFrame[] {
|
|
46
|
+
const fiberKey = Object.keys(element).find((key) => key.startsWith('__reactFiber$'))
|
|
47
|
+
if (fiberKey === undefined) return []
|
|
48
|
+
const frames: BundleFrame[] = []
|
|
49
|
+
let fiber: unknown = Reflect.get(element, fiberKey)
|
|
50
|
+
for (let depth = 0; isRecord(fiber) && frames.length < MAX_SOURCES && depth < 60; depth++, fiber = fiber['return']) {
|
|
51
|
+
const name = nameOf(fiber['type'])
|
|
52
|
+
// Host elements other than the picked one, and components without a name, say nothing new.
|
|
53
|
+
if (name === undefined || (frames.length > 0 && typeof fiber['type'] === 'string')) continue
|
|
54
|
+
const stack = isRecord(fiber['_debugStack']) ? fiber['_debugStack']['stack'] : undefined
|
|
55
|
+
if (typeof stack !== 'string') continue
|
|
56
|
+
const frame = creationFrame(stack)
|
|
57
|
+
if (frame === undefined) continue
|
|
58
|
+
const owner = isRecord(fiber['_debugOwner']) ? nameOf(fiber['_debugOwner']['type']) : undefined
|
|
59
|
+
frames.push({ label: owner === undefined ? `<${name}>` : `<${name}> in ${owner}`, ...frame })
|
|
60
|
+
}
|
|
61
|
+
return frames
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const traceMaps = new Map<string, Promise<TraceMap | null>>()
|
|
65
|
+
|
|
66
|
+
async function loadTraceMap(scriptUrl: string): Promise<TraceMap | null> {
|
|
67
|
+
const script = await fetch(scriptUrl).then((response) => response.text())
|
|
68
|
+
const reference = [...script.matchAll(/\/\/[#@] sourceMappingURL=(\S+)/g)].at(-1)?.[1]
|
|
69
|
+
if (reference === undefined) return null
|
|
70
|
+
const mapUrl = new URL(reference, scriptUrl).href
|
|
71
|
+
const map: unknown = await fetch(mapUrl).then((response) => response.json())
|
|
72
|
+
if (!isRecord(map) || map['version'] !== 3) return null
|
|
73
|
+
return new TraceMap(JSON.stringify(map), mapUrl)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function traceMapFor(scriptUrl: string): Promise<TraceMap | null> {
|
|
77
|
+
let map = traceMaps.get(scriptUrl)
|
|
78
|
+
if (map === undefined) {
|
|
79
|
+
map = loadTraceMap(scriptUrl).catch(() => null)
|
|
80
|
+
traceMaps.set(scriptUrl, map)
|
|
81
|
+
}
|
|
82
|
+
return map
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sourcePath(source: string): string {
|
|
86
|
+
return source.startsWith('file://') ? decodeURIComponent(new URL(source).pathname) : source
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function resolveFrame(frame: BundleFrame): Promise<ReviewSource | null> {
|
|
90
|
+
const map = await traceMapFor(frame.url)
|
|
91
|
+
if (map === null) return null
|
|
92
|
+
// Stack columns count from 1, source map columns from 0.
|
|
93
|
+
const position = originalPositionFor(map, { line: frame.line, column: frame.column - 1 })
|
|
94
|
+
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 }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Source positions of the frames, through the bundles' source maps; frames a map does not cover are left out. */
|
|
99
|
+
export async function resolveSources(frames: readonly BundleFrame[]): Promise<ReviewSource[]> {
|
|
100
|
+
const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), RESOLVE_TIMEOUT_MS))
|
|
101
|
+
const resolved = await Promise.race([Promise.all(frames.map((frame) => resolveFrame(frame).catch(() => null))), timeout])
|
|
102
|
+
return resolved === null ? [] : resolved.filter((source) => source !== null)
|
|
103
|
+
}
|