@vintasoftware/pr-review-canvas 0.4.0 → 0.5.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 (83) hide show
  1. package/README.md +27 -8
  2. package/docs/reference.md +247 -62
  3. package/package.json +1 -1
  4. package/pr-review.config.example.yml +38 -4
  5. package/prompts/generation-format.md +120 -26
  6. package/prompts/generation-strict-incremental.md +53 -0
  7. package/prompts/generation-strict.md +1 -27
  8. package/prompts/generation-surfacing-incremental.md +56 -0
  9. package/prompts/generation-surfacing.md +1 -58
  10. package/prompts/judging-strict.md +27 -0
  11. package/prompts/judging-surfacing.md +58 -0
  12. package/skills/pr-review-canvas/SKILL.md +13 -4
  13. package/src/acpx/acpx.ts +98 -5
  14. package/src/acpx/models.ts +43 -0
  15. package/src/chat/chat-manager.ts +27 -1
  16. package/src/cli.ts +58 -1
  17. package/src/commands.ts +5 -1
  18. package/src/contract/api.ts +26 -1
  19. package/src/contract/canvas-manifest.ts +5 -0
  20. package/src/contract/generation-context.ts +52 -1
  21. package/src/contract/keys.ts +1 -0
  22. package/src/contract/pending.ts +49 -0
  23. package/src/contract/review-artifact.ts +50 -7
  24. package/src/contract/reviews.ts +10 -1
  25. package/src/contract/settings.ts +5 -0
  26. package/src/contract/state.ts +23 -12
  27. package/src/contract/validation.ts +1 -0
  28. package/src/github/post-review.ts +62 -6
  29. package/src/gitlab/post-review.ts +48 -9
  30. package/src/gitlab/publish-drafts.ts +69 -0
  31. package/src/host/client.ts +3 -2
  32. package/src/host/host.ts +23 -5
  33. package/src/project-config.ts +15 -2
  34. package/src/review/carry-marks.ts +131 -0
  35. package/src/review/doctor.ts +60 -26
  36. package/src/review/incremental.ts +107 -0
  37. package/src/review/normalize.ts +14 -4
  38. package/src/review/prepare.ts +47 -0
  39. package/src/review/prompt.ts +112 -5
  40. package/src/review/publish.ts +1 -0
  41. package/src/review/test-paths.ts +44 -4
  42. package/src/review/validate-folds.ts +348 -23
  43. package/src/review/validate.ts +10 -1
  44. package/src/server/bundle.ts +14 -2
  45. package/src/server/html.ts +4 -4
  46. package/src/server/routes/chat-routes.ts +15 -6
  47. package/src/server/routes/pages.ts +4 -1
  48. package/src/server/routes/review-routes.ts +202 -42
  49. package/src/store/canvas-store.ts +3 -0
  50. package/src/store/settings-store.ts +9 -1
  51. package/src/store/state-store.ts +69 -4
  52. package/src/upgrade.ts +338 -0
  53. package/static/js/api.js +55 -1
  54. package/static/js/app.js +28 -7
  55. package/static/js/chat-panel.js +32 -9
  56. package/static/js/chat.js +27 -4
  57. package/static/js/code-folds.js +171 -44
  58. package/static/js/composer.js +109 -4
  59. package/static/js/contract-types.d.ts +4 -0
  60. package/static/js/diff-decorations.js +67 -1
  61. package/static/js/empty-state.js +17 -0
  62. package/static/js/fold-levels.js +176 -0
  63. package/static/js/header.js +36 -9
  64. package/static/js/interactions.js +273 -44
  65. package/static/js/keyboard.js +4 -1
  66. package/static/js/keys.js +12 -0
  67. package/static/js/layers.js +292 -29
  68. package/static/js/nav.js +22 -4
  69. package/static/js/pending.js +161 -0
  70. package/static/js/points.js +69 -9
  71. package/static/js/progress.js +4 -5
  72. package/static/js/quick-questions.js +15 -2
  73. package/static/js/reading-level.js +97 -0
  74. package/static/js/review-session.js +106 -27
  75. package/static/js/settings.js +53 -23
  76. package/static/js/signoff.js +75 -5
  77. package/static/js/skin.js +2 -2
  78. package/static/styles/chat-panel.css +22 -24
  79. package/static/styles/chat.css +4 -0
  80. package/static/styles/header.css +21 -0
  81. package/static/styles/pending.css +102 -0
  82. package/static/styles/review.css +4 -0
  83. package/static/styles.css +1 -0
@@ -5,6 +5,7 @@
5
5
  import { askButtonHtml } from './ask.js'
6
6
  import { viewCommentHtml } from './comment-link.js'
7
7
  import { esc } from './dom.js'
