@vintasoftware/pr-review-canvas 0.3.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 (137) hide show
  1. package/README.md +79 -31
  2. package/docs/reference.md +396 -101
  3. package/package.json +9 -4
  4. package/pr-review.config.example.yml +44 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +123 -26
  7. package/prompts/generation-strict-incremental.md +53 -0
  8. package/prompts/generation-strict.md +1 -27
  9. package/prompts/generation-surfacing-incremental.md +56 -0
  10. package/prompts/generation-surfacing.md +1 -58
  11. package/prompts/judging-strict.md +27 -0
  12. package/prompts/judging-surfacing.md +58 -0
  13. package/skills/pr-review-canvas/SKILL.md +81 -42
  14. package/src/acpx/acpx.ts +98 -5
  15. package/src/acpx/models.ts +43 -0
  16. package/src/canvas/comment.ts +24 -0
  17. package/src/canvas/import.ts +30 -9
  18. package/src/canvas/name.ts +1 -0
  19. package/src/canvas/zip.ts +21 -1
  20. package/src/chat/chat-manager.ts +67 -40
  21. package/src/chat/threads.ts +27 -26
  22. package/src/cli.ts +70 -8
  23. package/src/commands.ts +50 -17
  24. package/src/config.ts +14 -26
  25. package/src/contract/api.ts +58 -2
  26. package/src/contract/canvas-manifest.ts +7 -0
  27. package/src/contract/comments.ts +5 -0
  28. package/src/contract/discovery.ts +5 -2
  29. package/src/contract/generation-context.ts +76 -2
  30. package/src/contract/keys.ts +1 -0
  31. package/src/contract/pending.ts +49 -0
  32. package/src/contract/review-artifact.ts +50 -7
  33. package/src/contract/review-key.ts +51 -0
  34. package/src/contract/reviews.ts +26 -0
  35. package/src/contract/settings.ts +7 -0
  36. package/src/contract/state.ts +53 -22
  37. package/src/contract/validation.ts +1 -0
  38. package/src/git/environment.mjs +27 -0
  39. package/src/git/git.ts +109 -9
  40. package/src/git/local-target.ts +138 -0
  41. package/src/git/patch-lines.ts +34 -2
  42. package/src/git/pr-refs.ts +36 -0
  43. package/src/github/attachments.ts +9 -257
  44. package/src/github/canvas-comment.ts +22 -0
  45. package/src/github/capabilities.ts +3 -41
  46. package/src/github/comments.ts +3 -24
  47. package/src/github/post-comment.ts +3 -36
  48. package/src/github/post-review.ts +64 -23
  49. package/src/github/pr.ts +6 -87
  50. package/src/github/threads.ts +2 -2
  51. package/src/gitlab/attachments.ts +40 -0
  52. package/src/gitlab/canvas-comment.ts +26 -0
  53. package/src/gitlab/capabilities.ts +64 -0
  54. package/src/gitlab/comments.ts +164 -0
  55. package/src/gitlab/mr.ts +115 -0
  56. package/src/gitlab/post-comment.ts +111 -0
  57. package/src/gitlab/post-review.ts +93 -0
  58. package/src/gitlab/project.ts +13 -0
  59. package/src/gitlab/publish-drafts.ts +69 -0
  60. package/src/host/attachments.ts +293 -0
  61. package/src/host/capabilities.ts +38 -0
  62. package/src/host/client.ts +246 -0
  63. package/src/host/host.ts +154 -0
  64. package/src/host/pr.ts +51 -0
  65. package/src/host/remote.ts +42 -0
  66. package/src/project-config.ts +26 -0
  67. package/src/review/carry-marks.ts +131 -0
  68. package/src/review/carry-over.ts +79 -0
  69. package/src/review/doctor.ts +82 -38
  70. package/src/review/incremental.ts +107 -0
  71. package/src/review/normalize.ts +14 -4
  72. package/src/review/prepare.ts +111 -10
  73. package/src/review/prompt.ts +112 -5
  74. package/src/review/publish.ts +62 -10
  75. package/src/{github → review}/review-body.ts +17 -5
  76. package/src/review/skill-command.ts +5 -3
  77. package/src/review/test-paths.ts +44 -4
  78. package/src/review/validate-folds.ts +349 -24
  79. package/src/review/validate.ts +14 -5
  80. package/src/server/bundle.ts +324 -111
  81. package/src/server/context.ts +10 -8
  82. package/src/server/errors.ts +30 -8
  83. package/src/server/html.ts +35 -11
  84. package/src/server/routes/api.ts +63 -26
  85. package/src/server/routes/chat-routes.ts +91 -44
  86. package/src/server/routes/pages.ts +26 -9
  87. package/src/server/routes/review-routes.ts +278 -67
  88. package/src/store/canvas-store.ts +93 -55
  89. package/src/store/data-dir.ts +2 -1
  90. package/src/store/derived-store.ts +41 -27
  91. package/src/store/pr-store.ts +23 -14
  92. package/src/store/settings-store.ts +9 -1
  93. package/src/store/state-store.ts +105 -36
  94. package/src/upgrade.ts +338 -0
  95. package/static/js/api.js +87 -25
  96. package/static/js/app.js +50 -15
  97. package/static/js/chat-panel.js +32 -9
  98. package/static/js/chat.js +30 -6
  99. package/static/js/code-folds.js +171 -44
  100. package/static/js/composer.js +132 -17
  101. package/static/js/contract-types.d.ts +7 -0
  102. package/static/js/diff-decorations.js +67 -1
  103. package/static/js/diff-renderer.js +1 -1
  104. package/static/js/download.js +1 -1
  105. package/static/js/empty-state.js +102 -18
  106. package/static/js/errors.js +22 -6
  107. package/static/js/fold-levels.js +176 -0
  108. package/static/js/header.js +65 -15
  109. package/static/js/host.js +40 -0
  110. package/static/js/import-zone.js +1 -1
  111. package/static/js/interactions.js +274 -46
  112. package/static/js/keyboard.js +4 -1
  113. package/static/js/keys.js +12 -0
  114. package/static/js/layers.js +295 -32
  115. package/static/js/links.js +3 -3
  116. package/static/js/markdown.js +28 -1
  117. package/static/js/nav.js +22 -4
  118. package/static/js/pending.js +161 -0
  119. package/static/js/points.js +70 -9
  120. package/static/js/progress.js +4 -5
  121. package/static/js/quick-questions.js +15 -2
  122. package/static/js/reading-level.js +97 -0
  123. package/static/js/review-session.js +113 -29
  124. package/static/js/settings.js +55 -24
  125. package/static/js/signoff.js +79 -10
  126. package/static/js/skin.js +2 -2
  127. package/static/styles/chat-panel.css +22 -24
  128. package/static/styles/chat.css +4 -0
  129. package/static/styles/commands.css +6 -0
  130. package/static/styles/header.css +21 -0
  131. package/static/styles/panels.css +4 -0
  132. package/static/styles/pending.css +102 -0
  133. package/static/styles/review-actions.css +1 -0
  134. package/static/styles/review.css +4 -0
  135. package/static/styles/skin-github.css +7 -1
  136. package/static/styles.css +1 -0
  137. package/src/github/gh.ts +0 -211
