iterate-plugin 2.3.7 → 2.4.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 ADDED
@@ -0,0 +1,635 @@
1
+ /**
2
+ * lib/client.js — iterate-plugin client entry.
3
+ *
4
+ * Loaded by the dsh web front-end via `package.json.dsh.client` (the
5
+ * `exports["./client"]` subpath). Module contract matches dsh-gui-customization:
6
+ * the file exports an `apply(ctx)` function that is called with the client
7
+ * Cordis context.
8
+ *
9
+ * Implements the §14.4 / §16 six-part UI layer:
10
+ * 1. Convergence dashboard -> conversation.input.dock
11
+ * 2. Findings triage panel -> conversation.chat.turnTail
12
+ * 3. Iteration stats card -> conversation.chat.turnTail (same chain, empty-findings case)
13
+ * 4. iterate theme skin -> theme.overrideTokens
14
+ * 5. Progress event linkage -> ctx.on('slots/changed') + round-pulse + shell.overlay capsule
15
+ * 6. Settings page section -> settings.section
16
+ *
17
+ * Design notes:
18
+ * - Build-free: no bundler. All styles are injected via one <style> tag and
19
+ * colors use the dsh token variables (`--dsw-*`).
20
+ * - Defensive by design: every service lookup is optional and guarded, so a
21
+ * missing slot/theme/React degrades gracefully instead of crashing the UI.
22
+ */
23
+
24
+ import {
25
+ findReportInObject,
26
+ scanSessionForReport,
27
+ normalizeReport,
28
+ computeConvergenceProgress,
29
+ getCurrentRound,
30
+ getTotalRounds,
31
+ severityStats,
32
+ groupByDimension,
33
+ buildTriageState,
34
+ hashReport,
35
+ toKnownIntentionalYaml,
36
+ buildApplyInstruction,
37
+ collectIgnoredEntries,
38
+ SEVERITY_LABEL,
39
+ SEVERITY_COLOR,
40
+ } from './parse.js'
41
+
42
+ // ─── Constants ───────────────────────────────────────────────────────────────
43
+
44
+ const PLUGIN_TAG = 'iterate-ui'
45
+
46
+ /** localStorage key prefix for triage verdicts. */
47
+ const TRIAGE_STORAGE_PREFIX = 'iterate.triage.'
48
+
49
+ /** localStorage key for the theme skin toggle. */
50
+ const THEME_STORAGE_KEY = 'iterate.theme.enabled'
51
+
52
+ /** Theme override source name (matches the doc §16.1). */
53
+ const THEME_SOURCE = 'iterate'
54
+
55
+ /** Theme tokens: 13 dsw tokens, each with light + dark values (warm amber accent). */
56
+ const ITERATE_TOKENS = {
57
+ '--dsw-alias-bg-base': { light: '#FAF8F5', dark: '#171412' },
58
+ '--dsw-alias-bg-layer-1': { light: '#FFFFFF', dark: '#1F1B17' },
59
+ '--dsw-alias-bg-layer-2': { light: '#F4F1EA', dark: '#27221C' },
60
+ '--dsw-alias-bg-overlay': { light: 'rgba(255,255,255,0.96)', dark: 'rgba(23,20,18,0.96)' },
61
+ '--dsw-alias-border-l1': { light: '#E8E2D8', dark: '#332C25' },
62
+ '--dsw-alias-border-l2': { light: '#DDD5C9', dark: '#3C342B' },
63
+ '--dsw-alias-brand-primary': { light: '#B45309', dark: '#F59E0B' },
64
+ '--dsw-alias-label-primary': { light: '#1C1917', dark: '#F5F0EA' },
65
+ '--dsw-alias-label-secondary': { light: '#57534E', dark: '#A9A29B' },
66
+ '--dsw-alias-state-error-primary': { light: '#DC2626', dark: '#F87171' },
67
+ '--dsw-alias-state-success-primary': { light: '#15803D', dark: '#4ADE80' },
68
+ '--dsw-alias-state-warn-primary': { light: '#D97706', dark: '#FBBF24' },
69
+ '--dsw-specific-sidebar-fill': { light: '#F5F2EC', dark: '#14110E' },
70
+ }
71
+
72
+ /** Injected stylesheet. Every selector is prefixed to avoid clobbering dsh. */
73
+ const ITERATE_CSS = `
74
+ [data-iterate-root] { box-sizing: border-box; font-family: var(--dsw-font-sans, system-ui, sans-serif); }
75
+ [data-iterate-root] * , [data-iterate-root] *::before, [data-iterate-root] *::after { box-sizing: border-box; }
76
+
77
+ .iterate-dashboard {
78
+ display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
79
+ padding: 8px 14px; margin: 6px 0;
80
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px;
81
+ background: var(--dsw-alias-bg-layer-1);
82
+ font-size: 12px; color: var(--dsw-alias-label-primary);
83
+ }
84
+ .iterate-round-badge {
85
+ display: inline-flex; align-items: center; gap: 6px;
86
+ padding: 3px 10px; border-radius: 999px;
87
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary) 14%, transparent);
88
+ color: var(--dsw-alias-brand-primary); font-weight: 600; white-space: nowrap;
89
+ }
90
+ .iterate-round-badge[data-pulse] { animation: iterate-pulse 700ms ease-out; }
91
+ @keyframes iterate-pulse {
92
+ 0% { transform: scale(1); box-shadow: 0 0 0 0 color-mix(in srgb, var(--dsw-alias-brand-primary) 60%, transparent); }
93
+ 50% { transform: scale(1.12); box-shadow: 0 0 0 6px color-mix(in srgb, var(--dsw-alias-brand-primary) 0%, transparent); }
94
+ 100% { transform: scale(1); box-shadow: 0 0 0 0 transparent; }
95
+ }
96
+ .iterate-progress { flex: 1 1 120px; min-width: 120px; height: 6px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2); overflow: hidden; }
97
+ .iterate-progress-fill { height: 100%; border-radius: 999px; background: var(--dsw-alias-brand-primary); transition: width 300ms ease; }
98
+ .iterate-metric { display: inline-flex; align-items: center; gap: 5px; color: var(--dsw-alias-label-secondary); white-space: nowrap; }
99
+ .iterate-sev-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
100
+ .iterate-dim-badge {
101
+ padding: 2px 8px; border-radius: 6px;
102
+ background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1);
103
+ color: var(--dsw-alias-label-secondary); font-size: 11px; white-space: nowrap;
104
+ }
105
+
106
+ .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; }
107
+ .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); }
108
+ .iterate-triage-hint { font-size: 11px; font-weight: 400; color: var(--dsw-alias-label-secondary); }
109
+ .iterate-finding { padding: 10px 14px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
110
+ .iterate-finding:last-child { border-bottom: none; }
111
+ .iterate-finding-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 11px; color: var(--dsw-alias-label-secondary); }
112
+ .iterate-finding-file { font-family: var(--dsw-font-mono, ui-monospace, monospace); color: var(--dsw-alias-label-primary); }
113
+ .iterate-finding-summary { margin-top: 4px; font-size: 12px; color: var(--dsw-alias-label-primary); line-height: 1.5; }
114
+ .iterate-finding-actions { display: flex; gap: 6px; margin-top: 8px; }
115
+ .iterate-vbtn {
116
+ padding: 3px 10px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l1);
117
+ background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary);
118
+ font-size: 11px; cursor: pointer;
119
+ }
120
+ .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); }
121
+ .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); }
122
+ .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); }
123
+ .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); }
124
+ .iterate-btn {
125
+ padding: 4px 10px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l1);
126
+ background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font-size: 11px; cursor: pointer;
127
+ }
128
+ .iterate-btn[data-primary] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
129
+ .iterate-btn[data-copied] { border-color: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary); }
130
+ .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; }
131
+
132
+ .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; }
133
+ .iterate-stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); gap: 8px; margin-top: 8px; }
134
+ .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; }
135
+ .iterate-stat-num { font-size: 18px; font-weight: 700; color: var(--dsw-alias-label-primary); }
136
+ .iterate-stat-label { font-size: 10px; color: var(--dsw-alias-label-secondary); margin-top: 2px; }
137
+
138
+ .iterate-capsule {
139
+ position: fixed; bottom: 24px; right: 24px; z-index: 9999;
140
+ padding: 8px 14px; border-radius: 999px;
141
+ background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-brand-primary);
142
+ color: var(--dsw-alias-label-primary); font-size: 12px; box-shadow: 0 4px 16px rgba(0,0,0,0.15);
143
+ animation: iterate-fadein 200ms ease-out; pointer-events: auto;
144
+ }
145
+ @keyframes iterate-fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
146
+
147
+ .iterate-settings { display: flex; flex-direction: column; gap: 12px; padding: 4px 2px 12px; }
148
+ .iterate-settings-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--dsw-alias-border-l1); }
149
+ .iterate-settings-row:last-child { border-bottom: none; }
150
+ .iterate-settings-title { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); }
151
+ .iterate-settings-desc { font-size: 12px; color: var(--dsw-alias-label-secondary); margin-top: 3px; line-height: 1.5; }
152
+ .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); }
153
+ `
154
+
155
+ // ─── Small helpers ───────────────────────────────────────────────────────────
156
+
157
+ /** Namespaced logger (kept minimal; only errors/warnings, no debug spam). */
158
+ function log(...args) {
159
+ if (typeof console !== 'undefined' && typeof console.error === 'function') {
160
+ console.error(`[${PLUGIN_TAG}]`, ...args)
161
+ }
162
+ }
163
+
164
+ /** Resolve the React runtime (ctx.React first, then window.React). */
165
+ function resolveReact(ctx) {
166
+ if (ctx && ctx.React && typeof ctx.React.createElement === 'function') return ctx.React
167
+ const globalScope = typeof window !== 'undefined' ? window : typeof globalThis !== 'undefined' ? globalThis : null
168
+ if (globalScope && globalScope.React && typeof globalScope.React.createElement === 'function') {
169
+ return globalScope.React
170
+ }
171
+ return null
172
+ }
173
+
174
+ /** Safe localStorage wrapper (never throws in private mode / SSR). */
175
+ function createStorage() {
176
+ try {
177
+ const testKey = '__iterate_storage_test__'
178
+ window.localStorage.setItem(testKey, '1')
179
+ window.localStorage.removeItem(testKey)
180
+ return {
181
+ get(key) { return window.localStorage.getItem(key) },
182
+ set(key, value) { window.localStorage.setItem(key, value) },
183
+ remove(key) { window.localStorage.removeItem(key) },
184
+ }
185
+ } catch {
186
+ const mem = new Map()
187
+ return {
188
+ get(key) { return mem.has(key) ? mem.get(key) : null },
189
+ set(key, value) { mem.set(key, value) },
190
+ remove(key) { mem.delete(key) },
191
+ }
192
+ }
193
+ }
194
+
195
+ /** Copy text to the clipboard, returning whether it succeeded. */
196
+ function copyText(text) {
197
+ if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
198
+ navigator.clipboard.writeText(text).then(
199
+ () => true,
200
+ () => false,
201
+ )
202
+ return true
203
+ }
204
+ return false
205
+ }
206
+
207
+ // ─── Module-level runtime state (shared across slots) ────────────────────────
208
+
209
+ let React = null
210
+ let slotsSvc = null
211
+ let themeSvc = null
212
+ let storage = null
213
+ let themeDisposer = null
214
+ let themeEnabled = true
215
+ const roundPulseListeners = []
216
+
217
+ /** Emit a "round changed" pulse to subscribed components. */
218
+ function emitRoundPulse(round) {
219
+ for (const fn of roundPulseListeners.slice()) fn(round)
220
+ }
221
+
222
+ /** Apply the iterate theme skin (idempotent). */
223
+ function applyThemeSkin() {
224
+ if (!themeSvc || typeof themeSvc.overrideTokens !== 'function') return
225
+ // Never stack disposers: drop any previously applied override first.
226
+ clearThemeSkin()
227
+ themeDisposer = themeSvc.overrideTokens(THEME_SOURCE, ITERATE_TOKENS)
228
+ }
229
+
230
+ /** Remove the iterate theme skin (idempotent). */
231
+ function clearThemeSkin() {
232
+ if (themeDisposer) {
233
+ try { themeDisposer() } catch (err) { log('theme disposer failed', err) }
234
+ themeDisposer = null
235
+ }
236
+ }
237
+
238
+ /** Persist + apply the theme preference. */
239
+ function setThemeEnabled(enabled) {
240
+ themeEnabled = enabled
241
+ if (storage) storage.set(THEME_STORAGE_KEY, enabled ? '1' : '0')
242
+ if (enabled) applyThemeSkin()
243
+ else clearThemeSkin()
244
+ }
245
+
246
+ // ─── Components (React.createElement trees) ──────────────────────────────────
247
+
248
+ /** Obtain a session snapshot defensively from the slot props. */
249
+ function sessionSnapshot(props) {
250
+ let session = null
251
+ if (props && typeof props.useSession === 'function') {
252
+ try { session = props.useSession() } catch (err) { log('useSession failed', err) }
253
+ }
254
+ if (!session && props && props.session) session = props.session
255
+ return session
256
+ }
257
+
258
+ /** Find the latest report inside a session snapshot (normalized). */
259
+ function latestReport(session) {
260
+ if (!session) return null
261
+ const raw = scanSessionForReport(session) || findReportInObject(session, undefined, 24)
262
+ return raw ? normalizeReport(raw) : null
263
+ }
264
+
265
+ /** Dashboard: live convergence strip above the composer. */
266
+ function ConvergenceDashboard(props) {
267
+ const [pulseKey, setPulseKey] = React.useState(0)
268
+ const report = latestReport(sessionSnapshot(props))
269
+
270
+ React.useEffect(() => {
271
+ if (!report) return
272
+ const cur = getCurrentRound(report)
273
+ emitRoundPulse(cur)
274
+ setPulseKey((k) => k + 1)
275
+ }, [report && hashReport(report) + ':' + getCurrentRound(report)])
276
+
277
+ if (!report) return null
278
+
279
+ const round = getCurrentRound(report)
280
+ const total = getTotalRounds(report)
281
+ const progress = computeConvergenceProgress(report)
282
+ const stats = severityStats(report)
283
+ const dims = groupByDimension(report)
284
+
285
+ const dimBadges = Object.keys(dims).slice(0, 6).map((dim) =>
286
+ React.createElement(
287
+ 'span',
288
+ { key: dim, className: 'iterate-dim-badge' },
289
+ `${dim} · ${dims[dim].length}`,
290
+ ),
291
+ )
292
+
293
+ return React.createElement(
294
+ 'div',
295
+ { 'data-iterate-root': '', 'data-iterate': 'dashboard', className: 'iterate-dashboard' },
296
+ React.createElement(
297
+ 'span',
298
+ { className: 'iterate-round-badge', 'data-pulse': pulseKey > 0 ? '' : undefined, key: `round-${round}` },
299
+ `Round ${round} / ${total}`,
300
+ ),
301
+ React.createElement('div', { className: 'iterate-progress' },
302
+ React.createElement('div', { className: 'iterate-progress-fill', style: { width: `${progress}%` } }),
303
+ ),
304
+ React.createElement('span', { className: 'iterate-metric' },
305
+ React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.critical } }),
306
+ stats.critical,
307
+ ),
308
+ React.createElement('span', { className: 'iterate-metric' },
309
+ React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.high } }),
310
+ stats.high,
311
+ ),
312
+ React.createElement('span', { className: 'iterate-metric' },
313
+ React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.medium } }),
314
+ stats.medium,
315
+ ),
316
+ ...dimBadges,
317
+ )
318
+ }
319
+
320
+ /** Stats card (empty-findings case of the turn-tail chain). */
321
+ function StatsCard(props) {
322
+ const report = props.report
323
+ const stats = severityStats(report)
324
+ const total = stats.critical + stats.high + stats.medium + stats.low
325
+ const rows = [
326
+ { label: 'Critical', value: stats.critical, color: SEVERITY_COLOR.critical },
327
+ { label: 'High', value: stats.high, color: SEVERITY_COLOR.high },
328
+ { label: 'Medium', value: stats.medium, color: SEVERITY_COLOR.medium },
329
+ { label: 'Low', value: stats.low, color: SEVERITY_COLOR.low },
330
+ ].map((r) =>
331
+ React.createElement('div', { key: r.label, className: 'iterate-stat' },
332
+ React.createElement('div', { className: 'iterate-stat-num', style: { color: r.color } }, String(r.value)),
333
+ React.createElement('div', { className: 'iterate-stat-label' }, r.label),
334
+ ),
335
+ )
336
+
337
+ return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'stats', className: 'iterate-stats' },
338
+ React.createElement('div', { className: 'iterate-triage-head' },
339
+ React.createElement('span', {}, 'Iterate · 收敛统计'),
340
+ React.createElement('span', { className: 'iterate-triage-hint' },
341
+ `Round ${getCurrentRound(report)}/${getTotalRounds(report)} · ${total} findings · ${report.convergence && report.convergence.converged ? '已收敛' : '未收敛'}`,
342
+ ),
343
+ ),
344
+ React.createElement('div', { className: 'iterate-stats-grid' }, ...rows),
345
+ )
346
+ }
347
+
348
+ /** Triage panel: per-finding y / n / a with localStorage persistence. */
349
+ function TriagePanel(props) {
350
+ const report = props.report
351
+ const findings = report.findings
352
+ const storageKey = TRIAGE_STORAGE_PREFIX + hashReport(report)
353
+ const [verdicts, setVerdicts] = React.useState(() => {
354
+ const initial = buildTriageState(report)
355
+ const saved = storage ? storage.get(storageKey) : null
356
+ if (saved) {
357
+ try {
358
+ const parsed = JSON.parse(saved)
359
+ if (parsed && typeof parsed === 'object') {
360
+ for (const key of Object.keys(initial)) {
361
+ if (parsed[key] === 'keep' || parsed[key] === 'skip' || parsed[key] === 'ignore') {
362
+ initial[key] = parsed[key]
363
+ }
364
+ }
365
+ }
366
+ } catch { /* corrupt storage, fall back to defaults */ }
367
+ }
368
+ return initial
369
+ })
370
+ const [payload, setPayload] = React.useState(null)
371
+ const [copied, setCopied] = React.useState(false)
372
+
373
+ 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
+ })
379
+ }
380
+
381
+ const ignored = collectIgnoredEntries(verdicts, findings)
382
+ const ignoredCount = ignored.length
383
+
384
+ const doCopyYaml = () => {
385
+ const yaml = toKnownIntentionalYaml(ignored)
386
+ if (!yaml) return
387
+ const ok = copyText(yaml)
388
+ setCopied(true)
389
+ setTimeout(() => setCopied(false), 1600)
390
+ if (!ok) setPayload(yaml)
391
+ }
392
+
393
+ const doBuildInstruction = () => {
394
+ const text = buildApplyInstruction(ignored)
395
+ setPayload(text)
396
+ if (text) copyText(text)
397
+ }
398
+
399
+ const rows = findings.map((finding, index) => {
400
+ const severity = finding.severity || 'low'
401
+ const verdict = verdicts[String(index)] || 'keep'
402
+ const btn = (label, value, title) =>
403
+ React.createElement(
404
+ 'button',
405
+ {
406
+ key: value,
407
+ className: 'iterate-vbtn',
408
+ 'data-active': verdict === value ? value : undefined,
409
+ title,
410
+ onClick: () => setVerdict(index, value),
411
+ },
412
+ label,
413
+ )
414
+
415
+ return React.createElement('div', { key: String(index), className: 'iterate-finding' },
416
+ React.createElement('div', { className: 'iterate-finding-meta' },
417
+ React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR[severity] || SEVERITY_COLOR.low } }),
418
+ React.createElement('span', {}, String(SEVERITY_LABEL[severity] || 'LOW')),
419
+ React.createElement('span', { className: 'iterate-finding-file' }, String(finding.file || '?')),
420
+ finding.line ? React.createElement('span', {}, `:${finding.line}`) : null,
421
+ React.createElement('span', {}, String(finding.dimension || '')),
422
+ ),
423
+ React.createElement('div', { className: 'iterate-finding-summary' }, String(finding.summary || '')),
424
+ React.createElement('div', { className: 'iterate-finding-actions' },
425
+ btn('y 修复', 'keep', '保留该 finding,进入修复'),
426
+ btn('n 跳过', 'skip', '跳过该 finding'),
427
+ btn('a 已知有意', 'ignore', '标记为已知有意并写入 known_intentional'),
428
+ ),
429
+ )
430
+ })
431
+
432
+ return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'triage', className: 'iterate-triage' },
433
+ 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=已知有意'),
436
+ ),
437
+ ...rows,
438
+ React.createElement('div', { className: 'iterate-triage-foot' },
439
+ React.createElement('span', {}, `待写回 known_intentional:${ignoredCount} 条`),
440
+ React.createElement('span', { style: { display: 'flex', gap: 6 } },
441
+ React.createElement('button', { className: 'iterate-btn', 'data-primary': '', 'data-copied': copied ? '' : undefined, onClick: doCopyYaml }, copied ? '已复制' : '复制 known_intentional'),
442
+ React.createElement('button', { className: 'iterate-btn', onClick: doBuildInstruction }, '生成应用指令'),
443
+ ),
444
+ ),
445
+ payload
446
+ ? React.createElement('div', { className: 'iterate-payload' }, payload)
447
+ : null,
448
+ )
449
+ }
450
+
451
+ /** Turn-tail chain entry: triage when findings exist, stats card otherwise. */
452
+ function TurnTailEntry(props) {
453
+ const candidates = []
454
+ if (props && props.turn) candidates.push(props.turn)
455
+ if (props && props.matched) candidates.push(props.matched)
456
+ if (props && props.data) candidates.push(props.data)
457
+ candidates.push(props)
458
+
459
+ let report = null
460
+ for (const c of candidates) {
461
+ const raw = findReportInObject(c, undefined, 24) || scanSessionForReport(c)
462
+ if (raw) { report = normalizeReport(raw); break }
463
+ }
464
+ if (!report) return null
465
+ // Render through React.createElement so each panel is a real component with
466
+ // its own hook identity (calling them as functions would violate the Rules
467
+ // of Hooks and crash when the findings/empty branch flips between renders).
468
+ if (!report.findings || report.findings.length === 0) {
469
+ return React.createElement(StatsCard, { report })
470
+ }
471
+ return React.createElement(TriagePanel, { report })
472
+ }
473
+
474
+ /** Progress capsule: briefly surfaces round-completion in a frame overlay. */
475
+ function ProgressCapsule() {
476
+ const [info, setInfo] = React.useState(null)
477
+ React.useEffect(() => {
478
+ let timer = null
479
+ const listener = (round) => {
480
+ setInfo(`Round ${round} 完成`)
481
+ if (timer) clearTimeout(timer)
482
+ timer = setTimeout(() => setInfo(null), 2400)
483
+ }
484
+ roundPulseListeners.push(listener)
485
+ return () => {
486
+ const i = roundPulseListeners.indexOf(listener)
487
+ if (i >= 0) roundPulseListeners.splice(i, 1)
488
+ if (timer) clearTimeout(timer)
489
+ }
490
+ }, [])
491
+ if (!info) return null
492
+ return React.createElement('div', { 'data-iterate-root': '', className: 'iterate-capsule' }, info)
493
+ }
494
+
495
+ /** Settings page section (root scope; reads localStorage + latest known data). */
496
+ function SettingsPanel() {
497
+ const [enabled, setEnabled] = React.useState(themeEnabled)
498
+ const [copied, setCopied] = React.useState(false)
499
+
500
+ const toggleTheme = () => {
501
+ const next = !enabled
502
+ setEnabled(next)
503
+ setThemeEnabled(next)
504
+ }
505
+
506
+ 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
+ copyText(guide)
514
+ setCopied(true)
515
+ setTimeout(() => setCopied(false), 1600)
516
+ }
517
+
518
+ return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'settings', className: 'iterate-settings' },
519
+ React.createElement('div', { className: 'iterate-settings-title' }, 'iterate 设置'),
520
+ React.createElement('div', { className: 'iterate-settings-row' },
521
+ React.createElement('div', {},
522
+ React.createElement('div', { className: 'iterate-settings-title' }, 'iterate 主题'),
523
+ React.createElement('div', { className: 'iterate-settings-desc' }, '启用暖琥珀配色的 iterate 专属皮肤(覆盖 dsh 默认主题令牌)。'),
524
+ ),
525
+ React.createElement('button', {
526
+ className: 'iterate-btn',
527
+ 'data-primary': enabled ? '' : undefined,
528
+ onClick: toggleTheme,
529
+ }, enabled ? '已启用' : '已关闭'),
530
+ ),
531
+ React.createElement('div', { className: 'iterate-settings-row' },
532
+ React.createElement('div', {},
533
+ React.createElement('div', { className: 'iterate-settings-title' }, '分诊持久化'),
534
+ React.createElement('div', { className: 'iterate-settings-desc' }, '分诊面板的 y/n/a 判定保存在本地浏览器(localStorage),刷新会话后仍保留。'),
535
+ ),
536
+ React.createElement('span', { className: 'iterate-chip' }, '本地保存'),
537
+ ),
538
+ React.createElement('div', { className: 'iterate-settings-row' },
539
+ React.createElement('div', {},
540
+ React.createElement('div', { className: 'iterate-settings-title' }, '配置管理'),
541
+ React.createElement('div', { className: 'iterate-settings-desc' }, '目标 / 维度 / 最大轮数写在项目的 iterate.config.yaml。复制下方指引,让模型为你调整。'),
542
+ ),
543
+ React.createElement('button', { className: 'iterate-btn', 'data-primary': '', 'data-copied': copied ? '' : undefined, onClick: doCopyGuide }, copied ? '已复制' : '复制指引'),
544
+ ),
545
+ )
546
+ }
547
+
548
+ // ─── Registration ────────────────────────────────────────────────────────────
549
+
550
+ /**
551
+ * Chain selector for conversation.chat.turnTail.
552
+ * Returns a marker when the turn contains an iterate review report, null otherwise.
553
+ */
554
+ function selectTurnTail(owner) {
555
+ if (!owner) return null
556
+ const raw = findReportInObject(owner.turn, undefined, 24) || scanSessionForReport(owner.turn)
557
+ return raw ? { matched: true } : null
558
+ }
559
+
560
+ /**
561
+ * Plugin client entry. Called by dsh web with the client Cordis context.
562
+ */
563
+ export function apply(ctx) {
564
+ // 1. Resolve optional services (degrade, never throw).
565
+ const get = ctx && typeof ctx.get === 'function' ? ctx.get.bind(ctx) : null
566
+ slotsSvc = get ? get('slots') : undefined
567
+ themeSvc = get ? get('theme') : undefined
568
+ React = resolveReact(ctx)
569
+ storage = createStorage()
570
+
571
+ // 2. Theme preference bootstrap.
572
+ const savedTheme = storage.get(THEME_STORAGE_KEY)
573
+ themeEnabled = savedTheme === null ? true : savedTheme === '1'
574
+ if (themeEnabled) applyThemeSkin()
575
+
576
+ // 3. Inject styles (independent of slots / React availability).
577
+ if (typeof document !== 'undefined' && document.createElement && document.head) {
578
+ const style = document.createElement('style')
579
+ style.dataset.plugin = PLUGIN_TAG
580
+ style.dataset.pluginCss = 'iterate-main'
581
+ style.textContent = ITERATE_CSS
582
+ document.head.appendChild(style)
583
+ if (typeof ctx.effect === 'function') {
584
+ ctx.effect(() => { try { style.remove() } catch { /* noop */ } })
585
+ }
586
+ }
587
+
588
+ // 4. Slot UI requires React + slots service.
589
+ if (React === null || slotsSvc === undefined) {
590
+ log('React or slots service unavailable — slot UI disabled')
591
+ return
592
+ }
593
+
594
+ // 5. Register the six-part UI.
595
+ if (typeof slotsSvc.inject === 'function') {
596
+ // 1 + 5: convergence dashboard + round pulse (session scope).
597
+ slotsSvc.inject('conversation.input.dock', () =>
598
+ slotsSvc.register(
599
+ { name: 'conversation.input.dock', id: 'iterate-dashboard', order: 90 },
600
+ (props) => React.createElement(ConvergenceDashboard, props),
601
+ ),
602
+ )
603
+
604
+ // 2 + 3: triage panel / stats card (turn-tail chain, session scope).
605
+ slotsSvc.inject('conversation.chat.turnTail', () =>
606
+ slotsSvc.register(
607
+ { name: 'conversation.chat.turnTail', id: 'iterate-turn-tail', select: selectTurnTail },
608
+ (props) => React.createElement(TurnTailEntry, props),
609
+ ),
610
+ )
611
+
612
+ // 5: progress capsule (frame overlay, root scope).
613
+ slotsSvc.inject('shell.overlay', () =>
614
+ slotsSvc.register(
615
+ { name: 'shell.overlay', id: 'iterate-progress', order: 0 },
616
+ (props) => React.createElement(ProgressCapsule, props),
617
+ ),
618
+ )
619
+
620
+ // 6: settings page section (root scope).
621
+ slotsSvc.inject('settings.section', () =>
622
+ slotsSvc.register(
623
+ { name: 'settings.section', id: 'iterate-settings', order: 30, label: () => 'iterate' },
624
+ (props) => React.createElement(SettingsPanel, props),
625
+ ),
626
+ )
627
+ }
628
+
629
+ // 6: keep theme toggle state consistent across theme/change events.
630
+ if (typeof ctx.on === 'function') {
631
+ ctx.on('theme/change', () => {
632
+ if (themeEnabled) applyThemeSkin()
633
+ })
634
+ }
635
+ }