iterate-plugin 2.3.7 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +852 -0
- package/lib/parse.js +790 -0
- package/package.json +11 -4
- package/src/config-write.ts +181 -0
- package/src/index.ts +13 -4
- package/src/paths.ts +38 -0
- package/src/review.ts +13 -6
- package/src/skill-prompt.ts +117 -24
- package/src/tools/checkpoint.ts +285 -0
- package/src/tools/config.ts +64 -6
- package/src/tools/decision-log.ts +14 -4
- package/src/tools/fix.ts +565 -0
- package/src/tools/triage.ts +370 -0
- package/src/types.ts +64 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,852 @@
|
|
|
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
|
+
filterFindingsWithIndices,
|
|
39
|
+
buildFilterOptions,
|
|
40
|
+
countVerdicts,
|
|
41
|
+
batchSetVerdict,
|
|
42
|
+
setAllVerdicts,
|
|
43
|
+
buildRoundHistory,
|
|
44
|
+
buildFindingTrend,
|
|
45
|
+
computeTrendMetrics,
|
|
46
|
+
trendMax,
|
|
47
|
+
buildCompletionSummary,
|
|
48
|
+
buildConfigEditGuide,
|
|
49
|
+
keyToVerdict,
|
|
50
|
+
SEVERITY_LABEL,
|
|
51
|
+
SEVERITY_COLOR,
|
|
52
|
+
} from './parse.js'
|
|
53
|
+
|
|
54
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
const PLUGIN_TAG = 'iterate-ui'
|
|
57
|
+
|
|
58
|
+
/** localStorage key prefix for triage verdicts. */
|
|
59
|
+
const TRIAGE_STORAGE_PREFIX = 'iterate.triage.'
|
|
60
|
+
|
|
61
|
+
/** localStorage key for the theme skin toggle. */
|
|
62
|
+
const THEME_STORAGE_KEY = 'iterate.theme.enabled'
|
|
63
|
+
|
|
64
|
+
/** Theme override source name (matches the doc §16.1). */
|
|
65
|
+
const THEME_SOURCE = 'iterate'
|
|
66
|
+
|
|
67
|
+
/** Theme tokens: 13 dsw tokens, each with light + dark values (warm amber accent). */
|
|
68
|
+
const ITERATE_TOKENS = {
|
|
69
|
+
'--dsw-alias-bg-base': { light: '#FAF8F5', dark: '#171412' },
|
|
70
|
+
'--dsw-alias-bg-layer-1': { light: '#FFFFFF', dark: '#1F1B17' },
|
|
71
|
+
'--dsw-alias-bg-layer-2': { light: '#F4F1EA', dark: '#27221C' },
|
|
72
|
+
'--dsw-alias-bg-overlay': { light: 'rgba(255,255,255,0.96)', dark: 'rgba(23,20,18,0.96)' },
|
|
73
|
+
'--dsw-alias-border-l1': { light: '#E8E2D8', dark: '#332C25' },
|
|
74
|
+
'--dsw-alias-border-l2': { light: '#DDD5C9', dark: '#3C342B' },
|
|
75
|
+
'--dsw-alias-brand-primary': { light: '#B45309', dark: '#F59E0B' },
|
|
76
|
+
'--dsw-alias-label-primary': { light: '#1C1917', dark: '#F5F0EA' },
|
|
77
|
+
'--dsw-alias-label-secondary': { light: '#57534E', dark: '#A9A29B' },
|
|
78
|
+
'--dsw-alias-state-error-primary': { light: '#DC2626', dark: '#F87171' },
|
|
79
|
+
'--dsw-alias-state-success-primary': { light: '#15803D', dark: '#4ADE80' },
|
|
80
|
+
'--dsw-alias-state-warn-primary': { light: '#D97706', dark: '#FBBF24' },
|
|
81
|
+
'--dsw-specific-sidebar-fill': { light: '#F5F2EC', dark: '#14110E' },
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Injected stylesheet. Every selector is prefixed to avoid clobbering dsh. */
|
|
85
|
+
const ITERATE_CSS = `
|
|
86
|
+
[data-iterate-root] { box-sizing: border-box; font-family: var(--dsw-font-sans, system-ui, sans-serif); }
|
|
87
|
+
[data-iterate-root] * , [data-iterate-root] *::before, [data-iterate-root] *::after { box-sizing: border-box; }
|
|
88
|
+
|
|
89
|
+
.iterate-dashboard {
|
|
90
|
+
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
|
91
|
+
padding: 8px 14px; margin: 6px 0;
|
|
92
|
+
border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px;
|
|
93
|
+
background: var(--dsw-alias-bg-layer-1);
|
|
94
|
+
font-size: 12px; color: var(--dsw-alias-label-primary);
|
|
95
|
+
}
|
|
96
|
+
.iterate-round-badge {
|
|
97
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
98
|
+
padding: 3px 10px; border-radius: 999px;
|
|
99
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary) 14%, transparent);
|
|
100
|
+
color: var(--dsw-alias-brand-primary); font-weight: 600; white-space: nowrap;
|
|
101
|
+
}
|
|
102
|
+
.iterate-round-badge[data-pulse] { animation: iterate-pulse 700ms ease-out; }
|
|
103
|
+
@keyframes iterate-pulse {
|
|
104
|
+
0% { transform: scale(1); box-shadow: 0 0 0 0 color-mix(in srgb, var(--dsw-alias-brand-primary) 60%, transparent); }
|
|
105
|
+
50% { transform: scale(1.12); box-shadow: 0 0 0 6px color-mix(in srgb, var(--dsw-alias-brand-primary) 0%, transparent); }
|
|
106
|
+
100% { transform: scale(1); box-shadow: 0 0 0 0 transparent; }
|
|
107
|
+
}
|
|
108
|
+
.iterate-progress { flex: 1 1 120px; min-width: 120px; height: 6px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2); overflow: hidden; }
|
|
109
|
+
.iterate-progress-fill { height: 100%; border-radius: 999px; background: var(--dsw-alias-brand-primary); transition: width 300ms ease; }
|
|
110
|
+
.iterate-metric { display: inline-flex; align-items: center; gap: 5px; color: var(--dsw-alias-label-secondary); white-space: nowrap; }
|
|
111
|
+
.iterate-sev-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
|
112
|
+
.iterate-dim-badge {
|
|
113
|
+
padding: 2px 8px; border-radius: 6px;
|
|
114
|
+
background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1);
|
|
115
|
+
color: var(--dsw-alias-label-secondary); font-size: 11px; white-space: nowrap;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.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; }
|
|
119
|
+
.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); }
|
|
120
|
+
.iterate-triage-hint { font-size: 11px; font-weight: 400; color: var(--dsw-alias-label-secondary); }
|
|
121
|
+
.iterate-finding { padding: 10px 14px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
|
|
122
|
+
.iterate-finding:last-child { border-bottom: none; }
|
|
123
|
+
.iterate-finding-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 11px; color: var(--dsw-alias-label-secondary); }
|
|
124
|
+
.iterate-finding-file { font-family: var(--dsw-font-mono, ui-monospace, monospace); color: var(--dsw-alias-label-primary); }
|
|
125
|
+
.iterate-finding-summary { margin-top: 4px; font-size: 12px; color: var(--dsw-alias-label-primary); line-height: 1.5; }
|
|
126
|
+
.iterate-finding-actions { display: flex; gap: 6px; margin-top: 8px; }
|
|
127
|
+
.iterate-vbtn {
|
|
128
|
+
padding: 3px 10px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l1);
|
|
129
|
+
background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary);
|
|
130
|
+
font-size: 11px; cursor: pointer;
|
|
131
|
+
}
|
|
132
|
+
.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); }
|
|
133
|
+
.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); }
|
|
134
|
+
.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); }
|
|
135
|
+
.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); }
|
|
136
|
+
.iterate-btn {
|
|
137
|
+
padding: 4px 10px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l1);
|
|
138
|
+
background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font-size: 11px; cursor: pointer;
|
|
139
|
+
}
|
|
140
|
+
.iterate-btn[data-primary] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
|
|
141
|
+
.iterate-btn[data-copied] { border-color: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary); }
|
|
142
|
+
.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; }
|
|
143
|
+
|
|
144
|
+
.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; }
|
|
145
|
+
.iterate-stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); gap: 8px; margin-top: 8px; }
|
|
146
|
+
.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; }
|
|
147
|
+
.iterate-stat-num { font-size: 18px; font-weight: 700; color: var(--dsw-alias-label-primary); }
|
|
148
|
+
.iterate-stat-label { font-size: 10px; color: var(--dsw-alias-label-secondary); margin-top: 2px; }
|
|
149
|
+
|
|
150
|
+
.iterate-capsule {
|
|
151
|
+
position: fixed; bottom: 24px; right: 24px; z-index: 9999;
|
|
152
|
+
padding: 8px 14px; border-radius: 999px;
|
|
153
|
+
background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-brand-primary);
|
|
154
|
+
color: var(--dsw-alias-label-primary); font-size: 12px; box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
|
155
|
+
animation: iterate-fadein 200ms ease-out; pointer-events: auto;
|
|
156
|
+
}
|
|
157
|
+
@keyframes iterate-fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
|
158
|
+
|
|
159
|
+
.iterate-settings { display: flex; flex-direction: column; gap: 12px; padding: 4px 2px 12px; }
|
|
160
|
+
.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); }
|
|
161
|
+
.iterate-settings-row:last-child { border-bottom: none; }
|
|
162
|
+
.iterate-settings-title { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); }
|
|
163
|
+
.iterate-settings-desc { font-size: 12px; color: var(--dsw-alias-label-secondary); margin-top: 3px; line-height: 1.5; }
|
|
164
|
+
.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); }
|
|
165
|
+
|
|
166
|
+
/* Filter bar + batch toolbar (triage) */
|
|
167
|
+
.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); }
|
|
168
|
+
.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; }
|
|
169
|
+
.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; }
|
|
170
|
+
.iterate-filter-search::placeholder { color: var(--dsw-alias-label-secondary); }
|
|
171
|
+
.iterate-filter-count { margin-left: auto; white-space: nowrap; }
|
|
172
|
+
.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); }
|
|
173
|
+
.iterate-batch-label { margin-right: 2px; }
|
|
174
|
+
.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; }
|
|
175
|
+
.iterate-batch-btn:hover { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
|
|
176
|
+
.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); }
|
|
177
|
+
|
|
178
|
+
/* Trend chart (inline SVG) */
|
|
179
|
+
.iterate-trend { display: flex; align-items: flex-end; gap: 3px; height: 26px; padding: 2px 0; }
|
|
180
|
+
.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)); }
|
|
181
|
+
.iterate-trend-bar[data-hot] { background: var(--dsw-alias-brand-primary); }
|
|
182
|
+
|
|
183
|
+
/* History panel */
|
|
184
|
+
.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; }
|
|
185
|
+
.iterate-history-body { padding: 10px 14px; }
|
|
186
|
+
.iterate-history-table { width: 100%; border-collapse: collapse; font-size: 11px; color: var(--dsw-alias-label-primary); }
|
|
187
|
+
.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); }
|
|
188
|
+
.iterate-history-table td { padding: 4px 8px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
|
|
189
|
+
.iterate-history-table tr:last-child td { border-bottom: none; }
|
|
190
|
+
.iterate-history-num { font-weight: 600; }
|
|
191
|
+
.iterate-history-trendline { margin-top: 10px; font-size: 11px; color: var(--dsw-alias-label-secondary); }
|
|
192
|
+
.iterate-completion { margin-top: 8px; padding: 6px 10px; border-radius: 8px; font-size: 11px; }
|
|
193
|
+
.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); }
|
|
194
|
+
.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); }
|
|
195
|
+
.iterate-capsule[data-ok] { border-color: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary); }
|
|
196
|
+
.iterate-settings-guide { white-space: pre-wrap; padding: 8px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-family: var(--dsw-font-mono, ui-monospace, monospace); font-size: 11px; max-height: 180px; overflow: auto; }
|
|
197
|
+
`
|
|
198
|
+
|
|
199
|
+
// ─── Small helpers ───────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/** Namespaced logger (kept minimal; only errors/warnings, no debug spam). */
|
|
202
|
+
function log(...args) {
|
|
203
|
+
if (typeof console !== 'undefined' && typeof console.error === 'function') {
|
|
204
|
+
console.error(`[${PLUGIN_TAG}]`, ...args)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Resolve the React runtime (ctx.React first, then window.React). */
|
|
209
|
+
function resolveReact(ctx) {
|
|
210
|
+
if (ctx && ctx.React && typeof ctx.React.createElement === 'function') return ctx.React
|
|
211
|
+
const globalScope = typeof window !== 'undefined' ? window : typeof globalThis !== 'undefined' ? globalThis : null
|
|
212
|
+
if (globalScope && globalScope.React && typeof globalScope.React.createElement === 'function') {
|
|
213
|
+
return globalScope.React
|
|
214
|
+
}
|
|
215
|
+
return null
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Safe localStorage wrapper (never throws in private mode / SSR). */
|
|
219
|
+
function createStorage() {
|
|
220
|
+
try {
|
|
221
|
+
const testKey = '__iterate_storage_test__'
|
|
222
|
+
window.localStorage.setItem(testKey, '1')
|
|
223
|
+
window.localStorage.removeItem(testKey)
|
|
224
|
+
return {
|
|
225
|
+
get(key) { return window.localStorage.getItem(key) },
|
|
226
|
+
set(key, value) { window.localStorage.setItem(key, value) },
|
|
227
|
+
remove(key) { window.localStorage.removeItem(key) },
|
|
228
|
+
}
|
|
229
|
+
} catch {
|
|
230
|
+
const mem = new Map()
|
|
231
|
+
return {
|
|
232
|
+
get(key) { return mem.has(key) ? mem.get(key) : null },
|
|
233
|
+
set(key, value) { mem.set(key, value) },
|
|
234
|
+
remove(key) { mem.delete(key) },
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Copy text to the clipboard, returning whether it succeeded. */
|
|
240
|
+
function copyText(text) {
|
|
241
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
|
242
|
+
navigator.clipboard.writeText(text).then(
|
|
243
|
+
() => true,
|
|
244
|
+
() => false,
|
|
245
|
+
)
|
|
246
|
+
return true
|
|
247
|
+
}
|
|
248
|
+
return false
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ─── Module-level runtime state (shared across slots) ────────────────────────
|
|
252
|
+
|
|
253
|
+
let React = null
|
|
254
|
+
let slotsSvc = null
|
|
255
|
+
let themeSvc = null
|
|
256
|
+
let storage = null
|
|
257
|
+
let themeDisposer = null
|
|
258
|
+
let themeEnabled = true
|
|
259
|
+
const roundPulseListeners = []
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Emit a "round changed" pulse to subscribed components.
|
|
263
|
+
* The payload is `{ round, converged }` so the overlay capsule can surface
|
|
264
|
+
* completion AND convergence in one notification.
|
|
265
|
+
*/
|
|
266
|
+
function emitRoundPulse(round, converged) {
|
|
267
|
+
const payload = { round, converged: converged === true }
|
|
268
|
+
for (const fn of roundPulseListeners.slice()) fn(payload)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Apply the iterate theme skin (idempotent). */
|
|
272
|
+
function applyThemeSkin() {
|
|
273
|
+
if (!themeSvc || typeof themeSvc.overrideTokens !== 'function') return
|
|
274
|
+
// Never stack disposers: drop any previously applied override first.
|
|
275
|
+
clearThemeSkin()
|
|
276
|
+
themeDisposer = themeSvc.overrideTokens(THEME_SOURCE, ITERATE_TOKENS)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Remove the iterate theme skin (idempotent). */
|
|
280
|
+
function clearThemeSkin() {
|
|
281
|
+
if (themeDisposer) {
|
|
282
|
+
try { themeDisposer() } catch (err) { log('theme disposer failed', err) }
|
|
283
|
+
themeDisposer = null
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Persist + apply the theme preference. */
|
|
288
|
+
function setThemeEnabled(enabled) {
|
|
289
|
+
themeEnabled = enabled
|
|
290
|
+
if (storage) storage.set(THEME_STORAGE_KEY, enabled ? '1' : '0')
|
|
291
|
+
if (enabled) applyThemeSkin()
|
|
292
|
+
else clearThemeSkin()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ─── Components (React.createElement trees) ──────────────────────────────────
|
|
296
|
+
|
|
297
|
+
/** Obtain a session snapshot defensively from the slot props. */
|
|
298
|
+
function sessionSnapshot(props) {
|
|
299
|
+
let session = null
|
|
300
|
+
if (props && typeof props.useSession === 'function') {
|
|
301
|
+
try { session = props.useSession() } catch (err) { log('useSession failed', err) }
|
|
302
|
+
}
|
|
303
|
+
if (!session && props && props.session) session = props.session
|
|
304
|
+
return session
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Find the latest report inside a session snapshot (normalized). */
|
|
308
|
+
function latestReport(session) {
|
|
309
|
+
if (!session) return null
|
|
310
|
+
const raw = scanSessionForReport(session) || findReportInObject(session, undefined, 24)
|
|
311
|
+
return raw ? normalizeReport(raw) : null
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Inline bar chart of findings-by-round (pure divs, no SVG needed). */
|
|
315
|
+
function TrendChart({ points }) {
|
|
316
|
+
if (!points || points.length === 0) return null
|
|
317
|
+
const max = trendMax(points)
|
|
318
|
+
const bars = points.map((p) =>
|
|
319
|
+
React.createElement('div', {
|
|
320
|
+
key: p.round,
|
|
321
|
+
className: 'iterate-trend-bar',
|
|
322
|
+
'data-hot': p.count > 0 ? '' : undefined,
|
|
323
|
+
title: `Round ${p.round}:${p.count} 项`,
|
|
324
|
+
style: { height: `${Math.max(4, Math.round((p.count / max) * 24))}px` },
|
|
325
|
+
}),
|
|
326
|
+
)
|
|
327
|
+
return React.createElement('div', { className: 'iterate-trend', title: '各轮发现数量趋势' }, ...bars)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Dashboard: live convergence strip above the composer. */
|
|
331
|
+
function ConvergenceDashboard(props) {
|
|
332
|
+
const [pulseKey, setPulseKey] = React.useState(0)
|
|
333
|
+
const report = latestReport(sessionSnapshot(props))
|
|
334
|
+
|
|
335
|
+
React.useEffect(() => {
|
|
336
|
+
if (!report) return
|
|
337
|
+
const cur = getCurrentRound(report)
|
|
338
|
+
const conv = report.convergence
|
|
339
|
+
emitRoundPulse(cur, conv && conv.converged === true)
|
|
340
|
+
setPulseKey((k) => k + 1)
|
|
341
|
+
}, [report && hashReport(report) + ':' + getCurrentRound(report)])
|
|
342
|
+
|
|
343
|
+
if (!report) return null
|
|
344
|
+
|
|
345
|
+
const round = getCurrentRound(report)
|
|
346
|
+
const total = getTotalRounds(report)
|
|
347
|
+
const progress = computeConvergenceProgress(report)
|
|
348
|
+
const stats = severityStats(report)
|
|
349
|
+
const dims = groupByDimension(report)
|
|
350
|
+
const trend = computeTrendMetrics(report)
|
|
351
|
+
|
|
352
|
+
const dimBadges = Object.keys(dims).slice(0, 6).map((dim) =>
|
|
353
|
+
React.createElement(
|
|
354
|
+
'span',
|
|
355
|
+
{ key: dim, className: 'iterate-dim-badge' },
|
|
356
|
+
`${dim} · ${dims[dim].length}`,
|
|
357
|
+
),
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
return React.createElement(
|
|
361
|
+
'div',
|
|
362
|
+
{ 'data-iterate-root': '', 'data-iterate': 'dashboard', className: 'iterate-dashboard' },
|
|
363
|
+
React.createElement(
|
|
364
|
+
'span',
|
|
365
|
+
{ className: 'iterate-round-badge', 'data-pulse': pulseKey > 0 ? '' : undefined, key: `round-${round}` },
|
|
366
|
+
`Round ${round} / ${total}`,
|
|
367
|
+
),
|
|
368
|
+
React.createElement('div', { className: 'iterate-progress' },
|
|
369
|
+
React.createElement('div', { className: 'iterate-progress-fill', style: { width: `${progress}%` } }),
|
|
370
|
+
),
|
|
371
|
+
React.createElement('span', { className: 'iterate-metric' },
|
|
372
|
+
React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.critical } }),
|
|
373
|
+
stats.critical,
|
|
374
|
+
),
|
|
375
|
+
React.createElement('span', { className: 'iterate-metric' },
|
|
376
|
+
React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.high } }),
|
|
377
|
+
stats.high,
|
|
378
|
+
),
|
|
379
|
+
React.createElement('span', { className: 'iterate-metric' },
|
|
380
|
+
React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.medium } }),
|
|
381
|
+
stats.medium,
|
|
382
|
+
),
|
|
383
|
+
React.createElement(TrendChart, { points: trend.points }),
|
|
384
|
+
...dimBadges,
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Stats card (empty-findings case of the turn-tail chain). */
|
|
389
|
+
function StatsCard(props) {
|
|
390
|
+
const report = props.report
|
|
391
|
+
const [showHistory, setShowHistory] = React.useState(false)
|
|
392
|
+
const stats = severityStats(report)
|
|
393
|
+
const total = stats.critical + stats.high + stats.medium + stats.low
|
|
394
|
+
const rows = [
|
|
395
|
+
{ label: 'Critical', value: stats.critical, color: SEVERITY_COLOR.critical },
|
|
396
|
+
{ label: 'High', value: stats.high, color: SEVERITY_COLOR.high },
|
|
397
|
+
{ label: 'Medium', value: stats.medium, color: SEVERITY_COLOR.medium },
|
|
398
|
+
{ label: 'Low', value: stats.low, color: SEVERITY_COLOR.low },
|
|
399
|
+
].map((r) =>
|
|
400
|
+
React.createElement('div', { key: r.label, className: 'iterate-stat' },
|
|
401
|
+
React.createElement('div', { className: 'iterate-stat-num', style: { color: r.color } }, String(r.value)),
|
|
402
|
+
React.createElement('div', { className: 'iterate-stat-label' }, r.label),
|
|
403
|
+
),
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
const history = buildRoundHistory(report)
|
|
407
|
+
const trend = computeTrendMetrics(report)
|
|
408
|
+
const historyRows = history.map((h) =>
|
|
409
|
+
React.createElement('tr', { key: h.round },
|
|
410
|
+
React.createElement('td', { className: 'iterate-history-num' }, `Round ${h.round}`),
|
|
411
|
+
React.createElement('td', {}, String(h.count)),
|
|
412
|
+
React.createElement('td', { style: { color: SEVERITY_COLOR.critical } }, String(h.critical)),
|
|
413
|
+
React.createElement('td', { style: { color: SEVERITY_COLOR.high } }, String(h.high)),
|
|
414
|
+
React.createElement('td', { style: { color: SEVERITY_COLOR.medium } }, String(h.medium)),
|
|
415
|
+
React.createElement('td', { style: { color: SEVERITY_COLOR.low } }, String(h.low)),
|
|
416
|
+
),
|
|
417
|
+
)
|
|
418
|
+
const trendLine = `首轮 ${trend.firstRound} → 末轮 ${trend.lastRound} 项,降幅 ${trend.reductionPercent}%${trend.converged ? ',已收敛' : ''}`
|
|
419
|
+
const completion = buildCompletionSummary(report)
|
|
420
|
+
|
|
421
|
+
return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'stats', className: 'iterate-stats' },
|
|
422
|
+
React.createElement('div', { className: 'iterate-triage-head' },
|
|
423
|
+
React.createElement('span', {}, 'Iterate · 收敛统计'),
|
|
424
|
+
React.createElement('span', { className: 'iterate-triage-hint' },
|
|
425
|
+
`Round ${getCurrentRound(report)}/${getTotalRounds(report)} · ${total} findings · ${report.convergence && report.convergence.converged ? '已收敛' : '未收敛'}`,
|
|
426
|
+
),
|
|
427
|
+
),
|
|
428
|
+
React.createElement('div', { className: 'iterate-stats-grid' }, ...rows),
|
|
429
|
+
React.createElement('div', { className: 'iterate-completion', 'data-ok': trend.converged ? '' : undefined, 'data-warn': trend.converged ? undefined : '' }, completion),
|
|
430
|
+
React.createElement('div', { className: 'iterate-triage-foot', style: { marginTop: 8, borderRadius: 8 } },
|
|
431
|
+
React.createElement('span', {}, trendLine),
|
|
432
|
+
React.createElement('button', {
|
|
433
|
+
className: 'iterate-btn',
|
|
434
|
+
'data-primary': showHistory ? '' : undefined,
|
|
435
|
+
onClick: () => setShowHistory((v) => !v),
|
|
436
|
+
}, showHistory ? '收起历史' : '历史 / 趋势'),
|
|
437
|
+
),
|
|
438
|
+
showHistory
|
|
439
|
+
? React.createElement('div', { className: 'iterate-history-body' },
|
|
440
|
+
React.createElement('table', { className: 'iterate-history-table' },
|
|
441
|
+
React.createElement('thead', {},
|
|
442
|
+
React.createElement('tr', {},
|
|
443
|
+
React.createElement('th', {}, '轮次'),
|
|
444
|
+
React.createElement('th', {}, '发现'),
|
|
445
|
+
React.createElement('th', { style: { color: SEVERITY_COLOR.critical } }, 'CRIT'),
|
|
446
|
+
React.createElement('th', { style: { color: SEVERITY_COLOR.high } }, 'HIGH'),
|
|
447
|
+
React.createElement('th', { style: { color: SEVERITY_COLOR.medium } }, 'MED'),
|
|
448
|
+
React.createElement('th', { style: { color: SEVERITY_COLOR.low } }, 'LOW'),
|
|
449
|
+
),
|
|
450
|
+
),
|
|
451
|
+
React.createElement('tbody', {}, ...historyRows),
|
|
452
|
+
),
|
|
453
|
+
React.createElement('div', { className: 'iterate-history-trendline' },
|
|
454
|
+
React.createElement(TrendChart, { points: trend.points }),
|
|
455
|
+
),
|
|
456
|
+
)
|
|
457
|
+
: null,
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Triage panel: per-finding y / n / a with filters, batch ops, keyboard shortcuts. */
|
|
462
|
+
function TriagePanel(props) {
|
|
463
|
+
const report = props.report
|
|
464
|
+
const findings = report.findings
|
|
465
|
+
const storageKey = TRIAGE_STORAGE_PREFIX + hashReport(report)
|
|
466
|
+
const [verdicts, setVerdicts] = React.useState(() => {
|
|
467
|
+
const initial = buildTriageState(report)
|
|
468
|
+
const saved = storage ? storage.get(storageKey) : null
|
|
469
|
+
if (saved) {
|
|
470
|
+
try {
|
|
471
|
+
const parsed = JSON.parse(saved)
|
|
472
|
+
if (parsed && typeof parsed === 'object') {
|
|
473
|
+
for (const key of Object.keys(initial)) {
|
|
474
|
+
if (parsed[key] === 'keep' || parsed[key] === 'skip' || parsed[key] === 'ignore') {
|
|
475
|
+
initial[key] = parsed[key]
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
} catch { /* corrupt storage, fall back to defaults */ }
|
|
480
|
+
}
|
|
481
|
+
return initial
|
|
482
|
+
})
|
|
483
|
+
const [payload, setPayload] = React.useState(null)
|
|
484
|
+
const [copied, setCopied] = React.useState(false)
|
|
485
|
+
const [filter, setFilter] = React.useState({ severities: [], dimensions: [], search: '' })
|
|
486
|
+
const [selected, setSelected] = React.useState(null)
|
|
487
|
+
|
|
488
|
+
/** Persist + return the next verdicts state. */
|
|
489
|
+
const persistVerdicts = (next) => {
|
|
490
|
+
if (storage) storage.set(storageKey, JSON.stringify(next))
|
|
491
|
+
return next
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const setVerdict = (index, verdict) => {
|
|
495
|
+
setVerdicts((prev) => persistVerdicts({ ...prev, [String(index)]: verdict }))
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ── Filtering (visible findings + their original indices) ───────────────
|
|
499
|
+
const { filtered, indices } = filterFindingsWithIndices(findings, filter)
|
|
500
|
+
const indicesKey = indices.join(',')
|
|
501
|
+
const options = buildFilterOptions(findings)
|
|
502
|
+
const filterActive = filter.severities.length > 0 || filter.dimensions.length > 0 || filter.search !== ''
|
|
503
|
+
|
|
504
|
+
const setSeverityFilter = (value) => setFilter((f) => ({ ...f, severities: value ? [value] : [] }))
|
|
505
|
+
const setDimensionFilter = (value) => setFilter((f) => ({ ...f, dimensions: value ? [value] : [] }))
|
|
506
|
+
const setSearchFilter = (value) => setFilter((f) => ({ ...f, search: value }))
|
|
507
|
+
const clearFilter = () => setFilter({ severities: [], dimensions: [], search: '' })
|
|
508
|
+
|
|
509
|
+
// ── Batch operations (apply to the currently VISIBLE findings) ───────────
|
|
510
|
+
const applyBatch = (verdict) => {
|
|
511
|
+
setVerdicts((prev) => persistVerdicts(batchSetVerdict(prev, indices, verdict)))
|
|
512
|
+
}
|
|
513
|
+
const applyBatchAll = (verdict) => {
|
|
514
|
+
setVerdicts((prev) => persistVerdicts(setAllVerdicts(prev, verdict)))
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ── Keyboard shortcuts (y / n / a on the selected finding, ↑/↓ to move) ──
|
|
518
|
+
React.useEffect(() => {
|
|
519
|
+
const doc = typeof document !== 'undefined' ? document : null
|
|
520
|
+
if (!doc) return
|
|
521
|
+
const onKeyDown = (ev) => {
|
|
522
|
+
const t = ev.target
|
|
523
|
+
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return
|
|
524
|
+
const verdict = keyToVerdict(ev.key)
|
|
525
|
+
if (verdict && selected !== null && indices.includes(selected)) {
|
|
526
|
+
ev.preventDefault()
|
|
527
|
+
setVerdict(selected, verdict)
|
|
528
|
+
const pos = indices.indexOf(selected)
|
|
529
|
+
const nextIdx = indices[pos + 1]
|
|
530
|
+
if (nextIdx !== undefined) setSelected(nextIdx)
|
|
531
|
+
return
|
|
532
|
+
}
|
|
533
|
+
if (ev.key === 'ArrowDown' && indices.length > 0) {
|
|
534
|
+
ev.preventDefault()
|
|
535
|
+
const pos = selected === null ? -1 : indices.indexOf(selected)
|
|
536
|
+
setSelected(indices[Math.min(pos + 1, indices.length - 1)])
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
if (ev.key === 'ArrowUp' && indices.length > 0) {
|
|
540
|
+
ev.preventDefault()
|
|
541
|
+
const pos = selected === null ? 0 : indices.indexOf(selected)
|
|
542
|
+
setSelected(indices[Math.max(pos - 1, 0)])
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
doc.addEventListener('keydown', onKeyDown)
|
|
546
|
+
return () => doc.removeEventListener('keydown', onKeyDown)
|
|
547
|
+
}, [selected, indicesKey])
|
|
548
|
+
|
|
549
|
+
const ignored = collectIgnoredEntries(verdicts, findings)
|
|
550
|
+
const ignoredCount = ignored.length
|
|
551
|
+
const counts = countVerdicts(verdicts)
|
|
552
|
+
|
|
553
|
+
const doCopyYaml = () => {
|
|
554
|
+
const yaml = toKnownIntentionalYaml(ignored)
|
|
555
|
+
if (!yaml) return
|
|
556
|
+
const ok = copyText(yaml)
|
|
557
|
+
setCopied(true)
|
|
558
|
+
setTimeout(() => setCopied(false), 1600)
|
|
559
|
+
if (!ok) setPayload(yaml)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const doBuildInstruction = () => {
|
|
563
|
+
const text = buildApplyInstruction(ignored)
|
|
564
|
+
setPayload(text)
|
|
565
|
+
if (text) copyText(text)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const rows = filtered.map((finding, i) => {
|
|
569
|
+
const index = indices[i]
|
|
570
|
+
const severity = finding.severity || 'low'
|
|
571
|
+
const verdict = verdicts[String(index)] || 'keep'
|
|
572
|
+
const isSelected = selected === index
|
|
573
|
+
const btn = (label, value, title) =>
|
|
574
|
+
React.createElement(
|
|
575
|
+
'button',
|
|
576
|
+
{
|
|
577
|
+
key: value,
|
|
578
|
+
className: 'iterate-vbtn',
|
|
579
|
+
'data-active': verdict === value ? value : undefined,
|
|
580
|
+
title,
|
|
581
|
+
onClick: () => setVerdict(index, value),
|
|
582
|
+
},
|
|
583
|
+
label,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
return React.createElement('div', {
|
|
587
|
+
key: String(index),
|
|
588
|
+
className: 'iterate-finding',
|
|
589
|
+
'data-selected': isSelected ? '' : undefined,
|
|
590
|
+
onClick: () => setSelected(index),
|
|
591
|
+
},
|
|
592
|
+
React.createElement('div', { className: 'iterate-finding-meta' },
|
|
593
|
+
React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR[severity] || SEVERITY_COLOR.low } }),
|
|
594
|
+
React.createElement('span', {}, String(SEVERITY_LABEL[severity] || 'LOW')),
|
|
595
|
+
React.createElement('span', { className: 'iterate-finding-file' }, String(finding.file || '?')),
|
|
596
|
+
finding.line ? React.createElement('span', {}, `:${finding.line}`) : null,
|
|
597
|
+
React.createElement('span', {}, String(finding.dimension || '')),
|
|
598
|
+
),
|
|
599
|
+
React.createElement('div', { className: 'iterate-finding-summary' }, String(finding.summary || '')),
|
|
600
|
+
React.createElement('div', { className: 'iterate-finding-actions' },
|
|
601
|
+
btn('y 修复', 'keep', '保留该 finding,进入修复'),
|
|
602
|
+
btn('n 跳过', 'skip', '跳过该 finding'),
|
|
603
|
+
btn('a 已知有意', 'ignore', '标记为已知有意并写入 known_intentional'),
|
|
604
|
+
),
|
|
605
|
+
)
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'triage', className: 'iterate-triage' },
|
|
609
|
+
React.createElement('div', { className: 'iterate-triage-head' },
|
|
610
|
+
React.createElement('span', {}, `Iterate · Findings 分诊 (${filtered.length}/${findings.length})`),
|
|
611
|
+
React.createElement('span', { className: 'iterate-triage-hint' }, 'y=修复 · n=跳过 · a=已知有意 · ↑/↓ 选择'),
|
|
612
|
+
),
|
|
613
|
+
React.createElement('div', { className: 'iterate-filter' },
|
|
614
|
+
React.createElement('select', { className: 'iterate-filter-select', value: filter.severities[0] || '', onChange: (e) => setSeverityFilter(e.target.value), title: '按严重度筛选' },
|
|
615
|
+
React.createElement('option', { value: '' }, '全部严重度'),
|
|
616
|
+
...options.severities.map((s) =>
|
|
617
|
+
React.createElement('option', { key: s.value, value: s.value }, `${SEVERITY_LABEL[s.value] || s.value} (${s.count})`),
|
|
618
|
+
),
|
|
619
|
+
),
|
|
620
|
+
React.createElement('select', { className: 'iterate-filter-select', value: filter.dimensions[0] || '', onChange: (e) => setDimensionFilter(e.target.value), title: '按维度筛选' },
|
|
621
|
+
React.createElement('option', { value: '' }, '全部维度'),
|
|
622
|
+
...options.dimensions.map((d) =>
|
|
623
|
+
React.createElement('option', { key: d.value, value: d.value }, `${d.value} (${d.count})`),
|
|
624
|
+
),
|
|
625
|
+
),
|
|
626
|
+
React.createElement('input', {
|
|
627
|
+
className: 'iterate-filter-search',
|
|
628
|
+
type: 'search',
|
|
629
|
+
placeholder: '搜索文件 / 摘要…',
|
|
630
|
+
value: filter.search,
|
|
631
|
+
onChange: (e) => setSearchFilter(e.target.value),
|
|
632
|
+
}),
|
|
633
|
+
React.createElement('span', { className: 'iterate-filter-count' },
|
|
634
|
+
filterActive
|
|
635
|
+
? React.createElement('button', { className: 'iterate-batch-btn', onClick: clearFilter }, `清除筛选(显示 ${filtered.length})`)
|
|
636
|
+
: `共 ${findings.length} 项`,
|
|
637
|
+
),
|
|
638
|
+
),
|
|
639
|
+
React.createElement('div', { className: 'iterate-batch' },
|
|
640
|
+
React.createElement('span', { className: 'iterate-batch-label' }, '批量(当前可见):'),
|
|
641
|
+
React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('keep') }, '全部 y'),
|
|
642
|
+
React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('skip') }, '全部 n'),
|
|
643
|
+
React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('ignore') }, '全部 a'),
|
|
644
|
+
React.createElement('span', { className: 'iterate-batch-label', style: { marginLeft: 8 } }, '全部:'),
|
|
645
|
+
React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatchAll('keep') }, 'y'),
|
|
646
|
+
React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatchAll('skip') }, 'n'),
|
|
647
|
+
React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatchAll('ignore') }, 'a'),
|
|
648
|
+
),
|
|
649
|
+
...rows,
|
|
650
|
+
React.createElement('div', { className: 'iterate-triage-foot' },
|
|
651
|
+
React.createElement('span', {}, `y ${counts.keep} · n ${counts.skip} · a ${counts.ignore} · 待写回 known_intentional:${ignoredCount} 条`),
|
|
652
|
+
React.createElement('span', { style: { display: 'flex', gap: 6 } },
|
|
653
|
+
React.createElement('button', { className: 'iterate-btn', 'data-primary': '', 'data-copied': copied ? '' : undefined, onClick: doCopyYaml }, copied ? '已复制' : '复制 known_intentional'),
|
|
654
|
+
React.createElement('button', { className: 'iterate-btn', onClick: doBuildInstruction }, '生成应用指令'),
|
|
655
|
+
),
|
|
656
|
+
),
|
|
657
|
+
payload
|
|
658
|
+
? React.createElement('div', { className: 'iterate-payload' }, payload)
|
|
659
|
+
: null,
|
|
660
|
+
)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Turn-tail chain entry: triage when findings exist, stats card otherwise. */
|
|
664
|
+
function TurnTailEntry(props) {
|
|
665
|
+
const candidates = []
|
|
666
|
+
if (props && props.turn) candidates.push(props.turn)
|
|
667
|
+
if (props && props.matched) candidates.push(props.matched)
|
|
668
|
+
if (props && props.data) candidates.push(props.data)
|
|
669
|
+
candidates.push(props)
|
|
670
|
+
|
|
671
|
+
let report = null
|
|
672
|
+
for (const c of candidates) {
|
|
673
|
+
const raw = findReportInObject(c, undefined, 24) || scanSessionForReport(c)
|
|
674
|
+
if (raw) { report = normalizeReport(raw); break }
|
|
675
|
+
}
|
|
676
|
+
if (!report) return null
|
|
677
|
+
// Render through React.createElement so each panel is a real component with
|
|
678
|
+
// its own hook identity (calling them as functions would violate the Rules
|
|
679
|
+
// of Hooks and crash when the findings/empty branch flips between renders).
|
|
680
|
+
if (!report.findings || report.findings.length === 0) {
|
|
681
|
+
return React.createElement(StatsCard, { report })
|
|
682
|
+
}
|
|
683
|
+
return React.createElement(TriagePanel, { report })
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Progress capsule: briefly surfaces round-completion (incl. convergence). */
|
|
687
|
+
function ProgressCapsule() {
|
|
688
|
+
const [info, setInfo] = React.useState(null)
|
|
689
|
+
React.useEffect(() => {
|
|
690
|
+
let timer = null
|
|
691
|
+
const listener = (payload) => {
|
|
692
|
+
const converged = payload && payload.converged === true
|
|
693
|
+
const round = payload && typeof payload.round === 'number' ? payload.round : '?'
|
|
694
|
+
setInfo({ text: converged ? `Round ${round} 完成 · 已收敛` : `Round ${round} 完成`, ok: converged })
|
|
695
|
+
if (timer) clearTimeout(timer)
|
|
696
|
+
// Converged notifications stay a bit longer.
|
|
697
|
+
timer = setTimeout(() => setInfo(null), converged ? 3600 : 2400)
|
|
698
|
+
}
|
|
699
|
+
roundPulseListeners.push(listener)
|
|
700
|
+
return () => {
|
|
701
|
+
const i = roundPulseListeners.indexOf(listener)
|
|
702
|
+
if (i >= 0) roundPulseListeners.splice(i, 1)
|
|
703
|
+
if (timer) clearTimeout(timer)
|
|
704
|
+
}
|
|
705
|
+
}, [])
|
|
706
|
+
if (!info) return null
|
|
707
|
+
return React.createElement('div', { 'data-iterate-root': '', className: 'iterate-capsule', 'data-ok': info.ok ? '' : undefined }, info.text)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** Settings page section (root scope; reads localStorage + latest known data). */
|
|
711
|
+
function SettingsPanel() {
|
|
712
|
+
const [enabled, setEnabled] = React.useState(themeEnabled)
|
|
713
|
+
const [copied, setCopied] = React.useState(false)
|
|
714
|
+
const [showGuide, setShowGuide] = React.useState(false)
|
|
715
|
+
const guide = buildConfigEditGuide()
|
|
716
|
+
|
|
717
|
+
const toggleTheme = () => {
|
|
718
|
+
const next = !enabled
|
|
719
|
+
setEnabled(next)
|
|
720
|
+
setThemeEnabled(next)
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const doCopyGuide = () => {
|
|
724
|
+
copyText(guide)
|
|
725
|
+
setCopied(true)
|
|
726
|
+
setTimeout(() => setCopied(false), 1600)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'settings', className: 'iterate-settings' },
|
|
730
|
+
React.createElement('div', { className: 'iterate-settings-title' }, 'iterate 设置'),
|
|
731
|
+
React.createElement('div', { className: 'iterate-settings-row' },
|
|
732
|
+
React.createElement('div', {},
|
|
733
|
+
React.createElement('div', { className: 'iterate-settings-title' }, 'iterate 主题'),
|
|
734
|
+
React.createElement('div', { className: 'iterate-settings-desc' }, '启用暖琥珀配色的 iterate 专属皮肤(覆盖 dsh 默认主题令牌)。'),
|
|
735
|
+
),
|
|
736
|
+
React.createElement('button', {
|
|
737
|
+
className: 'iterate-btn',
|
|
738
|
+
'data-primary': enabled ? '' : undefined,
|
|
739
|
+
onClick: toggleTheme,
|
|
740
|
+
}, enabled ? '已启用' : '已关闭'),
|
|
741
|
+
),
|
|
742
|
+
React.createElement('div', { className: 'iterate-settings-row' },
|
|
743
|
+
React.createElement('div', {},
|
|
744
|
+
React.createElement('div', { className: 'iterate-settings-title' }, '分诊持久化'),
|
|
745
|
+
React.createElement('div', { className: 'iterate-settings-desc' }, '分诊面板的 y/n/a 判定保存在本地浏览器(localStorage),刷新会话后仍保留。'),
|
|
746
|
+
),
|
|
747
|
+
React.createElement('span', { className: 'iterate-chip' }, '本地保存'),
|
|
748
|
+
),
|
|
749
|
+
React.createElement('div', { className: 'iterate-settings-row' },
|
|
750
|
+
React.createElement('div', {},
|
|
751
|
+
React.createElement('div', { className: 'iterate-settings-title' }, '配置管理'),
|
|
752
|
+
React.createElement('div', { className: 'iterate-settings-desc' }, '目标 / 维度 / 最大轮数写在项目的 iterate.config.yaml。复制指引让模型为你调整,或展开查看可编辑字段。'),
|
|
753
|
+
),
|
|
754
|
+
React.createElement('span', { style: { display: 'flex', gap: 6 } },
|
|
755
|
+
React.createElement('button', { className: 'iterate-btn', 'data-primary': '', 'data-copied': copied ? '' : undefined, onClick: doCopyGuide }, copied ? '已复制' : '复制指引'),
|
|
756
|
+
React.createElement('button', { className: 'iterate-btn', onClick: () => setShowGuide((v) => !v) }, showGuide ? '收起' : '查看'),
|
|
757
|
+
),
|
|
758
|
+
),
|
|
759
|
+
showGuide
|
|
760
|
+
? React.createElement('div', { className: 'iterate-settings-guide' }, guide)
|
|
761
|
+
: null,
|
|
762
|
+
)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ─── Registration ────────────────────────────────────────────────────────────
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Chain selector for conversation.chat.turnTail.
|
|
769
|
+
* Returns a marker when the turn contains an iterate review report, null otherwise.
|
|
770
|
+
*/
|
|
771
|
+
function selectTurnTail(owner) {
|
|
772
|
+
if (!owner) return null
|
|
773
|
+
const raw = findReportInObject(owner.turn, undefined, 24) || scanSessionForReport(owner.turn)
|
|
774
|
+
return raw ? { matched: true } : null
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Plugin client entry. Called by dsh web with the client Cordis context.
|
|
779
|
+
*/
|
|
780
|
+
export function apply(ctx) {
|
|
781
|
+
// 1. Resolve optional services (degrade, never throw).
|
|
782
|
+
const get = ctx && typeof ctx.get === 'function' ? ctx.get.bind(ctx) : null
|
|
783
|
+
slotsSvc = get ? get('slots') : undefined
|
|
784
|
+
themeSvc = get ? get('theme') : undefined
|
|
785
|
+
React = resolveReact(ctx)
|
|
786
|
+
storage = createStorage()
|
|
787
|
+
|
|
788
|
+
// 2. Theme preference bootstrap.
|
|
789
|
+
const savedTheme = storage.get(THEME_STORAGE_KEY)
|
|
790
|
+
themeEnabled = savedTheme === null ? true : savedTheme === '1'
|
|
791
|
+
if (themeEnabled) applyThemeSkin()
|
|
792
|
+
|
|
793
|
+
// 3. Inject styles (independent of slots / React availability).
|
|
794
|
+
if (typeof document !== 'undefined' && document.createElement && document.head) {
|
|
795
|
+
const style = document.createElement('style')
|
|
796
|
+
style.dataset.plugin = PLUGIN_TAG
|
|
797
|
+
style.dataset.pluginCss = 'iterate-main'
|
|
798
|
+
style.textContent = ITERATE_CSS
|
|
799
|
+
document.head.appendChild(style)
|
|
800
|
+
if (typeof ctx.effect === 'function') {
|
|
801
|
+
ctx.effect(() => { try { style.remove() } catch { /* noop */ } })
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// 4. Slot UI requires React + slots service.
|
|
806
|
+
if (React === null || slotsSvc === undefined) {
|
|
807
|
+
log('React or slots service unavailable — slot UI disabled')
|
|
808
|
+
return
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// 5. Register the six-part UI.
|
|
812
|
+
if (typeof slotsSvc.inject === 'function') {
|
|
813
|
+
// 1 + 5: convergence dashboard + round pulse (session scope).
|
|
814
|
+
slotsSvc.inject('conversation.input.dock', () =>
|
|
815
|
+
slotsSvc.register(
|
|
816
|
+
{ name: 'conversation.input.dock', id: 'iterate-dashboard', order: 90 },
|
|
817
|
+
(props) => React.createElement(ConvergenceDashboard, props),
|
|
818
|
+
),
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
// 2 + 3: triage panel / stats card (turn-tail chain, session scope).
|
|
822
|
+
slotsSvc.inject('conversation.chat.turnTail', () =>
|
|
823
|
+
slotsSvc.register(
|
|
824
|
+
{ name: 'conversation.chat.turnTail', id: 'iterate-turn-tail', select: selectTurnTail },
|
|
825
|
+
(props) => React.createElement(TurnTailEntry, props),
|
|
826
|
+
),
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
// 5: progress capsule (frame overlay, root scope).
|
|
830
|
+
slotsSvc.inject('shell.overlay', () =>
|
|
831
|
+
slotsSvc.register(
|
|
832
|
+
{ name: 'shell.overlay', id: 'iterate-progress', order: 0 },
|
|
833
|
+
(props) => React.createElement(ProgressCapsule, props),
|
|
834
|
+
),
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
// 6: settings page section (root scope).
|
|
838
|
+
slotsSvc.inject('settings.section', () =>
|
|
839
|
+
slotsSvc.register(
|
|
840
|
+
{ name: 'settings.section', id: 'iterate-settings', order: 30, label: () => 'iterate' },
|
|
841
|
+
(props) => React.createElement(SettingsPanel, props),
|
|
842
|
+
),
|
|
843
|
+
)
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// 6: keep theme toggle state consistent across theme/change events.
|
|
847
|
+
if (typeof ctx.on === 'function') {
|
|
848
|
+
ctx.on('theme/change', () => {
|
|
849
|
+
if (themeEnabled) applyThemeSkin()
|
|
850
|
+
})
|
|
851
|
+
}
|
|
852
|
+
}
|