iterate-plugin 2.4.0 → 2.6.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.
package/lib/client.js CHANGED
@@ -35,6 +35,20 @@ import {
35
35
  toKnownIntentionalYaml,
36
36
  buildApplyInstruction,
37
37
  collectIgnoredEntries,
38
+ filterFindingsWithIndices,
39
+ buildFilterOptions,
40
+ countVerdicts,
41
+ batchSetVerdict,
42
+ setAllVerdicts,
43
+ buildRoundHistory,
44
+ buildFindingTrend,
45
+ computeTrendMetrics,
46
+ trendMax,
47
+ buildCompletionSummary,
48
+ buildConfigEditGuide,
49
+ keyToVerdict,
50
+ allVerdictKeys,
51
+ buildRuntimeStatusGuide,
38
52
  SEVERITY_LABEL,
39
53
  SEVERITY_COLOR,
40
54
  } from './parse.js'
@@ -150,6 +164,41 @@ const ITERATE_CSS = `
150
164
  .iterate-settings-title { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); }
151
165
  .iterate-settings-desc { font-size: 12px; color: var(--dsw-alias-label-secondary); margin-top: 3px; line-height: 1.5; }
152
166
  .iterate-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 6px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); font-size: 11px; color: var(--dsw-alias-label-secondary); }
167
+
168
+ /* Filter bar + batch toolbar (triage) */
169
+ .iterate-filter { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 8px 14px; border-bottom: 1px solid var(--dsw-alias-border-l1); font-size: 11px; color: var(--dsw-alias-label-secondary); }
170
+ .iterate-filter-select { padding: 4px 8px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-size: 11px; }
171
+ .iterate-filter-search { padding: 4px 8px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-size: 11px; min-width: 140px; }
172
+ .iterate-filter-search::placeholder { color: var(--dsw-alias-label-secondary); }
173
+ .iterate-filter-count { margin-left: auto; white-space: nowrap; }
174
+ .iterate-batch { display: flex; align-items: center; gap: 6px; padding: 8px 14px; border-bottom: 1px solid var(--dsw-alias-border-l1); font-size: 11px; color: var(--dsw-alias-label-secondary); }
175
+ .iterate-batch-label { margin-right: 2px; }
176
+ .iterate-batch-btn { padding: 3px 8px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); font-size: 11px; cursor: pointer; }
177
+ .iterate-batch-btn:hover { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
178
+ .iterate-finding[data-selected] { outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; background: color-mix(in srgb, var(--dsw-alias-brand-primary) 6%, transparent); }
179
+
180
+ /* Trend chart (inline SVG) */
181
+ .iterate-trend { display: flex; align-items: flex-end; gap: 3px; height: 26px; padding: 2px 0; }
182
+ .iterate-trend-bar { width: 6px; border-radius: 2px 2px 0 0; background: color-mix(in srgb, var(--dsw-alias-brand-primary) 70%, var(--dsw-alias-bg-layer-2)); }
183
+ .iterate-trend-bar[data-hot] { background: var(--dsw-alias-brand-primary); }
184
+
185
+ /* History panel */
186
+ .iterate-history { margin: 10px 0; border: 1px solid var(--dsw-alias-border-l1); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); overflow: hidden; }
187
+ .iterate-history-body { padding: 10px 14px; }
188
+ .iterate-history-table { width: 100%; border-collapse: collapse; font-size: 11px; color: var(--dsw-alias-label-primary); }
189
+ .iterate-history-table th { text-align: left; font-weight: 600; color: var(--dsw-alias-label-secondary); padding: 4px 8px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
190
+ .iterate-history-table td { padding: 4px 8px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
191
+ .iterate-history-table tr:last-child td { border-bottom: none; }
192
+ .iterate-history-num { font-weight: 600; }
193
+ .iterate-history-trendline { margin-top: 10px; font-size: 11px; color: var(--dsw-alias-label-secondary); }
194
+ .iterate-completion { margin-top: 8px; padding: 6px 10px; border-radius: 8px; font-size: 11px; }
195
+ .iterate-completion[data-ok] { border: 1px solid var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); }
196
+ .iterate-completion[data-warn] { border: 1px solid var(--dsw-alias-state-warn-primary); color: var(--dsw-alias-state-warn-primary); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 10%, transparent); }
197
+ .iterate-capsule[data-ok] { border-color: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary); }
198
+ .iterate-settings-guide { white-space: pre-wrap; padding: 8px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-family: var(--dsw-font-mono, ui-monospace, monospace); font-size: 11px; max-height: 180px; overflow: auto; }
199
+ .iterate-chip[data-ok] { border-color: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); }
200
+ .iterate-batch-check { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); font-size: 11px; cursor: pointer; }
201
+ .iterate-batch-check input { margin: 0; cursor: pointer; }
153
202
  `
