@vintasoftware/pr-review-canvas 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +27 -8
  2. package/docs/reference.md +247 -62
  3. package/package.json +1 -1
  4. package/pr-review.config.example.yml +38 -4
  5. package/prompts/generation-format.md +120 -26
  6. package/prompts/generation-strict-incremental.md +53 -0
  7. package/prompts/generation-strict.md +1 -27
  8. package/prompts/generation-surfacing-incremental.md +56 -0
  9. package/prompts/generation-surfacing.md +1 -58
  10. package/prompts/judging-strict.md +27 -0
  11. package/prompts/judging-surfacing.md +58 -0
  12. package/skills/pr-review-canvas/SKILL.md +13 -4
  13. package/src/acpx/acpx.ts +98 -5
  14. package/src/acpx/models.ts +43 -0
  15. package/src/chat/chat-manager.ts +27 -1
  16. package/src/cli.ts +58 -1
  17. package/src/commands.ts +5 -1
  18. package/src/contract/api.ts +26 -1
  19. package/src/contract/canvas-manifest.ts +5 -0
  20. package/src/contract/generation-context.ts +52 -1
  21. package/src/contract/keys.ts +1 -0
  22. package/src/contract/pending.ts +49 -0
  23. package/src/contract/review-artifact.ts +50 -7
  24. package/src/contract/reviews.ts +10 -1
  25. package/src/contract/settings.ts +5 -0
  26. package/src/contract/state.ts +23 -12
  27. package/src/contract/validation.ts +1 -0
  28. package/src/github/post-review.ts +62 -6
  29. package/src/gitlab/post-review.ts +48 -9
  30. package/src/gitlab/publish-drafts.ts +69 -0
  31. package/src/host/client.ts +3 -2
  32. package/src/host/host.ts +23 -5
  33. package/src/project-config.ts +15 -2
  34. package/src/review/carry-marks.ts +131 -0
  35. package/src/review/doctor.ts +60 -26
  36. package/src/review/incremental.ts +107 -0
  37. package/src/review/normalize.ts +14 -4
  38. package/src/review/prepare.ts +47 -0
  39. package/src/review/prompt.ts +112 -5
  40. package/src/review/publish.ts +1 -0
  41. package/src/review/test-paths.ts +44 -4
  42. package/src/review/validate-folds.ts +348 -23
  43. package/src/review/validate.ts +10 -1
  44. package/src/server/bundle.ts +14 -2
  45. package/src/server/html.ts +4 -4
  46. package/src/server/routes/chat-routes.ts +15 -6
  47. package/src/server/routes/pages.ts +4 -1
  48. package/src/server/routes/review-routes.ts +202 -42
  49. package/src/store/canvas-store.ts +3 -0
  50. package/src/store/settings-store.ts +9 -1
  51. package/src/store/state-store.ts +69 -4
  52. package/src/upgrade.ts +338 -0
  53. package/static/js/api.js +55 -1
  54. package/static/js/app.js +28 -7
  55. package/static/js/chat-panel.js +32 -9
  56. package/static/js/chat.js +27 -4
  57. package/static/js/code-folds.js +171 -44
  58. package/static/js/composer.js +109 -4
  59. package/static/js/contract-types.d.ts +4 -0
  60. package/static/js/diff-decorations.js +67 -1
  61. package/static/js/empty-state.js +17 -0
  62. package/static/js/fold-levels.js +176 -0
  63. package/static/js/header.js +36 -9
  64. package/static/js/interactions.js +273 -44
  65. package/static/js/keyboard.js +4 -1
  66. package/static/js/keys.js +12 -0
  67. package/static/js/layers.js +292 -29
  68. package/static/js/nav.js +22 -4
  69. package/static/js/pending.js +161 -0
  70. package/static/js/points.js +69 -9
  71. package/static/js/progress.js +4 -5
  72. package/static/js/quick-questions.js +15 -2
  73. package/static/js/reading-level.js +97 -0
  74. package/static/js/review-session.js +106 -27
  75. package/static/js/settings.js +53 -23
  76. package/static/js/signoff.js +75 -5
  77. package/static/js/skin.js +2 -2
  78. package/static/styles/chat-panel.css +22 -24
  79. package/static/styles/chat.css +4 -0
  80. package/static/styles/header.css +21 -0
  81. package/static/styles/pending.css +102 -0
  82. package/static/styles/review.css +4 -0
  83. package/static/styles.css +1 -0
