@linxin666/dsh-client-ui-task-board 0.3.23 → 0.4.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.
Files changed (37) hide show
  1. package/icon.svg +3 -0
  2. package/lib/client.js +351 -85
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +174 -916
  5. package/lib/types/client/PluginSettingsCard.d.ts +4 -0
  6. package/lib/types/client/TaskBoardSettingsCard.d.ts +5 -5
  7. package/lib/types/client/board/TaskForm.d.ts +6 -1
  8. package/lib/types/client/board/parse-model-pref.d.ts +4 -0
  9. package/lib/types/client/index.d.ts +43 -10
  10. package/lib/types/client/locales.d.ts +1 -0
  11. package/lib/types/client/main-session.d.ts +32 -0
  12. package/lib/types/client/plugin-card-seat.d.ts +103 -0
  13. package/lib/types/client/settings-form.d.ts +17 -17
  14. package/lib/types/core/controller.d.ts +10 -8
  15. package/lib/types/core/session-reuse.d.ts +4 -2
  16. package/lib/types/host-ledger.d.ts +15 -0
  17. package/lib/types/http.d.ts +18 -1
  18. package/lib/types/index.d.ts +72 -17
  19. package/package.json +30 -24
  20. package/src/client/PluginSettingsCard.tsx +7 -2
  21. package/src/client/TaskBoardSettingsCard.tsx +7 -5
  22. package/src/client/board/NewTaskModal.tsx +33 -7
  23. package/src/client/board/TaskForm.tsx +13 -0
  24. package/src/client/board/parse-model-pref.ts +35 -0
  25. package/src/client/board.module.css +31 -12
  26. package/src/client/index.ts +121 -38
  27. package/src/client/locales.ts +2 -0
  28. package/src/client/main-session.ts +43 -0
  29. package/src/client/plugin-card-seat.ts +192 -0
  30. package/src/client/settings-form.ts +34 -31
  31. package/src/client/sidebar-entry-core.ts +3 -3
  32. package/src/client/sidebar-entry.ts +2 -2
  33. package/src/core/controller.ts +12 -8
  34. package/src/core/session-reuse.ts +14 -5
  35. package/src/host-ledger.ts +59 -11
  36. package/src/http.ts +23 -1
  37. package/src/index.ts +97 -52
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-client-ui-task-board",
3
3
  "description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
4
- "version": "0.3.23",
4
+ "version": "0.4.0",
5
+ "icon": "icon.svg",
5
6
  "type": "module",
6
7
  "main": "lib/index.js",
7
8
  "types": "lib/types/index.d.ts",
@@ -23,7 +24,7 @@
23
24
  },
24
25
  "dsh": {
25
26
  "engines": {
26
- "dsh": ">=0.1.5-rc.1"
27
+ "dsh": ">=0.1.7-rc.1"
27
28
  },
28
29
  "bundle": {
29
30
  "patch": "./cordis.patch.yml"
@@ -35,45 +36,49 @@
35
36
  "@deepseek-ai/dsh-client-ui-renderer",
36
37
  "@deepseek-ai/dsh-api-session-controller",
37
38
  "@deepseek-ai/dsh-api-workspace-controller",
38
- "@deepseek-ai/dsh-api-remotes"
39
+ "@deepseek-ai/dsh-api-remotes",
40
+ "@deepseek-ai/dsh-client-ui-workspace"
39
41
  ],
40
42
  "platform": "web"
41
43
  }
42
44
  },
43
45
  "peerDependencies": {
46
+ "@deepseek-ai/dsh": ">=0.1.7-rc.1",
44
47
  "react": "^18.2.0"
45
48
  },
46
49
  "dependencies": {
47
- "schemastery": "^3.18.0"
50
+ "@deepseek-ai/schemastery": "^3.18.4"
48
51
  },