154
203
 
155
204
  // ─── Small helpers ───────────────────────────────────────────────────────────
@@ -181,6 +230,7 @@ function createStorage() {
181
230
  get(key) { return window.localStorage.getItem(key) },
182
231
  set(key, value) { window.localStorage.setItem(key, value) },
183
232
  remove(key) { window.localStorage.removeItem(key) },
233
+ keys() { return Object.keys(window.localStorage) },
184
234
  }
185
235
  } catch {
186
236
  const mem = new Map()
@@ -188,8 +238,29 @@ function createStorage() {
188
238
  get(key) { return mem.has(key) ? mem.get(key) : null },
189
239
  set(key, value) { mem.set(key, value) },
190
240
  remove(key) { mem.delete(key) },
241
+ keys() { return [...mem.keys()] },
242
+ }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Remove every stored key with the given prefix (e.g. all triage verdicts).
248
+ * Returns how many keys were removed.
249
+ */
250
+ function removeStorageByPrefix(prefix) {
251
+ if (!storage) return 0
252
+ let removed = 0
253
+ try {
254
+ for (const key of storage.keys()) {
255
+ if (key.startsWith(prefix)) {
256
+ storage.remove(key)
257
+ removed++
258
+ }
191
259
  }
260
+ } catch (err) {
261
+ log('failed to clear storage prefix', prefix, err)
192
262
  }
263
+ return removed
193
264
  }
194
265
 
195
266
  /** Copy text to the clipboard, returning whether it succeeded. */
@@ -214,9 +285,14 @@ let themeDisposer = null
214
285
  let themeEnabled = true
215
286
  const roundPulseListeners = []
216
287
 
217
- /** Emit a "round changed" pulse to subscribed components. */
218
- function emitRoundPulse(round) {
219
- for (const fn of roundPulseListeners.slice()) fn(round)
288
+ /**
289
+ * Emit a "round changed" pulse to subscribed components.
290
+ * The payload is `{ round, converged }` so the overlay capsule can surface
291
+ * completion AND convergence in one notification.
292
+ */
293
+ function emitRoundPulse(round, converged) {
294
+ const payload = { round, converged: converged === true }
295
+ for (const fn of roundPulseListeners.slice()) fn(payload)
220
296
  }
221
297
 
222
298
  /** Apply the iterate theme skin (idempotent). */
@@ -262,6 +338,22 @@ function latestReport(session) {
262
338
  return raw ? normalizeReport(raw) : null
263
339
  }
264
340
 
341
+ /** Inline bar chart of findings-by-round (pure divs, no SVG needed). */
342
+ function TrendChart({ points }) {
343
+ if (!points || points.length === 0) return null
344
+ const max = trendMax(points)
345
+ const bars = points.map((p) =>
346
+ React.createElement('div', {
347
+ key: p.round,
348
+ className: 'iterate-trend-bar',
349
+ 'data-hot': p.count > 0 ? '' : undefined,
350
+ title: `Round ${p.round}:${p.count} 项`,
351
+ style: { height: `${Math.max(4, Math.round((p.count / max) * 24))}px` },
352
+ }),
353
+ )
354
+ return React.createElement('div', { className: 'iterate-trend', title: '各轮发现数量趋势' }, ...bars)
355
+ }
356
+
265
357
  /** Dashboard: live convergence strip above the composer. */
266
358
  function ConvergenceDashboard(props) {
267
359
  const [pulseKey, setPulseKey] = React.useState(0)
@@ -270,7 +362,8 @@ function ConvergenceDashboard(props) {
270
362
  React.useEffect(() => {
271
363
  if (!report) return
272
364
  const cur = getCurrentRound(report)
273
- emitRoundPulse(cur)
365
+ const conv = report.convergence
366
+ emitRoundPulse(cur, conv && conv.converged === true)
274
367
  setPulseKey((k) => k + 1)
275
368
  }, [report && hashReport(report) + ':' + getCurrentRound(report)])
276
369
 
@@ -281,6 +374,7 @@ function ConvergenceDashboard(props) {
281
374
  const progress = computeConvergenceProgress(report)
282
375
  const stats = severityStats(report)
283
376
  const dims = groupByDimension(report)
377
+ const trend = computeTrendMetrics(report)
284
378
 
285
379
  const dimBadges = Object.keys(dims).slice(0, 6).map((dim) =>
286
380
  React.createElement(
@@ -290,6 +384,17 @@ function ConvergenceDashboard(props) {
290
384
  ),
291
385
  )
292
386
 
387
+ // Fix-count badge: show when the report has a fixes summary.
388
+ const mode = report.mode
389
+ const summary = report.summary
390
+ const isNormal = mode === 'normal'
391
+ const fixCount = isNormal && summary && typeof summary.fixedCount === 'number' ? summary.fixedCount : null
392
+ const fixBadge = fixCount !== null
393
+ ? React.createElement('span', { className: 'iterate-metric', key: 'fixes' },
394
+ '\u{1F527} ' + String(fixCount) + ' fixes',
395
+ )
396
+ : null
397
+
293
398
  return React.createElement(
294
399
  'div',
295
400
  { 'data-iterate-root': '', 'data-iterate': 'dashboard', className: 'iterate-dashboard' },
@@ -313,6 +418,8 @@ function ConvergenceDashboard(props) {
313
418
  React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.medium } }),
314
419
  stats.medium,
315
420
  ),
421
+ fixBadge,
422
+ React.createElement(TrendChart, { points: trend.points }),
316
423
  ...dimBadges,
317
424
  )
318
425
  }
@@ -320,6 +427,7 @@ function ConvergenceDashboard(props) {
320
427
  /** Stats card (empty-findings case of the turn-tail chain). */
321
428
  function StatsCard(props) {
322
429
  const report = props.report
430
+ const [showHistory, setShowHistory] = React.useState(false)
323
431
  const stats = severityStats(report)
324
432
  const total = stats.critical + stats.high + stats.medium + stats.low
325
433
  const rows = [
@@ -334,6 +442,21 @@ function StatsCard(props) {
334
442
  ),
335
443
  )
336
444
 
445
+ const history = buildRoundHistory(report)
446
+ const trend = computeTrendMetrics(report)
447
+ const historyRows = history.map((h) =>
448
+ React.createElement('tr', { key: h.round },
449
+ React.createElement('td', { className: 'iterate-history-num' }, `Round ${h.round}`),
450
+ React.createElement('td', {}, String(h.count)),
451
+ React.createElement('td', { style: { color: SEVERITY_COLOR.critical } }, String(h.critical)),
452
+ React.createElement('td', { style: { color: SEVERITY_COLOR.high } }, String(h.high)),
453
+ React.createElement('td', { style: { color: SEVERITY_COLOR.medium } }, String(h.medium)),
454
+ React.createElement('td', { style: { color: SEVERITY_COLOR.low } }, String(h.low)),
455
+ ),
456
+ )
457
+ const trendLine = `首轮 ${trend.firstRound} → 末轮 ${trend.lastRound} 项,降幅 ${trend.reductionPercent}%${trend.converged ? ',已收敛' : ''}`
458
+ const completion = buildCompletionSummary(report)
459
+
337
460
  return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'stats', className: 'iterate-stats' },
338
461
  React.createElement('div', { className: 'iterate-triage-head' },
339
462
  React.createElement('span', {}, 'Iterate · 收敛统计'),
@@ -342,10 +465,39 @@ function StatsCard(props) {
342
465
  ),
343
466
  ),
344
467
  React.createElement('div', { className: 'iterate-stats-grid' }, ...rows),
468
+ React.createElement('div', { className: 'iterate-completion', 'data-ok': trend.converged ? '' : undefined, 'data-warn': trend.converged ? undefined : '' }, completion),
469
+ React.createElement('div', { className: 'iterate-triage-foot', style: { marginTop: 8, borderRadius: 8 } },
470
+ React.createElement('span', {}, trendLine),
471
+ React.createElement('button', {
472
+ className: 'iterate-btn',
473
+ 'data-primary': showHistory ? '' : undefined,
474
+ onClick: () => setShowHistory((v) => !v),
475
+ }, showHistory ? '收起历史' : '历史 / 趋势'),
476
+ ),
477
+ showHistory
478
+ ? React.createElement('div', { className: 'iterate-history-body' },
479
+ React.createElement('table', { className: 'iterate-history-table' },
480
+ React.createElement('thead', {},
481
+ React.createElement('tr', {},
482
+ React.createElement('th', {}, '轮次'),
483
+ React.createElement('th', {}, '发现'),
484
+ React.createElement('th', { style: { color: SEVERITY_COLOR.critical } }, 'CRIT'),
485
+ React.createElement('th', { style: { color: SEVERITY_COLOR.high } }, 'HIGH'),
486
+ React.createElement('th', { style: { color: SEVERITY_COLOR.medium } }, 'MED'),
487
+ React.createElement('th', { style: { color: SEVERITY_COLOR.low } }, 'LOW'),
488
+ ),
489
+ ),
490
+ React.createElement('tbody', {}, ...historyRows),
491
+ ),
492
+ React.createElement('div', { className: 'iterate-history-trendline' },
493
+ React.createElement(TrendChart, { points: trend.points }),
494
+ ),
495
+ )
496
+ : null,
345
497
  )
346
498
  }
347
499
 
348
- /** Triage panel: per-finding y / n / a with localStorage persistence. */
500
+ /** Triage panel: per-finding y / n / a with filters, batch ops, keyboard shortcuts. */
349
501
  function TriagePanel(props) {
350
502
  const report = props.report
351
503
  const findings = report.findings
@@ -369,17 +521,81 @@ function TriagePanel(props) {
369
521
  })
370
522
  const [payload, setPayload] = React.useState(null)
371
523
  const [copied, setCopied] = React.useState(false)
524
+ const [filter, setFilter] = React.useState({ severities: [], dimensions: [], search: '' })
525
+ const [selected, setSelected] = React.useState(null)
526
+ const [selectAll, setSelectAll] = React.useState(false)
527
+
528
+ /** Persist + return the next verdicts state. */
529
+ const persistVerdicts = (next) => {
530
+ if (storage) storage.set(storageKey, JSON.stringify(next))
531
+ return next
532
+ }
372
533
 
373
534
  const setVerdict = (index, verdict) => {
374
- setVerdicts((prev) => {
375
- const next = { ...prev, [String(index)]: verdict }
376
- if (storage) storage.set(storageKey, JSON.stringify(next))
377
- return next
378
- })
535
+ setVerdicts((prev) => persistVerdicts({ ...prev, [String(index)]: verdict }))
379
536
  }
380
537
 
538
+ // ── Filtering (visible findings + their original indices) ───────────────
539
+ const { filtered, indices } = filterFindingsWithIndices(findings, filter)
540
+ const indicesKey = indices.join(',')
541
+ const options = buildFilterOptions(findings)
542
+ const filterActive = filter.severities.length > 0 || filter.dimensions.length > 0 || filter.search !== ''
543
+
544
+ const setSeverityFilter = (value) => setFilter((f) => ({ ...f, severities: value ? [value] : [] }))
545
+ const setDimensionFilter = (value) => setFilter((f) => ({ ...f, dimensions: value ? [value] : [] }))
546
+ const setSearchFilter = (value) => setFilter((f) => ({ ...f, search: value }))
547
+ const clearFilter = () => setFilter({ severities: [], dimensions: [], search: '' })
548
+
549
+ // ── Batch operations (apply to the currently VISIBLE findings, or to ALL
550
+ // findings when the select-all toggle is on) ──────────────────────────
551
+ const allIndices = allVerdictKeys(verdicts)
552
+ const batchTarget = selectAll ? allIndices : indices
553
+ const applyBatch = (verdict) => {
554
+ setVerdicts((prev) => persistVerdicts(batchSetVerdict(prev, batchTarget, verdict)))
555
+ }
556
+ const applyBatchAll = (verdict) => {
557
+ setVerdicts((prev) => persistVerdicts(setAllVerdicts(prev, verdict)))
558
+ }
559
+ const doResetVerdicts = () => {
560
+ setVerdicts((prev) => persistVerdicts(setAllVerdicts(prev, 'keep')))
561
+ setSelectAll(false)
562
+ }
563
+
564
+ // ── Keyboard shortcuts (y / n / a on the selected finding, ↑/↓ to move) ──
565
+ React.useEffect(() => {
566
+ const doc = typeof document !== 'undefined' ? document : null
567
+ if (!doc) return
568
+ const onKeyDown = (ev) => {
569
+ const t = ev.target
570
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return
571
+ const verdict = keyToVerdict(ev.key)
572
+ if (verdict && selected !== null && indices.includes(selected)) {
573
+ ev.preventDefault()
574
+ setVerdict(selected, verdict)
575
+ const pos = indices.indexOf(selected)
576
+ const nextIdx = indices[pos + 1]
577
+ if (nextIdx !== undefined) setSelected(nextIdx)
578
+ return
579
+ }
580
+ if (ev.key === 'ArrowDown' && indices.length > 0) {
581
+ ev.preventDefault()
582
+ const pos = selected === null ? -1 : indices.indexOf(selected)
583
+ setSelected(indices[Math.min(pos + 1, indices.length - 1)])
584
+ return
585
+ }
586
+ if (ev.key === 'ArrowUp' && indices.length > 0) {
587
+ ev.preventDefault()
588
+ const pos = selected === null ? 0 : indices.indexOf(selected)
589
+ setSelected(indices[Math.max(pos - 1, 0)])
590
+ }
591
+ }
592
+ doc.addEventListener('keydown', onKeyDown)
593
+ return () => doc.removeEventListener('keydown', onKeyDown)
594
+ }, [selected, indicesKey])
595
+
381
596
  const ignored = collectIgnoredEntries(verdicts, findings)
382
597
  const ignoredCount = ignored.length
598
+ const counts = countVerdicts(verdicts)
383
599
 
384
600
  const doCopyYaml = () => {
385
601
  const yaml = toKnownIntentionalYaml(ignored)
@@ -396,9 +612,11 @@ function TriagePanel(props) {
396
612
  if (text) copyText(text)
397
613
  }
398
614
 
399
- const rows = findings.map((finding, index) => {
615
+ const rows = filtered.map((finding, i) => {
616
+ const index = indices[i]
400
617
  const severity = finding.severity || 'low'
401
618
  const verdict = verdicts[String(index)] || 'keep'
619
+ const isSelected = selected === index
402
620
  const btn = (label, value, title) =>
403
621
  React.createElement(
404
622
  'button',
@@ -412,7 +630,12 @@ function TriagePanel(props) {
412
630
  label,
413
631
  )
414
632
 
415
- return React.createElement('div', { key: String(index), className: 'iterate-finding' },
633
+ return React.createElement('div', {
634
+ key: String(index),
635
+ className: 'iterate-finding',
636
+ 'data-selected': isSelected ? '' : undefined,
637
+ onClick: () => setSelected(index),
638
+ },
416
639
  React.createElement('div', { className: 'iterate-finding-meta' },
417
640
  React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR[severity] || SEVERITY_COLOR.low } }),
418
641
  React.createElement('span', {}, String(SEVERITY_LABEL[severity] || 'LOW')),
@@ -431,12 +654,53 @@ function TriagePanel(props) {
431
654
 
432
655
  return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'triage', className: 'iterate-triage' },
433
656
  React.createElement('div', { className: 'iterate-triage-head' },
434
- React.createElement('span', {}, `Iterate · Findings 分诊 (${findings.length})`),
435
- React.createElement('span', { className: 'iterate-triage-hint' }, 'y=修复 · n=跳过 · a=已知有意'),
657
+ React.createElement('span', {}, `Iterate · Findings 分诊 (${filtered.length}/${findings.length})`),
658
+ React.createElement('span', { className: 'iterate-triage-hint' }, 'y=修复 · n=跳过 · a=已知有意 · ↑/↓ 选择'),
659
+ ),
660
+ React.createElement('div', { className: 'iterate-filter' },
661
+ React.createElement('select', { className: 'iterate-filter-select', value: filter.severities[0] || '', onChange: (e) => setSeverityFilter(e.target.value), title: '按严重度筛选' },
662
+ React.createElement('option', { value: '' }, '全部严重度'),
663
+ ...options.severities.map((s) =>
664
+ React.createElement('option', { key: s.value, value: s.value }, `${SEVERITY_LABEL[s.value] || s.value} (${s.count})`),
665
+ ),
666
+ ),
667
+ React.createElement('select', { className: 'iterate-filter-select', value: filter.dimensions[0] || '', onChange: (e) => setDimensionFilter(e.target.value), title: '按维度筛选' },
668
+ React.createElement('option', { value: '' }, '全部维度'),
669
+ ...options.dimensions.map((d) =>
670
+ React.createElement('option', { key: d.value, value: d.value }, `${d.value} (${d.count})`),
671
+ ),
672
+ ),
673
+ React.createElement('input', {
674
+ className: 'iterate-filter-search',
675
+ type: 'search',
676
+ placeholder: '搜索文件 / 摘要…',
677
+ value: filter.search,
678
+ onChange: (e) => setSearchFilter(e.target.value),
679
+ }),
680
+ React.createElement('span', { className: 'iterate-filter-count' },
681
+ filterActive
682
+ ? React.createElement('button', { className: 'iterate-batch-btn', onClick: clearFilter }, `清除筛选(显示 ${filtered.length})`)
683
+ : `共 ${findings.length} 项`,
684
+ ),
685
+ ),
686
+ React.createElement('div', { className: 'iterate-batch' },
687
+ React.createElement('span', { className: 'iterate-batch-label' }, '批量:'),
688
+ React.createElement('label', { className: 'iterate-batch-check', title: '勾选后批量按钮作用于全部 findings,否则仅当前可见' },
689
+ React.createElement('input', { type: 'checkbox', checked: selectAll, onChange: (e) => setSelectAll(e.target.checked) }),
690
+ selectAll ? `全部 ${allIndices.length}` : '全选',
691
+ ),
692
+ React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('keep') }, '全部 y'),
693
+ React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('skip') }, '全部 n'),
694
+ React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('ignore') }, '全部 a'),
695
+ React.createElement('span', { className: 'iterate-batch-label', style: { marginLeft: 8 } }, '全部:'),
696
+ React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatchAll('keep') }, 'y'),
697
+ React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatchAll('skip') }, 'n'),
698
+ React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatchAll('ignore') }, 'a'),
699
+ React.createElement('button', { className: 'iterate-batch-btn', onClick: doResetVerdicts, title: '把所有判定恢复为默认 y(修复)' }, '重置'),
436
700
  ),