package/static/js/app.js CHANGED
@@ -13,28 +13,43 @@ import {
13
13
  saveAppearance,
14
14
  } from './api.js'
15
15
  import { setChatEnabled } from './ask.js'
16
- import { readChatWidth, renderChatShell, wireChat } from './chat.js'
16
+ import { readChatMinimized, readChatWidth, renderChatShell, wireChat } from './chat.js'
17
17
  import { runCommand, wireCopyCommands } from './commands.js'
18
18
  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 {
23
+ carriedOverBarHtml,
24
+ marksCarriedBarHtml,
25
+ renderEmptyState,
26
+ renderStaleState,
27
+ staleBarHtml,
28
+ } from './empty-state.js'
23
29
  import { errorCardHtml } from './errors.js'
24
30
  import { renderHeader } from './header.js'
25
31
  import { wireDropZone } from './import-zone.js'
26
32
  import { toast, wireReview } from './interactions.js'
27
- import { defineLayerElements, pathSet, renderLayers, renderRail, setRenderContext } from './layers.js'
33
+ import {
34
+ defineLayerElements,
35
+ pathSet,
36
+ renderLayers,
37
+ renderRail,
38
+ setFoldLevel,
39
+ setRenderContext,
40
+ } from './layers.js'
28
41
  import { renderOverview } from './overview.js'
29
42
  import { wireQuickQuestions } from './quick-questions.js'
30
43
  import { canvasChanged, openRegenerateDialog } from './regenerate.js'
31
44
  import { createReviewSession } from './review-session.js'
32
45
  import { initScrollSpy } from './scroll-spy.js'
33
46
  import { openSettingsDialog } from './settings.js'
34
- import { applySkin, nextSkin, readSkin, skinLabel } from './skin.js'
47
+ import { applySkin, DEFAULT_SKIN, nextSkin, readSkin, skinLabel } from './skin.js'
35
48
  import { applyTheme, nextTheme, readTheme, themeLabel } from './theme.js'
49
+ import { hostLabel, setHost } from './host.js'
36
50
 
