@vintasoftware/pr-review-canvas 0.1.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 (157) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +192 -0
  3. package/bin/pr-review.mjs +5 -0
  4. package/docs/reference.md +340 -0
  5. package/package.json +74 -0
  6. package/pr-review.config.example.yml +68 -0
  7. package/prompts/chat-seed.md +64 -0
  8. package/prompts/generation-format.md +255 -0
  9. package/prompts/generation-strict.md +34 -0
  10. package/prompts/generation-surfacing.md +67 -0
  11. package/prompts/layers-default.md +13 -0
  12. package/prompts/quality-standards.md +32 -0
  13. package/skills/pr-review-canvas/SKILL.md +177 -0
  14. package/src/acpx/acpx.ts +530 -0
  15. package/src/acpx/agents.ts +85 -0
  16. package/src/acpx/events.ts +216 -0
  17. package/src/acpx/ndjson.ts +69 -0
  18. package/src/acpx/preflight.ts +44 -0
  19. package/src/canvas/export.ts +95 -0
  20. package/src/canvas/import.ts +138 -0
  21. package/src/canvas/name.ts +55 -0
  22. package/src/canvas/zip.ts +123 -0
  23. package/src/chat/chat-manager.ts +389 -0
  24. package/src/chat/context.ts +160 -0
  25. package/src/chat/seed.ts +71 -0
  26. package/src/chat/threads.ts +114 -0
  27. package/src/cli.ts +199 -0
  28. package/src/commands.ts +424 -0
  29. package/src/config.ts +142 -0
  30. package/src/contract/api.ts +190 -0
  31. package/src/contract/canvas-manifest.ts +29 -0
  32. package/src/contract/chat.ts +76 -0
  33. package/src/contract/comments.ts +96 -0
  34. package/src/contract/discovery.ts +20 -0
  35. package/src/contract/generation-context.ts +77 -0
  36. package/src/contract/keys.ts +14 -0
  37. package/src/contract/links.ts +5 -0
  38. package/src/contract/mermaid-fences.ts +4 -0
  39. package/src/contract/review-artifact.ts +324 -0
  40. package/src/contract/settings.ts +144 -0
  41. package/src/contract/state.ts +46 -0
  42. package/src/contract/validation.ts +43 -0
  43. package/src/git/diff-collector.ts +151 -0
  44. package/src/git/git.ts +115 -0
  45. package/src/git/lang.ts +1 -0
  46. package/src/git/materialize.ts +79 -0
  47. package/src/git/patch-lines.ts +60 -0
  48. package/src/github/attachments.ts +288 -0
  49. package/src/github/capabilities.ts +112 -0
  50. package/src/github/comments.ts +132 -0
  51. package/src/github/gh.ts +196 -0
  52. package/src/github/post-comment.ts +104 -0
  53. package/src/github/post-review.ts +44 -0
  54. package/src/github/pr.ts +133 -0
  55. package/src/github/review-body.ts +72 -0
  56. package/src/github/threads.ts +63 -0
  57. package/src/paths.ts +10 -0
  58. package/src/project-config.ts +219 -0
  59. package/src/prompt-files.ts +26 -0
  60. package/src/review/diagram-nodes.ts +227 -0
  61. package/src/review/doctor.ts +139 -0
  62. package/src/review/glob.ts +33 -0
  63. package/src/review/install-skill.ts +107 -0
  64. package/src/review/normalize.ts +209 -0
  65. package/src/review/prepare.ts +165 -0
  66. package/src/review/prompt.ts +233 -0
  67. package/src/review/publish.ts +209 -0
  68. package/src/review/skill-command.ts +4 -0
  69. package/src/review/test-paths.ts +32 -0
  70. package/src/review/text-length.ts +15 -0
  71. package/src/review/trim-caps.ts +114 -0
  72. package/src/review/validate-folds.ts +110 -0
  73. package/src/review/validate.ts +520 -0
  74. package/src/server/app.ts +46 -0
  75. package/src/server/bundle.ts +266 -0
  76. package/src/server/capped-body.ts +62 -0
  77. package/src/server/context.ts +174 -0
  78. package/src/server/env.ts +7 -0
  79. package/src/server/errors.ts +65 -0
  80. package/src/server/html.ts +140 -0
  81. package/src/server/node-server.ts +42 -0
  82. package/src/server/routes/api.ts +256 -0
  83. package/src/server/routes/chat-routes.ts +221 -0
  84. package/src/server/routes/pages.ts +64 -0
  85. package/src/server/routes/review-routes.ts +245 -0
  86. package/src/server/routes/static.ts +114 -0
  87. package/src/server/security.ts +104 -0
  88. package/src/server/sse.ts +67 -0
  89. package/src/store/atomic-json.ts +68 -0
  90. package/src/store/canvas-store.ts +120 -0
  91. package/src/store/data-dir.ts +29 -0
  92. package/src/store/derived-store.ts +93 -0
  93. package/src/store/pr-store.ts +69 -0
  94. package/src/store/settings-store.ts +152 -0
  95. package/src/store/state-store.ts +121 -0
  96. package/static/js/anchors.js +141 -0
  97. package/static/js/api.js +542 -0
  98. package/static/js/app.js +418 -0
  99. package/static/js/ask.js +35 -0
  100. package/static/js/chat-context.js +137 -0
  101. package/static/js/chat-scroll.js +114 -0
  102. package/static/js/chat.js +843 -0
  103. package/static/js/code-folds.js +200 -0
  104. package/static/js/commands.js +110 -0
  105. package/static/js/comment-link.js +37 -0
  106. package/static/js/composer.js +241 -0
  107. package/static/js/contract-types.d.ts +59 -0
  108. package/static/js/deep-link.js +160 -0
  109. package/static/js/diagram.js +582 -0
  110. package/static/js/diff-decorations.js +204 -0
  111. package/static/js/diff-renderer.js +860 -0
  112. package/static/js/dom.js +145 -0
  113. package/static/js/download.js +52 -0
  114. package/static/js/empty-state.js +161 -0
  115. package/static/js/errors.js +135 -0
  116. package/static/js/fences.js +90 -0
  117. package/static/js/header.js +134 -0
  118. package/static/js/hunks.js +62 -0
  119. package/static/js/import-zone.js +95 -0
  120. package/static/js/interactions.js +952 -0
  121. package/static/js/keyboard.js +131 -0
  122. package/static/js/keys.js +97 -0
  123. package/static/js/lang.js +54 -0
  124. package/static/js/layers.js +596 -0
  125. package/static/js/links.js +150 -0
  126. package/static/js/markdown.js +232 -0
  127. package/static/js/mermaid-fences.js +55 -0
  128. package/static/js/nav.js +91 -0
  129. package/static/js/overview.js +85 -0
  130. package/static/js/points.js +247 -0
  131. package/static/js/progress.js +49 -0
  132. package/static/js/proposed-comment.js +133 -0
  133. package/static/js/quick-questions.js +216 -0
  134. package/static/js/regenerate.js +69 -0
  135. package/static/js/review-session.js +257 -0
  136. package/static/js/scroll-spy.js +66 -0
  137. package/static/js/selection.js +193 -0
  138. package/static/js/settings.js +206 -0
  139. package/static/js/signoff.js +171 -0
  140. package/static/js/skin.js +56 -0
  141. package/static/js/store.js +35 -0
  142. package/static/js/theme.js +56 -0
  143. package/static/js/threads.js +78 -0
  144. package/static/js/vendor.d.ts +15 -0
  145. package/static/styles/base.css +223 -0
  146. package/static/styles/chat-tools.css +130 -0
  147. package/static/styles/chat.css +140 -0
  148. package/static/styles/commands.css +156 -0
  149. package/static/styles/diff.css +258 -0
  150. package/static/styles/header.css +114 -0
  151. package/static/styles/layout.css +123 -0
  152. package/static/styles/panels.css +152 -0
  153. package/static/styles/responsive.css +80 -0
  154. package/static/styles/review-actions.css +124 -0
  155. package/static/styles/review.css +473 -0
  156. package/static/styles/skin-github.css +356 -0
  157. package/static/styles.css +14 -0