8
+ import { pendingForPoint } from './pending.js'
8
9
  import { postToLabel } from './host.js'
9
10
  import { layerAnchorId, pointAnchorId } from './keys.js'
10
11
  import { renderMarkdown } from './markdown.js'
@@ -65,11 +66,33 @@ export function pointContext(p) {
65
66
  return { kind: 'point', fingerprint: p.fingerprint }
66
67
  }
67
68
 
69
+ /**
70
+ * What a point offers for getting its text onto the forge: the link to the comment it was posted
71
+ * as, the note that it is waiting in the review, or the two ways to send it. Unlike the box on a
72
+ * diff line, a point keeps both ways while a review is open: its text is written in advance, so
73
+ * firing one off on its own is a use of its own, not a comment jumping the queue.
74
+ * @param {Point} p
75
+ * @param {{ postedUrl?: string | undefined, queued?: boolean }} opts
76
+ */
77
+ function pointSendHtml(p, opts) {
78
+ if (opts.postedUrl !== undefined) {
79
+ return viewCommentHtml(opts.postedUrl)
80
+ }
81
+ if (opts.queued === true) {
82
+ return '<span class="pill pending queued">in your review</span>'
83
+ }
84
+ return (
85
+ `<button class="cmd" type="button" data-act="point-post" data-point="${esc(p.id)}" data-needs-post>${postToLabel()}</button>` +
86
+ `<button class="cmd" type="button" data-act="point-queue" data-point="${esc(p.id)}">add to review</button>`
87
+ )
88
+ }
89
+
68
90
  /**
69
91
  * The commands every point carries. `copy` puts the markdown on the clipboard, `post to github`
70
- * opens nothing and posts it at the anchor, `dismiss` takes the point off the page.
92
+ * opens nothing and posts it at the anchor, `add to review` holds it as a draft instead, and
93
+ * `dismiss` takes the point off the page.
71
94
  * @param {Point} p
72
- * @param {{ dismissed?: boolean, postedUrl?: string | undefined }} [opts]
95
+ * @param {{ dismissed?: boolean, postedUrl?: string | undefined, queued?: boolean }} [opts]
73
96
  * @returns {string}
74
97
  */
