@vintasoftware/pr-review-canvas 0.2.0 → 0.3.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 (90) hide show
  1. package/README.md +9 -3
  2. package/docs/reference.md +99 -99
  3. package/package.json +12 -3
  4. package/pr-review.config.example.yml +4 -4
  5. package/skills/pr-review-canvas/SKILL.md +8 -1
  6. package/src/acpx/acpx.ts +21 -4
  7. package/src/acpx/events.ts +5 -1
  8. package/src/acpx/ndjson.ts +3 -1
  9. package/src/acpx/preflight.ts +5 -1
  10. package/src/canvas/export.ts +11 -2
  11. package/src/canvas/zip.ts +4 -1
  12. package/src/chat/chat-manager.ts +16 -3
  13. package/src/chat/context.ts +10 -3
  14. package/src/chat/seed.ts +3 -1
  15. package/src/cli.ts +12 -2
  16. package/src/commands.ts +41 -9
  17. package/src/config.ts +15 -3
  18. package/src/contract/comments.ts +3 -1
  19. package/src/contract/generation-context.ts +3 -1
  20. package/src/contract/review-artifact.ts +14 -8
  21. package/src/contract/state.ts +3 -1
  22. package/src/git/diff-collector.ts +2 -1
  23. package/src/git/git.ts +9 -4
  24. package/src/github/capabilities.ts +5 -1
  25. package/src/github/comments.ts +6 -3
  26. package/src/github/gh.ts +18 -3
  27. package/src/github/post-comment.ts +4 -1
  28. package/src/github/pr.ts +4 -1
  29. package/src/github/threads.ts +5 -1
  30. package/src/project-config.ts +12 -9
  31. package/src/prompt-files.ts +4 -3
  32. package/src/review/doctor.ts +9 -2
  33. package/src/review/normalize.ts +3 -1
  34. package/src/review/prepare.ts +24 -8
  35. package/src/review/prompt.ts +8 -2
  36. package/src/review/publish.ts +15 -3
  37. package/src/review/trim-caps.ts +10 -6
  38. package/src/review/validate.ts +65 -13
  39. package/src/server/app.ts +12 -4
  40. package/src/server/bundle.ts +17 -4
  41. package/src/server/context.ts +14 -2
  42. package/src/server/errors.ts +26 -2
  43. package/src/server/html.ts +2 -2
  44. package/src/server/node-server.ts +4 -2
  45. package/src/server/routes/api.ts +29 -5
  46. package/src/server/routes/chat-routes.ts +30 -9
  47. package/src/server/routes/pages.ts +3 -1
  48. package/src/server/routes/review-routes.ts +19 -4
  49. package/src/server/sse.ts +3 -1
  50. package/src/store/atomic-json.ts +5 -1
  51. package/src/store/canvas-store.ts +20 -6
  52. package/src/store/derived-store.ts +5 -1
  53. package/src/store/pr-store.ts +2 -1
  54. package/src/store/state-store.ts +2 -1
  55. package/static/brand.svg +19 -0
  56. package/static/js/api.js +15 -3
  57. package/static/js/app.js +8 -2
  58. package/static/js/chat-panel.js +93 -0
  59. package/static/js/chat.js +38 -15
  60. package/static/js/composer.js +6 -3
  61. package/static/js/diagram.js +2 -1
  62. package/static/js/diff-decorations.js +5 -3
  63. package/static/js/diff-renderer.js +6 -1
  64. package/static/js/dom.js +5 -7
  65. package/static/js/empty-state.js +3 -1
  66. package/static/js/header.js +1 -1
  67. package/static/js/interactions.js +55 -15
  68. package/static/js/keyboard.js +7 -2
  69. package/static/js/layers.js +23 -11
  70. package/static/js/links.js +8 -2
  71. package/static/js/nav.js +5 -2
  72. package/static/js/overview.js +32 -4
  73. package/static/js/points.js +3 -2
  74. package/static/js/progress.js +2 -1
  75. package/static/js/proposed-comment.js +4 -1
  76. package/static/js/quick-questions.js +2 -1
  77. package/static/js/regenerate.js +4 -1
  78. package/static/js/signoff.js +6 -2
  79. package/static/styles/base.css +16 -6
  80. package/static/styles/chat-panel.css +81 -0
  81. package/static/styles/chat-tools.css +28 -0
  82. package/static/styles/chat.css +1 -1
  83. package/static/styles/commands.css +4 -4
  84. package/static/styles/diff.css +1 -1
  85. package/static/styles/header.css +18 -4
  86. package/static/styles/layout.css +4 -4
  87. package/static/styles/responsive.css +1 -15
  88. package/static/styles/review.css +24 -3
  89. package/static/styles/skin-github.css +93 -100
  90. package/static/styles.css +13 -12
@@ -27,13 +27,25 @@ import { flash, scrollIntoViewSafe } from './dom.js'
27
27
  import { refreshProgress } from './header.js'
28
28
  import { keyAction, openHelpDialog } from './keyboard.js'
