@boyingliu01/xp-gate 0.32.1 → 0.32.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/adapters/powershell.sh +33 -7
  2. package/gate-3.sh +37 -2
  3. package/hooks/gate-3.sh +37 -2
  4. package/lib/mattpocock-license-verdict.json +1 -1
  5. package/mock-policy/AGENTS.md +3 -3
  6. package/mutation/AGENTS.md +3 -3
  7. package/package.json +1 -1
  8. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  9. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  10. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  11. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  12. package/plugins/dsh/README.md +51 -2
  13. package/plugins/dsh/client/client.js +760 -0
  14. package/plugins/dsh/lib/client.js +760 -0
  15. package/plugins/dsh/lib/delphi-settings.d.ts +52 -0
  16. package/plugins/dsh/lib/delphi-settings.d.ts.map +1 -0
  17. package/plugins/dsh/lib/delphi-settings.js +76 -0
  18. package/plugins/dsh/lib/hooks.d.ts +14 -1
  19. package/plugins/dsh/lib/hooks.d.ts.map +1 -1
  20. package/plugins/dsh/lib/hooks.js +44 -5
  21. package/plugins/dsh/lib/index.d.ts +63 -0
  22. package/plugins/dsh/lib/index.d.ts.map +1 -1
  23. package/plugins/dsh/lib/index.js +126 -21
  24. package/plugins/dsh/package.json +13 -3
  25. package/plugins/dsh/scripts/copy-client.cjs +32 -0
  26. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  27. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  28. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  29. package/plugins/dsh/src/client.test.ts +231 -0
  30. package/plugins/dsh/src/delphi-settings.test.ts +101 -0
  31. package/plugins/dsh/src/delphi-settings.ts +111 -0
  32. package/plugins/dsh/src/hooks.test.ts +48 -0
  33. package/plugins/dsh/src/hooks.ts +45 -5
  34. package/plugins/dsh/src/index.test.ts +3 -2
  35. package/plugins/dsh/src/index.ts +147 -25
  36. package/plugins/opencode/package.json +1 -1
  37. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  38. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  39. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  40. package/plugins/qoder/plugin.json +1 -1
  41. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  42. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  43. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  44. package/principles/AGENTS.md +3 -3
  45. package/skills/delphi-review/AGENTS.md +3 -3
  46. package/skills/sprint-flow/AGENTS.md +3 -3
  47. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -0,0 +1,760 @@
