@fonlan/dsh-task-kanban 0.2.6 → 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,143 +1,17 @@
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
10
- }
11
-
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
- )
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} />
141
15
  }
142
16
 
143
17
  interface SettingsSectionProps {
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
 
@@ -1,27 +1,28 @@
1
1
  /**
2
2
  * User-facing plugin settings (global parallel worker count, per-session-type
3
- * model/effort/preset defaults), persisted through the settings service
4
- * namespace `task-kanban`. The DSH settings service requires a lowercase
5
- * kebab-case namespace (/^[a-z][a-z0-9-]*$/), so the scoped package name
6
- * cannot be used here.
3
+ * model/effort/preset defaults), carried by this bundle's entry config: dsh
4
+ * >= 0.1.7 owns plugin settings as entry config (the plugin's `Config`
5
+ * schema), so the face reads the config it was applied with and persists
6
+ * updates through `settings.update`.
7
7
  */
8
8
  import type { Context } from '@deepseek-ai/cordis'
9
9
  import z from '@deepseek-ai/schemastery'
10
10
  import type { KanbanSessionKind, KanbanSettingsShape } from '../shared/card.js'
11
- import type {} from '@deepseek-ai/dsh-settings'
12
11
 
13
12
  export const KANBAN_SETTINGS_NS = 'task-kanban'
14
13
 
15
- export const KanbanSettingsSchema = z.object({
16
- maxParallelWorkers: z.natural().min(1).default(1),
17
- refinementModel: z.string().default(''),
18
- refinementProvider: z.string().default(''),
19
- refinementReasoningEffort: z.string().default(''),
20
- refinementPreset: z.string().default(''),
21
- phaseModel: z.string().default(''),
22
- phaseProvider: z.string().default(''),
23
- phaseReasoningEffort: z.string().default(''),
24
- phasePreset: z.string().default(''),
14
+ export const KanbanSettingsSchema: z<KanbanSettingsShape> = z.object({
15
+ // Volatile: the settings plane (settings page / settings.update) only
16
+ // writes volatile-marked fields on dsh >= 0.1.7.
17
+ maxParallelWorkers: z.natural().min(1).default(1).volatile(),
18
+ refinementModel: z.string().default('').volatile(),
19
+ refinementProvider: z.string().default('').volatile(),
20
+ refinementReasoningEffort: z.string().default('').volatile(),
21
+ refinementPreset: z.string().default('').volatile(),
22
+ phaseModel: z.string().default('').volatile(),
23
+ phaseProvider: z.string().default('').volatile(),
24
+ phaseReasoningEffort: z.string().default('').volatile(),
25
+ phasePreset: z.string().default('').volatile(),
25
26
  })
26
27
 
27
28
  /** The per-session-type default slice a session kind resolves. */
@@ -47,38 +48,59 @@ export interface KanbanSettingsFace {
47
48
 
48
49
  /** Structural settings-service subset; the real service resolves the schema. */
49
50
  interface SettingsServiceLike {
50
- register(ns: unknown, schema: unknown): unknown
51
51
  update(ns: unknown, patch: Record<string, unknown>, expectedRevision?: number): Promise<unknown>
52
52
  }
53
53
 
54
- const EMPTY_DEFAULTS: KanbanSessionDefaults = { model: '', provider: '', reasoningEffort: '', preset: '' }
54
+ /** One config field as the loader hands it over: a stable volatile reference. */
55
+ interface VolatileField<T> {
56
+ get(): T | undefined
57
+ }
55
58
 
56
- export function registerSettings(ctx: Context): KanbanSettingsFace {
57
- let current: KanbanSettingsShape = {
58
- maxParallelWorkers: 1,
59
- refinementModel: '',
60
- refinementProvider: '',
61
- refinementReasoningEffort: '',
62
- refinementPreset: '',
63
- phaseModel: '',
64
- phaseProvider: '',
65
- phaseReasoningEffort: '',
66
- phasePreset: '',
59
+ /**
60
+ * Dereference one config field. `Config` fields marked `.volatile()` are handed
61
+ * to the plugin as references (`{ get() }`) instead of plain values, so reading
62
+ * them raw yields the reference object — which serializes to `{}` and makes
63
+ * every setting look empty. Non-volatile (plain) configs keep working as-is.
64
+ */
65
+ function fieldValue<T>(value: unknown, fallback: T): T {
66
+ if (value !== null && typeof value === 'object' && typeof (value as VolatileField<T>).get === 'function') {
67
+ return (value as VolatileField<T>).get() ?? fallback
67
68
  }
69
+ return (value as T | undefined) ?? fallback
70
+ }
71
+
72
+ const EMPTY_DEFAULTS: KanbanSessionDefaults = { model: '', provider: '', reasoningEffort: '', preset: '' }
73
+
74
+ export function registerSettings(ctx: Context, config: KanbanSettingsShape): KanbanSettingsFace {
75
+ const fields = (config ?? {}) as unknown as Record<string, unknown>
76
+ // Read the references on EVERY access: the settings plane updates them in
77
+ // place (a volatile-only config change does not restart this entry).
78
+ const snapshot = (): KanbanSettingsShape => ({
79
+ maxParallelWorkers: fieldValue(fields.maxParallelWorkers, 1),
80
+ refinementModel: fieldValue(fields.refinementModel, ''),
81
+ refinementProvider: fieldValue(fields.refinementProvider, ''),
82
+ refinementReasoningEffort: fieldValue(fields.refinementReasoningEffort, ''),
83
+ refinementPreset: fieldValue(fields.refinementPreset, ''),
84
+ phaseModel: fieldValue(fields.phaseModel, ''),
85
+ phaseProvider: fieldValue(fields.phaseProvider, ''),
86
+ phaseReasoningEffort: fieldValue(fields.phaseReasoningEffort, ''),
87
+ phasePreset: fieldValue(fields.phasePreset, ''),
88
+ })
89
+ // Optimistic overlay: `settings.update` persists through the profile document
90
+ // and refreshes the references asynchronously, so the write's own caller must
91
+ // still observe the patch it just sent.
92
+ let pending: Partial<KanbanSettingsShape> = {}
93
+ const current = (): KanbanSettingsShape => ({ ...snapshot(), ...pending })
68
94
  let service: SettingsServiceLike | undefined
95
+ // Optional settings service: without one the settings still resolve from
96
+ // the entry config; edits just cannot persist.
69
97
  ctx.inject(['settings'], (sctx) => {
70
- service = sctx.settings as unknown as SettingsServiceLike
71
- const scope = service.register(KANBAN_SETTINGS_NS, KanbanSettingsSchema) as {
72
- get(): KanbanSettingsShape
73
- watch(callback: (next: KanbanSettingsShape) => void): () => void
74
- }
75
- current = scope.get()
76
- scope.watch((next) => { current = next })
98
+ service = (sctx as unknown as { settings?: SettingsServiceLike }).settings
77
99
  })
78
100
  return {
79
- get: () => current,
101
+ get: () => current(),
80
102
  sessionDefaults: (kind) => {
81
- const s = current
103
+ const s = current()
82
104
  switch (kind) {
83
105
  case 'refine':
84
106
  return { model: s.refinementModel, provider: s.refinementProvider, reasoningEffort: s.refinementReasoningEffort, preset: s.refinementPreset }
@@ -108,7 +130,10 @@ export function registerSettings(ctx: Context): KanbanSettingsFace {
108
130
  if (service === undefined) {
109
131
  throw new Error('@fonlan/dsh-task-kanban: settings service is not available in this profile')
110
132
  }
133
+ // The settings plane keys plugin settings by PROFILE ENTRY id (the id in
134
+ // cordis.patch.yml), which is what this plugin ships.
111
135
  await service.update(KANBAN_SETTINGS_NS, patch as Record<string, unknown>)
136
+ pending = { ...pending, ...patch }
112
137
  },
113
138
  }
114
139
  }