29
29
  import { pointAnchorId, sanitizeKey } from './keys.js'
30
- import { getRenderContext, pathSet, setCardRenderedHook, setRenderContext, updateRenderState } from './layers.js'
30
+ import {
31
+ getRenderContext,
32
+ pathSet,
33
+ setCardRenderedHook,
34
+ setRenderContext,
35
+ updateRenderState,
36
+ } from './layers.js'
31
37
  import { buildNavOrder, layerOf, nextFile, nextLayer, prevFile, prevLayer } from './nav.js'
32
38
  import { issueCommentHtml } from './overview.js'
33
39
  import { applyDismissed, pointToMarkdown, postedUrls } from './points.js'
34
40
  import { layerProgress } from './progress.js'
35
41
  import { lineRefFromEvent, markSelection, selectionReducer } from './selection.js'
36
- import { fillSignoffDialog, openSignoffDialog, showSignoffError, showSignoffResult, signoffBody } from './signoff.js'
42
+ import {
43
+ fillSignoffDialog,
44
+ openSignoffDialog,
45
+ showSignoffError,
46
+ showSignoffResult,
47
+ signoffBody,
48
+ } from './signoff.js'
37
49
 
38
50
  /** @typedef {NonNullable<ReturnType<typeof import('./chat.js').wireChat>>} ChatHandle */
39
51
 
