@vintasoftware/pr-review-canvas 0.3.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 (137) hide show
  1. package/README.md +79 -31
  2. package/docs/reference.md +396 -101
  3. package/package.json +9 -4
  4. package/pr-review.config.example.yml +44 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +123 -26
  7. package/prompts/generation-strict-incremental.md +53 -0
  8. package/prompts/generation-strict.md +1 -27
  9. package/prompts/generation-surfacing-incremental.md +56 -0
  10. package/prompts/generation-surfacing.md +1 -58
  11. package/prompts/judging-strict.md +27 -0
  12. package/prompts/judging-surfacing.md +58 -0
  13. package/skills/pr-review-canvas/SKILL.md +81 -42
  14. package/src/acpx/acpx.ts +98 -5
  15. package/src/acpx/models.ts +43 -0
  16. package/src/canvas/comment.ts +24 -0
  17. package/src/canvas/import.ts +30 -9
  18. package/src/canvas/name.ts +1 -0
  19. package/src/canvas/zip.ts +21 -1
  20. package/src/chat/chat-manager.ts +67 -40
  21. package/src/chat/threads.ts +27 -26
  22. package/src/cli.ts +70 -8
  23. package/src/commands.ts +50 -17
  24. package/src/config.ts +14 -26
  25. package/src/contract/api.ts +58 -2
  26. package/src/contract/canvas-manifest.ts +7 -0
  27. package/src/contract/comments.ts +5 -0
  28. package/src/contract/discovery.ts +5 -2
  29. package/src/contract/generation-context.ts +76 -2
  30. package/src/contract/keys.ts +1 -0
  31. package/src/contract/pending.ts +49 -0
  32. package/src/contract/review-artifact.ts +50 -7
  33. package/src/contract/review-key.ts +51 -0
  34. package/src/contract/reviews.ts +26 -0
  35. package/src/contract/settings.ts +7 -0
  36. package/src/contract/state.ts +53 -22
  37. package/src/contract/validation.ts +1 -0
  38. package/src/git/environment.mjs +27 -0
  39. package/src/git/git.ts +109 -9
  40. package/src/git/local-target.ts +138 -0
  41. package/src/git/patch-lines.ts +34 -2
  42. package/src/git/pr-refs.ts +36 -0
  43. package/src/github/attachments.ts +9 -257
  44. package/src/github/canvas-comment.ts +22 -0
  45. package/src/github/capabilities.ts +3 -41
  46. package/src/github/comments.ts +3 -24
  47. package/src/github/post-comment.ts +3 -36
  48. package/src/github/post-review.ts +64 -23
  49. package/src/github/pr.ts +6 -87
  50. package/src/github/threads.ts +2 -2
  51. package/src/gitlab/attachments.ts +40 -0
  52. package/src/gitlab/canvas-comment.ts +26 -0
  53. package/src/gitlab/capabilities.ts +64 -0
  54. package/src/gitlab/comments.ts +164 -0
  55. package/src/gitlab/mr.ts +115 -0
  56. package/src/gitlab/post-comment.ts +111 -0
  57. package/src/gitlab/post-review.ts +93 -0
  58. package/src/gitlab/project.ts +13 -0
  59. package/src/gitlab/publish-drafts.ts +69 -0
  60. package/src/host/attachments.ts +293 -0
  61. package/src/host/capabilities.ts +38 -0
  62. package/src/host/client.ts +246 -0
  63. package/src/host/host.ts +154 -0
  64. package/src/host/pr.ts +51 -0
  65. package/src/host/remote.ts +42 -0
  66. package/src/project-config.ts +26 -0
  67. package/src/review/carry-marks.ts +131 -0
  68. package/src/review/carry-over.ts +79 -0
  69. package/src/review/doctor.ts +82 -38
  70. package/src/review/incremental.ts +107 -0
  71. package/src/review/normalize.ts +14 -4
  72. package/src/review/prepare.ts +111 -10
  73. package/src/review/prompt.ts +112 -5
  74. package/src/review/publish.ts +62 -10
  75. package/src/{github → review}/review-body.ts +17 -5
  76. package/src/review/skill-command.ts +5 -3
  77. package/src/review/test-paths.ts +44 -4
  78. package/src/review/validate-folds.ts +349 -24
  79. package/src/review/validate.ts +14 -5
  80. package/src/server/bundle.ts +324 -111
  81. package/src/server/context.ts +10 -8
  82. package/src/server/errors.ts +30 -8
  83. package/src/server/html.ts +35 -11
  84. package/src/server/routes/api.ts +63 -26
  85. package/src/server/routes/chat-routes.ts +91 -44
  86. package/src/server/routes/pages.ts +26 -9
  87. package/src/server/routes/review-routes.ts +278 -67
  88. package/src/store/canvas-store.ts +93 -55
  89. package/src/store/data-dir.ts +2 -1
  90. package/src/store/derived-store.ts +41 -27
  91. package/src/store/pr-store.ts +23 -14
  92. package/src/store/settings-store.ts +9 -1
  93. package/src/store/state-store.ts +105 -36
  94. package/src/upgrade.ts +338 -0
  95. package/static/js/api.js +87 -25
  96. package/static/js/app.js +50 -15
  97. package/static/js/chat-panel.js +32 -9
  98. package/static/js/chat.js +30 -6
  99. package/static/js/code-folds.js +171 -44
  100. package/static/js/composer.js +132 -17
  101. package/static/js/contract-types.d.ts +7 -0
  102. package/static/js/diff-decorations.js +67 -1
  103. package/static/js/diff-renderer.js +1 -1
  104. package/static/js/download.js +1 -1
  105. package/static/js/empty-state.js +102 -18
  106. package/static/js/errors.js +22 -6
  107. package/static/js/fold-levels.js +176 -0
  108. package/static/js/header.js +65 -15
  109. package/static/js/host.js +40 -0
  110. package/static/js/import-zone.js +1 -1
  111. package/static/js/interactions.js +274 -46
  112. package/static/js/keyboard.js +4 -1
  113. package/static/js/keys.js +12 -0
  114. package/static/js/layers.js +295 -32
  115. package/static/js/links.js +3 -3
  116. package/static/js/markdown.js +28 -1
  117. package/static/js/nav.js +22 -4
  118. package/static/js/pending.js +161 -0
  119. package/static/js/points.js +70 -9
  120. package/static/js/progress.js +4 -5
  121. package/static/js/quick-questions.js +15 -2
  122. package/static/js/reading-level.js +97 -0
  123. package/static/js/review-session.js +113 -29
  124. package/static/js/settings.js +55 -24
  125. package/static/js/signoff.js +79 -10
  126. package/static/js/skin.js +2 -2
  127. package/static/styles/chat-panel.css +22 -24
  128. package/static/styles/chat.css +4 -0
  129. package/static/styles/commands.css +6 -0
  130. package/static/styles/header.css +21 -0
  131. package/static/styles/panels.css +4 -0
  132. package/static/styles/pending.css +102 -0
  133. package/static/styles/review-actions.css +1 -0
  134. package/static/styles/review.css +4 -0
  135. package/static/styles/skin-github.css +7 -1
  136. package/static/styles.css +1 -0
  137. package/src/github/gh.ts +0 -211
