@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,843 @@
1
+ // @ts-check
2
+ // The AI Chat pane: threads, the one context a message is about, the streamed answer, and the
3
+ // comment cards an answer can propose. The scrolling rules live in chat-scroll.js and the block
4
+ // parsing in proposed-comment.js; this file is the wiring between them and the DOM.
5
+ /** @typedef {import('./chat-context.js').ChatContext} ChatContext */
6
+ /** @typedef {import('./contract-types.js').ChatTurn} ChatTurn */
7
+ /** @typedef {import('./proposed-comment.js').ProposedComment} ProposedComment */
8
+ /** @typedef {import('./review-session.js').ReviewSession} ReviewSession */
9
+ import { cancelChat, createThread, fetchThreadHistory, fetchThreads, streamChat } from './api.js'
10
+ import { chatContextAttrs, chatContextLabel, sameChatContext, WHOLE_PR } from './chat-context.js'
11
+ import {
12
+ canScrollBy,
13
+ chatScrollReducer,
14
+ INITIAL_SCROLL_STATE,
15
+ isAtBottom,
16
+ redirectsWheelToTranscript,
17
+ scrollBehavior,
18
+ unseenLabel,
19
+ } from './chat-scroll.js'
20
+ import { runCommand, runControl, showCommandError } from './commands.js'
21
+ import { postedCommentUrl, viewCommentHtml } from './comment-link.js'
22
+ import { esc, qs } from './dom.js'
23
+ import { getRenderContext } from './layers.js'
24
+ import { renderMarkdown } from './markdown.js'
25
+ import { splitChatAnswer, targetsFromFiles } from './proposed-comment.js'
26
+
27
+ export const CHAT_WIDTH_KEY = 'pr-review.chat-width'
28
+ export const CHAT_WIDTH_MIN = 280
29
+ export const CHAT_WIDTH_MAX = 560
30
+ export const CHAT_WIDTH_DEFAULT = 340
31
+
32
+ /**
33
+ * @param {{ enabled: boolean, width?: number }} opts
34
+ * @returns {string} '' when AI Chat is disabled
35
+ */
36
+ export function renderChatShell(opts) {
37
+ if (!opts.enabled) {
38
+ return ''
39
+ }
40
+ const width = clampWidth(opts.width ?? CHAT_WIDTH_DEFAULT)
41
+ return (
42
+ '<aside class="chat" aria-labelledby="chat-h">' +
43
+ `<button class="handle" type="button" id="chat-handle" role="separator" aria-orientation="vertical" aria-label="Resize AI Chat" aria-valuenow="${width}" aria-valuemin="${CHAT_WIDTH_MIN}" aria-valuemax="${CHAT_WIDTH_MAX}"></button>` +
44
+ '<div class="chat-h"><h2 id="chat-h">AI Chat</h2><label class="sr" for="thread">Thread</label>' +
45
+ '<select id="thread"></select>' +
46
+ '<button class="cmd" type="button" id="new-thread">new thread</button></div>' +
47
+ '<p class="ctx-line" id="chat-ctx-line">Context: <span class="ctx-chip" id="chat-ctx">whole PR</span>' +
48
+ ' <button class="cmd" type="button" id="chat-clear" hidden>clear</button></p>' +
49
+ '<div class="transcript" id="chat-log" role="log" aria-label="Transcript" aria-live="polite" tabindex="0">' +
50
+ '<p class="empty">Ask AI Chat about a layer, a file, or a selection. Answers come with a verdict first, then evidence.</p>' +
51
+ '</div>' +
52
+ '<button class="cmd unseen" type="button" id="chat-unseen" hidden aria-live="polite"></button>' +
53
+ '<form class="chat-composer" id="chat-form"><label class="sr" for="msg">Message</label>' +
54
+ '<textarea id="msg" rows="3" placeholder="Ask AI Chat about this PR…"></textarea>' +
55
+ '<div class="tbtns"><button class="cmd fill" type="submit" id="chat-send">send</button>' +
56
+ '<button class="cmd" type="button" id="chat-stop" hidden>stop</button>' +
57
+ '<span class="muted small">enter to send</span></div></form>' +
58
+ '</aside>'
59
+ )
60
+ }
61
+
62
+ /**
63
+ * The attribute writer escapes five characters; this reads them back for `setAttribute`.
64
+ * @param {string} value
65
+ */
66
+ function unescapeAttr(value) {
67
+ return value
68
+ .replace(/&quot;/g, '"')
69
+ .replace(/&#39;/g, "'")
70
+ .replace(/&lt;/g, '<')
71
+ .replace(/&gt;/g, '>')
72
+ .replace(/&amp;/g, '&')
73
+ }
74
+
75
+ /** @param {number} width */
76
+ export function clampWidth(width) {
77
+ if (!Number.isFinite(width)) {
78
+ return CHAT_WIDTH_DEFAULT
79
+ }
80
+ return Math.min(CHAT_WIDTH_MAX, Math.max(CHAT_WIDTH_MIN, Math.round(width)))
81
+ }
82
+
83
+ /**
84
+ * @param {Storage | null} storage
85
+ * @returns {number}
86
+ */
87
+ export function readChatWidth(storage) {
88
+ const raw = storage === null ? null : storage.getItem(CHAT_WIDTH_KEY)
89
+ return clampWidth(raw === null ? CHAT_WIDTH_DEFAULT : Number(raw))
90
+ }
91
+
92
+ /**
93
+ * @param {Storage | null} storage
94
+ * @param {number} width
95
+ */
96
+ export function writeChatWidth(storage, width) {
97
+ storage?.setItem(CHAT_WIDTH_KEY, String(clampWidth(width)))
98
+ }
99
+
100
+ /** The four questions the quick menu offers, and the one that just focuses the box. */
101
+ export const QUICK_QUESTIONS = [
102
+ 'Is this covered by tests?',
103
+ 'What could break?',
104
+ 'Why this way, and what were the alternatives?',
105
+ 'Does this follow how the codebase already does it?',
106
+ ]
107
+
108
+ /**
109
+ * @typedef {{
110
+ * root: HTMLElement,
111
+ * prNumber: number,
112
+ * session: ReviewSession,
113
+ * storage?: Storage | null,
114
+ * api?: Partial<ChatApi>,
115
+ * reducedMotion?: boolean,
116
+ * onProposed?: (what: 'post' | 'edit', comment: ProposedComment, el: HTMLElement) => void,
117
+ * }} ChatOptions
118
+ */
119
+
120
+ /**
121
+ * @typedef {{
122
+ * fetchThreads: typeof fetchThreads,
123
+ * createThread: typeof createThread,
124
+ * fetchThreadHistory: typeof fetchThreadHistory,
125
+ * cancelChat: typeof cancelChat,
126
+ * streamChat: typeof streamChat,
127
+ * }} ChatApi
128
+ */
129
+
130
+ /** @param {Partial<ChatApi> | undefined} overrides */
131
+ function withDefaults(overrides) {
132
+ return { fetchThreads, createThread, fetchThreadHistory, cancelChat, streamChat, ...overrides }
133
+ }
134
+
135
+ /**
136
+ * @param {ChatTurn['role']} role
137
+ * @param {string} bodyHtml
138
+ * @param {{ incomplete?: string }} [opts]
139
+ */
140
+ function turnHtml(role, bodyHtml, opts = {}) {
141
+ return `<div class="turn ${role === 'assistant' ? 'a' : 'u'}">${turnInnerHtml(role, bodyHtml, opts)}</div>`
142
+ }
143
+
144
+ /**
145
+ * @param {ChatTurn['role']} role
146
+ * @param {string} bodyHtml
147
+ * @param {{ incomplete?: string }} [opts]
148
+ */
149
+ function turnInnerHtml(role, bodyHtml, opts = {}) {
150
+ const note =
151
+ opts.incomplete === undefined ? '' : `<p class="muted small">the answer stopped early (${esc(opts.incomplete)})</p>`
152
+ return (
153
+ `<span class="role">${role === 'assistant' ? 'AI Chat' : 'You'}</span>` +
154
+ `<div class="prose">${bodyHtml}</div>${note}`
155
+ )
156
+ }
157
+
158
+ /**
159
+ * The card a proposed comment renders as. `post to github` goes through the same path as every
160
+ * other post, so capability gating and the pending state apply here too.
161
+ * @param {ProposedComment} comment
162
+ * @param {string} id the key the pane stores this card's comment under
163
+ * @param {string} [postedUrl]
164
+ */
165
+ export function proposedCommentHtml(comment, id, postedUrl) {
166
+ const range = comment.startLine === undefined ? `${comment.line}` : `${comment.startLine}–${comment.line}`
167
+ const side = comment.side === 'old' ? ' (old side)' : ''
168
+ return (
169
+ `<div class="proposed" data-proposed="${esc(id)}">` +
170
+ `<div class="proposed-h"><span class="lbl">proposed comment</span>` +
171
+ `<span class="mono">${esc(comment.path)}:${esc(range)}${side}</span></div>` +
172
+ `<div class="prose">${renderMarkdown(comment.body)}</div>` +
173
+ '<span class="tbtns">' +
174
+ (postedUrl === undefined
175
+ ? `<button class="cmd fill" type="button" data-act="proposed-post" data-proposed="${esc(id)}" data-needs-post>post to github</button>`
176
+ : viewCommentHtml(postedUrl, true)) +
177
+ `<button class="cmd" type="button" data-act="proposed-edit" data-proposed="${esc(id)}" data-needs-post>edit</button>` +
178
+ `<button class="cmd" type="button" data-copy="${esc(comment.body)}">copy</button>` +
179
+ '</span></div>'
180
+ )
181
+ }
182
+
183
+ /**
184
+ * One assistant answer as HTML: prose, comment cards, and a code block for a block that claimed
185
+ * to be a comment but does not name a line of this diff.
186
+ * Card keys carry the turn they belong to, so an older answer's card still posts its own comment
187
+ * after a newer answer has drawn cards of its own.
188
+ * @param {string} text
189
+ * @param {import('./proposed-comment.js').CommentTargets} targets
190
+ * @param {Map<string, ProposedComment>} sink cards found, by key
191
+ * @param {ReadonlySet<string>} paths
192
+ * @param {string} [turnKey] the prefix of this turn's card keys
193
+ * @param {ReadonlyArray<import('./contract-types.js').ReviewComment>} [posted]
194
+ */
195
+ export function answerHtml(text, targets, sink, paths, turnKey = 'turn', posted = []) {
196
+ let index = 0
197
+ return splitChatAnswer(text, targets)
198
+ .map(segment => {
199
+ if (segment.type === 'markdown') {
200
+ return renderMarkdown(segment.text, { paths })
201
+ }
202
+ if (segment.type === 'comment') {
203
+ const id = `${turnKey}-${index}`
204
+ index += 1
205
+ sink.set(id, segment.comment)
206
+ return proposedCommentHtml(segment.comment, id, postedCommentUrl(segment.comment, posted))
207
+ }
208
+ return `<pre class="proposed-invalid"><code>${esc(segment.text)}</code></pre><p class="muted small">${esc(segment.reason)}</p>`
209
+ })
210
+ .join('')
211
+ }
212
+
213
+ /**
214
+ * One part of the pane, by selector and by the kind of element it has to be.
215
+ * @template {HTMLElement} T
216
+ * @param {ParentNode} root
217
+ * @param {string} selector
218
+ * @param {new () => T} kind
219
+ * @returns {T}
220
+ */
221
+ function mustFind(root, selector, kind) {
222
+ const el = root.querySelector(selector)
223
+ if (el instanceof kind) {
224
+ return el
225
+ }
226
+ throw new Error(`the AI Chat pane has no ${selector}`)
227
+ }
228
+
229
+ /**
230
+ * Wires the pane. The returned handle is what the page uses to set the context from an `[ ask ]`
231
+ * command and to take the wiring down before a re-render.
232
+ * @param {ChatOptions} options
233
+ */
234
+ export function wireChat(options) {
235
+ const { root, prNumber, session } = options
236
+ const api = withDefaults(options.api)
237
+ const storage =
238
+ options.storage === undefined ? (typeof localStorage === 'undefined' ? null : localStorage) : options.storage
239
+ const paths = new Set(session.artifact.files.map(f => f.path))
240
+ const targets = targetsFromFiles(session.artifact.files)
241
+ const reducedMotion =
242
+ options.reducedMotion ??
243
+ (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches)
244
+
245
+ const pane = root.querySelector('.chat')
246
+ if (!(pane instanceof HTMLElement)) {
247
+ return null
248
+ }
249
+ // The pane is here, so every part of it is: `renderChatShell` writes them together. A missing
250
+ // one is a mistake in this file, which `mustFind` reports instead of half-wiring the pane.
251
+ const log = mustFind(root, '#chat-log', HTMLElement)
252
+ const box = mustFind(root, '#msg', HTMLTextAreaElement)
253
+ const form = mustFind(root, '#chat-form', HTMLFormElement)
254
+ const select = mustFind(root, '#thread', HTMLSelectElement)
255
+ const chip = mustFind(root, '#chat-ctx', HTMLElement)
256
+ const clearButton = mustFind(root, '#chat-clear', HTMLElement)
257
+ const bubble = mustFind(root, '#chat-unseen', HTMLElement)
258
+ const sendButton = mustFind(root, '#chat-send', HTMLElement)
259
+ const stopButton = mustFind(root, '#chat-stop', HTMLElement)
260
+
261
+ /** @type {ChatContext} */
262
+ let context = WHOLE_PR
263
+ /** @type {import('./chat-scroll.js').ChatScrollState} */
264
+ let scroll = INITIAL_SCROLL_STATE
265
+ /** Where each thread was left, so switching back does not jump the reader to the bottom. */
266
+ const scrollTops = new Map()
267
+ /** Every comment card on screen, by the key its buttons carry. */
268
+ /** @type {Map<string, ProposedComment>} */
269
+ const proposed = new Map()
270
+ const postedComments = () => {
271
+ const ids = new Set(session.state.posted.map(p => p.commentId))
272
+ return getRenderContext()?.comments.filter(c => ids.has(c.id)) ?? []
273
+ }
274
+ /** @type {string | null} */
275
+ let activeThread = null
276
+ let streaming = false
277
+ let stopActivity = () => {}
278
+ /** The turn in flight, so taking the pane down stops its request too. */
279
+ /** @type {AbortController | null} */
280
+ let inFlight = null
281
+ /** @type {number | ReturnType<typeof setTimeout>} */
282
+ let frame = 0
283
+ let turnSeq = 0
284
+ /**
285
+ * Counts the things that replace the whole transcript. A history load that comes back after
286
+ * one of them reads a number that has moved on, and leaves the log to whoever owns it now.
287
+ */
288
+ let logSeq = 0
289
+ /** True while a thread is being drawn: the scrolling that comes with a redraw is not the reader's. */
290
+ let restoring = false
291
+
292
+ /** Takes the log over and returns the number that says so. */
293
+ const claimLog = () => {
294
+ logSeq += 1
295
+ restoring = false
296
+ return logSeq
297
+ }
298
+
299
+ const applyWidth = /** @param {number} width */ width => {
300
+ root.style.setProperty('--chat-w', `${clampWidth(width)}px`)
301
+ const handle = qs('#chat-handle', root)
302
+ handle?.setAttribute('aria-valuenow', String(clampWidth(width)))
303
+ }
304
+ applyWidth(readChatWidth(storage))
305
+
306
+ const layerTitle = /** @param {string} id */ id => session.artifact.layers.find(l => l.id === id)?.title
307
+ const pointTitle = /** @param {string} fp */ fp => session.artifact.points.find(p => p.fingerprint === fp)?.title
308
+
309
+ const drawContext = () => {
310
+ chip.textContent = chatContextLabel(context, layerTitle, pointTitle)
311
+ clearButton.hidden = context.kind === 'pr'
312
+ chip.toggleAttribute('data-ask-menu', context.kind !== 'pr')
313
+ if (context.kind === 'pr') {
314
+ chip.removeAttribute('tabindex')
315
+ chip.removeAttribute('aria-expanded')
316
+ } else {
317
+ chip.tabIndex = 0
318
+ }
319
+ // The quick-question menu reads the target off the chip, so the chip carries it too.
320
+ for (const name of [
321
+ 'data-ask-layer',
322
+ 'data-ask-path',
323
+ 'data-ask-side',
324
+ 'data-ask-start',
325
+ 'data-ask-end',
326
+ 'data-ask-point',
327
+ ]) {
328
+ chip.removeAttribute(name)
329
+ }
330
+ for (const pair of chatContextAttrs(context).matchAll(/([a-z-]+)="([^"]*)"/g)) {
331
+ chip.setAttribute(pair[1] ?? '', unescapeAttr(pair[2] ?? ''))
332
+ }
333
+ }
334
+
335
+ const drawBubble = () => {
336
+ const label = unseenLabel(scroll)
337
+ bubble.hidden = label === null
338
+ bubble.textContent = label ?? ''
339
+ }
340
+
341
+ /** @param {'follow' | 'jump'} reason */
342
+ const scrollToBottom = reason => {
343
+ log.scrollTo?.({ top: log.scrollHeight, behavior: scrollBehavior(reason, reducedMotion) })
344
+ log.scrollTop = log.scrollHeight
345
+ }
346
+
347
+ /** @param {import('./chat-scroll.js').ChatScrollAction} action */
348
+ const dispatch = action => {
349
+ scroll = chatScrollReducer(scroll, action)
350
+ drawBubble()
351
+ }
352
+
353
+ const emptyNote = () => {
354
+ const note = log.querySelector('.empty')
355
+ note?.remove()
356
+ }
357
+
358
+ /**
359
+ * Adds one turn and returns the element its body is drawn into, so a streamed answer can be
360
+ * redrawn without looking it up again.
361
+ * @param {ChatTurn['role']} role
362
+ * @param {string} html
363
+ * @param {{ incomplete?: string }} [opts]
364
+ * @returns {{ turn: HTMLElement, body: HTMLElement }}
365
+ */
366
+ const appendTurn = (role, html, opts = {}) => {
367
+ emptyNote()
368
+ turnSeq += 1
369
+ const id = `chat-turn-${turnSeq}`
370
+ const wasPinned = scroll.pinned
371
+ const turn = document.createElement('div')
372
+ turn.className = `turn ${role === 'assistant' ? 'a' : 'u'}`
373
+ turn.id = id
374
+ turn.innerHTML = turnInnerHtml(role, html, opts)
375
+ log.appendChild(turn)
376
+ dispatch({ type: 'append', id, belowFold: !wasPinned })
377
+ if (scroll.pinned) {
378
+ scrollToBottom('follow')
379
+ }
380
+ const body = turn.querySelector('.prose')
381
+ return { turn, body: body instanceof HTMLElement ? body : turn }
382
+ }
383
+
384
+ /**
385
+ * Redraws a streaming answer at most once per frame: a chunk every few milliseconds must not
386
+ * mean a markdown parse every few milliseconds.
387
+ * @param {HTMLElement} body the turn's `.prose` element
388
+ * @param {() => string} html
389
+ */
390
+ const scheduleRender = (body, html) => {
391
+ if (frame !== 0) {
392
+ return
393
+ }
394
+ // requestAnimationFrame is what batches the redraws; setTimeout stands in for it in a test
395
+ // environment that has none.
396
+ const run = () => {
397
+ frame = 0
398
+ body.innerHTML = html()
399
+ if (scroll.pinned) {
400
+ scrollToBottom('follow')
401
+ }
402
+ }
403
+ frame = typeof requestAnimationFrame === 'function' ? requestAnimationFrame(run) : setTimeout(run, 16)
404
+ }
405
+
406
+ /** @param {number | ReturnType<typeof setTimeout>} handle */
407
+ const cancelFrame = handle => {
408
+ if (typeof handle === 'number' && typeof cancelAnimationFrame === 'function') {
409
+ cancelAnimationFrame(handle)
410
+ return
411
+ }
412
+ clearTimeout(handle)
413
+ }
414
+
415
+ const drawThreads = /** @param {import('./contract-types.js').ChatThreadsResponse} data */ data => {
416
+ activeThread = data.activeThread
417
+ select.innerHTML = data.threads
418
+ .map(
419
+ t => `<option value="${esc(t.name)}"${t.name === data.activeThread ? ' selected' : ''}>${esc(t.title)}</option>`
420
+ )
421
+ .join('')
422
+ if (data.threads.length === 0) {
423
+ select.innerHTML = '<option value="">Thread 1</option>'
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Draws a thread's saved turns. The log is emptied first, so a question asked while the turns
429
+ * are still on their way is asked into the thread it belongs to and not under another one's.
430
+ * @param {string} name
431
+ */
432
+ const loadHistory = async name => {
433
+ const seq = claimLog()
434
+ restoring = true
435
+ proposed.clear()
436
+ log.innerHTML = '<p class="empty">Opening this thread…</p>'
437
+ const { turns } = await api.fetchThreadHistory(prNumber, name)
438
+ if (seq !== logSeq) {
439
+ return
440
+ }
441
+ proposed.clear()
442
+ log.innerHTML =
443
+ turns.length === 0
444
+ ? '<p class="empty">Ask AI Chat about a layer, a file, or a selection. Answers come with a verdict first, then evidence.</p>'
445
+ : turns
446
+ .map((turn, i) =>
447
+ turnHtml(
448
+ turn.role,
449
+ turn.role === 'assistant'
450
+ ? answerHtml(turn.text, targets, proposed, paths, `history-${i}`, postedComments())
451
+ : renderMarkdown(turn.text, { paths }),
452
+ turn.incomplete === undefined ? {} : { incomplete: turn.incomplete }
453
+ )
454
+ )
455
+ .join('')
456
+ const saved = scrollTops.get(name)
457
+ dispatch({ type: 'thread-switch', pinned: saved === undefined })
458
+ if (saved === undefined) {
459
+ scrollToBottom('follow')
460
+ } else {
461
+ log.scrollTop = saved
462
+ }
463
+ restoring = false
464
+ }
465
+
466
+ /** While a turn runs, the thread controls are locked: its callbacks draw into this transcript. */
467
+ /** @param {boolean} on */
468
+ const setStreaming = on => {
469
+ streaming = on
470
+ sendButton.hidden = on
471
+ stopButton.hidden = !on
472
+ box.disabled = on
473
+ select.disabled = on
474
+ const newThread = qs('#new-thread', root)
475
+ if (newThread instanceof HTMLButtonElement) {
476
+ newThread.disabled = on
477
+ }
478
+ }
479
+
480
+ /**
481
+ * The answer as HTML. A redraw replaces this turn's cards and leaves every other turn's alone.
482
+ * @param {string} text
483
+ * @param {string} turnKey
484
+ */
485
+ const renderAnswer = (text, turnKey) => {
486
+ for (const key of [...proposed.keys()]) {
487
+ if (key.startsWith(`${turnKey}-`)) {
488
+ proposed.delete(key)
489
+ }
490
+ }
491
+ return answerHtml(text, targets, proposed, paths, turnKey, postedComments())
492
+ }
493
+
494
+ const send = async () => {
495
+ const message = box.value.trim()
496
+ if (message === '' || streaming) {
497
+ return
498
+ }
499
+ box.value = ''
500
+ // The turn owns the log from here on, so a history load still on its way lands nowhere.
501
+ claimLog()
502
+ appendTurn('user', renderMarkdown(message, { paths }))
503
+ dispatch({ type: 'send' })
504
+ scrollToBottom('follow')
505
+ const answer = appendTurn('assistant', '')
506
+ const activity = document.createElement('p')
507
+ activity.className = 'muted small chat-activity'
508
+ activity.setAttribute('aria-live', 'off')
509
+ answer.turn.append(activity)
510
+ const started = Date.now()
511
+ const updateActivity = () => {
512
+ const elapsed = Date.now() - started
513
+ activity.textContent = `Preparing answer${'.'.repeat(Math.floor(elapsed / 400) % 3 + 1)} · ${Math.floor(elapsed / 1000)}s`
514
+ }
515
+ updateActivity()
516
+ const timer = setInterval(updateActivity, 400)
517
+ stopActivity = () => clearInterval(timer)
518
+ const toolDetails = document.createElement('details')
519
+ toolDetails.className = 'chat-tool-calls muted small'
520
+ toolDetails.hidden = true
521
+ answer.turn.append(toolDetails)
522
+ const tools = new Map()
523
+ const turnKey = answer.turn.id
524
+ setStreaming(true)
525
+ let text = ''
526
+ inFlight = new AbortController()
527
+ try {
528
+ await api.streamChat(
529
+ prNumber,
530
+ { message, context, ...(activeThread === null ? {} : { thread: activeThread }) },
531
+ {
532
+ signal: inFlight.signal,
533
+ onEvent: event => {
534
+ const data = /** @type {Record<string, unknown>} */ (event.data ?? {})
535
+ const field = /** @param {string} key */ key => data[key]
536
+ const thread = field('thread')
537
+ if (event.event === 'turn' && typeof thread === 'string') {
538
+ activeThread = thread
539
+ void refreshThreads()
540
+ return
541
+ }
542
+ if (event.event === 'tool') {
543
+ const id = String(field('id') ?? '')
544
+ const previous = tools.get(id)
545
+ const title = String(field('title') ?? 'tool')
546
+ tools.delete(id)
547
+ tools.set(id, { title: title === 'tool' && previous ? previous.title : title, status: String(field('status') ?? 'pending') })
548
+ toolDetails.hidden = false
549
+ const latest = tools.get(id)
550
+ toolDetails.innerHTML = `<summary>${esc(latest.title)} · ${esc(latest.status)} (${tools.size} tool calls)</summary><ul>${[...tools.values()].map(t => `<li>${esc(t.title)} · ${esc(t.status)}</li>`).join('')}</ul>`
551
+ return
552
+ }
553
+ const chunk = field('text')
554
+ if (event.event === 'chunk' && typeof chunk === 'string') {
555
+ text += chunk
556
+ // An answer growing out of sight is one thing to catch up on, however many chunks
557
+ // it arrives in; catching up clears the count, so drifting away again counts anew.
558
+ if (!scroll.pinned && scroll.unseen === 0) {
559
+ dispatch({ type: 'append', id: turnKey, belowFold: true })
560
+ }
561
+ scheduleRender(answer.body, () => renderAnswer(text, turnKey))
562
+ return
563
+ }
564
+ if (event.event === 'error') {
565
+ showCommandError(stopButton, String(field('message') ?? 'the agent failed'))
566
+ return
567
+ }
568
+ if (event.event === 'cancelled') {
569
+ answer.turn.insertAdjacentHTML('beforeend', '<p class="muted small">stopped</p>')
570
+ }
571
+ },
572
+ }
573
+ )
574
+ } finally {
575
+ inFlight = null
576
+ stopActivity()
577
+ toolDetails.open = false
578
+ const toolSummary = toolDetails.querySelector('summary')
579
+ if (toolSummary) toolSummary.textContent = `${tools.size} tool calls`
580
+ activity.textContent = `Elapsed: ${Math.floor((Date.now() - started) / 1000)}s`
581
+ if (frame !== 0) { cancelFrame(frame); frame = 0 }
582
+ setStreaming(false)
583
+ answer.body.innerHTML = text === '' ? '<span class="muted">no answer</span>' : renderAnswer(text, turnKey)
584
+ if (scroll.pinned) {
585
+ scrollToBottom('follow')
586
+ }
587
+ }
588
+ }
589
+
590
+ const refreshThreads = async () => {
591
+ drawThreads(await api.fetchThreads(prNumber))
592
+ }
593
+
594
+ /** On the first render, the thread that was open last time comes back with its transcript. */
595
+ const restoreThread = async () => {
596
+ const seq = claimLog()
597
+ const data = await api.fetchThreads(prNumber)
598
+ // A question sent while the thread list was on its way owns the pane now.
599
+ if (seq !== logSeq) {
600
+ return
601
+ }
602
+ drawThreads(data)
603
+ if (data.activeThread !== null) {
604
+ await loadHistory(data.activeThread)
605
+ }
606
+ }
607
+
608
+ /** @param {Event} event */
609
+ const onSubmit = event => {
610
+ event.preventDefault()
611
+ void runCommand(sendButton, send, { pendingLabel: 'sending…' })
612
+ }
613
+
614
+ /** @param {Event} event */
615
+ const onClick = event => {
616
+ const el = event.target instanceof Element ? event.target.closest('button') : null
617
+ if (!(el instanceof HTMLElement)) {
618
+ return
619
+ }
620
+ if (el.id === 'chat-clear') {
621
+ setContext(WHOLE_PR)
622
+ return
623
+ }
624
+ if (el.id === 'chat-stop') {
625
+ void runCommand(el, () => api.cancelChat(prNumber), { pendingLabel: 'stopping…' })
626
+ return
627
+ }
628
+ if (el.id === 'new-thread') {
629
+ void runCommand(
630
+ el,
631
+ async () => {
632
+ const seq = claimLog()
633
+ const data = await api.createThread(prNumber)
634
+ if (seq !== logSeq) {
635
+ return
636
+ }
637
+ drawThreads(data)
638
+ proposed.clear()
639
+ log.innerHTML = '<p class="empty">New thread. Ask about a layer, a file, or a selection.</p>'
640
+ dispatch({ type: 'thread-switch', pinned: true })
641
+ },
642
+ { pendingLabel: 'starting…' }
643
+ )
644
+ return
645
+ }
646
+ if (el.id === 'chat-unseen') {
647
+ const target = scroll.firstUnseenId === null ? null : log.querySelector(`#${scroll.firstUnseenId}`)
648
+ dispatch({ type: 'jump' })
649
+ if (target !== null && typeof target.scrollIntoView === 'function') {
650
+ target.scrollIntoView({ block: 'start', behavior: scrollBehavior('jump', reducedMotion) })
651
+ } else {
652
+ scrollToBottom('jump')
653
+ }
654
+ return
655
+ }
656
+ const act = el.getAttribute('data-act')
657
+ if (act === 'proposed-post' || act === 'proposed-edit') {
658
+ const comment = proposed.get(el.getAttribute('data-proposed') ?? '')
659
+ if (comment !== undefined) {
660
+ options.onProposed?.(act === 'proposed-post' ? 'post' : 'edit', comment, el)
661
+ }
662
+ }
663
+ }
664
+
665
+ const onScroll = () => {
666
+ // Emptying the log puts it back to the top; that is the redraw moving, not the reader.
667
+ if (restoring) {
668
+ return
669
+ }
670
+ if (activeThread !== null) {
671
+ scrollTops.set(activeThread, log.scrollTop)
672
+ }
673
+ dispatch({ type: 'scroll', atBottom: isAtBottom(log) })
674
+ }
675
+
676
+ /**
677
+ * A wheel turn anywhere over the pinned pane moves the transcript, so the page does not scroll
678
+ * out from under a reader who is reading the answer.
679
+ * @param {WheelEvent} event
680
+ */
681
+ const onPaneWheel = event => {
682
+ const target = event.target instanceof Element ? event.target : null
683
+ const insideOwnScroller =
684
+ target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement
685
+ ? canScrollBy(target, event.deltaY)
686
+ : false
687
+ const at = {
688
+ // The narrow layout drops the pin and the pane scrolls with the page like any other column.
689
+ pinned: getComputedStyle(pane).position === 'sticky',
690
+ insideTranscript: target?.closest('.transcript') !== null && target !== null,
691
+ zooming: event.ctrlKey,
692
+ insideOwnScroller,
693
+ }
694
+ if (!redirectsWheelToTranscript(at)) {
695
+ return
696
+ }
697
+ // Consumed whether or not the transcript can move, so the page stays put under the pointer.
698
+ event.preventDefault()
699
+ log.scrollTop += event.deltaY
700
+ }
701
+
702
+ /** @param {Event} event */
703
+ const onThreadChange = event => {
704
+ const name = event.target instanceof HTMLSelectElement ? event.target.value : ''
705
+ if (name === '' || name === activeThread) {
706
+ return
707
+ }
708
+ activeThread = name
709
+ void runControl(select, () => loadHistory(name))
710
+ }
711
+
712
+ /** @param {KeyboardEvent} event */
713
+ const onKeyDown = event => {
714
+ if (event.key === 'Enter' && !event.shiftKey) {
715
+ event.preventDefault()
716
+ void runCommand(sendButton, send, { pendingLabel: 'sending…' })
717
+ }
718
+ }
719
+
720
+ /** Sets the context. The same target twice is one state change, not a toggle. */
721
+ const setContext = /** @param {ChatContext} next */ next => {
722
+ if (sameChatContext(context, next)) {
723
+ return false
724
+ }
725
+ context = next
726
+ drawContext()
727
+ return true
728
+ }
729
+
730
+ const stopResize = wireResize(pane, root, storage, applyWidth)
731
+
732
+ form.addEventListener('submit', onSubmit)
733
+ root.addEventListener('click', onClick)
734
+ log.addEventListener('scroll', onScroll)
735
+ pane.addEventListener('wheel', onPaneWheel, { passive: false })
736
+ select.addEventListener('change', onThreadChange)
737
+ box.addEventListener('keydown', /** @type {EventListener} */ (onKeyDown))
738
+ drawContext()
739
+ void restoreThread().catch(() => undefined)
740
+
741
+ return {
742
+ get context() {
743
+ return context
744
+ },
745
+ setContext,
746
+ focusInput() {
747
+ box.focus()
748
+ },
749
+ /** @param {ChatContext} next */
750
+ ask(next) {
751
+ setContext(next)
752
+ box.focus()
753
+ },
754
+ /**
755
+ * Sends one of the quick questions about a target.
756
+ * @param {ChatContext} next
757
+ * @param {string} question
758
+ */
759
+ askQuestion(next, question) {
760
+ setContext(next)
761
+ box.value = question
762
+ void runCommand(sendButton, send, { pendingLabel: 'sending…' })
763
+ },
764
+ stop() {
765
+ stopActivity()
766
+ inFlight?.abort()
767
+ inFlight = null
768
+ if (frame !== 0) {
769
+ cancelFrame(frame)
770
+ frame = 0
771
+ }
772
+ form.removeEventListener('submit', onSubmit)
773
+ root.removeEventListener('click', onClick)
774
+ log.removeEventListener('scroll', onScroll)
775
+ select.removeEventListener('change', onThreadChange)
776
+ box.removeEventListener('keydown', /** @type {EventListener} */ (onKeyDown))
777
+ stopResize()
778
+ },
779
+ }
780
+ }
781
+
782
+ /**
783
+ * Dragging the pane's edge changes its width, between the two bounds, and the browser remembers
784
+ * it. Arrow keys move it too, so the handle works without a pointer.
785
+ * @param {HTMLElement} pane
786
+ * @param {HTMLElement} root
787
+ * @param {Storage | null} storage
788
+ * @param {(width: number) => void} applyWidth
789
+ */
790
+ export function wireResize(pane, root, storage, applyWidth) {
791
+ const handle = qs('#chat-handle', root)
792
+ if (!(handle instanceof HTMLElement)) {
793
+ return () => undefined
794
+ }
795
+ let dragging = false
796
+ const widthNow = () => readChatWidth(storage)
797
+
798
+ /** @param {PointerEvent} event */
799
+ const onDown = event => {
800
+ dragging = true
801
+ event.preventDefault()
802
+ }
803
+ /** @param {PointerEvent} event */
804
+ const onMove = event => {
805
+ if (!dragging) {
806
+ return
807
+ }
808
+ // The pane is on the right, so dragging left makes it wider.
809
+ const width = clampWidth(window.innerWidth - event.clientX)
810
+ applyWidth(width)
811
+ }
812
+ const onUp = () => {
813
+ if (!dragging) {
814
+ return
815
+ }
816
+ dragging = false
817
+ writeChatWidth(storage, pane.getBoundingClientRect().width || widthNow())
818
+ }
819
+ /** @param {KeyboardEvent} event */
820
+ const onKey = event => {
821
+ const step = event.key === 'ArrowLeft' ? 16 : event.key === 'ArrowRight' ? -16 : 0
822
+ if (step === 0) {
823
+ return
824
+ }
825
+ event.preventDefault()
826
+ const width = clampWidth(widthNow() + step)
827
+ applyWidth(width)
828
+ writeChatWidth(storage, width)
829
+ }
830
+
831
+ handle.addEventListener('pointerdown', /** @type {EventListener} */ (onDown))
832
+ document.addEventListener('pointermove', /** @type {EventListener} */ (onMove))
833
+ document.addEventListener('pointerup', onUp)
834
+ document.addEventListener('pointercancel', onUp)
835
+ handle.addEventListener('keydown', /** @type {EventListener} */ (onKey))
836
+ return () => {
837
+ handle.removeEventListener('pointerdown', /** @type {EventListener} */ (onDown))
838
+ document.removeEventListener('pointermove', /** @type {EventListener} */ (onMove))
839
+ document.removeEventListener('pointerup', onUp)
840
+ document.removeEventListener('pointercancel', onUp)
841
+ handle.removeEventListener('keydown', /** @type {EventListener} */ (onKey))
842
+ }
843
+ }