@iceinvein/agent-skills 0.1.24 → 0.1.26

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 (48) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/skills/magpie/README.md +16 -1
  4. package/skills/magpie/bin/magpie.ts +60 -2
  5. package/skills/magpie/fixtures/example-pr/diff.patch +86 -0
  6. package/skills/magpie/fixtures/example-pr/findings.final.json +137 -0
  7. package/skills/magpie/fixtures/example-pr/post-status.json +5 -0
  8. package/skills/magpie/fixtures/example-pr/pr.json +21 -0
  9. package/skills/magpie/fixtures/fake-gh.sh +10 -1
  10. package/skills/magpie/package.json +1 -1
  11. package/skills/magpie/scripts/__tests__/cli.test.ts +119 -0
  12. package/skills/magpie/scripts/__tests__/diff-utils.test.ts +106 -0
  13. package/skills/magpie/scripts/__tests__/file-tree.test.ts +97 -0
  14. package/skills/magpie/scripts/__tests__/gh.test.ts +8 -0
  15. package/skills/magpie/scripts/__tests__/helper.test.ts +63 -0
  16. package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +10 -0
  17. package/skills/magpie/scripts/__tests__/post-cmd.test.ts +90 -1
  18. package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +210 -0
  19. package/skills/magpie/scripts/__tests__/refresh.test.ts +2 -2
  20. package/skills/magpie/scripts/__tests__/render-action-bar.test.ts +66 -0
  21. package/skills/magpie/scripts/__tests__/render-annotation.test.ts +67 -0
  22. package/skills/magpie/scripts/__tests__/render-cmd.test.ts +46 -0
  23. package/skills/magpie/scripts/__tests__/render-diff.test.ts +84 -0
  24. package/skills/magpie/scripts/__tests__/render-file-tree.test.ts +54 -0
  25. package/skills/magpie/scripts/__tests__/render-findings.test.ts +104 -182
  26. package/skills/magpie/scripts/__tests__/render-issues-list.test.ts +67 -0
  27. package/skills/magpie/scripts/__tests__/render-progress.test.ts +86 -0
  28. package/skills/magpie/scripts/__tests__/server.test.ts +54 -0
  29. package/skills/magpie/scripts/__tests__/types.test.ts +48 -3
  30. package/skills/magpie/scripts/diff-utils.ts +129 -0
  31. package/skills/magpie/scripts/file-tree.ts +74 -0
  32. package/skills/magpie/scripts/gh.ts +1 -0
  33. package/skills/magpie/scripts/helper.js +402 -192
  34. package/skills/magpie/scripts/post-cmd.ts +202 -0
  35. package/skills/magpie/scripts/preview-cmd.ts +300 -0
  36. package/skills/magpie/scripts/refresh.ts +16 -1
  37. package/skills/magpie/scripts/render-action-bar.ts +51 -0
  38. package/skills/magpie/scripts/render-annotation.ts +96 -0
  39. package/skills/magpie/scripts/render-cmd.ts +24 -1
  40. package/skills/magpie/scripts/render-diff.ts +125 -0
  41. package/skills/magpie/scripts/render-file-tree.ts +86 -0
  42. package/skills/magpie/scripts/render-findings.ts +165 -196
  43. package/skills/magpie/scripts/render-issues-list.ts +78 -0
  44. package/skills/magpie/scripts/render-progress.ts +74 -32
  45. package/skills/magpie/scripts/server.ts +43 -0
  46. package/skills/magpie/scripts/types.ts +13 -0
  47. package/skills/magpie/skill.json +4 -2
  48. package/skills/magpie/templates/styles.css +1288 -629
