dsh-ptc-plus 0.5.3 → 0.5.6
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.en.md +3 -3
- package/README.md +3 -3
- package/client.cjs +3326 -2881
- package/compiler-core.cjs +127 -127
- package/compiler-core.cjs.map +2 -2
- package/docs/README.md +1 -0
- package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +2 -2
- package/docs/adr/0017-track-the-latest-dsh-public-surface.md +4 -4
- package/docs/architecture.md +1 -1
- package/docs/capability-planning-components.md +6 -6
- package/docs/capability-projection.md +1 -1
- package/docs/capability-surface.md +133 -0
- package/docs/client-ui.md +17 -15
- package/docs/client-visual-contract.md +7 -7
- package/docs/installation.md +14 -7
- package/docs/panel-concept.md +171 -0
- package/docs/publishing.md +1 -1
- package/docs/release-notes.md +1 -1
- package/docs/releases/v0.5.4.md +79 -0
- package/docs/releases/v0.5.5.md +41 -0
- package/docs/releases/v0.5.6.md +41 -0
- package/docs/runtime-reference.md +14 -0
- package/docs/verification.md +1 -1
- package/index.js +117 -30
- package/internal/binding-adoption.js +103 -11
- package/internal/capability-command-receipt.js +99 -15
- package/internal/capability-environment.js +1108 -348
- package/internal/capability-plan.js +18 -2
- package/internal/capability-policy.js +13 -0
- package/internal/capability-selection.js +330 -0
- package/internal/direct-surface-owner.js +17 -4
- package/internal/edit-transport-owner.js +3 -2
- package/internal/host-tool-sdk.js +17 -29
- package/internal/message-sources.js +21 -0
- package/internal/program-bindings.js +13 -4
- package/internal/runtime-bridge-owner.js +9 -9
- package/internal/runtime-config.js +10 -0
- package/internal/session-log-view.js +47 -5
- package/internal/settings-compat.js +6 -0
- package/internal/user-bindings-owner.js +20 -3
- package/package.json +45 -31
- package/src/client-authoring-view.js +7 -8
- package/src/client-capabilities-view.js +871 -948
- package/src/client-capability-sort.js +22 -9
- package/src/client-copy.js +116 -294
- package/src/client-settings-view.js +90 -33
- package/src/client-styles.js +67 -19
- package/src/client.js +46 -21
- package/internal/capability-delivery-proof.js +0 -70
|
@@ -1,36 +1,62 @@
|
|
|
1
|
-
import { capabilityPolicyForConfig,
|
|
1
|
+
import { capabilityPolicyForConfig, capabilityPolicyFromRecord, changeCapabilityRule, effectiveCapabilitySettings, CAPABILITY_DISCLOSURES, CAPABILITY_INVENTORY_CATEGORIES } from '../internal/capability-policy.js'
|
|
2
2
|
import { CONFIG_DEFAULTS, CONFIG_FIELDS } from '../internal/config-spec.js'
|
|
3
3
|
import { CAPABILITY_CONFIGURATION_KEYS, capabilityProfileConfiguration, validateCapabilityProfiles } from '../internal/runtime-config.js'
|
|
4
4
|
import { readCapabilityCommandReceipt } from '../internal/capability-command-receipt.js'
|
|
5
|
+
import { capabilitySortDirection, cycleCapabilitySort, sortCapabilityEntries } from './client-capability-sort.js'
|
|
5
6
|
import { createFallbackModal } from './client-primitives.js'
|
|
6
|
-
import { CAPABILITY_SORT_COLUMNS, capabilitySortDirection, cycleCapabilitySort, sortCapabilityEntries } from './client-capability-sort.js'
|
|
7
7
|
import {
|
|
8
8
|
PROMPT_ASSETS, PROMPT_TEXT_MAX_SLOT_BYTES, PROMPT_TEXT_SCHEMA,
|
|
9
9
|
accountPromptText, parsePromptTextDocument, resolvePromptText,
|
|
10
10
|
} from '../internal/prompt-text.js'
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* The command's own outcome, in the reader's language.
|
|
14
|
+
*
|
|
15
|
+
* A receipt may report that it found no active PTC session to update. That is a
|
|
16
|
+
* lookup result and a statement about what the command did not do, so the notice
|
|
17
|
+
* says so; it is not a usability verdict about the session and no state is named
|
|
18
|
+
* after one. Releases up to 0.5.4 recorded that lookup as the wire state
|
|
19
|
+
* `unavailable`, so the mapping below keeps those recorded notices readable; the
|
|
20
|
+
* chat notice is the only reader that can still meet one.
|
|
16
21
|
*/
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
const RECEIPT_STATE = Object.freeze({
|
|
23
|
+
recorded: 'recorded', pending: 'pending', 'saved-only': 'saved-only',
|
|
24
|
+
paused: 'paused', off: 'off', usage: 'usage', unavailable: 'no-session',
|
|
25
|
+
})
|
|
26
|
+
const receiptCopyKey = (surface, state) => `capabilities.${surface}.${RECEIPT_STATE[state] ?? 'usage'}`
|
|
27
|
+
|
|
28
|
+
/** The record one write named, in the §4 vocabulary the panel's columns use. */
|
|
29
|
+
function capabilityRecordText(t, record) {
|
|
30
|
+
if (record === null || typeof record !== 'object' || typeof record.id !== 'string') return undefined
|
|
31
|
+
return `${record.id} · ${record.dimension === undefined ? '' : `${t(`capabilities.dimension.${record.dimension}`)} `}`
|
|
32
|
+
+ t(`capabilities.word.${record.selection}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One receipt, one sentence, in the reader's language.
|
|
37
|
+
*
|
|
38
|
+
* A receipt states the write this command performed: which entry, which choice,
|
|
39
|
+
* and which session or global configuration now records it. Whether a cell later
|
|
40
|
+
* reached that entry is a catalogue row's `evidence` field — a marker means an
|
|
41
|
+
* accepted execution obtained it, and this page states no such column — so no
|
|
42
|
+
* receipt states it. The chat card and the composer
|
|
43
|
+
* toast read this one function; the `surface` argument picks which outcome
|
|
44
|
+
* sentence describes the command, and a write that named a record reads the same
|
|
45
|
+
* on both.
|
|
46
|
+
*/
|
|
47
|
+
export function capabilityReceiptNotice(t, receipt, surface = 'receipt') {
|
|
48
|
+
const record = capabilityRecordText(t, receipt?.record)
|
|
49
|
+
if (record !== undefined && receipt.state === 'recorded') {
|
|
50
|
+
return t(`capabilities.receipt.record${receipt.record.scope === 'global' ? '.global' : ''}`,
|
|
51
|
+
{ session: receipt.sessionId, record })
|
|
52
|
+
}
|
|
53
|
+
return t(receiptCopyKey(surface, receipt?.state), { session: receipt?.sessionId, error: receipt?.error ?? '' })
|
|
26
54
|
}
|
|
27
55
|
|
|
28
56
|
export function capabilityCommandText(t, text) {
|
|
29
57
|
if (text?.key) return t(text.key, text.params)
|
|
30
58
|
const receipt = readCapabilityCommandReceipt(text)
|
|
31
|
-
|
|
32
|
-
const line = t(`capabilities.receipt.${receipt.state}`, { session: receipt.sessionId, error: receipt.error ?? '' })
|
|
33
|
-
return receipt.proof === undefined ? line : `${line} ${capabilityProofCopy(t, receipt.proof)}`
|
|
59
|
+
return receipt === undefined ? text : capabilityReceiptNotice(t, receipt, 'receipt')
|
|
34
60
|
}
|
|
35
61
|
|
|
36
62
|
// Host-owned context the model sees in PTC mode. The plugin never receives the host's
|
|
@@ -50,13 +76,13 @@ const PROMPT_TABLE_COLUMNS = PROMPT_TABLE_COLUMN_KEYS.length
|
|
|
50
76
|
function capabilityChoiceLabel(t, value) {
|
|
51
77
|
return t(value === 'inherit' ? 'capabilities.quick.noOverride'
|
|
52
78
|
: ['enabled', 'pending', 'unknown'].includes(value) ? `capabilities.quick.${value}`
|
|
53
|
-
: [...selections, ...CAPABILITY_DISCLOSURES].includes(value) ? `capabilities.${value}` : 'capabilities.quick.unknown')
|
|
79
|
+
: [...selections, ...CAPABILITY_DISCLOSURES].includes(value) ? `capabilities.word.${value}` : 'capabilities.quick.unknown')
|
|
54
80
|
}
|
|
55
81
|
|
|
56
|
-
function capabilityRuleLabel(t, rule, empty = 'capabilities.policy.
|
|
82
|
+
function capabilityRuleLabel(t, rule, empty = 'capabilities.policy.layerAbove') {
|
|
57
83
|
const dimensions = ['availability', 'disclosure'].filter(dimension => rule?.[dimension] !== undefined)
|
|
58
84
|
return dimensions.length ? dimensions.map(dimension => `${t(`capabilities.policy.${dimension}`)}: ${rule[dimension] === 'automatic'
|
|
59
|
-
? t('capabilities.policy.
|
|
85
|
+
? t('capabilities.policy.layerAbove') : capabilityChoiceLabel(t, rule[dimension])}`).join(' · ') : t(empty)
|
|
60
86
|
}
|
|
61
87
|
|
|
62
88
|
function validCatalogRule(rule, temporary = false) {
|
|
@@ -66,6 +92,100 @@ function validCatalogRule(rule, temporary = false) {
|
|
|
66
92
|
? ['enabled', 'closed'].includes(choice) : CAPABILITY_DISCLOSURES.includes(choice))))
|
|
67
93
|
}
|
|
68
94
|
|
|
95
|
+
const CATALOG_DIMENSIONS = ['availability', 'disclosure']
|
|
96
|
+
// The panel's own word table, column by column: a dimension keeps the word naming
|
|
97
|
+
// where its value comes from, so the saved column can say `automatic` and the
|
|
98
|
+
// session column can also say `inherit`, and neither stands in for a decision.
|
|
99
|
+
const CATALOG_STATEMENT_WORDS = Object.freeze({
|
|
100
|
+
saved: Object.freeze({ availability: ['enabled', 'closed', 'automatic'],
|
|
101
|
+
disclosure: [...CAPABILITY_DISCLOSURES, 'automatic'] }),
|
|
102
|
+
temporary: Object.freeze({ availability: ['enabled', 'closed', 'automatic', 'inherit'],
|
|
103
|
+
disclosure: [...CAPABILITY_DISCLOSURES, 'automatic', 'inherit'] }),
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* One column statement as the listing publishes it: the panel's value for the
|
|
108
|
+
* dimensions that row decides.
|
|
109
|
+
*
|
|
110
|
+
* A capability row decides both dimensions and an MCP server row only
|
|
111
|
+
* availability, so a statement is a dimension object with one or two entries.
|
|
112
|
+
* Releases up to 0.5.4 stated one whole-selection word per column; a string stays
|
|
113
|
+
* readable as that word, and the earlier independent-rule form stays readable as
|
|
114
|
+
* the rule it was.
|
|
115
|
+
*/
|
|
116
|
+
function validCatalogStatement(column, value) {
|
|
117
|
+
if (typeof value === 'string') return true
|
|
118
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
|
|
119
|
+
const dimensions = Object.entries(value)
|
|
120
|
+
// A dimension name resolves to this column's own word list or to nothing at all: an own
|
|
121
|
+
// key such as `constructor` would otherwise reach an inherited function and throw while
|
|
122
|
+
// the card is rendering, where every other out-of-table shape falls back to the source.
|
|
123
|
+
return dimensions.length > 0 && dimensions.every(([dimension, choice]) =>
|
|
124
|
+
Object.hasOwn(CATALOG_STATEMENT_WORDS[column], dimension)
|
|
125
|
+
&& CATALOG_STATEMENT_WORDS[column][dimension].includes(choice))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The panel's words for one statement, in the panel's column order. */
|
|
129
|
+
function catalogStatementLabel(t, statement) {
|
|
130
|
+
return CATALOG_DIMENSIONS.filter(dimension => statement[dimension] !== undefined)
|
|
131
|
+
.map(dimension => t(`capabilities.word.${statement[dimension]}`)).join(' · ')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// A Skill's declaring bucket is one of the public provider sources. A bucket this
|
|
135
|
+
// build does not name is stated beside the generic Skill source rather than denied.
|
|
136
|
+
const SKILL_SOURCE_BUCKETS = Object.freeze(['project-dsh', 'project-agents', 'user-dsh', 'user-agents',
|
|
137
|
+
'runtime', 'bundled', 'custom'])
|
|
138
|
+
|
|
139
|
+
/** The declared MCP servers whose `mcp__<server>__` namespace begins one tool name. */
|
|
140
|
+
const mcpServersOf = source => Array.isArray(source?.servers)
|
|
141
|
+
? source.servers.filter(server => typeof server === 'string')
|
|
142
|
+
: typeof source?.server === 'string' ? [source.server] : []
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The source a row states when its payload names none.
|
|
146
|
+
*
|
|
147
|
+
* A settings row can name an identity that only the saved configuration or a frozen
|
|
148
|
+
* plan mentions. The identity still names its namespace — `tools.*` is the Host tool
|
|
149
|
+
* registry's, `skills.*` is a Skill's, `mcp.*`/`mcpServers.*` is MCP's — and stating
|
|
150
|
+
* the namespace is the same fact `inventory` states when no declared server names a
|
|
151
|
+
* tool.
|
|
152
|
+
*/
|
|
153
|
+
const namespaceSource = entry => {
|
|
154
|
+
const namespace = typeof entry.id === 'string' ? entry.id.slice(0, entry.id.indexOf('.')) : ''
|
|
155
|
+
return { kind: namespace === 'skills' ? 'skill'
|
|
156
|
+
: namespace === 'mcp' || namespace === 'mcpServers' ? 'mcp' : 'host' }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The source column's statement for one declared row.
|
|
161
|
+
*
|
|
162
|
+
* `servers` is every declared MCP server whose namespace names the row. One name can
|
|
163
|
+
* begin with two (`mcp__docs__deep__search` is named by `docs` and by `docs__deep`),
|
|
164
|
+
* and then the column states both of them: a source column that cannot name its
|
|
165
|
+
* source has nothing left to say but a denial, which §4 forbids. Payloads written
|
|
166
|
+
* before that list travelled said `kind: 'unknown'`; they still state that several
|
|
167
|
+
* servers match. Every other row states the source its payload names, or the
|
|
168
|
+
* namespace its identity belongs to.
|
|
169
|
+
*/
|
|
170
|
+
function capabilitySourceText(t, entry) {
|
|
171
|
+
// A server row is the declared server layer itself, so its source is MCP: naming
|
|
172
|
+
// the server there would state the row's own name as its source.
|
|
173
|
+
if (entry.kind === 'server') return t('capabilities.source.mcp')
|
|
174
|
+
const source = entry.source ?? namespaceSource(entry)
|
|
175
|
+
const servers = mcpServersOf(source)
|
|
176
|
+
if (servers.length > 0) return servers.join(t('capabilities.source.separator'))
|
|
177
|
+
if (source.kind === 'skill') {
|
|
178
|
+
return SKILL_SOURCE_BUCKETS.includes(source.bucket) ? t(`capabilities.source.${source.bucket}`)
|
|
179
|
+
: source.bucket ? `${t('capabilities.source.other-skill')} · ${source.bucket}` : t('capabilities.source.skill')
|
|
180
|
+
}
|
|
181
|
+
if (source.kind === 'unknown') return t('capabilities.source.multiple-servers')
|
|
182
|
+
// A source kind this build does not name falls back to the namespace the identity
|
|
183
|
+
// states, so each row keeps naming its own layer instead of another one's.
|
|
184
|
+
const namespace = namespaceSource(entry).kind
|
|
185
|
+
if (source.kind === 'mcp' || namespace === 'mcp') return t('capabilities.source.mcp')
|
|
186
|
+
return t(`capabilities.source.${namespace === 'skill' ? 'skill' : 'host'}`)
|
|
187
|
+
}
|
|
188
|
+
|
|
69
189
|
/** A Chat snapshot without a node store cannot order a receipt against the conversation. */
|
|
70
190
|
const noSnapshot = () => undefined
|
|
71
191
|
|
|
@@ -89,17 +209,67 @@ export function firstHumanMessageSeq(nodes) {
|
|
|
89
209
|
|
|
90
210
|
/**
|
|
91
211
|
* A capability receipt belongs to chat only after the conversation has opened.
|
|
92
|
-
* A blank Session has nothing to announce, and a receipt recorded
|
|
93
|
-
* first human message describes a previous attempt's publication. A
|
|
94
|
-
* Chat window cannot prove where the conversation opened, so its
|
|
95
|
-
* loaded message must not hide a receipt the session may still own.
|
|
212
|
+
* A blank Session has nothing to announce, and a publication receipt recorded
|
|
213
|
+
* before the first human message describes a previous attempt's publication. A
|
|
214
|
+
* truncated Chat window cannot prove where the conversation opened, so its
|
|
215
|
+
* earliest loaded message must not hide a receipt the session may still own.
|
|
216
|
+
*
|
|
217
|
+
* A receipt that carries the record of a write is different: the user asked for
|
|
218
|
+
* that change through this session's own panel or command, so it stays visible
|
|
219
|
+
* wherever the conversation currently starts. Hiding it once is what made the
|
|
220
|
+
* row appear while the session was blank and vanish the moment a first message
|
|
221
|
+
* gave the window a starting point.
|
|
96
222
|
*/
|
|
97
|
-
export function capabilityReceiptVisible(node, { blank = false, firstUserSeq = null, windowComplete = true
|
|
223
|
+
export function capabilityReceiptVisible(node, { blank = false, firstUserSeq = null, windowComplete = true,
|
|
224
|
+
written = false } = {}) {
|
|
98
225
|
if (blank === true) return false
|
|
226
|
+
if (written === true) return true
|
|
99
227
|
if (windowComplete !== true) return true
|
|
100
228
|
return !(Number.isSafeInteger(node?.seq) && Number.isSafeInteger(firstUserSeq) && node.seq < firstUserSeq)
|
|
101
229
|
}
|
|
102
230
|
|
|
231
|
+
/**
|
|
232
|
+
* One run of capability writes, as the card states it.
|
|
233
|
+
*
|
|
234
|
+
* The chat store is read once per node and the answer is a string, because a
|
|
235
|
+
* selector must return the same value for the same store: a fresh array would
|
|
236
|
+
* make every notification re-render. `node` names the member whose card this is;
|
|
237
|
+
* a store that does not contain it (a single-node render, or a window that does
|
|
238
|
+
* not reach it) still answers with that node's own row.
|
|
239
|
+
*/
|
|
240
|
+
export function capabilityWriteRun(nodes, node, t) {
|
|
241
|
+
const own = writeReceiptOf(node)
|
|
242
|
+
if (own === undefined) return ''
|
|
243
|
+
const ownRow = capabilityReceiptNotice(t, own, 'card')
|
|
244
|
+
const list = orderedChatNodes(nodes)
|
|
245
|
+
const index = list.findIndex(item => item === node
|
|
246
|
+
|| (node?.commandId !== undefined && item?.commandId === node.commandId))
|
|
247
|
+
if (index < 0) return JSON.stringify({ last: true, rows: [ownRow] })
|
|
248
|
+
let start = index
|
|
249
|
+
let end = index
|
|
250
|
+
while (start > 0 && writeReceiptOf(list[start - 1]) !== undefined) start -= 1
|
|
251
|
+
while (end + 1 < list.length && writeReceiptOf(list[end + 1]) !== undefined) end += 1
|
|
252
|
+
const rows = list.slice(start, end + 1).map(item => capabilityReceiptNotice(t, writeReceiptOf(item), 'card'))
|
|
253
|
+
return JSON.stringify({ last: end === index, rows })
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function parseWriteRun(signature, fallback) {
|
|
257
|
+
if (typeof signature !== 'string' || signature === '') return fallback === '' ? { last: true, rows: [] } : JSON.parse(fallback)
|
|
258
|
+
try { return JSON.parse(signature) } catch { return { last: true, rows: [] } }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function orderedChatNodes(nodes) {
|
|
262
|
+
if (nodes === null || nodes === undefined) return []
|
|
263
|
+
if (typeof nodes.values === 'function') return [...nodes.values()]
|
|
264
|
+
return Array.isArray(nodes) ? nodes : []
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function writeReceiptOf(node) {
|
|
268
|
+
if (typeof node?.args !== 'string' || !String(node.args).trim().startsWith('set ')) return undefined
|
|
269
|
+
const receipt = readCapabilityCommandReceipt(node?.outcome?.text)
|
|
270
|
+
return receipt?.state === 'recorded' && receipt.record !== undefined ? receipt : undefined
|
|
271
|
+
}
|
|
272
|
+
|
|
103
273
|
export function createCapabilityCommandCard(React) {
|
|
104
274
|
const h = React.createElement
|
|
105
275
|
return function CapabilityCommandCard({ node, t, useChat, useSession }) {
|
|
@@ -118,38 +288,56 @@ export function createCapabilityCommandCard(React) {
|
|
|
118
288
|
// not chat, so a node without that submitted input is not a user command and
|
|
119
289
|
// renders no notice: a fresh session has no change to announce.
|
|
120
290
|
if (typeof node.args !== 'string') return null
|
|
121
|
-
if (!capabilityReceiptVisible(node, { blank, firstUserSeq, windowComplete })) return null
|
|
122
|
-
const command = `/${node.name ?? 'capabilities'}${node.args ? ` ${String(node.args).trim()}` : ''}`
|
|
123
291
|
const text = node.outcome?.text
|
|
124
292
|
const receipt = readCapabilityCommandReceipt(text)
|
|
293
|
+
// A write is this session's own record, so it is visible wherever the window
|
|
294
|
+
// starts; a publication receipt keeps the conversation-start rule.
|
|
295
|
+
const written = receipt?.state === 'recorded' && receipt.record !== undefined
|
|
296
|
+
if (!capabilityReceiptVisible(node, { blank, firstUserSeq, windowComplete, written })) return null
|
|
297
|
+
const command = `/${node.name ?? 'capabilities'}${node.args ? ` ${String(node.args).trim()}` : ''}`
|
|
125
298
|
// Pending is the plan's publication state, not an outcome: it becomes true
|
|
126
299
|
// only at the next accepted model step, so a durable notice would state a
|
|
127
300
|
// future that a historical row can no longer verify.
|
|
128
301
|
if (receipt?.state === 'pending') return null
|
|
129
302
|
const legacyRefresh = text === 'Session capability environment refreshed; the model-visible notification is recorded.'
|
|
303
|
+
if (written) {
|
|
304
|
+
// Writes that arrive together are one event for the reader: the runs are
|
|
305
|
+
// counted once and stated as one card, and the last row of a run draws it so
|
|
306
|
+
// the card does not repeat per command. The signature is a string because a
|
|
307
|
+
// selector has to answer the same value for the same store.
|
|
308
|
+
const signature = readChat(snapshot => capabilityWriteRun(snapshot?.nodes, node, t))
|
|
309
|
+
const run = parseWriteRun(signature, capabilityWriteRun(undefined, node, t))
|
|
310
|
+
if (run.last !== true) return null
|
|
311
|
+
return h('section', { className: 'ptcPlusCapabilityCommandNotice', 'data-kind': 'writes',
|
|
312
|
+
role: node.outcome?.kind === 'error' ? 'alert' : 'status' },
|
|
313
|
+
h('p', { className: 'ptcPlusCapabilityCommandNoticeHead' },
|
|
314
|
+
t('capabilities.card.writes', { count: run.rows.length })),
|
|
315
|
+
h('ul', { className: 'ptcPlusCapabilityCommandNoticeList' },
|
|
316
|
+
run.rows.map((row, index) => h('li', { key: `${index}`, className: 'ptcPlusCapabilityCommandNoticeRow' }, row))))
|
|
317
|
+
}
|
|
130
318
|
if (receipt || String(node.args ?? '').trim() === 'refresh' || legacyRefresh) {
|
|
131
|
-
|
|
319
|
+
// The notice a command leaves in chat is the same receipt the composer toast
|
|
320
|
+
// and the panel's footer state, so no surface keeps its own account of a write.
|
|
321
|
+
const outcome = receipt ? capabilityReceiptNotice(t, receipt, 'card')
|
|
132
322
|
: legacyRefresh ? t('capabilities.card.recorded')
|
|
133
323
|
: node.outcome ? (typeof text === 'string' && !text.trim() ? undefined : capabilityCommandText(t, text))
|
|
134
324
|
?? t(node.outcome.kind === 'error' ? 'capabilities.commandFailed' : 'capabilities.refreshAcknowledged')
|
|
135
325
|
: t('capabilities.refreshing')
|
|
136
|
-
// A command that names one capability carries that capability's three
|
|
137
|
-
// facts; the notice states them beside its session outcome so "saved"
|
|
138
|
-
// cannot be read as "delivered" or "usable".
|
|
139
|
-
const notice = receipt?.proof === undefined || outcome === undefined
|
|
140
|
-
? outcome : `${outcome} ${capabilityProofCopy(t, receipt.proof)}`
|
|
141
326
|
return h('p', { className: 'ptcPlusCapabilityCommandNotice',
|
|
142
327
|
role: node.outcome?.kind === 'error' ? 'alert' : 'status',
|
|
143
|
-
'data-state': node.outcome?.kind ?? 'pending' },
|
|
328
|
+
'data-state': node.outcome?.kind ?? 'pending' }, outcome)
|
|
144
329
|
}
|
|
145
330
|
let json
|
|
146
331
|
if (typeof text === 'string') {
|
|
147
332
|
try { json = JSON.parse(text) } catch { /* Plain command outcomes remain readable text. */ }
|
|
148
333
|
}
|
|
334
|
+
// The listing states the saved column and this session's record; the composition
|
|
335
|
+
// a runtime would build is not one of its entries, so the card states these two
|
|
336
|
+
// columns and no third answer it could only guess.
|
|
149
337
|
const entries = json?.schema === 'ptc-capability-catalog/v1' && Array.isArray(json.entries)
|
|
150
338
|
&& json.entries.every(entry => entry && typeof entry.id === 'string'
|
|
151
|
-
&& ['name', 'description'
|
|
152
|
-
|
|
339
|
+
&& ['name', 'description'].every(key => entry[key] === undefined || typeof entry[key] === 'string')
|
|
340
|
+
&& ['saved', 'temporary'].every(key => entry[key] === undefined || validCatalogStatement(key, entry[key]))
|
|
153
341
|
&& ['savedRule', 'temporaryRule'].every(key => entry[key] === undefined || validCatalogRule(entry[key], key === 'temporaryRule'))) ? json.entries : null
|
|
154
342
|
const outcome = entries ? t('capabilities.card.catalogCount', { count: entries.length })
|
|
155
343
|
: json ? t('capabilities.card.details')
|
|
@@ -157,13 +345,10 @@ export function createCapabilityCommandCard(React) {
|
|
|
157
345
|
?? t(node.outcome.kind === 'error' ? 'capabilities.commandFailed' : 'capabilities.refreshAcknowledged')
|
|
158
346
|
: t('capabilities.refreshing')
|
|
159
347
|
const label = (entry, key) => {
|
|
160
|
-
|
|
161
|
-
const rule = entry[
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
return key === 'effective' ? `${capabilityChoiceLabel(t, entry.effective)} (${dimensions})` : dimensions
|
|
165
|
-
}
|
|
166
|
-
return capabilityChoiceLabel(t, entry[key])
|
|
348
|
+
if (typeof entry[key] === 'object') return catalogStatementLabel(t, entry[key])
|
|
349
|
+
const rule = entry[key === 'saved' ? 'savedRule' : 'temporaryRule']
|
|
350
|
+
return rule === undefined ? capabilityChoiceLabel(t, entry[key])
|
|
351
|
+
: capabilityRuleLabel(t, rule, key === 'temporary' ? 'capabilities.quick.noOverride' : 'capabilities.policy.layerAbove')
|
|
167
352
|
}
|
|
168
353
|
return h('section', { className: 'ptcPlusCapabilityReceipt', 'aria-label': t('capabilities.receipt.title') },
|
|
169
354
|
h('div', { className: 'ptcPlusCapabilityReceiptHead' },
|
|
@@ -175,12 +360,12 @@ export function createCapabilityCommandCard(React) {
|
|
|
175
360
|
entries?.length ? h('details', { className: 'ptcPlusCapabilityReceiptCatalog' },
|
|
176
361
|
h('summary', null, t('capabilities.card.browse')),
|
|
177
362
|
h('div', { className: 'ptcPlusCapabilityReceiptTable' }, h('table', null,
|
|
178
|
-
h('thead', null, h('tr', null, ...['member', 'quick.
|
|
363
|
+
h('thead', null, h('tr', null, ...['member', 'quick.globalSaved', 'quick.temporary']
|
|
179
364
|
.map(key => h('th', { key, scope: 'col' }, t(`capabilities.${key}`))))),
|
|
180
365
|
h('tbody', null, ...entries.map((entry, index) => h('tr', { key: `${entry.id}-${index}` },
|
|
181
366
|
h('td', null, h('strong', null, entry.name ?? entry.id),
|
|
182
367
|
entry.description ? h('small', null, entry.description) : null),
|
|
183
|
-
...['
|
|
368
|
+
...['saved', 'temporary'].map(key => h('td', { key }, label(entry, key))))))))) : null,
|
|
184
369
|
text ? h('details', { className: 'ptcPlusCapabilityReceiptSource',
|
|
185
370
|
onToggle: event => setShowSource(event.currentTarget.open) },
|
|
186
371
|
h('summary', null, t('capabilities.card.source')),
|
|
@@ -189,28 +374,8 @@ export function createCapabilityCommandCard(React) {
|
|
|
189
374
|
}
|
|
190
375
|
|
|
191
376
|
const selections = ['automatic', 'full', 'on-demand', 'closed']
|
|
192
|
-
const kinds = ['all', 'tool', 'skill', 'resource', 'other']
|
|
193
377
|
const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0
|
|
194
378
|
const configurationId = () => Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('')
|
|
195
|
-
|
|
196
|
-
/** Catalog facts and saved preferences stay separate from frozen session results. */
|
|
197
|
-
export function capabilitySettingsEntries(value, catalog, plans, uncertain = false) {
|
|
198
|
-
const baseline = JSON.parse(value.capabilityBaseline ?? CONFIG_DEFAULTS.capabilityBaseline)
|
|
199
|
-
const entries = new Map((catalog ?? []).map(entry => [entry.id, { ...entry, available: true }]))
|
|
200
|
-
for (const plan of plans ?? []) for (const entry of plan.effective.entries) {
|
|
201
|
-
if (!entries.has(entry.id)) entries.set(entry.id, { id: entry.id, category: entry.category })
|
|
202
|
-
}
|
|
203
|
-
const policy = capabilityPolicyForConfig(value)
|
|
204
|
-
for (const id of [...baseline, ...Object.keys(policy.items)].filter(id => !id.startsWith('mcpServers.'))) if (!entries.has(id)) entries.set(id, { id })
|
|
205
|
-
return [...entries.values()].map(entry => ({ ...entry,
|
|
206
|
-
name: entry.name ?? entry.id,
|
|
207
|
-
kind: ['tool', 'skill'].includes(entry.category) ? entry.category
|
|
208
|
-
: ['resource', 'resource-template'].includes(entry.category) ? 'resource' : 'other',
|
|
209
|
-
availability: catalog === null || uncertain ? 'unknown' : entry.available ? 'available' : 'unavailable',
|
|
210
|
-
common: baseline.includes(entry.id), rule: policy.items[entry.id] ?? {}, resolvedRule: capabilityRule(policy, entry),
|
|
211
|
-
}))
|
|
212
|
-
}
|
|
213
|
-
|
|
214
379
|
export function capabilityPlanReason(t, entry) {
|
|
215
380
|
const known = ['manual-override', 'baseline-not-selected', 'baseline', 'coverage-below-threshold', 'statistical-coverage',
|
|
216
381
|
'mcp-server-disabled', 'contract-budget', 'management-disabled', 'statistics-disabled', 'statistics-settings-invalid', 'statistics-data-invalid',
|
|
@@ -219,7 +384,7 @@ export function capabilityPlanReason(t, entry) {
|
|
|
219
384
|
.map(reason => known.includes(reason) ? t(`capabilities.reason.${reason}`) : reason).join(' · ')
|
|
220
385
|
}
|
|
221
386
|
|
|
222
|
-
export function createCapabilitySettingsViews(React, {
|
|
387
|
+
export function createCapabilitySettingsViews(React, { Select, Modal = createFallbackModal(React) }) {
|
|
223
388
|
const h = React.createElement
|
|
224
389
|
const field = key => CONFIG_FIELDS.find(item => item.key === key)
|
|
225
390
|
// Each group owns one settings tab; the prompt text switch lives in the same
|
|
@@ -308,8 +473,13 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
308
473
|
)
|
|
309
474
|
}
|
|
310
475
|
|
|
311
|
-
function PolicyRules({ value,
|
|
312
|
-
|
|
476
|
+
function PolicyRules({ value, disabled, onChange, t }) {
|
|
477
|
+
// This window edits the record's own document. A configuration that saved no policy has only
|
|
478
|
+
// a synthesized document, whose bottom layer the capability-management switch shaped, so the
|
|
479
|
+
// editor starts from the record's written layers instead of freezing that layer into the
|
|
480
|
+
// record on the first edit (capability-surface §8.1c).
|
|
481
|
+
const policy = value.capabilityPolicySaved === true
|
|
482
|
+
? capabilityPolicyForConfig(value) : capabilityPolicyFromRecord(value)
|
|
313
483
|
const write = next => onChange(field('capabilityPolicy'), JSON.stringify(next))
|
|
314
484
|
const ruleControls = (rule, update, label, defaults = false) => h('div', { className: 'ptcPlusPolicyRule', key: label },
|
|
315
485
|
h('strong', null, label), ...['availability', 'disclosure'].map(dimension => h('div', { className: 'ptcPlusCapabilityFilter', key: dimension },
|
|
@@ -318,7 +488,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
318
488
|
value: rule[dimension] ?? 'automatic', onChange: choice => {
|
|
319
489
|
const next = { ...rule }; if (choice === 'automatic') delete next[dimension]; else next[dimension] = choice; update(next)
|
|
320
490
|
}, options: [...(defaults ? [] : ['automatic']), ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)]
|
|
321
|
-
.map(choice => ({ value: choice, label: t(choice === 'automatic' ? 'capabilities.policy.
|
|
491
|
+
.map(choice => ({ value: choice, label: t(choice === 'automatic' ? 'capabilities.policy.layerAbove' : `capabilities.word.${choice}`) })) }))))
|
|
322
492
|
return h('section', { className: 'ptcPlusPolicyRules', 'aria-label': t('capabilities.policy.rules') },
|
|
323
493
|
h('h4', null, t('capabilities.inheritedRules')), h('p', { className: 'ptcPlusDetail' }, t('capabilities.inheritedRulesHelp')),
|
|
324
494
|
h('p', { className: 'ptcPlusDetail' }, t('capabilities.policy.precedence')),
|
|
@@ -327,7 +497,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
327
497
|
...Object.values(CAPABILITY_INVENTORY_CATEGORIES).map(category => ruleControls(policy.categories[category] ?? {}, rule =>
|
|
328
498
|
write({ ...policy, categories: { ...policy.categories, [category]: rule } }), t(`capabilities.policy.category.${category}`)))),
|
|
329
499
|
h('details', null, h('summary', { tabIndex: 0 }, t('capabilities.sourceRules')),
|
|
330
|
-
...[...new Set(
|
|
500
|
+
...[...new Set(Object.keys(policy.mcpServers))].sort(compare)
|
|
331
501
|
.map(server => ruleControls(policy.mcpServers[server] ?? {}, rule =>
|
|
332
502
|
write(changeCapabilityRule(changeCapabilityRule(policy, `mcpServers.${server}`, 'availability', rule.availability ?? 'automatic'),
|
|
333
503
|
`mcpServers.${server}`, 'disclosure', rule.disclosure ?? 'automatic')), server))))
|
|
@@ -345,340 +515,23 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
345
515
|
footer ? h('div', { className: 'ptcPlusCapabilityDialogFooter' }, footer) : null)
|
|
346
516
|
}
|
|
347
517
|
|
|
348
|
-
function CapabilityBatchDialog({ operation, value, entries, disabled, onChange, onClose, t }) {
|
|
349
|
-
const [choices, setChoices] = React.useState({ availability: 'keep', disclosure: 'keep' })
|
|
350
|
-
const [pending, setPending] = React.useState(false)
|
|
351
|
-
const [failed, setFailed] = React.useState(false)
|
|
352
|
-
const saving = React.useRef(false)
|
|
353
|
-
const mounted = React.useRef(true)
|
|
354
|
-
React.useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
|
|
355
|
-
const targetIds = new Set(entries.filter(entry => entry.availability === 'available').map(entry => entry.id))
|
|
356
|
-
const missing = operation.targets.filter(entry => !targetIds.has(entry.id)).length
|
|
357
|
-
const blocked = disabled('capabilityPolicy') || pending
|
|
358
|
-
const changed = Object.values(choices).some(choice => choice !== 'keep')
|
|
359
|
-
const close = () => { if (!saving.current) onClose() }
|
|
360
|
-
const confirm = async () => {
|
|
361
|
-
if (saving.current || blocked || missing || !changed || !operation.targets.length) return
|
|
362
|
-
let next = capabilityPolicyForConfig(value)
|
|
363
|
-
for (const target of operation.targets) for (const dimension of ['availability', 'disclosure']) {
|
|
364
|
-
if (choices[dimension] !== 'keep') next = changeCapabilityRule(next, target.id, dimension, choices[dimension])
|
|
365
|
-
}
|
|
366
|
-
saving.current = true; setPending(true); setFailed(false)
|
|
367
|
-
try {
|
|
368
|
-
const saved = await onChange(field('capabilityPolicy'), JSON.stringify(next))
|
|
369
|
-
if (!mounted.current) return
|
|
370
|
-
if (saved === true) onClose()
|
|
371
|
-
else setFailed(true)
|
|
372
|
-
} catch {
|
|
373
|
-
if (mounted.current) setFailed(true)
|
|
374
|
-
} finally {
|
|
375
|
-
saving.current = false
|
|
376
|
-
if (mounted.current) setPending(false)
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
return h(Modal, { open: true, onClose: close, title: t('capabilities.batchSettings'),
|
|
380
|
-
closeLabel: t('capabilities.batch.cancel'), headless: true, className: 'ptcPlusCapabilityBatchModal' },
|
|
381
|
-
h('div', { className: 'ptcPlusCapabilityBatchHead' }, h('h2', null, t('capabilities.batchSettings')),
|
|
382
|
-
h('button', { type: 'button', className: 'ptcPlusDialogClose', disabled: pending,
|
|
383
|
-
'aria-label': t('capabilities.batch.cancel'), onClick: close }, '×')),
|
|
384
|
-
h('div', { className: 'ptcPlusCapabilityBatchBody' },
|
|
385
|
-
h('p', { className: 'ptcPlusDetail', role: 'status' }, t('capabilities.batch.targets', { count: operation.targets.length })),
|
|
386
|
-
operation.excluded ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.excluded', { count: operation.excluded })) : null,
|
|
387
|
-
operation.targets.length ? h('ul', { className: 'ptcPlusCapabilityBatchTargets', 'aria-label': t('capabilities.batch.targetList') },
|
|
388
|
-
...operation.targets.map(target => h('li', { key: target.id }, h('span', null, target.name), h('code', null, target.id))))
|
|
389
|
-
: h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.empty')),
|
|
390
|
-
h('div', { className: 'ptcPlusCapabilityBatchFields' }, ...['availability', 'disclosure'].map(dimension =>
|
|
391
|
-
h('div', { key: dimension, className: 'ptcPlusCapabilityFilter' }, h('span', null, t(`capabilities.policy.${dimension}`)),
|
|
392
|
-
h(Select, { value: choices[dimension], disabled: blocked || !operation.targets.length,
|
|
393
|
-
label: t(`capabilities.batch.${dimension}`), onChange: choice => setChoices(current => ({ ...current, [dimension]: choice })),
|
|
394
|
-
options: ['keep', 'automatic', ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)]
|
|
395
|
-
.map(choice => ({ value: choice, label: t(choice === 'keep' ? 'capabilities.batch.keep'
|
|
396
|
-
: choice === 'automatic' ? 'capabilities.policy.inheritRule'
|
|
397
|
-
: choice === 'enabled' ? 'capabilities.action.enable'
|
|
398
|
-
: choice === 'closed' ? 'capabilities.action.close' : `capabilities.${choice}`) })) })))),
|
|
399
|
-
h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.effect')),
|
|
400
|
-
h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.sourceClosure')),
|
|
401
|
-
missing ? h('p', { className: 'ptcPlusMessage', role: 'alert' }, t('capabilities.batch.changedTargets', { count: missing })) : null,
|
|
402
|
-
failed ? h('p', { className: 'ptcPlusMessage', role: 'alert' }, t('capabilities.batch.failed')) : null),
|
|
403
|
-
h('div', { className: 'ptcPlusCapabilityBatchFooter' },
|
|
404
|
-
h('button', { type: 'button', className: 'ptcPlusButton', disabled: pending, onClick: close }, t('capabilities.batch.cancel')),
|
|
405
|
-
h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary',
|
|
406
|
-
disabled: blocked || Boolean(missing) || !changed || !operation.targets.length, onClick: confirm },
|
|
407
|
-
t(pending ? 'state.syncing' : 'capabilities.batch.confirm', { count: operation.targets.length }))))
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
function CapabilityChoices({ value, catalog, catalogError, plans, plansError, disabled, onChange, onReload, t, selectedSessionId, onSessionSelect }) {
|
|
411
|
-
const root = React.useRef(null)
|
|
412
|
-
const [query, setQuery] = React.useState('')
|
|
413
|
-
const [kind, setKind] = React.useState('all')
|
|
414
|
-
const [source, setSource] = React.useState('all')
|
|
415
|
-
const [strategy, setStrategy] = React.useState('all')
|
|
416
|
-
const [availabilityRule, setAvailabilityRule] = React.useState('all')
|
|
417
|
-
const [onlyCommon, setOnlyCommon] = React.useState(false)
|
|
418
|
-
const [availability, setAvailability] = React.useState('all')
|
|
419
|
-
const [sort, setSort] = React.useState(null)
|
|
420
|
-
const [selected, setSelected] = React.useState(null)
|
|
421
|
-
const [picked, setPicked] = React.useState([])
|
|
422
|
-
const [filtersOpen, setFiltersOpen] = React.useState(false)
|
|
423
|
-
const [batchOperation, setBatchOperation] = React.useState(null)
|
|
424
|
-
const [inheritedRulesOpen, setInheritedRulesOpen] = React.useState(false)
|
|
425
|
-
const [localSessionId, setLocalSessionId] = React.useState('')
|
|
426
|
-
const sessionId = onSessionSelect ? selectedSessionId : localSessionId
|
|
427
|
-
const setSessionId = next => { setLocalSessionId(next); onSessionSelect?.(next) }
|
|
428
|
-
const baseline = JSON.parse(value.capabilityBaseline ?? CONFIG_DEFAULTS.capabilityBaseline)
|
|
429
|
-
const policy = capabilityPolicyForConfig(value)
|
|
430
|
-
const entries = capabilitySettingsEntries(value, catalog, plans, Boolean(catalogError))
|
|
431
|
-
const sourceKey = entry => JSON.stringify([entry.source?.kind ?? 'unknown', entry.source?.server ?? null,
|
|
432
|
-
entry.source?.bucket ?? null, entry.source?.provider ?? null])
|
|
433
|
-
const sourceLabel = entry => {
|
|
434
|
-
if (entry.source?.kind === 'skill') {
|
|
435
|
-
const bucket = entry.source.bucket
|
|
436
|
-
const known = ['project-dsh', 'project-agents', 'user-dsh', 'user-agents', 'runtime', 'bundled', 'custom']
|
|
437
|
-
const label = known.includes(bucket) ? t(`capabilities.source.${bucket}`)
|
|
438
|
-
: bucket ? `${t('capabilities.source.other-skill')} · ${bucket}` : t('capabilities.source.unknown')
|
|
439
|
-
return `${label}${entry.source.provider ? ` · ${entry.source.provider}` : ''}`
|
|
440
|
-
}
|
|
441
|
-
if (entry.source?.kind === 'mcp') return `MCP${entry.source.server ? ` · ${entry.source.server}` : ''}`
|
|
442
|
-
return t(`capabilities.source.${entry.source?.kind === 'host' ? 'host' : 'unknown'}`)
|
|
443
|
-
}
|
|
444
|
-
const matchesKind = (entry, type) => type === 'all' || (type === 'resource' ? entry.source?.kind === 'mcp'
|
|
445
|
-
: type === 'tool' ? entry.kind === 'tool' && entry.source?.kind !== 'mcp' : type === entry.kind)
|
|
446
|
-
const sources = new Map(entries.filter(entry => matchesKind(entry, kind)).map(entry => [sourceKey(entry), sourceLabel(entry)]))
|
|
447
|
-
const setType = next => { setKind(next); if (!entries.some(entry => matchesKind(entry, next) && sourceKey(entry) === source)) setSource('all') }
|
|
448
|
-
const setCompatibleSource = next => {
|
|
449
|
-
setSource(next)
|
|
450
|
-
if (next !== 'all' && !entries.some(entry => matchesKind(entry, kind) && sourceKey(entry) === next)) setKind('all')
|
|
451
|
-
}
|
|
452
|
-
const normalizedQuery = query.trim().toLowerCase()
|
|
453
|
-
const filtered = entries.filter(entry => matchesKind(entry, kind)
|
|
454
|
-
&& (source === 'all' || sourceKey(entry) === source)
|
|
455
|
-
&& (strategy === 'all' || (entry.rule.disclosure ?? 'automatic') === strategy)
|
|
456
|
-
&& (availabilityRule === 'all' || (entry.rule?.availability ?? 'automatic') === availabilityRule)
|
|
457
|
-
&& (!onlyCommon || entry.common)
|
|
458
|
-
&& (availability === 'all' || entry.availability === availability)
|
|
459
|
-
&& `${entry.name} ${entry.id} ${entry.description ?? ''} ${sourceLabel(entry)}`.toLowerCase().includes(normalizedQuery))
|
|
460
|
-
const defaultOrder = (a, b) => compare(a.name.toLowerCase(), b.name.toLowerCase()) || compare(a.id, b.id)
|
|
461
|
-
const state = item => item.availability === 'unknown' ? 'unknown' : item.availability === 'unavailable' ? 'unavailable'
|
|
462
|
-
: value.capabilityContextEnabled === false || value.capabilityManagementEnabled !== true ? 'unapplied'
|
|
463
|
-
: item.resolvedRule.availabilitySource.startsWith('mcp:') && item.resolvedRule.availability === 'closed' ? 'source-closed' : item.resolvedRule.availability
|
|
464
|
-
const type = item => item.source?.kind === 'mcp' ? 'resource' : item.kind
|
|
465
|
-
const displayedSource = item => item.source?.kind === 'mcp' ? item.source.server ?? sourceLabel(item) : sourceLabel(item)
|
|
466
|
-
const groups = new Map()
|
|
467
|
-
for (const entry of filtered) {
|
|
468
|
-
const group = entry.source?.kind === 'mcp' && entry.source.server ? `mcp:${entry.source.server}`
|
|
469
|
-
: entry.availability === 'unavailable' ? 'unavailable' : entry.kind
|
|
470
|
-
if (!groups.has(group)) groups.set(group, [])
|
|
471
|
-
groups.get(group).push(entry)
|
|
472
|
-
}
|
|
473
|
-
const groupOrder = ['tool', ...[...groups.keys()].filter(key => key.startsWith('mcp:')).sort(compare), 'skill', 'resource', 'other', 'unavailable']
|
|
474
|
-
const defaultEntries = groupOrder.flatMap(group => (groups.get(group) ?? []).sort(defaultOrder))
|
|
475
|
-
const visible = sortCapabilityEntries(defaultEntries, sort, (item, column) => column === 'name' ? item.name
|
|
476
|
-
: column === 'type' ? t(`capabilities.type.${type(item)}`)
|
|
477
|
-
: column === 'source' ? displayedSource(item)
|
|
478
|
-
: column === 'availability' ? t(`capabilities.rowState.${state(item)}`)
|
|
479
|
-
: t(`capabilities.${item.resolvedRule.disclosure}`))
|
|
480
|
-
const pickedIds = picked.filter(id => entries.some(item => item.id === id))
|
|
481
|
-
const pickedEntries = pickedIds.map(id => entries.find(item => item.id === id)).filter(Boolean)
|
|
482
|
-
const pickedTargets = pickedEntries.filter(item => item.availability === 'available')
|
|
483
|
-
const allVisiblePicked = visible.length > 0 && visible.every(item => pickedIds.includes(item.id))
|
|
484
|
-
const someVisiblePicked = visible.some(item => pickedIds.includes(item.id))
|
|
485
|
-
const togglePick = id => setPicked(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
|
|
486
|
-
const toggleAllVisible = () => setPicked(current => allVisiblePicked
|
|
487
|
-
? current.filter(id => !visible.some(item => item.id === id))
|
|
488
|
-
: [...new Set([...current, ...visible.map(item => item.id)])])
|
|
489
|
-
const entry = visible.find(item => item.id === selected)
|
|
490
|
-
const plan = sessionId ? plans?.find(item => item.sessionId === sessionId) : undefined
|
|
491
|
-
const effective = plan?.effective.entries.find(item => item.id === entry?.id)
|
|
492
|
-
const change = (key, next) => onChange(field(key), JSON.stringify(next))
|
|
493
|
-
const setManual = (id, selection) => change('capabilityPolicy', changeCapabilityRule(policy, id, 'disclosure', selection))
|
|
494
|
-
const clear = () => { setQuery(''); setKind('all'); setSource('all'); setStrategy('all'); setAvailabilityRule('all'); setOnlyCommon(false); setAvailability('all') }
|
|
495
|
-
const activeFilters = Number(kind !== 'all') + Number(source !== 'all') + Number(strategy !== 'all') + Number(availabilityRule !== 'all') + Number(onlyCommon) + Number(availability !== 'all')
|
|
496
|
-
const select = (label, current, update, options, blocked = false) => h('div', { className: 'ptcPlusCapabilityFilter' },
|
|
497
|
-
h('span', null, label), h(Select, { label, value: current, disabled: blocked, onChange: update,
|
|
498
|
-
options: options.map(([key, text]) => ({ value: key, label: text })) }))
|
|
499
|
-
const editorField = (label, control) => h('div', { className: 'ptcPlusCapabilityField' }, h('span', null, label), control)
|
|
500
|
-
const manualSelect = item => h('div', { className: 'ptcPlusCapabilityEditorFields' },
|
|
501
|
-
editorField(t('capabilities.enabledColumn'), h(Select, { value: item.rule.availability ?? 'automatic', disabled: disabled('capabilityPolicy'),
|
|
502
|
-
label: t('capabilities.policy.availabilityFor', { name: item.id }),
|
|
503
|
-
onChange: next => change('capabilityPolicy', changeCapabilityRule(policy, item.id, 'availability', next)),
|
|
504
|
-
options: ['automatic', 'enabled', 'closed'].map(choice => ({ value: choice, label: t(choice === 'automatic' ? 'capabilities.policy.inheritRule' : choice === 'enabled' ? 'capabilities.quick.enabled' : `capabilities.${choice}`) })) })),
|
|
505
|
-
editorField(t('capabilities.contextColumn'), h(Select, { value: item.rule.disclosure ?? 'automatic', disabled: disabled('capabilityPolicy'),
|
|
506
|
-
label: t('capabilities.disclosureFor', { name: item.id }),
|
|
507
|
-
onChange: next => setManual(item.id, next), options: ['automatic', ...CAPABILITY_DISCLOSURES]
|
|
508
|
-
.map(choice => ({ value: choice, label: t(choice === 'automatic' ? 'capabilities.policy.inheritRule' : `capabilities.${choice}`) })) })))
|
|
509
|
-
const commonSwitch = item => h('div', { className: 'ptcPlusCapabilitySwitchRow' },
|
|
510
|
-
h('div', { className: 'ptcPlusCapabilitySwitchText' },
|
|
511
|
-
h('span', { className: 'ptcPlusCapabilitySwitchLabel' }, t('capabilities.commonBaseline')),
|
|
512
|
-
h('span', { className: 'ptcPlusCapabilitySwitchHint' }, t(item.common ? 'capabilities.inBaseline' : 'capabilities.notInBaseline'))),
|
|
513
|
-
h('input', { type: 'checkbox', role: 'switch', className: 'ptcPlusCheck', checked: item.common,
|
|
514
|
-
disabled: disabled('capabilityBaseline'), 'aria-label': t('capabilities.baselineFor', { name: item.id }),
|
|
515
|
-
onChange: event => change('capabilityBaseline', event.target.checked
|
|
516
|
-
? [...baseline.filter(id => id !== item.id), item.id] : baseline.filter(id => id !== item.id)) }))
|
|
517
|
-
const filterLabels = [kind !== 'all' ? t(`capabilities.type.${kind}`) : null,
|
|
518
|
-
source !== 'all' ? sources.get(source) ?? t('capabilities.previousSource') : null,
|
|
519
|
-
strategy !== 'all' ? t(strategy === 'automatic' ? 'capabilities.policy.inheritRule' : `capabilities.${strategy}`) : null,
|
|
520
|
-
availabilityRule !== 'all' ? t(availabilityRule === 'automatic' ? 'capabilities.policy.inheritRule'
|
|
521
|
-
: availabilityRule === 'enabled' ? 'capabilities.quick.enabled' : 'capabilities.closed') : null,
|
|
522
|
-
onlyCommon ? t('capabilities.onlyCommon') : null,
|
|
523
|
-
availability !== 'all' ? t(`capabilities.availability.${availability}`) : null].filter(Boolean)
|
|
524
|
-
const closeEditor = () => {
|
|
525
|
-
const button = [...(root.current?.querySelectorAll('[data-capability-edit-id]') ?? [])].find(item => item.dataset.capabilityEditId === selected)
|
|
526
|
-
setSelected(null); button?.focus({ preventScroll: true })
|
|
527
|
-
}
|
|
528
|
-
return h('div', { className: 'ptcPlusCapabilityChoices', ref: root },
|
|
529
|
-
h('div', { className: 'ptcPlusCapabilityToolbar' },
|
|
530
|
-
h('label', { className: 'ptcPlusSearch' }, h(SearchIcon),
|
|
531
|
-
h('input', { type: 'search', value: query, 'aria-label': t('capabilities.search'), placeholder: t('capabilities.search'),
|
|
532
|
-
onChange: event => setQuery(event.target.value) })),
|
|
533
|
-
h('div', { className: 'ptcPlusCapabilityTypes', role: 'group', 'aria-label': t('capabilities.types') },
|
|
534
|
-
...kinds.filter(key => key !== 'other' || entries.some(item => item.kind === 'other')).map(key => h('button', {
|
|
535
|
-
key, type: 'button', 'aria-pressed': kind === key, onClick: () => setType(key),
|
|
536
|
-
}, t(`capabilities.type.${key}`)))),
|
|
537
|
-
h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
|
|
538
|
-
onClick: () => setFiltersOpen(true) }, t('capabilities.filterAction')),
|
|
539
|
-
h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
|
|
540
|
-
'aria-label': t('capabilities.batchSettings'),
|
|
541
|
-
disabled: disabled('capabilityPolicy') || pickedTargets.length === 0,
|
|
542
|
-
onClick: () => setBatchOperation({
|
|
543
|
-
targets: pickedTargets.map(item => ({ id: item.id, name: item.name })),
|
|
544
|
-
excluded: pickedIds.length - pickedTargets.length,
|
|
545
|
-
}) }, t('capabilities.batchSettings'),
|
|
546
|
-
pickedIds.length ? h('span', { className: 'ptcPlusCapabilityBatchCount', 'aria-hidden': true,
|
|
547
|
-
title: t('capabilities.selectionCount', { count: pickedIds.length }) },
|
|
548
|
-
String(pickedIds.length)) : null),
|
|
549
|
-
h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
|
|
550
|
-
onClick: () => setInheritedRulesOpen(true) }, t('capabilities.inheritedRules'))),
|
|
551
|
-
filtersOpen ? h(CapabilityDialog, { title: t('capabilities.filterAction'), onClose: () => setFiltersOpen(false), t,
|
|
552
|
-
className: 'ptcPlusCapabilityFilterDialog', footer: h('button', { type: 'button', className: 'ptcPlusButton',
|
|
553
|
-
'data-kind': 'primary', onClick: () => setFiltersOpen(false) }, t('capabilities.dialogDone')) },
|
|
554
|
-
h('div', { className: 'ptcPlusCapabilityFilterFields' },
|
|
555
|
-
select(t('capabilities.sourceFilter'), source, setCompatibleSource, [['all', t('capabilities.allSources')], ...sources,
|
|
556
|
-
...(source !== 'all' && !sources.has(source) ? [[source, t('capabilities.previousSource')]] : [])]),
|
|
557
|
-
select(t('capabilities.policy.disclosureFilter'), strategy, setStrategy, [['all', t('capabilities.allStrategies')],
|
|
558
|
-
...['automatic', ...CAPABILITY_DISCLOSURES].map(key => [key, t(key === 'automatic' ? 'capabilities.policy.inheritRule' : `capabilities.${key}`)])]),
|
|
559
|
-
select(t('capabilities.policy.availabilityFilter'), availabilityRule, setAvailabilityRule, [['all', t('capabilities.allStrategies')],
|
|
560
|
-
...['automatic', 'enabled', 'closed'].map(key => [key, t(key === 'automatic' ? 'capabilities.policy.inheritRule' : key === 'enabled' ? 'capabilities.quick.enabled' : 'capabilities.closed')])]),
|
|
561
|
-
h('label', { className: 'ptcPlusCapabilityOnlyCommon' }, h('input', { type: 'checkbox', checked: onlyCommon,
|
|
562
|
-
onChange: event => setOnlyCommon(event.target.checked) }), t('capabilities.onlyCommon')),
|
|
563
|
-
select(t('capabilities.availabilityFilter'), availability, setAvailability,
|
|
564
|
-
['all', 'available', 'unavailable', 'unknown'].map(key => [key, t(`capabilities.availability.${key}`)])))) : null,
|
|
565
|
-
inheritedRulesOpen ? h(CapabilityDialog, { title: t('capabilities.inheritedRules'), onClose: () => setInheritedRulesOpen(false), t,
|
|
566
|
-
footer: h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary',
|
|
567
|
-
onClick: () => setInheritedRulesOpen(false) }, t('capabilities.dialogDone')) },
|
|
568
|
-
h(PolicyRules, { value, catalog, disabled, onChange, t })) : null,
|
|
569
|
-
batchOperation ? h(CapabilityBatchDialog, { operation: batchOperation, value, entries, disabled, onChange,
|
|
570
|
-
onClose: () => setBatchOperation(null), t }) : null,
|
|
571
|
-
activeFilters ? h('div', { className: 'ptcPlusActiveFilters' }, h('span', null, filterLabels.join(' · ')),
|
|
572
|
-
h('button', { type: 'button', className: 'ptcPlusCapabilityLink', onClick: clear }, t('capabilities.clear'))) : null,
|
|
573
|
-
h('div', { className: 'ptcPlusCapabilityResultLine' }, h('span', { role: 'status' }, t('capabilities.results', { count: visible.length, total: entries.length })),
|
|
574
|
-
h('button', { type: 'button', className: 'ptcPlusCapabilityLink', onClick: onReload }, t('capabilities.reloadCatalog'))),
|
|
575
|
-
catalogError ? h('p', { role: 'status', className: 'ptcPlusMessage' }, t('capabilities.catalogUnavailable', { error: catalogError })) : null,
|
|
576
|
-
entries.length === 0 ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.catalogEmpty'))
|
|
577
|
-
: visible.length === 0 ? h('div', { className: 'ptcPlusCapabilityEmpty' }, h('p', null, t('capabilities.noMatches')),
|
|
578
|
-
h('button', { type: 'button', className: 'ptcPlusCapabilityLink', onClick: clear }, t('capabilities.clear'))) :
|
|
579
|
-
h('div', { className: 'ptcPlusCapabilityTable', role: 'table', 'aria-label': t('capabilities.management') },
|
|
580
|
-
h('div', { className: 'ptcPlusCapabilityColumns', role: 'row' },
|
|
581
|
-
...CAPABILITY_SORT_COLUMNS.map(column => h('span', { key: column.key, role: 'columnheader',
|
|
582
|
-
'aria-sort': capabilitySortDirection(sort, column.key),
|
|
583
|
-
...(column.key === 'name' ? { 'aria-label': t('capabilities.member'), className: 'ptcPlusCapabilityPickHead' } : {}) },
|
|
584
|
-
...(column.key === 'name' ? [h('input', { type: 'checkbox', className: 'ptcPlusPick', checked: allVisiblePicked,
|
|
585
|
-
ref: node => { if (node) node.indeterminate = someVisiblePicked && !allVisiblePicked },
|
|
586
|
-
disabled: visible.length === 0, 'aria-label': t('capabilities.selectAllVisible'),
|
|
587
|
-
onChange: toggleAllVisible })] : []),
|
|
588
|
-
h('button', { type: 'button', className: 'ptcPlusCapabilitySort', title: t('capabilities.sortCycle'),
|
|
589
|
-
onClick: () => setSort(current => cycleCapabilitySort(current, column.key)) },
|
|
590
|
-
t(`capabilities.${column.label}`), h('span', { 'aria-hidden': true },
|
|
591
|
-
capabilitySortDirection(sort, column.key) === 'ascending' ? '↑' : capabilitySortDirection(sort, column.key) === 'descending' ? '↓' : '↕'))))),
|
|
592
|
-
h('div', { className: 'ptcPlusCapabilityList', role: 'rowgroup' }, ...visible.map(item => h('div', { key: item.id, className: 'ptcPlusCapabilityItem', role: 'row',
|
|
593
|
-
'data-selected': entry?.id === item.id, 'data-availability': item.availability,
|
|
594
|
-
onClick: () => setSelected(item.id) },
|
|
595
|
-
h('div', { className: 'ptcPlusCapabilityIdentity', role: 'cell' },
|
|
596
|
-
h('input', { type: 'checkbox', className: 'ptcPlusPick', checked: pickedIds.includes(item.id),
|
|
597
|
-
'aria-label': t('capabilities.selectForBatch', { name: item.name ?? item.id }),
|
|
598
|
-
onClick: event => event.stopPropagation(), onChange: () => togglePick(item.id) }),
|
|
599
|
-
h('div', { className: 'ptcPlusCapabilityIdentityText' },
|
|
600
|
-
h('span', { className: 'ptcPlusCapabilityName' }, item.name), h('code', null, item.id))),
|
|
601
|
-
h('span', { className: 'ptcPlusCapabilityBadge', role: 'cell', 'data-kind': type(item) }, t(`capabilities.type.${type(item)}`)),
|
|
602
|
-
h('span', { className: 'ptcPlusCapabilitySource', role: 'cell' }, displayedSource(item)),
|
|
603
|
-
// The cell stays a plain cell so its empty area selects the row; only the switch and
|
|
604
|
-
// its own label swallow the click, keeping a toggle from also opening the row editor.
|
|
605
|
-
h('div', { className: 'ptcPlusCapabilityAvailability', role: 'cell', 'data-state': state(item) },
|
|
606
|
-
h('input', { type: 'checkbox', role: 'switch', className: 'ptcPlusCheck', id: `ptcPlusCapabilityAvailability-${item.id}`,
|
|
607
|
-
checked: item.resolvedRule.availability === 'enabled',
|
|
608
|
-
disabled: disabled('capabilityPolicy') || state(item) === 'source-closed',
|
|
609
|
-
'aria-label': t('capabilities.policy.availabilityFor', { name: item.id }),
|
|
610
|
-
onClick: event => event.stopPropagation(),
|
|
611
|
-
onChange: event => change('capabilityPolicy', changeCapabilityRule(policy, item.id, 'availability', event.target.checked ? 'enabled' : 'closed')) }),
|
|
612
|
-
h('label', { htmlFor: `ptcPlusCapabilityAvailability-${item.id}`,
|
|
613
|
-
onClick: event => event.stopPropagation() }, t(`capabilities.rowState.${state(item)}`))),
|
|
614
|
-
h('div', { role: 'cell', className: 'ptcPlusCapabilityContextCell' }, h('button', { type: 'button', className: 'ptcPlusCapabilityContext', 'data-capability-edit-id': item.id,
|
|
615
|
-
'aria-label': t('capabilities.editContextFor', { name: item.id }), 'aria-haspopup': 'dialog',
|
|
616
|
-
onClick: event => { event.stopPropagation(); setSelected(item.id) } },
|
|
617
|
-
h('span', null, t(`capabilities.${item.resolvedRule.disclosure}`)),
|
|
618
|
-
h('svg', { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, 'aria-hidden': true },
|
|
619
|
-
h('path', { d: 'm15 5 4 4M4 20l4-1L20 7l-4-4L4 15Z' })))))))),
|
|
620
|
-
entry ? h(CapabilityDialog, { title: entry.name, onClose: closeEditor, t, className: 'ptcPlusCapabilityEditorDialog',
|
|
621
|
-
footer: h('button', { type: 'button', className: 'ptcPlusButton', onClick: closeEditor }, t('capabilities.closeDetails')) },
|
|
622
|
-
h('code', null, entry.id), h('p', { className: 'ptcPlusDetail' }, t('capabilities.contextHelp')),
|
|
623
|
-
manualSelect(entry), commonSwitch(entry),
|
|
624
|
-
h('p', { className: 'ptcPlusDetail' }, t(value.capabilityContextEnabled === false ? 'capabilities.effect.paused'
|
|
625
|
-
: value.capabilityManagementEnabled !== true ? 'capabilities.effect.rulesOff'
|
|
626
|
-
: entry.resolvedRule.availabilitySource.startsWith('mcp:') && entry.resolvedRule.availability === 'closed' ? 'capabilities.effect.sourceClosed'
|
|
627
|
-
: entry.resolvedRule.availability === 'closed' ? 'capabilities.effect.closed'
|
|
628
|
-
: `capabilities.effect.disclosure.${entry.resolvedRule.disclosure}`)),
|
|
629
|
-
h('p', { className: 'ptcPlusDetail' }, t('capabilities.policy.resolved', {
|
|
630
|
-
availability: t(entry.resolvedRule.availability === 'enabled' ? 'capabilities.quick.enabled' : 'capabilities.closed'),
|
|
631
|
-
disclosure: t(`capabilities.${entry.resolvedRule.disclosure}`),
|
|
632
|
-
source: `${entry.resolvedRule.availabilitySource} · ${entry.resolvedRule.disclosureSource}` })),
|
|
633
|
-
entry.rule.disclosure !== undefined ? h('button', { type: 'button', className: 'ptcPlusCapabilityLink', disabled: disabled('capabilityPolicy'),
|
|
634
|
-
onClick: () => setManual(entry.id, 'automatic') }, t('capabilities.restoreAutomatic')) : null,
|
|
635
|
-
entry.source?.kind === 'mcp' && entry.mcpServer ? h('section', { className: 'ptcPlusCapabilityEditorSection' },
|
|
636
|
-
h('h5', null, t('capabilities.sourceRules')),
|
|
637
|
-
h('p', { className: 'ptcPlusDetail' }, t('capabilities.serverHint')),
|
|
638
|
-
h('div', { className: 'ptcPlusCapabilityEditorFields' },
|
|
639
|
-
...['availability', 'disclosure'].map(dimension => editorField(t(`capabilities.policy.${dimension}`),
|
|
640
|
-
h(Select, { value: policy.mcpServers[entry.mcpServer]?.[dimension] ?? 'automatic', disabled: disabled('capabilityPolicy'),
|
|
641
|
-
label: `${entry.mcpServer} · ${t(`capabilities.policy.${dimension}`)}`,
|
|
642
|
-
onChange: next => change('capabilityPolicy', changeCapabilityRule(policy, `mcpServers.${entry.mcpServer}`, dimension, next)),
|
|
643
|
-
options: ['automatic', ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)].map(key => ({ value: key,
|
|
644
|
-
label: t(key === 'automatic' ? 'capabilities.policy.inheritRule' : key === 'enabled' ? 'capabilities.quick.enabled' : `capabilities.${key}`) })) }))))) : null,
|
|
645
|
-
entry.kind === 'skill' ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.skillDisclosure')) : null,
|
|
646
|
-
entry.kind === 'skill' && entry.invocationConflict ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.invocation.varies')) : null,
|
|
647
|
-
entry.kind === 'skill' && entry.invocation ? h('p', { className: 'ptcPlusDetail' },
|
|
648
|
-
t(entry.invocation.modelInvocable ? entry.invocation.userInvocable ? 'capabilities.invocation.both'
|
|
649
|
-
: 'capabilities.invocation.model' : 'capabilities.invocation.user', { name: entry.name })) : null,
|
|
650
|
-
entry.description ? h('section', { className: 'ptcPlusCapabilityEditorSection' },
|
|
651
|
-
h('h5', null, t('capabilities.catalogDescription')), h('p', { className: 'ptcPlusCapabilityProse' }, entry.description)) : null,
|
|
652
|
-
entry.availability === 'unavailable' ? h('button', { type: 'button', className: 'ptcPlusCapabilityLink',
|
|
653
|
-
disabled: disabled('capabilityBaseline') || disabled('capabilityPolicy'), onClick: () => {
|
|
654
|
-
const next = { ...policy, items: { ...policy.items } }; delete next.items[entry.id]; change('capabilityPolicy', next)
|
|
655
|
-
if (baseline.includes(entry.id)) change('capabilityBaseline', baseline.filter(id => id !== entry.id))
|
|
656
|
-
} }, t('capabilities.removeSaved')) : null,
|
|
657
|
-
value.capabilityContextEnabled !== false && value.capabilityManagementEnabled === true ? h('section', { className: 'ptcPlusCapabilityEditorSection' },
|
|
658
|
-
h('h5', null, t('capabilities.currentSession')),
|
|
659
|
-
editorField(t('capabilities.sessionSelector'), h(Select, { value: sessionId ?? '', label: t('capabilities.sessionSelector'), onChange: setSessionId,
|
|
660
|
-
options: [{ value: '', label: t('capabilities.chooseSession') }, ...(plans ?? []).map(item => ({ value: item.sessionId, label: item.sessionId }))] })),
|
|
661
|
-
plansError ? h('p', { role: 'status', className: 'ptcPlusDetail' }, t('capabilities.unavailable', { error: plansError }))
|
|
662
|
-
: !plan ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.selectObservation')) : h(React.Fragment, null,
|
|
663
|
-
h('p', null, effective ? t(`capabilities.${effective.selection}`) : t('capabilities.notInSession')),
|
|
664
|
-
effective ? h('p', { className: 'ptcPlusDetail' }, capabilityPlanReason(t, effective)) : null,
|
|
665
|
-
effective?.evidence ? h('p', { className: 'ptcPlusDetail' }, `${effective.evidence.used}/${effective.evidence.eligible} (${effective.evidence.scope})`) : null,
|
|
666
|
-
plan.pendingRefresh ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.pending')) : null)) : null) : null)
|
|
667
|
-
|
|
668
|
-
}
|
|
669
|
-
|
|
670
518
|
function AllocationEvidence({ allocation, allocationError, allocationPending, selectedSessionId, statistics = false, onReload, t }) {
|
|
671
519
|
const [statisticsScope, setStatisticsScope] = React.useState('workspace')
|
|
672
520
|
if (allocationPending) return h('p', { role: 'status' }, t('state.syncing'))
|
|
673
521
|
if (allocationError) return h('p', { role: 'status', className: 'ptcPlusMessage' }, t('capabilities.unavailable', { error: allocationError }))
|
|
674
|
-
if (!allocation) return selectedSessionId ? h('p', { className: 'ptcPlusCapabilityNotice' }, t('capabilities.
|
|
522
|
+
if (!allocation) return selectedSessionId ? h('p', { className: 'ptcPlusCapabilityNotice' }, t('capabilities.observationNotRead')) : null
|
|
675
523
|
const { plan, coverage, configuration } = allocation
|
|
676
524
|
const count = selection => plan.entries.filter(entry => entry.selection === selection).length
|
|
677
525
|
const evidenceFor = item => coverage?.[item.id]?.[statisticsScope]
|
|
678
526
|
const usable = evidence => evidence?.status === 'usable' && Number.isFinite(evidence.used) && Number.isFinite(evidence.eligible)
|
|
527
|
+
// Only the sample states the evidence itself reports have a name here. Any other
|
|
528
|
+
// value describes the samples the read did not deliver, never a capability state.
|
|
529
|
+
const SAMPLE_STATES = ['no-samples', 'pending-settlement', 'incomplete-window', 'capacity-exceeded']
|
|
530
|
+
const sampleText = evidence => SAMPLE_STATES.includes(evidence?.status)
|
|
531
|
+
? t(`capabilities.statStatus.${evidence.status}`) : t('capabilities.statStatus.not-reported')
|
|
679
532
|
const coverageText = evidence => usable(evidence) && Number.isFinite(evidence.coverage) ? `${Math.round(evidence.coverage * 100)}%`
|
|
680
533
|
: usable(evidence) ? t('capabilities.statStatus.no-samples')
|
|
681
|
-
|
|
534
|
+
: sampleText(evidence)
|
|
682
535
|
return h('section', { className: 'ptcPlusAllocationEvidence', 'aria-label': t(statistics ? 'capabilities.observedUsage' : 'capabilities.budgetPreview') },
|
|
683
536
|
h('div', { className: 'ptcPlusCapabilityResultLine' }, h('h4', null, t(statistics ? 'capabilities.observedUsage' : 'capabilities.budgetPreview')),
|
|
684
537
|
h('button', { type: 'button', className: 'ptcPlusCapabilityLink', onClick: onReload }, t('capabilities.reloadObservation'))),
|
|
@@ -689,14 +542,14 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
689
542
|
!statistics ? h('p', { className: 'ptcPlusCapabilityNotice' }, t('capabilities.coldStartHint')) : null,
|
|
690
543
|
!statistics ? h(React.Fragment, null,
|
|
691
544
|
h('div', { className: 'ptcPlusAllocationMetrics' },
|
|
692
|
-
...['full', 'on-demand', 'closed'].map(selection => h('div', { key: selection }, h('strong', null, count(selection)), h('span', null, t(`capabilities.${selection}`)))),
|
|
545
|
+
...['full', 'on-demand', 'closed'].map(selection => h('div', { key: selection }, h('strong', null, count(selection)), h('span', null, t(`capabilities.word.${selection}`)))),
|
|
693
546
|
h('div', null, h('strong', null, `${plan.budgets.contractBytes} / ${configuration.capabilityBudgetBytes}`), h('span', null, 'UTF-8 bytes'))),
|
|
694
547
|
h('progress', { max: configuration.capabilityBudgetBytes, value: Math.min(configuration.capabilityBudgetBytes, plan.budgets.contractBytes),
|
|
695
548
|
'aria-label': t('capabilities.budgetPreview') }),
|
|
696
549
|
h('p', { className: 'ptcPlusDetail' }, t('capabilities.budget', { bytes: plan.budgets.contractBytes, overage: plan.budgets.overageBytes })),
|
|
697
550
|
h('p', { className: 'ptcPlusDetail' }, t('capabilities.directoryUsage', { bytes: plan.budgets.directoryBytes, budget: configuration.capabilityDirectoryBytes })),
|
|
698
551
|
!plan.enabled ? h('p', { className: 'ptcPlusCapabilityNotice' }, t('capabilities.managementOff')) : null)
|
|
699
|
-
: coverage === null ? h('p', { role: 'status' }, t('capabilities.statStatus.
|
|
552
|
+
: coverage === null ? h('p', { role: 'status' }, t('capabilities.statStatus.not-reported')) : null,
|
|
700
553
|
h('div', { className: 'ptcPlusCapabilityEvidenceTable' }, h('table', null,
|
|
701
554
|
h('thead', null, h('tr', null, ...[t('capabilities.member'), ...(statistics
|
|
702
555
|
? [t('capabilities.usedFamilies'), t('capabilities.eligibleFamilies'), t('capabilities.coverageColumn')]
|
|
@@ -708,7 +561,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
708
561
|
h('td', { key: 'eligible' }, usable(evidenceFor(item)) ? evidenceFor(item).eligible : '—'),
|
|
709
562
|
h('td', { key: 'coverage' }, coverageText(evidenceFor(item)), usable(evidenceFor(item)) && Number.isFinite(evidenceFor(item).coverage)
|
|
710
563
|
? h('progress', { max: 1, value: evidenceFor(item).coverage, 'aria-label': t('capabilities.coverageFor', { name: item.id }) }) : null)]
|
|
711
|
-
: [h('td', { key: 'selection' }, t(`capabilities.${entry?.selection ?? 'host'}`)), h('td', { key: 'reason' }, entry ? capabilityPlanReason(t, entry) : '—'), h('td', { key: 'size' }, `${item.contractBytes} B`)]))
|
|
564
|
+
: [h('td', { key: 'selection' }, t(`capabilities.word.${entry?.selection ?? 'host'}`)), h('td', { key: 'reason' }, entry ? capabilityPlanReason(t, entry) : '—'), h('td', { key: 'size' }, `${item.contractBytes} B`)]))
|
|
712
565
|
})))))
|
|
713
566
|
}
|
|
714
567
|
|
|
@@ -868,7 +721,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
868
721
|
setting('group.capabilities', enabled('capabilityContextEnabled')),
|
|
869
722
|
setting('capabilities.management', enabled('capabilityManagementEnabled')),
|
|
870
723
|
setting('capabilities.statisticsEnabled', t(JSON.parse(configuration.capabilityStatistics).enabled ? 'capabilities.serverOn' : 'capabilities.serverOff')),
|
|
871
|
-
setting('capabilities.defaultDisclosure', t(`capabilities.${policy.defaults.disclosure}`)),
|
|
724
|
+
setting('capabilities.defaultDisclosure', t(`capabilities.word.${policy.defaults.disclosure}`)),
|
|
872
725
|
setting('capabilities.allocationTitle', ['baseline', 'statistics', 'budget'].filter(key => policy.allocation[key]).map(key => t(`capabilities.allocation.${key}`)).join(' · ') || '—'),
|
|
873
726
|
setting('capabilities.recoveryEnabled', enabled('capabilityRecoveryEnabled')),
|
|
874
727
|
setting('capabilities.previewEnabled', `${enabled('modelPreviewEnabled')}${configuration.modelPreviewEnabled ? ` · ${t('capabilities.estimatedTokens', { count: configuration.modelPreviewTokens })}` : ''}`),
|
|
@@ -901,14 +754,18 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
901
754
|
const saved = choicesFor(configurationReady ? value : undefined)
|
|
902
755
|
const retained = flagsFor(saved)
|
|
903
756
|
const applied = flagsFor(effectiveCapabilitySettings(saved))
|
|
904
|
-
|
|
757
|
+
// This pane states and writes the record's own rules: a configuration that saved no policy
|
|
758
|
+
// starts from the declared defaults plus the override record, so the synthesized bottom layer
|
|
759
|
+
// is neither shown as a saved default nor frozen into the record (§8.1c).
|
|
760
|
+
const policy = value.capabilityPolicySaved === true
|
|
761
|
+
? capabilityPolicyForConfig(value) : capabilityPolicyFromRecord(value)
|
|
905
762
|
const write = next => onChange(field('capabilityPolicy'), JSON.stringify(next))
|
|
906
763
|
const adaptive = [policy.defaults, ...Object.values(policy.categories), ...Object.values(policy.mcpServers), ...Object.values(policy.items)]
|
|
907
764
|
.some(rule => rule.disclosure === 'adaptive')
|
|
908
765
|
const summary = key => {
|
|
909
766
|
if (choiceState(retained, applied, key) === 'unknown') return t('capabilities.combination.unknownSettings')
|
|
910
767
|
if (key === 'description') {
|
|
911
|
-
const description = t('capabilities.context.summary.description', { disclosure: t(`capabilities.${policy.defaults.disclosure}`) })
|
|
768
|
+
const description = t('capabilities.context.summary.description', { disclosure: t(`capabilities.word.${policy.defaults.disclosure}`) })
|
|
912
769
|
if (policy.defaults.disclosure !== 'adaptive') return description
|
|
913
770
|
const criteria = ['baseline', 'statistics', 'budget'].filter(key => policy.allocation[key])
|
|
914
771
|
.map(key => t(`capabilities.allocation.${key}`)).join(t('capabilities.combination.separator'))
|
|
@@ -955,7 +812,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
955
812
|
h('p', { className: 'ptcPlusDetail' }, t('capabilities.descriptionDependency')),
|
|
956
813
|
h('div', { className: 'ptcPlusCapabilityFilter' }, h('span', null, t('capabilities.defaultDisclosure')),
|
|
957
814
|
h(Select, { value: policy.defaults.disclosure, disabled: disabled('capabilityPolicy'), label: t('capabilities.defaultDisclosure'),
|
|
958
|
-
options: CAPABILITY_DISCLOSURES.map(choice => ({ value: choice, label: t(`capabilities.${choice}`) })),
|
|
815
|
+
options: CAPABILITY_DISCLOSURES.map(choice => ({ value: choice, label: t(`capabilities.word.${choice}`) })),
|
|
959
816
|
onChange: disclosure => write({ ...policy, defaults: { ...policy.defaults, disclosure } }) })),
|
|
960
817
|
h('p', { className: 'ptcPlusDetail' }, effect(value.capabilityManagementEnabled !== true ? 'capabilities.effect.rulesOff' : `capabilities.effect.disclosure.${policy.defaults.disclosure}`)),
|
|
961
818
|
criteria,
|
|
@@ -1344,10 +1201,11 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
1344
1201
|
h('p', { className: 'ptcPlusPromptIntro' }, t('capabilities.prompt.intro')))
|
|
1345
1202
|
}
|
|
1346
1203
|
|
|
1347
|
-
function CapabilityWorkbench({ value, configurationReady,
|
|
1348
|
-
allocation, allocationError, allocationPending, onApplyConfiguration, configurationWritable, saving, saveStatus }) {
|
|
1204
|
+
function CapabilityWorkbench({ value, configurationReady, plans, plansError, plansPending, plansStale, disabled, onChange, onReload, t, renderField, renderPlans, renderRefresh, id, selectedSessionId, onSessionSelect,
|
|
1205
|
+
allocation, allocationError, allocationPending, onApplyConfiguration, configurationWritable, saving, saveStatus, surface }) {
|
|
1349
1206
|
const [tab, setTab] = React.useState('manual')
|
|
1350
1207
|
const [contextSection, setContextSection] = React.useState('description')
|
|
1208
|
+
const [rulesOpen, setRulesOpen] = React.useState(false)
|
|
1351
1209
|
const tabs = ['manual', 'budgets', 'statistics', 'prompt', 'plans']
|
|
1352
1210
|
const feedback = saving ? t('state.syncing') : saveStatus && saveStatus.key !== 'status.applied' ? t(saveStatus.key, saveStatus.params)
|
|
1353
1211
|
: t(configurationWritable ? 'capabilities.savedForNew' : 'footer.readOnly')
|
|
@@ -1371,7 +1229,18 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
1371
1229
|
onChange: event => onChange(field('capabilityManagementEnabled'), event.target.checked) })) : null),
|
|
1372
1230
|
key === 'manual' ? h(React.Fragment, null, h('p', { className: 'ptcPlusDetail' }, t(value.capabilityContextEnabled === false ? 'capabilities.effect.paused'
|
|
1373
1231
|
: value.capabilityManagementEnabled === true ? 'capabilities.managementHelp' : 'capabilities.effect.rulesOff')),
|
|
1374
|
-
|
|
1232
|
+
// The capability page is one renderer mounted here with the address this
|
|
1233
|
+
// entrance fixes. This entrance brings no columns, words, controls or
|
|
1234
|
+
// receipts of its own; the rule chain that answers for objects the saved
|
|
1235
|
+
// address has not decided one by one stays a rule editor beside it.
|
|
1236
|
+
h('div', { className: 'ptcPlusCapabilityRulesEntry' },
|
|
1237
|
+
h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
|
|
1238
|
+
onClick: () => setRulesOpen(true) }, t('capabilities.inheritedRules'))),
|
|
1239
|
+
rulesOpen ? h(CapabilityDialog, { title: t('capabilities.inheritedRules'), onClose: () => setRulesOpen(false), t,
|
|
1240
|
+
footer: h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary',
|
|
1241
|
+
onClick: () => setRulesOpen(false) }, t('capabilities.dialogDone')) },
|
|
1242
|
+
h(PolicyRules, { value, disabled, onChange, t })) : null,
|
|
1243
|
+
surface ?? null)
|
|
1375
1244
|
: key === 'statistics' ? h(React.Fragment, null, renderField('capabilityStatistics'),
|
|
1376
1245
|
h('p', { className: 'ptcPlusDetail' }, t(value.capabilityContextEnabled === false ? 'capabilities.effect.paused'
|
|
1377
1246
|
: choicesFor(value).statistics?.enabled === true ? 'capabilities.effect.collectionOn'
|
|
@@ -1390,21 +1259,28 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
1390
1259
|
return { CapabilityWorkbench }
|
|
1391
1260
|
}
|
|
1392
1261
|
|
|
1393
|
-
/**
|
|
1262
|
+
/**
|
|
1263
|
+
* Execute a human command through DSH; a receipt never stands in for model publication.
|
|
1264
|
+
*
|
|
1265
|
+
* The button asks the command channel to run the same `refresh` line a human types.
|
|
1266
|
+
* The channel publishes nothing until a session observes it, so the button keeps its
|
|
1267
|
+
* own observation open; that observation decides no branch here. Whether the session
|
|
1268
|
+
* lists the command is the command's own answer: a refusal arrives as its concrete
|
|
1269
|
+
* error in the receipt, which is more accurate than a pre-emptive verdict.
|
|
1270
|
+
*/
|
|
1394
1271
|
export function createCapabilityRefreshView(React, commands) {
|
|
1395
1272
|
const h = React.createElement
|
|
1396
|
-
const absent = Object.freeze({ getSnapshot: () => false, subscribe: () => () => {} })
|
|
1397
1273
|
return function CapabilityRefreshButton({ sessionId, enabled, t, onApplied, onInvalidated }) {
|
|
1398
|
-
const source = React.useMemo(() => sessionId ? commands.source(sessionId) : absent, [sessionId])
|
|
1399
|
-
const available = React.useSyncExternalStore(source.subscribe, source.getSnapshot)
|
|
1400
1274
|
const [pending, setPending] = React.useState(false)
|
|
1401
1275
|
const [status, setStatus] = React.useState(null)
|
|
1402
1276
|
const request = React.useRef(null)
|
|
1277
|
+
const source = React.useMemo(() => sessionId ? commands.source(sessionId) : null, [sessionId])
|
|
1278
|
+
React.useEffect(() => source === null ? undefined : source.subscribe(() => {}), [source])
|
|
1403
1279
|
React.useEffect(() => {
|
|
1404
1280
|
setStatus(null)
|
|
1405
1281
|
setPending(false)
|
|
1406
1282
|
return () => { request.current?.abort(); request.current = null }
|
|
1407
|
-
}, [sessionId, enabled
|
|
1283
|
+
}, [sessionId, enabled])
|
|
1408
1284
|
const cancel = event => {
|
|
1409
1285
|
event.stopPropagation()
|
|
1410
1286
|
request.current?.abort(); request.current = null; setPending(false)
|
|
@@ -1413,7 +1289,7 @@ export function createCapabilityRefreshView(React, commands) {
|
|
|
1413
1289
|
}
|
|
1414
1290
|
const execute = async event => {
|
|
1415
1291
|
event.stopPropagation()
|
|
1416
|
-
if (!enabled ||
|
|
1292
|
+
if (!enabled || request.current) return
|
|
1417
1293
|
const controller = new AbortController()
|
|
1418
1294
|
request.current = controller
|
|
1419
1295
|
setPending(true)
|
|
@@ -1437,596 +1313,643 @@ export function createCapabilityRefreshView(React, commands) {
|
|
|
1437
1313
|
}
|
|
1438
1314
|
}
|
|
1439
1315
|
return h('div', { className: 'ptcPlusCapabilityRefresh' },
|
|
1440
|
-
h('button', { type: 'button', className: 'ptcPlusButton', disabled: !enabled ||
|
|
1441
|
-
title:
|
|
1316
|
+
h('button', { type: 'button', className: 'ptcPlusButton', disabled: !enabled || pending,
|
|
1317
|
+
title: sessionId ? undefined : t('capabilities.chooseSession'),
|
|
1442
1318
|
onClick: execute }, t(pending ? 'capabilities.refreshing' : 'capabilities.applySession')),
|
|
1443
1319
|
pending ? h('button', { type: 'button', className: 'ptcPlusButton', onClick: cancel }, t('capabilities.cancelRefresh')) : null,
|
|
1444
1320
|
status ? h('p', { className: 'ptcPlusDetail', role: 'status' }, capabilityCommandText(t, status))
|
|
1445
|
-
:
|
|
1321
|
+
: sessionId ? null : h('p', { className: 'ptcPlusDetail' }, t('capabilities.chooseSession')))
|
|
1446
1322
|
}
|
|
1447
1323
|
}
|
|
1448
1324
|
|
|
1449
|
-
/**
|
|
1450
|
-
|
|
1325
|
+
/**
|
|
1326
|
+
* The one capability surface, mounted by every entrance.
|
|
1327
|
+
*
|
|
1328
|
+
* A page states, for one supplied object, which value each of its two dimensions has
|
|
1329
|
+
* at each address. Three permanent facts decide what it may state: the Host's supply
|
|
1330
|
+
* (which rows exist), the record at each address (the saved configuration and this
|
|
1331
|
+
* session's own log), and the receipt a write leaves. No runtime, service or global
|
|
1332
|
+
* switch produces any of them, so the read is not gated: a failed read is a defect
|
|
1333
|
+
* reported beside the rows, never a state the rows are replaced with.
|
|
1334
|
+
*
|
|
1335
|
+
* An entrance fixes which address this page may write. §4: a control exists exactly
|
|
1336
|
+
* when its cell's address is fixed by the entrance that opened the page — so the
|
|
1337
|
+
* session entrance controls this session's record and the settings entrance controls
|
|
1338
|
+
* the new-session default. The other address is still stated, because it is what the
|
|
1339
|
+
* reader compares against, and the session entrance adds the receipt column, which
|
|
1340
|
+
* names the record each decision leaves. The settings entrance has no session address
|
|
1341
|
+
* at all: its two session columns are absent, not disabled and not read-only.
|
|
1342
|
+
*/
|
|
1343
|
+
export function createCapabilitySurfaceView(React, { commands, bindingCommands, Modal, Select, inspectSessionCatalog }) {
|
|
1451
1344
|
const h = React.createElement
|
|
1452
|
-
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1345
|
+
// §4's seven columns plus the receipt column, in that order. One column, one
|
|
1346
|
+
// decision: a dimension's control owns a labelled cell instead of sharing an
|
|
1347
|
+
// unlabelled one with another dimension's control.
|
|
1348
|
+
const COLUMNS = Object.freeze({
|
|
1349
|
+
session: Object.freeze(['object', 'type', 'source', 'savedEnabled', 'savedDisclosure',
|
|
1350
|
+
'sessionEnabled', 'sessionDisclosure', 'receipt']),
|
|
1351
|
+
settings: Object.freeze(['object', 'type', 'source', 'savedEnabled', 'savedDisclosure']),
|
|
1352
|
+
})
|
|
1353
|
+
// One word per column, declared by this page and by no other surface (§6).
|
|
1354
|
+
const COLUMN_COPY = Object.freeze({
|
|
1355
|
+
object: 'capabilities.column.object',
|
|
1356
|
+
type: 'capabilities.typesColumn',
|
|
1357
|
+
source: 'capabilities.sourceColumn',
|
|
1358
|
+
savedEnabled: 'capabilities.column.savedEnabled',
|
|
1359
|
+
savedDisclosure: 'capabilities.column.savedDisclosure',
|
|
1360
|
+
sessionEnabled: 'capabilities.column.sessionEnabled',
|
|
1361
|
+
sessionDisclosure: 'capabilities.column.sessionDisclosure',
|
|
1362
|
+
receipt: 'capabilities.column.receipt',
|
|
1363
|
+
})
|
|
1364
|
+
// The page's one value table. `inherit` is the session columns' own word for an
|
|
1365
|
+
// address that recorded nothing, so no cell is blank and none carries a denial;
|
|
1366
|
+
// every other word answers "who reads it, from which moment", which is the question
|
|
1367
|
+
// both disclosure columns ask.
|
|
1368
|
+
// `automatic` is the saved address's own word for a dimension its configuration wrote
|
|
1369
|
+
// no rule for; the bottom layer a chain would fall back to is synthesized from
|
|
1370
|
+
// configuration this page does not own, so it states no value here.
|
|
1371
|
+
const SAVED_AVAILABILITY = Object.freeze(['enabled', 'closed', 'automatic'])
|
|
1372
|
+
const SAVED_DISCLOSURE = Object.freeze([...CAPABILITY_DISCLOSURES, 'automatic'])
|
|
1373
|
+
// A session column states the record's own words, so the record's own `automatic` is one
|
|
1374
|
+
// of them; `inherit` stays this column's word for an address that recorded nothing.
|
|
1375
|
+
const SESSION_AVAILABILITY = Object.freeze(['enabled', 'closed', 'automatic', 'inherit'])
|
|
1376
|
+
const SESSION_DISCLOSURE = Object.freeze([...CAPABILITY_DISCLOSURES, 'automatic', 'inherit'])
|
|
1377
|
+
const DIMENSIONS = Object.freeze(['availability', 'disclosure'])
|
|
1378
|
+
const ADOPTION_WORDS = Object.freeze({ use: 'capabilities.word.use', skip: 'capabilities.word.skip',
|
|
1379
|
+
damaged: 'capabilities.word.damaged' })
|
|
1380
|
+
const FAMILIES = Object.freeze(['tools', 'skills', 'mcp', 'mcp_tools'])
|
|
1381
|
+
|
|
1382
|
+
/** A receipt is the command's own text; a keyed receipt is translated, never rewritten. */
|
|
1383
|
+
const receiptText = (t, result) => {
|
|
1384
|
+
const text = result?.text
|
|
1385
|
+
if (typeof text === 'string') return text
|
|
1386
|
+
if (text !== null && typeof text === 'object' && typeof text.key === 'string') return t(text.key, text.params)
|
|
1387
|
+
return text === undefined || text === null ? '' : String(text)
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
return function CapabilitySurface({ entrance = 'session', sessionId, open, inline, writable = true, revision, savedCommands, onReload, t, onClose }) {
|
|
1391
|
+
const settingsEntrance = entrance === 'settings'
|
|
1392
|
+
const active = inline === true ? true : open
|
|
1393
|
+
const [rows, setRows] = React.useState([])
|
|
1394
|
+
// `loaded` is the read's own answer: an empty table means the Host declared
|
|
1395
|
+
// nothing only after a complete read said so.
|
|
1396
|
+
const [loaded, setLoaded] = React.useState(false)
|
|
1397
|
+
const [defect, setDefect] = React.useState(null)
|
|
1398
|
+
// The receipt of the most recent write, verbatim, or the error a refused write
|
|
1399
|
+
// returned. The panel owns no wording of its own here.
|
|
1400
|
+
const [receipt, setReceipt] = React.useState(null)
|
|
1401
|
+
const [writing, setWriting] = React.useState([])
|
|
1466
1402
|
const [picked, setPicked] = React.useState([])
|
|
1467
1403
|
const [batchOpen, setBatchOpen] = React.useState(false)
|
|
1468
1404
|
const [batchChoices, setBatchChoices] = React.useState({ availability: 'keep', disclosure: 'keep' })
|
|
1469
|
-
const [selectedId, setSelectedId] = React.useState(null)
|
|
1470
|
-
const [pane, setPane] = React.useState('list')
|
|
1471
1405
|
const [query, setQuery] = React.useState('')
|
|
1472
|
-
const [
|
|
1473
|
-
const [
|
|
1474
|
-
|
|
1406
|
+
const [family, setFamily] = React.useState('all')
|
|
1407
|
+
const [source, setSource] = React.useState('all')
|
|
1408
|
+
// The table's ordering, owned by this page: a display decision that writes nothing
|
|
1409
|
+
// and is dropped with the session it was made in.
|
|
1475
1410
|
const [sort, setSort] = React.useState(null)
|
|
1476
|
-
const [focusDimension, setFocusDimension] = React.useState(null)
|
|
1477
|
-
const [status, setStatus] = React.useState(null)
|
|
1478
|
-
const [proofs, setProofs] = React.useState(emptyProofs)
|
|
1479
|
-
const [pending, setPending] = React.useState(false)
|
|
1480
1411
|
const request = React.useRef(null)
|
|
1481
|
-
|
|
1412
|
+
// Every write owns its own signal: a submitted command is not cancelled because
|
|
1413
|
+
// another row was used, and each row reports only its own progress.
|
|
1414
|
+
const writes = React.useRef(new Set())
|
|
1415
|
+
const epoch = React.useRef(0)
|
|
1482
1416
|
const mounted = React.useRef(true)
|
|
1483
|
-
const detail = React.useRef(null)
|
|
1484
|
-
const list = React.useRef(null)
|
|
1485
|
-
React.useEffect(() => {
|
|
1486
|
-
if (!focusDimension) return
|
|
1487
|
-
const field = focusDimension === 'list' ? list.current?.querySelector('[data-selected="true"] .ptcPlusSessionCapabilityName')
|
|
1488
|
-
: detail.current?.querySelector(`[data-capability-dimension="${focusDimension}"] [role="combobox"]`)
|
|
1489
|
-
?? detail.current?.querySelector('[role="combobox"]')
|
|
1490
|
-
field?.focus({ preventScroll: true })
|
|
1491
|
-
setFocusDimension(null)
|
|
1492
|
-
}, [focusDimension, selectedId, pane])
|
|
1493
1417
|
React.useEffect(() => {
|
|
1494
1418
|
mounted.current = true
|
|
1495
|
-
return () => {
|
|
1419
|
+
return () => {
|
|
1420
|
+
mounted.current = false
|
|
1421
|
+
epoch.current += 1
|
|
1422
|
+
request.current?.abort()
|
|
1423
|
+
request.current = null
|
|
1424
|
+
for (const controller of writes.current) controller.abort()
|
|
1425
|
+
writes.current.clear()
|
|
1426
|
+
}
|
|
1496
1427
|
}, [])
|
|
1497
1428
|
React.useEffect(() => {
|
|
1429
|
+
// A session switch abandons the previous session's read and writes: neither may
|
|
1430
|
+
// land on the new session's rows.
|
|
1498
1431
|
request.current?.abort()
|
|
1499
1432
|
request.current = null
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1433
|
+
for (const controller of writes.current) controller.abort()
|
|
1434
|
+
writes.current.clear()
|
|
1435
|
+
epoch.current += 1
|
|
1436
|
+
setRows([])
|
|
1437
|
+
setLoaded(false)
|
|
1438
|
+
setDefect(null)
|
|
1439
|
+
setReceipt(null)
|
|
1440
|
+
setWriting([])
|
|
1505
1441
|
setPicked([])
|
|
1506
1442
|
setBatchOpen(false)
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
setProofs(emptyProofs())
|
|
1512
|
-
setPending(false)
|
|
1513
|
-
setError(null)
|
|
1443
|
+
setQuery('')
|
|
1444
|
+
setFamily('all')
|
|
1445
|
+
setSource('all')
|
|
1446
|
+
setSort(null)
|
|
1514
1447
|
}, [sessionId])
|
|
1515
1448
|
const load = React.useCallback(async signal => {
|
|
1516
|
-
|
|
1449
|
+
const mine = ++epoch.current
|
|
1517
1450
|
try {
|
|
1518
1451
|
const payload = await inspectSessionCatalog(sessionId, signal)
|
|
1519
|
-
if (
|
|
1520
|
-
|
|
1521
|
-
|
|
1452
|
+
if (!mounted.current || signal.aborted || mine !== epoch.current) return
|
|
1453
|
+
// The only accepted response shape. `config` states a global switch, so it is
|
|
1454
|
+
// read to confirm the shape and never drawn: a switch cannot decide a cell.
|
|
1455
|
+
if (payload?.schema !== 'ptc-capability-session-catalog/v2' || !Array.isArray(payload.rows)
|
|
1456
|
+
|| payload.config === null || typeof payload.config !== 'object') {
|
|
1457
|
+
throw new Error('Invalid capability catalogue')
|
|
1522
1458
|
}
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
setError(null)
|
|
1530
|
-
return payload.entries
|
|
1531
|
-
} catch (failure) {
|
|
1532
|
-
if (!signal.aborted && mounted.current && request.current?.signal === signal) setError(String(failure?.message ?? failure))
|
|
1533
|
-
} finally {
|
|
1534
|
-
if (!signal.aborted && mounted.current && request.current?.signal === signal) setLoading(false)
|
|
1459
|
+
setRows(payload.rows)
|
|
1460
|
+
setLoaded(true)
|
|
1461
|
+
setDefect(null)
|
|
1462
|
+
setPicked(current => current.filter(id => payload.rows.some(row => row.id === id)))
|
|
1463
|
+
} catch (cause) {
|
|
1464
|
+
if (mounted.current && !signal.aborted && mine === epoch.current) setDefect(String(cause?.message ?? cause))
|
|
1535
1465
|
}
|
|
1536
1466
|
}, [sessionId])
|
|
1537
1467
|
React.useEffect(() => {
|
|
1538
|
-
if (!
|
|
1539
|
-
|
|
1540
|
-
|
|
1468
|
+
if (!active) {
|
|
1469
|
+
request.current?.abort()
|
|
1470
|
+
request.current = null
|
|
1541
1471
|
return
|
|
1542
1472
|
}
|
|
1543
|
-
// An in-flight apply owns the shared catalog request and reloads it on completion.
|
|
1544
|
-
if (applying.current) return
|
|
1545
1473
|
const controller = new AbortController()
|
|
1546
1474
|
request.current = controller
|
|
1547
1475
|
void load(controller.signal)
|
|
1548
|
-
return () => {
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
const choiceFor = entry => choices[entry.id] ?? entry.temporary ?? 'inherit'
|
|
1552
|
-
// Session writes also require the saved configuration to keep capability
|
|
1553
|
-
// management and context on; a frozen managed session cannot be edited
|
|
1554
|
-
// while the current settings have them off.
|
|
1555
|
-
const savedBlocks = savedSettings !== null && (savedSettings.enabled === false || savedSettings.contextEnabled === false)
|
|
1556
|
-
const writesBlocked = management === 'paused' || management === 'off' || savedBlocks
|
|
1557
|
-
const sourceKey = entry => entry.source?.kind === 'mcp' ? 'mcp:' + (entry.source.server ?? '')
|
|
1558
|
-
: entry.source?.kind === 'skill' ? 'skill:' + (entry.source.bucket ?? '')
|
|
1559
|
-
: entry.source?.kind ?? 'unknown'
|
|
1560
|
-
const sourceLabel = entry => entry.source?.kind === 'mcp' ? `MCP · ${entry.source.server ?? ''}`
|
|
1561
|
-
: entry.source?.kind === 'skill' ? `Skill · ${entry.source.bucket
|
|
1562
|
-
? t(`capabilities.source.${entry.source.bucket}`) : t('capabilities.source.unknown')}`
|
|
1563
|
-
: t(`capabilities.source.${entry.source?.kind === 'host' ? 'host' : 'unknown'}`)
|
|
1564
|
-
const category = entry => entry.category === 'skill' ? 'skills'
|
|
1565
|
-
: ['server', 'resource'].includes(entry.category) ? 'mcp'
|
|
1566
|
-
: entry.source?.kind === 'mcp' ? 'mcp_tools' : 'tools'
|
|
1567
|
-
const type = entry => entry.category === 'server' ? 'server'
|
|
1568
|
-
: entry.source?.kind === 'mcp' ? 'resource'
|
|
1569
|
-
: entry.category === 'skill' ? 'skill'
|
|
1570
|
-
: entry.category === 'resource' ? 'resource'
|
|
1571
|
-
: entry.category === 'tool' ? 'tool' : 'other'
|
|
1572
|
-
const displayedSource = entry => entry.source?.kind === 'mcp' ? entry.source.server ?? sourceLabel(entry) : sourceLabel(entry)
|
|
1573
|
-
const effectiveAvailability = entry => entry.effective === 'pending' ? 'pending'
|
|
1574
|
-
: entry.enclosingClosure || entry.effective === 'closed' ? 'closed'
|
|
1575
|
-
: entry.effectiveDimensions?.availability
|
|
1576
|
-
?? (['full', 'on-demand', 'host', 'enabled'].includes(entry.effective) ? 'enabled' : 'unknown')
|
|
1577
|
-
const effectiveContext = entry => entry.effective === 'pending' ? 'pending'
|
|
1578
|
-
: effectiveAvailability(entry) === 'closed' ? 'closed'
|
|
1579
|
-
: entry.effectiveDimensions?.disclosure
|
|
1580
|
-
?? (['full', 'on-demand', 'host'].includes(entry.effective) ? entry.effective : 'unknown')
|
|
1581
|
-
const sources = [...new Set(catalog.filter(entry => kind === 'all' || category(entry) === kind).map(sourceKey))].sort()
|
|
1582
|
-
const setCompatibleKind = next => {
|
|
1583
|
-
setKind(next)
|
|
1584
|
-
if (sourceFilter !== 'all' && !catalog.some(entry => (next === 'all' || category(entry) === next)
|
|
1585
|
-
&& sourceKey(entry) === sourceFilter)) setSourceFilter('all')
|
|
1586
|
-
}
|
|
1587
|
-
const setCompatibleSource = next => {
|
|
1588
|
-
setSourceFilter(next)
|
|
1589
|
-
if (next !== 'all' && !catalog.some(entry => (kind === 'all' || category(entry) === kind)
|
|
1590
|
-
&& sourceKey(entry) === next)) setKind('all')
|
|
1591
|
-
}
|
|
1592
|
-
const filtered = catalog.filter(entry => {
|
|
1593
|
-
const text = [entry.name, entry.id, entry.description, entry.source?.server, entry.source?.bucket].filter(Boolean).join(' ').toLocaleLowerCase()
|
|
1594
|
-
return (kind === 'all' || category(entry) === kind)
|
|
1595
|
-
&& (sourceFilter === 'all' || sourceKey(entry) === sourceFilter)
|
|
1596
|
-
&& (stateFilter === 'all' || (stateFilter === 'changed' ? Object.hasOwn(choices, entry.id) : entry.effective === stateFilter))
|
|
1597
|
-
&& text.includes(query.trim().toLocaleLowerCase())
|
|
1598
|
-
}).sort((a, b) => (a.name ?? a.id).localeCompare(b.name ?? b.id) || a.id.localeCompare(b.id))
|
|
1599
|
-
const groups = ['tools', 'skills', 'mcp', 'mcp_tools']
|
|
1600
|
-
const defaultOrder = groups.flatMap(group => filtered.filter(entry => category(entry) === group))
|
|
1601
|
-
const matches = sortCapabilityEntries(defaultOrder, sort, (entry, column) => column === 'name' ? entry.name ?? entry.id
|
|
1602
|
-
: column === 'type' ? t(`capabilities.type.${type(entry)}`)
|
|
1603
|
-
: column === 'source' ? displayedSource(entry)
|
|
1604
|
-
: column === 'availability' ? capabilityChoiceLabel(t, effectiveAvailability(entry))
|
|
1605
|
-
: capabilityChoiceLabel(t, effectiveContext(entry)))
|
|
1606
|
-
const selected = matches.find(entry => entry.id === selectedId)
|
|
1607
|
-
?? ['tools', 'skills', 'mcp', 'mcp_tools'].map(group => matches.find(entry => category(entry) === group)).find(Boolean)
|
|
1608
|
-
const staged = Object.entries(choices).flatMap(([id, choice]) => typeof choice === 'object'
|
|
1609
|
-
? Object.entries(choice).map(([dimension, selection]) => [id, selection, dimension]) : [[id, choice]])
|
|
1610
|
-
const pickedRows = picked.map(id => catalog.find(entry => entry.id === id)).filter(Boolean)
|
|
1611
|
-
const allMatchesPicked = matches.length > 0 && matches.every(entry => picked.includes(entry.id))
|
|
1612
|
-
const someMatchesPicked = matches.some(entry => picked.includes(entry.id))
|
|
1613
|
-
const togglePick = id => setPicked(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
|
|
1614
|
-
const toggleAllMatches = () => setPicked(current => allMatchesPicked
|
|
1615
|
-
? current.filter(id => !matches.some(entry => entry.id === id))
|
|
1616
|
-
: [...new Set([...current, ...matches.map(entry => entry.id)])])
|
|
1617
|
-
// One staging rule for single and batch edits: an item keeps one temporary
|
|
1618
|
-
// patch, "inherit" removes the dimension and reselecting the stored choice
|
|
1619
|
-
// never dirties the draft.
|
|
1620
|
-
const stageDimension = (current, entry, dimension, choice) => {
|
|
1621
|
-
const next = { ...current }
|
|
1622
|
-
const patch = typeof next[entry.id] === 'object' && next[entry.id] !== null ? { ...next[entry.id] } : {}
|
|
1623
|
-
const original = storedDimension(entry, dimension) ?? 'inherit'
|
|
1624
|
-
const selectedChoice = patch[dimension] ?? original
|
|
1625
|
-
if (choice !== selectedChoice) {
|
|
1626
|
-
if (choice === original) delete patch[dimension]
|
|
1627
|
-
else patch[dimension] = choice
|
|
1476
|
+
return () => {
|
|
1477
|
+
controller.abort()
|
|
1478
|
+
if (request.current === controller) request.current = null
|
|
1628
1479
|
}
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
}
|
|
1632
|
-
const
|
|
1633
|
-
const entry = catalog.find(item => item.id === id)
|
|
1634
|
-
if (entry) setDrafts(current => stageDimension(current, entry, dimension, choice))
|
|
1635
|
-
}
|
|
1636
|
-
const choose = (id, next) => {
|
|
1637
|
-
const entry = catalog.find(item => item.id === id)
|
|
1638
|
-
if (!entry) return
|
|
1639
|
-
const original = entry.temporary ?? 'inherit'
|
|
1640
|
-
if (next === (choices[id] ?? original)) return
|
|
1641
|
-
setDrafts(current => {
|
|
1642
|
-
const nextChoices = { ...current }
|
|
1643
|
-
if (next === original) delete nextChoices[id]
|
|
1644
|
-
else nextChoices[id] = next
|
|
1645
|
-
return nextChoices
|
|
1646
|
-
})
|
|
1647
|
-
}
|
|
1648
|
-
const stageBatch = (ids, selections) => setDrafts(current => ids.reduce((draft, id) => {
|
|
1649
|
-
const entry = catalog.find(item => item.id === id)
|
|
1650
|
-
if (!entry) return draft
|
|
1651
|
-
return ['availability', 'disclosure'].reduce((next, dimension) =>
|
|
1652
|
-
selections[dimension] === undefined || selections[dimension] === 'keep'
|
|
1653
|
-
? next : stageDimension(next, entry, dimension, selections[dimension]), draft)
|
|
1654
|
-
}, current))
|
|
1655
|
-
const hasInheritanceReset = staged.some(([, selection]) => selection === 'inherit')
|
|
1656
|
-
const apply = async targetScope => {
|
|
1657
|
-
if (applying.current || !enabled || !available || writesBlocked || staged.length === 0) return
|
|
1658
|
-
if (targetScope === 'global' && hasInheritanceReset) return
|
|
1659
|
-
const submitted = structuredClone(choices)
|
|
1660
|
-
applying.current = true
|
|
1661
|
-
setPending(true)
|
|
1662
|
-
setStatus(null)
|
|
1480
|
+
// The saved address is read again when the configuration under this page changes,
|
|
1481
|
+
// so a rule edit elsewhere cannot leave the previous value on screen.
|
|
1482
|
+
}, [active, load, revision])
|
|
1483
|
+
const reload = () => {
|
|
1663
1484
|
const controller = new AbortController()
|
|
1664
1485
|
request.current?.abort()
|
|
1665
1486
|
request.current = controller
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1487
|
+
void load(controller.signal)
|
|
1488
|
+
onReload?.()
|
|
1489
|
+
}
|
|
1490
|
+
/**
|
|
1491
|
+
* One intention, one record. The write itself is the caller's: a cell runs the
|
|
1492
|
+
* write its own address owns, the receipt that write returns is what the footer
|
|
1493
|
+
* shows, and the rows are re-read from the record rather than patched from the
|
|
1494
|
+
* answer. A command that is not listed for this session reports its own refusal;
|
|
1495
|
+
* it is never pre-empted.
|
|
1496
|
+
*/
|
|
1497
|
+
const perform = async (id, run) => {
|
|
1498
|
+
const controller = new AbortController()
|
|
1499
|
+
writes.current.add(controller)
|
|
1500
|
+
setWriting(current => current.includes(id) ? current : [...current, id])
|
|
1501
|
+
setReceipt(null)
|
|
1673
1502
|
try {
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
throw new Error(receipt ? capabilityCommandText(t, result.text) : t('capabilities.quick.unknownReceipt'))
|
|
1687
|
-
}
|
|
1688
|
-
if (receipt?.state === 'pending') pendingCount += 1
|
|
1689
|
-
// A receipt that names the capability carries the only proof this
|
|
1690
|
-
// panel can hold: what the save did and did not establish.
|
|
1691
|
-
if (receipt.proof !== undefined) observed[receipt.proof.id] = { proof: receipt.proof, command: `/capabilities ${line}` }
|
|
1692
|
-
completed += 1
|
|
1693
|
-
} catch (cause) {
|
|
1694
|
-
failure = String(cause?.message ?? cause)
|
|
1695
|
-
savedOnly = readCapabilityCommandReceipt(failure)?.state === 'saved-only'
|
|
1696
|
-
? readCapabilityCommandReceipt(failure) : null
|
|
1697
|
-
break
|
|
1698
|
-
}
|
|
1699
|
-
}
|
|
1700
|
-
if (mounted.current && Object.keys(observed).length) setProofs(current => ({ ...current, ...observed }))
|
|
1701
|
-
if (request.current !== controller || controller.signal.aborted || !mounted.current) return
|
|
1702
|
-
const refreshed = await load(controller.signal)
|
|
1703
|
-
if (request.current !== controller || controller.signal.aborted || !mounted.current) return
|
|
1704
|
-
const confirmed = refreshed ? staged.slice(0, completed).filter(([id, selection, dimension]) => {
|
|
1705
|
-
const entry = refreshed.find(item => item.id === id)
|
|
1706
|
-
if (dimension) return entry && (targetScope === 'session'
|
|
1707
|
-
? (entry.temporaryRule?.[dimension] ?? 'inherit') === selection
|
|
1708
|
-
: (entry.savedRule?.[dimension] ?? 'automatic') === selection && entry.temporaryRule?.[dimension] === undefined)
|
|
1709
|
-
return entry && (targetScope === 'session' ? entry.temporary === selection
|
|
1710
|
-
: entry.saved === selection && entry.temporary === 'inherit')
|
|
1711
|
-
}) : []
|
|
1712
|
-
setDrafts(current => {
|
|
1713
|
-
const nextChoices = { ...current }
|
|
1714
|
-
for (const [id, selection, dimension] of confirmed) {
|
|
1715
|
-
if (savedOnly && id === staged[completed]?.[0] && dimension === staged[completed]?.[2]) continue
|
|
1716
|
-
if (dimension) {
|
|
1717
|
-
if (nextChoices[id]?.[dimension] !== submitted[id]?.[dimension]) continue
|
|
1718
|
-
const patch = { ...nextChoices[id] }; delete patch[dimension]
|
|
1719
|
-
if (Object.keys(patch).length) nextChoices[id] = patch; else delete nextChoices[id]
|
|
1720
|
-
} else if (nextChoices[id] === submitted[id]) delete nextChoices[id]
|
|
1721
|
-
}
|
|
1722
|
-
return nextChoices
|
|
1723
|
-
})
|
|
1724
|
-
if (savedOnly) setStatus({ key: 'capabilities.quick.savedOnly', params: { count: completed, error: savedOnly.error ?? '' } })
|
|
1725
|
-
else if (failure || !refreshed || confirmed.length < completed) setStatus({ key: 'capabilities.quick.partial',
|
|
1726
|
-
params: { count: confirmed.length, error: failure ?? t('capabilities.quick.confirmationUnavailable') } })
|
|
1727
|
-
else setStatus({ key: pendingCount ? 'capabilities.quick.pendingApplied' : 'capabilities.quick.applied', params: { count: completed } })
|
|
1503
|
+
const answer = await run(controller.signal)
|
|
1504
|
+
if (!mounted.current || controller.signal.aborted) return
|
|
1505
|
+
setReceipt({ text: receiptText(t, answer) })
|
|
1506
|
+
await load(controller.signal)
|
|
1507
|
+
// The caller distinguishes a write that ran from a row it skipped as already matching.
|
|
1508
|
+
return { ok: true }
|
|
1509
|
+
} catch (cause) {
|
|
1510
|
+
const error = String(cause?.message ?? cause)
|
|
1511
|
+
if (mounted.current && !controller.signal.aborted) setReceipt({ error })
|
|
1512
|
+
// A batch runs one write at a time, so the next row's receipt would replace this
|
|
1513
|
+
// refusal; the caller gets it back to state the batch's failure after the loop.
|
|
1514
|
+
return { error }
|
|
1728
1515
|
} finally {
|
|
1729
|
-
|
|
1516
|
+
writes.current.delete(controller)
|
|
1517
|
+
if (mounted.current) setWriting(current => current.filter(item => item !== id))
|
|
1730
1518
|
}
|
|
1731
1519
|
}
|
|
1732
|
-
const
|
|
1733
|
-
const
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1520
|
+
const record = (channel, line, id) => perform(id, signal => channel.run(sessionId, line, signal))
|
|
1521
|
+
const word = value => t(`capabilities.word.${value}`)
|
|
1522
|
+
// A batch writes one column of one entrance, so it names that column's dimension with
|
|
1523
|
+
// the page's own word instead of the layer editor's rule name.
|
|
1524
|
+
const dimensionWord = dimension => t(`capabilities.dimension.${dimension}`)
|
|
1525
|
+
const choiceOptions = values => values.map(value => ({ value, label: word(value) }))
|
|
1526
|
+
const familyOf = row => row.kind === 'binding' ? 'bindings'
|
|
1527
|
+
: row.kind === 'server' ? 'mcp'
|
|
1528
|
+
: row.category === 'skill' ? 'skills'
|
|
1529
|
+
: row.category === 'resource' ? 'mcp'
|
|
1530
|
+
: row.source?.kind === 'mcp' ? 'mcp_tools' : 'tools'
|
|
1531
|
+
const badgeOf = row => row.kind === 'binding' ? 'binding'
|
|
1532
|
+
: row.kind === 'server' ? 'server'
|
|
1533
|
+
: row.category === 'skill' ? 'skill'
|
|
1534
|
+
: row.category === 'resource' || row.source?.kind === 'mcp' ? 'resource' : 'tool'
|
|
1535
|
+
// Two rows share one source filter entry exactly when they state the same source,
|
|
1536
|
+
// and the entry is named by the row that states it.
|
|
1537
|
+
const sourceKeyOf = row => row.kind === 'binding' ? 'bindings'
|
|
1538
|
+
: JSON.stringify(row.source ?? namespaceSource(row))
|
|
1539
|
+
const sourceText = row => row.kind === 'binding' ? t('capabilities.source.bindings')
|
|
1540
|
+
: capabilitySourceText(t, row)
|
|
1541
|
+
const sourceFilterLabel = row => {
|
|
1542
|
+
const servers = mcpServersOf(row.source)
|
|
1543
|
+
if (row.source?.kind === 'mcp' && servers.length > 0) return `MCP · ${servers.join(t('capabilities.source.separator'))}`
|
|
1544
|
+
// The option names the whole source its key identifies. A skill's bucket does not:
|
|
1545
|
+
// two providers can declare into one bucket, and two options reading one word would
|
|
1546
|
+
// offer a distinction this filter cannot state.
|
|
1547
|
+
return row.source?.kind === 'skill'
|
|
1548
|
+
? `Skill · ${sourceText(row)}${row.source.provider ? ` · ${row.source.provider}` : ''}`
|
|
1549
|
+
: sourceText(row)
|
|
1744
1550
|
}
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
// The
|
|
1751
|
-
//
|
|
1752
|
-
const
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1551
|
+
// Both addresses, stated from the read: the saved column is the configuration's
|
|
1552
|
+
// own value for this object and the session column is the record's answer, with
|
|
1553
|
+
// `inherit` when this session recorded nothing. A server has no disclosure of its
|
|
1554
|
+
// own, so both disclosure cells state where that decision lives instead of a
|
|
1555
|
+
// denial or another row's answer.
|
|
1556
|
+
// The saved cell states its own record's word: a dimension this record wrote nothing for reads
|
|
1557
|
+
// `automatic`, never a resolved value another layer would answer (capability-surface §8.1c).
|
|
1558
|
+
const savedOf = (row, dimension) => row.saved?.[dimension] ?? 'automatic'
|
|
1559
|
+
const savedEnabled = row => word(savedOf(row, 'availability'))
|
|
1560
|
+
const savedDisclosure = row => row.kind === 'server' ? t('capabilities.word.perMember')
|
|
1561
|
+
: word(savedOf(row, 'disclosure'))
|
|
1562
|
+
const recordedOf = (row, dimension) => row.session?.[dimension] ?? 'inherit'
|
|
1563
|
+
const recordWord = (row, dimension) => row.record?.[dimension]
|
|
1564
|
+
/** The layer whose record decided this dimension, or null when this object's own entry did. */
|
|
1565
|
+
const recordLayer = (row, dimension) => row.record?.layers?.[dimension] ?? null
|
|
1566
|
+
/** A recorded decision above this object: the row states the layer's word in its receipt. */
|
|
1567
|
+
const adoptionOf = row => ['use', 'skip', 'damaged'].includes(row.session?.use) ? row.session.use : 'use'
|
|
1568
|
+
/**
|
|
1569
|
+
* The receipt column: which record this row's decision leaves, and what it states
|
|
1570
|
+
* there — or that nothing has been written at that address yet. A missing record is
|
|
1571
|
+
* that address's own "unwritten" receipt, not a blank: deleting this column would
|
|
1572
|
+
* make "where does this decision get reconciled" unanswerable, which is why §4 asks
|
|
1573
|
+
* for it and why the evidence column left the page.
|
|
1574
|
+
*/
|
|
1575
|
+
const receiptOf = row => {
|
|
1576
|
+
if (row.kind === 'binding') {
|
|
1577
|
+
const record = t('capabilities.word.bindingRecord')
|
|
1578
|
+
if (adoptionOf(row) === 'damaged') return `${record} · ${word('damaged')}`
|
|
1579
|
+
return row.session?.recorded === true ? `${record} · ${t(ADOPTION_WORDS[adoptionOf(row)])}`
|
|
1580
|
+
: `${record} · ${t('capabilities.word.unwritten')}`
|
|
1761
1581
|
}
|
|
1762
|
-
|
|
1582
|
+
const record = t('capabilities.word.sessionRecord')
|
|
1583
|
+
// The receipt states the record's own text, not the resolution the value column
|
|
1584
|
+
// shows, and names the layer when a record above this object decided it. That layer's row is
|
|
1585
|
+
// part of this same read and states the word it wrote, so the receipt states that word:
|
|
1586
|
+
// `inherit` is this row's word for its own entry having written nothing (§4), never a word a
|
|
1587
|
+
// deciding record wrote.
|
|
1588
|
+
// A server row states every word its own rule holds, including a disclosure word its rule
|
|
1589
|
+
// can carry (§8.3): that word answers the members that wrote none at runtime, and a receipt
|
|
1590
|
+
// that said `未写下` while the record held it would deny what this same read carries. Its
|
|
1591
|
+
// disclosure cell still states the delegation, because the decision is made for the members.
|
|
1592
|
+
const statedDimensions = DIMENSIONS
|
|
1593
|
+
const layerWord = (layer, dimension) => rows
|
|
1594
|
+
.find(candidate => candidate.id === layer)?.record?.[dimension]
|
|
1595
|
+
const own = statedDimensions.filter(dimension => recordWord(row, dimension) !== undefined
|
|
1596
|
+
&& recordLayer(row, dimension) === null)
|
|
1597
|
+
const byLayer = new Map()
|
|
1598
|
+
for (const dimension of statedDimensions) {
|
|
1599
|
+
const layer = recordLayer(row, dimension)
|
|
1600
|
+
// A dimension this object did not write is still answered by that layer at runtime, so the
|
|
1601
|
+
// receipt names it. A layer is named only for a dimension that layer's record wrote; a read
|
|
1602
|
+
// that does not carry the layer's row states the layer alone instead of inventing a word.
|
|
1603
|
+
if (layer === null) continue
|
|
1604
|
+
const stated = layerWord(layer, dimension)
|
|
1605
|
+
byLayer.set(layer, [...(byLayer.get(layer) ?? []), ...(stated === undefined ? [] : [`${dimension} ${stated}`])])
|
|
1606
|
+
}
|
|
1607
|
+
const parts = [
|
|
1608
|
+
...(own.length === 0 ? [] : [own.map(dimension => `${dimension} ${recordWord(row, dimension)}`).join(' · ')]),
|
|
1609
|
+
...[...byLayer].map(([layer, stated]) => stated.length === 0
|
|
1610
|
+
? t('capabilities.receipt.layer', { layer })
|
|
1611
|
+
: `${t('capabilities.receipt.layer', { layer })} · ${stated.join(' · ')}`),
|
|
1612
|
+
]
|
|
1613
|
+
return parts.length === 0 ? `${record} · ${t('capabilities.word.unwritten')}`
|
|
1614
|
+
: `${record} · ${parts.join(' · ')}`
|
|
1763
1615
|
}
|
|
1764
|
-
//
|
|
1765
|
-
//
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1616
|
+
// A control's own label names the address it writes, so a cell needs no neighbour
|
|
1617
|
+
// to be read (§3.2). The address is the object's identity, not its display name:
|
|
1618
|
+
// two rows can read alike, and only one of them is the record's key.
|
|
1619
|
+
const controlLabel = (column, row) => t(`capabilities.control.${column}`, { name: row.id })
|
|
1620
|
+
// The no-op test compares the record's own word, not the resolved value: a record
|
|
1621
|
+
// that wrote `automatic` and one that wrote the value it resolves to are different
|
|
1622
|
+
// records, and the second can only replace the first if the write is allowed.
|
|
1623
|
+
const writeSession = (row, dimension, value) => {
|
|
1624
|
+
if ((recordWord(row, dimension) ?? 'inherit') === value) return
|
|
1625
|
+
// The promise is the write: a batch awaits it so each command composes from the record
|
|
1626
|
+
// the previous one left, and nothing is fired in parallel.
|
|
1627
|
+
return record(commands, `set session ${encodeURIComponent(row.id)} ${dimension} ${value}`, row.id)
|
|
1774
1628
|
}
|
|
1775
|
-
const
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
if (choice === 'automatic') return { upper: true, staged: stagedChange }
|
|
1783
|
-
return { state: choice, origin: 'capabilities.quick.originSession', staged: stagedChange }
|
|
1629
|
+
const writeSaved = (row, dimension, value) => {
|
|
1630
|
+
if (savedOf(row, dimension) === value) return
|
|
1631
|
+
if (typeof savedCommands?.run !== 'function') return
|
|
1632
|
+
return perform(row.id, async signal => {
|
|
1633
|
+
const written = await savedCommands.run({ id: row.id, dimension, selection: value }, signal)
|
|
1634
|
+
return { text: capabilityReceiptNotice(t, { state: 'recorded', record: { ...written, scope: 'global' } }, 'receipt') }
|
|
1635
|
+
})
|
|
1784
1636
|
}
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1637
|
+
// A damaged record still gets a control: the click writes the record that replaces
|
|
1638
|
+
// it, which is the only way the row can leave that state.
|
|
1639
|
+
const writeAdoption = row => void record(bindingCommands,
|
|
1640
|
+
`${adoptionOf(row) === 'use' ? 'skip' : 'use'} ${row.id}`, row.id)
|
|
1641
|
+
// The session column's own word, as one stable token per state. The row and the cell
|
|
1642
|
+
// both state it, so availability is read from a stripe and a mark before the word is
|
|
1643
|
+
// read: `enabled` and `closed` are the two answers no reader should have to decode from
|
|
1644
|
+
// identical text, and `inherit` must not read as a decision this session made. What those
|
|
1645
|
+
// words resolve to at runtime stays off the page: a category or `mcpServers.<server>` rule
|
|
1646
|
+
// answers a member whose own entry wrote nothing, a closed server answers over a member's
|
|
1647
|
+
// own `enabled`, and a paused capability switch stops applying the saved document — none of
|
|
1648
|
+
// those resolutions is a word these two columns hold (capability-surface §8.1b/§8.1c), so
|
|
1649
|
+
// none of them becomes this row's colour or a second state token.
|
|
1650
|
+
const sessionStateOf = row => row.kind === 'binding'
|
|
1651
|
+
? adoptionOf(row)
|
|
1652
|
+
: (recordedOf(row, 'availability') ?? 'inherit')
|
|
1653
|
+
const sessionAvailabilityCell = row => row.kind === 'binding'
|
|
1654
|
+
? h('div', { className: 'ptcPlusSessionCapabilityAvailability', 'data-state': sessionStateOf(row) },
|
|
1655
|
+
h('span', { className: 'ptcPlusSessionStateMark', 'data-state': sessionStateOf(row), 'aria-hidden': true }),
|
|
1656
|
+
h('button', { type: 'button', className: 'ptcPlusBindingToggle', 'data-kind': adoptionOf(row),
|
|
1657
|
+
'aria-pressed': adoptionOf(row) === 'use' ? 'true' : 'false',
|
|
1658
|
+
'aria-label': controlLabel('sessionEnabled', row),
|
|
1659
|
+
'aria-busy': writing.includes(row.id) ? 'true' : undefined,
|
|
1660
|
+
onClick: () => writeAdoption(row) }, t(ADOPTION_WORDS[adoptionOf(row)])))
|
|
1661
|
+
: h('div', { className: 'ptcPlusSessionCapabilityAvailability', 'data-state': sessionStateOf(row) },
|
|
1662
|
+
h('span', { className: 'ptcPlusSessionStateMark', 'data-state': sessionStateOf(row), 'aria-hidden': true }),
|
|
1663
|
+
h(Select, { label: controlLabel('sessionEnabled', row), value: recordedOf(row, 'availability'),
|
|
1664
|
+
options: choiceOptions(SESSION_AVAILABILITY),
|
|
1665
|
+
onChange: value => writeSession(row, 'availability', value) }))
|
|
1666
|
+
const sessionDisclosureCell = row => row.kind === 'binding'
|
|
1667
|
+
// A binding's adoption record holds no disclosure decision at all, so the cell
|
|
1668
|
+
// states that address's own word instead of borrowing the one a service row owns.
|
|
1669
|
+
? h('span', { className: 'ptcPlusSessionCapabilityDisclosure', 'data-state': 'inherit' },
|
|
1670
|
+
t('capabilities.word.inherit'))
|
|
1671
|
+
: row.kind === 'server'
|
|
1672
|
+
// A server carries no disclosure of its own: its members do.
|
|
1673
|
+
? h('span', { className: 'ptcPlusSessionCapabilityDisclosure', 'data-state': 'per-member' },
|
|
1674
|
+
t('capabilities.word.perMember'))
|
|
1675
|
+
: h('div', { className: 'ptcPlusSessionCapabilityDisclosure',
|
|
1676
|
+
'data-state': recordedOf(row, 'disclosure') ?? 'inherit' },
|
|
1677
|
+
h(Select, { label: controlLabel('sessionDisclosure', row), value: recordedOf(row, 'disclosure'),
|
|
1678
|
+
options: choiceOptions(SESSION_DISCLOSURE),
|
|
1679
|
+
onChange: value => writeSession(row, 'disclosure', value) }))
|
|
1680
|
+
// A control exists exactly when this entrance fixes the address the cell writes: the settings
|
|
1681
|
+
// entrance writes the saved document, so every row it states gets the controls its own address
|
|
1682
|
+
// holds — both dimensions for a capability, availability alone for a server, whose saved cell
|
|
1683
|
+
// edits `mcpServers.<server>` in that same document. Read-only disables, never withdraws.
|
|
1684
|
+
const savedControl = row => settingsEntrance
|
|
1685
|
+
const savedDisabled = () => writable === false
|
|
1686
|
+
// The saved column states the word its own record wrote, and that word is what this column
|
|
1687
|
+
// makes readable: `enabled`/`closed` take the success and error colours and `automatic` the
|
|
1688
|
+
// business colour, so the entrance that fixes the saved document shows at a glance which
|
|
1689
|
+
// objects it switched off — the entrance that has no session column included. The colour
|
|
1690
|
+
// states this column's own word only; it does not claim the answer a row gets here, because
|
|
1691
|
+
// the saved entry is one layer of a resolution this page does not compute (§8.1b/§8.1c).
|
|
1692
|
+
// The saved disclosure cell states one of five disclosure words and carries no state token:
|
|
1693
|
+
// no rule colours it, and a token no selector or case observes states nothing.
|
|
1694
|
+
const savedState = row => ({ 'data-state': savedOf(row, 'availability') })
|
|
1695
|
+
const savedEnabledCell = row => savedControl(row)
|
|
1696
|
+
? h('div', { className: 'ptcPlusSessionCapabilitySaved', ...savedState(row) },
|
|
1697
|
+
h(Select, { label: controlLabel('savedEnabled', row), value: savedOf(row, 'availability'),
|
|
1698
|
+
disabled: savedDisabled(), options: choiceOptions(SAVED_AVAILABILITY),
|
|
1699
|
+
onChange: value => writeSaved(row, 'availability', value) }))
|
|
1700
|
+
: h('span', { className: 'ptcPlusSessionCapabilitySaved', ...savedState(row) }, savedEnabled(row))
|
|
1701
|
+
const savedDisclosureCell = row => savedControl(row) && row.kind !== 'server'
|
|
1702
|
+
// A server carries no disclosure of its own, so its saved disclosure cell is the statement
|
|
1703
|
+
// that the members decide it; its availability cell is the rule edit below.
|
|
1704
|
+
? h('div', { className: 'ptcPlusSessionCapabilitySavedDisclosure' },
|
|
1705
|
+
h(Select, { label: controlLabel('savedDisclosure', row), value: savedOf(row, 'disclosure'),
|
|
1706
|
+
disabled: savedDisabled(), options: choiceOptions(SAVED_DISCLOSURE),
|
|
1707
|
+
onChange: value => writeSaved(row, 'disclosure', value) }))
|
|
1708
|
+
: h('span', { className: 'ptcPlusSessionCapabilitySavedDisclosure' }, savedDisclosure(row))
|
|
1709
|
+
// Every cell states one clipped line and keeps its full text as the title, so no row grows
|
|
1710
|
+
// with the length of its words. A display name that differs from the id is the one remaining
|
|
1711
|
+
// difference between two rows: it adds the id line below the name.
|
|
1712
|
+
const identityName = row => h('span', { className: 'ptcPlusSessionCapabilityName',
|
|
1713
|
+
title: row.name ?? row.id }, row.name ?? row.id)
|
|
1714
|
+
const identityId = row => row.name && row.name !== row.id
|
|
1715
|
+
? h('code', { title: row.id }, row.id) : null
|
|
1716
|
+
const identityCell = row => row.kind === 'binding'
|
|
1717
|
+
? h('span', { className: 'ptcPlusCapabilityIdentity' },
|
|
1718
|
+
h('span', { className: 'ptcPlusCapabilityIdentityText' }, identityName(row), identityId(row)))
|
|
1719
|
+
: h('div', { className: 'ptcPlusCapabilityIdentity' },
|
|
1720
|
+
// Only a capability row takes part in a batch: a server row's saved cell edits the scope's
|
|
1721
|
+
// `mcpServers.<server>` rule on its own, and a binding row writes the bindings document.
|
|
1722
|
+
row.kind !== 'capability' ? null : h('input', { type: 'checkbox', className: 'ptcPlusPick', checked: picked.includes(row.id),
|
|
1723
|
+
'aria-label': t('capabilities.selectForBatch', { name: row.name ?? row.id }),
|
|
1724
|
+
disabled: writable === false,
|
|
1725
|
+
onChange: () => setPicked(current => current.includes(row.id)
|
|
1726
|
+
? current.filter(id => id !== row.id) : [...current, row.id]) }),
|
|
1727
|
+
h('div', { className: 'ptcPlusCapabilityIdentityText' }, identityName(row), identityId(row)))
|
|
1728
|
+
const cellsOf = row => {
|
|
1729
|
+
const cells = {
|
|
1730
|
+
object: identityCell(row),
|
|
1731
|
+
type: h('span', { className: 'ptcPlusCapabilityBadge', 'data-kind': badgeOf(row) }, t(`capabilities.type.${badgeOf(row)}`)),
|
|
1732
|
+
source: h('span', { className: 'ptcPlusCapabilitySource', title: sourceText(row) }, sourceText(row)),
|
|
1733
|
+
savedEnabled: savedEnabledCell(row),
|
|
1734
|
+
savedDisclosure: savedDisclosureCell(row),
|
|
1735
|
+
sessionEnabled: sessionAvailabilityCell(row),
|
|
1736
|
+
sessionDisclosure: sessionDisclosureCell(row),
|
|
1737
|
+
receipt: h('span', { className: 'ptcPlusSessionCapabilityReceipt', title: receiptOf(row) }, receiptOf(row)),
|
|
1738
|
+
}
|
|
1739
|
+
return cells
|
|
1740
|
+
}
|
|
1741
|
+
const columns = COLUMNS[settingsEntrance ? 'settings' : 'session']
|
|
1742
|
+
// What the reader compares in a column is the word that column shows: a translated
|
|
1743
|
+
// badge, a source label, a record's own word. Sorting therefore reads this page's
|
|
1744
|
+
// display values instead of the fields they were derived from.
|
|
1745
|
+
const sortValue = (row, column) => column === 'object' ? (row.name ?? row.id)
|
|
1746
|
+
: column === 'type' ? t(`capabilities.type.${badgeOf(row)}`)
|
|
1747
|
+
: column === 'source' ? sourceText(row)
|
|
1748
|
+
: column === 'savedEnabled' ? savedEnabled(row)
|
|
1749
|
+
: column === 'savedDisclosure' ? savedDisclosure(row)
|
|
1750
|
+
: column === 'sessionEnabled' ? (row.kind === 'binding' ? t(ADOPTION_WORDS[adoptionOf(row)])
|
|
1751
|
+
: word(recordedOf(row, 'availability')))
|
|
1752
|
+
: column === 'sessionDisclosure' ? (row.kind === 'binding' ? t('capabilities.word.inherit')
|
|
1753
|
+
// A server states where the decision lives, not a disclosure of its own,
|
|
1754
|
+
// so that is the word this column shows and therefore the word it sorts by.
|
|
1755
|
+
: row.kind === 'server' ? t('capabilities.word.perMember')
|
|
1756
|
+
: word(recordedOf(row, 'disclosure')))
|
|
1757
|
+
: ''
|
|
1758
|
+
// The receipt column states a whole sentence per row, so it offers no ordering: a
|
|
1759
|
+
// reader sorting by the wording of a receipt would be sorting by prose.
|
|
1760
|
+
const sortable = column => column !== 'receipt'
|
|
1761
|
+
const renderRow = row => {
|
|
1762
|
+
const cells = cellsOf(row)
|
|
1763
|
+
return h('div', { key: row.id, role: 'row',
|
|
1764
|
+
className: `ptcPlusSessionCapabilityRow ptcPlusCapabilityItem ptcPlusSessionCapabilityRow-${columns.length}`,
|
|
1765
|
+
'data-capability-id': row.id, 'data-kind': row.kind,
|
|
1766
|
+
'data-session-state': sessionStateOf(row),
|
|
1767
|
+
...(row.kind === 'binding' ? { 'data-binding-id': row.id, 'data-binding-state': adoptionOf(row) } : {}) },
|
|
1768
|
+
...columns.map(column => h(React.Fragment, { key: column }, React.cloneElement(cells[column], { role: 'cell' }))))
|
|
1803
1769
|
}
|
|
1804
|
-
const
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1770
|
+
const columnsRow = () => h('div', {
|
|
1771
|
+
className: `ptcPlusSessionCapabilityColumns ptcPlusCapabilityColumns ptcPlusSessionCapabilityColumns-${columns.length}`,
|
|
1772
|
+
role: 'row' }, ...columns.map(column => h('span', { key: column, role: 'columnheader',
|
|
1773
|
+
'aria-sort': sortable(column) ? capabilitySortDirection(sort, column) : undefined },
|
|
1774
|
+
sortable(column)
|
|
1775
|
+
? h('button', { type: 'button', className: 'ptcPlusCapabilitySort', title: t('capabilities.sortCycle'),
|
|
1776
|
+
'aria-label': t('capabilities.sortColumn', { column: t(COLUMN_COPY[column]) }),
|
|
1777
|
+
onClick: () => setSort(current => cycleCapabilitySort(current, column)) },
|
|
1778
|
+
t(COLUMN_COPY[column]),
|
|
1779
|
+
h('span', { className: 'ptcPlusCapabilitySortMark', 'aria-hidden': true },
|
|
1780
|
+
capabilitySortDirection(sort, column) === 'ascending' ? '↑'
|
|
1781
|
+
: capabilitySortDirection(sort, column) === 'descending' ? '↓' : '↕'))
|
|
1782
|
+
: t(COLUMN_COPY[column]))))
|
|
1783
|
+
const capabilities = rows.filter(row => row.kind !== 'binding')
|
|
1784
|
+
const matches = row => {
|
|
1785
|
+
const text = [row.name, row.id, sourceText(row)].filter(Boolean).join(' ').toLocaleLowerCase()
|
|
1786
|
+
return text.includes(query.trim().toLocaleLowerCase())
|
|
1808
1787
|
}
|
|
1809
|
-
const
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
if (
|
|
1815
|
-
|
|
1816
|
-
|
|
1788
|
+
const listed = sortCapabilityEntries(capabilities.filter(row => (family === 'all' || familyOf(row) === family)
|
|
1789
|
+
&& (source === 'all' || sourceKeyOf(row) === source) && matches(row)), sort, sortValue)
|
|
1790
|
+
const bindings = sortCapabilityEntries(rows.filter(row => row.kind === 'binding'), sort, sortValue)
|
|
1791
|
+
const sources = new Map()
|
|
1792
|
+
for (const row of capabilities) {
|
|
1793
|
+
if (family !== 'all' && familyOf(row) !== family) continue
|
|
1794
|
+
const key = sourceKeyOf(row)
|
|
1795
|
+
if (!sources.has(key)) sources.set(key, sourceFilterLabel(row))
|
|
1817
1796
|
}
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1797
|
+
// A batch writes only a dimension this entrance shows a control for. The settings entrance
|
|
1798
|
+
// fixes saved addresses and reads no session record, so what a session recorded decides
|
|
1799
|
+
// nothing there; the session entrance writes the session address, and every capability row
|
|
1800
|
+
// now carries both of its controls — a member's cell states its own record, so writing it
|
|
1801
|
+
// always changes what the cell says.
|
|
1802
|
+
const batchWritable = row => DIMENSIONS.filter(dimension => settingsEntrance
|
|
1803
|
+
? savedControl(row) && (dimension === 'availability' || row.kind === 'capability')
|
|
1804
|
+
: dimension === 'availability' || row.kind === 'capability')
|
|
1805
|
+
const pickedRows = capabilities.filter(row => picked.includes(row.id) && batchWritable(row).length > 0)
|
|
1806
|
+
const batchLines = () => pickedRows.flatMap(row => batchWritable(row)
|
|
1807
|
+
.filter(dimension => batchChoices[dimension] !== 'keep')
|
|
1808
|
+
.map(dimension => ({ row, dimension, choice: batchChoices[dimension] })))
|
|
1809
|
+
// A batch is the same decision applied to existing rows: it writes one record per
|
|
1810
|
+
// selected row through the same channel and creates no rows of its own.
|
|
1811
|
+
const applyBatch = async () => {
|
|
1812
|
+
const lines = batchLines()
|
|
1813
|
+
setBatchOpen(false)
|
|
1814
|
+
// Every dimension kept its current setting, so this batch writes nothing. The
|
|
1815
|
+
// click still owes the reader one sentence, and it is the footer's own receipt
|
|
1816
|
+
// expression that states it: a zero-write batch names no record, so it states the
|
|
1817
|
+
// batch's outcome instead of widening the command receipt vocabulary.
|
|
1818
|
+
if (lines.length === 0) {
|
|
1819
|
+
setReceipt({ text: { key: 'capabilities.batch.kept', params: {} } })
|
|
1820
|
+
return
|
|
1836
1821
|
}
|
|
1837
|
-
|
|
1822
|
+
// One write at a time: each command composes the whole record from the log it reads, so
|
|
1823
|
+
// overlapping commands would drop each other's decision. A refusal does not stop the
|
|
1824
|
+
// remaining rows, and the batch states the first refusal after them instead of letting the
|
|
1825
|
+
// next row's success sentence replace it.
|
|
1826
|
+
let refusal, wrote = 0
|
|
1827
|
+
for (const { row, dimension, choice } of lines) {
|
|
1828
|
+
try {
|
|
1829
|
+
const outcome = settingsEntrance
|
|
1830
|
+
? await writeSaved(row, dimension, choice)
|
|
1831
|
+
: await writeSession(row, dimension, choice)
|
|
1832
|
+
if (outcome?.error !== undefined) refusal ??= outcome.error
|
|
1833
|
+
else if (outcome?.ok === true) wrote += 1
|
|
1834
|
+
} catch (error) { refusal ??= String(error?.message ?? error) }
|
|
1835
|
+
}
|
|
1836
|
+
if (refusal !== undefined) setReceipt({ error: refusal })
|
|
1837
|
+
// Every picked word already matched its record, so no command ran; the click still owes the
|
|
1838
|
+
// reader one sentence rather than leaving the previous row's receipt standing.
|
|
1839
|
+
else if (wrote === 0) setReceipt({ text: { key: 'capabilities.batch.kept', params: {} } })
|
|
1838
1840
|
}
|
|
1839
|
-
const
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
h('
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
h('
|
|
1877
|
-
h('
|
|
1878
|
-
h('
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
'
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
h('
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
const pendingClose = server.managementEnabled !== false && (availability === 'inherit' ? sourceAvailability : availability) === 'closed'
|
|
1953
|
-
const currentlyClosed = server.enclosingClosure ?? server.effective === 'closed'
|
|
1954
|
-
const key = currentlyClosed ? 'capabilities.quick.disabledByServer'
|
|
1955
|
-
: pendingClose ? 'capabilities.quick.pendingServerClose' : null
|
|
1956
|
-
return key ? h('p', { className: 'ptcPlusCapabilityNotice' }, t(key, { server: selected.source.server })) : null
|
|
1957
|
-
})() : null,
|
|
1958
|
-
selected.category === 'server' ? h('div', { className: 'ptcPlusSessionCapabilityChildren' },
|
|
1959
|
-
h('h4', null, t('capabilities.quick.serverTools')),
|
|
1960
|
-
h('ul', null, ...catalog.filter(entry => entry.id !== selected.id
|
|
1961
|
-
&& entry.source?.kind === 'mcp' && entry.source.server === selected.source?.server)
|
|
1962
|
-
.map(entry => h('li', { key: entry.id }, entry.name ?? entry.id)))) : null,
|
|
1963
|
-
selected.savedRule ? h(React.Fragment, null, ...['availability', 'disclosure'].map(dimension => h(React.Fragment, { key: dimension },
|
|
1964
|
-
h('div', { className: 'ptcPlusCapabilityFilter', 'data-capability-dimension': dimension },
|
|
1965
|
-
h('span', null, t(`capabilities.policy.${dimension}`)),
|
|
1966
|
-
h(Select, { label: t(`capabilities.policy.${dimension}`), disabled: !enabled || !available || pending || writesBlocked,
|
|
1967
|
-
value: dimensionCurrent(selected, dimension),
|
|
1968
|
-
onChange: next => chooseDimension(selected.id, dimension, next),
|
|
1969
|
-
options: dimensionOptions(selected, dimension) })),
|
|
1970
|
-
h('p', { className: 'ptcPlusDetail ptcPlusCapabilityImpact', 'data-impact-dimension': dimension },
|
|
1971
|
-
impactLine(dimensionImpact(selected, dimension))))))
|
|
1972
|
-
: h(React.Fragment, null,
|
|
1973
|
-
h('div', { className: 'ptcPlusCapabilityFilter' },
|
|
1974
|
-
h('span', null, t('capabilities.quick.choice')),
|
|
1975
|
-
h(Select, { label: t('capabilities.quick.choice'), value: choiceCurrent(selected),
|
|
1976
|
-
disabled: !enabled || !available || pending || writesBlocked, options: choiceOptions,
|
|
1977
|
-
onChange: next => choose(selected.id, next) })),
|
|
1978
|
-
h('p', { className: 'ptcPlusDetail ptcPlusCapabilityImpact', 'data-impact-dimension': 'selection' },
|
|
1979
|
-
impactLine(choiceImpact(selected)))),
|
|
1980
|
-
(typeof choices[selected.id] === 'string' ? choices[selected.id] === 'inherit'
|
|
1981
|
-
: Object.values(choices[selected.id] ?? {}).includes('inherit')) ? h('p', { className: 'ptcPlusDetail' },
|
|
1982
|
-
t('capabilities.quick.inheritSessionOnly')) : null) : h('p', null, t('capabilities.noMatches')))),
|
|
1983
|
-
batchOpen ? h(Modal, { open: true, onClose: () => setBatchOpen(false), title: t('capabilities.batchSettings'),
|
|
1984
|
-
closeLabel: t('capabilities.batch.cancel'), headless: true, className: 'ptcPlusCapabilityBatchModal' },
|
|
1985
|
-
h('div', { className: 'ptcPlusCapabilityBatchHead' }, h('h2', null, t('capabilities.batchSettings')),
|
|
1986
|
-
h('button', { type: 'button', className: 'ptcPlusDialogClose', 'aria-label': t('capabilities.batch.cancel'),
|
|
1987
|
-
onClick: () => setBatchOpen(false) }, '\u00d7')),
|
|
1988
|
-
h('div', { className: 'ptcPlusCapabilityBatchBody' },
|
|
1989
|
-
h('p', { className: 'ptcPlusDetail', role: 'status' }, t('capabilities.batch.selected', { count: pickedRows.length })),
|
|
1990
|
-
h('ul', { className: 'ptcPlusCapabilityBatchTargets', 'aria-label': t('capabilities.batch.targetList') },
|
|
1991
|
-
...pickedRows.map(entry => h('li', { key: entry.id }, h('span', null, entry.name ?? entry.id), h('code', null, entry.id)))),
|
|
1992
|
-
h('div', { className: 'ptcPlusCapabilityBatchFields' }, ...['availability', 'disclosure'].map(dimension =>
|
|
1993
|
-
h('div', { key: dimension, className: 'ptcPlusCapabilityFilter' }, h('span', null, t(`capabilities.policy.${dimension}`)),
|
|
1994
|
-
h(Select, { value: batchChoices[dimension], label: t(`capabilities.batch.${dimension}`),
|
|
1995
|
-
onChange: choice => setBatchChoices(current => ({ ...current, [dimension]: choice })),
|
|
1996
|
-
options: ['keep', 'inherit', ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)]
|
|
1997
|
-
.map(choice => ({ value: choice, label: choice === 'keep' ? t('capabilities.batch.keep')
|
|
1998
|
-
: choice === 'inherit' ? t('capabilities.batch.sessionInherit')
|
|
1999
|
-
: choice === 'enabled' ? t('capabilities.action.enable')
|
|
2000
|
-
: choice === 'closed' ? t('capabilities.action.close') : t(`capabilities.${choice}`) })) })))),
|
|
2001
|
-
h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.sessionStage'))),
|
|
2002
|
-
h('div', { className: 'ptcPlusCapabilityBatchFooter' },
|
|
2003
|
-
h('button', { type: 'button', className: 'ptcPlusButton', onClick: () => setBatchOpen(false) }, t('capabilities.batch.cancel')),
|
|
2004
|
-
h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary',
|
|
2005
|
-
disabled: pickedRows.length === 0 || !['availability', 'disclosure'].some(dimension => batchChoices[dimension] !== 'keep'),
|
|
2006
|
-
onClick: () => { stageBatch(pickedRows.map(entry => entry.id), batchChoices); setBatchOpen(false) } },
|
|
2007
|
-
t('capabilities.batch.stage', { count: pickedRows.length })))) : null,
|
|
2008
|
-
h('div', { className: 'ptcPlusSessionCapabilityFooter' },
|
|
2009
|
-
h('span', { role: 'status' }, pending ? t('capabilities.quick.applying') : status ? t(status.key, status.params)
|
|
2010
|
-
: t('capabilities.quick.changes', { count: staged.length })),
|
|
2011
|
-
h('button', { type: 'button', className: 'ptcPlusButton', disabled: pending || staged.length === 0, onClick: discard },
|
|
2012
|
-
t('capabilities.quick.discard')),
|
|
2013
|
-
// Session-scoped temporary application stays the footer's primary action; saving global
|
|
2014
|
-
// defaults is an explicit secondary choice, so no default-looking control changes global state.
|
|
2015
|
-
h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary', title: t('capabilities.quick.sessionHint'),
|
|
2016
|
-
disabled: pending || !enabled || !available || writesBlocked || staged.length === 0, onClick: () => void apply('session') },
|
|
2017
|
-
t('capabilities.quick.applySession')),
|
|
2018
|
-
h('button', { type: 'button', className: 'ptcPlusButton',
|
|
2019
|
-
title: t(hasInheritanceReset ? 'capabilities.quick.inheritSessionOnly' : 'capabilities.quick.globalHint'),
|
|
2020
|
-
disabled: pending || !enabled || !available || writesBlocked || staged.length === 0 || hasInheritanceReset,
|
|
2021
|
-
onClick: () => void apply('global') },
|
|
2022
|
-
t('capabilities.quick.applyGlobal')),
|
|
2023
|
-
h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
|
|
2024
|
-
'aria-label': t('capabilities.batchSettings'),
|
|
2025
|
-
disabled: pending || writesBlocked || picked.length === 0,
|
|
2026
|
-
onClick: () => { setBatchChoices({ availability: 'keep', disclosure: 'keep' }); setBatchOpen(true) } },
|
|
2027
|
-
t('capabilities.batchSettings'),
|
|
2028
|
-
picked.length ? h('span', { className: 'ptcPlusCapabilityBatchCount', 'aria-hidden': true,
|
|
2029
|
-
title: t('capabilities.selectionCount', { count: picked.length }) },
|
|
2030
|
-
String(picked.length)) : null)))
|
|
1841
|
+
const filter = (label, value, onChange, options) => h('div', { className: 'ptcPlusCapabilityFilter' },
|
|
1842
|
+
h('span', null, label), h(Select, { label, value, onChange,
|
|
1843
|
+
options: options.map(([key, text]) => ({ value: key, label: text })) }))
|
|
1844
|
+
// The two filters narrow the same list, so a value that no longer matches the
|
|
1845
|
+
// other dimension is withdrawn instead of silently emptying the table.
|
|
1846
|
+
const chooseFamily = next => {
|
|
1847
|
+
setFamily(next)
|
|
1848
|
+
if (source !== 'all'
|
|
1849
|
+
&& !capabilities.some(row => (next === 'all' || familyOf(row) === next) && sourceKeyOf(row) === source)) setSource('all')
|
|
1850
|
+
}
|
|
1851
|
+
const chooseSource = next => {
|
|
1852
|
+
setSource(next)
|
|
1853
|
+
if (next !== 'all'
|
|
1854
|
+
&& !capabilities.some(row => (family === 'all' || familyOf(row) === family) && sourceKeyOf(row) === next)) setFamily('all')
|
|
1855
|
+
}
|
|
1856
|
+
const close = () => {
|
|
1857
|
+
request.current?.abort()
|
|
1858
|
+
request.current = null
|
|
1859
|
+
onClose?.()
|
|
1860
|
+
}
|
|
1861
|
+
// §3.3: a page reports its own address set, so a missing receipt is read against the
|
|
1862
|
+
// record it belongs to instead of against an unnamed blank.
|
|
1863
|
+
const addressSet = settingsEntrance ? t('capabilities.surface.addresses.settings')
|
|
1864
|
+
: t('capabilities.surface.addresses.session', { session: sessionId })
|
|
1865
|
+
const table = h('div', { role: 'table',
|
|
1866
|
+
'aria-label': t(settingsEntrance ? 'capabilities.surface.settingsTitle' : 'capabilities.quick.members') },
|
|
1867
|
+
columnsRow(),
|
|
1868
|
+
loaded && listed.length === 0 ? h('p', { className: 'ptcPlusSessionCapabilityMessage' },
|
|
1869
|
+
t(capabilities.length === 0 ? 'capabilities.quick.supplyEmpty' : 'capabilities.noMatches')) : null,
|
|
1870
|
+
...listed.map(renderRow))
|
|
1871
|
+
const body = [
|
|
1872
|
+
h('div', { key: 'head', className: 'ptcPlusSessionCapabilityHead' },
|
|
1873
|
+
h('div', null,
|
|
1874
|
+
h('h2', null, t(settingsEntrance ? 'capabilities.surface.settingsTitle' : 'capabilities.quick.modalTitle')),
|
|
1875
|
+
h('p', { className: 'ptcPlusSessionCapabilityAddresses' }, addressSet)),
|
|
1876
|
+
inline === true ? null : h('button', { type: 'button', className: 'ptcPlusDialogClose',
|
|
1877
|
+
'aria-label': t('settings.close'), onClick: close }, '\u00d7')),
|
|
1878
|
+
h('div', { key: 'toolbar', className: 'ptcPlusSessionCapabilityToolbar' },
|
|
1879
|
+
h('label', { className: 'ptcPlusSearch' },
|
|
1880
|
+
h('svg', { viewBox: '0 0 24 24', width: 16, height: 16, 'aria-hidden': true },
|
|
1881
|
+
h('circle', { cx: 10, cy: 10, r: 6, fill: 'none', stroke: 'currentColor', strokeWidth: 2 }),
|
|
1882
|
+
h('path', { d: 'm15 15 6 6', fill: 'none', stroke: 'currentColor', strokeWidth: 2 })),
|
|
1883
|
+
h('input', { type: 'search', value: query, 'aria-label': t('capabilities.search'),
|
|
1884
|
+
placeholder: t('capabilities.search'), onChange: event => setQuery(event.target.value) })),
|
|
1885
|
+
filter(t('capabilities.quick.kind'), family, chooseFamily,
|
|
1886
|
+
[['all', t('capabilities.quick.all')], ...FAMILIES.map(key => [key, t(`capabilities.quick.${key}`)])]),
|
|
1887
|
+
filter(t('capabilities.sourceFilter'), source, chooseSource,
|
|
1888
|
+
[['all', t('capabilities.allSources')], ...[...sources].sort(([a], [b]) => compare(a, b))]),
|
|
1889
|
+
h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
|
|
1890
|
+
'aria-label': t('capabilities.batchSettings'), disabled: writable === false,
|
|
1891
|
+
onClick: () => { setBatchChoices({ availability: 'keep', disclosure: 'keep' }); setBatchOpen(true) } },
|
|
1892
|
+
t('capabilities.batchSettings'),
|
|
1893
|
+
picked.length ? h('span', { className: 'ptcPlusCapabilityBatchCount', 'aria-hidden': true },
|
|
1894
|
+
String(picked.length)) : null)),
|
|
1895
|
+
// A read that failed is a defect in the panel's own source, so it names the error
|
|
1896
|
+
// and offers the retry beside the rows it could not replace.
|
|
1897
|
+
defect === null ? null : h('p', { key: 'defect', className: 'ptcPlusSessionCapabilityMessage', role: 'status' },
|
|
1898
|
+
t('capabilities.quick.catalogError', { error: defect }), ' ',
|
|
1899
|
+
h('button', { type: 'button', className: 'ptcPlusCapabilityLink', onClick: reload }, t('capabilities.reloadCatalog'))),
|
|
1900
|
+
h('div', { key: 'list', className: 'ptcPlusSessionCapabilityList' },
|
|
1901
|
+
h('div', { className: 'ptcPlusSessionCapabilityTable' }, table),
|
|
1902
|
+
// The group is present whenever this entrance states the binding document: the
|
|
1903
|
+
// Host supplies it whether or not this session has used any of it, and a
|
|
1904
|
+
// zero-row group is an empty table.
|
|
1905
|
+
settingsEntrance ? null
|
|
1906
|
+
: h('section', { key: 'bindings', className: 'ptcPlusSessionCapabilityGroup ptcPlusBindingGroup' },
|
|
1907
|
+
h('h3', null, t('bindings.title')),
|
|
1908
|
+
h('div', { className: 'ptcPlusBindingTable', role: 'table', 'aria-label': t('bindings.title') },
|
|
1909
|
+
columnsRow(),
|
|
1910
|
+
...bindings.map(renderRow)))),
|
|
1911
|
+
batchOpen ? h(Modal, { key: 'batch', open: true, onClose: () => setBatchOpen(false),
|
|
1912
|
+
title: t('capabilities.batchSettings'), closeLabel: t('capabilities.batch.cancel'), headless: true,
|
|
1913
|
+
className: 'ptcPlusCapabilityBatchModal' },
|
|
1914
|
+
h('div', { className: 'ptcPlusCapabilityBatchHead' }, h('h2', null, t('capabilities.batchSettings')),
|
|
1915
|
+
h('button', { type: 'button', className: 'ptcPlusDialogClose', 'aria-label': t('capabilities.batch.cancel'),
|
|
1916
|
+
onClick: () => setBatchOpen(false) }, '\u00d7')),
|
|
1917
|
+
h('div', { className: 'ptcPlusCapabilityBatchBody' },
|
|
1918
|
+
h('p', { className: 'ptcPlusDetail', role: 'status' }, t('capabilities.batch.selected', { count: pickedRows.length })),
|
|
1919
|
+
h('ul', { className: 'ptcPlusCapabilityBatchTargets', 'aria-label': t('capabilities.batch.targetList') },
|
|
1920
|
+
...pickedRows.map(row => h('li', { key: row.id }, h('span', null, row.name ?? row.id), h('code', null, row.id)))),
|
|
1921
|
+
h('div', { className: 'ptcPlusCapabilityBatchFields' }, ...DIMENSIONS.map(dimension => {
|
|
1922
|
+
const address = t(settingsEntrance ? 'capabilities.batch.address.saved' : 'capabilities.batch.address.session')
|
|
1923
|
+
const label = `${address} · ${dimensionWord(dimension)}`
|
|
1924
|
+
return h('div', { key: dimension, className: 'ptcPlusCapabilityFilter' }, h('span', null, label),
|
|
1925
|
+
h(Select, { value: batchChoices[dimension], label,
|
|
1926
|
+
onChange: choice => setBatchChoices(current => ({ ...current, [dimension]: choice })),
|
|
1927
|
+
// Each entrance offers the words its own address can hold: the session batch offers
|
|
1928
|
+
// the record's words plus `inherit`, the saved batch offers `automatic`.
|
|
1929
|
+
options: [{ value: 'keep', label: t('capabilities.batch.keep') },
|
|
1930
|
+
...choiceOptions(dimension === 'availability'
|
|
1931
|
+
? (settingsEntrance ? SAVED_AVAILABILITY : SESSION_AVAILABILITY)
|
|
1932
|
+
: (settingsEntrance ? SAVED_DISCLOSURE : SESSION_DISCLOSURE))] }))
|
|
1933
|
+
})),
|
|
1934
|
+
h('p', { className: 'ptcPlusDetail' },
|
|
1935
|
+
t(settingsEntrance ? 'capabilities.batch.effect' : 'capabilities.batch.effectSession'))),
|
|
1936
|
+
h('div', { className: 'ptcPlusCapabilityBatchFooter' },
|
|
1937
|
+
h('button', { type: 'button', className: 'ptcPlusButton', onClick: () => setBatchOpen(false) },
|
|
1938
|
+
t('capabilities.batch.cancel')),
|
|
1939
|
+
h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary',
|
|
1940
|
+
disabled: writable === false, onClick: () => void applyBatch() },
|
|
1941
|
+
t('capabilities.batch.record', { count: batchLines().length })))) : null,
|
|
1942
|
+
h('div', { key: 'footer', className: 'ptcPlusSessionCapabilityFooter' },
|
|
1943
|
+
// The footer states the same receipt the chat card does, in the current
|
|
1944
|
+
// language: which record this write named, its choice and its session. The raw
|
|
1945
|
+
// payload stays in the command's own result, where history keeps it.
|
|
1946
|
+
receipt === null ? null : h('span', { role: 'status' }, receipt.error === undefined
|
|
1947
|
+
? capabilityCommandText(t, receipt.text)
|
|
1948
|
+
: t('capabilities.quick.writeError', { error: receipt.error }))),
|
|
1949
|
+
]
|
|
1950
|
+
return inline === true
|
|
1951
|
+
? h('div', { className: 'ptcPlusCapabilitySurface ptcPlusCapabilitySurface-inline' }, ...body)
|
|
1952
|
+
: h(Modal, { open, onClose: close, title: t('capabilities.quick.modalTitle'),
|
|
1953
|
+
closeLabel: t('settings.close'), headless: true, className: 'ptcPlusSessionCapabilityModal' }, ...body)
|
|
2031
1954
|
}
|
|
2032
1955
|
}
|