75
98
  export function pointCommandsHtml(p, opts = {}) {
@@ -78,17 +101,24 @@ export function pointCommandsHtml(p, opts = {}) {
78
101
  ? `<button class="cmd" type="button" data-act="point-restore" data-fingerprint="${fp}">restore</button>`
79
102
  : `<button class="cmd" type="button" data-act="point-dismiss" data-fingerprint="${fp}">dismiss</button>`
80
103
  return (
81
- '<span class="tbtns">' +
104
+ `<span class="tbtns" data-queued="${opts.queued === true ? '1' : '0'}">` +
82
105
  `<button class="cmd" type="button" data-copy="${esc(pointToMarkdown(p))}">copy</button>` +
83
- (opts.postedUrl === undefined
84
- ? `<button class="cmd" type="button" data-act="point-post" data-point="${esc(p.id)}" data-needs-post>${postToLabel()}</button>`
85
- : viewCommentHtml(opts.postedUrl)) +
106
+ pointSendHtml(p, opts) +
86
107
  askButtonHtml(pointContext(p)) +
87
108
  toggle +
88
109
  '</span>'
89
110
  )
90
111
  }
91
112
 
113
+ /**
114
+ * Whether this point is waiting in the review, read from the state the renderer was given.
115
+ * @param {Point} p
116
+ * @param {{ state?: PrState }} ctx
117
+ */
118
+ export function queuedFor(p, ctx) {
119
+ return pendingForPoint(ctx.state, p.fingerprint) !== undefined
120
+ }
121
+
92
122
  /**
93
123
  * The comment this point was posted as, when it was.
94
124
  * @param {Point} p
@@ -130,7 +160,7 @@ export function pointCardHtml(p, ctx) {
130
160
  `<div class="f-title"><span>${esc(p.title)}</span><span class="pill kind">${esc(p.kind)}</span>` +
131
161
  `<a class="loc" href="${esc(pointLink(p))}">${esc(pointLocation(p))}</a></div>` +
132
162
  `<div class="prose">${renderMarkdown(p.body, { paths: ctx.paths })}</div>` +
133
- `${pointCommandsHtml(p, { postedUrl: posted })}</div></li>`
163
+ `${pointCommandsHtml(p, { postedUrl: posted, queued: queuedFor(p, ctx) })}</div></li>`
134
164
  )
135
165
  }
136
166
 
@@ -154,7 +184,7 @@ export function dismissedListHtml(points, state, ctx, expanded = false) {
154
184
  `<div class="f-title"><span>${esc(p.title)}</span><span class="pill kind">${esc(p.kind)}</span>` +
155
185
  `<a class="loc" href="${esc(pointLink(p))}">${esc(pointLocation(p))}</a></div>` +
156
186
  `<div class="prose">${renderMarkdown(p.body, { paths: ctx.paths })}</div>` +
157
- `${pointCommandsHtml(p, { dismissed: true, postedUrl: posted })}</div></li>`
187
+ `${pointCommandsHtml(p, { dismissed: true, postedUrl: posted, queued: queuedFor(p, { state }) })}</div></li>`
158
188
  )
159
189
  })
160
190
  .join('')
@@ -183,6 +213,7 @@ export function applyDismissed(root, points, state, ctx) {
183
213
  }
184
214
  const dismissed = state.dismissed[point.fingerprint] !== undefined
185
215
  el.toggleAttribute('hidden', dismissed)
216
+ refreshPointCommands(el, point, { dismissed, state, ...ctx })
186
217
  }
187
218
  for (const counter of Array.from(root.querySelectorAll('.point-count'))) {
188
219
  const layerId = counter.closest('[data-layer]')?.getAttribute('data-layer')
@@ -212,6 +243,35 @@ export function applyDismissed(root, points, state, ctx) {
212
243
  }
213
244
  }
214
245
 
246
+ /**
247
+ * Draws a point's commands again when it has just joined the review or just left it, wherever the
248
+ * point is on the page. Nothing else is touched: a command that is mid-request keeps its state,
249
+ * because neither posting nor dismissing changes whether the point is waiting in the review.
250
+ * @param {Element} el the element that carries `data-point`
251
+ * @param {Point} p
252
+ * @param {{ dismissed: boolean, state: PrState, posted?: ReadonlyMap<string, string> }} ctx
253
+ * @returns {boolean} true when the commands were drawn again
254
+ */
255
+ export function refreshPointCommands(el, p, ctx) {
256
+ const tbtns = el.querySelector('.tbtns')
257
+ const queued = queuedFor(p, ctx)
258
+ if (tbtns === null || (tbtns.getAttribute('data-queued') === '1') === queued) {
259
+ return false
260
+ }
261
+ const template = document.createElement('template')
262
+ template.innerHTML = pointCommandsHtml(p, {
263
+ dismissed: ctx.dismissed,
264
+ queued,
265
+ ...(postedFor(p, ctx) === undefined ? {} : { postedUrl: postedFor(p, ctx) }),
266
+ })
267
+ const next = template.content.firstElementChild
268
+ if (next === null) {
269
+ return false
270
+ }
271
+ tbtns.replaceWith(next)
272
+ return true
273
+ }
274
+
215
275
  /**
216
276
  * The inline row under a diff line.
217
277
  * @param {Point} p
@@ -224,7 +284,7 @@ export function pointRowHtml(p, ctx) {
224
284
  `<tr class="ifind ${p.level}" data-point="${esc(p.id)}" data-fingerprint="${esc(p.fingerprint)}"${dismissed ? ' hidden' : ''}><td class="code x" colspan="4">` +
225
285
  `<div class="f-title">${squareHtml(p)}<span>${esc(p.title)}</span><span class="pill kind">${esc(p.kind)}</span></div>` +
226
286
  `<div class="prose">${renderMarkdown(p.body, { paths: ctx.paths })}</div>` +
227
- `${pointCommandsHtml(p, { postedUrl: posted })}</td></tr>`
287
+ `${pointCommandsHtml(p, { postedUrl: posted, queued: queuedFor(p, ctx) })}</td></tr>`
228
288
  )
229
289
  }
230
290
 
@@ -3,7 +3,7 @@
3
3
  /** @typedef {import('./contract-types.js').Layer} Layer */
4
4
  /** @typedef {import('./contract-types.js').PrState} PrState */
5
5
  /** @typedef {import('./contract-types.js').ReviewArtifact} ReviewArtifact */
6
- import { sanitizeKey } from './keys.js'
6
+ import { reviewedId } from './keys.js'
7
7
 
8
8
  /**
9
9
  * @param {Layer} layer
@@ -11,13 +11,13 @@ import { sanitizeKey } from './keys.js'
11
11
  * @returns {'done' | 'partial' | 'none'}
12
12
  */
13
13
  export function layerProgress(layer, state) {
14
- if (state.reviewed[`layer:${layer.id}`] === true) {
14
+ if (state.reviewed[reviewedId(layer.key)] === true) {
15
15
  return 'done'
16
16
  }
17
17
  const files = layer.files.length
18
18
  let reviewedFiles = 0
19
19
  for (const f of layer.files) {
20
- if (state.reviewed[`layer:${layer.id}/file:${sanitizeKey(f.path)}`] === true) {
20
+ if (state.reviewed[reviewedId(layer.key, f.path)] === true) {
21
21
  reviewedFiles++
22
22
  }
23
23
  }
@@ -45,6 +45,5 @@ export function progressSummary(artifact, state) {
45
45
  * @param {PrState} state
46
46
  */
47
47
  export function filesReviewed(layer, state) {
48
- return layer.files.filter(f => state.reviewed[`layer:${layer.id}/file:${sanitizeKey(f.path)}`] === true)
49
- .length
48
+ return layer.files.filter(f => state.reviewed[reviewedId(layer.key, f.path)] === true).length
50
49
  }
@@ -12,6 +12,9 @@ export const HOVER_DELAY_MS = 150
12
12
  /** The pointer crosses a gap between the command and the menu, so closing waits out the crossing. */
13
13
  export const CLOSE_DELAY_MS = 200
14
14
  export const QQ_MENU_ID = 'qq-menu'
15
+ /** The gap between a command and its menu, and the room the menu keeps from the window edge. */
16
+ export const MENU_GAP_PX = 4
17
+ export const MENU_EDGE_PX = 8
15
18
  export const ASK_SOMETHING_ELSE = 'ask something else…'
16
19
 
17
20
  /** @returns {string} */
@@ -77,8 +80,18 @@ export function wireQuickQuestions(root, options) {
77
80
  el.setAttribute('aria-expanded', 'true')
78
81
  menu.hidden = false
79
82
  const rect = el.getBoundingClientRect()
80
- menu.style.left = `${Math.round(rect.left)}px`
81
- menu.style.top = `${Math.round(rect.bottom + 4)}px`
83
+ // The menu is positioned against the window, so a command near an edge would push it off
84
+ // screen: keep it inside, and hang it above a command with no room below.
85
+ const view = doc.defaultView ?? window
86
+ // `clientWidth` leaves out the scrollbar; a document that does not report it falls back.
87
+ const roomAcross = doc.documentElement.clientWidth || view.innerWidth
88
+ const roomDown = doc.documentElement.clientHeight || view.innerHeight
89
+ const below = rect.bottom + MENU_GAP_PX
90
+ const top =
91
+ below + menu.offsetHeight > roomDown - MENU_EDGE_PX ? rect.top - MENU_GAP_PX - menu.offsetHeight : below
92
+ const left = Math.min(rect.left, roomAcross - menu.offsetWidth - MENU_EDGE_PX)
93
+ menu.style.left = `${Math.round(Math.max(MENU_EDGE_PX, left))}px`
94
+ menu.style.top = `${Math.round(Math.max(MENU_EDGE_PX, top))}px`
82
95
  }
83
96
 
84
97
  const clearTimer = () => {
@@ -0,0 +1,97 @@
1
+ // @ts-check
2
+ // The reader's control over how much code the canvas hides: the copy that says what each level
3
+ // takes off the screen, the control under the header, and the labels that say how much of the
4
+ // diff that is. The level itself, and the counts, belong to layers.js, which owns the file cards
5
+ // and the rule for what a card keeps open.
6
+ import { esc } from './dom.js'
7
+ import { FOLD_LEVELS } from './fold-levels.js'
8
+
9
+ /** @typedef {import('./contract-types.js').FoldLevel} FoldLevel */
10
+ /** @typedef {{ total: number, hidden: number }} HiddenCounts */
11
+
12
+ export const FOLD_LEVEL_SELECT_ID = 'fold-level'
13
+
14
+ /** What each level adds to the one below it, said in the reader's terms. */
15
+ const FOLD_LEVEL_WHAT = {
16
+ light: 'the diff as always: imports, whitespace, moved blocks, and generated files',
17
+ moderate: 'also test bodies, helpers, wiring, templates, and boilerplate',
18
+ aggressive: 'only the code you have to judge stays open',
19
+ }
20
+
21
+ /**
22
+ * What the counter says, or '' when this level hides nothing.
23
+ * @param {HiddenCounts} counts
24
+ * @returns {string}
25
+ */
26
+ export function hiddenLabel(counts) {
27
+ return counts.hidden === 0 ? '' : `${counts.hidden} of ${counts.total} lines hidden`
28
+ }
29
+
30
+ /**
31
+ * The same with the separator the Files heading needs, so an empty counter adds nothing.
32
+ * @param {HiddenCounts} counts
33
+ * @returns {string}
34
+ */
35
+ export function foldCountText(counts) {
36
+ const label = hiddenLabel(counts)
37
+ return label === '' ? '' : ` · ${label}`
38
+ }
39
+
40
+ /**
41
+ * The line under the control: what this level hides, and how much of the diff that is. The reader
42
+ * changes one setting, so the page says what it did rather than leaving them to scroll and find out.
43
+ * @param {FoldLevel} level
44
+ * @param {HiddenCounts} counts what the level hides across the canvas
45
+ * @returns {string}
46
+ */
47
+ export function foldLevelHint(level, counts) {
48
+ const amount = counts.hidden === 0 ? 'nothing hidden yet' : `${hiddenLabel(counts)} of the diff`
49
+ return `${FOLD_LEVEL_WHAT[level]} · ${amount}`
50
+ }
51
+
52
+ /**
53
+ * The levels as options, with one selected. The control on the page and the default field of the
54
+ * settings dialog are the same list.
55
+ * @param {FoldLevel} level
56
+ * @returns {string}
57
+ */
58
+ export function foldLevelOptionsHtml(level) {
59
+ return FOLD_LEVELS.map(
60
+ l => `<option value="${esc(l)}"${l === level ? ' selected' : ''}>${esc(l)}</option>`
61
+ ).join('')
62
+ }
63
+
64
+ /**
65
+ * How much of the diff the page hides. One control for the whole canvas, next to the review
66
+ * progress rather than among the commands, because it changes what the reader sees and does not
67
+ * act on the pull request. The page opens at the level saved in the settings dialog; a change
68
+ * here holds for this page only.
69
+ * @param {FoldLevel} level
70
+ * @param {HiddenCounts} counts what the level hides across the canvas
71
+ * @returns {string}
72
+ */
73
+ export function foldLevelControlHtml(level, counts) {
74
+ const options = foldLevelOptionsHtml(level)
75
+ return (
76
+ `<div class="reading"><label for="${FOLD_LEVEL_SELECT_ID}">Hide code</label>` +
77
+ `<select id="${FOLD_LEVEL_SELECT_ID}" title="How much of the diff this page hides. Press f to step through the levels; the settings dialog sets the default.">${options}</select>` +
78
+ `<span class="fold-hint" role="status">${esc(foldLevelHint(level, counts))}</span></div>`
79
+ )
80
+ }
81
+
82
+ /**
83
+ * Points the control and its hint at a level the reader chose elsewhere, such as with the `f` key.
84
+ * @param {ParentNode} root
85
+ * @param {FoldLevel} level
86
+ * @param {HiddenCounts} counts what the level hides across the canvas
87
+ */
88
+ export function refreshFoldLevel(root, level, counts) {
89
+ const select = root.querySelector(`#${FOLD_LEVEL_SELECT_ID}`)
90
+ if (select instanceof HTMLSelectElement) {
91
+ select.value = level
92
+ }
93
+ const hint = root.querySelector('.reading .fold-hint')
94
+ if (hint !== null) {
95
+ hint.textContent = foldLevelHint(level, counts)
96
+ }
97
+ }
@@ -9,8 +9,18 @@
9
9
  /** @typedef {import('./contract-types.js').PrState} PrState */
10
10
  /** @typedef {import('./contract-types.js').ReviewArtifact} ReviewArtifact */
11
11
  /** @typedef {import('./contract-types.js').ReviewSummary} ReviewSummary */
12
- import { postComment, postReview, putDismissed, putReviewed, putThreadHidden } from './api.js'
13
- import { sanitizeKey } from './keys.js'
12
+ /** @typedef {import('./contract-types.js').ReviewComment} ReviewComment */
13
+ import {
14
+ addPending,
15
+ deletePending,
16
+ discardPending,
17
+ editPending,
18
+ postComment,
19
+ postReview,
20
+ putDismissed,
21
+ putReviewed,
22
+ putThreadHidden,
23
+ } from './api.js'
14
24
 
15
25
  /**
16
26
  * @typedef {{
@@ -19,6 +29,10 @@ import { sanitizeKey } from './keys.js'
19
29
  * putThreadHidden: typeof putThreadHidden,
20
30
  * postComment: typeof postComment,
21
31
  * postReview: typeof postReview,
32
+ * addPending: typeof addPending,
33
+ * editPending: typeof editPending,
34
+ * deletePending: typeof deletePending,
35
+ * discardPending: typeof discardPending,
22
36
  * }} SessionApi
23
37
  */
24
38
 
@@ -38,22 +52,29 @@ import { sanitizeKey } from './keys.js'
38
52
 
39
53
  /** @param {Partial<SessionApi> | undefined} overrides */
40
54
  function withDefaults(overrides) {
41
- return { putReviewed, putDismissed, putThreadHidden, postComment, postReview, ...overrides }
55
+ return {
56
+ putReviewed,
57
+ putDismissed,
58
+ putThreadHidden,
59
+ postComment,
60
+ postReview,
61
+ addPending,
62
+ editPending,
63
+ deletePending,
64
+ discardPending,
65
+ ...overrides,
66
+ }
42
67
  }
43
68
 
44
- /**
45
- * @param {string} layerId
46
- * @param {string} [path]
47
- */
48
- export function reviewedId(layerId, path) {
49
- return path === undefined ? `layer:${layerId}` : `layer:${layerId}/file:${sanitizeKey(path)}`
50
- }
69
+ export { reviewedId } from './keys.js'
51
70
 
52
71
  /** @param {SessionOptions} options */
53
72
  export function createReviewSession(options) {
54
73
  const api = withDefaults(options.api)
55
74
  /** @type {PrState} */
56
75
  let state = options.state
76
+ /** @type {ReviewComment[]} */
77
+ let submittedComments = []
57
78
  /** @type {Capabilities} */
58
79
  let capabilities = options.capabilities
59
80
  const keyToPath = new Map(options.files.map(f => [f.key, f.path]))
@@ -131,6 +152,28 @@ export function createReviewSession(options) {
131
152
  }
132
153
  }
133
154
 
155
+ /**
156
+ * A change the server owns outright: the answer it sends is the new state. Unlike `change`
157
+ * there is nothing to show first and nothing to take back, because the page never guessed.
158
+ * @template {{ state: PrState }} T
159
+ * @param {() => Promise<T>} request
160
+ * @returns {Promise<T>}
161
+ */
162
+ const run = async request => {
163
+ inFlight += 1
164
+ try {
165
+ const answer = await request()
166
+ inFlight -= 1
167
+ takeAnswer(answer.state)
168
+ settle()
169
+ return answer
170
+ } catch (err) {
171
+ inFlight -= 1
172
+ settle()
173
+ throw err
174
+ }
175
+ }
176
+
134
177
  /**
135
178
  * @param {PrState} current
136
179
  * @param {'reviewed' | 'dismissed' | 'hiddenThreads'} field
@@ -234,27 +277,63 @@ export function createReviewSession(options) {
234
277
  * @returns {Promise<PostCommentResponse>}
235
278
  */
236
279
  async postComment(input) {
237
- inFlight += 1
238
- try {
239
- const answer = await api.postComment(options.prNumber, { ...input, headSha: options.headSha })
240
- inFlight -= 1
241
- takeAnswer(answer.state)
242
- settle()
243
- return answer
244
- } catch (err) {
245
- inFlight -= 1
246
- settle()
247
- throw err
248
- }
280
+ return run(() => api.postComment(options.prNumber, { ...input, headSha: options.headSha }))
281
+ },
282
+ /** Comments returned by reviews submitted during this session. */
283
+ get submittedComments() {
284
+ return submittedComments
285
+ },
286
+ /** The comments waiting in the pending review, oldest first. */
287
+ get pending() {
288
+ return state.pending ?? []
249
289
  },
250
290
  /**
251
- * @param {'APPROVE' | 'REQUEST_CHANGES'} event
291
+ * Writes one comment into the pending review. The answer carries the state that holds it, so
292
+ * the page draws the draft from the same place a reload would read it from.
293
+ * @param {Omit<import('./contract-types.js').AddPendingInput, 'headSha'>} input
294
+ * @returns {Promise<PrState>}
295
+ */
296
+ async addPending(input) {
297
+ return (await run(() => api.addPending(options.prNumber, { ...input, headSha: options.headSha }))).state
298
+ },
299
+ /**
300
+ * @param {string} id
301
+ * @param {string} body
302
+ * @returns {Promise<PrState>}
303
+ */
304
+ async editPending(id, body) {
305
+ return (await run(() => api.editPending(options.prNumber, id, body))).state
306
+ },
307
+ /**
308
+ * @param {string} id
309
+ * @returns {Promise<PrState>}
310
+ */
311
+ async deletePending(id) {
312
+ return (await run(() => api.deletePending(options.prNumber, id))).state
313
+ },
314
+ /** @returns {Promise<PrState>} */
315
+ async discardPending() {
316
+ return (await run(() => api.discardPending(options.prNumber))).state
317
+ },
318
+ /**
319
+ * Submits the review. The pending comments go out with it unless the caller says otherwise,
320
+ * and the answer's state is the one with them cleared.
321
+ * @param {import('./contract-types.js').ReviewEvent} event
252
322
  * @param {string} [body]
253
- * @returns {Promise<ReviewSummary>}
323
+ * @param {{ includePending?: boolean }} [opts]
324
+ * @returns {Promise<import('./contract-types.js').PostReviewResponse>}
254
325
  */
255
- async postReview(event, body) {
256
- const input = body === undefined ? { event } : { event, body }
257
- return (await api.postReview(options.prNumber, { ...input, headSha: options.headSha })).review
326
+ async postReview(event, body, opts = {}) {
327
+ const input = {
328
+ event,
329
+ ...(body === undefined ? {} : { body }),
330
+ ...(opts.includePending === undefined ? {} : { includePending: opts.includePending }),
331
+ }
332
+ return run(async () => {
333
+ const answer = await api.postReview(options.prNumber, { ...input, headSha: options.headSha })
334
+ submittedComments = [...submittedComments, ...answer.comments]
335
+ return answer
336
+ })
258
337
  },
259
338
  }
260
339
  }
@@ -1,18 +1,24 @@
1
1
  // @ts-check
2
- // The settings dialog: the personal chat settings this browser can change, and a read-only look
3
- // at the project config, which is committed and belongs to the repository.
2
+ // The settings dialog: the personal settings this browser can change, the reading level a review
3
+ // opens at and the chat settings, and a read-only look at the project config, which is committed
4
+ // and belongs to the repository.
4
5
  /** @typedef {import('./contract-types.js').AgentsResponse} AgentsResponse */
5
6
  /** @typedef {import('./contract-types.js').SettingsResponse} SettingsResponse */
6
7
  import { fetchAgents, fetchSettings, probeAgent, saveSettings } from './api.js'
7
8
  import { runCommand } from './commands.js'
8
9
  import { esc, qs } from './dom.js'
10
+ import { isFoldLevel } from './fold-levels.js'
11
+ import { foldLevelOptionsHtml } from './reading-level.js'
9
12
 
10
13
  export const SETTINGS_DIALOG_ID = 'settings-dialog'
11
14
 
12
- /** Model ids the input suggests per agent. Free text is allowed; this is only a shortcut. */
15
+ /**
16
+ * Model ids the input suggests per agent. Free text is allowed; this is only a shortcut. The
17
+ * server runs the newest model of whichever family is saved, so these do not go stale.
18
+ */
13
19
  export const MODEL_SUGGESTIONS = {
14
- claude: ['claude-opus-5', 'claude-sonnet-5', 'claude-haiku-4-5-20251001'],
15
- codex: ['gpt-5.2', 'gpt-5.2[high]'],
20
+ claude: ['opus', 'opus[1m]', 'sonnet', 'haiku', 'fable'],
21
+ codex: ['gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna', 'gpt-6-astra[high]'],
16
22
  }
17
23
 
18
24
  /** @param {string} agent */
@@ -22,18 +28,43 @@ export function modelOptionsHtml(agent) {
22
28
  }
23
29
 
24
30
  /**
25
- * @param {SettingsResponse} data
31
+ * The chat agent's fields, with the notice about acpx when it is missing.
32
+ * @param {SettingsResponse['settings']} settings
26
33
  * @param {AgentsResponse} agents
27
34
  * @returns {string}
28
35
  */
29
- export function settingsDialogHtml(data, agents) {
30
- const { settings, overrides, project } = data
36
+ function chatFieldsHtml(settings, agents) {
31
37
  const options = agents.agents
32
38
  .map(a => {
33
39
  const reason = a.available ? '' : ` (${a.reason ?? 'not available'})`
34
40
  return `<option value="${esc(a.id)}"${a.id === settings.agent ? ' selected' : ''}${a.available ? '' : ' disabled'}>${esc(a.id)}${esc(reason)}</option>`
35
41
  })
36
42
  .join('')
43
+ return (
44
+ (agents.acpx.installed
45
+ ? ''
46
+ : '<p class="notice" role="status">acpx is not on PATH, so AI Chat is off. Install acpx and reload.</p>') +
47
+ '<div class="field"><label for="set-agent">Agent</label>' +
48
+ `<select id="set-agent">${options}</select></div>` +
49
+ '<div class="field"><label for="set-model">Model</label>' +
50
+ `<input id="set-model" list="model-list" value="${esc(settings.model ?? '')}" placeholder="the agent's default">` +
51
+ `<datalist id="model-list">${modelOptionsHtml(settings.agent)}</datalist></div>` +
52
+ '<div class="field"><label for="set-timeout">Chat timeout (seconds)</label>' +
53
+ `<input id="set-timeout" type="number" min="30" max="3600" value="${esc(settings.chatTimeoutSec)}"></div>` +
54
+ '<div class="field"><label for="set-turns">Max turns</label>' +
55
+ `<input id="set-turns" type="number" min="1" max="100" value="${esc(settings.maxTurns ?? '')}" placeholder="the agent's default"></div>` +
56
+ '<p class="muted small">Changing the agent starts a new chat thread; the old ones stay in the list.</p>'
57
+ )
58
+ }
59
+
60
+ /**
61
+ * @param {SettingsResponse} data
62
+ * @param {AgentsResponse | null} agents null when the project turns chat off, so the dialog holds
63
+ * only what the page itself reads
64
+ * @returns {string}
65
+ */
66
+ export function settingsDialogHtml(data, agents) {
67
+ const { settings, overrides, project } = data
37
68
  const overrideNote =
38
69
  overrides.agent === undefined && overrides.model === undefined
39
70
  ? ''
@@ -49,19 +80,10 @@ export function settingsDialogHtml(data, agents) {
49
80
  `<dialog id="${SETTINGS_DIALOG_ID}" class="settings" aria-labelledby="settings-h">` +
50
81
  '<h2 id="settings-h">Settings</h2>' +
51
82
  overrideNote +
52
- (agents.acpx.installed
53
- ? ''
54
- : '<p class="notice" role="status">acpx is not on PATH, so AI Chat is off. Install acpx and reload.</p>') +
55
- '<div class="field"><label for="set-agent">Agent</label>' +
56
- `<select id="set-agent">${options}</select></div>` +
57
- '<div class="field"><label for="set-model">Model</label>' +
58
- `<input id="set-model" list="model-list" value="${esc(settings.model ?? '')}" placeholder="the agent's default">` +
59
- `<datalist id="model-list">${modelOptionsHtml(settings.agent)}</datalist></div>` +
60
- '<div class="field"><label for="set-timeout">Chat timeout (seconds)</label>' +
61
- `<input id="set-timeout" type="number" min="30" max="3600" value="${esc(settings.chatTimeoutSec)}"></div>` +
62
- '<div class="field"><label for="set-turns">Max turns</label>' +
63
- `<input id="set-turns" type="number" min="1" max="100" value="${esc(settings.maxTurns ?? '')}" placeholder="the agent's default"></div>` +
64
- '<p class="muted small">Changing the agent starts a new chat thread; the old ones stay in the list.</p>' +
83
+ '<div class="field"><label for="set-fold-level">Hide code by default</label>' +
84
+ `<select id="set-fold-level">${foldLevelOptionsHtml(settings.foldLevel)}</select></div>` +
85
+ '<p class="muted small">The level every review opens at. The Hide code control and the <span class="mono">f</span> key change it for one page.</p>' +
86
+ (agents === null ? '' : chatFieldsHtml(settings, agents)) +
65
87
  `<p class="muted small mono">${esc(data.file)}</p>` +
66
88
  '<div class="panel-ro"><h3>Project config (read-only)</h3>' +
67
89
  `<ul class="plain"><li>chat enabled: ${project.chatEnabled ? 'yes' : 'no'}</li>` +
@@ -75,7 +97,9 @@ export function settingsDialogHtml(data, agents) {
75
97
  `<p class="muted small mono">${esc(project.file ?? 'built-in defaults (no pr-review.config.yml)')}</p></div>` +
76
98
  '<p class="probe-result" role="status"></p>' +
77
99
  '<div class="dialog-actions">' +
78
- '<button class="cmd" type="button" data-act="settings-probe">test agent</button>' +
100
+ (agents === null
101
+ ? ''
102
+ : '<button class="cmd" type="button" data-act="settings-probe">test agent</button>') +
79
103
  '<button class="cmd fill" type="button" data-act="settings-save">save</button>' +
80
104
  '<button class="cmd" type="button" data-act="settings-close">close</button>' +
81
105
  '</div></dialog>'
@@ -104,7 +128,9 @@ export async function openSettingsDialog(root, opener, opts = {}) {
104
128
  const loaded = await runCommand(
105
129
  opener,
106
130
  async () => {
107
- const [data, agents] = await Promise.all([api.fetchSettings(), api.fetchAgents()])
131
+ // With chat off the agent routes do not exist, and the dialog holds only the reading level.
132
+ const data = await api.fetchSettings()
133
+ const agents = data.project.chatEnabled ? await api.fetchAgents() : null
108
134
  return { data, agents }
109
135
  },
110
136
  { pendingLabel: 'loading…' }
@@ -129,12 +155,16 @@ export async function openSettingsDialog(root, opener, opts = {}) {
129
155
 
130
156
  /** The values the dialog holds right now, as the PUT body. */
131
157
  export function readSettingsForm(/** @type {ParentNode} */ dialog) {
158
+ const foldLevel = qs('#set-fold-level', dialog)
132
159
  const agent = qs('#set-agent', dialog)
133
160
  const model = qs('#set-model', dialog)
134
161
  const timeout = qs('#set-timeout', dialog)
135
162
  const turns = qs('#set-turns', dialog)
136
163
  /** @type {import('./contract-types.js').SettingsInput} */
137
164
  const input = {}
165
+ if (foldLevel instanceof HTMLSelectElement && isFoldLevel(foldLevel.value)) {
166
+ input.foldLevel = foldLevel.value
167
+ }
138
168
  if (agent instanceof HTMLSelectElement && agent.value !== '') {
139
169
  input.agent = /** @type {import('./contract-types.js').ChatAgent} */ (agent.value)
140
170
  }