49
52
  "devDependencies": {
50
- "@deepseek-ai/cordis": "^4.0.2",
51
- "@deepseek-ai/dsh-agent": "^0.1.5-rc.1",
52
- "@deepseek-ai/dsh-agent-presets": "^0.1.5-rc.1",
53
- "@deepseek-ai/dsh-api-gateway": "^0.1.5-rc.1",
54
- "@deepseek-ai/dsh-api-remotes": "^0.1.5-rc.1",
55
- "@deepseek-ai/dsh-workspace": "^0.1.5-rc.1",
56
- "@deepseek-ai/dsh-api-session-controller": "^0.1.5-rc.1",
57
- "@deepseek-ai/dsh-api-workspace-controller": "^0.1.5-rc.1",
58
- "@deepseek-ai/dsh-client-connection": "^0.1.5-rc.1",
59
- "@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
60
- "@deepseek-ai/dsh-client-store": "^0.1.5-rc.1",
61
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.1",
62
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.1",
63
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
64
- "@deepseek-ai/dsh-commands": "^0.1.5-rc.1",
65
- "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.1",
66
- "@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
67
- "@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.1",
53
+ "@deepseek-ai/cordis": "^4.0.4",
54
+ "@deepseek-ai/dsh-agent": "^0.1.7-rc.1",
55
+ "@deepseek-ai/dsh-agent-preset-registry": "^0.1.7-rc.1",
56
+ "@deepseek-ai/dsh-api-gateway": "^0.1.7-rc.1",
57
+ "@deepseek-ai/dsh-api-remotes": "^0.1.7-rc.1",
58
+ "@deepseek-ai/dsh-api-session-controller": "^0.1.7-rc.1",
59
+ "@deepseek-ai/dsh-api-workspace-controller": "^0.1.7-rc.1",
60
+ "@deepseek-ai/dsh-client-connection": "^0.1.7-rc.1",
61
+ "@deepseek-ai/dsh-client-locale": "^0.1.7-rc.1",
62
+ "@deepseek-ai/dsh-client-store": "^0.1.7-rc.1",
63
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.1",
64
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.7-rc.1",
65
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.1",
66
+ "@deepseek-ai/dsh-client-ui-workspace": "^0.1.7-rc.1",
67
+ "@deepseek-ai/dsh-commands": "^0.1.7-rc.1",
68
+ "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.1",
69
+ "@deepseek-ai/dsh-settings": "^0.1.7-rc.1",
70
+ "@deepseek-ai/dsh-system-prompt": "^0.1.7-rc.1",
71
+ "@deepseek-ai/dsh-workspace": "^0.1.7-rc.1",
68
72
  "@types/node": "^22.20.0",
69
73
  "@types/react": "~18.3.1",
70
74
  "@types/react-dom": "^18.3.5",
75
+ "@vitest/coverage-v8": "^4.1.11",
71
76
  "jsdom": "^25.0.0",
72
77
  "react": "^18.3.1",
73
78
  "react-dom": "^18.3.1",
74
79
  "tsdown": "0.22.2",
75
80
  "typescript": "~5.7.2",
76
- "vitest": "^3.0.0"
81
+ "vitest": "^4.1.11"
77
82
  },
78
83
  "files": [
79
84
  "lib/**/*.js",
@@ -81,7 +86,8 @@
81
86
  "lib/**/*.d.ts",
82
87
  "lib/**/*.d.ts.map",
83
88
  "src",
84
- "cordis.patch.yml"
89
+ "cordis.patch.yml",
90
+ "icon.svg"
85
91
  ],
86
92
  "license": "Apache-2.0",
87
93
  "repository": {
@@ -64,6 +64,10 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
64
64
  * edits, so the footer would sit there permanently disabled.
65
65
  */
66
66
  hideFooter?: boolean
67
+ /** Render children even when the settings namespace is not exposed by the host. */
68
+ renderChildrenWhenNotExposed?: boolean
69
+ /** Hide the 'not exposed' warning banner when the settings namespace is not exposed. */
70
+ hideNotExposedNotice?: boolean
67
71
  /** The plugin's controls. */
68
72
  children: ReactNode
69
73
  }
@@ -126,14 +130,15 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
126
130
  // official settings allowlist omits third-party namespaces): show a card
127
131
  // that explains the gap instead of vanishing, so a missing card never
128
132
  // reads as a missing plugin.
