iterate-plugin 2.10.0 → 2.12.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/README.md +42 -2
- package/README.zh-CN.md +40 -2
- package/dist/approval-gate.js +92 -0
- package/dist/config-loader.js +18 -3
- package/dist/config-write.js +7 -4
- package/dist/evidence.js +67 -1
- package/dist/git-scope.js +35 -6
- package/dist/index.js +15 -5
- package/dist/live.js +155 -0
- package/dist/meta-review.js +19 -5
- package/dist/method-scope.js +5 -1
- package/dist/paths.js +4 -0
- package/dist/review-scope.js +12 -8
- package/dist/review.js +76 -24
- package/dist/session-hooks.js +89 -0
- package/dist/skill-prompt.js +101 -19
- package/dist/tools/checkpoint.js +10 -3
- package/dist/tools/context.js +16 -4
- package/dist/tools/decision-log.js +29 -9
- package/dist/tools/fix.js +120 -3
- package/dist/tools/prune.js +16 -9
- package/dist/tools/review.js +4 -1
- package/dist/tools/transcript.js +324 -0
- package/dist/tools/triage.js +9 -6
- package/dist/tools/validate.js +5 -2
- package/dist/transcript.js +421 -0
- package/lib/client.js +966 -80
- package/lib/parse.js +302 -17
- package/package.json +1 -1
- package/src/approval-gate.ts +119 -0
- package/src/client/index.ts +807 -62
- package/src/config-loader.ts +16 -2
- package/src/config-write.ts +6 -4
- package/src/evidence.ts +69 -1
- package/src/git-scope.ts +34 -6
- package/src/index.ts +17 -6
- package/src/live.ts +185 -0
- package/src/meta-review.ts +24 -10
- package/src/method-scope.ts +5 -1
- package/src/paths.ts +5 -0
- package/src/review-scope.ts +11 -7
- package/src/review.ts +82 -25
- package/src/session-hooks.ts +90 -0
- package/src/skill-prompt.ts +101 -19
- package/src/tools/checkpoint.ts +10 -3
- package/src/tools/context.ts +14 -3
- package/src/tools/decision-log.ts +27 -10
- package/src/tools/fix.ts +114 -3
- package/src/tools/prune.ts +14 -11
- package/src/tools/review.ts +5 -2
- package/src/tools/transcript.ts +334 -0
- package/src/tools/triage.ts +9 -6
- package/src/tools/validate.ts +5 -2
- package/src/transcript.ts +475 -0
- package/src/types.ts +129 -0
package/src/client/index.ts
CHANGED
|
@@ -17,13 +17,14 @@
|
|
|
17
17
|
* - `inject`: services the shell must have up before `apply` runs.
|
|
18
18
|
* - `apply(ctx)`: called by the client runtime with the ClientContext.
|
|
19
19
|
*
|
|
20
|
-
* Implements the §14.4 / §16
|
|
20
|
+
* Implements the §14.4 / §16 UI layer:
|
|
21
21
|
* 1. Convergence dashboard -> conversation.input.dock
|
|
22
|
-
* 2. Findings triage panel
|
|
23
|
-
* 3. Iteration stats card
|
|
24
|
-
* 4. iterate theme skin
|
|
25
|
-
* 5. Progress event linkage
|
|
26
|
-
* 6. Settings page section
|
|
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
|
+
* 7. Runtime observatory -> conversation.input.dock (order 91, F1–F7 board)
|
|
27
28
|
*
|
|
28
29
|
* Design notes:
|
|
29
30
|
* - All styles are injected via one <style> tag; colors use dsh token
|
|
@@ -70,6 +71,8 @@ import {
|
|
|
70
71
|
buildRuntimeStatusGuide,
|
|
71
72
|
scanSessionForResume,
|
|
72
73
|
countSessionImages,
|
|
74
|
+
scanSessionForTranscript,
|
|
75
|
+
normalizeTranscript,
|
|
73
76
|
SEVERITY_LABEL,
|
|
74
77
|
SEVERITY_COLOR,
|
|
75
78
|
} from '../../lib/parse.js'
|
|
@@ -142,6 +145,100 @@ interface ThemeService {
|
|
|
142
145
|
overrideTokens(source: string, tokens: IterateTokenMap): () => void
|
|
143
146
|
}
|
|
144
147
|
|
|
148
|
+
// ─── Runtime observatory (transcript) types ─────────────────────────────────
|
|
149
|
+
// Mirrors src/types.ts Transcript* series, but every field is defensive/optional
|
|
150
|
+
// so the panel never crashes on a malformed or partial manifest.
|
|
151
|
+
|
|
152
|
+
interface ObsCheckpoint {
|
|
153
|
+
mode?: string
|
|
154
|
+
round?: number
|
|
155
|
+
maxRounds?: number
|
|
156
|
+
fixedCount?: number
|
|
157
|
+
resumeCount?: number
|
|
158
|
+
updatedAt?: string
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface ObsNudge {
|
|
162
|
+
timestamp?: string
|
|
163
|
+
text?: string
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface ObsApproval {
|
|
167
|
+
active?: boolean
|
|
168
|
+
policy?: string
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
interface ObsThread {
|
|
172
|
+
dimension?: string
|
|
173
|
+
attempt?: number
|
|
174
|
+
messages?: string[]
|
|
175
|
+
readFiles?: string[]
|
|
176
|
+
findings?: ObsFinding[]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
interface ObsRound {
|
|
180
|
+
round?: number
|
|
181
|
+
threads?: ObsThread[]
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
interface ObsFinding {
|
|
185
|
+
dimension?: string
|
|
186
|
+
file?: string
|
|
187
|
+
line?: number
|
|
188
|
+
severity?: string
|
|
189
|
+
summary?: string
|
|
190
|
+
failure_scenario?: string
|
|
191
|
+
suggested_fix?: string
|
|
192
|
+
is_atomic?: boolean
|
|
193
|
+
acknowledged?: boolean
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface ObsFix {
|
|
197
|
+
id?: string
|
|
198
|
+
timestamp?: string
|
|
199
|
+
round?: number
|
|
200
|
+
file?: string
|
|
201
|
+
summary?: string
|
|
202
|
+
linesAdded?: number
|
|
203
|
+
linesRemoved?: number
|
|
204
|
+
success?: boolean
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
interface ObsEntry {
|
|
208
|
+
timestamp?: string
|
|
209
|
+
round?: number
|
|
210
|
+
type?: string
|
|
211
|
+
data?: Record<string, unknown>
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
interface ObsLive {
|
|
215
|
+
ts?: string
|
|
216
|
+
type?: string
|
|
217
|
+
tool?: string
|
|
218
|
+
target?: string
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
interface ObsManifest {
|
|
222
|
+
version?: number
|
|
223
|
+
project?: string
|
|
224
|
+
updatedAt?: string
|
|
225
|
+
active?: boolean
|
|
226
|
+
mode?: string
|
|
227
|
+
goal?: string
|
|
228
|
+
phases?: string[]
|
|
229
|
+
round?: number
|
|
230
|
+
maxRounds?: number
|
|
231
|
+
rounds?: ObsRound[]
|
|
232
|
+
convergence?: number[]
|
|
233
|
+
findings?: ObsFinding[]
|
|
234
|
+
fixes?: ObsFix[]
|
|
235
|
+
live?: ObsLive[]
|
|
236
|
+
checkpoint?: ObsCheckpoint | null
|
|
237
|
+
timeline?: ObsEntry[]
|
|
238
|
+
nudge?: ObsNudge | null
|
|
239
|
+
approval?: ObsApproval
|
|
240
|
+
}
|
|
241
|
+
|
|
145
242
|
/** Opaque slot props: heterogeneous per-slot payloads. */
|
|
146
243
|
type SlotProps = Record<string, unknown>
|
|
147
244
|
|
|
@@ -333,6 +430,27 @@ const ITERATE_CSS = `
|
|
|
333
430
|
.iterate-switch[data-on] { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); }
|
|
334
431
|
.iterate-switch[data-on] .iterate-switch-knob { transform: translateX(18px); background: #FFFFFF; }
|
|
335
432
|
|
|
433
|
+
/* Shared keyboard focus ring for every iterate interactive control */
|
|
434
|
+
.iterate-btn:focus-visible, .iterate-vbtn:focus-visible, .iterate-batch-btn:focus-visible,
|
|
435
|
+
.iterate-filter-select:focus-visible, .iterate-filter-search:focus-visible,
|
|
436
|
+
.iterate-finding:focus-visible, .iterate-switch:focus-visible {
|
|
437
|
+
outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/* Dashboard empty/onboarding state */
|
|
441
|
+
.iterate-dashboard-empty { opacity: 0.75; }
|
|
442
|
+
.iterate-empty-hint { font-size: 12px; color: var(--dsw-alias-label-secondary); }
|
|
443
|
+
|
|
444
|
+
/* Convergence-completed progress fill */
|
|
445
|
+
.iterate-progress-fill-done { background: var(--dsw-alias-state-success-primary); }
|
|
446
|
+
|
|
447
|
+
/* Batch scope segmented control */
|
|
448
|
+
.iterate-batch-scope { opacity: 0.6; }
|
|
449
|
+
.iterate-batch-scope-on { opacity: 1; border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-label-primary); }
|
|
450
|
+
|
|
451
|
+
/* Overflow dimension chip */
|
|
452
|
+
.iterate-dim-more { opacity: 0.7; font-style: italic; }
|
|
453
|
+
|
|
336
454
|
/* Button variants */
|
|
337
455
|
.iterate-btn[data-ghost] { background: transparent; }
|
|
338
456
|
.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; }
|
|
@@ -343,6 +461,38 @@ const ITERATE_CSS = `
|
|
|
343
461
|
.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); }
|
|
344
462
|
.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); }
|
|
345
463
|
.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; }
|
|
464
|
+
|
|
465
|
+
/* ── Runtime observatory panel ──────────────────────────────────────────── */
|
|
466
|
+
.iterate-obs { margin: 6px 0; border: 1px solid var(--dsw-alias-border-l1); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); overflow: hidden; font-size: 12px; color: var(--dsw-alias-label-primary); }
|
|
467
|
+
.iterate-obs-head { display: flex; align-items: center; gap: 10px; padding: 8px 14px; cursor: pointer; border-bottom: 1px solid var(--dsw-alias-border-l1); flex-wrap: wrap; }
|
|
468
|
+
.iterate-obs-head[data-closed] { border-bottom: none; }
|
|
469
|
+
.iterate-obs-title { font-weight: 650; }
|
|
470
|
+
.iterate-obs-badge { display: inline-flex; align-items: center; gap: 6px; padding: 2px 10px; border-radius: 999px; background: color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent); color: var(--dsw-alias-brand-primary); font-size: 11px; font-weight: 600; white-space: nowrap; }
|
|
471
|
+
.iterate-obs-badge[data-live] { color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); }
|
|
472
|
+
.iterate-obs-head-meta { margin-left: auto; font-size: 11px; color: var(--dsw-alias-label-secondary); white-space: nowrap; }
|
|
473
|
+
.iterate-obs-tabs { display: flex; gap: 4px; padding: 8px 14px 0; flex-wrap: wrap; border-bottom: 1px solid var(--dsw-alias-border-l1); }
|
|
474
|
+
.iterate-obs-tab { padding: 4px 10px; border-radius: 7px; 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; }
|
|
475
|
+
.iterate-obs-tab[data-active] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
|
|
476
|
+
.iterate-obs-body { padding: 12px 14px; max-height: 460px; overflow: auto; }
|
|
477
|
+
.iterate-obs-block { border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; overflow: hidden; margin-bottom: 8px; }
|
|
478
|
+
.iterate-obs-block:last-child { margin-bottom: 0; }
|
|
479
|
+
.iterate-obs-block-head { display: flex; align-items: center; gap: 8px; padding: 7px 10px; background: var(--dsw-alias-bg-layer-2); border-bottom: 1px solid var(--dsw-alias-border-l1); font-size: 11px; color: var(--dsw-alias-label-primary); flex-wrap: wrap; }
|
|
480
|
+
.iterate-obs-block-head[data-click] { cursor: pointer; }
|
|
481
|
+
.iterate-obs-block-body { padding: 8px 10px; }
|
|
482
|
+
.iterate-obs-chip { display: inline-flex; align-items: center; gap: 6px; padding: 2px 8px; border-radius: 6px; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); font-size: 11px; color: var(--dsw-alias-label-secondary); white-space: nowrap; }
|
|
483
|
+
.iterate-obs-msg { padding: 2px 0; color: var(--dsw-alias-label-secondary); line-height: 1.5; font-size: 11px; word-break: break-word; }
|
|
484
|
+
.iterate-obs-file { font-family: var(--dsw-font-mono, ui-monospace, monospace); font-size: 10.5px; color: var(--dsw-alias-label-secondary); word-break: break-all; }
|
|
485
|
+
.iterate-obs-bar { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--dsw-alias-label-secondary); flex-wrap: wrap; }
|
|
486
|
+
.iterate-obs-bar b { color: var(--dsw-alias-label-primary); font-weight: 600; }
|
|
487
|
+
.iterate-obs-mono { font-family: var(--dsw-font-mono, ui-monospace, monospace); }
|
|
488
|
+
.iterate-obs-code { padding: 6px 8px; margin-top: 4px; border-radius: 6px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); font-family: var(--dsw-font-mono, ui-monospace, monospace); font-size: 10.5px; color: var(--dsw-alias-label-secondary); white-space: pre-wrap; word-break: break-all; }
|
|
489
|
+
.iterate-obs-input { width: 100%; 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-size: 11px; resize: vertical; }
|
|
490
|
+
.iterate-obs-input:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }
|
|
491
|
+
.iterate-obs-input::placeholder { color: var(--dsw-alias-label-secondary); }
|
|
492
|
+
.iterate-obs-empty { font-size: 11px; color: var(--dsw-alias-label-secondary); padding: 8px 0; }
|
|
493
|
+
.iterate-obs-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--dsw-alias-border-l1); font-size: 11px; color: var(--dsw-alias-label-secondary); flex-wrap: wrap; }
|
|
494
|
+
.iterate-obs-row:last-child { border-bottom: none; }
|
|
495
|
+
.iterate-obs-livebadge { display: inline-flex; align-items: center; gap: 4px; padding: 1px 7px; border-radius: 6px; border: 1px solid currentColor; background: color-mix(in srgb, currentColor 10%, transparent); font-size: 10.5px; font-weight: 600; white-space: nowrap; }
|
|
346
496
|
`
|
|
347
497
|
|
|
348
498
|
// ─── Small helpers ───────────────────────────────────────────────────────────
|
|
@@ -402,16 +552,15 @@ function removeStorageByPrefix(prefix: string): number {
|
|
|
402
552
|
return removed
|
|
403
553
|
}
|
|
404
554
|
|
|
405
|
-
/** Copy text to the clipboard,
|
|
406
|
-
function copyText(text: string): boolean {
|
|
555
|
+
/** Copy text to the clipboard, resolving to whether it actually succeeded. */
|
|
556
|
+
function copyText(text: string): Promise<boolean> {
|
|
407
557
|
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
|
408
|
-
navigator.clipboard.writeText(text).then(
|
|
558
|
+
return navigator.clipboard.writeText(text).then(
|
|
409
559
|
() => true,
|
|
410
560
|
() => false,
|
|
411
561
|
)
|
|
412
|
-
return true
|
|
413
562
|
}
|
|
414
|
-
return false
|
|
563
|
+
return Promise.resolve(false)
|
|
415
564
|
}
|
|
416
565
|
|
|
417
566
|
/** Literal severity keys recognized by SEVERITY_LABEL / SEVERITY_COLOR. */
|
|
@@ -523,7 +672,14 @@ function TrendChart({ points }: { points: Array<{ round: number; count: number }
|
|
|
523
672
|
style: { height: `${Math.max(4, Math.round((p.count / max) * 24))}px` },
|
|
524
673
|
}),
|
|
525
674
|
)
|
|
526
|
-
|
|
675
|
+
// Accessible summary: the per-round counts are otherwise invisible to
|
|
676
|
+
// assistive tech (mouse-only title divs).
|
|
677
|
+
const summary = points.map((p) => `Round ${p.round}: ${p.count}`).join(', ')
|
|
678
|
+
return React.createElement('div', {
|
|
679
|
+
className: 'iterate-trend',
|
|
680
|
+
role: 'img',
|
|
681
|
+
'aria-label': `各轮发现数量趋势:${summary}`,
|
|
682
|
+
}, ...bars)
|
|
527
683
|
}
|
|
528
684
|
|
|
529
685
|
/** Dashboard: live convergence strip above the composer.
|
|
@@ -549,7 +705,16 @@ function ConvergenceDashboard(props: SlotProps) {
|
|
|
549
705
|
setPulseKey((k) => k + 1)
|
|
550
706
|
}, [report && hashReport(report) + ':' + getCurrentRound(report)])
|
|
551
707
|
|
|
552
|
-
if (!report)
|
|
708
|
+
if (!report) {
|
|
709
|
+
// Empty/onboarding state: first-time users otherwise see nothing and have
|
|
710
|
+
// no idea the plugin exists or how to start.
|
|
711
|
+
return React.createElement(
|
|
712
|
+
'div',
|
|
713
|
+
{ 'data-iterate-root': '', 'data-iterate': 'dashboard', className: 'iterate-dashboard iterate-dashboard-empty' },
|
|
714
|
+
React.createElement('span', { className: 'iterate-round-badge' }, 'iterate'),
|
|
715
|
+
React.createElement('span', { className: 'iterate-empty-hint' }, '运行一次评审后,这里会显示收敛进度与发现统计。试试「review this project」或「/iterate review-only」'),
|
|
716
|
+
)
|
|
717
|
+
}
|
|
553
718
|
|
|
554
719
|
const round = getCurrentRound(report)
|
|
555
720
|
const total = getTotalRounds(report)
|
|
@@ -577,13 +742,25 @@ function ConvergenceDashboard(props: SlotProps) {
|
|
|
577
742
|
}, `附件图片 ${String(imageCount)}`)
|
|
578
743
|
: null
|
|
579
744
|
|
|
580
|
-
const
|
|
745
|
+
const dimNames = Object.keys(dims)
|
|
746
|
+
const dimBadges = dimNames.slice(0, 6).map((dim) =>
|
|
581
747
|
React.createElement(
|
|
582
748
|
'span',
|
|
583
749
|
{ key: dim, className: 'iterate-dim-badge' },
|
|
584
750
|
`${dim} · ${(dims[dim]?.length ?? 0)}`,
|
|
585
751
|
),
|
|
586
752
|
)
|
|
753
|
+
// Don't silently drop dimensions: surface the overflow as a +N chip.
|
|
754
|
+
const overflow = dimNames.length - 6
|
|
755
|
+
if (overflow > 0) {
|
|
756
|
+
dimBadges.push(
|
|
757
|
+
React.createElement(
|
|
758
|
+
'span',
|
|
759
|
+
{ key: '+more', className: 'iterate-dim-badge iterate-dim-more', title: dimNames.slice(6).join(', ') },
|
|
760
|
+
`+${overflow} 更多`,
|
|
761
|
+
),
|
|
762
|
+
)
|
|
763
|
+
}
|
|
587
764
|
|
|
588
765
|
// Fix-count badge: show a running "fixes applied" metric when the report
|
|
589
766
|
// carries a number (normal mode only — threaded through `fixedCount`).
|
|
@@ -599,6 +776,23 @@ function ConvergenceDashboard(props: SlotProps) {
|
|
|
599
776
|
}, `${String(fixCount)} fixes`)
|
|
600
777
|
: null
|
|
601
778
|
|
|
779
|
+
// Convergence is the payoff of the review — make it visible on the
|
|
780
|
+
// persistent dashboard, not only in the transient 3.6s capsule.
|
|
781
|
+
const converged = report.convergence && report.convergence.converged === true
|
|
782
|
+
const convChip = converged
|
|
783
|
+
? React.createElement('span', {
|
|
784
|
+
className: 'iterate-chip-resume',
|
|
785
|
+
key: 'converged',
|
|
786
|
+
title: '审查已收敛:最后一轮未发现新问题',
|
|
787
|
+
}, '✓ 已收敛')
|
|
788
|
+
: null
|
|
789
|
+
|
|
790
|
+
const sevMetric = (key: 'critical' | 'high' | 'medium' | 'low', label: string) =>
|
|
791
|
+
React.createElement('span', { className: 'iterate-metric', key, title: label },
|
|
792
|
+
React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR[key] } }),
|
|
793
|
+
`${label} ${String(stats[key])}`,
|
|
794
|
+
)
|
|
795
|
+
|
|
602
796
|
return React.createElement(
|
|
603
797
|
'div',
|
|
604
798
|
{ 'data-iterate-root': '', 'data-iterate': 'dashboard', className: 'iterate-dashboard' },
|
|
@@ -608,20 +802,16 @@ function ConvergenceDashboard(props: SlotProps) {
|
|
|
608
802
|
`Round ${round} / ${total}`,
|
|
609
803
|
),
|
|
610
804
|
React.createElement('div', { className: 'iterate-progress' },
|
|
611
|
-
React.createElement('div', {
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
stats.critical,
|
|
616
|
-
),
|
|
617
|
-
React.createElement('span', { className: 'iterate-metric' },
|
|
618
|
-
React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.high } }),
|
|
619
|
-
stats.high,
|
|
620
|
-
),
|
|
621
|
-
React.createElement('span', { className: 'iterate-metric' },
|
|
622
|
-
React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.medium } }),
|
|
623
|
-
stats.medium,
|
|
805
|
+
React.createElement('div', {
|
|
806
|
+
className: converged ? 'iterate-progress-fill iterate-progress-fill-done' : 'iterate-progress-fill',
|
|
807
|
+
style: { width: `${progress}%` },
|
|
808
|
+
}),
|
|
624
809
|
),
|
|
810
|
+
convChip,
|
|
811
|
+
sevMetric('critical', 'CRIT'),
|
|
812
|
+
sevMetric('high', 'HIGH'),
|
|
813
|
+
sevMetric('medium', 'MED'),
|
|
814
|
+
sevMetric('low', 'LOW'),
|
|
625
815
|
fixBadge,
|
|
626
816
|
resumeChip,
|
|
627
817
|
imageChip,
|
|
@@ -731,14 +921,15 @@ function TriagePanel(props: SlotProps) {
|
|
|
731
921
|
const [selected, setSelected] = React.useState<number | null>(null)
|
|
732
922
|
const [selectAll, setSelectAll] = React.useState(false)
|
|
733
923
|
|
|
734
|
-
/** Persist
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
924
|
+
/** Persist the verdicts to localStorage whenever they change. Kept OUT of
|
|
925
|
+
* the state updater (updaters must stay pure — React may double-invoke them
|
|
926
|
+
* under StrictMode, and a storage throw must not surface during render). */
|
|
927
|
+
React.useEffect(() => {
|
|
928
|
+
if (storage) storage.set(storageKey, JSON.stringify(verdicts))
|
|
929
|
+
}, [storageKey, verdicts])
|
|
739
930
|
|
|
740
931
|
const setVerdict = (index: number, verdict: 'keep' | 'skip' | 'ignore') => {
|
|
741
|
-
setVerdicts((prev) =>
|
|
932
|
+
setVerdicts((prev) => ({ ...prev, [String(index)]: verdict }))
|
|
742
933
|
}
|
|
743
934
|
|
|
744
935
|
// ── Filtering (visible findings + their original indices) ───────────────
|
|
@@ -757,13 +948,10 @@ function TriagePanel(props: SlotProps) {
|
|
|
757
948
|
const allIndices = allVerdictKeys(verdicts)
|
|
758
949
|
const batchTarget = selectAll ? allIndices : indices
|
|
759
950
|
const applyBatch = (verdict: 'keep' | 'skip' | 'ignore') => {
|
|
760
|
-
setVerdicts((prev) =>
|
|
761
|
-
}
|
|
762
|
-
const applyBatchAll = (verdict: 'keep' | 'skip' | 'ignore') => {
|
|
763
|
-
setVerdicts((prev) => persistVerdicts(setAllVerdicts(prev, verdict)))
|
|
951
|
+
setVerdicts((prev) => batchSetVerdict(prev, batchTarget, verdict))
|
|
764
952
|
}
|
|
765
953
|
const doResetVerdicts = () => {
|
|
766
|
-
setVerdicts((prev) =>
|
|
954
|
+
setVerdicts((prev) => setAllVerdicts(prev, 'keep'))
|
|
767
955
|
setSelectAll(false)
|
|
768
956
|
}
|
|
769
957
|
|
|
@@ -772,8 +960,13 @@ function TriagePanel(props: SlotProps) {
|
|
|
772
960
|
const doc = typeof document !== 'undefined' ? document : null
|
|
773
961
|
if (!doc) return
|
|
774
962
|
const onKeyDown = (ev: KeyboardEvent) => {
|
|
963
|
+
// Never hijack modified shortcuts (Cmd/Ctrl/Alt combos like Cmd+A
|
|
964
|
+
// select-all) or keystrokes typed into an editable surface (the composer
|
|
965
|
+
// may be a contenteditable div, not a textarea).
|
|
966
|
+
if (ev.metaKey || ev.ctrlKey || ev.altKey) return
|
|
775
967
|
const t = ev.target as HTMLElement | null
|
|
776
968
|
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return
|
|
969
|
+
if (t && typeof t.isContentEditable === 'boolean' && t.isContentEditable) return
|
|
777
970
|
const verdict = keyToVerdict(ev.key)
|
|
778
971
|
if (verdict && selected !== null && indices.includes(selected)) {
|
|
779
972
|
ev.preventDefault()
|
|
@@ -807,10 +1000,16 @@ function TriagePanel(props: SlotProps) {
|
|
|
807
1000
|
const doCopyYaml = () => {
|
|
808
1001
|
const yaml = toKnownIntentionalYaml(ignored)
|
|
809
1002
|
if (!yaml) return
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
1003
|
+
copyText(yaml).then((ok) => {
|
|
1004
|
+
if (ok) {
|
|
1005
|
+
setCopied(true)
|
|
1006
|
+
setTimeout(() => setCopied(false), 1600)
|
|
1007
|
+
} else {
|
|
1008
|
+
// Copy failed (permissions/unsupported) — reveal the payload as a
|
|
1009
|
+
// manual-copy fallback instead of claiming success.
|
|
1010
|
+
setPayload(yaml)
|
|
1011
|
+
}
|
|
1012
|
+
})
|
|
814
1013
|
}
|
|
815
1014
|
|
|
816
1015
|
const doBuildInstruction = () => {
|
|
@@ -841,7 +1040,14 @@ function TriagePanel(props: SlotProps) {
|
|
|
841
1040
|
key: String(index),
|
|
842
1041
|
className: 'iterate-finding',
|
|
843
1042
|
'data-selected': isSelected ? '' : undefined,
|
|
1043
|
+
role: 'option',
|
|
1044
|
+
'aria-selected': isSelected,
|
|
1045
|
+
tabIndex: 0,
|
|
844
1046
|
onClick: () => setSelected(index),
|
|
1047
|
+
onFocus: () => setSelected(index),
|
|
1048
|
+
onKeyDown: (e: React.KeyboardEvent) => {
|
|
1049
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelected(index) }
|
|
1050
|
+
},
|
|
845
1051
|
},
|
|
846
1052
|
React.createElement('div', { className: 'iterate-finding-meta' },
|
|
847
1053
|
React.createElement('span', { className: 'iterate-sev-dot', style: { background: severityColor(severity) } }),
|
|
@@ -861,17 +1067,17 @@ function TriagePanel(props: SlotProps) {
|
|
|
861
1067
|
|
|
862
1068
|
return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'triage', className: 'iterate-triage' },
|
|
863
1069
|
React.createElement('div', { className: 'iterate-triage-head' },
|
|
864
|
-
React.createElement('span', {}, `Iterate · Findings 分诊 (${filtered.length}/${findings.length})`),
|
|
1070
|
+
React.createElement('span', { role: 'heading', 'aria-level': 3 }, `Iterate · Findings 分诊 (${filtered.length}/${findings.length})`),
|
|
865
1071
|
React.createElement('span', { className: 'iterate-triage-hint' }, 'y=修复 · n=跳过 · a=已知有意 · ↑/↓ 选择'),
|
|
866
1072
|
),
|
|
867
1073
|
React.createElement('div', { className: 'iterate-filter' },
|
|
868
|
-
React.createElement('select', { className: 'iterate-filter-select', value: filter.severities[0] || '', onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setSeverityFilter(e.target.value),
|
|
1074
|
+
React.createElement('select', { className: 'iterate-filter-select', value: filter.severities[0] || '', onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setSeverityFilter(e.target.value), 'aria-label': '按严重度筛选' },
|
|
869
1075
|
React.createElement('option', { value: '' }, '全部严重度'),
|
|
870
1076
|
...options.severities.map((s) =>
|
|
871
1077
|
React.createElement('option', { key: s.value, value: s.value }, `${severityLabel(s.value)} (${s.count})`),
|
|
872
1078
|
),
|
|
873
1079
|
),
|
|
874
|
-
React.createElement('select', { className: 'iterate-filter-select', value: filter.dimensions[0] || '', onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setDimensionFilter(e.target.value),
|
|
1080
|
+
React.createElement('select', { className: 'iterate-filter-select', value: filter.dimensions[0] || '', onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setDimensionFilter(e.target.value), 'aria-label': '按维度筛选' },
|
|
875
1081
|
React.createElement('option', { value: '' }, '全部维度'),
|
|
876
1082
|
...options.dimensions.map((d) =>
|
|
877
1083
|
React.createElement('option', { key: d.value, value: d.value }, `${d.value} (${d.count})`),
|
|
@@ -881,6 +1087,7 @@ function TriagePanel(props: SlotProps) {
|
|
|
881
1087
|
className: 'iterate-filter-search',
|
|
882
1088
|
type: 'search',
|
|
883
1089
|
placeholder: '搜索文件 / 摘要…',
|
|
1090
|
+
'aria-label': '搜索文件或摘要',
|
|
884
1091
|
value: filter.search,
|
|
885
1092
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setSearchFilter(e.target.value),
|
|
886
1093
|
}),
|
|
@@ -891,26 +1098,35 @@ function TriagePanel(props: SlotProps) {
|
|
|
891
1098
|
),
|
|
892
1099
|
),
|
|
893
1100
|
React.createElement('div', { className: 'iterate-batch' },
|
|
894
|
-
React.createElement('span', { className: 'iterate-batch-label' }, '
|
|
895
|
-
React.createElement('
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
React.createElement('button', {
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
React.createElement('button', { className: 'iterate-batch-btn', onClick: () =>
|
|
1101
|
+
React.createElement('span', { className: 'iterate-batch-label' }, '批量作用于:'),
|
|
1102
|
+
React.createElement('button', {
|
|
1103
|
+
className: selectAll ? 'iterate-batch-btn iterate-batch-scope' : 'iterate-batch-btn iterate-batch-scope iterate-batch-scope-on',
|
|
1104
|
+
onClick: () => setSelectAll(false),
|
|
1105
|
+
title: '批量按钮仅作用于当前筛选可见的 findings',
|
|
1106
|
+
}, `可见 ${indices.length}`),
|
|
1107
|
+
React.createElement('button', {
|
|
1108
|
+
className: selectAll ? 'iterate-batch-btn iterate-batch-scope iterate-batch-scope-on' : 'iterate-batch-btn iterate-batch-scope',
|
|
1109
|
+
onClick: () => setSelectAll(true),
|
|
1110
|
+
title: '批量按钮作用于全部 findings',
|
|
1111
|
+
}, `全部 ${allIndices.length}`),
|
|
1112
|
+
React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('keep') }, 'y'),
|
|
1113
|
+
React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('skip') }, 'n'),
|
|
1114
|
+
React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('ignore') }, 'a'),
|
|
906
1115
|
React.createElement('button', { className: 'iterate-batch-btn', onClick: doResetVerdicts, title: '把所有判定恢复为默认 y(修复)' }, '重置'),
|
|
907
1116
|
),
|
|
908
1117
|
...rows,
|
|
909
1118
|
React.createElement('div', { className: 'iterate-triage-foot' },
|
|
910
1119
|
React.createElement('span', {}, `y ${counts.keep} · n ${counts.skip} · a ${counts.ignore} · 待写回 known_intentional:${ignoredCount} 条`),
|
|
911
1120
|
React.createElement('span', { style: { display: 'flex', gap: 6 } },
|
|
912
|
-
React.createElement('button', {
|
|
913
|
-
|
|
1121
|
+
React.createElement('button', {
|
|
1122
|
+
className: 'iterate-btn', 'data-primary': '', 'data-copied': copied ? '' : undefined,
|
|
1123
|
+
onClick: doCopyYaml, disabled: ignoredCount === 0,
|
|
1124
|
+
title: ignoredCount === 0 ? '当前没有标记为「已知有意」的 finding' : '复制 known_intentional YAML',
|
|
1125
|
+
}, copied ? '已复制' : `复制 known_intentional${ignoredCount > 0 ? `(${ignoredCount})` : ''}`),
|
|
1126
|
+
React.createElement('button', {
|
|
1127
|
+
className: 'iterate-btn', onClick: doBuildInstruction, disabled: ignoredCount === 0,
|
|
1128
|
+
title: ignoredCount === 0 ? '当前没有标记为「已知有意」的 finding' : '生成 iterate_triage 应用指令',
|
|
1129
|
+
}, '生成应用指令'),
|
|
914
1130
|
),
|
|
915
1131
|
),
|
|
916
1132
|
payload
|
|
@@ -1031,10 +1247,11 @@ function SettingsPanel(_props: SlotProps) {
|
|
|
1031
1247
|
setTimeout(() => setter(false), 1600)
|
|
1032
1248
|
}
|
|
1033
1249
|
|
|
1034
|
-
/** Copy a guide/status block;
|
|
1250
|
+
/** Copy a guide/status block; flash success only when it actually copied. */
|
|
1035
1251
|
const doCopy = (text: string, slot: 'guide' | 'status') => {
|
|
1036
|
-
copyText(text)
|
|
1037
|
-
|
|
1252
|
+
copyText(text).then((ok) => {
|
|
1253
|
+
if (ok) flashCopied(slot)
|
|
1254
|
+
})
|
|
1038
1255
|
}
|
|
1039
1256
|
|
|
1040
1257
|
/** Two-step destroy guard: first click arms, second click clears. */
|
|
@@ -1160,6 +1377,525 @@ function SettingsPanel(_props: SlotProps) {
|
|
|
1160
1377
|
)
|
|
1161
1378
|
}
|
|
1162
1379
|
|
|
1380
|
+
// ─── Runtime observatory panel (F1–F7) ──────────────────────────────────────
|
|
1381
|
+
|
|
1382
|
+
/** Tab id → label for the observatory's eight panels. Live (real-time activity
|
|
1383
|
+
* stream) is first so it is the default view; F-panels follow. */
|
|
1384
|
+
const OBS_TABS = [
|
|
1385
|
+
{ key: 'live', label: '实时活动流' },
|
|
1386
|
+
{ key: 'f1', label: '审查线程' },
|
|
1387
|
+
{ key: 'f2', label: '收敛趋势' },
|
|
1388
|
+
{ key: 'f3', label: '发现定位' },
|
|
1389
|
+
{ key: 'f4', label: '修复与回滚' },
|
|
1390
|
+
{ key: 'f5', label: '断点恢复' },
|
|
1391
|
+
{ key: 'f6', label: '运行控制台' },
|
|
1392
|
+
{ key: 'f7', label: '决策时间线' },
|
|
1393
|
+
] as const
|
|
1394
|
+
|
|
1395
|
+
/** Live subagent activity badges: type → Chinese label + dsw token color accent. */
|
|
1396
|
+
const OBS_LIVE_META: Record<string, { label: string; color: string }> = {
|
|
1397
|
+
read: { label: '阅读', color: 'var(--dsw-alias-brand-primary)' },
|
|
1398
|
+
fix: { label: '修复', color: 'var(--dsw-alias-state-success-primary)' },
|
|
1399
|
+
rollback: { label: '回滚', color: 'var(--dsw-alias-state-error-primary)' },
|
|
1400
|
+
diff: { label: '差异', color: 'var(--dsw-alias-state-warn-primary)' },
|
|
1401
|
+
review: { label: '审查', color: 'var(--dsw-alias-brand-primary)' },
|
|
1402
|
+
triage: { label: '分流', color: 'var(--dsw-alias-state-warn-primary)' },
|
|
1403
|
+
checkpoint: { label: '断点', color: 'var(--dsw-alias-brand-primary)' },
|
|
1404
|
+
validate: { label: '校验', color: 'var(--dsw-alias-state-success-primary)' },
|
|
1405
|
+
log: { label: '记录', color: 'var(--dsw-alias-label-secondary)' },
|
|
1406
|
+
prune: { label: '清理', color: 'var(--dsw-alias-state-warn-primary)' },
|
|
1407
|
+
info: { label: '信息', color: 'var(--dsw-alias-label-secondary)' },
|
|
1408
|
+
}
|
|
1409
|
+
const OBS_LIVE_FALLBACK_COLOR = 'var(--dsw-alias-label-secondary)'
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* Format a live-event timestamp as DD/MM HH:mm:ss (0-padded). Accepts an epoch
|
|
1413
|
+
* ms number or an ISO-ish string; any unparseable value degrades to ''.
|
|
1414
|
+
*/
|
|
1415
|
+
function formatObsLiveTime(ts?: string): string {
|
|
1416
|
+
if (!ts) return ''
|
|
1417
|
+
let date: Date | null = null
|
|
1418
|
+
const num = Number(ts)
|
|
1419
|
+
if (typeof ts === 'string' && ts !== '' && !Number.isNaN(num) && /^\d+$/.test(ts)) {
|
|
1420
|
+
date = new Date(num)
|
|
1421
|
+
} else {
|
|
1422
|
+
const d = new Date(ts)
|
|
1423
|
+
date = Number.isNaN(d.getTime()) ? null : d
|
|
1424
|
+
}
|
|
1425
|
+
if (!date) return String(ts)
|
|
1426
|
+
const p = (n: number) => String(n).padStart(2, '0')
|
|
1427
|
+
const h = p(date.getHours())
|
|
1428
|
+
const mi = p(date.getMinutes())
|
|
1429
|
+
const s = p(date.getSeconds())
|
|
1430
|
+
return `${p(date.getDate())}/${p(date.getMonth() + 1)} ${h}:${mi}:${s}`
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
/** Find the latest runtime-observatory manifest inside a session snapshot. */
|
|
1434
|
+
function latestTranscript(session: unknown): ObsManifest | null {
|
|
1435
|
+
if (!session) return null
|
|
1436
|
+
const raw = scanSessionForTranscript(session)
|
|
1437
|
+
return raw ? normalizeTranscript(raw) as ObsManifest : null
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/** Build a human-pasteable `iterate_fix` instruction for one finding. */
|
|
1441
|
+
function buildObsFixInstruction(f: ObsFinding): string {
|
|
1442
|
+
const payload = JSON.stringify({
|
|
1443
|
+
file: String(f.file || ''),
|
|
1444
|
+
...(typeof f.line === 'number' && f.line > 0 ? { line: f.line } : {}),
|
|
1445
|
+
dimension: String(f.dimension || ''),
|
|
1446
|
+
summary: String(f.summary || ''),
|
|
1447
|
+
...(f.suggested_fix ? { suggested_fix: String(f.suggested_fix) } : {}),
|
|
1448
|
+
}, null, 2)
|
|
1449
|
+
return `请调用 \`iterate_fix\` 修复以下 finding:\n\n\`\`\`json\n${payload}\n\`\`\``
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* ObservatoryPanel: an 8-tab runtime observatory that renders the latest
|
|
1454
|
+
* iterate_transcript manifest found in the dsh session stream. The panel is
|
|
1455
|
+
* collapsed by default to a single entry row so it never re-occludes the main
|
|
1456
|
+
* convergence dashboard; clicking expands it into tabs. Every field read is
|
|
1457
|
+
* defensively guarded (optional chaining + fallbacks) so a partial/malformed
|
|
1458
|
+
* manifest never crashes the slot. The client cannot call harness tools
|
|
1459
|
+
* directly, so every action copies a paste-able instruction text instead.
|
|
1460
|
+
*/
|
|
1461
|
+
function ObservatoryPanel(props: SlotProps) {
|
|
1462
|
+
const session = props && props.session ? props.session : null
|
|
1463
|
+
const manifest = latestTranscript(session)
|
|
1464
|
+
|
|
1465
|
+
const [open, setOpen] = React.useState(false)
|
|
1466
|
+
const [tab, setTab] = React.useState('live')
|
|
1467
|
+
const [expandedThreads, setExpandedThreads] = React.useState<Set<string>>(new Set())
|
|
1468
|
+
const [copiedKey, setCopiedKey] = React.useState<string | null>(null)
|
|
1469
|
+
const [nudgeText, setNudgeText] = React.useState('')
|
|
1470
|
+
const [timelineType, setTimelineType] = React.useState('')
|
|
1471
|
+
const [timelineSearch, setTimelineSearch] = React.useState('')
|
|
1472
|
+
|
|
1473
|
+
// Single shared "copied" flash timer (reused across all copy buttons).
|
|
1474
|
+
const copyTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
1475
|
+
React.useEffect(() => () => { if (copyTimer.current) clearTimeout(copyTimer.current) }, [])
|
|
1476
|
+
|
|
1477
|
+
/** Copy an instruction, flashing "已复制" on the originating button only when the copy actually succeeded. */
|
|
1478
|
+
const copyInstruction = (key: string, text: string) => {
|
|
1479
|
+
if (!text) return
|
|
1480
|
+
copyText(text).then((ok) => {
|
|
1481
|
+
if (!ok) return
|
|
1482
|
+
setCopiedKey(key)
|
|
1483
|
+
if (copyTimer.current) clearTimeout(copyTimer.current)
|
|
1484
|
+
copyTimer.current = setTimeout(() => setCopiedKey((cur) => (cur === key ? null : cur)), 1600)
|
|
1485
|
+
})
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
const toggleThread = (key: string) => {
|
|
1489
|
+
setExpandedThreads((prev) => {
|
|
1490
|
+
const next = new Set(prev)
|
|
1491
|
+
if (next.has(key)) next.delete(key); else next.add(key)
|
|
1492
|
+
return next
|
|
1493
|
+
})
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// No observable transcript yet: show a compact, collapsed header row.
|
|
1497
|
+
if (!manifest) {
|
|
1498
|
+
return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'obs', className: 'iterate-obs' },
|
|
1499
|
+
React.createElement('div', { className: 'iterate-obs-head', 'data-closed': '' },
|
|
1500
|
+
React.createElement('span', { className: 'iterate-obs-title' }, 'iterate 观测台'),
|
|
1501
|
+
React.createElement('span', { className: 'iterate-obs-head-meta' }, '暂无运行时观测数据'),
|
|
1502
|
+
),
|
|
1503
|
+
)
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
const live = manifest.active === true
|
|
1507
|
+
const roundMeta = typeof manifest.round === 'number'
|
|
1508
|
+
? (typeof manifest.maxRounds === 'number' ? `Round ${manifest.round}/${manifest.maxRounds}` : `Round ${manifest.round}`)
|
|
1509
|
+
: ''
|
|
1510
|
+
const headMeta = [manifest.mode || '', roundMeta, manifest.updatedAt || ''].filter(Boolean).join(' · ')
|
|
1511
|
+
|
|
1512
|
+
// ── F1: review threads ─────────────────────────────────────────────────
|
|
1513
|
+
const threadFindingPill = (f: ObsFinding, keyBase: string) => {
|
|
1514
|
+
const loc = `${String(f.file || '?')}${typeof f.line === 'number' && f.line > 0 ? `:${f.line}` : ''}`
|
|
1515
|
+
return React.createElement('div', { key: keyBase, className: 'iterate-obs-row' },
|
|
1516
|
+
React.createElement('span', { className: 'iterate-sev-dot', style: { background: severityColor(f.severity) } }),
|
|
1517
|
+
React.createElement('span', {}, severityLabel(f.severity)),
|
|
1518
|
+
React.createElement('span', { className: 'iterate-obs-file' }, loc),
|
|
1519
|
+
React.createElement('span', {}, String(f.dimension || '')),
|
|
1520
|
+
React.createElement('span', { className: 'iterate-obs-msg' }, String(f.summary || '')),
|
|
1521
|
+
)
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
const renderThreads = () => {
|
|
1525
|
+
const rounds = manifest.rounds || []
|
|
1526
|
+
if (rounds.length === 0) {
|
|
1527
|
+
return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无审查线程')
|
|
1528
|
+
}
|
|
1529
|
+
return React.createElement('div', {}, ...rounds.map((r, ri) => {
|
|
1530
|
+
const threads = r.threads || []
|
|
1531
|
+
const fCount = threads.reduce((sum, t) => sum + (t.findings ? t.findings.length : 0), 0)
|
|
1532
|
+
const threadBlocks = threads.length === 0
|
|
1533
|
+
? [React.createElement('div', { key: 'none', className: 'iterate-obs-empty' }, '本轮无线程')]
|
|
1534
|
+
: threads.map((t, ti) => {
|
|
1535
|
+
const key = `${ri}-${ti}`
|
|
1536
|
+
const expanded = expandedThreads.has(key)
|
|
1537
|
+
const dim = t.dimension || '未命名维度'
|
|
1538
|
+
const tFindings = t.findings || []
|
|
1539
|
+
const files = t.readFiles || []
|
|
1540
|
+
const msgs = t.messages || []
|
|
1541
|
+
return React.createElement('div', { key, className: 'iterate-obs-block' },
|
|
1542
|
+
React.createElement('div', { className: 'iterate-obs-block-head', 'data-click': '', onClick: () => toggleThread(key), title: expanded ? '收起线程' : '展开线程' },
|
|
1543
|
+
React.createElement('span', {}, expanded ? '−' : '+'),
|
|
1544
|
+
React.createElement('b', {}, dim),
|
|
1545
|
+
React.createElement('span', { className: 'iterate-obs-chip' }, `attempt ${typeof t.attempt === 'number' ? t.attempt : '?'}`),
|
|
1546
|
+
React.createElement('span', { className: 'iterate-obs-chip' }, `${tFindings.length} findings`),
|
|
1547
|
+
),
|
|
1548
|
+
expanded
|
|
1549
|
+
? React.createElement('div', { className: 'iterate-obs-block-body' },
|
|
1550
|
+
React.createElement('div', { className: 'iterate-obs-bar' },
|
|
1551
|
+
React.createElement('b', {}, '叙述'),
|
|
1552
|
+
React.createElement('span', { className: 'iterate-obs-head-meta' }, `${msgs.length} 条`),
|
|
1553
|
+
),
|
|
1554
|
+
...(msgs.length === 0
|
|
1555
|
+
? [React.createElement('div', { key: 'msg-none', className: 'iterate-obs-empty' }, '无线索叙述')]
|
|
1556
|
+
: msgs.map((m, mi) => React.createElement('div', { key: mi, className: 'iterate-obs-msg' }, String(m)))),
|
|
1557
|
+
...(files.length > 0
|
|
1558
|
+
? [
|
|
1559
|
+
React.createElement('div', { key: 'files-h', className: 'iterate-obs-bar', style: { marginTop: 6 } },
|
|
1560
|
+
React.createElement('b', {}, '读取文件'),
|
|
1561
|
+
),
|
|
1562
|
+
...files.map((f, fi) =>
|
|
1563
|
+
React.createElement('div', { key: `f${fi}`, className: 'iterate-obs-file' }, String(f)),
|
|
1564
|
+
),
|
|
1565
|
+
]
|
|
1566
|
+
: []),
|
|
1567
|
+
React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 6 } },
|
|
1568
|
+
React.createElement('b', {}, '发现'),
|
|
1569
|
+
),
|
|
1570
|
+
...(tFindings.length === 0
|
|
1571
|
+
? [React.createElement('div', { key: 'finding-none', className: 'iterate-obs-empty' }, '本线程无发现')]
|
|
1572
|
+
: tFindings.map((f, fi) => threadFindingPill(f, `tf${fi}`))),
|
|
1573
|
+
)
|
|
1574
|
+
: null,
|
|
1575
|
+
)
|
|
1576
|
+
})
|
|
1577
|
+
return React.createElement('div', { key: ri, className: 'iterate-obs-block' },
|
|
1578
|
+
React.createElement('div', { className: 'iterate-obs-block-head' },
|
|
1579
|
+
React.createElement('b', {}, `Round ${typeof r.round === 'number' ? r.round : ri + 1}`),
|
|
1580
|
+
React.createElement('span', { className: 'iterate-obs-chip' }, `${threads.length} 线程`),
|
|
1581
|
+
React.createElement('span', { className: 'iterate-obs-head-meta' }, `${fCount} findings`),
|
|
1582
|
+
),
|
|
1583
|
+
React.createElement('div', { className: 'iterate-obs-block-body' }, ...threadBlocks),
|
|
1584
|
+
)
|
|
1585
|
+
}))
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// ── F2: convergence trend (inline bars, reusing TrendChart) ─────────────
|
|
1589
|
+
const renderTrend = () => {
|
|
1590
|
+
const conv = manifest.convergence || []
|
|
1591
|
+
if (conv.length === 0) {
|
|
1592
|
+
return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无收敛数据')
|
|
1593
|
+
}
|
|
1594
|
+
const points = conv.map((n, i) => ({ round: i + 1, count: n }))
|
|
1595
|
+
return React.createElement('div', {},
|
|
1596
|
+
React.createElement(TrendChart, { points }),
|
|
1597
|
+
React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 6 } },
|
|
1598
|
+
`各轮发现数量:${conv.join(' → ')}${typeof manifest.round === 'number' ? ` · 当前 Round ${manifest.round}` : ''}`,
|
|
1599
|
+
),
|
|
1600
|
+
)
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// ── F3: finding location + action copy buttons ─────────────────────────
|
|
1604
|
+
const renderFindings = () => {
|
|
1605
|
+
const findings = manifest.findings || []
|
|
1606
|
+
if (findings.length === 0) {
|
|
1607
|
+
return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无发现')
|
|
1608
|
+
}
|
|
1609
|
+
return React.createElement('div', {}, ...findings.map((f, i) => {
|
|
1610
|
+
const k = `f3-${i}`
|
|
1611
|
+
const loc = `${String(f.file || '?')}${typeof f.line === 'number' && f.line > 0 ? `:${f.line}` : ''}`
|
|
1612
|
+
const entry = {
|
|
1613
|
+
file: String(f.file || ''),
|
|
1614
|
+
...(typeof f.line === 'number' && f.line > 0 ? { line: f.line } : {}),
|
|
1615
|
+
dimension: String(f.dimension || ''),
|
|
1616
|
+
reason: String(f.summary || ''),
|
|
1617
|
+
}
|
|
1618
|
+
const knownInstruction = buildApplyInstruction([entry])
|
|
1619
|
+
const fixInstruction = buildObsFixInstruction(f)
|
|
1620
|
+
return React.createElement('div', { key: k, className: 'iterate-obs-block' },
|
|
1621
|
+
React.createElement('div', { className: 'iterate-obs-block-head' },
|
|
1622
|
+
React.createElement('span', { className: 'iterate-sev-dot', style: { background: severityColor(f.severity) } }),
|
|
1623
|
+
React.createElement('span', {}, severityLabel(f.severity)),
|
|
1624
|
+
React.createElement('span', { className: 'iterate-obs-file' }, loc),
|
|
1625
|
+
React.createElement('span', {}, String(f.dimension || '')),
|
|
1626
|
+
f.acknowledged === true
|
|
1627
|
+
? React.createElement('span', { className: 'iterate-obs-chip' }, '已确认')
|
|
1628
|
+
: null,
|
|
1629
|
+
),
|
|
1630
|
+
React.createElement('div', { className: 'iterate-obs-block-body' },
|
|
1631
|
+
React.createElement('div', { className: 'iterate-obs-msg' }, String(f.summary || '')),
|
|
1632
|
+
React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 8 } },
|
|
1633
|
+
React.createElement('button', {
|
|
1634
|
+
className: 'iterate-btn', 'data-copied': copiedKey === `${k}-fix` ? '' : undefined,
|
|
1635
|
+
onClick: () => copyInstruction(`${k}-fix`, String(f.suggested_fix || '')),
|
|
1636
|
+
title: '复制该 finding 的建议修复文本',
|
|
1637
|
+
}, copiedKey === `${k}-fix` ? '已复制' : '复制 suggested_fix'),
|
|
1638
|
+
React.createElement('button', {
|
|
1639
|
+
className: 'iterate-btn', 'data-copied': copiedKey === `${k}-known` ? '' : undefined,
|
|
1640
|
+
onClick: () => copyInstruction(`${k}-known`, knownInstruction),
|
|
1641
|
+
title: '复制 iterate_triage 指令文本(标记为 known_intentional)',
|
|
1642
|
+
}, copiedKey === `${k}-known` ? '已复制' : 'known_intentional'),
|
|
1643
|
+
React.createElement('button', {
|
|
1644
|
+
className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === `${k}-repair` ? '' : undefined,
|
|
1645
|
+
onClick: () => copyInstruction(`${k}-repair`, fixInstruction),
|
|
1646
|
+
title: '复制 iterate_fix 指令文本',
|
|
1647
|
+
}, copiedKey === `${k}-repair` ? '已复制' : '复制修复指令'),
|
|
1648
|
+
),
|
|
1649
|
+
),
|
|
1650
|
+
)
|
|
1651
|
+
}))
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
// ── F4: fixes + rollback ───────────────────────────────────────────────
|
|
1655
|
+
const renderFixes = () => {
|
|
1656
|
+
const fixes = manifest.fixes || []
|
|
1657
|
+
if (fixes.length === 0) {
|
|
1658
|
+
return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无修复记录')
|
|
1659
|
+
}
|
|
1660
|
+
return React.createElement('div', {}, ...fixes.map((f, i) => {
|
|
1661
|
+
const k = `f4-${i}`
|
|
1662
|
+
const id = String(f.id || `fix#${i + 1}`)
|
|
1663
|
+
const rollbackText = `请调用 \`iterate_rollback\` 回滚以下修复:\n\n\`\`\`json\n${JSON.stringify({ id }, null, 2)}\n\`\`\``
|
|
1664
|
+
const added = typeof f.linesAdded === 'number' ? f.linesAdded : 0
|
|
1665
|
+
const removed = typeof f.linesRemoved === 'number' ? f.linesRemoved : 0
|
|
1666
|
+
return React.createElement('div', { key: k, className: 'iterate-obs-block' },
|
|
1667
|
+
React.createElement('div', { className: 'iterate-obs-block-head' },
|
|
1668
|
+
React.createElement('b', {}, id),
|
|
1669
|
+
React.createElement('span', { className: 'iterate-obs-file' }, String(f.file || '?')),
|
|
1670
|
+
React.createElement('span', { className: 'iterate-obs-chip' }, f.success === true ? '成功' : '失败'),
|
|
1671
|
+
typeof f.round === 'number'
|
|
1672
|
+
? React.createElement('span', { className: 'iterate-obs-head-meta' }, `Round ${f.round}`)
|
|
1673
|
+
: null,
|
|
1674
|
+
),
|
|
1675
|
+
React.createElement('div', { className: 'iterate-obs-block-body' },
|
|
1676
|
+
React.createElement('div', { className: 'iterate-obs-msg' }, String(f.summary || '')),
|
|
1677
|
+
React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 8 } },
|
|
1678
|
+
React.createElement('span', { className: 'iterate-obs-chip' }, `+${added}`),
|
|
1679
|
+
React.createElement('span', { className: 'iterate-obs-chip' }, `−${removed}`),
|
|
1680
|
+
React.createElement('button', {
|
|
1681
|
+
className: 'iterate-btn', 'data-danger': '', 'data-copied': copiedKey === `${k}-rb` ? '' : undefined,
|
|
1682
|
+
onClick: () => copyInstruction(`${k}-rb`, rollbackText),
|
|
1683
|
+
title: '复制 iterate_rollback 指令文本',
|
|
1684
|
+
}, copiedKey === `${k}-rb` ? '已复制' : '回滚'),
|
|
1685
|
+
),
|
|
1686
|
+
),
|
|
1687
|
+
)
|
|
1688
|
+
}))
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// ── F5: checkpoint resume ──────────────────────────────────────────────
|
|
1692
|
+
const renderCheckpoint = () => {
|
|
1693
|
+
const cp = manifest.checkpoint
|
|
1694
|
+
if (!cp) {
|
|
1695
|
+
return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无断点')
|
|
1696
|
+
}
|
|
1697
|
+
const resumeText = `请调用 \`iterate_checkpoint\` 从断点恢复迭代:\n\n\`\`\`json\n${JSON.stringify({
|
|
1698
|
+
operation: 'resume',
|
|
1699
|
+
mode: String(cp.mode || 'normal'),
|
|
1700
|
+
maxRounds: typeof cp.maxRounds === 'number' ? cp.maxRounds : null,
|
|
1701
|
+
}, null, 2)}\n\`\`\``
|
|
1702
|
+
const item = (label: string, value: unknown) =>
|
|
1703
|
+
React.createElement('span', { className: 'iterate-obs-chip' }, `${label} ${String(value ?? '?')}`)
|
|
1704
|
+
return React.createElement('div', { className: 'iterate-obs-block' },
|
|
1705
|
+
React.createElement('div', { className: 'iterate-obs-block-head' },
|
|
1706
|
+
React.createElement('b', {}, '断点'),
|
|
1707
|
+
React.createElement('span', { className: 'iterate-obs-head-meta' }, String(cp.updatedAt || '')),
|
|
1708
|
+
),
|
|
1709
|
+
React.createElement('div', { className: 'iterate-obs-block-body' },
|
|
1710
|
+
React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
|
|
1711
|
+
item('mode', cp.mode || 'normal'),
|
|
1712
|
+
item('round', cp.round),
|
|
1713
|
+
item('maxRounds', cp.maxRounds),
|
|
1714
|
+
item('fixed', cp.fixedCount),
|
|
1715
|
+
item('resume', cp.resumeCount),
|
|
1716
|
+
),
|
|
1717
|
+
React.createElement('button', {
|
|
1718
|
+
className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'cp-resume' ? '' : undefined,
|
|
1719
|
+
onClick: () => copyInstruction('cp-resume', resumeText),
|
|
1720
|
+
title: '复制 iterate_checkpoint 综/恢复指令文本',
|
|
1721
|
+
}, copiedKey === 'cp-resume' ? '已复制' : '复制恢复指令'),
|
|
1722
|
+
),
|
|
1723
|
+
)
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
// ── F6: run console (status / approval / nudge draft) ──────────────────
|
|
1727
|
+
const renderConsole = () => {
|
|
1728
|
+
const approval = manifest.approval || { policy: 'ask' }
|
|
1729
|
+
const policyLabelMap: Record<string, string> = { ask: '询问用户', deny: '拒绝', allow: '直接执行' }
|
|
1730
|
+
const policyLabel = policyLabelMap[String(approval.policy || 'ask')] || String(approval.policy || 'ask')
|
|
1731
|
+
const nudge = manifest.nudge || null
|
|
1732
|
+
const present = Boolean(nudge && nudge.text)
|
|
1733
|
+
const activeNudgeText = nudge && nudge.text ? nudge.text : ''
|
|
1734
|
+
const nudgeInstruction = `请调用 \`iterate_transcript\` 写入 nudge 指令:\n\n\`\`\`json\n${JSON.stringify({ operation: 'nudge', text: nudgeText }, null, 2)}\n\`\`\``
|
|
1735
|
+
return React.createElement('div', { className: 'iterate-obs-block' },
|
|
1736
|
+
React.createElement('div', { className: 'iterate-obs-block-head' },
|
|
1737
|
+
React.createElement('span', {}, '运行控制台'),
|
|
1738
|
+
React.createElement('span', { className: 'iterate-obs-badge', 'data-live': live ? '' : undefined }, live ? '运行中' : '已结束'),
|
|
1739
|
+
React.createElement('span', { className: 'iterate-obs-head-meta' }, `批准策略:${policyLabel}`),
|
|
1740
|
+
),
|
|
1741
|
+
React.createElement('div', { className: 'iterate-obs-block-body' },
|
|
1742
|
+
present
|
|
1743
|
+
? React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 6 } },
|
|
1744
|
+
React.createElement('b', {}, '当前 nudge'),
|
|
1745
|
+
React.createElement('span', { className: 'iterate-obs-msg' }, activeNudgeText),
|
|
1746
|
+
React.createElement('button', { className: 'iterate-btn', onClick: () => setNudgeText('') }, '清除'),
|
|
1747
|
+
)
|
|
1748
|
+
: null,
|
|
1749
|
+
React.createElement('textarea', {
|
|
1750
|
+
className: 'iterate-obs-input iterate-obs-mono',
|
|
1751
|
+
rows: 3,
|
|
1752
|
+
placeholder: '起草一条 nudge 方向指令…',
|
|
1753
|
+
value: nudgeText,
|
|
1754
|
+
'aria-label': 'nudge 草稿',
|
|
1755
|
+
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => setNudgeText(e.target.value),
|
|
1756
|
+
}),
|
|
1757
|
+
React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 6 } },
|
|
1758
|
+
React.createElement('button', {
|
|
1759
|
+
className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'nudge' ? '' : undefined,
|
|
1760
|
+
disabled: !nudgeText,
|
|
1761
|
+
onClick: () => copyInstruction('nudge', nudgeInstruction),
|
|
1762
|
+
title: '复制 iterate_transcript nudge 指令文本',
|
|
1763
|
+
}, copiedKey === 'nudge' ? '已复制' : '复制 nudge 指令'),
|
|
1764
|
+
),
|
|
1765
|
+
),
|
|
1766
|
+
)
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// ── F7: decision timeline (type filter + search, newest first) ──────────
|
|
1770
|
+
const renderTimeline = () => {
|
|
1771
|
+
const entries = manifest.timeline || []
|
|
1772
|
+
if (entries.length === 0) {
|
|
1773
|
+
return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无时间线条目')
|
|
1774
|
+
}
|
|
1775
|
+
const types = Array.from(new Set(entries.map((t) => String(t.type || 'unknown')).filter(Boolean))).sort()
|
|
1776
|
+
const q = timelineSearch.trim().toLowerCase()
|
|
1777
|
+
const filtered = entries.filter((t) => {
|
|
1778
|
+
if (timelineType && String(t.type || '') !== timelineType) return false
|
|
1779
|
+
if (!q) return true
|
|
1780
|
+
const hay = [String(t.type || ''), String(t.round ?? ''), JSON.stringify(t.data || {})].join(' ').toLowerCase()
|
|
1781
|
+
return hay.indexOf(q) >= 0
|
|
1782
|
+
})
|
|
1783
|
+
// Newest first: entries are not guaranteed to be reverse ordered in the
|
|
1784
|
+
// manifest, so sort by timestamp string descending for a stable timeline.
|
|
1785
|
+
const sorted = filtered.slice().sort((a, b) => String(b.timestamp || '').localeCompare(String(a.timestamp || '')))
|
|
1786
|
+
const rows = sorted.map((t, i) => {
|
|
1787
|
+
const k = `f7-${i}`
|
|
1788
|
+
const dataText = t.data && typeof t.data === 'object' ? JSON.stringify(t.data) : ''
|
|
1789
|
+
return React.createElement('div', { key: k, className: 'iterate-obs-row', style: { flexDirection: 'column', alignItems: 'flex-start' } },
|
|
1790
|
+
React.createElement('div', { className: 'iterate-obs-bar', style: { width: '100%' } },
|
|
1791
|
+
React.createElement('span', { className: 'iterate-obs-chip' }, String(t.type || '?')),
|
|
1792
|
+
React.createElement('span', { className: 'iterate-obs-head-meta' },
|
|
1793
|
+
`${typeof t.round === 'number' ? `Round ${t.round} · ` : ''}${String(t.timestamp || '')}`,
|
|
1794
|
+
),
|
|
1795
|
+
),
|
|
1796
|
+
dataText ? React.createElement('div', { className: 'iterate-obs-code' }, dataText) : null,
|
|
1797
|
+
)
|
|
1798
|
+
})
|
|
1799
|
+
if (rows.length === 0) {
|
|
1800
|
+
return React.createElement('div', { className: 'iterate-obs-empty' }, '无匹配的时间线条目')
|
|
1801
|
+
}
|
|
1802
|
+
return React.createElement('div', {},
|
|
1803
|
+
React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
|
|
1804
|
+
React.createElement('select', {
|
|
1805
|
+
className: 'iterate-filter-select',
|
|
1806
|
+
value: timelineType,
|
|
1807
|
+
'aria-label': '按时间线类型筛选',
|
|
1808
|
+
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setTimelineType(e.target.value),
|
|
1809
|
+
},
|
|
1810
|
+
React.createElement('option', { value: '' }, '全部类型'),
|
|
1811
|
+
...types.map((t) => React.createElement('option', { key: t, value: t }, t)),
|
|
1812
|
+
),
|
|
1813
|
+
React.createElement('input', {
|
|
1814
|
+
className: 'iterate-filter-search',
|
|
1815
|
+
type: 'search',
|
|
1816
|
+
placeholder: '搜索时间线…',
|
|
1817
|
+
'aria-label': '搜索时间线',
|
|
1818
|
+
value: timelineSearch,
|
|
1819
|
+
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setTimelineSearch(e.target.value),
|
|
1820
|
+
}),
|
|
1821
|
+
React.createElement('span', { className: 'iterate-filter-count' }, `${filtered.length}/${entries.length}`),
|
|
1822
|
+
),
|
|
1823
|
+
...rows,
|
|
1824
|
+
)
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// ── Live: real-time secondary-subagent activity stream (newest first) ─────
|
|
1828
|
+
const renderLive = () => {
|
|
1829
|
+
const liveEntries = manifest.live || []
|
|
1830
|
+
if (liveEntries.length === 0) {
|
|
1831
|
+
return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无实时活动')
|
|
1832
|
+
}
|
|
1833
|
+
const rows = liveEntries.map((e, i) => {
|
|
1834
|
+
const type = String(e.type || 'unknown')
|
|
1835
|
+
const meta = OBS_LIVE_META[type] || { label: String(e.type || '?'), color: OBS_LIVE_FALLBACK_COLOR }
|
|
1836
|
+
const tool = String(e.tool || '')
|
|
1837
|
+
const target = String(e.target || '')
|
|
1838
|
+
const time = formatObsLiveTime(e.ts)
|
|
1839
|
+
return React.createElement('div', { key: `live-${i}`, className: 'iterate-obs-row' },
|
|
1840
|
+
React.createElement('span', { className: 'iterate-obs-livebadge', style: { color: meta.color } }, meta.label),
|
|
1841
|
+
tool
|
|
1842
|
+
? React.createElement('span', { className: 'iterate-obs-chip' }, tool)
|
|
1843
|
+
: null,
|
|
1844
|
+
React.createElement('span', { className: 'iterate-obs-msg' }, target || '—'),
|
|
1845
|
+
React.createElement('span', { className: 'iterate-obs-head-meta' }, time),
|
|
1846
|
+
)
|
|
1847
|
+
})
|
|
1848
|
+
return React.createElement('div', {},
|
|
1849
|
+
React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
|
|
1850
|
+
React.createElement('b', {}, '实时活动'),
|
|
1851
|
+
React.createElement('span', { className: 'iterate-obs-chip' }, `${liveEntries.length} 项`),
|
|
1852
|
+
),
|
|
1853
|
+
...rows,
|
|
1854
|
+
)
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
const renderBody = () => {
|
|
1858
|
+
switch (tab) {
|
|
1859
|
+
case 'live': return renderLive()
|
|
1860
|
+
case 'f1': return renderThreads()
|
|
1861
|
+
case 'f2': return renderTrend()
|
|
1862
|
+
case 'f3': return renderFindings()
|
|
1863
|
+
case 'f4': return renderFixes()
|
|
1864
|
+
case 'f5': return renderCheckpoint()
|
|
1865
|
+
case 'f6': return renderConsole()
|
|
1866
|
+
case 'f7': return renderTimeline()
|
|
1867
|
+
default: return null
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'obs', className: 'iterate-obs' },
|
|
1872
|
+
React.createElement('div', { className: 'iterate-obs-head', 'data-closed': open ? undefined : '', onClick: () => setOpen((v) => !v) },
|
|
1873
|
+
React.createElement('span', { className: 'iterate-obs-title' }, 'iterate 观测台'),
|
|
1874
|
+
React.createElement('span', { className: 'iterate-obs-badge', 'data-live': live ? '' : undefined }, live ? '运行中' : '已结束'),
|
|
1875
|
+
React.createElement('span', { className: 'iterate-obs-head-meta' }, headMeta || 'runtime'),
|
|
1876
|
+
React.createElement('button', {
|
|
1877
|
+
className: 'iterate-btn', 'data-ghost': '',
|
|
1878
|
+
onClick: (e: React.MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); setOpen((v) => !v) },
|
|
1879
|
+
}, open ? '收起' : '展开'),
|
|
1880
|
+
),
|
|
1881
|
+
open
|
|
1882
|
+
? React.createElement('div', {},
|
|
1883
|
+
React.createElement('div', { className: 'iterate-obs-tabs' },
|
|
1884
|
+
...OBS_TABS.map((t) =>
|
|
1885
|
+
React.createElement('button', {
|
|
1886
|
+
key: t.key,
|
|
1887
|
+
className: 'iterate-obs-tab',
|
|
1888
|
+
'data-active': tab === t.key ? '' : undefined,
|
|
1889
|
+
onClick: () => setTab(t.key),
|
|
1890
|
+
}, t.label),
|
|
1891
|
+
),
|
|
1892
|
+
),
|
|
1893
|
+
React.createElement('div', { className: 'iterate-obs-body' }, renderBody()),
|
|
1894
|
+
)
|
|
1895
|
+
: null,
|
|
1896
|
+
)
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1163
1899
|
// ─── Registration ────────────────────────────────────────────────────────────
|
|
1164
1900
|
|
|
1165
1901
|
/**
|
|
@@ -1215,6 +1951,15 @@ export function apply(ctx: ClientContext): void {
|
|
|
1215
1951
|
),
|
|
1216
1952
|
)
|
|
1217
1953
|
|
|
1954
|
+
// F1–F7: runtime observatory board (order 91, below the dashboard's 90 so
|
|
1955
|
+
// its collapsed header never occludes the main convergence dashboard).
|
|
1956
|
+
slotsSvc.inject('conversation.input.dock', () =>
|
|
1957
|
+
slotsSvc?.register(
|
|
1958
|
+
{ name: 'conversation.input.dock', id: 'iterate-observatory', order: 91 },
|
|
1959
|
+
(props) => React.createElement(ObservatoryPanel, props),
|
|
1960
|
+
),
|
|
1961
|
+
)
|
|
1962
|
+
|
|
1218
1963
|
// 2 + 3: triage panel / stats card (turn-tail chain, session scope).
|
|
1219
1964
|
slotsSvc.inject('conversation.chat.turnTail', () =>
|
|
1220
1965
|
slotsSvc?.register(
|