@linxin666/dsh-client-ui-task-board 0.3.24 → 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.
- package/icon.svg +3 -0
- package/lib/client.js +154 -115
- package/lib/client.js.map +1 -1
- package/lib/index.js +109 -898
- package/lib/types/client/PluginSettingsCard.d.ts +4 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +5 -5
- package/lib/types/client/index.d.ts +43 -10
- package/lib/types/client/main-session.d.ts +32 -0
- package/lib/types/client/plugin-card-seat.d.ts +23 -16
- package/lib/types/client/settings-form.d.ts +17 -17
- package/lib/types/client/sidebar-entry-core.d.ts +0 -32
- package/lib/types/core/controller.d.ts +10 -8
- package/lib/types/http.d.ts +18 -1
- package/lib/types/index.d.ts +72 -17
- package/package.json +30 -25
- package/src/client/PluginSettingsCard.tsx +7 -2
- package/src/client/TaskBoardSettingsCard.tsx +7 -5
- package/src/client/board.module.css +31 -12
- package/src/client/index.ts +112 -23
- package/src/client/main-session.ts +43 -0
- package/src/client/plugin-card-seat.ts +24 -17
- package/src/client/settings-form.ts +34 -31
- package/src/client/sidebar-entry-core.ts +10 -94
- package/src/client/sidebar-entry.ts +2 -2
- package/src/core/controller.ts +12 -8
- package/src/http.ts +23 -1
- package/src/index.ts +97 -52
|
@@ -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 {
|
|
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`
|
|
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
|
|
49
|
-
constructor(scope:
|
|
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
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
min-height: 36px;
|
|
63
|
+
padding: 7px 8px;
|
|
64
|
+
margin: 0 2px;
|
|
61
65
|
background: transparent;
|
|
62
66
|
border: none;
|
|
63
|
-
border-radius:
|
|
64
|
-
color: var(--dsw-alias-label-
|
|
67
|
+
border-radius: 12px;
|
|
68
|
+
color: var(--dsw-alias-label-primary);
|
|
65
69
|
cursor: pointer;
|
|
66
|
-
font
|
|
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:
|
|
86
|
-
height:
|
|
94
|
+
width: 16px;
|
|
95
|
+
height: 16px;
|
|
87
96
|
flex: none;
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
.entryIcon svg {
|
|
91
100
|
display: block;
|
|
92
|
-
width:
|
|
93
|
-
height:
|
|
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:
|
|
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,
|
package/src/client/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
51
|
-
* Spelled here with the same shape so this
|
|
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
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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?:
|
|
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', '
|
|
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
|
-
// (
|
|
170
|
-
const
|
|
171
|
-
const
|
|
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
|
-
|
|
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
|
|
184
|
-
// while the
|
|
185
|
-
// nothing mounts yet. Only an unavailable
|
|
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
|
-
|
|
204
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
* - `
|
|
11
|
-
*
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
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 = '
|
|
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
|
|
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
|
-
/**
|
|
73
|
-
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
|
215
|
-
private readonly
|
|
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
|
|
221
|
+
/** @param scope - the bound configuration form for this card's namespace. */
|
|
222
222
|
constructor(
|
|
223
|
-
private readonly scope:
|
|
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.
|
|
227
|
+
this.disposeForm = scope.subscribe(() => { this.publish() })
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
/**
|
|
231
|
-
* Release the
|
|
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.
|
|
237
|
+
this.disposeForm()
|
|
238
238
|
this.listeners.clear()
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
/** Publish a projection of this form, rebuilt whenever the
|
|
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
|
|
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
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
* settled snapshot back
|
|
307
|
-
* fails the whole save. A
|
|
308
|
-
*
|
|
309
|
-
* message. A save that did not land keeps its drafts, so
|
|
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
|
|
327
|
-
//
|
|
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
|
-
//
|
|
339
|
-
//
|
|
340
|
-
// judge every planned write against the settled
|
|
341
|
-
// atomic, so one missed write fails the
|
|
342
|
-
|
|
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
|
|
349
|
-
// generic failure copy; a rejecting
|
|
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
|
|
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():
|
|
418
|
+
private snapshotOf(): ConfigFormSnapshot<T> {
|
|
416
419
|
return this.scope.getSnapshot()
|
|
417
420
|
}
|
|
418
421
|
|