@@ -0,0 +1,247 @@
1
+ // @ts-check
2
+ /** @typedef {import('./contract-types.js').Layer} Layer */
3
+ /** @typedef {import('./contract-types.js').Point} Point */
4
+ /** @typedef {import('./contract-types.js').PrState} PrState */
5
+ import { askButtonHtml } from './ask.js'
6
+ import { viewCommentHtml } from './comment-link.js'
7
+ import { esc } from './dom.js'
8
+ import { layerAnchorId, pointAnchorId } from './keys.js'
9
+ import { renderMarkdown } from './markdown.js'
10
+
11
+ export const LEVELS = /** @type {const} */ (['decide', 'check', 'fyi'])
12
+
13
+ /**
14
+ * @param {ReadonlyArray<Point>} points
15
+ * @returns {Record<'decide' | 'check' | 'fyi', Point[]>}
16
+ */
17
+ export function pointsByLevel(points) {
18
+ /** @type {Record<'decide' | 'check' | 'fyi', Point[]>} */
19
+ const out = { decide: [], check: [], fyi: [] }
20
+ for (const p of points) {
21
+ out[p.level].push(p)
22
+ }
23
+ return out
24
+ }
25
+
26
+ /**
27
+ * The `#line:` link for a point's anchor.
28
+ * @param {Point} p
29
+ */
30
+ export function pointLink(p) {
31
+ const range = p.endLine !== undefined && p.endLine !== p.line ? `${p.line}-${p.endLine}` : `${p.line}`
32
+ return `#line:${p.path}:${range}${p.side === 'old' ? ':old' : ''}`
33
+ }
34
+
35
+ /** @param {Point} p */
36
+ export function pointLocation(p) {
37
+ const range = p.endLine !== undefined && p.endLine !== p.line ? `${p.line}-${p.endLine}` : `${p.line}`
38
+ return `${p.path}:${range}`
39
+ }
40
+
41
+ /**
42
+ * @param {Point} p
43
+ */
44
+ export function squareHtml(p) {
45
+ return `<span class="sq ${p.level}" role="img" aria-label="${p.level}" title="${p.level}"></span>`
46
+ }
47
+
48
+ /**
49
+ * The point as a GitHub comment: the title, the body, and where it is anchored.
50
+ * @param {Point} p
51
+ */
52
+ export function pointToMarkdown(p) {
53
+ return `**${p.title}**\n\n${p.body}\n\n_${pointLocation(p)} · ${p.kind} · ${p.level} · from the pr-review canvas_`
54
+ }
55
+
56
+ /**
57
+ * What the chat is asked about when a point's `[ ask ]` is used: the point itself. The server
58
+ * resolves the fingerprint against the canvas and sends the agent the point's text along with
59
+ * the lines it sits on, so the agent sees what the reader is reacting to.
60
+ * @param {Point} p
61
+ * @returns {import('./chat-context.js').ChatContext}
62
+ */
63
+ export function pointContext(p) {
64
+ return { kind: 'point', fingerprint: p.fingerprint }
65
+ }
66
+
67
+ /**
68
+ * The commands every point carries. `copy` puts the markdown on the clipboard, `post to github`
69
+ * opens nothing and posts it at the anchor, `dismiss` takes the point off the page.
70
+ * @param {Point} p
71
+ * @param {{ dismissed?: boolean, postedUrl?: string | undefined }} [opts]
72
+ * @returns {string}
73
+ */
74
+ export function pointCommandsHtml(p, opts = {}) {
75
+ const fp = esc(p.fingerprint)
76
+ const toggle = opts.dismissed
77
+ ? `<button class="cmd" type="button" data-act="point-restore" data-fingerprint="${fp}">restore</button>`
78
+ : `<button class="cmd" type="button" data-act="point-dismiss" data-fingerprint="${fp}">dismiss</button>`
79
+ return (
80
+ '<span class="tbtns">' +
81
+ `<button class="cmd" type="button" data-copy="${esc(pointToMarkdown(p))}">copy</button>` +
82
+ (opts.postedUrl === undefined
83
+ ? `<button class="cmd" type="button" data-act="point-post" data-point="${esc(p.id)}" data-needs-post>post to github</button>`
84
+ : viewCommentHtml(opts.postedUrl)) +
85
+ askButtonHtml(pointContext(p)) +
86
+ toggle +
87
+ '</span>'
88
+ )
89
+ }
90
+
91
+ /**
92
+ * The comment this point was posted as, when it was.
93
+ * @param {Point} p
94
+ * @param {{ posted?: ReadonlyMap<string, string> }} ctx
95
+ */
96
+ export function postedFor(p, ctx) {
97
+ return ctx.posted?.get(p.fingerprint)
98
+ }
99
+
100
+ /**
101
+ * Where each point's posted comment lives, read from the state and the comments the page holds.
102
+ * @param {import('./contract-types.js').PrState} state
103
+ * @param {ReadonlyArray<{ id: number, url: string }>} comments
104
+ * @returns {Map<string, string>} point fingerprint → comment url
105
+ */
106
+ export function postedUrls(state, comments) {
107
+ const urls = new Map(comments.map(c => [c.id, c.url]))
108
+ /** @type {Map<string, string>} */
109
+ const out = new Map()
110
+ for (const entry of state.posted) {
111
+ const url = urls.get(entry.commentId)
112
+ if (entry.pointFingerprint !== undefined && url !== undefined) {
113
+ out.set(entry.pointFingerprint, url)
114
+ }
115
+ }
116
+ return out
117
+ }
118
+
119
+ /**
120
+ * One attention point card, shown in the layer that owns the point.
121
+ * @param {Point} p
122
+ * @param {{ paths: ReadonlySet<string>, state?: PrState, posted?: ReadonlyMap<string, string> }} ctx
123
+ */
124
+ export function pointCardHtml(p, ctx) {
125
+ const dismissed = ctx.state?.dismissed[p.fingerprint] !== undefined
126
+ const posted = postedFor(p, ctx)
127
+ return (
128
+ `<li class="finding" id="${esc(pointAnchorId(p.id))}" data-point="${esc(p.id)}" data-fingerprint="${esc(p.fingerprint)}"${dismissed ? ' hidden' : ''}>${squareHtml(p)}<div>` +
129
+ `<div class="f-title"><span>${esc(p.title)}</span><span class="pill kind">${esc(p.kind)}</span>` +
130
+ `<a class="loc" href="${esc(pointLink(p))}">${esc(pointLocation(p))}</a></div>` +
131
+ `<div class="prose">${renderMarkdown(p.body, { paths: ctx.paths })}</div>` +
132
+ `${pointCommandsHtml(p, { postedUrl: posted })}</div></li>`
133
+ )
134
+ }
135
+
136
+ /**
137
+ * The muted line under the overview that holds the points the reader set aside.
138
+ * @param {ReadonlyArray<Point>} points
139
+ * @param {PrState} state
140
+ * @param {{ paths: ReadonlySet<string>, posted?: ReadonlyMap<string, string> }} ctx
141
+ * @param {boolean} [expanded]
142
+ */
143
+ export function dismissedListHtml(points, state, ctx, expanded = false) {
144
+ const dismissed = points.filter(p => state.dismissed[p.fingerprint] !== undefined)
145
+ if (dismissed.length === 0) {
146
+ return '<div class="dismissed-list" hidden></div>'
147
+ }
148
+ const rows = dismissed
149
+ .map(p => {
150
+ const posted = postedFor(p, ctx)
151
+ return (
152
+ `<li class="finding" data-point="${esc(p.id)}" data-fingerprint="${esc(p.fingerprint)}">${squareHtml(p)}<div>` +
153
+ `<div class="f-title"><span>${esc(p.title)}</span><span class="pill kind">${esc(p.kind)}</span>` +
154
+ `<a class="loc" href="${esc(pointLink(p))}">${esc(pointLocation(p))}</a></div>` +
155
+ `<div class="prose">${renderMarkdown(p.body, { paths: ctx.paths })}</div>` +
156
+ `${pointCommandsHtml(p, { dismissed: true, postedUrl: posted })}</div></li>`
157
+ )
158
+ })
159
+ .join('')
160
+ return (
161
+ '<div class="dismissed-list">' +
162
+ `<p class="muted dismissed-line">${dismissed.length} dismissed <button class="cmd" type="button" data-act="show-dismissed" aria-expanded="${expanded}">${expanded ? 'hide' : 'show'}</button></p>` +
163
+ `<ol class="findings dismissed"${expanded ? '' : ' hidden'}>${rows}</ol></div>`
164
+ )
165
+ }
166
+
167
+ /**
168
+ * Shows the points that are active and hides the ones the reader dismissed, wherever they are
169
+ * on the page, and rebuilds the dismissed list. Calling it again with the same state changes
170
+ * nothing.
171
+ * @param {ParentNode} root
172
+ * @param {ReadonlyArray<Point>} points
173
+ * @param {PrState} state
174
+ * @param {{ paths: ReadonlySet<string>, layers?: ReadonlyArray<Layer>, posted?: ReadonlyMap<string, string> }} ctx
175
+ */
176
+ export function applyDismissed(root, points, state, ctx) {
177
+ const byId = new Map(points.map(p => [p.id, p]))
178
+ for (const el of Array.from(root.querySelectorAll('[data-point]'))) {
179
+ const point = byId.get(el.getAttribute('data-point') ?? '')
180
+ if (point === undefined || el.closest('.dismissed-list') !== null) {
181
+ continue
182
+ }
183
+ const dismissed = state.dismissed[point.fingerprint] !== undefined
184
+ el.toggleAttribute('hidden', dismissed)
185
+ }
186
+ for (const counter of Array.from(root.querySelectorAll('.point-count'))) {
187
+ const layerId = counter.closest('[data-layer]')?.getAttribute('data-layer')
188
+ const own = points.filter(p => p.layerId === layerId && state.dismissed[p.fingerprint] === undefined)
189
+ counter.textContent = String(own.length)
190
+ }
191
+ const host = root.querySelector('.dismissed-list')
192
+ if (host !== null) {
193
+ const expanded = host.querySelector('[data-act="show-dismissed"]')?.getAttribute('aria-expanded') === 'true'
194
+ const template = document.createElement('template')
195
+ template.innerHTML = dismissedListHtml(points, state, ctx, expanded)
196
+ const next = template.content.firstElementChild
197
+ if (next !== null) {
198
+ host.replaceWith(next)
199
+ }
200
+ }
201
+ const sevsum = root.querySelector('.sevsum')
202
+ if (sevsum !== null) {
203
+ const active = points.filter(p => state.dismissed[p.fingerprint] === undefined)
204
+ const template = document.createElement('template')
205
+ template.innerHTML = sevsumHtml(active, ctx.layers ?? [])
206
+ const next = template.content.firstElementChild
207
+ if (next !== null) {
208
+ sevsum.replaceWith(next)
209
+ }
210
+ }
211
+ }
212
+
213
+ /**
214
+ * The inline row under a diff line.
215
+ * @param {Point} p
216
+ * @param {{ paths: ReadonlySet<string>, state?: PrState, posted?: ReadonlyMap<string, string> }} ctx
217
+ */
218
+ export function pointRowHtml(p, ctx) {
219
+ const dismissed = ctx.state?.dismissed[p.fingerprint] !== undefined
220
+ const posted = postedFor(p, ctx)
221
+ return (
222
+ `<tr class="ifind ${p.level}" data-point="${esc(p.id)}" data-fingerprint="${esc(p.fingerprint)}"${dismissed ? ' hidden' : ''}><td class="ln" colspan="3"></td><td class="code x">` +
223
+ `<div class="f-title">${squareHtml(p)}<span>${esc(p.title)}</span><span class="pill kind">${esc(p.kind)}</span></div>` +
224
+ `<div class="prose">${renderMarkdown(p.body, { paths: ctx.paths })}</div>` +
225
+ `${pointCommandsHtml(p, { postedUrl: posted })}</td></tr>`
226
+ )
227
+ }
228
+
229
+ /**
230
+ * The three squares with counts in the overview header. Each count links to the first layer that
231
+ * has a point of that level, so the overview only counts and the layers hold the cards.
232
+ * @param {ReadonlyArray<Point>} points
233
+ * @param {ReadonlyArray<Layer>} [layers] artifact order; omitted when there is nothing to link to
234
+ */
235
+ export function sevsumHtml(points, layers = []) {
236
+ const by = pointsByLevel(points)
237
+ const cell = /** @param {'decide' | 'check' | 'fyi'} level */ level => {
238
+ const n = by[level].length
239
+ const square = `<span class="sq ${level}" role="img" aria-label="${n} ${level}" title="${n} ${level}"></span>${n}`
240
+ const target = layers.find(l => by[level].some(p => p.layerId === l.id))
241
+ return target ? `<a href="#${esc(layerAnchorId(target.key))}">${square}</a>` : `<span>${square}</span>`
242
+ }
243
+ return (
244
+ `<span class="sevsum" tabindex="0" role="group" aria-label="Attention points by level">${cell('decide')}${cell('check')}${cell('fyi')}` +
245
+ '<span class="legend" aria-hidden="true"><span><span class="sq decide"></span>decide</span><span><span class="sq check"></span>check</span><span><span class="sq fyi"></span>fyi</span></span></span>'
246
+ )
247
+ }
@@ -0,0 +1,49 @@
1
+ // @ts-check
2
+ // Reviewed state per layer. Other changes never count.
3
+ /** @typedef {import('./contract-types.js').Layer} Layer */
4
+ /** @typedef {import('./contract-types.js').PrState} PrState */
5
+ /** @typedef {import('./contract-types.js').ReviewArtifact} ReviewArtifact */
6
+ import { sanitizeKey } from './keys.js'
7
+
8
+ /**
9
+ * @param {Layer} layer
10
+ * @param {PrState} state
11
+ * @returns {'done' | 'partial' | 'none'}
12
+ */
13
+ export function layerProgress(layer, state) {
14
+ if (state.reviewed[`layer:${layer.id}`] === true) {
15
+ return 'done'
16
+ }
17
+ const files = layer.files.length
18
+ let reviewedFiles = 0
19
+ for (const f of layer.files) {
20
+ if (state.reviewed[`layer:${layer.id}/file:${sanitizeKey(f.path)}`] === true) {
21
+ reviewedFiles++
22
+ }
23
+ }
24
+ if (files > 0 && reviewedFiles === files) {
25
+ return 'done'
26
+ }
27
+ return reviewedFiles > 0 ? 'partial' : 'none'
28
+ }
29
+
30
+ /**
31
+ * @param {ReviewArtifact} artifact
32
+ * @param {PrState} state
33
+ * @returns {{ done: number, total: number, percent: number }}
34
+ */
35
+ export function progressSummary(artifact, state) {
36
+ const layers = artifact.layers.filter(l => l.kind !== 'other')
37
+ const done = layers.filter(l => layerProgress(l, state) === 'done').length
38
+ const total = layers.length
39
+ return { done, total, percent: total === 0 ? 0 : Math.round((done / total) * 100) }
40
+ }
41
+
42
+ /**
43
+ * Files reviewed in a layer, for the rail meta ("2 of 3 files").
44
+ * @param {Layer} layer
45
+ * @param {PrState} state
46
+ */
47
+ export function filesReviewed(layer, state) {
48
+ return layer.files.filter(f => state.reviewed[`layer:${layer.id}/file:${sanitizeKey(f.path)}`] === true).length
49
+ }
@@ -0,0 +1,133 @@
1
+ // @ts-check
2
+ // A ```comment block in an assistant turn is a comment the agent thinks a human should post.
3
+ // It is model output, so every field is checked against the pull request's own files before the
4
+ // card offers to post it; a block that does not check out stays a code block.
5
+ import { splitFences } from './fences.js'
6
+
7
+ /**
8
+ * @typedef {{ path: string, line: number, side: 'new' | 'old', startLine?: number, body: string }} ProposedComment
9
+ */
10
+
11
+ /**
12
+ * @typedef {{ type: 'markdown', text: string }
13
+ * | { type: 'comment', comment: ProposedComment }
14
+ * | { type: 'invalid', text: string, reason: string }} ChatSegment
15
+ */
16
+
17
+ /** The comment body a card will show; longer than this is not a review comment. */
18
+ export const PROPOSED_BODY_MAX = 4000
19
+
20
+ /**
21
+ * What a target has to satisfy to be postable: the file is in this pull request, and the line is
22
+ * one the diff shows on that side.
23
+ * @typedef {{ hasPath: (path: string) => boolean, hasLine: (path: string, side: 'new' | 'old', line: number) => boolean }} CommentTargets
24
+ */
25
+
26
+ /**
27
+ * Reads one block. Returns the comment, or the reason the block is not one.
28
+ * @param {string} source the text inside the fence
29
+ * @param {CommentTargets} [targets]
30
+ * @returns {{ comment: ProposedComment } | { reason: string }}
31
+ */
32
+ export function parseProposedComment(source, targets) {
33
+ /** @type {unknown} */
34
+ let raw
35
+ try {
36
+ raw = JSON.parse(source)
37
+ } catch {
38
+ return { reason: 'the block is not JSON' }
39
+ }
40
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
41
+ return { reason: 'the block is not a JSON object' }
42
+ }
43
+ const record = /** @type {Record<string, unknown>} */ (raw)
44
+ const field = /** @param {string} key */ key => record[key]
45
+ const path = field('path')
46
+ const line = field('line')
47
+ const body = field('body')
48
+ const sideRaw = field('side')
49
+ const startRaw = field('startLine')
50
+ if (typeof path !== 'string' || path === '') {
51
+ return { reason: 'the block has no path' }
52
+ }
53
+ if (typeof line !== 'number' || !Number.isInteger(line) || line <= 0) {
54
+ return { reason: 'the block has no line number' }
55
+ }
56
+ if (typeof body !== 'string' || body.trim() === '') {
57
+ return { reason: 'the block has no body' }
58
+ }
59
+ if (body.length > PROPOSED_BODY_MAX) {
60
+ return { reason: 'the body is too long to post' }
61
+ }
62
+ if (sideRaw !== undefined && sideRaw !== 'new' && sideRaw !== 'old') {
63
+ return { reason: 'side must be "new" or "old"' }
64
+ }
65
+ const side = sideRaw === 'old' ? 'old' : 'new'
66
+ if (startRaw !== undefined && (typeof startRaw !== 'number' || !Number.isInteger(startRaw) || startRaw <= 0)) {
67
+ return { reason: 'startLine must be a line number' }
68
+ }
69
+ const startLine = typeof startRaw === 'number' ? startRaw : undefined
70
+ if (startLine !== undefined && startLine > line) {
71
+ return { reason: 'startLine comes after line' }
72
+ }
73
+ if (targets !== undefined) {
74
+ if (!targets.hasPath(path)) {
75
+ return { reason: `${path} is not a file of this pull request` }
76
+ }
77
+ // Both ends of a range have to be on screen, or the comment lands where nobody looked.
78
+ for (const at of startLine === undefined ? [line] : [startLine, line]) {
79
+ if (!targets.hasLine(path, side, at)) {
80
+ return { reason: `${path}:${at} is not a line the diff shows` }
81
+ }
82
+ }
83
+ }
84
+ /** @type {ProposedComment} */
85
+ const comment = { path, line, side, body }
86
+ if (startLine !== undefined) {
87
+ comment.startLine = startLine
88
+ }
89
+ return { comment }
90
+ }
91
+
92
+ /**
93
+ * One assistant turn split into what to render: prose, comment cards, and blocks that claimed to
94
+ * be comments but are not.
95
+ * @param {string} markdown
96
+ * @param {CommentTargets} [targets]
97
+ * @returns {ChatSegment[]}
98
+ */
99
+ export function splitChatAnswer(markdown, targets) {
100
+ return splitFences(markdown, 'comment').map(segment => {
101
+ if (segment.type === 'markdown') {
102
+ return /** @type {ChatSegment} */ ({ type: 'markdown', text: segment.text })
103
+ }
104
+ const parsed = parseProposedComment(segment.text, targets)
105
+ return 'comment' in parsed
106
+ ? /** @type {ChatSegment} */ ({ type: 'comment', comment: parsed.comment })
107
+ : /** @type {ChatSegment} */ ({ type: 'invalid', text: segment.text, reason: parsed.reason })
108
+ })
109
+ }
110
+
111
+ /**
112
+ * The lines a diff shows, as the card's check needs them: every hunk's line numbers per side.
113
+ * @param {ReadonlyArray<import('./contract-types.js').FileEntry>} files
114
+ * @returns {CommentTargets}
115
+ */
116
+ export function targetsFromFiles(files) {
117
+ const byPath = new Map(files.map(f => [f.path, f]))
118
+ return {
119
+ hasPath: path => byPath.has(path),
120
+ hasLine: (path, side, line) => {
121
+ const entry = byPath.get(path)
122
+ if (entry === undefined) {
123
+ return false
124
+ }
125
+ return entry.hunks.some(h => {
126
+ const start = side === 'new' ? h.newStart : h.oldStart
127
+ // A hunk that adds lines shows none on the old side, and the other way round.
128
+ const count = side === 'new' ? h.newLines : h.oldLines
129
+ return count > 0 && line >= start && line < start + count
130
+ })
131
+ },
132
+ }
133
+ }
@@ -0,0 +1,216 @@
1
+ // @ts-check
2
+ // The small menu that opens on an `[ ask ]` command: four questions that are worth asking about
3
+ // almost anything, plus a way to write your own. Picking one sets the context to that target and
4
+ // sends, so the common questions cost one click.
5
+
6
+ import { QUICK_QUESTIONS } from './chat.js'
7
+ import { chatContextFromElement } from './chat-context.js'
8
+ import { esc, qs } from './dom.js'
9
+
10
+ /** A menu that opened on the way to a click would be in the way, so it waits. */
11
+ export const HOVER_DELAY_MS = 150
12
+ /** The pointer crosses a gap between the command and the menu, so closing waits out the crossing. */
13
+ export const CLOSE_DELAY_MS = 200
14
+ export const QQ_MENU_ID = 'qq-menu'
15
+ export const ASK_SOMETHING_ELSE = 'ask something else…'
16
+
17
+ /** @returns {string} */
18
+ export function quickMenuHtml() {
19
+ const items = [...QUICK_QUESTIONS, ASK_SOMETHING_ELSE]
20
+ .map(
21
+ (q, i) => `<button class="qq-item" type="button" role="menuitem" tabindex="-1" data-qq="${i}">${esc(q)}</button>`
22
+ )
23
+ .join('')
24
+ return `<div class="qq-menu" id="${QQ_MENU_ID}" role="menu" aria-label="Quick questions" hidden>${items}</div>`
25
+ }
26
+
27
+ /**
28
+ * @typedef {{
29
+ * onPick: (context: import('./chat-context.js').ChatContext, question: string | null) => void,
30
+ * delayMs?: number,
31
+ * doc?: Document,
32
+ * }} QuickQuestionOptions
33
+ */
34
+
35
+ /**
36
+ * Wires one menu for every `[ ask ]` command under `root` and for the context chip. The menu is
37
+ * a single element that moves to whichever trigger is in play.
38
+ * @param {HTMLElement} root
39
+ * @param {QuickQuestionOptions} options
40
+ */
41
+ export function wireQuickQuestions(root, options) {
42
+ const doc = options.doc ?? document
43
+ const delayMs = options.delayMs ?? HOVER_DELAY_MS
44
+ if (qs(`#${QQ_MENU_ID}`, root) === null) {
45
+ root.insertAdjacentHTML('beforeend', quickMenuHtml())
46
+ }
47
+ const menu = qs(`#${QQ_MENU_ID}`, root)
48
+ if (!(menu instanceof HTMLElement)) {
49
+ return { stop: () => undefined }
50
+ }
51
+ /** @type {HTMLElement | null} */
52
+ let trigger = null
53
+ /** @type {ReturnType<typeof setTimeout> | null} */
54
+ let timer = null
55
+ /** @type {ReturnType<typeof setTimeout> | null} */
56
+ let closeTimer = null
57
+ /**
58
+ * The command Escape closed the menu on. Focus goes back to it, and that focus must not open
59
+ * the menu again.
60
+ */
61
+ /** @type {HTMLElement | null} */
62
+ let dismissed = null
63
+
64
+ const items = () => Array.from(menu.querySelectorAll('.qq-item')).filter(el => el instanceof HTMLElement)
65
+
66
+ const close = () => {
67
+ menu.hidden = true
68
+ trigger?.setAttribute('aria-expanded', 'false')
69
+ trigger = null
70
+ }
71
+
72
+ /** @param {HTMLElement} el */
73
+ const open = el => {
74
+ dismissed = null
75
+ trigger = el
76
+ el.setAttribute('aria-expanded', 'true')
77
+ menu.hidden = false
78
+ const rect = el.getBoundingClientRect()
79
+ menu.style.left = `${Math.round(rect.left)}px`
80
+ menu.style.top = `${Math.round(rect.bottom + 4)}px`
81
+ }
82
+
83
+ const clearTimer = () => {
84
+ if (timer !== null) {
85
+ clearTimeout(timer)
86
+ timer = null
87
+ }
88
+ if (closeTimer !== null) {
89
+ clearTimeout(closeTimer)
90
+ closeTimer = null
91
+ }
92
+ }
93
+
94
+ /** @param {EventTarget | null} target */
95
+ const triggerOf = target => {
96
+ const el = target instanceof Element ? target.closest('[data-ask], [data-ask-menu]') : null
97
+ return el instanceof HTMLElement ? el : null
98
+ }
99
+
100
+ /** @param {Event} event */
101
+ const onTriggerOver = event => {
102
+ const el = triggerOf(event.target)
103
+ if (el === null) {
104
+ return
105
+ }
106
+ // Being back on a command calls off the countdown its own pointer-out started.
107
+ cancelClose()
108
+ if (el === trigger) {
109
+ return
110
+ }
111
+ clearTimer()
112
+ timer = setTimeout(() => open(el), delayMs)
113
+ }
114
+
115
+ /**
116
+ * Leaving starts a short countdown rather than closing at once: the pointer has to cross the
117
+ * few pixels between the command and the menu to reach it.
118
+ * @param {Event} event
119
+ */
120
+ const onOut = event => {
121
+ const to = /** @type {MouseEvent} */ (event).relatedTarget
122
+ if (to instanceof Node && (menu.contains(to) || trigger?.contains(to) === true)) {
123
+ return
124
+ }
125
+ clearTimer()
126
+ if (trigger !== null) {
127
+ closeTimer = setTimeout(close, CLOSE_DELAY_MS)
128
+ }
129
+ }
130
+
131
+ const cancelClose = () => {
132
+ if (closeTimer !== null) {
133
+ clearTimeout(closeTimer)
134
+ closeTimer = null
135
+ }
136
+ }
137
+
138
+ /** Reaching the menu calls the countdown off. */
139
+ const onOver = cancelClose
140
+
141
+ /** @param {Event} event */
142
+ const onFocusIn = event => {
143
+ const el = triggerOf(event.target)
144
+ if (el !== null && el === dismissed) {
145
+ dismissed = null
146
+ return
147
+ }
148
+ if (el !== null && el !== trigger) {
149
+ clearTimer()
150
+ open(el)
151
+ }
152
+ }
153
+
154
+ /** @param {Event} event */
155
+ const onClick = event => {
156
+ const item = event.target instanceof Element ? event.target.closest('.qq-item') : null
157
+ if (!(item instanceof HTMLElement) || trigger === null) {
158
+ return
159
+ }
160
+ event.preventDefault()
161
+ const index = Number(item.getAttribute('data-qq'))
162
+ const question = QUICK_QUESTIONS[index] ?? null
163
+ const context = chatContextFromElement(trigger)
164
+ close()
165
+ options.onPick(context, question)
166
+ }
167
+
168
+ /** @param {KeyboardEvent} event */
169
+ const onKeyDown = event => {
170
+ if (menu.hidden) {
171
+ return
172
+ }
173
+ const list = items()
174
+ const at = list.indexOf(/** @type {HTMLElement} */ (doc.activeElement))
175
+ if (event.key === 'Escape') {
176
+ event.preventDefault()
177
+ const back = trigger
178
+ close()
179
+ dismissed = back
180
+ back?.focus()
181
+ return
182
+ }
183
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
184
+ event.preventDefault()
185
+ const step = event.key === 'ArrowDown' ? 1 : -1
186
+ const next = list[(at + step + list.length) % list.length] ?? list[0]
187
+ next?.focus()
188
+ }
189
+ }
190
+
191
+ root.addEventListener('pointerover', onTriggerOver)
192
+ root.addEventListener('pointerout', onOut)
193
+ menu.addEventListener('pointerover', onOver)
194
+ root.addEventListener('focusin', onFocusIn)
195
+ menu.addEventListener('click', onClick)
196
+ doc.addEventListener('keydown', /** @type {EventListener} */ (onKeyDown))
197
+
198
+ return {
199
+ /** Opens the menu on a target without waiting, which is what a tap and the `a` key do. */
200
+ openFor(/** @type {HTMLElement} */ el) {
201
+ clearTimer()
202
+ open(el)
203
+ items()[0]?.focus()
204
+ },
205
+ isOpen: () => !menu.hidden,
206
+ stop() {
207
+ clearTimer()
208
+ root.removeEventListener('pointerover', onTriggerOver)
209
+ root.removeEventListener('pointerout', onOut)
210
+ menu.removeEventListener('pointerover', onOver)
211
+ root.removeEventListener('focusin', onFocusIn)
212
+ menu.removeEventListener('click', onClick)
213
+ doc.removeEventListener('keydown', /** @type {EventListener} */ (onKeyDown))
214
+ },
215
+ }
216
+ }