@vintasoftware/pr-review-canvas 0.2.0 → 0.4.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 (138) hide show
  1. package/README.md +61 -26
  2. package/docs/reference.md +231 -121
  3. package/package.json +18 -4
  4. package/pr-review.config.example.yml +10 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +3 -0
  7. package/skills/pr-review-canvas/SKILL.md +77 -40
  8. package/src/acpx/acpx.ts +21 -4
  9. package/src/acpx/events.ts +5 -1
  10. package/src/acpx/ndjson.ts +3 -1
  11. package/src/acpx/preflight.ts +5 -1
  12. package/src/canvas/comment.ts +24 -0
  13. package/src/canvas/export.ts +11 -2
  14. package/src/canvas/import.ts +30 -9
  15. package/src/canvas/name.ts +1 -0
  16. package/src/canvas/zip.ts +25 -2
  17. package/src/chat/chat-manager.ts +55 -41
  18. package/src/chat/context.ts +10 -3
  19. package/src/chat/seed.ts +3 -1
  20. package/src/chat/threads.ts +27 -26
  21. package/src/cli.ts +24 -9
  22. package/src/commands.ts +86 -25
  23. package/src/config.ts +24 -24
  24. package/src/contract/api.ts +32 -1
  25. package/src/contract/canvas-manifest.ts +2 -0
  26. package/src/contract/comments.ts +8 -1
  27. package/src/contract/discovery.ts +5 -2
  28. package/src/contract/generation-context.ts +27 -2
  29. package/src/contract/review-artifact.ts +14 -8
  30. package/src/contract/review-key.ts +51 -0
  31. package/src/contract/reviews.ts +17 -0
  32. package/src/contract/settings.ts +2 -0
  33. package/src/contract/state.ts +41 -19
  34. package/src/git/diff-collector.ts +2 -1
  35. package/src/git/environment.mjs +27 -0
  36. package/src/git/git.ts +117 -12
  37. package/src/git/local-target.ts +138 -0
  38. package/src/git/patch-lines.ts +34 -2
  39. package/src/git/pr-refs.ts +36 -0
  40. package/src/github/attachments.ts +9 -257
  41. package/src/github/canvas-comment.ts +22 -0
  42. package/src/github/capabilities.ts +3 -37
  43. package/src/github/comments.ts +9 -27
  44. package/src/github/post-comment.ts +7 -37
  45. package/src/github/post-review.ts +4 -19
  46. package/src/github/pr.ts +6 -84
  47. package/src/github/threads.ts +6 -2
  48. package/src/gitlab/attachments.ts +40 -0
  49. package/src/gitlab/canvas-comment.ts +26 -0
  50. package/src/gitlab/capabilities.ts +64 -0
  51. package/src/gitlab/comments.ts +164 -0
  52. package/src/gitlab/mr.ts +115 -0
  53. package/src/gitlab/post-comment.ts +111 -0
  54. package/src/gitlab/post-review.ts +54 -0
  55. package/src/gitlab/project.ts +13 -0
  56. package/src/host/attachments.ts +293 -0
  57. package/src/host/capabilities.ts +38 -0
  58. package/src/host/client.ts +245 -0
  59. package/src/host/host.ts +136 -0
  60. package/src/host/pr.ts +51 -0
  61. package/src/host/remote.ts +42 -0
  62. package/src/project-config.ts +25 -9
  63. package/src/prompt-files.ts +4 -3
  64. package/src/review/carry-over.ts +79 -0
  65. package/src/review/doctor.ts +31 -14
  66. package/src/review/normalize.ts +3 -1
  67. package/src/review/prepare.ts +87 -17
  68. package/src/review/prompt.ts +8 -2
  69. package/src/review/publish.ts +76 -13
  70. package/src/{github → review}/review-body.ts +17 -5
  71. package/src/review/skill-command.ts +5 -3
  72. package/src/review/trim-caps.ts +10 -6
  73. package/src/review/validate-folds.ts +2 -2
  74. package/src/review/validate.ts +67 -15
  75. package/src/server/app.ts +12 -4
  76. package/src/server/bundle.ts +320 -106
  77. package/src/server/context.ts +24 -10
  78. package/src/server/errors.ts +56 -10
  79. package/src/server/html.ts +36 -12
  80. package/src/server/node-server.ts +4 -2
  81. package/src/server/routes/api.ts +92 -31
  82. package/src/server/routes/chat-routes.ts +105 -46
  83. package/src/server/routes/pages.ts +25 -9
  84. package/src/server/routes/review-routes.ts +101 -35
  85. package/src/server/sse.ts +3 -1
  86. package/src/store/atomic-json.ts +5 -1
  87. package/src/store/canvas-store.ts +95 -46
  88. package/src/store/data-dir.ts +2 -1
  89. package/src/store/derived-store.ts +41 -23
  90. package/src/store/pr-store.ts +25 -15
  91. package/src/store/state-store.ts +42 -37
  92. package/static/brand.svg +19 -0
  93. package/static/js/api.js +47 -27
  94. package/static/js/app.js +27 -7
  95. package/static/js/chat-panel.js +93 -0
  96. package/static/js/chat.js +41 -17
  97. package/static/js/composer.js +30 -17
  98. package/static/js/contract-types.d.ts +3 -0
  99. package/static/js/diagram.js +2 -1
  100. package/static/js/diff-decorations.js +5 -3
  101. package/static/js/diff-renderer.js +7 -2
  102. package/static/js/dom.js +5 -7
  103. package/static/js/download.js +1 -1
  104. package/static/js/empty-state.js +85 -16
  105. package/static/js/errors.js +22 -6
  106. package/static/js/header.js +32 -9
  107. package/static/js/host.js +40 -0
  108. package/static/js/import-zone.js +1 -1
  109. package/static/js/interactions.js +56 -17
  110. package/static/js/keyboard.js +7 -2
  111. package/static/js/layers.js +26 -14
  112. package/static/js/links.js +9 -3
  113. package/static/js/markdown.js +28 -1
  114. package/static/js/nav.js +5 -2
  115. package/static/js/overview.js +32 -4
  116. package/static/js/points.js +5 -3
  117. package/static/js/progress.js +2 -1
  118. package/static/js/proposed-comment.js +4 -1
  119. package/static/js/quick-questions.js +2 -1
  120. package/static/js/regenerate.js +4 -1
  121. package/static/js/review-session.js +7 -2
  122. package/static/js/settings.js +2 -1
  123. package/static/js/signoff.js +9 -6
  124. package/static/styles/base.css +16 -6
  125. package/static/styles/chat-panel.css +81 -0
  126. package/static/styles/chat-tools.css +28 -0
  127. package/static/styles/chat.css +1 -1
  128. package/static/styles/commands.css +10 -4
  129. package/static/styles/diff.css +1 -1
  130. package/static/styles/header.css +18 -4
  131. package/static/styles/layout.css +4 -4
  132. package/static/styles/panels.css +4 -0
  133. package/static/styles/responsive.css +1 -15
  134. package/static/styles/review-actions.css +1 -0
  135. package/static/styles/review.css +24 -3
  136. package/static/styles/skin-github.css +99 -100
  137. package/static/styles.css +13 -12
  138. package/src/github/gh.ts +0 -196
