iterate-plugin 2.11.0 → 2.12.1

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.
@@ -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 six-part UI layer:
20
+ * Implements the §14.4 / §16 UI layer:
21
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
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
 
@@ -364,6 +461,38 @@ const ITERATE_CSS = `
364
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); }
365
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); }
366
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; }
367
496
  `
368
497
 
369
498
  // ─── Small helpers ───────────────────────────────────────────────────────────
@@ -838,6 +967,14 @@ function TriagePanel(props: SlotProps) {
838
967
  const t = ev.target as HTMLElement | null
839
968
  if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return
840
969
  if (t && typeof t.isContentEditable === 'boolean' && t.isContentEditable) return
970
+ // Only act when focus is INSIDE the triage panel. Everything else keeps
971
+ // standard behavior (↑/↓ page scroll, typing in the composer), so we do
972
+ // not hijack document-level keys just because a report is mounted.
973
+ const rootEl = doc.querySelector('[data-iterate="triage"]')
974
+ if (!rootEl) return
975
+ const activeEl = doc.activeElement as HTMLElement | null
976
+ if (!activeEl) return
977
+ if (activeEl !== rootEl && !(typeof rootEl.contains === 'function' && rootEl.contains(activeEl))) return
841
978
  const verdict = keyToVerdict(ev.key)
842
979
  if (verdict && selected !== null && indices.includes(selected)) {
843
980
  ev.preventDefault()
@@ -1248,6 +1385,564 @@ function SettingsPanel(_props: SlotProps) {
1248
1385
  )
1249
1386
  }
1250
1387
 
1388
+ // ─── Runtime observatory panel (F1–F7) ──────────────────────────────────────
1389
+
1390
+ /** Tab id → label for the observatory's eight panels. Live (real-time activity
1391
+ * stream) is first so it is the default view; F-panels follow. */
1392
+ const OBS_TABS = [
1393
+ { key: 'live', label: '实时活动流' },
1394
+ { key: 'f1', label: '审查线程' },
1395
+ { key: 'f2', label: '收敛趋势' },
1396
+ { key: 'f3', label: '发现定位' },
1397
+ { key: 'f4', label: '修复与回滚' },
1398
+ { key: 'f5', label: '断点恢复' },
1399
+ { key: 'f6', label: '运行控制台' },
1400
+ { key: 'f7', label: '决策时间线' },
1401
+ ] as const
1402
+
1403
+ /** Live subagent activity badges: type → Chinese label + dsw token color accent. */
1404
+ const OBS_LIVE_META: Record<string, { label: string; color: string }> = {
1405
+ read: { label: '阅读', color: 'var(--dsw-alias-brand-primary)' },
1406
+ fix: { label: '修复', color: 'var(--dsw-alias-state-success-primary)' },
1407
+ rollback: { label: '回滚', color: 'var(--dsw-alias-state-error-primary)' },
1408
+ diff: { label: '差异', color: 'var(--dsw-alias-state-warn-primary)' },
1409
+ review: { label: '审查', color: 'var(--dsw-alias-brand-primary)' },
1410
+ triage: { label: '分流', color: 'var(--dsw-alias-state-warn-primary)' },
1411
+ checkpoint: { label: '断点', color: 'var(--dsw-alias-brand-primary)' },
1412
+ validate: { label: '校验', color: 'var(--dsw-alias-state-success-primary)' },
1413
+ log: { label: '记录', color: 'var(--dsw-alias-label-secondary)' },
1414
+ prune: { label: '清理', color: 'var(--dsw-alias-state-warn-primary)' },
1415
+ info: { label: '信息', color: 'var(--dsw-alias-label-secondary)' },
1416
+ }
1417
+ const OBS_LIVE_FALLBACK_COLOR = 'var(--dsw-alias-label-secondary)'
1418
+
1419
+ /**
1420
+ * Format a live-event timestamp as DD/MM HH:mm:ss (0-padded). Accepts an epoch
1421
+ * ms number or an ISO-ish string; any unparseable value degrades to ''.
1422
+ */
1423
+ function formatObsLiveTime(ts?: string): string {
1424
+ if (!ts) return ''
1425
+ let date: Date | null = null
1426
+ const num = Number(ts)
1427
+ if (typeof ts === 'string' && ts !== '' && !Number.isNaN(num) && /^\d+$/.test(ts)) {
1428
+ date = new Date(num)
1429
+ } else {
1430
+ const d = new Date(ts)
1431
+ date = Number.isNaN(d.getTime()) ? null : d
1432
+ }
1433
+ if (!date) return String(ts)
1434
+ const p = (n: number) => String(n).padStart(2, '0')
1435
+ const h = p(date.getHours())
1436
+ const mi = p(date.getMinutes())
1437
+ const s = p(date.getSeconds())
1438
+ return `${p(date.getDate())}/${p(date.getMonth() + 1)} ${h}:${mi}:${s}`
1439
+ }
1440
+
1441
+ /** Find the latest runtime-observatory manifest inside a session snapshot. */
1442
+ function latestTranscript(session: unknown): ObsManifest | null {
1443
+ if (!session) return null
1444
+ const raw = scanSessionForTranscript(session)
1445
+ return raw ? normalizeTranscript(raw) as ObsManifest : null
1446
+ }
1447
+
1448
+ /** Build a human-pasteable `iterate_fix` instruction for one finding. */
1449
+ function buildObsFixInstruction(f: ObsFinding): string {
1450
+ const payload = JSON.stringify({
1451
+ file: String(f.file || ''),
1452
+ ...(typeof f.line === 'number' && f.line > 0 ? { line: f.line } : {}),
1453
+ dimension: String(f.dimension || ''),
1454
+ summary: String(f.summary || ''),
1455
+ ...(f.suggested_fix ? { suggested_fix: String(f.suggested_fix) } : {}),
1456
+ }, null, 2)
1457
+ return `请调用 \`iterate_fix\` 修复以下 finding:\n\n\`\`\`json\n${payload}\n\`\`\``
1458
+ }
1459
+
1460
+ /**
1461
+ * ObservatoryPanel: an 8-tab runtime observatory that renders the latest
1462
+ * iterate_transcript manifest found in the dsh session stream. The panel is
1463
+ * collapsed by default to a single entry row so it never re-occludes the main
1464
+ * convergence dashboard; clicking expands it into tabs. Every field read is
1465
+ * defensively guarded (optional chaining + fallbacks) so a partial/malformed
1466
+ * manifest never crashes the slot. The client cannot call harness tools
1467
+ * directly, so every action copies a paste-able instruction text instead.
1468
+ */
1469
+ function ObservatoryPanel(props: SlotProps) {
1470
+ const session = props && props.session ? props.session : null
1471
+ const manifest = latestTranscript(session)
1472
+
1473
+ const [open, setOpen] = React.useState(false)
1474
+ const [tab, setTab] = React.useState('live')
1475
+ const [expandedThreads, setExpandedThreads] = React.useState<Set<string>>(new Set())
1476
+ const [copiedKey, setCopiedKey] = React.useState<string | null>(null)
1477
+ // When a clipboard write is blocked (permissions/unsupported), reveal the
1478
+ // raw instruction text so the user can copy it manually instead of silently
1479
+ // dropping the action (mirrors the triage/settings fallback).
1480
+ const [copyFailText, setCopyFailText] = React.useState<string | null>(null)
1481
+ const [nudgeText, setNudgeText] = React.useState('')
1482
+ const [timelineType, setTimelineType] = React.useState('')
1483
+ const [timelineSearch, setTimelineSearch] = React.useState('')
1484
+
1485
+ // Single shared "copied" flash timer (reused across all copy buttons).
1486
+ const copyTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
1487
+ React.useEffect(() => () => { if (copyTimer.current) clearTimeout(copyTimer.current) }, [])
1488
+
1489
+ /** Copy an instruction, flashing "已复制" on the originating button only when the copy actually succeeded. */
1490
+ const copyInstruction = (key: string, text: string) => {
1491
+ if (!text) return
1492
+ copyText(text).then((ok) => {
1493
+ if (!ok) { setCopyFailText(text); return }
1494
+ setCopiedKey(key)
1495
+ if (copyTimer.current) clearTimeout(copyTimer.current)
1496
+ copyTimer.current = setTimeout(() => setCopiedKey((cur) => (cur === key ? null : cur)), 1600)
1497
+ })
1498
+ }
1499
+
1500
+ const toggleThread = (key: string) => {
1501
+ setExpandedThreads((prev) => {
1502
+ const next = new Set(prev)
1503
+ if (next.has(key)) next.delete(key); else next.add(key)
1504
+ return next
1505
+ })
1506
+ }
1507
+
1508
+ // No observable transcript yet: show a compact, collapsed header row.
1509
+ if (!manifest) {
1510
+ return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'obs', className: 'iterate-obs' },
1511
+ React.createElement('div', { className: 'iterate-obs-head', 'data-closed': '' },
1512
+ React.createElement('span', { className: 'iterate-obs-title' }, 'iterate 观测台'),
1513
+ React.createElement('span', { className: 'iterate-obs-head-meta' }, '暂无运行时观测数据'),
1514
+ ),
1515
+ )
1516
+ }
1517
+
1518
+ const live = manifest.active === true
1519
+ const roundMeta = typeof manifest.round === 'number'
1520
+ ? (typeof manifest.maxRounds === 'number' ? `Round ${manifest.round}/${manifest.maxRounds}` : `Round ${manifest.round}`)
1521
+ : ''
1522
+ const headMeta = [manifest.mode || '', roundMeta, manifest.updatedAt || ''].filter(Boolean).join(' · ')
1523
+
1524
+ // ── F1: review threads ─────────────────────────────────────────────────
1525
+ const threadFindingPill = (f: ObsFinding, keyBase: string) => {
1526
+ const loc = `${String(f.file || '?')}${typeof f.line === 'number' && f.line > 0 ? `:${f.line}` : ''}`
1527
+ return React.createElement('div', { key: keyBase, className: 'iterate-obs-row' },
1528
+ React.createElement('span', { className: 'iterate-sev-dot', style: { background: severityColor(f.severity) } }),
1529
+ React.createElement('span', {}, severityLabel(f.severity)),
1530
+ React.createElement('span', { className: 'iterate-obs-file' }, loc),
1531
+ React.createElement('span', {}, String(f.dimension || '')),
1532
+ React.createElement('span', { className: 'iterate-obs-msg' }, String(f.summary || '')),
1533
+ )
1534
+ }
1535
+
1536
+ const renderThreads = () => {
1537
+ const rounds = manifest.rounds || []
1538
+ if (rounds.length === 0) {
1539
+ return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无审查线程')
1540
+ }
1541
+ return React.createElement('div', {}, ...rounds.map((r, ri) => {
1542
+ const threads = r.threads || []
1543
+ const fCount = threads.reduce((sum, t) => sum + (t.findings ? t.findings.length : 0), 0)
1544
+ const threadBlocks = threads.length === 0
1545
+ ? [React.createElement('div', { key: 'none', className: 'iterate-obs-empty' }, '本轮无线程')]
1546
+ : threads.map((t, ti) => {
1547
+ const key = `${ri}-${ti}`
1548
+ const expanded = expandedThreads.has(key)
1549
+ const dim = t.dimension || '未命名维度'
1550
+ const tFindings = t.findings || []
1551
+ const files = t.readFiles || []
1552
+ const msgs = t.messages || []
1553
+ return React.createElement('div', { key, className: 'iterate-obs-block' },
1554
+ React.createElement('div', { className: 'iterate-obs-block-head', 'data-click': '', onClick: () => toggleThread(key), title: expanded ? '收起线程' : '展开线程' },
1555
+ React.createElement('span', {}, expanded ? '−' : '+'),
1556
+ React.createElement('b', {}, dim),
1557
+ React.createElement('span', { className: 'iterate-obs-chip' }, `attempt ${typeof t.attempt === 'number' ? t.attempt : '?'}`),
1558
+ React.createElement('span', { className: 'iterate-obs-chip' }, `${tFindings.length} findings`),
1559
+ ),
1560
+ expanded
1561
+ ? React.createElement('div', { className: 'iterate-obs-block-body' },
1562
+ React.createElement('div', { className: 'iterate-obs-bar' },
1563
+ React.createElement('b', {}, '叙述'),
1564
+ React.createElement('span', { className: 'iterate-obs-head-meta' }, `${msgs.length} 条`),
1565
+ ),
1566
+ ...(msgs.length === 0
1567
+ ? [React.createElement('div', { key: 'msg-none', className: 'iterate-obs-empty' }, '无线索叙述')]
1568
+ : msgs.map((m, mi) => React.createElement('div', { key: mi, className: 'iterate-obs-msg' }, String(m)))),
1569
+ ...(files.length > 0
1570
+ ? [
1571
+ React.createElement('div', { key: 'files-h', className: 'iterate-obs-bar', style: { marginTop: 6 } },
1572
+ React.createElement('b', {}, '读取文件'),
1573
+ ),
1574
+ ...files.map((f, fi) =>
1575
+ React.createElement('div', { key: `f${fi}`, className: 'iterate-obs-file' }, String(f)),
1576
+ ),
1577
+ ]
1578
+ : []),
1579
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 6 } },
1580
+ React.createElement('b', {}, '发现'),
1581
+ ),
1582
+ ...(tFindings.length === 0
1583
+ ? [React.createElement('div', { key: 'finding-none', className: 'iterate-obs-empty' }, '本线程无发现')]
1584
+ : tFindings.map((f, fi) => threadFindingPill(f, `tf${fi}`))),
1585
+ )
1586
+ : null,
1587
+ )
1588
+ })
1589
+ return React.createElement('div', { key: ri, className: 'iterate-obs-block' },
1590
+ React.createElement('div', { className: 'iterate-obs-block-head' },
1591
+ React.createElement('b', {}, `Round ${typeof r.round === 'number' ? r.round : ri + 1}`),
1592
+ React.createElement('span', { className: 'iterate-obs-chip' }, `${threads.length} 线程`),
1593
+ React.createElement('span', { className: 'iterate-obs-head-meta' }, `${fCount} findings`),
1594
+ ),
1595
+ React.createElement('div', { className: 'iterate-obs-block-body' }, ...threadBlocks),
1596
+ )
1597
+ }))
1598
+ }
1599
+
1600
+ // ── F2: convergence trend (inline bars, reusing TrendChart) ─────────────
1601
+ const renderTrend = () => {
1602
+ const conv = manifest.convergence || []
1603
+ if (conv.length === 0) {
1604
+ return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无收敛数据')
1605
+ }
1606
+ const points = conv.map((n, i) => ({ round: i + 1, count: n }))
1607
+ return React.createElement('div', {},
1608
+ React.createElement(TrendChart, { points }),
1609
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 6 } },
1610
+ `各轮发现数量:${conv.join(' → ')}${typeof manifest.round === 'number' ? ` · 当前 Round ${manifest.round}` : ''}`,
1611
+ ),
1612
+ )
1613
+ }
1614
+
1615
+ // ── F3: finding location + action copy buttons ─────────────────────────
1616
+ const renderFindings = () => {
1617
+ const findings = manifest.findings || []
1618
+ if (findings.length === 0) {
1619
+ return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无发现')
1620
+ }
1621
+ return React.createElement('div', {}, ...findings.map((f, i) => {
1622
+ const k = `f3-${i}`
1623
+ const loc = `${String(f.file || '?')}${typeof f.line === 'number' && f.line > 0 ? `:${f.line}` : ''}`
1624
+ const entry = {
1625
+ file: String(f.file || ''),
1626
+ ...(typeof f.line === 'number' && f.line > 0 ? { line: f.line } : {}),
1627
+ dimension: String(f.dimension || ''),
1628
+ reason: String(f.summary || ''),
1629
+ }
1630
+ const knownInstruction = buildApplyInstruction([entry])
1631
+ const fixInstruction = buildObsFixInstruction(f)
1632
+ return React.createElement('div', { key: k, className: 'iterate-obs-block' },
1633
+ React.createElement('div', { className: 'iterate-obs-block-head' },
1634
+ React.createElement('span', { className: 'iterate-sev-dot', style: { background: severityColor(f.severity) } }),
1635
+ React.createElement('span', {}, severityLabel(f.severity)),
1636
+ React.createElement('span', { className: 'iterate-obs-file' }, loc),
1637
+ React.createElement('span', {}, String(f.dimension || '')),
1638
+ f.acknowledged === true
1639
+ ? React.createElement('span', { className: 'iterate-obs-chip' }, '已确认')
1640
+ : null,
1641
+ ),
1642
+ React.createElement('div', { className: 'iterate-obs-block-body' },
1643
+ React.createElement('div', { className: 'iterate-obs-msg' }, String(f.summary || '')),
1644
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 8 } },
1645
+ React.createElement('button', {
1646
+ className: 'iterate-btn', 'data-copied': copiedKey === `${k}-fix` ? '' : undefined,
1647
+ onClick: () => copyInstruction(`${k}-fix`, String(f.suggested_fix || '')),
1648
+ title: '复制该 finding 的建议修复文本',
1649
+ }, copiedKey === `${k}-fix` ? '已复制' : '复制 suggested_fix'),
1650
+ React.createElement('button', {
1651
+ className: 'iterate-btn', 'data-copied': copiedKey === `${k}-known` ? '' : undefined,
1652
+ onClick: () => copyInstruction(`${k}-known`, knownInstruction),
1653
+ title: '复制 iterate_triage 指令文本(标记为 known_intentional)',
1654
+ }, copiedKey === `${k}-known` ? '已复制' : 'known_intentional'),
1655
+ React.createElement('button', {
1656
+ className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === `${k}-repair` ? '' : undefined,
1657
+ onClick: () => copyInstruction(`${k}-repair`, fixInstruction),
1658
+ title: '复制 iterate_fix 指令文本',
1659
+ }, copiedKey === `${k}-repair` ? '已复制' : '复制修复指令'),
1660
+ ),
1661
+ ),
1662
+ )
1663
+ }))
1664
+ }
1665
+
1666
+ // ── F4: fixes + rollback ───────────────────────────────────────────────
1667
+ const renderFixes = () => {
1668
+ const fixes = manifest.fixes || []
1669
+ if (fixes.length === 0) {
1670
+ return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无修复记录')
1671
+ }
1672
+ return React.createElement('div', {}, ...fixes.map((f, i) => {
1673
+ const k = `f4-${i}`
1674
+ const id = String(f.id || `fix#${i + 1}`)
1675
+ const rollbackText = `请调用 \`iterate_rollback\` 回滚以下修复:\n\n\`\`\`json\n${JSON.stringify({ id }, null, 2)}\n\`\`\``
1676
+ const added = typeof f.linesAdded === 'number' ? f.linesAdded : 0
1677
+ const removed = typeof f.linesRemoved === 'number' ? f.linesRemoved : 0
1678
+ return React.createElement('div', { key: k, className: 'iterate-obs-block' },
1679
+ React.createElement('div', { className: 'iterate-obs-block-head' },
1680
+ React.createElement('b', {}, id),
1681
+ React.createElement('span', { className: 'iterate-obs-file' }, String(f.file || '?')),
1682
+ React.createElement('span', { className: 'iterate-obs-chip' }, f.success === true ? '成功' : '失败'),
1683
+ typeof f.round === 'number'
1684
+ ? React.createElement('span', { className: 'iterate-obs-head-meta' }, `Round ${f.round}`)
1685
+ : null,
1686
+ ),
1687
+ React.createElement('div', { className: 'iterate-obs-block-body' },
1688
+ React.createElement('div', { className: 'iterate-obs-msg' }, String(f.summary || '')),
1689
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 8 } },
1690
+ React.createElement('span', { className: 'iterate-obs-chip' }, `+${added}`),
1691
+ React.createElement('span', { className: 'iterate-obs-chip' }, `−${removed}`),
1692
+ React.createElement('button', {
1693
+ className: 'iterate-btn', 'data-danger': '', 'data-copied': copiedKey === `${k}-rb` ? '' : undefined,
1694
+ onClick: () => copyInstruction(`${k}-rb`, rollbackText),
1695
+ title: '复制 iterate_rollback 指令文本',
1696
+ }, copiedKey === `${k}-rb` ? '已复制' : '回滚'),
1697
+ ),
1698
+ ),
1699
+ )
1700
+ }))
1701
+ }
1702
+
1703
+ // ── F5: checkpoint resume ──────────────────────────────────────────────
1704
+ const renderCheckpoint = () => {
1705
+ const cp = manifest.checkpoint
1706
+ if (!cp) {
1707
+ return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无断点')
1708
+ }
1709
+ const resumeText = `请调用 \`iterate_checkpoint\` 从断点恢复迭代:\n\n\`\`\`json\n${JSON.stringify({
1710
+ operation: 'resume',
1711
+ mode: String(cp.mode || 'normal'),
1712
+ maxRounds: typeof cp.maxRounds === 'number' ? cp.maxRounds : null,
1713
+ }, null, 2)}\n\`\`\``
1714
+ const item = (label: string, value: unknown) =>
1715
+ React.createElement('span', { className: 'iterate-obs-chip' }, `${label} ${String(value ?? '?')}`)
1716
+ return React.createElement('div', { className: 'iterate-obs-block' },
1717
+ React.createElement('div', { className: 'iterate-obs-block-head' },
1718
+ React.createElement('b', {}, '断点'),
1719
+ React.createElement('span', { className: 'iterate-obs-head-meta' }, String(cp.updatedAt || '')),
1720
+ ),
1721
+ React.createElement('div', { className: 'iterate-obs-block-body' },
1722
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
1723
+ item('mode', cp.mode || 'normal'),
1724
+ item('round', cp.round),
1725
+ item('maxRounds', cp.maxRounds),
1726
+ item('fixed', cp.fixedCount),
1727
+ item('resume', cp.resumeCount),
1728
+ ),
1729
+ React.createElement('button', {
1730
+ className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'cp-resume' ? '' : undefined,
1731
+ onClick: () => copyInstruction('cp-resume', resumeText),
1732
+ title: '复制 iterate_checkpoint 综/恢复指令文本',
1733
+ }, copiedKey === 'cp-resume' ? '已复制' : '复制恢复指令'),
1734
+ ),
1735
+ )
1736
+ }
1737
+
1738
+ // ── F6: run console (status / approval / nudge draft) ──────────────────
1739
+ const renderConsole = () => {
1740
+ const approval = manifest.approval || { policy: 'ask' }
1741
+ const policyLabelMap: Record<string, string> = { ask: '询问用户', deny: '拒绝', allow: '直接执行' }
1742
+ const policyLabel = policyLabelMap[String(approval.policy || 'ask')] || String(approval.policy || 'ask')
1743
+ const nudge = manifest.nudge || null
1744
+ const present = Boolean(nudge && nudge.text)
1745
+ const activeNudgeText = nudge && nudge.text ? nudge.text : ''
1746
+ const nudgeInstruction = `请调用 \`iterate_transcript\` 写入 nudge 指令:\n\n\`\`\`json\n${JSON.stringify({ operation: 'nudge', text: nudgeText }, null, 2)}\n\`\`\``
1747
+ // Clearing the PERSISTED nudge (as opposed to the draft) requires
1748
+ // text:null. Copied as a paste-able instruction, mirroring the panel's
1749
+ // copy-to-command pattern — it cannot be cleared from the client directly.
1750
+ const activeClearInstruction = `请调用 \`iterate_transcript\` 清除当前 nudge:\n\n\`\`\`json\n${JSON.stringify({ operation: 'nudge', text: null }, null, 2)}\n\`\`\``
1751
+ return React.createElement('div', { className: 'iterate-obs-block' },
1752
+ React.createElement('div', { className: 'iterate-obs-block-head' },
1753
+ React.createElement('span', {}, '运行控制台'),
1754
+ React.createElement('span', { className: 'iterate-obs-badge', 'data-live': live ? '' : undefined }, live ? '运行中' : '已结束'),
1755
+ React.createElement('span', { className: 'iterate-obs-head-meta' }, `批准策略:${policyLabel}`),
1756
+ ),
1757
+ React.createElement('div', { className: 'iterate-obs-block-body' },
1758
+ present
1759
+ ? React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 6 } },
1760
+ React.createElement('b', {}, '当前 nudge'),
1761
+ React.createElement('span', { className: 'iterate-obs-msg' }, activeNudgeText),
1762
+ React.createElement('button', {
1763
+ className: 'iterate-btn', 'data-copied': copiedKey === 'nudge-clear' ? '' : undefined,
1764
+ onClick: () => copyInstruction('nudge-clear', activeClearInstruction),
1765
+ title: '复制清除 nudge 指令(写 text:null),在运行控制台提示清除已持久化的 nudge',
1766
+ }, copiedKey === 'nudge-clear' ? '已复制清除指令' : '复制清除指令'),
1767
+ )
1768
+ : null,
1769
+ React.createElement('textarea', {
1770
+ className: 'iterate-obs-input iterate-obs-mono',
1771
+ rows: 3,
1772
+ placeholder: '起草一条 nudge 方向指令…',
1773
+ value: nudgeText,
1774
+ 'aria-label': 'nudge 草稿',
1775
+ onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => setNudgeText(e.target.value),
1776
+ }),
1777
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 6 } },
1778
+ React.createElement('button', {
1779
+ className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'nudge' ? '' : undefined,
1780
+ disabled: !nudgeText,
1781
+ onClick: () => copyInstruction('nudge', nudgeInstruction),
1782
+ title: '复制 iterate_transcript nudge 指令文本',
1783
+ }, copiedKey === 'nudge' ? '已复制' : '复制 nudge 指令'),
1784
+ ),
1785
+ ),
1786
+ )
1787
+ }
1788
+
1789
+ // ── F7: decision timeline (type filter + search, newest first) ──────────
1790
+ const renderTimeline = () => {
1791
+ const entries = manifest.timeline || []
1792
+ if (entries.length === 0) {
1793
+ return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无时间线条目')
1794
+ }
1795
+ const types = Array.from(new Set(entries.map((t) => String(t.type || 'unknown')).filter(Boolean))).sort()
1796
+ const q = timelineSearch.trim().toLowerCase()
1797
+ const filtered = entries.filter((t) => {
1798
+ if (timelineType && String(t.type || '') !== timelineType) return false
1799
+ if (!q) return true
1800
+ const hay = [String(t.type || ''), String(t.round ?? ''), JSON.stringify(t.data || {})].join(' ').toLowerCase()
1801
+ return hay.indexOf(q) >= 0
1802
+ })
1803
+ // Newest first: entries are not guaranteed to be reverse ordered in the
1804
+ // manifest, so sort by timestamp string descending for a stable timeline.
1805
+ const sorted = filtered.slice().sort((a, b) => String(b.timestamp || '').localeCompare(String(a.timestamp || '')))
1806
+ const rows = sorted.map((t, i) => {
1807
+ const k = `f7-${i}`
1808
+ const dataText = t.data && typeof t.data === 'object' ? JSON.stringify(t.data) : ''
1809
+ return React.createElement('div', { key: k, className: 'iterate-obs-row', style: { flexDirection: 'column', alignItems: 'flex-start' } },
1810
+ React.createElement('div', { className: 'iterate-obs-bar', style: { width: '100%' } },
1811
+ React.createElement('span', { className: 'iterate-obs-chip' }, String(t.type || '?')),
1812
+ React.createElement('span', { className: 'iterate-obs-head-meta' },
1813
+ `${typeof t.round === 'number' ? `Round ${t.round} · ` : ''}${String(t.timestamp || '')}`,
1814
+ ),
1815
+ ),
1816
+ dataText ? React.createElement('div', { className: 'iterate-obs-code' }, dataText) : null,
1817
+ )
1818
+ })
1819
+ if (rows.length === 0) {
1820
+ return React.createElement('div', { className: 'iterate-obs-empty' }, '无匹配的时间线条目')
1821
+ }
1822
+ return React.createElement('div', {},
1823
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
1824
+ React.createElement('select', {
1825
+ className: 'iterate-filter-select',
1826
+ value: timelineType,
1827
+ 'aria-label': '按时间线类型筛选',
1828
+ onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setTimelineType(e.target.value),
1829
+ },
1830
+ React.createElement('option', { value: '' }, '全部类型'),
1831
+ ...types.map((t) => React.createElement('option', { key: t, value: t }, t)),
1832
+ ),
1833
+ React.createElement('input', {
1834
+ className: 'iterate-filter-search',
1835
+ type: 'search',
1836
+ placeholder: '搜索时间线…',
1837
+ 'aria-label': '搜索时间线',
1838
+ value: timelineSearch,
1839
+ onChange: (e: React.ChangeEvent<HTMLInputElement>) => setTimelineSearch(e.target.value),
1840
+ }),
1841
+ React.createElement('span', { className: 'iterate-filter-count' }, `${filtered.length}/${entries.length}`),
1842
+ ),
1843
+ ...rows,
1844
+ )
1845
+ }
1846
+
1847
+ // ── Live: real-time secondary-subagent activity stream (newest first) ─────
1848
+ const renderLive = () => {
1849
+ const liveEntries = manifest.live || []
1850
+ if (liveEntries.length === 0) {
1851
+ // Live entries only accumulate during an ACTIVE run. For a completed run
1852
+ // (or before any capture) this tab is empty — guide the user to the
1853
+ // populated summary tabs instead of leaving a dead-end "暂无实时活动" state.
1854
+ const hasThreads = (manifest.rounds || []).length > 0
1855
+ const hasFindings = (manifest.findings || []).length > 0
1856
+ if (hasThreads || hasFindings) {
1857
+ return React.createElement('div', { className: 'iterate-obs-empty' },
1858
+ React.createElement('div', {}, '实时活动流仅在运行期间记录,当前已完成。'),
1859
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 8 } },
1860
+ React.createElement('button', { className: 'iterate-btn', onClick: () => setTab('f1') }, '查看审查线程'),
1861
+ React.createElement('button', { className: 'iterate-btn', onClick: () => setTab('f3') }, '查看发现'),
1862
+ ),
1863
+ )
1864
+ }
1865
+ return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无实时活动')
1866
+ }
1867
+ const rows = liveEntries.map((e, i) => {
1868
+ const type = String(e.type || 'unknown')
1869
+ const meta = OBS_LIVE_META[type] || { label: String(e.type || '?'), color: OBS_LIVE_FALLBACK_COLOR }
1870
+ const tool = String(e.tool || '')
1871
+ const target = String(e.target || '')
1872
+ const time = formatObsLiveTime(e.ts)
1873
+ return React.createElement('div', { key: `live-${i}`, className: 'iterate-obs-row' },
1874
+ React.createElement('span', { className: 'iterate-obs-livebadge', style: { color: meta.color } }, meta.label),
1875
+ tool
1876
+ ? React.createElement('span', { className: 'iterate-obs-chip' }, tool)
1877
+ : null,
1878
+ React.createElement('span', { className: 'iterate-obs-msg' }, target || '—'),
1879
+ React.createElement('span', { className: 'iterate-obs-head-meta' }, time),
1880
+ )
1881
+ })
1882
+ return React.createElement('div', {},
1883
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
1884
+ React.createElement('b', {}, '实时活动'),
1885
+ React.createElement('span', { className: 'iterate-obs-chip' }, `${liveEntries.length} 项`),
1886
+ ),
1887
+ ...rows,
1888
+ )
1889
+ }
1890
+
1891
+ const renderBody = () => {
1892
+ switch (tab) {
1893
+ case 'live': return renderLive()
1894
+ case 'f1': return renderThreads()
1895
+ case 'f2': return renderTrend()
1896
+ case 'f3': return renderFindings()
1897
+ case 'f4': return renderFixes()
1898
+ case 'f5': return renderCheckpoint()
1899
+ case 'f6': return renderConsole()
1900
+ case 'f7': return renderTimeline()
1901
+ default: return null
1902
+ }
1903
+ }
1904
+
1905
+ return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'obs', className: 'iterate-obs' },
1906
+ React.createElement('div', { className: 'iterate-obs-head', 'data-closed': open ? undefined : '', onClick: () => setOpen((v) => !v) },
1907
+ React.createElement('span', { className: 'iterate-obs-title' }, 'iterate 观测台'),
1908
+ React.createElement('span', { className: 'iterate-obs-badge', 'data-live': live ? '' : undefined }, live ? '运行中' : '已结束'),
1909
+ React.createElement('span', { className: 'iterate-obs-head-meta' }, headMeta || 'runtime'),
1910
+ React.createElement('button', {
1911
+ className: 'iterate-btn', 'data-ghost': '',
1912
+ onClick: (e: React.MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); setOpen((v) => !v) },
1913
+ }, open ? '收起' : '展开'),
1914
+ ),
1915
+ open
1916
+ ? React.createElement('div', {},
1917
+ copyFailText
1918
+ ? React.createElement('div', { className: 'iterate-obs-block' },
1919
+ React.createElement('div', { className: 'iterate-obs-block-head' },
1920
+ React.createElement('b', {}, '复制未成功'),
1921
+ React.createElement('span', { className: 'iterate-obs-head-meta' }, '剪贴板写入被拒绝,请手动选中下方指令复制'),
1922
+ React.createElement('button', {
1923
+ className: 'iterate-btn', 'data-ghost': '', 'aria-label': '关闭复制失败提示',
1924
+ onClick: () => setCopyFailText(null),
1925
+ }, '关闭'),
1926
+ ),
1927
+ React.createElement('div', { className: 'iterate-obs-code' }, copyFailText),
1928
+ )
1929
+ : null,
1930
+ React.createElement('div', { className: 'iterate-obs-tabs' },
1931
+ ...OBS_TABS.map((t) =>
1932
+ React.createElement('button', {
1933
+ key: t.key,
1934
+ className: 'iterate-obs-tab',
1935
+ 'data-active': tab === t.key ? '' : undefined,
1936
+ onClick: () => setTab(t.key),
1937
+ }, t.label),
1938
+ ),
1939
+ ),
1940
+ React.createElement('div', { className: 'iterate-obs-body' }, renderBody()),
1941
+ )
1942
+ : null,
1943
+ )
1944
+ }
1945
+
1251
1946
  // ─── Registration ────────────────────────────────────────────────────────────
1252
1947
 
1253
1948
  /**
@@ -1303,6 +1998,15 @@ export function apply(ctx: ClientContext): void {
1303
1998
  ),
1304
1999
  )
1305
2000
 
2001
+ // F1–F7: runtime observatory board (order 91, below the dashboard's 90 so
2002
+ // its collapsed header never occludes the main convergence dashboard).
2003
+ slotsSvc.inject('conversation.input.dock', () =>
2004
+ slotsSvc?.register(
2005
+ { name: 'conversation.input.dock', id: 'iterate-observatory', order: 91 },
2006
+ (props) => React.createElement(ObservatoryPanel, props),
2007
+ ),
2008
+ )
2009
+
1306
2010
  // 2 + 3: triage panel / stats card (turn-tail chain, session scope).
1307
2011
  slotsSvc.inject('conversation.chat.turnTail', () =>
1308
2012
  slotsSvc?.register(