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.
Files changed (99) hide show
  1. package/README.md +42 -17
  2. package/bin/agent-canvas.js +7 -7
  3. package/bin/agent-canvas.ts +48 -37
  4. package/bin/lib/commands/daemon.ts +46 -43
  5. package/bin/lib/commands/exec.ts +100 -100
  6. package/bin/lib/commands/fetch.ts +15 -15
  7. package/bin/lib/commands/install.ts +36 -36
  8. package/bin/lib/commands/instructions.ts +51 -51
  9. package/bin/lib/commands/push.ts +115 -97
  10. package/bin/lib/commands/review.ts +190 -0
  11. package/bin/lib/commands/watch.ts +24 -24
  12. package/bin/lib/config.ts +12 -12
  13. package/bin/lib/daemon-lifecycle.ts +47 -51
  14. package/bin/lib/helpers.ts +82 -76
  15. package/bin/lib/instructions/app-review.md +88 -0
  16. package/bin/lib/instructions/component-checklist.md +7 -5
  17. package/bin/lib/instructions/component-custom.md +20 -14
  18. package/bin/lib/instructions/component-diff.md +4 -3
  19. package/bin/lib/instructions/component-item.md +18 -8
  20. package/bin/lib/instructions/component-markdown.md +4 -2
  21. package/bin/lib/instructions/component-mermaid.md +12 -6
  22. package/bin/lib/instructions/component-multichoice.md +6 -2
  23. package/bin/lib/instructions/component-note.md +1 -1
  24. package/bin/lib/instructions/component-priority.md +1 -1
  25. package/bin/lib/instructions/component-rangeinput.md +9 -2
  26. package/bin/lib/instructions/component-secretinput.md +12 -11
  27. package/bin/lib/instructions/component-svg.md +62 -16
  28. package/bin/lib/instructions/component-table.md +6 -6
  29. package/bin/lib/instructions/component-usefeedback.md +8 -8
  30. package/bin/lib/instructions/component-userinput.md +6 -2
  31. package/bin/lib/instructions/flow-feature.md +22 -5
  32. package/bin/lib/instructions/flow-plan.md +5 -5
  33. package/bin/lib/instructions/flow-runbook.md +28 -25
  34. package/bin/lib/instructions/overview.md +27 -18
  35. package/bin/lib/instructions/theming.md +5 -0
  36. package/daemon/build.ts +171 -152
  37. package/daemon/client/App.tsx +1205 -1077
  38. package/daemon/client/CompareView.tsx +478 -458
  39. package/daemon/client/FileBrowser.tsx +241 -203
  40. package/daemon/client/FileViewer.tsx +220 -211
  41. package/daemon/client/OverviewView.tsx +283 -267
  42. package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
  43. package/daemon/client/RevisionSelect.tsx +111 -95
  44. package/daemon/client/SessionSwitcher.tsx +81 -81
  45. package/daemon/client/ShareDialog.tsx +211 -212
  46. package/daemon/client/apiSchemas.ts +78 -0
  47. package/daemon/client/canvas-runtime.ts +1 -1
  48. package/daemon/client/clientApi.ts +194 -206
  49. package/daemon/client/components/SecretInput.tsx +138 -138
  50. package/daemon/client/components/index.ts +2 -2
  51. package/daemon/client/domDiff.ts +341 -333
  52. package/daemon/client/errorMessage.ts +3 -0
  53. package/daemon/client/exportMarkdown.ts +222 -201
  54. package/daemon/client/lcs.ts +19 -14
  55. package/daemon/client/shareCrypto.ts +57 -57
  56. package/daemon/client/unifiedDiff.ts +660 -649
  57. package/daemon/dist/annotations-runtime.js +1 -1
  58. package/daemon/dist/client.css +2 -2
  59. package/daemon/dist/client.js +77 -80
  60. package/daemon/dist/components.js +59 -59
  61. package/daemon/dist/preact-compat.js +1 -1
  62. package/daemon/dist/review.js +361 -0
  63. package/daemon/dist/runtime.js +1 -1
  64. package/daemon/langMap.ts +24 -6
  65. package/daemon/review-widget/anchor.ts +187 -0
  66. package/daemon/review-widget/client.ts +95 -0
  67. package/daemon/review-widget/index.tsx +36 -0
  68. package/daemon/review-widget/markup.ts +71 -0
  69. package/daemon/review-widget/screenshot.ts +91 -0
  70. package/daemon/review-widget/source.ts +103 -0
  71. package/daemon/review-widget/styles.css +359 -0
  72. package/daemon/review-widget/text-modules.d.ts +4 -0
  73. package/daemon/review-widget/tsconfig.json +16 -0
  74. package/daemon/review-widget/widget.tsx +436 -0
  75. package/daemon/src/components.ts +3 -3
  76. package/daemon/src/handlers/api.ts +330 -328
  77. package/daemon/src/handlers/files.ts +112 -106
  78. package/daemon/src/handlers/static.ts +47 -46
  79. package/daemon/src/handlers/uploads.ts +61 -56
  80. package/daemon/src/paths.ts +13 -10
  81. package/daemon/src/remote-feedback.ts +192 -192
  82. package/daemon/src/review/markdown.ts +52 -0
  83. package/daemon/src/review/model.ts +132 -0
  84. package/daemon/src/review/proxy.ts +177 -0
  85. package/daemon/src/review/server.ts +199 -0
  86. package/daemon/src/review/store.ts +151 -0
  87. package/daemon/src/server.ts +127 -86
  88. package/daemon/src/share.ts +163 -169
  89. package/daemon/src/stubFetch.ts +7 -0
  90. package/daemon/tsconfig.json +19 -19
  91. package/package.json +47 -47
  92. package/skills/canvas/SKILL.md +5 -1
  93. package/bin/lib/commands/exec.test.ts +0 -27
  94. package/bin/lib/helpers.test.ts +0 -79
  95. package/daemon/src/files-api.test.ts +0 -78
  96. package/daemon/src/remote-feedback.test.ts +0 -165
  97. package/daemon/src/secrets-api.test.ts +0 -123
  98. package/daemon/src/share-e2e.test.ts +0 -277
  99. 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
+ }
@@ -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 "@fabrika/canvas-kernel/server";
9
+ import type { CanvasComponents } from '@fabrika/canvas-kernel/server'
10
10
 
11
11
  export const HOST_COMPONENTS: CanvasComponents = {
12
- SecretInput: { requiredProps: { id: "string", label: "string", env: "string" } },
13
- };
12
+ SecretInput: { requiredProps: { id: 'string', label: 'string', env: 'string' } },
13
+ }