@@ -1,18 +1,24 @@
1
1
  // @ts-check
2
- // The settings dialog: the personal chat settings this browser can change, and a read-only look
3
- // at the project config, which is committed and belongs to the repository.
2
+ // The settings dialog: the personal settings this browser can change, the reading level a review
3
+ // opens at and the chat settings, and a read-only look at the project config, which is committed
4
+ // and belongs to the repository.
4
5
  /** @typedef {import('./contract-types.js').AgentsResponse} AgentsResponse */
5
6
  /** @typedef {import('./contract-types.js').SettingsResponse} SettingsResponse */
6
7
  import { fetchAgents, fetchSettings, probeAgent, saveSettings } from './api.js'
7
8
  import { runCommand } from './commands.js'
8
9
  import { esc, qs } from './dom.js'
10
+ import { isFoldLevel } from './fold-levels.js'
11
+ import { foldLevelOptionsHtml } from './reading-level.js'
9
12
 
10
13
  export const SETTINGS_DIALOG_ID = 'settings-dialog'
11
14
 
12
- /** Model ids the input suggests per agent. Free text is allowed; this is only a shortcut. */
15
+ /**
16
+ * Model ids the input suggests per agent. Free text is allowed; this is only a shortcut. The
17
+ * server runs the newest model of whichever family is saved, so these do not go stale.
18
+ */
13
19
  export const MODEL_SUGGESTIONS = {
14
- claude: ['claude-opus-5', 'claude-sonnet-5', 'claude-haiku-4-5-20251001'],
15
- codex: ['gpt-5.2', 'gpt-5.2[high]'],
20
+ claude: ['opus', 'opus[1m]', 'sonnet', 'haiku', 'fable'],
21
+ codex: ['gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna', 'gpt-6-astra[high]'],
16
22
  }
