dsh-checklists 0.0.0-stage → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +160 -2
- package/assets/screenshot-1.png +0 -0
- package/client/index.js +713 -0
- package/cordis.patch.yml +10 -0
- package/lib/digest.js +76 -0
- package/lib/index.js +97 -0
- package/lib/model.js +222 -0
- package/lib/probe.js +46 -0
- package/lib/route.js +155 -0
- package/lib/state.js +36 -0
- package/lib/store.js +174 -0
- package/lib/tools.js +638 -0
- package/lib/types/digest.d.ts +30 -0
- package/lib/types/index.d.ts +28 -0
- package/lib/types/model.d.ts +154 -0
- package/lib/types/probe.d.ts +17 -0
- package/lib/types/route.d.ts +32 -0
- package/lib/types/state.d.ts +30 -0
- package/lib/types/store.d.ts +43 -0
- package/lib/types/tools.d.ts +13 -0
- package/package.json +80 -4
package/client/index.js
ADDED
|
@@ -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
|
+
})
|