package/static/js/api.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // @ts-check
2
2
  /** @typedef {import('./contract-types.js').ErrorEnvelope} ErrorEnvelope */
3
+ /** @typedef {import('./contract-types.js').ReviewKey} ReviewKey */
3
4
  /** @typedef {import('./contract-types.js').PrBundle} PrBundle */
4
5
  /** @typedef {import('./contract-types.js').PatchesResponse} PatchesResponse */
5
6
 
@@ -69,17 +70,20 @@ export async function fetchJson(url, opts = {}) {
69
70
  }
70
71
 
71
72
  /**
72
- * @param {number} prNumber
73
- * @param {{ refresh?: boolean, fetchImpl?: typeof fetch | undefined }} [opts]
73
+ * The bundle for one target. `refresh` asks the server to read the forge, or the working tree,
74
+ * again; `poll` says this is the background poller, which is answered from the head the page was
75
+ * opened with rather than by snapshotting the working tree every few seconds.
76
+ * @param {ReviewKey} prNumber
77
+ * @param {{ refresh?: boolean, poll?: boolean, fetchImpl?: typeof fetch | undefined }} [opts]
74
78
  * @returns {Promise<PrBundle>}
75
79
  */
76
80
  export function fetchBundle(prNumber, opts = {}) {
77
- const q = opts.refresh ? '?refresh=1' : ''
81
+ const q = opts.refresh ? '?refresh=1' : opts.poll ? '?poll=1' : ''
78
82
  return fetchJson(`/api/prs/${prNumber}${q}`, { fetchImpl: opts.fetchImpl })
79
83
  }
80
84
 
81
85
  /**
82
- * @param {number} prNumber
86
+ * @param {ReviewKey} prNumber
83
87
  * @param {{ headSha?: string, fetchImpl?: typeof fetch }} [opts]
84
88
  * @returns {Promise<PatchesResponse>}
85
89
  */
@@ -89,7 +93,7 @@ export function fetchPatches(prNumber, opts = {}) {
89
93
  }
90
94
 
91
95
  /**
92
- * @param {number} prNumber
96
+ * @param {ReviewKey} prNumber
93
97
  * @param {{ fetchImpl?: typeof fetch }} [opts]
94
98
  * @returns {Promise<import('./contract-types.js').StateResponse>}
95
99
  */
@@ -98,14 +102,18 @@ export function fetchState(prNumber, opts = {}) {
98
102
  }
99
103
 
100
104
  /**
101
- * @param {number} prNumber
105
+ * @param {ReviewKey} prNumber
102
106
  * @param {string} id `layer:<id>` or `layer:<id>/file:<key>`
103
107
  * @param {boolean} reviewed
104
- * @param {{ headSha?: string, fetchImpl?: typeof fetch }} [opts]
108
+ * @param {{ headSha?: string, canvasSha?: string, fetchImpl?: typeof fetch }} [opts]
105
109
  * @returns {Promise<import('./contract-types.js').StateResponse>}
106
110
  */
