dsh-checklists 0.0.0-stage → 0.3.2

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,713 @@
1
+ /**
2
+ * dsh-checklist — client half (right-sidebar tab).
3
+ *
4
+ * Loaded by the DSH web module loader, not by Node. The loader calls
5
+ * `factory(require)` and the factory returns the module exports; only `react`
6
+ * is reachable through `require`, and every service arrives on the `ctx` that
7
+ * `apply` receives.
8
+ *
9
+ * The tab is read-mostly: it polls the host state route and renders. The one
10
+ * thing it can change is an item's TEXT, through a narrow POST that refuses to
11
+ * overwrite a value it did not read (409 on conflict). States stay agent-only.
12
+ */
13
+ window.__ModuleLoader__.load({
14
+ id: 'dsh-checklists',
15
+ factory: (require) => {
16
+ const React = require('react')
17
+ const h = React.createElement
18
+
19
+ const TAB_ID = 'dsh-checklists'
20
+ const TAB_KIND = 'checklist'
21
+ const STATE_ROUTE = '/dsh-checklist/state'
22
+ const ITEM_ROUTE = '/dsh-checklist/item'
23
+ const PROBE_ROUTE = '/dsh-checklist/client-probe'
24
+ const POLL_MS = 2000
25
+ const MAX_TEXT = 500
26
+
27
+ const GLYPH = { done: '✓', doing: '◐', blocked: '!', todo: '○' }
28
+ const COLOR = { done: '#3fb950', doing: '#d29922', blocked: '#f85149', todo: 'inherit' }
29
+
30
+ const MUTED = { opacity: 0.72, fontSize: 13, padding: '10px 12px', lineHeight: 1.5 }
31
+ const BUTTON = {
32
+ background: 'transparent',
33
+ border: 'none',
34
+ color: 'inherit',
35
+ opacity: 0.62,
36
+ cursor: 'pointer',
37
+ fontSize: 11,
38
+ fontFamily: 'inherit',
39
+ padding: '2px 5px',
40
+ borderRadius: 4,
41
+ }
42
+
43
+ /**
44
+ * User-facing strings. Model-facing text (tool descriptions, the digest)
45
+ * deliberately stays out of here: it is prompt material, not UI copy.
46
+ *
47
+ * English is the fallback for every other locale — the harness can report
48
+ * any BCP-47 tag, and a half-translated panel is worse than an English one.
49
+ */
50
+ const STRINGS = {
51
+ en: {
52
+ title: 'Checklist',
53
+ loading: 'Loading checklist…',
54
+ noSession: 'No active session.',
55
+ readError: 'Could not read the checklist',
56
+ retrying: 'Retrying…',
57
+ emptyTitle: 'No checklist in this session',
58
+ emptyHint: 'Ask the agent to create one: “make me a checklist for…”.',
59
+ hideDone: 'Hide done',
60
+ showDone: 'Show done',
61
+ hideDoneHint: 'Hide or show completed items',
62
+ copy: 'Copy',
63
+ copyHint: 'Copy the checklist as markdown',
64
+ copied: '✓ Copied',
65
+ copyFailed: 'Could not copy',
66
+ noClipboard: 'Clipboard unavailable',
67
+ allDone: 'All done.',
68
+ editHint: 'Double-click to edit the text',
69
+ collapse: 'Collapse',
70
+ expand: 'Expand',
71
+ tooLong: 'Text is too long',
72
+ conflict: 'The item changed while you were editing; try again',
73
+ missing: 'The item no longer exists',
74
+ saveFailed: 'Could not save',
75
+ },
76
+ es: {
77
+ title: 'Checklist',
78
+ loading: 'Cargando checklist…',
79
+ noSession: 'Sin sesión activa.',
80
+ readError: 'No se pudo leer el checklist',
81
+ retrying: 'Reintentando…',
82
+ emptyTitle: 'Sin checklist en esta sesión',
83
+ emptyHint: 'Pídele al agente que cree uno: «hazme un checklist para…».',
84
+ hideDone: 'Ocultar hechos',
85
+ showDone: 'Mostrar hechos',
86
+ hideDoneHint: 'Ocultar o mostrar los ítems hechos',
87
+ copy: 'Copiar',
88
+ copyHint: 'Copiar el checklist como markdown',
89
+ copied: '✓ Copiado',
90
+ copyFailed: 'No se pudo copiar',
91
+ noClipboard: 'Portapapeles no disponible',
92
+ allDone: 'Todo hecho.',
93
+ editHint: 'Doble clic para editar el texto',
94
+ collapse: 'Colapsar',
95
+ expand: 'Expandir',
96
+ tooLong: 'Texto demasiado largo',
97
+ conflict: 'El ítem cambió mientras editabas; reintenta',
98
+ missing: 'El ítem ya no existe',
99
+ saveFailed: 'No se pudo guardar',
100
+ },
101
+ }
102
+
103
+ function readLang(locale) {
104
+ try {
105
+ const active = locale && typeof locale.getSnapshot === 'function' ? locale.getSnapshot().active : ''
106
+ return String(active || '').toLowerCase().startsWith('es') ? 'es' : 'en'
107
+ } catch (_) {
108
+ return 'en'
109
+ }
110
+ }
111
+
112
+ function makeT(lang) {
113
+ const table = STRINGS[lang] || STRINGS.en
114
+ return (key) => (table[key] !== undefined ? table[key] : STRINGS.en[key] !== undefined ? STRINGS.en[key] : key)
115
+ }
116
+
117
+ /** Follow the locale service, including later changes. */
118
+ function useLang(locale) {
119
+ const [lang, setLang] = React.useState(() => readLang(locale))
120
+
121
+ React.useEffect(() => {
122
+ if (!locale || typeof locale.subscribe !== 'function') return undefined
123
+ const update = () => setLang(readLang(locale))
124
+ update()
125
+ const unsubscribe = locale.subscribe(update)
126
+ return typeof unsubscribe === 'function' ? unsubscribe : undefined
127
+ }, [locale])
128
+
129
+ return lang
130
+ }
131
+
132
+ /**
133
+ * Report one load/registration stage to the host.
134
+ *
135
+ * The browser console is not reachable from the host, so the plugin tells
136
+ * the host where it got to. This is how "the tab is missing" is told apart
137
+ * from "the module never loaded". Never throws.
138
+ *
139
+ * @param stage - short stage label.
140
+ * @param detail - optional extra context, truncated by the host.
141
+ */
142
+ function report(stage, detail) {
143
+ try {
144
+ const url = `${PROBE_ROUTE}?stage=${encodeURIComponent(stage)}&detail=${encodeURIComponent(String(detail || '').slice(0, 400))}`
145
+ fetch(url, { cache: 'no-store' }).catch(() => {})
146
+ } catch (_) {
147
+ // Diagnostics must never break the plugin.
148
+ }
149
+ }
150
+
151
+ function useChecklistState(sessionId) {
152
+ const [snapshot, setSnapshot] = React.useState({ phase: 'loading' })
153
+
154
+ React.useEffect(() => {
155
+ if (!sessionId) {
156
+ setSnapshot({ phase: 'no-session' })
157
+ return undefined
158
+ }
159
+
160
+ let alive = true
161
+ let timer = null
162
+
163
+ const load = async () => {
164
+ try {
165
+ const response = await fetch(`${STATE_ROUTE}?session=${encodeURIComponent(sessionId)}`, {
166
+ cache: 'no-store',
167
+ headers: { accept: 'application/json' },
168
+ })
169
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
170
+ const data = await response.json()
171
+ if (alive) setSnapshot({ phase: 'ready', data })
172
+ } catch (error) {
173
+ if (alive) setSnapshot({ phase: 'error', message: String((error && error.message) || error) })
174
+ }
175
+ }
176
+
177
+ void load()
178
+ timer = setInterval(() => void load(), POLL_MS)
179
+ return () => {
180
+ alive = false
181
+ if (timer) clearInterval(timer)
182
+ }
183
+ }, [sessionId])
184
+
185
+ return snapshot
186
+ }
187
+
188
+ /**
189
+ * Save one item's new text.
190
+ *
191
+ * `expectedText` makes the write optimistic-checked: the host answers 409
192
+ * when the item changed since this tab read it, instead of silently
193
+ * clobbering the agent's edit.
194
+ *
195
+ * Returns a machine-readable code, not a sentence: the caller owns the
196
+ * locale, this function does not.
197
+ *
198
+ * @param sessionId - current session.
199
+ * @param itemId - item to change.
200
+ * @param text - new text.
201
+ * @param expectedText - the text this tab last saw.
202
+ * @returns `{ ok }` or `{ ok: false, code, status? }`.
203
+ */
204
+ async function saveItem(sessionId, itemId, text, expectedText) {
205
+ try {
206
+ const response = await fetch(ITEM_ROUTE, {
207
+ method: 'POST',
208
+ headers: { 'content-type': 'application/json' },
209
+ body: JSON.stringify({ session: sessionId, itemId, text, expectedText }),
210
+ })
211
+ if (response.ok) return { ok: true }
212
+ if (response.status === 409) return { ok: false, code: 'conflict' }
213
+ if (response.status === 404) return { ok: false, code: 'missing' }
214
+ return { ok: false, code: 'http', status: response.status }
215
+ } catch {
216
+ return { ok: false, code: 'network' }
217
+ }
218
+ }
219
+
220
+ function countTree(items) {
221
+ let total = 0
222
+ let done = 0
223
+ for (const item of items || []) {
224
+ total += 1
225
+ if (item.state === 'done') done += 1
226
+ const nested = countTree(item.children)
227
+ total += nested.total
228
+ done += nested.done
229
+ }
230
+ return { total, done }
231
+ }
232
+
233
+ function withoutDone(items) {
234
+ const out = []
235
+ for (const item of items || []) {
236
+ const children = withoutDone(item.children)
237
+ const wasOpen = (item.children || []).length !== children.length
238
+ if (item.state === 'done' && children.length === 0) continue
239
+ out.push(wasOpen ? { ...item, children } : item)
240
+ }
241
+ return out
242
+ }
243
+
244
+ function toMarkdown(data) {
245
+ const lines = [`## Checklist · ${data.counts.done}/${data.counts.total}`, '']
246
+ for (const list of data.lists || []) {
247
+ lines.push(`### ${list.title}`)
248
+ const walk = (items, depth) => {
249
+ for (const item of items || []) {
250
+ const mark = item.state === 'done' ? 'x' : ' '
251
+ lines.push(`${' '.repeat(depth)}- [${mark}] ${item.text}`)
252
+ walk(item.children, depth + 1)
253
+ }
254
+ }
255
+ walk(list.items, 0)
256
+ lines.push('')
257
+ }
258
+ return lines.join('\n')
259
+ }
260
+
261
+ function Progress(props) {
262
+ const total = props.total || 0
263
+ const done = props.done || 0
264
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0
265
+ return h(
266
+ 'div',
267
+ {
268
+ style: {
269
+ height: 4,
270
+ borderRadius: 2,
271
+ background: 'rgba(127,127,127,0.25)',
272
+ margin: '0 0 12px',
273
+ overflow: 'hidden',
274
+ },
275
+ },
276
+ h('div', {
277
+ style: {
278
+ height: '100%',
279
+ width: `${pct}%`,
280
+ background: '#3fb950',
281
+ transition: 'width 200ms ease',
282
+ },
283
+ }),
284
+ )
285
+ }
286
+
287
+ function Item(props) {
288
+ const item = props.item
289
+ const depth = props.depth || 0
290
+ const done = item.state === 'done'
291
+ const children = item.children || []
292
+ const isEditing = props.editing && props.editing.id === item.id
293
+ const nodeKey = `${props.listId}/${item.id}`
294
+ const isCollapsed = Boolean(props.collapsed[nodeKey])
295
+ const counts = countTree(children)
296
+
297
+ const label = isEditing
298
+ ? h('input', {
299
+ value: props.editing.draft,
300
+ autoFocus: true,
301
+ maxLength: MAX_TEXT,
302
+ onChange: (event) => props.onDraft(event.target.value),
303
+ onKeyDown: (event) => {
304
+ if (event.key === 'Enter') {
305
+ event.preventDefault()
306
+ props.onCommit()
307
+ } else if (event.key === 'Escape') {
308
+ event.preventDefault()
309
+ props.onCancel()
310
+ }
311
+ },
312
+ onBlur: () => props.onCommit(),
313
+ style: {
314
+ flex: 1,
315
+ minWidth: 0,
316
+ font: 'inherit',
317
+ color: 'inherit',
318
+ background: 'rgba(127,127,127,0.15)',
319
+ border: '1px solid rgba(127,127,127,0.45)',
320
+ borderRadius: 4,
321
+ padding: '1px 4px',
322
+ },
323
+ })
324
+ : h(
325
+ 'span',
326
+ {
327
+ onDoubleClick: () => props.onStartEdit(item),
328
+ title: props.t('editHint'),
329
+ style: {
330
+ textDecoration: done ? 'line-through' : 'none',
331
+ wordBreak: 'break-word',
332
+ cursor: 'text',
333
+ },
334
+ },
335
+ item.text,
336
+ )
337
+
338
+ const chevron =
339
+ children.length > 0
340
+ ? h(
341
+ 'button',
342
+ {
343
+ onClick: (event) => {
344
+ event.preventDefault()
345
+ props.onToggleNode(nodeKey)
346
+ },
347
+ title: isCollapsed ? props.t('expand') : props.t('collapse'),
348
+ style: {
349
+ ...BUTTON,
350
+ flex: '0 0 12px',
351
+ width: 12,
352
+ padding: 0,
353
+ fontSize: 9,
354
+ textAlign: 'center',
355
+ },
356
+ },
357
+ isCollapsed ? '▸' : '▾',
358
+ )
359
+ : h('span', { style: { flex: '0 0 12px', width: 12 } })
360
+
361
+ return h(
362
+ 'li',
363
+ { style: { listStyle: 'none', margin: 0, padding: 0 } },
364
+ h(
365
+ 'div',
366
+ {
367
+ style: {
368
+ display: 'flex',
369
+ gap: 6,
370
+ alignItems: 'baseline',
371
+ paddingLeft: depth * 14,
372
+ paddingBlock: 1,
373
+ opacity: done ? 0.55 : 1,
374
+ },
375
+ },
376
+ h(
377
+ 'span',
378
+ {
379
+ style: {
380
+ color: COLOR[item.state] || 'inherit',
381
+ flex: '0 0 14px',
382
+ width: 14,
383
+ textAlign: 'center',
384
+ fontSize: 12,
385
+ },
386
+ },
387
+ GLYPH[item.state] || '·',
388
+ ),
389
+ chevron,
390
+ label,
391
+ isCollapsed && children.length > 0
392
+ ? h(
393
+ 'span',
394
+ { style: { opacity: 0.5, fontSize: 11, flex: '0 0 auto' } },
395
+ `${counts.done}/${counts.total}`,
396
+ )
397
+ : null,
398
+ ),
399
+ !isCollapsed && children.length > 0
400
+ ? h(
401
+ 'ul',
402
+ { style: { margin: 0, padding: 0 } },
403
+ children.map((child) =>
404
+ h(Item, {
405
+ key: child.id,
406
+ item: child,
407
+ depth: depth + 1,
408
+ t: props.t,
409
+ listId: props.listId,
410
+ collapsed: props.collapsed,
411
+ onToggleNode: props.onToggleNode,
412
+ editing: props.editing,
413
+ onStartEdit: props.onStartEdit,
414
+ onDraft: props.onDraft,
415
+ onCommit: props.onCommit,
416
+ onCancel: props.onCancel,
417
+ }),
418
+ ),
419
+ )
420
+ : null,
421
+ )
422
+ }
423
+
424
+ function List(props) {
425
+ const list = props.list
426
+ const collapsed = Boolean(props.collapsed[list.id])
427
+ const source = props.hideDone ? withoutDone(list.items) : list.items || []
428
+ const counts = countTree(list.items)
429
+
430
+ const header = h(
431
+ 'button',
432
+ {
433
+ onClick: () => props.onToggleNode(list.id),
434
+ title: collapsed ? props.t('expand') : props.t('collapse'),
435
+ style: {
436
+ ...BUTTON,
437
+ opacity: 0.9,
438
+ fontWeight: 600,
439
+ fontSize: 12.5,
440
+ display: 'flex',
441
+ alignItems: 'baseline',
442
+ gap: 5,
443
+ width: '100%',
444
+ textAlign: 'left',
445
+ padding: '2px 0',
446
+ marginBottom: 3,
447
+ },
448
+ },
449
+ h('span', { style: { opacity: 0.55, fontSize: 10 } }, collapsed ? '▸' : '▾'),
450
+ h('span', null, list.title),
451
+ h('span', { style: { opacity: 0.55, fontWeight: 400 } }, `${counts.done}/${counts.total}`),
452
+ )
453
+
454
+ let body = null
455
+ if (!collapsed) {
456
+ body = source.length === 0
457
+ ? h('div', { style: { opacity: 0.55, fontSize: 12, paddingLeft: 4 } }, props.t('allDone'))
458
+ : h(
459
+ 'ul',
460
+ { style: { margin: 0, padding: 0 } },
461
+ source.map((item) =>
462
+ h(Item, {
463
+ key: item.id,
464
+ item,
465
+ depth: 0,
466
+ t: props.t,
467
+ listId: list.id,
468
+ collapsed: props.collapsed,
469
+ onToggleNode: props.onToggleNode,
470
+ editing: props.editing,
471
+ onStartEdit: props.onStartEdit,
472
+ onDraft: props.onDraft,
473
+ onCommit: props.onCommit,
474
+ onCancel: props.onCancel,
475
+ }),
476
+ ),
477
+ )
478
+ }
479
+
480
+ return h('section', { style: { marginBottom: 12 } }, header, body)
481
+ }
482
+
483
+ function ChecklistBody(props) {
484
+ const sessionId = props && props.sessionId
485
+ const snapshot = useChecklistState(sessionId)
486
+ const lang = useLang(props && props.locale)
487
+ const t = makeT(lang)
488
+
489
+ const [hideDone, setHideDone] = React.useState(false)
490
+ const [collapsed, setCollapsed] = React.useState({})
491
+ const [editing, setEditing] = React.useState(null)
492
+ const [notice, setNotice] = React.useState(null)
493
+ const [copied, setCopied] = React.useState(false)
494
+ const editingRef = React.useRef(null)
495
+ const knownLists = React.useRef(null)
496
+
497
+ React.useEffect(() => {
498
+ editingRef.current = editing
499
+ }, [editing])
500
+
501
+ // A brand-new checklist takes the stage: collapse every other list so it
502
+ // is the one you see. Only a genuinely new id triggers it — reacting to
503
+ // every poll would fight the user's own expand/collapse every 2 seconds.
504
+ React.useEffect(() => {
505
+ if (!sessionId || snapshot.phase !== 'ready') return
506
+ const lists = (snapshot.data && snapshot.data.lists) || []
507
+ const ids = lists.map((list) => list.id)
508
+ const previous = knownLists.current
509
+ knownLists.current = new Set(ids)
510
+ if (!previous) return
511
+
512
+ const fresh = new Set(ids.filter((id) => !previous.has(id)))
513
+ if (fresh.size === 0) return
514
+
515
+ setCollapsed((current) => {
516
+ const next = { ...current }
517
+ for (const id of ids) next[id] = !fresh.has(id)
518
+ return next
519
+ })
520
+ }, [snapshot, sessionId])
521
+
522
+ React.useEffect(() => {
523
+ if (!notice) return undefined
524
+ const timer = setTimeout(() => setNotice(null), 3000)
525
+ return () => clearTimeout(timer)
526
+ }, [notice])
527
+
528
+ const startEdit = (item) => {
529
+ const next = { id: item.id, original: item.text, draft: item.text }
530
+ editingRef.current = next
531
+ setEditing(next)
532
+ }
533
+
534
+ const changeDraft = (value) => {
535
+ const current = editingRef.current
536
+ if (!current) return
537
+ const next = { ...current, draft: value }
538
+ editingRef.current = next
539
+ setEditing(next)
540
+ }
541
+
542
+ const cancelEdit = () => {
543
+ editingRef.current = null
544
+ setEditing(null)
545
+ }
546
+
547
+ const commitEdit = () => {
548
+ const current = editingRef.current
549
+ if (!current) return
550
+ editingRef.current = null
551
+ setEditing(null)
552
+
553
+ const next = String(current.draft || '').trim()
554
+ if (next.length === 0 || next === current.original) return
555
+ if (next.length > MAX_TEXT) {
556
+ setNotice(t('tooLong'))
557
+ return
558
+ }
559
+
560
+ void saveItem(sessionId, current.id, next, current.original).then((outcome) => {
561
+ if (outcome.ok) return
562
+ if (outcome.code === 'conflict') setNotice(t('conflict'))
563
+ else if (outcome.code === 'missing') setNotice(t('missing'))
564
+ else if (outcome.code === 'http') setNotice(`${t('saveFailed')} (HTTP ${outcome.status})`)
565
+ else setNotice(t('saveFailed'))
566
+ })
567
+ }
568
+
569
+ const toggleNode = (key) => {
570
+ setCollapsed((current) => ({ ...current, [key]: !current[key] }))
571
+ }
572
+
573
+ if (!sessionId) return h('div', { style: MUTED }, t('noSession'))
574
+ if (snapshot.phase === 'loading') return h('div', { style: MUTED }, t('loading'))
575
+
576
+ if (snapshot.phase === 'error') {
577
+ return h(
578
+ 'div',
579
+ { style: MUTED },
580
+ h('div', { style: { fontWeight: 600, marginBottom: 4 } }, t('readError')),
581
+ h('div', { style: { opacity: 0.8 } }, snapshot.message),
582
+ h('div', { style: { opacity: 0.6, marginTop: 6 } }, t('retrying')),
583
+ )
584
+ }
585
+
586
+ const data = snapshot.data || {}
587
+ const lists = data.lists || []
588
+ const counts = data.counts || { done: 0, total: 0 }
589
+ const allDone = counts.total > 0 && counts.done === counts.total
590
+
591
+ if (lists.length === 0) {
592
+ return h(
593
+ 'div',
594
+ { style: MUTED },
595
+ h('div', { style: { fontWeight: 600, marginBottom: 4 } }, t('emptyTitle')),
596
+ h('div', null, t('emptyHint')),
597
+ )
598
+ }
599
+
600
+ const copyMarkdown = () => {
601
+ const markdown = toMarkdown(data)
602
+ if (!navigator.clipboard || !navigator.clipboard.writeText) {
603
+ setNotice(t('noClipboard'))
604
+ return
605
+ }
606
+ navigator.clipboard
607
+ .writeText(markdown)
608
+ .then(() => {
609
+ setCopied(true)
610
+ setTimeout(() => setCopied(false), 1500)
611
+ })
612
+ .catch(() => setNotice(t('copyFailed')))
613
+ }
614
+
615
+ return h(
616
+ 'div',
617
+ { style: { padding: '10px 12px', fontSize: 13, lineHeight: 1.5 } },
618
+ h(
619
+ 'div',
620
+ { style: { display: 'flex', alignItems: 'baseline', marginBottom: 6, gap: 2 } },
621
+ h('strong', { style: { fontSize: 13 } }, t('title')),
622
+ h(
623
+ 'span',
624
+ {
625
+ style: {
626
+ marginLeft: 6,
627
+ opacity: allDone ? 1 : 0.62,
628
+ color: allDone ? '#3fb950' : 'inherit',
629
+ fontWeight: 400,
630
+ fontVariantNumeric: 'tabular-nums',
631
+ },
632
+ },
633
+ `${counts.done}/${counts.total}`,
634
+ ),
635
+ h('span', { style: { flex: 1 } }),
636
+ counts.done > 0
637
+ ? h(
638
+ 'button',
639
+ { onClick: () => setHideDone((value) => !value), style: BUTTON, title: t('hideDoneHint') },
640
+ hideDone ? t('showDone') : t('hideDone'),
641
+ )
642
+ : null,
643
+ h(
644
+ 'button',
645
+ { onClick: copyMarkdown, style: BUTTON, title: t('copyHint') },
646
+ copied ? t('copied') : t('copy'),
647
+ ),
648
+ ),
649
+ notice ? h('div', { style: { fontSize: 11, color: '#f85149', marginBottom: 6 } }, notice) : null,
650
+ h(Progress, { done: counts.done, total: counts.total }),
651
+ lists.map((list) =>
652
+ h(List, {
653
+ key: list.id,
654
+ list,
655
+ t,
656
+ hideDone,
657
+ collapsed,
658
+ onToggleNode: toggleNode,
659
+ editing,
660
+ onStartEdit: startEdit,
661
+ onDraft: changeDraft,
662
+ onCommit: commitEdit,
663
+ onCancel: cancelEdit,
664
+ }),
665
+ ),
666
+ )
667
+ }
668
+
669
+ function apply(ctx) {
670
+ report('apply-start', 'ctx=' + typeof ctx)
671
+
672
+ try {
673
+ // The locale service is optional: without it every string falls back to
674
+ // English instead of crashing the panel.
675
+ const locale = typeof ctx.get === 'function' ? ctx.get('locale') : undefined
676
+
677
+ ctx.inject(['sidebarRightTabs'], (owner) => {
678
+ const tabs = owner.get('sidebarRightTabs')
679
+ report('tabs-service', tabs ? 'present' : 'missing')
680
+ if (!tabs) return
681
+
682
+ owner.effect(
683
+ () => tabs.register({
684
+ id: TAB_ID,
685
+ kind: TAB_KIND,
686
+ priority: 'extension',
687
+ title: () => 'Checklist',
688
+ guide: [{ id: 'checklist', order: 120, title: () => 'Checklist' }],
689
+ }),
690
+ 'dsh-checklists: tab type',
691
+ )
692
+ report('tab-type', 'registered')
693
+
694
+ const register = owner.slots.register.bind(owner.slots)
695
+ owner.slots.inject('sidebar.right.pane.tab', () => register({
696
+ name: 'sidebar.right.pane.tab',
697
+ key: TAB_ID,
698
+ inject: () => ({ locale }),
699
+ }, ChecklistBody))
700
+ report('tab-body', 'registered')
701
+ })
702
+ } catch (error) {
703
+ report('apply-error', (error && (error.stack || error.message)) || String(error))
704
+ throw error
705
+ }
706
+ }
707
+
708
+ // Only REAL services go here: a static inject that never resolves parks the
709
+ // plugin forever and `apply` never runs (this was the first bug). The tab
710
+ // registry is picked up lazily above, exactly as dsh-image-gen does.
711
+ return { apply, inject: ['slots', 'locale'] }
712
+ },
713
+ })