@fonlan/dsh-task-kanban 0.2.5 → 0.2.7

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.
@@ -1,44 +1,41 @@
1
- import { useSyncExternalStore } from 'react'
2
1
  import type { Context } from '@deepseek-ai/cordis'
3
2
  type ClientContext = Context
4
3
 
5
4
  /**
6
- * HMR-safe shared state store keyed on the global symbol registry.
5
+ * Panel id shared by this plugin's main-panel key (`main`) and its sidebar
6
+ * entry (`sidebar.panellist`): the shell selects the panel by this id, so the
7
+ * two registrations must agree.
8
+ */
9
+ export const KANBAN_PANEL_ID = 'task-kanban'
10
+
11
+ /**
12
+ * Structural view of the client shell's panel-navigation face (`ctx.layout`).
13
+ * Declared locally (no value import) so the plugin keeps working on a runtime
14
+ * whose layout types differ.
15
+ */
16
+ interface LayoutLike {
17
+ selectPanel(panelId: string | null): void
18
+ }
19
+
20
+ /**
21
+ * HMR-safe owner context, keyed on the global symbol registry.
7
22
  *
8
23
  * The client-hmr hot swap re-evaluates this module (a fresh `lib/client.js`
9
- * bundle) WITHOUT reloading the page. A module-level singleton would then be
10
- * duplicated per evaluation: the session-navigation wrapper installed by the
11
- * OLD module copy keeps calling the OLD copy's `exitBoard`, which reads the
12
- * OLD copy's `boardOpen`/`boardDisposer` — while a board opened after the swap
13
- * writes the NEW copy's state cell. The wrapper would then no-op and clicking
14
- * a sidebar session would never leave the board.
15
- *
16
- * Keeping every field on one `globalThis`-backed record (via `Symbol.for`, so
17
- * all bundle copies resolve the same key) makes every copy read/write the same
18
- * state cell, so exit works regardless of which module copy performed the
19
- * enter.
24
+ * bundle) WITHOUT reloading the page; a module-level cell would then be
25
+ * duplicated per evaluation and an old copy's helper would read a stale owner.
26
+ * `Symbol.for` makes every bundle copy share one cell.
20
27
  */
21
28
  const STATE_KEY = Symbol.for('@fonlan/dsh-task-kanban/state')
22
29
 
23
30
  interface KanbanState {
24
31
  client: ClientContext | null
25
- boardRoot: ((props: Record<string, unknown>) => React.ReactNode) | null
26
- boardDisposer: (() => void) | null
27
- boardOpen: boolean
28
- listeners: Set<() => void>
29
32
  }
30
33
 
