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.
- package/README.md +8 -0
- package/README.zh-CN.md +8 -0
- package/lib/client.js +1474 -734
- package/package.json +6 -2
- package/src/client/index.ts +1218 -0
|
@@ -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
|
+
}
|