agent-canvas 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,22 @@
1
+ import { z } from 'zod'
1
2
  import type { ReviewStore } from './store'
2
3
 
4
+ /** Vite's hot reload message that makes its client reload the page. */
5
+ const viteFullReloadSchema = z.object({ type: z.literal('full-reload'), path: z.string().default('*') })
6
+
7
+ /** The page path a Vite full reload names, or `undefined` for any other frame. */
8
+ export function viteFullReloadPath(frame: string): string | undefined {
9
+ if (!frame.includes('full-reload')) return undefined
10
+ let message: unknown
11
+ try {
12
+ message = JSON.parse(frame)
13
+ } catch {
14
+ return undefined
15
+ }
16
+ const parsed = viteFullReloadSchema.safeParse(message)
17
+ return parsed.success ? parsed.data.path : undefined
18
+ }
19
+
3
20
  /** One browser socket proxied to the application, e.g. its dev server's hot reload. */
4
21
  interface ProxySocketData {
5
22
  upstream: WebSocket
@@ -38,7 +55,15 @@ function connectUpstream(url: URL, protocols: string[]): Promise<WebSocket> {
38
55
  export class ReviewProxies {
39
56
  private readonly running = new Map<string, RunningProxy>()
40
57
 
41
- constructor(private readonly store: ReviewStore, private readonly widgetScript: string) {}
58
+ /**
59
+ * `onFullReload` receives the full reloads the application's dev server asks for: the proxy holds them back from
60
+ * the page, so a reload does not take the comment the reviewer is writing.
61
+ */
62
+ constructor(
63
+ private readonly store: ReviewStore,
64
+ private readonly widgetScript: string,
65
+ private readonly onFullReload: (project: string, path: string) => void,
66
+ ) {}
42
67
 
43
68
  /** Serves the project's application at `target` and returns where the reviewer opens it, `target`'s path kept. */
44
69
  open(project: string, target: string): string {
@@ -94,7 +119,7 @@ export class ReviewProxies {
94
119
  const upstreamUrl = new URL(`${url.pathname}${url.search}`, target)
95
120
  if (req.headers.get('Upgrade')?.toLowerCase() === 'websocket') {
96
121
  upstreamUrl.protocol = upstreamUrl.protocol === 'https:' ? 'wss:' : 'ws:'
97
- return await this.upgrade(req, server, upstreamUrl)
122
+ return await this.upgrade(project, req, server, upstreamUrl)
98
123
  }
99
124
  return await forward(req, upstreamUrl, url.origin, widgetTag)
100
125
  },
@@ -116,7 +141,7 @@ export class ReviewProxies {
116
141
  }
117
142
 
118
143
  /** Connects to the application first, so the browser gets the subprotocol the application chose. */
119
- private async upgrade(req: Request, server: ProxyServer, upstreamUrl: URL): Promise<Response | undefined> {
144
+ private async upgrade(project: string, req: Request, server: ProxyServer, upstreamUrl: URL): Promise<Response | undefined> {
120
145
  const protocols = (req.headers.get('Sec-WebSocket-Protocol') ?? '').split(',').map((protocol) => protocol.trim()).filter((protocol) =>
121
146
  protocol !== ''
122
147
  )
@@ -129,6 +154,8 @@ export class ReviewProxies {
129
154
  const data: ProxySocketData = { upstream, early: [], browser: null }
130
155
  upstream.onmessage = (event) => {
131
156
  if (typeof event.data !== 'string' && !(event.data instanceof ArrayBuffer)) return
157
+ const reloadPath = typeof event.data === 'string' ? viteFullReloadPath(event.data) : undefined
158
+ if (reloadPath !== undefined) return this.onFullReload(project, reloadPath)
132
159
  if (data.browser === null) data.early.push(event.data)
133
160
  else data.browser.send(event.data)
134
161
  }
@@ -130,6 +130,15 @@ export function createReviewServer(store: ReviewStore, proxies: ReviewProxies) {
130
130
  notifyAgent(project)
131
131
  return { thread }
132
132
  }),
133
+ route('POST', '/api/review/:project/notes/send', 'browser', async (_req, match) => {
134
+ const project = projectOf(match)
135
+ const threads = store.sendNotes(project)
136
+ if (threads.length > 0) {
137
+ publish(project)
138
+ notifyAgent(project)
139
+ }
140
+ return { threads }
141
+ }),
133
142
  route('POST', '/api/review/:project/threads/:thread/resolve', 'browser', async (_req, match) => {
134
143
  const project = projectOf(match)
135
144
  const thread = store.setResolved(project, threadIdOf(match), true)
@@ -170,6 +179,11 @@ export function createReviewServer(store: ReviewStore, proxies: ReviewProxies) {
170
179
 
171
180
  return {
172
181
  channel,
182
+ /** Hands a full reload the application asked for to the project's widgets, which reload unless the reviewer is writing. */
183
+ fullReload(project: string, path: string): void {
184
+ const frame: ReviewBrowserFrame = { type: 'reload', path }
185
+ channel.broadcast(project, frame)
186
+ },
173
187
  /** Answers the request when it is review mode's: CORS preflight, a route, or a WebSocket upgrade. */
174
188
  fetch(
175
189
  req: Request,
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs'
2
2
  import { join } from 'path'
3
3
  import {
4
+ isUnsentNote,
4
5
  PROJECT_RE,
5
6
  type ReviewAnchor,
6
7
  type ReviewAuthor,
@@ -61,6 +62,16 @@ export class ReviewStore {
61
62
  })
62
63
  }
63
64
 
65
+ /** Sends every unsent note at once, so the agent picks them up as one batch. */
66
+ sendNotes(project: string): readonly ReviewThread[] {
67
+ const threads = this.list(project)
68
+ const updatedAt = this.timestamp()
69
+ const sent = threads.filter(isUnsentNote).map((thread): ReviewThread => ({ ...thread, state: 'queued', updatedAt }))
70
+ const sentById = new Map(sent.map((thread) => [thread.id, thread]))
71
+ this.save(project, threads.map((thread) => sentById.get(thread.id) ?? thread))
72
+ return sent
73
+ }
74
+
64
75
  setResolved(project: string, threadId: string, resolved: boolean): ReviewThread {
65
76
  return this.update(project, threadId, (thread) => ({ ...thread, state: resolved ? 'resolved' : 'open', updatedAt: this.timestamp() }))
66
77
  }
@@ -27,7 +27,7 @@ chmodSync(CLI_AUTH_FILE, 0o600)
27
27
  const sessionManager = new SessionManager(SESSIONS_DIR)
28
28
  const wsManager = createWebSocketManager(sessionManager)
29
29
  const reviewStore = new ReviewStore(REVIEWS_DIR)
30
- const reviewProxies = new ReviewProxies(reviewStore, `http://localhost:${PORT}/assets/review.js`)
30
+ const reviewProxies = new ReviewProxies(reviewStore, `http://localhost:${PORT}/assets/review.js`, (project, path) => review.fullReload(project, path))
31
31
  const review = createReviewServer(reviewStore, reviewProxies)
32
32
 
33
33
  type SocketData = WSData | ReviewSocketData
@@ -14,7 +14,7 @@
14
14
  "#canvas/components": ["./client/components/index.ts"]
15
15
  }
16
16
  },
17
- "include": ["src/**/*", "client/**/*"],
17
+ "include": ["src/**/*", "client/**/*", "review-widget/**/*.test.ts"],
18
18
  "exclude": [
19
19
  "src/share-e2e.test.ts"
20
20
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-canvas",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Interactive visual canvas for Claude Code — rich annotatable documents in the browser",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,9 +30,9 @@
30
30
  "test": "bun test"
31
31
  },
32
32
  "dependencies": {
33
- "@fabrika/annotations": "0.8.0",
34
- "@fabrika/canvas-kernel": "0.8.0",
35
- "@fabrika/daemon-kit": "0.8.0",
33
+ "@fabrika/annotations": "0.8.2",
34
+ "@fabrika/canvas-kernel": "0.8.2",
35
+ "@fabrika/daemon-kit": "0.8.2",
36
36
  "@jridgewell/trace-mapping": "^0.3.31",
37
37
  "marked": "^17.0.4",
38
38
  "mermaid-parser-bundle": "^0.1.2",