package/src/upgrade.ts ADDED
@@ -0,0 +1,338 @@
1
+ // `pr-review upgrade`: brings pr-review, acpx, and the project's copy of the skill up to date. It
2
+ // says what it will change and asks first. Once pr-review itself moves, the new version checks the
3
+ // skill, so the skill it ships is stamped by the code that ships it.
4
+ import { realpath } from 'node:fs/promises'
5
+ import path from 'node:path'
6
+ import { parseArgs } from 'node:util'
7
+ import { z } from 'zod'
8
+ import { type CliIo, EXIT, printJson, UsageError } from './commands.js'
9
+ import { findSkillCopies, type ReadSkill, type SkillCopy } from './review/doctor.js'
10
+ import { installSkill, SkillDirExistsError } from './review/install-skill.js'
11
+
12
+ export const ACPX_PACKAGE = 'acpx'
13
+
14
+ export interface CommandResult {
15
+ ok: boolean
16
+ stdout: string
17
+ stderr: string
18
+ }
19
+
20
+ export interface UpgradeDeps {
21
+ /** The running pr-review: its package name, version, and where it is installed. */
22
+ packageName: string
23
+ version: string
24
+ packageRoot: string
25
+ /** The repository whose skill copies are refreshed, or null outside one. */
26
+ repoRoot: string | null
27
+ acpxVersion: () => Promise<string | null>
28
+ /** The acpx that PATH runs, which chat uses. */
29
+ acpxPath: string | null
30
+ /** Runs one command without a shell. Only `npm` is run. */
31
+ run: (file: string, args: string[]) => Promise<CommandResult>
32
+ /** Runs `pr-review <args>` from the package on disk, which is the new one after its upgrade. */
33
+ runInstalled: (args: string[]) => Promise<CommandResult>
34
+ /** Asks a yes/no question, or returns null when there is no one to ask. */
35
+ confirm: (question: string) => Promise<boolean | null>
36
+ readSkill?: ReadSkill
37
+ }
38
+
39
+ /** What one step upgrades, in the order a plan runs them. */
40
+ const StepKindSchema = z.enum(['package', 'acpx', 'skill'])
41
+ type StepKind = z.infer<typeof StepKindSchema>
42
+
43
+ const NpmStepSchema = z.object({
44
+ kind: z.enum(['package', 'acpx']),
45
+ name: z.string(),
46
+ from: z.string(),
47
+ to: z.string(),
48
+ })
49
+ const SkillStepSchema = z.object({ kind: z.literal('skill'), paths: z.array(z.string()) })
50
+ const OutcomeFields = {
51
+ status: z.enum(['done', 'failed']),
52
+ detail: z.string().optional(),
53
+ }
54
+ const StepOutcomeSchema = z.discriminatedUnion('kind', [
55
+ NpmStepSchema.extend(OutcomeFields),
56
+ SkillStepSchema.extend(OutcomeFields),
57
+ ])
58
+ /** What an applied upgrade prints, and so what the parent reads back from the new version. */
59
+ const AppliedReportSchema = z.object({ steps: z.array(StepOutcomeSchema) })
60
+
61
+ export type UpgradeStep = z.infer<typeof NpmStepSchema> | z.infer<typeof SkillStepSchema>
62
+ export type StepOutcome = z.infer<typeof StepOutcomeSchema>
63
+
64
+ export interface UpgradePlan {
65
+ steps: UpgradeStep[]
66
+ /** What was looked at and left alone, and why. */
67
+ notes: string[]
68
+ }
69
+
70
+ /** `1.2.10` against `1.2.9`, numerically. A prerelease tag is ignored. */
71
+ export function isNewer(candidate: string, current: string): boolean {
72
+ const parts = (v: string): number[] => v.trim().replace(/^v/, '').split(/[-+]/)[0]!.split('.').map(Number)
73
+ const a = parts(candidate)
74
+ const b = parts(current)
75
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
76
+ const diff = (a[i] ?? 0) - (b[i] ?? 0)
77
+ if (Number.isNaN(diff)) return false
78
+ if (diff !== 0) return diff > 0
79
+ }
80
+ return false
81
+ }
82
+
83
+ async function latestVersion(deps: UpgradeDeps, name: string): Promise<string | null> {
84
+ const result = await deps.run('npm', ['view', name, 'version'])
85
+ const version = result.stdout.trim()
86
+ return result.ok && version !== '' ? version : null
87
+ }
88
+
89
+ /**
90
+ * Whether `installed` lies in the copy of `name` that `npm install -g` replaces. Installing over
91
+ * any other copy (pnpm, brew, another node) would leave the one in use as it was.
92
+ */
93
+ async function ownedByGlobalNpm(
94
+ globalRoot: string | null,
95
+ name: string,
96
+ installed: string
97
+ ): Promise<boolean> {
98
+ if (globalRoot === null) return false
99
+ try {
100
+ const globalCopy = await realpath(path.join(globalRoot, name))
101
+ const real = await realpath(installed)
102
+ return real === globalCopy || real.startsWith(`${globalCopy}${path.sep}`)
103
+ } catch {
104
+ return false
105
+ }
106
+ }
107
+
108
+ function notGlobalNote(name: string, latest: string, installed: string): string {
109
+ return `${name} ${latest} is out, but the copy in use (${installed}) is not the global npm install; update it the way you installed it`
110
+ }
111
+
112
+ async function skillCopies(deps: UpgradeDeps): Promise<SkillCopy[]> {
113
+ return deps.repoRoot === null ? [] : findSkillCopies(deps.repoRoot, deps.readSkill)
114
+ }
115
+
116
+ export async function planUpgrade(deps: UpgradeDeps): Promise<UpgradePlan> {
117
+ const steps: UpgradeStep[] = []
118
+ const notes: string[] = []
119
+
120
+ const root = await deps.run('npm', ['root', '-g'])
121
+ const globalRoot = root.ok ? root.stdout.trim() : null
122
+ const latest = await latestVersion(deps, deps.packageName)
123
+ if (latest === null) {
124
+ notes.push(`${deps.packageName}: could not read the latest version from npm`)
125
+ } else if (!isNewer(latest, deps.version)) {
126
+ notes.push(`${deps.packageName} ${deps.version} is up to date`)
127
+ } else if (await ownedByGlobalNpm(globalRoot, deps.packageName, deps.packageRoot)) {
128
+ steps.push({ kind: 'package', name: deps.packageName, from: deps.version, to: latest })
129
+ } else {
130
+ notes.push(notGlobalNote(deps.packageName, latest, deps.packageRoot))
131
+ }
132
+
133
+ const acpx = deps.acpxPath === null ? null : await deps.acpxVersion()
134
+ const acpxLatest = acpx === null ? null : await latestVersion(deps, ACPX_PACKAGE)
135
+ if (acpx === null || deps.acpxPath === null) {
136
+ notes.push('acpx is not installed; AI Chat needs it: npm install -g acpx@latest')
137
+ } else if (acpxLatest === null) {
138
+ notes.push('acpx: could not read the latest version from npm')
139
+ } else if (!isNewer(acpxLatest, acpx)) {
140
+ notes.push(`acpx ${acpx} is up to date`)
141
+ } else if (await ownedByGlobalNpm(globalRoot, ACPX_PACKAGE, deps.acpxPath)) {
142
+ steps.push({ kind: 'acpx', name: ACPX_PACKAGE, from: acpx, to: acpxLatest })
143
+ } else {
144
+ notes.push(notGlobalNote(ACPX_PACKAGE, acpxLatest, deps.acpxPath))
145
+ }
146
+
147
+ if (deps.repoRoot === null) {
148
+ notes.push('not in a repository, so no project skill to refresh')
149
+ } else {
150
+ const copies = await skillCopies(deps)
151
+ // A new pr-review can ship a new skill, so every copy is in question once the package moves.
152
+ const packageMoves = steps.some(step => step.kind === 'package')
153
+ const toCheck = copies.filter(copy => packageMoves || copy.stale)
154
+ if (copies.length === 0) {
155
+ notes.push('the project has no copy of the skill; run `pr-review install-skill` to add one')
156
+ } else if (toCheck.length > 0) {
157
+ steps.push({ kind: 'skill', paths: toCheck.map(copy => copy.path) })
158
+ } else {
159
+ notes.push(`the project skill matches pr-review ${deps.version}`)
160
+ }
161
+ }
162
+ return { steps, notes }
163
+ }
164
+
165
+ export function describeStep(step: UpgradeStep): string {
166
+ if (step.kind === 'skill') {
167
+ return `refresh the project skill where it differs from pr-review's: ${step.paths.join(', ')}`
168
+ }
169
+ return `upgrade ${step.name} ${step.from} -> ${step.to} (npm install -g ${step.name}@${step.to})`
170
+ }
171
+
172
+ async function applySkill(deps: UpgradeDeps): Promise<{ written: string[]; skipped: string[] }> {
173
+ const written: string[] = []
174
+ const skipped: string[] = []
175
+ for (const copy of await skillCopies(deps)) {
176
+ if (!copy.stale) continue
177
+ try {
178
+ await installSkill({ targets: [{ kind: copy.kind, dir: copy.dir }] })
179
+ written.push(copy.path)
180
+ } catch (err) {
181
+ if (!(err instanceof SkillDirExistsError)) throw err
182
+ skipped.push(copy.path)
183
+ }
184
+ }
185
+ return { written, skipped }
186
+ }
187
+
188
+ async function applySkillStep(
189
+ step: z.infer<typeof SkillStepSchema>,
190
+ deps: UpgradeDeps
191
+ ): Promise<StepOutcome> {
192
+ const { written, skipped } = await applySkill(deps)
193
+ if (skipped.length > 0) {
194
+ return {
195
+ ...step,
196
+ paths: written,
197
+ status: 'failed',
198
+ detail: `not a managed copy, left alone: ${skipped.join(', ')}; run \`pr-review install-skill --force\` to replace it`,
199
+ }
200
+ }
201
+ return { ...step, paths: written, status: 'done' }
202
+ }
203
+
204
+ /**
205
+ * The new pr-review's own `upgrade --yes --only <kinds>`: it plans again with its own code, and runs
206
+ * only the kinds of step the user confirmed. Its report's steps join this one's, so this process
207
+ * stays the one that prints them.
208
+ */
209
+ async function handOff(deps: UpgradeDeps, kinds: StepKind[]): Promise<StepOutcome[]> {
210
+ const result = await deps.runInstalled([
211
+ 'upgrade',
212
+ '--yes',
213
+ '--only',
214
+ kinds.join(','),
215
+ ...(deps.repoRoot === null ? [] : ['--repo', deps.repoRoot]),
216
+ ])
217
+ let report: unknown
218
+ try {
219
+ // The report is the last line; String() turns a missing one into text JSON.parse rejects.
220
+ report = JSON.parse(String(result.stdout.trim().split('\n').at(-1)))
221
+ } catch {
222
+ report = null
223
+ }
224
+ const parsed = AppliedReportSchema.safeParse(report)
225
+ if (parsed.success) return parsed.data.steps
226
+ return [
227
+ {
228
+ kind: 'skill',
229
+ paths: [],
230
+ status: 'failed',
231
+ detail:
232
+ `the new pr-review did not report a result; run \`pr-review upgrade\` again\n` +
233
+ result.stderr.trim().split('\n').slice(-3).join('\n'),
234
+ },
235
+ ]
236
+ }
237
+
238
+ /**
239
+ * Runs every step in order. A failed npm install does not stop the rest. Once pr-review itself is
240
+ * upgraded, the new version plans and runs whatever is left, so the skill it ships is checked and
241
+ * copied by its own code.
242
+ */
243
+ export async function applyUpgrade(plan: UpgradePlan, deps: UpgradeDeps): Promise<StepOutcome[]> {
244
+ const outcomes: StepOutcome[] = []
245
+ for (const [index, step] of plan.steps.entries()) {
246
+ if (step.kind === 'skill') {
247
+ outcomes.push(await applySkillStep(step, deps))
248
+ continue
249
+ }
250
+ const result = await deps.run('npm', ['install', '-g', `${step.name}@${step.to}`])
251
+ if (!result.ok) {
252
+ const detail = result.stderr.trim().split('\n').slice(-3).join('\n') || 'npm install failed'
253
+ outcomes.push({ ...step, status: 'failed', detail })
254
+ continue
255
+ }
256
+ outcomes.push({ ...step, status: 'done' })
257
+ const rest = plan.steps.slice(index + 1)
258
+ if (step.kind === 'package' && rest.length > 0) {
259
+ return [
260
+ ...outcomes,
261
+ ...(await handOff(
262
+ deps,
263
+ rest.map(later => later.kind)
264
+ )),
265
+ ]
266
+ }
267
+ }
268
+ return outcomes
269
+ }
270
+
271
+ /** `--only package,acpx,skill`: the kinds of step this run may take. */
272
+ function parseOnly(raw: string | undefined): ReadonlySet<StepKind> | null {
273
+ if (raw === undefined) return null
274
+ const kinds = z.array(StepKindSchema).safeParse(raw.split(','))
275
+ if (!kinds.success) {
276
+ throw new UsageError(`--only takes a comma list of ${StepKindSchema.options.join(', ')}; got "${raw}"`)
277
+ }
278
+ return new Set(kinds.data)
279
+ }
280
+
281
+ /**
282
+ * `upgrade [--yes] [--only <kinds>]`: the plan on stderr, a confirmation, then one JSON line with
283
+ * what happened.
284
+ */
285
+ export async function runUpgrade(deps: UpgradeDeps, argv: string[], io: CliIo): Promise<number> {
286
+ const { values } = parseArgs({
287
+ args: argv,
288
+ options: { yes: { type: 'boolean', short: 'y' }, only: { type: 'string' } },
289
+ strict: true,
290
+ })
291
+ const only = parseOnly(values.only)
292
+ const full = await planUpgrade(deps)
293
+ const plan: UpgradePlan =
294
+ only === null
295
+ ? full
296
+ : {
297
+ steps: full.steps.filter(step => only.has(step.kind)),
298
+ notes: [
299
+ ...full.notes,
300
+ ...full.steps
301
+ .filter(step => !only.has(step.kind))
302
+ .map(step => `left out by --only: ${describeStep(step)}`),
303
+ ],
304
+ }
305
+ for (const note of plan.notes) io.stderr(` ${note}`)
306
+ if (plan.steps.length === 0) {
307
+ io.stderr('Everything is up to date.')
308
+ printJson(io, { applied: false, steps: [], notes: plan.notes })
309
+ return EXIT.ok
310
+ }
311
+ io.stderr('pr-review upgrade will:')
312
+ for (const step of plan.steps) io.stderr(` - ${describeStep(step)}`)
313
+ if (plan.steps.some(step => step.kind === 'package')) {
314
+ io.stderr(' Once pr-review is upgraded, the new version checks and runs the steps after it.')
315
+ }
316
+
317
+ const confirmed = values.yes === true ? true : await deps.confirm('Proceed? [y/N] ')
318
+ if (confirmed !== true) {
319
+ io.stderr(confirmed === null ? 'Nothing changed. Re-run with --yes to apply.' : 'Nothing changed.')
320
+ printJson(io, { applied: false, steps: plan.steps, notes: plan.notes })
321
+ return EXIT.ok
322
+ }
323
+
324
+ const outcomes = await applyUpgrade(plan, deps)
325
+ for (const outcome of outcomes) {
326
+ io.stderr(
327
+ ` ${outcome.status}: ${describeStep(outcome)}${outcome.detail ? `\n ${outcome.detail}` : ''}`
328
+ )
329
+ }
330
+ // A skill step lists only the copies it wrote, failed or not.
331
+ const refreshed = outcomes.flatMap(o => (o.kind === 'skill' ? o.paths : []))
332
+ if (refreshed.length > 0) {
333
+ io.stderr(`The project skill changed. Commit and push ${refreshed.join(' and ')} so your team gets it.`)
334
+ }
335
+ const ok = outcomes.every(o => o.status !== 'failed')
336
+ printJson(io, { applied: true, ok, steps: outcomes, notes: plan.notes })
337
+ return ok ? EXIT.ok : EXIT.error
338
+ }
package/static/js/api.js CHANGED
@@ -176,6 +176,60 @@ export function postComment(prNumber, input, opts = {}) {
176
176
  })