@@ -1,8 +1,7 @@
1
1
  ;(() => {
2
2
  let isLive = false
3
- let focusedIndex = -1
4
3
 
5
- const filters = { sev: new Set(), domain: new Set(), search: '' }
4
+ const fileFindingIdx = new Map()
6
5
 
7
6
  function isLikelyArchived() {
8
7
  return location.protocol === 'file:' || location.protocol === 'about:'
@@ -38,12 +37,9 @@
38
37
  return Array.from(document.querySelectorAll('input[type="checkbox"][data-finding-id]'))
39
38
  }
40
39
 
41
- function allFindings() {
42
- return Array.from(document.querySelectorAll('.finding'))
43
- }
44
-
45
- function visibleFindings() {
46
- return allFindings().filter((el) => !el.classList.contains('is-hidden'))
40
+ function cssEscape(s) {
41
+ // Minimal CSS attr-selector escape: backslash-escape backslashes and quotes.
42
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
47
43
  }
48
44
 
49
45
  function post(payload) {
@@ -59,7 +55,7 @@
59
55
  let pendingPostIds = []
60
56
 
61
57
  function showStatus(message, tone, sticky) {
62
- const el = document.querySelector('[data-role="submit-status"]')
58
+ const el = document.querySelector('[data-role="post-status"]')
63
59
  if (!(el instanceof HTMLElement)) return
64
60
  el.textContent = message
65
61
  el.hidden = false
@@ -106,34 +102,33 @@
106
102
  return bar instanceof HTMLElement && !bar.hidden
107
103
  }
108
104
 
109
- function badgeHtml(result) {
110
- if (result.status === 'posted') return '<span class="badge posted">posted</span>'
111
- if (result.status === 'already-posted')
112
- return '<span class="badge posted">already posted</span>'
113
- if (result.status === 'unknown-id')
114
- return '<span class="badge failed">failed: unknown id</span>'
115
- const msg = result.message || 'gh failed'
116
- const span = document.createElement('span')
117
- span.className = 'badge failed'
118
- span.textContent = `failed: ${msg}`
119
- return span.outerHTML
120
- }
121
-
122
105
  function applyResult(result) {
123
- const card = document.getElementById(`finding-${result.id}`)
124
- if (!(card instanceof HTMLElement)) return
125
- const head = card.querySelector('.finding-head')
126
- if (!head) return
127
- // Remove any pre-existing badge in this head.
128
- const existing = head.querySelector('.badge')
129
- if (existing) existing.remove()
130
- head.insertAdjacentHTML('beforeend', badgeHtml(result))
106
+ if (!result?.id) return
107
+ const containers = findAnnotation(result.id)
108
+ if (containers.length === 0) return
131
109
  if (result.status === 'posted' || result.status === 'already-posted') {
132
- const cb = card.querySelector('input[type="checkbox"][data-finding-id]')
133
- if (cb instanceof HTMLInputElement) {
110
+ for (const el of containers) {
111
+ el.setAttribute('data-posted', 'true')
112
+ const chip = el.querySelector('.status-chip')
113
+ if (chip) {
114
+ chip.className = 'status-chip posted'
115
+ chip.textContent = 'POSTED'
116
+ }
117
+ }
118
+ for (const cb of findCheckboxes(result.id)) {
134
119
  cb.checked = true
135
120
  cb.disabled = true
136
121
  }
122
+ } else if (result.status === 'failed' || result.status === 'unknown-id') {
123
+ const msg = result.message || (result.status === 'unknown-id' ? 'unknown id' : 'gh failed')
124
+ for (const el of containers) {
125
+ el.setAttribute('data-failed', 'true')
126
+ const chip = el.querySelector('.status-chip')
127
+ if (chip) {
128
+ chip.className = 'status-chip failed'
129
+ chip.textContent = `FAILED: ${msg}`
130
+ }
131
+ }
137
132
  }
138
133
  }
139
134
 
@@ -197,12 +192,24 @@
197
192
  // active selection. Exclude them so "clear" actually clears, the counter
198
193
  // reflects only what's actionable, and localStorage doesn't pin them.
199
194
  const activelyChecked = allCheckboxes().filter((el) => el.checked && !el.disabled)
200
- const n = activelyChecked.length
201
- const node = document.querySelector('[data-role="selected-count"] .num')
202
- if (node) node.textContent = String(n)
203
- const btn = document.querySelector('[data-action="post"]')
204
- if (btn instanceof HTMLButtonElement) btn.disabled = n === 0
205
195
  writePersistedSelection(activelyChecked.map((el) => el.dataset.findingId))
196
+ updateSelectedCount()
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // New Pylon-style selection count
201
+ // ---------------------------------------------------------------------------
202
+
203
+ function updateSelectedCount() {
204
+ // Count unique finding ids that have any checked checkbox.
205
+ const ids = new Set()
206
+ for (const cb of document.querySelectorAll('input[type="checkbox"][data-finding-id]')) {
207
+ if (cb.checked && !cb.disabled) ids.add(cb.getAttribute('data-finding-id'))
208
+ }
209
+ const node = document.querySelector('[data-role="selected-count"]')
210
+ if (node) node.textContent = String(ids.size)
211
+ const postSel = document.querySelector('[data-action="post-selected"]')
212
+ if (postSel) postSel.disabled = ids.size === 0
206
213
  }
207
214
 
208
215
  function restoreSelection() {
@@ -214,144 +221,306 @@
214
221
  }
215
222
  }
216
223
 
217
- function applyFilters() {
218
- const search = filters.search.trim().toLowerCase()
219
- const sevSet = filters.sev
220
- const domainSet = filters.domain
221
- let visible = 0
222
- for (const card of allFindings()) {
223
- const sev = card.dataset.severity
224
- const dom = card.dataset.domain
225
- const text = card.dataset.searchText || ''
226
- const sevOk = sevSet.size === 0 || sevSet.has(sev)
227
- const domOk = domainSet.size === 0 || domainSet.has(dom)
228
- const searchOk = !search || text.includes(search)
229
- const ok = sevOk && domOk && searchOk
230
- card.classList.toggle('is-hidden', !ok)
231
- if (ok) visible++
224
+ // ---------------------------------------------------------------------------
225
+ // Tab switching
226
+ // ---------------------------------------------------------------------------
227
+
228
+ function handleSetView(btn) {
229
+ const view = btn.getAttribute('data-view')
230
+ if (!view) return
231
+ document.body.dataset.view = view
232
+ for (const s of document.querySelectorAll('[data-action="set-view"]')) {
233
+ s.setAttribute('aria-pressed', s.getAttribute('data-view') === view ? 'true' : 'false')
232
234
  }
233
- const total = allFindings().length
234
- const visibleNode = document.querySelector('[data-role="visible-count"]')
235
- if (visibleNode) visibleNode.textContent = String(visible)
236
- const statusNode = document.querySelector('[data-role="filter-status"]')
237
- if (statusNode?.firstChild) {
238
- const filtersActive = sevSet.size > 0 || domainSet.size > 0 || !!search
239
- statusNode.childNodes[0].textContent = filtersActive ? 'showing ' : 'showing all '
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // File selection
239
+ // ---------------------------------------------------------------------------
240
+
241
+ function handleSelectFile(btn) {
242
+ const path = btn.getAttribute('data-file') ?? ''
243
+ document.body.dataset.selectedFile = path
244
+ for (const p of document.querySelectorAll('[data-file-pane]')) {
245
+ p.hidden = p.getAttribute('data-file-pane') !== path
240
246
  }
241
- const clearBtn = document.querySelector('[data-action="clear-filters"]')
242
- if (clearBtn instanceof HTMLButtonElement) {
243
- const hasFilter = sevSet.size > 0 || domainSet.size > 0 || !!search
244
- clearBtn.hidden = !hasFilter
247
+ for (const b of document.querySelectorAll('[data-action="select-file"]')) {
248
+ b.classList.toggle('active', b.getAttribute('data-file') === path)
245
249
  }
246
- const noMatches = document.querySelector('[data-role="no-matches"]')
247
- if (noMatches instanceof HTMLElement) noMatches.hidden = visible !== 0 || total === 0
248
- // Reset focus when filters shift the visible set.
249
- if (focusedIndex !== -1 && visibleFindings()[focusedIndex] == null) {
250
- setFocused(-1)
250
+ // Reset finding nav for new file.
251
+ fileFindingIdx.set(path, -1)
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Diff-mode toggle
256
+ // ---------------------------------------------------------------------------
257
+
258
+ function handleSetDiffMode(btn) {
259
+ const mode = btn.getAttribute('data-mode')
260
+ if (!mode) return
261
+ document.body.dataset.diffMode = mode
262
+ for (const b of document.querySelectorAll('[data-action="set-diff-mode"]')) {
263
+ b.setAttribute('aria-pressed', b.getAttribute('data-mode') === mode ? 'true' : 'false')
251
264
  }
265
+ // Toggle visibility of .diff-unified and .diff-split inside each file pane.
266
+ for (const el of document.querySelectorAll('.diff-unified')) {
267
+ el.hidden = mode !== 'unified'
268
+ }
269
+ for (const el of document.querySelectorAll('.diff-split')) {
270
+ el.hidden = mode !== 'split'
271
+ }
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // Finding navigation (per file)
276
+ // ---------------------------------------------------------------------------
277
+
278
+ function annotsInActiveFile() {
279
+ const path = document.body.dataset.selectedFile ?? ''
280
+ const pane = document.querySelector(`[data-file-pane="${cssEscape(path)}"]`)
281
+ if (!pane) return []
282
+ // Use the visible diff-mode container so split mode finds its own annotations.
283
+ const mode = document.body.dataset.diffMode ?? 'unified'
284
+ const container = pane.querySelector(`[data-diff-mode="${mode}"]`) || pane
285
+ return Array.from(container.querySelectorAll('.annot'))
252
286
  }
253
287
 
254
- function toggleFilterChip(button) {
255
- const group = button.dataset.filterGroup
256
- const value = button.dataset.filterValue
257
- if (!group || !value) return
258
- const bucket = group === 'sev' ? filters.sev : filters.domain
259
- if (bucket.has(value)) {
260
- bucket.delete(value)
261
- button.setAttribute('aria-pressed', 'false')
288
+ function navigateFinding(direction) {
289
+ const annots = annotsInActiveFile()
290
+ if (annots.length === 0) return
291
+ const path = document.body.dataset.selectedFile ?? ''
292
+ const cur = fileFindingIdx.get(path) ?? -1
293
+ let next
294
+ if (cur === -1) {
295
+ next = direction > 0 ? 0 : annots.length - 1
262
296
  } else {
263
- bucket.add(value)
264
- button.setAttribute('aria-pressed', 'true')
297
+ next = cur + direction
298
+ if (next < 0) next = annots.length - 1
299
+ if (next >= annots.length) next = 0
300
+ }
301
+ fileFindingIdx.set(path, next)
302
+ for (const a of annots) a.classList.remove('is-focused')
303
+ const target = annots[next]
304
+ if (target) {
305
+ target.classList.add('is-focused')
306
+ target.scrollIntoView({ block: 'center', behavior: 'smooth' })
265
307
  }
266
- applyFilters()
267
308
  }
268
309
 
269
- function clearAllFilters() {
270
- filters.sev.clear()
271
- filters.domain.clear()
272
- filters.search = ''
273
- for (const btn of document.querySelectorAll('.filter-chip')) {
274
- btn.setAttribute('aria-pressed', 'false')
275
- }
276
- const search = document.querySelector('[data-role="search"]')
277
- if (search instanceof HTMLInputElement) search.value = ''
278
- applyFilters()
310
+ // ---------------------------------------------------------------------------
311
+ // Suggestions toggle
312
+ // ---------------------------------------------------------------------------
313
+
314
+ function handleToggleSuggestions(btn) {
315
+ const cur = document.body.dataset.showSuggestions === 'true'
316
+ document.body.dataset.showSuggestions = cur ? 'false' : 'true'
317
+ btn.setAttribute('aria-pressed', cur ? 'false' : 'true')
279
318
  }
280
319
 
281
- function selectMany(predicate) {
282
- let toggled = false
283
- for (const cb of allCheckboxes()) {
320
+ // ---------------------------------------------------------------------------
321
+ // Severity filter (hide-sev-*)
322
+ // ---------------------------------------------------------------------------
323
+
324
+ function handleFilterSev(btn) {
325
+ const sev = btn.getAttribute('data-sev')
326
+ if (!sev) return
327
+ const cls = `hide-sev-${sev}`
328
+ const wasHidden = document.body.classList.contains(cls)
329
+ document.body.classList.toggle(cls, !wasHidden)
330
+ btn.setAttribute('aria-pressed', wasHidden ? 'true' : 'false')
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Checkbox helpers
335
+ // ---------------------------------------------------------------------------
336
+
337
+ // findAnnotation returns all non-input elements for a given finding id.
338
+ function findAnnotation(id) {
339
+ return Array.from(document.querySelectorAll(`[data-finding-id="${cssEscape(id)}"]:not(input)`))
340
+ }
341
+
342
+ function findCheckboxes(id) {
343
+ return Array.from(
344
+ document.querySelectorAll(`input[type="checkbox"][data-finding-id="${cssEscape(id)}"]`),
345
+ )
346
+ }
347
+
348
+ function setChecked(id, checked) {
349
+ for (const cb of findCheckboxes(id)) {
284
350
  if (cb.disabled) continue
285
- const card = cb.closest('.finding')
286
- if (!card) continue
287
- if (predicate(card) && !cb.checked) {
288
- cb.checked = true
289
- toggled = true
290
- post({
291
- type: 'select',
292
- findingId: cb.dataset.findingId,
293
- timestamp: Date.now(),
294
- })
351
+ cb.checked = checked
352
+ }
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // Bulk selection
357
+ // ---------------------------------------------------------------------------
358
+
359
+ function handleSelectSev(btn) {
360
+ const sev = btn.getAttribute('data-sev')
361
+ if (!sev) return
362
+ const ids = new Set()
363
+ for (const el of document.querySelectorAll(
364
+ `[data-finding-id][data-severity="${sev}"][data-suggestion="false"]`,
365
+ )) {
366
+ if (el.tagName.toLowerCase() === 'input') continue
367
+ if (el.getAttribute('data-posted') === 'true') continue
368
+ ids.add(el.getAttribute('data-finding-id'))
369
+ }
370
+ for (const id of ids) setChecked(id, true)
371
+ updateSelectedCount()
372
+ }
373
+
374
+ function handleSelectRecommended() {
375
+ const ids = new Set()
376
+ for (const el of document.querySelectorAll('[data-finding-id][data-suggestion="false"]')) {
377
+ if (el.tagName.toLowerCase() === 'input') continue
378
+ if (el.getAttribute('data-posted') === 'true') continue
379
+ ids.add(el.getAttribute('data-finding-id'))
380
+ }
381
+ for (const id of ids) setChecked(id, true)
382
+ updateSelectedCount()
383
+ }
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // Toggle focused finding's checkbox
387
+ // ---------------------------------------------------------------------------
388
+
389
+ function toggleFocusedFinding() {
390
+ const path = document.body.dataset.selectedFile ?? ''
391
+ const idx = fileFindingIdx.get(path) ?? -1
392
+ if (idx === -1) return
393
+ const annots = annotsInActiveFile()
394
+ const annot = annots[idx]
395
+ if (!annot) return
396
+ const id = annot.getAttribute('data-finding-id')
397
+ if (!id) return
398
+ const cbs = findCheckboxes(id)
399
+ if (cbs.length === 0) return
400
+ const cb = cbs[0]
401
+ if (cb.disabled) return
402
+ const next = !cb.checked
403
+ setChecked(id, next)
404
+ post({
405
+ type: next ? 'select' : 'deselect',
406
+ findingId: id,
407
+ timestamp: Date.now(),
408
+ })
409
+ updateSelectedCount()
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Pylon-style bulk post via /api/post-review
414
+ // ---------------------------------------------------------------------------
415
+
416
+ async function postToReview(findingIds) {
417
+ if (!isLive) {
418
+ showStatus(
419
+ 'Cannot post from an archived view. Run `magpie serve <run-dir>` first.',
420
+ 'warn',
421
+ true,
422
+ )
423
+ return null
424
+ }
425
+ showStatus(`Posting ${findingIds.length} as a single review...`, 'info', true)
426
+ try {
427
+ const r = await fetch('/api/post-review', {
428
+ method: 'POST',
429
+ headers: { 'content-type': 'application/json' },
430
+ body: JSON.stringify({ findingIds }),
431
+ })
432
+ const data = await r.json()
433
+ if (!r.ok) {
434
+ showStatus(`Post failed: ${data?.error || `HTTP ${r.status}`}`, 'warn', true)
435
+ return null
295
436
  }
437
+ return data
438
+ } catch (err) {
439
+ showStatus(`Post failed: ${err?.message || err}`, 'warn', true)
440
+ return null
296
441
  }
297
- if (toggled) recountSelected()
298
442
  }
299
443
 
300
- function selectNone() {
301
- let toggled = false
302
- for (const cb of allCheckboxes()) {
303
- if (cb.disabled) continue
304
- if (cb.checked) {
305
- cb.checked = false
306
- toggled = true
307
- post({
308
- type: 'deselect',
309
- findingId: cb.dataset.findingId,
310
- timestamp: Date.now(),
311
- })
444
+ function applyReviewResult(result) {
445
+ if (!result) return
446
+ for (const c of result.comments || []) {
447
+ if (c.status === 'posted') {
448
+ for (const el of findAnnotation(c.id)) {
449
+ el.setAttribute('data-posted', 'true')
450
+ const chip = el.querySelector('.status-chip')
451
+ if (chip) {
452
+ chip.className = 'status-chip posted'
453
+ chip.textContent = 'POSTED'
454
+ }
455
+ }
456
+ for (const cb of findCheckboxes(c.id)) {
457
+ cb.checked = false
458
+ cb.disabled = true
459
+ }
460
+ } else if (c.status === 'failed') {
461
+ for (const el of findAnnotation(c.id)) {
462
+ el.setAttribute('data-failed', 'true')
463
+ const chip = el.querySelector('.status-chip')
464
+ if (chip) {
465
+ chip.className = 'status-chip failed'
466
+ chip.textContent = 'FAILED'
467
+ }
468
+ }
312
469
  }
313
470
  }
314
- if (toggled) recountSelected()
471
+ updateSelectedCount()
472
+ const okCount = (result.comments || []).filter((c) => c.status === 'posted').length
473
+ const failCount = (result.comments || []).filter((c) => c.status === 'failed').length
474
+ const reviewBit = result.reviewId ? ` (review ${result.reviewId})` : ''
475
+ if (failCount === 0) {
476
+ showStatus(`Posted ${okCount}${reviewBit}.`, 'ok')
477
+ } else {
478
+ showStatus(`Posted ${okCount}, ${failCount} failed${reviewBit}.`, 'warn', true)
479
+ }
315
480
  }
316
481
 
317
- function setFocused(index) {
318
- const all = visibleFindings()
319
- for (const card of allFindings()) card.classList.remove('is-focused')
320
- if (index < 0 || index >= all.length) {
321
- focusedIndex = -1
482
+ async function handlePostSelected() {
483
+ const ids = []
484
+ const seen = new Set()
485
+ for (const cb of document.querySelectorAll('input[type="checkbox"][data-finding-id]')) {
486
+ if (!cb.checked || cb.disabled) continue
487
+ const id = cb.getAttribute('data-finding-id')
488
+ if (id && !seen.has(id)) {
489
+ seen.add(id)
490
+ ids.push(id)
491
+ }
492
+ }
493
+ if (ids.length === 0) {
494
+ showStatus('Nothing selected yet.', 'info')
322
495
  return
323
496
  }
324
- focusedIndex = index
325
- const target = all[index]
326
- target.classList.add('is-focused')
327
- target.scrollIntoView({ block: 'center', behavior: 'smooth' })
497
+ const result = await postToReview(ids)
498
+ applyReviewResult(result)
328
499
  }
329
500
 
330
- function moveFocus(delta) {
331
- const all = visibleFindings()
332
- if (all.length === 0) return
333
- if (focusedIndex === -1) {
334
- setFocused(delta > 0 ? 0 : all.length - 1)
501
+ async function handlePostRecommended() {
502
+ const ids = []
503
+ const seen = new Set()
504
+ for (const el of document.querySelectorAll('[data-finding-id][data-suggestion="false"]')) {
505
+ if (el.tagName.toLowerCase() === 'input') continue
506
+ if (el.getAttribute('data-posted') === 'true') continue
507
+ const id = el.getAttribute('data-finding-id')
508
+ if (id && !seen.has(id)) {
509
+ seen.add(id)
510
+ ids.push(id)
511
+ }
512
+ }
513
+ if (ids.length === 0) {
514
+ showStatus('Nothing to post.', 'info')
335
515
  return
336
516
  }
337
- const next = Math.min(all.length - 1, Math.max(0, focusedIndex + delta))
338
- setFocused(next)
517
+ const result = await postToReview(ids)
518
+ applyReviewResult(result)
339
519
  }
340
520
 
341
- function toggleFocusedSelection() {
342
- if (focusedIndex === -1) return
343
- const card = visibleFindings()[focusedIndex]
344
- if (!card) return
345
- const cb = card.querySelector('input[type="checkbox"][data-finding-id]')
346
- if (!(cb instanceof HTMLInputElement) || cb.disabled) return
347
- cb.checked = !cb.checked
348
- post({
349
- type: cb.checked ? 'select' : 'deselect',
350
- findingId: cb.dataset.findingId,
351
- timestamp: Date.now(),
352
- })
353
- recountSelected()
354
- }
521
+ // ---------------------------------------------------------------------------
522
+ // Event binding
523
+ // ---------------------------------------------------------------------------
355
524
 
356
525
  function bind() {
357
526
  document.addEventListener('change', (event) => {
@@ -359,10 +528,17 @@
359
528
  if (!(target instanceof HTMLInputElement)) return
360
529
  if (target.type !== 'checkbox') return
361
530
  if (!target.dataset.findingId) return
362
- recountSelected()
531
+ const id = target.dataset.findingId
532
+ // Sync the paired copy (inline .annot and .issue-card share the same id).
533
+ for (const cb of findCheckboxes(id)) {
534
+ if (cb === target) continue
535
+ if (cb.disabled) continue
536
+ cb.checked = target.checked
537
+ }
538
+ updateSelectedCount()
363
539
  post({
364
540
  type: target.checked ? 'select' : 'deselect',
365
- findingId: target.dataset.findingId,
541
+ findingId: id,
366
542
  timestamp: Date.now(),
367
543
  })
368
544
  })
@@ -371,8 +547,7 @@
371
547
  const target = event.target
372
548
  if (!(target instanceof HTMLElement)) return
373
549
 
374
- // Click outside the confirm popup (and not on the Post to PR trigger)
375
- // dismisses the popup, matching GitHub's small-popup behavior.
550
+ // Click outside the confirm popup dismisses it.
376
551
  if (isConfirmOpen()) {
377
552
  const insidePopup = target.closest('[data-role="confirm-bar"]')
378
553
  const onTrigger = target.closest('[data-action="post"]')
@@ -381,15 +556,63 @@
381
556
  }
382
557
  }
383
558
 
384
- const chip = target.closest('.filter-chip')
385
- if (chip instanceof HTMLElement) {
386
- toggleFilterChip(chip)
387
- return
388
- }
389
-
390
559
  const action = target.closest('[data-action]')
391
560
  if (!(action instanceof HTMLElement)) return
392
561
  switch (action.dataset.action) {
562
+ // -----------------------------------------------------------------------
563
+ // New Pylon-style actions
564
+ // -----------------------------------------------------------------------
565
+ case 'set-view':
566
+ handleSetView(action)
567
+ break
568
+ case 'select-file':
569
+ handleSelectFile(action)
570
+ break
571
+ case 'set-diff-mode':
572
+ handleSetDiffMode(action)
573
+ break
574
+ case 'prev-finding':
575
+ navigateFinding(-1)
576
+ break
577
+ case 'next-finding':
578
+ navigateFinding(1)
579
+ break
580
+ case 'toggle-suggestions':
581
+ handleToggleSuggestions(action)
582
+ break
583
+ case 'filter-sev':
584
+ handleFilterSev(action)
585
+ break
586
+ case 'select-sev':
587
+ handleSelectSev(action)
588
+ break
589
+ case 'select-recommended':
590
+ handleSelectRecommended()
591
+ break
592
+ case 'post-selected':
593
+ void handlePostSelected()
594
+ break
595
+ case 'post-recommended':
596
+ void handlePostRecommended()
597
+ break
598
+ case 'post-one': {
599
+ // Legacy per-finding send icon (hover button).
600
+ const id = action.getAttribute('data-finding-id')
601
+ if (id && isLive) {
602
+ openConfirm([id])
603
+ } else if (!isLive) {
604
+ showStatus(
605
+ `Cannot post from an archived view. Run \`magpie serve ${runId()}\` to make this live.`,
606
+ 'warn',
607
+ )
608
+ }
609
+ break
610
+ }
611
+
612
+ // -----------------------------------------------------------------------
613
+ // Legacy actions (kept so action-key strings remain present for tests;
614
+ // the new markup does not emit these data-action values).
615
+ // -----------------------------------------------------------------------
393
616
  case 'post': {
394
617
  const checked = allCheckboxes()
395
618
  .filter((el) => el.checked)
@@ -419,32 +642,29 @@
419
642
  break
420
643
  }
421
644
  case 'select-visible':
422
- selectMany((card) => !card.classList.contains('is-hidden'))
645
+ // Legacy; no-op in new layout.
423
646
  break
424
647
  case 'select-priority':
425
- selectMany(
426
- (card) =>
427
- !card.classList.contains('is-hidden') &&
428
- (card.dataset.severity === 'blocker' || card.dataset.severity === 'high'),
429
- )
648
+ // Legacy; no-op in new layout.
430
649
  break
431
- case 'select-none':
432
- selectNone()
650
+ case 'select-none': {
651
+ // Legacy: deselect all.
652
+ for (const cb of allCheckboxes()) {
653
+ if (cb.disabled) continue
654
+ if (cb.checked) {
655
+ cb.checked = false
656
+ post({ type: 'deselect', findingId: cb.dataset.findingId, timestamp: Date.now() })
657
+ }
658
+ }
659
+ updateSelectedCount()
433
660
  break
661
+ }
434
662
  case 'clear-filters':
435
- clearAllFilters()
663
+ // Legacy; no-op in new layout (filter-sev handles individual toggles).
436
664
  break
437
665
  }
438
666
  })
439
667
 
440
- const search = document.querySelector('[data-role="search"]')
441
- if (search instanceof HTMLInputElement) {
442
- search.addEventListener('input', () => {
443
- filters.search = search.value
444
- applyFilters()
445
- })
446
- }
447
-
448
668
  document.addEventListener('keydown', (event) => {
449
669
  const t = event.target
450
670
  const isTyping = t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement
@@ -455,38 +675,28 @@
455
675
  switch (event.key) {
456
676
  case 'j':
457
677
  event.preventDefault()
458
- moveFocus(1)
678
+ navigateFinding(1)
459
679
  break
460
680
  case 'k':
461
681
  event.preventDefault()
462
- moveFocus(-1)
682
+ navigateFinding(-1)
463
683
  break
464
684
  case 'x':
465
685
  case ' ':
466
686
  event.preventDefault()
467
- toggleFocusedSelection()
687
+ toggleFocusedFinding()
468
688
  break
469
- case 'a':
470
- event.preventDefault()
471
- selectMany((card) => !card.classList.contains('is-hidden'))
472
- break
473
- case 'n':
474
- event.preventDefault()
475
- selectNone()
476
- break
477
- case '/': {
478
- event.preventDefault()
479
- const s = document.querySelector('[data-role="search"]')
480
- if (s instanceof HTMLInputElement) s.focus()
481
- break
482
- }
483
689
  case 'Escape':
484
690
  event.preventDefault()
485
691
  if (isConfirmOpen()) {
486
692
  closeConfirm()
487
693
  } else {
488
- clearAllFilters()
489
- setFocused(-1)
694
+ // Clear focused annotation.
695
+ for (const el of document.querySelectorAll('.annot.is-focused')) {
696
+ el.classList.remove('is-focused')
697
+ }
698
+ const path = document.body.dataset.selectedFile ?? ''
699
+ fileFindingIdx.set(path, -1)
490
700
  }
491
701
  break
492
702
  }