129
- if (!state.exposed) {
133
+ if (!state.exposed && props.renderChildrenWhenNotExposed !== true) {
134
+ const showNotice = props.hideNotExposedNotice !== true
130
135
  return (
131
136
  <li className={cardClass}>
132
137
  {header}
133
138
  {expanded
134
139
  ? (
135
140
  <div className={css.body}>
136
- <p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p>
141
+ {showNotice ? <p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p> : null}
137
142
  </div>
138
143
  )
139
144
  : null}
@@ -6,7 +6,7 @@
6
6
 
7
7
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
8
8
  import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
9
- import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
9
+ import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client'
10
10
  import { useEffect, useState } from 'react'
11
11
  import type { TaskBoardPowerSnapshot } from '../protocol.ts'
12
12
  import { PluginSettingsCard, BooleanField } from './PluginSettingsCard.tsx'
@@ -40,13 +40,13 @@ export interface TaskBoardSettingsCardFace extends CardActions {
40
40
  }
41
41
  }
42
42
 
43
- /** Bridges the `task-board` scope onto the card's staged form. */
43
+ /** Bridges the `task-board` settings form onto the card's staged form. */
44
44
  export class TaskBoardSettingsCardController {
45
45
  private readonly form: CardForm<TaskBoardSettings>
46
46
  private readonly store: SnapshotStore<TaskBoardSettingsCardState>
47
47
 
48
- /** @param scope - the bound settings scope for the `task-board` namespace. */
49
- constructor(scope: SettingsScope<TaskBoardSettings>) {
48
+ /** @param scope - the bound configuration form for the `task-board` namespace. */
49
+ constructor(scope: ConfigForm<TaskBoardSettings>) {
50
50
  this.form = new CardForm(scope, [
51
51
  booleanField('enabled'),
52
52
  booleanField('announceToAgent'),
@@ -73,7 +73,7 @@ export class TaskBoardSettingsCardController {
73
73
  }
74
74
 
75
75
  /**
76
- * Release the card's scope subscription and bound stores; the slot
76
+ * Release the card's form subscription and bound stores; the slot
77
77
  * disposer calls this on teardown.
78
78
  */
79
79
  dispose(): void {
@@ -126,6 +126,8 @@ export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
126
126
  descriptionKey="settings.description"
127
127
  defaultOpen={false}
128
128
  state={state}
129
+ renderChildrenWhenNotExposed
130
+ hideNotExposedNotice
129
131
  onSave={props.save}
130
132
  onDiscard={props.discard}
131
133
  >
@@ -10,6 +10,7 @@ import { collectKnownTags, TASK_PERMISSIONS, type TaskPermission, type TaskRecor
10
10
  import { t, type TaskBoardKey } from '../locales.ts'
11
11
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
12
12
  import { ModalShell, TaskContentFields, TaskTagFields, cleanTags } from './TaskForm.tsx'
13
+ import { readParseModelPreference, writeParseModelPreference } from './parse-model-pref.ts'
13
14
  import css from '../board.module.css'
14
15
 
15
16
  export interface NewTaskModalProps {
@@ -54,7 +55,9 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
54
55
  // parse face; the section stays hidden otherwise.
55
56
  const [canParse] = useState(controller.getSnapshot().canParseTask === true)
56
57
  const [parseText, setParseText] = useState('')
57
- const [parseModel, setParseModel] = useState('')
58
+ // Issue #1621: start from the model this browser used last, not from the
59
+ // roster's first entry; '' is the Host default and stays valid.
60
+ const [parseModel, setParseModel] = useState(() => readParseModelPreference())
58
61
  const [parsePending, setParsePending] = useState(false)
59
62
  const [parseError, setParseError] = useState<string | undefined>(undefined)
60
63
  const parseAbort = useRef<AbortController | undefined>(undefined)
@@ -67,10 +70,14 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
67
70
  [controller],
68
71
  )
69
72
 
70
- // The model roster arrives asynchronously: default to its first entry, which
71
- // the user can change before parsing.
73
+ // The model roster arrives asynchronously. A remembered model the deployment
74
+ // no longer offers falls back to the Host default; an empty value is the
75
+ // Host-default choice and is never overwritten by the roster (issue #1621).
72
76
  useEffect(() => {
73
- if (parseModel === '' && parseModels.length > 0) setParseModel(parseModels[0]!.id)
77
+ if (parseModel === '' || parseModels.length === 0) return
78
+ if (parseModels.some(option => option.id === parseModel)) return
79
+ setParseModel('')
80
+ writeParseModelPreference('')
74
81
  }, [parseModel, options.models])
75
82
 
76
83
  const runParse = async (): Promise<void> => {
@@ -97,7 +104,13 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
97
104
  }
98
105
  }
99
106
 
100
- const submit = async (): Promise<void> => {
107
+ /**
108
+ * Create the task through the Host, then optionally start it.
109
+ * @param runAfterCreate - true for the "create and run" action: the task is
110
+ * committed either way, and a refused start opens the task instead of
111
+ * reporting the creation as failed.
112
+ */
113
+ const submit = async (runAfterCreate: boolean): Promise<void> => {
101
114
  if (scheduleEnabled) {
102
115
  const cron = scheduleCron.trim()
103
116
  if (cron === '' || !isValidCron(cron)) {
@@ -156,6 +169,14 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
156
169
  await controller.archiveTask(initialTask.id)
157
170
  }
158
171
  }
172
+ if (runAfterCreate) {
173
+ // The task exists from here on, so a refused start (a permission above
174
+ // the session default, a pinned target that went stale) must not read as
175
+ // a failed creation: open the task, whose detail view owns the
176
+ // confirmation step and the refusal message.
177
+ const started = await controller.runTask(task.id)
178
+ if (!started) controller.openTask(task.id)
179
+ }
159
180
  onClose()
160
181
  }
161
182
 
@@ -173,8 +194,9 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
173
194
  error={error}
174
195
  pending={pending}
175
196
  submitLabel={t('new.submit')}
176
- onSubmit={() => { void submit() }}
197
+ onSubmit={() => { void submit(false) }}
177
198
  onClose={onClose}
199
+ secondaryAction={{ label: t('new.createAndRun'), onSubmit: () => { void submit(true) } }}
178
200
  >
179
201
  {canParse && (
180
202
  <section className={css.aiParse} data-dsh-part="ai-parse">
@@ -193,8 +215,12 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
193
215
  className={css.select}
194
216
  value={parseModel}
195
217
  aria-label={t('new.aiParseModel')}
196
- onChange={event => { setParseModel(event.target.value) }}
218
+ onChange={event => {
219
+ setParseModel(event.target.value)
220
+ writeParseModelPreference(event.target.value)
221
+ }}
197
222
  >
223
+ <option value="">{t('exec.model.default')}</option>
198
224
  {parseModels.map(option => (
199
225
  <option key={option.id} value={option.id}>{option.name ?? option.id}</option>
200
226
  ))}
@@ -21,6 +21,7 @@ export function ModalShell({
21
21
  submitLabel,
22
22
  onSubmit,
23
23
  onClose,
24
+ secondaryAction,
24
25
  children,
25
26
  }: {
26
27
  ariaLabel: string
@@ -30,6 +31,8 @@ export function ModalShell({
30
31
  submitLabel: string
31
32
  onSubmit: () => void
32
33
  onClose: () => void
34
+ /** Optional second action beside the primary submit (e.g. create and run). */
35
+ secondaryAction?: { label: string; onSubmit: () => void }
33
36
  children: ReactNode
34
37
  }) {
35
38
  return (
@@ -50,6 +53,16 @@ export function ModalShell({
50
53
  <button type="button" className={css.ghostButton} onClick={onClose}>
51
54
  {t('new.cancel')}
52
55
  </button>
56
+ {secondaryAction !== undefined && (
57
+ <button
58
+ type="button"
59
+ className={css.ghostButton}
60
+ disabled={pending}
61
+ onClick={secondaryAction.onSubmit}
62
+ >
63
+ {secondaryAction.label}
64
+ </button>
65
+ )}
53
66
  <button type="submit" className={css.primaryButton} disabled={pending}>
54
67
  {submitLabel}
55
68
  </button>
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The model the user last picked for AI parsing, remembered per browser so the
3
+ * next new-task modal starts from it instead of the roster's first entry
4
+ * (issue #1621). An empty value is a real choice — "let the Host use its
5
+ * default model" — and is remembered as such.
6
+ */
7
+ const PARSE_MODEL_KEY = 'dsh-task-board.parse-model'
8
+
9
+ /** The nearest browser storage, or undefined when the global itself throws. */
10
+ function defaultStorage(): Storage | undefined {
11
+ try {
12
+ return globalThis.localStorage
13
+ } catch {
14
+ return undefined
15
+ }
16
+ }
17
+
18
+ /** The remembered parse model; '' means the Host default (or no preference). */
19
+ export function readParseModelPreference(storage: Pick<Storage, 'getItem'> | undefined = defaultStorage()): string {
20
+ try {
21
+ return storage?.getItem(PARSE_MODEL_KEY) ?? ''
22
+ } catch {
23
+ // A blocked or full store must never break the form.
24
+ return ''
25
+ }
26
+ }
27
+
28
+ /** Remember the picked parse model; '' records the Host-default choice. */
29
+ export function writeParseModelPreference(model: string, storage: Pick<Storage, 'setItem'> | undefined = defaultStorage()): void {
30
+ try {
31
+ storage?.setItem(PARSE_MODEL_KEY, model)
32
+ } catch {
33
+ // Storage is optional: the picker still works without it.
34
+ }
35
+ }
@@ -50,20 +50,27 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
50
50
 
51
51
  /* --- sidebar entry row ------------------------------------------------------- */
52
52
 
53
+ /* Same geometry as the shell's own panel rows (SidebarRoot .panelRow): 2px
54
+ inset with a 12px radius, 8px content padding, 36px row, 14/22 type, and the
55
+ primary ink — so an injected row and an official row line up column for
56
+ column instead of each carrying its own box. */
53
57
  .entry {
54
58
  box-sizing: border-box;
55
59
  display: flex;
56
60
  align-items: center;
57
61
  gap: 8px;
58
- width: 100%;
59
- height: 36px;
60
- padding: 0 10px;
62
+ min-height: 36px;
63
+ padding: 7px 8px;
64
+ margin: 0 2px;
61
65
  background: transparent;
62
66
  border: none;
63
- border-radius: 8px;
64
- color: var(--dsw-alias-label-secondary);
67
+ border-radius: 12px;
68
+ color: var(--dsw-alias-label-primary);
65
69
  cursor: pointer;
66
- font-size: 13px;
70
+ font: inherit;
71
+ font-size: 14px;
72
+ line-height: 22px;
73
+ text-align: left;
67
74
  white-space: nowrap;
68
75
  }
69
76
 
@@ -78,19 +85,21 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
78
85
  font-weight: 600;
79
86
  }
80
87
 
88
+ /* Icon box matches the shell's panel glyph exactly (16px), so the glyph column
89
+ and the label column land on the official rows' x positions. */
81
90
  .entryIcon {
82
91
  display: inline-flex;
83
92
  align-items: center;
84
93
  justify-content: center;
85
- width: 24px;
86
- height: 24px;
94
+ width: 16px;
95
+ height: 16px;
87
96
  flex: none;
88
97
  }
89
98
 
90
99
  .entryIcon svg {
91
100
  display: block;
92
- width: 18px;
93
- height: 18px;
101
+ width: 16px;
102
+ height: 16px;
94
103
  }
95
104
 
96
105
  .entryLabel {
@@ -98,7 +107,9 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
98
107
  text-overflow: ellipsis;
99
108
  }
100
109
 
101
- /* Collapsed rail: icon-only, centered, matching the shell's 56px rail. */
110
+ /* Collapsed rail: icon-only, centered, matching the shell's 56px rail. The
111
+ shell asks its own panel rows for an 18px glyph on the rail, so the icon box
112
+ and the svg step up together with the 12px rail radius. */
102
113
  :global([data-dsh-frame][data-sidebar-collapsed]) .entry,
103
114
  :global([data-sidebar-collapsed]) .entry {
104
115
  justify-content: center;
@@ -106,7 +117,15 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
106
117
  width: 36px;
107
118
  height: 36px;
108
119
  margin: 0 auto 12px;
109
- border-radius: 50%;
120
+ border-radius: 12px;
121
+ }
122
+
123
+ :global([data-dsh-frame][data-sidebar-collapsed]) .entryIcon,
124
+ :global([data-sidebar-collapsed]) .entryIcon,
125
+ :global([data-dsh-frame][data-sidebar-collapsed]) .entryIcon svg,
126
+ :global([data-sidebar-collapsed]) .entryIcon svg {
127
+ width: 18px;
128
+ height: 18px;
110
129
  }
111
130
 
112
131
  :global([data-dsh-frame][data-sidebar-collapsed]) .entryLabel,
@@ -12,16 +12,20 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis'
12
12
  import type { ClientRemote, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
13
13
  import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
14
14
  import type { IWorkspaces } from '@deepseek-ai/dsh-api-workspace-controller/client'
15
- import type { SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-ui-settings/client'
15
+ import type { ConfigForm, ConfigForms } from '@deepseek-ai/dsh-client-ui-settings/client'
16
16
  import type {} from '@deepseek-ai/dsh-api-remotes/client'
17
17
  import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
18
18
  import type {} from '@deepseek-ai/dsh-client-ui-slots'
19
19
  // Type-only: pulls the locale plugin's Context merge (ctx.locale) and its
20
20
  // LocaleNamespaceMap merge table.
21
21
  import type {} from '@deepseek-ai/dsh-client-locale/client'
22
- // Type-only: pulls the settings-surface Context merge (ctx.settingsScope).
22
+ // Type-only: pulls the shared-forms Context merge (ctx.configForms).
23
23
  import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
24
+ // Type-only: pulls the workspace plugin's Context merge (ctx.uiWorkspace), the
25
+ // multi-instance navigation face that replaced ISessions.open().
26
+ import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
24
27
  import { BoardController } from '../core/controller.ts'
28
+ import { mainViewSessionId } from './main-session.ts'
25
29
  import { LocalStorageTaskStore } from '../core/store.ts'
26
30
  import { claimTaskboardApply, releaseTaskboardApply } from './apply-guard.ts'
27
31
  import { mountBoard } from './board-mount.tsx'
@@ -30,13 +34,53 @@ import { TaskBoardSettingsCard, TaskBoardSettingsCardController, type TaskBoardS
30
34
  import { en, zh, setRuntimeTranslate, type TaskBoardKey } from './locales.ts'
31
35
  import { HttpTaskBoardHostTransport } from './host-api.ts'
32
36
  import { reportDailyHeartbeat } from './telemetry.ts'
37
+ import { installPluginCard } from './plugin-card-seat.ts'
33
38
 
34
39
  /** Locale namespace this plugin owns. */
35
40
  const NS = 'task-board'
36
41
 
37
- /** Settings namespace the settings card edits (the Host plugin registers it). */
42
+ /** Settings namespace this card edits (the family identity of the plugin's own settings form). */
38
43
  const TASK_BOARD_NS = 'task-board'
39
44
 
45
+ /**
46
+ * Profile entry id the family aggregate's generated row carries — the
47
+ * deployment shape nearly every user runs. Under 0.1.7 a settings form is
48
+ * addressed by profile entry id, so the shared-forms fallback below has to
49
+ * name it; the family binder resolves the family namespace instead.
50
+ */
51
+ const AGGREGATE_ENTRY_ID = 'web-ui-task-board'
52
+
53
+ /**
54
+ * Profile entry ids this package's two patch rows carry: the aggregate's
55
+ * generated row and the standalone bundle patch's row (`ui-task-board`), plus
56
+ * the bare namespace as the last resort for a Host whose descriptor is keyed
57
+ * by the family namespace itself.
58
+ */
59
+ const TASK_BOARD_ENTRY_IDS: readonly string[] = [AGGREGATE_ENTRY_ID, 'ui-task-board', TASK_BOARD_NS]
60
+
61
+ /** Domain-owned description of one settings namespace a family card binds. */
62
+ export interface SettingsFormSpec<T> {
63
+ /** Settings namespace the card edits. */
64
+ namespace: string
65
+ /**
66
+ * Narrow one wire section; undefined keeps the last accepted value. The
67
+ * shared form already resolves the namespace's own serialized wire schema,
68
+ * so a decoder exists only to narrow beyond that schema.
69
+ */
70
+ decode?: (section: unknown) => T | undefined
71
+ }
72
+
73
+ /**
74
+ * The family settings binder published by dsh-web-settings. Its `bind` resolves
75
+ * a family namespace to the profile entry id that owns it and hands back the
76
+ * shared configuration form, so it is the only seat that can reach this card's
77
+ * form on a Host whose row id is not the namespace.
78
+ */
79
+ export interface SettingsFormBinder {
80
+ /** Bind one family settings namespace. */
81
+ bind<T>(spec: SettingsFormSpec<T>): ConfigForm<T>
82
+ }
83
+
40
84
  declare module '@deepseek-ai/dsh-client-ui-slots' {
41
85
  interface LocaleNamespaceMap {
42
86
  /** Task-board surface copy. */
@@ -46,9 +90,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
46
90
  interface SlotMap {
47
91
  /**
48
92
  * The child slot the Web UI plugin group declares; this card registers
49
- * into the group instead of the top-level `settings.plugin.item` list.
50
- * Spelled here with the same shape so this package can register without
51
- * depending on the sibling UI package.
93
+ * into the group's list seat rather than the official
94
+ * bundle-configuration seat. Spelled here with the same shape so this
95
+ * package can register without depending on the sibling UI package.
52
96
  */
53
97
  'web-ui.plugin.item': { kind: 'list'; scope: 'root'; owner: SettingsPluginItemOwnerProps }
54
98
  }
@@ -63,11 +107,11 @@ export interface SettingsPluginItemOwnerProps {
63
107
  declare module '@deepseek-ai/cordis' {
64
108
  interface Context {
65
109
  /**
66
- * Optional rc.6 compatibility binder provided by dsh-web-settings;
67
- * absent when that group plugin is not installed, so callers fall back to
68
- * the official settings scope.
110
+ * Optional family settings binder provided by dsh-web-settings; absent
111
+ * when that group plugin is not installed, so callers fall back to the
112
+ * shared configuration forms service.
69
113
  */
70
- webUiSettings?: { bind<S>(spec: SettingsScopeSpec<S>): SettingsScope<S> }
114
+ webUiSettings?: SettingsFormBinder
71
115
  }
72
116
  }
73
117
 
@@ -80,7 +124,7 @@ declare module '@deepseek-ai/cordis' {
80
124
  * on hosts below that cohort, which serve the same roster through the
81
125
  * connection RPC face.
82
126
  */
83
- export const inject = ['slots', 'sessions', 'workspaces', 'connection', 'settingsScope', 'locale', 'remote', 'remote.session']
127
+ export const inject = ['slots', 'sessions', 'workspaces', 'connection', 'configForms', 'locale', 'remote', 'remote.session', 'uiWorkspace']
84
128
 
85
129
  /** One agent-preset row the mode picker consumes (either face's wire shape). */
86
130
  interface PresetRosterRow {
@@ -164,31 +208,23 @@ export function apply(ctx: ClientContext): void {
164
208
  try { setRuntimeTranslate(ctx.locale.bind(NS)) } catch { /* locale missing: document-language fallback stays */ }
165
209
 
166
210
  // Plugin configuration card: one staged form over the `task-board` settings
167
- // namespace, contributed to the Web UI plugin group.
168
- const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
169
- const settingsScope = binder.bind<TaskBoardSettings>({ namespace: TASK_BOARD_NS })
170
- const settingsCard = new TaskBoardSettingsCardController(settingsScope)
171
- ctx.slots.inject('web-ui.plugin.item', () => {
172
- try {
173
- const unregister = ctx.slots.register({
174
- name: 'web-ui.plugin.item',
175
- id: 'task-board',
176
- order: 110,
177
- locale: NS,
178
- inject: () => settingsCard.inject(),
179
- }, TaskBoardSettingsCard)
180
- return () => {
181
- settingsCard.dispose()
182
- unregister()
183
- }
184
- } catch {
185
- return () => {}
186
- }
211
+ // namespace, contributed to whichever plugin-card seat this host declares
212
+ // (the family group's list seat, or the official bundle-configuration seat).
213
+ const settingsForm = bindSettingsForm(ctx)
214
+ const settingsCard = new TaskBoardSettingsCardController(settingsForm)
215
+ installPluginCard(ctx, {
216
+ bundle: '@linxin666/dsh-client-ui-task-board',
217
+ id: 'task-board',
218
+ order: 110,
219
+ locale: NS,
220
+ inject: () => settingsCard.inject(),
221
+ component: TaskBoardSettingsCard,
187
222
  })
223
+ ctx.effect(() => () => { settingsCard.dispose() }, 'task-board: settings card')
188
224
 
189
- // The sidebar entry and board view mount once the settings scope settles;
190
- // while the scope is still loading, the composition default is unknown, so
191
- // nothing mounts yet. Only an unavailable scope (no settings surface served)
225
+ // The sidebar entry and board view mount once the settings form settles;
226
+ // while the form is still loading, the composition default is unknown, so
227
+ // nothing mounts yet. Only an unavailable form (no settings surface served)
192
228
  // falls back to the composition default (enabled).
193
229
  let uiDisposer: (() => void) | undefined
194
230
  const mountUi = (): void => {
@@ -206,8 +242,12 @@ export function apply(ctx: ClientContext): void {
206
242
  store,
207
243
  transport: new HttpTaskBoardHostTransport(),
208
244
  sessions: {
209
- list: sessions.list,
210
- open: id => sessions.open(id as never),
245
+ // The main-view Session comes from the catalog's per-source ownership
246
+ // counts; navigation belongs to the workspace UI since the
247
+ // multi-instance Client Session model.
248
+ current: () => mainViewSessionId(sessions.list.getSnapshot().byId),
249
+ open: id => ctx.uiWorkspace.openSession(id as never),
250
+ subscribe: fn => sessions.list.subscribe(fn),
211
251
  },
212
252
  })
213
253
  controller.start()
@@ -337,13 +377,56 @@ export function apply(ctx: ClientContext): void {
337
377
  }
338
378
  }
339
379
  const syncEnabled = (): void => {
340
- const snapshot = settingsScope.getSnapshot()
380
+ const snapshot = settingsForm.getSnapshot()
341
381
  const enabled = snapshot.status === 'ready'
342
382
  ? snapshot.value?.enabled ?? true
343
383
  : snapshot.status === 'unavailable'
344
384
  if (enabled) mountUi()
345
385
  else uiDisposer?.()
346
386
  }
347
- settingsScope.subscribe(syncEnabled)
387
+ settingsForm.subscribe(syncEnabled)
348
388
  syncEnabled()
349
389
  }
390
+
391
+ /**
392
+ * Bind the settings form this card stages over.
393
+ *
394
+ * The family binder (`ctx.get('webUiSettings')`, published by dsh-web-settings)
395
+ * comes first: it is what traces this package's family namespace onto the
396
+ * profile entry id the Host serves the form under, and it keeps the loopback
397
+ * bridge as its own fallback. A page without that group falls back to the
398
+ * shared configuration forms service bound directly at one of this package's
399
+ * own profile entry ids.
400
+ * @param ctx - client root context.
401
+ * @returns the form the settings card reads and writes.
402
+ */
403
+ export function bindSettingsForm(ctx: ClientContext): ConfigForm<TaskBoardSettings> {
404
+ const binder = ctx.get('webUiSettings')
405
+ if (binder !== undefined && typeof binder.bind === 'function') {
406
+ return binder.bind<TaskBoardSettings>({ namespace: TASK_BOARD_NS })
407
+ }
408
+ return ctx.configForms.get<TaskBoardSettings>(servedEntryId(ctx.configForms))
409
+ }
410
+
411
+ /**
412
+ * The profile entry id this package's own row carries.
413
+ *
414
+ * The shared describe mirror is the only local evidence of which row id this
415
+ * profile actually serves, but it answers asynchronously: at plugin
416
+ * activation it usually holds nothing yet. An unanswered mirror therefore
417
+ * binds the aggregate row id rather than guessing among the candidates —
418
+ * the form is bound once for the session, so a wrong guess would leave the
419
+ * card reporting an unserved namespace even after the mirror settles.
420
+ * @param forms - the shared configuration forms service.
421
+ * @returns the entry id to bind.
422
+ */
423
+ function servedEntryId(forms: ConfigForms): string {
424
+ let served: readonly string[] | undefined
425
+ try {
426
+ served = forms.describe().getSnapshot().view?.namespaces.map(view => view.ns)
427
+ } catch {
428
+ served = undefined
429
+ }
430
+ if (served === undefined) return AGGREGATE_ENTRY_ID
431
+ return TASK_BOARD_ENTRY_IDS.find(id => served.includes(id)) ?? TASK_BOARD_NS
432
+ }