@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,200 @@
1
+ // @ts-check
2
+ // Rows a reviewer can hide: the canvas folds the model named, and the folds the diff renderer
3
+ // marked (import-only and whitespace-only changes, and moved blocks). Both open again when a
4
+ // deep link lands inside them.
5
+ import { findRow } from './anchors.js'
6
+
7
+ /** @typedef {import('./contract-types.js').CodeFold} CodeFold */
8
+
9
+ /**
10
+ * @param {Element | null} summary
11
+ * @param {readonly HTMLTableRowElement[]} rows
12
+ */
13
+ function coveredFoldSummary(summary, rows) {
14
+ if (!summary?.matches('.fold')) {
15
+ return false
16
+ }
17
+
18
+ let next = summary.nextElementSibling
19
+ while (next instanceof HTMLTableRowElement && next.matches('.folded')) {
20
+ if (!rows.includes(next)) {
21
+ return false
22
+ }
23
+ next = next.nextElementSibling
24
+ }
25
+
26
+ return true
27
+ }
28
+
29
+ /**
30
+ * Finds the rows between both anchors. A decorated or overlapping range stays open.
31
+ * @param {HTMLElement} card
32
+ * @param {string} key
33
+ * @param {CodeFold} fold
34
+ * @returns {HTMLTableRowElement[]}
35
+ */
36
+ function foldRows(card, key, fold) {
37
+ const first = findRow(card, key, fold.side, fold.startLine)
38
+ const last = findRow(card, key, fold.side, fold.endLine)
39
+
40
+ if (first === null || last === null || first.parentElement === null) {
41
+ return []
42
+ }
43
+
44
+ if (last.parentElement !== first.parentElement) {
45
+ return []
46
+ }
47
+
48
+ const siblings = Array.from(first.parentElement.children)
49
+ const from = siblings.indexOf(first)
50
+ const to = siblings.indexOf(last)
51
+
52
+ if (to < from) {
53
+ return []
54
+ }
55
+
56
+ const rows = siblings.slice(from, to + 1).filter(row => row instanceof HTMLTableRowElement)
57
+ const hasDiscussion = rows.some(row => row.matches('.ann, [data-decoration], [data-code-fold], .code-fold'))
58
+
59
+ const hasThread = Boolean(first.closest('table')?.querySelector('[data-decoration="thread"]'))
60
+
61
+ if (hasDiscussion || hasThread || last.nextElementSibling?.matches('[data-decoration]')) {
62
+ return []
63
+ }
64
+
65
+ const preceding = first.previousElementSibling
66
+ if (preceding instanceof HTMLTableRowElement && coveredFoldSummary(preceding, rows)) {
67
+ rows.unshift(preceding)
68
+ }
69
+
70
+ return rows
71
+ }
72
+
73
+ /**
74
+ * Inserts the fold's title above its first row.
75
+ * @param {HTMLTableRowElement} first
76
+ * @param {string} title
77
+ */
78
+ function createToggle(first, title) {
79
+ const header = document.createElement('tr')
80
+ header.className = 'more code-fold'
81
+
82
+ const cell = document.createElement('td')
83
+ cell.colSpan = 4
84
+
85
+ const toggle = document.createElement('button')
86
+ toggle.className = 'cmd'
87
+ toggle.type = 'button'
88
+ toggle.textContent = title
89
+
90
+ cell.appendChild(toggle)
91
+ header.appendChild(cell)
92
+ first.before(header)
93
+
94
+ return toggle
95
+ }
96
+
97
+ /**
98
+ * Keeps the original rows in the DOM so comments and deep links retain their anchors.
99
+ * @param {HTMLTableRowElement} first
100
+ * @param {HTMLTableRowElement[]} rows
101
+ * @param {string} title
102
+ */
103
+ function wireFold(first, rows, title) {
104
+ const toggle = createToggle(first, title)
105
+ const table = first.closest('table')
106
+ const foldSummaries = new Set(rows.filter(row => coveredFoldSummary(row, rows)))
107
+ toggle.setAttribute(
108
+ 'aria-controls',
109
+ rows
110
+ .map(row => row.id)
111
+ .filter(Boolean)
112
+ .join(' ')
113
+ )
114
+
115
+ const setExpanded = (/** @type {boolean} */ expanded) => {
116
+ toggle.setAttribute('aria-expanded', String(expanded))
117
+
118
+ for (const row of rows) {
119
+ row.hidden = !expanded || foldSummaries.has(row)
120
+ row.setAttribute('data-code-fold', '')
121
+ }
122
+ }
123
+
124
+ toggle.addEventListener('click', () => setExpanded(toggle.getAttribute('aria-expanded') !== 'true'))
125
+
126
+ table?.addEventListener('reveal-code', event => {
127
+ const target = event.target
128
+ if (target instanceof Node && (target === table || rows.some(row => row.contains(target)))) {
129
+ setExpanded(true)
130
+ }
131
+ })
132
+
133
+ setExpanded(false)
134
+ }
135
+
136
+ /**
137
+ * Adds title-only folds; ranges with annotations, attention points, or discussions stay open.
138
+ * @param {HTMLElement} card
139
+ * @param {string} key
140
+ * @param {readonly CodeFold[]} folds
141
+ */
142
+ export function applyCodeFolds(card, key, folds) {
143
+ for (const fold of folds) {
144
+ const rows = foldRows(card, key, fold)
145
+ const first = rows[0]
146
+
147
+ if (first !== undefined) {
148
+ wireFold(first, rows, fold.title)
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * The `.fold` summary row that owns a folded row, or null when the row is not in a fold.
155
+ * @param {Element} row
156
+ * @returns {HTMLTableRowElement | null}
157
+ */
158
+ function foldSummaryFor(row) {
159
+ if (!row.matches('tr.folded')) {
160
+ return null
161
+ }
162
+ let previous = row.previousElementSibling
163
+ while (previous instanceof HTMLTableRowElement && previous.matches('.folded')) {
164
+ previous = previous.previousElementSibling
165
+ }
166
+ return previous instanceof HTMLTableRowElement && previous.matches('.more.fold') ? previous : null
167
+ }
168
+
169
+ /**
170
+ * Shows or hides the rows a summary row stands for, and relabels its button.
171
+ * @param {HTMLTableRowElement} summary
172
+ * @param {boolean} shown
173
+ */
174
+ export function setFoldShown(summary, shown) {
175
+ let next = summary.nextElementSibling
176
+ while (next instanceof HTMLTableRowElement && next.matches('.folded')) {
177
+ next.classList.toggle('shown', shown)
178
+ next = next.nextElementSibling
179
+ }
180
+ const toggle = summary.querySelector('button[data-act="show-fold"]')
181
+ if (toggle instanceof HTMLElement) {
182
+ toggle.setAttribute('aria-expanded', String(shown))
183
+ toggle.textContent = shown ? 'hide' : 'show'
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Opens the folded rows around a deep-link target, so a line inside a fold can be reached.
189
+ * @param {HTMLElement} card
190
+ */
191
+ export function wireFoldReveal(card) {
192
+ card.addEventListener('reveal-code', event => {
193
+ const target = event.target
194
+ const row = target instanceof Element ? target.closest('tr') : null
195
+ const summary = row === null ? null : foldSummaryFor(row)
196
+ if (summary !== null) {
197
+ setFoldShown(summary, true)
198
+ }
199
+ })
200
+ }
@@ -0,0 +1,110 @@
1
+ // @ts-check
2
+ // Every command that triggers a request goes through runCommand, so the click always shows a
3
+ // pending state and a failure always shows its message next to the command.
4
+ import { copyToClipboard } from './dom.js'
5
+
6
+ /**
7
+ * @template T
8
+ * @param {HTMLElement} el the .cmd button
9
+ * @param {() => Promise<T>} fn
10
+ * @param {{ pendingLabel?: string }} [opts] label shown while `fn` runs; default is the label plus `…`
11
+ * @returns {Promise<T | undefined>} the result, or undefined after a failure (already shown inline)
12
+ */
13
+ export async function runCommand(el, fn, opts = {}) {
14
+ const original = el.textContent ?? ''
15
+ const pending = opts.pendingLabel ?? `${original}…`
16
+ clearCommandError(el)
17
+ if (el instanceof HTMLButtonElement) {
18
+ el.disabled = true
19
+ }
20
+ el.setAttribute('aria-busy', 'true')
21
+ el.textContent = pending
22
+ try {
23
+ return await fn()
24
+ } catch (err) {
25
+ showCommandError(el, err instanceof Error ? err.message : String(err))
26
+ return undefined
27
+ } finally {
28
+ el.textContent = original
29
+ el.removeAttribute('aria-busy')
30
+ if (el instanceof HTMLButtonElement) {
31
+ el.disabled = false
32
+ }
33
+ }
34
+ }
35
+
36
+ /**
37
+ * The same pending and error handling for a control whose label must not change: a checkbox
38
+ * keeps its box, so only `aria-busy` and the disabled state move.
39
+ * @template T
40
+ * @param {HTMLElement} el the element the error is shown next to
41
+ * @param {() => Promise<T>} fn
42
+ * @returns {Promise<T | undefined>}
43
+ */
44
+ export async function runControl(el, fn) {
45
+ clearCommandError(el)
46
+ const inputs = el instanceof HTMLInputElement ? [el] : Array.from(el.querySelectorAll('input'))
47
+ /** @param {boolean} disabled */
48
+ const setDisabled = disabled => {
49
+ for (const input of inputs) {
50
+ input.disabled = disabled
51
+ }
52
+ }
53
+ el.setAttribute('aria-busy', 'true')
54
+ setDisabled(true)
55
+ try {
56
+ return await fn()
57
+ } catch (err) {
58
+ showCommandError(el, err instanceof Error ? err.message : String(err))
59
+ return undefined
60
+ } finally {
61
+ el.removeAttribute('aria-busy')
62
+ setDisabled(false)
63
+ }
64
+ }
65
+
66
+ /**
67
+ * @param {HTMLElement} el
68
+ * @param {string} message
69
+ */
70
+ export function showCommandError(el, message) {
71
+ clearCommandError(el)
72
+ const err = document.createElement('span')
73
+ err.className = 'cmd-err'
74
+ err.setAttribute('role', 'alert')
75
+ err.textContent = message
76
+ el.insertAdjacentElement('afterend', err)
77
+ }
78
+
79
+ /** @param {HTMLElement} el */
80
+ export function clearCommandError(el) {
81
+ const next = el.nextElementSibling
82
+ if (next?.classList.contains('cmd-err')) {
83
+ next.remove()
84
+ }
85
+ }
86
+
87
+ const COPY_WIRED = new WeakSet()
88
+
89
+ /**
90
+ * One delegated click handler per root for every `button[data-copy]` under it, present now or
91
+ * rendered later. Calling it again for the same root does nothing, so re-rendering the root's
92
+ * content never stacks handlers (a second handler would run runCommand twice on one click).
93
+ * @param {HTMLElement} root
94
+ * @param {(text: string) => Promise<void>} [copy]
95
+ * @returns {boolean} true when the handler was added by this call
96
+ */
97
+ export function wireCopyCommands(root, copy = copyToClipboard) {
98
+ if (COPY_WIRED.has(root)) {
99
+ return false
100
+ }
101
+ COPY_WIRED.add(root)
102
+ root.addEventListener('click', event => {
103
+ const el = event.target instanceof Element ? event.target.closest('button[data-copy]') : null
104
+ if (el instanceof HTMLElement) {
105
+ const text = el.getAttribute('data-copy') ?? ''
106
+ void runCommand(el, () => copy(text), { pendingLabel: 'copying…' })
107
+ }
108
+ })
109
+ return true
110
+ }
@@ -0,0 +1,37 @@
1
+ // @ts-check
2
+ import { esc } from './dom.js'
3
+
4
+ /** @param {string} url @param {boolean} [filled] */
5
+ export function viewCommentHtml(url, filled = false) {
6
+ return `<a class="cmd${filled ? ' fill' : ''}" href="${esc(url)}" target="_blank" rel="noopener noreferrer">view comment</a>`
7
+ }
8
+
9
+ /** @param {HTMLElement} button @param {string} url */
10
+ export function replacePostButton(button, url) {
11
+ const template = button.ownerDocument.createElement('template')
12
+ template.innerHTML = viewCommentHtml(url, button.classList.contains('fill'))
13
+ const link = template.content.firstElementChild
14
+ if (link instanceof HTMLAnchorElement) {
15
+ const focused = button.ownerDocument.activeElement === button
16
+ button.replaceWith(link)
17
+ if (focused) {
18
+ link.focus()
19
+ }
20
+ }
21
+ }
22
+
23
+ /**
24
+ * @param {import('./proposed-comment.js').ProposedComment} proposed
25
+ * @param {ReadonlyArray<import('./contract-types.js').ReviewComment>} posted
26
+ */
27
+ export function postedCommentUrl(proposed, posted) {
28
+ return posted.find(
29
+ c =>
30
+ c.inReplyToId === undefined &&
31
+ c.path === proposed.path &&
32
+ c.line === proposed.line &&
33
+ c.side === proposed.side &&
34
+ (c.startLine ?? c.line) === (proposed.startLine ?? proposed.line) &&
35
+ c.body === proposed.body
36
+ )?.url
37
+ }
@@ -0,0 +1,241 @@
1
+ // @ts-check
2
+ // The inline comment box. One markup for all three places it appears: on a diff line, under a
3
+ // thread as a reply, and in the overview for a PR-level comment.
4
+ /** @typedef {import('./contract-types.js').Side} Side */
5
+ /** @typedef {import('./contract-types.js').PostCommentInput} PostCommentInput */
6
+ import { cssEscape } from './anchors.js'
7
+ import { renderMarkdown } from './markdown.js'
8
+ import { esc } from './dom.js'
9
+
10
+ /**
11
+ * @typedef {{
12
+ * id: string,
13
+ * label: string,
14
+ * kind: 'inline' | 'reply' | 'issue',
15
+ * path?: string,
16
+ * line?: number,
17
+ * side?: Side,
18
+ * startLine?: number,
19
+ * inReplyToId?: number,
20
+ * pointFingerprint?: string,
21
+ * body?: string,
22
+ * }} ComposerOptions
23
+ */
24
+
25
+ /** @param {ComposerOptions} opts */
26
+ function dataAttributes(opts) {
27
+ const pairs = [
28
+ ['data-kind', opts.kind],
29
+ ['data-path', opts.path],
30
+ ['data-line', opts.line],
31
+ ['data-side', opts.side],
32
+ ['data-start-line', opts.startLine],
33
+ ['data-in-reply-to', opts.inReplyToId],
34
+ ['data-fingerprint', opts.pointFingerprint],
35
+ ]
36
+ return pairs
37
+ .filter(([, value]) => value !== undefined)
38
+ .map(([name, value]) => ` ${String(name)}="${esc(value)}"`)
39
+ .join('')
40
+ }
41
+
42
+ /**
43
+ * The box itself. The textarea carries the draft; the two commands act on the nearest
44
+ * `.composer-box` ancestor.
45
+ * @param {ComposerOptions} opts
46
+ */
47
+ export function composerHtml(opts) {
48
+ return (
49
+ `<div class="composer-box" id="${esc(opts.id)}"${dataAttributes(opts)}>` +
50
+ `<label class="sr" for="${esc(opts.id)}-t">${esc(opts.label)}</label>` +
51
+ previewControlsHtml() +
52
+ `<textarea id="${esc(opts.id)}-t" rows="3" placeholder="${esc(opts.label)}">${esc(opts.body ?? '')}</textarea>` +
53
+ '<div class="composer-actions"><button class="cmd fill" type="button" data-act="composer-post" data-needs-post>post to github</button>' +
54
+ '<button class="cmd" type="button" data-act="composer-cancel">cancel</button></div></div>'
55
+ )
56
+ }
57
+
58
+ /**
59
+ * The same box as a diff row, so it can sit under the line it comments on.
60
+ * @param {ComposerOptions} opts
61
+ */
62
+ export function composerRowHtml(opts) {
63
+ return (
64
+ `<tr class="composer" data-decoration="composer"><td class="ln" colspan="3"></td>` +
65
+ `<td class="code x">${composerHtml(opts)}</td></tr>`
66
+ )
67
+ }
68
+
69
+ /** The pending item shown while GitHub answers, replaced by the comment it returns. */
70
+ export function pendingCommentHtml() {
71
+ return '<div class="cmt pending" data-pending="1"><span class="who muted">posting…</span></div>'
72
+ }
73
+
74
+ /**
75
+ * The body a composer holds, trimmed. Empty means there is nothing to post.
76
+ * @param {Element} box
77
+ */
78
+ export function composerBody(box) {
79
+ const textarea = box.querySelector('textarea')
80
+ return textarea instanceof HTMLTextAreaElement ? textarea.value.trim() : ''
81
+ }
82
+
83
+ /**
84
+ * The request that posts what this composer holds.
85
+ * @param {Element} box
86
+ * @returns {PostCommentInput | null} null when the box is empty or misses its target
87
+ */
88
+ export function composerInput(box) {
89
+ const body = composerBody(box)
90
+ if (body === '') {
91
+ return null
92
+ }
93
+ const kind = box.getAttribute('data-kind')
94
+ if (kind === 'issue') {
95
+ return { kind: 'issue', body }
96
+ }
97
+ if (kind === 'reply') {
98
+ const inReplyToId = Number(box.getAttribute('data-in-reply-to'))
99
+ return Number.isInteger(inReplyToId) && inReplyToId > 0 ? { kind: 'reply', inReplyToId, body } : null
100
+ }
101
+ const path = box.getAttribute('data-path')
102
+ const line = Number(box.getAttribute('data-line'))
103
+ const side = box.getAttribute('data-side') === 'old' ? 'old' : 'new'
104
+ if (path === null || !Number.isInteger(line) || line <= 0) {
105
+ return null
106
+ }
107
+ /** @type {PostCommentInput} */
108
+ const input = { kind: 'inline', path, line, side, body }
109
+ const startLine = Number(box.getAttribute('data-start-line'))
110
+ if (Number.isInteger(startLine) && startLine > 0 && startLine !== line) {
111
+ input.startLine = startLine
112
+ }
113
+ const fingerprint = box.getAttribute('data-fingerprint')
114
+ if (fingerprint !== null && fingerprint !== '') {
115
+ input.pointFingerprint = fingerprint
116
+ }
117
+ return input
118
+ }
119
+
120
+ /**
121
+ * Puts the focus in the box that was just opened.
122
+ * @param {ParentNode} root
123
+ * @param {string} id
124
+ */
125
+ export function focusComposer(root, id) {
126
+ const textarea = root.querySelector(`#${cssEscape(id)}-t`)
127
+ if (textarea instanceof HTMLTextAreaElement) {
128
+ textarea.focus()
129
+ }
130
+ return textarea
131
+ }
132
+
133
+ /**
134
+ * Removes every open composer under `root`, so only one is open at a time.
135
+ * @param {ParentNode} root
136
+ */
137
+ export function closeComposers(root) {
138
+ let closed = 0
139
+ for (const box of Array.from(root.querySelectorAll('.composer-box'))) {
140
+ const row = box.closest('tr.composer')
141
+ ;(row ?? box).remove()
142
+ closed++
143
+ }
144
+ return closed
145
+ }
146
+
147
+ /** What a command says when this GitHub login may not post. */
148
+ export const NO_POSTING_TITLE = 'this GitHub login cannot post on this repository'
149
+
150
+ /**
151
+ * Disables everything that posts when the probe said no, and enables it otherwise. A token
152
+ * whose rights cannot be read ('unknown') stays enabled: GitHub answers for itself.
153
+ *
154
+ * A command can be disabled for a reason of its own (the approve command before every layer is
155
+ * read, a command whose request is still running). Those keep their state: this only adds and
156
+ * removes the reason it owns, which the element carries in `data-post-blocked`.
157
+ * @param {ParentNode} root
158
+ * @param {import('./contract-types.js').Capabilities} capabilities
159
+ */
160
+ export function applyCapabilityGating(root, capabilities) {
161
+ const blocked = capabilities.canComment === false
162
+ const reason = capabilities.reason ?? NO_POSTING_TITLE
163
+ for (const el of Array.from(root.querySelectorAll('[data-needs-post]'))) {
164
+ if (!(el instanceof HTMLButtonElement) || el.getAttribute('aria-busy') === 'true') {
165
+ continue
166
+ }
167
+ const own = el.getAttribute('data-disabled-reason')
168
+ if (blocked) {
169
+ el.toggleAttribute('data-post-blocked', true)
170
+ el.disabled = true
171
+ el.title = reason
172
+ } else {
173
+ el.removeAttribute('data-post-blocked')
174
+ el.disabled = own !== null
175
+ if (own === null) {
176
+ const tooltip = el.getAttribute('data-tooltip')
177
+ if (tooltip) {
178
+ el.title = tooltip
179
+ } else {
180
+ el.removeAttribute('title')
181
+ }
182
+ } else {
183
+ el.title = own
184
+ }
185
+ }
186
+ }
187
+ const note = root.querySelector('.capability-note')
188
+ if (note !== null) {
189
+ // A disabled command cannot take focus, so the reason is also said in plain sight.
190
+ note.textContent = blocked ? `Posting to GitHub is off: ${reason}` : ''
191
+ }
192
+ return blocked
193
+ }
194
+
195
+ /**
196
+ * Disables one command for a reason of its own, or lifts that reason. The capability state is
197
+ * untouched, so a command that both rules disable stays disabled until both allow it.
198
+ * @param {Element | null} el
199
+ * @param {string | null} reason null enables the command again
200
+ */
201
+ export function setDisabledReason(el, reason) {
202
+ if (!(el instanceof HTMLButtonElement)) {
203
+ return
204
+ }
205
+ if (reason === null) {
206
+ el.removeAttribute('data-disabled-reason')
207
+ if (!el.hasAttribute('data-post-blocked')) {
208
+ el.disabled = false
209
+ const tooltip = el.getAttribute('data-tooltip')
210
+ if (tooltip) {
211
+ el.title = tooltip
212
+ } else {
213
+ el.removeAttribute('title')
214
+ }
215
+ }
216
+ return
217
+ }
218
+ el.setAttribute('data-disabled-reason', reason)
219
+ el.disabled = true
220
+ if (!el.hasAttribute('data-post-blocked')) {
221
+ el.title = reason
222
+ }
223
+ }
224
+
225
+ export function previewControlsHtml() {
226
+ return '<div class="preview-controls" role="group" aria-label="Markdown editor"><button class="cmd" type="button" data-act="markdown-write" aria-pressed="true">Write</button><button class="cmd" type="button" data-act="markdown-preview" aria-pressed="false">Preview</button></div><div class="markdown-preview prose" hidden></div>'
227
+ }
228
+
229
+ /** @param {HTMLElement} button @param {boolean} preview */
230
+ export function toggleMarkdownPreview(button, preview) {
231
+ const host = button.closest('.composer-box, .signoff-dialog')
232
+ const textarea = host?.querySelector('textarea')
233
+ const output = host?.querySelector('.markdown-preview')
234
+ if (!(textarea instanceof HTMLTextAreaElement) || !(output instanceof HTMLElement)) return
235
+ if (preview) output.innerHTML = textarea.value.trim() ? renderMarkdown(textarea.value, { github: true }) : '<p class="muted">Nothing to preview.</p>'
236
+ textarea.hidden = preview
237
+ output.hidden = !preview
238
+ host?.querySelector('[data-act="markdown-write"]')?.setAttribute('aria-pressed', String(!preview))
239
+ host?.querySelector('[data-act="markdown-preview"]')?.setAttribute('aria-pressed', String(preview))
240
+ if (!preview) textarea.focus()
241
+ }
@@ -0,0 +1,59 @@
1
+ // Type-only bridge so the browser modules (JSDoc) see the same contract as the server.
2
+
3
+ export type {
4
+ CanvasRelation,
5
+ Capabilities,
6
+ ChatStatus,
7
+ ErrorEnvelope,
8
+ ImportResult,
9
+ PatchesResponse,
10
+ PostCommentResponse,
11
+ PostReviewResponse,
12
+ PrBundle,
13
+ ReviewBodyResponse,
14
+ ReviewSummary,
15
+ SharedCanvasFetchResponse,
16
+ SharedCanvasInfo,
17
+ StaleInfo,
18
+ StateResponse,
19
+ } from '../../src/contract/api.js'
20
+ export type {
21
+ ChatEvent,
22
+ ChatHistoryResponse,
23
+ ChatThreadsResponse,
24
+ ChatTurn,
25
+ } from '../../src/contract/chat.js'
26
+ export type {
27
+ CommentsPayload,
28
+ IssueComment,
29
+ PostCommentInput,
30
+ ReviewComment,
31
+ } from '../../src/contract/comments.js'
32
+ export type {
33
+ Annotation,
34
+ CodeFold,
35
+ Diagram,
36
+ FileEntry,
37
+ Hunk,
38
+ Layer,
39
+ LayerFile,
40
+ Point,
41
+ Pr,
42
+ ReviewArtifact,
43
+ RiskTag,
44
+ Side,
45
+ TestEntry,
46
+ } from '../../src/contract/review-artifact.js'
47
+ export type {
48
+ AgentAvailability,
49
+ AgentProbeResult,
50
+ AgentsResponse,
51
+ AppearanceInput,
52
+ AppearanceResponse,
53
+ ChatAgent,
54
+ Settings,
55
+ SettingsInput,
56
+ SettingsResponse,
57
+ Theme,
58
+ } from '../../src/contract/settings.js'
59
+ export type { PrState } from '../../src/contract/state.js'