@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.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +1128 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1514 -0
- package/lib/types/index.d.ts +14 -0
- package/lib/types/server/git.d.ts +16 -0
- package/lib/types/server/models.d.ts +4 -0
- package/lib/types/server/prompts.d.ts +7 -0
- package/lib/types/server/rpc.d.ts +4 -0
- package/lib/types/server/runner.d.ts +84 -0
- package/lib/types/server/settings.d.ts +27 -0
- package/lib/types/server/task-store.d.ts +27 -0
- package/lib/types/server/tools.d.ts +11 -0
- package/lib/types/shared/card.d.ts +86 -0
- package/lib/types/shared/lanes.d.ts +9 -0
- package/lib/types/shared/plan.d.ts +12 -0
- package/package.json +105 -0
- package/src/client/api.ts +42 -0
- package/src/client/board.css +361 -0
- package/src/client/board.tsx +516 -0
- package/src/client/index.tsx +47 -0
- package/src/client/kanban-state.ts +104 -0
- package/src/client/locales.ts +127 -0
- package/src/client/sections.tsx +209 -0
- package/src/client/slot-augment.d.ts +15 -0
- package/src/index.ts +34 -0
- package/src/server/git.ts +68 -0
- package/src/server/models.ts +20 -0
- package/src/server/prompts.ts +54 -0
- package/src/server/rpc.ts +167 -0
- package/src/server/runner.ts +834 -0
- package/src/server/settings.ts +69 -0
- package/src/server/task-store.ts +128 -0
- package/src/server/tools.ts +131 -0
- package/src/shared/card.ts +115 -0
- package/src/shared/lanes.ts +43 -0
- package/src/shared/plan.ts +54 -0
|
@@ -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
|
+
}
|