iterate-plugin 3.12.0 → 3.14.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.
@@ -0,0 +1,289 @@
1
+ /**
2
+ * src/client/context-menu.ts — iterate-plugin right-click menu contributions.
3
+ *
4
+ * dsh 0.2.2-alpha.3 added the *unified context-menu* capability
5
+ * (`@deepseek-ai/dsh-client-ui-context-menu`): any client plugin may register
6
+ * actions that appear on right-click / Shift+F10 / ContextMenu key over a
7
+ * Session, file, directory, URL, image, tab, or workspace, without importing
8
+ * a React component. The captured target stays fixed even when selection
9
+ * changes, and contributions are withdrawn automatically when the plugin
10
+ * unloads.
11
+ *
12
+ * This module turns that new upstream capability into a *user-perceivable*
13
+ * iterate surface: instead of only being reachable through the docked
14
+ * observatory and the empty-dashboard CTA, the iterate entry points now also
15
+ * hang off the native right-click menus — on a Session (open the quality
16
+ * command center, copy a preflight / dry-run / resume / disk-snapshot
17
+ * instruction), on a file (dry-run review scoped to that file), and on a
18
+ * directory (scoped review + preflight).
19
+ *
20
+ * Design (mirrors the rest of the client half):
21
+ * - **Defensive**: the `contextMenus` service is optional and probed at
22
+ * apply-time; a host that predates 0.2.2-alpha.3 simply has no menu
23
+ * entries (the docked panel remains the entry point). Nothing throws.
24
+ * - **Pure builders**: `buildSessionMenuEntries` / `buildFileMenuEntries` /
25
+ * `buildDirectoryMenuEntries` return *localized descriptors* with no DOM
26
+ * or clipboard side effects, so they are unit-testable on their own
27
+ * (normal + empty + boundary). The imperative wiring (`copy` / `openPanel`
28
+ * / `notify`) is injected by `registerIterateContextMenus`.
29
+ * - **Bilingual**: every label carries zh + en and resolves against the
30
+ * language passed in (the caller reads `getLang()` at menu-build time, so
31
+ * a language switch is picked up on the next open).
32
+ *
33
+ * Event bridge: the observatory is a React panel with its own open/tab state.
34
+ * The browser client cannot call harness tools, so the "open command center"
35
+ * menu action dispatches a window CustomEvent (`OBS_OPEN_EVENT`) that the
36
+ * ObservatoryPanel listens for — the panel expands and switches tab without
37
+ * any cross-plugin import. The event carries an optional `sessionId` so a
38
+ * right-click in the session sidebar targets the right session's panel and is
39
+ * ignored by every other mounted panel.
40
+ */
41
+
42
+ import { getLang, type Lang } from './i18n.ts'
43
+
44
+ // ─── Structural service types (defensive; mirrors SlotsService) ──────────────
45
+ //
46
+ // The dsh client runtime is not type-imported (no compile-time coupling to a
47
+ // package a pre-0.2.2-alpha.3 host may not have). The surface below is the
48
+ // subset this plugin consumes; `register` is only ever called when a service
49
+ // with the right shape is present.
50
+
51
+ /** A right-click target this plugin contributes actions to. */
52
+ export type IterateMenuTarget =
53
+ | { kind: 'session'; value: { sessionId: string; displayTitle?: string } }
54
+ | { kind: 'file'; value: { sessionId: string; path: string; line?: number } }
55
+ | { kind: 'directory'; value: { sessionId: string; path: string } }
56
+
57
+ /** One menu action (presentation + optional execution). */
58
+ export interface IterateMenuAction {
59
+ id: string
60
+ label: string
61
+ disabled?: boolean
62
+ danger?: boolean
63
+ separatorBefore?: boolean
64
+ /** Localized operation + recovery guidance when `run` rejects. */
65
+ failureLabel?: string
66
+ run?: () => void | Promise<void>
67
+ }
68
+
69
+ /** A namespaced contribution, ordered within its target family. */
70
+ export interface IterateMenuContribution<K extends string> {
71
+ id: string
72
+ target: K
73
+ order: number
74
+ resolve(target: unknown, signal: AbortSignal): readonly IterateMenuAction[]
75
+ }
76
+
77
+ /** Structural view of the dsh `contextMenus` service this plugin consumes. */
78
+ export interface ContextMenusService {
79
+ register<K extends string>(contribution: IterateMenuContribution<K>): () => void
80
+ notify(
81
+ text: string,
82
+ success?: boolean,
83
+ action?: { label: string; run: () => void | Promise<void>; failureLabel: string },
84
+ ): void
85
+ }
86
+
87
+ // ─── Observatory open event bridge ───────────────────────────────────────────
88
+
89
+ /** Window event that asks the (matching) ObservatoryPanel to expand. */
90
+ export const OBS_OPEN_EVENT = 'iterate-plugin:open-observatory'
91
+
92
+ /** Payload carried by {@link OBS_OPEN_EVENT}. */
93
+ export interface ObsOpenDetail {
94
+ /** Optional target session; omitted = open whatever panel is mounted. */
95
+ sessionId?: string
96
+ /** Tab key to switch to (`live` by default). */
97
+ tab?: string
98
+ }
99
+
100
+ /**
101
+ * Dispatch the "open observatory" event. No-op outside a browser (SSR / test
102
+ * environments without `window`). Returns whether the event was dispatched.
103
+ */
104
+ export function openObservatory(detail?: ObsOpenDetail): boolean {
105
+ if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') return false
106
+ try {
107
+ window.dispatchEvent(new CustomEvent<ObsOpenDetail>(OBS_OPEN_EVENT, { detail: detail ?? {} }))
108
+ return true
109
+ } catch {
110
+ return false
111
+ }
112
+ }
113
+
114
+ // ─── Localized descriptors (pure, testable) ──────────────────────────────────
115
+
116
+ /** The action ids this plugin registers, for tests + duplicate guards. */
117
+ export const SESSION_MENU_IDS = ['iterate.open-panel', 'iterate.preflight', 'iterate.dry-run', 'iterate.resume', 'iterate.pull'] as const
118
+ export const FILE_MENU_IDS = ['iterate.review-file', 'iterate.open-panel'] as const
119
+ export const DIRECTORY_MENU_IDS = ['iterate.review-dir', 'iterate.preflight', 'iterate.open-panel'] as const
120
+
121
+ interface MenuEntry {
122
+ id: string
123
+ label: string
124
+ instruction?: string
125
+ /** Tab key to open when this entry is the "open panel" action. */
126
+ openTab?: string
127
+ danger?: boolean
128
+ disabled?: boolean
129
+ separatorBefore?: boolean
130
+ }
131
+
132
+ /** Bilingual labels; resolved by `pick` at build time. */
133
+ const LABELS: Record<string, { zh: string; en: string }> = {
134
+ 'iterate.open-panel': { zh: '打开 iterate 质量指挥中心', en: 'Open iterate command center' },
135
+ 'iterate.preflight': { zh: '复制开跑预检指令', en: 'Copy preflight instruction' },
136
+ 'iterate.dry-run': { zh: '复制 dry-run 审查指令', en: 'Copy dry-run review instruction' },
137
+ 'iterate.resume': { zh: '复制断点恢复指令', en: 'Copy checkpoint-resume instruction' },
138
+ 'iterate.pull': { zh: '复制拉取磁盘快照指令', en: 'Copy disk-snapshot pull instruction' },
139
+ 'iterate.review-file': { zh: '复制「仅审查此文件」指令', en: 'Copy "review this file only" instruction' },
140
+ 'iterate.review-dir': { zh: '复制「仅审查此目录」指令', en: 'Copy "review this directory only" instruction' },
141
+ }
142
+
143
+ /** Pick a localized label; unknown ids degrade to the raw id (never throw). */
144
+ function pick(id: string, lang: Lang): string {
145
+ const entry = LABELS[id]
146
+ if (!entry) return id
147
+ return lang === 'zh' ? entry.zh : entry.en
148
+ }
149
+
150
+ /** Copy-failure guidance (a menu action's `failureLabel`). */
151
+ const COPY_FAILURE: Record<'zh' | 'en', string> = {
152
+ zh: '复制失败:剪贴板被拒绝。请改为在观测台面板里用同一指令的「复制」按钮,或手动选中指令文本复制。',
153
+ en: 'Copy failed: clipboard denied. Use the matching "Copy" button in the observatory panel, or select the instruction text manually.',
154
+ }
155
+
156
+ /** Build the Session target menu entries (pure). */
157
+ export function buildSessionMenuEntries(lang: Lang, opts?: { hasCheckpoint?: boolean }): MenuEntry[] {
158
+ return [
159
+ { id: 'iterate.open-panel', label: pick('iterate.open-panel', lang), openTab: 'live' },
160
+ { id: 'iterate.preflight', label: pick('iterate.preflight', lang), separatorBefore: true },
161
+ { id: 'iterate.dry-run', label: pick('iterate.dry-run', lang) },
162
+ { id: 'iterate.resume', label: pick('iterate.resume', lang), disabled: opts?.hasCheckpoint === false },
163
+ { id: 'iterate.pull', label: pick('iterate.pull', lang), separatorBefore: true },
164
+ ]
165
+ }
166
+
167
+ /** Build the File target menu entries (pure). */
168
+ export function buildFileMenuEntries(lang: Lang): MenuEntry[] {
169
+ return [
170
+ { id: 'iterate.review-file', label: pick('iterate.review-file', lang) },
171
+ { id: 'iterate.open-panel', label: pick('iterate.open-panel', lang) },
172
+ ]
173
+ }
174
+
175
+ /** Build the Directory target menu entries (pure). */
176
+ export function buildDirectoryMenuEntries(lang: Lang): MenuEntry[] {
177
+ return [
178
+ { id: 'iterate.review-dir', label: pick('iterate.review-dir', lang) },
179
+ { id: 'iterate.preflight', label: pick('iterate.preflight', lang), separatorBefore: true },
180
+ { id: 'iterate.open-panel', label: pick('iterate.open-panel', lang) },
181
+ ]
182
+ }
183
+
184
+ // ─── Imperative wiring (register + run callbacks) ────────────────────────────
185
+
186
+ /** Injection points the menu wiring needs (kept dependency-light for tests). */
187
+ export interface IterateMenuDeps {
188
+ /** Copy an instruction; resolves to whether the clipboard write succeeded. */
189
+ copy: (text: string) => Promise<boolean>
190
+ /** Open the observatory panel (optionally for a session, on a tab). */
191
+ openPanel: (detail?: ObsOpenDetail) => void
192
+ /** Show the shared toast. */
193
+ notify: (text: string, success?: boolean, action?: { label: string; run: () => void | Promise<void>; failureLabel: string }) => void
194
+ }
195
+
196
+ /** Map a pure entry to a runnable menu action, wiring the injected deps. */
197
+ function toAction(entry: MenuEntry, deps: IterateMenuDeps, lang: Lang): IterateMenuAction {
198
+ const action: IterateMenuAction = {
199
+ id: entry.id,
200
+ label: entry.label,
201
+ danger: entry.danger,
202
+ separatorBefore: entry.separatorBefore,
203
+ }
204
+ if (entry.disabled) action.disabled = true
205
+ if (entry.openTab) {
206
+ action.run = () => {
207
+ deps.openPanel({ tab: entry.openTab })
208
+ deps.notify(lang === 'zh' ? '已打开 iterate 观测台。' : 'Iterate observatory opened.', true)
209
+ }
210
+ return action
211
+ }
212
+ if (entry.instruction) {
213
+ const text = entry.instruction
214
+ const failureLabel = COPY_FAILURE[lang]
215
+ action.failureLabel = failureLabel
216
+ action.run = async () => {
217
+ const ok = await deps.copy(text)
218
+ if (!ok) throw new Error(failureLabel)
219
+ deps.notify(lang === 'zh' ? '指令已复制,粘贴给模型即可。' : 'Instruction copied — paste it to the model.', true)
220
+ }
221
+ }
222
+ return action
223
+ }
224
+
225
+ /**
226
+ * Register this plugin's context-menu contributions on the (already-validated)
227
+ * `contextMenus` service. Returns an unregister function; a host without the
228
+ * service is a no-op that returns `() => {}` (callers guard).
229
+ */
230
+ export function registerIterateContextMenus(
231
+ service: ContextMenusService,
232
+ deps: IterateMenuDeps,
233
+ instructionByEntry: (id: string) => string,
234
+ ): () => void {
235
+ const unregister: Array<() => void> = []
236
+
237
+ const register = <K extends string>(target: K, order: number, build: (lang: Lang, target: unknown) => MenuEntry[]) => {
238
+ try {
239
+ const dispose = service.register({
240
+ id: `iterate-plugin.${target}`,
241
+ target,
242
+ order,
243
+ resolve: (t, _signal) => {
244
+ const lang = getLang()
245
+ return build(lang, t).map((entry) => {
246
+ const full: MenuEntry = { ...entry, instruction: entry.instruction ?? instructionByEntry(entry.id) }
247
+ return toAction(full, deps, lang)
248
+ })
249
+ },
250
+ })
251
+ unregister.push(dispose)
252
+ } catch (err) {
253
+ // A duplicate contribution id or an unsupported target family must not
254
+ // take the plugin down — log and continue with the remaining targets.
255
+ if (typeof console !== 'undefined' && typeof console.error === 'function') {
256
+ console.error('[iterate-ui] context-menu registration failed for target', target, err)
257
+ }
258
+ }
259
+ }
260
+
261
+ register('session', 300, (_lang, t) => {
262
+ const target = t as { value?: { sessionId?: string } } | undefined
263
+ return buildSessionMenuEntries(getLang(), { hasCheckpoint: target?.value?.sessionId ? true : undefined })
264
+ })
265
+ register('file', 300, (_lang, t) => {
266
+ const target = t as { value?: { path?: string; line?: number } } | undefined
267
+ return buildFileMenuEntries(getLang()).map((e) => ({
268
+ ...e,
269
+ instruction: e.id === 'iterate.review-file'
270
+ ? instructionByEntry(`file:${target?.value?.path ?? ''}${typeof target?.value?.line === 'number' ? `:${target.value.line}` : ''}`)
271
+ : e.instruction,
272
+ }))
273
+ })
274
+ register('directory', 300, (_lang, t) => {
275
+ const target = t as { value?: { path?: string } } | undefined
276
+ return buildDirectoryMenuEntries(getLang()).map((e) => ({
277
+ ...e,
278
+ instruction: e.id === 'iterate.review-dir'
279
+ ? instructionByEntry(`dir:${target?.value?.path ?? ''}`)
280
+ : e.instruction,
281
+ }))
282
+ })
283
+
284
+ return () => {
285
+ for (const dispose of unregister.splice(0)) {
286
+ try { dispose() } catch { /* noop */ }
287
+ }
288
+ }
289
+ }
@@ -460,6 +460,8 @@ export const EN: Record<string, string> = {
460
460
  复制预检指令: 'Copy preflight instruction',
461
461
  验证命令: 'Validation commands',
462
462
  '已配置 {a0} 条验证命令': '{a0} validation command(s) configured',
463
+ '未配置 validation.commands(阻断项)—— 检测到 {a0} 项目,建议:{a1}':
464
+ 'validation.commands is not configured (blocking) — detected {a0} project; suggested: {a1}',
463
465
  '未配置 validation.commands —— 本轮无法证明修复,属阻断项':
464
466
  'validation.commands is not configured — the run cannot prove fixes (blocking)',
465
467
  'Git 仓库': 'Git repository',
@@ -481,6 +483,7 @@ export const EN: Record<string, string> = {
481
483
  'An interrupted checkpoint is waiting (resumed {a0}x) — resume or clear it first',
482
484
  无中断断点: 'No interrupted checkpoint',
483
485
  配置验证命令: 'Configure validation',
486
+ 应用建议: 'Apply suggestion',
484
487
  恢复断点: 'Resume checkpoint',
485
488
  '阻断 {a0} · 警告 {a1}': 'Blocking {a0} · warnings {a1}',
486
489
  '预检失败:{a0}': 'Preflight failed: {a0}',
@@ -77,6 +77,8 @@ import {
77
77
  scanSessionForDefenseEvents,
78
78
  scanSessionForPreflight,
79
79
  buildPreflightInstruction,
80
+ buildValidationApplyInstruction,
81
+ buildScopedReviewInstruction,
80
82
  filterLiveEntries,
81
83
  filterTimelineEntries,
82
84
  serializeObservatoryExport,
@@ -131,6 +133,14 @@ import {
131
133
  type Lang,
132
134
  } from './i18n.ts'
133
135
  import { noteSession, readSession, subscribeSession } from './session-cache.ts'
136
+ // v3.14: right-click menu contributions (dsh 0.2.2-alpha.3 unified context menus).
137
+ import {
138
+ openObservatory,
139
+ OBS_OPEN_EVENT,
140
+ registerIterateContextMenus,
141
+ type ContextMenusService,
142
+ type ObsOpenDetail,
143
+ } from './context-menu.ts'
134
144
 
135
145
  // ─── Module contract ─────────────────────────────────────────────────────────
136
146
 
@@ -906,6 +916,47 @@ function readTheme(ctx: ClientContext): ThemeService | undefined {
906
916
  return undefined
907
917
  }
908
918
 
919
+ /**
920
+ * Resolve the dsh 0.2.2-alpha.3 `contextMenus` service. Unlike slots/theme it
921
+ * is NOT in the typed `ClientContext`, so it is probed through `ctx.get` (the
922
+ * cordis service locator) and structurally validated. A host that predates the
923
+ * capability returns `undefined` and the plugin simply has no menu entries.
924
+ */
925
+ function readContextMenus(ctx: ClientContext): ContextMenusService | undefined {
926
+ if (!ctx || typeof ctx.get !== 'function') return undefined
927
+ const raw = ctx.get('contextMenus', false)
928
+ if (!raw || typeof raw !== 'object') return undefined
929
+ const svc = raw as { register?: unknown; notify?: unknown }
930
+ if (typeof svc.register !== 'function') return undefined
931
+ if (typeof svc.notify !== 'function') return undefined
932
+ return svc as unknown as ContextMenusService
933
+ }
934
+
935
+ /**
936
+ * Extract a session identifier from an opaque host-shaped session object.
937
+ * The slot `props.session` shape is host-defined; this probes the common
938
+ * keys so the observatory open-event can target the right session and be
939
+ * ignored by every other mounted panel. Returns `null` when unknown (the
940
+ * panel then responds to any open event — the single-panel fallback).
941
+ */
942
+ function sessionIdOfSession(session: unknown): string | null {
943
+ if (!session || typeof session !== 'object') return null
944
+ const s = session as Record<string, unknown>
945
+ const candidates = [
946
+ s.sessionId, s.id, s.header, s.session,
947
+ ] as unknown[]
948
+ for (const c of candidates) {
949
+ if (typeof c === 'string' && c) return c
950
+ if (c && typeof c === 'object') {
951
+ const o = c as Record<string, unknown>
952
+ for (const k of ['sessionId', 'id']) {
953
+ if (typeof o[k] === 'string' && o[k]) return o[k] as string
954
+ }
955
+ }
956
+ }
957
+ return null
958
+ }
959
+
909
960
  // ─── Module-level runtime state (shared across slots) ────────────────────────
910
961
 
911
962
  let slotsSvc: SlotsService | undefined = undefined
@@ -914,6 +965,8 @@ let storage: ReturnType<typeof createStorage> | null = null
914
965
  let themeDisposer: (() => void) | null = null
915
966
  let themeListener: (() => void) | null = null
916
967
  let themeEnabled = true
968
+ /** v3.14: right-click menu contribution disposer (re-apply() never stacks). */
969
+ let menuDisposer: (() => void) | null = null
917
970
  const roundPulseListeners: Array<(payload: { round: number; converged: boolean }) => void> = []
918
971
 
919
972
  /**
@@ -2391,6 +2444,28 @@ function ObservatoryPanel(props: SlotProps) {
2391
2444
  // eslint-disable-next-line react-hooks/exhaustive-deps
2392
2445
  }, [open])
2393
2446
 
2447
+ // v3.14: the right-click "open command center" menu action dispatches a
2448
+ // window event; this panel expands and switches tab when the event targets
2449
+ // it (no sessionId = single-panel fallback, a different sessionId = ignore).
2450
+ const panelSessionId = sessionIdOfSession(session)
2451
+ React.useEffect(() => {
2452
+ if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return
2453
+ const onOpen = (ev: Event) => {
2454
+ const detail = (ev as CustomEvent<ObsOpenDetail>).detail
2455
+ const detailSession = detail && typeof detail.sessionId === 'string' && detail.sessionId
2456
+ ? detail.sessionId
2457
+ : null
2458
+ if (detailSession && panelSessionId && detailSession !== panelSessionId) return
2459
+ setOpen(true)
2460
+ const target = detail && typeof detail.tab === 'string' ? detail.tab : 'live'
2461
+ const valid = OBS_TABS.some((x) => x.key === target)
2462
+ setTab(valid ? target : 'live')
2463
+ }
2464
+ window.addEventListener(OBS_OPEN_EVENT, onOpen)
2465
+ return () => window.removeEventListener(OBS_OPEN_EVENT, onOpen)
2466
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2467
+ }, [panelSessionId])
2468
+
2394
2469
  /** Copy an instruction, flashing "已复制" on the originating button only when the copy actually succeeded. */
2395
2470
  const copyInstruction = (key: string, text: string) => {
2396
2471
  if (!text) return
@@ -3464,9 +3539,17 @@ function ObservatoryPanel(props: SlotProps) {
3464
3539
  label: t('验证命令'),
3465
3540
  detail: (d) => {
3466
3541
  const n = Number(d.commandCount ?? 0)
3467
- return n > 0
3468
- ? tf('已配置 {a0} 条验证命令', { a0: n })
3469
- : t('未配置 validation.commands —— 本轮无法证明修复,属阻断项')
3542
+ if (n > 0) return tf('已配置 {a0} 条验证命令', { a0: n })
3543
+ const suggested = Array.isArray(d.suggestedCommands)
3544
+ ? d.suggestedCommands.map((c) => String(c)).filter((c) => c.length > 0)
3545
+ : []
3546
+ if (suggested.length > 0) {
3547
+ return tf('未配置 validation.commands(阻断项)—— 检测到 {a0} 项目,建议:{a1}', {
3548
+ a0: String(d.ecosystem || ''),
3549
+ a1: suggested.join(' · '),
3550
+ })
3551
+ }
3552
+ return t('未配置 validation.commands —— 本轮无法证明修复,属阻断项')
3470
3553
  },
3471
3554
  },
3472
3555
  git: {
@@ -3505,10 +3588,21 @@ function ObservatoryPanel(props: SlotProps) {
3505
3588
  }
3506
3589
 
3507
3590
  // Repair action per check id; null = guidance only (no tool can fix it).
3508
- const fixFor = (id: string): { key: string; label: string; instruction: string } | null => {
3591
+ const fixFor = (id: string, detail: Record<string, unknown>): { key: string; label: string; instruction: string } | null => {
3509
3592
  switch (id) {
3510
- case 'validation':
3593
+ case 'validation': {
3594
+ const suggested = Array.isArray(detail.suggestedCommands)
3595
+ ? detail.suggestedCommands.map((c) => String(c)).filter((c) => c.length > 0)
3596
+ : []
3597
+ if (suggested.length > 0) {
3598
+ return {
3599
+ key: 'preflight-fix-validation',
3600
+ label: t('应用建议'),
3601
+ instruction: buildValidationApplyInstruction(suggested),
3602
+ }
3603
+ }
3511
3604
  return { key: 'preflight-fix-validation', label: t('配置验证命令'), instruction: buildConfigFieldInstruction('validation.commands') }
3605
+ }
3512
3606
  case 'stale-gate':
3513
3607
  return { key: 'preflight-fix-gate', label: t('清除门禁'), instruction: buildQualityGateClearInstruction() }
3514
3608
  case 'interrupted-checkpoint':
@@ -3523,12 +3617,12 @@ function ObservatoryPanel(props: SlotProps) {
3523
3617
  const status = String(c.status || 'warn')
3524
3618
  const meta = CHECK_META[id]
3525
3619
  const label = meta ? meta.label : id || '?'
3526
- const detail = meta ? meta.detail(c.detail || {}) : ''
3527
- const fix = status !== 'pass' ? fixFor(id) : null
3620
+ const detailText = meta ? meta.detail(c.detail || {}) : ''
3621
+ const fix = status !== 'pass' ? fixFor(id, c.detail || {}) : null
3528
3622
  return React.createElement('div', { key: `pf-${i}`, className: 'iterate-obs-row' },
3529
3623
  React.createElement('span', { className: 'iterate-gate', 'data-status': status }, status.toUpperCase()),
3530
3624
  React.createElement('b', {}, label),
3531
- React.createElement('span', { className: 'iterate-obs-msg' }, detail),
3625
+ React.createElement('span', { className: 'iterate-obs-msg' }, detailText),
3532
3626
  fix
3533
3627
  ? React.createElement('button', {
3534
3628
  className: 'iterate-btn', 'data-copied': copiedKey === fix.key ? '' : undefined,
@@ -3847,6 +3941,49 @@ export function apply(ctx: ClientContext): void {
3847
3941
  )
3848
3942
  }
3849
3943
 
3944
+ // 5b. v3.14: right-click menu contributions (dsh 0.2.2-alpha.3 unified
3945
+ // context menus). Optional service: a host that predates the capability has
3946
+ // no menu entries, and the docked observatory stays the entry point.
3947
+ {
3948
+ const menuSvc = readContextMenus(ctx)
3949
+ if (menuSvc) {
3950
+ // Resolve a paste-able instruction for a menu action id. Session-level
3951
+ // ids map to the shared builders (the SAME single source of truth the
3952
+ // observatory copy buttons use); file:/dir: ids are the scoped review.
3953
+ const instructionByEntry = (id: string): string => {
3954
+ if (id === 'iterate.preflight') return buildPreflightInstruction()
3955
+ if (id === 'iterate.dry-run') return START_INSTRUCTIONS.reviewOnly
3956
+ if (id === 'iterate.resume') return buildCheckpointResumeInstruction()
3957
+ if (id === 'iterate.pull') return buildDiskSnapshotInstruction()
3958
+ if (id.startsWith('file:')) {
3959
+ const rest = id.slice('file:'.length)
3960
+ const colon = rest.lastIndexOf(':')
3961
+ const path = colon > 0 ? rest.slice(0, colon) : rest
3962
+ const line = colon > 0 ? Number(rest.slice(colon + 1)) : undefined
3963
+ return buildScopedReviewInstruction({ file: path, ...(Number.isInteger(line) && line! > 0 ? { line: line! } : {}) })
3964
+ }
3965
+ if (id.startsWith('dir:')) return buildScopedReviewInstruction({ dir: id.slice('dir:'.length) })
3966
+ return ''
3967
+ }
3968
+ if (menuDisposer) {
3969
+ try { menuDisposer() } catch { /* noop */ }
3970
+ menuDisposer = null
3971
+ }
3972
+ menuDisposer = registerIterateContextMenus(
3973
+ menuSvc,
3974
+ {
3975
+ copy: (text) => copyText(text),
3976
+ openPanel: (detail) => { openObservatory(detail) },
3977
+ notify: (text, success, action) => { menuSvc.notify(text, success, action) },
3978
+ },
3979
+ instructionByEntry,
3980
+ )
3981
+ if (typeof ctx.effect === 'function') {
3982
+ ctx.effect(() => { if (menuDisposer) { menuDisposer(); menuDisposer = null } })
3983
+ }
3984
+ }
3985
+ }
3986
+
3850
3987
  // 6: keep theme toggle state consistent across theme/change events.
3851
3988
  // Registered through registerThemeListener so repeated apply() and client
3852
3989
  // unmounts never leak a second listener.