@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.
- package/README.md +61 -26
- package/docs/reference.md +231 -121
- package/package.json +18 -4
- package/pr-review.config.example.yml +10 -4
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +3 -0
- package/skills/pr-review-canvas/SKILL.md +77 -40
- package/src/acpx/acpx.ts +21 -4
- package/src/acpx/events.ts +5 -1
- package/src/acpx/ndjson.ts +3 -1
- package/src/acpx/preflight.ts +5 -1
- package/src/canvas/comment.ts +24 -0
- package/src/canvas/export.ts +11 -2
- package/src/canvas/import.ts +30 -9
- package/src/canvas/name.ts +1 -0
- package/src/canvas/zip.ts +25 -2
- package/src/chat/chat-manager.ts +55 -41
- package/src/chat/context.ts +10 -3
- package/src/chat/seed.ts +3 -1
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +24 -9
- package/src/commands.ts +86 -25
- package/src/config.ts +24 -24
- package/src/contract/api.ts +32 -1
- package/src/contract/canvas-manifest.ts +2 -0
- package/src/contract/comments.ts +8 -1
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +27 -2
- package/src/contract/review-artifact.ts +14 -8
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +17 -0
- package/src/contract/settings.ts +2 -0
- package/src/contract/state.ts +41 -19
- package/src/git/diff-collector.ts +2 -1
- package/src/git/environment.mjs +27 -0
- package/src/git/git.ts +117 -12
- package/src/git/local-target.ts +138 -0
- package/src/git/patch-lines.ts +34 -2
- package/src/git/pr-refs.ts +36 -0
- package/src/github/attachments.ts +9 -257
- package/src/github/canvas-comment.ts +22 -0
- package/src/github/capabilities.ts +3 -37
- package/src/github/comments.ts +9 -27
- package/src/github/post-comment.ts +7 -37
- package/src/github/post-review.ts +4 -19
- package/src/github/pr.ts +6 -84
- package/src/github/threads.ts +6 -2
- package/src/gitlab/attachments.ts +40 -0
- package/src/gitlab/canvas-comment.ts +26 -0
- package/src/gitlab/capabilities.ts +64 -0
- package/src/gitlab/comments.ts +164 -0
- package/src/gitlab/mr.ts +115 -0
- package/src/gitlab/post-comment.ts +111 -0
- package/src/gitlab/post-review.ts +54 -0
- package/src/gitlab/project.ts +13 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +245 -0
- package/src/host/host.ts +136 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +25 -9
- package/src/prompt-files.ts +4 -3
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +31 -14
- package/src/review/normalize.ts +3 -1
- package/src/review/prepare.ts +87 -17
- package/src/review/prompt.ts +8 -2
- package/src/review/publish.ts +76 -13
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/trim-caps.ts +10 -6
- package/src/review/validate-folds.ts +2 -2
- package/src/review/validate.ts +67 -15
- package/src/server/app.ts +12 -4
- package/src/server/bundle.ts +320 -106
- package/src/server/context.ts +24 -10
- package/src/server/errors.ts +56 -10
- package/src/server/html.ts +36 -12
- package/src/server/node-server.ts +4 -2
- package/src/server/routes/api.ts +92 -31
- package/src/server/routes/chat-routes.ts +105 -46
- package/src/server/routes/pages.ts +25 -9
- package/src/server/routes/review-routes.ts +101 -35
- package/src/server/sse.ts +3 -1
- package/src/store/atomic-json.ts +5 -1
- package/src/store/canvas-store.ts +95 -46
- package/src/store/data-dir.ts +2 -1
- package/src/store/derived-store.ts +41 -23
- package/src/store/pr-store.ts +25 -15
- package/src/store/state-store.ts +42 -37
- package/static/brand.svg +19 -0
- package/static/js/api.js +47 -27
- package/static/js/app.js +27 -7
- package/static/js/chat-panel.js +93 -0
- package/static/js/chat.js +41 -17
- package/static/js/composer.js +30 -17
- package/static/js/contract-types.d.ts +3 -0
- package/static/js/diagram.js +2 -1
- package/static/js/diff-decorations.js +5 -3
- package/static/js/diff-renderer.js +7 -2
- package/static/js/dom.js +5 -7
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +85 -16
- package/static/js/errors.js +22 -6
- package/static/js/header.js +32 -9
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +56 -17
- package/static/js/keyboard.js +7 -2
- package/static/js/layers.js +26 -14
- package/static/js/links.js +9 -3
- package/static/js/markdown.js +28 -1
- package/static/js/nav.js +5 -2
- package/static/js/overview.js +32 -4
- package/static/js/points.js +5 -3
- package/static/js/progress.js +2 -1
- package/static/js/proposed-comment.js +4 -1
- package/static/js/quick-questions.js +2 -1
- package/static/js/regenerate.js +4 -1
- package/static/js/review-session.js +7 -2
- package/static/js/settings.js +2 -1
- package/static/js/signoff.js +9 -6
- package/static/styles/base.css +16 -6
- package/static/styles/chat-panel.css +81 -0
- package/static/styles/chat-tools.css +28 -0
- package/static/styles/chat.css +1 -1
- package/static/styles/commands.css +10 -4
- package/static/styles/diff.css +1 -1
- package/static/styles/header.css +18 -4
- package/static/styles/layout.css +4 -4
- package/static/styles/panels.css +4 -0
- package/static/styles/responsive.css +1 -15
- package/static/styles/review-actions.css +1 -0
- package/static/styles/review.css +24 -3
- package/static/styles/skin-github.css +99 -100
- package/static/styles.css +13 -12
- 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
|
-
*
|
|
73
|
-
*
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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`, {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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`, {
|
|
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 {
|
|
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 {
|
|
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`, {
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
105
|
+
/** The questions the quick menu offers, and the one that just focuses the box. */
|
|
101
106
|
export const QUICK_QUESTIONS = [
|
|
102
|
-
'
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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 =>
|
|
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 =>
|
|
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, {
|
|
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) {
|
|
598
|
+
if (frame !== 0) {
|
|
599
|
+
cancelFrame(frame)
|
|
600
|
+
frame = 0
|
|
601
|
+
}
|
|
582
602
|
setStreaming(false)
|
|
583
|
-
answer.body.innerHTML =
|
|
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
|
-
|
|
769
|
+
panel.open()
|
|
748
770
|
},
|
|
749
771
|
/** @param {ChatContext} next */
|
|
750
772
|
ask(next) {
|
|
751
773
|
setContext(next)
|
|
752
|
-
|
|
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
|
}
|