iterate-plugin 2.7.2 → 2.7.3

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.
@@ -0,0 +1,1218 @@
1
+ /**
2
+ * src/client/index.ts — iterate-plugin browser half.
3
+ *
4
+ * Built by `scripts/build-client.mjs` (esbuild) into `lib/client.js`, a
5
+ * single-file artifact for the dsh web shell's GUI loader:
6
+ *
7
+ * window.__ModuleLoader__.load({ id: "iterate-plugin", factory: (require) => {...} })
8
+ *
9
+ * The factory's injected `require` resolves externals (react and co) from the
10
+ * shell's frozen platform module table. All local code — this module plus the
11
+ * pure parse helpers imported from `lib/parse.js` — is bundled inline, so the
12
+ * artifact has no relative imports at runtime. This mirrors the dsh-hooks
13
+ * client contract exactly (see tsdown.config.ts in PeterBon/dsh-hooks).
14
+ *
15
+ * Module contract (same as dsh-hooks / task-board plugins):
16
+ * - `name`: loader id this module registers under.
17
+ * - `inject`: services the shell must have up before `apply` runs.
18
+ * - `apply(ctx)`: called by the client runtime with the ClientContext.
19
+ *
20
+ * Implements the §14.4 / §16 six-part UI layer:
21
+ * 1. Convergence dashboard -> conversation.input.dock
22
+ * 2. Findings triage panel -> conversation.chat.turnTail
23
+ * 3. Iteration stats card -> conversation.chat.turnTail (same chain, empty-findings case)
24
+ * 4. iterate theme skin -> theme.overrideTokens
25
+ * 5. Progress event linkage -> ctx.on('slots/changed') + round-pulse + shell.overlay capsule
26
+ * 6. Settings page section -> settings.section
27
+ *
28
+ * Design notes:
29
+ * - All styles are injected via one <style> tag; colors use dsh token
30
+ * variables (`--dsw-*`).
31
+ * - Defensive by design: every service lookup is optional and guarded, so a
32
+ * missing slot/theme degrades gracefully instead of crashing the UI.
33
+ */
34
+
35
+ import * as React from 'react'
36
+ import {
37
+ findReportInObject,
38
+ scanSessionForReport,
39
+ scanSessionForRunSummary,
40
+ extractVerdict,
41
+ normalizeReport,
42
+ computeConvergenceProgress,
43
+ getCurrentRound,
44
+ getTotalRounds,
45
+ severityStats,
46
+ groupByDimension,
47
+ buildTriageState,
48
+ hashReport,
49
+ toKnownIntentionalYaml,
50
+ buildApplyInstruction,
51
+ collectIgnoredEntries,
52
+ filterFindingsWithIndices,
53
+ buildFilterOptions,
54
+ countVerdicts,
55
+ batchSetVerdict,
56
+ setAllVerdicts,
57
+ buildRoundHistory,
58
+ buildFindingTrend,
59
+ computeTrendMetrics,
60
+ trendMax,
61
+ buildCompletionSummary,
62
+ buildConfigEditGuide,
63
+ keyToVerdict,
64
+ allVerdictKeys,
65
+ buildRuntimeStatusGuide,
66
+ SEVERITY_LABEL,
67
+ SEVERITY_COLOR,
68
+ } from '../../lib/parse.js'
69
+
70
+ // ─── Module contract ─────────────────────────────────────────────────────────
71
+
72
+ /** Loader id this client module registers under. */
73
+ export const name = 'iterate-plugin'
74
+
75
+ /** Required services: slots + theme must be up before this plugin applies. */
76
+ export const inject = ['slots', 'theme'] as const
77
+
78
+ // ─── Client context / service types ─────────────────────────────────────────
79
+
80
+ /** A single finding produced by the harness review. */
81
+ interface IterateFinding {
82
+ [key: string]: unknown
83
+ dimension?: string
84
+ file?: string
85
+ line?: number
86
+ severity?: string
87
+ summary?: string
88
+ failure_scenario?: string
89
+ suggested_fix?: string
90
+ }
91
+
92
+ /** Normalized review report consumed by the triage / dashboard components. */
93
+ interface ReviewReport {
94
+ [key: string]: unknown
95
+ mode?: string
96
+ findings?: IterateFindings
97
+ rounds?: unknown[]
98
+ convergence?: {
99
+ converged?: boolean
100
+ totalRounds?: number
101
+ findingsByRound?: number[]
102
+ stoppedReason?: string
103
+ }
104
+ summary?: {
105
+ totalFindings?: number
106
+ critical?: number
107
+ high?: number
108
+ medium?: number
109
+ low?: number
110
+ fixedCount?: number
111
+ byDimension?: Record<string, number>
112
+ }
113
+ }
114
+
115
+ /** Non-null findings list (index-signature compatible). */
116
+ type IterateFindings = IterateFinding[]
117
+
118
+ /** Registration metadata handed to the slots `register` call. */
119
+ interface SlotsRegistrationMeta {
120
+ name: string
121
+ id: string
122
+ order?: number
123
+ label?: () => string
124
+ select?: (owner: SlotProps) => { matched: boolean } | null
125
+ }
126
+
127
+ /** Client slots service (same surface dsh-hooks relies on). */
128
+ interface SlotsService {
129
+ inject(slot: string, getLayer?: () => void): void
130
+ register(meta: SlotsRegistrationMeta, render: (props: SlotProps) => unknown): () => void
131
+ }
132
+
133
+ /** Client theme service exposing token overrides. */
134
+ interface ThemeService {
135
+ overrideTokens(source: string, tokens: IterateTokenMap): () => void
136
+ }
137
+
138
+ /** Opaque slot props: heterogeneous per-slot payloads. */
139
+ type SlotProps = Record<string, unknown>
140
+
141
+ /** Token map passed to theme.overrideTokens (light + dark per dsw token). */
142
+ type IterateTokenMap = Record<string, { light: string; dark: string }>
143
+
144
+ /** Subset of the dsh client runtime context used by this plugin. */
145
+ interface ClientContext {
146
+ slots?: SlotsService
147
+ theme?: ThemeService
148
+ get?(service: string, optional?: boolean): unknown
149
+ on?(event: string, listener: (...args: unknown[]) => void): void | (() => void)
150
+ effect?(task: () => void | (() => void), label?: string): () => void
151
+ }
152
+
153
+ // ─── Constants ───────────────────────────────────────────────────────────────
154
+
155
+ const PLUGIN_TAG = 'iterate-ui'
156
+
157
+ /** localStorage key prefix for triage verdicts. */
158
+ const TRIAGE_STORAGE_PREFIX = 'iterate.triage.'
159
+
160
+ /** localStorage key for the theme skin toggle. */
161
+ const THEME_STORAGE_KEY = 'iterate.theme.enabled'
162
+
163
+ /** Theme override source name (matches the doc §16.1). */
164
+ const THEME_SOURCE = 'iterate'
165
+
166
+ /** Theme tokens: 13 dsw tokens, each with light + dark values (warm amber accent). */
167
+ const ITERATE_TOKENS: IterateTokenMap = {
168
+ '--dsw-alias-bg-base': { light: '#FAF8F5', dark: '#171412' },
169
+ '--dsw-alias-bg-layer-1': { light: '#FFFFFF', dark: '#1F1B17' },
170
+ '--dsw-alias-bg-layer-2': { light: '#F4F1EA', dark: '#27221C' },
171
+ '--dsw-alias-bg-overlay': { light: 'rgba(255,255,255,0.96)', dark: 'rgba(23,20,18,0.96)' },
172
+ '--dsw-alias-border-l1': { light: '#E8E2D8', dark: '#332C25' },
173
+ '--dsw-alias-border-l2': { light: '#DDD5C9', dark: '#3C342B' },
174
+ '--dsw-alias-brand-primary': { light: '#B45309', dark: '#F59E0B' },
175
+ '--dsw-alias-label-primary': { light: '#1C1917', dark: '#F5F0EA' },
176
+ '--dsw-alias-label-secondary': { light: '#57534E', dark: '#A9A29B' },
177
+ '--dsw-alias-state-error-primary': { light: '#DC2626', dark: '#F87171' },
178
+ '--dsw-alias-state-success-primary': { light: '#15803D', dark: '#4ADE80' },
179
+ '--dsw-alias-state-warn-primary': { light: '#D97706', dark: '#FBBF24' },
180
+ '--dsw-specific-sidebar-fill': { light: '#F5F2EC', dark: '#14110E' },
181
+ }
182
+
183
+ /** Injected stylesheet. Every selector is prefixed to avoid clobbering dsh. */
184
+ const ITERATE_CSS = `
185
+ [data-iterate-root] { box-sizing: border-box; font-family: var(--dsw-font-sans, system-ui, sans-serif); }
186
+ [data-iterate-root] * , [data-iterate-root] *::before, [data-iterate-root] *::after { box-sizing: border-box; }
187
+
188
+ .iterate-dashboard {
189
+ display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
190
+ padding: 8px 14px; margin: 6px 0;
191
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px;
192
+ background: var(--dsw-alias-bg-layer-1);
193
+ font-size: 12px; color: var(--dsw-alias-label-primary);
194
+ }
195
+ .iterate-round-badge {
196
+ display: inline-flex; align-items: center; gap: 6px;
197
+ padding: 3px 10px; border-radius: 999px;
198
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary) 14%, transparent);
199
+ color: var(--dsw-alias-brand-primary); font-weight: 600; white-space: nowrap;
200
+ }
201
+ .iterate-round-badge[data-pulse] { animation: iterate-pulse 700ms ease-out; }
202
+ @keyframes iterate-pulse {
203
+ 0% { transform: scale(1); box-shadow: 0 0 0 0 color-mix(in srgb, var(--dsw-alias-brand-primary) 60%, transparent); }
204
+ 50% { transform: scale(1.12); box-shadow: 0 0 0 6px color-mix(in srgb, var(--dsw-alias-brand-primary) 0%, transparent); }
205
+ 100% { transform: scale(1); box-shadow: 0 0 0 0 transparent; }
206
+ }
207
+ .iterate-progress { flex: 1 1 120px; min-width: 120px; height: 6px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2); overflow: hidden; }
208
+ .iterate-progress-fill { height: 100%; border-radius: 999px; background: var(--dsw-alias-brand-primary); transition: width 300ms ease; }
209
+ .iterate-metric { display: inline-flex; align-items: center; gap: 5px; color: var(--dsw-alias-label-secondary); white-space: nowrap; }
210
+ .iterate-sev-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
211
+ .iterate-dim-badge {
212
+ padding: 2px 8px; border-radius: 6px;
213
+ background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1);
214
+ color: var(--dsw-alias-label-secondary); font-size: 11px; white-space: nowrap;
215
+ }
216
+
217
+ .iterate-triage { margin: 10px 0; border: 1px solid var(--dsw-alias-border-l1); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); overflow: hidden; }
218
+ .iterate-triage-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--dsw-alias-border-l1); font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary); }
219
+ .iterate-triage-hint { font-size: 11px; font-weight: 400; color: var(--dsw-alias-label-secondary); }
220
+ .iterate-finding { padding: 10px 14px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
221
+ .iterate-finding:last-child { border-bottom: none; }
222
+ .iterate-finding-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 11px; color: var(--dsw-alias-label-secondary); }
223
+ .iterate-finding-file { font-family: var(--dsw-font-mono, ui-monospace, monospace); color: var(--dsw-alias-label-primary); }
224
+ .iterate-finding-summary { margin-top: 4px; font-size: 12px; color: var(--dsw-alias-label-primary); line-height: 1.5; }
225
+ .iterate-finding-actions { display: flex; gap: 6px; margin-top: 8px; }
226
+ .iterate-vbtn {
227
+ padding: 3px 10px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l1);
228
+ background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary);
229
+ font-size: 11px; cursor: pointer;
230
+ }
231
+ .iterate-vbtn[data-active="keep"] { 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) 12%, transparent); }
232
+ .iterate-vbtn[data-active="skip"] { border-color: var(--dsw-alias-state-warn-primary); color: var(--dsw-alias-state-warn-primary); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent); }
233
+ .iterate-vbtn[data-active="ignore"] { border-color: var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); }
234
+ .iterate-triage-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; padding: 10px 14px; background: var(--dsw-alias-bg-layer-2); font-size: 11px; color: var(--dsw-alias-label-secondary); }
235
+ .iterate-btn {
236
+ padding: 4px 10px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l1);
237
+ background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font-size: 11px; cursor: pointer;
238
+ }
239
+ .iterate-btn[data-primary] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
240
+ .iterate-btn[data-copied] { border-color: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary); }
241
+ .iterate-payload { width: 100%; margin-top: 8px; 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; white-space: pre-wrap; }
242
+
243
+ .iterate-stats { margin: 10px 0; border: 1px solid var(--dsw-alias-border-l1); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); padding: 12px 14px; }
244
+ .iterate-stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); gap: 8px; margin-top: 8px; }
245
+ .iterate-stat { padding: 8px; border-radius: 8px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); text-align: center; }
246
+ .iterate-stat-num { font-size: 18px; font-weight: 700; color: var(--dsw-alias-label-primary); }
247
+ .iterate-stat-label { font-size: 10px; color: var(--dsw-alias-label-secondary); margin-top: 2px; }
248
+
249
+ .iterate-capsule {
250
+ position: fixed; bottom: 24px; right: 24px; z-index: 9999;
251
+ padding: 8px 14px; border-radius: 999px;
252
+ background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-brand-primary);
253
+ color: var(--dsw-alias-label-primary); font-size: 12px; box-shadow: 0 4px 16px rgba(0,0,0,0.15);
254
+ animation: iterate-fadein 200ms ease-out; pointer-events: auto;
255
+ }
256
+ @keyframes iterate-fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
257
+
258
+ .iterate-settings { display: flex; flex-direction: column; gap: 12px; padding: 4px 2px 12px; }
259
+ .iterate-settings-title { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); }
260
+ .iterate-settings-desc { font-size: 12px; color: var(--dsw-alias-label-secondary); margin-top: 3px; line-height: 1.5; }
261
+ .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); }
262
+
263
+ /* Filter bar + batch toolbar (triage) */
264
+ .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); }
265
+ .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; }
266
+ .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; }
267
+ .iterate-filter-search::placeholder { color: var(--dsw-alias-label-secondary); }
268
+ .iterate-filter-count { margin-left: auto; white-space: nowrap; }
269
+ .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); }
270
+ .iterate-batch-label { margin-right: 2px; }
271
+ .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; }
272
+ .iterate-batch-btn:hover { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
273
+ .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); }
274
+
275
+ /* Trend chart (inline SVG) */
276
+ .iterate-trend { display: flex; align-items: flex-end; gap: 3px; height: 26px; padding: 2px 0; }
277
+ .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)); }
278
+ .iterate-trend-bar[data-hot] { background: var(--dsw-alias-brand-primary); }
279
+
280
+ /* History panel */
281
+ .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; }
282
+ .iterate-history-body { padding: 10px 14px; }
283
+ .iterate-history-table { width: 100%; border-collapse: collapse; font-size: 11px; color: var(--dsw-alias-label-primary); }
284
+ .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); }
285
+ .iterate-history-table td { padding: 4px 8px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
286
+ .iterate-history-table tr:last-child td { border-bottom: none; }
287
+ .iterate-history-num { font-weight: 600; }
288
+ .iterate-history-trendline { margin-top: 10px; font-size: 11px; color: var(--dsw-alias-label-secondary); }
289
+ .iterate-completion { margin-top: 8px; padding: 6px 10px; border-radius: 8px; font-size: 11px; }
290
+ .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); }
291
+ .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); }
292
+ .iterate-capsule[data-ok] { border-color: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary); }
293
+ .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); }
294
+ .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; }
295
+ .iterate-batch-check input { margin: 0; cursor: pointer; }
296
+
297
+ /* Meta-review verdict banner (dry-run closing result) */
298
+ .iterate-verdict { margin: 10px 0; padding: 10px 14px; border-radius: 12px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 12px; color: var(--dsw-alias-label-primary); }
299
+ .iterate-verdict-tag { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-weight: 600; white-space: nowrap; }
300
+ .iterate-verdict-tag[data-ok] { color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 14%, transparent); }
301
+ .iterate-verdict-tag[data-warn] { color: var(--dsw-alias-state-warn-primary); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 14%, transparent); }
302
+ .iterate-verdict-detail { display: inline-flex; align-items: center; gap: 10px; flex-wrap: wrap; color: var(--dsw-alias-label-secondary); }
303
+ .iterate-verdict-item { white-space: nowrap; }
304
+ .iterate-verdict-item b { color: var(--dsw-alias-label-primary); font-weight: 600; }
305
+
306
+ /* Settings page redesign: grouped cards + switch + code blocks. */
307
+ .iterate-settings { display: flex; flex-direction: column; gap: 14px; padding: 6px 2px 16px; }
308
+ .iterate-scard { border: 1px solid var(--dsw-alias-border-l1); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); padding: 14px 16px; }
309
+ .iterate-scard-head { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
310
+ .iterate-scard-title { font-size: 14px; font-weight: 650; color: var(--dsw-alias-label-primary); letter-spacing: -0.01em; }
311
+ .iterate-scard-desc { font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-secondary); margin-top: 4px; }
312
+ .iterate-scard-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
313
+
314
+ /* Status pill */
315
+ .iterate-pill { display: inline-flex; align-items: center; gap: 7px; padding: 3px 11px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 28%, transparent); }
316
+ .iterate-pill-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
317
+
318
+ /* Accessibility-switch toggle */
319
+ .iterate-switch { position: relative; width: 42px; height: 24px; border-radius: 999px; padding: 0; cursor: pointer; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); transition: background-color 160ms ease, border-color 160ms ease; }
320
+ .iterate-switch:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }
321
+ .iterate-switch-knob { position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: var(--dsw-alias-label-secondary); transition: transform 160ms ease, background-color 160ms ease; }
322
+ .iterate-switch[data-on] { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); }
323
+ .iterate-switch[data-on] .iterate-switch-knob { transform: translateX(18px); background: #FFFFFF; }
324
+
325
+ /* Button variants */
326
+ .iterate-btn[data-ghost] { background: transparent; }
327
+ .iterate-btn[data-danger] { border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent); color: var(--dsw-alias-state-error-primary); background: transparent; }
328
+ .iterate-btn[data-danger]:hover { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); }
329
+ .iterate-btn[data-confirm] { border-color: var(--dsw-alias-state-error-primary); color: #FFFFFF; background: var(--dsw-alias-state-error-primary); }
330
+
331
+ /* Collapsible guide / status code blocks */
332
+ .iterate-guide { margin-top: 4px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; overflow: hidden; background: var(--dsw-alias-bg-layer-2); }
333
+ .iterate-guide-bar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 12px; background: var(--dsw-alias-bg-layer-2); border-bottom: 1px solid var(--dsw-alias-border-l1); font-size: 12px; color: var(--dsw-alias-label-secondary); }
334
+ .iterate-guide-body { padding: 12px 14px; font-family: var(--dsw-font-mono, ui-monospace, monospace); font-size: 11.5px; line-height: 1.75; white-space: pre-wrap; color: var(--dsw-alias-label-primary); max-height: 260px; overflow: auto; }
335
+ `
336
+
337
+ // ─── Small helpers ───────────────────────────────────────────────────────────
338
+
339
+ /** Namespaced logger (kept minimal; only errors/warnings, no debug spam). */
340
+ function log(...args: unknown[]): void {
341
+ if (typeof console !== 'undefined' && typeof console.error === 'function') {
342
+ console.error(`[${PLUGIN_TAG}]`, ...args)
343
+ }
344
+ }
345
+
346
+ /** Safe localStorage wrapper (never throws in private mode / SSR). */
347
+ function createStorage(): {
348
+ get(key: string): string | null
349
+ set(key: string, value: string): void
350
+ remove(key: string): void
351
+ keys(): string[]
352
+ } {
353
+ try {
354
+ const testKey = '__iterate_storage_test__'
355
+ window.localStorage.setItem(testKey, '1')
356
+ window.localStorage.removeItem(testKey)
357
+ return {
358
+ get(key) { return window.localStorage.getItem(key) },
359
+ set(key, value) { window.localStorage.setItem(key, value) },
360
+ remove(key) { window.localStorage.removeItem(key) },
361
+ keys() { return Object.keys(window.localStorage) },
362
+ }
363
+ } catch {
364
+ const mem = new Map<string, string>()
365
+ return {
366
+ get(key) { return mem.has(key) ? mem.get(key) as string : null },
367
+ set(key, value) { mem.set(key, value) },
368
+ remove(key) { mem.delete(key) },
369
+ keys() { return [...mem.keys()] },
370
+ }
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Remove every stored key with the given prefix (e.g. all triage verdicts).
376
+ * Returns how many keys were removed.
377
+ */
378
+ function removeStorageByPrefix(prefix: string): number {
379
+ if (!storage) return 0
380
+ let removed = 0
381
+ try {
382
+ for (const key of storage.keys()) {
383
+ if (key.startsWith(prefix)) {
384
+ storage.remove(key)
385
+ removed++
386
+ }
387
+ }
388
+ } catch (err) {
389
+ log('failed to clear storage prefix', prefix, err)
390
+ }
391
+ return removed
392
+ }
393
+
394
+ /** Copy text to the clipboard, returning whether it succeeded. */
395
+ function copyText(text: string): boolean {
396
+ if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
397
+ navigator.clipboard.writeText(text).then(
398
+ () => true,
399
+ () => false,
400
+ )
401
+ return true
402
+ }
403
+ return false
404
+ }
405
+
406
+ /** Literal severity keys recognized by SEVERITY_LABEL / SEVERITY_COLOR. */
407
+ const SEVERITY_KEYS = ['critical', 'high', 'medium', 'low'] as const
408
+
409
+ /** Coerce an arbitrary severity string to a known key, defaulting to `low`. */
410
+ function coerceSeverity(severity: string | undefined): (typeof SEVERITY_KEYS)[number] {
411
+ return (SEVERITY_KEYS as readonly string[]).includes(severity ?? '')
412
+ ? (severity as (typeof SEVERITY_KEYS)[number])
413
+ : 'low'
414
+ }
415
+
416
+ /** Severity accent color with safe fallback. */
417
+ function severityColor(severity: string | undefined): string {
418
+ return SEVERITY_COLOR[coerceSeverity(severity)]
419
+ }
420
+
421
+ /** Severity short label with safe fallback. */
422
+ function severityLabel(severity: string | undefined): string {
423
+ return SEVERITY_LABEL[coerceSeverity(severity)]
424
+ }
425
+
426
+ /** Resolve the slots service: prefer the typed `ctx.slots`, fall back to `ctx.get`. */
427
+ function readSlots(ctx: ClientContext): SlotsService | undefined {
428
+ if (ctx && ctx.slots && typeof ctx.slots.inject === 'function' && typeof ctx.slots.register === 'function') {
429
+ return ctx.slots
430
+ }
431
+ const raw = ctx && typeof ctx.get === 'function' ? ctx.get('slots', false) : undefined
432
+ if (raw && typeof (raw as SlotsService).inject === 'function' && typeof (raw as SlotsService).register === 'function') {
433
+ return raw as SlotsService
434
+ }
435
+ return undefined
436
+ }
437
+
438
+ /** Resolve the theme service: prefer the typed `ctx.theme`, fall back to `ctx.get`. */
439
+ function readTheme(ctx: ClientContext): ThemeService | undefined {
440
+ if (ctx && ctx.theme && typeof ctx.theme.overrideTokens === 'function') {
441
+ return ctx.theme
442
+ }
443
+ const raw = ctx && typeof ctx.get === 'function' ? ctx.get('theme', false) : undefined
444
+ if (raw && typeof (raw as ThemeService).overrideTokens === 'function') {
445
+ return raw as ThemeService
446
+ }
447
+ return undefined
448
+ }
449
+
450
+ // ─── Module-level runtime state (shared across slots) ────────────────────────
451
+
452
+ let slotsSvc: SlotsService | undefined = undefined
453
+ let themeSvc: ThemeService | undefined = undefined
454
+ let storage: ReturnType<typeof createStorage> | null = null
455
+ let themeDisposer: (() => void) | null = null
456
+ let themeEnabled = true
457
+ const roundPulseListeners: Array<(payload: { round: number; converged: boolean }) => void> = []
458
+
459
+ /**
460
+ * Emit a "round changed" pulse to subscribed components.
461
+ * The payload is `{ round, converged }` so the overlay capsule can surface
462
+ * completion AND convergence in one notification.
463
+ */
464
+ function emitRoundPulse(round: number, converged: boolean): void {
465
+ const payload = { round, converged: converged === true }
466
+ for (const fn of roundPulseListeners.slice()) fn(payload)
467
+ }
468
+
469
+ /** Apply the iterate theme skin (idempotent). */
470
+ function applyThemeSkin(): void {
471
+ if (!themeSvc || typeof themeSvc.overrideTokens !== 'function') return
472
+ // Never stack disposers: drop any previously applied override first.
473
+ clearThemeSkin()
474
+ themeDisposer = themeSvc.overrideTokens(THEME_SOURCE, ITERATE_TOKENS)
475
+ }
476
+
477
+ /** Remove the iterate theme skin (idempotent). */
478
+ function clearThemeSkin(): void {
479
+ if (themeDisposer) {
480
+ try { themeDisposer() } catch (err) { log('theme disposer failed', err) }
481
+ themeDisposer = null
482
+ }
483
+ }
484
+
485
+ /** Persist + apply the theme preference. */
486
+ function setThemeEnabled(enabled: boolean): void {
487
+ themeEnabled = enabled
488
+ if (storage) storage.set(THEME_STORAGE_KEY, enabled ? '1' : '0')
489
+ if (enabled) applyThemeSkin()
490
+ else clearThemeSkin()
491
+ }
492
+
493
+ // ─── Components (React.createElement trees) ──────────────────────────────────
494
+
495
+ /** Obtain a session snapshot defensively from the slot props. */
496
+ function sessionSnapshot(props: SlotProps) {
497
+ let session: unknown = null
498
+ const useSession = props && typeof props.useSession === 'function' ? props.useSession as () => unknown : null
499
+ if (useSession) {
500
+ try { session = useSession() } catch (err) { log('useSession failed', err) }
501
+ }
502
+ if (!session && props && props.session) session = props.session
503
+ return session
504
+ }
505
+
506
+ /** Find the latest report inside a session snapshot (normalized). */
507
+ function latestReport(session: unknown): ReviewReport | null {
508
+ if (!session) return null
509
+ const raw = scanSessionForReport(session) || findReportInObject(session, undefined, 24)
510
+ return raw ? normalizeReport(raw) as ReviewReport : null
511
+ }
512
+
513
+ /** Inline bar chart of findings-by-round (pure divs, no SVG needed). */
514
+ function TrendChart({ points }: { points: Array<{ round: number; count: number }> }) {
515
+ if (!points || points.length === 0) return null
516
+ const max = trendMax(points)
517
+ const bars = points.map((p) =>
518
+ React.createElement('div', {
519
+ key: p.round,
520
+ className: 'iterate-trend-bar',
521
+ 'data-hot': p.count > 0 ? '' : undefined,
522
+ title: `Round ${p.round}:${p.count} 项`,
523
+ style: { height: `${Math.max(4, Math.round((p.count / max) * 24))}px` },
524
+ }),
525
+ )
526
+ return React.createElement('div', { className: 'iterate-trend', title: '各轮发现数量趋势' }, ...bars)
527
+ }
528
+
529
+ /** Dashboard: live convergence strip above the composer. */
530
+ function ConvergenceDashboard(props: SlotProps) {
531
+ const [pulseKey, setPulseKey] = React.useState(0)
532
+ const report = latestReport(sessionSnapshot(props))
533
+
534
+ React.useEffect(() => {
535
+ if (!report) return
536
+ const cur = getCurrentRound(report)
537
+ const conv = report.convergence
538
+ emitRoundPulse(cur, conv?.converged === true)
539
+ setPulseKey((k) => k + 1)
540
+ }, [report && hashReport(report) + ':' + getCurrentRound(report)])
541
+
542
+ if (!report) return null
543
+
544
+ const round = getCurrentRound(report)
545
+ const total = getTotalRounds(report)
546
+ const progress = computeConvergenceProgress(report)
547
+ const stats = severityStats(report)
548
+ const dims = groupByDimension(report)
549
+ const trend = computeTrendMetrics(report)
550
+
551
+ const dimBadges = Object.keys(dims).slice(0, 6).map((dim) =>
552
+ React.createElement(
553
+ 'span',
554
+ { key: dim, className: 'iterate-dim-badge' },
555
+ `${dim} · ${(dims[dim]?.length ?? 0)}`,
556
+ ),
557
+ )
558
+
559
+ // Fix-count badge: show a running "fixes applied" metric when the report
560
+ // carries a number (normal mode only — threaded through `fixedCount`).
561
+ const mode = report.mode
562
+ const summary = report.summary
563
+ const isNormal = mode === 'normal'
564
+ const fixCount = isNormal && summary && typeof summary.fixedCount === 'number' ? summary.fixedCount : null
565
+ const fixBadge = fixCount !== null
566
+ ? React.createElement('span', {
567
+ className: 'iterate-metric',
568
+ key: 'fixes',
569
+ title: '本轮已应用的原子修复数(正常模式)',
570
+ }, `${String(fixCount)} fixes`)
571
+ : null
572
+
573
+ return React.createElement(
574
+ 'div',
575
+ { 'data-iterate-root': '', 'data-iterate': 'dashboard', className: 'iterate-dashboard' },
576
+ React.createElement(
577
+ 'span',
578
+ { className: 'iterate-round-badge', 'data-pulse': pulseKey > 0 ? '' : undefined, key: `round-${round}` },
579
+ `Round ${round} / ${total}`,
580
+ ),
581
+ React.createElement('div', { className: 'iterate-progress' },
582
+ React.createElement('div', { className: 'iterate-progress-fill', style: { width: `${progress}%` } }),
583
+ ),
584
+ React.createElement('span', { className: 'iterate-metric' },
585
+ React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.critical } }),
586
+ stats.critical,
587
+ ),
588
+ React.createElement('span', { className: 'iterate-metric' },
589
+ React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.high } }),
590
+ stats.high,
591
+ ),
592
+ React.createElement('span', { className: 'iterate-metric' },
593
+ React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.medium } }),
594
+ stats.medium,
595
+ ),
596
+ fixBadge,
597
+ React.createElement(TrendChart, { points: trend.points }),
598
+ ...dimBadges,
599
+ )
600
+ }
601
+
602
+ /** Stats card (empty-findings case of the turn-tail chain). */
603
+ function StatsCard(props: SlotProps) {
604
+ const report = props.report as ReviewReport
605
+ const [showHistory, setShowHistory] = React.useState(false)
606
+ const stats = severityStats(report)
607
+ const total = stats.critical + stats.high + stats.medium + stats.low
608
+ const rows = [
609
+ { label: 'Critical', value: stats.critical, color: SEVERITY_COLOR.critical },
610
+ { label: 'High', value: stats.high, color: SEVERITY_COLOR.high },
611
+ { label: 'Medium', value: stats.medium, color: SEVERITY_COLOR.medium },
612
+ { label: 'Low', value: stats.low, color: SEVERITY_COLOR.low },
613
+ ].map((r) =>
614
+ React.createElement('div', { key: r.label, className: 'iterate-stat' },
615
+ React.createElement('div', { className: 'iterate-stat-num', style: { color: r.color } }, String(r.value)),
616
+ React.createElement('div', { className: 'iterate-stat-label' }, r.label),
617
+ ),
618
+ )
619
+
620
+ const history = buildRoundHistory(report)
621
+ const trend = computeTrendMetrics(report)
622
+ const historyRows = history.map((h) =>
623
+ React.createElement('tr', { key: h.round },
624
+ React.createElement('td', { className: 'iterate-history-num' }, `Round ${h.round}`),
625
+ React.createElement('td', {}, String(h.count)),
626
+ React.createElement('td', { style: { color: SEVERITY_COLOR.critical } }, String(h.critical)),
627
+ React.createElement('td', { style: { color: SEVERITY_COLOR.high } }, String(h.high)),
628
+ React.createElement('td', { style: { color: SEVERITY_COLOR.medium } }, String(h.medium)),
629
+ React.createElement('td', { style: { color: SEVERITY_COLOR.low } }, String(h.low)),
630
+ ),
631
+ )
632
+ const trendLine = `首轮 ${trend.firstRound} → 末轮 ${trend.lastRound} 项,降幅 ${trend.reductionPercent}%${trend.converged ? ',已收敛' : ''}`
633
+ const completion = buildCompletionSummary(report)
634
+
635
+ return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'stats', className: 'iterate-stats' },
636
+ React.createElement('div', { className: 'iterate-triage-head' },
637
+ React.createElement('span', {}, 'Iterate · 收敛统计'),
638
+ React.createElement('span', { className: 'iterate-triage-hint' },
639
+ `Round ${getCurrentRound(report)}/${getTotalRounds(report)} · ${total} findings · ${report.convergence && report.convergence.converged ? '已收敛' : '未收敛'}`,
640
+ ),
641
+ ),
642
+ React.createElement('div', { className: 'iterate-stats-grid' }, ...rows),
643
+ React.createElement('div', { className: 'iterate-completion', 'data-ok': trend.converged ? '' : undefined, 'data-warn': trend.converged ? undefined : '' }, completion),
644
+ React.createElement('div', { className: 'iterate-triage-foot', style: { marginTop: 8, borderRadius: 8 } },
645
+ React.createElement('span', {}, trendLine),
646
+ React.createElement('button', {
647
+ className: 'iterate-btn',
648
+ 'data-primary': showHistory ? '' : undefined,
649
+ onClick: () => setShowHistory((v) => !v),
650
+ }, showHistory ? '收起历史' : '历史 / 趋势'),
651
+ ),
652
+ showHistory
653
+ ? React.createElement('div', { className: 'iterate-history-body' },
654
+ React.createElement('table', { className: 'iterate-history-table' },
655
+ React.createElement('thead', {},
656
+ React.createElement('tr', {},
657
+ React.createElement('th', {}, '轮次'),
658
+ React.createElement('th', {}, '发现'),
659
+ React.createElement('th', { style: { color: SEVERITY_COLOR.critical } }, 'CRIT'),
660
+ React.createElement('th', { style: { color: SEVERITY_COLOR.high } }, 'HIGH'),
661
+ React.createElement('th', { style: { color: SEVERITY_COLOR.medium } }, 'MED'),
662
+ React.createElement('th', { style: { color: SEVERITY_COLOR.low } }, 'LOW'),
663
+ ),
664
+ ),
665
+ React.createElement('tbody', {}, ...historyRows),
666
+ ),
667
+ React.createElement('div', { className: 'iterate-history-trendline' },
668
+ React.createElement(TrendChart, { points: trend.points }),
669
+ ),
670
+ )
671
+ : null,
672
+ )
673
+ }
674
+
675
+ /** Triage panel: per-finding y / n / a with filters, batch ops, keyboard shortcuts. */
676
+ function TriagePanel(props: SlotProps) {
677
+ const report = props.report as ReviewReport
678
+ const findings = report.findings || []
679
+ const storageKey = TRIAGE_STORAGE_PREFIX + hashReport(report)
680
+ const [verdicts, setVerdicts] = React.useState<Record<string, 'keep' | 'skip' | 'ignore'>>(() => {
681
+ const initial = buildTriageState(report)
682
+ const saved = storage ? storage.get(storageKey) : null
683
+ if (saved) {
684
+ try {
685
+ const parsed = JSON.parse(saved) as Record<string, string>
686
+ if (parsed && typeof parsed === 'object') {
687
+ for (const key of Object.keys(initial)) {
688
+ if (parsed[key] === 'keep' || parsed[key] === 'skip' || parsed[key] === 'ignore') {
689
+ initial[key] = parsed[key] as 'keep' | 'skip' | 'ignore'
690
+ }
691
+ }
692
+ }
693
+ } catch { /* corrupt storage, fall back to defaults */ }
694
+ }
695
+ return initial
696
+ })
697
+ const [payload, setPayload] = React.useState<string | null>(null)
698
+ const [copied, setCopied] = React.useState(false)
699
+ const [filter, setFilter] = React.useState<{ severities: string[]; dimensions: string[]; search: string }>({ severities: [], dimensions: [], search: '' })
700
+ const [selected, setSelected] = React.useState<number | null>(null)
701
+ const [selectAll, setSelectAll] = React.useState(false)
702
+
703
+ /** Persist + return the next verdicts state. */
704
+ const persistVerdicts = (next: Record<string, 'keep' | 'skip' | 'ignore'>) => {
705
+ if (storage) storage.set(storageKey, JSON.stringify(next))
706
+ return next
707
+ }
708
+
709
+ const setVerdict = (index: number, verdict: 'keep' | 'skip' | 'ignore') => {
710
+ setVerdicts((prev) => persistVerdicts({ ...prev, [String(index)]: verdict }))
711
+ }
712
+
713
+ // ── Filtering (visible findings + their original indices) ───────────────
714
+ const { filtered, indices } = filterFindingsWithIndices(findings, filter)
715
+ const indicesKey = indices.join(',')
716
+ const options = buildFilterOptions(findings)
717
+ const isFilterActive = filter.severities.length > 0 || filter.dimensions.length > 0 || filter.search !== ''
718
+
719
+ const setSeverityFilter = (value: string) => setFilter((f) => ({ ...f, severities: value ? [value] : [] }))
720
+ const setDimensionFilter = (value: string) => setFilter((f) => ({ ...f, dimensions: value ? [value] : [] }))
721
+ const setSearchFilter = (value: string) => setFilter((f) => ({ ...f, search: value }))
722
+ const clearFilter = () => setFilter({ severities: [], dimensions: [], search: '' })
723
+
724
+ // ── Batch operations (apply to the currently VISIBLE findings, or to ALL
725
+ // findings when the select-all toggle is on) ──────────────────────────
726
+ const allIndices = allVerdictKeys(verdicts)
727
+ const batchTarget = selectAll ? allIndices : indices
728
+ const applyBatch = (verdict: 'keep' | 'skip' | 'ignore') => {
729
+ setVerdicts((prev) => persistVerdicts(batchSetVerdict(prev, batchTarget, verdict)))
730
+ }
731
+ const applyBatchAll = (verdict: 'keep' | 'skip' | 'ignore') => {
732
+ setVerdicts((prev) => persistVerdicts(setAllVerdicts(prev, verdict)))
733
+ }
734
+ const doResetVerdicts = () => {
735
+ setVerdicts((prev) => persistVerdicts(setAllVerdicts(prev, 'keep')))
736
+ setSelectAll(false)
737
+ }
738
+
739
+ // ── Keyboard shortcuts (y / n / a on the selected finding, ↑/↓ to move) ──
740
+ React.useEffect(() => {
741
+ const doc = typeof document !== 'undefined' ? document : null
742
+ if (!doc) return
743
+ const onKeyDown = (ev: KeyboardEvent) => {
744
+ const t = ev.target as HTMLElement | null
745
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return
746
+ const verdict = keyToVerdict(ev.key)
747
+ if (verdict && selected !== null && indices.includes(selected)) {
748
+ ev.preventDefault()
749
+ setVerdict(selected, verdict)
750
+ const pos = indices.indexOf(selected)
751
+ const nextIdx = indices[pos + 1]
752
+ if (nextIdx !== undefined) setSelected(nextIdx)
753
+ return
754
+ }
755
+ if (ev.key === 'ArrowDown' && indices.length > 0) {
756
+ ev.preventDefault()
757
+ const pos = selected === null ? -1 : indices.indexOf(selected)
758
+ setSelected(indices[Math.min(pos + 1, indices.length - 1)] ?? null)
759
+ return
760
+ }
761
+ if (ev.key === 'ArrowUp' && indices.length > 0) {
762
+ ev.preventDefault()
763
+ const pos = selected === null ? 0 : indices.indexOf(selected)
764
+ setSelected(indices[Math.max(pos - 1, 0)] ?? null)
765
+ }
766
+ }
767
+ doc.addEventListener('keydown', onKeyDown)
768
+ return () => doc.removeEventListener('keydown', onKeyDown)
769
+ // eslint-disable-next-line react-hooks/exhaustive-deps
770
+ }, [selected, indicesKey])
771
+
772
+ const ignored = collectIgnoredEntries(verdicts, findings)
773
+ const ignoredCount = ignored.length
774
+ const counts = countVerdicts(verdicts)
775
+
776
+ const doCopyYaml = () => {
777
+ const yaml = toKnownIntentionalYaml(ignored)
778
+ if (!yaml) return
779
+ const ok = copyText(yaml)
780
+ setCopied(true)
781
+ setTimeout(() => setCopied(false), 1600)
782
+ if (!ok) setPayload(yaml)
783
+ }
784
+
785
+ const doBuildInstruction = () => {
786
+ const text = buildApplyInstruction(ignored)
787
+ setPayload(text)
788
+ if (text) copyText(text)
789
+ }
790
+
791
+ const rows = (filtered as IterateFinding[]).map((finding, i) => {
792
+ const index = indices[i] as number
793
+ const severity = finding.severity || 'low'
794
+ const verdict = verdicts[String(index)] || 'keep'
795
+ const isSelected = selected === index
796
+ const btn = (label: string, value: 'keep' | 'skip' | 'ignore', title: string) =>
797
+ React.createElement(
798
+ 'button',
799
+ {
800
+ key: value,
801
+ className: 'iterate-vbtn',
802
+ 'data-active': verdict === value ? value : undefined,
803
+ title,
804
+ onClick: () => setVerdict(index, value),
805
+ },
806
+ label,
807
+ )
808
+
809
+ return React.createElement('div', {
810
+ key: String(index),
811
+ className: 'iterate-finding',
812
+ 'data-selected': isSelected ? '' : undefined,
813
+ onClick: () => setSelected(index),
814
+ },
815
+ React.createElement('div', { className: 'iterate-finding-meta' },
816
+ React.createElement('span', { className: 'iterate-sev-dot', style: { background: severityColor(severity) } }),
817
+ React.createElement('span', {}, severityLabel(severity)),
818
+ React.createElement('span', { className: 'iterate-finding-file' }, String(finding.file || '?')),
819
+ finding.line ? React.createElement('span', {}, `:${finding.line}`) : null,
820
+ React.createElement('span', {}, String(finding.dimension || '')),
821
+ ),
822
+ React.createElement('div', { className: 'iterate-finding-summary' }, String(finding.summary || '')),
823
+ React.createElement('div', { className: 'iterate-finding-actions' },
824
+ btn('y 修复', 'keep', '保留该 finding,进入修复'),
825
+ btn('n 跳过', 'skip', '跳过该 finding'),
826
+ btn('a 已知有意', 'ignore', '标记为已知有意并写入 known_intentional'),
827
+ ),
828
+ )
829
+ })
830
+
831
+ return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'triage', className: 'iterate-triage' },
832
+ React.createElement('div', { className: 'iterate-triage-head' },
833
+ React.createElement('span', {}, `Iterate · Findings 分诊 (${filtered.length}/${findings.length})`),
834
+ React.createElement('span', { className: 'iterate-triage-hint' }, 'y=修复 · n=跳过 · a=已知有意 · ↑/↓ 选择'),
835
+ ),
836
+ React.createElement('div', { className: 'iterate-filter' },
837
+ React.createElement('select', { className: 'iterate-filter-select', value: filter.severities[0] || '', onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setSeverityFilter(e.target.value), title: '按严重度筛选' },
838
+ React.createElement('option', { value: '' }, '全部严重度'),
839
+ ...options.severities.map((s) =>
840
+ React.createElement('option', { key: s.value, value: s.value }, `${severityLabel(s.value)} (${s.count})`),
841
+ ),
842
+ ),
843
+ React.createElement('select', { className: 'iterate-filter-select', value: filter.dimensions[0] || '', onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setDimensionFilter(e.target.value), title: '按维度筛选' },
844
+ React.createElement('option', { value: '' }, '全部维度'),
845
+ ...options.dimensions.map((d) =>
846
+ React.createElement('option', { key: d.value, value: d.value }, `${d.value} (${d.count})`),
847
+ ),
848
+ ),
849
+ React.createElement('input', {
850
+ className: 'iterate-filter-search',
851
+ type: 'search',
852
+ placeholder: '搜索文件 / 摘要…',
853
+ value: filter.search,
854
+ onChange: (e: React.ChangeEvent<HTMLInputElement>) => setSearchFilter(e.target.value),
855
+ }),
856
+ React.createElement('span', { className: 'iterate-filter-count' },
857
+ isFilterActive
858
+ ? React.createElement('button', { className: 'iterate-batch-btn', onClick: clearFilter }, `清除筛选(显示 ${filtered.length})`)
859
+ : `共 ${findings.length} 项`,
860
+ ),
861
+ ),
862
+ React.createElement('div', { className: 'iterate-batch' },
863
+ React.createElement('span', { className: 'iterate-batch-label' }, '批量:'),
864
+ React.createElement('label', { className: 'iterate-batch-check', title: '勾选后批量按钮作用于全部 findings,否则仅当前可见' },
865
+ React.createElement('input', { type: 'checkbox', checked: selectAll, onChange: (e) => setSelectAll(e.target.checked) }),
866
+ selectAll ? `全部 ${allIndices.length}` : '全选',
867
+ ),
868
+ React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('keep') }, '全部 y'),
869
+ React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('skip') }, '全部 n'),
870
+ React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('ignore') }, '全部 a'),
871
+ React.createElement('span', { className: 'iterate-batch-label', style: { marginLeft: 8 } }, '全部:'),
872
+ React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatchAll('keep') }, 'y'),
873
+ React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatchAll('skip') }, 'n'),
874
+ React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatchAll('ignore') }, 'a'),
875
+ React.createElement('button', { className: 'iterate-batch-btn', onClick: doResetVerdicts, title: '把所有判定恢复为默认 y(修复)' }, '重置'),
876
+ ),
877
+ ...rows,
878
+ React.createElement('div', { className: 'iterate-triage-foot' },
879
+ React.createElement('span', {}, `y ${counts.keep} · n ${counts.skip} · a ${counts.ignore} · 待写回 known_intentional:${ignoredCount} 条`),
880
+ React.createElement('span', { style: { display: 'flex', gap: 6 } },
881
+ React.createElement('button', { className: 'iterate-btn', 'data-primary': '', 'data-copied': copied ? '' : undefined, onClick: doCopyYaml }, copied ? '已复制' : '复制 known_intentional'),
882
+ React.createElement('button', { className: 'iterate-btn', onClick: doBuildInstruction }, '生成应用指令'),
883
+ ),
884
+ ),
885
+ payload
886
+ ? React.createElement('div', { className: 'iterate-payload' }, payload)
887
+ : null,
888
+ )
889
+ }
890
+
891
+ /** Meta-review verdict banner: surfaces the closing dry-run audit result. */
892
+ function VerdictBanner(props: SlotProps) {
893
+ const verdict = props.verdict as { verdict?: string; totalFindings?: number; totalRounds?: number; checksRun?: number; reportIssues?: number; converged?: boolean } | null
894
+ if (!verdict) return null
895
+ const ok = verdict.verdict === 'approved'
896
+ const item = (num: number | undefined, unit: string) =>
897
+ React.createElement('span', { className: 'iterate-verdict-item' },
898
+ React.createElement('b', {}, String(num)), ` ${unit}`)
899
+ const phrase = (text: string) =>
900
+ React.createElement('span', { className: 'iterate-verdict-item' }, text)
901
+ return React.createElement(
902
+ 'div',
903
+ { 'data-iterate-root': '', 'data-iterate': 'verdict', className: 'iterate-verdict' },
904
+ React.createElement('span', { className: 'iterate-verdict-tag', 'data-ok': ok ? '' : undefined, 'data-warn': ok ? undefined : '' },
905
+ ok ? '报告已批准' : '报告需修订'),
906
+ React.createElement('span', { className: 'iterate-verdict-detail' },
907
+ item(verdict.totalFindings, '项发现'),
908
+ item(verdict.totalRounds, '轮'),
909
+ item(verdict.checksRun, '项审查'),
910
+ ok
911
+ ? phrase('报告通过全部一致性检查')
912
+ : item(verdict.reportIssues, '处报告缺陷'),
913
+ phrase(verdict.converged ? '已收敛' : '未收敛'),
914
+ ),
915
+ )
916
+ }
917
+
918
+ /** Turn-tail chain entry: triage when findings exist, stats card otherwise. */
919
+ function TurnTailEntry(props: SlotProps) {
920
+ const candidates: unknown[] = []
921
+ if (props && props.turn) candidates.push(props.turn)
922
+ if (props && props.matched) candidates.push(props.matched)
923
+ if (props && props.data) candidates.push(props.data)
924
+ candidates.push(props)
925
+
926
+ // Meta-review verdict (dry-run closing result), found before the report.
927
+ let verdict: { verdict?: string; totalFindings?: number; totalRounds?: number; checksRun?: number; reportIssues?: number; converged?: boolean } | null = null
928
+ for (const c of candidates) {
929
+ const run = scanSessionForRunSummary(c)
930
+ if (run) { verdict = extractVerdict(run); break }
931
+ }
932
+
933
+ let report: ReviewReport | null = null
934
+ for (const c of candidates) {
935
+ const raw = findReportInObject(c, undefined, 24) || scanSessionForReport(c)
936
+ if (raw) { report = normalizeReport(raw) as ReviewReport; break }
937
+ }
938
+
939
+ const blocks: React.ReactElement[] = []
940
+ if (verdict) blocks.push(React.createElement(VerdictBanner, { key: 'verdict', verdict }))
941
+ if (report) {
942
+ // Render through React.createElement so each panel is a real component with
943
+ // its own hook identity (calling them as functions would violate the Rules
944
+ // of Hooks and crash when the findings/empty branch flips between renders).
945
+ const panel = !report.findings || report.findings.length === 0
946
+ ? React.createElement(StatsCard, { report })
947
+ : React.createElement(TriagePanel, { report })
948
+ blocks.push(React.createElement('div', { key: 'report' }, panel))
949
+ }
950
+ if (blocks.length === 0) return null
951
+ return React.createElement('div', { 'data-iterate-root': '', className: 'iterate-turn-tail-root' }, ...blocks)
952
+ }
953
+
954
+ /** Progress capsule: briefly surfaces round-completion (incl. convergence). */
955
+ function ProgressCapsule() {
956
+ const [info, setInfo] = React.useState<{ text: string; ok: boolean } | null>(null)
957
+ React.useEffect(() => {
958
+ let timer: ReturnType<typeof setTimeout> | null = null
959
+ const listener = (payload: { round: number; converged: boolean }) => {
960
+ const converged = payload && payload.converged === true
961
+ const round = payload && typeof payload.round === 'number' ? payload.round : '?'
962
+ setInfo({ text: converged ? `Round ${round} 完成 · 已收敛` : `Round ${round} 完成`, ok: converged })
963
+ if (timer) clearTimeout(timer)
964
+ // Converged notifications stay a bit longer.
965
+ timer = setTimeout(() => setInfo(null), converged ? 3600 : 2400)
966
+ }
967
+ roundPulseListeners.push(listener)
968
+ return () => {
969
+ const i = roundPulseListeners.indexOf(listener)
970
+ if (i >= 0) roundPulseListeners.splice(i, 1)
971
+ if (timer) clearTimeout(timer)
972
+ }
973
+ }, [])
974
+ if (!info) return null
975
+ return React.createElement('div', { 'data-iterate-root': '', className: 'iterate-capsule', 'data-ok': info.ok ? '' : undefined }, info.text)
976
+ }
977
+
978
+ /** Settings page section (root scope; reads localStorage + latest known data). */
979
+ function SettingsPanel(_props: SlotProps) {
980
+ const [enabled, setEnabled] = React.useState(themeEnabled)
981
+ const [guideCopied, setGuideCopied] = React.useState(false)
982
+ const [statusCopied, setStatusCopied] = React.useState(false)
983
+ const [showGuide, setShowGuide] = React.useState(false)
984
+ const [showStatus, setShowStatus] = React.useState(false)
985
+ const [confirming, setConfirming] = React.useState(false)
986
+ const [clearedInfo, setClearedInfo] = React.useState<number | null>(null)
987
+ const guide = buildConfigEditGuide()
988
+ const statusGuide = buildRuntimeStatusGuide()
989
+
990
+ const toggleTheme = () => {
991
+ const next = !enabled
992
+ setEnabled(next)
993
+ setThemeEnabled(next)
994
+ }
995
+
996
+ /** Flash a per-slot "已复制" state for the given duration. */
997
+ const flashCopied = (slot: 'guide' | 'status') => {
998
+ const setter = slot === 'guide' ? setGuideCopied : setStatusCopied
999
+ setter(true)
1000
+ setTimeout(() => setter(false), 1600)
1001
+ }
1002
+
1003
+ /** Copy a guide/status block; keep the copy button state in sync. */
1004
+ const doCopy = (text: string, slot: 'guide' | 'status') => {
1005
+ copyText(text)
1006
+ flashCopied(slot)
1007
+ }
1008
+
1009
+ /** Two-step destroy guard: first click arms, second click clears. */
1010
+ const requestClear = () => {
1011
+ if (confirming) {
1012
+ const count = removeStorageByPrefix(TRIAGE_STORAGE_PREFIX)
1013
+ setClearedInfo(count)
1014
+ setConfirming(false)
1015
+ setTimeout(() => setClearedInfo(null), 3000)
1016
+ return
1017
+ }
1018
+ setConfirming(true)
1019
+ setTimeout(() => setConfirming(false), 3000)
1020
+ }
1021
+
1022
+ const clearButton = clearedInfo !== null
1023
+ ? React.createElement('button', { className: 'iterate-btn', 'data-copied': '', disabled: true }, `已清除 ${clearedInfo} 条`)
1024
+ : React.createElement('button', {
1025
+ className: 'iterate-btn',
1026
+ 'data-danger': confirming ? undefined : '',
1027
+ 'data-confirm': confirming ? '' : undefined,
1028
+ onClick: requestClear,
1029
+ title: confirming ? '再次点击以确认清除全部判定' : '清除所有分诊判定记录(需要二次确认)',
1030
+ }, confirming ? '确认清除?' : '清除分诊')
1031
+
1032
+ const themeCard = React.createElement('div', { key: 'theme', className: 'iterate-scard' },
1033
+ React.createElement('div', { className: 'iterate-scard-head' },
1034
+ React.createElement('div', {},
1035
+ React.createElement('div', { className: 'iterate-settings-title' }, '迭代主题'),
1036
+ React.createElement('div', { className: 'iterate-settings-desc' }, '启用暖琥珀配色的 iterate 专属皮肤,覆盖 dsh 默认主题令牌。'),
1037
+ ),
1038
+ React.createElement('div', { className: 'iterate-scard-actions' },
1039
+ React.createElement('span', { className: 'iterate-chip', 'data-ok': enabled ? '' : undefined }, enabled ? '已启用' : '已关闭'),
1040
+ React.createElement('button', {
1041
+ role: 'switch',
1042
+ 'aria-checked': enabled,
1043
+ 'aria-label': '开关 iterate 主题',
1044
+ className: 'iterate-switch',
1045
+ 'data-on': enabled ? '' : undefined,
1046
+ onClick: toggleTheme,
1047
+ }, React.createElement('span', { className: 'iterate-switch-knob' })),
1048
+ ),
1049
+ ),
1050
+ )
1051
+
1052
+ const dataCard = React.createElement('div', { key: 'data', className: 'iterate-scard' },
1053
+ React.createElement('div', { className: 'iterate-scard-head' },
1054
+ React.createElement('div', {},
1055
+ React.createElement('div', { className: 'iterate-settings-title' }, '分诊持久化'),
1056
+ React.createElement('div', { className: 'iterate-settings-desc' }, 'y / n / a 判定保存在本地浏览器(localStorage),刷新会话后仍保留。'),
1057
+ ),
1058
+ React.createElement('div', { className: 'iterate-scard-actions' },
1059
+ React.createElement('span', { className: 'iterate-chip' }, '本地保存'),
1060
+ clearButton,
1061
+ ),
1062
+ ),
1063
+ )
1064
+
1065
+ const guideCard = React.createElement('div', { key: 'guide', className: 'iterate-scard' },
1066
+ React.createElement('div', { className: 'iterate-scard-head' },
1067
+ React.createElement('div', {},
1068
+ React.createElement('div', { className: 'iterate-settings-title' }, '配置管理'),
1069
+ React.createElement('div', { className: 'iterate-settings-desc' }, '目标 / 维度 / 最大轮数写在项目的 iterate.config.yaml,复制指引可让模型按需调整。'),
1070
+ ),
1071
+ React.createElement('div', { className: 'iterate-scard-actions' },
1072
+ React.createElement('button', { className: 'iterate-btn', 'data-primary': '', 'data-copied': guideCopied ? '' : undefined, onClick: () => doCopy(guide, 'guide') }, guideCopied ? '已复制' : '复制指引'),
1073
+ React.createElement('button', { className: 'iterate-btn', 'data-ghost': '', onClick: () => setShowGuide((v) => !v) }, showGuide ? '收起' : '展开'),
1074
+ ),
1075
+ ),
1076
+ showGuide
1077
+ ? React.createElement('div', { className: 'iterate-guide' },
1078
+ React.createElement('div', { className: 'iterate-guide-bar' },
1079
+ React.createElement('span', {}, 'iterate.config.yaml 可编辑字段'),
1080
+ React.createElement('button', { className: 'iterate-btn', 'data-ghost': '', onClick: () => doCopy(guide, 'guide') }, '复制'),
1081
+ ),
1082
+ React.createElement('div', { className: 'iterate-guide-body' }, guide),
1083
+ )
1084
+ : null,
1085
+ )
1086
+
1087
+ const statusCard = React.createElement('div', { key: 'status', className: 'iterate-scard' },
1088
+ React.createElement('div', { className: 'iterate-scard-head' },
1089
+ React.createElement('div', {},
1090
+ React.createElement('div', { className: 'iterate-settings-title' }, '状态概览'),
1091
+ React.createElement('div', { className: 'iterate-settings-desc' }, '查看运行时产物布局与清理指引。iterate_status / iterate_history / iterate_prune 工具用于查看和管理。'),
1092
+ ),
1093
+ React.createElement('div', { className: 'iterate-scard-actions' },
1094
+ React.createElement('button', { className: 'iterate-btn', 'data-primary': '', 'data-copied': statusCopied ? '' : undefined, onClick: () => doCopy(statusGuide, 'status') }, statusCopied ? '已复制' : '复制指引'),
1095
+ React.createElement('button', { className: 'iterate-btn', 'data-ghost': '', onClick: () => setShowStatus((v) => !v) }, showStatus ? '收起' : '展开'),
1096
+ ),
1097
+ ),
1098
+ showStatus
1099
+ ? React.createElement('div', { className: 'iterate-guide' },
1100
+ React.createElement('div', { className: 'iterate-guide-bar' },
1101
+ React.createElement('span', {}, '运行时布局与清理'),
1102
+ React.createElement('button', { className: 'iterate-btn', 'data-ghost': '', onClick: () => doCopy(statusGuide, 'status') }, '复制'),
1103
+ ),
1104
+ React.createElement('div', { className: 'iterate-guide-body' }, statusGuide),
1105
+ )
1106
+ : null,
1107
+ )
1108
+
1109
+ const banner = React.createElement('div', { key: 'banner', className: 'iterate-scard' },
1110
+ React.createElement('div', { className: 'iterate-scard-head' },
1111
+ React.createElement('div', {},
1112
+ React.createElement('div', { className: 'iterate-settings-title' }, 'iterate'),
1113
+ React.createElement('div', { className: 'iterate-settings-desc' }, '为每次代码评审生成 Review 报告与交互式分诊视图,专注 AI 自我审查与修正。'),
1114
+ ),
1115
+ React.createElement('span', { className: 'iterate-pill' },
1116
+ React.createElement('span', { className: 'iterate-pill-dot' }),
1117
+ '就绪',
1118
+ ),
1119
+ ),
1120
+ )
1121
+
1122
+ return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'settings', className: 'iterate-settings' },
1123
+ React.createElement('div', { className: 'iterate-settings-title', style: { fontSize: 15, fontWeight: 700 } }, 'iterate 设置'),
1124
+ banner,
1125
+ themeCard,
1126
+ dataCard,
1127
+ guideCard,
1128
+ statusCard,
1129
+ )
1130
+ }
1131
+
1132
+ // ─── Registration ────────────────────────────────────────────────────────────
1133
+
1134
+ /**
1135
+ * Chain selector for conversation.chat.turnTail.
1136
+ * Returns a marker when the turn contains an iterate review report, null otherwise.
1137
+ */
1138
+ function selectTurnTail(owner: SlotProps): { matched: boolean } | null {
1139
+ if (!owner) return null
1140
+ if (findReportInObject(owner.turn, undefined, 24) || scanSessionForReport(owner.turn)) return { matched: true }
1141
+ if (scanSessionForRunSummary(owner.turn)) return { matched: true }
1142
+ return null
1143
+ }
1144
+
1145
+ /**
1146
+ * Plugin client entry. Called by the dsh client runtime with its context.
1147
+ */
1148
+ export function apply(ctx: ClientContext): void {
1149
+ // 1. Resolve optional services (degrade, never throw).
1150
+ slotsSvc = readSlots(ctx)
1151
+ themeSvc = readTheme(ctx)
1152
+ storage = createStorage()
1153
+
1154
+ // 2. Theme preference bootstrap.
1155
+ const savedTheme = storage.get(THEME_STORAGE_KEY)
1156
+ themeEnabled = savedTheme === null ? true : savedTheme === '1'
1157
+ if (themeEnabled) applyThemeSkin()
1158
+
1159
+ // 3. Inject styles (independent of slots / React availability).
1160
+ if (typeof document !== 'undefined' && document.createElement && document.head) {
1161
+ const style = document.createElement('style')
1162
+ style.dataset.plugin = PLUGIN_TAG
1163
+ style.dataset.pluginCss = 'iterate-main'
1164
+ style.textContent = ITERATE_CSS
1165
+ document.head.appendChild(style)
1166
+ if (typeof ctx.effect === 'function') {
1167
+ ctx.effect(() => { try { style.remove() } catch { /* noop */ } })
1168
+ }
1169
+ }
1170
+
1171
+ // 4. Slot UI requires the slots service.
1172
+ if (slotsSvc === undefined) {
1173
+ log('slots service unavailable — slot UI disabled')
1174
+ return
1175
+ }
1176
+
1177
+ // 5. Register the six-part UI.
1178
+ if (typeof slotsSvc.inject === 'function') {
1179
+ // 1 + 5: convergence dashboard + round pulse (session scope).
1180
+ slotsSvc.inject('conversation.input.dock', () =>
1181
+ slotsSvc?.register(
1182
+ { name: 'conversation.input.dock', id: 'iterate-dashboard', order: 90 },
1183
+ (props) => React.createElement(ConvergenceDashboard, props),
1184
+ ),
1185
+ )
1186
+
1187
+ // 2 + 3: triage panel / stats card (turn-tail chain, session scope).
1188
+ slotsSvc.inject('conversation.chat.turnTail', () =>
1189
+ slotsSvc?.register(
1190
+ { name: 'conversation.chat.turnTail', id: 'iterate-turn-tail', select: selectTurnTail },
1191
+ (props) => React.createElement(TurnTailEntry, props),
1192
+ ),
1193
+ )
1194
+
1195
+ // 5: progress capsule (frame overlay, root scope).
1196
+ slotsSvc.inject('shell.overlay', () =>
1197
+ slotsSvc?.register(
1198
+ { name: 'shell.overlay', id: 'iterate-progress', order: 0 },
1199
+ (props) => React.createElement(ProgressCapsule, props),
1200
+ ),
1201
+ )
1202
+
1203
+ // 6: settings page section (root scope).
1204
+ slotsSvc.inject('settings.section', () =>
1205
+ slotsSvc?.register(
1206
+ { name: 'settings.section', id: 'iterate-settings', order: 30, label: () => 'iterate' },
1207
+ (props) => React.createElement(SettingsPanel, props),
1208
+ ),
1209
+ )
1210
+ }
1211
+
1212
+ // 6: keep theme toggle state consistent across theme/change events.
1213
+ if (typeof ctx.on === 'function') {
1214
+ ctx.on('theme/change', () => {
1215
+ if (themeEnabled) applyThemeSkin()
1216
+ })
1217
+ }
1218
+ }