@linxin666/dsh-client-ui-task-board 0.3.24 → 0.4.1

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.
@@ -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
  >
@@ -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'
@@ -35,9 +39,48 @@ import { installPluginCard } from './plugin-card-seat.ts'
35
39
  /** Locale namespace this plugin owns. */
36
40
  const NS = 'task-board'
37
41
 
38
- /** 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). */
39
43
  const TASK_BOARD_NS = 'task-board'
40
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
+
41
84
  declare module '@deepseek-ai/dsh-client-ui-slots' {
42
85
  interface LocaleNamespaceMap {
43
86
  /** Task-board surface copy. */
@@ -47,9 +90,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
47
90
  interface SlotMap {
48
91
  /**
49
92
  * The child slot the Web UI plugin group declares; this card registers
50
- * into the group instead of the top-level `settings.plugin.item` list.
51
- * Spelled here with the same shape so this package can register without
52
- * 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.
53
96
  */
54
97
  'web-ui.plugin.item': { kind: 'list'; scope: 'root'; owner: SettingsPluginItemOwnerProps }
55
98
  }
@@ -64,11 +107,11 @@ export interface SettingsPluginItemOwnerProps {
64
107
  declare module '@deepseek-ai/cordis' {
65
108
  interface Context {
66
109
  /**
67
- * Optional rc.6 compatibility binder provided by dsh-web-settings;
68
- * absent when that group plugin is not installed, so callers fall back to
69
- * 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.
70
113
  */
71
- webUiSettings?: { bind<S>(spec: SettingsScopeSpec<S>): SettingsScope<S> }
114
+ webUiSettings?: SettingsFormBinder
72
115
  }
73
116
  }
74
117
 
@@ -81,7 +124,7 @@ declare module '@deepseek-ai/cordis' {
81
124
  * on hosts below that cohort, which serve the same roster through the
82
125
  * connection RPC face.
83
126
  */
84
- 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']
85
128
 
86
129
  /** One agent-preset row the mode picker consumes (either face's wire shape). */
87
130
  interface PresetRosterRow {
@@ -166,12 +209,11 @@ export function apply(ctx: ClientContext): void {
166
209
 
167
210
  // Plugin configuration card: one staged form over the `task-board` settings
168
211
  // namespace, contributed to whichever plugin-card seat this host declares
169
- // (issue #1589).
170
- const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
171
- const settingsScope = binder.bind<TaskBoardSettings>({ namespace: TASK_BOARD_NS })
172
- const settingsCard = new TaskBoardSettingsCardController(settingsScope)
212
+ // (the family group's list seat, or the official bundle-configuration seat).
213
+ const settingsForm = bindSettingsForm(ctx)
214
+ const settingsCard = new TaskBoardSettingsCardController(settingsForm)
173
215
  installPluginCard(ctx, {
174
- namespace: TASK_BOARD_NS,
216
+ bundle: '@linxin666/dsh-client-ui-task-board',
175
217
  id: 'task-board',
176
218
  order: 110,
177
219
  locale: NS,
@@ -180,9 +222,9 @@ export function apply(ctx: ClientContext): void {
180
222
  })
181
223
  ctx.effect(() => () => { settingsCard.dispose() }, 'task-board: settings card')
182
224
 
183
- // The sidebar entry and board view mount once the settings scope settles;
184
- // while the scope is still loading, the composition default is unknown, so
185
- // 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)
186
228
  // falls back to the composition default (enabled).
187
229
  let uiDisposer: (() => void) | undefined
188
230
  const mountUi = (): void => {
@@ -200,8 +242,12 @@ export function apply(ctx: ClientContext): void {
200
242
  store,
201
243
  transport: new HttpTaskBoardHostTransport(),
202
244
  sessions: {
203
- list: sessions.list,
204
- 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),
205
251
  },
206
252
  })
207
253
  controller.start()
@@ -331,13 +377,56 @@ export function apply(ctx: ClientContext): void {
331
377
  }
332
378
  }
333
379
  const syncEnabled = (): void => {
334
- const snapshot = settingsScope.getSnapshot()
380
+ const snapshot = settingsForm.getSnapshot()
335
381
  const enabled = snapshot.status === 'ready'
336
382
  ? snapshot.value?.enabled ?? true
337
383
  : snapshot.status === 'unavailable'
338
384
  if (enabled) mountUi()
339
385
  else uiDisposer?.()
340
386
  }
341
- settingsScope.subscribe(syncEnabled)
387
+ settingsForm.subscribe(syncEnabled)
342
388
  syncEnabled()
343
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
+ }
@@ -0,0 +1,43 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/main-session.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Main-view Session derivation for the multi-instance Client Session model.
4
+ *
5
+ * The Client Session Controller carries no global "current" selection: view
6
+ * selection belongs to the workspace UI, which owns the main-area reference and
7
+ * publishes it through the catalog's per-source ownership counts
8
+ * (`SessionSummary.retainedBy.mainView`). A plugin that needs the Session the
9
+ * main view shows derives it from that marker instead of the removed
10
+ * `SessionListState.current` field.
11
+ *
12
+ * The marker describes view ownership, not authority. It is the right input for
13
+ * presentation and for deciding which view a navigation targets; it is not a
14
+ * business fallback, because source counts never imply visibility or success.
15
+ * @module dsh-web/shared/main-session
16
+ */
17
+
18
+ /** The catalog row subset this helper reads (a `SessionSummary` projection). */
19
+ export interface MainViewOwnedRow<Id extends string = string> {
20
+ /** Catalog row identity. */
21
+ readonly id: Id
22
+ /** Per-source local ownership counts; absent rows are never owned. */
23
+ readonly retainedBy?: Readonly<Partial<Record<string, number>>> | undefined
24
+ }
25
+
26
+ /**
27
+ * Resolve the Session the main view currently shows.
28
+ *
29
+ * Reads the catalog's rows rather than a per-id retain-info source: ownership
30
+ * counts ride the list snapshot, so this neither allocates observers nor opens
31
+ * history, and a subscription to the list still fires when the selection moves.
32
+ * @param byId - the session catalog's rows (`SessionListState.byId`), when available.
33
+ * @returns the main-view session id, or undefined when the main view shows none.
34
+ */
35
+ export function mainViewSessionId<Id extends string>(
36
+ byId: Readonly<Record<Id, MainViewOwnedRow<Id> | undefined>> | undefined | null,
37
+ ): Id | undefined {
38
+ if (byId === undefined || byId === null) return undefined
39
+ for (const row of Object.values<MainViewOwnedRow<Id> | undefined>(byId)) {
40
+ if (row !== undefined && (row.retainedBy?.mainView ?? 0) > 0) return row.id
41
+ }
42
+ return undefined
43
+ }
@@ -7,22 +7,24 @@
7
7
  *
8
8
  * - `web-ui.plugin.item` — the list seat declared by the dsh-web-settings
9
9
  * group section (this family's own first-level "Web UI plugins" section);
10
- * - `settings.plugin.item` — the official keyed seat of the harness's
11
- * `ui-settings-plugins` tab, keyed by the settings namespace the card edits.
10
+ * - `plugins.bundle.config` — the official keyed seat of the harness's plugin
11
+ * manager page, keyed by the bundle's package name and rendered on that
12
+ * bundle's page. alpha.2 removed the `settings.plugin.item` keyed seat of the
13
+ * `ui-settings-plugins` tab that this helper used before, so a card keyed by
14
+ * its settings namespace has no seat to land in any more.
12
15
  *
13
- * SEAT SELECTION IS NOT A DECLARATION PROBE. The official `ui-settings-plugins`
14
- * row belongs to the harness bundle and its `configurable` tab always declares
15
- * `settings.plugin.item` before any external plugin's `apply()` runs, so
16
- * "is the official seat declared?" answers yes even in the deployment whose
17
- * whole point is the family group. Choosing on that probe sends every family
18
- * card to the official Plugins tab and leaves the group's own section
19
- * permanently empty — the family of reports where the section renders its
20
- * heading and zero cards.
16
+ * SEAT SELECTION IS NOT A DECLARATION PROBE. The official plugin surface
17
+ * belongs to the harness bundle and declares its seats before any external
18
+ * plugin's `apply()` runs, so "is the official seat declared?" answers yes even
19
+ * in the deployment whose whole point is the family group. Choosing on that
20
+ * probe sends every family card to the official page and leaves the group's own
21
+ * section permanently empty — the family of reports where the section renders
22
+ * its heading and zero cards.
21
23
  *
22
24
  * The signal that actually distinguishes the two deployments is whether
23
25
  * dsh-web-settings is loaded: it is the package that owns the group section and
24
26
  * it publishes the `webUiSettings` service during `apply()`, which every
25
- * family plugin already reads for its settings scope. Group loaded -> the family
27
+ * family plugin already reads for its settings form. Group loaded -> the family
26
28
  * seat; group absent -> the official seat.
27
29
  *
28
30
  * The decision is re-evaluated on every `slots/changed` because the group may
@@ -40,8 +42,8 @@
40
42
  /** The family list seat key. */
41
43
  export const FAMILY_PLUGIN_CARD_SEAT = 'web-ui.plugin.item'
42
44
 
43
- /** The official keyed plugin-card seat key. */
44
- export const OFFICIAL_PLUGIN_CARD_SEAT = 'settings.plugin.item'
45
+ /** The official keyed plugin-card seat key (the alpha.2 bundle-configuration seat). */
46
+ export const OFFICIAL_PLUGIN_CARD_SEAT = 'plugins.bundle.config'
45
47
 
46
48
  /** The service dsh-web-settings publishes while it is loaded. */
47
49
  export const FAMILY_GROUP_SERVICE = 'webUiSettings'
@@ -61,7 +63,7 @@ export interface PluginCardContext {
61
63
  on?(event: string, listener: (...args: never[]) => void): unknown
62
64
  }
63
65
 
64
- /** Owner share of a plugin card (both seats supply nothing). */
66
+ /** Owner share of a family list-seat card (that seat supplies nothing). */
65
67
  export interface SettingsPluginItemOwnerProps {
66
68
  /** Marker field: card owner props are intentionally empty. */
67
69
  children?: never
@@ -69,8 +71,13 @@ export interface SettingsPluginItemOwnerProps {
69
71
 
70
72
  /** One family plugin's card contribution. */
71
73
  export interface PluginCardSeat {
72
- /** Settings namespace the card edits (the official seat's dispatch key). */
73
- namespace: string
74
+ /**
75
+ * Npm package name of the bundle this card configures; the official keyed
76
+ * seat dispatches on it. Every family package ships its own bundle patch, so
77
+ * a standalone install lists that package as the bundle whose page renders
78
+ * this card.
79
+ */
80
+ bundle: string
74
81
  /** Family list-seat entry id. */
75
82
  id: string
76
83
  /** Family list-seat sort order. */
@@ -159,7 +166,7 @@ export function installPluginCard(ctx: PluginCardContext, seat: PluginCardSeat):
159
166
  }
160
167
  : {
161
168
  name: OFFICIAL_PLUGIN_CARD_SEAT,
162
- key: seat.namespace,
169
+ key: seat.bundle,
163
170
  locale: seat.locale,
164
171
  ...(seat.inject === undefined ? {} : { inject }),
165
172
  }) as never, component) as () => void
@@ -8,7 +8,7 @@
8
8
  * card-store pattern.
9
9
  */
10
10
 
11
- import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
11
+ import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
12
12
  import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
13
13
  import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
14
14
 
@@ -114,7 +114,7 @@ interface PlannedWrite {
114
114
  judge: (() => boolean) | undefined
115
115
  }
116
116
 
117
- /** One durable write inside the save's atomic scope mutation. */
117
+ /** One durable write inside the save's atomic form mutation. */
118
118
  export interface BatchedWrite {
119
119
  /** Field this entry writes. */
120
120
  field: string
@@ -211,34 +211,34 @@ export class CardForm<T> {
211
211
  private readonly specs: Map<string, FieldSpec>
212
212
  private readonly staged = new Map<string, StagedEdit>()
213
213
  private readonly listeners = new Set<() => void>()
214
- /** The scope subscription installed in the constructor; released by dispose(). */
215
- private readonly disposeScope: () => void
214
+ /** The form subscription installed in the constructor; released by dispose(). */
215
+ private readonly disposeForm: () => void
216
216
  private disposed = false
217
217
  private saving = false
218
218
  private failed = false
219
219
  private failedReason: string | undefined
220
220
 
221
- /** @param scope - the bound settings scope for this card's namespace. */
221
+ /** @param scope - the bound configuration form for this card's namespace. */
222
222
  constructor(
223
- private readonly scope: SettingsScope<T>,
223
+ private readonly scope: ConfigForm<T>,
224
224
  specs: FieldSpec[],
225
225
  ) {
226
226
  this.specs = new Map(specs.map(spec => [spec.field, spec]))
227
- this.disposeScope = scope.subscribe(() => { this.publish() })
227
+ this.disposeForm = scope.subscribe(() => { this.publish() })
228
228
  }
229
229
 
230
230
  /**
231
- * Release the scope subscription and every bound store listener. The card
231
+ * Release the form subscription and every bound store listener. The card
232
232
  * must call this on teardown; later calls are no-ops.
233
233
  */
234
234
  dispose(): void {
235
235
  if (this.disposed) return
236
236
  this.disposed = true
237
- this.disposeScope()
237
+ this.disposeForm()
238
238
  this.listeners.clear()
239
239
  }
240
240
 
241
- /** Publish a projection of this form, rebuilt whenever the scope or a draft changes. */
241
+ /** Publish a projection of this form, rebuilt whenever the form or a draft changes. */
242
242
  bind<S>(project: () => S): SnapshotStore<S> {
243
243
  const store = createSnapshotStore(project())
244
244
  this.listeners.add(() => { store.set(project()) })
@@ -295,19 +295,19 @@ export class CardForm<T> {
295
295
  }
296
296
 
297
297
  /**
298
- * Write every staged edit in one atomic scope mutation, then re-seed from
298
+ * Write every staged edit in one atomic form mutation, then re-seed from
299
299
  * what the Host accepted.
300
300
  *
301
301
  * The whole batch rides one mutate, so cross-field validate hooks
302
302
  * (baseURL+model) judge it as a unit: the Host either applies every write
303
- * or refuses the batch. The 0.1.2 scope contract never rejects a refused
304
- * mutation — the scope recovers with a fresh Host view and resolves — so
305
- * resolution alone proves nothing: the outcome is judged by reading the
306
- * settled snapshot back, one planned write at a time, and one missed write
307
- * fails the whole save. A scope that still rejects on refusal (the dsh-web
308
- * bridge scope) reports through the same failure path with its rejection
309
- * message. A save that did not land keeps its drafts, so the user can
310
- * correct them instead of retyping.
303
+ * or refuses the batch. The form contract answers a refusal or a skipped
304
+ * write with `false` (it recovers with a fresh Host view instead of
305
+ * throwing), so the outcome is judged twice: the answer itself, and then the
306
+ * settled snapshot read back one planned write at a time. One missed write
307
+ * fails the whole save. A transport that rejects instead (the dsh-web bridge
308
+ * controller on a dead connection) reports through the same failure path
309
+ * with its rejection message. A save that did not land keeps its drafts, so
310
+ * the user can correct them instead of retyping.
311
311
  * @returns settlement after the mutation and the read-back.
312
312
  */
313
313
  async save(): Promise<void> {
@@ -323,30 +323,33 @@ export class CardForm<T> {
323
323
  this.failed = false
324
324
  this.failedReason = undefined
325
325
  this.publish()
326
- // One atomic namespace mutation: the 0.1.2 scope contract takes ordered
327
- // path operations, so the whole staged batch is validated, persisted, and
326
+ // One atomic namespace mutation: the form contract takes ordered path
327
+ // operations, so the whole staged batch is validated, persisted, and
328
328
  // recovered together — either every write lands or none does.
329
329
  const ops: Array<{ op: 'set'; path: string[]; value: string | number | boolean } | { op: 'unset'; path: string[] }> = valid.map(item => item.op.op === 'set'
330
330
  ? { op: 'set', path: [item.field], value: (item.op as { value: string | number | boolean }).value }
331
331
  : { op: 'unset', path: [item.field] })
332
332
  let failedReason: string | undefined
333
+ let accepted = false
333
334
  try {
334
- await this.scope.mutate(ops)
335
+ accepted = await this.scope.mutate(ops)
335
336
  } catch (error) {
336
337
  failedReason = error instanceof Error ? error.message : String(error)
337
338
  }
338
- // The 0.1.2 scope resolves even a refused mutation (it recovers with a
339
- // fresh view instead of throwing), so resolution alone proves nothing:
340
- // judge every planned write against the settled snapshot. The mutation is
341
- // atomic, so one missed write fails the whole save and keeps the drafts.
342
- const landed = failedReason === undefined && valid.every(item => item.judge())
339
+ // `false` is the contract's refusal/skip answer, and the form still
340
+ // resolves after a refusal (it recovers with a fresh view), so the answer
341
+ // alone is not enough: judge every planned write against the settled
342
+ // snapshot as well. The mutation is atomic, so one missed write fails the
343
+ // whole save and keeps the drafts.
344
+ const landed = accepted && failedReason === undefined && valid.every(item => item.judge())
343
345
  for (const [field, before] of pending) {
344
346
  if (landed && this.staged.get(field) === before) this.staged.delete(field)
345
347
  }
346
348
  this.saving = false
347
349
  this.failed = !landed
348
- // A read-back failure carries no server reason: the card surfaces its
349
- // generic failure copy; a rejecting scope (the bridge) adds its message.
350
+ // A refused or unlanded batch carries no server reason: the card surfaces
351
+ // its generic failure copy; a rejecting transport (the bridge) adds its
352
+ // message.
350
353
  this.failedReason = failedReason
351
354
  this.publish()
352
355
  }
@@ -381,7 +384,7 @@ export class CardForm<T> {
381
384
  */
382
385
  private landedSet(field: string, value: unknown): boolean {
383
386
  // A redacted secret never appears in any wire view layer: the Host strips
384
- // role('secret') fields and reports them through a sidecar the scope
387
+ // role('secret') fields and reports them through a sidecar the form
385
388
  // snapshot does not expose, so there is nothing to compare the draft
386
389
  // against. Settling is the only signal the form has; the rest of the
387
390
  // batch, when one exists, still carries the atomic verdict by read-back.
@@ -412,7 +415,7 @@ export class CardForm<T> {
412
415
  return spec
413
416
  }
414
417
 
415
- private snapshotOf(): SettingsScopeSnapshot<T> {
418
+ private snapshotOf(): ConfigFormSnapshot<T> {
416
419
  return this.scope.getSnapshot()
417
420
  }
418
421