31
34
  function getState(): KanbanState {
32
35
  const g = globalThis as unknown as Record<symbol, KanbanState | undefined>
33
36
  let state = g[STATE_KEY]
34
37
  if (state === undefined) {
35
- state = {
36
- client: null,
37
- boardRoot: null,
38
- boardDisposer: null,
39
- boardOpen: false,
40
- listeners: new Set(),
41
- }
38
+ state = { client: null }
42
39
  g[STATE_KEY] = state
43
40
  }
44
41
  return state
@@ -51,80 +48,49 @@ export function getClient(): ClientContext | null {
51
48
  return getState().client
52
49
  }
53
50
 
54
- export function setBoardRoot(component: (props: Record<string, unknown>) => React.ReactNode): void {
55
- getState().boardRoot = component
56
- }
57
- export function getBoardRoot(): ((props: Record<string, unknown>) => React.ReactNode) | null {
58
- return getState().boardRoot
59
- }
60
-
61
- function notify(): void {
62
- for (const listener of getState().listeners) listener()
63
- }
64
- export function isBoardOpen(): boolean {
65
- return getState().boardOpen
66
- }
67
- function subscribe(cb: () => void): () => void {
68
- const listeners = getState().listeners
69
- listeners.add(cb)
70
- return () => { listeners.delete(cb) }
71
- }
72
- export function useBoardOpen(): boolean {
73
- return useSyncExternalStore(subscribe, isBoardOpen)
51
+ /** The shell's panel face, when this runtime provides it. */
52
+ function layoutOf(ctx: ClientContext | null): LayoutLike | null {
53
+ if (ctx === null) return null
54
+ try {
55
+ const layout = (ctx as unknown as { layout?: LayoutLike }).layout
56
+ return typeof layout?.selectPanel === 'function' ? layout : null
57
+ } catch {
58
+ return null
59
+ }
74
60
  }
75
61
 
76
- export function enterBoard(): void {
77
- const state = getState()
78
- const ctx = state.client
79
- const root = state.boardRoot
80
- if (ctx === null || root === null || state.boardDisposer !== null) return
62
+ /** Select the kanban panel. The sidebar's own entry does this; kept for in-app callers. */
63
+ export function showKanbanPanel(ctx: ClientContext | null = getClient()): void {
81
64
  try {
82
- // DSH 0.1.5 moved the replaceable shell from the legacy `conversation`
83
- // seat to `main.conversation`. Register through `inject` so the board
84
- // waits for that owner if the client plugin load order is reversed.
85
- state.boardDisposer = ctx.slots.inject('main.conversation', () =>
86
- ctx.slots.register({ name: 'main.conversation', priority: -1, locale: 'task-kanban' } as never, root as never),
87
- )
88
- state.boardOpen = true
89
- notify()
65
+ layoutOf(ctx)?.selectPanel(KANBAN_PANEL_ID)
90
66
  } catch (error) {
91
- console.error('[@fonlan/dsh-task-kanban] cannot open the board:', error)
67
+ console.error('[@fonlan/dsh-task-kanban] cannot select the kanban panel:', error)
92
68
  }
93
69
  }
94
70
 
95
- export function exitBoard(): void {
96
- const state = getState()
97
- if (state.boardDisposer !== null) {
98
- state.boardDisposer()
99
- state.boardDisposer = null
100
- }
101
- if (state.boardOpen) {
102
- state.boardOpen = false
103
- notify()
71
+ /** Hand the center column back to the Conversation. */
72
+ export function leaveKanbanPanel(ctx: ClientContext | null = getClient()): void {
73
+ try {
74
+ layoutOf(ctx)?.selectPanel(null)
75
+ } catch (error) {
76
+ console.error('[@fonlan/dsh-task-kanban] cannot leave the kanban panel:', error)
104
77
  }
105
78
  }
106
79
 
107
- export function toggleBoard(): void {
108
- if (isBoardOpen()) exitBoard()
109
- else enterBoard()
110
- }
111
-
112
80
  /**
113
- * Route session navigation through the board: opening any session while the
114
- * board is open leaves the board first, so the conversation view can render
115
- * for the newly selected session.
81
+ * Route session navigation through the panel selection: opening any session
82
+ * while the board is the active panel first returns the center column to the
83
+ * Conversation, so the session view renders.
116
84
  *
117
85
  * Every sidebar path that selects a session funnels through `sessions.open`
118
86
  * (session rows, search results, fork results, New Session) or
119
87
  * `sessions.openSubagent` (catalog children) — including re-clicking the
120
88
  * already-current session, which never changes `list.current` and therefore
121
- * cannot be caught by a list-store subscription. Wrapping the two entry
122
- * points covers all of them in one place.
89
+ * cannot be caught by a list-store subscription. Wrapping the two entry points
90
+ * covers all of them in one place.
123
91
  *
124
92
  * The wrapper is idempotent: a `kbBound` marker on the wrapper prevents a
125
- * second bind (plugin re-apply / HMR) from stacking another layer. Because the
126
- * wrapper's exit goes through the shared state store, a wrapper left over from
127
- * a previous (HMR-replaced) module copy still closes the board correctly.
93
+ * second bind (plugin re-apply / HMR) from stacking another layer.
128
94
  */
129
95
  export function bindSessionNavigation(ctx: ClientContext): void {
130
96
  const sessions = ctx.sessions
@@ -133,7 +99,7 @@ export function bindSessionNavigation(ctx: ClientContext): void {
133
99
  const open = sessions.open
134
100
  if (typeof open === 'function' && (open as unknown as { kbBound?: boolean }).kbBound !== true) {
135
101
  const bound = ((id: Parameters<typeof open>[0]) => {
136
- exitBoard()
102
+ leaveKanbanPanel(ctx)
137
103
  return open.call(sessions, id)
138
104
  }) as typeof open
139
105
  ;(bound as unknown as { kbBound?: boolean }).kbBound = true
@@ -143,10 +109,10 @@ export function bindSessionNavigation(ctx: ClientContext): void {
143
109
  const openSubagent = sessions.openSubagent
144
110
  if (typeof openSubagent === 'function' && (openSubagent as unknown as { kbBound?: boolean }).kbBound !== true) {
145
111
  const bound = ((address: Parameters<typeof openSubagent>[0]) => {
146
- exitBoard()
112
+ leaveKanbanPanel(ctx)
147
113
  return openSubagent.call(sessions, address)
148
114
  }) as typeof openSubagent
149
115
  ;(bound as unknown as { kbBound?: boolean }).kbBound = true
150
116
  sessions.openSubagent = bound
151
117
  }
152
- }
118
+ }
@@ -53,9 +53,7 @@ export const zh = {
53
53
  noWorkspace: '没有可用工作区,请先在侧边栏添加',
54
54
  refresh: '刷新',
55
55
  settingsTitle: '任务看板',
56
- settingsDescription: '全局并行 worker 数与各会话类型的默认模型、推理强度、Agent 预设',
57
- settingsExpand: '展开设置',
58
- settingsCollapse: '收起设置',
56
+ sectionSub: '全局并行 worker 数与各会话类型的默认模型、推理强度、Agent 预设',
59
57
  maxParallelWorkers: '全局并行 worker 数',
60
58
  settingsRefineGroup: '细化需求会话默认',
61
59
  settingsPhaseGroup: 'Phase 实现会话默认',
@@ -129,9 +127,7 @@ export const en = {
129
127
  noWorkspace: 'No workspaces available — add one in the sidebar first',
130
128
  refresh: 'Refresh',
131
129
  settingsTitle: 'Task Kanban',
132
- settingsDescription: 'Global parallel workers and per-session-type defaults for model, reasoning effort, and agent preset',
133
- settingsExpand: 'Show settings',
134
- settingsCollapse: 'Hide settings',
130
+ sectionSub: 'Global parallel workers and per-session-type defaults for model, reasoning effort, and agent preset',
135
131
  maxParallelWorkers: 'Global parallel workers',
136
132
  settingsRefineGroup: 'Refinement session defaults',
137
133
  settingsPhaseGroup: 'Phase implementation defaults',
@@ -1,146 +1,20 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react'
2
- import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
2
+ import { ChecklistIcon } from './icons'
3
3
  import { api, type PresetOption, type ReasoningOptions } from './api'
4
- import { toggleBoard, useBoardOpen } from './kanban-state'
5
4
  import type { KanbanSettingsShape, ModelOption } from '../shared/card'
6
5
 
7
- interface FooterActionProps {
8
- wide?: boolean
9
- t?: (key: string) => string
6
+ /**
7
+ * Sidebar panel-row glyph for the board (`sidebar.panellist`).
8
+ *
9
+ * The shell owns the row: it renders the button, its label, tooltip and active
10
+ * state, and calls `layout.selectPanel(id)` on click. The entry contributes
11
+ * only the glyph, so this component must stay purely presentational.
12
+ */
13
+ export function KanbanPanelIcon({ size }: { size: number }): JSX.Element {
14
+ return <ChecklistIcon size={size} />
10
15
  }
11
16
 
12
- /** Sidebar footer entry: toggles the kanban board main view.
13
- * The shell renders footer actions on their own row ABOVE the settings row,
14
- * so the button is reparented into the settings area to sit on the same
15
- * line, right of the Settings trigger (see board.css .kb-settings-row). */
16
- export function KanbanFooterButton({ wide, t }: FooterActionProps): JSX.Element {
17
- const open = useBoardOpen()
18
- const ref = useRef<HTMLButtonElement | null>(null)
19
- const label = t !== undefined ? t(open ? 'closeBoard' : 'openBoard') : 'Task Kanban'
20
-
21
- useEffect(() => {
22
- const btn = ref.current
23
- if (btn === null) return
24
- let foot: Element | null = null
25
- let disposed = false
26
-
27
- // Locate the sidebar foot area WITHOUT moving any DOM: it is the ancestor
28
- // of the button whose LAST child contains the settings trigger and which
29
- // is itself the last child of its parent (root > footArea > settingsArea).
30
- // The slot machinery wraps entries in an anonymous div, so the search
31
- // tolerates extra wrapper levels. Styling is pure CSS (see board.css
32
- // .kb-foot-row), so a failed lookup only leaves the button in its natural
33
- // slot row — it can never vanish.
34
- const findFoot = (): Element | null => {
35
- let el: Element | null = btn.parentElement
36
- while (el !== null && el !== document.body && el.parentElement !== null) {
37
- const last = el.lastElementChild
38
- const hasTrigger =
39
- last !== null &&
40
- (last.querySelector?.('button[aria-haspopup="dialog"]') !== null ||
41
- last.querySelector?.('button[aria-haspopup="menu"]') !== null)
42
- const isLastChild = el.parentElement.lastElementChild === el
43
- if (hasTrigger && isLastChild) return el
44
- el = el.parentElement
45
- }
46
- return null
47
- }
48
-
49
- const tryApply = (): boolean => {
50
- if (foot === null) {
51
- try {
52
- foot = findFoot()
53
- } catch {
54
- foot = null
55
- }
56
- }
57
- if (foot === null) return false
58
- try {
59
- foot.classList.add('kb-foot-row')
60
- foot.classList.toggle('kb-foot-rail', wide !== true)
61
- } catch {
62
- // element already gone; retry will re-locate
63
- }
64
- return true
65
- }
66
-
67
- let applied = false
68
- try {
69
- applied = tryApply()
70
- } catch (error) {
71
- console.error('[@fonlan/dsh-task-kanban] foot placement error:', error)
72
- }
73
- if (!applied) {
74
- // The settings entry may mount later than this entry (plugin load
75
- // order); retry on DOM mutations AND on a short interval until the
76
- // foot area is found (give up after 15s).
77
- const observer = new MutationObserver(() => {
78
- if (disposed) return
79
- if (tryApply()) observer.disconnect()
80
- })
81
- observer.observe(document.body, { childList: true, subtree: true })
82
- const timer = window.setInterval(() => {
83
- if (disposed) {
84
- window.clearInterval(timer)
85
- return
86
- }
87
- if (tryApply()) {
88
- observer.disconnect()
89
- window.clearInterval(timer)
90
- }
91
- }, 1000)
92
- window.setTimeout(() => {
93
- observer.disconnect()
94
- window.clearInterval(timer)
95
- if (foot === null) {
96
- // report the DOM chain so the next report carries evidence
97
- const chain: string[] = []
98
- let el: Element | null = btn.parentElement
99
- while (el !== null && chain.length < 8) {
100
- chain.push(el.tagName + '.' + String(el.className).slice(0, 60))
101
- el = el.parentElement
102
- }
103
- console.warn('[@fonlan/dsh-task-kanban] could not locate the sidebar foot area:', chain)
104
- }
105
- }, 15000)
106
- return () => {
107
- disposed = true
108
- observer.disconnect()
109
- window.clearInterval(timer)
110
- try {
111
- foot?.classList.remove('kb-foot-row')
112
- foot?.classList.remove('kb-foot-rail')
113
- } catch {
114
- // already gone
115
- }
116
- }
117
- }
118
- return () => {
119
- try {
120
- foot?.classList.remove('kb-foot-row')
121
- foot?.classList.remove('kb-foot-rail')
122
- } catch {
123
- // already gone
124
- }
125
- }
126
- }, [wide])
127
-
128
- return (
129
- <button
130
- ref={ref}
131
- type="button"
132
- className={'kb-footer-action' + (wide === true ? ' kb-footer-action-wide' : '') + (open ? ' kb-footer-action-active' : '')}
133
- aria-label={label}
134
- title={label}
135
- onClick={() => toggleBoard()}
136
- >
137
- <IconChecklistOutline14 size={wide === true ? 16 : 18} />
138
- {wide === true && <span className="kb-footer-label">{t !== undefined ? t('kanban') : 'Kanban'}</span>}
139
- </button>
140
- )
141
- }
142
-
143
- interface SettingsCardProps {
17
+ interface SettingsSectionProps {
144
18
  t?: (key: string) => string
145
19
  }
146
20
 
@@ -247,11 +121,9 @@ function SessionDefaultsGroup({ kind, settings, models, presets, efforts, tr, on
247
121
  )
248
122
  }
249
123
 
250
- /** Plugin settings card (设置 → 插件配置): workers + default model + per-session-type defaults. */
251
- export function KanbanSettingsCard({ t }: SettingsCardProps): JSX.Element {
124
+ /** Plugin settings page (设置 -> 侧栏「任务看板」): workers + default model + per-session-type defaults. */
125
+ export function KanbanSettingsSection({ t }: SettingsSectionProps): JSX.Element {
252
126
  const tr = t ?? ((key: string) => key)
253
- // Card-local disclosure: collapsed by default, like the built-in plugin cards.
254
- const [open, setOpen] = useState(false)
255
127
  const [settings, setSettings] = useState<KanbanSettingsShape>(EMPTY_SETTINGS)
256
128
  const [models, setModels] = useState<ModelOption[]>([])
257
129
  const [presets, setPresets] = useState<PresetOption[]>([])
@@ -339,67 +211,49 @@ export function KanbanSettingsCard({ t }: SettingsCardProps): JSX.Element {
339
211
  }, [efforts])
340
212
 
341
213
  return (
342
- <li className="kb-settings-card" data-open={open ? '' : undefined}>
343
- <button
344
- type="button"
345
- className="kb-settings-card-head"
346
- aria-expanded={open}
347
- aria-label={(open ? tr('settingsCollapse') : tr('settingsExpand')) + ':' + tr('settingsTitle')}
348
- onClick={() => setOpen(!open)}
349
- >
350
- <span className="kb-settings-card-headText">
351
- <span className="kb-settings-card-title">{tr('settingsTitle')}</span>
352
- <span className="kb-settings-card-sub">{tr('settingsDescription')}</span>
353
- </span>
354
- <span className="kb-settings-card-chevron" data-open={open ? '' : undefined} aria-hidden="true">
355
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
356
- <path d="M3.5 5.25L7 8.75L10.5 5.25" stroke="currentColor" strokeWidth="1.5"
357
- strokeLinecap="round" strokeLinejoin="round" />
358
- </svg>
359
- </span>
360
- </button>
361
- {open && (
362
- <div className="kb-settings-card-body">
363
- <div className="kb-settings">
364
- <label className="kb-field kb-field-row">
365
- <span>{tr('maxParallelWorkers')}</span>
366
- <input
367
- className="kb-input kb-input-number"
368
- type="number"
369
- min={1}
370
- step={1}
371
- value={settings.maxParallelWorkers}
372
- onChange={(e) => {
373
- const value = Math.max(1, Math.floor(Number(e.target.value) || 1))
374
- setSettings((s) => ({ ...s, maxParallelWorkers: value }))
375
- void commit({ maxParallelWorkers: value })
376
- }}
377
- />
378
- </label>
379
- <SessionDefaultsGroup
380
- kind="refinement"
381
- settings={settings}
382
- models={models}
383
- presets={presets}
384
- efforts={efforts}
385
- tr={tr}
386
- onCommit={commit}
387
- loadEfforts={loadEfforts}
388
- />
389
- <SessionDefaultsGroup
390
- kind="phase"
391
- settings={settings}
392
- models={models}
393
- presets={presets}
394
- efforts={efforts}
395
- tr={tr}
396
- onCommit={commit}
397
- loadEfforts={loadEfforts}
398
- />
399
- {saved && <div className="kb-settings-saved">{tr('saved')}</div>}
400
- </div>
401
- </div>
402
- )}
403
- </li>
214
+ <div className="kb-page">
215
+ <header className="kb-page-head">
216
+ <h3 className="kb-page-title">{tr('settingsTitle')}</h3>
217
+ <p className="kb-page-sub">{tr('sectionSub')}</p>
218
+ </header>
219
+ <div className="kb-settings">
220
+ <label className="kb-field kb-field-row">
221
+ <span>{tr('maxParallelWorkers')}</span>
222
+ <input
223
+ className="kb-input kb-input-number"
224
+ type="number"
225
+ min={1}
226
+ step={1}
227
+ value={settings.maxParallelWorkers}
228
+ onChange={(e) => {
229
+ const value = Math.max(1, Math.floor(Number(e.target.value) || 1))
230
+ setSettings((s) => ({ ...s, maxParallelWorkers: value }))
231
+ void commit({ maxParallelWorkers: value })
232
+ }}
233
+ />
234
+ </label>
235
+ <SessionDefaultsGroup
236
+ kind="refinement"
237
+ settings={settings}
238
+ models={models}
239
+ presets={presets}
240
+ efforts={efforts}
241
+ tr={tr}
242
+ onCommit={commit}
243
+ loadEfforts={loadEfforts}
244
+ />
245
+ <SessionDefaultsGroup
246
+ kind="phase"
247
+ settings={settings}
248
+ models={models}
249
+ presets={presets}
250
+ efforts={efforts}
251
+ tr={tr}
252
+ onCommit={commit}
253
+ loadEfforts={loadEfforts}
254
+ />
255
+ {saved && <div className="kb-settings-saved">{tr('saved')}</div>}
256
+ </div>
257
+ </div>
404
258
  )
405
259
  }
package/src/index.ts CHANGED
@@ -7,20 +7,22 @@
7
7
  * serves the fenced JSON API the board UI calls.
8
8
  */
9
9
  import type { Context } from '@deepseek-ai/cordis'
10
- import z from '@deepseek-ai/schemastery'
11
- import { registerSettings } from './server/settings.js'
10
+ import { KanbanSettingsSchema, registerSettings } from './server/settings.js'
12
11
  import { TaskStore } from './server/task-store.js'
13
12
  import { KanbanRunner } from './server/runner.js'
14
13
  import { registerApiRoutes } from './server/rpc.js'
14
+ import type { KanbanSettingsShape } from './shared/card.js'
15
15
 
16
16
  export const name = '@fonlan/dsh-task-kanban'
17
17
 
18
18
  export const inject = ['webServer', 'agents', 'skills']
19
19
 
20
- export const Config = z.object({})
20
+ // The entry config is the settings document (worker count + per-session-type
21
+ // defaults); a changed config restarts this entry.
22
+ export const Config = KanbanSettingsSchema
21
23
 
22
- export function apply(ctx: Context, _config: unknown): void {
23
- const settings = registerSettings(ctx)
24
+ export function apply(ctx: Context, config: KanbanSettingsShape): void {
25
+ const settings = registerSettings(ctx, config)
24
26
  const store = new TaskStore()
25
27
  const runner = new KanbanRunner(ctx, store, settings)
26
28