177
177
  }
178
178
 
179
+ /**
180
+ * Adds one comment to the pending review. Nothing reaches the forge: it is kept in the local
181
+ * state until the review is submitted.
182
+ * @param {ReviewKey} prNumber
183
+ * @param {import('./contract-types.js').AddPendingInput} input
184
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
185
+ * @returns {Promise<import('./contract-types.js').StateResponse>}
186
+ */
187
+ export function addPending(prNumber, input, opts = {}) {
188
+ return fetchJson(`/api/prs/${prNumber}/pending`, {
189
+ method: 'POST',
190
+ body: input,
191
+ fetchImpl: opts.fetchImpl,
192
+ })
193
+ }
194
+
195
+ /**
196
+ * @param {ReviewKey} prNumber
197
+ * @param {string} id
198
+ * @param {string} body
199
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
200
+ * @returns {Promise<import('./contract-types.js').StateResponse>}
201
+ */
202
+ export function editPending(prNumber, id, body, opts = {}) {
203
+ return fetchJson(`/api/prs/${prNumber}/pending/${encodeURIComponent(id)}`, {
204
+ method: 'PATCH',
205
+ body: { body },
206
+ fetchImpl: opts.fetchImpl,
207
+ })
208
+ }
209
+
210
+ /**
211
+ * @param {ReviewKey} prNumber
212
+ * @param {string} id
213
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
214
+ * @returns {Promise<import('./contract-types.js').StateResponse>}
215
+ */
216
+ export function deletePending(prNumber, id, opts = {}) {
217
+ return fetchJson(`/api/prs/${prNumber}/pending/${encodeURIComponent(id)}`, {
218
+ method: 'DELETE',
219
+ fetchImpl: opts.fetchImpl,
220
+ })
221
+ }
222
+
223
+ /**
224
+ * Throws the whole pending review away.
225
+ * @param {ReviewKey} prNumber
226
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
227
+ * @returns {Promise<import('./contract-types.js').StateResponse>}
228
+ */
229
+ export function discardPending(prNumber, opts = {}) {
230
+ return fetchJson(`/api/prs/${prNumber}/pending`, { method: 'DELETE', fetchImpl: opts.fetchImpl })
231
+ }
232
+
179
233
  /**
180
234
  * @param {ReviewKey} prNumber
181
235
  * @param {{ fetchImpl?: typeof fetch }} [opts]
@@ -187,7 +241,7 @@ export function fetchReviewBody(prNumber, opts = {}) {
187
241
 
188
242
  /**
189
243
  * @param {ReviewKey} prNumber
190
- * @param {{ event: 'APPROVE' | 'REQUEST_CHANGES', body?: string, headSha?: string }} input
244
+ * @param {{ event: import('./contract-types.js').ReviewEvent, body?: string, headSha?: string, includePending?: boolean }} input
191
245
  * @param {{ fetchImpl?: typeof fetch }} [opts]
192
246
  * @returns {Promise<import('./contract-types.js').PostReviewResponse>}
193
247
  */
