@fonlan/dsh-task-kanban 0.1.1 → 0.2.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.
@@ -21,14 +21,20 @@
21
21
  .kb-board-header {
22
22
  display: flex;
23
23
  align-items: center;
24
- justify-content: space-between;
25
24
  gap: 12px;
26
25
  padding-bottom: 10px;
27
26
  border-bottom: 1px solid rgba(128, 128, 128, 0.25);
28
27
  flex: 0 0 auto;
29
28
  }
30
- .kb-board-actions { display: flex; gap: 8px; align-items: center; flex: none; }
31
- .kb-board-actions .kb-btn { flex: none; }
29
+ /* Left-aligned group: workspace selector + new-task button sit adjacent. */
30
+ .kb-board-title {
31
+ display: flex;
32
+ align-items: center;
33
+ gap: 8px;
34
+ min-width: 0;
35
+ flex: 0 1 auto;
36
+ }
37
+ .kb-board-title .kb-btn { flex: none; }
32
38
  .kb-ws-select {
33
39
  max-width: 320px;
34
40
  min-width: 0;
@@ -302,16 +308,46 @@
302
308
  .kb-status-error { background: rgba(239, 68, 68, 0.3) !important; color: #f87171; }
303
309
  .kb-status-merged { background: rgba(34, 197, 94, 0.3) !important; }
304
310
  .kb-plan-summary { font-size: 13px; margin-bottom: 10px; }
305
- .kb-plan-phases { margin: 0; padding-left: 20px; }
306
- .kb-plan-phase { margin-bottom: 10px; }
307
- .kb-plan-phase-current .kb-plan-phase-title { color: #60a5fa; }
308
- .kb-plan-phase-head { display: flex; gap: 8px; align-items: baseline; }
311
+ /* Phase cards: each plan phase is a card with an ordered number (kept from the
312
+ parent <ol>) and three labeled sections (goal / sessions / conclusion) split
313
+ by section titles and thin divider lines. */
314
+ .kb-plan-phases { margin: 0; padding-left: 24px; }
315
+ .kb-plan-phase {
316
+ margin-bottom: 10px;
317
+ padding: 8px 10px;
318
+ background: rgba(128, 128, 128, 0.08);
319
+ border: 1px solid rgba(128, 128, 128, 0.2);
320
+ border-radius: 8px;
321
+ }
322
+ .kb-plan-phase-head {
323
+ display: flex;
324
+ gap: 8px;
325
+ align-items: baseline;
326
+ padding-bottom: 6px;
327
+ margin-bottom: 6px;
328
+ border-bottom: 1px solid rgba(128, 128, 128, 0.16);
329
+ }
309
330
  .kb-plan-phase-title { font-weight: 600; font-size: 13px; }
310
331
  .kb-plan-phase-id { font-size: 11px; opacity: 0.5; }
311
- .kb-plan-phase-goal { font-size: 12px; opacity: 0.85; margin: 2px 0 6px; white-space: pre-wrap; }
332
+ .kb-plan-phase-body { display: flex; flex-direction: column; }
333
+ /* Section isolation: label on top, thin divider between adjacent sections. */
334
+ .kb-plan-phase-section { display: flex; flex-direction: column; }
335
+ .kb-plan-phase-section + .kb-plan-phase-section {
336
+ margin-top: 6px;
337
+ padding-top: 6px;
338
+ border-top: 1px solid rgba(128, 128, 128, 0.14);
339
+ }
340
+ .kb-plan-phase-section-title { font-size: 11px; opacity: 0.55; margin-bottom: 3px; }
341
+ .kb-plan-phase-goal { font-size: 12px; opacity: 0.85; white-space: pre-wrap; word-break: break-all; }
342
+ .kb-plan-phase-sessions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
343
+ .kb-plan-phase-conclusion { font-size: 12px; opacity: 0.85; white-space: pre-wrap; word-break: break-all; }
344
+ .kb-plan-phase-conclusion-item + .kb-plan-phase-conclusion-item { margin-top: 6px; }
345
+ /* Running phase keeps the blue highlight; completed phases get success color. */
346
+ .kb-plan-phase-current { border-color: rgba(59, 130, 246, 0.6); background: rgba(59, 130, 246, 0.08); }
347
+ .kb-plan-phase-current .kb-plan-phase-title { color: #60a5fa; }
348
+ .kb-plan-phase-completed { border-color: rgba(34, 197, 94, 0.45); background: rgba(34, 197, 94, 0.06); }
349
+ .kb-plan-phase-completed .kb-plan-phase-title { color: #4ade80; }
312
350
  .kb-session-links { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
313
- .kb-session-attempt { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
314
- .kb-session-summary { font-size: 12px; opacity: 0.75; }
315
351
  .kb-settings { display: flex; flex-direction: column; gap: 12px; max-width: 420px; }
316
352
  .kb-settings-saved { font-size: 12px; color: #4ade80; }
317
353
  .kb-footer-action {
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
- import type { KanbanCard, Lane } from '../shared/card'
2
+ import type { KanbanCard, Lane, PhaseAttempt, PlanPhase } from '../shared/card'
3
3
  import { DROP_RULES, laneOf } from '../shared/lanes'
4
4
  import { api, gatewaySkillList, type SkillOption } from './api'
5
5
  import { exitBoard, getClient } from './kanban-state'
@@ -189,8 +189,6 @@ export function BoardRoot(props: BoardProps): JSX.Element {
189
189
  <option key={w.workspaceId} value={w.path}>{w.title}</option>
190
190
  ))}
191
191
  </select>
192
- </div>
193
- <div className="kb-board-actions">
194
192
  <button type="button" className="kb-btn kb-btn-primary" onClick={() => setNewTaskOpen(true)}>{t('newTask')}</button>
195
193
  </div>
196
194
  </div>
@@ -564,14 +562,14 @@ function DetailPanel({ card, t, onClose, onChanged, onToast, onOpenSession }: De
564
562
  <div className="kb-plan-summary">{card.plan.summary}</div>
565
563
  <ol className="kb-plan-phases">
566
564
  {card.plan.phases.map((phase, i) => (
567
- <li key={phase.id} className={'kb-plan-phase' + (i === card.currentPhase && card.status === 'running' ? ' kb-plan-phase-current' : '')}>
568
- <div className="kb-plan-phase-head">
569
- <span className="kb-plan-phase-title">{phase.title}</span>
570
- <span className="kb-plan-phase-id">{phase.id}</span>
571
- </div>
572
- <div className="kb-plan-phase-goal">{phase.goal}</div>
573
- <PhaseSessions phaseIndex={i} card={card} t={t} onOpenSession={onOpenSession} />
574
- </li>
565
+ <PhaseCard
566
+ key={phase.id}
567
+ phase={phase}
568
+ phaseIndex={i}
569
+ card={card}
570
+ t={t}
571
+ onOpenSession={onOpenSession}
572
+ />
575
573
  ))}
576
574
  </ol>
577
575
  </div>
@@ -628,23 +626,68 @@ function DetailPanel({ card, t, onClose, onChanged, onToast, onOpenSession }: De
628
626
  )
629
627
  }
630
628
 
631
- function PhaseSessions({ phaseIndex, card, t, onOpenSession }: {
629
+ /** One plan phase rendered as a card with goal / sessions / conclusion sections. */
630
+ function PhaseCard({ phase, phaseIndex, card, t, onOpenSession }: {
631
+ phase: PlanPhase
632
632
  phaseIndex: number
633
633
  card: KanbanCard
634
- t: (key: string) => string
634
+ t: (key: string, params?: Record<string, unknown>) => string
635
635
  onOpenSession: (sessionId: string) => void
636
- }): JSX.Element | null {
636
+ }): JSX.Element {
637
637
  const attempts = card.sessions.phases.filter((a) => a.phaseIndex === phaseIndex)
638
- if (attempts.length === 0) return null
638
+ const summaries = attempts
639
+ .filter((a) => a.summary !== undefined && a.summary.trim() !== '')
640
+ .map((a) => a.summary as string)
641
+ const isCurrent = phaseIndex === card.currentPhase && card.status === 'running'
642
+ // A phase with at least one concluded attempt counts as completed.
643
+ const isCompleted = summaries.length > 0
644
+ const cls = 'kb-plan-phase'
645
+ + (isCurrent ? ' kb-plan-phase-current' : '')
646
+ + (isCompleted ? ' kb-plan-phase-completed' : '')
647
+ return (
648
+ <li className={cls}>
649
+ <div className="kb-plan-phase-head">
650
+ <span className="kb-plan-phase-title">{phase.title}</span>
651
+ <span className="kb-plan-phase-id">{phase.id}</span>
652
+ </div>
653
+ <div className="kb-plan-phase-body">
654
+ <div className="kb-plan-phase-section">
655
+ <div className="kb-plan-phase-section-title">{t('phaseGoal')}</div>
656
+ <div className="kb-plan-phase-goal">{phase.goal}</div>
657
+ </div>
658
+ {attempts.length > 0 && (
659
+ <div className="kb-plan-phase-section">
660
+ <div className="kb-plan-phase-section-title">{t('phaseSessions')}</div>
661
+ <PhaseSessions attempts={attempts} t={t} onOpenSession={onOpenSession} />
662
+ </div>
663
+ )}
664
+ {summaries.length > 0 && (
665
+ <div className="kb-plan-phase-section">
666
+ <div className="kb-plan-phase-section-title">{t('phaseConclusion')}</div>
667
+ <div className="kb-plan-phase-conclusion">
668
+ {summaries.map((s, idx) => (
669
+ <div key={idx} className="kb-plan-phase-conclusion-item">{s}</div>
670
+ ))}
671
+ </div>
672
+ </div>
673
+ )}
674
+ </div>
675
+ </li>
676
+ )
677
+ }
678
+
679
+ /** Open-session buttons for one phase's attempts (retries append more). */
680
+ function PhaseSessions({ attempts, t, onOpenSession }: {
681
+ attempts: PhaseAttempt[]
682
+ t: (key: string) => string
683
+ onOpenSession: (sessionId: string) => void
684
+ }): JSX.Element {
639
685
  return (
640
- <div className="kb-session-links">
686
+ <div className="kb-plan-phase-sessions">
641
687
  {attempts.map((a) => (
642
- <span key={a.sessionId} className="kb-session-attempt">
643
- <button type="button" className="kb-btn kb-btn-small" onClick={() => onOpenSession(a.sessionId)}>
644
- {t('openSession')} · {a.sessionId.slice(0, 13)}
645
- </button>
646
- {a.summary !== undefined && <span className="kb-session-summary">{a.summary}</span>}
647
- </span>
688
+ <button key={a.sessionId} type="button" className="kb-btn kb-btn-small" onClick={() => onOpenSession(a.sessionId)}>
689
+ {t('openSession')} · {a.sessionId.slice(0, 13)}
690
+ </button>
648
691
  ))}
649
692
  </div>
650
693
  )
@@ -38,6 +38,9 @@ export const zh = {
38
38
  plan: '实现方案',
39
39
  summary: '摘要',
40
40
  phases: '阶段',
41
+ phaseGoal: '目标',
42
+ phaseSessions: '会话',
43
+ phaseConclusion: '结论',
41
44
  error: '错误',
42
45
  requirementLabel: '需求文本',
43
46
  requirementPlaceholder: '描述你要实现的需求……',
@@ -48,7 +51,7 @@ export const zh = {
48
51
  modelPlaceholder: '(使用默认模型)',
49
52
  noWorkspace: '没有可用工作区,请先在侧边栏添加',
50
53
  refresh: '刷新',
51
- settingsTitle: '任务看板设置',
54
+ settingsTitle: '任务看板',
52
55
  maxParallelWorkers: '全局并行 worker 数',
53
56
  defaultModel: '默认模型',
54
57
  saved: '已保存',
@@ -103,6 +106,9 @@ export const en = {
103
106
  plan: 'Plan',
104
107
  summary: 'Summary',
105
108
  phases: 'Phases',
109
+ phaseGoal: 'Goal',
110
+ phaseSessions: 'Sessions',
111
+ phaseConclusion: 'Conclusion',
106
112
  error: 'Error',
107
113
  requirementLabel: 'Requirement',
108
114
  requirementPlaceholder: 'Describe what you want to build…',
@@ -113,7 +119,7 @@ export const en = {
113
119
  modelPlaceholder: '(use default model)',
114
120
  noWorkspace: 'No workspaces available — add one in the sidebar first',
115
121
  refresh: 'Refresh',
116
- settingsTitle: 'Task Kanban Settings',
122
+ settingsTitle: 'Task Kanban',
117
123
  maxParallelWorkers: 'Global parallel workers',
118
124
  defaultModel: 'Default model',
119
125
  saved: 'Saved',
@@ -49,6 +49,11 @@ export class KanbanRunner implements KanbanToolResolver {
49
49
  private merging = new Set<string>()
50
50
  /** Retained agent handles (kept so ended sessions stay in the store/UI). */
51
51
  private handles = new Map<string, AgentHandle[]>()
52
+ /** Whether startup recovery + session indexing ran (deferred until workspaces exist). */
53
+ private recovered = false
54
+ /** Agents that already got their scoped kanban tools (event idempotency). */
55
+ private toolScopedAgents = new WeakSet<object>()
56
+ private agentStartOff: (() => void) | undefined
52
57
 
53
58
  constructor(ctx: Context, store: TaskStore, settings: KanbanSettingsFace) {
54
59
  this.ctx = ctx
@@ -62,10 +67,22 @@ export class KanbanRunner implements KanbanToolResolver {
62
67
 
63
68
  start(): void {
64
69
  this.pumpTimer = setInterval(() => { void this.pump() }, 2500)
70
+ // `agent/session-start` fires for BOTH agents.create (startup) and
71
+ // agents.resume (host re-open after a restart). Kanban tools are scoped
72
+ // per agent, so a resumed refinement/phase/merge session would otherwise
73
+ // lose them — register there instead of only in createAgent's setup.
74
+ this.agentStartOff = this.ctx.on('agent/session-start', (payload: { agent?: { id?: string; ctx?: Context } }) => {
75
+ void this.onAgentSessionStart(payload.agent)
76
+ })
65
77
  }
66
78
 
67
79
  stop(): void {
68
80
  if (this.pumpTimer !== undefined) clearInterval(this.pumpTimer)
81
+ this.pumpTimer = undefined
82
+ if (this.agentStartOff !== undefined) {
83
+ this.agentStartOff()
84
+ this.agentStartOff = undefined
85
+ }
69
86
  }
70
87
 
71
88
  private newSessionId(): SessionId {
@@ -157,6 +174,63 @@ export class KanbanRunner implements KanbanToolResolver {
157
174
  }
158
175
  }
159
176
 
177
+ /**
178
+ * Rebuild sessionId → cardId from the task store. Runs once the workspace
179
+ * registry is available (see pump), so kanban sessions resumed after a
180
+ * restart still resolve their card.
181
+ */
182
+ private async indexSessions(): Promise<void> {
183
+ for (const ws of await this.workspaceList()) {
184
+ const cards = await this.store.list(ws.path)
185
+ for (const card of cards) this.mapCardSessions(card.id, card)
186
+ }
187
+ }
188
+
189
+ private mapCardSessions(cardId: string, card: KanbanCard): void {
190
+ for (const sid of card.sessions.refinement) this.sessionCards.set(sid, cardId)
191
+ for (const attempt of card.sessions.phases) this.sessionCards.set(attempt.sessionId, cardId)
192
+ for (const sid of card.sessions.merge) this.sessionCards.set(sid, cardId)
193
+ }
194
+
195
+ /** Fallback lookup when a session starts before indexSessions ran. */
196
+ private async lookupCardOfSession(sessionId: string): Promise<string | undefined> {
197
+ for (const ws of await this.workspaceList()) {
198
+ const cards = await this.store.list(ws.path)
199
+ for (const card of cards) {
200
+ const hit =
201
+ card.sessions.refinement.includes(sessionId) ||
202
+ card.sessions.phases.some((a) => a.sessionId === sessionId) ||
203
+ card.sessions.merge.includes(sessionId)
204
+ if (hit) {
205
+ this.mapCardSessions(card.id, card)
206
+ return card.id
207
+ }
208
+ }
209
+ }
210
+ return undefined
211
+ }
212
+
213
+ /**
214
+ * The single place kanban tools get registered. `agent/session-start` fires
215
+ * for BOTH `agents.create` (startup) and `agents.resume` (host re-open after
216
+ * a restart), so a resumed refinement/phase/merge session keeps its scoped
217
+ * tools. Idempotent per agent object; non-kanban sessions are skipped.
218
+ */
219
+ private async onAgentSessionStart(agent: { id?: string; ctx?: Context } | undefined): Promise<void> {
220
+ if (agent === undefined || agent.id === undefined || agent.ctx === undefined) return
221
+ if (this.toolScopedAgents.has(agent)) return
222
+ let cardId = this.sessionCards.get(agent.id)
223
+ if (cardId === undefined) cardId = await this.lookupCardOfSession(agent.id)
224
+ if (cardId === undefined) return
225
+ this.sessionCards.set(agent.id, cardId)
226
+ try {
227
+ registerKanbanTools(agent.ctx, this.toolResolver())
228
+ this.toolScopedAgents.add(agent)
229
+ } catch (error) {
230
+ console.error('[task-kanban] register kanban tools for ' + agent.id + ' failed:', String(error))
231
+ }
232
+ }
233
+
160
234
  // ── Card operations (RPC entry points) ────────────────────────────────────
161
235
 
162
236
  async listCards(workspacePath: string): Promise<KanbanCard[]> {
@@ -287,6 +361,26 @@ export class KanbanRunner implements KanbanToolResolver {
287
361
  }
288
362
 
289
363
  async pump(): Promise<void> {
364
+ // Recovery and session indexing need the workspace registry, which may
365
+ // not be populated when the plugin first applies (the old code silently
366
+ // no-opped and left interrupted cards stuck in refining). Retry on the
367
+ // pump tick until the first workspace appears.
368
+ if (!this.recovered) {
369
+ const ws = await this.workspaceList()
370
+ if (ws.length > 0) {
371
+ this.recovered = true
372
+ try {
373
+ await this.recoverInterrupted()
374
+ } catch (error) {
375
+ console.error('[task-kanban] recoverInterrupted failed:', String(error))
376
+ }
377
+ try {
378
+ await this.indexSessions()
379
+ } catch (error) {
380
+ console.error('[task-kanban] indexSessions failed:', String(error))
381
+ }
382
+ }
383
+ }
290
384
  if (this.slots >= this.maxParallel()) return
291
385
  const workspaces = await this.workspaceList()
292
386
  for (const ws of workspaces) {
@@ -340,7 +434,7 @@ export class KanbanRunner implements KanbanToolResolver {
340
434
  c.sessions.refinement.push(sessionId)
341
435
  }, wsPath)
342
436
  try {
343
- const handle = await this.createAgent(sessionId, wsPath, route, this.toolResolver())
437
+ const handle = await this.createAgent(sessionId, wsPath, route)
344
438
  this.keepHandle(cardId, handle)
345
439
  await this.attachRefinementSession(wsPath, sessionId)
346
440
  // The agent scope is the layer where preset skill providers live
@@ -466,7 +560,6 @@ export class KanbanRunner implements KanbanToolResolver {
466
560
  sessionId: SessionId,
467
561
  cwd: string,
468
562
  route: { provider?: string; model?: string },
469
- resolver: KanbanToolResolver,
470
563
  ) {
471
564
  const agentOptions =
472
565
  route.provider !== undefined && route.model !== undefined && route.model !== ''
@@ -476,6 +569,9 @@ export class KanbanRunner implements KanbanToolResolver {
476
569
  sessionId,
477
570
  meta: { cwd },
478
571
  ...(agentOptions !== undefined ? { agentOptions } : {}),
572
+ // Kanban tools are NOT registered here: `agent/session-start` (see
573
+ // start()) fires for both create and resume, keeping them available
574
+ // even when the host re-opens a persisted kanban session.
479
575
  setup: async (agentCtx) => {
480
576
  const agentPresets = agentCtx.get('agentPresets') as
481
577
  | { mount(agentCtx: unknown, id?: string): Promise<unknown> }
@@ -485,7 +581,6 @@ export class KanbanRunner implements KanbanToolResolver {
485
581
  } else {
486
582
  throw new Error('@fonlan/dsh-task-kanban: agent-presets service is not mounted')
487
583
  }
488
- registerKanbanTools(agentCtx, resolver)
489
584
  },
490
585
  })
491
586
  }
@@ -638,7 +733,7 @@ export class KanbanRunner implements KanbanToolResolver {
638
733
  }, wsPath)
639
734
  const route = await this.modelRoute(cardId, wsPath)
640
735
  try {
641
- const handle = await this.createAgent(sessionId, workdir, route, this.toolResolver())
736
+ const handle = await this.createAgent(sessionId, workdir, route)
642
737
  this.keepHandle(cardId, handle)
643
738
  entry.cancel = () => handle.agent.cancel({ kind: 'user' })
644
739
  handle.agent.followup(this.asUserMessage(phasePrompt({ ...(await this.store.get(wsPath, cardId)) ?? { plan } as KanbanCard, plan } as KanbanCard, phaseIndex, workdir)))
@@ -891,7 +986,7 @@ export class KanbanRunner implements KanbanToolResolver {
891
986
  await this.store.mutate(cardId, (c) => { c.sessions.merge.push(sessionId) }, wsPath)
892
987
  const route = await this.modelRoute(cardId, wsPath)
893
988
  try {
894
- const handle = await this.createAgent(sessionId, workdir, route, this.toolResolver())
989
+ const handle = await this.createAgent(sessionId, workdir, route)
895
990
  this.keepHandle(cardId, handle)
896
991
  handle.agent.followup(this.asUserMessage(mergePrompt(workdir, conflicts)))
897
992
  await handle.agent.whenIdle()