@@ -148,7 +160,8 @@ export function nextUnreviewedTarget(root, session, fromId, kind) {
148
160
  if (item === undefined || item.kind !== kind || item.other) {
149
161
  continue
150
162
  }
151
- const id = item.kind === 'file' ? `layer:${item.layerId}/file:${sanitizeKey(item.path)}` : `layer:${item.layerId}`
163
+ const id =
164
+ item.kind === 'file' ? `layer:${item.layerId}/file:${sanitizeKey(item.path)}` : `layer:${item.layerId}`
152
165
  if (!session.isReviewed(id)) {
153
166
  return root.querySelector(`#${cssEscape(item.id)}`)
154
167
  }
@@ -167,7 +180,13 @@ export function nextUnreviewedTarget(root, session, fromId, kind) {
167
180
  */
168
181
  export function askTargetFor(root, focusId, focusPointId, selection) {
169
182
  if (selection !== null) {
170
- return { kind: 'lines', path: selection.path, side: selection.side, start: selection.start, end: selection.end }
183
+ return {
184
+ kind: 'lines',
185
+ path: selection.path,
186
+ side: selection.side,
187
+ start: selection.start,
188
+ end: selection.end,
189
+ }
171
190
  }
172
191
  if (focusPointId !== null) {
173
192
  const el = root.querySelector(`[data-point="${cssEscape(focusPointId)}"] [data-act="ask"]`)
@@ -213,7 +232,8 @@ export function wireReview(root, session, opts = {}) {
213
232
 
214
233
  const paths = () => pathSet(getRenderContext()?.files ?? [])
215
234
  /** The diff key of a path, which is what the row ids are built from. */
216
- const keyForPath = (/** @type {string} */ path) => getRenderContext()?.files.find(f => f.path === path)?.key ?? null
235
+ const keyForPath = (/** @type {string} */ path) =>
236
+ getRenderContext()?.files.find(f => f.path === path)?.key ?? null
217
237
  /** The time the page was drawn, which the comments it adds are timed against. */
218
238
  const renderNow = () => getRenderContext()?.now ?? new Date()
219
239
 
@@ -263,7 +283,10 @@ export function wireReview(root, session, opts = {}) {
263
283
  const answer = await session.postComment(input)
264
284
  const ctx = getRenderContext()
265
285
  if (answer.kind === 'review' && ctx !== null) {
266
- setRenderContext({ ...ctx, comments: [...ctx.comments.filter(c => c.id !== answer.comment.id), answer.comment] })
286
+ setRenderContext({
287
+ ...ctx,
288
+ comments: [...ctx.comments.filter(c => c.id !== answer.comment.id), answer.comment],
289
+ })
267
290
  onState(session.state)
268
291
  }
269
292
  return answer
@@ -325,7 +348,8 @@ export function wireReview(root, session, opts = {}) {
325
348
  */
326
349
  const openComposer = (anchor, options, shape) => {
327
350
  closeComposers(root)
328
- const node = shape === 'row' ? rowFrom(doc, composerRowHtml(options)) : nodeFrom(doc, composerHtml(options))
351
+ const node =
352
+ shape === 'row' ? rowFrom(doc, composerRowHtml(options)) : nodeFrom(doc, composerHtml(options))
329
353
  if (node === null) {
330
354
  return null
331
355
  }
@@ -364,7 +388,10 @@ export function wireReview(root, session, opts = {}) {
364
388
  }
365
389
 
366
390
  /** The inline comment that a posted review comment becomes on the page. */
367
- const insertPostedReviewComment = (/** @type {Element} */ composerNode, /** @type {ReviewComment} */ comment) => {
391
+ const insertPostedReviewComment = (
392
+ /** @type {Element} */ composerNode,
393
+ /** @type {ReviewComment} */ comment
394
+ ) => {
368
395
  const thread = root.querySelector(
369
396
  `tr.thread[data-thread="${cssEscape(String(comment.inReplyToId ?? comment.id))}"]`
370
397
  )
@@ -461,9 +488,9 @@ export function wireReview(root, session, opts = {}) {
461
488
  * @param {string} id
462
489
  * @param {boolean} reviewed
463
490
  * @param {'layer' | 'file'} kind
464
- * @param {{ quiet?: boolean }} [opts] `quiet` keeps a checkbox label intact while it runs
491
+ * @param {{ quiet?: boolean }} [reviewOptions] `quiet` keeps a checkbox label intact while it runs
465
492
  */
466
- const markReviewed = (button, id, reviewed, kind, opts = {}) => {
493
+ const markReviewed = (button, id, reviewed, kind, reviewOptions = {}) => {
467
494
  const run = async () => {
468
495
  await session.setReviewed(id, reviewed)
469
496
  const parts = cardForReviewedId(root, id)
@@ -479,7 +506,7 @@ export function wireReview(root, session, opts = {}) {
479
506
  }
480
507
  toast(root, reviewed ? `${kind} marked reviewed` : `${kind} reopened`)
481
508
  }
482
- void (opts.quiet === true
509
+ void (reviewOptions.quiet === true
483
510
  ? runControl(button, run)
484
511
  : runCommand(button, run, { pendingLabel: reviewed ? 'marking…' : 'unmarking…' }))
485
512
  }
@@ -641,7 +668,11 @@ export function wireReview(root, session, opts = {}) {
641
668
  return
642
669
  }
643
670
  composerSeq += 1
644
- openComposer(full, { id: `composer-${composerSeq}`, label: 'Reply', kind: 'reply', inReplyToId: id }, 'block')
671
+ openComposer(
672
+ full,
673
+ { id: `composer-${composerSeq}`, label: 'Reply', kind: 'reply', inReplyToId: id },
674
+ 'block'
675
+ )
645
676
  },
646
677
  'pr-comment': () => {
647
678
  const host = root.querySelector('.conversation .pr-composer-host')
@@ -733,7 +764,10 @@ export function wireReview(root, session, opts = {}) {
733
764
  }
734
765
  event.preventDefault()
735
766
  setSelection(
736
- selectionReducer(selection, event.shiftKey ? { type: 'shift-click', target } : { type: 'click', target })
767
+ selectionReducer(
768
+ selection,
769
+ event.shiftKey ? { type: 'shift-click', target } : { type: 'click', target }
770
+ )
737
771
  )
738
772
  if (selection !== null && !event.shiftKey) {
739
773
  setSelection(selectionReducer(selection, { type: 'drag-start', target }))
@@ -807,12 +841,18 @@ export function wireReview(root, session, opts = {}) {
807
841
  const item = order.find(i => i.id === focusId)
808
842
  const wanted = decided.action === 'reviewed-file' ? 'file' : 'layer'
809
843
  const found =
810
- item?.kind === wanted ? item : wanted === 'layer' && focusId !== null ? layerOf(order, focusId) : null
844
+ item?.kind === wanted
845
+ ? item
846
+ : wanted === 'layer' && focusId !== null
847
+ ? layerOf(order, focusId)
848
+ : null
811
849
  if (found === null || found === undefined || found.kind === 'overview') {
812
850
  break
813
851
  }
814
852
  const id =
815
- found.kind === 'file' ? `layer:${found.layerId}/file:${sanitizeKey(found.path)}` : `layer:${found.layerId}`
853
+ found.kind === 'file'
854
+ ? `layer:${found.layerId}/file:${sanitizeKey(found.path)}`
855
+ : `layer:${found.layerId}`
816
856
  const parts = cardForReviewedId(root, id)
817
857
  const box = parts?.card.querySelector('input[data-reviewed-id]')
818
858
  if (box instanceof HTMLElement) {
@@ -35,7 +35,10 @@ export function isTypingTarget(target) {
35
35
  }
36
36
  const tag = target.tagName.toLowerCase()
37
37
  return (
38
- tag === 'input' || tag === 'textarea' || tag === 'select' || target.closest('[contenteditable="true"]') !== null
38
+ tag === 'input' ||
39
+ tag === 'textarea' ||
40
+ tag === 'select' ||
41
+ target.closest('[contenteditable="true"]') !== null
39
42
  )
40
43
  }
41
44
 
@@ -97,7 +100,9 @@ export function keyAction(event, opts = {}) {
97
100
  export const HELP_DIALOG_ID = 'help-dialog'
98
101
 
99
102
  export function helpDialogHtml() {
100
- const rows = KEY_HELP.map(r => `<tr><td class="mono">${esc(r.keys)}</td><td>${esc(r.what)}</td></tr>`).join('')
103
+ const rows = KEY_HELP.map(r => `<tr><td class="mono">${esc(r.keys)}</td><td>${esc(r.what)}</td></tr>`).join(
104
+ ''
105
+ )
101
106
  return (
102
107
  `<dialog id="${HELP_DIALOG_ID}" class="help" aria-labelledby="help-h"><form method="dialog">` +
103
108
  '<h2 id="help-h">Keyboard</h2>' +
@@ -117,7 +117,8 @@ export function railInnerHtml(artifact, state, opts = {}) {
117
117
  .map((layer, i) => {
118
118
  const progress = layerProgress(layer, state)
119
119
  const dotClass = progress === 'done' ? 'dot on' : progress === 'partial' ? 'dot half' : 'dot'
120
- const dotLabel = progress === 'done' ? 'reviewed' : progress === 'partial' ? 'in progress' : 'not started'
120
+ const dotLabel =
121
+ progress === 'done' ? 'reviewed' : progress === 'partial' ? 'in progress' : 'not started'
121
122
  const reviewed = filesReviewed(layer, state)
122
123
  const files = layer.files.length
123
124
  const meta = reviewed > 0 && reviewed < files ? `${reviewed} of ${files} files` : fileCount(files)
@@ -206,7 +207,11 @@ export function layerDiagramHtml(layer) {
206
207
  * @param {ReadonlySet<string>} paths
207
208
  */
208
209
  export function judgmentHtml(layer, paths) {
209
- const block = /** @param {string} cls @param {string} label @param {string | undefined} text */ (cls, label, text) =>
210
+ const block = /** @param {string} cls @param {string} label @param {string | undefined} text */ (
211
+ cls,
212
+ label,
213
+ text
214
+ ) =>
210
215
  text === undefined
211
216
  ? ''
212
217
  : `<div class="judgment ${cls}"><h3 class="lbl">${label}</h3><div class="prose">${renderMarkdown(text, { paths })}</div></div>`
@@ -230,7 +235,9 @@ export function layerPointsHtml(layer, points, paths, state, posted) {
230
235
  return ''
231
236
  }
232
237
  const active = own.filter(p => state.dismissed[p.fingerprint] === undefined).length
233
- const cards = own.map(p => pointCardHtml(p, posted === undefined ? { paths, state } : { paths, state, posted }))
238
+ const cards = own.map(p =>
239
+ pointCardHtml(p, posted === undefined ? { paths, state } : { paths, state, posted })
240
+ )
234
241
  return `<h3 class="lbl sub">Attention points · <span class="point-count">${active}</span></h3><ol class="findings">${cards.join('')}</ol>`
235
242
  }
236
243
 
@@ -245,14 +252,16 @@ export function renderFileCard(lf, entry, layer, ctx) {
245
252
  const key = entry?.key ?? sanitizeKey(lf.path)
246
253
  const cardReviewedId = `layer:${layer.id}/file:${sanitizeKey(lf.path)}`
247
254
  const cardReviewed = ctx.state?.reviewed[cardReviewedId] === true
248
- const collapsed = cardReviewed || (lf.collapsed === true && lf.annotations.length === 0 && ctx.keepOpen !== true)
255
+ const collapsed =
256
+ cardReviewed || (lf.collapsed === true && lf.annotations.length === 0 && ctx.keepOpen !== true)
249
257
  const isFirst = !ctx.firstCardFor.has(key)
250
258
  ctx.firstCardFor.add(key)
251
259
  const id = isFirst ? fileAnchorId(key) : `${fileAnchorId(key)}-${layer.key}`
252
260
  const pills = entry
253
261
  ? `<span class="pill add">+${entry.additions}</span><span class="pill del">&minus;${entry.deletions}</span>`
254
262
  : ''
255
- const status = entry && entry.status !== 'modified' ? `<span class="status">${esc(entry.status)}</span>` : ''
263
+ const status =
264
+ entry && entry.status !== 'modified' ? `<span class="status">${esc(entry.status)}</span>` : ''
256
265
  const path =
257
266
  entry?.oldPath !== undefined
258
267
  ? `<span class="old">${esc(entry.oldPath)} &rarr; </span>${esc(lf.path)}`
@@ -411,7 +420,8 @@ export function hydrateFileCard(card, ctx, opts = {}) {
411
420
  const entry = ctx.files.find(f => f.key === key)
412
421
  const patch = ctx.patches?.[key]
413
422
  if (!entry || patch === undefined) {
414
- host.innerHTML = '<div class="unavailable">Diff not available locally. Fetch the PR head and reload.</div>'
423
+ host.innerHTML =
424
+ '<div class="unavailable">Diff not available locally. Fetch the PR head and reload.</div>'
415
425
  return { rendered: false, deferred: false, placed: 0, missed: 0 }
416
426
  }
417
427
  const lines = patchLineCount(patch)
@@ -432,10 +442,10 @@ export function hydrateFileCard(card, ctx, opts = {}) {
432
442
  async () => {
433
443
  hydrateFileCard(card, ctx, { force: true })
434
444
  // The host element now holds a drawn diff, so a later link into it leaves it alone.
435
- const host = card.closest('pr-file')
436
- if (host instanceof PrFileElement) {
437
- host.rendered = true
438
- host.deferred = false
445
+ const fileHost = card.closest('pr-file')
446
+ if (fileHost instanceof PrFileElement) {
447
+ fileHost.rendered = true
448
+ fileHost.deferred = false
439
449
  }
440
450
  },
441
451
  { pendingLabel: 'drawing…' }
@@ -450,7 +460,9 @@ export function hydrateFileCard(card, ctx, opts = {}) {
450
460
  const layer = ctx.artifact.layers.find(l => l.id === layerId)
451
461
  const lf = layer?.files.find(f => f.path === entry.path)
452
462
  const annotations = lf?.annotations ?? []
453
- const points = ctx.artifact.points.filter(p => p.path === entry.path && hunkIds.has(hunkIdForPoint(p, entry)))
463
+ const points = ctx.artifact.points.filter(
464
+ p => p.path === entry.path && hunkIds.has(hunkIdForPoint(p, entry))
465
+ )
454
466
  const threads = threadsForHunks(ctx.comments, entry, hunkIds)
455
467
  const { placed, missed } = applyDecorations(card, key, {
456
468
  annotations,
@@ -99,14 +99,20 @@ export function resolveLink(link, targets) {
99
99
  return { ok: false, message: `${link.path} is not in the diff` }
100
100
  }
101
101
  if (link.kind === 'hunk' && (link.n < 1 || link.n > file.hunks.length)) {
102
- return { ok: false, message: `${link.path}#${link.n} does not exist (file has ${file.hunks.length} hunks)` }
102
+ return {
103
+ ok: false,
104
+ message: `${link.path}#${link.n} does not exist (file has ${file.hunks.length} hunks)`,
105
+ }
103
106
  }
104
107
  if (link.kind === 'line') {
105
108
  const start = hunkForLine(file.hunks, link.side, link.start)
106
109
  const end = hunkForLine(file.hunks, link.side, link.end)
107
110
  if (start === null || start !== end) {
108
111
  const range = link.end === link.start ? `${link.start}` : `${link.start}-${link.end}`
109
- return { ok: false, message: `${link.path}:${range} (${link.side}) is not inside one hunk of the diff (${hunkLineRanges(file.hunks, link.side)})` }
112
+ return {
113
+ ok: false,
114
+ message: `${link.path}:${range} (${link.side}) is not inside one hunk of the diff (${hunkLineRanges(file.hunks, link.side)})`,
115
+ }
110
116
  }
111
117
  }
112
118
  return { ok: true }
package/static/js/nav.js CHANGED
@@ -46,7 +46,8 @@ export function buildNavOrder(artifact) {
46
46
  * @returns {NavItem | null}
47
47
  */
48
48
  function step(order, currentId, kind, direction) {
49
- const from = currentId === null ? (direction === 1 ? -1 : order.length) : order.findIndex(i => i.id === currentId)
49
+ const from =
50
+ currentId === null ? (direction === 1 ? -1 : order.length) : order.findIndex(i => i.id === currentId)
50
51
  const start = from === -1 && currentId !== null ? (direction === 1 ? -1 : order.length) : from
51
52
  for (let i = start + direction; i >= 0 && i < order.length; i += direction) {
52
53
  const item = order[i]
@@ -87,5 +88,7 @@ export function layerOf(order, id) {
87
88
  if (!item || item.kind === 'overview') {
88
89
  return null
89
90
  }
90
- return item.kind === 'layer' ? item : (order.find(i => i.kind === 'layer' && i.layerId === item.layerId) ?? null)
91
+ return item.kind === 'layer'
92
+ ? item
93
+ : (order.find(i => i.kind === 'layer' && i.layerId === item.layerId) ?? null)
91
94
  }
@@ -2,11 +2,32 @@
2
2
  /** @typedef {import('./contract-types.js').PrBundle} PrBundle */
3
3
  /** @typedef {import('./contract-types.js').IssueComment} IssueComment */
4
4
 
5
- import { commentHtml } from './diff-decorations.js'
6
5
  import { viewCommentHtml } from './comment-link.js'
7
6
  import { detailsSummaryHtml, esc, avatarHtml, timeAgo } from './dom.js'
8
7
  import { renderMarkdown } from './markdown.js'
9
8
  import { dismissedListHtml, postedUrls, sevsumHtml } from './points.js'
9
+ import { buildThreads } from './threads.js'
10
+
11
+ /**
12
+ * @param {PrBundle['comments']['reviewComments']} comments
13
+ * @param {Date} now
14
+ */
15
+ function outdatedCommentsHtml(comments, now) {
16
+ const threads = [...buildThreads(comments).outdated.values()].flat()
17
+ if (!threads.length) return ''
18
+ const count = threads.reduce((total, thread) => total + 1 + thread.replies.length, 0)
19
+ const entries = threads
20
+ .map(thread => {
21
+ const line = thread.root.originalLine
22
+ return (
23
+ `<div class="body"><p class="muted small">${esc(thread.path)}${line === null ? '' : `:${line} (original)`}${thread.resolved ? ' · resolved' : ''}</p>` +
24
+ [thread.root, ...thread.replies].map(comment => issueCommentHtml(comment, now)).join('') +
25
+ '</div>'
26
+ )
27
+ })
28
+ .join('')
29
+ return `<details class="outdated-comments">${detailsSummaryHtml(`<span>Outdated comments · ${count}</span>`, 'Toggle outdated comments')}${entries}</details>`
30
+ }
10
31
 
11
32
  /**
12
33
  * "What changes": the PR-wide summary as one prose block.
@@ -61,7 +82,12 @@ export function conversationHtml(comments, now) {
61
82
  */
62
83
  export function renderOverview(bundle, ctx) {
63
84
  const artifact = bundle.artifact
64
- const active = artifact ? artifact.points.filter(p => bundle.state.dismissed[p.fingerprint] === undefined) : []
85
+ const reviews = (bundle.comments.reviews ?? []).filter(
86
+ review => review.state !== 'COMMENTED' || review.body.trim().length > 0
87
+ )
88
+ const active = artifact
89
+ ? artifact.points.filter(p => bundle.state.dismissed[p.fingerprint] === undefined)
90
+ : []
65
91
  const summary = artifact ? summaryHtml(artifact.summary, ctx.paths) : ''
66
92
  const description = bundle.pr.body.trim()
67
93
  ? `<details class="pr-desc">${detailsSummaryHtml('<span>PR description (from GitHub)</span>', 'Toggle PR description')}<div class="body prose">${renderMarkdown(bundle.pr.body, { paths: ctx.paths, github: true })}</div></details>`
@@ -72,8 +98,10 @@ export function renderOverview(bundle, ctx) {
72
98
  `<div class="body">${summary}</div>` +
73
99
  description +
74
100
  conversationHtml(bundle.comments.issueComments, ctx.now) +
75
- (bundle.comments.reviews ?? []).map(review => `<div class="body"><span class="pill">${esc(review.state.toLowerCase().replaceAll('_', ' '))}</span>${issueCommentHtml(review, ctx.now)}</div>`).join('') +
76
- (bundle.comments.reviewComments.length ? `<details class="body all-review-comments">${detailsSummaryHtml(`<span>All review comments · ${bundle.comments.reviewComments.length}</span>`, 'Toggle all review comments')}${bundle.comments.reviewComments.map(c => `<div><p class="muted small">${esc(c.path)}${c.line ? `:${c.line}` : ''}${c.outdated ? ' · outdated' : ''}${c.resolved ? ' · resolved' : ''}</p>${commentHtml(c, ctx.now)}</div>`).join('')}</details>` : '') +
101
+ (reviews.length
102
+ ? `<details class="review-history">${detailsSummaryHtml(`<span>Review history · ${reviews.length}</span>`, 'Toggle review history')}${reviews.map(review => `<div class="body review-entry"><span class="pill">${esc(review.state.toLowerCase().replaceAll('_', ' '))}</span>${issueCommentHtml(review, ctx.now)}</div>`).join('')}</details>`
103
+ : '') +
104
+ outdatedCommentsHtml(bundle.comments.reviewComments, ctx.now) +
77
105
  (artifact
78
106
  ? dismissedListHtml(artifact.points, bundle.state, {
79
107
  paths: ctx.paths,
@@ -190,7 +190,8 @@ export function applyDismissed(root, points, state, ctx) {
190
190
  }
191
191
  const host = root.querySelector('.dismissed-list')
192
192
  if (host !== null) {
193
- const expanded = host.querySelector('[data-act="show-dismissed"]')?.getAttribute('aria-expanded') === 'true'
193
+ const expanded =
194
+ host.querySelector('[data-act="show-dismissed"]')?.getAttribute('aria-expanded') === 'true'
194
195
  const template = document.createElement('template')
195
196
  template.innerHTML = dismissedListHtml(points, state, ctx, expanded)
196
197
  const next = template.content.firstElementChild
@@ -219,7 +220,7 @@ export function pointRowHtml(p, ctx) {
219
220
  const dismissed = ctx.state?.dismissed[p.fingerprint] !== undefined
220
221
  const posted = postedFor(p, ctx)
221
222
  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
+ `<tr class="ifind ${p.level}" data-point="${esc(p.id)}" data-fingerprint="${esc(p.fingerprint)}"${dismissed ? ' hidden' : ''}><td class="code x" colspan="4">` +
223
224
  `<div class="f-title">${squareHtml(p)}<span>${esc(p.title)}</span><span class="pill kind">${esc(p.kind)}</span></div>` +
224
225
  `<div class="prose">${renderMarkdown(p.body, { paths: ctx.paths })}</div>` +
225
226
  `${pointCommandsHtml(p, { postedUrl: posted })}</td></tr>`
@@ -45,5 +45,6 @@ export function progressSummary(artifact, state) {
45
45
  * @param {PrState} state
46
46
  */
47
47
  export function filesReviewed(layer, state) {
48
- return layer.files.filter(f => state.reviewed[`layer:${layer.id}/file:${sanitizeKey(f.path)}`] === true).length
48
+ return layer.files.filter(f => state.reviewed[`layer:${layer.id}/file:${sanitizeKey(f.path)}`] === true)
49
+ .length
49
50
  }
@@ -63,7 +63,10 @@ export function parseProposedComment(source, targets) {
63
63
  return { reason: 'side must be "new" or "old"' }
64
64
  }
65
65
  const side = sideRaw === 'old' ? 'old' : 'new'
66
- if (startRaw !== undefined && (typeof startRaw !== 'number' || !Number.isInteger(startRaw) || startRaw <= 0)) {
66
+ if (
67
+ startRaw !== undefined &&
68
+ (typeof startRaw !== 'number' || !Number.isInteger(startRaw) || startRaw <= 0)
69
+ ) {
67
70
  return { reason: 'startLine must be a line number' }
68
71
  }
69
72
  const startLine = typeof startRaw === 'number' ? startRaw : undefined
@@ -18,7 +18,8 @@ export const ASK_SOMETHING_ELSE = 'ask something else…'
18
18
  export function quickMenuHtml() {
19
19
  const items = [...QUICK_QUESTIONS, ASK_SOMETHING_ELSE]
20
20
  .map(
21
- (q, i) => `<button class="qq-item" type="button" role="menuitem" tabindex="-1" data-qq="${i}">${esc(q)}</button>`
21
+ (q, i) =>
22
+ `<button class="qq-item" type="button" role="menuitem" tabindex="-1" data-qq="${i}">${esc(q)}</button>`
22
23
  )
23
24
  .join('')
24
25
  return `<div class="qq-menu" id="${QQ_MENU_ID}" role="menu" aria-label="Quick questions" hidden>${items}</div>`
@@ -65,5 +65,8 @@ export function canvasChanged(current, next) {
65
65
  if (current.status !== 'ready' || !current.artifact || !current.canvas) {
66
66
  return true
67
67
  }
68
- return next.canvas.headSha !== current.canvas.headSha || next.artifact.generatedAt !== current.artifact.generatedAt
68
+ return (
69
+ next.canvas.headSha !== current.canvas.headSha ||
70
+ next.artifact.generatedAt !== current.artifact.generatedAt
71
+ )
69
72
  }
@@ -20,7 +20,9 @@ export const LOADING_REASON = 'the review body is still loading'
20
20
  * @returns {string | null}
21
21
  */
22
22
  export function approveBlockedReason(artifact, state) {
23
- const [first, ...rest] = artifact.layers.filter(l => l.kind !== 'other' && layerProgress(l, state) !== 'done')
23
+ const [first, ...rest] = artifact.layers.filter(
24
+ l => l.kind !== 'other' && layerProgress(l, state) !== 'done'
25
+ )
24
26
  if (first === undefined) {
25
27
  return null
26
28
  }
@@ -75,7 +77,9 @@ export function openSignoffDialog(root, opts) {
75
77
  if (!(dialog instanceof HTMLDialogElement)) {
76
78
  throw new Error('sign-off dialog did not render')
77
79
  }
78
- dialog.querySelector('[data-act="markdown-write"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
80
+ dialog
81
+ .querySelector('[data-act="markdown-write"]')
82
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
79
83
  dialog.setAttribute('data-event', opts.event)
80
84
  const heading = dialog.querySelector('#signoff-h')
81
85
  if (heading !== null) {
@@ -22,8 +22,10 @@
22
22
  --s4: var(--v4);
23
23
  --s5: var(--v5);
24
24
  --s6: var(--v6);
25
- --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
26
- --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
25
+ --sans:
26
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif,
27
+ 'Apple Color Emoji', 'Segoe UI Emoji';
28
+ --mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
27
29
 
28
30
  --v1: light-dark(#ffd43b, #ffe066);
29
31
  --v2: light-dark(#ffa94d, #ffc078);
@@ -31,7 +33,15 @@
31
33
  --v4: light-dark(#da77f2, #e599f7);
32
34
  --v5: light-dark(#9775fa, #b197fc);
33
35
  --v6: light-dark(#748ffc, #91a7ff);
34
- --stripe-y: linear-gradient(180deg, var(--v1) 0 1px, var(--v2) 1px 2px, var(--v3) 2px 3px, var(--v4) 3px 4px, var(--v5) 4px 5px, var(--v6) 5px 6px);
36
+ --stripe-y: linear-gradient(
37
+ 180deg,
38
+ var(--v1) 0 1px,
39
+ var(--v2) 1px 2px,
40
+ var(--v3) 2px 3px,
41
+ var(--v4) 3px 4px,
42
+ var(--v5) 4px 5px,
43
+ var(--v6) 5px 6px
44
+ );
35
45
 
36
46
  --bg: light-dark(#ffffff, #1e1f2b);
37
47
  --panel: light-dark(#ffffff, #282a3a);
@@ -80,10 +90,10 @@
80
90
 
81
91
  /* The server writes `data-theme` for every load. `auto` matches neither rule, which leaves the
82
92
  `color-scheme: light dark` above to follow the operating system. */
83
- :root[data-theme="dark"] {
93
+ :root[data-theme='dark'] {
84
94
  color-scheme: dark;
85
95
  }
86
- :root[data-theme="light"] {
96
+ :root[data-theme='light'] {
87
97
  color-scheme: light;
88
98
  }
89
99
 
@@ -218,6 +228,6 @@ strong {
218
228
  }
219
229
 
220
230
  /* The hidden attribute takes priority over component layout styles. */
221
- [hidden]:not([hidden="until-found"]) {
231
+ [hidden]:not([hidden='until-found']) {
222
232
  display: none !important;
223
233
  }
@@ -0,0 +1,81 @@
1
+ .chat-launcher,
2
+ .chat-minimize {
3
+ display: none;
4
+ }
5
+ @media (max-width: 1360px) {
6
+ .chat-launcher {
7
+ display: block;
8
+ position: fixed;
9
+ right: 20px;
10
+ bottom: 20px;
11
+ z-index: 10;
12
+ padding: 14px 20px;
13
+ border: 1px solid var(--ink);
14
+ border-radius: 999px;
15
+ background: var(--accent);
16
+ color: var(--bg);
17
+ box-shadow: 0 4px 20px #0003;
18
+ font: inherit;
19
+ font-weight: 600;
20
+ cursor: pointer;
21
+ }
22
+ .chat-launcher[hidden] {
23
+ display: none;
24
+ }
25
+ .chat-minimize {
26
+ display: inline-block;
27
+ }
28
+ dialog.chat-dialog {
29
+ position: fixed;
30
+ inset: auto 20px 20px auto;
31
+ width: min(420px, calc(100vw - 40px));
32
+ height: min(680px, calc(100dvh - 40px));
33
+ max-width: none;
34
+ max-height: none;
35
+ margin: 0;
36
+ padding: 0;
37
+ border: 1px solid var(--ink);
38
+ border-radius: 12px;
39
+ background: var(--panel);
40
+ color: var(--fg);
41
+ box-shadow: 0 8px 32px #0004;
42
+ overflow: hidden;
43
+ z-index: 11;
44
+ }
45
+ .chat-dialog .chat {
46
+ position: relative;
47
+ top: auto;
48
+ height: 100%;
49
+ padding: 0;
50
+ }
51
+ .chat-dialog .chat-h,
52
+ .chat-dialog .ctx-line,
53
+ .chat-dialog .chat-composer {
54
+ flex-shrink: 0;
55
+ }
56
+ .chat-dialog .chat-h h2 {
57
+ margin-bottom: 0;
58
+ }
59
+ .chat-dialog .chat-composer {
60
+ margin: 0;
61
+ padding: 12px 14px;
62
+ gap: 8px;
63
+ }
64
+ .chat-dialog textarea {
65
+ max-height: 25dvh;
66
+ }
67
+ }
68
+ @media (max-width: 600px) {
69
+ dialog.chat-dialog {
70
+ inset: auto 8px max(8px, env(safe-area-inset-bottom)) 8px;
71
+ width: auto;
72
+ height: calc(100dvh - 24px - env(safe-area-inset-bottom));
73
+ }
74
+ .chat-launcher {
75
+ right: 16px;
76
+ bottom: max(16px, env(safe-area-inset-bottom));
77
+ }
78
+ html:has(.chat-dialog:modal) {
79
+ overflow: hidden;
80
+ }
81
+ }
@@ -1,4 +1,32 @@
1
1
  /* Chat context, proposed comments, quick questions, and settings. */
2
+ .chat-tool-calls > summary {
3
+ padding: 4px 0;
4
+ gap: 8px;
5
+ align-items: baseline;
6
+ overflow-wrap: anywhere;
7
+ cursor: pointer;
8
+ }
9
+ .chat-tool-calls > summary::before {
10
+ content: '';
11
+ width: 6px;
12
+ height: 6px;
13
+ flex: 0 0 6px;
14
+ border-right: 1.5px solid currentColor;
15
+ border-bottom: 1.5px solid currentColor;
16
+ transform: rotate(-45deg);
17
+ }
18
+ .chat-tool-calls[open] > summary::before {
19
+ transform: rotate(45deg);
20
+ }
21
+ .chat-tool-calls > ul {
22
+ margin: 4px 0 8px;
23
+ padding-left: 14px;
24
+ list-style: none;
25
+ }
26
+ .chat-tool-calls > ul > li {
27
+ margin: 4px 0;
28
+ overflow-wrap: anywhere;
29
+ }
2
30
  .ctx-line {
3
31
  display: flex;
4
32
  align-items: baseline;
@@ -25,7 +25,7 @@
25
25
  z-index: 3;
26
26
  }
27
27
  .handle::before {
28
- content: "";
28
+ content: '';
29
29
  width: 4px;
30
30
  height: 36px;
31
31
  border-left: 1px solid currentColor;