@fonlan/dsh-task-kanban 0.1.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,516 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react'
2
+ import type { KanbanCard, Lane } from '../shared/card'
3
+ import { DROP_RULES, laneOf } from '../shared/lanes'
4
+ import { api } from './api'
5
+ import { exitBoard, getClient } from './kanban-state'
6
+ import { en, zh } from './locales'
7
+ import './board.css'
8
+
9
+ interface BoardProps {
10
+ t?: (key: string, params?: Record<string, unknown>) => string
11
+ useWorkspaces?: (selector: (state: unknown) => unknown) => unknown
12
+ }
13
+
14
+ function firstLine(text: string): string {
15
+ return text.split('\n').find((l) => l.trim() !== '')?.trim() ?? ''
16
+ }
17
+
18
+ function statusLabel(card: KanbanCard, t: (key: string) => string): string {
19
+ if (card.status === 'error') {
20
+ switch (card.error?.kind) {
21
+ case 'interrupted': return t('interrupted')
22
+ case 'no_base_branch': return t('noBaseBranch')
23
+ case 'worktree_failed': return t('worktreeFailed')
24
+ case 'refine_failed': return t('refineFailed')
25
+ case 'phase_failed': return t('phaseFailed')
26
+ case 'merge_failed': return t('mergeFailed')
27
+ default: return t('createFailed')
28
+ }
29
+ }
30
+ switch (card.status) {
31
+ case 'draft': return t('statusDraft')
32
+ case 'refining': return t('statusRefining')
33
+ case 'planned': return t('statusPlanned')
34
+ case 'queued': return t('statusQueued')
35
+ case 'running': return t('statusRunning')
36
+ case 'completed': return t('statusCompleted')
37
+ case 'merging': return t('statusMerging')
38
+ case 'merged': return t('statusMerged')
39
+ }
40
+ }
41
+
42
+ interface WorkspaceRow {
43
+ workspaceId: string
44
+ path: string
45
+ title: string
46
+ }
47
+
48
+ const LANE_ORDER: Array<{ key: Lane; labelKey: string }> = [
49
+ { key: 'demand', labelKey: 'laneDemand' },
50
+ { key: 'queue', labelKey: 'laneQueue' },
51
+ { key: 'running', labelKey: 'laneRunning' },
52
+ { key: 'completed', labelKey: 'laneCompleted' },
53
+ { key: 'merged', labelKey: 'laneMerged' },
54
+ ]
55
+
56
+ const WS_STORAGE_KEY = '@fonlan/dsh-task-kanban:workspace'
57
+
58
+ export function BoardRoot(props: BoardProps): JSX.Element {
59
+ const t = props.t ?? ((key: string, params?: Record<string, unknown>) => {
60
+ const dict = (zh as Record<string, string>)[key] ?? (en as Record<string, string>)[key]
61
+ if (dict === undefined || params === undefined) return dict ?? key
62
+ return dict.replace(/\{(\w+)\}/g, (m, name: string) => (params[name] as string) ?? m)
63
+ })
64
+ const useWorkspaces = props.useWorkspaces
65
+ const wsState = useWorkspaces !== undefined ? (useWorkspaces((s: unknown) => s) as { items?: WorkspaceRow[] } | undefined) : undefined
66
+ const items: WorkspaceRow[] = wsState?.items ?? []
67
+
68
+ const [selectedPath, setSelectedPath] = useState<string>(() => {
69
+ try {
70
+ return localStorage.getItem(WS_STORAGE_KEY) ?? ''
71
+ } catch {
72
+ return ''
73
+ }
74
+ })
75
+ const [cards, setCards] = useState<KanbanCard[]>([])
76
+ const [toast, setToast] = useState<string | null>(null)
77
+ const [detailId, setDetailId] = useState<string | null>(null)
78
+ const [newTaskOpen, setNewTaskOpen] = useState(false)
79
+
80
+ useEffect(() => {
81
+ if (selectedPath === '' && items.length > 0) {
82
+ setSelectedPath(items[0].path)
83
+ }
84
+ }, [selectedPath, items])
85
+
86
+ const showToast = useCallback((message: string) => {
87
+ setToast(message)
88
+ window.setTimeout(() => setToast(null), 3500)
89
+ }, [])
90
+
91
+ const refresh = useCallback(async () => {
92
+ if (selectedPath === '') return
93
+ try {
94
+ setCards(await api.list(selectedPath))
95
+ } catch (error) {
96
+ showToast(error instanceof Error ? error.message : String(error))
97
+ }
98
+ }, [selectedPath, showToast])
99
+
100
+ useEffect(() => {
101
+ void refresh()
102
+ const timer = window.setInterval(() => { void refresh() }, 2500)
103
+ return () => window.clearInterval(timer)
104
+ }, [refresh])
105
+
106
+ const byLane = useMemo(() => {
107
+ const map: Record<Lane, KanbanCard[]> = { demand: [], queue: [], running: [], completed: [], merged: [] }
108
+ for (const card of cards) map[laneOf(card)].push(card)
109
+ for (const lane of LANE_ORDER) map[lane.key].sort((a, b) => a.createdAt - b.createdAt)
110
+ return map
111
+ }, [cards])
112
+
113
+ const handleDrop = useCallback(async (target: Lane, event: React.DragEvent) => {
114
+ event.preventDefault()
115
+ const cardId = event.dataTransfer.getData('text/plain')
116
+ if (cardId === '') return
117
+ const card = cards.find((c) => c.id === cardId)
118
+ if (card === undefined) return
119
+ if (target === 'queue' && card.status !== 'running' && card.plan === undefined) {
120
+ showToast(t('dragNoPlan'))
121
+ return
122
+ }
123
+ try {
124
+ const result = await api.move(cardId, target)
125
+ if (!result.ok) {
126
+ showToast(t('actionFailed', { message: result.message ?? '' }))
127
+ }
128
+ void refresh()
129
+ } catch (error) {
130
+ showToast(error instanceof Error ? error.message : String(error))
131
+ }
132
+ }, [cards, refresh, showToast, t])
133
+
134
+ const openSession = useCallback((sessionId: string) => {
135
+ const ctx = getClient()
136
+ exitBoard()
137
+ if (ctx === null) return
138
+ try {
139
+ ;(ctx.sessions as { open(id: string): void }).open(sessionId)
140
+ } catch {
141
+ showToast(t('sessionNotListed'))
142
+ }
143
+ }, [showToast, t])
144
+
145
+ if (items.length === 0) {
146
+ return (
147
+ <div className="kb-board kb-empty">
148
+ <div className="kb-empty-text">{t('noWorkspace')}</div>
149
+ </div>
150
+ )
151
+ }
152
+
153
+ return (
154
+ <div className="kb-board">
155
+ <div className="kb-board-header">
156
+ <div className="kb-board-title">
157
+ <select
158
+ className="kb-ws-select"
159
+ value={selectedPath}
160
+ onChange={(e) => {
161
+ setSelectedPath(e.target.value)
162
+ try {
163
+ localStorage.setItem(WS_STORAGE_KEY, e.target.value)
164
+ } catch {
165
+ // storage unavailable
166
+ }
167
+ }}
168
+ >
169
+ {items.map((w) => (
170
+ <option key={w.workspaceId} value={w.path}>{w.title}</option>
171
+ ))}
172
+ </select>
173
+ </div>
174
+ <div className="kb-board-actions">
175
+ <button type="button" className="kb-btn kb-btn-primary" onClick={() => setNewTaskOpen(true)}>{t('newTask')}</button>
176
+ </div>
177
+ </div>
178
+
179
+ <div className="kb-lanes">
180
+ {LANE_ORDER.map((lane) => (
181
+ <div
182
+ key={lane.key}
183
+ className={'kb-lane' + (DROP_RULES[lane.key].length > 0 ? ' kb-lane-drop' : '')}
184
+ onDragOver={(e) => {
185
+ if (DROP_RULES[lane.key].length > 0) e.preventDefault()
186
+ }}
187
+ onDrop={(e) => { void handleDrop(lane.key, e) }}
188
+ >
189
+ <div className="kb-lane-head">
190
+ <span className="kb-lane-title">{t(lane.labelKey)}</span>
191
+ <span className="kb-lane-count">{byLane[lane.key].length}</span>
192
+ </div>
193
+ <div className="kb-lane-body">
194
+ {byLane[lane.key].map((card) => (
195
+ <CardView
196
+ key={card.id}
197
+ card={card}
198
+ t={t}
199
+ onToggle={() => setDetailId(detailId === card.id ? null : card.id)}
200
+ />
201
+ ))}
202
+ </div>
203
+ </div>
204
+ ))}
205
+ </div>
206
+
207
+ {toast !== null && <div className="kb-toast">{toast}</div>}
208
+
209
+ {newTaskOpen && (
210
+ <NewTaskModal
211
+ t={t}
212
+ workspaces={items}
213
+ selectedPath={selectedPath}
214
+ onClose={() => setNewTaskOpen(false)}
215
+ onCreated={() => {
216
+ setNewTaskOpen(false)
217
+ void refresh()
218
+ }}
219
+ onError={(message) => showToast(message)}
220
+ />
221
+ )}
222
+
223
+ {detailId !== null && (
224
+ <DetailPanel
225
+ card={cards.find((c) => c.id === detailId) ?? null}
226
+ t={t}
227
+ onClose={() => setDetailId(null)}
228
+ onChanged={() => {
229
+ setDetailId(null)
230
+ void refresh()
231
+ }}
232
+ onToast={showToast}
233
+ onOpenSession={openSession}
234
+ />
235
+ )}
236
+ </div>
237
+ )
238
+ }
239
+
240
+ interface CardViewProps {
241
+ card: KanbanCard
242
+ t: (key: string, params?: Record<string, unknown>) => string
243
+ onToggle: () => void
244
+ }
245
+
246
+ function CardView({ card, t, onToggle }: CardViewProps): JSX.Element {
247
+ const draggable = card.status === 'planned' || card.status === 'queued' || card.status === 'running'
248
+ const title = card.plan !== undefined && card.plan.title !== '' ? card.plan.title : firstLine(card.requirement)
249
+ const badge = statusLabel(card, t)
250
+ const phaseInfo = card.status === 'running' || card.status === 'error'
251
+ ? card.currentPhase < card.phaseCount ? t('phaseProgress', { current: card.currentPhase + 1, total: card.phaseCount }) : ''
252
+ : ''
253
+ return (
254
+ <div
255
+ className={'kb-card' + (card.status === 'error' ? ' kb-card-error' : '') + (card.status === 'running' ? ' kb-card-running' : '')}
256
+ draggable={draggable}
257
+ onDragStart={(e) => {
258
+ e.dataTransfer.setData('text/plain', card.id)
259
+ e.dataTransfer.effectAllowed = 'move'
260
+ }}
261
+ onClick={onToggle}
262
+ >
263
+ <div className="kb-card-title">{title}</div>
264
+ <div className="kb-card-meta">
265
+ <span className="kb-card-badge">{badge}</span>
266
+ {phaseInfo !== '' && <span className="kb-card-phase">{phaseInfo}</span>}
267
+ {card.model !== '' && <span className="kb-card-model">{card.model}</span>}
268
+ </div>
269
+ {card.status === 'error' && card.error !== undefined && (
270
+ <div className="kb-card-error-text">{card.error.message}</div>
271
+ )}
272
+ {card.status === 'running' && card.gitMode === true && (
273
+ <div className="kb-card-worktree">{card.worktreePath ?? ''}</div>
274
+ )}
275
+ </div>
276
+ )
277
+ }
278
+
279
+ interface NewTaskModalProps {
280
+ t: (key: string, params?: Record<string, unknown>) => string
281
+ workspaces: WorkspaceRow[]
282
+ selectedPath: string
283
+ onClose: () => void
284
+ onCreated: () => void
285
+ onError: (message: string) => void
286
+ }
287
+
288
+ function NewTaskModal({ t, workspaces, selectedPath, onClose, onCreated, onError }: NewTaskModalProps): JSX.Element {
289
+ const [requirement, setRequirement] = useState('')
290
+ const [project, setProject] = useState(selectedPath)
291
+ const [model, setModel] = useState('')
292
+ const [modelProvider, setModelProvider] = useState('')
293
+ const [models, setModels] = useState<Array<{ provider: string; id: string; name?: string }>>([])
294
+ const [busy, setBusy] = useState(false)
295
+
296
+ useEffect(() => {
297
+ let alive = true
298
+ api.models()
299
+ .then((list) => { if (alive) setModels(list) })
300
+ .catch(() => { if (alive) setModels([]) })
301
+ api.settingsGet()
302
+ .then((s) => {
303
+ if (alive && s.defaultModel !== '') {
304
+ setModel(s.defaultModel)
305
+ }
306
+ })
307
+ .catch(() => undefined)
308
+ return () => { alive = false }
309
+ }, [])
310
+
311
+ const submit = async (): Promise<void> => {
312
+ if (requirement.trim() === '') {
313
+ onError(t('requirementLabel') + ' ' + t('error'))
314
+ return
315
+ }
316
+ if (project === '') {
317
+ onError(t('noWorkspace'))
318
+ return
319
+ }
320
+ setBusy(true)
321
+ try {
322
+ await api.create(project, requirement.trim(), model, modelProvider)
323
+ onCreated()
324
+ } catch (error) {
325
+ onError(error instanceof Error ? error.message : String(error))
326
+ setBusy(false)
327
+ }
328
+ }
329
+
330
+ return (
331
+ <div className="kb-modal-backdrop" onClick={busy ? undefined : onClose}>
332
+ <div className="kb-modal" onClick={(e) => e.stopPropagation()}>
333
+ <div className="kb-modal-title">{t('newTask')}</div>
334
+ <label className="kb-field">
335
+ <span>{t('requirementLabel')}</span>
336
+ <textarea
337
+ className="kb-input kb-textarea"
338
+ value={requirement}
339
+ placeholder={t('requirementPlaceholder')}
340
+ onChange={(e) => setRequirement(e.target.value)}
341
+ />
342
+ </label>
343
+ <label className="kb-field">
344
+ <span>{t('project')}</span>
345
+ <select className="kb-input" value={project} onChange={(e) => setProject(e.target.value)}>
346
+ {workspaces.map((w) => (
347
+ <option key={w.workspaceId} value={w.path}>{w.title}</option>
348
+ ))}
349
+ </select>
350
+ </label>
351
+ <label className="kb-field">
352
+ <span>{t('model')}</span>
353
+ <select
354
+ className="kb-input"
355
+ value={model}
356
+ onChange={(e) => {
357
+ const id = e.target.value
358
+ setModel(id)
359
+ const found = models.find((m) => m.id === id)
360
+ setModelProvider(found?.provider ?? '')
361
+ }}
362
+ >
363
+ <option value="">{t('modelPlaceholder')}</option>
364
+ {models.map((m) => (
365
+ <option key={m.provider + '/' + m.id} value={m.id}>{m.name ?? m.id}</option>
366
+ ))}
367
+ </select>
368
+ </label>
369
+ <div className="kb-modal-actions">
370
+ <button type="button" className="kb-btn" onClick={onClose} disabled={busy}>{t('cancel')}</button>
371
+ <button type="button" className="kb-btn kb-btn-primary" onClick={() => { void submit() }} disabled={busy}>
372
+ {t('addAndRefine')}
373
+ </button>
374
+ </div>
375
+ </div>
376
+ </div>
377
+ )
378
+ }
379
+
380
+ interface DetailPanelProps {
381
+ card: KanbanCard | null
382
+ t: (key: string, params?: Record<string, unknown>) => string
383
+ onClose: () => void
384
+ onChanged: () => void
385
+ onToast: (message: string) => void
386
+ onOpenSession: (sessionId: string) => void
387
+ }
388
+
389
+ function DetailPanel({ card, t, onClose, onChanged, onToast, onOpenSession }: DetailPanelProps): JSX.Element | null {
390
+ const [busy, setBusy] = useState(false)
391
+ if (card === null) return null
392
+
393
+ const act = async (fn: () => Promise<unknown>): Promise<void> => {
394
+ setBusy(true)
395
+ try {
396
+ await fn()
397
+ onChanged()
398
+ } catch (error) {
399
+ onToast(error instanceof Error ? error.message : String(error))
400
+ setBusy(false)
401
+ }
402
+ }
403
+
404
+ const remove = async (): Promise<void> => {
405
+ if (!window.confirm(t('deleteConfirm'))) return
406
+ await act(() => api.remove(card!.id))
407
+ }
408
+
409
+ return (
410
+ <div className="kb-detail-panel">
411
+ <div className="kb-detail-panel-head">
412
+ <div className="kb-modal-title">
413
+ {card.plan !== undefined && card.plan.title !== '' ? card.plan.title : firstLine(card.requirement)}
414
+ <span className={'kb-detail-status kb-status-' + card.status}>{statusLabel(card, t)}</span>
415
+ </div>
416
+ <button type="button" className="kb-detail-close" aria-label={t('close')} title={t('close')} onClick={onClose}>✕</button>
417
+ </div>
418
+
419
+ <div className="kb-detail-panel-body">
420
+ <div className="kb-detail-section">
421
+ <div className="kb-detail-label">{t('requirement')}</div>
422
+ <div className="kb-detail-text">{card.requirement}</div>
423
+ </div>
424
+
425
+ {card.plan !== undefined && (
426
+ <div className="kb-detail-section">
427
+ <div className="kb-detail-label">{t('plan')}</div>
428
+ <div className="kb-detail-plan">
429
+ <div className="kb-plan-summary">{card.plan.summary}</div>
430
+ <ol className="kb-plan-phases">
431
+ {card.plan.phases.map((phase, i) => (
432
+ <li key={phase.id} className={'kb-plan-phase' + (i === card.currentPhase && card.status === 'running' ? ' kb-plan-phase-current' : '')}>
433
+ <div className="kb-plan-phase-head">
434
+ <span className="kb-plan-phase-title">{phase.title}</span>
435
+ <span className="kb-plan-phase-id">{phase.id}</span>
436
+ </div>
437
+ <div className="kb-plan-phase-goal">{phase.goal}</div>
438
+ <PhaseSessions phaseIndex={i} card={card} t={t} onOpenSession={onOpenSession} />
439
+ </li>
440
+ ))}
441
+ </ol>
442
+ </div>
443
+ </div>
444
+ )}
445
+
446
+ {card.sessions.refinement.length > 0 && (
447
+ <div className="kb-detail-section">
448
+ <div className="kb-detail-label">{t('refinementLabel')}</div>
449
+ <div className="kb-session-links">
450
+ {card.sessions.refinement.map((sid) => (
451
+ <button key={sid} type="button" className="kb-btn kb-btn-small" onClick={() => onOpenSession(sid)}>
452
+ {t('openSession')} · {sid.slice(0, 13)}
453
+ </button>
454
+ ))}
455
+ </div>
456
+ </div>
457
+ )}
458
+
459
+ {card.status === 'error' && card.error !== undefined && (
460
+ <div className="kb-detail-section kb-detail-error">
461
+ <div className="kb-detail-label">{t('error')}</div>
462
+ <div className="kb-detail-text">{card.error.message}</div>
463
+ </div>
464
+ )}
465
+
466
+ {card.merge?.mergeCommit !== undefined && (
467
+ <div className="kb-detail-section">
468
+ <div className="kb-detail-label">{t('mergeLabel')}</div>
469
+ <div className="kb-detail-text">{t('commitLabel')} {card.merge.mergeCommit.slice(0, 12)}</div>
470
+ </div>
471
+ )}
472
+
473
+ <div className="kb-modal-actions">
474
+ {card.status === 'error' && (
475
+ <button type="button" className="kb-btn kb-btn-primary" disabled={busy} onClick={() => { void act(() => api.retry(card!.id)) }}>
476
+ {t('retry')}
477
+ </button>
478
+ )}
479
+ {(card.status === 'running' || card.status === 'merging') && (
480
+ <button type="button" className="kb-btn" disabled={busy} onClick={() => { void act(() => api.stop(card!.id)) }}>
481
+ {t('stop')}
482
+ </button>
483
+ )}
484
+ {card.status !== 'running' && card.status !== 'refining' && card.status !== 'merging' && (
485
+ <button type="button" className="kb-btn kb-btn-danger" disabled={busy} onClick={() => { void remove() }}>
486
+ {t('delete')}
487
+ </button>
488
+ )}
489
+ <button type="button" className="kb-btn" onClick={onClose}>{t('cancel')}</button>
490
+ </div>
491
+ </div>
492
+ </div>
493
+ )
494
+ }
495
+
496
+ function PhaseSessions({ phaseIndex, card, t, onOpenSession }: {
497
+ phaseIndex: number
498
+ card: KanbanCard
499
+ t: (key: string) => string
500
+ onOpenSession: (sessionId: string) => void
501
+ }): JSX.Element | null {
502
+ const attempts = card.sessions.phases.filter((a) => a.phaseIndex === phaseIndex)
503
+ if (attempts.length === 0) return null
504
+ return (
505
+ <div className="kb-session-links">
506
+ {attempts.map((a) => (
507
+ <span key={a.sessionId} className="kb-session-attempt">
508
+ <button type="button" className="kb-btn kb-btn-small" onClick={() => onOpenSession(a.sessionId)}>
509
+ {t('openSession')} · {a.sessionId.slice(0, 13)}
510
+ </button>
511
+ {a.summary !== undefined && <span className="kb-session-summary">{a.summary}</span>}
512
+ </span>
513
+ ))}
514
+ </div>
515
+ )
516
+ }
@@ -0,0 +1,47 @@
1
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
2
+ import type {} from '@deepseek-ai/dsh-client-locale/client'
3
+ import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
4
+ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
5
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
6
+ import { BoardRoot } from './board'
7
+ import { KanbanFooterButton, KanbanSettingsSection } from './sections'
8
+ import { LOCALE_NS, zh, en } from './locales'
9
+ import { setClient, setBoardRoot, bindSessionNavigation } from './kanban-state'
10
+
11
+ /** Services required before mounting (provided by the client runtime). */
12
+ export const inject = ['slots', 'sessions', 'workspaces', 'locale']
13
+
14
+ /** Client plugin body. */
15
+ export function apply(ctx: ClientContext): void {
16
+ setClient(ctx)
17
+ setBoardRoot(BoardRoot as unknown as (props: Record<string, unknown>) => React.ReactNode)
18
+ // Selecting a session while the board is open must leave the board first.
19
+ bindSessionNavigation(ctx)
20
+
21
+ // Live translate bound to the active locale (labels re-read it per call).
22
+ const t = ctx.locale.bind(LOCALE_NS)
23
+ ctx.effect(() => {
24
+ const off = ctx.locale.register(LOCALE_NS, { zh, en })
25
+ return () => off()
26
+ }, 'task-kanban: dictionaries')
27
+
28
+ ctx.slots.inject('sidebar.footer.action', () =>
29
+ ctx.slots.register({
30
+ name: 'sidebar.footer.action',
31
+ id: 'task-kanban',
32
+ order: 60,
33
+ label: () => t('kanban'),
34
+ locale: LOCALE_NS,
35
+ }, KanbanFooterButton as never),
36
+ )
37
+
38
+ ctx.slots.inject('settings.section', () =>
39
+ ctx.slots.register({
40
+ name: 'settings.section',
41
+ id: 'task-kanban',
42
+ order: 200,
43
+ label: () => t('settingsTitle'),
44
+ locale: LOCALE_NS,
45
+ }, KanbanSettingsSection as never),
46
+ )
47
+ }
@@ -0,0 +1,104 @@
1
+ import { useSyncExternalStore } from 'react'
2
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
3
+
4
+ let client: ClientContext | null = null
5
+ export function setClient(ctx: ClientContext | null): void {
6
+ client = ctx
7
+ }
8
+ export function getClient(): ClientContext | null {
9
+ return client
10
+ }
11
+
12
+ let boardRoot: ((props: Record<string, unknown>) => React.ReactNode) | null = null
13
+ export function setBoardRoot(component: (props: Record<string, unknown>) => React.ReactNode): void {
14
+ boardRoot = component
15
+ }
16
+ export function getBoardRoot(): ((props: Record<string, unknown>) => React.ReactNode) | null {
17
+ return boardRoot
18
+ }
19
+
20
+ let boardDisposer: (() => void) | null = null
21
+ let boardOpen = false
22
+ const listeners = new Set<() => void>()
23
+ function notify(): void {
24
+ for (const l of listeners) l()
25
+ }
26
+ export function isBoardOpen(): boolean {
27
+ return boardOpen
28
+ }
29
+ function subscribe(cb: () => void): () => void {
30
+ listeners.add(cb)
31
+ return () => { listeners.delete(cb) }
32
+ }
33
+ export function useBoardOpen(): boolean {
34
+ return useSyncExternalStore(subscribe, isBoardOpen)
35
+ }
36
+
37
+ export function enterBoard(): void {
38
+ const ctx = client
39
+ const root = boardRoot
40
+ if (ctx === null || root === null || boardDisposer !== null) return
41
+ try {
42
+ boardDisposer = ctx.slots.register({ name: 'conversation', priority: -1, locale: 'task-kanban' } as never, root as never)
43
+ boardOpen = true
44
+ notify()
45
+ } catch (error) {
46
+ console.error('[@fonlan/dsh-task-kanban] cannot open the board:', error)
47
+ }
48
+ }
49
+
50
+ export function exitBoard(): void {
51
+ if (boardDisposer !== null) {
52
+ boardDisposer()
53
+ boardDisposer = null
54
+ }
55
+ if (boardOpen) {
56
+ boardOpen = false
57
+ notify()
58
+ }
59
+ }
60
+
61
+ export function toggleBoard(): void {
62
+ if (boardOpen) exitBoard()
63
+ else enterBoard()
64
+ }
65
+
66
+ /**
67
+ * Route session navigation through the board: opening any session while the
68
+ * board is open leaves the board first, so the conversation view can render
69
+ * for the newly selected session.
70
+ *
71
+ * Every sidebar path that selects a session funnels through `sessions.open`
72
+ * (session rows, search results, fork results, New Session) or
73
+ * `sessions.openSubagent` (catalog children) — including re-clicking the
74
+ * already-current session, which never changes `list.current` and therefore
75
+ * cannot be caught by a list-store subscription. Wrapping the two entry
76
+ * points covers all of them in one place.
77
+ *
78
+ * The wrapper is idempotent: a `kbBound` marker on the wrapper prevents a
79
+ * second bind (plugin re-apply / HMR) from stacking another layer.
80
+ */
81
+ export function bindSessionNavigation(ctx: ClientContext): void {
82
+ const sessions = ctx.sessions
83
+ if (sessions === undefined) return
84
+
85
+ const open = sessions.open
86
+ if (typeof open === 'function' && (open as unknown as { kbBound?: boolean }).kbBound !== true) {
87
+ const bound = ((id: Parameters<typeof open>[0]) => {
88
+ exitBoard()
89
+ return open.call(sessions, id)
90
+ }) as typeof open
91
+ ;(bound as unknown as { kbBound?: boolean }).kbBound = true
92
+ sessions.open = bound
93
+ }
94
+
95
+ const openSubagent = sessions.openSubagent
96
+ if (typeof openSubagent === 'function' && (openSubagent as unknown as { kbBound?: boolean }).kbBound !== true) {
97
+ const bound = ((address: Parameters<typeof openSubagent>[0]) => {
98
+ exitBoard()
99
+ return openSubagent.call(sessions, address)
100
+ }) as typeof openSubagent
101
+ ;(bound as unknown as { kbBound?: boolean }).kbBound = true
102
+ sessions.openSubagent = bound
103
+ }
104
+ }