37
- /** @typedef {{ prNumber: number, owner: string, repo: string, version: string }} Bootstrap */
51
+ /** @typedef {import('./contract-types.js').ReviewKey} ReviewKey */
52
+ /** @typedef {import('./contract-types.js').ReviewBootstrap} Bootstrap */
38
53
 
39
54
  /** @returns {Bootstrap | null} */
40
55
  function readBootstrap() {
@@ -57,7 +72,7 @@ function footerHtml(version, bundle) {
57
72
  const canvas = bundle?.canvas
58
73
  ? `<span>canvas <span class="mono">${esc(bundle.canvas.headSha.slice(0, 7))}</span> · ${esc(bundle.canvas.source)}</span>`
59
74
  : ''
60
- return `<footer><span>pr-review ${esc(version)}</span>${canvas}<span>localhost only · nothing leaves this machine except GitHub posts you confirm</span></footer>`
75
+ 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
76
  }
62
77
 
63
78
  /** @param {ReadonlyArray<string>} warnings */
@@ -83,7 +98,7 @@ function toEnvelopeError(err) {
83
98
  /**
84
99
  * The patches for the bundle. The first visit builds derived/ while the bundle resolves, so the
85
100
  * parallel fetch can land before the patches exist; one more try after the bundle is enough.
86
- * @param {number} prNumber
101
+ * @param {ReviewKey} prNumber
87
102
  * @param {Promise<Record<string, string> | null>} started
88
103
  * @param {string} [headSha] the canvas commit, when it is not the PR head
89
104
  */
@@ -107,7 +122,7 @@ export class PrAppElement extends HTMLElement {
107
122
  /** @type {import('./theme.js').Theme} */
108
123
  theme = 'auto'
109
124
  /** @type {import('./skin.js').Skin} */
110
- skin = 'terminal'
125
+ skin = DEFAULT_SKIN
111
126
  /** @type {{ stop: () => void } | null} */
112
127
  diagrams = null
113
128
  /** @type {{ stop: () => void } | null} */
@@ -155,8 +170,12 @@ export class PrAppElement extends HTMLElement {
155
170
  this.innerHTML = errorCardHtml({ code: 'INTERNAL', message: 'missing bootstrap data' })
156
171
  return
157
172
  }
173
+ setHost(this.bootstrap.host)
158
174
  this.theme = readTheme(document.documentElement)
159
175
  this.skin = readSkin(document.documentElement)
176
+ // The reading level the canvas opens at comes from the settings file with the page, so the
177
+ // first draw hides what the reader asked for.
178
+ setFoldLevel(this, this.bootstrap.foldLevel)
160
179
  const patchesPromise = fetchPatches(this.bootstrap.prNumber).then(
161
180
  r => r.patches,
162
181
  () => null
@@ -196,6 +215,7 @@ export class PrAppElement extends HTMLElement {
196
215
  const header = renderHeader(bundle, { host: location.host, theme: this.theme, skin: this.skin, now })
197
216
  const chatEnabled = bundle.chat.enabled
198
217
  const storage = typeof localStorage === 'undefined' ? null : localStorage
218
+ const chatMinimized = chatEnabled && readChatMinimized(storage)
199
219
  // The renderers read this while they build the cards, so it is set before the first one.
200
220
  setChatEnabled(chatEnabled)
201
221
  const showsCanvas = bundle.artifact !== undefined && (bundle.status === 'ready' || this.viewStale)
@@ -215,20 +235,29 @@ export class PrAppElement extends HTMLElement {
215
235
  staleSha
216
236
  )
217
237
  // The context is set before any <pr-file> connects, so each card renders once, when visible.
238
+ const headSha = staleSha ?? bundle.pr.headSha
218
239
  setRenderContext({
219
240
  artifact,
220
241
  files,
221
242
  patches,
243
+ headSha,
222
244
  comments: bundle.comments.reviewComments,
223
245
  state: bundle.state,
224
246
  now,
225
247
  })
226
248
  defineLayerElements()
227
- const staleBar = bundle.status === 'stale' && bundle.stale ? staleBarHtml(bundle.stale) : ''
249
+ const staleBar =
250
+ bundle.status === 'stale' && bundle.stale
251
+ ? staleBarHtml(bundle.stale, bundle.local)
252
+ : bundle.carriedOver
253
+ ? carriedOverBarHtml(bundle.carriedOver)
254
+ : ''
255
+ const marksBar =
256
+ bundle.marksCarriedFrom === undefined ? '' : marksCarriedBarHtml(bundle.marksCarriedFrom)
228
257
  this.innerHTML =
229
258
  header +
230
259
  bannerHtml(bundle.warnings) +
231
- `<div class="layout${chatEnabled ? '' : ' no-chat'}">${renderRail(artifact, bundle.state)}<main id="main">${staleBar}${renderOverview(bundle, { paths, now })}${renderLayers(artifact, files, bundle.state, bundle.comments.reviewComments)}</main>${renderChatShell({ enabled: chatEnabled, width: readChatWidth(storage) })}</div>` +
260
+ `<div class="layout${chatEnabled && !chatMinimized ? '' : ' no-chat'}">${renderRail(artifact, bundle.state)}<main id="main">${staleBar}${marksBar}${renderOverview(bundle, { paths, now })}${renderLayers(artifact, files, bundle.state, bundle.comments.reviewComments, headSha)}</main>${renderChatShell({ enabled: chatEnabled, width: readChatWidth(storage), minimized: chatMinimized })}</div>` +
232
261
  footerHtml(boot.version, bundle)
233
262
  // The screen is interactive from here: reviewed state, dismissals, threads, and posting.
234
263
  // A stale canvas shows the diff of an older commit, so nothing is posted from it: a line
@@ -249,6 +278,8 @@ export class PrAppElement extends HTMLElement {
249
278
  state: bundle.state,
250
279
  capabilities,
251
280
  headSha: bundle.pr.headSha,
281
+ // The marks are keyed to the canvas on the page, so every mark names it.
282
+ ...(bundle.canvas === undefined ? {} : { canvasSha: bundle.canvas.headSha }),
252
283
  })
253
284
  const interactions = wireReview(this, session, {
254
285
  chat: () => this.chat,
@@ -371,11 +402,15 @@ export class PrAppElement extends HTMLElement {
371
402
  this.viewStale = true
372
403
  void this.render(bundle)
373
404
  })
374
- wireDropZone(this, {
375
- prNumber: boot.prNumber,
376
- importImpl: importCanvas,
377
- onImported: () => void this.reload(),
378
- })
405
+ // Importing a canvas a teammate attached is a pull request thing; local work has no thread
406
+ // to attach one to, and the local screens draw no drop zone.
407
+ if (typeof boot.prNumber === 'number') {
408
+ wireDropZone(this, {
409
+ prNumber: boot.prNumber,
410
+ importImpl: importCanvas,
411
+ onImported: () => void this.reload(),
412
+ })
413
+ }
379
414
  }
380
415
 
381
416
  /**
@@ -1,42 +1,65 @@
1
1
  // @ts-check
2
2
 
3
3
  /** Keeps the same chat mounted while switching between the sidebar and floating panel.
4
+ *
5
+ * The chat minimizes at every width. Narrow screens float it over the canvas, so minimizing
6
+ * closes the dialog; wide screens dock it in the layout grid, so minimizing drops the column.
7
+ * Either way the launcher brings back the same pane with its draft and transcript intact.
8
+ *
4
9
  * @param {HTMLElement} root
5
10
  * @param {HTMLElement} pane
11
+ * @param {{ minimized?: boolean, onMinimizedChange?: (minimized: boolean) => void }} [opts]
6
12
  */
7
- export function wireChatPanel(root, pane) {
13
+ export function wireChatPanel(root, pane, opts = {}) {
8
14
  const dialog = /** @type {HTMLDialogElement} */ (root.querySelector('#chat-dialog'))
9
15
  const launcher = /** @type {HTMLButtonElement} */ (root.querySelector('#chat-launcher'))
10
16
  const minimize = /** @type {HTMLButtonElement} */ (root.querySelector('#chat-minimize'))
17
+ const layout = root.querySelector('.layout')
11
18
  const narrow = window.matchMedia('(max-width: 1360px)')
12
19
  const mobile = window.matchMedia('(max-width: 600px)')
13
- let expanded = false
20
+ /** Floating panels start minimized; a docked one starts the way the reader last left it. */
21
+ let floating = false
22
+ let docked = !opts.minimized
23
+
24
+ const shown = () => (narrow.matches ? floating : docked)
14
25
 
15
26
  function sync() {
16
27
  if (dialog.open) dialog.close()
17
28
  if (narrow.matches) {
18
29
  dialog.append(pane)
19
- if (expanded) {
30
+ pane.hidden = false
31
+ if (floating) {
20
32
  if (mobile.matches) dialog.showModal()
21
33
  else dialog.show()
22
34
  }
23
35
  } else {
24
36
  dialog.before(pane)
37
+ pane.hidden = !docked
25
38
  }
26
- launcher.setAttribute('aria-expanded', String(narrow.matches && expanded))
27
- launcher.hidden = narrow.matches && expanded
39
+ layout?.classList.toggle('no-chat', !narrow.matches && !docked)
40
+ launcher.setAttribute('aria-expanded', String(shown()))
41
+ launcher.hidden = shown()
28
42
  }
29
43
 
30
44
  function open() {
31
- if (narrow.matches && !expanded) {
32
- expanded = true
45
+ if (!shown()) {
46
+ if (narrow.matches) floating = true
47
+ else {
48
+ docked = true
49
+ opts.onMinimizedChange?.(false)
50
+ }
33
51
  sync()
34
52
  }
35
- pane.querySelector('textarea')?.focus()
53
+ // The composer sits low in a tall sticky pane, so plain focus() would scroll the canvas to it.
54
+ pane.querySelector('textarea')?.focus({ preventScroll: true })
36
55
  }
37
56
 
38
57
  function close() {
39
- expanded = false
58
+ if (narrow.matches) floating = false
59
+ else {
60
+ docked = false
61
+ opts.onMinimizedChange?.(true)
62
+ }
40
63
  sync()
41
64
  launcher.focus()
42
65
  }
package/static/js/chat.js CHANGED
@@ -23,15 +23,17 @@ import { postedCommentUrl, viewCommentHtml } from './comment-link.js'
23
23
  import { esc, qs } from './dom.js'
24
24
  import { getRenderContext } from './layers.js'
25
25
  import { renderMarkdown } from './markdown.js'
26
+ import { postToLabel } from './host.js'
26
27
  import { splitChatAnswer, targetsFromFiles } from './proposed-comment.js'
27
28
 
28
29
  export const CHAT_WIDTH_KEY = 'pr-review.chat-width'
30
+ export const CHAT_MINIMIZED_KEY = 'pr-review.chat-minimized'
29
31
  export const CHAT_WIDTH_MIN = 280
30
32
  export const CHAT_WIDTH_MAX = 560
31
33
  export const CHAT_WIDTH_DEFAULT = 340
32
34
 
33
35
  /**
34
- * @param {{ enabled: boolean, width?: number }} opts
36
+ * @param {{ enabled: boolean, width?: number, minimized?: boolean }} opts
35
37
  * @returns {string} '' when AI Chat is disabled
36
38
  */
37
39
  export function renderChatShell(opts) {
@@ -39,8 +41,10 @@ export function renderChatShell(opts) {
39
41
  return ''
40
42
  }
41
43
  const width = clampWidth(opts.width ?? CHAT_WIDTH_DEFAULT)
44
+ // The launcher starts hidden and `wireChatPanel` reveals it where it belongs, so a docked
45
+ // chat never paints a launcher over itself on the first frame.
42
46
  return (
43
- '<aside class="chat" aria-labelledby="chat-h">' +
47
+ `<aside class="chat" aria-labelledby="chat-h"${opts.minimized ? ' hidden' : ''}>` +
44
48
  `<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>` +
45
49
  '<div class="chat-h"><h2 id="chat-h">AI Chat</h2><label class="sr" for="thread">Thread</label>' +
46
50
  '<select id="thread"></select>' +
@@ -59,7 +63,7 @@ export function renderChatShell(opts) {
59
63
  '<span class="muted small">enter to send</span></div></form>' +
60
64
  '</aside>' +
61
65
  '<dialog class="chat-dialog" id="chat-dialog" aria-labelledby="chat-h"></dialog>' +
62
- '<button class="chat-launcher" id="chat-launcher" type="button" aria-controls="chat-dialog" aria-expanded="false">AI Chat</button>'
66
+ '<button class="chat-launcher" id="chat-launcher" type="button" hidden aria-controls="chat-dialog" aria-expanded="false">AI Chat</button>'
63
67
  )
64
68
  }
65
69
 
@@ -101,6 +105,23 @@ export function writeChatWidth(storage, width) {
101
105
  storage?.setItem(CHAT_WIDTH_KEY, String(clampWidth(width)))
102
106
  }
103
107
 
108
+ /**
109
+ * Only a wide screen stores this: a floating chat always starts minimized.
110
+ * @param {Storage | null} storage
111
+ * @returns {boolean}
112
+ */
113
+ export function readChatMinimized(storage) {
114
+ return (storage === null ? null : storage.getItem(CHAT_MINIMIZED_KEY)) === '1'
115
+ }
116
+
117
+ /**
118
+ * @param {Storage | null} storage
119
+ * @param {boolean} minimized
120
+ */
121
+ export function writeChatMinimized(storage, minimized) {
122
+ storage?.setItem(CHAT_MINIMIZED_KEY, minimized ? '1' : '0')
123
+ }
124
+
104
125
  /** The questions the quick menu offers, and the one that just focuses the box. */
105
126
  export const QUICK_QUESTIONS = [
106
127
  'Suggestion to solve this?',
@@ -113,7 +134,7 @@ export const QUICK_QUESTIONS = [
113
134
  /**
114
135
  * @typedef {{
115
136
  * root: HTMLElement,
116
- * prNumber: number,
137
+ * prNumber: import('./contract-types.js').ReviewKey,
117
138
  * session: ReviewSession,
118
139
  * storage?: Storage | null,
119
140
  * api?: Partial<ChatApi>,
@@ -179,7 +200,7 @@ export function proposedCommentHtml(comment, id, postedUrl) {
179
200
  `<div class="prose">${renderMarkdown(comment.body)}</div>` +
180
201
  '<span class="tbtns">' +
181
202
  (postedUrl === undefined
182
- ? `<button class="cmd fill" type="button" data-act="proposed-post" data-proposed="${esc(id)}" data-needs-post>post to github</button>`
203
+ ? `<button class="cmd fill" type="button" data-act="proposed-post" data-proposed="${esc(id)}" data-needs-post>${postToLabel()}</button>`
183
204
  : viewCommentHtml(postedUrl, true)) +
184
205
  `<button class="cmd" type="button" data-act="proposed-edit" data-proposed="${esc(id)}" data-needs-post>edit</button>` +
185
206
  `<button class="cmd" type="button" data-copy="${esc(comment.body)}">copy</button>` +
@@ -748,7 +769,10 @@ export function wireChat(options) {
748
769
  }
749
770
 
750
771
  const stopResize = wireResize(pane, root, storage, applyWidth)
751
- const panel = wireChatPanel(root, pane)
772
+ const panel = wireChatPanel(root, pane, {
773
+ minimized: readChatMinimized(storage),
774
+ onMinimizedChange: minimized => writeChatMinimized(storage, minimized),
775
+ })
752
776
 
753
777
  form.addEventListener('submit', onSubmit)
754
778
  root.addEventListener('click', onClick)
@@ -1,10 +1,45 @@
1
1
  // @ts-check
2
2
  // Rows a reviewer can hide: the canvas folds the model named, and the folds the diff renderer
3
3
  // marked (import-only and whitespace-only changes, and moved blocks). Both open again when a
4
- // deep link lands inside them.
4
+ // deep link lands inside them. Every model fold is wired once, when the diff is drawn; the
5
+ // reader's level only decides which of them are active, so changing it never rebuilds the table
6
+ // and an open composer or an expanded fold survives. The renderer's own folds hide in every mode.
5
7
  import { findRow } from './anchors.js'
8
+ import { foldsForLevel } from './fold-levels.js'
6
9
 
7
10
  /** @typedef {import('./contract-types.js').CodeFold} CodeFold */
11
+ /** @typedef {import('./contract-types.js').FoldLevel} FoldLevel */
12
+
13
+ /** Rows that keep their code open at every level. */
14
+ const PINNED = '[data-decoration="point"], [data-decoration="thread"]'
15
+ /** Annotation band rows and the note row under them; only an aggressive fold may hide these. */
16
+ const ANNOTATED = '.ann, [data-decoration="note"]'
17
+
18
+ /**
19
+ * One model fold on a drawn table. `rows` are fixed when the diff is drawn; `hiddenBefore` is how
20
+ * each row looked before any fold touched it, so a fold the level turns off gives it back.
21
+ * @typedef {{
22
+ * fold: CodeFold,
23
+ * rows: HTMLTableRowElement[],
24
+ * hiddenBefore: boolean[],
25
+ * summaries: Set<HTMLTableRowElement>,
26
+ * header: HTMLTableRowElement,
27
+ * toggle: HTMLButtonElement,
28
+ * active: boolean,
29
+ * expanded: boolean,
30
+ * }} WiredFold
31
+ */
32
+
33
+ /** The folds wired on each drawn diff. A redraw builds a new diff, so it starts a new list. */
34
+ const wiredFolds = /** @type {WeakMap<Element, WiredFold[]>} */ (new WeakMap())
35
+
36
+ /**
37
+ * The drawn diff of a card, one table per hunk, or null before it draws.
38
+ * @param {HTMLElement} card
39
+ */
40
+ function drawnDiff(card) {
41
+ return card.querySelector('.diff-wrap')
42
+ }
8
43
 
9
44
  /**
10
45
  * @param {Element | null} summary
@@ -27,7 +62,8 @@ function coveredFoldSummary(summary, rows) {
27
62
  }
28
63
 
29
64
  /**
30
- * Finds the rows between both anchors. A decorated or overlapping range stays open.
65
+ * Finds the rows between both anchors. A range holding an attention point or a discussion stays
66
+ * open at every level; a range holding an annotation stays open unless the fold is aggressive.
31
67
  * @param {HTMLElement} card
32
68
  * @param {string} key
33
69
  * @param {CodeFold} fold
@@ -47,18 +83,30 @@ function foldRows(card, key, fold) {
47
83
 
48
84
  const siblings = Array.from(first.parentElement.children)
49
85
  const from = siblings.indexOf(first)
50
- const to = siblings.indexOf(last)
86
+ let to = siblings.indexOf(last)
51
87
 
52
88
  if (to < from) {
53
89
  return []
54
90
  }
55
91
 
56
- const rows = siblings.slice(from, to + 1).filter(row => row instanceof HTMLTableRowElement)
57
- const hasDiscussion = rows.some(row => row.matches('.ann, [data-decoration], [data-code-fold], .code-fold'))
92
+ const aggressive = fold.level === 'aggressive'
58
93
 
59
- const hasThread = Boolean(first.closest('table')?.querySelector('[data-decoration="thread"]'))
94
+ // An annotation's note row sits under the last line it covers, so an aggressive fold that ends
95
+ // on that line takes the note with it rather than leaving it stranded above open code.
96
+ if (aggressive) {
97
+ while (siblings[to + 1]?.matches('[data-decoration="note"]') === true) {
98
+ to++
99
+ }
100
+ }
60
101
 
61
- if (hasDiscussion || hasThread || last.nextElementSibling?.matches('[data-decoration]')) {
102
+ const rows = siblings.slice(from, to + 1).filter(row => row instanceof HTMLTableRowElement)
103
+ const trailing = siblings[to + 1]
104
+ const blocked =
105
+ rows.some(row => row.matches(PINNED)) ||
106
+ (!aggressive && rows.some(row => row.matches(ANNOTATED))) ||
107
+ trailing?.matches(aggressive ? PINNED : '[data-decoration]') === true
108
+
109
+ if (blocked) {
62
110
  return []
63
111
  }
64
112
 
@@ -70,6 +118,20 @@ function foldRows(card, key, fold) {
70
118
  return rows
71
119
  }
72
120
 
121
+ /**
122
+ * What the toggle says. An aggressive fold that hides an annotation shows the annotation instead
123
+ * of its own title, so the reader still reads the explanation of the code it replaces. The
124
+ * validator lets such a fold cover one whole annotation at most, so there is one note to show.
125
+ * @param {readonly HTMLTableRowElement[]} rows
126
+ * @param {CodeFold} fold
127
+ * @returns {string}
128
+ */
129
+ function foldLabel(rows, fold) {
130
+ const note = rows.find(row => row.matches('[data-decoration="note"]'))
131
+ const text = note?.querySelector('.prose')?.textContent?.trim()
132
+ return text === undefined || text === '' ? fold.title : text
133
+ }
134
+
73
135
  /**
74
136
  * Inserts the fold's title above its first row.
75
137
  * @param {HTMLTableRowElement} first
@@ -91,63 +153,128 @@ function createToggle(first, title) {
91
153
  header.appendChild(cell)
92
154
  first.before(header)
93
155
 
94
- return toggle
156
+ return { header, toggle }
95
157
  }
96
158
 
97
159
  /**
98
- * Keeps the original rows in the DOM so comments and deep links retain their anchors.
99
- * @param {HTMLTableRowElement} first
100
- * @param {HTMLTableRowElement[]} rows
101
- * @param {string} title
160
+ * Draws one fold as the level and the reader left it. An inactive fold gives its rows back as
161
+ * they were; an active one hides them behind its title until the reader expands it.
162
+ * @param {WiredFold} wired
102
163
  */
103
- function wireFold(first, rows, title) {
104
- const toggle = createToggle(first, title)
105
- const table = first.closest('table')
106
- const foldSummaries = new Set(rows.filter(row => coveredFoldSummary(row, rows)))
107
- toggle.setAttribute(
108
- 'aria-controls',
109
- rows
110
- .map(row => row.id)
111
- .filter(Boolean)
112
- .join(' ')
113
- )
114
-
115
- const setExpanded = (/** @type {boolean} */ expanded) => {
116
- toggle.setAttribute('aria-expanded', String(expanded))
117
-
118
- for (const row of rows) {
119
- row.hidden = !expanded || foldSummaries.has(row)
164
+ function paintFold(wired) {
165
+ wired.header.hidden = !wired.active
166
+ wired.toggle.setAttribute('aria-expanded', String(wired.active && wired.expanded))
167
+ for (const [index, row] of wired.rows.entries()) {
168
+ if (wired.active) {
169
+ row.hidden = !wired.expanded || wired.summaries.has(row)
120
170
  row.setAttribute('data-code-fold', '')
171
+ } else {
172
+ row.hidden = wired.hiddenBefore[index] === true
173
+ row.removeAttribute('data-code-fold')
121
174
  }
122
175
  }
176
+ }
123
177
 
124
- toggle.addEventListener('click', () => setExpanded(toggle.getAttribute('aria-expanded') !== 'true'))
178
+ /**
179
+ * Points a drawn card's folds at a level: the outermost folds that hide at it become active and
180
+ * start folded, and the rest give their rows back. A fold that stays active keeps whatever the
181
+ * reader did with it. A card with a thread or a pending draft keeps all of its code open, so none
182
+ * is active. Inactive
183
+ * folds paint first, so a row shared with an active outer or inner fold ends up as the active
184
+ * one says.
185
+ * @param {HTMLElement} card
186
+ * @param {FoldLevel} level
187
+ * @param {boolean} discussed whether a thread or a pending draft sits in the card's chunks
188
+ */
189
+ export function setCodeFoldLevel(card, level, discussed) {
190
+ const diff = drawnDiff(card)
191
+ const wired = diff === null ? [] : (wiredFolds.get(diff) ?? [])
192
+ const active = new Set(
193
+ discussed
194
+ ? []
195
+ : foldsForLevel(
196
+ wired.map(w => w.fold),
197
+ level
198
+ )
199
+ )
125
200
 
126
- table?.addEventListener('reveal-code', event => {
127
- const target = event.target
128
- if (target instanceof Node && (target === table || rows.some(row => row.contains(target)))) {
129
- setExpanded(true)
201
+ for (const w of wired) {
202
+ const now = active.has(w.fold)
203
+ if (now && !w.active) {
204
+ w.expanded = false
130
205
  }
131
- })
132
-
133
- setExpanded(false)
206
+ w.active = now
207
+ }
208
+ const inactive = wired.filter(w => !w.active)
209
+ for (const w of [...inactive, ...wired.filter(fold => fold.active)]) {
210
+ paintFold(w)
211
+ }
134
212
  }
135
213
 
136
214
  /**
137
- * Adds title-only folds; ranges with annotations, attention points, or discussions stay open.
215
+ * Wires every model fold of a freshly drawn diff, then applies the reader's level. The rows of
216
+ * every fold are found before any title row goes in, so nested folds each see only diff rows.
217
+ * Ranges with attention points stay open, and so do ranges with annotations unless the fold is
218
+ * aggressive. A second call on the same diff does nothing.
138
219
  * @param {HTMLElement} card
139
220
  * @param {string} key
140
221
  * @param {readonly CodeFold[]} folds
222
+ * @param {FoldLevel} level the reader's level
223
+ * @param {boolean} discussed whether a thread or a pending draft sits in the card's chunks
141
224
  */
142
- export function applyCodeFolds(card, key, folds) {
143
- for (const fold of folds) {
144
- const rows = foldRows(card, key, fold)
145
- const first = rows[0]
225
+ export function applyCodeFolds(card, key, folds, level, discussed) {
226
+ const diff = drawnDiff(card)
227
+ if (diff === null || wiredFolds.has(diff)) {
228
+ return
229
+ }
146
230
 
147
- if (first !== undefined) {
148
- wireFold(first, rows, fold.title)
231
+ const found = folds.map(fold => ({ fold, rows: foldRows(card, key, fold) }))
232
+ /** @type {WiredFold[]} */
233
+ const wired = []
234
+ for (const { fold, rows } of found) {
235
+ const first = rows[0]
236
+ if (first === undefined) {
237
+ continue
149
238
  }
239
+ const { header, toggle } = createToggle(first, foldLabel(rows, fold))
240
+ toggle.setAttribute(
241
+ 'aria-controls',
242
+ rows
243
+ .map(row => row.id)
244
+ .filter(Boolean)
245
+ .join(' ')
246
+ )
247
+ const w = {
248
+ fold,
249
+ rows,
250
+ hiddenBefore: rows.map(row => row.hidden),
251
+ summaries: new Set(rows.filter(row => coveredFoldSummary(row, rows))),
252
+ header,
253
+ toggle,
254
+ active: false,
255
+ expanded: false,
256
+ }
257
+ toggle.addEventListener('click', () => {
258
+ w.expanded = !w.expanded
259
+ paintFold(w)
260
+ })
261
+ const table = first.closest('table')
262
+ table?.addEventListener('reveal-code', event => {
263
+ const target = event.target
264
+ if (
265
+ w.active &&
266
+ target instanceof Node &&
267
+ (target === table || rows.some(row => row.contains(target)))
268
+ ) {
269
+ w.expanded = true
270
+ paintFold(w)
271
+ }
272
+ })
273
+ wired.push(w)
150
274
  }
275
+
276
+ wiredFolds.set(diff, wired)
277
+ setCodeFoldLevel(card, level, discussed)
151
278
  }
152
279
 
153
280
  /**