107
111
  export function putReviewed(prNumber, id, reviewed, opts = {}) {
108
- const body = opts.headSha === undefined ? { reviewed } : { reviewed, headSha: opts.headSha }
112
+ const body = {
113
+ reviewed,
114
+ ...(opts.headSha === undefined ? {} : { headSha: opts.headSha }),
115
+ ...(opts.canvasSha === undefined ? {} : { canvasSha: opts.canvasSha }),
116
+ }
109
117
  return fetchJson(`/api/prs/${prNumber}/reviewed/${id}`, {
110
118
  method: 'PUT',
111
119
  body,
@@ -114,7 +122,7 @@ export function putReviewed(prNumber, id, reviewed, opts = {}) {
114
122
  }
115
123
 
116
124
  /**
117
- * @param {number} prNumber
125
+ * @param {ReviewKey} prNumber
118
126
  * @param {string} fingerprint
119
127
  * @param {boolean} dismissed
120
128
  * @param {{ reason?: string, fetchImpl?: typeof fetch }} [opts]
@@ -130,7 +138,7 @@ export function putDismissed(prNumber, fingerprint, dismissed, opts = {}) {
130
138
  }
131
139
 
132
140
  /**
133
- * @param {number} prNumber
141
+ * @param {ReviewKey} prNumber
134
142
  * @param {number} rootCommentId
135
143
  * @param {boolean} hidden
136
144
  * @param {{ fetchImpl?: typeof fetch }} [opts]
@@ -145,7 +153,7 @@ export function putThreadHidden(prNumber, rootCommentId, hidden, opts = {}) {
145
153
  }
146
154
 
147
155
  /**
148
- * @param {number} prNumber
156
+ * @param {ReviewKey} prNumber
149
157
  * @param {{ refresh?: boolean, fetchImpl?: typeof fetch }} [opts]
150
158
  * @returns {Promise<import('./contract-types.js').Capabilities>}
151
159
  */
@@ -155,17 +163,21 @@ export function fetchCapabilities(prNumber, opts = {}) {
155
163
  }
156
164
 
157
165
  /**
158
- * @param {number} prNumber
166
+ * @param {ReviewKey} prNumber
159
167
  * @param {import('./contract-types.js').PostCommentInput} input
160
168
  * @param {{ fetchImpl?: typeof fetch }} [opts]
161
169
  * @returns {Promise<import('./contract-types.js').PostCommentResponse>}
162
170
  */
163
171
  export function postComment(prNumber, input, opts = {}) {
164
- return fetchJson(`/api/prs/${prNumber}/comments`, { method: 'POST', body: input, fetchImpl: opts.fetchImpl })
172
+ return fetchJson(`/api/prs/${prNumber}/comments`, {
173
+ method: 'POST',
174
+ body: input,
175
+ fetchImpl: opts.fetchImpl,
176
+ })
165
177
  }
166
178
 
167
179
  /**
168
- * @param {number} prNumber
180
+ * @param {ReviewKey} prNumber
169
181
  * @param {{ fetchImpl?: typeof fetch }} [opts]
170
182
  * @returns {Promise<import('./contract-types.js').ReviewBodyResponse>}
171
183
  */
@@ -174,7 +186,7 @@ export function fetchReviewBody(prNumber, opts = {}) {
174
186
  }
175
187
 
176
188
  /**
177
- * @param {number} prNumber
189
+ * @param {ReviewKey} prNumber
178
190
  * @param {{ event: 'APPROVE' | 'REQUEST_CHANGES', body?: string, headSha?: string }} input
179
191
  * @param {{ fetchImpl?: typeof fetch }} [opts]
180
192
  * @returns {Promise<import('./contract-types.js').PostReviewResponse>}
@@ -206,7 +218,7 @@ export const POLL_SLOW_INTERVAL_MS = 15_000
206
218
  * After five minutes of waiting the gap grows to 15 s, because a generation that has not finished
207
219
  * by then takes minutes more. A failed poll is reported and polling goes on; the page never
208
220
  * blocks on it.
209
- * @param {number} prNumber
221
+ * @param {ReviewKey} prNumber
210
222
  * @param {PollOptions} [opts]
211
223
  * @returns {{ stop: () => void }}
212
224
  */
@@ -226,7 +238,7 @@ export function pollBundle(prNumber, opts = {}) {
226
238
  /** @type {PrBundle} */
227
239
  let bundle
228
240
  try {
229
- bundle = await fetchBundle(prNumber, { fetchImpl: opts.fetchImpl })
241
+ bundle = await fetchBundle(prNumber, { poll: true, fetchImpl: opts.fetchImpl })
230
242
  } catch (err) {
231
243
  if (!stopped) {
232
244
  opts.onError?.(err)
@@ -302,7 +314,7 @@ export function uploadForm(url, form, opts = {}) {
302
314
  }
303
315
 
304
316
  /**
305
- * @param {number} prNumber
317
+ * @param {ReviewKey} prNumber
306
318
  * @param {File} file
307
319
  * @param {{ force?: boolean, onProgress?: (fraction: number) => void, xhrImpl?: () => XMLHttpRequest }} [opts]
308
320
  * @returns {Promise<import('./contract-types.js').ImportResult>}
@@ -317,7 +329,7 @@ export function importCanvas(prNumber, file, opts = {}) {
317
329
  }
318
330
 
319
331
  /**
320
- * @param {number} prNumber
332
+ * @param {ReviewKey} prNumber
321
333
  * @param {{ fetchImpl?: typeof fetch }} [opts]
322
334
  * @returns {Promise<import('./contract-types.js').SharedCanvasFetchResponse>}
323
335
  */
@@ -327,7 +339,7 @@ export function fetchSharedCanvas(prNumber, opts = {}) {
327
339
 
328
340
  /**
329
341
  * The zip as a blob plus the name the server chose, ready to hand to the browser.
330
- * @param {number} prNumber
342
+ * @param {ReviewKey} prNumber
331
343
  * @param {{ headSha?: string, fetchImpl?: typeof fetch }} [opts]
332
344
  * @returns {Promise<{ blob: Blob, filename: string }>}
333
345
  */
@@ -374,7 +386,7 @@ export function saveAppearance(input, opts = {}) {
374
386
  /* ---- AI Chat and its settings ---- */
375
387
 
376
388
  /**
377
- * @param {number} prNumber
389
+ * @param {ReviewKey} prNumber
378
390
  * @param {{ fetchImpl?: typeof fetch }} [opts]
379
391
  * @returns {Promise<import('./contract-types.js').ChatThreadsResponse>}
380
392
  */
@@ -383,16 +395,20 @@ export function fetchThreads(prNumber, opts = {}) {
383
395
  }
384
396
 
385
397
  /**
386
- * @param {number} prNumber
398
+ * @param {ReviewKey} prNumber
387
399
  * @param {{ fetchImpl?: typeof fetch }} [opts]
388
400
  * @returns {Promise<import('./contract-types.js').ChatThreadsResponse>}
389
401
  */
390
402
  export function createThread(prNumber, opts = {}) {
391
- return fetchJson(`/api/prs/${prNumber}/chat/threads`, { method: 'POST', body: {}, fetchImpl: opts.fetchImpl })
403
+ return fetchJson(`/api/prs/${prNumber}/chat/threads`, {
404
+ method: 'POST',
405
+ body: {},
406
+ fetchImpl: opts.fetchImpl,
407
+ })
392
408
  }
393
409
 
394
410
  /**
395
- * @param {number} prNumber
411
+ * @param {ReviewKey} prNumber
396
412
  * @param {string} name
397
413
  * @param {{ fetchImpl?: typeof fetch }} [opts]
398
414
  * @returns {Promise<import('./contract-types.js').ChatHistoryResponse>}
@@ -404,12 +420,16 @@ export function fetchThreadHistory(prNumber, name, opts = {}) {
404
420
  }
405
421
 
406
422
  /**
407
- * @param {number} prNumber
423
+ * @param {ReviewKey} prNumber
408
424
  * @param {{ fetchImpl?: typeof fetch }} [opts]
409
425
  * @returns {Promise<{ cancelled: boolean }>}
410
426
  */
411
427
  export function cancelChat(prNumber, opts = {}) {
412
- return fetchJson(`/api/prs/${prNumber}/chat/cancel`, { method: 'POST', body: {}, fetchImpl: opts.fetchImpl })
428
+ return fetchJson(`/api/prs/${prNumber}/chat/cancel`, {
429
+ method: 'POST',
430
+ body: {},
431
+ fetchImpl: opts.fetchImpl,
432
+ })
413
433
  }
414
434
 
415
435
  /**
@@ -489,7 +509,7 @@ export function readSseFrames(buffer, chunk) {
489
509
  /**
490
510
  * Sends one chat message and calls `onEvent` for every frame until the turn ends. Rejects with
491
511
  * an ApiError when the server refuses the message (a busy chat, a context it cannot resolve).
492
- * @param {number} prNumber
512
+ * @param {ReviewKey} prNumber
493
513
  * @param {{ message: string, context: import('./chat-context.js').ChatContext, thread?: string }} input
494
514
  * @param {{
495
515
  * onEvent: (event: { event: string, data: unknown }) => void,
package/static/js/app.js CHANGED
@@ -19,7 +19,7 @@ import { initDeepLinks } from './deep-link.js'
19
19
  import { initDiagrams } from './diagram.js'
20
20
  import { esc, qs } from './dom.js'
21
21
  import { exportCanvasZip } from './download.js'
22
- import { renderEmptyState, renderStaleState, staleBarHtml } from './empty-state.js'
22
+ import { carriedOverBarHtml, renderEmptyState, renderStaleState, staleBarHtml } from './empty-state.js'
23
23
  import { errorCardHtml } from './errors.js'
24
24
  import { renderHeader } from './header.js'
25
25
  import { wireDropZone } from './import-zone.js'
@@ -33,8 +33,10 @@ import { initScrollSpy } from './scroll-spy.js'
33
33
  import { openSettingsDialog } from './settings.js'
34
34
  import { applySkin, nextSkin, readSkin, skinLabel } from './skin.js'
35
35
  import { applyTheme, nextTheme, readTheme, themeLabel } from './theme.js'
36
+ import { hostLabel, setHost } from './host.js'
36
37
 
37
- /** @typedef {{ prNumber: number, owner: string, repo: string, version: string }} Bootstrap */
38
+ /** @typedef {import('./contract-types.js').ReviewKey} ReviewKey */
39
+ /** @typedef {{ prNumber: ReviewKey, owner: string, repo: string, version: string, host: import('./contract-types.js').PublicHost }} Bootstrap */
38
40
 
39
41
  /** @returns {Bootstrap | null} */
40
42
  function readBootstrap() {
@@ -57,12 +59,14 @@ function footerHtml(version, bundle) {
57
59
  const canvas = bundle?.canvas
58
60
  ? `<span>canvas <span class="mono">${esc(bundle.canvas.headSha.slice(0, 7))}</span> · ${esc(bundle.canvas.source)}</span>`
59
61
  : ''
60
- return `<footer><span>pr-review ${esc(version)}</span>${canvas}<span>localhost only · nothing leaves this machine except GitHub posts you confirm</span></footer>`
62
+ return `<footer><span>pr-review ${esc(version)}</span>${canvas}<span>localhost only · nothing leaves this machine except ${esc(hostLabel())} posts you confirm</span></footer>`
61
63
  }
62
64
 
63
65
  /** @param {ReadonlyArray<string>} warnings */
64
66
  function bannerHtml(warnings) {
65
- return warnings.length === 0 ? '' : `<div class="banner" role="status">${warnings.map(w => esc(w)).join(' · ')}</div>`
67
+ return warnings.length === 0
68
+ ? ''
69
+ : `<div class="banner" role="status">${warnings.map(w => esc(w)).join(' · ')}</div>`
66
70
  }
67
71
 
68
72
  /**
@@ -81,7 +85,7 @@ function toEnvelopeError(err) {
81
85
  /**
82
86
  * The patches for the bundle. The first visit builds derived/ while the bundle resolves, so the
83
87
  * parallel fetch can land before the patches exist; one more try after the bundle is enough.
84
- * @param {number} prNumber
88
+ * @param {ReviewKey} prNumber
85
89
  * @param {Promise<Record<string, string> | null>} started
86
90
  * @param {string} [headSha] the canvas commit, when it is not the PR head
87
91
  */
@@ -153,6 +157,7 @@ export class PrAppElement extends HTMLElement {
153
157
  this.innerHTML = errorCardHtml({ code: 'INTERNAL', message: 'missing bootstrap data' })
154
158
  return
155
159
  }
160
+ setHost(this.bootstrap.host)
156
161
  this.theme = readTheme(document.documentElement)
157
162
  this.skin = readSkin(document.documentElement)
158
163
  const patchesPromise = fetchPatches(this.bootstrap.prNumber).then(
@@ -222,7 +227,12 @@ export class PrAppElement extends HTMLElement {
222
227
  now,
223
228
  })
224
229
  defineLayerElements()
225
- const staleBar = bundle.status === 'stale' && bundle.stale ? staleBarHtml(bundle.stale) : ''
230
+ const staleBar =
231
+ bundle.status === 'stale' && bundle.stale
232
+ ? staleBarHtml(bundle.stale, bundle.local)
233
+ : bundle.carriedOver
234
+ ? carriedOverBarHtml(bundle.carriedOver)
235
+ : ''
226
236
  this.innerHTML =
227
237
  header +
228
238
  bannerHtml(bundle.warnings) +
@@ -247,6 +257,8 @@ export class PrAppElement extends HTMLElement {
247
257
  state: bundle.state,
248
258
  capabilities,
249
259
  headSha: bundle.pr.headSha,
260
+ // The marks are keyed to the canvas on the page, so every mark names it.
261
+ ...(bundle.canvas === undefined ? {} : { canvasSha: bundle.canvas.headSha }),
250
262
  })
251
263
  const interactions = wireReview(this, session, {
252
264
  chat: () => this.chat,
@@ -369,7 +381,15 @@ export class PrAppElement extends HTMLElement {
369
381
  this.viewStale = true
370
382
  void this.render(bundle)
371
383
  })
372
- wireDropZone(this, { prNumber: boot.prNumber, importImpl: importCanvas, onImported: () => void this.reload() })
384
+ // Importing a canvas a teammate attached is a pull request thing; local work has no thread
385
+ // to attach one to, and the local screens draw no drop zone.
386
+ if (typeof boot.prNumber === 'number') {
387
+ wireDropZone(this, {
388
+ prNumber: boot.prNumber,
389
+ importImpl: importCanvas,
390
+ onImported: () => void this.reload(),
391
+ })
392
+ }
373
393
  }
374
394
 
375
395
  /**
@@ -0,0 +1,93 @@
1
+ // @ts-check
2
+
3
+ /** Keeps the same chat mounted while switching between the sidebar and floating panel.
4
+ * @param {HTMLElement} root
5
+ * @param {HTMLElement} pane
6
+ */
7
+ export function wireChatPanel(root, pane) {
8
+ const dialog = /** @type {HTMLDialogElement} */ (root.querySelector('#chat-dialog'))
9
+ const launcher = /** @type {HTMLButtonElement} */ (root.querySelector('#chat-launcher'))
10
+ const minimize = /** @type {HTMLButtonElement} */ (root.querySelector('#chat-minimize'))
11
+ const narrow = window.matchMedia('(max-width: 1360px)')
12
+ const mobile = window.matchMedia('(max-width: 600px)')
13
+ let expanded = false
14
+
15
+ function sync() {
16
+ if (dialog.open) dialog.close()
17
+ if (narrow.matches) {
18
+ dialog.append(pane)
19
+ if (expanded) {
20
+ if (mobile.matches) dialog.showModal()
21
+ else dialog.show()
22
+ }
23
+ } else {
24
+ dialog.before(pane)
25
+ }
26
+ launcher.setAttribute('aria-expanded', String(narrow.matches && expanded))
27
+ launcher.hidden = narrow.matches && expanded
28
+ }
29
+
30
+ function open() {
31
+ if (narrow.matches && !expanded) {
32
+ expanded = true
33
+ sync()
34
+ }
35
+ pane.querySelector('textarea')?.focus()
36
+ }
37
+
38
+ function close() {
39
+ expanded = false
40
+ sync()
41
+ launcher.focus()
42
+ }
43
+
44
+ /** @param {Event} event */
45
+ function cancel(event) {
46
+ event.preventDefault()
47
+ close()
48
+ }
49
+
50
+ /** @param {KeyboardEvent} event */
51
+ function trapFocus(event) {
52
+ if (event.key !== 'Tab' || !mobile.matches || !dialog.open) return
53
+ const controls = [
54
+ ...dialog.querySelectorAll('button, select, textarea, input, a[href], [tabindex]'),
55
+ ].filter(
56
+ el =>
57
+ el instanceof HTMLElement &&
58
+ el.tabIndex >= 0 &&
59
+ !el.matches(':disabled') &&
60
+ el.getClientRects().length
61
+ )
62
+ const first = /** @type {HTMLElement | undefined} */ (controls[0])
63
+ const last = /** @type {HTMLElement | undefined} */ (controls.at(-1))
64
+ if (event.shiftKey && document.activeElement === first) {
65
+ event.preventDefault()
66
+ last?.focus()
67
+ } else if (!event.shiftKey && document.activeElement === last) {
68
+ event.preventDefault()
69
+ first?.focus()
70
+ }
71
+ }
72
+
73
+ launcher.addEventListener('click', open)
74
+ minimize.addEventListener('click', close)
75
+ dialog.addEventListener('cancel', cancel)
76
+ dialog.addEventListener('keydown', trapFocus)
77
+ narrow.addEventListener('change', sync)
78
+ mobile.addEventListener('change', sync)
79
+ sync()
80
+
81
+ return {
82
+ open,
83
+ stop() {
84
+ if (dialog.open) dialog.close()
85
+ launcher.removeEventListener('click', open)
86
+ minimize.removeEventListener('click', close)
87
+ dialog.removeEventListener('cancel', cancel)
88
+ dialog.removeEventListener('keydown', trapFocus)
89
+ narrow.removeEventListener('change', sync)
90
+ mobile.removeEventListener('change', sync)
91
+ },
92
+ }
93
+ }
package/static/js/chat.js CHANGED
@@ -8,6 +8,7 @@
8
8
  /** @typedef {import('./review-session.js').ReviewSession} ReviewSession */
9
9
  import { cancelChat, createThread, fetchThreadHistory, fetchThreads, streamChat } from './api.js'
10
10
  import { chatContextAttrs, chatContextLabel, sameChatContext, WHOLE_PR } from './chat-context.js'
11
+ import { wireChatPanel } from './chat-panel.js'
11
12
  import {
12
13
  canScrollBy,
13
14
  chatScrollReducer,
@@ -22,6 +23,7 @@ import { postedCommentUrl, viewCommentHtml } from './comment-link.js'
22
23
  import { esc, qs } from './dom.js'
23
24
  import { getRenderContext } from './layers.js'
24
25
  import { renderMarkdown } from './markdown.js'
26
+ import { postToLabel } from './host.js'
25
27
  import { splitChatAnswer, targetsFromFiles } from './proposed-comment.js'
26
28
 
27
29
  export const CHAT_WIDTH_KEY = 'pr-review.chat-width'
@@ -43,7 +45,8 @@ export function renderChatShell(opts) {
43
45
  `<button class="handle" type="button" id="chat-handle" role="separator" aria-orientation="vertical" aria-label="Resize AI Chat" aria-valuenow="${width}" aria-valuemin="${CHAT_WIDTH_MIN}" aria-valuemax="${CHAT_WIDTH_MAX}"></button>` +
44
46
  '<div class="chat-h"><h2 id="chat-h">AI Chat</h2><label class="sr" for="thread">Thread</label>' +
45
47
  '<select id="thread"></select>' +
46
- '<button class="cmd" type="button" id="new-thread">new thread</button></div>' +
48
+ '<button class="cmd" type="button" id="new-thread">new thread</button>' +
49
+ '<button class="cmd chat-minimize" type="button" id="chat-minimize" aria-label="Minimize AI Chat">minimize</button></div>' +
47
50
  '<p class="ctx-line" id="chat-ctx-line">Context: <span class="ctx-chip" id="chat-ctx">whole PR</span>' +
48
51
  ' <button class="cmd" type="button" id="chat-clear" hidden>clear</button></p>' +
49
52
  '<div class="transcript" id="chat-log" role="log" aria-label="Transcript" aria-live="polite" tabindex="0">' +
@@ -55,7 +58,9 @@ export function renderChatShell(opts) {
55
58
  '<div class="tbtns"><button class="cmd fill" type="submit" id="chat-send">send</button>' +
56
59
  '<button class="cmd" type="button" id="chat-stop" hidden>stop</button>' +
57
60
  '<span class="muted small">enter to send</span></div></form>' +
58
- '</aside>'
61
+ '</aside>' +
62
+ '<dialog class="chat-dialog" id="chat-dialog" aria-labelledby="chat-h"></dialog>' +
63
+ '<button class="chat-launcher" id="chat-launcher" type="button" aria-controls="chat-dialog" aria-expanded="false">AI Chat</button>'
59
64
  )
60
65
  }
61
66
 
@@ -97,18 +102,19 @@ export function writeChatWidth(storage, width) {
97
102
  storage?.setItem(CHAT_WIDTH_KEY, String(clampWidth(width)))
98
103
  }
99
104
 
100
- /** The four questions the quick menu offers, and the one that just focuses the box. */
105
+ /** The questions the quick menu offers, and the one that just focuses the box. */
101
106
  export const QUICK_QUESTIONS = [
102
- 'Is this covered by tests?',
103
- 'What could break?',
107
+ 'Suggestion to solve this?',
104
108
  'Why this way, and what were the alternatives?',
109
+ 'What could break?',
105
110
  'Does this follow how the codebase already does it?',
111
+ 'Is this covered by tests?',
106
112
  ]
107
113
 
108
114
  /**
109
115
  * @typedef {{
110
116
  * root: HTMLElement,
111
- * prNumber: number,
117
+ * prNumber: import('./contract-types.js').ReviewKey,
112
118
  * session: ReviewSession,
113
119
  * storage?: Storage | null,
114
120
  * api?: Partial<ChatApi>,
@@ -148,7 +154,9 @@ function turnHtml(role, bodyHtml, opts = {}) {
148
154
  */
149
155
  function turnInnerHtml(role, bodyHtml, opts = {}) {
150
156
  const note =
151
- opts.incomplete === undefined ? '' : `<p class="muted small">the answer stopped early (${esc(opts.incomplete)})</p>`
157
+ opts.incomplete === undefined
158
+ ? ''
159
+ : `<p class="muted small">the answer stopped early (${esc(opts.incomplete)})</p>`
152
160
  return (
153
161
  `<span class="role">${role === 'assistant' ? 'AI Chat' : 'You'}</span>` +
154
162
  `<div class="prose">${bodyHtml}</div>${note}`
@@ -172,7 +180,7 @@ export function proposedCommentHtml(comment, id, postedUrl) {
172
180
  `<div class="prose">${renderMarkdown(comment.body)}</div>` +
173
181
  '<span class="tbtns">' +
174
182
  (postedUrl === undefined
175
- ? `<button class="cmd fill" type="button" data-act="proposed-post" data-proposed="${esc(id)}" data-needs-post>post to github</button>`
183
+ ? `<button class="cmd fill" type="button" data-act="proposed-post" data-proposed="${esc(id)}" data-needs-post>${postToLabel()}</button>`
176
184
  : viewCommentHtml(postedUrl, true)) +
177
185
  `<button class="cmd" type="button" data-act="proposed-edit" data-proposed="${esc(id)}" data-needs-post>edit</button>` +
178
186
  `<button class="cmd" type="button" data-copy="${esc(comment.body)}">copy</button>` +
@@ -235,7 +243,11 @@ export function wireChat(options) {
235
243
  const { root, prNumber, session } = options
236
244
  const api = withDefaults(options.api)
237
245
  const storage =
238
- options.storage === undefined ? (typeof localStorage === 'undefined' ? null : localStorage) : options.storage
246
+ options.storage === undefined
247
+ ? typeof localStorage === 'undefined'
248
+ ? null
249
+ : localStorage
250
+ : options.storage
239
251
  const paths = new Set(session.artifact.files.map(f => f.path))
240
252
  const targets = targetsFromFiles(session.artifact.files)
241
253
  const reducedMotion =
@@ -304,7 +316,8 @@ export function wireChat(options) {
304
316
  applyWidth(readChatWidth(storage))
305
317
 
306
318
  const layerTitle = /** @param {string} id */ id => session.artifact.layers.find(l => l.id === id)?.title
307
- const pointTitle = /** @param {string} fp */ fp => session.artifact.points.find(p => p.fingerprint === fp)?.title
319
+ const pointTitle = /** @param {string} fp */ fp =>
320
+ session.artifact.points.find(p => p.fingerprint === fp)?.title
308
321
 
309
322
  const drawContext = () => {
310
323
  chip.textContent = chatContextLabel(context, layerTitle, pointTitle)
@@ -416,7 +429,8 @@ export function wireChat(options) {
416
429
  activeThread = data.activeThread
417
430
  select.innerHTML = data.threads
418
431
  .map(
419
- t => `<option value="${esc(t.name)}"${t.name === data.activeThread ? ' selected' : ''}>${esc(t.title)}</option>`
432
+ t =>
433
+ `<option value="${esc(t.name)}"${t.name === data.activeThread ? ' selected' : ''}>${esc(t.title)}</option>`
420
434
  )
421
435
  .join('')
422
436
  if (data.threads.length === 0) {
@@ -510,7 +524,7 @@ export function wireChat(options) {
510
524
  const started = Date.now()
511
525
  const updateActivity = () => {
512
526
  const elapsed = Date.now() - started
513
- activity.textContent = `Preparing answer${'.'.repeat(Math.floor(elapsed / 400) % 3 + 1)} · ${Math.floor(elapsed / 1000)}s`
527
+ activity.textContent = `Preparing answer${'.'.repeat((Math.floor(elapsed / 400) % 3) + 1)} · ${Math.floor(elapsed / 1000)}s`
514
528
  }
515
529
  updateActivity()
516
530
  const timer = setInterval(updateActivity, 400)
@@ -544,7 +558,10 @@ export function wireChat(options) {
544
558
  const previous = tools.get(id)
545
559
  const title = String(field('title') ?? 'tool')
546
560
  tools.delete(id)
547
- tools.set(id, { title: title === 'tool' && previous ? previous.title : title, status: String(field('status') ?? 'pending') })
561
+ tools.set(id, {
562
+ title: title === 'tool' && previous ? previous.title : title,
563
+ status: String(field('status') ?? 'pending'),
564
+ })
548
565
  toolDetails.hidden = false
549
566
  const latest = tools.get(id)
550
567
  toolDetails.innerHTML = `<summary>${esc(latest.title)} · ${esc(latest.status)} (${tools.size} tool calls)</summary><ul>${[...tools.values()].map(t => `<li>${esc(t.title)} · ${esc(t.status)}</li>`).join('')}</ul>`
@@ -578,9 +595,13 @@ export function wireChat(options) {
578
595
  const toolSummary = toolDetails.querySelector('summary')
579
596
  if (toolSummary) toolSummary.textContent = `${tools.size} tool calls`
580
597
  activity.textContent = `Elapsed: ${Math.floor((Date.now() - started) / 1000)}s`
581
- if (frame !== 0) { cancelFrame(frame); frame = 0 }
598
+ if (frame !== 0) {
599
+ cancelFrame(frame)
600
+ frame = 0
601
+ }
582
602
  setStreaming(false)
583
- answer.body.innerHTML = text === '' ? '<span class="muted">no answer</span>' : renderAnswer(text, turnKey)
603
+ answer.body.innerHTML =
604
+ text === '' ? '<span class="muted">no answer</span>' : renderAnswer(text, turnKey)
584
605
  if (scroll.pinned) {
585
606
  scrollToBottom('follow')
586
607
  }
@@ -728,6 +749,7 @@ export function wireChat(options) {
728
749
  }
729
750
 
730
751
  const stopResize = wireResize(pane, root, storage, applyWidth)
752
+ const panel = wireChatPanel(root, pane)
731
753
 
732
754
  form.addEventListener('submit', onSubmit)
733
755
  root.addEventListener('click', onClick)
@@ -744,12 +766,12 @@ export function wireChat(options) {
744
766
  },
745
767
  setContext,
746
768
  focusInput() {
747
- box.focus()
769
+ panel.open()
748
770
  },
749
771
  /** @param {ChatContext} next */
750
772
  ask(next) {
751
773
  setContext(next)
752
- box.focus()
774
+ panel.open()
753
775
  },
754
776
  /**
755
777
  * Sends one of the quick questions about a target.
@@ -758,6 +780,7 @@ export function wireChat(options) {
758
780
  */
759
781
  askQuestion(next, question) {
760
782
  setContext(next)
783
+ panel.open()
761
784
  box.value = question
762
785
  void runCommand(sendButton, send, { pendingLabel: 'sending…' })
763
786
  },
@@ -775,6 +798,7 @@ export function wireChat(options) {
775
798
  select.removeEventListener('change', onThreadChange)
776
799
  box.removeEventListener('keydown', /** @type {EventListener} */ (onKeyDown))
777
800
  stopResize()
801
+ panel.stop()
778
802
  },
779
803
  }
780
804
  }