1
+ /**
2
+ * Delphi model configuration page — the plugin's browser half.
3
+ *
4
+ * Rendered by the DSH Web client from the Plugins page: open
5
+ * **Plugins → @boyingliu01/dsh-plugin-xp-gate → tool-xp-gate → Configure**.
6
+ *
7
+ * Why hand-written CommonJS instead of tsdown: the DSH client module system
8
+ * (`dsh-client-modules`) evaluates exactly this shape — one
9
+ * `window.__ModuleLoader__.load({ id, factory })` call per package, receiving a
10
+ * `require` that resolves the shared platform table (React, Cordis, primitives).
11
+ * A bundler that adds a prelude or exports ESM would register nothing.
12
+ *
13
+ * Contract details worth stating, because a shipped third-party plugin got them
14
+ * wrong and every save failed silently:
15
+ *
16
+ * - settings writes are `{ op: 'set', path: [name], value }` (`SettingsPathOpView`),
17
+ * NOT `{ type, key, value }`
18
+ * - a row's configuration page is keyed `<package name>#<row id>`
19
+ * - `plugins.item` belongs to the official settings pages, so this registers only
20
+ * `plugins.row.config`
21
+ *
22
+ * The page cannot import the plugin's host code: `src/delphi.ts` and friends import
23
+ * `node:fs` and `@deepseek-ai/dsh-llm`, neither of which the browser has. The one
24
+ * rule it must agree with — three distinct models — is therefore re-applied here
25
+ * from the same three-seat invariant, and the Host re-checks it on every review
26
+ * run (`seatsFromSettings`), so a disagreement can never leak money.
27
+ */
28
+ window.__ModuleLoader__.load({
29
+ id: '@boyingliu01/dsh-plugin-xp-gate',
30
+ factory: (require) => {
31
+ var module = { exports: {} }
32
+ var exports = module.exports
33
+
34
+ /** Plugin package name, and the Loader row id its bundle patch declares. */
35
+ var PKG = '@boyingliu01/dsh-plugin-xp-gate'
36
+ var ROW_ID = 'tool-xp-gate'
37
+ /** The settings namespace is the row id: DSH keys a served form by entry id. */
38
+ var NS = ROW_ID
39
+ /** Dictionary namespace owned by this page. */
40
+ var LOCALE_NS = 'xp-gate-delphi'
41
+
42
+ var React = require('react')
43
+
44
+ /** Exactly three seats, matching the Delphi invariant the Host enforces. */
45
+ var SEAT_COUNT = 3
46
+ /** The roles xp-gate writes into `.delphi-config.json`; kept as the defaults. */
47
+ var DEFAULT_ROLES = ['architecture', 'technical', 'feasibility']
48
+
49
+ var en = {
50
+ summary: 'Choose the three Delphi reviewer models. Saving here overrides .delphi-config.json.',
51
+ seats: 'Reviewer seats',
52
+ seatsHint: 'Exactly three distinct models. A repeated model, or a model shared with another seat, is rejected before any request is sent.',
53
+ seat: 'Seat {n}',
54
+ role: 'Role',
55
+ model: 'Model',
56
+ pickModel: 'Choose from configured providers',
57
+ gateway: 'Gateway',
58
+ gatewayHint: 'OpenAI-compatible base URL, e.g. https://host/gpt-proxy. All seats share it.',
59
+ apiKey: 'API key reference',
60
+ apiKeyHint: 'A credential reference such as ${WHALECLOUD_API_KEY}. A literal key is refused: it would be written to the profile file.',
61
+ consensus: 'Consensus',
62
+ threshold: 'Approval threshold (%)',
63
+ maxRounds: 'Maximum rounds',
64
+ catalogLoading: 'Loading models…',
65
+ catalogFailed: 'Could not load the model catalog.',
66
+ catalogUnavailable: 'No providers configured; type model ids directly.',
67
+ invalid: 'The three seats need three distinct, non-empty models.',
68
+ invalidGateway: 'A gateway URL is required when seats are configured.',
69
+ save: 'Save',
70
+ saving: 'Saving…',
71
+ saveFailed: 'The Host did not accept these values.',
72
+ readOnly: 'This deployment stores settings read-only.',
73
+ unavailable: 'This plugin is not loaded, so it cannot be configured right now.',
74
+ fallbackNote: 'No seats saved here yet, so reviews use .delphi-config.json.',
75
+ }
76
+
77
+ var zh = {
78
+ summary: '选择三个 Delphi 评审模型。在此保存会覆盖 .delphi-config.json。',
79
+ seats: '评审席位',
80
+ seatsHint: '必须恰好三个互不相同的模型。重复或与其它席位相同,会在发出任何请求之前被拒绝。',
81
+ seat: '席位 {n}',
82
+ role: '角色',
83
+ model: '模型',
84
+ pickModel: '从已配置的提供方选择',
85
+ gateway: '网关',
86
+ gatewayHint: 'OpenAI 兼容的 base URL,例如 https://host/gpt-proxy。三个席位共用。',
87
+ apiKey: '密钥引用',
88
+ apiKeyHint: '使用凭据引用,如 ${WHALECLOUD_API_KEY}。拒绝明文密钥——它会写进 profile 文件。',
89
+ consensus: '共识',
90
+ threshold: '通过阈值(%)',
91
+ maxRounds: '最大轮次',
92
+ catalogLoading: '正在加载模型…',
93
+ catalogFailed: '无法加载模型目录。',
94
+ catalogUnavailable: '未配置提供方,请直接填写模型 ID。',
95
+ invalid: '三个席位需要三个互不相同的非空模型。',
96
+ invalidGateway: '配置了席位就必填网关地址。',
97
+ save: '保存',
98
+ saving: '保存中…',
99
+ saveFailed: '宿主未接受这些值。',
100
+ readOnly: '本部署的设置是只读的。',
101
+ unavailable: '插件未加载,暂时无法配置。',
102
+ fallbackNote: '这里还没有保存席位上,评审仍使用 .delphi-config.json。',
103
+ }
104
+
105
+ /** The active locale's BCP-47 tag, read defensively: a missing API is not an error. */
106
+ function activeLocale(ctx) {
107
+ try {
108
+ var snap = ctx && ctx.locale && ctx.locale.getSnapshot ? ctx.locale.getSnapshot() : null
109
+ if (snap && typeof snap.active === 'string') return snap.active
110
+ } catch (error) {
111
+ console.warn('[xp-gate] locale read failed', error && error.message)
112
+ }
113
+ return ''
114
+ }
115
+
116
+ /** Pick the dictionary for the active locale, defaulting to English. */
117
+ function translator(ctx) {
118
+ var dict = activeLocale(ctx).indexOf('zh') === 0 ? zh : en
119
+ return function t(key, params) {
120
+ var text = dict[key] !== undefined ? dict[key] : en[key] !== undefined ? en[key] : key
121
+ if (params) {
122
+ Object.keys(params).forEach(function (name) {
123
+ text = text.split('{' + name + '}').join(String(params[name]))
124
+ })
125
+ }
126
+ return text
127
+ }
128
+ }
129
+
130
+ /** One provider group from the Host's catalog, in the shape the select renders. */
131
+ function catalogGroup(group) {
132
+ var models = (group.models || []).map(function (model) { return model.id })
133
+ return { provider: group.id, providerName: group.name, models: models }
134
+ }
135
+
136
+ /** The Host's session handle that can list models, or null when unreachable. */
137
+ function catalogSession(ctx) {
138
+ if (!ctx || !ctx.remote || !ctx.remote.session) return null
139
+ return ctx.remote.session
140
+ }
141
+
142
+ /**
143
+ * The non-empty provider groups a catalog response offers, or null on failure.
144
+ *
145
+ * Grouping here rather than in the effect keeps the promise handler a
146
+ * two-branch decision: one shape or an error.
147
+ */
148
+ function healthyCatalogGroups(response) {
149
+ if (!response || !response.ok) return null
150
+ var groups = (response.value.groups || []).map(catalogGroup)
151
+ return groups.filter(function (group) { return group.models.length > 0 })
152
+ }
153
+
154
+ /**
155
+ * Build the editable draft from the Host's accepted values.
156
+ *
157
+ * Lives outside the component on purpose: the page's effect has to stay a
158
+ * subscription, not a place where six defaulting ternaries live.
159
+ */
160
+ function draftFrom(value) {
161
+ var delphi = (value && value.delphi) || {}
162
+ return {
163
+ seats: seatsOf(delphi),
164
+ baseUrl: delphi.baseUrl || '',
165
+ apiKey: delphi.apiKey || '',
166
+ thresholdPercent: typeof delphi.thresholdPercent === 'number' ? delphi.thresholdPercent : 90,
167
+ maxRounds: typeof delphi.maxRounds === 'number' ? delphi.maxRounds : 5,
168
+ }
169
+ }
170
+
171
+ /** One draft seat: the stored strings, with the role defaulting to this seat's slot. */
172
+ function draftSeat(stored, index) {
173
+ var seat = stored || {}
174
+ var role = typeof seat.role === 'string' && seat.role ? seat.role : DEFAULT_ROLES[index]
175
+ return { role: role, model: typeof seat.model === 'string' ? seat.model : '' }
176
+ }
177
+
178
+ /** Read a seat list from the section value, always returning exactly three drafts. */
179
+ function seatsOf(value) {
180
+ var stored = value && Array.isArray(value.seats) ? value.seats : []
181
+ var seats = []
182
+ for (var i = 0; i < SEAT_COUNT; i++) seats.push(draftSeat(stored[i], i))
183
+ return seats
184
+ }
185
+
186
+ /** Every distinctness rule the Host will re-check, applied to the draft. */
187
+ function validationError(seats, baseUrl) {
188
+ var models = seats.map(function (s) {
189
+ return (s.model || '').trim()
190
+ })
191
+ if (models.some(function (m) { return m === '' })) return 'empty'
192
+ var unique = models.filter(function (m, i) { return models.indexOf(m) === i })
193
+ if (unique.length !== SEAT_COUNT) return 'duplicate'
194
+ if (!baseUrl || !String(baseUrl).trim()) return 'gateway'
195
+ return ''
196
+ }
197
+
198
+ /** Build the ordered `mutate` operations one save sends. */
199
+ function saveOperations(draft) {
200
+ var delphi = {
201
+ baseUrl: (draft.baseUrl || '').trim(),
202
+ apiKey: (draft.apiKey || '').trim(),
203
+ seats: draft.seats.map(function (seat, index) {
204
+ return { role: (seat.role || DEFAULT_ROLES[index]).trim(), model: (seat.model || '').trim() }
205
+ }),
206
+ thresholdPercent: Number(draft.thresholdPercent),
207
+ maxRounds: Number(draft.maxRounds),
208
+ }
209
+ // DSH's SettingsPathOpView: path-addressed, one op per field. The whole
210
+ // block is one op so the sections can never be saved half-way.
211
+ return [{ op: 'set', path: ['delphi'], value: delphi }]
212
+ }
213
+
214
+ /** A labelled text input. */
215
+ function Field(props) {
216
+ return React.createElement(
217
+ 'label',
218
+ { style: { display: 'flex', flexDirection: 'column', gap: 4, flex: 1, minWidth: 0 } },
219
+ React.createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } }, props.label),
220
+ React.createElement('input', {
221
+ type: 'text',
222
+ value: props.value,
223
+ disabled: props.disabled,
224
+ onChange: function (event) { props.onChange(event.target.value) },
225
+ style: {
226
+ padding: '6px 10px',
227
+ borderRadius: 8,
228
+ border: '1px solid var(--dsw-alias-border-l2)',
229
+ background: 'var(--dsw-alias-bg-layer-2)',
230
+ color: 'var(--dsw-alias-label-primary)',
231
+ fontSize: 13,
232
+ },
233
+ }),
234
+ props.hint
235
+ ? React.createElement('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } }, props.hint)
236
+ : null,
237
+ )
238
+ }
239
+
240
+ /** One provider's models as an `<optgroup>`, so the select body stays shallow. */
241
+ function CatalogGroup(props) {
242
+ return React.createElement(
243
+ 'optgroup',
244
+ { label: props.group.providerName },
245
+ props.group.models.map(function (model) {
246
+ return React.createElement('option', { key: model, value: model }, model)
247
+ }),
248
+ )
249
+ }
250
+
251
+ /**
252
+ * The catalog picker beside the model text input.
253
+ *
254
+ * Blank means "keep whatever was typed": an empty option is only the initial
255
+ * state, and selecting it must not clear a model the operator pinned by hand.
256
+ */
257
+ function CatalogPicker(props) {
258
+ if (props.catalogs.length === 0) return null
259
+ return React.createElement(
260
+ 'label',
261
+ { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
262
+ React.createElement(
263
+ 'span',
264
+ { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } },
265
+ props.t('pickModel'),
266
+ ),
267
+ React.createElement(
268
+ 'select',
269
+ {
270
+ value: props.value,
271
+ disabled: props.disabled,
272
+ onChange: function (event) {
273
+ if (event.target.value) props.onChange(event.target.value)
274
+ },
275
+ style: {
276
+ padding: '6px 10px',
277
+ borderRadius: 8,
278
+ border: '1px solid var(--dsw-alias-border-l2)',
279
+ background: 'var(--dsw-alias-bg-layer-2)',
280
+ color: 'var(--dsw-alias-label-primary)',
281
+ fontSize: 13,
282
+ maxWidth: 260,
283
+ },
284
+ },
285
+ React.createElement('option', { value: '' }, '—'),
286
+ props.catalogs.map(function (group) {
287
+ return React.createElement(CatalogGroup, { key: group.provider, group: group })
288
+ }),
289
+ ),
290
+ )
291
+ }
292
+
293
+ /** Render one seat row: role, model text input, and a catalog select when available. */
294
+ function SeatRow(props) {
295
+ var seat = props.seat
296
+ return React.createElement(
297
+ 'div',
298
+ { style: { display: 'flex', gap: 8, alignItems: 'flex-end', padding: '4px 0' } },
299
+ React.createElement(Field, {
300
+ label: props.t('role'),
301
+ value: seat.role,
302
+ disabled: props.disabled,
303
+ onChange: function (v) { props.onChange('role', v) },
304
+ }),
305
+ React.createElement(Field, {
306
+ label: props.t('model'),
307
+ value: seat.model,
308
+ disabled: props.disabled,
309
+ onChange: function (v) { props.onChange('model', v) },
310
+ }),
311
+ React.createElement(CatalogPicker, {
312
+ t: props.t,
313
+ value: seat.model,
314
+ disabled: props.disabled,
315
+ catalogs: props.catalogs,
316
+ onChange: function (v) { props.onChange('model', v) },
317
+ }),
318
+ )
319
+ }
320
+
321
+ /**
322
+ * The page's section list, in render order.
323
+ *
324
+ * Split out of the component so the component stays a state machine — the
325
+ * markup is a pure function of the draft and the flags, which is also what
326
+ * makes it readable without scrolling through a hook order.
327
+ */
328
+ function pageSections(f) {
329
+ var t = f.t
330
+ var children = []
331
+
332
+ if (!f.writable) children.push(React.createElement('p', { key: 'ro', role: 'status' }, t('readOnly')))
333
+ children.push(
334
+ React.createElement(
335
+ 'p',
336
+ { key: 'sum', style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } },
337
+ t('summary'),
338
+ ),
339
+ )
340
+ if (f.noSeats) {
341
+ children.push(React.createElement('p', { key: 'fb', style: { fontSize: 12 } }, t('fallbackNote')))
342
+ }
343
+
344
+ children.push(React.createElement(seatsSection, { key: 'seats', f: f }))
345
+ children.push(React.createElement(gatewaySection, { key: 'gateway', f: f }))
346
+ children.push(React.createElement(consensusSection, { key: 'consensus', f: f }))
347
+
348
+ if (f.problem && !f.noSeats) {
349
+ var message = f.problem === 'gateway' ? t('invalidGateway') : t('invalid')
350
+ children.push(errorLine('inv', message))
351
+ }
352
+ if (f.failed) children.push(errorLine('fail', t('saveFailed')))
353
+
354
+ children.push(React.createElement(saveAction, { key: 'actions', f: f }))
355
+ return children
356
+ }
357
+
358
+ /** One red line of form feedback. */
359
+ function errorLine(key, message) {
360
+ var style = { fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' }
361
+ return React.createElement('p', { key: key, role: 'alert', style: style }, message)
362
+ }
363
+
364
+ /** The three seat editors plus the catalog failure note. */
365
+ function seatsSection(props) {
366
+ var f = props.f
367
+ var t = f.t
368
+ return React.createElement(
369
+ 'section',
370
+ null,
371
+ React.createElement('h4', { style: { margin: '12px 0 4px', fontSize: 13 } }, t('seats')),
372
+ React.createElement(
373
+ 'p',
374
+ { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)', margin: 0 } },
375
+ t('seatsHint'),
376
+ ),
377
+ f.draft.seats.map(function (seat, index) {
378
+ return React.createElement(SeatRow, {
379
+ key: index,
380
+ t: t,
381
+ seat: seat,
382
+ catalogs: f.catalogs,
383
+ disabled: f.disabled,
384
+ onChange: function (key, value) { f.editSeat(index, key, value) },
385
+ })
386
+ }),
387
+ f.catalogs.length === 0 && f.catalogError
388
+ ? React.createElement('p', { style: { fontSize: 12 } }, t('catalogFailed'))
389
+ : null,
390
+ )
391
+ }
392
+
393
+ /** Gateway URL and the api key reference. */
394
+ function gatewaySection(props) {
395
+ var f = props.f
396
+ var t = f.t
397
+ return React.createElement(
398
+ 'section',
399
+ { style: { display: 'flex', gap: 8, marginTop: 12 } },
400
+ React.createElement(Field, {
401
+ label: t('gateway'),
402
+ value: f.draft.baseUrl,
403
+ hint: t('gatewayHint'),
404
+ disabled: f.disabled,
405
+ onChange: function (v) { f.editField('baseUrl', v) },
406
+ }),
407
+ React.createElement(Field, {
408
+ label: t('apiKey'),
409
+ value: f.draft.apiKey,
410
+ hint: t('apiKeyHint'),
411
+ disabled: f.disabled,
412
+ onChange: function (v) { f.editField('apiKey', v) },
413
+ }),
414
+ )
415
+ }
416
+
417
+ /** Consensus threshold and the round cap. */
418
+ function consensusSection(props) {
419
+ var f = props.f
420
+ var t = f.t
421
+ return React.createElement(
422
+ 'section',
423
+ { style: { display: 'flex', gap: 8, marginTop: 12 } },
424
+ React.createElement(Field, {
425
+ label: t('threshold'),
426
+ value: String(f.draft.thresholdPercent),
427
+ disabled: f.disabled,
428
+ onChange: function (v) { f.editField('thresholdPercent', v) },
429
+ }),
430
+ React.createElement(Field, {
431
+ label: t('maxRounds'),
432
+ value: String(f.draft.maxRounds),
433
+ disabled: f.disabled,
434
+ onChange: function (v) { f.editField('maxRounds', v) },
435
+ }),
436
+ )
437
+ }
438
+
439
+ /** The single save button; disabled while a write is not possible. */
440
+ function saveAction(props) {
441
+ var f = props.f
442
+ var t = f.t
443
+ var blockedStyle = {
444
+ padding: '6px 14px',
445
+ borderRadius: 8,
446
+ border: '1px solid transparent',
447
+ cursor: f.writing ? 'not-allowed' : 'pointer',
448
+ background: 'var(--dsw-alias-brand-primary)',
449
+ color: 'var(--dsw-alias-brand-primary-invert, #fff)',
450
+ fontSize: 13,
451
+ opacity: f.writing ? 0.6 : 1,
452
+ }
453
+ return React.createElement(
454
+ 'div',
455
+ { style: { marginTop: 12 } },
456
+ React.createElement(
457
+ 'button',
458
+ { type: 'button', disabled: f.writing, onClick: f.save, style: blockedStyle },
459
+ f.saving ? t('saving') : t('save'),
460
+ ),
461
+ )
462
+ }
463
+
464
+ /**
465
+ * The three "nothing to show yet" states, decided once.
466
+ *
467
+ * `refused` carries the element to render; the caller returns it verbatim.
468
+ */
469
+ function pageGate(t, scope, snapshot, draft) {
470
+ if (!scope) return { refused: React.createElement('p', null, t('unavailable')) }
471
+ if (snapshot && snapshot.status === 'unavailable') {
472
+ return { refused: React.createElement('p', { role: 'status' }, t('unavailable')) }
473
+ }
474
+ if (!draft) return { refused: React.createElement('p', { role: 'status' }, t('catalogLoading')) }
475
+ return { refused: null }
476
+ }
477
+
478
+ /** Subscribe to the Host's accepted values and seed the draft from them. */
479
+ function useDraftSubscription(scope, setSnapshot, setDraft) {
480
+ React.useEffect(
481
+ function () {
482
+ if (!scope) return function () {}
483
+ var cancelled = false
484
+ function sync() {
485
+ var next = scope.getSnapshot()
486
+ if (cancelled) return
487
+ setSnapshot(next)
488
+ setDraft(function (current) {
489
+ // Never clobber what the user is typing: seed once, and re-seed only
490
+ // after a save lands (current === null).
491
+ if (current !== null) return current
492
+ return draftFrom(next && next.value)
493
+ })
494
+ }
495
+ sync()
496
+ var unsubscribe = scope.subscribe ? scope.subscribe(sync) : null
497
+ return function () {
498
+ cancelled = true
499
+ if (typeof unsubscribe === 'function') unsubscribe()
500
+ }
501
+ },
502
+ [scope],
503
+ )
504
+ }
505
+
506
+ /** Offer the Host's configured models, grouped by provider, when reachable. */
507
+ function useCatalogSubscription(ctx, setCatalogs, setCatalogError) {
508
+ React.useEffect(
509
+ function () {
510
+ var cancelled = false
511
+ var session = catalogSession(ctx)
512
+ if (!session) return function () {}
513
+ Promise.resolve(session.modelCatalog())
514
+ .then(function (response) {
515
+ if (cancelled) return
516
+ var groups = healthyCatalogGroups(response)
517
+ if (!groups) setCatalogError('failed')
518
+ else setCatalogs(groups)
519
+ })
520
+ .catch(function () {
521
+ if (!cancelled) setCatalogError('failed')
522
+ })
523
+ return function () {
524
+ cancelled = true
525
+ }
526
+ },
527
+ [ctx],
528
+ )
529
+ }
530
+
531
+ /** One seat updated in place: the edited key replaced, the other kept. */
532
+ function withSeatEdit(seats, index, key, value) {
533
+ var next = seats.slice()
534
+ var edited = Object.assign({}, next[index])
535
+ edited[key] = value
536
+ next[index] = edited
537
+ return next
538
+ }
539
+
540
+ /** Hand the action handlers live getters, so a save always reads current state. */
541
+ function pageActionParams(state, scope, facts) {
542
+ return {
543
+ setDraft: state.setDraft,
544
+ setFailed: state.setFailed,
545
+ setSaving: state.setSaving,
546
+ getScope: function () { return scope },
547
+ getDraft: function () { return state.draft },
548
+ getSnapshot: function () { return state.snapshot },
549
+ getSaving: function () { return state.saving },
550
+ getDisabled: function () { return facts.disabled },
551
+ getNoSeats: function () { return facts.noSeats },
552
+ getProblem: function () { return facts.problem },
553
+ }
554
+ }
555
+
556
+ /** The props the section list renders from. */
557
+ function pageSectionProps(t, state, facts, actions) {
558
+ return {
559
+ t: t,
560
+ draft: state.draft,
561
+ catalogs: state.catalogs,
562
+ catalogError: state.catalogError,
563
+ disabled: facts.disabled,
564
+ writing: facts.disabled || Boolean(facts.problem),
565
+ noSeats: facts.noSeats,
566
+ writable: facts.writable,
567
+ problem: facts.problem,
568
+ failed: state.failed,
569
+ saving: state.saving,
570
+ editSeat: actions.editSeat,
571
+ editField: actions.editField,
572
+ save: actions.save,
573
+ }
574
+ }
575
+
576
+ /**
577
+ * Why a save cannot proceed, or null when it can.
578
+ *
579
+ * Two distinct refusals: `busy` (already saving, or the form is read-only)
580
+ * is silent, anything else is the operator's error and gets a banner.
581
+ */
582
+ function saveRefused(params) {
583
+ if (!params.getScope() || params.getSaving() || params.getDisabled()) return 'busy'
584
+ // A form with no seats has nothing to save: clearing seats is what makes
585
+ // the JSON file apply again, and that is an explicit act, not a blank save.
586
+ if (params.getNoSeats() || params.getProblem()) return 'invalid'
587
+ return null
588
+ }
589
+
590
+ /** POST the draft and reconcile the form with what the Host accepted. */
591
+ function commitDraft(params) {
592
+ var snapshot = params.getSnapshot()
593
+ params.setSaving(true)
594
+ params.setFailed(false)
595
+ Promise.resolve(params.getScope().mutate(saveOperations(params.getDraft()), snapshot ? snapshot.revision : undefined))
596
+ .then(function (accepted) {
597
+ if (!accepted) params.setFailed(true)
598
+ else params.setDraft(null)
599
+ })
600
+ .catch(function (error) {
601
+ console.error('[xp-gate] delphi save failed', error)
602
+ params.setFailed(true)
603
+ })
604
+ .then(function () {
605
+ params.setSaving(false)
606
+ })
607
+ }
608
+
609
+ /**
610
+ * The page's mutation handlers and the save sequence.
611
+ *
612
+ * Busy and invalid are separate refusals: one is "do nothing", the other is
613
+ * "tell the user why". Folding them into one predicate is what made the
614
+ * handler a nest of early returns.
615
+ */
616
+ function pageActions(params) {
617
+ /** Apply a draft change and drop any stale "save failed" banner with it. */
618
+ function amend(change) {
619
+ params.setFailed(false)
620
+ params.setDraft(function (current) {
621
+ return Object.assign({}, current, change(current))
622
+ })
623
+ }
624
+
625
+ function editSeat(index, key, value) {
626
+ amend(function (current) {
627
+ return { seats: withSeatEdit(current.seats, index, key, value) }
628
+ })
629
+ }
630
+
631
+ function editField(key, value) {
632
+ amend(function () {
633
+ return { [key]: value }
634
+ })
635
+ }
636
+
637
+ function save() {
638
+ var refusal = saveRefused(params)
639
+ if (refusal === null) commitDraft(params)
640
+ else if (refusal === 'invalid') params.setFailed(true)
641
+ }
642
+
643
+ return { editSeat: editSeat, editField: editField, save: save }
644
+ }
645
+
646
+ /** The six pieces of state the page keeps, unwrapped for readability. */
647
+ function usePageState() {
648
+ var snapshotState = React.useState(null)
649
+ var draftState = React.useState(null)
650
+ var catalogsState = React.useState([])
651
+ var catalogErrorState = React.useState('')
652
+ var savingState = React.useState(false)
653
+ var failedState = React.useState(false)
654
+ return {
655
+ snapshot: snapshotState[0],
656
+ draft: draftState[0],
657
+ setDraft: draftState[1],
658
+ setSnapshot: snapshotState[1],
659
+ catalogs: catalogsState[0],
660
+ setCatalogs: catalogsState[1],
661
+ catalogError: catalogErrorState[0],
662
+ setCatalogError: catalogErrorState[1],
663
+ saving: savingState[0],
664
+ setSaving: savingState[1],
665
+ failed: failedState[0],
666
+ setFailed: failedState[1],
667
+ }
668
+ }
669
+
670
+ /** The page's derived flags: is the form writable, and what is wrong with it. */
671
+ function pageFacts(state) {
672
+ var draft = state.draft
673
+ var writable = state.snapshot ? state.snapshot.writable !== false : false
674
+ return {
675
+ writable: writable,
676
+ problem: validationError(draft.seats, draft.baseUrl),
677
+ disabled: !writable || state.saving,
678
+ noSeats: !draft.seats.some(function (seat) { return (seat.model || '').trim() }),
679
+ }
680
+ }
681
+
682
+ /** The configuration page: seat editors, gateway, and one save. */
683
+ function DelphiConfigPage(props) {
684
+ var t = props.t
685
+ var scope = props.scope
686
+ var state = usePageState()
687
+
688
+ useDraftSubscription(scope, state.setSnapshot, state.setDraft)
689
+ useCatalogSubscription(props.ctx, state.setCatalogs, state.setCatalogError)
690
+
691
+ var gate = pageGate(t, scope, state.snapshot, state.draft)
692
+ if (gate.refused) return gate.refused
693
+
694
+ var facts = pageFacts(state)
695
+ var actions = pageActions(pageActionParams(state, scope, facts))
696
+ var children = pageSections(pageSectionProps(t, state, facts, actions))
697
+ return React.createElement('div', { style: { padding: '4px 0 8px' } }, children)
698
+ }
699
+
700
+ /**
701
+ * What the page renders for a slot dispatch.
702
+ *
703
+ * `summary` is the row's one-liner when the package declares no description;
704
+ * `page` is the configuration form.
705
+ */
706
+ /**
707
+ * The Host's live settings scope for this plugin, or null.
708
+ *
709
+ * A Host without the forms service is not a failure: the page then renders
710
+ * its "read-only / unavailable" branch instead of throwing at mount.
711
+ */
712
+ function configScope(ctx) {
713
+ if (!ctx || !ctx.configForms || typeof ctx.configForms.get !== 'function') return null
714
+ try {
715
+ return ctx.configForms.get(NS)
716
+ } catch (error) {
717
+ console.warn('[xp-gate] configForms.get failed', error && error.message)
718
+ return null
719
+ }
720
+ }
721
+
722
+ function DelphiConfigEntry(props) {
723
+ var ctx = props.ctx
724
+ var t = props.t || translator(ctx)
725
+ if (props.view === 'summary') return t('summary')
726
+ return React.createElement(DelphiConfigPage, { ctx: ctx, t: t, scope: configScope(ctx) })
727
+ }
728
+
729
+ module.exports.inject = ['slots', 'locale', 'configForms']
730
+ module.exports.NS = NS
731
+ module.exports.PKG = PKG
732
+ module.exports.ROW_ID = ROW_ID
733
+ module.exports.saveOperations = saveOperations
734
+ module.exports.seatsOf = seatsOf
735
+ module.exports.validationError = validationError
736
+
737
+ /**
738
+ * Mount the page.
739
+ *
740
+ * Registered on `plugins.row.config` only. The page's lifetime follows the
741
+ * Loader row, so switching the row off removes the page with it.
742
+ */
743
+ module.exports.apply = function apply(ctx) {
744
+ if (typeof ctx.effect === 'function') {
745
+ ctx.effect(function () {
746
+ return ctx.locale.register(LOCALE_NS, { en: en, zh: zh })
747
+ }, 'xp-gate: dictionaries')
748
+ }
749
+ if (!ctx.slots || typeof ctx.slots.inject !== 'function') return
750
+ ctx.slots.inject('plugins.row.config', function () {
751
+ return ctx.slots.register(
752
+ { name: 'plugins.row.config', key: PKG + '#' + ROW_ID, locale: LOCALE_NS },
753
+ DelphiConfigEntry,
754
+ )
755
+ })
756
+ }
757
+
758
+ return module.exports
759
+ },
760
+ })