437
701
  ...rows,
438
702
  React.createElement('div', { className: 'iterate-triage-foot' },
439
- React.createElement('span', {}, `待写回 known_intentional:${ignoredCount} 条`),
703
+ React.createElement('span', {}, `y ${counts.keep} · n ${counts.skip} · a ${counts.ignore} · 待写回 known_intentional:${ignoredCount} 条`),
440
704
  React.createElement('span', { style: { display: 'flex', gap: 6 } },
441
705
  React.createElement('button', { className: 'iterate-btn', 'data-primary': '', 'data-copied': copied ? '' : undefined, onClick: doCopyYaml }, copied ? '已复制' : '复制 known_intentional'),
442
706
  React.createElement('button', { className: 'iterate-btn', onClick: doBuildInstruction }, '生成应用指令'),
@@ -471,15 +735,18 @@ function TurnTailEntry(props) {
471
735
  return React.createElement(TriagePanel, { report })
472
736
  }
473
737
 
474
- /** Progress capsule: briefly surfaces round-completion in a frame overlay. */
738
+ /** Progress capsule: briefly surfaces round-completion (incl. convergence). */
475
739
  function ProgressCapsule() {
476
740
  const [info, setInfo] = React.useState(null)
477
741
  React.useEffect(() => {
478
742
  let timer = null
479
- const listener = (round) => {
480
- setInfo(`Round ${round} 完成`)
743
+ const listener = (payload) => {
744
+ const converged = payload && payload.converged === true
745
+ const round = payload && typeof payload.round === 'number' ? payload.round : '?'
746
+ setInfo({ text: converged ? `Round ${round} 完成 · 已收敛` : `Round ${round} 完成`, ok: converged })
481
747
  if (timer) clearTimeout(timer)
482
- timer = setTimeout(() => setInfo(null), 2400)
748
+ // Converged notifications stay a bit longer.
749
+ timer = setTimeout(() => setInfo(null), converged ? 3600 : 2400)
483
750
  }
484
751
  roundPulseListeners.push(listener)
485
752
  return () => {
@@ -489,13 +756,18 @@ function ProgressCapsule() {
489
756
  }
490
757
  }, [])
491
758
  if (!info) return null
492
- return React.createElement('div', { 'data-iterate-root': '', className: 'iterate-capsule' }, info)
759
+ return React.createElement('div', { 'data-iterate-root': '', className: 'iterate-capsule', 'data-ok': info.ok ? '' : undefined }, info.text)
493
760
  }
494
761
 
495
762
  /** Settings page section (root scope; reads localStorage + latest known data). */
496
763
  function SettingsPanel() {
497
764
  const [enabled, setEnabled] = React.useState(themeEnabled)
498
765
  const [copied, setCopied] = React.useState(false)
766
+ const [showGuide, setShowGuide] = React.useState(false)
767
+ const [clearedCount, setClearedCount] = React.useState(null)
768
+ const [showStatus, setShowStatus] = React.useState(false)
769
+ const guide = buildConfigEditGuide()
770
+ const statusGuide = buildRuntimeStatusGuide()
499
771
 
500
772
  const toggleTheme = () => {
501
773
  const next = !enabled
@@ -504,17 +776,17 @@ function SettingsPanel() {
504
776
  }
505
777
 
506
778
  const doCopyGuide = () => {
507
- const guide =
508
- 'iterate 配置指引\n' +
509
- '----------------\n' +
510
- '目标 / 维度 / 最大轮数在项目的 iterate.config.yaml 中配置。\n' +
511
- '让模型修改配置:请调用 iterate_config(读)与 iterate_triage(写 known_intentional),\n' +
512
- '并说明你希望调整的字段(goal、dimensions、max_rounds 等)。\n'
513
779
  copyText(guide)
514
780
  setCopied(true)
515
781
  setTimeout(() => setCopied(false), 1600)
516
782
  }
517
783
 
784
+ const doClearTriage = () => {
785
+ const count = removeStorageByPrefix(TRIAGE_STORAGE_PREFIX)
786
+ setClearedCount(count)
787
+ setTimeout(() => setClearedCount(null), 3000)
788
+ }
789
+
518
790
  return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'settings', className: 'iterate-settings' },
519
791
  React.createElement('div', { className: 'iterate-settings-title' }, 'iterate 设置'),
520
792
  React.createElement('div', { className: 'iterate-settings-row' },
@@ -533,15 +805,40 @@ function SettingsPanel() {
533
805
  React.createElement('div', { className: 'iterate-settings-title' }, '分诊持久化'),
534
806
  React.createElement('div', { className: 'iterate-settings-desc' }, '分诊面板的 y/n/a 判定保存在本地浏览器(localStorage),刷新会话后仍保留。'),
535
807
  ),
536
- React.createElement('span', { className: 'iterate-chip' }, '本地保存'),
808
+ React.createElement('span', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
809
+ React.createElement('span', { className: 'iterate-chip' }, '本地保存'),
810
+ React.createElement('button', {
811
+ className: 'iterate-btn',
812
+ onClick: doClearTriage,
813
+ title: '清除所有分诊判定记录',
814
+ 'data-primary': clearedCount !== null ? '' : undefined,
815
+ 'data-copied': clearedCount !== null ? '' : undefined,
816
+ }, clearedCount !== null ? `已清除 ${clearedCount} 条` : '清除分诊'),
817
+ ),
537
818
  ),
538
819
  React.createElement('div', { className: 'iterate-settings-row' },
539
820
  React.createElement('div', {},
540
821
  React.createElement('div', { className: 'iterate-settings-title' }, '配置管理'),
541
- React.createElement('div', { className: 'iterate-settings-desc' }, '目标 / 维度 / 最大轮数写在项目的 iterate.config.yaml。复制下方指引,让模型为你调整。'),
822
+ React.createElement('div', { className: 'iterate-settings-desc' }, '目标 / 维度 / 最大轮数写在项目的 iterate.config.yaml。复制指引让模型为你调整,或展开查看可编辑字段。'),
823
+ ),
824
+ React.createElement('span', { style: { display: 'flex', gap: 6 } },
825
+ React.createElement('button', { className: 'iterate-btn', 'data-primary': '', 'data-copied': copied ? '' : undefined, onClick: doCopyGuide }, copied ? '已复制' : '复制指引'),
826
+ React.createElement('button', { className: 'iterate-btn', onClick: () => setShowGuide((v) => !v) }, showGuide ? '收起' : '查看'),
542
827
  ),
543
- React.createElement('button', { className: 'iterate-btn', 'data-primary': '', 'data-copied': copied ? '' : undefined, onClick: doCopyGuide }, copied ? '已复制' : '复制指引'),
544
828
  ),
829
+ showGuide
830
+ ? React.createElement('div', { className: 'iterate-settings-guide' }, guide)
831
+ : null,
832
+ React.createElement('div', { className: 'iterate-settings-row' },
833
+ React.createElement('div', {},
834
+ React.createElement('div', { className: 'iterate-settings-title' }, '状态概览'),
835
+ React.createElement('div', { className: 'iterate-settings-desc' }, '运行时产物布局与清理指引。iterate_status / iterate_history / iterate_prune 工具用于查看和管理。'),
836
+ ),
837
+ React.createElement('button', { className: 'iterate-btn', onClick: () => setShowStatus((v) => !v) }, showStatus ? '收起' : '查看'),
838
+ ),
839
+ showStatus
840
+ ? React.createElement('div', { className: 'iterate-settings-guide' }, statusGuide)
841
+ : null,
545
842
  )
546
843
  }
547
844