package/static/js/app.js CHANGED
@@ -13,30 +13,43 @@ import {
13
13
  saveAppearance,
14
14
  } from './api.js'
15
15
  import { setChatEnabled } from './ask.js'
16
- import { readChatWidth, renderChatShell, wireChat } from './chat.js'
16
+ import { readChatMinimized, readChatWidth, renderChatShell, wireChat } from './chat.js'
17
17
  import { runCommand, wireCopyCommands } from './commands.js'
18
18
  import { initDeepLinks } from './deep-link.js'
19
19
  import { initDiagrams } from './diagram.js'
20
20
  import { esc, qs } from './dom.js'
21
21
  import { exportCanvasZip } from './download.js'
22
- import { carriedOverBarHtml, renderEmptyState, renderStaleState, staleBarHtml } from './empty-state.js'
22
+ import {
23
+ carriedOverBarHtml,
24
+ marksCarriedBarHtml,
25
+ renderEmptyState,
26
+ renderStaleState,
27
+ staleBarHtml,
28
+ } from './empty-state.js'
23
29
  import { errorCardHtml } from './errors.js'
24
30
  import { renderHeader } from './header.js'
25
31
  import { wireDropZone } from './import-zone.js'
26
32
  import { toast, wireReview } from './interactions.js'
27
- import { defineLayerElements, pathSet, renderLayers, renderRail, setRenderContext } from './layers.js'
33
+ import {
34
+ defineLayerElements,
35
+ pathSet,
36
+ renderLayers,
37
+ renderRail,
38
+ setFoldLevel,
39
+ setRenderContext,
40
+ } from './layers.js'
28
41
  import { renderOverview } from './overview.js'