17
23
 
18
24
  /** @param {string} agent */
@@ -22,18 +28,43 @@ export function modelOptionsHtml(agent) {
22
28
  }
23
29
 
24
30
  /**
25
- * @param {SettingsResponse} data
31
+ * The chat agent's fields, with the notice about acpx when it is missing.
32
+ * @param {SettingsResponse['settings']} settings
26
33
  * @param {AgentsResponse} agents
27
34
  * @returns {string}
28
35
  */
29
- export function settingsDialogHtml(data, agents) {
30
- const { settings, overrides, project } = data
36
+ function chatFieldsHtml(settings, agents) {
31
37
  const options = agents.agents
32
38
  .map(a => {
33
39
  const reason = a.available ? '' : ` (${a.reason ?? 'not available'})`
34
40
  return `<option value="${esc(a.id)}"${a.id === settings.agent ? ' selected' : ''}${a.available ? '' : ' disabled'}>${esc(a.id)}${esc(reason)}</option>`
35
41
  })
36
42
  .join('')
43
+ return (
44
+ (agents.acpx.installed
45
+ ? ''
46
+ : '<p class="notice" role="status">acpx is not on PATH, so AI Chat is off. Install acpx and reload.</p>') +
47
+ '<div class="field"><label for="set-agent">Agent</label>' +
48
+ `<select id="set-agent">${options}</select></div>` +
49
+ '<div class="field"><label for="set-model">Model</label>' +
50
+ `<input id="set-model" list="model-list" value="${esc(settings.model ?? '')}" placeholder="the agent's default">` +
51
+ `<datalist id="model-list">${modelOptionsHtml(settings.agent)}</datalist></div>` +
52
+ '<div class="field"><label for="set-timeout">Chat timeout (seconds)</label>' +
53
+ `<input id="set-timeout" type="number" min="30" max="3600" value="${esc(settings.chatTimeoutSec)}"></div>` +
54
+ '<div class="field"><label for="set-turns">Max turns</label>' +
55
+ `<input id="set-turns" type="number" min="1" max="100" value="${esc(settings.maxTurns ?? '')}" placeholder="the agent's default"></div>` +
56
+ '<p class="muted small">Changing the agent starts a new chat thread; the old ones stay in the list.</p>'
57
+ )
58
+ }
59
+
60
+ /**
61
+ * @param {SettingsResponse} data
62
+ * @param {AgentsResponse | null} agents null when the project turns chat off, so the dialog holds
63
+ * only what the page itself reads
64
+ * @returns {string}
65
+ */
66
+ export function settingsDialogHtml(data, agents) {
67
+ const { settings, overrides, project } = data
37
68
  const overrideNote =
38
69
  overrides.agent === undefined && overrides.model === undefined
39
70
  ? ''
@@ -49,32 +80,26 @@ export function settingsDialogHtml(data, agents) {
49
80
  `<dialog id="${SETTINGS_DIALOG_ID}" class="settings" aria-labelledby="settings-h">` +
50
81
  '<h2 id="settings-h">Settings</h2>' +
51
82
  overrideNote +
52
- (agents.acpx.installed
53
- ? ''
54
- : '<p class="notice" role="status">acpx is not on PATH, so AI Chat is off. Install acpx and reload.</p>') +
55
- '<div class="field"><label for="set-agent">Agent</label>' +
56
- `<select id="set-agent">${options}</select></div>` +
57
- '<div class="field"><label for="set-model">Model</label>' +
58
- `<input id="set-model" list="model-list" value="${esc(settings.model ?? '')}" placeholder="the agent's default">` +
59
- `<datalist id="model-list">${modelOptionsHtml(settings.agent)}</datalist></div>` +
60
- '<div class="field"><label for="set-timeout">Chat timeout (seconds)</label>' +
61
- `<input id="set-timeout" type="number" min="30" max="3600" value="${esc(settings.chatTimeoutSec)}"></div>` +
62
- '<div class="field"><label for="set-turns">Max turns</label>' +
63
- `<input id="set-turns" type="number" min="1" max="100" value="${esc(settings.maxTurns ?? '')}" placeholder="the agent's default"></div>` +
64
- '<p class="muted small">Changing the agent starts a new chat thread; the old ones stay in the list.</p>' +
83
+ '<div class="field"><label for="set-fold-level">Hide code by default</label>' +
84
+ `<select id="set-fold-level">${foldLevelOptionsHtml(settings.foldLevel)}</select></div>` +
85
+ '<p class="muted small">The level every review opens at. The Hide code control and the <span class="mono">f</span> key change it for one page.</p>' +
86
+ (agents === null ? '' : chatFieldsHtml(settings, agents)) +
65
87
  `<p class="muted small mono">${esc(data.file)}</p>` +
66
88
  '<div class="panel-ro"><h3>Project config (read-only)</h3>' +
67
89
  `<ul class="plain"><li>chat enabled: ${project.chatEnabled ? 'yes' : 'no'}</li>` +
90
+ `<li>canvas kept for an identical diff: ${project.keepForIdenticalDiff ? 'yes' : 'no'}</li>` +
68
91
  `<li>rulebook: ${esc(project.rulebook ?? 'none')}</li>` +
69
92
  `<li>configured layer suggestions: ${project.layers}</li>` +
70
93
  `<li>high-risk patterns: ${project.highRisk}</li>` +
71
94
  `<li>max repair rounds: ${project.maxRepairRounds}</li>` +
72
95
  `<li>inline diff max lines: ${project.inlineDiffMaxLines}</li>` +
73
- `<li>small change set: ${project.smallPrHunks} hunks</li></ul>` +
96
+ `<li>small change set: ${project.smallPrHunks} chunks</li></ul>` +
74
97
  `<p class="muted small mono">${esc(project.file ?? 'built-in defaults (no pr-review.config.yml)')}</p></div>` +
75
98
  '<p class="probe-result" role="status"></p>' +
76
99
  '<div class="dialog-actions">' +
77
- '<button class="cmd" type="button" data-act="settings-probe">test agent</button>' +
100
+ (agents === null
101
+ ? ''
102
+ : '<button class="cmd" type="button" data-act="settings-probe">test agent</button>') +
78
103
  '<button class="cmd fill" type="button" data-act="settings-save">save</button>' +
79
104
  '<button class="cmd" type="button" data-act="settings-close">close</button>' +
80
105
  '</div></dialog>'
@@ -103,7 +128,9 @@ export async function openSettingsDialog(root, opener, opts = {}) {
103
128
  const loaded = await runCommand(
104
129
  opener,
105
130
  async () => {
106
- const [data, agents] = await Promise.all([api.fetchSettings(), api.fetchAgents()])
131
+ // With chat off the agent routes do not exist, and the dialog holds only the reading level.
132
+ const data = await api.fetchSettings()
133
+ const agents = data.project.chatEnabled ? await api.fetchAgents() : null
107
134
  return { data, agents }
108
135
  },
109
136
  { pendingLabel: 'loading…' }
@@ -128,12 +155,16 @@ export async function openSettingsDialog(root, opener, opts = {}) {
128
155
 
129
156
  /** The values the dialog holds right now, as the PUT body. */
130
157
  export function readSettingsForm(/** @type {ParentNode} */ dialog) {
158
+ const foldLevel = qs('#set-fold-level', dialog)
131
159
  const agent = qs('#set-agent', dialog)
132
160
  const model = qs('#set-model', dialog)
133
161
  const timeout = qs('#set-timeout', dialog)
134
162
  const turns = qs('#set-turns', dialog)
135
163
  /** @type {import('./contract-types.js').SettingsInput} */
136
164
  const input = {}
165
+ if (foldLevel instanceof HTMLSelectElement && isFoldLevel(foldLevel.value)) {
166
+ input.foldLevel = foldLevel.value
167
+ }
137
168
  if (agent instanceof HTMLSelectElement && agent.value !== '') {
138
169
  input.agent = /** @type {import('./contract-types.js').ChatAgent} */ (agent.value)
139
170
  }
@@ -4,8 +4,10 @@
4
4
  /** @typedef {import('./contract-types.js').PrState} PrState */
5
5
  /** @typedef {import('./contract-types.js').ReviewArtifact} ReviewArtifact */
6
6
  /** @typedef {import('./contract-types.js').ReviewBodyResponse} ReviewBodyResponse */
7
- import { previewControlsHtml, setDisabledReason } from './composer.js'
7
+ import { previewControlsHtml, setDisabledReason, setMarkdownPreview } from './composer.js'
8
8
  import { esc } from './dom.js'
9
+ import { hostLabel } from './host.js'
10
+ import { pendingLabel } from './pending.js'
9
11
  import { layerProgress } from './progress.js'
10
12
 
11
13
  export const SIGNOFF_DIALOG_ID = 'signoff-dialog'
@@ -13,6 +15,9 @@ export const SIGNOFF_DIALOG_ID = 'signoff-dialog'
13
15
  /** Why the post command waits: the body the server writes is not there yet. */
14
16
  export const LOADING_REASON = 'the review body is still loading'
15
17
 
18
+ /** Why it waits after the body failed to load: there is nothing to post. */
19
+ export const FAILED_REASON = 'the review body could not be loaded'
20
+
16
21
  /**
17
22
  * Why approve is not allowed yet, or null when it is. Other changes never counts.
18
23
  * @param {ReviewArtifact} artifact
@@ -32,22 +37,47 @@ export function approveBlockedReason(artifact, state) {
32
37
  }
33
38
 
34
39
  /**
35
- * @param {'APPROVE' | 'REQUEST_CHANGES'} event
40
+ * @param {import('./contract-types.js').ReviewEvent} event
36
41
  */
37
42
  export function signoffTitle(event) {
38
- return event === 'APPROVE' ? 'Approve on GitHub' : 'Request changes on GitHub'
43
+ switch (event) {
44
+ case 'APPROVE':
45
+ return `Approve on ${hostLabel()}`
46
+ case 'REQUEST_CHANGES':
47
+ return `Request changes on ${hostLabel()}`
48
+ case 'COMMENT':
49
+ return `Comment on ${hostLabel()}`
50
+ }
39
51
  }
40
52
 
41
53
  /**
42
- * @param {{ event: 'APPROVE' | 'REQUEST_CHANGES' }} opts
54
+ * What the dialog says this event does, under its heading.
55
+ * @param {import('./contract-types.js').ReviewEvent} event
56
+ */
57
+ export function signoffHint(event) {
58
+ const where = `on ${hostLabel()} as you`
59
+ switch (event) {
60
+ case 'APPROVE':
61
+ return `This posts an approving review ${where}. Edit the body first if you want to.`
62
+ case 'REQUEST_CHANGES':
63
+ return `This posts a review requesting changes ${where}. Edit the body first if you want to.`
64
+ case 'COMMENT':
65
+ return `This posts a review with no verdict ${where}: nothing is approved or rejected. Edit the body first if you want to.`
66
+ }
67
+ }
68
+
69
+ /**
70
+ * @param {{ event: import('./contract-types.js').ReviewEvent }} opts
43
71
  * @returns {string}
44
72
  */
45
73
  export function signoffDialogHtml(opts) {
46
74
  return (
47
75
  `<dialog id="${SIGNOFF_DIALOG_ID}" class="signoff-dialog" aria-labelledby="signoff-h">` +
48
76
  `<h2 id="signoff-h">${esc(signoffTitle(opts.event))}</h2>` +
49
- '<p class="hint">This posts a review on GitHub as you. Edit the body first if you want to.</p>' +
77
+ `<p class="hint">${esc(signoffHint(opts.event))}</p>` +
50
78
  '<p class="signoff-target muted mono"></p>' +
79
+ '<p class="signoff-folds muted"></p>' +
80
+ '<p class="signoff-pending"></p>' +
51
81
  '<label class="sr" for="signoff-body">Review body</label>' +
52
82
  previewControlsHtml() +
53
83
  '<textarea id="signoff-body" rows="12" aria-busy="true"></textarea>' +
@@ -63,7 +93,7 @@ export function signoffDialogHtml(opts) {
63
93
  * Creates the dialog on first use, points it at this event, and opens it. The body arrives
64
94
  * afterwards through `fillSignoffDialog`.
65
95
  * @param {HTMLElement} root
66
- * @param {{ event: 'APPROVE' | 'REQUEST_CHANGES' }} opts
96
+ * @param {{ event: import('./contract-types.js').ReviewEvent }} opts
67
97
  * @returns {HTMLDialogElement}
68
98
  */
69
99
  export function openSignoffDialog(root, opts) {
@@ -77,14 +107,20 @@ export function openSignoffDialog(root, opts) {
77
107
  if (!(dialog instanceof HTMLDialogElement)) {
78
108
  throw new Error('sign-off dialog did not render')
79
109
  }
80
- dialog
81
- .querySelector('[data-act="markdown-write"]')
82
- ?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
110
+ setMarkdownPreview(dialog, false)
83
111
  dialog.setAttribute('data-event', opts.event)
84
112
  const heading = dialog.querySelector('#signoff-h')
85
113
  if (heading !== null) {
86
114
  heading.textContent = signoffTitle(opts.event)
87
115
  }
116
+ const hint = dialog.querySelector('.hint')
117
+ if (hint !== null) {
118
+ hint.textContent = signoffHint(opts.event)
119
+ }
120
+ const pending = dialog.querySelector('.signoff-pending')
121
+ if (pending !== null) {
122
+ pending.textContent = ''
123
+ }
88
124
  const result = dialog.querySelector('.signoff-result')
89
125
  if (result !== null) {
90
126
  result.textContent = ''
@@ -104,6 +140,20 @@ export function openSignoffDialog(root, opts) {
104
140
  return dialog
105
141
  }
106
142
 
143
+ /**
144
+ * Records how much code the reader had hidden while reviewing, so the sign-off says what was
145
+ * read at a glance and what was read line by line.
146
+ * @param {HTMLDialogElement} dialog
147
+ * @param {string} note
148
+ */
149
+ export function setSignoffFolds(dialog, note) {
150
+ const box = dialog.querySelector('.signoff-folds')
151
+ if (box !== null) {
152
+ box.textContent = note
153
+ }
154
+ return dialog
155
+ }
156
+
107
157
  /**
108
158
  * Puts the generated body and the target commit in the dialog.
109
159
  * @param {HTMLDialogElement} dialog
@@ -121,6 +171,17 @@ export function fillSignoffDialog(dialog, preview) {
121
171
  if (target !== null) {
122
172
  target.textContent = `on commit ${preview.headSha.slice(0, 7)}`
123
173
  }
174
+ // The drafts go out with the review, so the dialog says so before anything is posted.
175
+ const pending = dialog.querySelector('.signoff-pending')
176
+ if (pending !== null) {
177
+ pending.textContent =
178
+ preview.pending > 0 ? `${pendingLabel(preview.pending)} will be posted with this review.` : ''
179
+ pending.classList.toggle('has-pending', preview.pending > 0)
180
+ }
181
+ const post = dialog.querySelector('[data-act="signoff-post"]')
182
+ if (post !== null) {
183
+ post.textContent = preview.pending > 0 ? `post review · ${pendingLabel(preview.pending)}` : 'post review'
184
+ }
124
185
  return dialog
125
186
  }
126
187
 
@@ -133,7 +194,7 @@ export function showSignoffResult(dialog, review) {
133
194
  const result = dialog.querySelector('.signoff-result')
134
195
  if (result !== null) {
135
196
  result.textContent = 'Posted · '
136
- result.append(externalLink(review.url, 'see it on GitHub'))
197
+ result.append(externalLink(review.url, `see it on ${hostLabel()}`))
137
198
  }
138
199
  return result
139
200
  }
@@ -165,6 +226,14 @@ export function showSignoffError(dialog, message) {
165
226
  if (result !== null) {
166
227
  result.textContent = message
167
228
  }
229
+ // The load this reports is over, so the box stops saying it is busy. Posting stays out of
230
+ // reach, but for the reason that is true now rather than the one that was true before.
231
+ const area = dialog.querySelector('textarea')
232
+ if (area instanceof HTMLTextAreaElement && area.getAttribute('aria-busy') === 'true') {
233
+ area.removeAttribute('aria-busy')
234
+ area.placeholder = ''
235
+ setDisabledReason(dialog.querySelector('[data-act="signoff-post"]'), FAILED_REASON)
236
+ }
168
237
  return result
169
238
  }
170
239
 
package/static/js/skin.js CHANGED
@@ -11,7 +11,7 @@ export const SKINS = /** @type {const} */ (['terminal', 'github'])
11
11
  /** @typedef {(typeof SKINS)[number]} Skin */
12
12
 
13
13
  /** The look a page wears when the settings file says nothing. */
14
- export const DEFAULT_SKIN = /** @type {Skin} */ ('terminal')
14
+ export const DEFAULT_SKIN = /** @type {Skin} */ ('github')
15
15
 
16
16
  /**
17
17
  * @param {unknown} value
@@ -37,7 +37,7 @@ export function readSkin(root) {
37
37
  */
38
38
  export function nextSkin(skin) {
39
39
  const next = SKINS[SKINS.indexOf(skin) + 1]
40
- return next ?? DEFAULT_SKIN
40
+ return next ?? SKINS[0]
41
41
  }
42
42
 
43
43
  /**
@@ -1,30 +1,28 @@
1
- .chat-launcher,
2
- .chat-minimize {
1
+ /* The launcher stands in for the chat at every width; the script hides it while the chat is up. */
2
+ .chat-launcher {
3
+ display: block;
4
+ position: fixed;
5
+ right: 20px;
6
+ bottom: 20px;
7
+ z-index: 10;
8
+ padding: 14px 20px;
9
+ border: 1px solid var(--ink);
10
+ border-radius: 999px;
11
+ background: var(--accent);
12
+ color: var(--bg);
13
+ box-shadow: 0 4px 20px #0003;
14
+ font: inherit;
15
+ font-weight: 600;
16
+ cursor: pointer;
17
+ }
18
+ .chat-launcher[hidden] {
3
19
  display: none;
4
20
  }
21
+ /* Always the last thing on its row, whether or not the header wraps. */
22
+ .chat-minimize {
23
+ margin-left: auto;
24
+ }
5
25
  @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
26
  dialog.chat-dialog {
29
27
  position: fixed;
30
28
  inset: auto 20px 20px auto;
@@ -7,6 +7,10 @@
7
7
  height: 100vh;
8
8
  background: transparent;
9
9
  }
10
+ /* Minimized on a wide screen: the pane stays mounted with its draft, out of the grid. */
11
+ .chat[hidden] {
12
+ display: none;
13
+ }
10
14
  .handle {
11
15
  position: absolute;
12
16
  left: -1px;
@@ -116,6 +116,12 @@
116
116
  color: var(--purple);
117
117
  border-color: currentColor;
118
118
  }
119
+ /* The two local reviews: work with no pull request, so neither open nor closed. */
120
+ .pill.branch,
121
+ .pill.uncommitted {
122
+ color: var(--accent);
123
+ border-color: currentColor;
124
+ }
119
125
  .pill.agent {
120
126
  color: var(--fg);
121
127
  border-color: var(--ink);
@@ -46,6 +46,27 @@
46
46
  gap: 4px 16px;
47
47
  align-items: baseline;
48
48
  }
49
+ /* How much code is hidden: a setting on the reading, so it sits with the progress line and not
50
+ among the commands, and says what it hides rather than leaving the reader to find out. */
51
+ .reading {
52
+ display: flex;
53
+ flex-wrap: wrap;
54
+ gap: 4px 10px;
55
+ align-items: baseline;
56
+ margin-top: 14px;
57
+ }
58
+ .reading label {
59
+ font-size: 12px;
60
+ color: var(--fg-muted);
61
+ }
62
+ .reading select {
63
+ padding: 2px 6px;
64
+ font-size: 13px;
65
+ }
66
+ .fold-hint {
67
+ font-size: 12px;
68
+ color: var(--fg-muted);
69
+ }
49
70
  .stripe {
50
71
  height: 6px;
51
72
  background: var(--stripe-y);
@@ -88,6 +88,10 @@
88
88
  box-shadow: 2px 2px 0 var(--shadow);
89
89
  font-size: 13px;
90
90
  }
91
+ /* The same bar for a canvas that still applies: a note, not a warning. */
92
+ .stale-bar.carried-over-bar {
93
+ background: var(--bg);
94
+ }
91
95
  .notice {
92
96
  margin: 10px 0 0;
93
97
  padding: 6px 10px;
@@ -0,0 +1,102 @@
1
+ /* The pending review: the bar that says one is open, and the drafts drawn on the diff. */
2
+
3
+ .pending-bar-host:empty {
4
+ display: none;
5
+ }
6
+
7
+ .pending-bar {
8
+ display: flex;
9
+ flex-wrap: wrap;
10
+ gap: 10px;
11
+ align-items: center;
12
+ margin: 10px 0 0;
13
+ padding: 10px 12px;
14
+ color: var(--fg);
15
+ background: var(--warn-bg);
16
+ /* The heavy left edge is what carries across the page: a review is open and unsent. */
17
+ border: 1px solid var(--warn);
18
+ border-left: 4px solid var(--warn);
19
+ }
20
+
21
+ /* The dot pulses so an open review is noticed on a page the reader has scrolled far down. */
22
+ .pending-mark {
23
+ flex: none;
24
+ width: 10px;
25
+ height: 10px;
26
+ background: var(--warn);
27
+ border-radius: 50%;
28
+ animation: pending-pulse 2s ease-in-out infinite;
29
+ }
30
+
31
+ @keyframes pending-pulse {
32
+ 0%,
33
+ 100% {
34
+ opacity: 1;
35
+ }
36
+ 50% {
37
+ opacity: 0.35;
38
+ }
39
+ }
40
+
41
+ @media (prefers-reduced-motion: reduce) {
42
+ .pending-mark {
43
+ animation: none;
44
+ }
45
+ }
46
+
47
+ .pending-bar .pending-text {
48
+ flex: 1 1 260px;
49
+ }
50
+
51
+ .pending-bar .pending-actions {
52
+ display: flex;
53
+ flex: none;
54
+ gap: 6px;
55
+ }
56
+
57
+ /* A draft on the diff reads as unfinished: dashed edge, and the badge that names it. */
58
+ .cmt.pending-cmt {
59
+ padding: 8px;
60
+ background: var(--warn-bg);
61
+ border: 1px dashed var(--warn);
62
+ }
63
+
64
+ .pending-av {
65
+ color: var(--fg-muted);
66
+ border-color: var(--warn);
67
+ }
68
+
69
+ /* The avatar column covers the byline and the body, so the commands clear both. */
70
+ .cmt.pending-cmt .tbtns {
71
+ display: flex;
72
+ grid-column: 1 / -1;
73
+ gap: 6px;
74
+ margin-top: 6px;
75
+ }
76
+
77
+ .pill.pending {
78
+ color: var(--bg);
79
+ background: var(--warn);
80
+ }
81
+
82
+ .signoff-pending:empty {
83
+ display: none;
84
+ }
85
+
86
+ .signoff-pending.has-pending {
87
+ padding: 6px 8px;
88
+ background: var(--warn-bg);
89
+ border-left: 3px solid var(--warn);
90
+ }
91
+
92
+ .earlier-pending {
93
+ margin-top: 8px;
94
+ padding: 8px 12px;
95
+ border: 1px solid var(--warn);
96
+ }
97
+
98
+ .earlier-draft + .earlier-draft {
99
+ margin-top: 12px;
100
+ padding-top: 12px;
101
+ border-top: 1px dashed var(--warn);
102
+ }
@@ -35,6 +35,7 @@ tr.sel-bar .lbl {
35
35
  outline: 2px solid var(--focus);
36
36
  outline-offset: 1px;
37
37
  }
38
+ .preview-controls,
38
39
  .composer-actions {
39
40
  display: flex;
40
41
  gap: 12px;
@@ -65,6 +65,10 @@
65
65
  border-bottom: 1px solid var(--line);
66
66
  color: var(--fg);
67
67
  }
68
+ .fold-count {
69
+ color: var(--fg-muted);
70
+ font-weight: 400;
71
+ }
68
72
  .summary,
69
73
  .rationale,
70
74
  .prose {
@@ -153,7 +153,9 @@
153
153
  [data-skin='github'] .pill.open,
154
154
  [data-skin='github'] .pill.merged,
155
155
  [data-skin='github'] .pill.closed,
156
- [data-skin='github'] .pill.draft {
156
+ [data-skin='github'] .pill.draft,
157
+ [data-skin='github'] .pill.branch,
158
+ [data-skin='github'] .pill.uncommitted {
157
159
  border-color: transparent;
158
160
  color: #ffffff;
159
161
  }
@@ -169,6 +171,10 @@
169
171
  [data-skin='github'] .pill.draft {
170
172
  background: light-dark(#6e7781, #636e7b);
171
173
  }
174
+ [data-skin='github'] .pill.branch,
175
+ [data-skin='github'] .pill.uncommitted {
176
+ background: light-dark(#0969da, #1f6feb);
177
+ }
172
178
  [data-skin='github'] .pill.risk,
173
179
  [data-skin='github'] .pill.kind,
174
180
  [data-skin='github'] .pill.agent {
package/static/styles.css CHANGED
@@ -10,6 +10,7 @@
10
10
  @import './styles/panels.css';
11
11
  @import './styles/responsive.css';
12
12
  @import './styles/review-actions.css';
13
+ @import './styles/pending.css';
13
14
  @import './styles/chat-tools.css';
14
15
  @import './styles/skin-github.css';
15
16
  @import './styles/chat-panel.css';