@linxin666/dsh-client-ui-task-board 0.3.5 → 0.3.9
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/README.i18n.yaml +2 -2
- package/README.md +9 -4
- package/README.zh.md +9 -4
- package/lib/client.js +947 -354
- package/lib/client.js.map +1 -1
- package/lib/index.js +712 -180
- package/lib/types/client/TaskBoardSettingsCard.d.ts +2 -1
- package/lib/types/client/board/TaskBoard.d.ts +3 -0
- package/lib/types/client/board/TaskForm.d.ts +28 -0
- package/lib/types/client/host-api.d.ts +2 -2
- package/lib/types/client/index.d.ts +10 -2
- package/lib/types/client/locales.d.ts +19 -0
- package/lib/types/client/settings-form.d.ts +35 -43
- package/lib/types/core/controller.d.ts +11 -2
- package/lib/types/core/freeze-snapshot.d.ts +71 -0
- package/lib/types/core/handover.d.ts +58 -0
- package/lib/types/core/tasks.d.ts +70 -1
- package/lib/types/core/use-cases/task-update.d.ts +16 -7
- package/lib/types/host-ledger.d.ts +17 -2
- package/lib/types/host-runner.d.ts +44 -17
- package/lib/types/host-service.d.ts +8 -5
- package/lib/types/index.d.ts +9 -1
- package/lib/types/protocol.d.ts +15 -2
- package/package.json +26 -17
- package/src/client/TaskBoardSettingsCard.tsx +2 -1
- package/src/client/board/EditTaskModal.tsx +19 -55
- package/src/client/board/NewTaskModal.tsx +59 -42
- package/src/client/board/TaskBoard.tsx +5 -3
- package/src/client/board/TaskCard.tsx +3 -0
- package/src/client/board/TaskDetail.tsx +54 -0
- package/src/client/board/TaskForm.tsx +111 -0
- package/src/client/board-mount.tsx +7 -1
- package/src/client/host-api.ts +5 -5
- package/src/client/index.ts +67 -7
- package/src/client/locales.ts +40 -2
- package/src/client/settings-form.ts +78 -92
- package/src/core/controller.ts +36 -16
- package/src/core/freeze-snapshot.ts +213 -0
- package/src/core/handover.ts +117 -0
- package/src/core/store.ts +48 -1
- package/src/core/tasks.ts +87 -0
- package/src/core/use-cases/task-update.ts +30 -10
- package/src/dsh-home.ts +1 -1
- package/src/host-ledger.ts +137 -59
- package/src/host-routes.ts +1 -1
- package/src/host-runner.ts +223 -83
- package/src/host-service.ts +14 -10
- package/src/index.ts +24 -10
- package/src/protocol.ts +98 -14
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared task-modal pieces: the overlay shell (backdrop, form, title, error,
|
|
3
|
+
* footer) and the title/description/prompt field trio used by both the
|
|
4
|
+
* NewTaskModal and the EditTaskModal. State stays in the owning modal; these
|
|
5
|
+
* are controlled components.
|
|
6
|
+
*/
|
|
7
|
+
import type { ReactNode } from 'react'
|
|
8
|
+
import { t } from '../locales.ts'
|
|
9
|
+
import css from '../board.module.css'
|
|
10
|
+
|
|
11
|
+
/** Modal overlay: closes on backdrop press, submits through the form. */
|
|
12
|
+
export function ModalShell({
|
|
13
|
+
ariaLabel,
|
|
14
|
+
title,
|
|
15
|
+
error,
|
|
16
|
+
pending,
|
|
17
|
+
submitLabel,
|
|
18
|
+
onSubmit,
|
|
19
|
+
onClose,
|
|
20
|
+
children,
|
|
21
|
+
}: {
|
|
22
|
+
ariaLabel: string
|
|
23
|
+
title: string
|
|
24
|
+
error: string | undefined
|
|
25
|
+
pending: boolean
|
|
26
|
+
submitLabel: string
|
|
27
|
+
onSubmit: () => void
|
|
28
|
+
onClose: () => void
|
|
29
|
+
children: ReactNode
|
|
30
|
+
}) {
|
|
31
|
+
return (
|
|
32
|
+
<div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}>
|
|
33
|
+
<form
|
|
34
|
+
className={css.modal}
|
|
35
|
+
role="dialog"
|
|
36
|
+
aria-label={ariaLabel}
|
|
37
|
+
onSubmit={event => { event.preventDefault(); onSubmit() }}
|
|
38
|
+
>
|
|
39
|
+
<h2 className={css.modalTitle}>{title}</h2>
|
|
40
|
+
|
|
41
|
+
{children}
|
|
42
|
+
|
|
43
|
+
{error !== undefined && <p className={css.formError}>{error}</p>}
|
|
44
|
+
|
|
45
|
+
<footer className={css.modalFooter}>
|
|
46
|
+
<button type="button" className={css.ghostButton} onClick={onClose}>
|
|
47
|
+
{t('new.cancel')}
|
|
48
|
+
</button>
|
|
49
|
+
<button type="submit" className={css.primaryButton} disabled={pending}>
|
|
50
|
+
{submitLabel}
|
|
51
|
+
</button>
|
|
52
|
+
</footer>
|
|
53
|
+
</form>
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Title + description + prompt fields shared by the new and edit task forms. */
|
|
59
|
+
export function TaskContentFields({
|
|
60
|
+
title,
|
|
61
|
+
description,
|
|
62
|
+
prompt,
|
|
63
|
+
onTitleChange,
|
|
64
|
+
onDescriptionChange,
|
|
65
|
+
onPromptChange,
|
|
66
|
+
}: {
|
|
67
|
+
title: string
|
|
68
|
+
description: string
|
|
69
|
+
prompt: string
|
|
70
|
+
/** Receives the new title; the owner also clears its error state. */
|
|
71
|
+
onTitleChange: (value: string) => void
|
|
72
|
+
onDescriptionChange: (value: string) => void
|
|
73
|
+
onPromptChange: (value: string) => void
|
|
74
|
+
}) {
|
|
75
|
+
return (
|
|
76
|
+
<>
|
|
77
|
+
<label className={css.field}>
|
|
78
|
+
<span className={css.fieldLabel}>{t('new.title')}</span>
|
|
79
|
+
<input
|
|
80
|
+
className={css.input}
|
|
81
|
+
value={title}
|
|
82
|
+
autoFocus
|
|
83
|
+
placeholder={t('new.titlePlaceholder')}
|
|
84
|
+
onChange={event => onTitleChange(event.target.value)}
|
|
85
|
+
/>
|
|
86
|
+
</label>
|
|
87
|
+
|
|
88
|
+
<label className={css.field}>
|
|
89
|
+
<span className={css.fieldLabel}>{t('new.description')}</span>
|
|
90
|
+
<textarea
|
|
91
|
+
className={css.input}
|
|
92
|
+
rows={3}
|
|
93
|
+
value={description}
|
|
94
|
+
placeholder={t('new.descriptionPlaceholder')}
|
|
95
|
+
onChange={event => onDescriptionChange(event.target.value)}
|
|
96
|
+
/>
|
|
97
|
+
</label>
|
|
98
|
+
|
|
99
|
+
<label className={css.field}>
|
|
100
|
+
<span className={css.fieldLabel}>{t('new.prompt')}</span>
|
|
101
|
+
<textarea
|
|
102
|
+
className={css.input}
|
|
103
|
+
rows={4}
|
|
104
|
+
value={prompt}
|
|
105
|
+
placeholder={t('new.promptPlaceholder')}
|
|
106
|
+
onChange={event => onPromptChange(event.target.value)}
|
|
107
|
+
/>
|
|
108
|
+
</label>
|
|
109
|
+
</>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -44,7 +44,13 @@ export function mountBoard(controller: BoardController): () => void {
|
|
|
44
44
|
let container: HTMLDivElement | undefined
|
|
45
45
|
|
|
46
46
|
const ensure = (): void => {
|
|
47
|
-
if (container !== undefined)
|
|
47
|
+
if (container !== undefined) {
|
|
48
|
+
if (container.isConnected) return
|
|
49
|
+
root?.unmount()
|
|
50
|
+
root = undefined
|
|
51
|
+
container.remove()
|
|
52
|
+
container = undefined
|
|
53
|
+
}
|
|
48
54
|
const column = conversationColumn()
|
|
49
55
|
if (column === undefined) return
|
|
50
56
|
container = document.createElement('div')
|
package/src/client/host-api.ts
CHANGED
|
@@ -25,7 +25,7 @@ async function readJson<T>(response: Response): Promise<T> {
|
|
|
25
25
|
export interface TaskBoardHostTransport {
|
|
26
26
|
bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
|
|
27
27
|
state(): Promise<TaskBoardSnapshot>
|
|
28
|
-
action(action: TaskBoardAction): Promise<TaskBoardSnapshot>
|
|
28
|
+
action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot>
|
|
29
29
|
subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -57,12 +57,12 @@ export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
|
|
|
57
57
|
return await this.request(`${TASK_BOARD_API_PREFIX}/state`, { cache: 'no-store' })
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
async action(action: TaskBoardAction): Promise<TaskBoardSnapshot> {
|
|
61
|
-
return await this.post(uuid(), action)
|
|
60
|
+
async action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot> {
|
|
61
|
+
return await this.post(uuid(), action, initiator)
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
private async post(requestId: string, action: TaskBoardAction): Promise<TaskBoardSnapshot> {
|
|
65
|
-
const envelope: TaskBoardActionEnvelope = { requestId, action }
|
|
64
|
+
private async post(requestId: string, action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot> {
|
|
65
|
+
const envelope: TaskBoardActionEnvelope = { requestId, action, ...(initiator === undefined || initiator === '' ? {} : { initiator }) }
|
|
66
66
|
return await this.request(`${TASK_BOARD_API_PREFIX}/action`, {
|
|
67
67
|
method: 'POST',
|
|
68
68
|
headers: { 'content-type': 'application/json' },
|
package/src/client/index.ts
CHANGED
|
@@ -8,8 +8,13 @@
|
|
|
8
8
|
* shell fails the whole boot when a plugin apply throws, and an external
|
|
9
9
|
* plugin must not take the GUI down.
|
|
10
10
|
*/
|
|
11
|
-
import type {
|
|
12
|
-
import type {
|
|
11
|
+
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
|
12
|
+
import type { ClientRemote, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
|
13
|
+
import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
|
|
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'
|
|
16
|
+
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
|
17
|
+
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
|
13
18
|
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
14
19
|
// Type-only: pulls the locale plugin's Context merge (ctx.locale) and its
|
|
15
20
|
// LocaleNamespaceMap merge table.
|
|
@@ -67,9 +72,64 @@ declare module '@deepseek-ai/cordis' {
|
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
|
|
70
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Required services (fiber inject waiting — the runtime must be up first).
|
|
77
|
+
* The generated remote faces are probed at use time instead of injected:
|
|
78
|
+
* `remote.agentPresets` only registers on 0.1.2-alpha.2 hosts (the
|
|
79
|
+
* api-remotes contribution), so a hard wait would pend the entry forever
|
|
80
|
+
* on hosts below that cohort, which serve the same roster through the
|
|
81
|
+
* connection RPC face.
|
|
82
|
+
*/
|
|
71
83
|
export const inject = ['slots', 'sessions', 'workspaces', 'connection', 'settingsScope', 'locale', 'remote']
|
|
72
84
|
|
|
85
|
+
/** One agent-preset row the mode picker consumes (either face's wire shape). */
|
|
86
|
+
interface PresetRosterRow {
|
|
87
|
+
id: string
|
|
88
|
+
name?: string
|
|
89
|
+
description?: string
|
|
90
|
+
/** Why this preset cannot compose a session; absent when it can. */
|
|
91
|
+
broken?: string
|
|
92
|
+
isDefault: boolean
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Read the agent-preset roster through whichever face the running host
|
|
97
|
+
* serves: the generated api-remotes face (`remote.agentPresets`,
|
|
98
|
+
* 0.1.2-alpha.2) or the connection RPC face
|
|
99
|
+
* (`connection.api.agentPresets`, hosts below that cohort). Answers
|
|
100
|
+
* undefined when the host serves neither, so the caller leaves the picker
|
|
101
|
+
* options untouched instead of erroring.
|
|
102
|
+
*/
|
|
103
|
+
async function readPresetRoster(
|
|
104
|
+
ctx: ClientContext,
|
|
105
|
+
remote: ClientRemote,
|
|
106
|
+
): Promise<{ ok: boolean; presets: readonly PresetRosterRow[] } | undefined> {
|
|
107
|
+
// The cordis `remote` proxy throws on a property that was never injected
|
|
108
|
+
// ("cannot get property X without inject") rather than returning undefined,
|
|
109
|
+
// so the probe must guard the access — a hard read would abort mounting on
|
|
110
|
+
// hosts below the 0.1.2-alpha.2 cohort instead of degrading to the legacy
|
|
111
|
+
// connection RPC face below.
|
|
112
|
+
let remotes: ClientRemote['agentPresets'] | undefined
|
|
113
|
+
try {
|
|
114
|
+
remotes = (remote as Partial<ClientRemote>).agentPresets
|
|
115
|
+
} catch {
|
|
116
|
+
remotes = undefined
|
|
117
|
+
}
|
|
118
|
+
if (remotes !== undefined) {
|
|
119
|
+
const response = await remotes.list()
|
|
120
|
+
if (!response.ok) return { ok: false, presets: [] }
|
|
121
|
+
return { ok: true, presets: response.value.presets }
|
|
122
|
+
}
|
|
123
|
+
const connection = ctx.get('connection') as unknown as {
|
|
124
|
+
api?: { agentPresets?: { list(request: Record<string, never>): Promise<{ result: { ok: boolean; value?: { presets?: readonly PresetRosterRow[] } } }> } }
|
|
125
|
+
}
|
|
126
|
+
const legacy = connection.api?.agentPresets
|
|
127
|
+
if (legacy === undefined) return undefined
|
|
128
|
+
const response = await legacy.list({})
|
|
129
|
+
if (!response.result.ok || response.result.value === undefined) return { ok: false, presets: [] }
|
|
130
|
+
return { ok: true, presets: response.result.value.presets ?? [] }
|
|
131
|
+
}
|
|
132
|
+
|
|
73
133
|
/**
|
|
74
134
|
* Mount the task board.
|
|
75
135
|
* @param ctx - client root context (services: sessions, workspaces).
|
|
@@ -132,7 +192,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
132
192
|
// narrow these two client services during a combined package build.
|
|
133
193
|
const sessions = ctx.get('sessions') as unknown as ISessions
|
|
134
194
|
const workspaces = ctx.get('workspaces') as unknown as IWorkspaces
|
|
135
|
-
const
|
|
195
|
+
const remote = ctx.get('remote') as unknown as ClientRemote
|
|
136
196
|
|
|
137
197
|
// Core wiring: real runtime faces into the framework-free services.
|
|
138
198
|
const store = new LocalStorageTaskStore()
|
|
@@ -166,10 +226,10 @@ export function apply(ctx: ClientContext): void {
|
|
|
166
226
|
disposers.push(workspaces.list.subscribe(pushWorkspaceOptions))
|
|
167
227
|
const pushPresetOptions = async (): Promise<void> => {
|
|
168
228
|
try {
|
|
169
|
-
const
|
|
170
|
-
if (!
|
|
229
|
+
const roster = await readPresetRoster(ctx, remote)
|
|
230
|
+
if (roster === undefined || !roster.ok) return
|
|
171
231
|
controller.setExecutionOptions({
|
|
172
|
-
presets:
|
|
232
|
+
presets: roster.presets.map(preset => ({
|
|
173
233
|
id: preset.id,
|
|
174
234
|
name: preset.name,
|
|
175
235
|
description: preset.description,
|
package/src/client/locales.ts
CHANGED
|
@@ -40,6 +40,24 @@ export const zh = {
|
|
|
40
40
|
'new.submit': '创建',
|
|
41
41
|
'new.cancel': '取消',
|
|
42
42
|
'new.required': '标题不能为空',
|
|
43
|
+
'new.freeze': '冻结快照(可选)',
|
|
44
|
+
'new.freezePlaceholder': '粘贴会话输出的 <<<FREEZE … >>>FREEZE 冻结块,解析为目标/进度/下一步(可选)',
|
|
45
|
+
'detail.freeze': '冻结快照',
|
|
46
|
+
'detail.freeze.goal': '目标',
|
|
47
|
+
'detail.freeze.progress': '进度',
|
|
48
|
+
'detail.freeze.next': '下一步',
|
|
49
|
+
'detail.freeze.frozenAt': '冻结于 {time}',
|
|
50
|
+
'detail.freeze.frozenBy': '来源会话 {session}',
|
|
51
|
+
'detail.freeze.redacted': '冻结文本命中敏感模式,已自动替换为 [REDACTED];接手前请人工复核。',
|
|
52
|
+
'card.frozen': '冻结',
|
|
53
|
+
'new.handover': '交接包引用(可选)',
|
|
54
|
+
'new.handoverPlaceholder': '每行一个文档/脚本引用;填写后,上面选择的钉住三元组(工作区/模式/权限)会作为交接包附加到卡片',
|
|
55
|
+
'detail.handover': '交接包',
|
|
56
|
+
'detail.handover.bundledAt': '打包于 {time}',
|
|
57
|
+
'detail.handover.references': '引用',
|
|
58
|
+
'detail.permissionPending': '权限待确认:本卡有效权限({permission})高于会话默认,手动执行与定时调度都会被拒绝,需人工确认后才能运行。',
|
|
59
|
+
'detail.permissionConfirm': '确认权限绑定',
|
|
60
|
+
'detail.permissionConfirmed': '已确认权限绑定 · {time}',
|
|
43
61
|
'detail.title': '任务详情',
|
|
44
62
|
'detail.edit': '编辑',
|
|
45
63
|
'detail.close': '关闭',
|
|
@@ -58,6 +76,7 @@ export const zh = {
|
|
|
58
76
|
'detail.viewSession': '查看会话',
|
|
59
77
|
'detail.noSession': '暂无会话',
|
|
60
78
|
'detail.executionStarted': '已启动',
|
|
79
|
+
'detail.execution.initiator': '发起会话 {session}',
|
|
61
80
|
'detail.executionEnded': '已结束',
|
|
62
81
|
'detail.result.succeeded': '成功',
|
|
63
82
|
'detail.result.failed': '失败',
|
|
@@ -119,7 +138,7 @@ export const zh = {
|
|
|
119
138
|
'settings.off': '关',
|
|
120
139
|
'settings.overridden': '已覆盖',
|
|
121
140
|
'settings.reset': '恢复默认',
|
|
122
|
-
'settings.notExposed': '当前 DSH 版本未向设置页暴露本插件的配置命名空间,表单不可用。可编辑 ~/.dsh/settings.yaml
|
|
141
|
+
'settings.notExposed': '当前 DSH 版本未向设置页暴露本插件的配置命名空间,表单不可用。可编辑 ~/.dsh/settings.yaml 直接配置,或将本命名空间加入 Host 设置白名单后重启。',
|
|
123
142
|
'settings.readOnly': '当前部署的设置只读。',
|
|
124
143
|
'settings.expand': '展开设置',
|
|
125
144
|
'settings.collapse': '收起设置',
|
|
@@ -166,6 +185,24 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
166
185
|
'new.submit': 'Create',
|
|
167
186
|
'new.cancel': 'Cancel',
|
|
168
187
|
'new.required': 'Title is required',
|
|
188
|
+
'new.freeze': 'Frozen snapshot (optional)',
|
|
189
|
+
'new.freezePlaceholder': 'Paste a <<<FREEZE ... >>>FREEZE block from a session; it parses into goal/progress/next (optional)',
|
|
190
|
+
'detail.freeze': 'Frozen snapshot',
|
|
191
|
+
'detail.freeze.goal': 'Goal',
|
|
192
|
+
'detail.freeze.progress': 'Progress',
|
|
193
|
+
'detail.freeze.next': 'Next',
|
|
194
|
+
'detail.freeze.frozenAt': 'Frozen at {time}',
|
|
195
|
+
'detail.freeze.frozenBy': 'Source session {session}',
|
|
196
|
+
'detail.freeze.redacted': 'Sensitive patterns were detected in the frozen text and replaced with [REDACTED]; review manually before resuming.',
|
|
197
|
+
'card.frozen': 'frozen',
|
|
198
|
+
'new.handover': 'Handover references (optional)',
|
|
199
|
+
'new.handoverPlaceholder': 'One doc/script reference per line; when filled, the pinned triplet picked above (workspace/mode/permission) is attached to the card as a handover bundle',
|
|
200
|
+
'detail.handover': 'Handover bundle',
|
|
201
|
+
'detail.handover.bundledAt': 'Bundled at {time}',
|
|
202
|
+
'detail.handover.references': 'References',
|
|
203
|
+
'detail.permissionPending': 'Permission pending confirmation: this card\'s effective permission ({permission}) is above the session default; manual runs and cron are refused until a human confirms.',
|
|
204
|
+
'detail.permissionConfirm': 'Confirm permission binding',
|
|
205
|
+
'detail.permissionConfirmed': 'Permission confirmed · {time}',
|
|
169
206
|
'detail.title': 'Task Detail',
|
|
170
207
|
'detail.edit': 'Edit',
|
|
171
208
|
'detail.close': 'Close',
|
|
@@ -184,6 +221,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
184
221
|
'detail.viewSession': 'View Session',
|
|
185
222
|
'detail.noSession': 'No session',
|
|
186
223
|
'detail.executionStarted': 'Started',
|
|
224
|
+
'detail.execution.initiator': 'Initiated by session {session}',
|
|
187
225
|
'detail.executionEnded': 'Ended',
|
|
188
226
|
'detail.result.succeeded': 'Succeeded',
|
|
189
227
|
'detail.result.failed': 'Failed',
|
|
@@ -245,7 +283,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
245
283
|
'settings.off': 'Off',
|
|
246
284
|
'settings.overridden': 'Overridden',
|
|
247
285
|
'settings.reset': 'Reset to default',
|
|
248
|
-
'settings.notExposed': 'This DSH version does not expose this plugin\'s settings namespace to the configuration page, so the form is unavailable. Edit ~/.dsh/settings.yaml directly, or add the namespace to
|
|
286
|
+
'settings.notExposed': 'This DSH version does not expose this plugin\'s settings namespace to the configuration page, so the form is unavailable. Edit ~/.dsh/settings.yaml directly, or add the namespace to the Host settings allowlist and restart.',
|
|
249
287
|
'settings.readOnly': 'This deployment stores settings read-only.',
|
|
250
288
|
'settings.expand': 'Show settings',
|
|
251
289
|
'settings.collapse': 'Hide settings',
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* card-store pattern.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type { SettingsScope, SettingsScopeSnapshot
|
|
12
|
-
import {
|
|
11
|
+
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
12
|
+
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
13
|
+
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
13
14
|
|
|
14
15
|
/** The write one field's staged text performs when the card is saved. */
|
|
15
16
|
export type FieldWrite =
|
|
@@ -23,9 +24,11 @@ export interface FieldSpec {
|
|
|
23
24
|
/**
|
|
24
25
|
* Whether the Host treats this field as a secret and redacts its value from
|
|
25
26
|
* the read-back (role('secret') in the section schema). Redacted secrets are
|
|
26
|
-
* never compared against the draft on save
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* never compared against the draft on save: the Host strips them from every
|
|
28
|
+
* wire view layer, so the settled snapshot carries nothing to read back. A
|
|
29
|
+
* staged secret set is judged by the mutation settling; the rest of its
|
|
30
|
+
* batch, when one exists, is still judged by read-back, and the atomic
|
|
31
|
+
* mutation lands every write or none.
|
|
29
32
|
*/
|
|
30
33
|
secret?: boolean
|
|
31
34
|
/** Render a stored value as draft text; the empty string when the section carries none. */
|
|
@@ -53,8 +56,8 @@ export interface CardShell {
|
|
|
53
56
|
available: boolean
|
|
54
57
|
/**
|
|
55
58
|
* Whether the namespace is actually served to this client. False when the
|
|
56
|
-
* Host deployment does not expose it (e.g. the
|
|
57
|
-
*
|
|
59
|
+
* Host deployment does not expose it (e.g. the owning plugin's settings
|
|
60
|
+
* domain is not mounted): the card renders an explanation
|
|
58
61
|
* instead of its form, so a missing namespace never looks like a missing
|
|
59
62
|
* plugin.
|
|
60
63
|
*/
|
|
@@ -101,13 +104,17 @@ interface StagedEdit {
|
|
|
101
104
|
interface PlannedWrite {
|
|
102
105
|
/** Field this entry writes. */
|
|
103
106
|
field: string
|
|
104
|
-
/** The durable write this entry performs,
|
|
107
|
+
/** The durable write this entry performs, inside the save's one atomic mutation. */
|
|
105
108
|
op: BatchedWrite
|
|
106
|
-
/**
|
|
107
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Read the settled snapshot back and report whether the Host holds this
|
|
111
|
+
* write's effect. Undefined when the draft is not a value the field
|
|
112
|
+
* accepts: there is nothing to write, and the entry blocks the save.
|
|
113
|
+
*/
|
|
114
|
+
judge: (() => boolean) | undefined
|
|
108
115
|
}
|
|
109
116
|
|
|
110
|
-
/** One durable write
|
|
117
|
+
/** One durable write inside the save's atomic scope mutation. */
|
|
111
118
|
export interface BatchedWrite {
|
|
112
119
|
/** Field this entry writes. */
|
|
113
120
|
field: string
|
|
@@ -117,36 +124,6 @@ export interface BatchedWrite {
|
|
|
117
124
|
value?: unknown
|
|
118
125
|
}
|
|
119
126
|
|
|
120
|
-
/** Per-field outcome of one batched scope write. */
|
|
121
|
-
export interface BatchedFieldResult {
|
|
122
|
-
/** Field this entry writes. */
|
|
123
|
-
field: string
|
|
124
|
-
/** Whether the Host accepted this field's write (per the read-back view). */
|
|
125
|
-
landed: boolean
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Result of a batched scope write. The bridge scope posts every planned write
|
|
130
|
-
* in one /mutate so the Host validate hook judges baseURL+model together; a
|
|
131
|
-
* batched refusal fails the whole save rather than per-field.
|
|
132
|
-
*/
|
|
133
|
-
export interface BatchResult {
|
|
134
|
-
/** Whether the whole mutate was accepted. */
|
|
135
|
-
ok: boolean
|
|
136
|
-
/** Per-field success, in the request order (always present when ok). */
|
|
137
|
-
fields: BatchedFieldResult[]
|
|
138
|
-
/** Host rejection code (mutate refused). */
|
|
139
|
-
code?: string
|
|
140
|
-
/** Host rejection message (mutate refused). */
|
|
141
|
-
message?: string
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** The optional batch surface the bridge scope adds over the SettingsScope contract. */
|
|
145
|
-
interface BatchedSettingsScope {
|
|
146
|
-
/** Write every operation in one scope mutation, reporting per-field success. */
|
|
147
|
-
mutate: (writes: BatchedWrite[]) => Promise<BatchResult>
|
|
148
|
-
}
|
|
149
|
-
|
|
150
127
|
/** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
|
|
151
128
|
export interface NumberConstraints {
|
|
152
129
|
/** The accepted value must be a whole number. */
|
|
@@ -188,8 +165,8 @@ export function textField(field: string): FieldSpec {
|
|
|
188
165
|
/**
|
|
189
166
|
* A free-text field the Host treats as a secret and redacts from the read-back
|
|
190
167
|
* (role('secret') in the section schema). The card still edits it like text,
|
|
191
|
-
* but a save never compares the redacted value back
|
|
192
|
-
*
|
|
168
|
+
* but a save never compares the redacted value back: the staged set is judged
|
|
169
|
+
* by the mutation settling (see {@link FieldSpec.secret}).
|
|
193
170
|
*/
|
|
194
171
|
export function secretField(field: string): FieldSpec {
|
|
195
172
|
return { ...textField(field), secret: true }
|
|
@@ -277,7 +254,7 @@ export class CardForm<T> {
|
|
|
277
254
|
exposed: snapshot.status === 'ready',
|
|
278
255
|
writable: snapshot.writable,
|
|
279
256
|
dirty: plan.length > 0,
|
|
280
|
-
invalid: plan.some(item => item.
|
|
257
|
+
invalid: plan.some(item => item.judge === undefined),
|
|
281
258
|
saving: this.saving,
|
|
282
259
|
failed: this.failed,
|
|
283
260
|
...this.failedReason === undefined ? {} : { failedReason: this.failedReason },
|
|
@@ -318,21 +295,25 @@ export class CardForm<T> {
|
|
|
318
295
|
}
|
|
319
296
|
|
|
320
297
|
/**
|
|
321
|
-
* Write every staged edit, then re-seed from
|
|
298
|
+
* Write every staged edit in one atomic scope mutation, then re-seed from
|
|
299
|
+
* what the Host accepted.
|
|
322
300
|
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
301
|
+
* The whole batch rides one mutate, so cross-field validate hooks
|
|
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.
|
|
311
|
+
* @returns settlement after the mutation and the read-back.
|
|
330
312
|
*/
|
|
331
313
|
async save(): Promise<void> {
|
|
332
314
|
const plan = this.plan()
|
|
333
|
-
const valid = plan.filter(item => item.
|
|
315
|
+
const valid = plan.filter((item): item is PlannedWrite & { judge: () => boolean } => item.judge !== undefined)
|
|
334
316
|
if (plan.length === 0 || this.saving || valid.length !== plan.length) return
|
|
335
|
-
const plannedWrites = valid.map(item => item.op)
|
|
336
317
|
// Snapshot the staged entries this save writes, so an edit staged while it
|
|
337
318
|
// is in flight (which replaces the same key) survives: only delete the key
|
|
338
319
|
// when the entry is still the one this save started from.
|
|
@@ -342,36 +323,34 @@ export class CardForm<T> {
|
|
|
342
323
|
this.failed = false
|
|
343
324
|
this.failedReason = undefined
|
|
344
325
|
this.publish()
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
} else {
|
|
357
|
-
for (const item of valid) {
|
|
358
|
-
if (await item.run!()) landed.add(item.field)
|
|
359
|
-
}
|
|
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
|
|
328
|
+
// recovered together — either every write lands or none does.
|
|
329
|
+
const ops: Array<{ op: 'set'; path: string[]; value: string | number | boolean } | { op: 'unset'; path: string[] }> = valid.map(item => item.op.op === 'set'
|
|
330
|
+
? { op: 'set', path: [item.field], value: (item.op as { value: string | number | boolean }).value }
|
|
331
|
+
: { op: 'unset', path: [item.field] })
|
|
332
|
+
let failedReason: string | undefined
|
|
333
|
+
try {
|
|
334
|
+
await this.scope.mutate(ops)
|
|
335
|
+
} catch (error) {
|
|
336
|
+
failedReason = error instanceof Error ? error.message : String(error)
|
|
360
337
|
}
|
|
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())
|
|
361
343
|
for (const [field, before] of pending) {
|
|
362
|
-
if (landed
|
|
344
|
+
if (landed && this.staged.get(field) === before) this.staged.delete(field)
|
|
363
345
|
}
|
|
364
346
|
this.saving = false
|
|
365
|
-
this.failed = landed
|
|
347
|
+
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
|
+
this.failedReason = failedReason
|
|
366
351
|
this.publish()
|
|
367
352
|
}
|
|
368
353
|
|
|
369
|
-
/** The scope's batch surface when it supports one; undefined conservatively otherwise. */
|
|
370
|
-
private batchedScope(): BatchedSettingsScope | undefined {
|
|
371
|
-
const candidate = this.scope as unknown as BatchedSettingsScope | undefined
|
|
372
|
-
return typeof candidate?.mutate === 'function' ? candidate : undefined
|
|
373
|
-
}
|
|
374
|
-
|
|
375
354
|
/**
|
|
376
355
|
* Every staged edit a save would write. An entry whose draft is not a value
|
|
377
356
|
* its field accepts carries no write: the form is still dirty, and the save
|
|
@@ -384,33 +363,40 @@ export class CardForm<T> {
|
|
|
384
363
|
for (const [field, staged] of this.staged) {
|
|
385
364
|
const spec = this.specOf(field)
|
|
386
365
|
if (staged.clear) {
|
|
387
|
-
if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' },
|
|
366
|
+
if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' }, judge: () => this.landedUnset(field) })
|
|
388
367
|
continue
|
|
389
368
|
}
|
|
390
369
|
if (staged.text === spec.format(this.sectionValue(field))) continue
|
|
391
370
|
const write = spec.parse(staged.text)
|
|
392
|
-
if (write === undefined) plan.push({ field, op: { field, op: 'unset' },
|
|
393
|
-
else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' },
|
|
394
|
-
else plan.push({ field, op: { field, op: 'set', value: write.value },
|
|
371
|
+
if (write === undefined) plan.push({ field, op: { field, op: 'unset' }, judge: undefined })
|
|
372
|
+
else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' }, judge: () => this.landedUnset(field) })
|
|
373
|
+
else plan.push({ field, op: { field, op: 'set', value: write.value }, judge: () => this.landedSet(field, write.value) })
|
|
395
374
|
}
|
|
396
375
|
return plan
|
|
397
376
|
}
|
|
398
377
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
// the per-field path the scope resolved, so the write is landed.
|
|
378
|
+
/**
|
|
379
|
+
* Read-back judgment for a planned set: the user layer must hold the
|
|
380
|
+
* intended value once the mutation has settled.
|
|
381
|
+
*/
|
|
382
|
+
private landedSet(field: string, value: unknown): boolean {
|
|
383
|
+
// 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
|
|
385
|
+
// snapshot does not expose, so there is nothing to compare the draft
|
|
386
|
+
// against. Settling is the only signal the form has; the rest of the
|
|
387
|
+
// batch, when one exists, still carries the atomic verdict by read-back.
|
|
410
388
|
if (this.specOf(field).secret) return true
|
|
411
389
|
return this.userLayer()?.[field] === value
|
|
412
390
|
}
|
|
413
391
|
|
|
392
|
+
/**
|
|
393
|
+
* Read-back judgment for a planned unset: the field must be gone from the
|
|
394
|
+
* user layer once the mutation has settled.
|
|
395
|
+
*/
|
|
396
|
+
private landedUnset(field: string): boolean {
|
|
397
|
+
return !this.stored(field)
|
|
398
|
+
}
|
|
399
|
+
|
|
414
400
|
private stage(field: string, edit: StagedEdit): void {
|
|
415
401
|
this.staged.set(field, edit)
|
|
416
402
|
this.failed = false
|