29
42
  import { wireQuickQuestions } from './quick-questions.js'
30
43
  import { canvasChanged, openRegenerateDialog } from './regenerate.js'
31
44
  import { createReviewSession } from './review-session.js'
32
45
  import { initScrollSpy } from './scroll-spy.js'
33
46
  import { openSettingsDialog } from './settings.js'
34
- import { applySkin, nextSkin, readSkin, skinLabel } from './skin.js'
47
+ import { applySkin, DEFAULT_SKIN, nextSkin, readSkin, skinLabel } from './skin.js'
35
48
  import { applyTheme, nextTheme, readTheme, themeLabel } from './theme.js'
36
49
  import { hostLabel, setHost } from './host.js'
37
50
 
38
51
  /** @typedef {import('./contract-types.js').ReviewKey} ReviewKey */
39
- /** @typedef {{ prNumber: ReviewKey, owner: string, repo: string, version: string, host: import('./contract-types.js').PublicHost }} Bootstrap */
52
+ /** @typedef {import('./contract-types.js').ReviewBootstrap} Bootstrap */
40
53
 
41
54
  /** @returns {Bootstrap | null} */
42
55
  function readBootstrap() {
@@ -109,7 +122,7 @@ export class PrAppElement extends HTMLElement {
109
122
  /** @type {import('./theme.js').Theme} */
110
123
  theme = 'auto'
111
124
  /** @type {import('./skin.js').Skin} */
112
- skin = 'terminal'
125
+ skin = DEFAULT_SKIN
113
126
  /** @type {{ stop: () => void } | null} */
114
127
  diagrams = null
115
128
  /** @type {{ stop: () => void } | null} */
@@ -160,6 +173,9 @@ export class PrAppElement extends HTMLElement {
160
173
  setHost(this.bootstrap.host)
161
174
  this.theme = readTheme(document.documentElement)
162
175
  this.skin = readSkin(document.documentElement)
176
+ // The reading level the canvas opens at comes from the settings file with the page, so the
177
+ // first draw hides what the reader asked for.
178
+ setFoldLevel(this, this.bootstrap.foldLevel)
163
179
  const patchesPromise = fetchPatches(this.bootstrap.prNumber).then(
164
180
  r => r.patches,
165
181
  () => null
@@ -199,6 +215,7 @@ export class PrAppElement extends HTMLElement {
199
215
  const header = renderHeader(bundle, { host: location.host, theme: this.theme, skin: this.skin, now })
200
216
  const chatEnabled = bundle.chat.enabled
201
217
  const storage = typeof localStorage === 'undefined' ? null : localStorage
218
+ const chatMinimized = chatEnabled && readChatMinimized(storage)
202
219
  // The renderers read this while they build the cards, so it is set before the first one.
203
220
  setChatEnabled(chatEnabled)
204
221
  const showsCanvas = bundle.artifact !== undefined && (bundle.status === 'ready' || this.viewStale)
@@ -218,10 +235,12 @@ export class PrAppElement extends HTMLElement {
218
235
  staleSha
219
236
  )
220
237
  // The context is set before any <pr-file> connects, so each card renders once, when visible.
238
+ const headSha = staleSha ?? bundle.pr.headSha
221
239
  setRenderContext({
222
240
  artifact,
223
241
  files,
224
242
  patches,
243
+ headSha,
225
244
  comments: bundle.comments.reviewComments,
226
245
  state: bundle.state,
227
246
  now,
@@ -233,10 +252,12 @@ export class PrAppElement extends HTMLElement {
233
252
  : bundle.carriedOver
234
253
  ? carriedOverBarHtml(bundle.carriedOver)
235
254
  : ''
255
+ const marksBar =
256
+ bundle.marksCarriedFrom === undefined ? '' : marksCarriedBarHtml(bundle.marksCarriedFrom)
236
257
  this.innerHTML =
237
258
  header +
238
259
  bannerHtml(bundle.warnings) +
239
- `<div class="layout${chatEnabled ? '' : ' no-chat'}">${renderRail(artifact, bundle.state)}<main id="main">${staleBar}${renderOverview(bundle, { paths, now })}${renderLayers(artifact, files, bundle.state, bundle.comments.reviewComments)}</main>${renderChatShell({ enabled: chatEnabled, width: readChatWidth(storage) })}</div>` +
260
+ `<div class="layout${chatEnabled && !chatMinimized ? '' : ' no-chat'}">${renderRail(artifact, bundle.state)}<main id="main">${staleBar}${marksBar}${renderOverview(bundle, { paths, now })}${renderLayers(artifact, files, bundle.state, bundle.comments.reviewComments, headSha)}</main>${renderChatShell({ enabled: chatEnabled, width: readChatWidth(storage), minimized: chatMinimized })}</div>` +
240
261
  footerHtml(boot.version, bundle)
241
262
  // The screen is interactive from here: reviewed state, dismissals, threads, and posting.
242
263
  // A stale canvas shows the diff of an older commit, so nothing is posted from it: a line
@@ -1,42 +1,65 @@
1
1
  // @ts-check
2
2
 
3
3
  /** Keeps the same chat mounted while switching between the sidebar and floating panel.
4
+ *
5
+ * The chat minimizes at every width. Narrow screens float it over the canvas, so minimizing
6
+ * closes the dialog; wide screens dock it in the layout grid, so minimizing drops the column.
7
+ * Either way the launcher brings back the same pane with its draft and transcript intact.
8
+ *
4
9
  * @param {HTMLElement} root
5
10
  * @param {HTMLElement} pane
11
+ * @param {{ minimized?: boolean, onMinimizedChange?: (minimized: boolean) => void }} [opts]
6
12
  */
7
- export function wireChatPanel(root, pane) {
13
+ export function wireChatPanel(root, pane, opts = {}) {
8
14
  const dialog = /** @type {HTMLDialogElement} */ (root.querySelector('#chat-dialog'))
9
15
  const launcher = /** @type {HTMLButtonElement} */ (root.querySelector('#chat-launcher'))
10
16
  const minimize = /** @type {HTMLButtonElement} */ (root.querySelector('#chat-minimize'))
17
+ const layout = root.querySelector('.layout')
11
18
  const narrow = window.matchMedia('(max-width: 1360px)')
12
19
  const mobile = window.matchMedia('(max-width: 600px)')
13
- let expanded = false
20
+ /** Floating panels start minimized; a docked one starts the way the reader last left it. */
21
+ let floating = false
22
+ let docked = !opts.minimized
23
+
24
+ const shown = () => (narrow.matches ? floating : docked)
14
25
 
15
26
  function sync() {
16
27
  if (dialog.open) dialog.close()
17
28
  if (narrow.matches) {
18
29
  dialog.append(pane)
19
- if (expanded) {
30
+ pane.hidden = false
31
+ if (floating) {
20
32
  if (mobile.matches) dialog.showModal()
21
33
  else dialog.show()
22
34
  }
23
35
  } else {
24
36
  dialog.before(pane)
37
+ pane.hidden = !docked
25
38
  }
26
- launcher.setAttribute('aria-expanded', String(narrow.matches && expanded))
27
- launcher.hidden = narrow.matches && expanded
39
+ layout?.classList.toggle('no-chat', !narrow.matches && !docked)
40
+ launcher.setAttribute('aria-expanded', String(shown()))
41
+ launcher.hidden = shown()
28
42
  }
29
43
 
30
44
  function open() {
31
- if (narrow.matches && !expanded) {
32
- expanded = true
45
+ if (!shown()) {
46
+ if (narrow.matches) floating = true
47
+ else {
48
+ docked = true
49
+ opts.onMinimizedChange?.(false)
50
+ }
33
51
  sync()
34
52
  }
35
- pane.querySelector('textarea')?.focus()
53
+ // The composer sits low in a tall sticky pane, so plain focus() would scroll the canvas to it.
54
+ pane.querySelector('textarea')?.focus({ preventScroll: true })
36
55
  }
37
56
 
38
57
  function close() {
39
- expanded = false
58
+ if (narrow.matches) floating = false
59
+ else {
60
+ docked = false
61
+ opts.onMinimizedChange?.(true)
62
+ }
40
63
  sync()
41
64
  launcher.focus()
42
65
  }
package/static/js/chat.js CHANGED
@@ -27,12 +27,13 @@ import { postToLabel } from './host.js'
27
27
  import { splitChatAnswer, targetsFromFiles } from './proposed-comment.js'
28
28
 
29
29
  export const CHAT_WIDTH_KEY = 'pr-review.chat-width'
30
+ export const CHAT_MINIMIZED_KEY = 'pr-review.chat-minimized'
30
31
  export const CHAT_WIDTH_MIN = 280
31
32
  export const CHAT_WIDTH_MAX = 560
32
33
  export const CHAT_WIDTH_DEFAULT = 340
33
34
 
34
35
  /**
35
- * @param {{ enabled: boolean, width?: number }} opts
36
+ * @param {{ enabled: boolean, width?: number, minimized?: boolean }} opts
36
37
  * @returns {string} '' when AI Chat is disabled
37
38
  */
38
39
  export function renderChatShell(opts) {
@@ -40,8 +41,10 @@ export function renderChatShell(opts) {
40
41
  return ''
41
42
  }
42
43
  const width = clampWidth(opts.width ?? CHAT_WIDTH_DEFAULT)
44
+ // The launcher starts hidden and `wireChatPanel` reveals it where it belongs, so a docked
45
+ // chat never paints a launcher over itself on the first frame.
43
46
  return (
44
- '<aside class="chat" aria-labelledby="chat-h">' +
47
+ `<aside class="chat" aria-labelledby="chat-h"${opts.minimized ? ' hidden' : ''}>` +
45
48
  `<button class="handle" type="button" id="chat-handle" role="separator" aria-orientation="vertical" aria-label="Resize AI Chat" aria-valuenow="${width}" aria-valuemin="${CHAT_WIDTH_MIN}" aria-valuemax="${CHAT_WIDTH_MAX}"></button>` +
46
49
  '<div class="chat-h"><h2 id="chat-h">AI Chat</h2><label class="sr" for="thread">Thread</label>' +
47
50
  '<select id="thread"></select>' +
@@ -60,7 +63,7 @@ export function renderChatShell(opts) {
60
63
  '<span class="muted small">enter to send</span></div></form>' +
61
64
  '</aside>' +
62
65
  '<dialog class="chat-dialog" id="chat-dialog" aria-labelledby="chat-h"></dialog>' +
63
- '<button class="chat-launcher" id="chat-launcher" type="button" aria-controls="chat-dialog" aria-expanded="false">AI Chat</button>'
66
+ '<button class="chat-launcher" id="chat-launcher" type="button" hidden aria-controls="chat-dialog" aria-expanded="false">AI Chat</button>'
64
67
  )
65
68
  }
66
69
 
@@ -102,6 +105,23 @@ export function writeChatWidth(storage, width) {
102
105
  storage?.setItem(CHAT_WIDTH_KEY, String(clampWidth(width)))
103
106
  }
104
107
 
108
+ /**
109
+ * Only a wide screen stores this: a floating chat always starts minimized.
110
+ * @param {Storage | null} storage
111
+ * @returns {boolean}
112
+ */
113
+ export function readChatMinimized(storage) {
114
+ return (storage === null ? null : storage.getItem(CHAT_MINIMIZED_KEY)) === '1'
115
+ }
116
+
117
+ /**
118
+ * @param {Storage | null} storage
119
+ * @param {boolean} minimized
120
+ */
121
+ export function writeChatMinimized(storage, minimized) {
122
+ storage?.setItem(CHAT_MINIMIZED_KEY, minimized ? '1' : '0')
123
+ }
124
+
105
125
  /** The questions the quick menu offers, and the one that just focuses the box. */
106
126
  export const QUICK_QUESTIONS = [
107
127
  'Suggestion to solve this?',
@@ -749,7 +769,10 @@ export function wireChat(options) {
749
769
  }
750
770
 
751
771
  const stopResize = wireResize(pane, root, storage, applyWidth)
752
- const panel = wireChatPanel(root, pane)
772
+ const panel = wireChatPanel(root, pane, {
773
+ minimized: readChatMinimized(storage),
774
+ onMinimizedChange: minimized => writeChatMinimized(storage, minimized),
775
+ })
753
776
 
754
777
  form.addEventListener('submit', onSubmit)
755
778
  root.addEventListener('click', onClick)