agent-canvas 0.2.1 → 0.7.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
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
2
|
+
import type { ReviewAnchor, ReviewSource, ReviewThread, ReviewThreadState } from '../src/review/model'
|
|
3
|
+
import { describe, find } from './anchor'
|
|
4
|
+
import type { ReviewClient } from './client'
|
|
5
|
+
import { captureElement } from './screenshot'
|
|
6
|
+
import { bundleFrames, resolveSources } from './source'
|
|
7
|
+
|
|
8
|
+
/** A type alias, not an interface, so it passes as an inline style. */
|
|
9
|
+
type Box = {
|
|
10
|
+
top: number
|
|
11
|
+
left: number
|
|
12
|
+
width: number
|
|
13
|
+
height: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Draft {
|
|
17
|
+
anchor: ReviewAnchor
|
|
18
|
+
box: Box
|
|
19
|
+
element: Element
|
|
20
|
+
/** Resolved while the reviewer writes: source maps can take a moment to load. */
|
|
21
|
+
sources: Promise<ReviewSource[]>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const STATE_LABELS: Record<ReviewThreadState, string> = {
|
|
25
|
+
open: 'Note',
|
|
26
|
+
queued: 'Sent to Claude',
|
|
27
|
+
working: 'Claude is on it',
|
|
28
|
+
resolved: 'Resolved',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const INTERACTIVE =
|
|
32
|
+
'a, button, input, select, textarea, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [contenteditable]'
|
|
33
|
+
const CURSOR_CLASS = 'agent-canvas-review-picking'
|
|
34
|
+
|
|
35
|
+
const PENDING_REVEAL_KEY = 'agent-canvas-review:reveal'
|
|
36
|
+
|
|
37
|
+
/** A hash route changes without a reload; any other URL reloads the page, so the thread to reveal survives in the session. */
|
|
38
|
+
function navigate(href: string, threadId: string): void {
|
|
39
|
+
const target = new URL(href)
|
|
40
|
+
if (target.origin === location.origin && target.pathname === location.pathname && target.search === location.search) {
|
|
41
|
+
location.hash = target.hash
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
sessionStorage.setItem(PENDING_REVEAL_KEY, threadId)
|
|
46
|
+
} catch {}
|
|
47
|
+
location.href = href
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function takePendingReveal(): string | null {
|
|
51
|
+
try {
|
|
52
|
+
const threadId = sessionStorage.getItem(PENDING_REVEAL_KEY)
|
|
53
|
+
sessionStorage.removeItem(PENDING_REVEAL_KEY)
|
|
54
|
+
return threadId
|
|
55
|
+
} catch {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function boxOf(element: Element): Box {
|
|
61
|
+
const rect = element.getBoundingClientRect()
|
|
62
|
+
return { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Two URLs show the same page when they differ at most in a hash route's query, which apps use for transient state. */
|
|
66
|
+
function pageKey(href: string): string {
|
|
67
|
+
const url = new URL(href)
|
|
68
|
+
return `${url.origin}${url.pathname}${url.search}${url.hash.split('?')[0] ?? ''}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function anchorLabel(anchor: ReviewAnchor): string {
|
|
72
|
+
if (anchor.quote !== undefined) return `“${anchor.quote.exact}”`
|
|
73
|
+
if (anchor.role !== undefined && anchor.name !== undefined) return `${anchor.role} “${anchor.name}”`
|
|
74
|
+
if (anchor.text !== undefined) return anchor.text
|
|
75
|
+
return anchor.selector
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Re-renders on scroll, resize and once a second, so pins follow elements the application moves. */
|
|
79
|
+
function useLayoutTick(): number {
|
|
80
|
+
const [tick, setTick] = useState(0)
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
let frame = 0
|
|
83
|
+
const schedule = () => {
|
|
84
|
+
cancelAnimationFrame(frame)
|
|
85
|
+
frame = requestAnimationFrame(() => setTick((value) => value + 1))
|
|
86
|
+
}
|
|
87
|
+
window.addEventListener('scroll', schedule, true)
|
|
88
|
+
window.addEventListener('resize', schedule)
|
|
89
|
+
const interval = setInterval(schedule, 1000)
|
|
90
|
+
return () => {
|
|
91
|
+
cancelAnimationFrame(frame)
|
|
92
|
+
window.removeEventListener('scroll', schedule, true)
|
|
93
|
+
window.removeEventListener('resize', schedule)
|
|
94
|
+
clearInterval(interval)
|
|
95
|
+
}
|
|
96
|
+
}, [])
|
|
97
|
+
return tick
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function useHref(): string {
|
|
101
|
+
const [href, setHref] = useState(location.href)
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const check = () => setHref(location.href)
|
|
104
|
+
window.addEventListener('hashchange', check)
|
|
105
|
+
window.addEventListener('popstate', check)
|
|
106
|
+
const interval = setInterval(check, 400)
|
|
107
|
+
return () => {
|
|
108
|
+
window.removeEventListener('hashchange', check)
|
|
109
|
+
window.removeEventListener('popstate', check)
|
|
110
|
+
clearInterval(interval)
|
|
111
|
+
}
|
|
112
|
+
}, [])
|
|
113
|
+
return href
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** While picking, the page's own handlers must not see the click that chooses an element. */
|
|
117
|
+
function usePicker(
|
|
118
|
+
host: HTMLElement,
|
|
119
|
+
active: boolean,
|
|
120
|
+
onHover: (element: Element | null) => void,
|
|
121
|
+
onPick: (element: Element, range?: Range) => void,
|
|
122
|
+
onCancel: () => void,
|
|
123
|
+
) {
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (!active) return
|
|
126
|
+
document.documentElement.classList.add(CURSOR_CLASS)
|
|
127
|
+
const isOwn = (event: Event) => event.composedPath().includes(host)
|
|
128
|
+
// A click on a link's or a button's inner span means the link or the button.
|
|
129
|
+
const targetOf = (event: Event) => (event.target instanceof Element ? (event.target.closest(INTERACTIVE) ?? event.target) : null)
|
|
130
|
+
const onMove = (event: MouseEvent) => {
|
|
131
|
+
if (!isOwn(event)) onHover(targetOf(event))
|
|
132
|
+
}
|
|
133
|
+
const onDown = (event: MouseEvent) => {
|
|
134
|
+
if (isOwn(event)) return
|
|
135
|
+
if (targetOf(event)?.closest(INTERACTIVE)) {
|
|
136
|
+
event.preventDefault()
|
|
137
|
+
event.stopPropagation()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const onClick = (event: MouseEvent) => {
|
|
141
|
+
if (isOwn(event)) return
|
|
142
|
+
event.preventDefault()
|
|
143
|
+
event.stopPropagation()
|
|
144
|
+
const selection = window.getSelection()
|
|
145
|
+
const range = selection !== null && selection.rangeCount > 0 && !selection.isCollapsed ? selection.getRangeAt(0) : undefined
|
|
146
|
+
if (range !== undefined) {
|
|
147
|
+
const container = range.commonAncestorContainer
|
|
148
|
+
const element = container instanceof Element ? container : container.parentElement
|
|
149
|
+
if (element !== null) return onPick(element, range)
|
|
150
|
+
}
|
|
151
|
+
const target = targetOf(event)
|
|
152
|
+
if (target !== null) onPick(target)
|
|
153
|
+
}
|
|
154
|
+
const onKey = (event: KeyboardEvent) => {
|
|
155
|
+
if (event.key === 'Escape') onCancel()
|
|
156
|
+
}
|
|
157
|
+
document.addEventListener('mousemove', onMove, true)
|
|
158
|
+
document.addEventListener('pointerdown', onDown, true)
|
|
159
|
+
document.addEventListener('mousedown', onDown, true)
|
|
160
|
+
document.addEventListener('click', onClick, true)
|
|
161
|
+
document.addEventListener('keydown', onKey, true)
|
|
162
|
+
return () => {
|
|
163
|
+
document.documentElement.classList.remove(CURSOR_CLASS)
|
|
164
|
+
document.removeEventListener('mousemove', onMove, true)
|
|
165
|
+
document.removeEventListener('pointerdown', onDown, true)
|
|
166
|
+
document.removeEventListener('mousedown', onDown, true)
|
|
167
|
+
document.removeEventListener('click', onClick, true)
|
|
168
|
+
document.removeEventListener('keydown', onKey, true)
|
|
169
|
+
}
|
|
170
|
+
}, [host, active, onHover, onPick, onCancel])
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function popoverPosition(box: Box): { top: number; left: number } {
|
|
174
|
+
const width = 320
|
|
175
|
+
const height = 190
|
|
176
|
+
const left = Math.min(Math.max(8, box.left), window.innerWidth - width - 8)
|
|
177
|
+
const below = box.top + box.height + 8
|
|
178
|
+
const top = below + height < window.innerHeight ? below : Math.max(8, box.top - height - 8)
|
|
179
|
+
return { top, left }
|
|
180
|
+
}
|
|
181
|
+
|
|
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)
|
|
185
|
+
const [busy, setBusy] = useState(false)
|
|
186
|
+
const [error, setError] = useState<string | null>(null)
|
|
187
|
+
const textarea = useRef<HTMLTextAreaElement>(null)
|
|
188
|
+
useEffect(() => textarea.current?.focus(), [])
|
|
189
|
+
|
|
190
|
+
const submit = async (send: boolean) => {
|
|
191
|
+
if (body.trim() === '') return
|
|
192
|
+
setBusy(true)
|
|
193
|
+
setError(null)
|
|
194
|
+
try {
|
|
195
|
+
await props.onSubmit(body, send, screenshot)
|
|
196
|
+
} catch (failure) {
|
|
197
|
+
setError(failure instanceof Error ? failure.message : String(failure))
|
|
198
|
+
setBusy(false)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<div class="popover" style={popoverPosition(props.draft.box)}>
|
|
204
|
+
<div class="target" title={props.draft.anchor.selector}>{anchorLabel(props.draft.anchor)}</div>
|
|
205
|
+
{props.draft.anchor.components !== undefined && <div class="target">{props.draft.anchor.components.slice(-3).join(' › ')}</div>}
|
|
206
|
+
<textarea
|
|
207
|
+
ref={textarea}
|
|
208
|
+
value={body}
|
|
209
|
+
placeholder="What should change here?"
|
|
210
|
+
onInput={(event) => setBody(event.currentTarget.value)}
|
|
211
|
+
onKeyDown={(event) => {
|
|
212
|
+
if (event.key === 'Escape') props.onCancel()
|
|
213
|
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) void submit(true)
|
|
214
|
+
}}
|
|
215
|
+
/>
|
|
216
|
+
<label class="option">
|
|
217
|
+
<input type="checkbox" checked={screenshot} onChange={(event) => setScreenshot(event.currentTarget.checked)} />
|
|
218
|
+
Attach a screenshot
|
|
219
|
+
</label>
|
|
220
|
+
{error !== null && <div class="error">{error}</div>}
|
|
221
|
+
<div class="actions">
|
|
222
|
+
<button type="button" class="ghost" onClick={props.onCancel}>Cancel</button>
|
|
223
|
+
<button type="button" disabled={busy || body.trim() === ''} onClick={() => void submit(false)}>Save note</button>
|
|
224
|
+
<button type="button" class="primary" disabled={busy || body.trim() === ''} onClick={() => void submit(true)}>Send to Claude</button>
|
|
225
|
+
</div>
|
|
226
|
+
<div class="hint">Ctrl+Enter sends to Claude · Esc cancels</div>
|
|
227
|
+
</div>
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function ThreadCard(props: { thread: ReviewThread; number: number | null; client: ReviewClient; onReveal: () => void }) {
|
|
232
|
+
const { thread, client } = props
|
|
233
|
+
const [reply, setReply] = useState<string | null>(null)
|
|
234
|
+
const [error, setError] = useState<string | null>(null)
|
|
235
|
+
const run = (action: () => Promise<unknown>) => {
|
|
236
|
+
setError(null)
|
|
237
|
+
action().catch((failure: unknown) => setError(failure instanceof Error ? failure.message : String(failure)))
|
|
238
|
+
}
|
|
239
|
+
const sendReply = (send: boolean) => {
|
|
240
|
+
if (reply === null || reply.trim() === '') return
|
|
241
|
+
run(() => client.comment(thread.id, reply, send).then(() => setReply(null)))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<div class={`thread${props.number === null ? '' : ' current'}`}>
|
|
246
|
+
<div class="thread-head">
|
|
247
|
+
<button type="button" class="ghost" title="Show in page" aria-label="Show in page" onClick={props.onReveal}>
|
|
248
|
+
{props.number === null ? '↗' : `#${props.number}`}
|
|
249
|
+
</button>
|
|
250
|
+
<span class="target" title={thread.anchor.url}>{anchorLabel(thread.anchor)}</span>
|
|
251
|
+
<span class={`badge ${thread.state}`}>{STATE_LABELS[thread.state]}</span>
|
|
252
|
+
</div>
|
|
253
|
+
{thread.comments.map((comment) => (
|
|
254
|
+
<div key={comment.id} class={`comment ${comment.author}`}>
|
|
255
|
+
<span class="author">{comment.author === 'user' ? 'You' : 'Claude'}</span>
|
|
256
|
+
<span class="body">{comment.body}</span>
|
|
257
|
+
</div>
|
|
258
|
+
))}
|
|
259
|
+
{reply !== null && (
|
|
260
|
+
<textarea
|
|
261
|
+
value={reply}
|
|
262
|
+
placeholder="Reply…"
|
|
263
|
+
onInput={(event) => setReply(event.currentTarget.value)}
|
|
264
|
+
onKeyDown={(event) => {
|
|
265
|
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) sendReply(true)
|
|
266
|
+
}}
|
|
267
|
+
/>
|
|
268
|
+
)}
|
|
269
|
+
{error !== null && <div class="error">{error}</div>}
|
|
270
|
+
<div class="actions">
|
|
271
|
+
{reply === null
|
|
272
|
+
? (
|
|
273
|
+
<>
|
|
274
|
+
<button type="button" class="ghost" onClick={() => run(() => client.delete(thread.id))}>Delete</button>
|
|
275
|
+
{thread.state === 'resolved'
|
|
276
|
+
? <button type="button" onClick={() => run(() => client.setResolved(thread.id, false))}>Reopen</button>
|
|
277
|
+
: <button type="button" onClick={() => run(() => client.setResolved(thread.id, true))}>Resolve</button>}
|
|
278
|
+
<button type="button" onClick={() => setReply('')}>Reply</button>
|
|
279
|
+
{thread.state === 'open' && <button type="button" class="primary" onClick={() => run(() => client.send(thread.id))}>Send to Claude</button>}
|
|
280
|
+
</>
|
|
281
|
+
)
|
|
282
|
+
: (
|
|
283
|
+
<>
|
|
284
|
+
<button type="button" class="ghost" onClick={() => setReply(null)}>Cancel</button>
|
|
285
|
+
<button type="button" disabled={reply.trim() === ''} onClick={() => sendReply(false)}>Reply</button>
|
|
286
|
+
<button type="button" class="primary" disabled={reply.trim() === ''} onClick={() => sendReply(true)}>Reply & send</button>
|
|
287
|
+
</>
|
|
288
|
+
)}
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function ReviewWidget(props: { client: ReviewClient; host: HTMLElement }) {
|
|
295
|
+
const { client, host } = props
|
|
296
|
+
const [threads, setThreads] = useState<readonly ReviewThread[]>([])
|
|
297
|
+
const [watching, setWatching] = useState(false)
|
|
298
|
+
const [connected, setConnected] = useState(false)
|
|
299
|
+
const [picking, setPicking] = useState(false)
|
|
300
|
+
const [hovered, setHovered] = useState<Element | null>(null)
|
|
301
|
+
const [draft, setDraft] = useState<Draft | null>(null)
|
|
302
|
+
const [panelOpen, setPanelOpen] = useState(false)
|
|
303
|
+
const [showAll, setShowAll] = useState(false)
|
|
304
|
+
const [showResolved, setShowResolved] = useState(false)
|
|
305
|
+
const [revealId, setRevealId] = useState<string | null>(takePendingReveal)
|
|
306
|
+
const [flash, setFlash] = useState<Box | null>(null)
|
|
307
|
+
const href = useHref()
|
|
308
|
+
useLayoutTick()
|
|
309
|
+
|
|
310
|
+
useEffect(() => {
|
|
311
|
+
client.connect({ threads: setThreads, agentWatching: setWatching, connected: setConnected })
|
|
312
|
+
return () => client.close()
|
|
313
|
+
}, [client])
|
|
314
|
+
|
|
315
|
+
const cancelPicking = useCallback(() => {
|
|
316
|
+
setPicking(false)
|
|
317
|
+
setHovered(null)
|
|
318
|
+
}, [])
|
|
319
|
+
const pick = useCallback((element: Element, range?: Range) => {
|
|
320
|
+
setPicking(false)
|
|
321
|
+
setHovered(null)
|
|
322
|
+
window.getSelection()?.removeAllRanges()
|
|
323
|
+
setDraft({ anchor: describe(element, location.href, range), box: boxOf(element), element, sources: resolveSources(bundleFrames(element)) })
|
|
324
|
+
}, [])
|
|
325
|
+
usePicker(host, picking, setHovered, pick, cancelPicking)
|
|
326
|
+
// The panel covers the right edge of the page, which the reviewer may want to pick from.
|
|
327
|
+
const startPicking = () => {
|
|
328
|
+
setPanelOpen(false)
|
|
329
|
+
setDraft(null)
|
|
330
|
+
setPicking(true)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const page = pageKey(href)
|
|
334
|
+
const onPage = useMemo(
|
|
335
|
+
() => threads.filter((thread) => pageKey(thread.anchor.url) === page && (showResolved || thread.state !== 'resolved')),
|
|
336
|
+
[threads, page, showResolved],
|
|
337
|
+
)
|
|
338
|
+
const listed = showAll ? threads.filter((thread) => showResolved || thread.state !== 'resolved') : onPage
|
|
339
|
+
const numbers = new Map(onPage.map((thread, index) => [thread.id, index + 1]))
|
|
340
|
+
const pending = threads.filter((thread) => thread.state !== 'resolved').length
|
|
341
|
+
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
if (revealId === null) return
|
|
344
|
+
const thread = threads.find((candidate) => candidate.id === revealId)
|
|
345
|
+
if (thread === undefined || pageKey(thread.anchor.url) !== page) return
|
|
346
|
+
const element = find(document, thread.anchor)
|
|
347
|
+
if (element === null) return
|
|
348
|
+
element.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
349
|
+
setRevealId(null)
|
|
350
|
+
setTimeout(() => setFlash(boxOf(element)), 350)
|
|
351
|
+
setTimeout(() => setFlash(null), 1600)
|
|
352
|
+
}, [revealId, threads, page])
|
|
353
|
+
|
|
354
|
+
const reveal = (thread: ReviewThread) => {
|
|
355
|
+
setRevealId(thread.id)
|
|
356
|
+
if (pageKey(thread.anchor.url) !== page) navigate(thread.anchor.url, thread.id)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const presence = !connected ? 'Daemon offline' : watching ? 'Claude is listening' : 'Claude is not listening'
|
|
360
|
+
const hoveredBox = hovered === null ? null : boxOf(hovered)
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<>
|
|
364
|
+
{hoveredBox !== null && <div class="highlight" style={hoveredBox} />}
|
|
365
|
+
{flash !== null && <div class="highlight flash" style={flash} />}
|
|
366
|
+
{onPage.map((thread) => {
|
|
367
|
+
const element = find(document, thread.anchor)
|
|
368
|
+
if (element === null) return null
|
|
369
|
+
const box = boxOf(element)
|
|
370
|
+
if (box.width === 0 && box.height === 0) return null
|
|
371
|
+
return (
|
|
372
|
+
<button
|
|
373
|
+
key={thread.id}
|
|
374
|
+
type="button"
|
|
375
|
+
class={`pin ${thread.state}`}
|
|
376
|
+
style={{ top: Math.max(0, box.top - 11), left: Math.max(0, box.left + box.width - 11) }}
|
|
377
|
+
title={thread.comments[0]?.body}
|
|
378
|
+
onClick={() => setPanelOpen(true)}
|
|
379
|
+
>
|
|
380
|
+
{numbers.get(thread.id)}
|
|
381
|
+
</button>
|
|
382
|
+
)
|
|
383
|
+
})}
|
|
384
|
+
{draft !== null && (
|
|
385
|
+
<Composer
|
|
386
|
+
draft={draft}
|
|
387
|
+
onCancel={() => setDraft(null)}
|
|
388
|
+
onSubmit={async (body, send, screenshot) => {
|
|
389
|
+
// The capture asks for the tab, which the browser allows only while the click still counts as a gesture.
|
|
390
|
+
const image = screenshot ? await captureElement(draft.element, host) : undefined
|
|
391
|
+
const sources = await draft.sources
|
|
392
|
+
await client.createThread(sources.length === 0 ? draft.anchor : { ...draft.anchor, sources }, body, send, image)
|
|
393
|
+
setDraft(null)
|
|
394
|
+
}}
|
|
395
|
+
/>
|
|
396
|
+
)}
|
|
397
|
+
{panelOpen && (
|
|
398
|
+
<aside class="panel">
|
|
399
|
+
<header>
|
|
400
|
+
<h2>Review</h2>
|
|
401
|
+
<button type="button" class="ghost" onClick={() => setPanelOpen(false)}>Close</button>
|
|
402
|
+
</header>
|
|
403
|
+
<div class="tabs">
|
|
404
|
+
<button type="button" class={showAll ? '' : 'active'} onClick={() => setShowAll(false)}>This page</button>
|
|
405
|
+
<button type="button" class={showAll ? 'active' : ''} onClick={() => setShowAll(true)}>All pages</button>
|
|
406
|
+
<button type="button" class={showResolved ? 'active' : ''} onClick={() => setShowResolved(!showResolved)}>Resolved</button>
|
|
407
|
+
</div>
|
|
408
|
+
<div class="threads">
|
|
409
|
+
{listed.length === 0 && <div class="empty">No comments yet. Click “Comment”, then an element or a text selection.</div>}
|
|
410
|
+
{listed.map((thread) => (
|
|
411
|
+
<ThreadCard
|
|
412
|
+
key={thread.id}
|
|
413
|
+
thread={thread}
|
|
414
|
+
number={numbers.get(thread.id) ?? null}
|
|
415
|
+
client={client}
|
|
416
|
+
onReveal={() => reveal(thread)}
|
|
417
|
+
/>
|
|
418
|
+
))}
|
|
419
|
+
</div>
|
|
420
|
+
</aside>
|
|
421
|
+
)}
|
|
422
|
+
<div class={`toolbar${panelOpen ? ' beside-panel' : ''}`}>
|
|
423
|
+
<span class="presence" title={presence}>
|
|
424
|
+
<span class={`dot ${!connected ? 'off-line' : watching ? 'on' : ''}`} />
|
|
425
|
+
{presence}
|
|
426
|
+
</span>
|
|
427
|
+
<button type="button" class={picking ? 'active' : ''} onClick={() => (picking ? cancelPicking() : startPicking())}>
|
|
428
|
+
{picking ? 'Pick an element…' : 'Comment'}
|
|
429
|
+
</button>
|
|
430
|
+
<button type="button" class={panelOpen ? 'active' : ''} onClick={() => setPanelOpen(!panelOpen)}>
|
|
431
|
+
Threads{pending > 0 ? ` (${pending})` : ''}
|
|
432
|
+
</button>
|
|
433
|
+
</div>
|
|
434
|
+
</>
|
|
435
|
+
)
|
|
436
|
+
}
|
package/daemon/src/components.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* through `#canvas/components`. A name here with no matching export renders as
|
|
7
7
|
* undefined.
|
|
8
8
|
*/
|
|
9
|
-
import type { CanvasComponents } from
|
|
9
|
+
import type { CanvasComponents } from '@fabrika/canvas-kernel/server'
|
|
10
10
|
|
|
11
11
|
export const HOST_COMPONENTS: CanvasComponents = {
|
|
12
|
-
|
|
13
|
-
}
|
|
12
|
+
SecretInput: { requiredProps: { id: 'string', label: 'string', env: 'string' } },
|
|
13
|
+
}
|